""" 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()