################################################
# Jegadeesh & Titman (1993) Momentum Portfolio #
# CRSP CIZ Format #
# Qingyi (Freda) Song Drechsler #
# Date: October 2026 #
################################################
import pandas as pd
import numpy as np
import wrds
import plotly.graph_objects as go
import plotly.io as pio
from pandas.tseries.offsets import *
from scipy import stats
pio.renderers.default = 'notebook_connected'Momentum
Jegadeesh and Titman (1993)
This code replicates the methodology of Jegadeesh and Titman (1993). Momentum portfolios are formed based on past 3-12 months returns. This is a relatively simple Python application as it involves only one database, which is CRSP, and main variable of interest, cumulative past return, is fairly easy to compute.
Python version of the WRDS research application Momentum Strategies (CIZ format). Monthly returns come from the CRSP Monthly Stock File in the new CIZ format (msf_v2). The sample is NYSE and AMEX common stocks; portfolios are formed on past J-month returns and held for K months.
The sample runs through June 2026, using the CRSP monthly update on WRDS (crsp_m_stock); the annual release in the crsp library ends in December 2025. The original 1965–1989 holding period is kept as a subsample to check the results against Jegadeesh and Titman (1993) and the WRDS SAS code.
conn = wrds.Connection()Loading library list...
Done
######################
# Specifying Options #
######################
J = 6 # Formation Period Length: J can be between 3 to 12 months
K = 6 # Holding Period Length: K can be between 3 to 12 months
# Jegadeesh and Titman's Footnote 4 page 69: 1965-1989 are holding period dates
# Need 2 years of return history to form momentum portfolios that start in 1965
# Sample extended to June 2026; 1965-1989 is kept as the replication subsample
begdate = '01/01/1963'
enddate = '06/30/2026'
# CRSP monthly update: the annual library (crsp) ends December 2025,
# the monthly update (crsp_m_stock) covers the sample through June 2026
crsp_lib = 'crsp_m_stock'CRSP data
msf_v2 carries the security attributes on every monthly record, so no separate names-file merge is needed. The CIZ flags below replace the legacy shrcd in (10,11) and exchcd in (1,2) filters: common shares of US-incorporated operating companies, primary listing on NYSE (N) or AMEX (A), regular-way trading in active status.
###################
# CRSP Block #
###################
crsp_m = conn.raw_sql(f"""
select a.permno, a.mthcaldt, a.mthret,
a.issuertype, a.securitytype, a.securitysubtype, a.sharetype, a.usincflg,
a.primaryexch, a.conditionaltype, a.tradingstatusflg
from {crsp_lib}.msf_v2 as a
where a.mthcaldt between '{begdate}' and '{enddate}'
""", date_cols=['mthcaldt'])
crsp_m.shape(4910823, 11)
# Restriction on share type: common shares only
# and primary exchange: NYSE and AMEX securities only
crsp_m = crsp_m.loc[(crsp_m.sharetype=='NS') & (crsp_m.securitytype=='EQTY') &
(crsp_m.securitysubtype=='COM') & (crsp_m.usincflg=='Y') &
(crsp_m.issuertype.isin(['ACOR','CORP']))]
crsp_m = crsp_m.loc[(crsp_m.primaryexch.isin(['N','A'])) & (crsp_m.conditionaltype=='RW') &
(crsp_m.tradingstatusflg=='A')]
crsp_m = crsp_m[['permno','mthcaldt','mthret']].sort_values(['permno','mthcaldt']).reset_index(drop=True)
crsp_m['mthret'] = crsp_m['mthret'].astype(float)
crsp_m['permno'] = crsp_m['permno'].astype(int)
crsp_m.shape(1511654, 3)
Formation-period returns
Cumulative return over the past J months, computed over each stock’s consecutive monthly records: the rolling product of (1 + return) minus one. As in the SAS proc expand ... transformout=(movprod &J -1 trimleft &J), missing returns are skipped inside the window and each stock’s first J records are set to missing.
#######################################################
# Create Momentum Portfolio #
# Measures Based on Past (J) Month Compounded Returns #
#######################################################
umd = crsp_m.copy()
umd['gross'] = 1 + umd['mthret']
umd['cum_return'] = umd.groupby('permno')['gross']\
.transform(lambda x: x.rolling(J, min_periods=1).apply(np.nanprod, raw=True)) - 1
# a window with no non-missing return has no cumulative return
_nret = umd.groupby('permno')['mthret'].transform(lambda x: x.rolling(J, min_periods=1).count())
umd.loc[_nret == 0, 'cum_return'] = np.nan
# trimleft J: the first J records of each permno are set to missing
umd['obs'] = umd.groupby('permno').cumcount() + 1
umd.loc[umd['obs'] <= J, 'cum_return'] = np.nan
umd = umd[['permno','mthcaldt','cum_return']]Portfolio formation
Ranks follow SAS proc rank groups=10: within each month, floor(rank × 10 / (n + 1)) using average ranks for ties, then shifted to run 1 (losers) to 10 (winners). Stocks without a formation return are dropped. The holding period starts the month after formation (HDATE1) and ends K months later (HDATE2).
########################################
# Formation of 10 Momentum Portfolios #
########################################
umd = umd.dropna(subset=['cum_return'])
_rank = umd.groupby('mthcaldt')['cum_return'].rank(method='average')
_n = umd.groupby('mthcaldt')['cum_return'].transform('count')
umd['momr'] = np.floor(_rank*10/(_n+1)).astype(int) + 1
# 1 - the lowest momentum group: Losers
# 10 - the highest momentum group: Winners
umd['form_date'] = umd['mthcaldt']
umd['hdate1'] = umd['mthcaldt'] + MonthBegin(1)
umd['hdate2'] = umd['mthcaldt'] + MonthEnd(0) + MonthEnd(K)
umd = umd[['permno','form_date','momr','hdate1','hdate2']]\
.drop_duplicates(subset=['permno','form_date'])
umd.momr.value_counts().sort_index()1 145240
2 145617
3 145686
4 145623
5 145527
6 145761
7 145699
8 145611
9 145692
10 145310
Name: momr, dtype: int64
hdate2 reproduces SAS intnx("MONTH", mthcaldt, K, "E"): the end of the calendar month K months after formation. CIZ mthcaldt is the last trading day of the month, so the month-end anchor matters.
# Check hdate1/hdate2 against intnx: first day of the next month, end of the K-th month after formation
assert (umd['hdate1'].dt.to_period('M') - umd['form_date'].dt.to_period('M')).apply(lambda x: x.n).eq(1).all()
assert (umd['hdate2'].dt.to_period('M') - umd['form_date'].dt.to_period('M')).apply(lambda x: x.n).eq(K).all()
assert umd['hdate2'].dt.is_month_end.all() and (umd['hdate1'].dt.day == 1).all()
#############################################
# Holding Period Returns #
#############################################
# hdate1<=mthcaldt<=hdate2 covers the K calendar months after formation.
# Expand each formation record into its K holding months and join on permno-month,
# instead of merging every formation record with every return of the same permno.
hold = umd.loc[umd.index.repeat(K)].copy()
hold['hmonth'] = hold['form_date'].dt.to_period('M') + hold.groupby(level=0).cumcount().add(1).values
_ret = crsp_m.assign(hmonth=crsp_m['mthcaldt'].dt.to_period('M'))
port = pd.merge(hold, _ret, on=['permno','hmonth'], how='inner')
port = port[(port['hdate1']<=port['mthcaldt']) & (port['mthcaldt']<=port['hdate2'])]
port = port[['momr','form_date','permno','mthcaldt','mthret']]\
.drop_duplicates(subset=['mthcaldt','momr','form_date','permno'])
port.shape(8538326, 5)
Equally-weighted portfolio returns
Every month, each momentum decile holds K overlapping sub-portfolios, one per formation month. Returns are averaged within each sub-portfolio, then across sub-portfolios. The first two years (1963–1964) are formation history only and are excluded.
##############################################
# Equally-Weighted Average Monthly Returns #
##############################################
# Every date, each MOM group has J portfolios identified by formation date
umd3 = port.groupby(['mthcaldt','momr','form_date'])['mthret'].mean().reset_index()
# Skip first two years of the sample
start_yr = pd.to_datetime(begdate).year + 2
umd3 = umd3[umd3['mthcaldt'].dt.year >= start_yr]
# Create one return series per MOM group every month
ewretdat = umd3.groupby(['mthcaldt','momr'])['mthret'].agg(ewret='mean', ewretstd='std').reset_index()
#######################################
# Jegadeesh and Titman (1993) Table 1 #
#######################################
def tstats(x):
t, p = stats.ttest_1samp(x.dropna(), 0.0)
return pd.Series({'n': x.count(), 'mean': x.mean(), 't-stat': t, 'p-value': p})
mom_table = ewretdat.groupby('momr')['ewret'].apply(tstats).unstack()
mom_table['n'] = mom_table['n'].astype(int)
mom_table.style.format({'mean':'{:.2%}', 't-stat':'{:.2f}', 'p-value':'{:.4f}'})| n | mean | t-stat | p-value | |
|---|---|---|---|---|
| momr | ||||
| 1 | 738 | 0.72% | 1.98 | 0.0476 |
| 2 | 738 | 1.00% | 3.88 | 0.0001 |
| 3 | 738 | 1.14% | 5.02 | 0.0000 |
| 4 | 738 | 1.17% | 5.66 | 0.0000 |
| 5 | 738 | 1.21% | 6.24 | 0.0000 |
| 6 | 738 | 1.23% | 6.54 | 0.0000 |
| 7 | 738 | 1.24% | 6.69 | 0.0000 |
| 8 | 738 | 1.26% | 6.62 | 0.0000 |
| 9 | 738 | 1.34% | 6.65 | 0.0000 |
| 10 | 738 | 1.53% | 6.39 | 0.0000 |
Long–short portfolio
Full sample, January 1965 to June 2026.
#################################
# Long-Short Portfolio Returns #
#################################
# Transpose portfolio layout to have columns as portfolio returns
ewretdat2 = ewretdat.pivot(index='mthcaldt', columns='momr', values='ewret')
ewretdat2 = ewretdat2.add_prefix('port')
ewretdat2 = ewretdat2.rename(columns={'port1':'losers', 'port10':'winners'})
ewretdat2['long_short'] = ewretdat2['winners'] - ewretdat2['losers']
# Compute Long-Short Portfolio Cumulative Returns
ewretdat3 = ewretdat2.copy()
ewretdat3['cumret_winners'] = (1+ewretdat3['winners']).cumprod()-1
ewretdat3['cumret_losers'] = (1+ewretdat3['losers']).cumprod()-1
ewretdat3['cumret_long_short'] = (1+ewretdat3['long_short']).cumprod()-1
#################################
# Portfolio Summary Statistics #
#################################
mom_output = ewretdat3[['winners','losers','long_short']].apply(tstats).T
mom_output['n'] = mom_output['n'].astype(int)
mom_output.style.format({'mean':'{:.2%}', 't-stat':'{:.2f}', 'p-value':'{:.4f}'})| n | mean | t-stat | p-value | |
|---|---|---|---|---|
| momr | ||||
| winners | 738 | 1.53% | 6.39 | 0.0000 |
| losers | 738 | 0.72% | 1.98 | 0.0476 |
| long_short | 738 | 0.81% | 3.27 | 0.0011 |
Comparison with published results
Restricting the holding months to 1965–1989 reproduces the original sample. For J = 6 and K = 6, the WRDS SAS (CIZ format) code reports winners 1.71% (t = 4.22), losers 0.80% (t = 1.59) and long–short 0.91% (t = 3.01). Jegadeesh and Titman (1993, Table 1) report 1.74% (4.33), 0.79% (1.56) and 0.95% (3.07).
jt_sample = ewretdat3.loc['1965-01-01':'1989-12-31', ['winners','losers','long_short']]
jt_output = jt_sample.apply(tstats).T
published = pd.DataFrame({'SAS CIZ mean': [0.0171, 0.0080, 0.0091], 'SAS CIZ t': [4.22, 1.59, 3.01],
'JT (1993) mean': [0.0174, 0.0079, 0.0095], 'JT (1993) t': [4.33, 1.56, 3.07]},
index=['winners','losers','long_short'])
comparison = pd.concat([jt_output[['mean','t-stat']].rename(columns={'mean':'Python mean','t-stat':'Python t'}),
published], axis=1)
comparison.style.format({c:'{:.2%}' for c in comparison.columns if 'mean' in c} |
{c:'{:.2f}' for c in comparison.columns if c.endswith(' t')})| Python mean | Python t | SAS CIZ mean | SAS CIZ t | JT (1993) mean | JT (1993) t | |
|---|---|---|---|---|---|---|
| winners | 1.69% | 4.22 | 1.71% | 4.22 | 1.74% | 4.33 |
| losers | 0.77% | 1.54 | 0.80% | 1.59 | 0.79% | 1.56 |
| long_short | 0.92% | 3.03 | 0.91% | 3.01 | 0.95% | 3.07 |
Cumulative returns
Growth of $1 invested in January 1965, on a log scale so that equal vertical distances are equal percentage changes across six decades. Hover over a line for the month, the value of $1 and that month’s return.
#################################
# Chart Style #
#################################
COLORS = {'winners': '#2a78d6', 'losers': '#eb6834', 'long_short': '#2a78d6'}
LABELS = {'winners': 'Winners (decile 10)', 'losers': 'Losers (decile 1)', 'long_short': 'Winners minus losers'}
def log_ticks(cols):
# $ ticks at 1-2-5 steps covering the plotted range
growth = 1 + ewretdat3[['cumret_' + c for c in cols]]
lo, hi = growth.min().min(), growth.max().max()
vals = [m * 10**e for e in range(-2, 8) for m in (1, 2, 5)]
vals = [v for v in vals if lo / 2 <= v <= hi * 2]
return dict(tickvals=vals, ticktext=[f'${v:,.0f}' if v >= 1 else f'${v:g}' for v in vals])
def base_layout(title, subtitle, cols):
return dict(
title=dict(text=f'{title}<br><sup style="color:#52514e">{subtitle}</sup>', x=0, xanchor='left'),
template='plotly_white', height=480, margin=dict(l=70, r=150, t=80, b=50),
font=dict(family='Inter, system-ui, sans-serif', size=13, color='#0b0b0b'),
paper_bgcolor='#fcfcfb', plot_bgcolor='#fcfcfb', hovermode='x unified',
xaxis=dict(showgrid=False, ticks='outside', tickcolor='#c3c2b7', linecolor='#c3c2b7',
range=[ewretdat3.index[0], ewretdat3.index[-1]]),
yaxis=dict(type='log', title='Growth of $1 (log scale)', gridcolor='#ecebe7', zeroline=False,
**log_ticks(cols)),
)
def growth_trace(col, showlegend=True):
growth = 1 + ewretdat3['cumret_' + col]
return go.Scatter(x=ewretdat3.index, y=growth, name=LABELS[col], mode='lines',
line=dict(color=COLORS[col], width=2), showlegend=showlegend,
customdata=ewretdat3[col],
hovertemplate='$%{y:,.2f} (month: %{customdata:+.1%})<extra>' + LABELS[col] + '</extra>')
def end_label(fig, col):
last = ewretdat3.index[-1]
growth = 1 + ewretdat3['cumret_' + col].iloc[-1]
fig.add_annotation(x=last, y=np.log10(growth), text=f'<b>{LABELS[col].split(" (")[0]}</b><br>${growth:,.0f}',
showarrow=False, xanchor='left', xshift=8, align='left', font=dict(color='#0b0b0b'))fig = go.Figure([growth_trace('winners'), growth_trace('losers')])
fig.update_layout(**base_layout('Cumulative Momentum Portfolio Returns',
f'Jegadeesh–Titman J={J}, K={K}; NYSE and AMEX common stocks, equally weighted, Jan 1965–{ewretdat3.index[-1]:%b %Y}',
['winners','losers']))
fig.update_layout(height=520, margin=dict(b=90), legend=dict(orientation='h', x=0, y=-0.12, yanchor='top'))
end_label(fig, 'winners'); end_label(fig, 'losers')
fig.show()fig = go.Figure([growth_trace('long_short', showlegend=False)])
fig.add_hline(y=1, line=dict(color='#8a8984', width=1, dash='dot'))
fig.update_layout(**base_layout('Performance of Long/Short Momentum Strategy',
f'Long winners, short losers; J={J}, K={K}, Jan 1965–{ewretdat3.index[-1]:%b %Y}',
['long_short']))
end_label(fig, 'long_short')
fig.show()