Chen, Hong and Stein (2002) studies the relationship between institutional ownership breadth and underlying stock returns. This set of code replicates this exercise using Thomson Reuters 13F data. The output of this code includes breadth of ownership, institutional ownership relative to total shares, concentration of ownership, etc.
Start by importing Python packages and establishing connection to WRDS server:
################################################### Calculate IO, Concentration and Breadth Ratios ## Qingyi (Freda) Song Drechsler ## Date: November 2018 ## Updated: June 2020 ## Updated: October 2026, CRSP CIZ format ###################################################import pandas as pdimport numpy as npimport wrdsimport datetime as dtfrom pandas.tseries.offsets import*import plotly.graph_objects as goimport plotly.io as piopio.renderers.default ='notebook_connected'#################### Connect to WRDS ####################conn = wrds.Connection()
Loading library list...
Done
####################################### Step 1 ## CRSP Block ######################################## set sample date rangebegdate ='03/01/1980'# 13F report dates through December 2025: on WRDS, Q1 2026 lacks# Vanguard's holdings (filing header only), which would understate# ownership. CRSP uses the same end date: Step 6 keeps stocks# without 13F data through a right join.enddate ='12/31/2025'# sql similar to crspmerge macro# CRSP CIZ format: msf_v2 carries the security attributes on each record;# common stocks only (equivalent to legacy shrcd = 10 or 11)crsp_m = conn.raw_sql(f""" select distinct permno, mthcaldt, mthret, mthvol, shrout, mthprc, mthcumfacpr, mthcumfacshr from crsp_m_stock.msf_v2 where mthcaldt between '{begdate}' and '{enddate}' and sharetype='NS' and securitytype='EQTY' and securitysubtype='COM' and usincflg='Y' and issuertype in ('ACOR', 'CORP') """, date_cols=['mthcaldt'],)# change variable format to intcrsp_m[['permno']] = crsp_m[['permno']].astype(int)# get month and quarter-end datescrsp_m['mdate'] = crsp_m['mthcaldt'] + pd.offsets.MonthEnd(0)crsp_m['qdate'] = crsp_m['mthcaldt'] + pd.offsets.QuarterEnd(0)# calculate adjusted price, total shares and market capcrsp_m['p'] = ( crsp_m['mthprc'].abs() / crsp_m['mthcumfacpr']) # price adjustedcrsp_m['tso'] = ( crsp_m['shrout'] * crsp_m['mthcumfacshr'] *1e3) # total shares out adjustedcrsp_m['me'] = crsp_m['p'] * crsp_m['tso'] /1e6# market cap in $mil# keep only relevant columnscrsp_m = crsp_m[ ['permno','mdate','qdate','mthcaldt','mthcumfacshr','p','tso','me', ]]# For each stock(permno), each quarter (qdate),# find the last monthly date (mdate)qend = ( crsp_m[['permno', 'mdate', 'qdate']] .groupby(['permno', 'qdate'])['mdate'] .max() .reset_index())# Merge back to keep last monthly observation for each quartercrsp_qend = pd.merge( crsp_m, qend, how='inner', on=['permno', 'qdate', 'mdate'])
####################################### Step 2 ## Merge TR 13f s34type1 and s34type3 #######################################fst_vint = conn.raw_sql(""" select rdate, fdate, mgrno, mgrname from tfn.s34type1 """, date_cols=['rdate', 'fdate'],)# Keep first vintage with holding data for each mgrno-rdate combomin_fdate = ( fst_vint.groupby(['mgrno', 'rdate'])['fdate'].min().reset_index())# Merge back with the fst_vint data to keep only the first vintage recordsfst_vint = pd.merge( fst_vint, min_fdate, how='inner', on=['mgrno', 'rdate', 'fdate'])# Sort by mgrno and rdate and create lag_rdate to calculate gapfst_vint = fst_vint.sort_values(['mgrno', 'rdate'])fst_vint['lag_rdate'] = fst_vint.groupby(['mgrno'])['rdate'].shift(1)# Number of quarters gap between rdate and lag_rdatefst_vint['rdate_year'] = fst_vint.rdate.dt.yearfst_vint['rdate_qtr'] = fst_vint.rdate.dt.quarterfst_vint['lag_rdate_year'] = fst_vint.lag_rdate.dt.yearfst_vint['lag_rdate_qtr'] = fst_vint.lag_rdate.dt.quarterfst_vint['qtr'] = (fst_vint.rdate_year - fst_vint.lag_rdate_year) *4+ ( fst_vint.rdate_qtr - fst_vint.lag_rdate_qtr)# label first_report flagfst_vint['first_report'] = (fst_vint.qtr.isnull()) | (fst_vint.qtr >=2)fst_vint = fst_vint.drop(['qtr'], axis=1)# Last report by manager or missing 13F reports in the next quarter(s)fst_vint = fst_vint.sort_values( ['mgrno', 'rdate'], ascending=[True, False])fst_vint['lead_rdate'] = fst_vint.groupby(['mgrno'])['rdate'].shift(1)# Number of quarters gap between lead_rdate and rdatefst_vint['lead_rdate_year'] = fst_vint.lead_rdate.dt.yearfst_vint['lead_rdate_qtr'] = fst_vint.lead_rdate.dt.quarterfst_vint['qtr'] = (fst_vint.lead_rdate_year - fst_vint.rdate_year) *4+ ( fst_vint.lead_rdate_qtr - fst_vint.rdate_qtr)# label last_report flagfst_vint['last_report'] = (fst_vint.qtr.isnull()) | (fst_vint.qtr >=2)fst_vint = fst_vint.drop(['qtr'], axis=1)fst_vint = fst_vint[ (fst_vint['rdate'] <= enddate) & (fst_vint['rdate'] >= begdate)].drop(['lag_rdate', 'lead_rdate'], axis=1)# Add total number of 13f filers during each quarterfst_vint = fst_vint.sort_values(['rdate', 'mgrno'])NumInst = ( fst_vint.groupby(['rdate'])['mgrno'] .count() .reset_index() .rename(columns={'mgrno': 'NumInst'}))fst_vint = pd.merge(fst_vint, NumInst, how='left', on='rdate')fst_vint = fst_vint.drop(['mgrname'], axis=1)
To make sure share holdings across different quarters are compared on the same basis, we need to gather the shares adjustment factors from CRSP to adjust the holding figures reported in 13F data.
####################################### Step 3 ## Extract Holdings and Adjust Shares ######################################## Map 13F's historical cusip to CRSP's permno information# (CRSP CIZ format: historical cusip from stksecurityinfohist)crsp = conn.raw_sql(""" select distinct permno, cusip from crsp_m_stock.stksecurityinfohist where cusip != '' """)# The 13F holdings file has grown to over 100 million records, so Steps 3-5# are written as functions and run one report year at a time (see below).def extract_holdings(fst_vint):# holdings for the first-vintage reports in fst_vint only s34type3 = conn.raw_sql(f""" select fdate, mgrno, cusip, shares from tfn.s34type3 where fdate between '{fst_vint.fdate.min():%Y-%m-%d}' and '{fst_vint.fdate.max():%Y-%m-%d}' """, date_cols=['fdate'], ) holdings_v1 = pd.merge( fst_vint, s34type3, how='inner', on=['fdate', 'mgrno'] ) holdings_v2 = pd.merge(holdings_v1, crsp, how='inner', on='cusip') holdings_v2 = holdings_v2.drop(['cusip'], axis=1)return holdings_v2
Now use shares adjustment factors (cfacshr) to adjust the “shares” figure:
####################################### Step 4 ## Adjust Shares Using CRSP CFACSHR ## Align at Vintage Dates ######################################## Sanity Checks for Duplicates - Ultimately, Should be 0 Duplicatescrsp_qend = crsp_qend.drop_duplicates(subset=['permno', 'qdate'])def adjust_shares(holdings_v2): holdings = pd.merge( holdings_v2, crsp_qend[['qdate', 'permno', 'mthcumfacshr']], how='inner', left_on=['permno', 'fdate'], right_on=['permno', 'qdate'], )# Calculate Adjusted Shares holdings['shares_adj'] = holdings['shares'] * holdings['mthcumfacshr'] holdings = holdings.drop( ['shares', 'qdate', 'mthcumfacshr', 'fdate'], axis=1 )# Sanity Checks for Duplicates - Ultimately, Should be 0 Duplicates holdings = holdings.drop_duplicates( subset=['permno', 'rdate', 'mgrno'] )return holdings
With the adjusted portfolio holdings shares ready, now we can calculate various institutional holdings metrics down at security level:
####################################### Step 5 ## Calculate Institutional Measures ## At Security Level #######################################def io_measures(holdings): holdings = holdings[holdings['shares_adj'] >0]# Number of Owners io_numowners = ( holdings.groupby(['permno', 'rdate'])['shares_adj'] .count() .reset_index() .rename(columns={'shares_adj': 'numowners'}) )# Number of Institutions io_numinst = ( holdings.groupby(['permno', 'rdate'])['NumInst'] .max() .reset_index() .rename(columns={'NumInst': 'numinst'}) )# New Old Institutions and Total Shares held by institutions io_total = ( holdings.groupby(['permno', 'rdate'])[ ['first_report', 'last_report', 'shares_adj'] ] .sum() .reset_index() .rename( columns={'first_report': 'newinst','last_report': 'oldinst','shares_adj': 'io_total', } ) )# USS tmp = holdings[['permno', 'rdate', 'shares_adj']].copy() tmp['shares_adj2'] = tmp['shares_adj'] **2 io_uss = ( tmp.groupby(['permno', 'rdate'])['shares_adj2'] .sum() .reset_index() .rename(columns={'shares_adj2': 'io_ss'}) )# combine various metrics together io_metrics = pd.merge( io_numowners, io_numinst, how='inner', on=['permno', 'rdate'] ) io_metrics = pd.merge( io_metrics, io_total, how='inner', on=['permno', 'rdate'] ) io_metrics = pd.merge( io_metrics, io_uss, how='inner', on=['permno', 'rdate'] )return io_metrics
# Run Steps 3-5 one report year at a time and stack the security-level# measures. Each (permno, rdate) group falls within a single report year,# so the result is the same as processing all holdings at once.io_metrics_list = []for yr insorted(fst_vint.rdate.dt.year.unique()): holdings_v2 = extract_holdings(fst_vint[fst_vint.rdate.dt.year == yr]) holdings = adjust_shares(holdings_v2) io_metrics_list.append(io_measures(holdings))io_metrics = pd.concat(io_metrics_list, ignore_index=True)print(f'{len(io_metrics):,} security-quarter records, 'f'{io_metrics.rdate.min():%b %Y} - {io_metrics.rdate.max():%b %Y}')
884,150 security-quarter records, Mar 1980 - Dec 2025
We follow the Lehavy and Sloan (2008) method to calculate changes in Institutional Breadth:
Breadth Condition: institution should exist in Q(t) and Q(t-1)
Objective: Mitigate Bias due to Universe Changes - $100M AUM Filing Threshold
Breadth = ((numinst(t) - newinst(t)) -(numinst(t-1)-oldinst(t-1))) / total number of 13f filers in Q(t-1) where, NewInst(t): Number of 13F filers that reported in t, but did not report in (t-1)
OldInst(t): Number of 13F filers that reported in (t-1), but did not report in t
(NumOwners(t)-NewInst(t)): Number of 13F filers holding security in quarter t, that have reported in both quarters t and t-1
(NumOwners(t-1)-OldInst(t-1)): number of 13F filers that held the security in quarter (t-1), and have reported in both quarters t and t-1
Now we add back the CRSP shares outstanding data back, and institutional ownership ratio (IOR) is calculated as total shares owned by institutions divided by total shares outstanding.
####################################### Step 6 ## Add CRSP Market Data to Holdings ## At Calendar Quarter End ######################################## Note: a right join is necessary to indentify common stocks# with no 13F dataio_ts = pd.merge( io_metrics, crsp_qend[['permno', 'qdate', 'p', 'tso', 'me']], how='right', left_on=['permno', 'rdate'], right_on=['permno', 'qdate'],)io_ts = io_ts.sort_values(['permno', 'rdate'])# keep only records with tso>0io_ts = io_ts[io_ts['tso'] >0]io_ts['ior'] = (io_ts['io_total'] / io_ts['tso']).fillna(0)io_ts['io_missing'] = np.where(io_ts['rdate'].isna(), 1, 0)io_ts['io_g1'] = np.where(io_ts['ior'] >1, 1, 0)# fill in missing rdate with valid qdateio_ts['rdate'] = np.where( io_ts['rdate'].isna(), io_ts['qdate'], io_ts['rdate'])
Prepare the final table and plots time series of various stats:
####################################### Step 7 ## Final Table and Plotting ######################################## Sanity check for duplicatesio_ts = io_ts.drop_duplicates(subset=['rdate', 'permno']).sort_values( ['rdate', 'permno'])# Calculate summary statistics# ncomps - # of Common Stock Securities in CRSP# ncomps_no13f - # of Stocks in CRSP without 13F Dataio_stats = ( io_ts.groupby(['rdate']) .agg({'ior': 'count', 'io_missing': 'sum', 'io_g1': 'sum'}) .reset_index() .rename( columns={'ior': 'ncomps','io_missing': 'ncomps_no13f','io_g1': 'sum_io_g1', } ))# Mediansio_median = ( io_ts.groupby(['rdate'])[['ior', 'ioc_hhi', 'io_missing']] .median() .reset_index())# Combine statistics in one dfio_stats = pd.merge(io_stats, io_median, how='inner', on='rdate')# fraction of companies without 13fio_stats['io_missing'] = io_stats['ncomps_no13f'] / io_stats['ncomps']# fraction of companies with IOR > 1io_stats['io_g1'] = io_stats['sum_io_g1'] / io_stats['ncomps']io_stats = io_stats.drop('sum_io_g1', axis=1)
# Time series of IO statistics of US common stocksSTATS = [ ('ior', 'ior: median IO ratio', '#2a78d6'), ('io_missing', 'io_missing: share without 13F data', '#eb6834'), ('ioc_hhi', 'ioc_hhi: median ownership concentration', '#1baf7a'), ('io_g1', 'io_g1: share with IO ratio above 1', '#eda100'),]fig = go.Figure()for col, label, color in STATS: fig.add_trace( go.Scatter( x=io_stats['rdate'], y=io_stats[col], name=label, mode='lines', line=dict(color=color, width=2), hovertemplate='%{y:.3f}<extra>'+ col +'</extra>', ) )fig.update_layout( title=dict( text='Time Series of IO Statistics of US Common Stocks''<br><sup style="color:#52514e">Median Statistics, 'f'{io_stats.rdate.min():%b %Y}\u2013{io_stats.rdate.max():%b %Y}''</sup>', x=0, xanchor='left', ), template='plotly_white', height=560, margin=dict(l=70, r=40, t=90, b=150), font=dict( family='Inter, system-ui, sans-serif', size=13, color='#0b0b0b' ), paper_bgcolor='#fcfcfb', plot_bgcolor='#fcfcfb', hovermode='x unified', legend=dict(orientation='h', x=0, y=-0.18, yanchor='top'), xaxis=dict( title='Report Date', showgrid=False, ticks='outside', tickcolor='#c3c2b7', linecolor='#c3c2b7',range=[io_stats.rdate.min(), io_stats.rdate.max()], ), yaxis=dict(gridcolor='#ecebe7', zeroline=False),)fig.show()
We now use the IO metrics produced above to form portfolios and examine the performance:
####################################### Step 8 ## IO Trends by Size Portfolios #######################################io_bucket = io_ts# Assign stocks into quintiles at each quarter based on market cap# Exclude Companies with Missing Mktcap Info at Quarter Endio_bucket['bucket'] = io_bucket.groupby('rdate')['me'].transform(lambda x: pd.qcut(x, 5, labels=False))io_bucket['bucket'] = io_bucket['bucket'] +1# Censor ior at maximum 100%io_bucket['ior'] = np.where(io_bucket['ior'] >1, 1, io_bucket['ior'])# Caculate IO Mean by Size Bucketio_bucket_mean = ( io_bucket.groupby(['rdate', 'bucket'])['ior'].mean().reset_index())# Transpose df for plottingio_bucket_ts = io_bucket_mean.pivot( index='rdate', columns='bucket', values='ior').reset_index()io_bucket_ts = io_bucket_ts.rename( columns={1.0: 'IOR 1 Small',2.0: 'IOR 2',3.0: 'IOR 3',4.0: 'IOR 4',5.0: 'IOR 5 Large', })# Plot Time Series of IO Ratio by Size Quintiles# size quintiles on a sequential scale: small (light) to large (dark)QUINTS = [ ('IOR 1 Small', '#86b6ef'), ('IOR 2', '#5598e7'), ('IOR 3', '#2a78d6'), ('IOR 4', '#1c5cab'), ('IOR 5 Large', '#104281'),]# end labels: nudge apart labels whose values are within 3 pointsends = {col: io_bucket_ts[col].dropna().iloc[-1] for col, _ in QUINTS}shift = {col: 0for col in ends}order =sorted(ends, key=ends.get)for lo, hi inzip(order, order[1:]):if ends[hi] - ends[lo] <0.03: shift[lo] -=7 shift[hi] +=7fig = go.Figure()for col, color in QUINTS: fig.add_trace( go.Scatter( x=io_bucket_ts['rdate'], y=io_bucket_ts[col], name=col, mode='lines', line=dict(color=color, width=2), hovertemplate='%{y:.1%}<extra>'+ col +'</extra>', ) ) fig.add_annotation( x=io_bucket_ts['rdate'].iloc[-1], y=ends[col], text=f'<b>{col.split()[1]}</b> {ends[col]:.0%}', showarrow=False, xanchor='left', xshift=8, yshift=shift[col], font=dict(size=12, color='#0b0b0b'), )fig.update_layout( title=dict( text='Time Series of IO Ratio - by Size Buckets''<br><sup style="color:#52514e">Mean IO ratio within each ''market-cap quintile (censored at 100%), 'f'{io_bucket_ts.rdate.min():%b %Y}\u2013'f'{io_bucket_ts.rdate.max():%b %Y}''</sup>', x=0, xanchor='left', ), template='plotly_white', height=560, margin=dict(l=70, r=90, t=90, b=150), font=dict( family='Inter, system-ui, sans-serif', size=13, color='#0b0b0b' ), paper_bgcolor='#fcfcfb', plot_bgcolor='#fcfcfb', hovermode='x unified', legend=dict(orientation='h', x=0, y=-0.18, yanchor='top'), xaxis=dict( title='Report Date', showgrid=False, ticks='outside', tickcolor='#c3c2b7', linecolor='#c3c2b7',range=[io_bucket_ts.rdate.min(), io_bucket_ts.rdate.max()], ), yaxis=dict(tickformat='.0%', gridcolor='#ecebe7', zeroline=False),)fig.show()