This document explains the exact data loading, processing, and merging operations in the quantitative backtest pipeline.
Source Table: crsp.msf (CRSP Monthly Stock File)
Exact SQL Query:
SELECT
a.permno, -- Permanent number (stock identifier)
a.date, -- Trading date
a.ret, -- Monthly return
a.prc, -- Price at month-end
a.shrout, -- Shares outstanding (in thousands)
a.hexcd as exchcd, -- Exchange code (1=NYSE, 2=AMEX, 3=NASDAQ)
a.hsiccd as siccd -- Historical SIC code
FROM crsp.msf AS a
WHERE a.date >= '1985-01-01'
AND a.date <= '2024-12-31'
AND a.hexcd IN (1, 2, 3) -- Only NYSE, AMEX, NASDAQAdditional Table: crsp.stocknames (for share codes)
SELECT permno, shrcd, namedt, nameenddt
FROM crsp.stocknames
WHERE namedt <= '2024-12-31'
AND (nameenddt >= '1985-01-01' OR nameenddt IS NULL)-
Date Processing:
- Convert
dateto datetime format - Extract
yearandmonthcolumns
- Convert
-
Share Code Merge:
- Merge with
crsp.stocknamesusingpd.merge_asof()(backward direction) - Match share codes (
shrcd) based on date ranges (namedttonameenddt) - Filter to keep only common stocks:
shrcd IN (10, 11)
- Merge with
-
Return Cleaning:
- Convert returns to numeric (coerce errors to NaN)
- Fill NaN returns with 0
-
Output Files Created:
-
crsp_monthly_raw.parquet: Full monthly data (3,350,013 rows × 10 columns)- Columns:
permno,date,ret,prc,shrout,exchcd,siccd,year,month,shrcd
- Columns:
-
annual_returns.parquet: Annual returns computed by compounding monthly returns (299,118 rows × 3 columns)- Operation:
(1 + monthly_ret).prod() - 1grouped by(permno, year) - Columns:
permno,year,annual_ret
- Operation:
-
december_snapshot.parquet: December data for universe filtering (279,291 rows × 8 columns)- Filter:
month == 12 - Computed columns:
price = abs(prc)mktcap = price * shrout / 1000(in millions)
- Columns:
permno,year,date,price,mktcap,siccd,exchcd,shrcd
- Filter:
-
Source: Open Asset Pricing (OSAP) library via openassetpricing package
Signals Downloaded: 110+ factors, downloaded one-by-one, including:
- Value factors: BM, BMdec, BPEBM, EP, SP, cfp, EntMult, PS, IntanBM
- Profitability: GP, OperProf, OperProfRD, roaq
- Growth/Investment: AssetGrowth, Investment, InvGrowth, NOA, dNoa, GrLTNOA, RDS, OrgCap, InvestPPEInv
- Accruals: Accruals, AbnormalAccruals, AccrualsBM, TotalAccruals, PctAcc, PctTotAcc, ChNWC
- Capital Structure: BookLeverage, Leverage, NetDebtFinance, NetDebtPrice, NetEquityFinance, CompEquIss, ShareIss1Y, NetPayoutYield
- Distress: OScore, Tax
- Momentum: Mom12m, Mom6m, ResidualMomentum, IndMom, LRreversal, STreversal, MaxRet, MomSeason
- Liquidity: Illiquidity, DolVol, std_turn, ShareVol, BidAskSpread, zerotrade1M, zerotrade6M, zerotrade12M, VolumeTrend
- Risk: Beta, BetaDimson, BetaTailRisk, DownsideBeta, IdioVol3F, IdioVolAHT, VolSD, VolMkt, ReturnSkew, ReturnSkew3F
- Analyst: AnalystRevision, ChangeInRecommendation, ConsRecomm, UpRecomm, DownRecomm, FEPS, ForecastDispersion, EarningsForecastDisparity, Recomm_ShortInterest
- Size/Events: Size, DivInit, Spinoff, IndIPO
-
Signal Validation:
- Check each signal name against OSAP documentation
- Skip invalid signals
-
Download Process:
- Download each signal individually using
openap.dl_signal('pandas', [signal_name]) - Each signal returns a DataFrame with columns:
permno, time dimension (yyyymmordate), and the signal value
- Download each signal individually using
-
Time Normalization:
- Convert time dimension to
yearandmonth:- If
yyyymmexists:year = yyyymm // 100,month = yyyymm % 100 - If
dateexists: extract year and month
- If
- Filter to keep only December snapshots (
formation_month = 12)
- Convert time dimension to
-
Panel Construction:
- Merge all signals into a single wide panel
- Merge operation:
panel.merge(df_small, on=['permno', 'year'], how='outer') - Final structure:
(permno, year, factor1, factor2, ..., factorN)
-
Output File:
osap_signals_december.parquet: Wide panel with all OSAP factors (323,073 rows × 79 columns)- Columns:
permno,year, plus 77 OSAP factor columns
- Columns:
Source Table 1: comp.funda (Compustat Fundamentals Annual)
Exact SQL Query:
SELECT
gvkey, datadate, fyear, fyr, conm, sich,
at, sale, ni, cogs, ap, rect, invt, act, lct, che,
dlc, dltt
FROM comp.funda
WHERE indfmt='INDL' -- Industrial format
AND datafmt='STD' -- Standard format
AND popsrc='D' -- Primary source
AND consol='C' -- Consolidated
AND fyear BETWEEN 1980 AND 2024Source Table 2: crsp.ccmxpf_linktable (CRSP-Compustat Link Table)
Exact SQL Query:
SELECT
gvkey,
lpermno AS permno,
linktype, linkprim,
linkdt, linkenddt
FROM crsp.ccmxpf_linktable
WHERE linktype IN ('LU','LC') -- Link types
AND linkprim IN ('P','C') -- Primary or consolidated-
Date Processing:
- Convert
datadateto datetime - Convert
linkdtandlinkenddtto datetime - Fill NULL
linkenddtwith2099-12-31
- Convert
-
Linking:
- Merge
fundawithccmxpf_linktableongvkey(left join) - Filter to keep only valid links where
datadatefalls within link date range:valid = (datadate >= linkdt) & (datadate <= linkenddt)
- Merge
-
Output File:
compustat_annual.parquet: Annual fundamentals linked to CRSP permnos (279,628 rows × 23 columns)- Columns:
gvkey,datadate,fyear,fyr,conm,sich,at,sale,ni,cogs,ap,rect,invt,act,lct,che,dlc,dltt,permno,linktype,linkprim,linkdt,linkenddt
- Columns:
Function: compute_compustat_factors(comp_annual)
Factors Computed:
-
DSO (Days Sales Outstanding):
DSO = (rect / sale) * 365
-
DIO (Days Inventory Outstanding):
DIO = (invt / cogs) * 365
-
DPO (Days Payable Outstanding):
DPO = (ap / cogs) * 365
-
CCC (Cash Conversion Cycle):
CCC = DSO + DIO - DPO
-
OpLev (Operating Leverage):
OpLev = 1 - (cogs / sale)
-
FinLev (Financial Leverage):
FinLev = (dltt + dlc) / at
Output:
- DataFrame with columns:
permno,year(orfyear),CCC,OpLev,FinLev,DSO,DIO,DPO - 279,628 rows (same as input Compustat data)
Location: After loading OSAP and CRSP data
Operation:
merged_data = osap_signals.merge(
dec_data[['permno', 'year', 'date', 'price', 'mktcap', 'siccd']],
on=['permno', 'year'],
how='inner'
)Purpose: Combine OSAP factors with CRSP universe information (price, market cap, SIC code)
Result: 279,291 observations (inner join keeps only stocks present in both datasets)
Filters Applied:
- Price filter:
price >= $5.00 - Market cap filter:
mktcap >= $100 million - Exchange filter: Already applied in CRSP query (exchcd IN 1,2,3)
- Share code filter: Already applied in CRSP loading (shrcd IN 10,11)
Result: filtered_data with 151,247 observations
Location: After universe filtering, before z-scoring
Operation:
filtered_data = filtered_data.merge(
comp_factors[['permno', 'year', 'CCC', 'OpLev', 'FinLev', 'DSO', 'DIO', 'DPO']],
on=['permno', 'year'],
how='left'
)Purpose: Add custom Compustat factors to the filtered universe
Result: 151,256 observations (left join keeps all filtered_data, adds Compustat factors where available)
Note: Custom factors (e.g., VolOfVol) can also be merged at this step using the same pattern.
No new merges - operations are performed on existing filtered_data:
- Map SIC codes to Fama-French 49 industries
- Compute industry-adjusted z-scores for each factor
Output: factors_with_zscores_filtered.parquet (151,256 rows × 170 columns)
- Original factors + z-scored versions (suffix
_z)
Merge Operation:
merged = factors_t.merge(
returns_t1, # Annual returns for year t+1
on='permno',
how='inner'
)Purpose: Match factor values at time t with returns at time t+1 for cross-sectional regression
Note: This merge happens year-by-year during Fama-MacBeth estimation
No explicit merges - uses factors_with_zscores_filtered.parquet directly
Merge Operations:
-
CAPM Regression:
merged = portfolio_returns.merge( ff_factors[['date', 'mktrf', 'rf']], on='date', how='inner' )
-
Carhart 4-Factor Regression:
merged = portfolio_returns.merge( ff_factors[['date', 'mktrf', 'smb', 'hml', 'umd', 'rf']], on='date', how='inner' )
Purpose: Match portfolio returns with Fama-French factors for risk-adjusted performance evaluation
1. CRSP Monthly (WRDS)
└─> crsp_monthly_raw.parquet
└─> annual_returns.parquet (computed)
└─> december_snapshot.parquet (filtered)
2. OSAP Signals (Open Asset Pricing)
└─> osap_signals_december.parquet
3. Compustat Annual (WRDS)
└─> compustat_annual.parquet
└─> comp_factors (computed: CCC, OpLev, FinLev, DSO, DIO, DPO)
MERGE 1: OSAP + CRSP December
└─> merged_data
FILTER: Universe filters (price, mktcap)
└─> filtered_data
MERGE 2: Compustat factors
└─> filtered_data (with Compustat factors)
Z-SCORING: Industry-adjusted z-scores
└─> factors_with_zscores_filtered.parquet
FAMA-MACBETH: Year-by-year merge with returns
└─> Factor selection
PORTFOLIO CONSTRUCTION
└─> portfolio_returns_monthly.csv
PERFORMANCE EVALUATION: Merge with FF factors
└─> Performance metrics
- CRSP data provides stock returns, prices, market caps, and identifiers
- OSAP data provides 110+ pre-computed quantitative factors
- Compustat data provides fundamentals for computing custom factors
- Merges happen at specific steps:
- OSAP + CRSP: After data loading (Step 1)
- Compustat factors: After universe filtering (Step 2.5)
- Returns: During Fama-MacBeth estimation (Step 4)
- FF factors: During performance evaluation (Step 6)
- All data is saved to parquet files for efficient storage and loading
- Final dataset (
factors_with_zscores_filtered.parquet) contains all factors with industry-adjusted z-scores ready for portfolio construction