From 8182df4414bcc22d078ed6909cb711aa2181352a Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Wed, 10 Jun 2026 12:05:11 +0800 Subject: [PATCH] feat(factors): P3-1 multi-factor pipeline + first real price+financial baseline The pipeline previously consumed only the FIRST enabled factor. P3-1 makes it consume every enabled factor and adds the first real multi-factor baseline (momentum_20 + roe + netprofit_yoy on SSE50), still combined by the equal-weight alpha. No parameter search, no learned weights, not a performance claim. Pipeline: - _build_factors instantiates EVERY enabled factor (config order; duplicate names are a readable config error). Single-factor configs unchanged. - _maybe_enrich_financials batches ALL financial fields into ONE fina_indicator fetch + ONE asof_financials pass (each field independently honours ann_date <= trade_date). Demo + financial factor still raises a readable error (no fabricated financials). - _compute_factor_panel writes one column per factor; processing (drop_missing / neutralize / zscore) is already per-column. drop_missing requires ALL enabled factors per name/date (disclosed). - _factor_analytics now returns per-factor {ic, ic_ir, quantiles, coverage} plus combo-score analytics (forward returns computed once). Top-level metrics stay the PRIMARY (first-enabled) factor's -- byte-identical for single-factor configs; the std (alphalens) cross-check stays on the primary raw factor. Baseline + report: - config/phase3_real_multifactor.yaml (SSE50, 2023-07~2024-06, same window as phase2 for comparability); output.baseline_report_name keeps the phase3 report file separate from the phase2 one. - Report gains: active factor list, per-factor coverage/IC/quantile tables, combo-score diagnostics, and per-field ann_date coverage labelled TRADED factor vs diagnostic-only. Real runs (not in CI): - Phase2 single-factor regression rerun: annual -10.19% / IC 0.0083 / PIT SW-L1 98.53% -- unchanged (no regression). - Phase3 multi-factor: annual -9.05%, per-factor IC momentum_20 0.0083 (identical across runs) / roe 0.0006 / netprofit_yoy 0.0001, combo -0.0038, financial ann_date coverage 100% on both fields. Financial ICs ~0 on this small cross-section / short window -- disclosed as plumbing validation. Tests: 249 passed (was 234): +10 multifactor pipeline, +1 multi-field as-of, +4 baseline report surface. Demo numbers unchanged (ic 0.96 / annual 0.84). --- AGENTS.md | 10 +- BIAS_AUDIT.md | 1 + CLAUDE.md | 12 +- RUNBOOK.md | 39 +++++ TEST_REPORT.md | 35 ++++- config/phase3_real_multifactor.yaml | 107 ++++++++++++++ qt/config.py | 4 + qt/phase2_baseline.py | 109 +++++++++----- qt/pipeline.py | 218 ++++++++++++++++++++-------- qt/reports.py | 108 +++++++++++--- tests/test_financial_pipeline.py | 14 +- tests/test_multifactor_pipeline.py | 212 +++++++++++++++++++++++++++ tests/test_phase2_baseline.py | 59 +++++++- tests/test_pit_financials.py | 17 +++ 14 files changed, 809 insertions(+), 136 deletions(-) create mode 100644 config/phase3_real_multifactor.yaml create mode 100644 tests/test_multifactor_pipeline.py diff --git a/AGENTS.md b/AGENTS.md index 40022e8..afc7f62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,10 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - `analytics/alphalens_adapter.py`(IC mean/IR + 分位均值)+ `analytics/quantstats_adapter.py`(CAGR/Sharpe/maxDD/vol)薄 adapter,各带 `backend` 字段;`_import_*` 间接层使 import-missing 路径可测;alphalens stdout/warnings 已抑制。 - **简版仍权威**(驱动回测 + cross-check);依赖不可用/报错 → 报告披露 backend(unavailable/error,只记异常类型),**绝不静默假装**。 - **不改 alpha/portfolio/runtime/fills/universe**:P0/P2 交易数字不变(demo ic 0.96/annual 0.84 不变;alphalens IC=简版 IC 吻合),只新增 **Standard analytics** 报告段。 -- ✅ 当前质量门:`pytest -p no:cacheprovider` **234 passed**;`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)OK;`run-phase0`(demo)OK。 -- ⚠️ 剩余 P2(已显式披露):日线 only、demo 路径非真数据。 -- 路线图下一步:财务因子组合 / 分钟级(architecture.html §11)。 +- 🔧 **Phase 3-1 首个真实多因子 baseline**(`p3-multifactor-financial-baseline` 分支,代劳待验收):pipeline 消费**全部 enabled factors**(曾只用第一个);`config/phase3_real_multifactor.yaml` = momentum_20 + roe + netprofit_yoy(SSE50 同窗口,与 phase2 可比;不调参、无 learned weights、非收益承诺)。 + - 财务字段**一次 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。 +- ⚠️ 剩余(已显式披露):日线 only、demo 路径非真数据。 +- 路线图下一步:alpha 加权合成(IC 加权/回归)/ 分钟级(architecture.html §11)。 diff --git a/BIAS_AUDIT.md b/BIAS_AUDIT.md index 0da0350..e65d03b 100644 --- a/BIAS_AUDIT.md +++ b/BIAS_AUDIT.md @@ -37,6 +37,7 @@ - 拉取窗口向回看约 16 个月(`start` 之前),确保回测 `start` 前已披露的上一期财报在集合内、能 as-of **carry forward** 到早期交易日,避免早期 NaN 缺口。 - 实证:平安银行 2024 Q1(end_date 2024-03-31)披露日 ann_date 2024-04-20;as-of roe 在 04-19 仍是上一期年报值(10.2436),04-22 才切到 Q1(3.1176)——晚于报告期末约 3 周,证明无未来披露泄漏。 - 财务因子仅在 tushare 数据路径可用;demo 无披露日,配置财务因子 + demo 源会报可读错误,**不伪造财务**。 +- **多因子(P3-1)**:多个财务字段(如 roe + netprofit_yoy)**一次 fetch、一次 as-of 对齐**(同一 `asof_financials` 调用,逐字段独立遵守 `ann_date <= trade_date`),无每因子重复拉取;财务字段可作为**被交易的因子**进入组合(不再只是诊断),报告按字段披露 TRADED vs diagnostic 角色与覆盖率。多因子合成是处理后(z-score/中性化)各列的**等权平均**(EqualWeightAlpha)——无 learned weights、不看 forward returns、不调参;`drop_missing` 要求该日该票**所有**启用因子齐备,缺任一因子即从该截面剔除(显式约定,绝不在部分数据上打分)。 ## 复权 diff --git a/CLAUDE.md b/CLAUDE.md index 12e0d25..ccd2b33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,12 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - `analytics/alphalens_adapter.py`(IC mean/IR + 分位均值)+ `analytics/quantstats_adapter.py`(CAGR/Sharpe/maxDD/vol)薄 adapter,各带 `backend` 字段。 - **简版 numpy/pandas 仍权威**(驱动回测 + cross-check);依赖不可用/报错 → 报告显式披露 backend(unavailable/error,只记异常**类型**不记消息),**绝不静默假装用了标准库**。 - **不改 alpha/portfolio/runtime/fills/universe**:P0/P2 交易数字不变(demo ic 0.96/annual 0.84 不变;实测 alphalens IC=简版 IC 完全吻合),只新增 **Standard analytics** 报告段。 -- ✅ 质量门:`pytest` **234 passed**(P0=97 / P1=77 / P2-1=16 / P2-2=22 / P2-3=14 / P2-4=8);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)+ `run-phase0`(demo)均 OK。 -- ⚠️ 剩余 P2(已显式披露):日线 only、demo 路径非真数据。 -- 路线图下一步:财务因子组合 / 分钟级(architecture.html §11)。 +- 🔧 **Phase 3-1 首个真实多因子 baseline**(`p3-multifactor-financial-baseline` 分支,代劳待验收):pipeline 从"只用第一个 enabled factor"升级为**消费全部 enabled factors**;新增 `config/phase3_real_multifactor.yaml`(momentum_20 + roe + netprofit_yoy,SSE50 同窗口,与 phase2 可比)。**不调参、无 learned weights、非收益承诺**。 + - `_build_factors` 全量实例化(配置序;重名报错);财务字段**一次 fetch + 一次 as-of 对齐**(逐字段独立守 `ann_date <= trade_date`,无每因子重复拉取);demo+财务因子仍可读报错。 + - 合成仍 `EqualWeightAlpha`(处理后各列等权平均,不看 forward returns);`drop_missing` 要求该日该票**所有**因子齐备(显式披露)。 + - 报告增强: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。 +- ⚠️ 剩余(已显式披露):日线 only、demo 路径非真数据。 +- 路线图下一步:alpha 加权合成(IC 加权/回归)/ 分钟级(architecture.html §11)。 diff --git a/RUNBOOK.md b/RUNBOOK.md index 95f6b29..d9e0d64 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -183,6 +183,45 @@ The phase2 baseline report (`artifacts/reports/phase2_real_baseline.md`) gains a **Execution feasibility** section: per-rebalance blocked buys / blocked sells / carried positions / executed turnover / invested fraction, from `BacktestDriver.feasibility_log()`. +## Phase 3-1 — first REAL multi-factor baseline (price + PIT financials) + +P3-1 makes the pipeline consume **every enabled factor** (it previously used only +the first) and adds the first real multi-factor baseline: `momentum_20` + `roe` + +`netprofit_yoy`, combined by the SAME equal-weight alpha (row-wise mean of the +processed z-scored / neutralized columns). **No parameter search, no learned +weights, not a performance claim** — it validates the multi-factor plumbing. +Documented by `config/phase3_real_multifactor.yaml` (same SSE50 universe + window +as the phase2 baseline, so the two are comparable). + +```bash +# validate (no network) +... -m qt.cli validate-config --config config/phase3_real_multifactor.yaml +# run the multi-factor real baseline (network + token; heavy, ~10-30 min) +... -m qt.cli run-phase2-baseline --config config/phase3_real_multifactor.yaml +``` + +The real-baseline RUNNER is shared with phase2 (`run-phase2-baseline` — same +diagnostics machine); `output.baseline_report_name` keeps the report separate: +`artifacts/reports/phase3_real_multifactor.md` (git-ignored, regenerable). + +What P3-1 changes (locked by tests): + +- **Multiple enabled factors** each become their own factor-panel column + (config order; duplicate names are a config error). Single-factor configs are + unchanged (primary factor = the only factor). +- **Financial fields are fetched ONCE** for all financial factors and as-of + aligned in a single `asof_financials` pass (`ann_date <= trade_date` per + field) — no per-factor refetch. Demo + financial factor still raises a + readable error (no fabricated financials). +- **Combination** is the equal-weight mean of the per-date processed columns + (`EqualWeightAlpha`); `drop_missing` requires ALL enabled factors for a name + on a date (disclosed — a name missing any factor is dropped from that + cross-section, never scored on partial data). +- **Report**: active factor list; per-factor coverage / IC / quantile + diagnostics plus the COMBO score's; financial ann_date coverage **per field**, + labelled TRADED factor vs diagnostic-only. Standard analytics stays + report-only (alphalens cross-checks the primary factor). + ## Quality gate ```bash diff --git a/TEST_REPORT.md b/TEST_REPORT.md index 4a68c99..d12fa32 100644 --- a/TEST_REPORT.md +++ b/TEST_REPORT.md @@ -1,4 +1,4 @@ -# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 (bias-boundary → execution realism → PIT industry → standard analytics) +# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 + Phase 3-1 (bias-boundary → execution realism → PIT industry → standard analytics → multi-factor) ## Commands @@ -10,6 +10,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.yaml /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 run-phase0 --config config/example.yaml ``` @@ -17,12 +18,12 @@ Run from the repo root with the project python (env `quant_mf`): | Gate | Command | Result | |---|---|---| -| Unit + integration | `pytest -q` | **234 passed, 0 failed** | +| Unit + integration | `pytest -q` | **249 passed, 0 failed** | | Lint | `ruff check .` | **All checks passed** | -| Config validation | `validate-config` (demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`) | exit `0`, prints `OK` | +| Config validation | `validate-config` (demo + `example_tushare.yaml` + `phase2_real_baseline.yaml` + `phase3_real_multifactor.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 = 234). +Counts below are the actual per-file `pytest` numbers (sum = 249). ## Per-file breakdown — Phase 0 core (97) @@ -58,7 +59,7 @@ Counts below are the actual per-file `pytest` numbers (sum = 234). | `test_tradability_filters.py` | 7 | shared suspended/ST/limit filter | | `test_tradability_enrich.py` | 6 | flag enrichment onto panel | | `test_tushare_flags.py` | 3 | suspend_d/namechange/stk_limit feed | -| `test_pit_financials.py` | 7 | **ann_date as-of** (no disclosure leak) | +| `test_pit_financials.py` | 8 | **ann_date as-of** (no disclosure leak; +1 P3-1 multi-field single-pass) | | `test_tushare_fina.py` | 2 | fina_indicator feed | | `test_factors_financial.py` | 3 | financial factor (roe/netprofit_yoy) | | `test_financial_pipeline.py` | 5 | factor dispatch + demo-source guard | @@ -72,7 +73,7 @@ Counts below are the actual per-file `pytest` numbers (sum = 234). | Test file | Tests | Feature | |---|---|---| -| `test_phase2_baseline.py` | 16 | 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 | +| `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 | ## Per-file breakdown — Phase 2-2 execution realism (22) @@ -94,7 +95,13 @@ Counts below are the actual per-file `pytest` numbers (sum = 234). |---|---|---| | `test_quantstats_adapter.py` | 4 | quantstats perf metrics + unavailable / error fallback disclosure (no silent fake) | | `test_alphalens_adapter.py` | 4 | alphalens IC / quantile metrics + unavailable / error fallback + stdout suppression | -| **Total (P0 + P1 + P2-1 + P2-2 + P2-3 + P2-4)** | **234** | | + +## Per-file breakdown — Phase 3-1 multi-factor (10) + +| 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** | | ## Real-data validation (manual, not in CI — TEST-002 keeps the suite network-free) @@ -142,6 +149,20 @@ Counts below are the actual per-file `pytest` numbers (sum = 234). backends are disclosed (`backend` + exception TYPE only, no message) and keep the simple fallback. Empirically the alphalens IC matched the simple IC exactly on the demo (0.96), and the demo trading numbers (ic 0.96, annual 0.84) are unchanged. +- **P3-1 multi-factor (locked by tests):** the pipeline consumes EVERY enabled + factor (one factor-panel column each, config order; duplicate names are a + config error). Financial fields are fetched in ONE `fina_indicator` pass and + as-of aligned in ONE `asof_financials` call (each field independently honours + `ann_date <= trade_date`); demo + financial factor still raises a readable + error. The combination is the EQUAL-WEIGHT mean of the processed columns — + no learned weights, no forward-return fitting; `drop_missing` requires ALL + enabled factors (a name missing any factor is dropped from that + cross-section, disclosed). Single-factor configs keep their legacy shape and + numbers (demo ic 0.96 / annual 0.84 unchanged); the baseline report gains the + active factor list, per-factor coverage/IC/quantile tables, the combo-score + 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. - 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/config/phase3_real_multifactor.yaml b/config/phase3_real_multifactor.yaml new file mode 100644 index 0000000..2a0c672 --- /dev/null +++ b/config/phase3_real_multifactor.yaml @@ -0,0 +1,107 @@ +# Phase 3-1 — first REAL multi-factor baseline (price + PIT financial factors). +# +# Purpose: the FIRST multi-factor run on the real tushare path: momentum_20 (price) +# + roe + netprofit_yoy (ann_date PIT-aligned financials), combined by the same +# EQUAL-WEIGHT alpha (row-wise mean of the processed z-scored / neutralized +# columns). NO parameter search, NO learned weights, NOT a performance claim — +# it validates the multi-factor plumbing (batched financial fetch, per-factor +# diagnostics, combo score) against real data. +# +# Universe / window deliberately match config/phase2_real_baseline.yaml (SSE50, +# 2023-07 ~ 2024-06) so the single- vs multi-factor baselines are comparable. +# +# Run: python -m qt.cli run-phase2-baseline --config config/phase3_real_multifactor.yaml +# (the real-baseline runner is shared; output.baseline_report_name keeps this +# report separate from the phase2 one. Needs the tushare token in the external +# .config.json; hits the network; heavy.) + +project: + name: quantitative_trading_phase3_real_multifactor + 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: phase3_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: equal_weight + params: {} + +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_multifactor.md diff --git a/qt/config.py b/qt/config.py index f9f82db..13d41aa 100644 --- a/qt/config.py +++ b/qt/config.py @@ -176,6 +176,10 @@ class OutputCfg(_Strict): report_dir: str = "artifacts/reports" log_dir: str = "artifacts/logs" overwrite: bool = True + # Filename for the real-baseline report (run-phase2-baseline). None keeps the + # historical default 'phase2_real_baseline.md'; a multi-factor baseline config + # sets its own name so it never overwrites the phase2 report (P3-1). + baseline_report_name: str | None = None class RootConfig(_Strict): diff --git a/qt/phase2_baseline.py b/qt/phase2_baseline.py index c175cfe..e18142d 100644 --- a/qt/phase2_baseline.py +++ b/qt/phase2_baseline.py @@ -38,7 +38,7 @@ from qt.config import RootConfig, load_config from qt.pipeline import ( _FrameScores, - _build_factor, + _build_factors, _build_scores, _build_universe, _collect_downgrades, @@ -83,10 +83,12 @@ class Phase2Result: list_date_total: int # PIT SW industry coverage for neutralization (NaN if neutralize off) industry_pit_coverage: float - # ann_date financial coverage (diagnostic) - financial_field: str - financial_coverage_overall: float - financial_coverage_by_rebalance: pd.DataFrame + # ann_date financial coverage PER FIELD (P3-1): + # financial_coverage[field] = {"is_factor": bool, "overall": float, + # "by_rebalance": DataFrame} + # is_factor distinguishes a TRADED financial factor from a pure diagnostic + # (a no-financial-factor run still reports the default field as diagnostic). + financial_coverage: dict[str, dict] # tradability filter hits tradability_hits: pd.DataFrame # execution feasibility (direction-aware fills) @@ -97,7 +99,14 @@ class Phase2Result: skipped_terminal_dates: tuple[pd.Timestamp, ...] holdings: pd.DataFrame # turnover / cost / performance + # primary factor = first enabled; factor_names = ALL enabled (P3-1). factor_name: str + factor_names: tuple[str, ...] + # per-factor + combo-score simple analytics (P3-1): + # per_factor[name] = {ic_mean, ic_ir, quantile_returns, coverage}; + # combo_analytics = {ic_mean, ic_ir, quantile_returns} on the traded score. + per_factor: dict[str, dict] + combo_analytics: dict nav_table: pd.DataFrame avg_turnover: float cost_drag: float @@ -283,13 +292,30 @@ def financial_coverage_at_dates( return pd.DataFrame(rows, columns=["date", "n_members", "n_covered", "coverage"]) -def _phase2_downgrades(cfg: RootConfig, financial_field: str) -> tuple[str, ...]: - """Base downgrades + phase2-baseline-specific disclosures.""" +def _phase2_downgrades( + cfg: RootConfig, financial_coverage: dict[str, dict] +) -> tuple[str, ...]: + """Base downgrades + baseline-specific disclosures (per-field aware, P3-1).""" + traded = sorted(f for f, info in financial_coverage.items() if info.get("is_factor")) + diag_only = sorted( + f for f, info in financial_coverage.items() if not info.get("is_factor") + ) + if traded: + fin_item = ( + f"Financial ann_date coverage is reported PER FIELD: {traded} are TRADED " + "financial factors in this run (ann_date PIT-aligned, fetched in one " + "pass); their coverage tables are a data-quality lens on the same " + "columns the strategy consumes." + ) + else: + fin_item = ( + f"Financial ann_date coverage is a DATA-QUALITY DIAGNOSTIC on " + f"{diag_only} (how well disclosed reports populate the universe " + "as-of); it is NOT the alpha factor in this baseline (the factor is the " + "configured price factor). No financial signal is traded here." + ) extra = ( - f"Financial ann_date coverage is a DATA-QUALITY DIAGNOSTIC on " - f"'{financial_field}' (how well disclosed reports populate the universe " - "as-of); it is NOT the alpha factor in this baseline (the factor is the " - "configured price factor). No financial signal is traded here.", + fin_item, "Per-period holdings are the ACHIEVED book recorded by the backtest driver " "(post execution-feasibility), NOT the constructor's desired target: a " "blocked sell shows the carried name and a blocked buy is absent.", @@ -328,11 +354,12 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: # --- reuse the exact P0/P1 spine ------------------------------------- # universe, symbols = _build_universe(cfg, logger) panel = _load_panel(cfg, symbols, logger) - factor = _build_factor(cfg) - panel = _maybe_enrich_financials(cfg, panel, symbols, factor, logger) + factors = _build_factors(cfg) + primary = factors[0] + panel = _maybe_enrich_financials(cfg, panel, symbols, factors, logger) panel = _maybe_enrich_covariates(cfg, panel, symbols, logger) panel = _maybe_enrich_listing(cfg, panel, symbols, logger) - factor_panel = _compute_factor_panel(cfg, panel, factor, logger) + factor_panel = _compute_factor_panel(cfg, panel, factors, logger) processed = _process_factors(cfg, factor_panel, panel) score_panel = _build_scores(processed, EqualWeightAlpha()) scores = _FrameScores(score_panel) @@ -364,7 +391,9 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: logger.info("backtest: %d candidate rebalance dates, %d settled rows (skipped %d)", len(candidate_dates), len(nav_table), len(skipped_dates)) - ic_mean, ic_ir, q_returns = _factor_analytics(cfg, panel, factor_panel, factor.name) + per_factor, combo = _factor_analytics(cfg, panel, factor_panel, score_panel) + first = per_factor[primary.name] + ic_mean, ic_ir, q_returns = first["ic_mean"], first["ic_ir"], first["quantile_returns"] perf = ( performance_summary( nav_table["nav"], periods_per_year=_periods_per_year(cfg.backtest.rebalance) @@ -373,29 +402,37 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: else {k: float("nan") for k in ("annual_return", "max_drawdown", "volatility", "sharpe")} ) std_performance, std_factor = _standard_analytics( - cfg, panel, factor_panel, factor.name, nav_table, ic_mean, ic_ir, perf, logger + cfg, panel, factor_panel, primary.name, nav_table, ic_mean, ic_ir, perf, logger ) # --- diagnostics (read-only) ----------------------------------------- # - financial_field = ( - factor.name if isinstance(factor, FinancialFactor) else SUPPORTED_FINANCIAL_FIELDS[0] - ) - if isinstance(factor, FinancialFactor): - diag_col = panel[financial_field] + # ann_date coverage PER financial field (P3-1). Traded financial factors are + # already on the panel (one batched fetch); a run with NO financial factor + # still reports the default field as a pure diagnostic (extra fetch, not traded). + factor_fields = [f.name for f in factors if isinstance(f, FinancialFactor)] + if factor_fields: + diag_fields = list(factor_fields) + diag_panel = panel else: + diag_fields = [SUPPORTED_FINANCIAL_FIELDS[0]] logger.info( "diagnostics: fetching '%s' for ann_date coverage report ONLY " - "(NOT the active factor; the strategy factor is '%s')", - financial_field, factor.name, + "(NOT an active factor; the strategy factors are %s)", + diag_fields[0], [f.name for f in factors], ) diag_panel = _maybe_enrich_financials( - cfg, panel, symbols, FinancialFactor(financial_field), logger + cfg, panel, symbols, [FinancialFactor(diag_fields[0])], logger ) - diag_col = diag_panel[financial_field] - coverage_by_rebalance = financial_coverage_at_dates( - diag_col, universe, panel, settled_dates - ) - coverage_overall = float(diag_col.notna().mean()) if len(diag_col) else float("nan") + financial_coverage: dict[str, dict] = {} + for field in diag_fields: + col = diag_panel[field] + financial_coverage[field] = { + "is_factor": field in factor_fields, + "overall": float(col.notna().mean()) if len(col) else float("nan"), + "by_rebalance": financial_coverage_at_dates( + col, universe, panel, settled_dates + ), + } hits = tradability_hit_stats( universe, panel, settled_dates, cfg.universe.filters.model_dump() @@ -435,16 +472,17 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: list_date_known=list_date_known, list_date_total=list_date_total, industry_pit_coverage=industry_pit_coverage, - financial_field=financial_field, - financial_coverage_overall=coverage_overall, - financial_coverage_by_rebalance=coverage_by_rebalance, + financial_coverage=financial_coverage, tradability_hits=hits, feasibility_log=feasibility_log, rebalance_dates=tuple(settled_dates), candidate_rebalance_dates=tuple(candidate_dates), skipped_terminal_dates=tuple(skipped_dates), holdings=holdings, - factor_name=factor.name, + factor_name=primary.name, + factor_names=tuple(f.name for f in factors), + per_factor=per_factor, + combo_analytics=combo, 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, @@ -454,8 +492,9 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: performance=perf, std_performance=std_performance, std_factor=std_factor, - downgrades=_phase2_downgrades(cfg, financial_field), - report_path=Path(cfg.output.report_dir) / "phase2_real_baseline.md", + downgrades=_phase2_downgrades(cfg, financial_coverage), + report_path=Path(cfg.output.report_dir) + / (cfg.output.baseline_report_name or "phase2_real_baseline.md"), log_path=log_path, ) write_phase2_baseline_summary(result) diff --git a/qt/pipeline.py b/qt/pipeline.py index 483385e..872e9d7 100644 --- a/qt/pipeline.py +++ b/qt/pipeline.py @@ -91,7 +91,17 @@ class Phase0Result: config: RootConfig panel_rows: int panel_symbols: int + # primary factor = first enabled (drives the legacy single-factor fields and + # the standard-analytics factor cross-check); factor_names = ALL enabled. factor_name: str + factor_names: tuple[str, ...] + # P3-1 per-factor + combo-score analytics (simple, authoritative). + # per_factor[name] = {ic_mean, ic_ir, quantile_returns, coverage}; + # combo_analytics = {ic_mean, ic_ir, quantile_returns} on the traded score. + per_factor: dict[str, dict] + combo_analytics: dict + # legacy top-level metrics == the PRIMARY factor's (unchanged for + # single-factor configs). ic_mean: float ic_ir: float quantile_returns: pd.DataFrame @@ -177,8 +187,9 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: mistake a demo run for a real PIT / financial validation, or vice versa. """ real = cfg.data.source == "tushare" - factor_name = cfg.factors[0].name if cfg.factors else "?" - is_financial = factor_name in SUPPORTED_FINANCIAL_FIELDS + enabled_names = [f.name for f in cfg.factors if f.enabled] or ["?"] + financial_names = [n for n in enabled_names if n in SUPPORTED_FINANCIAL_FIELDS] + price_names = [n for n in enabled_names if n not in SUPPORTED_FINANCIAL_FIELDS] if real: parts = ["front-adjusted (qfq) prices"] @@ -187,11 +198,12 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: if cfg.universe.type == "index" else "STATIC universe (still a PIT downgrade — see below)" ) - parts.append( - f"ann_date-aligned financial factor '{factor_name}'" - if is_financial - else f"price factor '{factor_name}'" - ) + factor_bits = [] + if price_names: + factor_bits.append(f"price factor(s) {price_names}") + if financial_names: + factor_bits.append(f"ann_date-aligned financial factor(s) {financial_names}") + parts.append(" + ".join(factor_bits) or "no factor (?)") if cfg.processing.neutralize.enabled: parts.append("industry+size neutralized") path = "DATA PATH = REAL tushare: " + "; ".join(parts) + "." @@ -218,10 +230,12 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: ) # financials / ann_date - if is_financial: + if financial_names: financials = ( - f"Financial factor '{factor_name}' is ann_date PIT-aligned (DATA-012): " - "a figure is used only after its disclosure date, never by report period." + f"Financial factor(s) {financial_names} are ann_date PIT-aligned " + "(DATA-012): a figure is used only after its disclosure date, never by " + "report period. All financial fields are fetched in ONE pass and " + "as-of aligned together (P3-1, no per-factor refetch)." ) else: financials = ( @@ -229,6 +243,23 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: "available but unused here (price factor only)." ) + # multi-factor combination (P3-1) + if 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) " + "columns — no learned weights, no forward-return fitting, no parameter " + "search (the alpha layer never sees future returns). drop_missing " + "requires ALL enabled factors for a name on a date: a name missing any " + "factor value is dropped from that cross-section (disclosed, not " + "silently scored on partial data)." + ) + else: + combo = ( + "Single-factor run: the combined score equals the processed factor " + "(equal-weight mean of one column)." + ) + # neutralization if cfg.processing.neutralize.enabled and real: level = cfg.processing.neutralize.industry_level @@ -275,6 +306,7 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: path, membership, financials, + combo, neutral, execution, f"Daily ({cfg.data.freq}) bars only; minute-level link is deferred.", @@ -305,17 +337,20 @@ def run_phase0(config_path: str) -> Phase0Result: universe, symbols = _build_universe(cfg, logger) panel = _load_panel(cfg, symbols, logger) - factor = _build_factor(cfg) - panel = _maybe_enrich_financials(cfg, panel, symbols, factor, logger) + factors = _build_factors(cfg) + primary = factors[0] + panel = _maybe_enrich_financials(cfg, panel, symbols, factors, logger) panel = _maybe_enrich_covariates(cfg, panel, symbols, logger) panel = _maybe_enrich_listing(cfg, panel, symbols, logger) - factor_panel = _compute_factor_panel(cfg, panel, factor, logger) + factor_panel = _compute_factor_panel(cfg, panel, factors, logger) processed = _process_factors(cfg, factor_panel, panel) scores = _build_scores(processed, EqualWeightAlpha()) - nav_table = _run_backtest(cfg, panel, scores, factor.name, universe, logger) - ic_mean, ic_ir, q_returns = _factor_analytics(cfg, panel, factor_panel, factor.name) + nav_table = _run_backtest(cfg, panel, scores, primary.name, universe, logger) + per_factor, combo = _factor_analytics(cfg, panel, factor_panel, scores) + first = per_factor[primary.name] + ic_mean, ic_ir, q_returns = first["ic_mean"], first["ic_ir"], first["quantile_returns"] perf = ( performance_summary( nav_table["nav"], @@ -332,7 +367,7 @@ def run_phase0(config_path: str) -> Phase0Result: 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 std_performance, std_factor = _standard_analytics( - cfg, panel, factor_panel, factor.name, nav_table, ic_mean, ic_ir, perf, logger + cfg, panel, factor_panel, primary.name, nav_table, ic_mean, ic_ir, perf, logger ) downgrades = _collect_downgrades(cfg) @@ -340,7 +375,10 @@ def run_phase0(config_path: str) -> Phase0Result: config=cfg, panel_rows=len(panel), panel_symbols=panel.index.get_level_values("symbol").nunique(), - factor_name=factor.name, + factor_name=primary.name, + factor_names=tuple(f.name for f in factors), + per_factor=per_factor, + combo_analytics=combo, ic_mean=ic_mean, ic_ir=ic_ir, quantile_returns=q_returns, @@ -515,49 +553,73 @@ def _enrich_tradability( return panel -def _build_factor(cfg: RootConfig): - """Instantiate the (single, first-enabled) factor from config by name. +def _build_factors(cfg: RootConfig) -> list: + """Instantiate EVERY enabled factor from config, in config order (P3-1). ``momentum*`` -> :class:`MomentumFactor` (price-based, P0). A financial field name (e.g. ``roe``, ``netprofit_yoy``) -> :class:`FinancialFactor`, which needs the tushare data path (ann_date alignment) — not available for demo data. + + Multiple enabled factors each become their own factor-panel column; the + combined score stays the alpha layer's equal-weight mean (no learned weights). + Duplicate factor names are a config error: names become panel columns and a + silent collision would overwrite one factor with another. """ enabled = [f for f in cfg.factors if f.enabled] if not enabled: - raise ValueError("No enabled factor in config.factors; phase0 needs a factor.") - spec = enabled[0] - params = dict(spec.params) - if spec.name in SUPPORTED_FINANCIAL_FIELDS: - return FinancialFactor(field=spec.name) - if spec.name.startswith("momentum"): - return MomentumFactor( - window=int(params.get("window", 20)), - price_col=str(params.get("price_col", "close")), + raise ValueError( + "No enabled factor in config.factors; the pipeline needs at least one." ) - raise ValueError( - f"Unknown factor {spec.name!r}; expected 'momentum*' or one of " - f"{SUPPORTED_FINANCIAL_FIELDS}." - ) + factors: list = [] + for spec in enabled: + params = dict(spec.params) + if spec.name in SUPPORTED_FINANCIAL_FIELDS: + factors.append(FinancialFactor(field=spec.name)) + elif spec.name.startswith("momentum"): + factors.append( + MomentumFactor( + window=int(params.get("window", 20)), + price_col=str(params.get("price_col", "close")), + ) + ) + else: + raise ValueError( + f"Unknown factor {spec.name!r}; expected 'momentum*' or one of " + f"{SUPPORTED_FINANCIAL_FIELDS}." + ) + names = [f.name for f in factors] + dupes = sorted({n for n in names if names.count(n) > 1}) + if dupes: + raise ValueError( + f"Duplicate enabled factor name(s) {dupes}: factor names become factor-" + "panel columns and must be unique (e.g. two momentum entries with the " + "same window resolve to the same name)." + ) + return factors def _maybe_enrich_financials( cfg: RootConfig, panel: pd.DataFrame, symbols: list[str], - factor, + factors: list, logger: logging.Logger, ) -> pd.DataFrame: - """Attach the ann_date-aligned financial column when a financial factor is used. + """Attach ann_date-aligned columns for ALL financial factors (single fetch). - Financial factors require the tushare data path: a demo run has no disclosure - dates, so we fail with a readable error rather than fabricate financials. + Every :class:`FinancialFactor` field among the enabled ``factors`` is fetched + in ONE ``fina_indicator`` pass and as-of aligned in ONE + :func:`asof_financials` call (P3-1) — no per-factor refetch. Financial factors + require the tushare data path: a demo run has no disclosure dates, so we fail + with a readable error rather than fabricate financials. """ - if not isinstance(factor, FinancialFactor): + fields = [f.name for f in factors if isinstance(f, FinancialFactor)] + if not fields: return panel if cfg.data.source != "tushare": raise ValueError( - f"Factor '{factor.name}' is a financial factor and needs real financial " - f"data (data.source='tushare'); it cannot run on demo data." + f"Factor(s) {fields} are financial factors and need real financial " + f"data (data.source='tushare'); they cannot run on demo data." ) feed = TushareFinancialFeed( cfg.data.external_secret_file, token_key=cfg.data.tushare_token_key @@ -567,13 +629,14 @@ def _maybe_enrich_financials( fetch_start = ( pd.Timestamp(cfg.data.start) - pd.Timedelta(days=_FINANCIAL_LOOKBACK_DAYS) ).strftime("%Y-%m-%d") - fina = feed.get_fina_indicator(symbols, fetch_start, cfg.data.end, fields=[factor.name]) + fina = feed.get_fina_indicator(symbols, fetch_start, cfg.data.end, fields=fields) enriched = panel.copy() - aligned = asof_financials(panel.index, fina, [factor.name]) - enriched[factor.name] = aligned[factor.name] + aligned = asof_financials(panel.index, fina, fields) + for field in fields: + enriched[field] = aligned[field] logger.info( - "financials: as-of aligned '%s' by ann_date (%d disclosed rows)", - factor.name, len(fina), + "financials: as-of aligned %s by ann_date (%d disclosed rows, single fetch)", + fields, len(fina), ) return enriched @@ -608,14 +671,23 @@ def _maybe_enrich_listing( def _compute_factor_panel( cfg: RootConfig, panel: pd.DataFrame, - factor: MomentumFactor, + factors: list, logger: logging.Logger, ) -> pd.DataFrame: - """Compute the factor series, frame it, and persist it via PanelStore-style write.""" - series = factor.compute(panel) - factor_panel = series.to_frame(name=factor.name) + """Compute every factor as its own column and persist the multi-column panel. + + One column per enabled factor (P3-1); a single-factor config produces the + same one-column frame as before. All columns share the panel's + MultiIndex(date, symbol), so downstream processing stays per-date and + per-column. + """ + columns = [factor.compute(panel).rename(factor.name) for factor in factors] + factor_panel = pd.concat(columns, axis=1) _write_factor_panel(cfg, factor_panel) - logger.info("factor: %s computed (%d rows)", factor.name, len(factor_panel)) + logger.info( + "factors: %s computed (%d rows x %d columns)", + [f.name for f in factors], len(factor_panel), factor_panel.shape[1], + ) return factor_panel @@ -715,22 +787,42 @@ def _factor_analytics( cfg: RootConfig, panel: pd.DataFrame, factor_panel: pd.DataFrame, - factor_name: str, -) -> tuple[float, float, pd.DataFrame]: - """IC summary + quantile returns from the RAW factor + forward returns. - - Analytics is the only place forward returns are computed (INV-001). We use - the first configured forward-return period for IC (the holding horizon proxy). + score_panel: pd.Series, +) -> tuple[dict[str, dict], dict]: + """Per-factor + combo-score IC / quantile analytics (simple, authoritative). + + Analytics is the only place forward returns are computed (INV-001); they are + computed ONCE here and shared. Each RAW factor column gets its own IC summary + / quantile returns / coverage (non-NaN fraction); the COMBO score — the + processed equal-weight mean the backtest actually trades — gets the same + treatment (P3-1). The first configured forward-return period is the holding + horizon proxy. Returns ``(per_factor, combo)``. """ periods = tuple(int(p) for p in cfg.analytics.forward_return_periods) fwd = forward_returns(panel, periods=periods) horizon = periods[0] fwd_col = fwd[f"forward_return_{horizon}d"] - factor_series = factor_panel[factor_name] - ic = compute_ic(factor_series, fwd_col) - summary = ic_summary(ic) - q_returns = quantile_returns(factor_series, fwd_col, quantiles=cfg.analytics.quantiles) - return summary["ic_mean"], summary["ic_ir"], q_returns + per_factor: dict[str, dict] = {} + for name in factor_panel.columns: + series = factor_panel[name] + summary = ic_summary(compute_ic(series, fwd_col)) + per_factor[name] = { + "ic_mean": summary["ic_mean"], + "ic_ir": summary["ic_ir"], + "quantile_returns": quantile_returns( + series, fwd_col, quantiles=cfg.analytics.quantiles + ), + "coverage": float(series.notna().mean()) if len(series) else float("nan"), + } + combo_summary = ic_summary(compute_ic(score_panel, fwd_col)) + combo = { + "ic_mean": combo_summary["ic_mean"], + "ic_ir": combo_summary["ic_ir"], + "quantile_returns": quantile_returns( + score_panel, fwd_col, quantiles=cfg.analytics.quantiles + ), + } + return per_factor, combo def _standard_analytics( @@ -749,7 +841,11 @@ def _standard_analytics( Reads the already-computed nav / factor and produces standard-tool metrics for the report; it NEVER feeds back into selection / portfolio / execution, so the backtest numbers are unchanged. Unavailable/erroring backends are disclosed and - keep the authoritative simple fallback (no silent fake). + keep the authoritative simple fallback (no silent fake). The alphalens factor + cross-check runs on the PRIMARY (first-enabled) raw factor column — the same + series the legacy simple IC is reported on — so single-factor reports are + byte-stable; per-factor / combo diagnostics come from the simple + implementation (P3-1). """ if not nav_table.empty: std_performance = quantstats_performance( diff --git a/qt/reports.py b/qt/reports.py index 5a25319..c2f056b 100644 --- a/qt/reports.py +++ b/qt/reports.py @@ -90,6 +90,40 @@ 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: + """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 '—'. + """ + header = "| Factor | coverage | IC mean | IC IR |\n|---|---|---|---|\n" + rows = "" + for name, m in (per_factor or {}).items(): + rows += ( + f"| `{name}` | {_fmt(m.get('coverage', float('nan')), pct=True)} | " + f"{_fmt(m.get('ic_mean', float('nan')))} | " + f"{_fmt(m.get('ic_ir', float('nan')))} |\n" + ) + c = combo or {} + rows += ( + f"| **combo score (equal-weight)** | — | {_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: + """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(_quantile_table((combo or {}).get("quantile_returns")) + "\n") + return "".join(parts) + + def render_phase0_summary(result: "Phase0Result") -> str: """Build the phase0 summary markdown string (pure; no I/O).""" cfg = result.config @@ -104,7 +138,8 @@ def render_phase0_summary(result: "Phase0Result") -> str: f"window=`[{cfg.data.start}, {cfg.data.end}]`\n" f"- universe: type=`{cfg.universe.type}`, " f"symbols={list(cfg.universe.symbols)}\n" - f"- factor: `{result.factor_name}`\n" + f"- factors (active): `{list(result.factor_names)}` " + f"(primary: `{result.factor_name}`)\n" f"- alpha: `{cfg.alpha.model}`\n" f"- portfolio: `{cfg.portfolio.constructor}`, top_n=`{cfg.portfolio.top_n}`\n" f"- backtest: rebalance=`{cfg.backtest.rebalance}`, " @@ -120,12 +155,16 @@ def render_phase0_summary(result: "Phase0Result") -> str: lines.append("## Factor IC\n") lines.append( - f"- IC mean: **{_fmt(result.ic_mean)}**\n" - f"- IC_IR (mean/std): **{_fmt(result.ic_ir)}**\n" + 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("## 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("## Portfolio performance\n") lines.append( @@ -245,6 +284,32 @@ def _coverage_block(overall: float, by_reb: pd.DataFrame) -> str: return head + table +def _financial_coverage_block(financial_coverage: dict) -> str: + """Render the per-field ann_date coverage (P3-1): one subsection per field. + + Each field is labelled by its role — a TRADED financial factor vs a pure + diagnostic — so a reader can never mistake a data-quality lens for a signal + (or vice versa). + """ + if not financial_coverage: + return "_(no financial fields)_\n" + parts: list[str] = [] + for field, info in financial_coverage.items(): + role = ( + "TRADED financial factor in this run (ann_date PIT-aligned)" + if info.get("is_factor") + else "diagnostic only — NOT an alpha factor in this run" + ) + parts.append(f"### `{field}` — {role}\n\n") + parts.append( + _coverage_block( + info.get("overall", float("nan")), info.get("by_rebalance") + ) + ) + parts.append("\n") + return "".join(parts) + + def _tradability_block(hits: pd.DataFrame) -> str: """Render the tradability filter-hit funnel.""" candidates = hits.attrs.get("candidates", 0) @@ -324,15 +389,15 @@ def render_phase2_baseline(result: "Phase2Result") -> str: cfg = result.config perf = result.performance lines: list[str] = [] - lines.append("# Phase 2-1 — Real-data Reproducibility Baseline\n") + lines.append("# Real-data Reproducibility Baseline\n") lines.append( f"Project: **{cfg.project.name}** · source: **{cfg.data.source}** · " f"ran in **{result.elapsed_seconds:.1f}s**\n" ) lines.append( - "\n> Small-scale REAL (tushare) baseline that runs the existing P0/P1 spine " - "end-to-end. No new factor, no parameter search, not a performance claim — " - "it validates the real-data plumbing and is fully reproducible from the " + "\n> Small-scale REAL (tushare) baseline that runs the existing pipeline " + "spine end-to-end. No parameter search, not a performance claim — it " + "validates the real-data plumbing and is fully reproducible from the " "config below.\n" ) @@ -340,7 +405,9 @@ def render_phase2_baseline(result: "Phase2Result") -> str: lines.append( f"- universe: type=`{cfg.universe.type}`, index_code=`{cfg.universe.index_code}`, " f"top_n=`{cfg.portfolio.top_n}`\n" - f"- factor: `{result.factor_name}` · neutralize=`{cfg.processing.neutralize.enabled}`\n" + f"- factors (active): `{list(result.factor_names)}` " + f"(primary for the std cross-check: `{result.factor_name}`) · " + f"neutralize=`{cfg.processing.neutralize.enabled}`\n" f"- filters: `{cfg.universe.filters.model_dump()}`\n" f"- backtest: rebalance=`{cfg.backtest.rebalance}`, fee_rate=`{cfg.cost.fee_rate}`\n" ) @@ -374,12 +441,10 @@ def render_phase2_baseline(result: "Phase2Result") -> str: lines.append("\n## Financial ann_date coverage\n") lines.append( - f"_Diagnostic on `{result.financial_field}` (ann_date as-of); NOT the alpha " - f"factor._\n\n" - ) - lines.append( - _coverage_block(result.financial_coverage_overall, result.financial_coverage_by_rebalance) + "_Per-field ann_date as-of coverage; each field is labelled TRADED factor " + "vs diagnostic-only below._\n\n" ) + lines.append(_financial_coverage_block(result.financial_coverage)) lines.append("\n## Tradability filter hits\n") lines.append(_tradability_block(result.tradability_hits)) @@ -422,12 +487,13 @@ def render_phase2_baseline(result: "Phase2Result") -> str: lines.append("\n## Factor IC\n") lines.append( - f"- IC mean: **{_fmt(result.ic_mean)}**\n" - f"- IC_IR (mean/std): **{_fmt(result.ic_ir)}**\n" + 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("\n## Quantile returns\n") - lines.append(_quantile_table(result.quantile_returns) + "\n") + lines.append(per_factor_quantiles_block(result.per_factor, result.combo_analytics)) lines.append("\n## Portfolio performance\n") lines.append( @@ -546,7 +612,15 @@ def render_bias_audit() -> str: "as-of roe 在 04-19 仍是上一期年报值(10.2436),04-22 才切到 Q1(3.1176)——" "晚于报告期末约 3 周,证明无未来披露泄漏。\n" "- 财务因子仅在 tushare 数据路径可用;demo 无披露日,配置财务因子 + demo 源" - "会报可读错误,**不伪造财务**。\n\n" + "会报可读错误,**不伪造财务**。\n" + "- **多因子(P3-1)**:多个财务字段(如 roe + netprofit_yoy)**一次 fetch、" + "一次 as-of 对齐**(同一 `asof_financials` 调用,逐字段独立遵守 " + "`ann_date <= trade_date`),无每因子重复拉取;财务字段可作为**被交易的" + "因子**进入组合(不再只是诊断),报告按字段披露 TRADED vs diagnostic 角色" + "与覆盖率。多因子合成是处理后(z-score/中性化)各列的**等权平均**" + "(EqualWeightAlpha)——无 learned weights、不看 forward returns、不调参;" + "`drop_missing` 要求该日该票**所有**启用因子齐备,缺任一因子即从该截面剔除" + "(显式约定,绝不在部分数据上打分)。\n\n" "## 复权\n\n" "- 状态: **前复权已实现(P1)**。\n" "- panel 始终携带 `adj_factor` 列(DemoFeed 中恒为 1.0)。`data/clean/adjust.py` " diff --git a/tests/test_financial_pipeline.py b/tests/test_financial_pipeline.py index 07cceb6..d7f07ca 100644 --- a/tests/test_financial_pipeline.py +++ b/tests/test_financial_pipeline.py @@ -11,7 +11,7 @@ from factors.compute.financial import FinancialFactor from factors.compute.momentum import MomentumFactor from qt.config import load_config -from qt.pipeline import _build_factor, _maybe_enrich_financials +from qt.pipeline import _build_factors, _maybe_enrich_financials def _cfg(tmp_path, example_config_path, factor_name, source): @@ -25,27 +25,27 @@ def _cfg(tmp_path, example_config_path, factor_name, source): def test_build_factor_dispatches_financial(tmp_path, example_config_path): cfg = _cfg(tmp_path, example_config_path, "roe", "tushare") - assert isinstance(_build_factor(cfg), FinancialFactor) + assert isinstance(_build_factors(cfg)[0], FinancialFactor) def test_build_factor_dispatches_momentum(tmp_path, example_config_path): cfg = _cfg(tmp_path, example_config_path, "momentum_20", "demo") - assert isinstance(_build_factor(cfg), MomentumFactor) + assert isinstance(_build_factors(cfg)[0], MomentumFactor) def test_financial_factor_on_demo_source_raises(tmp_path, example_config_path): cfg = _cfg(tmp_path, example_config_path, "roe", "demo") - factor = _build_factor(cfg) + factors = _build_factors(cfg) with pytest.raises(ValueError, match="cannot run on demo"): _maybe_enrich_financials( - cfg, pd.DataFrame(), ["000001.SZ"], factor, logging.getLogger("test") + cfg, pd.DataFrame(), ["000001.SZ"], factors, logging.getLogger("test") ) def test_unknown_factor_name_raises(tmp_path, example_config_path): cfg = _cfg(tmp_path, example_config_path, "totally_made_up", "demo") with pytest.raises(ValueError, match="Unknown factor"): - _build_factor(cfg) + _build_factors(cfg) def test_financial_fetch_uses_lookback_before_start(tmp_path, example_config_path, monkeypatch): @@ -72,7 +72,7 @@ def get_fina_indicator(self, symbols, start, end, fields=None): ) panel = pd.DataFrame({"close": [1.0]}, index=idx) _maybe_enrich_financials( - cfg, panel, ["000001.SZ"], FinancialFactor("roe"), logging.getLogger("test") + cfg, panel, ["000001.SZ"], [FinancialFactor("roe")], logging.getLogger("test") ) fetched = pd.Timestamp(captured["start"]) assert fetched <= pd.Timestamp(cfg.data.start) - pd.Timedelta( diff --git a/tests/test_multifactor_pipeline.py b/tests/test_multifactor_pipeline.py new file mode 100644 index 0000000..7469c58 --- /dev/null +++ b/tests/test_multifactor_pipeline.py @@ -0,0 +1,212 @@ +"""P3-1: multi-factor pipeline (multiple enabled factors, network-free). + +Locks the P3-1 contract: + * every ENABLED factor is instantiated (config order) and lands as its own + factor-panel column; + * financial fields are fetched ONCE for all financial factors and as-of + aligned by ann_date in a single pass (no per-factor refetch); + * demo + financial factor still fails readably (no fabricated financials); + * single-factor configs keep their old behaviour (primary == only factor); + * the result exposes per-factor + combo-score analytics and the report + discloses the active factor list without leaking any secret. +""" + +from __future__ import annotations + +import logging +import math +from pathlib import Path + +import pandas as pd +import pytest +import yaml + +from factors.compute.financial import FinancialFactor +from factors.compute.momentum import MomentumFactor +from qt.config import load_config +from qt.pipeline import _build_factors, _maybe_enrich_financials, run_phase0 + + +def _write_cfg(tmp_path: Path, example_config_path: str, factors: list[dict], + source: str = "demo", name: str = "cfg.yaml") -> Path: + """Copy the example config, swap the factor list, redirect outputs to tmp.""" + raw = yaml.safe_load(Path(example_config_path).read_text(encoding="utf-8")) + out = tmp_path / "artifacts" + raw["data"]["source"] = source + raw["data"]["start"] = "2024-01-01" + raw["data"]["end"] = "2024-06-30" + 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, + } + cfg_path = tmp_path / name + cfg_path.write_text(yaml.safe_dump(raw), encoding="utf-8") + return cfg_path + + +_TWO_MOMENTUM = [ + {"name": "momentum_20", "enabled": True, "params": {"window": 20}}, + {"name": "momentum_5", "enabled": True, "params": {"window": 5}}, +] + + +# --------------------------------------------------------------------------- # +# _build_factors +# --------------------------------------------------------------------------- # +def test_build_factors_returns_all_enabled_in_config_order(tmp_path, example_config_path): + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, _TWO_MOMENTUM))) + factors = _build_factors(cfg) + assert [f.name for f in factors] == ["momentum_20", "momentum_5"] + assert all(isinstance(f, MomentumFactor) for f in factors) + + +def test_build_factors_skips_disabled(tmp_path, example_config_path): + specs = [ + {"name": "momentum_20", "enabled": True, "params": {"window": 20}}, + {"name": "momentum_5", "enabled": False, "params": {"window": 5}}, + ] + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, specs))) + assert [f.name for f in _build_factors(cfg)] == ["momentum_20"] + + +def test_build_factors_mixed_price_and_financial(tmp_path, example_config_path): + specs = _TWO_MOMENTUM + [ + {"name": "roe", "enabled": True, "params": {}}, + {"name": "netprofit_yoy", "enabled": True, "params": {}}, + ] + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, specs, source="tushare"))) + factors = _build_factors(cfg) + assert [f.name for f in factors] == ["momentum_20", "momentum_5", "roe", "netprofit_yoy"] + assert isinstance(factors[2], FinancialFactor) + assert isinstance(factors[3], FinancialFactor) + + +def test_build_factors_duplicate_names_raise(tmp_path, example_config_path): + specs = [ + {"name": "momentum_20", "enabled": True, "params": {"window": 20}}, + {"name": "momentum_x", "enabled": True, "params": {"window": 20}}, # same name + ] + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, specs))) + with pytest.raises(ValueError, match="[Dd]uplicate"): + _build_factors(cfg) + + +def test_build_factors_none_enabled_raises(tmp_path, example_config_path): + specs = [{"name": "momentum_20", "enabled": False, "params": {}}] + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, specs))) + with pytest.raises(ValueError, match="[Nn]o enabled factor"): + _build_factors(cfg) + + +# --------------------------------------------------------------------------- # +# batched financial enrichment (single fetch + single as-of pass) +# --------------------------------------------------------------------------- # +def test_financials_fetched_once_for_all_fields(tmp_path, example_config_path, monkeypatch): + specs = [ + {"name": "roe", "enabled": True, "params": {}}, + {"name": "netprofit_yoy", "enabled": True, "params": {}}, + ] + cfg = load_config(str(_write_cfg(tmp_path, example_config_path, specs, source="tushare"))) + calls: list[list[str]] = [] + + class _FakeFeed: + def __init__(self, *a, **k): + pass + + def get_fina_indicator(self, symbols, start, end, fields=None): + calls.append(list(fields or [])) + return pd.DataFrame( + { + "symbol": ["000001.SZ"], + "ann_date": ["20240110"], + "end_date": ["20231231"], + "roe": [5.0], + "netprofit_yoy": [10.0], + } + ) + + monkeypatch.setattr("qt.pipeline.TushareFinancialFeed", _FakeFeed) + idx = pd.MultiIndex.from_product( + [pd.to_datetime(["2024-01-05", "2024-01-15"]), ["000001.SZ"]], + names=["date", "symbol"], + ) + panel = pd.DataFrame({"close": [1.0, 1.1]}, index=idx) + factors = [FinancialFactor("roe"), FinancialFactor("netprofit_yoy")] + enriched = _maybe_enrich_financials( + cfg, panel, ["000001.SZ"], factors, logging.getLogger("test") + ) + + # ONE fetch for BOTH fields (no per-factor refetch). + assert len(calls) == 1 + assert set(calls[0]) == {"roe", "netprofit_yoy"} + # both columns as-of aligned: invisible before ann_date, visible after. + assert math.isnan(enriched.loc[(pd.Timestamp("2024-01-05"), "000001.SZ"), "roe"]) + assert enriched.loc[(pd.Timestamp("2024-01-15"), "000001.SZ"), "roe"] == 5.0 + assert enriched.loc[(pd.Timestamp("2024-01-15"), "000001.SZ"), "netprofit_yoy"] == 10.0 + # the input panel is never mutated (immutability). + assert "roe" not in panel.columns + + +def test_demo_with_financial_factor_raises_readable(tmp_path, example_config_path): + cfg = load_config(str(_write_cfg( + tmp_path, example_config_path, + _TWO_MOMENTUM + [{"name": "roe", "enabled": True, "params": {}}], + source="demo", + ))) + factors = _build_factors(cfg) + with pytest.raises(ValueError, match="cannot run on demo"): + _maybe_enrich_financials( + cfg, pd.DataFrame(), ["000001.SZ"], factors, logging.getLogger("test") + ) + + +# --------------------------------------------------------------------------- # +# end-to-end demo run with two price factors +# --------------------------------------------------------------------------- # +def test_phase0_multifactor_panel_and_analytics(tmp_path, example_config_path): + cfg_path = _write_cfg(tmp_path, example_config_path, _TWO_MOMENTUM) + result = run_phase0(str(cfg_path)) + + # every enabled factor is an own column in the persisted factor panel. + stored = pd.read_parquet(result.factor_path) + assert {"momentum_20", "momentum_5"} <= set(stored.columns) + + # active factor list + per-factor + combo analytics on the result. + assert result.factor_names == ("momentum_20", "momentum_5") + assert result.factor_name == "momentum_20" # primary = first enabled + assert set(result.per_factor.keys()) == {"momentum_20", "momentum_5"} + for metrics in result.per_factor.values(): + assert math.isfinite(metrics["ic_mean"]) + assert 0.0 <= metrics["coverage"] <= 1.0 + assert math.isfinite(result.combo_analytics["ic_mean"]) + + # top-level (legacy) metrics == the PRIMARY factor's metrics. + assert result.ic_mean == result.per_factor["momentum_20"]["ic_mean"] + assert result.ic_ir == result.per_factor["momentum_20"]["ic_ir"] + + +def test_phase0_multifactor_report_lists_factors_and_combo(tmp_path, example_config_path): + cfg_path = _write_cfg(tmp_path, example_config_path, _TWO_MOMENTUM) + result = run_phase0(str(cfg_path)) + text = result.report_path.read_text(encoding="utf-8") + assert "momentum_20" in text and "momentum_5" in text # active factor list + assert "combo" in text.lower() # combo score diagnostics shown + assert "token" not in text.lower() # no secret leak + + +def test_phase0_single_factor_keeps_legacy_shape(tmp_path, example_config_path): + """A single-factor config behaves exactly as before (primary == only factor).""" + specs = [{"name": "momentum_20", "enabled": True, + "params": {"window": 20, "price_col": "close"}}] + cfg_path = _write_cfg(tmp_path, example_config_path, specs) + result = run_phase0(str(cfg_path)) + assert result.factor_names == ("momentum_20",) + assert result.factor_name == "momentum_20" + assert result.per_factor["momentum_20"]["ic_mean"] == result.ic_mean + stored = pd.read_parquet(result.factor_path) + assert "momentum_20" in stored.columns diff --git a/tests/test_phase2_baseline.py b/tests/test_phase2_baseline.py index f128bfd..d4c2eba 100644 --- a/tests/test_phase2_baseline.py +++ b/tests/test_phase2_baseline.py @@ -283,9 +283,9 @@ def _synthetic_result() -> Phase2Result: list_date_known=67, list_date_total=68, industry_pit_coverage=0.985, - financial_field="roe", - financial_coverage_overall=0.5, - financial_coverage_by_rebalance=cov, + financial_coverage={ + "roe": {"is_factor": False, "overall": 0.5, "by_rebalance": cov} + }, tradability_hits=hits, feasibility_log=feas, rebalance_dates=(date,), @@ -293,6 +293,12 @@ def _synthetic_result() -> Phase2Result: skipped_terminal_dates=(pd.Timestamp("2024-02-29"),), holdings=holdings, factor_name="momentum_20", + factor_names=("momentum_20",), + per_factor={ + "momentum_20": {"ic_mean": 0.05, "ic_ir": 0.5, + "quantile_returns": qret, "coverage": 0.9} + }, + combo_analytics={"ic_mean": 0.04, "ic_ir": 0.45, "quantile_returns": qret}, nav_table=nav, avg_turnover=1.0, cost_drag=0.001, @@ -351,3 +357,50 @@ def test_render_discloses_pit_industry_coverage(): assert "point-in-time SW-L1" in md # phase2 config sets industry_level: L1 assert "98.50%" in md # industry_pit_coverage=0.985 rendered as a pct assert "current-tag fallback" in md # explicitly no silent fallback + + +# --------------------------------------------------------------------------- # +# P3-1 — multi-factor report surface +# --------------------------------------------------------------------------- # +def test_render_shows_active_factor_list_and_combo(): + md = render_phase2_baseline(_synthetic_result()) + assert "factors (active)" in md # active factor list disclosed + assert "combo score (equal-weight)" in md # combo diagnostics rendered + + +def test_render_financial_coverage_labels_role_per_field(): + import dataclasses + + base = _synthetic_result() + cov = base.financial_coverage["roe"]["by_rebalance"] + multi = dataclasses.replace( + base, + financial_coverage={ + "roe": {"is_factor": True, "overall": 0.9, "by_rebalance": cov}, + "netprofit_yoy": {"is_factor": False, "overall": 0.4, "by_rebalance": cov}, + }, + ) + md = render_phase2_baseline(multi) + assert "### `roe` — TRADED financial factor" in md + assert "### `netprofit_yoy` — diagnostic only" in md + assert "90.00%" in md and "40.00%" in md # per-field overall coverage + + +def test_render_per_factor_table_includes_coverage_and_ic(): + md = render_phase2_baseline(_synthetic_result()) + # the per-factor table row carries the factor's coverage + IC. + assert "| `momentum_20` | 90.00% | 0.0500 | 0.5000 |" in md + + +def test_baseline_report_name_is_configurable(tmp_path): + """output.baseline_report_name overrides the default phase2 report filename.""" + import yaml as _yaml + + raw = _yaml.safe_load(Path(_CONFIG).read_text(encoding="utf-8")) + raw["output"]["baseline_report_name"] = "phase3_real_multifactor.md" + p = tmp_path / "cfg.yaml" + p.write_text(_yaml.safe_dump(raw), encoding="utf-8") + cfg = load_config(str(p)) + 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 diff --git a/tests/test_pit_financials.py b/tests/test_pit_financials.py index e8fe2ea..c5e6132 100644 --- a/tests/test_pit_financials.py +++ b/tests/test_pit_financials.py @@ -95,3 +95,20 @@ def test_asof_raises_on_missing_field(): with pytest.raises(ValueError, match="missing field"): asof_financials(_index(["2024-04-21"]), _fina(), ["nonexistent"]) + + +def test_asof_aligns_multiple_fields_in_one_call(): + """P3-1: several financial fields are as-of aligned in a SINGLE pass. + + The multi-factor pipeline fetches all financial fields once and aligns them + together; each field must independently honour ann_date <= trade_date. + """ + fina = _fina().assign(netprofit_yoy=[12.0, -4.0]) + idx = _index(["2024-04-19", "2024-04-21"]) + out = asof_financials(idx, fina, ["roe", "netprofit_yoy"]) + # before the Q1 disclosure both fields still show the prior annual report. + assert out["roe"].iloc[0] == 8.0 + assert out["netprofit_yoy"].iloc[0] == 12.0 + # after disclosure both switch together. + assert out["roe"].iloc[1] == 3.1 + assert out["netprofit_yoy"].iloc[1] == -4.0