From 32a932eccaf4040329b9025097ba64d3325f870b Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Tue, 9 Jun 2026 23:08:29 +0800 Subject: [PATCH 1/2] feat(analytics): wire alphalens + quantstats as report-only cross-check (P2-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add thin, report-only adapters over alphalens-reloaded and quantstats so the report shows the standard-tool metrics alongside the existing simple ones. This is analytics/reporting only: no new factor, no parameter tuning, and selection / alpha / portfolio / runtime / fills / universe are untouched — the backtest trading numbers are unchanged. - analytics/quantstats_adapter.py: quantstats_performance(returns, periods_per_year) -> CAGR / Sharpe / max-drawdown / volatility, tagged with the backend used. - analytics/alphalens_adapter.py: alphalens_factor_metrics(factor, prices, quantiles, period) -> IC mean / IC-IR / per-quantile mean returns; suppresses alphalens' stdout banner / warnings. - Both use a `_import_*` indirection so the ImportError path is unit-testable, and on ImportError / runtime error they return backend=unavailable/error (exception TYPE only, no message) and carry the simple-pandas fallback — never a silent fake (INV-007). - qt/pipeline.py `_standard_analytics`: computes both from the already-built nav / factor (report-only); Phase0Result/Phase2Result gain std_performance/std_factor; `_collect_downgrades` rewrites the IC/perf items to "simple authoritative + standard cross-check, backend disclosed". - qt/reports.py: a "Standard analytics" section in the phase0 and phase2 reports (added to the phase2 required-section contract). Tests (234 passed, network-free): adapter metrics + unavailable/error fallback + stdout suppression; phase0 run proves the standard analytics are additive, not replacing (simple perf keeps its keys, report flags them authoritative), and no secret leak. Empirically alphalens IC == simple IC on the demo (0.96) and the demo trading numbers (ic 0.96 / annual 0.84) are unchanged. Docs (RUNBOOK, TEST_REPORT, CLAUDE, AGENTS) updated; BIAS_AUDIT carries no analytics-tool claim (biases only). --- AGENTS.md | 10 ++-- CLAUDE.md | 10 ++-- RUNBOOK.md | 12 ++++- TEST_REPORT.md | 34 ++++++++---- analytics/alphalens_adapter.py | 90 ++++++++++++++++++++++++++++++++ analytics/quantstats_adapter.py | 82 +++++++++++++++++++++++++++++ qt/phase2_baseline.py | 9 ++++ qt/pipeline.py | 63 ++++++++++++++++++++-- qt/reports.py | 63 ++++++++++++++++++++++ tests/test_alphalens_adapter.py | 80 ++++++++++++++++++++++++++++ tests/test_phase0_pipeline.py | 29 ++++++++++ tests/test_phase2_baseline.py | 12 +++++ tests/test_quantstats_adapter.py | 66 +++++++++++++++++++++++ 13 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 analytics/alphalens_adapter.py create mode 100644 analytics/quantstats_adapter.py create mode 100644 tests/test_alphalens_adapter.py create mode 100644 tests/test_quantstats_adapter.py diff --git a/AGENTS.md b/AGENTS.md index feb8a07..4bbc7e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,10 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - **SW 层级可配置** `processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,中性化标准 + 小截面自由度更稳)。**关键实证**:旧 tag 年化 −17.6% / SW-L1 −10.2% / SW-L2 −9.3% → **L1≈L2**,−17.6→−10 大跳主因是 **tushare→SW 分类切换**(补 PIT 必然:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**。(曾误判粒度、默认 L2,经 L2 实测推翻,改默认 L1。) - 不静默退回 current:无 SW 历史的票 → 行业 NaN,被 neutralize 按截面丢弃;每次运行 **实际 level + PIT 覆盖率**进 phase2 报告。 - 真实 baseline(SW-L1,默认):`run-phase2-baseline` OK,symbols=68,settled=11,**PIT SW-L1 coverage 98.53%**(67/68),IC 0.0083,**annual −10.19%**。 -- ✅ 当前质量门:`pytest -p no:cacheprovider` **223 passed**;`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)OK;`run-phase0`(demo)OK。 -- ⚠️ 剩余 P2(已显式披露):日线 only、简版 IC/绩效未走 alphalens/quantstats、demo 路径非真数据。 -- 路线图下一步:财务因子组合 / 分钟级 / alphalens·quantstats 接入(architecture.html §11)。 +- 🔧 **Phase 2-4 标准分析集成**(`p2-standard-analytics` 分支,未 PR,代劳待验收):alphalens-reloaded + quantstats 接入报告(**report-only cross-check**)。 + - `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)。 diff --git a/CLAUDE.md b/CLAUDE.md index e9bde01..00919e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,10 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - `tushare_covariates.pit_sw_intervals(symbols, level)` 读 `index_member_all` 取每股 SW 行业 `in_date`/`out_date` 区间;`data/clean/pit_industry.py::asof_industry` 按 `[in_date,out_date)` 覆盖该日取行业(改分类日新行业生效,PIT-safe;起始日前成分 carry forward 到窗口开始)。 - **SW 层级可配置** `processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,中性化标准 + 小截面自由度更稳)。**关键实证**:旧 tag 年化 −17.6% / SW-L1 −10.2% / SW-L2 −9.3% → **L1≈L2**,大跳的主因是 **tushare→SW 分类切换**(补 PIT 的必然:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**。(注:曾误以为是粒度、默认 L2,经 L2 实测推翻,改默认 L1。) - **不静默退回 current**:无 SW 历史的票 → 行业 NaN,被 neutralize 按截面丢弃(neutralize 数学不变,本就丢 NaN 行);每次运行 **实际 level + PIT 覆盖率**进 phase2 报告。 -- ✅ 质量门:`pytest` **223 passed**(P0=95 / P1=77 / P2-1=15 / P2-2=22 / P2-3=14);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)+ `run-phase0`(demo)均 OK。 -- ⚠️ 剩余 P2(已显式披露):日线 only、简版 IC/绩效未走 alphalens/quantstats、demo 路径非真数据。 -- 路线图下一步:财务因子组合 / 分钟级 / alphalens·quantstats 接入(architecture.html §11)。 +- ✅ **Phase 2-4 标准分析集成**(`p2-standard-analytics` 分支,未 PR):alphalens-reloaded + quantstats 接入报告(**report-only cross-check**)。 + - `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)。 diff --git a/RUNBOOK.md b/RUNBOOK.md index 76d65ea..95f6b29 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -223,9 +223,17 @@ Resolved in P2-2 / P2-3 (was deferred): neutralizer drops) — never a silent current-tag fallback; the actual level + PIT coverage are reported in `phase2_real_baseline.md`. +Resolved in P2-4 (was deferred): + +- **Standard analytics** — alphalens-reloaded (IC / quantiles) and quantstats (CAGR / + Sharpe / maxDD / vol) are now computed and shown in the report as a **report-only + cross-check** (`analytics/alphalens_adapter.py`, `analytics/quantstats_adapter.py`). + The simple numpy/pandas metrics remain the AUTHORITATIVE backtest result and drive + the run; the standard tools never alter selection / portfolio / execution. When a + library is unavailable or errors, the report discloses the `backend` (no silent fake). + Still downgraded / deferred (disclosed): - **Demo path** uses offline `DemoFeed` — NOT real data (no PIT/financial meaning). - **Static universe** option remains a PIT downgrade (use `type: index` for real). -- **Daily bars only**; **simple IC / performance** (numpy/pandas, not - alphalens-reloaded / quantstats). +- **Daily bars only** (minute-level link deferred). diff --git a/TEST_REPORT.md b/TEST_REPORT.md index b1a53a7..4755434 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) +# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 (bias-boundary → execution realism → PIT industry → standard analytics) ## Commands @@ -17,14 +17,14 @@ Run from the repo root with the project python (env `quant_mf`): | Gate | Command | Result | |---|---|---| -| Unit + integration | `pytest -q` | **223 passed, 0 failed** | +| Unit + integration | `pytest -q` | **234 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` | | 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 = 223). +Counts below are the actual per-file `pytest` numbers (sum = 234). -## Per-file breakdown — Phase 0 core (95) +## Per-file breakdown — Phase 0 core (97) | Test file | Tests | Area | |---|---|---| @@ -42,7 +42,7 @@ Counts below are the actual per-file `pytest` numbers (sum = 223). | `test_backtest_driver.py` | 7 | backtest driver | | `test_analytics_factor.py` | 5 | IC / quantile | | `test_analytics_performance.py` | 3 | performance metrics | -| `test_phase0_pipeline.py` | 8 | end-to-end pipeline | +| `test_phase0_pipeline.py` | 10 | end-to-end pipeline (+ std-analytics additive / no-leak) | | `test_bias_audit_report.py` | 6 | bias audit doc (+2: P2-2 + P2-3 disclosures) | ## Per-file breakdown — Phase 1 bias-boundary (79) @@ -68,11 +68,11 @@ Counts below are the actual per-file `pytest` numbers (sum = 223). | `test_tushare_covariates.py` | 5 | stock_basic + daily_basic + index_member_all SW feed (level L1/L2/L3 select, bad level) | | `test_real_path_config.py` | 4 | demo vs real-path downgrade disclosure (+ PIT industry) | -## Per-file breakdown — Phase 2-1 real-data baseline (15) +## Per-file breakdown — Phase 2-1 real-data baseline (16) | Test file | Tests | Feature | |---|---|---| -| `test_phase2_baseline.py` | 15 | collectors, demo/real guard, report-field contract, no-secret-leak, settled-vs-candidate dates, loaded-vs-in-window membership, list_date + PIT-industry coverage | +| `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 | ## Per-file breakdown — Phase 2-2 execution realism (22) @@ -87,7 +87,14 @@ Counts below are the actual per-file `pytest` numbers (sum = 223). | Test file | Tests | Red-line / feature | |---|---|---| | `test_pit_industry.py` | 12 | SW **as-of** industry (switch at reclassification, carry-forward pre-start, missing → NaN, latest-in_date on overlap) + `enrich_pit_industry` + pipeline wiring (per-date industry, configured SW level passed, no current-tag fallback) + `industry_level` config (default L1, accepts L1/L2/L3, rejects invalid, phase2 config = L1) | -| **Total (P0 + P1 + P2-1 + P2-2 + P2-3)** | **223** | | + +## Per-file breakdown — Phase 2-4 standard analytics (8) + +| Test file | Tests | Feature | +|---|---|---| +| `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** | | ## Real-data validation (manual, not in CI — TEST-002 keeps the suite network-free) @@ -120,7 +127,16 @@ Counts below are the actual per-file `pytest` numbers (sum = 223). history get NaN (the neutralizer drops them) — never a silent current-tag fallback; the actual level + PIT coverage are disclosed in the phase2 report. The neutralize math is unchanged. +- **P2-4 standard analytics (locked by tests):** alphalens-reloaded and quantstats are + thin, report-only adapters (`analytics/alphalens_adapter.py`, + `analytics/quantstats_adapter.py`). The simple numpy/pandas metrics remain the + authoritative backtest result and drive the run; the standard tools are an additive + cross-check that never touches selection / portfolio / execution + (`test_phase0_standard_analytics_is_additive_not_replacing`). Unavailable / erroring + 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. - 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 - a test (per-file sum == full-run total = 217). + a test (per-file sum == full-run total). diff --git a/analytics/alphalens_adapter.py b/analytics/alphalens_adapter.py new file mode 100644 index 0000000..4fab9ea --- /dev/null +++ b/analytics/alphalens_adapter.py @@ -0,0 +1,90 @@ +"""Thin, report-only alphalens-reloaded adapter (P2-4). + +Consumes the factor Series + a wide price frame and produces the standard factor +diagnostics (IC mean / IC std / IC-IR, per-quantile mean returns) via +``alphalens``, tagging the ``backend`` it actually used. This is REPORTING ONLY — +it never feeds back into the factor / selection / portfolio, so trading results +are unchanged. + +Honesty contract (INV-007): if alphalens is unavailable (``ImportError``) or +raises (small/degenerate cross-sections are common), the result is tagged +``unavailable`` / ``error`` and the caller's simple-pandas ``simple_fallback`` is +carried through — never a silent fake. The ``_import_alphalens`` indirection makes +the unavailable path testable; alphalens' ``print``/warnings are suppressed so the +CLI / report stays clean. +""" + +from __future__ import annotations + +import contextlib +import io +import math +import warnings + +import pandas as pd + + +def _import_alphalens(): + """Import alphalens (indirection so the ImportError path is testable).""" + import alphalens + + return alphalens + + +def alphalens_factor_metrics( + factor: pd.Series, + prices: pd.DataFrame, + quantiles: int = 5, + period: int = 1, + simple_fallback: dict | None = None, +) -> dict: + """Standard factor diagnostics from a factor Series + wide price frame. + + Args: + factor: MultiIndex(date, symbol) factor values. + prices: wide price frame, index = date, columns = symbol (e.g. + ``panel["close"].unstack("symbol")``). + quantiles: number of factor buckets. + period: forward-return horizon (trading days); alphalens labels the column + ``f"{period}D"``. + simple_fallback: the authoritative simple-pandas IC metrics, carried + through verbatim when alphalens is unavailable or errors. + + Returns: + ``{"backend": "alphalens", "ic_mean", "ic_std", "ic_ir", "quantile_mean", + "n_dates"}`` on success; ``{"backend": "unavailable", **fallback}`` or + ``{"backend": "error", "error_type", **fallback}`` otherwise. + """ + fallback = dict(simple_fallback or {}) + try: + al = _import_alphalens() + except ImportError: + return {"backend": "unavailable", **fallback} + + try: + col = f"{period}D" + # alphalens uses level 0 = date, level 1 = asset; rename for clarity and + # suppress its stdout banner / RuntimeWarnings so the CLI stays clean. + fac = factor.copy() + fac.index = fac.index.set_names(["date", "asset"]) + with warnings.catch_warnings(), contextlib.redirect_stdout(io.StringIO()): + warnings.simplefilter("ignore") + factor_data = al.utils.get_clean_factor_and_forward_returns( + fac, prices, quantiles=quantiles, periods=(period,), max_loss=1.0 + ) + ic = al.performance.factor_information_coefficient(factor_data)[col] + mq = al.performance.mean_return_by_quantile(factor_data)[0][col] + ic_mean = float(ic.mean()) + ic_std = float(ic.std(ddof=1)) if len(ic) > 1 else float("nan") + ic_ir = ic_mean / ic_std if (math.isfinite(ic_std) and ic_std != 0) else float("nan") + quantile_mean = {int(q): float(v) for q, v in mq.items()} + return { + "backend": "alphalens", + "ic_mean": ic_mean, "ic_std": ic_std, "ic_ir": float(ic_ir), + "quantile_mean": quantile_mean, "n_dates": int(len(ic)), + } + except Exception as exc: # noqa: BLE001 - any alphalens failure -> disclosed fallback + return {"backend": "error", "error_type": type(exc).__name__, **fallback} + + +__all__ = ["alphalens_factor_metrics"] diff --git a/analytics/quantstats_adapter.py b/analytics/quantstats_adapter.py new file mode 100644 index 0000000..a159952 --- /dev/null +++ b/analytics/quantstats_adapter.py @@ -0,0 +1,82 @@ +"""Thin, report-only quantstats adapter (P2-4). + +Consumes the backtest's per-period returns and produces the standard portfolio +metrics (CAGR / Sharpe / max-drawdown / volatility) via ``quantstats``, tagging +the ``backend`` it actually used. This is REPORTING ONLY — it never feeds back +into selection / portfolio / execution, so trading results are unchanged. + +Honesty contract (INV-007): if quantstats is unavailable (``ImportError``) or +raises, the result is tagged ``unavailable`` / ``error`` and the caller's simple +pandas ``simple_fallback`` is carried through — the report must never claim the +standard library ran when it did not. The ``_import_quantstats`` indirection makes +the unavailable path unit-testable without uninstalling the package. +""" + +from __future__ import annotations + +import math + +import pandas as pd + + +def _import_quantstats(): + """Import quantstats (indirection so the ImportError path is testable).""" + import quantstats + + return quantstats + + +def quantstats_performance( + returns: pd.Series, + periods_per_year: int = 252, + simple_fallback: dict | None = None, +) -> dict: + """Standard performance metrics from a per-period returns series. + + Args: + returns: per-period (e.g. per-rebalance) simple returns, ideally with a + DatetimeIndex (CAGR uses the date span). + periods_per_year: annualization factor for Sharpe / volatility (12 for + monthly rebalances). + simple_fallback: the authoritative simple-pandas metrics, carried through + verbatim when quantstats is unavailable or errors. + + Returns: + ``{"backend": "quantstats", "cagr", "sharpe", "max_drawdown", + "volatility"}`` on success; ``{"backend": "unavailable", **fallback}`` or + ``{"backend": "error", "error_type", **fallback}`` otherwise. + """ + fallback = dict(simple_fallback or {}) + try: + qs = _import_quantstats() + except ImportError: + return {"backend": "unavailable", **fallback} + + try: + r = pd.Series(returns, dtype=float).dropna() + if len(r) < 2: + return { + "backend": "quantstats", + "cagr": float("nan"), "sharpe": float("nan"), + "max_drawdown": float("nan"), "volatility": float("nan"), + } + # quantstats annualizes by ``periods`` (12 for monthly); the DEFAULT 252 + # would treat monthly returns as daily and explode CAGR. max_drawdown takes + # the RETURNS series (it builds the equity curve itself) — passing a prebuilt + # nav makes it read 0. + metrics = { + "cagr": float(qs.stats.cagr(r, periods=periods_per_year)), + "sharpe": float(qs.stats.sharpe(r, periods=periods_per_year)), + "volatility": float(qs.stats.volatility(r, periods=periods_per_year)), + "max_drawdown": float(qs.stats.max_drawdown(r)), + } + # guard non-finite (e.g. zero-volatility degenerate input -> inf Sharpe). + metrics = {k: (v if math.isfinite(v) else float("nan")) for k, v in metrics.items()} + if metrics["max_drawdown"] > 0: # drawdown is a non-positive fraction + metrics["max_drawdown"] = -metrics["max_drawdown"] + return {"backend": "quantstats", **metrics} + except Exception as exc: # noqa: BLE001 - any quantstats failure -> disclosed fallback + return {"backend": "error", "error_type": type(exc).__name__, **fallback} + + +__all__ = ["quantstats_performance"] diff --git a/qt/phase2_baseline.py b/qt/phase2_baseline.py index 334e765..c175cfe 100644 --- a/qt/phase2_baseline.py +++ b/qt/phase2_baseline.py @@ -44,6 +44,7 @@ _collect_downgrades, _compute_factor_panel, _factor_analytics, + _standard_analytics, _load_panel, _make_logger, _maybe_enrich_covariates, @@ -104,6 +105,9 @@ class Phase2Result: ic_ir: float quantile_returns: pd.DataFrame performance: dict + # P2-4 standard-analytics cross-check (report-only; never alters trading) + std_performance: dict + std_factor: dict # disclosure downgrades: tuple[str, ...] # paths @@ -368,6 +372,9 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: if not nav_table.empty 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 + ) # --- diagnostics (read-only) ----------------------------------------- # financial_field = ( @@ -445,6 +452,8 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: ic_ir=ic_ir, quantile_returns=q_returns, 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", log_path=log_path, diff --git a/qt/pipeline.py b/qt/pipeline.py index dbb5155..483385e 100644 --- a/qt/pipeline.py +++ b/qt/pipeline.py @@ -34,8 +34,10 @@ import pandas as pd from alpha.equal_weight import EqualWeightAlpha +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 +from analytics.quantstats_adapter import quantstats_performance from data.clean.adjust import front_adjust from data.clean.covariates import enrich_covariates, enrich_listing, enrich_pit_industry from data.clean.pit_industry import asof_industry @@ -97,6 +99,9 @@ class Phase0Result: performance: dict[str, float] avg_turnover: float cost_drag: float + # P2-4 standard-analytics cross-check (report-only; never alters the above). + std_performance: dict + std_factor: dict downgrades: tuple[str, ...] data_path: Path factor_path: Path @@ -273,10 +278,13 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: neutral, execution, f"Daily ({cfg.data.freq}) bars only; minute-level link is deferred.", - "IC / quantile returns use a simple numpy/pandas implementation, NOT " - "alphalens-reloaded (simple-vs-alphalens fallback, INV-007).", - "Performance metrics use a simple numpy/pandas implementation, NOT " - "quantstats (simple-vs-quantstats fallback, INV-007).", + "Analytics (P2-4): the AUTHORITATIVE backtest metrics are the simple " + "numpy/pandas implementation (deterministic, audit-light); " + "alphalens-reloaded (IC / quantiles) and quantstats (CAGR / Sharpe / maxDD " + "/ vol) are computed ALONGSIDE as a standard cross-check, report-only — they " + "never alter selection / portfolio / execution. The 'Standard analytics' " + "report section names the backend actually used and discloses it when a " + "library is unavailable or errors (no silent fake, INV-007).", listing, ] return tuple(items) @@ -323,6 +331,9 @@ 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 + ) downgrades = _collect_downgrades(cfg) result = Phase0Result( @@ -337,6 +348,8 @@ def run_phase0(config_path: str) -> Phase0Result: performance=perf, avg_turnover=avg_turnover, cost_drag=cost_drag, + std_performance=std_performance, + std_factor=std_factor, downgrades=downgrades, data_path=Path(cfg.output.data_dir) / f"{cfg.data.output_name}.parquet", factor_path=Path(cfg.output.factor_dir) / "factors.parquet", @@ -718,3 +731,45 @@ def _factor_analytics( 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 + + +def _standard_analytics( + cfg: RootConfig, + panel: pd.DataFrame, + factor_panel: pd.DataFrame, + factor_name: str, + nav_table: pd.DataFrame, + ic_mean: float, + ic_ir: float, + perf: dict, + logger: logging.Logger, +) -> tuple[dict, dict]: + """Report-only standard-library cross-check (quantstats + alphalens, P2-4). + + 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). + """ + if not nav_table.empty: + std_performance = quantstats_performance( + nav_table["net_return"], + periods_per_year=_periods_per_year(cfg.backtest.rebalance), + simple_fallback=perf, + ) + else: + std_performance = {"backend": "skipped", **perf} + prices_wide = panel["close"].unstack("symbol") + horizon = int(cfg.analytics.forward_return_periods[0]) + std_factor = alphalens_factor_metrics( + factor_panel[factor_name], + prices_wide, + quantiles=int(cfg.analytics.quantiles), + period=horizon, + simple_fallback={"ic_mean": ic_mean, "ic_ir": ic_ir}, + ) + logger.info( + "standard analytics (report-only): quantstats=%s alphalens=%s", + std_performance.get("backend"), std_factor.get("backend"), + ) + return std_performance, std_factor diff --git a/qt/reports.py b/qt/reports.py index a41d3c6..5a25319 100644 --- a/qt/reports.py +++ b/qt/reports.py @@ -42,6 +42,54 @@ def _quantile_table(q_returns: pd.DataFrame) -> str: return header + rows +def _err_suffix(d: dict) -> str: + """' (error_type=X)' when a standard-analytics backend errored, else ''.""" + if (d or {}).get("backend") == "error" and d.get("error_type"): + return f" (error_type={d['error_type']})" + return "" + + +def standard_analytics_block(std_performance: dict, std_factor: dict) -> str: + """Render the report-only standard-library cross-check (P2-4). + + Shows the quantstats performance + alphalens factor metrics WHEN those backends + ran, and otherwise discloses the backend (``unavailable`` / ``error`` / ``skipped``) + so the report never implies a standard library ran when it did not. These never + replace the authoritative simple metrics shown elsewhere. + """ + lines: list[str] = [] + qb = (std_performance or {}).get("backend", "n/a") + if qb == "quantstats": + sp = std_performance + lines.append( + f"- **quantstats** performance: CAGR **{_fmt(sp.get('cagr', float('nan')), pct=True)}** · " + f"Sharpe **{_fmt(sp.get('sharpe', float('nan')))}** · " + f"maxDD **{_fmt(sp.get('max_drawdown', float('nan')), pct=True)}** · " + f"vol **{_fmt(sp.get('volatility', float('nan')), pct=True)}**\n" + ) + else: + lines.append( + f"- quantstats performance: **backend={qb}**{_err_suffix(std_performance)} " + f"— the simple metrics above are authoritative (not faked).\n" + ) + ab = (std_factor or {}).get("backend", "n/a") + if ab == "alphalens": + sf = std_factor + qm = sf.get("quantile_mean", {}) or {} + qstr = ", ".join(f"Q{k} {_fmt(float(v), pct=True)}" for k, v in sorted(qm.items())) + lines.append( + f"- **alphalens** factor: IC mean **{_fmt(sf.get('ic_mean', float('nan')))}** · " + f"IC-IR **{_fmt(sf.get('ic_ir', float('nan')))}** " + f"({sf.get('n_dates', 0)} dates) · quantile mean fwd-ret: {qstr or '_n/a_'}\n" + ) + else: + lines.append( + f"- alphalens factor: **backend={ab}**{_err_suffix(std_factor)} " + f"— the simple IC above is authoritative (not faked).\n" + ) + return "".join(lines) + + def render_phase0_summary(result: "Phase0Result") -> str: """Build the phase0 summary markdown string (pure; no I/O).""" cfg = result.config @@ -93,6 +141,13 @@ def render_phase0_summary(result: "Phase0Result") -> str: f"- total cost drag: **{_fmt(result.cost_drag, pct=True)}**\n" ) + lines.append("## Standard analytics (alphalens / quantstats cross-check)\n") + lines.append( + "_Report-only standard-library metrics; the simple metrics above remain the " + "authoritative backtest result (these never alter trading)._\n" + ) + lines.append(standard_analytics_block(result.std_performance, result.std_factor)) + lines.append("## DOWNGRADES (INV-007 — must be disclosed)\n") lines.append( "This Phase 0 run intentionally uses simplified / downgraded components. " @@ -134,6 +189,7 @@ def write_phase0_summary(result: "Phase0Result") -> Path: "## Factor IC", "## Quantile returns", "## Portfolio performance", + "## Standard analytics", "## DOWNGRADES", ) @@ -381,6 +437,13 @@ def render_phase2_baseline(result: "Phase2Result") -> str: f"- sharpe: **{_fmt(perf.get('sharpe', float('nan')))}**\n" ) + lines.append("\n## Standard analytics (alphalens / quantstats cross-check)\n") + lines.append( + "_Report-only standard-library metrics; the simple metrics above remain the " + "authoritative backtest result (these never alter trading)._\n\n" + ) + lines.append(standard_analytics_block(result.std_performance, result.std_factor)) + lines.append("\n## DOWNGRADES (INV-007 — must be disclosed)\n") for item in result.downgrades: lines.append(f"- {item}\n") diff --git a/tests/test_alphalens_adapter.py b/tests/test_alphalens_adapter.py new file mode 100644 index 0000000..0ddc4c0 --- /dev/null +++ b/tests/test_alphalens_adapter.py @@ -0,0 +1,80 @@ +"""alphalens factor-metrics adapter (P2-4, network-free). + +A thin, report-only wrapper over alphalens-reloaded: it consumes the factor Series +plus a wide price frame and produces IC mean / IC-IR / per-quantile mean returns, +tagging the ``backend`` it used. Unavailable/erroring alphalens must be disclosed +and fall back to the simple-pandas metrics — never a silent fake. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd + +from analytics import alphalens_adapter as aa +from analytics.alphalens_adapter import alphalens_factor_metrics + + +def _synthetic_factor_and_prices(n_days=40, n_sym=12): + dates = pd.bdate_range("2024-01-01", periods=n_days) + syms = [f"{i:06d}.SZ" for i in range(n_sym)] + # each symbol a distinct deterministic upward drift; higher index = stronger. + prices = pd.DataFrame( + {s: 100.0 + j + 0.3 * np.arange(n_days) + j * 0.05 * np.arange(n_days) + for j, s in enumerate(syms)}, + index=dates, + ) + # factor = the symbol's drift rank (constant per symbol) -> well-defined IC. + idx = pd.MultiIndex.from_product([dates, syms], names=["date", "symbol"]) + factor = pd.Series( + [syms.index(s) for _, s in idx], index=idx, dtype=float, name="momentum_20" + ) + return factor, prices + + +def test_alphalens_metrics_reports_ic_and_quantiles(): + factor, prices = _synthetic_factor_and_prices() + out = alphalens_factor_metrics(factor, prices, quantiles=5, period=1) + assert out["backend"] == "alphalens" + assert "ic_mean" in out and math.isfinite(out["ic_mean"]) + assert "ic_ir" in out + # quantile_mean: one entry per bucket 1..5 + assert set(out["quantile_mean"].keys()) == {1, 2, 3, 4, 5} + + +def test_alphalens_unavailable_discloses_and_keeps_fallback(monkeypatch): + def _raise(): + raise ImportError("alphalens not installed") + + monkeypatch.setattr(aa, "_import_alphalens", _raise) + factor, prices = _synthetic_factor_and_prices() + out = alphalens_factor_metrics( + factor, prices, simple_fallback={"ic_mean": 0.05, "ic_ir": 0.4} + ) + assert out["backend"] == "unavailable" # honest + assert out["ic_mean"] == 0.05 and out["ic_ir"] == 0.4 # simple fallback kept + + +def test_alphalens_error_discloses_error_type(monkeypatch): + class _Boom: + class utils: + @staticmethod + def get_clean_factor_and_forward_returns(*a, **k): + raise ValueError("not enough names") + + monkeypatch.setattr(aa, "_import_alphalens", lambda: _Boom) + factor, prices = _synthetic_factor_and_prices() + out = alphalens_factor_metrics(factor, prices, simple_fallback={"ic_mean": 0.01}) + assert out["backend"] == "error" + assert out["error_type"] == "ValueError" # type only + assert out["ic_mean"] == 0.01 + + +def test_alphalens_suppresses_stdout(capsys): + # alphalens prints a "Dropped ..." banner; the adapter must not pollute stdout. + factor, prices = _synthetic_factor_and_prices() + alphalens_factor_metrics(factor, prices, quantiles=5, period=1) + captured = capsys.readouterr() + assert "Dropped" not in captured.out diff --git a/tests/test_phase0_pipeline.py b/tests/test_phase0_pipeline.py index 005a387..7622441 100644 --- a/tests/test_phase0_pipeline.py +++ b/tests/test_phase0_pipeline.py @@ -212,3 +212,32 @@ def __init__(self, secret_file: str, token_key: str = "tushare.token"): from data.feed.demo_feed import DemoFeed assert isinstance(_build_feed(demo_cfg), DemoFeed) + + +def test_phase0_standard_analytics_is_additive_not_replacing(tmp_path, example_config_path): + """P2-4: alphalens/quantstats are report-only — they never replace the simple + authoritative metrics or change the trading result.""" + cfg_path = _write_tmp_config(tmp_path, example_config_path) + result = run_phase0(str(cfg_path)) + + # standard-analytics backends are recorded (whatever actually ran). + assert result.std_performance["backend"] in ( + "quantstats", "error", "unavailable", "skipped", + ) + assert result.std_factor["backend"] in ("alphalens", "error", "unavailable") + # the AUTHORITATIVE simple perf is untouched and SEPARATE from the std dict. + assert "annual_return" in result.performance # simple perf keys + assert "cagr" not in result.performance # cagr lives only in std_performance + # the report shows the cross-check section and flags the simple metrics authoritative. + text = result.report_path.read_text(encoding="utf-8") + assert "## Standard analytics" in text + assert "authoritative" in text.lower() + + +def test_phase0_standard_analytics_no_secret_leak(tmp_path, example_config_path): + """The standard-analytics section must not leak the tushare token (only the + exception TYPE is ever recorded, never a message).""" + cfg_path = _write_tmp_config(tmp_path, example_config_path) + result = run_phase0(str(cfg_path)) + text = result.report_path.read_text(encoding="utf-8") + assert "token" not in text.lower() diff --git a/tests/test_phase2_baseline.py b/tests/test_phase2_baseline.py index 4d8378a..f128bfd 100644 --- a/tests/test_phase2_baseline.py +++ b/tests/test_phase2_baseline.py @@ -301,6 +301,11 @@ def _synthetic_result() -> Phase2Result: quantile_returns=qret, performance={"annual_return": 0.1, "max_drawdown": -0.05, "volatility": 0.2, "sharpe": 0.5}, + std_performance={"backend": "quantstats", "cagr": 0.11, "sharpe": 0.52, + "max_drawdown": -0.06, "volatility": 0.21}, + std_factor={"backend": "alphalens", "ic_mean": 0.05, "ic_ir": 0.4, + "quantile_mean": {1: -0.01, 2: 0.0, 3: 0.01, 4: 0.02, 5: 0.03}, + "n_dates": 11}, downgrades=("DATA PATH = REAL tushare: ...", "small-scale baseline"), report_path=Path("artifacts/reports/phase2_real_baseline.md"), log_path=Path("artifacts/logs/run_phase2_baseline.log"), @@ -322,6 +327,13 @@ def test_render_does_not_leak_secret_file_or_token(): assert "token" not in md.lower() +def test_render_shows_standard_analytics_cross_check(): + md = render_phase2_baseline(_synthetic_result()) + assert "## Standard analytics" in md + assert "quantstats" in md and "alphalens" in md # backends named + assert "authoritative" in md.lower() # simple metrics flagged authoritative + + def test_render_reports_holdings_and_rebalance_dates(): md = render_phase2_baseline(_synthetic_result()) assert "000001.SZ" in md and "000002.SZ" in md # holdings listed diff --git a/tests/test_quantstats_adapter.py b/tests/test_quantstats_adapter.py new file mode 100644 index 0000000..64fa361 --- /dev/null +++ b/tests/test_quantstats_adapter.py @@ -0,0 +1,66 @@ +"""quantstats performance adapter (P2-4, network-free). + +A thin, report-only wrapper over quantstats: it consumes the backtest's per-period +returns and produces CAGR / Sharpe / max-drawdown / volatility, tagging the +``backend`` it used. If quantstats is unavailable or raises, it must say so and +carry the simple-pandas fallback through — never silently pretend the standard +library ran. +""" + +from __future__ import annotations + +import math + +import pandas as pd + +from analytics import quantstats_adapter as qa +from analytics.quantstats_adapter import quantstats_performance + + +def _monthly_returns(n=12): + idx = pd.date_range("2023-01-31", periods=n, freq="ME") + vals = [0.02, -0.01, 0.03, -0.02, 0.01, 0.00, 0.04, -0.03, 0.02, -0.01, 0.01, 0.02][:n] + return pd.Series(vals, index=idx, name="net_return") + + +def test_quantstats_performance_reports_standard_metrics(): + out = quantstats_performance(_monthly_returns(), periods_per_year=12) + assert out["backend"] == "quantstats" + for key in ("cagr", "sharpe", "max_drawdown", "volatility"): + assert key in out and math.isfinite(out[key]) + assert out["max_drawdown"] <= 0.0 # drawdown is a non-positive fraction + + +def test_quantstats_unavailable_discloses_and_keeps_fallback(monkeypatch): + def _raise(): + raise ImportError("quantstats not installed") + + monkeypatch.setattr(qa, "_import_quantstats", _raise) + out = quantstats_performance( + _monthly_returns(), periods_per_year=12, + simple_fallback={"sharpe": 0.5, "annual_return": -0.1}, + ) + assert out["backend"] == "unavailable" # honest: NOT "quantstats" + assert out["sharpe"] == 0.5 # simple fallback carried through + assert out["annual_return"] == -0.1 + + +def test_quantstats_error_discloses_error_type(monkeypatch): + class _Boom: + class stats: + @staticmethod + def cagr(*a, **k): + raise ValueError("degenerate series") + + monkeypatch.setattr(qa, "_import_quantstats", lambda: _Boom) + out = quantstats_performance(_monthly_returns(), periods_per_year=12, + simple_fallback={"sharpe": 0.3}) + assert out["backend"] == "error" + assert out["error_type"] == "ValueError" # type only, no message/secret + assert out["sharpe"] == 0.3 # fallback preserved + + +def test_quantstats_empty_returns_is_safe(): + out = quantstats_performance(pd.Series([], dtype=float), periods_per_year=12) + # empty -> either a clean unavailable/error tag or NaN metrics, never a crash + assert out["backend"] in ("quantstats", "error", "unavailable") From ae0674f762216a27a5b8c055fe73486615762788 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Wed, 10 Jun 2026 10:56:31 +0800 Subject: [PATCH 2/2] test: make analytics success-path tests skip without optional extras; fix doc drift Review LOW fixes on PR #7: - tests: pytest.importorskip(quantstats/alphalens) on the 3 success-path adapter tests so a clean .[dev]-only env skips (disclosed) instead of failing; fallback/disclosure tests still monkeypatch and run everywhere. Verified: with both libs shadowed, 5 passed / 3 skipped; with quant_mf, 234 passed unchanged. - analytics/factor.py + performance.py: stale 'may be wired in later' docstrings -> simple metrics remain AUTHORITATIVE; standard adapters live in analytics/*_adapter.py as report-only cross-check (P2-4). - CLAUDE.md / AGENTS.md progress: P2-4 is now 'PR #7 OPEN/MERGEABLE, awaiting acceptance/merge' (was stale 'no PR yet'). - TEST_REPORT.md: document the analytics optional extra and the skip behaviour for the full 234-passed run. --- AGENTS.md | 2 +- CLAUDE.md | 2 +- TEST_REPORT.md | 6 ++++++ analytics/factor.py | 10 +++++----- analytics/performance.py | 9 +++++---- tests/test_alphalens_adapter.py | 7 +++++++ tests/test_quantstats_adapter.py | 6 ++++++ 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4bbc7e5..ffb184c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - **SW 层级可配置** `processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,中性化标准 + 小截面自由度更稳)。**关键实证**:旧 tag 年化 −17.6% / SW-L1 −10.2% / SW-L2 −9.3% → **L1≈L2**,−17.6→−10 大跳主因是 **tushare→SW 分类切换**(补 PIT 必然:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**。(曾误判粒度、默认 L2,经 L2 实测推翻,改默认 L1。) - 不静默退回 current:无 SW 历史的票 → 行业 NaN,被 neutralize 按截面丢弃;每次运行 **实际 level + PIT 覆盖率**进 phase2 报告。 - 真实 baseline(SW-L1,默认):`run-phase2-baseline` OK,symbols=68,settled=11,**PIT SW-L1 coverage 98.53%**(67/68),IC 0.0083,**annual −10.19%**。 -- 🔧 **Phase 2-4 标准分析集成**(`p2-standard-analytics` 分支,未 PR,代劳待验收):alphalens-reloaded + quantstats 接入报告(**report-only cross-check**)。 +- 🔧 **Phase 2-4 标准分析集成**(**PR #7 OPEN/MERGEABLE,待验收/合并**):alphalens-reloaded + quantstats 接入报告(**report-only cross-check**)。 - `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** 报告段。 diff --git a/CLAUDE.md b/CLAUDE.md index 00919e3..4a9d102 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - `tushare_covariates.pit_sw_intervals(symbols, level)` 读 `index_member_all` 取每股 SW 行业 `in_date`/`out_date` 区间;`data/clean/pit_industry.py::asof_industry` 按 `[in_date,out_date)` 覆盖该日取行业(改分类日新行业生效,PIT-safe;起始日前成分 carry forward 到窗口开始)。 - **SW 层级可配置** `processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,中性化标准 + 小截面自由度更稳)。**关键实证**:旧 tag 年化 −17.6% / SW-L1 −10.2% / SW-L2 −9.3% → **L1≈L2**,大跳的主因是 **tushare→SW 分类切换**(补 PIT 的必然:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**。(注:曾误以为是粒度、默认 L2,经 L2 实测推翻,改默认 L1。) - **不静默退回 current**:无 SW 历史的票 → 行业 NaN,被 neutralize 按截面丢弃(neutralize 数学不变,本就丢 NaN 行);每次运行 **实际 level + PIT 覆盖率**进 phase2 报告。 -- ✅ **Phase 2-4 标准分析集成**(`p2-standard-analytics` 分支,未 PR):alphalens-reloaded + quantstats 接入报告(**report-only cross-check**)。 +- ✅ **Phase 2-4 标准分析集成**(**PR #7 OPEN/MERGEABLE,待验收/合并**):alphalens-reloaded + quantstats 接入报告(**report-only cross-check**)。 - `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** 报告段。 diff --git a/TEST_REPORT.md b/TEST_REPORT.md index 4755434..4a68c99 100644 --- a/TEST_REPORT.md +++ b/TEST_REPORT.md @@ -107,6 +107,12 @@ Counts below are the actual per-file `pytest` numbers (sum = 234). - No test hits the network or reads the tushare token (TEST-002, INV-004): the whole suite runs on `DemoFeed` / fixtures / monkeypatched SDKs. +- **Optional analytics extras:** `alphalens-reloaded` / `quantstats` are the + `analytics` optional extra in `pyproject.toml` (installed in `quant_mf`). The + adapter success-path tests `pytest.importorskip` them — a clean `.[dev]`-only + environment SKIPS those 3 tests (disclosed) instead of failing; the + fallback/disclosure tests monkeypatch the import and run everywhere. For the + full 234-passed run, install `.[analytics]` too (or use `quant_mf`). - **P2-2 execution realism (locked by tests):** selection eligibility and execution feasibility are split. `runtime.fills.simulate_fills` is the cash-coherent sell-then-buy model — at-up-limit blocks buys, at-down-limit blocks sells, diff --git a/analytics/factor.py b/analytics/factor.py index fbda238..af148fb 100644 --- a/analytics/factor.py +++ b/analytics/factor.py @@ -4,11 +4,11 @@ (future-looking) returns. The factor layer must never receive them — see CLAUDE.md invariant #1 and CONTRACTS.md §3. -Implementation note (INV-007 downgrade): the IC / quantile logic here is a -simple numpy/pandas implementation, not alphalens-reloaded. It is deterministic, -dependency-light, and easy to audit. ``alphalens`` is available and may be wired -in later for the HTML report; any such swap must be recorded in the phase0 -report. The simple version is what runs in P0. +Implementation note: the IC / quantile logic here is a simple numpy/pandas +implementation — deterministic, dependency-light, easy to audit — and it remains +the AUTHORITATIVE result that drives the run. Since P2-4, alphalens-reloaded is +wired in as a report-only cross-check via ``analytics/alphalens_adapter.py`` +(backend disclosed in the report); it never replaces these numbers. All functions are pure: inputs are never mutated. """ diff --git a/analytics/performance.py b/analytics/performance.py index 1ccdbe1..7897f0d 100644 --- a/analytics/performance.py +++ b/analytics/performance.py @@ -4,10 +4,11 @@ volatility, Sharpe. ``performance_summary`` bundles them into a plain dict for the phase0 report. -Implementation note (INV-007 downgrade): these are simple, dependency-light -numpy/pandas computations, NOT quantstats/empyrical. ``quantstats`` is available -and may be used later for the richer HTML tearsheet; any such use must be -recorded in the phase0 report. The simple version is what runs in P0. +Implementation note: these are simple, dependency-light numpy/pandas +computations, and they remain the AUTHORITATIVE backtest result that drives the +run. Since P2-4, quantstats is wired in as a report-only cross-check via +``analytics/quantstats_adapter.py`` (backend disclosed in the report); it never +replaces these numbers. All functions are pure: inputs are never mutated. diff --git a/tests/test_alphalens_adapter.py b/tests/test_alphalens_adapter.py index 0ddc4c0..92727eb 100644 --- a/tests/test_alphalens_adapter.py +++ b/tests/test_alphalens_adapter.py @@ -4,6 +4,10 @@ plus a wide price frame and produces IC mean / IC-IR / per-quantile mean returns, tagging the ``backend`` it used. Unavailable/erroring alphalens must be disclosed and fall back to the simple-pandas metrics — never a silent fake. + +alphalens-reloaded is an OPTIONAL extra (``pip install .[analytics]``): the +success-path tests skip (disclosed) when it is not installed; the +fallback/disclosure tests monkeypatch the import and run everywhere. """ from __future__ import annotations @@ -12,6 +16,7 @@ import numpy as np import pandas as pd +import pytest from analytics import alphalens_adapter as aa from analytics.alphalens_adapter import alphalens_factor_metrics @@ -35,6 +40,7 @@ def _synthetic_factor_and_prices(n_days=40, n_sym=12): def test_alphalens_metrics_reports_ic_and_quantiles(): + pytest.importorskip("alphalens") # optional extra; skip (not fail) without it factor, prices = _synthetic_factor_and_prices() out = alphalens_factor_metrics(factor, prices, quantiles=5, period=1) assert out["backend"] == "alphalens" @@ -73,6 +79,7 @@ def get_clean_factor_and_forward_returns(*a, **k): def test_alphalens_suppresses_stdout(capsys): + pytest.importorskip("alphalens") # optional extra; skip (not fail) without it # alphalens prints a "Dropped ..." banner; the adapter must not pollute stdout. factor, prices = _synthetic_factor_and_prices() alphalens_factor_metrics(factor, prices, quantiles=5, period=1) diff --git a/tests/test_quantstats_adapter.py b/tests/test_quantstats_adapter.py index 64fa361..790db3f 100644 --- a/tests/test_quantstats_adapter.py +++ b/tests/test_quantstats_adapter.py @@ -5,6 +5,10 @@ ``backend`` it used. If quantstats is unavailable or raises, it must say so and carry the simple-pandas fallback through — never silently pretend the standard library ran. + +quantstats is an OPTIONAL extra (``pip install .[analytics]``): the success-path +test skips (disclosed) when it is not installed; the fallback/disclosure tests +monkeypatch the import and run everywhere. """ from __future__ import annotations @@ -12,6 +16,7 @@ import math import pandas as pd +import pytest from analytics import quantstats_adapter as qa from analytics.quantstats_adapter import quantstats_performance @@ -24,6 +29,7 @@ def _monthly_returns(n=12): def test_quantstats_performance_reports_standard_metrics(): + pytest.importorskip("quantstats") # optional extra; skip (not fail) without it out = quantstats_performance(_monthly_returns(), periods_per_year=12) assert out["backend"] == "quantstats" for key in ("cagr", "sharpe", "max_drawdown", "volatility"):