Files
quant-trading/research/lab/factor_scan.py
Gahow Wang 0e378efa67 research: point-in-time factor research harness (research/lab)
Event-driven long-only simulator with next-open fills, CN tradability
(halts, limit-up/down, stamp duty), PIT S&P 500 / CSI 300 membership,
rebalance-phase averaging, factor IC scans, maximin stepwise composite
search across 5-year blocks, walk-forward LightGBM/ridge combiners,
overlays, and deflated Sharpe. Fetchers pull 20y US OHLCV from Yahoo and
CSI 300/500 ever-members (membership snapshot from baostock, bars from
Yahoo). Research data lives in data/research/ (ignored).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:11:59 +08:00

96 lines
3.8 KiB
Python

"""
Single-factor scan: rank IC and long-only top-N backtests per period.
PYTHONPATH=. uv run python -m research.lab.factor_scan --market us
PYTHONPATH=. uv run python -m research.lab.factor_scan --market cn_hs300
Writes data/research/results/factor_scan_{market}.csv.
"""
import argparse
import os
import time
import warnings
import pandas as pd
from research.lab import panel as pnl
from research.lab.engine import simulate
from research.lab.factors import FACTORS
from research.lab.report import (forward_returns, ic_series, ic_summary, stats,
top_minus_universe)
warnings.filterwarnings("ignore")
PERIODS = {
"us": {"design": ("2006-01-01", "2016-09-26"), "holdout": ("2016-09-27", "2026-09-24")},
"cn": {"design": ("2010-01-01", "2016-09-26"), "holdout": ("2016-09-27", "2026-09-24")},
}
RESULTS = os.path.join("data", "research", "results")
def load(market: str):
if market == "us":
return pnl.load_us(), PERIODS["us"]
return pnl.load_cn(market.split("_", 1)[1]), PERIODS["cn"]
def phase_avg(p, score, start, end, top_n, every, **kw) -> dict:
"""Average stats over every rebalance phase (offset 0..every-1)."""
w0 = p.dates.searchsorted(pd.Timestamp(start))
sp = p.slice(p.dates[max(w0 - 5, 0)], end)
rows = []
for off in range(every):
r = simulate(sp, score, top_n=top_n, every=every, offset=off, warmup=5, **kw)
rows.append(stats(r.equity.loc[start:end], r.turnover))
return pd.DataFrame(rows).mean().to_dict()
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--market", default="us")
ap.add_argument("--top-n", type=int, default=20)
ap.add_argument("--every", type=int, default=5)
ap.add_argument("--only", nargs="*")
args = ap.parse_args()
mk = "us" if args.market == "us" else "cn"
p, periods = load(args.market)
fwd5 = forward_returns(p, 5)
fwd20 = forward_returns(p, 20)
rows = []
names = args.only or [n for n, f in FACTORS.items() if mk in f.markets]
for name in names:
t = time.time()
try:
s = FACTORS[name](p)
except Exception as exc: # noqa: BLE001
print(f"{name}: failed {exc}")
continue
row = {"factor": name}
for per, (a, b) in periods.items():
sl = slice(a, b)
ic5 = ic_summary(ic_series(s.loc[sl], fwd5.loc[sl], p.member.loc[sl], 5))
ic20 = ic_summary(ic_series(s.loc[sl], fwd20.loc[sl], p.member.loc[sl], 20), 12)
tmu = top_minus_universe(s.loc[sl], fwd5.loc[sl], p.member.loc[sl], args.top_n, 5)
st = phase_avg(p, s, a, b, args.top_n, args.every)
row.update({f"{per}_ic5": ic5["ic"], f"{per}_ic5_t": ic5["t"],
f"{per}_ic20": ic20["ic"], f"{per}_ic20_t": ic20["t"],
f"{per}_top_excess_ann": tmu.mean() * 52,
f"{per}_cagr": st["cagr"], f"{per}_sharpe": st["sharpe"],
f"{per}_mdd": st["mdd"], f"{per}_turn": st["turnover"]})
rows.append(row)
print(f"{name:16s} "
f"D: ic5 {row['design_ic5']:+.3f} (t{row['design_ic5_t']:+.1f}) ic20 {row['design_ic20']:+.3f} "
f"xs {row['design_top_excess_ann']*100:+5.1f}% cagr {row['design_cagr']*100:5.1f}% sh {row['design_sharpe']:.2f} | "
f"H: ic5 {row['holdout_ic5']:+.3f} (t{row['holdout_ic5_t']:+.1f}) ic20 {row['holdout_ic20']:+.3f} "
f"xs {row['holdout_top_excess_ann']*100:+5.1f}% cagr {row['holdout_cagr']*100:5.1f}% sh {row['holdout_sharpe']:.2f} "
f"turn {row['holdout_turn']:.0f}x [{time.time()-t:.0f}s]", flush=True)
# equal-weight universe reference
os.makedirs(RESULTS, exist_ok=True)
out = pd.DataFrame(rows)
out.to_csv(os.path.join(RESULTS, f"factor_scan_{args.market}.csv"), index=False)
if __name__ == "__main__":
main()