Institutional Ownership Breadth

Chen, Hong and Stein (2002)

Paper
Chen, J., H. Hong and J. Stein (2002), Breadth of Ownership and Stock Returns, Journal of Financial Economics
Data
Thomson Reuters 13F holdings (tfn.s34type1, s34type3); CRSP monthly stock file msf_v2 and stksecurityinfohist CIZ format
Sample
CRSP common stocks, 13F report dates March 1980 to December 2025 (Q1 2026 left out: Vanguard’s holdings are not yet in the 13F data on WRDS)
Author
Qingyi (Freda) Song Drechsler

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 pd
import numpy as np
import wrds
import datetime as dt
from pandas.tseries.offsets import *
import plotly.graph_objects as go
import plotly.io as pio

pio.renderers.default = 'notebook_connected'

###################
# Connect to WRDS #
###################
conn = wrds.Connection()
Loading library list...
Done
######################################
# Step 1                             #
# CRSP Block                         #
######################################

# set sample date range
begdate = '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 int
crsp_m[['permno']] = crsp_m[['permno']].astype(int)

# get month and quarter-end dates
crsp_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 cap
crsp_m['p'] = (
    crsp_m['mthprc'].abs() / crsp_m['mthcumfacpr']
)  # price adjusted
crsp_m['tso'] = (
    crsp_m['shrout'] * crsp_m['mthcumfacshr'] * 1e3
)  # total shares out adjusted
crsp_m['me'] = crsp_m['p'] * crsp_m['tso'] / 1e6  # market cap in $mil

# keep only relevant columns
crsp_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 quarter
crsp_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 combo
min_fdate = (
    fst_vint.groupby(['mgrno', 'rdate'])['fdate'].min().reset_index()
)

# Merge back with the fst_vint data to keep only the first vintage records
fst_vint = pd.merge(
    fst_vint, min_fdate, how='inner', on=['mgrno', 'rdate', 'fdate']
)

# Sort by mgrno and rdate and create lag_rdate to calculate gap
fst_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_rdate
fst_vint['rdate_year'] = fst_vint.rdate.dt.year
fst_vint['rdate_qtr'] = fst_vint.rdate.dt.quarter

fst_vint['lag_rdate_year'] = fst_vint.lag_rdate.dt.year
fst_vint['lag_rdate_qtr'] = fst_vint.lag_rdate.dt.quarter

fst_vint['qtr'] = (fst_vint.rdate_year - fst_vint.lag_rdate_year) * 4 + (
    fst_vint.rdate_qtr - fst_vint.lag_rdate_qtr
)


# label first_report flag
fst_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 rdate
fst_vint['lead_rdate_year'] = fst_vint.lead_rdate.dt.year
fst_vint['lead_rdate_qtr'] = fst_vint.lead_rdate.dt.quarter

fst_vint['qtr'] = (fst_vint.lead_rdate_year - fst_vint.rdate_year) * 4 + (
    fst_vint.lead_rdate_qtr - fst_vint.rdate_qtr
)


# label last_report flag
fst_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 quarter
fst_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 Duplicates
crsp_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 in sorted(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:

# Calculate IO DBreadth and Concentration Metrics

io_metrics['ioc_hhi'] = io_metrics['io_ss'] / (io_metrics['io_total'] ** 2)
io_metrics['d_owner'] = io_metrics['numowners'] - io_metrics['oldinst']
io_metrics = io_metrics.sort_values(['permno', 'rdate'])

# Create lag_numinst and lag_d_owner for breadth calculation
io_metrics['lag_numinst'] = io_metrics.groupby(['permno'])[
    'numinst'
].shift(1)
io_metrics['lag_d_owner'] = io_metrics.groupby(['permno'])[
    'd_owner'
].shift(1)

# Calculate change in breadth (dbreath)
io_metrics['dbreadth'] = (
    (io_metrics['numowners'] - io_metrics['newinst'])
    - io_metrics['lag_d_owner']
) / io_metrics['lag_numinst']

# Keep only relevant columns
io_metrics = io_metrics[
    ['permno', 'rdate', 'numowners', 'io_total', 'ioc_hhi', 'dbreadth']
]

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 data
io_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>0
io_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 qdate
io_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 duplicates
io_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 Data
io_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',
        }
    )
)

# Medians
io_median = (
    io_ts.groupby(['rdate'])[['ior', 'ioc_hhi', 'io_missing']]
    .median()
    .reset_index()
)

# Combine statistics in one df
io_stats = pd.merge(io_stats, io_median, how='inner', on='rdate')

# fraction of companies without 13f
io_stats['io_missing'] = io_stats['ncomps_no13f'] / io_stats['ncomps']

# fraction of companies with IOR > 1
io_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 stocks
STATS = [
    ('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 End
io_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 Bucket
io_bucket_mean = (
    io_bucket.groupby(['rdate', 'bucket'])['ior'].mean().reset_index()
)

# Transpose df for plotting
io_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 points
ends = {col: io_bucket_ts[col].dropna().iloc[-1] for col, _ in QUINTS}
shift = {col: 0 for col in ends}
order = sorted(ends, key=ends.get)
for lo, hi in zip(order, order[1:]):
    if ends[hi] - ends[lo] < 0.03:
        shift[lo] -= 7
        shift[hi] += 7

fig = 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()