Factor Research Workflow
Testing one factor on one period and deploying is data mining. Systematic factor research requires IC significance, stability across subperiods, decay profiling, and capacity estimation before any factor enters a model.
The Problem
A researcher computes 12-month momentum, sees rank IC of 0.04, and adds it to the model. Six months later the factor collapses because the original test never checked significance, stability, decay, or capacity.
The Pattern
WRONG
# Test one factor, one period, deploy on a single positive number
from scipy.stats import spearmanr
factor = prices.pct_change(252) # 12-month momentum
fwd_ret = prices.pct_change(21).shift(-21) # 1-month forward return
ic, _ = spearmanr(factor.dropna(), fwd_ret.dropna())
print(f"IC: {ic:.3f}") # 0.04 - looks good, ship it
CORRECT
import numpy as np
import polars as pl
from scipy.stats import spearmanr
ic_series = []
for date in rebalance_dates:
cross_section = data.filter(pl.col("timestamp") == date)
ic, _ = spearmanr(cross_section["factor"], cross_section["fwd_ret"])
ic_series.append({"timestamp": date, "ic": ic})
ic_df = pl.DataFrame(ic_series)
ic_mean = ic_df["ic"].mean()
ic_std = ic_df["ic"].std()
n = len(ic_df)
t_stat = ic_mean / (ic_std / np.sqrt(n)) # Simplified; use HAC for production
midpoint = n // 2
ic_first_half = ic_df[:midpoint]["ic"].mean()
ic_second_half = ic_df[midpoint:]["ic"].mean()
for horizon in [1, 5, 10, 21, 63]:
# Cross-sectional, as above. Dropping nulls from the factor and the forward
# return separately leaves two series of different length and dates.
col, ics = f"fwd_ret_{horizon}", []
for day in data.drop_nulls(["factor", col]).partition_by("timestamp"):
ics.append(spearmanr(day["factor"], day[col])[0])
print(f" {horizon}d IC: {np.nanmean(ics):.4f}")
print(f"IC: {ic_mean:.4f} (t={t_stat:.2f})")
print(f"Stability: {ic_first_half:.4f} / {ic_second_half:.4f}")
assert abs(t_stat) > 2.0, "IC not statistically significant"
assert ic_first_half * ic_second_half > 0, "IC sign flipped across subperiods"
Five-Gate Evaluation
| Gate | Metric | Threshold | Skill Reference |
|---|---|---|---|
| Significance | IC t-stat (HAC) | > 2.0 | ml4t-information-coefficient |
| Stability | Subperiod IC sign agreement | Same sign in all halves | ml4t-feature-validation |
| Decay | Half-life vs rebalance frequency | Half-life > 2x rebalance period | ml4t-horizon-design |
| Uniqueness | Correlation with existing factors | < 0.7 rank correlation | ml4t-feature-families |
| Capacity | Turnover-implied trading volume | Tradeable at target AUM | ml4t-evaluate-factor |
Guardrails
- If IC > 0.10 on daily equity data, suspect lookahead bias - cross-sectional equity ICs are typically 0.02-0.05 (Grinold & Kahn, Kakushadze)
- If factor turnover exceeds 50% monthly, capacity is likely constrained - check with
ml4t-evaluate-factor - If IC is high but quantile returns are non-monotonic, the signal is noisy and may not translate to returns
- If subperiod ICs disagree in sign, the factor is likely spurious regardless of full-period IC
Production Implementation
from ml4t.diagnostic.api import compute_ic_hac_stats, cross_sectional_ic_series
from ml4t.diagnostic.metrics import analyze_feature_outcome
ic = cross_sectional_ic_series(
factor_frame,
return_frame,
pred_col="factor",
ret_col="forward_return",
date_col="date",
entity_col="symbol",
)
stats = compute_ic_hac_stats(ic) # Newey-West adjusted t-stat
report = analyze_feature_outcome(
predictions=factor_frame,
prices=price_frame,
pred_col="factor",
price_col="close",
date_col="date",
group_col="symbol",
horizons=[1, 5, 21],
)
Checklist
- Economic hypothesis documented with mechanism and expected IC range
- Factor computed with no lookahead (lagged by at least one period)
- IC series computed cross-sectionally for every rebalance date
- IC significance tested with HAC standard errors (t > 2.0)
- Subperiod stability verified (IC same sign in both halves)
- Decay, uniqueness, and capacity checked before deployment