From 989e5e1d262dfb0432f3c2fce2c736df230b5a92 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Wed, 10 Jun 2026 15:26:38 +0800 Subject: [PATCH] feat(alpha): P3-2 walk-forward IC-weighted alpha (RollingICWeightAlpha) Adds the first LEARNED factor combination while keeping the lookahead boundary auditable. EqualWeightAlpha stays the default and the regression baseline; alpha.model: ic_weighted opts into walk-forward rolling-IC weights. No parameter search; not a performance claim. Lookahead boundary (locked by tests): - Training is walk-forward: at each date d, a (factor[t], fwd_h[t]) pair is admissible only once REALIZED -- t + h <= d in trading-day positions (the h-day forward return of factor date t realizes at t+h). A perturbation test proves unrealized forward returns cannot change any date's weights; a boundary test locks the exact t+h cutoff via min_periods. - Forward returns are computed at the alpha boundary and handed ONLY to alpha.fit; the factor layer never sees them (invariant #1 unchanged). Model: - weights = mean per-factor cross-sectional rank IC over the trailing window of realized observations (rolling, conservative default; expanding optional), L1-normalized and SIGN-PRESERVING (negative-IC factor gets a negative weight). - Fallback: any factor with < min_periods valid realized ICs (or degenerate ICs) -> that date uses the EQUAL-WEIGHT mean (bitwise EqualWeightAlpha) and is logged; the report discloses coverage + per-rebalance effective weights (fallback rows flagged). Wiring: - _build_alpha dispatch (alpha.model now a validated Literal); horizon tied to the first forward-return period; alpha_summary/alpha_weights on Phase0Result/Phase2Result; 'Alpha model' is a REQUIRED report section; combo-score labels name the active alpha. - config/phase3_real_ic_weighted.yaml: identical to phase3_real_multifactor except alpha.model (directly comparable). Real runs (not in CI): - Equal-weight phase3 regression rerun: annual -9.05% / IC 0.0083 unchanged. - ic_weighted: annual -3.57%, maxDD -12.93%, training coverage 201/221 (20 fallbacks, all before the window filled). Beating equal weight here is NOT a performance claim: one-year window + per-period weight sign flips (e.g. momentum_20 -0.58 -> +0.36) show small-sample instability, disclosed. Tests: 269 passed (was 249): +12 alpha unit (incl. perturbation + cutoff boundary), +6 pipeline wiring, +2 report disclosure. Demo equal-weight numbers locked unchanged (ic 0.96 / annual 0.84). --- AGENTS.md | 9 +- BIAS_AUDIT.md | 1 + CLAUDE.md | 11 +- RUNBOOK.md | 40 +++++ TEST_REPORT.md | 31 +++- alpha/base.py | 4 + alpha/ic_weight.py | 248 ++++++++++++++++++++++++++++ config/phase3_real_ic_weighted.yaml | 107 ++++++++++++ qt/config.py | 4 +- qt/phase2_baseline.py | 17 +- qt/pipeline.py | 113 ++++++++++++- qt/reports.py | 136 +++++++++++++-- tests/test_ic_alpha_pipeline.py | 144 ++++++++++++++++ tests/test_ic_weight_alpha.py | 203 +++++++++++++++++++++++ tests/test_phase2_baseline.py | 37 +++++ 15 files changed, 1073 insertions(+), 32 deletions(-) create mode 100644 alpha/ic_weight.py create mode 100644 config/phase3_real_ic_weighted.yaml create mode 100644 tests/test_ic_alpha_pipeline.py create mode 100644 tests/test_ic_weight_alpha.py diff --git a/AGENTS.md b/AGENTS.md index de00d70..d7d2ce8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,11 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - 财务字段**一次 fetch + 一次 as-of 对齐**;合成仍 EqualWeightAlpha 等权;`drop_missing` 要求全因子齐备(披露);demo+财务因子仍可读报错;重名因子报错。 - 报告:active factor list / per-factor coverage+IC+分位 / combo score 诊断 / 财务 coverage 按字段(TRADED vs diagnostic);`output.baseline_report_name` 分离 phase3 报告。 - 真实结果:多因子 annual **−9.05%**(单因子 −10.19%);momentum_20 IC 0.0083(跨 run 一致)/ roe 0.0006 / netprofit_yoy 0.0001 / combo −0.0038;财务 ann_date 覆盖 100%;PIT SW-L1 98.53%。回归不破:phase2 rerun −10.19%/0.0083 不变,demo 0.96/0.84 不变。 -- ✅ 当前质量门:`pytest -p no:cacheprovider` **249 passed**;`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.yaml`)OK;`run-phase0`(demo)OK。 +- 🔧 **Phase 3-2 walk-forward IC 加权 alpha**(`p3-ic-weighted-alpha` 分支,代劳待验收):`alpha/ic_weight.py::RollingICWeightAlpha`(`alpha.model: ic_weighted`);EqualWeightAlpha 仍默认+回归基线;不调参、非收益声明。 + - Lookahead 边界测试锁定:(factor[t], fwd_h[t]) 仅**已实现**(`t+h <= d` 交易日序)进权重;扰动未实现 fwd 权重不变;fwd 只进 `alpha.fit`,factors 层绝不接触。 + - rolling(默认 60d/min20)/expanding;历史不足→该日退回等权(计数披露);权重 L1 归一化、保留符号。报告新增 **Alpha model** 必含小节(模型/超参/覆盖率/每期权重表/fallback/非调参声明)。 + - `config/phase3_real_ic_weighted.yaml` 与 phase3_real_multifactor 唯一差异 alpha.model(直接可比)。 + - 真实结果:annual **−3.57%**(等权 −9.05%),训练覆盖 201/221(20 fallback 全在窗口攒满前)。⚠️ 优于等权非业绩声明——单年窗口+权重逐期翻号即小样本不稳定,照实披露。回归不破:等权 rerun −9.05%/0.0083 不变,demo 0.96/0.84 不变。 +- ✅ 当前质量门:`pytest -p no:cacheprovider` **269 passed**;`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.yaml` + `phase3_real_ic_weighted.yaml`)OK;`run-phase0`(demo)OK。 - ⚠️ 剩余(已显式披露):日线 only、demo 路径非真数据。 -- 路线图下一步:alpha 加权合成(IC 加权/回归)/ 分钟级(architecture.html §11)。 +- 路线图下一步:样本外/IC 稳定性检验 / 分钟级(architecture.html §11)。 diff --git a/BIAS_AUDIT.md b/BIAS_AUDIT.md index e65d03b..e360c13 100644 --- a/BIAS_AUDIT.md +++ b/BIAS_AUDIT.md @@ -8,6 +8,7 @@ - `momentum_20[t] = close[t] / close[t-window] - 1`,严格只用 t 及之前的收盘价(`groupby(symbol).shift(window)`)。 - 事件顺序固定:在 t 收盘计算因子,t 收盘后调仓,从 t+1 持有。回测用**下一持有期**的收益结算,绝不使用因子已经看见的当日收益。 - forward returns 只在 `analytics/` 计算,因子层永远拿不到未来收益(INV-001)。 +- **alpha 层 walk-forward 权重训练(P3-2,`alpha.model: ic_weighted`)**:alpha 层是**唯一**允许看 forward returns 的层,且只用于拟合因子权重——训练严格 walk-forward:对每个打分日 d,(factor[t], fwd_h[t]) 对只有在**已实现**(按交易日序 `t + h <= d`,h 日 forward return 在 t+h 才实现)时才进入 d 的权重;扰动任何未实现的 forward return 不改变 d 的权重(扰动测试锁定)。窗口 rolling(默认,保守)或 expanding;历史不足(任一因子有效已实现 IC < min_periods)→ 该日**退回等权**并计数披露;权重 L1 归一化、保留符号。固定配方、不调参,非收益声明。factors 层边界不变:forward returns 由 pipeline 在 alpha 边界计算、只传 `alpha.fit`,因子计算在其之前完成且永不接触。 ## PIT 成分股 diff --git a/CLAUDE.md b/CLAUDE.md index 0308310..0987dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,13 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - 报告增强:active factor list / per-factor coverage+IC+分位 / **combo score** 诊断 / 财务 coverage **按字段**披露(TRADED vs diagnostic 角色标注);`output.baseline_report_name` 使 phase3 报告独立于 phase2。 - **真实结果**(SSE50 2023-07~2024-06,~14min):多因子 annual **−9.05%**(单因子 −10.19%);per-factor IC: momentum_20 0.0083(与 phase2 run 完全一致,跨 run 一致性实证)/ roe 0.0006 / netprofit_yoy 0.0001;combo IC −0.0038;财务两字段 ann_date 覆盖 **100%**;PIT SW-L1 98.53%。财务因子在该小截面短窗口 IC≈0,照实披露——这是 plumbing 验证。 - **回归不破**:phase2 单因子真实 rerun annual −10.19% / IC 0.0083 不变;demo ic 0.96/annual 0.84 不变。 -- ✅ 质量门:`pytest` **249 passed**(P0=97 / P1=78 / P2-1=20 / P2-2=22 / P2-3=14 / P2-4=8 / P3-1=10);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.yaml`)+ `run-phase0`(demo)均 OK。 +- 🔧 **Phase 3-2 walk-forward IC 加权 alpha**(`p3-ic-weighted-alpha` 分支,代劳待验收):新增 `alpha/ic_weight.py::RollingICWeightAlpha`(`alpha.model: ic_weighted`);EqualWeightAlpha 仍默认 + 回归基线。**不调参、非收益声明**。 + - **Lookahead 边界(测试锁定)**:训练严格 walk-forward——(factor[t], fwd_h[t]) 只有**已实现**(交易日序 `t+h <= d`)才进日 d 的权重;**扰动未实现 forward returns 权重不变**(扰动测试)+ `t+h` 切片精确边界测试。forward returns 由 pipeline 在 alpha 边界计算、只传 `alpha.fit`,factors 层照旧绝不接触(不变量 #1)。 + - rolling(默认保守,窗口=60 交易日,min_periods=20)/ expanding 可配;历史不足 → 该日**退回等权**(与 EqualWeightAlpha 逐 bit 一致)并计数披露;权重 L1 归一化、保留符号(负 IC 因子负权重)。 + - 报告新增 **Alpha model** 必含小节:active model / 超参 / 训练覆盖率 + fallback 次数 / 每调仓日生效权重表(fallback 行标注)/ 非调参声明 + 等权基线对比指引。 + - `config/phase3_real_ic_weighted.yaml`:与 phase3_real_multifactor **唯一差异是 alpha.model**(universe/window/因子/中性化全同,直接可比)。 + - **真实结果**(SSE50 2023-07~2024-06,~14min):annual **−3.57%**(等权 −9.05% / 单因子 −10.19%),maxDD −12.93%,训练覆盖 **201/221**(20 个 fallback 全在窗口攒满前,90.95%)。⚠️ 优于等权**不是**业绩声明——单年窗口 + 权重逐期翻号(如 momentum_20 从 −0.58 到 +0.36)正是小样本不稳定的体现,照实披露。 + - **回归不破**:phase3 等权真实 rerun annual −9.05% / IC 0.0083 不变;demo equal_weight ic 0.96/annual 0.84 不变(测试锁定)。 +- ✅ 质量门:`pytest` **269 passed**(P0=97 / P1=78 / P2-1=22 / P2-2=22 / P2-3=14 / P2-4=8 / P3-1=10 / P3-2=18);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.yaml` + `phase3_real_ic_weighted.yaml`)+ `run-phase0`(demo)均 OK。 - ⚠️ 剩余(已显式披露):日线 only、demo 路径非真数据。 -- 路线图下一步:alpha 加权合成(IC 加权/回归)/ 分钟级(architecture.html §11)。 +- 路线图下一步:样本外/IC 稳定性检验 / 分钟级(architecture.html §11)。 diff --git a/RUNBOOK.md b/RUNBOOK.md index d9e0d64..e4f0684 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -222,6 +222,46 @@ What P3-1 changes (locked by tests): labelled TRADED factor vs diagnostic-only. Standard analytics stays report-only (alphalens cross-checks the primary factor). +## Phase 3-2 — walk-forward IC-weighted alpha + +P3-2 adds the first LEARNED factor combination while keeping the lookahead +boundary auditable: `alpha.model: ic_weighted` weights each factor by its mean +cross-sectional rank IC over a trailing window of **realized** observations +only. `EqualWeightAlpha` stays the default and the regression baseline. +Documented by `config/phase3_real_ic_weighted.yaml` (identical universe / +window / factors / neutralization to `phase3_real_multifactor.yaml` — the ONLY +change is the alpha model, so the two runs are directly comparable). + +```bash +# validate (no network) +... -m qt.cli validate-config --config config/phase3_real_ic_weighted.yaml +# run the ic-weighted real baseline (network + token; heavy, ~10-30 min) +... -m qt.cli run-phase2-baseline --config config/phase3_real_ic_weighted.yaml +``` + +Lookahead boundary (locked by tests): + +- The factor layer NEVER sees forward returns (invariant #1 unchanged); the + pipeline computes them at the alpha boundary and hands them ONLY to + ``alpha.fit``. +- Training is **walk-forward**: at each date d, a (factor[t], fwd_h[t]) pair is + admissible only once REALIZED — ``t + h <= d`` in trading-day positions (the + h-day forward return of factor date t realizes at t+h). Perturbing any + not-yet-realized forward return cannot change d's weights (perturbation test). +- Window modes: ``rolling`` (trailing `window` trading days, the conservative + default) or ``expanding`` (all realized history). +- **Fallback**: if any factor has fewer than ``min_periods`` valid realized ICs + in the window (or the ICs are degenerate), that date falls back to the EQUAL + WEIGHT mean — identical to `EqualWeightAlpha` — and is counted + disclosed in + the report. +- Weights are L1-normalized and sign-preserving (a negative-IC factor gets a + negative weight). Fixed recipe, no parameter search — NOT a tuned-performance + claim. + +The baseline report gains an **Alpha model** section: active model, +hyper-params, training coverage (fallback count), and the effective weights at +every settled rebalance date (fallback rows flagged). + ## Quality gate ```bash diff --git a/TEST_REPORT.md b/TEST_REPORT.md index d12fa32..53a764e 100644 --- a/TEST_REPORT.md +++ b/TEST_REPORT.md @@ -1,4 +1,4 @@ -# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 + Phase 3-1 (bias-boundary → execution realism → PIT industry → standard analytics → multi-factor) +# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 + Phase 3 (bias-boundary → execution realism → PIT industry → standard analytics → multi-factor → walk-forward IC alpha) ## Commands @@ -11,6 +11,7 @@ Run from the repo root with the project python (env `quant_mf`): /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.cli validate-config --config config/example_tushare.yaml /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.cli validate-config --config config/phase2_real_baseline.yaml /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.cli validate-config --config config/phase3_real_multifactor.yaml +/home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.cli validate-config --config config/phase3_real_ic_weighted.yaml /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m qt.cli run-phase0 --config config/example.yaml ``` @@ -18,12 +19,12 @@ Run from the repo root with the project python (env `quant_mf`): | Gate | Command | Result | |---|---|---| -| Unit + integration | `pytest -q` | **249 passed, 0 failed** | +| Unit + integration | `pytest -q` | **269 passed, 0 failed** | | Lint | `ruff check .` | **All checks passed** | -| Config validation | `validate-config` (demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.yaml`) | exit `0`, prints `OK` | +| Config validation | `validate-config` (demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.yaml` + `phase3_real_ic_weighted.yaml`) | exit `0`, prints `OK` | | End-to-end run | `run-phase0` (demo) | exit `0`, writes `artifacts/reports/phase0_summary.md` | -Counts below are the actual per-file `pytest` numbers (sum = 249). +Counts below are the actual per-file `pytest` numbers (sum = 269). ## Per-file breakdown — Phase 0 core (97) @@ -73,7 +74,7 @@ Counts below are the actual per-file `pytest` numbers (sum = 249). | Test file | Tests | Feature | |---|---|---| -| `test_phase2_baseline.py` | 20 | collectors, demo/real guard, report-field contract, no-secret-leak, settled-vs-candidate dates, loaded-vs-in-window membership, list_date + PIT-industry coverage, standard-analytics cross-check, P3-1 factor-list/per-field-role/per-factor-table/report-name | +| `test_phase2_baseline.py` | 22 | collectors, demo/real guard, report-field contract, no-secret-leak, settled-vs-candidate dates, loaded-vs-in-window membership, list_date + PIT-industry coverage, standard-analytics cross-check, P3-1 factor-list/per-field-role/per-factor-table/report-name, P3-2 alpha-model disclosure (equal-weight line + ic-weighted weights/fallback/no-claim) | ## Per-file breakdown — Phase 2-2 execution realism (22) @@ -101,7 +102,14 @@ Counts below are the actual per-file `pytest` numbers (sum = 249). | Test file | Tests | Red-line / feature | |---|---|---| | `test_multifactor_pipeline.py` | 10 | all enabled factors built (order / disabled / duplicate / none), financials fetched ONCE for all fields + as-of both columns + input immutability, demo+financial readable error, e2e demo multi-factor panel + per-factor/combo analytics + primary==first, report factor list + combo + no secret, single-factor legacy shape | -| **Total (P0 + P1 + P2-1..P2-4 + P3-1)** | **249** | | + +## Per-file breakdown — Phase 3-2 walk-forward IC alpha (18) + +| Test file | Tests | Red-line / feature | +|---|---|---| +| `test_ic_weight_alpha.py` | 12 | **lookahead red-line**: perturbing unrealized forward returns cannot change weights; exact `t + h <= d` realization cutoff (min_periods boundary); insufficient-history equal-weight fallback (== EqualWeightAlpha row mean); single-factor degeneration to ±1; L1 normalization + sign preservation; degenerate-IC fallback; rolling-vs-expanding window; fit requires forward_returns; dated-cross-section contract; input immutability; weights/fallback log | +| `test_ic_alpha_pipeline.py` | 6 | alpha dispatch by config (equal_weight / ic_weighted + params / unknown = ConfigError), equal-weight default keeps exact demo numbers (ic 0.96 / annual 0.84) + report line, ic_weighted demo e2e (summary, weights log, early-fallback→late-trained, L1 rows, report disclosure, no secret), ic-weights differ from equal weight on diverging-IC synthetic data | +| **Total (P0 + P1 + P2-1..P2-4 + P3-1 + P3-2)** | **269** | | ## Real-data validation (manual, not in CI — TEST-002 keeps the suite network-free) @@ -163,6 +171,17 @@ Counts below are the actual per-file `pytest` numbers (sum = 249). diagnostics, and per-field ann_date coverage labelled TRADED vs diagnostic-only. `output.baseline_report_name` keeps the phase3 report file separate from the phase2 one. +- **P3-2 walk-forward IC alpha (locked by tests):** `alpha.model: ic_weighted` + weights factors by mean realized rank IC over a trailing window — a + (factor[t], fwd_h[t]) pair enters date d's weights only once REALIZED + (`t + h <= d`, trading days); a perturbation test proves unrealized forward + returns cannot change any date's weights. Forward returns are computed at the + alpha boundary and handed ONLY to `alpha.fit` (the factor layer never sees + them, invariant #1). Insufficient realized history (< min_periods valid ICs + for any factor) falls back per-date to the EQUAL-WEIGHT mean — bitwise the + EqualWeightAlpha combination — and is counted in the report. Weights are + L1-normalized, sign-preserving. `EqualWeightAlpha` remains the default; its + demo numbers (ic 0.96 / annual 0.84) are locked unchanged. - A duplicate test-function name across two files was found and renamed during P2-2 (it had been silently shadowing one test in the full-suite run). A second, harmless duplicate (`test_enrich_does_not_mutate_input` in two files) was verified NOT to drop diff --git a/alpha/base.py b/alpha/base.py index 8ad5fe3..0a3e5b5 100644 --- a/alpha/base.py +++ b/alpha/base.py @@ -16,6 +16,10 @@ class AlphaModel(ABC): """Abstract multi-factor combination / prediction model.""" + # pipeline hint: True when fit() needs historical forward returns (the + # pipeline then computes and passes them to fit — and ONLY to fit). + requires_forward_returns: bool = False + @abstractmethod def fit( self, diff --git a/alpha/ic_weight.py b/alpha/ic_weight.py new file mode 100644 index 0000000..634a6c6 --- /dev/null +++ b/alpha/ic_weight.py @@ -0,0 +1,248 @@ +"""RollingICWeightAlpha: walk-forward IC-weighted multi-factor combination (P3-2). + +The alpha layer is the ONLY layer allowed to see forward returns, and only for +weight fitting (CLAUDE.md invariant #1; the factor layer never receives them). +This model makes that boundary auditable: + + * ``fit`` computes the per-date cross-sectional rank IC of every factor column + against the supplied forward returns (the full series is STORED, never + consumed whole); + * ``predict`` at date ``d`` uses ONLY realized observations: a (factor[t], + fwd_h[t]) pair is admissible iff ``t + h <= d`` in TRADING-DAY positions — + the h-day forward return of factor date t realizes at t+h, so anything later + is invisible at d. Perturbing unrealized forward returns cannot change the + weights (locked by tests). + +Weights = mean realized IC per factor over the training window (``rolling`` = +the trailing ``window`` trading days, the conservative default; ``expanding`` = +all realized history), L1-normalized and SIGN-PRESERVING (a negative-IC factor +gets a negative weight — standard IC weighting). Fallback contract: if any +factor has fewer than ``min_periods`` valid realized ICs in the window, or the +ICs are degenerate (all zero/NaN), the date falls back to the EQUAL-WEIGHT mean +(identical to ``EqualWeightAlpha``) and the fallback is logged for disclosure. + +Per-date effective weights + fallback flags are recorded (``weights_log`` / +``fallback_log``) so the report can disclose them. Inputs are never mutated. +""" + +from __future__ import annotations + +import math + +import pandas as pd + +from alpha.base import AlphaModel +from analytics.factor import compute_ic + +_SYMBOL_LEVEL = "symbol" +_DATE_LEVEL = "date" +_VALID_MODES = ("rolling", "expanding") + + +class RollingICWeightAlpha(AlphaModel): + """Walk-forward rolling/expanding IC-weighted factor combiner.""" + + # pipeline hint: this model needs historical forward returns in fit(). + requires_forward_returns: bool = True + + def __init__( + self, + window: int = 60, + min_periods: int = 20, + horizon: int = 1, + mode: str = "rolling", + ) -> None: + if mode not in _VALID_MODES: + raise ValueError( + f"RollingICWeightAlpha mode must be one of {_VALID_MODES}; got {mode!r}." + ) + if window < 1 or min_periods < 1 or horizon < 1: + raise ValueError( + "RollingICWeightAlpha window / min_periods / horizon must be >= 1; " + f"got window={window}, min_periods={min_periods}, horizon={horizon}." + ) + self._window = int(window) + self._min_periods = int(min_periods) + self._horizon = int(horizon) + self._mode = mode + self._ic: pd.DataFrame | None = None + self._calendar: pd.DatetimeIndex | None = None + self._log: dict[pd.Timestamp, dict] = {} + + # ------------------------------------------------------------------ # + # AlphaModel interface + # ------------------------------------------------------------------ # + def fit( + self, + factors: pd.DataFrame, + forward_returns: pd.Series | None = None, + ) -> "RollingICWeightAlpha": + """Compute and store the per-date per-factor rank-IC series. + + ``forward_returns`` is REQUIRED here (this model learns weights from + realized history); the series is stored as per-date ICs and only ever + consumed through the realized-at-d slice in :meth:`predict`. + """ + if not isinstance(factors, pd.DataFrame): + raise TypeError( + "RollingICWeightAlpha expects a pandas DataFrame of factors; " + f"got {type(factors).__name__}." + ) + if forward_returns is None: + raise ValueError( + "RollingICWeightAlpha.fit requires forward_returns (it learns " + "factor weights from realized history). Use EqualWeightAlpha for " + "a no-future-data baseline." + ) + ic_cols = { + name: compute_ic(factors[name], forward_returns) + for name in factors.columns + } + ic = pd.DataFrame(ic_cols).sort_index() + self._ic = ic + self._calendar = pd.DatetimeIndex(ic.index) + self._log = {} + return self + + def predict(self, factors_today: pd.DataFrame) -> pd.Series: + """Score one DATED cross-section with weights trained walk-forward. + + The cross-section must carry its date (MultiIndex with a ``date`` + level): without it the realized-history cutoff ``t + h <= d`` cannot be + enforced, so a plain symbol-indexed frame is a readable error. + """ + self._require_fitted() + date = self._extract_date(factors_today) + weights = self.weights_for(date) + n_factors = factors_today.shape[1] + if weights is None: + # EQUAL-WEIGHT fallback — identical to EqualWeightAlpha's row mean. + scores = factors_today.mean(axis=1) + effective = pd.Series( + 1.0 / n_factors if n_factors else float("nan"), + index=list(factors_today.columns), + ) + self._log[date] = { + "fallback": True, + "reason": ( + f"insufficient realized IC history (< {self._min_periods} valid " + f"realized ICs for some factor in the {self._mode} window) or " + "degenerate ICs" + ), + "weights": effective, + } + else: + # NaN rows propagate to NaN scores (skipna=False): a name missing a + # factor is not scored on a partial weighted sum. + scores = (factors_today[weights.index] * weights).sum(axis=1, skipna=False) + self._log[date] = {"fallback": False, "reason": None, "weights": weights} + return self._to_symbol_index(scores).rename("score") + + # ------------------------------------------------------------------ # + # walk-forward training slice (the lookahead boundary lives HERE) + # ------------------------------------------------------------------ # + def _realized_window(self, date: pd.Timestamp) -> pd.DataFrame: + """IC rows realized at ``date``, truncated to the training window. + + A factor date t's h-day forward return realizes at trading position + pos(t) + h, so the admissible rows are pos(t) <= pos(date) - h. Rolling + mode then keeps the trailing ``window`` TRADING DAYS (not the trailing + ``window`` valid ICs — conservative and auditable); expanding keeps all. + """ + assert self._ic is not None and self._calendar is not None + pos_d = int(self._calendar.searchsorted(pd.Timestamp(date), side="right")) - 1 + cutoff = pos_d - self._horizon + if cutoff < 0: + return self._ic.iloc[0:0] + realized = self._ic.iloc[: cutoff + 1] + if self._mode == "rolling": + realized = realized.iloc[-self._window:] + return realized + + def weights_for(self, date: pd.Timestamp) -> pd.Series | None: + """L1-normalized sign-preserving weights at ``date`` (None = fallback).""" + self._require_fitted() + realized = self._realized_window(pd.Timestamp(date)) + if realized.empty: + return None + counts = realized.notna().sum() + if bool((counts < self._min_periods).any()): + return None + raw = realized.mean() # skipna mean IC per factor + l1 = float(raw.abs().sum()) + if not math.isfinite(l1) or l1 == 0.0: + return None # degenerate (all-zero / NaN ICs) + return raw / l1 + + def train_size_for(self, date: pd.Timestamp) -> int: + """Number of realized IC rows in the training window at ``date``.""" + self._require_fitted() + return int(len(self._realized_window(pd.Timestamp(date)))) + + # ------------------------------------------------------------------ # + # disclosure logs (consumed by the report) + # ------------------------------------------------------------------ # + def weights_log(self) -> pd.DataFrame: + """Per-predicted-date EFFECTIVE weights + a ``fallback`` flag column.""" + if not self._log: + cols = list(self._ic.columns) if self._ic is not None else [] + return pd.DataFrame(columns=[*cols, "fallback"]) + rows = {} + for date, entry in sorted(self._log.items()): + row = dict(entry["weights"]) + row["fallback"] = entry["fallback"] + rows[date] = row + out = pd.DataFrame.from_dict(rows, orient="index") + out.index.name = _DATE_LEVEL + return out + + def fallback_log(self) -> dict[pd.Timestamp, str | None]: + """date -> fallback reason (None when the trained weights were used).""" + return {date: entry["reason"] for date, entry in sorted(self._log.items())} + + def params(self) -> dict: + """Echoable hyper-parameters (for the report; no secrets).""" + return { + "window": self._window, + "min_periods": self._min_periods, + "horizon": self._horizon, + "mode": self._mode, + } + + # ------------------------------------------------------------------ # + # helpers + # ------------------------------------------------------------------ # + def _require_fitted(self) -> None: + if self._ic is None: + raise ValueError( + "RollingICWeightAlpha is not fitted; call fit(factors, " + "forward_returns) first." + ) + + @staticmethod + def _extract_date(factors_today: pd.DataFrame) -> pd.Timestamp: + idx = factors_today.index + if not (isinstance(idx, pd.MultiIndex) and _DATE_LEVEL in (idx.names or [])): + raise ValueError( + "RollingICWeightAlpha.predict needs a DATED cross-section " + "(MultiIndex with a 'date' level): the walk-forward cutoff " + "t + h <= d cannot be enforced without the prediction date." + ) + dates = idx.get_level_values(_DATE_LEVEL).unique() + if len(dates) != 1: + raise ValueError( + "RollingICWeightAlpha.predict expects a single-date cross-section; " + f"got {len(dates)} distinct dates." + ) + return pd.Timestamp(dates[0]) + + @staticmethod + def _to_symbol_index(scores: pd.Series) -> pd.Series: + """Collapse a (date, symbol) cross-section to a plain symbol index.""" + index = scores.index + if isinstance(index, pd.MultiIndex) and _SYMBOL_LEVEL in (index.names or []): + symbols = index.get_level_values(_SYMBOL_LEVEL) + out = pd.Series(scores.to_numpy(), index=symbols) + out.index.name = _SYMBOL_LEVEL + return out + return scores diff --git a/config/phase3_real_ic_weighted.yaml b/config/phase3_real_ic_weighted.yaml new file mode 100644 index 0000000..d8eb2d6 --- /dev/null +++ b/config/phase3_real_ic_weighted.yaml @@ -0,0 +1,107 @@ +# Phase 3-2 — REAL walk-forward IC-weighted multi-factor baseline. +# +# Identical universe / window / factors / neutralization to +# config/phase3_real_multifactor.yaml (the EQUAL-WEIGHT baseline) so the two are +# directly comparable; the ONLY change is alpha.model: ic_weighted — factor +# weights are the mean per-factor rank IC over a trailing window of REALIZED +# observations only (a (factor[t], fwd_h[t]) pair enters date d's weights only +# once t + h <= d in trading days). Insufficient history falls back to equal +# weight (disclosed in the report). NO parameter search; NOT a performance claim. +# +# Run: python -m qt.cli run-phase2-baseline --config config/phase3_real_ic_weighted.yaml +# (shared real-baseline runner; report goes to artifacts/reports/phase3_real_ic_weighted.md. +# Needs the tushare token in the external .config.json; hits the network; heavy.) + +project: + name: quantitative_trading_phase3_real_ic_weighted + timezone: Asia/Shanghai + +data: + source: tushare + freq: D + start: "2023-07-01" + end: "2024-06-30" + external_secret_file: "/home/shaofl/Projects/financial_projects/.config.json" + tushare_token_key: "tushare.token" + output_name: phase3iw_daily + +universe: + type: index + index_code: "000016.SH" + symbols: [] + min_listing_days: 60 + filters: + missing_close: true + suspended: true + st: true + limit_up_down: true + +factors: + - name: momentum_20 + enabled: true + params: + window: 20 + price_col: close + - name: roe + enabled: true + params: {} + - name: netprofit_yoy + enabled: true + params: {} + +processing: + drop_missing: true + standardize: + enabled: true + method: zscore + winsorize: + enabled: false + method: mad + n: 3.0 + neutralize: + enabled: true + industry_col: industry + size_col: market_cap + industry_level: L1 # PIT SW level (L1/L2/L3); L1 = 31 broad sectors (standard, DOF-safe) + +alpha: + model: ic_weighted # P3-2 walk-forward rolling-IC weights + params: + window: 60 # trailing trading days of REALIZED ICs (conservative rolling) + min_periods: 20 # < this many valid realized ICs -> equal-weight fallback (disclosed) + mode: rolling # rolling (default) | expanding + +portfolio: + constructor: topn_equal_weight + top_n: 20 + long_only: true + max_weight: null + turnover_cap: null + +backtest: + initial_nav: 1.0 + rebalance: monthly + event_order: close_to_next_period + cash_return: 0.0 + +cost: + fee_rate: 0.001 + slippage_rate: 0.0 + turnover_formula: l1 + +analytics: + forward_return_periods: + - 1 + - 5 + - 20 + quantiles: 5 + benchmark: null + +output: + root_dir: artifacts + data_dir: artifacts/data + factor_dir: artifacts/factors + report_dir: artifacts/reports + log_dir: artifacts/logs + overwrite: true + baseline_report_name: phase3_real_ic_weighted.md diff --git a/qt/config.py b/qt/config.py index 13d41aa..2df2151 100644 --- a/qt/config.py +++ b/qt/config.py @@ -131,7 +131,9 @@ class ProcessingCfg(_Strict): class AlphaCfg(_Strict): - model: str = "equal_weight" + # equal_weight = P0 baseline (no future data); ic_weighted = P3-2 + # walk-forward rolling-IC weights (alpha layer only sees REALIZED history). + model: Literal["equal_weight", "ic_weighted"] = "equal_weight" params: dict[str, Any] = Field(default_factory=dict) diff --git a/qt/phase2_baseline.py b/qt/phase2_baseline.py index e18142d..34a0240 100644 --- a/qt/phase2_baseline.py +++ b/qt/phase2_baseline.py @@ -30,7 +30,6 @@ import pandas as pd -from alpha.equal_weight import EqualWeightAlpha from analytics.performance import performance_summary from factors.compute.financial import SUPPORTED_FIELDS as SUPPORTED_FINANCIAL_FIELDS from factors.compute.financial import FinancialFactor @@ -38,6 +37,9 @@ from qt.config import RootConfig, load_config from qt.pipeline import ( _FrameScores, + _alpha_disclosure, + _alpha_forward_returns, + _build_alpha, _build_factors, _build_scores, _build_universe, @@ -107,6 +109,10 @@ class Phase2Result: # combo_analytics = {ic_mean, ic_ir, quantile_returns} on the traded score. per_factor: dict[str, dict] combo_analytics: dict + # P3-2 alpha disclosure (model, hyper-params, fallback counts; weights log + # is the full per-date EFFECTIVE weights for walk-forward models else None). + alpha_summary: dict + alpha_weights: pd.DataFrame | None nav_table: pd.DataFrame avg_turnover: float cost_drag: float @@ -361,7 +367,12 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: panel = _maybe_enrich_listing(cfg, panel, symbols, logger) factor_panel = _compute_factor_panel(cfg, panel, factors, logger) processed = _process_factors(cfg, factor_panel, panel) - score_panel = _build_scores(processed, EqualWeightAlpha()) + alpha = _build_alpha(cfg) + score_panel = _build_scores( + processed, alpha, _alpha_forward_returns(cfg, panel, alpha) + ) + alpha_summary, alpha_weights = _alpha_disclosure(cfg, alpha) + logger.info("alpha: %s", alpha_summary) scores = _FrameScores(score_panel) constructor = TopNEqualWeight(cfg.portfolio.top_n, long_only=cfg.portfolio.long_only) @@ -483,6 +494,8 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: factor_names=tuple(f.name for f in factors), per_factor=per_factor, combo_analytics=combo, + alpha_summary=alpha_summary, + alpha_weights=alpha_weights, nav_table=nav_table, avg_turnover=float(nav_table["turnover"].mean()) if not nav_table.empty else 0.0, cost_drag=float(nav_table["cost"].sum()) if not nav_table.empty else 0.0, diff --git a/qt/pipeline.py b/qt/pipeline.py index 872e9d7..b9f9d7c 100644 --- a/qt/pipeline.py +++ b/qt/pipeline.py @@ -33,7 +33,9 @@ import pandas as pd +from alpha.base import AlphaModel from alpha.equal_weight import EqualWeightAlpha +from alpha.ic_weight import RollingICWeightAlpha from analytics.alphalens_adapter import alphalens_factor_metrics from analytics.factor import compute_ic, forward_returns, ic_summary, quantile_returns from analytics.performance import performance_summary @@ -100,6 +102,11 @@ class Phase0Result: # combo_analytics = {ic_mean, ic_ir, quantile_returns} on the traded score. per_factor: dict[str, dict] combo_analytics: dict + # P3-2 alpha disclosure: summary = {model, [hyper-params, n_dates, + # n_fallback, trained_coverage]}; weights = full per-date EFFECTIVE weights + # log (+ fallback flag) for walk-forward models, None for equal_weight. + alpha_summary: dict + alpha_weights: pd.DataFrame | None # legacy top-level metrics == the PRIMARY factor's (unchanged for # single-factor configs). ic_mean: float @@ -159,16 +166,87 @@ def _make_logger(log_path: Path, name: str = _LOGGER_NAME) -> logging.Logger: return logger +def _build_alpha(cfg: RootConfig) -> AlphaModel: + """Instantiate the configured alpha model (P3-2). + + ``equal_weight`` -> :class:`EqualWeightAlpha` (P0 baseline, no future data). + ``ic_weighted`` -> :class:`RollingICWeightAlpha` (walk-forward rolling-IC + weights; the IC horizon is tied to the FIRST configured forward-return + period so training and the reported IC share one definition). + """ + params = dict(cfg.alpha.params) + if cfg.alpha.model == "equal_weight": + return EqualWeightAlpha() + if cfg.alpha.model == "ic_weighted": + return RollingICWeightAlpha( + window=int(params.get("window", 60)), + min_periods=int(params.get("min_periods", 20)), + horizon=int(cfg.analytics.forward_return_periods[0]), + mode=str(params.get("mode", "rolling")), + ) + raise ValueError( + f"Unknown alpha.model {cfg.alpha.model!r}; expected 'equal_weight' or " + "'ic_weighted'." + ) + + +def _alpha_forward_returns( + cfg: RootConfig, panel: pd.DataFrame, alpha: AlphaModel +) -> pd.Series | None: + """Forward returns for ALPHA FITTING only (None for no-future-data models). + + Computed here — at the alpha boundary — and handed ONLY to ``alpha.fit``: + the factor layer never sees them (CLAUDE.md invariant #1). The model itself + enforces the walk-forward cutoff (a pair is used at date d only once + realized, t + h <= d), locked by tests. + """ + if not getattr(alpha, "requires_forward_returns", False): + return None + horizon = int(cfg.analytics.forward_return_periods[0]) + fwd = forward_returns(panel, periods=(horizon,)) + return fwd[f"forward_return_{horizon}d"] + + +def _alpha_disclosure( + cfg: RootConfig, alpha: AlphaModel +) -> tuple[dict, pd.DataFrame | None]: + """(alpha_summary, full per-date weights log) for the result/report. + + For ``ic_weighted``: echoes the hyper-parameters and counts the fallback + dates (insufficient realized history -> equal weight) so the report can + disclose training coverage. For ``equal_weight``: just the model name. + """ + if isinstance(alpha, RollingICWeightAlpha): + log = alpha.weights_log() + n_dates = int(len(log)) + n_fallback = int(log["fallback"].sum()) if n_dates else 0 + summary = { + "model": cfg.alpha.model, + **alpha.params(), + "n_dates": n_dates, + "n_fallback": n_fallback, + "trained_coverage": ( + (n_dates - n_fallback) / n_dates if n_dates else float("nan") + ), + } + return summary, log + return {"model": cfg.alpha.model}, None + + def _build_scores( - processed: pd.DataFrame, alpha: EqualWeightAlpha + processed: pd.DataFrame, + alpha: AlphaModel, + forward_returns_for_fit: pd.Series | None = None, ) -> pd.Series: """Predict one score per (date, symbol) from the processed factor panel. - The alpha is fit with ``forward_returns=None`` (equal-weight needs no future - data) and predicts per-date cross-sections, so a single (date, symbol) score - panel is produced for the backtest scores source. + ``forward_returns_for_fit`` is passed ONLY to ``alpha.fit`` (the alpha-layer + boundary; None for equal-weight). Prediction is per-date cross-sections, so + a single (date, symbol) score panel is produced for the backtest scores + source; a walk-forward model derives each date's weights from realized + history only. """ - alpha.fit(processed, None) + alpha.fit(processed, forward_returns_for_fit) dates = processed.index.get_level_values("date") blocks: list[pd.Series] = [] for date, block in processed.groupby(dates, sort=True): @@ -243,8 +321,22 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: "available but unused here (price factor only)." ) - # multi-factor combination (P3-1) - if len(enabled_names) > 1: + # factor combination (P3-1 equal-weight / P3-2 walk-forward IC weights) + if cfg.alpha.model == "ic_weighted": + combo = ( + f"Alpha = WALK-FORWARD rolling-IC weights (P3-2) over {enabled_names}: " + "at each date d the factor weights are the mean per-factor rank IC over " + "the trailing window of REALIZED observations only — a (factor[t], " + "fwd_h[t]) pair is admitted only once realized (t + h <= d in trading " + "days), so no full-sample or future information enters any date's weights " + "(locked by a perturb-the-future test). The alpha layer sees historical " + "realized forward returns ONLY for this fitting (invariant #1: factors " + "never see them). Weights are L1-normalized and sign-preserving (a " + "negative-IC factor gets a negative weight). Dates with insufficient " + "realized history fall back to EQUAL WEIGHT — the fallback count is " + "disclosed in the report. NOT a tuned-performance claim." + ) + elif len(enabled_names) > 1: combo = ( f"Multi-factor combination (P3-1): {enabled_names} are combined as the " "EQUAL-WEIGHT mean of the per-date processed (z-scored / neutralized) " @@ -345,7 +437,10 @@ def run_phase0(config_path: str) -> Phase0Result: factor_panel = _compute_factor_panel(cfg, panel, factors, logger) processed = _process_factors(cfg, factor_panel, panel) - scores = _build_scores(processed, EqualWeightAlpha()) + alpha = _build_alpha(cfg) + scores = _build_scores(processed, alpha, _alpha_forward_returns(cfg, panel, alpha)) + alpha_summary, alpha_weights = _alpha_disclosure(cfg, alpha) + logger.info("alpha: %s", alpha_summary) nav_table = _run_backtest(cfg, panel, scores, primary.name, universe, logger) per_factor, combo = _factor_analytics(cfg, panel, factor_panel, scores) @@ -379,6 +474,8 @@ def run_phase0(config_path: str) -> Phase0Result: factor_names=tuple(f.name for f in factors), per_factor=per_factor, combo_analytics=combo, + alpha_summary=alpha_summary, + alpha_weights=alpha_weights, ic_mean=ic_mean, ic_ir=ic_ir, quantile_returns=q_returns, diff --git a/qt/reports.py b/qt/reports.py index c2f056b..e803b68 100644 --- a/qt/reports.py +++ b/qt/reports.py @@ -90,12 +90,80 @@ def standard_analytics_block(std_performance: dict, std_factor: dict) -> str: return "".join(lines) -def per_factor_ic_table(per_factor: dict, combo: dict) -> str: +def alpha_model_block( + alpha_summary: dict, + alpha_weights: pd.DataFrame | None, + rebalance_dates: tuple | None = None, +) -> str: + """Render the active alpha model + walk-forward weight disclosure (P3-2). + + equal_weight -> one line (no trained weights to disclose). ic_weighted -> + hyper-params, training coverage (fallback count), and the per-rebalance + EFFECTIVE weights table (a fallback row shows the equal weights actually + used, flagged). Always states this is NOT a tuned-performance claim. + """ + model = (alpha_summary or {}).get("model", "?") + if model != "ic_weighted" or alpha_weights is None: + return ( + f"- active alpha model: **`{model}`** — equal-weight mean of the " + f"processed factor columns; no future data, no trained weights.\n" + ) + s = alpha_summary + n_dates, n_fallback = s.get("n_dates", 0), s.get("n_fallback", 0) + lines = [ + "- active alpha model: **`ic_weighted`** (walk-forward rolling-IC weights)\n", + f"- params: window=`{s.get('window')}` trading days · " + f"min_periods=`{s.get('min_periods')}` · horizon=`{s.get('horizon')}`d · " + f"mode=`{s.get('mode')}`\n", + "- lookahead boundary: a (factor[t], fwd[t]) pair enters a date d's " + "weights only once REALIZED (t + horizon <= d, trading days); " + "insufficient history falls back to equal weight.\n", + f"- training coverage: **{n_dates - n_fallback}/{n_dates}** scored dates " + f"used trained weights (**{n_fallback}** equal-weight fallback" + f"{'s' if n_fallback != 1 else ''}; coverage " + f"{_fmt(s.get('trained_coverage', float('nan')), pct=True)})\n", + "- _Weights are L1-normalized and sign-preserving (negative-IC factor -> " + "negative weight). This is a fixed, disclosed recipe — NOT a tuned-" + "performance claim._\n\n", + ] + show = alpha_weights + if rebalance_dates: + wanted = [d for d in rebalance_dates if d in alpha_weights.index] + show = alpha_weights.loc[wanted] if wanted else alpha_weights.iloc[0:0] + lines.append("Effective weights at each SETTLED rebalance date:\n\n") + else: + lines.append("Effective weights (all scored dates summarized below):\n\n") + show = alpha_weights.iloc[0:0] # phase0 keeps it short: summary only + if not show.empty: + factor_cols = [c for c in show.columns if c != "fallback"] + header = "| Date | " + " | ".join(f"`{c}`" for c in factor_cols) + " | fallback |\n" + header += "|" + "---|" * (len(factor_cols) + 2) + "\n" + body = "" + for date, row in show.iterrows(): + cells = " | ".join(_fmt(float(row[c])) for c in factor_cols) + body += f"| {_date_str(date)} | {cells} | {'YES' if row['fallback'] else 'no'} |\n" + lines.append(header + body) + return "".join(lines) + + +def _combo_label(alpha_summary: dict) -> str: + """Label the combo-score rows by the ACTIVE alpha model (P3-2).""" + model = (alpha_summary or {}).get("model", "equal_weight") + return ( + "combo score (ic-weighted, walk-forward)" + if model == "ic_weighted" + else "combo score (equal-weight)" + ) + + +def per_factor_ic_table( + per_factor: dict, combo: dict, combo_label: str = "combo score (equal-weight)" +) -> str: """Per-factor + combo-score IC table (simple implementation, P3-1). One row per RAW factor (with its non-NaN coverage) plus the COMBO row — the - processed equal-weight score the backtest actually trades. The combo has no - raw-coverage notion (it exists only post drop_missing), shown as '—'. + processed score the backtest actually trades (label names the active alpha, + P3-2). The combo has no raw-coverage notion (post drop_missing), shown '—'. """ header = "| Factor | coverage | IC mean | IC IR |\n|---|---|---|---|\n" rows = "" @@ -107,19 +175,21 @@ def per_factor_ic_table(per_factor: dict, combo: dict) -> str: ) c = combo or {} rows += ( - f"| **combo score (equal-weight)** | — | {_fmt(c.get('ic_mean', float('nan')))} | " + f"| **{combo_label}** | — | {_fmt(c.get('ic_mean', float('nan')))} | " f"{_fmt(c.get('ic_ir', float('nan')))} |\n" ) return header + rows -def per_factor_quantiles_block(per_factor: dict, combo: dict) -> str: +def per_factor_quantiles_block( + per_factor: dict, combo: dict, combo_label: str = "combo score (equal-weight)" +) -> str: """Quantile-return tables per factor plus the combo score (P3-1).""" parts: list[str] = [] for name, m in (per_factor or {}).items(): parts.append(f"### `{name}`\n\n") parts.append(_quantile_table(m.get("quantile_returns")) + "\n") - parts.append("### combo score (equal-weight)\n\n") + parts.append(f"### {combo_label}\n\n") parts.append(_quantile_table((combo or {}).get("quantile_returns")) + "\n") return "".join(parts) @@ -147,6 +217,17 @@ def render_phase0_summary(result: "Phase0Result") -> str: f"- cost: fee_rate=`{cfg.cost.fee_rate}`, slippage=`{cfg.cost.slippage_rate}`\n" ) + lines.append("## Alpha model\n") + lines.append(alpha_model_block(result.alpha_summary, result.alpha_weights)) + if result.alpha_weights is not None and not result.alpha_weights.empty: + w = result.alpha_weights + factor_cols = [c for c in w.columns if c != "fallback"] + means = ", ".join( + f"`{c}` {_fmt(float(w.loc[~w['fallback'], c].mean()) if (~w['fallback']).any() else float('nan'))}" + for c in factor_cols + ) + lines.append(f"- mean trained weights over scored dates: {means}\n") + lines.append("## Data shape\n") lines.append( f"- panel rows: **{result.panel_rows}**\n" @@ -158,13 +239,17 @@ def render_phase0_summary(result: "Phase0Result") -> str: f"- IC mean: **{_fmt(result.ic_mean)}** (primary `{result.factor_name}`)\n" f"- IC_IR (mean/std): **{_fmt(result.ic_ir)}**\n\n" ) - lines.append(per_factor_ic_table(result.per_factor, result.combo_analytics)) + lines.append(per_factor_ic_table( + result.per_factor, result.combo_analytics, _combo_label(result.alpha_summary) + )) lines.append("## Quantile returns\n") lines.append(_quantile_table(result.quantile_returns) + "\n") if len(result.factor_names) > 1: lines.append("\n") - lines.append(per_factor_quantiles_block(result.per_factor, result.combo_analytics)) + lines.append(per_factor_quantiles_block( + result.per_factor, result.combo_analytics, _combo_label(result.alpha_summary) + )) lines.append("## Portfolio performance\n") lines.append( @@ -219,6 +304,7 @@ def write_phase0_summary(result: "Phase0Result") -> Path: _PHASE2_REQUIRED_SECTIONS = ( "## Data window", "## Universe / PIT membership", + "## Alpha model", "## Financial ann_date coverage", "## Tradability filter hits", "## Execution feasibility", @@ -439,6 +525,20 @@ def render_phase2_baseline(result: "Phase2Result") -> str: f"current-tag fallback.\n" ) + lines.append("\n## Alpha model\n") + lines.append( + alpha_model_block( + result.alpha_summary, result.alpha_weights, result.rebalance_dates + ) + ) + if (result.alpha_summary or {}).get("model") == "ic_weighted": + lines.append( + "\n_Comparable EQUAL-WEIGHT baseline: the same universe / window / " + "factors under `config/phase3_real_multifactor.yaml` " + "(alpha.model=equal_weight) — rerun it for a side-by-side; this " + "report makes no tuned-performance claim._\n" + ) + lines.append("\n## Financial ann_date coverage\n") lines.append( "_Per-field ann_date as-of coverage; each field is labelled TRADED factor " @@ -490,10 +590,14 @@ def render_phase2_baseline(result: "Phase2Result") -> str: f"- IC mean: **{_fmt(result.ic_mean)}** (primary `{result.factor_name}`)\n" f"- IC_IR (mean/std): **{_fmt(result.ic_ir)}**\n\n" ) - lines.append(per_factor_ic_table(result.per_factor, result.combo_analytics)) + lines.append(per_factor_ic_table( + result.per_factor, result.combo_analytics, _combo_label(result.alpha_summary) + )) lines.append("\n## Quantile returns\n") - lines.append(per_factor_quantiles_block(result.per_factor, result.combo_analytics)) + lines.append(per_factor_quantiles_block( + result.per_factor, result.combo_analytics, _combo_label(result.alpha_summary) + )) lines.append("\n## Portfolio performance\n") lines.append( @@ -554,7 +658,17 @@ def render_bias_audit() -> str: "- 事件顺序固定:在 t 收盘计算因子,t 收盘后调仓,从 t+1 持有。回测用" "**下一持有期**的收益结算,绝不使用因子已经看见的当日收益。\n" "- forward returns 只在 `analytics/` 计算,因子层永远拿不到未来收益" - "(INV-001)。\n\n" + "(INV-001)。\n" + "- **alpha 层 walk-forward 权重训练(P3-2,`alpha.model: ic_weighted`)**:" + "alpha 层是**唯一**允许看 forward returns 的层,且只用于拟合因子权重——" + "训练严格 walk-forward:对每个打分日 d,(factor[t], fwd_h[t]) 对只有在" + "**已实现**(按交易日序 `t + h <= d`,h 日 forward return 在 t+h 才实现)" + "时才进入 d 的权重;扰动任何未实现的 forward return 不改变 d 的权重" + "(扰动测试锁定)。窗口 rolling(默认,保守)或 expanding;历史不足" + "(任一因子有效已实现 IC < min_periods)→ 该日**退回等权**并计数披露;" + "权重 L1 归一化、保留符号。固定配方、不调参,非收益声明。factors 层" + "边界不变:forward returns 由 pipeline 在 alpha 边界计算、只传 " + "`alpha.fit`,因子计算在其之前完成且永不接触。\n\n" "## PIT 成分股\n\n" "- 状态: **PIT 已实现(P1) / StaticUniverse 为离线降级**。\n" "- `PITIndexUniverse`(`universe.type=index`)用 tushare `index_weight` 的" diff --git a/tests/test_ic_alpha_pipeline.py b/tests/test_ic_alpha_pipeline.py new file mode 100644 index 0000000..45016a8 --- /dev/null +++ b/tests/test_ic_alpha_pipeline.py @@ -0,0 +1,144 @@ +"""P3-2: ic_weighted alpha pipeline wiring (network-free, demo data). + +Locks the wiring contract: alpha dispatch by config, forward returns reach ONLY +``alpha.fit`` (and only for models that require them), the equal-weight default +keeps its exact P0 numbers, and the report discloses the model / weights / +fallbacks without leaking any secret. +""" + +from __future__ import annotations + +import math +from pathlib import Path + +import pandas as pd +import pytest +import yaml + +from alpha.equal_weight import EqualWeightAlpha +from alpha.ic_weight import RollingICWeightAlpha +from qt.config import ConfigError, load_config +from qt.pipeline import _build_alpha, run_phase0 + + +def _write_cfg(tmp_path: Path, example_config_path: str, *, alpha=None, + factors=None, name="cfg.yaml") -> Path: + # keep the example config's own window so the demo regression numbers + # (ic 0.96 / annual 0.84) stay comparable. + raw = yaml.safe_load(Path(example_config_path).read_text(encoding="utf-8")) + out = tmp_path / "artifacts" + if alpha is not None: + raw["alpha"] = alpha + if factors is not None: + raw["factors"] = factors + raw["output"] = { + "root_dir": str(out), "data_dir": str(out / "data"), + "factor_dir": str(out / "factors"), "report_dir": str(out / "reports"), + "log_dir": str(out / "logs"), "overwrite": True, + } + p = tmp_path / name + p.write_text(yaml.safe_dump(raw), encoding="utf-8") + return p + + +_TWO_FACTORS = [ + {"name": "momentum_20", "enabled": True, "params": {"window": 20}}, + {"name": "momentum_5", "enabled": True, "params": {"window": 5}}, +] +_IC_ALPHA = {"model": "ic_weighted", "params": {"window": 30, "min_periods": 10}} + + +# --------------------------------------------------------------------------- # +# dispatch + config validation +# --------------------------------------------------------------------------- # +def test_build_alpha_dispatches_equal_weight(tmp_path, example_config_path): + cfg = load_config(str(_write_cfg(tmp_path, example_config_path))) + assert isinstance(_build_alpha(cfg), EqualWeightAlpha) + + +def test_build_alpha_dispatches_ic_weighted_with_params(tmp_path, example_config_path): + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, alpha=_IC_ALPHA))) + alpha = _build_alpha(cfg) + assert isinstance(alpha, RollingICWeightAlpha) + p = alpha.params() + assert p["window"] == 30 and p["min_periods"] == 10 + # the IC horizon is tied to the FIRST forward-return period (config: 1). + assert p["horizon"] == int(cfg.analytics.forward_return_periods[0]) + + +def test_unknown_alpha_model_is_a_config_error(tmp_path, example_config_path): + path = _write_cfg(tmp_path, example_config_path, + alpha={"model": "clairvoyant", "params": {}}) + with pytest.raises(ConfigError, match="alpha"): + load_config(str(path)) + + +# --------------------------------------------------------------------------- # +# end-to-end demo runs +# --------------------------------------------------------------------------- # +def test_phase0_equal_weight_default_numbers_unchanged(tmp_path, example_config_path): + """The P0 regression line: the default alpha keeps its exact demo numbers.""" + result = run_phase0(str(_write_cfg(tmp_path, example_config_path))) + assert result.alpha_summary == {"model": "equal_weight"} + assert result.alpha_weights is None + assert result.ic_mean == pytest.approx(0.96, abs=0.005) + assert result.performance["annual_return"] == pytest.approx(0.8408, abs=0.005) + text = result.report_path.read_text(encoding="utf-8") + assert "## Alpha model" in text and "`equal_weight`" in text + + +def test_phase0_ic_weighted_runs_and_discloses(tmp_path, example_config_path): + cfg_path = _write_cfg(tmp_path, example_config_path, + alpha=_IC_ALPHA, factors=_TWO_FACTORS) + result = run_phase0(str(cfg_path)) + + # summary + weights log populated + s = result.alpha_summary + assert s["model"] == "ic_weighted" + assert s["n_dates"] > 0 and 0 <= s["n_fallback"] <= s["n_dates"] + log = result.alpha_weights + assert log is not None and {"momentum_20", "momentum_5", "fallback"} <= set(log.columns) + # early dates (insufficient realized history) fell back; later ones trained. + assert bool(log["fallback"].iloc[0]) is True + assert bool(log["fallback"].iloc[-1]) is False + # trained rows are L1-normalized + trained = log.loc[~log["fallback"], ["momentum_20", "momentum_5"]] + assert (trained.abs().sum(axis=1) - 1.0).abs().max() < 1e-9 + + # the run still produces a valid backtest + report disclosure + assert math.isfinite(result.performance["annual_return"]) + text = result.report_path.read_text(encoding="utf-8") + assert "`ic_weighted`" in text and "walk-forward" in text + assert "fallback" in text.lower() + assert "NOT a tuned-" in text + assert "token" not in text.lower() # no secret leak + + +def test_ic_weighted_scores_differ_from_equal_weight_when_ics_diverge(): + """Trained weights actually change the combination (not a silent no-op). + + Built on a synthetic panel whose two factors rank OPPOSITELY (the demo + feed's momentum_5/momentum_20 share one ranking, so their rank ICs — and + hence the IC weights — coincide with equal weight there by construction). + """ + import numpy as np + + from qt.pipeline import _build_scores + + rng = np.random.default_rng(3) + dates = pd.bdate_range("2024-01-01", periods=60) + syms = [f"S{i}" for i in range(8)] + idx = pd.MultiIndex.from_product([dates, syms], names=["date", "symbol"]) + base = rng.normal(size=len(idx)) + factors = pd.DataFrame({"good": base, "bad": -base}, index=idx) + fwd = pd.Series(base, index=idx) + + eq_scores = _build_scores(factors, EqualWeightAlpha()) + ic_alpha = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + ic_scores = _build_scores(factors, ic_alpha, fwd) + # equal weight of perfectly opposed factors is ~0 everywhere; IC weights + # restore the signal -> the score panels must differ materially. + assert (eq_scores.abs() < 1e-12).all() + trained_dates = ic_alpha.weights_log().query("~fallback").index + trained_scores = ic_scores[ic_scores.index.get_level_values("date").isin(trained_dates)] + assert trained_scores.abs().max() > 0.1 diff --git a/tests/test_ic_weight_alpha.py b/tests/test_ic_weight_alpha.py new file mode 100644 index 0000000..4756517 --- /dev/null +++ b/tests/test_ic_weight_alpha.py @@ -0,0 +1,203 @@ +"""P3-2: RollingICWeightAlpha — walk-forward IC-weighted combination. + +Locks the lookahead red-line and the fallback contract: + * weight fitting may use ONLY realized history: a (factor[t], fwd_h[t]) pair + is admissible at prediction date d only if t + h <= d in TRADING-DAY + positions (the forward return realizes at t+h); + * perturbing any not-yet-realized forward return must NOT change the weights; + * insufficient realized history falls back to EQUAL WEIGHT (logged); + * weights are L1-normalized and sign-preserving; a single factor degenerates + to +/-1; all-NaN/zero IC falls back; + * inputs are never mutated. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from alpha.ic_weight import RollingICWeightAlpha + + +def _panel(n_days=80, n_sym=8, seed=7): + """Synthetic (date, symbol) factor frame + forward returns. + + factor 'good' predicts fwd POSITIVELY on every date; factor 'bad' predicts + NEGATIVELY. Deterministic, so IC signs are stable. + """ + rng = np.random.default_rng(seed) + dates = pd.bdate_range("2024-01-01", periods=n_days) + syms = [f"S{i}" for i in range(n_sym)] + idx = pd.MultiIndex.from_product([dates, syms], names=["date", "symbol"]) + base = rng.normal(size=(n_days, n_sym)) + fwd = pd.Series(base.ravel(), index=idx, name="fwd") + good = pd.Series(base.ravel(), index=idx) # corr +1 with fwd + bad = pd.Series(-base.ravel(), index=idx) # corr -1 with fwd + factors = pd.DataFrame({"good": good, "bad": bad}, index=idx) + return factors, fwd, dates + + +def _block(factors: pd.DataFrame, date) -> pd.DataFrame: + dates = factors.index.get_level_values("date") + return factors.loc[dates == pd.Timestamp(date)] + + +# --------------------------------------------------------------------------- # +# lookahead red-line +# --------------------------------------------------------------------------- # +def test_perturbing_unrealized_future_does_not_change_weights(): + factors, fwd, dates = _panel() + h = 5 + d = dates[40] + model = RollingICWeightAlpha(window=20, min_periods=5, horizon=h) + model.fit(factors, fwd) + w_before = model.weights_for(d) + + # perturb every forward return whose realization date t+h is AFTER d + # (factor dates t > d - h): these are NOT visible at d and must not matter. + poisoned = fwd.copy() + cutoff_pos = list(dates).index(d) - h + poison_mask = factors.index.get_level_values("date") > dates[cutoff_pos] + rng = np.random.default_rng(99) + poisoned[poison_mask] = rng.normal(size=int(poison_mask.sum())) * 100.0 + + model2 = RollingICWeightAlpha(window=20, min_periods=5, horizon=h) + model2.fit(factors, poisoned) + w_after = model2.weights_for(d) + pd.testing.assert_series_equal(w_before, w_after) + + +def test_realization_cutoff_is_exact_t_plus_h(): + # 11 trading days, horizon 5, predict at the last date (pos 10): only ICs of + # factor dates t with pos(t) <= 10 - 5 = 5 are realized -> exactly 6 rows. + factors, fwd, dates = _panel(n_days=11) + d = dates[10] + # min_periods = 7 > 6 available -> MUST fall back; 6 -> must NOT. + strict = RollingICWeightAlpha(window=50, min_periods=7, horizon=5) + strict.fit(factors, fwd) + strict.predict(_block(factors, d)) + assert strict.fallback_log()[pd.Timestamp(d)] is not None + + ok = RollingICWeightAlpha(window=50, min_periods=6, horizon=5) + ok.fit(factors, fwd) + ok.predict(_block(factors, d)) + assert ok.fallback_log()[pd.Timestamp(d)] is None + + +# --------------------------------------------------------------------------- # +# fallback + degeneration + normalization +# --------------------------------------------------------------------------- # +def test_insufficient_history_falls_back_to_equal_weight(): + factors, fwd, dates = _panel() + model = RollingICWeightAlpha(window=20, min_periods=10, horizon=1) + model.fit(factors, fwd) + early = dates[3] # only ~2 realized ICs -> fallback + scores = model.predict(_block(factors, early)) + block = _block(factors, early) + expected = block.mean(axis=1) + expected.index = expected.index.get_level_values("symbol") + pd.testing.assert_series_equal( + scores, expected.rename("score"), check_names=True + ) + assert model.fallback_log()[pd.Timestamp(early)] is not None + + +def test_single_factor_degenerates_to_unit_weight(): + factors, fwd, dates = _panel() + pos = factors[["good"]] + model = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + model.fit(pos, fwd) + d = dates[60] + w = model.weights_for(d) + assert w["good"] == pytest.approx(1.0) # positive IC -> +1 + scores = model.predict(_block(pos, d)) + block = _block(pos, d)["good"] + block.index = block.index.get_level_values("symbol") + pd.testing.assert_series_equal(scores, block.rename("score")) + + neg = factors[["bad"]] + model2 = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + model2.fit(neg, fwd) + w2 = model2.weights_for(d) + assert w2["bad"] == pytest.approx(-1.0) # negative IC -> -1 (sign kept) + + +def test_weights_are_l1_normalized_and_sign_preserving(): + factors, fwd, dates = _panel() + model = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + model.fit(factors, fwd) + w = model.weights_for(dates[60]) + assert abs(w).sum() == pytest.approx(1.0) + assert w["good"] > 0 and w["bad"] < 0 # negative-IC factor gets a NEGATIVE weight + + +def test_degenerate_zero_ic_falls_back(): + # constant forward return -> zero-variance cross-sections -> IC NaN -> fallback. + factors, fwd, dates = _panel() + flat = pd.Series(0.0, index=fwd.index) + model = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + model.fit(factors, flat) + d = dates[60] + model.predict(_block(factors, d)) + assert model.fallback_log()[pd.Timestamp(d)] is not None + + +# --------------------------------------------------------------------------- # +# modes + interface contract +# --------------------------------------------------------------------------- # +def test_expanding_mode_uses_all_realized_history(): + factors, fwd, dates = _panel() + roll = RollingICWeightAlpha(window=10, min_periods=5, horizon=1, mode="rolling") + expa = RollingICWeightAlpha(window=10, min_periods=5, horizon=1, mode="expanding") + roll.fit(factors, fwd) + expa.fit(factors, fwd) + d = dates[60] + n_roll = roll.train_size_for(d) + n_expa = expa.train_size_for(d) + assert n_roll == 10 # capped by the window + assert n_expa > n_roll # expanding sees ALL realized history + + +def test_fit_requires_forward_returns(): + factors, _, _ = _panel() + model = RollingICWeightAlpha() + with pytest.raises(ValueError, match="forward_returns"): + model.fit(factors, None) + + +def test_predict_requires_a_dated_cross_section(): + factors, fwd, dates = _panel() + model = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + model.fit(factors, fwd) + undated = _block(factors, dates[60]).droplevel("date") + with pytest.raises(ValueError, match="date"): + model.predict(undated) + + +def test_bad_mode_raises(): + with pytest.raises(ValueError, match="mode"): + RollingICWeightAlpha(mode="clairvoyant") + + +def test_inputs_are_not_mutated(): + factors, fwd, dates = _panel() + f_copy, r_copy = factors.copy(), fwd.copy() + model = RollingICWeightAlpha(window=20, min_periods=5, horizon=1) + model.fit(factors, fwd) + model.predict(_block(factors, dates[60])) + pd.testing.assert_frame_equal(factors, f_copy) + pd.testing.assert_series_equal(fwd, r_copy) + + +def test_weights_log_records_per_date_weights_and_fallback(): + factors, fwd, dates = _panel() + model = RollingICWeightAlpha(window=20, min_periods=10, horizon=1) + model.fit(factors, fwd) + model.predict(_block(factors, dates[3])) # fallback (early) + model.predict(_block(factors, dates[60])) # trained + log = model.weights_log() + assert list(log.columns[:2]) == ["good", "bad"] + assert bool(log.loc[pd.Timestamp(dates[3]), "fallback"]) is True + assert bool(log.loc[pd.Timestamp(dates[60]), "fallback"]) is False + assert log.loc[pd.Timestamp(dates[60]), "good"] > 0 diff --git a/tests/test_phase2_baseline.py b/tests/test_phase2_baseline.py index d4c2eba..bbab068 100644 --- a/tests/test_phase2_baseline.py +++ b/tests/test_phase2_baseline.py @@ -299,6 +299,8 @@ def _synthetic_result() -> Phase2Result: "quantile_returns": qret, "coverage": 0.9} }, combo_analytics={"ic_mean": 0.04, "ic_ir": 0.45, "quantile_returns": qret}, + alpha_summary={"model": "equal_weight"}, + alpha_weights=None, nav_table=nav, avg_turnover=1.0, cost_drag=0.001, @@ -404,3 +406,38 @@ def test_baseline_report_name_is_configurable(tmp_path): assert cfg.output.baseline_report_name == "phase3_real_multifactor.md" # default stays None -> historical filename preserved. assert load_config(_CONFIG).output.baseline_report_name is None + + +# --------------------------------------------------------------------------- # +# P3-2 — alpha model disclosure in the report +# --------------------------------------------------------------------------- # +def test_render_equal_weight_alpha_disclosed_without_weights(): + md = render_phase2_baseline(_synthetic_result()) + assert "## Alpha model" in md + assert "`equal_weight`" in md + assert "no trained weights" in md + + +def test_render_ic_weighted_alpha_shows_weights_and_fallback(): + import dataclasses + + base = _synthetic_result() + date = base.rebalance_dates[0] + weights = pd.DataFrame( + {"momentum_20": [0.7], "roe": [-0.3], "fallback": [False]}, + index=pd.Index([date], name="date"), + ) + ic = dataclasses.replace( + base, + alpha_summary={"model": "ic_weighted", "window": 60, "min_periods": 20, + "horizon": 1, "mode": "rolling", "n_dates": 220, + "n_fallback": 25, "trained_coverage": 195 / 220}, + alpha_weights=weights, + ) + md = render_phase2_baseline(ic) + assert "`ic_weighted`" in md and "walk-forward" in md + assert "195/220" in md and "25" in md # fallback count disclosed + assert "0.7000" in md and "-0.3000" in md # per-rebalance weights table + assert "t + horizon <= d" in md # lookahead boundary stated + assert "NOT a tuned-" in md # no tuned-performance claim + assert "equal-weight baseline" in md.lower() # comparison pointer present