| name | factor-construction |
| description | Look-ahead bias prevention and portfolio formation rules for cross-sectional asset pricing factors. Covers signal timing, portfolio sorts, return alignment, and rebalancing conventions. Auto-apply when constructing factors, sorting stocks into portfolios, or computing long-short returns. |
Factor Construction Rules
These rules prevent look-ahead bias (LAB) when forming cross-sectional factors. LAB means using information beyond the portfolio formation date — it silently inflates factor returns and invalidates results.
LAB Audit Checklist
Run this checklist whenever forming portfolios or constructing factors. Flag any violation.
1. Signal is point-in-time at formation date t
2. Universe defined at t
3. Breakpoints computed at t
4. Returns from t+1
5. Output dating convention
Core Convention: Lead Returns, Not Signals
Preferred approach: Keep signals at their natural date. Lead only the portfolio return series.
Step 1: At date t, sort stocks using signal known at t
Step 2: Compute portfolio return from stock returns at t+1
Step 3: Assign this return to date t+1 (the period it was earned)
Why this is preferred:
- You only lead one value (portfolio return), not N signal columns
- Signal dates stay interpretable ("BM as of June 2023")
- The gap check from panel-data-rules applies to the final return series
Date re-alignment for the output:
port_ret["date"] = port_ret["date"] + pd.offsets.MonthEnd(1)
Monthly Rebalancing Template
import pandas as pd
import numpy as np
def monthly_factor(df, signal_col, date_col="date", id_col="permno",
ret_col="ret", me_col="me", n_groups=5,
weighting="vw"):
"""
Form monthly-rebalanced long-short factor.
df must have: date (month-end), permno, signal (known at date t),
ret (return earned in month t), me (market equity at date t).
Convention: signal_t is used to form portfolios. Returns from t+1
are the out-of-sample returns. Output dated to t+1.
"""
df = df.sort_values([date_col, id_col]).copy()
df = df.dropna(subset=[signal_col])
df["port"] = df.groupby(date_col)[signal_col].transform(
lambda x: pd.qcut(x, n_groups, labels=False, duplicates="drop")
)
df = df.sort_values([id_col, date_col])
df["fwd_ret"] = df.groupby(id_col)[ret_col].shift(-1)
df["fwd_date"] = df.groupby(id_col)[date_col].shift(-1)
day_gap = (df["fwd_date"] - df[date_col]).dt.days
df.loc[day_gap > 31, "fwd_ret"] = np.nan
df = df.dropna(subset=["fwd_ret"])
if weighting == "vw":
df["weight"] = df.groupby([date_col, "port"])[me_col].transform(
lambda x: x / x.sum()
)
port_ret = df.groupby([date_col, "port"]).apply(
lambda g: (g["fwd_ret"] * g["weight"]).sum(),
include_groups=False
).unstack("port")
else:
port_ret = df.groupby([date_col, "port"])["fwd_ret"].mean().unstack("port")
port_ret["hml"] = port_ret[n_groups - 1] - port_ret[0]
port_ret.index = port_ret.index + pd.offsets.MonthEnd(1)
port_ret.index.name = date_col
return port_ret
Annual Rebalancing (Fama-French Style)
Convention: Sort at end of June using signals from the most recent December fiscal year-end (with 4-month lag ensuring availability by April).
Timeline:
Dec t-1: Fiscal year ends → accounting data (BE, etc.)
Apr t: Data becomes available (4-month lag)
Jun t: Sort stocks into portfolios using Dec t-1 accounting + Jun t ME
Jul t - Jun t+1: Hold portfolios, rebalance next June
def annual_factor(df, signal_col, date_col="date", id_col="permno",
ret_col="ret", me_col="me", n_groups=5,
formation_month=6, weighting="vw"):
"""
Annual rebalancing (Fama-French style).
Signal is measured once per year at formation_month (default June).
Portfolios held for 12 months (July through next June).
"""
df = df.sort_values([id_col, date_col]).copy()
df["month"] = df[date_col].dt.month
df["year"] = df[date_col].dt.year
formation = df[df["month"] == formation_month].dropna(subset=[signal_col]).copy()
formation["port"] = formation.groupby("year")[signal_col].transform(
lambda x: pd.qcut(x, n_groups, labels=False, duplicates="drop")
)
assign = formation[[id_col, "year", "port"]].copy()
assign = assign.rename(columns={"year": "form_year"})
df["form_year"] = np.where(
df["month"] <= formation_month, df["year"] - 1, df["year"]
)
df = df.merge(assign, on=[id_col, "form_year"], how="left")
df = df.dropna(subset=["port"])
df = df.sort_values([id_col, date_col])
df["fwd_ret"] = df.groupby(id_col)[ret_col].shift(-1)
df["fwd_date"] = df.groupby(id_col)[date_col].shift(-1)
day_gap = (df["fwd_date"] - df[date_col]).dt.days
df.loc[day_gap > 31, "fwd_ret"] = np.nan
df = df.dropna(subset=["fwd_ret"])
if weighting == "vw":
df["weight"] = df.groupby([date_col, "port"])[me_col].transform(
lambda x: x / x.sum()
)
port_ret = df.groupby([date_col, "port"]).apply(
lambda g: (g["fwd_ret"] * g["weight"]).sum(),
include_groups=False
).unstack("port")
else:
port_ret = df.groupby([date_col, "port"])["fwd_ret"].mean().unstack("port")
port_ret["hml"] = port_ret[n_groups - 1] - port_ret[0]
port_ret.index = port_ret.index + pd.offsets.MonthEnd(1)
port_ret.index.name = date_col
return port_ret
Key differences from monthly:
- Signal measured once per year at formation month (June)
- Portfolio assignment carried forward for 12 months
- VW weights updated monthly using current ME (standard for FF)
- Same forward-return and gap-check convention applies
Breakpoint Conventions — ASK USER
The templates above use pd.qcut across all stocks (equal-count portfolios). Fama-French uses NYSE-only breakpoints applied to all stocks:
nyse_mask = df["exchcd"] == 1
breakpoints = df.loc[nyse_mask, signal_col].quantile(
[i/n_groups for i in range(1, n_groups)]
)
df["port"] = pd.cut(df[signal_col], bins=[-np.inf] + list(breakpoints) + [np.inf],
labels=False)
This creates unequal-sized portfolios (many small NASDAQ stocks in extreme groups). Always confirm with the user which convention they want — all-stock quantiles vs NYSE breakpoints.
Gotchas
pd.qcut with duplicates='drop' can silently produce fewer groups than requested when many stocks share identical signal values. Always verify the actual number of groups created:
actual_groups = df["port"].nunique()
assert actual_groups == n_groups, f"Expected {n_groups} groups, got {actual_groups}"
Delisting returns must be incorporated. Factor returns that ignore delistings suffer from survivorship bias. In CRSP v2, mthret/dlyret already include delisting returns. In CRSP v1, you must manually compound dlret with the final trading return: ret_adj = (1 + ret) * (1 + dlret) - 1.
Post-Construction Diagnostics
After building any factor, run these sanity checks:
- Monotonicity: Are portfolio mean returns monotonically increasing (or decreasing) across sort groups? Non-monotonicity in a well-known factor suggests a data or construction error (though weak/absent monotonicity can be genuine in recent samples).
- Long-short t-statistic: Is the HML spread statistically significant?
t = mean / (std / sqrt(T)). For well-known factors over long samples, expect |t| > 2.
- Portfolio sizes: Are there enough stocks per portfolio per month? Print
df.groupby(['date','port']).size().describe(). Fewer than ~30 stocks per portfolio is a red flag.
- Date coverage: Does the output span the expected date range with no gaps?
- Benchmark correlation: If replicating a known factor (e.g., FF HML), correlate with the published series. Expect r > 0.8 for value-weighted decile sorts.
- Signal staleness: For accounting-based signals, check how old the underlying data is:
(panel_date - datadate).median(). Median should be reasonable (e.g., ~10 months for annual BE).