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>
This commit is contained in:
95
research/lab/factor_scan.py
Normal file
95
research/lab/factor_scan.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user