From b8edb958393460a6b5ad2e6c67ada13b7678f07e Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Tue, 9 Jun 2026 15:50:47 +0800 Subject: [PATCH 1/2] feat(runtime): direction-aware execution feasibility + enforce min_listing_days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-2 closes execution/tradability realism gaps. It splits SELECTION eligibility (what the strategy wants) from EXECUTION feasibility (what the market lets you trade) and stops the backtest from silently forcing impossible trades. No new factor, no parameter tuning. Execution feasibility (new): - runtime/fills.py: simulate_fills(current, target, can_buy, can_sell) — a cash-coherent sell-then-buy fill model. Sells execute first (freeing cash), buys are funded from available cash and scaled down proportionally if blocked sells starved them, so the book never sums to > 1 (no leverage). Blocked trades carry forward; turnover counts only executed trades. feasibility_from_cross derives (can_buy, can_sell) from the panel flags: at_up_limit blocks buys, at_down_limit blocks sells, suspended/missing-close blocks both — independent of the selection toggles (feasibility is market physics, not policy). - SimExecution.rebalance_to routes through simulate_fills (optional can_buy/ can_sell; None -> all feasible -> achieved == target, so demo/P0/P1 numbers are unchanged). settle() returns the invested book's return only; the driver owns the idle-cash return (BT-007), so SimExecution no longer takes a cash_return (it was dead after this split). - BacktestDriver._step reads feasibility from the rebalance date's cross-section (no lookahead), settles over the achieved book, adds idle*cash_return, and records a per-rebalance feasibility_log() (blocked buys/sells, carried, executed turnover, invested fraction) aligned 1:1 with the NAV index. min_listing_days (was a no-op): - apply_tradable_filters enforces it as a buy/selection filter: names younger than min_listing_days as of each date are excluded (boundary age==min allowed); a missing list_date is a disclosed data gap (kept, never silently dropped). - Real path enriches list_date from tushare stock_basic; demo stays a disclosed no-op. enrich_listing + TushareCovariatesFeed.listing_dates + pipeline wiring. Reporting/disclosure: phase2 report gains an Execution feasibility section; BIAS_AUDIT/RUNBOOK/TEST_REPORT/CLAUDE updated; _collect_downgrades is now direction-aware and path-aware for listing. Tests (204 passed): test_fills (10), test_driver_feasibility (5), test_min_listing_days (6). Also fixed a duplicate test-function name across two files that had been silently shadowing one test in the full-suite run. Real validation (SSE50, ~11 min, not in CI): the feasibility machinery runs and honestly reports zero blocks on blue-chips (they don't hit limits); list_date enriched 67/68; numbers unchanged vs P2-1 (P2-2 is behaviour-neutral there). --- BIAS_AUDIT.md | 5 +- CLAUDE.md | 14 ++- RUNBOOK.md | 30 +++++ TEST_REPORT.md | 71 +++++------ data/clean/covariates.py | 16 +++ data/feed/tushare_covariates.py | 20 ++++ qt/phase2_baseline.py | 8 +- qt/pipeline.py | 60 +++++++++- qt/reports.py | 51 +++++++- runtime/backtest/driver.py | 92 ++++++++++++--- runtime/backtest/sim_execution.py | 65 ++++++---- runtime/execution.py | 15 ++- runtime/fills.py | 189 ++++++++++++++++++++++++++++++ tests/test_bias_audit_report.py | 19 ++- tests/test_driver_feasibility.py | 118 +++++++++++++++++++ tests/test_fills.py | 140 ++++++++++++++++++++++ tests/test_min_listing_days.py | 68 +++++++++++ tests/test_phase2_baseline.py | 6 + universe/filters.py | 10 ++ 19 files changed, 902 insertions(+), 95 deletions(-) create mode 100644 runtime/fills.py create mode 100644 tests/test_driver_feasibility.py create mode 100644 tests/test_fills.py create mode 100644 tests/test_min_listing_days.py diff --git a/BIAS_AUDIT.md b/BIAS_AUDIT.md index e92655e..bfc0acb 100644 --- a/BIAS_AUDIT.md +++ b/BIAS_AUDIT.md @@ -24,10 +24,11 @@ - `missing_close`(总是开):截面日 `close` 为 NaN 的标的不可交易(UNI-004)。 - 统一在 `universe.filters.apply_tradable_filters` 按 `UniverseFilters` 开关执行;flag 由 `data.clean.tradability.enrich_tradability` 从 tushare `suspend_d` / `namechange` / `stk_limit` 富化到 panel(StaticUniverse 与 PITIndexUniverse 共用)。demo 无 flag 数据时各过滤自动 no-op。 - **ST(UNI-006)**:`namechange` 名称区间含 'ST'/'*ST' 即标记,按 date 取生效名称(实证:`000005.SZ` 2024 全程 ST,正确剔除)。 -- **涨跌停(UNI-007)**:用**未复权 raw close** 与当日 raw `up_limit`/`down_limit` 比较,标记 `at_up_limit`/`at_down_limit`(qfq 复权价仅用于因子/回测收益;flag 富化在 front_adjust **之前**完成,故比较的是同口径 raw 价)。实证:`000005.SZ` 2024-02-01 触跌停。当前选股层对两个方向都剔除;**方向感知**(买入只看涨停、持有跌停不强卖)属执行层,后续细化。 +- **涨跌停(UNI-007)**:用**未复权 raw close** 与当日 raw `up_limit`/`down_limit` 比较,标记 `at_up_limit`/`at_down_limit`(qfq 复权价仅用于因子/回测收益;flag 富化在 front_adjust **之前**完成,故比较的是同口径 raw 价)。实证:`000005.SZ` 2024-02-01 触跌停。 +- **方向感知执行(UNI-007 / P2-2,已实现)**:选股层(`apply_tradable_filters`)与执行层(`runtime.fills.simulate_fills`)**拆分**。执行可行性按 panel flag 实时判定、与选股 toggle 无关:`at_up_limit` 挡**买入/加仓**,`at_down_limit` 挡**卖出/减仓**,`suspended`/缺收盘价双向挡。被挡的交易 **carry forward**(绝不强行成交不可能的单),现金一致 sell-then-buy(卖在前释放现金、买在后,现金不足按比例部分成交,**无杠杆**),换手/成本只算**实际成交**,闲置现金按 `cash_return` 计息。demo 无 flag → 全可成交 → P0/P1 数字不变。每个调仓期的 blocked buys/sells/carried/executed turnover 记入回测 feasibility log,phase2 报告有专门小节。 - **停牌(UNI-005)**:`suspend_d` 标记停牌日。**实测发现**:tushare 全天停牌当日**无 bar** → 已被 `missing_close` 剔除,故显式 suspended flag 与之重叠;其价值在盘中停牌(`suspend_timing`)或会给停牌日 bar 的数据源,属防御性。 - 退市 / 无数据标的(如 `000003.SZ`)同样表现为不在 panel 而被剔除。PIT 历史成分见上节。 -- `universe.min_listing_days` 已在配置中(默认 60),但仍 **未执行**(no-op,降级):新上市标的不会被剔除。显式披露(INV-007),后续接上市日期后强制。 +- **`universe.min_listing_days`(UNI-008,P2-2)**:作为**买入/选股资格**过滤。**真实路径已执行**——从 tushare `stock_basic.list_date` 富化每只票上市日,某调仓日`age < min_listing_days` 的新上市标的剔除(边界 `age == min` 放行);**缺 list_date 视为数据缺口,保留并披露**,绝不静默剔除。**demo 路径无上市日 → 仍 no-op(显式披露的降级)**,不伪造上市日。 ## ann_date 财务对齐 diff --git a/CLAUDE.md b/CLAUDE.md index b22400a..112cb48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri ## 开发约定 - **交流中文**;代码/注释/commit message 用**英文**。 -- **Git**:feature 分支 + PR。**PR #1(P0+P1)已 merge 到 `main`**;当前在 `p2-real-baseline` 分支推进 P2-1。commit 用 conventional 格式,**无 attribution**(不加 Co-Authored-By)。 +- **Git**:feature 分支 + PR。**PR #1(P0+P1)、PR #2(P2-1)已 merge 到 `main`**;当前在 `p2-execution-realism` 分支推进 P2-2。commit 用 conventional 格式,**无 attribution**(不加 Co-Authored-By)。 - **不过度设计**:按路线图 MVP 先打通一条端到端链路,再加层(architecture.html §11,Phase 0→3)。 - **secrets** 一律走外部 `.config.json`;repo `.gitignore` 已排除数据产物(`*.parquet`等)、缓存、`tmp/`(仅留架构文档)。 - 文件小而专(<800 行),immutable 优先。 @@ -74,6 +74,12 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri - 路径感知降级披露(demo/static vs tushare/index/ann_date,绝不把 demo 当真实验证) - ✅ 真数据实证(tushare,非 CI):复权除权日 raw−5.74%→qfq+0.99% / CSI300 全年 24 快照 328 名换手 / ann_date Q1 延后至 04-20 / 中性化 corr −0.617→0。详见 `BIAS_AUDIT.md`、`artifacts/reports/phase1_summary.md`。 - ✅ **Phase 2-1 真实数据可复现基准**(`p2-real-baseline` 分支,**PR #2 OPEN**):新 run mode `run-phase2-baseline` + `config/phase2_real_baseline.yaml`(上证50 `000016.SH`,2023-07~2024-06)。**复用 P0/P1 全套机器,不扩因子、不调参**。一次真实跑 ~11min(68 成分 / 25 loaded 快照 / in-window distinct 60 / 11 settled 调仓,候选 12 末日跳过),输出 `artifacts/reports/phase2_real_baseline.md`(gitignored):数据窗口 / PIT 成分摘要(loaded vs in-window) / ann_date 覆盖率(100%) / 可交易过滤命中(首命中互斥) / 每期持仓 / 换手成本 / IC(≈0.008) / 绩效(年化−17.6%,**亏损动量基准,非业绩声明**) / 全部 P2 降级。诊断只读、demo 源拒绝、token 不入报告。 -- ✅ 质量门:`pytest` **182 passed**(P0=93 / P1=75 / P2-1=14);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)+ `run-phase0`(demo)均 OK。 -- ⚠️ 剩余 P2(已显式披露):行业标签用**当前值**非 PIT、涨跌停未方向感知、`min_listing_days` no-op、日线 only、简版 IC/绩效未走 alphalens/quantstats、demo 路径非真数据。 -- 路线图下一步:财务因子组合 / 历史 PIT 行业 / 更细交易约束(architecture.html §11)。 +- ✅ **Phase 2-2 执行真实性**(`p2-execution-realism` 分支,未 PR):**拆分 selection(选谁)与 execution feasibility(能否成交)**。 + - 方向感知执行 `runtime/fills.py::simulate_fills`:涨停挡买 / 跌停挡卖 / 停牌·缺收盘双向挡;按 panel flag 实时判定,与选股 toggle 无关。 + - **现金一致 sell-then-buy**:卖在前释放现金、买在后,现金不足按比例部分成交 → **无杠杆**;被挡交易 carry forward;换手/成本只算实际成交;闲置现金按 driver 的 `cash_return` 计息(BT-007)。 + - `universe.min_listing_days` **真实路径已执行**(`stock_basic.list_date` 富化,买入资格过滤,边界 age==min 放行,缺 list_date 保留并披露);demo 无上市日 → 披露 no-op。 + - 回测 `feasibility_log()`:每调仓期 blocked buys/sells/carried/executed turnover/invested;phase2 报告新增 **Execution feasibility** 小节。 + - **保不变量**:demo 无 flag → 全可成交 → P0/P1 数字不变;无未来函数、PIT/ann_date/real-demo 分离不变。 +- ✅ 质量门:`pytest` **204 passed**(P0=94 / P1=75 / P2-1=14 / P2-2=21);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)+ `run-phase0`(demo)均 OK。 +- ⚠️ 剩余 P2(已显式披露):行业标签用**当前值**非 PIT、日线 only、简版 IC/绩效未走 alphalens/quantstats、demo 路径非真数据。 +- 路线图下一步:财务因子组合 / 历史 PIT 行业 / 分钟级 / alphalens·quantstats 接入(architecture.html §11)。 diff --git a/RUNBOOK.md b/RUNBOOK.md index c860260..d26ebb6 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -152,6 +152,36 @@ Guards (correctness / honesty): - **No secret leak.** The report echoes only non-sensitive config (window, universe, factor); the token / secret file path is never written into it. +## Phase 2-2 — execution realism (direction-aware fills + min_listing_days) + +P2-2 closes execution/tradability gaps in the backtest. No new factor, no parameter +tuning — it changes how the driver *fills* a target and how selection eligibility is +computed, and it applies to every real-path run (`run-phase0` and +`run-phase2-baseline`). + +**Selection vs execution feasibility (split):** + +- *Selection* (`universe.tradable` / `apply_tradable_filters`): missing_close / + suspended / ST / limit toggles, plus **`min_listing_days`** (UNI-008) — a + buy/selection filter that drops names younger than `min_listing_days` as of each + date. Real path enriches `list_date` from `stock_basic`; a missing list_date is a + disclosed data gap (kept, never silently dropped); demo has no listing dates → a + disclosed no-op. +- *Execution feasibility* (`runtime.fills.simulate_fills`): read off the panel flags, + independent of the selection toggles — `at_up_limit` blocks **buys**, `at_down_limit` + blocks **sells**, `suspended`/missing-close blocks **both**. + +**Cash-coherent fill model:** sells execute first (freeing cash), buys are funded from +available cash and scaled down proportionally if blocked sells starved them (the book +never sums to > 1 — no leverage). Blocked trades carry the current position forward; +turnover/cost count only executed trades; idle cash earns the driver's `cash_return` +(BT-007). The demo panel carries no flags, so every trade is feasible and P0/P1 +numbers are unchanged. + +The phase2 baseline report (`artifacts/reports/phase2_real_baseline.md`) gains an +**Execution feasibility** section: per-rebalance blocked buys / blocked sells / carried +positions / executed turnover / invested fraction, from `BacktestDriver.feasibility_log()`. + ## Quality gate ```bash diff --git a/TEST_REPORT.md b/TEST_REPORT.md index dc8963f..87cd106 100644 --- a/TEST_REPORT.md +++ b/TEST_REPORT.md @@ -1,4 +1,4 @@ -# TEST_REPORT — Phase 1 (bias-boundary) +# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 (bias-boundary → execution realism) ## Commands @@ -8,6 +8,8 @@ Run from the repo root with the project python (env `quant_mf`): /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m pytest -q /home/shaofl/Development/env_tools/envs/quant_mf/bin/python -m ruff check . /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 run-phase0 --config config/example.yaml ``` @@ -15,14 +17,14 @@ Run from the repo root with the project python (env `quant_mf`): | Gate | Command | Result | |---|---|---| -| Unit + integration | `pytest -q` | **168 passed, 0 failed** | +| Unit + integration | `pytest -q` | **204 passed, 0 failed** | | Lint | `ruff check .` | **All checks passed** | -| Config validation | `validate-config` (demo + `example_tushare.yaml`) | exit `0`, prints `OK` | +| 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 `pytest --collect-only` numbers (sum = 168). +Counts below are the actual per-file `pytest` numbers (sum = 204). -## Per-file breakdown — Phase 0 core (93) +## Per-file breakdown — Phase 0 core (94) | Test file | Tests | Area | |---|---|---| @@ -41,7 +43,7 @@ Counts below are the actual `pytest --collect-only` numbers (sum = 168). | `test_analytics_factor.py` | 5 | IC / quantile | | `test_analytics_performance.py` | 3 | performance metrics | | `test_phase0_pipeline.py` | 8 | end-to-end pipeline | -| `test_bias_audit_report.py` | 4 | bias audit doc | +| `test_bias_audit_report.py` | 5 | bias audit doc (+1: P2-2 disclosures) | ## Per-file breakdown — Phase 1 bias-boundary (75) @@ -65,38 +67,41 @@ Counts below are the actual `pytest --collect-only` numbers (sum = 168). | `test_covariates_enrich.py` | 3 | industry + market_cap enrichment | | `test_tushare_covariates.py` | 2 | stock_basic + daily_basic feed | | `test_real_path_config.py` | 3 | demo vs real-path downgrade disclosure | -| **Total (P0 + P1)** | **168** | | -## Real-data validation (manual, not in CI — TEST-002 keeps the suite network-free) +## Per-file breakdown — Phase 2-1 real-data baseline (14) + +| Test file | Tests | Feature | +|---|---|---| +| `test_phase2_baseline.py` | 14 | collectors, demo/real guard, report-field contract, no-secret-leak, settled-vs-candidate dates, loaded-vs-in-window membership | + +## Per-file breakdown — Phase 2-2 execution realism (21) -Verified directly against tushare (results recorded in `BIAS_AUDIT.md` and -`artifacts/reports/phase1_summary.md`): +| Test file | Tests | Red-line / feature | +|---|---|---| +| `test_fills.py` | 10 | direction-aware fill sim (`simulate_fills`) + panel→feasibility adapter; cash-coherent sell-then-buy, no leverage, executed-only turnover | +| `test_driver_feasibility.py` | 5 | end-to-end: down-limit carries, up-limit blocks buy, suspended no-trade, feasibility log == nav index | +| `test_min_listing_days.py` | 6 | `min_listing_days` buy-eligibility boundaries (age <, ==, >; missing list_date kept; no-op cases) | +| **Total (P0 + P1 + P2-1 + P2-2)** | **204** | | + +## Real-data validation (manual, not in CI — TEST-002 keeps the suite network-free) -- **front-adjust**: 平安银行 2024-06-14 ex-dividend — raw −5.74% vs qfq +0.99%; - momentum_20 shifts up to 6.77pp. -- **PIT index**: CSI300 2024 — 24 snapshots, 328 distinct names (28 in / 28 out); - `members(2024-06-15)` uses the 2024-06-03 snapshot; a dropped name stays in its era. -- **ann_date**: 平安银行 Q1 (end 2024-03-31, ann 2024-04-20) — as-of roe stays the - prior annual (10.24) until 04-19, switches to Q1 (3.12) on 04-22; no leak. -- **neutralization**: 12 names / 4 industries — corr(momentum, log_mcap) - −0.617 → −0.000; per-industry residual means ≈ 0. +- **P1** (front-adjust / PIT / ann_date / neutralization): see `BIAS_AUDIT.md` and + `artifacts/reports/phase1_summary.md`. +- **P2-1** baseline (SSE50, ~11 min): `artifacts/reports/phase2_real_baseline.md` — + settled-date diagnostics, ann_date coverage, tradability funnel. ## Notes - No test hits the network or reads the tushare token (TEST-002, INV-004): the whole suite runs on `DemoFeed` / fixtures / monkeypatched SDKs. -- Financial factors, the PIT index universe, tradability filters and neutralization - all require the real tushare path; on demo they raise a readable error rather than - fabricate (verified by `test_financial_pipeline.py`, `_build_universe`, the - neutralize guard). -- The demo portfolio's annualized return is intentionally extreme (demo price paths - include a 3x jump); P0/P1 do not optimize for realistic returns — pipeline - correctness (event order, costs, no-lookahead) is what is under test. -- P1 acceptance hardening (locked by tests): price-limit flags compare the RAW - (unadjusted) close to the raw `stk_limit`, enriched BEFORE front-adjust - (`test_tradability_enrich::test_limit_flag_uses_raw_close_and_survives_front_adjust`); - financials are fetched ~16 months before `start` so the prior disclosed report - carries forward (`test_pit_financials::test_asof_carries_forward_report_disclosed_before_window`, - `test_financial_pipeline::test_financial_fetch_uses_lookback_before_start`); - neutralization returns NaN on a saturated cross-section instead of fabricated ~0 - residuals (`test_neutralize::test_saturated_cross_section_returns_nan_not_zeros`). +- **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, + suspended/missing blocks both; blocked trades carry forward, turnover/cost count + only executed trades, and idle cash earns the driver's `cash_return` (BT-007). The + demo path has no flags, so every trade is feasible and P0/P1 numbers are unchanged. +- `universe.min_listing_days` is enforced on the real path (list_date from + `stock_basic`) as a buy/selection filter; a missing list_date is kept and disclosed; + the demo path stays a disclosed no-op. +- 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). diff --git a/data/clean/covariates.py b/data/clean/covariates.py index effc079..63c2387 100644 --- a/data/clean/covariates.py +++ b/data/clean/covariates.py @@ -37,3 +37,19 @@ def enrich_covariates( out["market_cap"] = mc["market_cap"].reindex(out.index) return out + + +def enrich_listing( + panel: pd.DataFrame, listing_dates: dict[str, pd.Timestamp] +) -> pd.DataFrame: + """Return a NEW panel with a per-symbol ``list_date`` column (for UNI-008). + + Broadcasts each symbol's listing date to every date. A symbol absent from + ``listing_dates`` gets ``NaT`` (a disclosed data gap, never treated as young). + Pure: never mutates the input panel. + """ + validate_panel(panel) + out = panel.copy() + symbols = out.index.get_level_values("symbol") + out["list_date"] = [listing_dates.get(str(s), pd.NaT) for s in symbols] + return out diff --git a/data/feed/tushare_covariates.py b/data/feed/tushare_covariates.py index 39472d7..2258468 100644 --- a/data/feed/tushare_covariates.py +++ b/data/feed/tushare_covariates.py @@ -59,6 +59,26 @@ def industry(self, symbols: list[str]) -> dict[str, str]: df = df[df["ts_code"].astype(str).isin(wanted)] return {str(r.ts_code): r.industry for r in df.itertuples()} + def listing_dates(self, symbols: list[str]) -> dict[str, pd.Timestamp]: + """Return {symbol: list_date} from ``stock_basic.list_date`` (for UNI-008). + + Used by the ``min_listing_days`` selection filter. A symbol absent from + ``stock_basic`` simply does not appear in the map (the caller treats an + unknown listing date as a disclosed data gap, never as a young name). + """ + pro = self._client() + df = self._call(pro.stock_basic, fields="ts_code,list_date") + if df is None or len(df) == 0: + return {} + wanted = set(map(str, symbols)) + df = df[df["ts_code"].astype(str).isin(wanted)] + out: dict[str, pd.Timestamp] = {} + for r in df.itertuples(): + out[str(r.ts_code)] = pd.to_datetime( + str(r.list_date), format="%Y%m%d", errors="coerce" + ) + return out + def market_cap(self, symbols: list[str], start: str, end: str) -> pd.DataFrame: """Return DataFrame[date, symbol, market_cap] from daily_basic.total_mv.""" pro = self._client() diff --git a/qt/phase2_baseline.py b/qt/phase2_baseline.py index ad5adf3..d5645b0 100644 --- a/qt/phase2_baseline.py +++ b/qt/phase2_baseline.py @@ -46,6 +46,7 @@ _make_logger, _maybe_enrich_covariates, _maybe_enrich_financials, + _maybe_enrich_listing, _periods_per_year, _process_factors, ) @@ -80,6 +81,8 @@ class Phase2Result: financial_coverage_by_rebalance: pd.DataFrame # tradability filter hits tradability_hits: pd.DataFrame + # execution feasibility (direction-aware fills) + feasibility_log: pd.DataFrame # rebalance + holdings (rebalance_dates are the SETTLED dates == nav_table.index) rebalance_dates: tuple[pd.Timestamp, ...] candidate_rebalance_dates: tuple[pd.Timestamp, ...] @@ -347,13 +350,14 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: factor = _build_factor(cfg) panel = _maybe_enrich_financials(cfg, panel, symbols, factor, 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) processed = _process_factors(cfg, factor_panel, panel) score_panel = _build_scores(processed, EqualWeightAlpha()) scores = _FrameScores(score_panel) constructor = TopNEqualWeight(cfg.portfolio.top_n, long_only=cfg.portfolio.long_only) - execution = SimExecution(fee_rate=cfg.cost.fee_rate, cash_return=cfg.backtest.cash_return) + execution = SimExecution(fee_rate=cfg.cost.fee_rate) driver = BacktestDriver( universe=universe, scores=scores, @@ -367,6 +371,7 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: ) candidate_dates = driver.rebalance_dates() nav_table = driver.run() + feasibility_log = driver.feasibility_log() # The diagnostics (coverage / filter hits / holdings) MUST key off the dates # that were actually held + settled — i.e. nav_table.index — NOT the candidate # rebalance dates. The driver skips a terminal rebalance with no forward @@ -429,6 +434,7 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: financial_coverage_overall=coverage_overall, financial_coverage_by_rebalance=coverage_by_rebalance, tradability_hits=hits, + feasibility_log=feasibility_log, rebalance_dates=tuple(settled_dates), candidate_rebalance_dates=tuple(candidate_dates), skipped_terminal_dates=tuple(skipped_dates), diff --git a/qt/pipeline.py b/qt/pipeline.py index 63334e8..8bce489 100644 --- a/qt/pipeline.py +++ b/qt/pipeline.py @@ -37,7 +37,7 @@ from analytics.factor import compute_ic, forward_returns, ic_summary, quantile_returns from analytics.performance import performance_summary from data.clean.adjust import front_adjust -from data.clean.covariates import enrich_covariates +from data.clean.covariates import enrich_covariates, enrich_listing from data.clean.pit_financials import asof_financials from data.clean.tradability import enrich_tradability from data.feed.base import DataFeed @@ -233,18 +233,38 @@ def _collect_downgrades(cfg: RootConfig) -> tuple[str, ...]: else: neutral = "No neutralization in this run (raw cross-sectional factor)." + if real and cfg.universe.min_listing_days > 0: + listing = ( + f"universe.min_listing_days={cfg.universe.min_listing_days} is ENFORCED " + "(real path): names younger than that as of each date are excluded from " + "selection (UNI-008); a missing list_date is a disclosed data gap (kept, " + "never silently dropped)." + ) + else: + listing = ( + f"universe.min_listing_days={cfg.universe.min_listing_days} is NOT enforced " + "on this path (no listing dates on the demo source); disclosed no-op (UNI-008)." + ) + execution = ( + "Execution feasibility is direction-aware (UNI-007 / P2-2): at-up-limit blocks " + "buys, at-down-limit blocks sells, suspended/missing-close blocks both; blocked " + "trades carry forward and turnover/cost count only executed trades (no forced " + "impossible trades; idle cash from blocked buys earns cash_return). The demo " + "panel carries no flags, so every trade is feasible there (P0/P1 unchanged)." + ) + items = [ path, membership, financials, 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).", - f"universe.min_listing_days is configured ({cfg.universe.min_listing_days}) " - "but NOT enforced (no-op); newly listed names are not excluded (INV-007).", + listing, ] return tuple(items) @@ -267,6 +287,7 @@ def run_phase0(config_path: str) -> Phase0Result: factor = _build_factor(cfg) panel = _maybe_enrich_financials(cfg, panel, symbols, factor, 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) processed = _process_factors(cfg, factor_panel, panel) @@ -361,6 +382,10 @@ def _build_universe( backtest can settle names that later left the index (no survivorship bias). """ filters = cfg.universe.filters.model_dump() + # min_listing_days is a buy/selection-eligibility filter (UNI-008); thread it + # into the shared filter dict so both static and index universes enforce it + # when a list_date column is present (real path) and no-op otherwise (demo). + filters["min_listing_days"] = int(cfg.universe.min_listing_days or 0) if cfg.universe.type == "static": symbols = list(cfg.universe.symbols) if not symbols: @@ -527,6 +552,33 @@ def _maybe_enrich_financials( return enriched +def _maybe_enrich_listing( + cfg: RootConfig, panel: pd.DataFrame, symbols: list[str], logger: logging.Logger +) -> pd.DataFrame: + """Attach a per-symbol ``list_date`` column when min_listing_days is enforced. + + Real (tushare) path only: a demo run has no listing dates, so the + ``min_listing_days`` selection filter stays a DISCLOSED no-op there rather + than fabricating ages. A missing list_date is carried as NaT (data gap; the + filter keeps such names and the report discloses the count). + """ + if int(cfg.universe.min_listing_days or 0) <= 0: + return panel + if cfg.data.source != "tushare": + return panel # demo: no listing dates -> filter is a disclosed no-op + feed = TushareCovariatesFeed( + cfg.data.external_secret_file, token_key=cfg.data.tushare_token_key + ) + listing = feed.listing_dates(symbols) + panel = enrich_listing(panel, listing) + n_known = sum(1 for v in listing.values() if pd.notna(v)) + logger.info( + "listing: list_date enriched (%d/%d known) for min_listing_days=%d", + n_known, len(symbols), cfg.universe.min_listing_days, + ) + return panel + + def _compute_factor_panel( cfg: RootConfig, panel: pd.DataFrame, @@ -607,7 +659,7 @@ def _run_backtest( ) -> pd.DataFrame: """Wire the universe + scores + constructor + execution into the driver.""" constructor = TopNEqualWeight(cfg.portfolio.top_n, long_only=cfg.portfolio.long_only) - execution = SimExecution(fee_rate=cfg.cost.fee_rate, cash_return=cfg.backtest.cash_return) + execution = SimExecution(fee_rate=cfg.cost.fee_rate) driver = BacktestDriver( universe=universe, scores=_FrameScores(scores), diff --git a/qt/reports.py b/qt/reports.py index 41ef41a..405296e 100644 --- a/qt/reports.py +++ b/qt/reports.py @@ -127,6 +127,7 @@ def write_phase0_summary(result: "Phase0Result") -> Path: "## Universe / PIT membership", "## Financial ann_date coverage", "## Tradability filter hits", + "## Execution feasibility", "## Rebalance dates", "## Holdings per period", "## Turnover & cost", @@ -202,6 +203,35 @@ def _tradability_block(hits: pd.DataFrame) -> str: return head + table +def _feasibility_block(log: pd.DataFrame) -> str: + """Render the direction-aware execution-feasibility funnel (P2-2).""" + if log is None or log.empty: + return "_(no settled rebalances)_\n" + tot_bb = int(log["blocked_buys"].sum()) + tot_bs = int(log["blocked_sells"].sum()) + tot_cc = int(log["cash_constrained_buys"].sum()) + avg_inv = float(log["invested"].mean()) + head = ( + f"_Direction-aware fills: at-up-limit blocks buys, at-down-limit blocks " + f"sells, suspended/missing blocks both; blocked trades carry forward and " + f"turnover/cost count only executed trades._\n\n" + f"- total blocked buys: **{tot_bb}** · blocked sells: **{tot_bs}** · " + f"cash-constrained buys: **{tot_cc}**\n" + f"- avg invested fraction (1 − idle cash): **{_fmt(avg_inv)}**\n\n" + ) + table = ( + "| Rebalance date | blocked_buys | blocked_sells | carried | exec_turnover | invested |\n" + "|---|---|---|---|---|---|\n" + ) + for date, r in log.iterrows(): + table += ( + f"| {_date_str(date)} | {int(r['blocked_buys'])} | {int(r['blocked_sells'])} | " + f"{int(r['carried'])} | {_fmt(float(r['executed_turnover']))} | " + f"{_fmt(float(r['invested']))} |\n" + ) + return head + table + + def _holdings_block(holdings: pd.DataFrame) -> str: """Render per-period holdings (complete; one line per rebalance date).""" if holdings is None or holdings.empty: @@ -282,6 +312,9 @@ def render_phase2_baseline(result: "Phase2Result") -> str: lines.append("\n## Tradability filter hits\n") lines.append(_tradability_block(result.tradability_hits)) + lines.append("\n## Execution feasibility\n") + lines.append(_feasibility_block(result.feasibility_log)) + lines.append("\n## Rebalance dates\n") reb = ", ".join(_date_str(d) for d in result.rebalance_dates) or "_(none)_" lines.append( @@ -401,15 +434,25 @@ def render_bias_audit() -> str: "- **涨跌停(UNI-007)**:用**未复权 raw close** 与当日 raw `up_limit`/" "`down_limit` 比较,标记 `at_up_limit`/`at_down_limit`(qfq 复权价仅用于因子/" "回测收益;flag 富化在 front_adjust **之前**完成,故比较的是同口径 raw 价)。" - "实证:`000005.SZ` 2024-02-01 触跌停。当前选股层对两个方向都剔除;**方向感知**" - "(买入只看涨停、持有跌停不强卖)属执行层,后续细化。\n" + "实证:`000005.SZ` 2024-02-01 触跌停。\n" + "- **方向感知执行(UNI-007 / P2-2,已实现)**:选股层(`apply_tradable_filters`)" + "与执行层(`runtime.fills.simulate_fills`)**拆分**。执行可行性按 panel flag 实时" + "判定、与选股 toggle 无关:`at_up_limit` 挡**买入/加仓**,`at_down_limit` 挡" + "**卖出/减仓**,`suspended`/缺收盘价双向挡。被挡的交易 **carry forward**(绝不" + "强行成交不可能的单),现金一致 sell-then-buy(卖在前释放现金、买在后,现金不足" + "按比例部分成交,**无杠杆**),换手/成本只算**实际成交**,闲置现金按 `cash_return` " + "计息。demo 无 flag → 全可成交 → P0/P1 数字不变。每个调仓期的 blocked buys/sells/" + "carried/executed turnover 记入回测 feasibility log,phase2 报告有专门小节。\n" "- **停牌(UNI-005)**:`suspend_d` 标记停牌日。**实测发现**:tushare 全天停牌" "当日**无 bar** → 已被 `missing_close` 剔除,故显式 suspended flag 与之重叠;" "其价值在盘中停牌(`suspend_timing`)或会给停牌日 bar 的数据源,属防御性。\n" "- 退市 / 无数据标的(如 `000003.SZ`)同样表现为不在 panel 而被剔除。PIT 历史" "成分见上节。\n" - "- `universe.min_listing_days` 已在配置中(默认 60),但仍 **未执行**(no-op," - "降级):新上市标的不会被剔除。显式披露(INV-007),后续接上市日期后强制。\n\n" + "- **`universe.min_listing_days`(UNI-008,P2-2)**:作为**买入/选股资格**过滤。" + "**真实路径已执行**——从 tushare `stock_basic.list_date` 富化每只票上市日,某调仓日" + "`age < min_listing_days` 的新上市标的剔除(边界 `age == min` 放行);**缺 list_date " + "视为数据缺口,保留并披露**,绝不静默剔除。**demo 路径无上市日 → 仍 no-op(显式披露" + "的降级)**,不伪造上市日。\n\n" "## ann_date 财务对齐\n\n" "- 状态: **已实现(P1)**。\n" "- 财务因子(`roe` / `netprofit_yoy`)经 `data.clean.pit_financials.asof_financials` " diff --git a/runtime/backtest/driver.py b/runtime/backtest/driver.py index 357b4a4..47213be 100644 --- a/runtime/backtest/driver.py +++ b/runtime/backtest/driver.py @@ -29,6 +29,7 @@ import pandas as pd from runtime.execution import BacktestExecution +from runtime.fills import feasibility_from_cross class ScoresSource(Protocol): @@ -83,6 +84,7 @@ def __init__( self._fee_rate = float(fee_rate) self._initial_nav = float(initial_nav) self._cash_return = float(cash_return) + self._feasibility_log: list[dict] = [] # -- calendar --------------------------------------------------------- # def _calendar(self) -> pd.DatetimeIndex: @@ -142,6 +144,7 @@ def run(self) -> pd.DataFrame: cal = self._calendar() rows: list[dict] = [] nav = self._initial_nav + self._feasibility_log = [] # re-entrant: fresh per run for i, date in enumerate(reb): end = reb[i + 1] if i + 1 < len(reb) else cal[-1] # A rebalance on the final trading day has no forward holding window @@ -167,27 +170,86 @@ def run(self) -> pd.DataFrame: def _step( self, date: pd.Timestamp, end: pd.Timestamp ) -> tuple[float, float, float, float]: - """One rebalance: universe -> scores -> build -> rebalance -> settle. + """One rebalance: universe -> scores -> build -> feasible fill -> settle. + + Selection (``universe.tradable``) decides what the strategy WANTS; the + execution-feasibility flags (limits / suspension / missing, read from the + date's cross-section) decide what it can actually trade. Blocked trades + carry forward and turnover/cost count only executed trades (no forced + impossible trades). An empty tradable universe still routes through the + fill so held names that turned untradeable are carried, not force-sold. Returns ``(turnover, cost, gross_return, net_return)`` for the period held from ``date`` (exclusive) to ``end`` (inclusive). """ - tradable = list(self._universe.tradable(date, self._prices)) - if not tradable: - # Cash book: no trade, no turnover/cost, earns cash_return (BT-007). - # The driver owns cash semantics so cash_return is honoured even when - # the execution adapter's own cash_return differs. - self._execution.rebalance_to(pd.Series(dtype=float), date) - cost = self._execution.last_cost - gross = self._cash_return - return (self._execution.last_turnover, cost, gross, gross - cost) - scores = self._scores.get(date, tradable) current = self._execution.positions() - target = self._constructor.build(scores, current) - self._execution.rebalance_to(target, date) - holding = self._holding_returns(date, end, list(target.index)) - net = self._execution.settle(holding) + tradable = list(self._universe.tradable(date, self._prices)) + if tradable: + scores = self._scores.get(date, tradable) + target = self._constructor.build(scores, current) + else: + target = pd.Series(dtype=float) # nothing to hold -> exit what we can + + symbols = sorted(set(current.index) | set(target.index)) + can_buy, can_sell = self._feasibility(date, symbols) + self._execution.rebalance_to(target, date, can_buy=can_buy, can_sell=can_sell) + + achieved = self._execution.positions() + holding = self._holding_returns(date, end, list(achieved.index)) + invested_net = self._execution.settle(holding) + # The driver owns cash semantics (BT-007): the uninvested fraction — + # nonzero when blocked/cash-starved buys left cash idle — earns the + # driver's cash_return, even if the execution adapter's differs. + idle = 1.0 - float(achieved.sum()) + net = invested_net + idle * self._cash_return turnover = self._execution.last_turnover cost = self._execution.last_cost gross = net + cost + self._record_feasibility(date, achieved) return (turnover, cost, gross, net) + + # -- execution feasibility ------------------------------------------- # + def _feasibility( + self, date: pd.Timestamp, symbols: list[str] + ) -> tuple[dict, dict]: + """Per-symbol (can_buy, can_sell) from the date's cross-section.""" + if not symbols: + return {}, {} + try: + cross = self._prices.xs(pd.Timestamp(date).normalize(), level="date") + except KeyError: + cross = self._prices.iloc[0:0] + return feasibility_from_cross(cross, symbols) + + def _record_feasibility(self, date: pd.Timestamp, achieved: pd.Series) -> None: + """Append the most recent fill's feasibility diagnostics to the log.""" + fill = getattr(self._execution, "last_fill", None) + if fill is None: + return + self._feasibility_log.append( + { + "date": date, + "blocked_buys": len(fill.blocked_buys), + "blocked_sells": len(fill.blocked_sells), + "cash_constrained_buys": len(fill.cash_constrained_buys), + "carried": len(fill.carried), + "executed_turnover": float(fill.executed_turnover), + "invested": float(achieved.sum()), + } + ) + + def feasibility_log(self) -> pd.DataFrame: + """Per-settled-rebalance execution-feasibility diagnostics (date-indexed). + + Columns: ``blocked_buys``, ``blocked_sells``, ``cash_constrained_buys``, + ``carried`` (counts), ``executed_turnover``, ``invested`` (sum of achieved + weights; ``1 - invested`` is idle cash). Aligns 1:1 with the NAV table's + settled rebalance dates. + """ + cols = [ + "blocked_buys", "blocked_sells", "cash_constrained_buys", + "carried", "executed_turnover", "invested", + ] + if not self._feasibility_log: + return pd.DataFrame(columns=cols, index=pd.Index([], name="date")) + return pd.DataFrame(self._feasibility_log).set_index("date") diff --git a/runtime/backtest/sim_execution.py b/runtime/backtest/sim_execution.py index 835d73f..0bf382d 100644 --- a/runtime/backtest/sim_execution.py +++ b/runtime/backtest/sim_execution.py @@ -20,6 +20,7 @@ import pandas as pd from runtime.execution import BacktestExecution +from runtime.fills import FillResult, simulate_fills class SimExecution(BacktestExecution): @@ -33,25 +34,24 @@ class SimExecution(BacktestExecution): replaces the book. """ - def __init__(self, fee_rate: float = 0.0, cash_return: float = 0.0) -> None: + def __init__(self, fee_rate: float = 0.0) -> None: + # NOTE (P2-2): idle-cash return is owned by the backtest driver (BT-007), + # not this adapter, so SimExecution no longer takes a cash_return — settle() + # returns the invested book's return only and the driver adds idle*cash. if fee_rate < 0: raise ValueError(f"fee_rate must be >= 0, got {fee_rate!r}") self._fee_rate = float(fee_rate) - self._cash_return = float(cash_return) self._positions: pd.Series = pd.Series(dtype=float) self._last_turnover: float = 0.0 self._last_cost: float = 0.0 self._last_return: float = 0.0 + self._last_fill: FillResult | None = None # -- properties ------------------------------------------------------- # @property def fee_rate(self) -> float: return self._fee_rate - @property - def cash_return(self) -> float: - return self._cash_return - @property def last_turnover(self) -> float: """L1 turnover recorded by the most recent ``rebalance_to``.""" @@ -62,28 +62,38 @@ def last_cost(self) -> float: """Trading cost (turnover * fee_rate) of the most recent rebalance.""" return self._last_cost + @property + def last_fill(self) -> FillResult | None: + """Feasibility diagnostics of the most recent rebalance (or None).""" + return self._last_fill + # -- Execution port --------------------------------------------------- # - def rebalance_to(self, target_weights: pd.Series, date: pd.Timestamp) -> None: + def rebalance_to( + self, + target_weights: pd.Series, + date: pd.Timestamp, + *, + can_buy=None, + can_sell=None, + ) -> None: """Move the book toward ``target_weights`` as of the close of ``date``. - Computes full-L1 turnover against the current book over the union of - symbols, stores the resulting cost, and replaces the current positions - with a clean copy of the target. Does not mutate the input. The new book - is held from the next trading day (fixed event order). + Simulates only the FEASIBLE fills via :func:`runtime.fills.simulate_fills` + (cash-coherent sell-then-buy): blocked trades carry forward and turnover/ + cost count only what actually executed. With ``can_buy``/``can_sell`` both + ``None`` every trade is feasible, so the achieved book equals the target + and the L1 turnover is identical to a naive rebalance (offline/demo path + unchanged). Does not mutate the input; the new book is held from the next + trading day (fixed event order). """ target = self._clean_weights(target_weights) - current = self._positions - union = current.index.union(target.index) - aligned_target = target.reindex(union, fill_value=0.0) - aligned_current = current.reindex(union, fill_value=0.0) - turnover = float((aligned_target - aligned_current).abs().sum()) - self._last_turnover = turnover - self._last_cost = turnover * self._fee_rate - # Settle to the (gross) cost-only return for this rebalance until the - # holding period is settled; keeps last_return defined right after a - # rebalance with no holding info yet. + fill = simulate_fills(self._positions, target, can_buy, can_sell) + self._last_fill = fill + self._last_turnover = fill.executed_turnover + self._last_cost = fill.executed_turnover * self._fee_rate + # last_return is the cost-only return until the holding period settles. self._last_return = -self._last_cost - self._positions = target.copy() + self._positions = fill.achieved.copy() def positions(self) -> pd.Series: """Return current symbol-indexed weights (after the last rebalance).""" @@ -103,13 +113,16 @@ def settle(self, holding_returns: pd.Series | None) -> float: the gross return is ``cash_return``. Returns: - Net return = gross portfolio return - cost of forming this book. - Symbols in the book but missing from ``holding_returns`` contribute - zero (treated as flat). Stored as ``last_return``. + Net return of the INVESTED book = sum(book * returns) - cost. Symbols + in the book but missing from ``holding_returns`` contribute zero (flat), + and an empty book contributes zero invested return. The idle-cash + return on the uninvested fraction ``1 - sum(book)`` is owned by the + backtest driver (which honours its own ``cash_return``, BT-007), not by + this adapter. Stored as ``last_return``. """ book = self._positions if book.empty: - gross = self._cash_return + gross = 0.0 else: if holding_returns is None: rets = pd.Series(dtype=float) diff --git a/runtime/execution.py b/runtime/execution.py index b17c308..bb23d4e 100644 --- a/runtime/execution.py +++ b/runtime/execution.py @@ -17,7 +17,14 @@ class Execution(ABC): """Abstract execution port (backtest sim or live broker).""" @abstractmethod - def rebalance_to(self, target_weights: pd.Series, date: pd.Timestamp) -> None: + def rebalance_to( + self, + target_weights: pd.Series, + date: pd.Timestamp, + *, + can_buy=None, + can_sell=None, + ) -> None: """Move the book toward ``target_weights`` as of ``date``. Args: @@ -25,6 +32,12 @@ def rebalance_to(self, target_weights: pd.Series, date: pd.Timestamp) -> None: date: the rebalance date. Per the fixed event order, this is the close of ``date``; the new position is held from the next trading day. + can_buy / can_sell: optional per-symbol execution-feasibility maps + (mapping/set/Series). When supplied, a backtest adapter simulates + only the feasible fills (blocked trades carry forward) instead of + assuming every trade executes; ``None`` -> all feasible (the + offline/demo path is unchanged). A live adapter may use them as + pre-trade checks or ignore them. A backtest adapter records turnover and trading cost here; a live adapter would emit orders. Returns None. diff --git a/runtime/fills.py b/runtime/fills.py new file mode 100644 index 0000000..3e48ede --- /dev/null +++ b/runtime/fills.py @@ -0,0 +1,189 @@ +"""Execution-feasibility fill simulation (P2-2). + +Turns a DESIRED target portfolio into the ACHIEVED book given per-symbol buy/sell +feasibility, simulating what a broker would actually fill. This is the seam where +"selection" (what the strategy wants) meets "execution feasibility" (what the +market lets you trade): limits, suspension, and missing prices block trades, and +the backtest must NOT silently pretend an impossible trade happened. + +Cash-coherent sell-then-buy model (no leverage): + + 1. ``cash = 1 - sum(current)`` (uninvested fraction). + 2. SELLS first: for each name the target reduces, if it can be sold, execute the + reduction and add the proceeds to cash; otherwise carry the position forward + (a blocked sell — a forced hold). + 3. BUYS next: the desired increases for buyable names are funded from available + cash. If blocked sells starved the cash, the buys are scaled DOWN + proportionally so the book never sums to more than 1 (no leverage). A name + that cannot be bought is skipped (a blocked buy). + 4. Turnover counts only the trades actually executed. + +Feasibility is a market reality, not a config toggle: ``can_buy`` / ``can_sell`` +come from the panel flags (at-up-limit / at-down-limit / suspended / missing +close). A symbol absent from the maps defaults to feasible, so the offline demo +path (no flags) reduces to an exact ``achieved == target`` rebalance — P0/P1 +behaviour is unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import pandas as pd + +_TOL = 1e-9 + + +@dataclass(frozen=True) +class FillResult: + """Outcome of one simulated rebalance (immutable).""" + + achieved: pd.Series + executed_turnover: float + blocked_buys: list[str] = field(default_factory=list) + blocked_sells: list[str] = field(default_factory=list) + cash_constrained_buys: list[str] = field(default_factory=list) + carried: list[str] = field(default_factory=list) + + @property + def cash_constrained(self) -> bool: + """True if any buyable name was scaled down for lack of cash.""" + return bool(self.cash_constrained_buys) + + +def _as_float_map(weights) -> dict[str, float]: + """Coerce a Series/dict of weights to a clean ``{symbol: float}`` map.""" + if weights is None: + return {} + s = pd.Series(weights, dtype=float).dropna() + return {str(k): float(v) for k, v in s.items()} + + +def _feasible(flag_map, symbol: str) -> bool: + """Resolve a per-symbol bool flag; absent symbol -> feasible (True).""" + if flag_map is None: + return True + if isinstance(flag_map, (set, frozenset)): + return symbol in flag_map + try: + return bool(flag_map.get(symbol, True)) + except AttributeError: # pandas Series or mapping-like without .get semantics + return bool(flag_map[symbol]) if symbol in flag_map else True + + +def feasibility_from_cross(cross: pd.DataFrame, symbols) -> tuple[dict, dict]: + """Derive per-symbol ``(can_buy, can_sell)`` from one date's cross-section. + + Feasibility is a market reality read off the panel flags, independent of the + selection-filter config toggles: + + * a symbol with no bar that day (absent, or NaN ``close``) -> cannot trade; + * ``suspended`` -> cannot trade either direction; + * ``at_up_limit`` -> cannot BUY (price pinned at the ceiling); + * ``at_down_limit`` -> cannot SELL (price pinned at the floor). + + Missing flag columns (e.g. the offline demo panel) default to not-flagged, so + every present, non-NaN name is fully tradable and the fill reduces to an exact + rebalance. Pure: never mutates ``cross``. + """ + can_buy: dict[str, bool] = {} + can_sell: dict[str, bool] = {} + for sym in symbols: + s = str(sym) + if s not in cross.index: + can_buy[s] = False + can_sell[s] = False + continue + row = cross.loc[s] + hard = bool(pd.isna(row.get("close", float("nan")))) or bool( + row.get("suspended", False) + ) + can_buy[s] = not (hard or bool(row.get("at_up_limit", False))) + can_sell[s] = not (hard or bool(row.get("at_down_limit", False))) + return can_buy, can_sell + + +def simulate_fills( + current, + target, + can_buy=None, + can_sell=None, + *, + tol: float = _TOL, +) -> FillResult: + """Simulate feasible fills moving ``current`` toward ``target``. + + Args: + current: symbol-indexed weights currently held (may sum to < 1; the rest + is cash). Empty == an all-cash book. + target: symbol-indexed desired weights (long-only; sums to ~1). + can_buy: per-symbol buy feasibility (mapping/set/Series). Absent -> True. + can_sell: per-symbol sell feasibility. Absent -> True. + tol: numerical tolerance for "no change". + + Returns: + A :class:`FillResult` with the achieved book, executed turnover, and the + blocked-buy / blocked-sell / cash-constrained / carried diagnostics. + """ + cur = _as_float_map(current) + tgt = _as_float_map(target) + symbols = sorted(set(cur) | set(tgt)) + + achieved = {s: cur.get(s, 0.0) for s in symbols} + cash = 1.0 - sum(cur.values()) + blocked_sells: list[str] = [] + blocked_buys: list[str] = [] + cash_constrained_buys: list[str] = [] + + # 1) SELLS (target < current): feasible reductions free cash; blocked = hold. + for s in symbols: + delta = tgt.get(s, 0.0) - cur.get(s, 0.0) + if delta < -tol: # want to reduce / exit + if _feasible(can_sell, s): + achieved[s] = tgt.get(s, 0.0) + cash += -delta # proceeds of the executed sell + else: + blocked_sells.append(s) # carried at cur[s] + + # 2) BUYS (target > current): fund from cash, scale down if starved. + desired_buys = { + s: tgt[s] - cur.get(s, 0.0) + for s in symbols + if tgt.get(s, 0.0) - cur.get(s, 0.0) > tol + } + buyable = {s: d for s, d in desired_buys.items() if _feasible(can_buy, s)} + blocked_buys = [s for s in desired_buys if not _feasible(can_buy, s)] + total_buy = sum(buyable.values()) + scale = 1.0 + if total_buy > tol and cash < total_buy - tol: + scale = max(0.0, cash) / total_buy + for s, d in buyable.items(): + exec_buy = d * scale + if exec_buy > tol: + achieved[s] = cur.get(s, 0.0) + exec_buy + cash -= exec_buy + # A buyable name scaled by a short cash budget (incl. fully starved -> 0) + # is cash-constrained: this records the REASON (cash shortage); a starved + # name also appears in `carried` below, which records the OUTCOME. The two + # answer different questions, so the (intentional) overlap is fine. + if scale < 1.0 - tol: + cash_constrained_buys.append(s) + + achieved = {s: w for s, w in achieved.items() if w > tol} + carried = [ + s for s in symbols if abs(achieved.get(s, 0.0) - tgt.get(s, 0.0)) > tol + ] + executed_turnover = sum( + abs(achieved.get(s, 0.0) - cur.get(s, 0.0)) for s in symbols + ) + + book = pd.Series(achieved, dtype=float) + book.index.name = "symbol" + return FillResult( + achieved=book.sort_index(), + executed_turnover=float(executed_turnover), + blocked_buys=sorted(blocked_buys), + blocked_sells=sorted(blocked_sells), + cash_constrained_buys=sorted(cash_constrained_buys), + carried=sorted(carried), + ) diff --git a/tests/test_bias_audit_report.py b/tests/test_bias_audit_report.py index 9128956..b914357 100644 --- a/tests/test_bias_audit_report.py +++ b/tests/test_bias_audit_report.py @@ -45,13 +45,22 @@ def test_bias_audit_records_known_phase0_limitations(tmp_path): assert "analytics" in text -def test_bias_audit_discloses_min_listing_days_noop(): - """min_listing_days is disclosed as a configured-but-unenforced no-op (LOW).""" +def test_bias_audit_discloses_min_listing_days_enforced_real_noop_demo(): + """min_listing_days is enforced on the real path; the demo no-op is disclosed (P2-2).""" text = render_bias_audit() assert "min_listing_days" in text - # Explicitly flagged as a no-op / downgrade, not silently ignored (INV-007). - assert "no-op" in text or "no op" in text.lower() - assert "降级" in text + # Real path enforces it via list_date; demo stays a disclosed no-op. + assert "list_date" in text + assert "已执行" in text # real path + assert "no-op" in text and "降级" in text # demo fallback, still disclosed + + +def test_bias_audit_discloses_direction_aware_execution(): + """Direction-aware execution feasibility (limits/suspension) is disclosed (P2-2).""" + text = render_bias_audit() + assert "方向感知" in text + assert "at_up_limit" in text and "at_down_limit" in text + assert "carry forward" in text or "carry" in text def test_bias_audit_discloses_missing_settlement_price_convention(): diff --git a/tests/test_driver_feasibility.py b/tests/test_driver_feasibility.py new file mode 100644 index 0000000..8071886 --- /dev/null +++ b/tests/test_driver_feasibility.py @@ -0,0 +1,118 @@ +"""End-to-end execution-feasibility through the BacktestDriver (P2-2). + +These drive a real :class:`BacktestDriver` over a tiny flagged panel and assert the +realism contract: a name at the down-limit cannot be sold (carried forward), a name +at the up-limit cannot be bought, suspended names cannot trade either way, and +turnover/cost count only the trades that actually executed — no forced impossible +trades. The per-rebalance feasibility log records the blocks. +""" + +from __future__ import annotations + +import pandas as pd + +from portfolio.construct import TopNEqualWeight +from runtime.backtest.driver import BacktestDriver +from runtime.backtest.sim_execution import SimExecution + +# Calendar spans Jan..early-Mar 2024 so the settled rebalances are Jan-end and +# Feb-end (the final March date is the terminal skip, BT-003). +_DATES = pd.bdate_range("2024-01-01", "2024-03-05") +_JAN, _FEB = pd.Timestamp("2024-01-31"), pd.Timestamp("2024-02-29") + + +class _Universe: + def members(self, date): + return ["A", "B"] + + def tradable(self, date, panel): + return ["A", "B"] + + +class _Scores: + """A is top in January, B is top in February (forces an A->B switch).""" + + def get(self, date, symbols): + d = pd.Timestamp(date).normalize() + pref = "A" if d <= _JAN else "B" + return pd.Series({s: (2.0 if s == pref else 1.0) for s in symbols}) + + +def _panel(flags: dict | None = None) -> pd.DataFrame: + rows = [] + for d in _DATES: + for s in ("A", "B"): + row = { + "date": d, "symbol": s, "close": 10.0, + "suspended": False, "at_up_limit": False, "at_down_limit": False, + } + if flags and (d, s) in flags: + row.update(flags[(d, s)]) + rows.append(row) + return pd.DataFrame(rows).set_index(["date", "symbol"]).sort_index() + + +def _driver(panel, fee_rate=0.001): + return BacktestDriver( + universe=_Universe(), + scores=_Scores(), + constructor=TopNEqualWeight(1), + execution=SimExecution(fee_rate=fee_rate), + prices=panel, + rebalance="monthly", + fee_rate=fee_rate, + initial_nav=1.0, + ) + + +def test_driver_blocked_sell_at_down_limit_carries_position(): + # On Feb-end A is at the down-limit -> the A->B switch can't sell A. + panel = _panel({(_FEB, "A"): {"at_down_limit": True}}) + driver = _driver(panel) + driver.run() + log = driver.feasibility_log() + feb = log.loc[_FEB] + assert feb["blocked_sells"] == 1 # A could not be sold + assert feb["executed_turnover"] == 0.0 # nothing executed (no cash for B) + assert feb["invested"] == 1.0 # A carried at full weight + + +def test_blocked_buy_at_up_limit_leaves_cash(): + # On Jan-end A is at the up-limit -> the initial buy of A is blocked. + panel = _panel({(_JAN, "A"): {"at_up_limit": True}}) + driver = _driver(panel) + driver.run() + log = driver.feasibility_log() + jan = log.loc[_JAN] + assert jan["blocked_buys"] == 1 + assert jan["invested"] == 0.0 # nothing bought -> all cash + assert jan["executed_turnover"] == 0.0 + + +def test_suspended_blocks_trading_both_ways(): + # A held from January; on Feb-end A is suspended -> cannot exit A, cannot + # enter B. Book is carried unchanged. + panel = _panel({(_FEB, "A"): {"suspended": True}}) + driver = _driver(panel) + driver.run() + feb = driver.feasibility_log().loc[_FEB] + assert feb["blocked_sells"] == 1 + assert feb["invested"] == 1.0 + assert feb["executed_turnover"] == 0.0 + + +def test_no_flags_executes_full_switch(): + # Sanity: with no blocks the A->B switch fully executes (turnover 2.0). + driver = _driver(_panel()) + driver.run() + feb = driver.feasibility_log().loc[_FEB] + assert feb["blocked_sells"] == 0 and feb["blocked_buys"] == 0 + assert feb["executed_turnover"] == 2.0 # sold A (1) + bought B (1) + assert feb["invested"] == 1.0 + + +def test_feasibility_log_aligns_with_nav_index(): + driver = _driver(_panel()) + nav = driver.run() + log = driver.feasibility_log() + assert list(log.index) == list(nav.index) # settled dates only, 1:1 diff --git a/tests/test_fills.py b/tests/test_fills.py new file mode 100644 index 0000000..9393b72 --- /dev/null +++ b/tests/test_fills.py @@ -0,0 +1,140 @@ +"""Execution-feasibility fill simulation (P2-2 core, pure + network-free). + +``simulate_fills`` turns a DESIRED target into an ACHIEVED book given per-symbol +buy/sell feasibility, using a cash-coherent sell-then-buy model: + + * sells execute first (feasible ones), freeing cash; + * buys are funded from available cash, scaled down proportionally if blocked + sells starved them (no leverage — the book never sums to > 1); + * blocked trades carry the current position forward; + * turnover/cost count only the trades actually executed. + +These tests pin every branch the backtest driver relies on. +""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from runtime.fills import feasibility_from_cross, simulate_fills + + +def _s(d: dict) -> pd.Series: + return pd.Series(d, dtype=float) + + +def _cross(rows: dict) -> pd.DataFrame: + """Build a symbol-indexed cross-section from {symbol: {col: val}}.""" + frame = pd.DataFrame.from_dict(rows, orient="index") + frame.index.name = "symbol" + return frame + + +def test_feasibility_from_cross_directional_flags(): + cross = _cross( + { + "OK": {"close": 10.0, "suspended": False, "at_up_limit": False, "at_down_limit": False}, + "UP": {"close": 10.0, "suspended": False, "at_up_limit": True, "at_down_limit": False}, + "DN": {"close": 10.0, "suspended": False, "at_up_limit": False, "at_down_limit": True}, + "SUS": {"close": 10.0, "suspended": True, "at_up_limit": False, "at_down_limit": False}, + "NAN": {"close": float("nan"), "suspended": False, "at_up_limit": False, "at_down_limit": False}, + } + ) + can_buy, can_sell = feasibility_from_cross(cross, ["OK", "UP", "DN", "SUS", "NAN", "ABSENT"]) + assert can_buy["OK"] and can_sell["OK"] + assert not can_buy["UP"] and can_sell["UP"] # up-limit: no buy, can sell + assert can_buy["DN"] and not can_sell["DN"] # down-limit: can buy, no sell + assert not can_buy["SUS"] and not can_sell["SUS"] # suspended: neither + assert not can_buy["NAN"] and not can_sell["NAN"] # no price: neither + assert not can_buy["ABSENT"] and not can_sell["ABSENT"] # no bar: neither + + +def test_feasibility_from_cross_missing_flag_columns_default_feasible(): + cross = _cross({"A": {"close": 10.0}, "B": {"close": 10.0}}) + can_buy, can_sell = feasibility_from_cross(cross, ["A", "B"]) + assert all(can_buy.values()) and all(can_sell.values()) + + +def test_all_feasible_achieves_target_exactly(): + # demo-equivalence: every trade feasible -> achieved == target, no leverage. + current = _s({"A": 0.5, "B": 0.5}) + target = _s({"C": 0.5, "D": 0.5}) + res = simulate_fills(current, target) + assert res.achieved.reindex(["C", "D"]).tolist() == [0.5, 0.5] + assert "A" not in res.achieved.index and "B" not in res.achieved.index + assert res.executed_turnover == pytest.approx(2.0) # sold 1.0, bought 1.0 + assert not res.blocked_buys and not res.blocked_sells + + +def test_first_period_from_cash_buys_full_target(): + res = simulate_fills(_s({}), _s({"A": 0.5, "B": 0.5})) + assert res.achieved.sum() == pytest.approx(1.0) + assert res.executed_turnover == pytest.approx(1.0) + + +def test_blocked_buy_at_up_limit_is_not_added(): + # want to buy C but C is at up-limit (can't buy) -> C not added; cash held. + current = _s({}) + target = _s({"C": 0.5, "D": 0.5}) + res = simulate_fills(current, target, can_buy={"C": False}) + assert "C" not in res.achieved.index + assert res.achieved.get("D", 0.0) == pytest.approx(0.5) + assert res.blocked_buys == ["C"] + assert res.achieved.sum() == pytest.approx(0.5) # rest is cash, no leverage + + +def test_blocked_sell_at_down_limit_carries_position(): + # want to exit A but A is at down-limit (can't sell) -> A carried forward. + current = _s({"A": 0.5, "B": 0.5}) + target = _s({"B": 1.0}) # exit A, double B + res = simulate_fills(current, target, can_sell={"A": False}) + assert res.achieved.get("A", 0.0) == pytest.approx(0.5) # forced hold + assert res.blocked_sells == ["A"] + assert "A" in res.carried + # no leverage: A's blocked sell freed no cash, so B can't grow past 0.5. + assert res.achieved.sum() == pytest.approx(1.0) + assert res.achieved.get("B", 0.0) == pytest.approx(0.5) + assert res.cash_constrained is True + + +def test_suspended_blocks_both_directions(): + current = _s({"A": 0.5, "B": 0.5}) + target = _s({"A": 1.0}) # want to increase A, exit B + res = simulate_fills( + current, target, can_buy={"A": False}, can_sell={"B": False} + ) + # A can't be bought (stays 0.5), B can't be sold (stays 0.5). + assert res.achieved.get("A", 0.0) == pytest.approx(0.5) + assert res.achieved.get("B", 0.0) == pytest.approx(0.5) + assert "A" in res.blocked_buys and "B" in res.blocked_sells + + +def test_executed_turnover_counts_only_executed_trades(): + # exit A (blocked), buy C (feasible but cash-starved by A's blocked sell). + current = _s({"A": 1.0}) + target = _s({"C": 1.0}) + res = simulate_fills(current, target, can_sell={"A": False}) + # A can't be sold -> no cash -> C can't be bought at all. + assert res.achieved.get("A", 0.0) == pytest.approx(1.0) + assert res.achieved.get("C", 0.0) == pytest.approx(0.0) + assert res.executed_turnover == pytest.approx(0.0) # nothing executed + + +def test_partial_fill_when_one_of_two_sells_blocked(): + # current A,B (0.5 each); target C,D (0.5 each). A can't sell, B can. + current = _s({"A": 0.5, "B": 0.5}) + target = _s({"C": 0.5, "D": 0.5}) + res = simulate_fills(current, target, can_sell={"A": False}) + # B sold -> 0.5 cash. Buys C,D want 1.0 total but only 0.5 cash -> scaled 0.5x. + assert res.achieved.get("A", 0.0) == pytest.approx(0.5) # carried + assert res.achieved.get("C", 0.0) == pytest.approx(0.25) + assert res.achieved.get("D", 0.0) == pytest.approx(0.25) + assert res.achieved.sum() == pytest.approx(1.0) # 0.5 + 0.25 + 0.25, no leverage + assert res.cash_constrained is True + + +def test_unknown_symbol_defaults_to_feasible(): + # a symbol absent from can_buy/can_sell maps to feasible (True). + res = simulate_fills(_s({}), _s({"Z": 1.0}), can_buy={"Y": False}) + assert res.achieved.get("Z", 0.0) == pytest.approx(1.0) diff --git a/tests/test_min_listing_days.py b/tests/test_min_listing_days.py new file mode 100644 index 0000000..d8043e9 --- /dev/null +++ b/tests/test_min_listing_days.py @@ -0,0 +1,68 @@ +"""universe.min_listing_days as a buy/selection-eligibility filter (P2-2). + +Newly-listed names (age < min_listing_days as of the cross-section date) are +excluded from selection. Boundaries: age < min excluded, age == min allowed, and +a missing ``list_date`` is handled honestly — NOT silently dropped (a data gap +must not shrink the universe) — and is disclosed by the caller. +""" + +from __future__ import annotations + +import pandas as pd + +from universe.filters import apply_tradable_filters + + +def _cross_panel(list_dates: dict, date: pd.Timestamp) -> pd.DataFrame: + """One-date panel with close + per-symbol list_date (NaT where unknown).""" + rows = [] + for sym, ld in list_dates.items(): + rows.append( + {"date": date, "symbol": sym, "close": 10.0, + "list_date": pd.Timestamp(ld) if ld is not None else pd.NaT} + ) + return pd.DataFrame(rows).set_index(["date", "symbol"]).sort_index() + + +def test_age_below_min_is_excluded(): + date = pd.Timestamp("2024-03-01") + panel = _cross_panel({"YOUNG": "2024-02-20"}, date) # ~10 days old + out = apply_tradable_filters(["YOUNG"], date, panel, {"min_listing_days": 60}) + assert out == [] + + +def test_age_at_min_is_allowed(): + date = pd.Timestamp("2024-03-01") + # exactly 60 days old -> allowed (boundary is inclusive). + panel = _cross_panel({"BORDER": date - pd.Timedelta(days=60)}, date) + out = apply_tradable_filters(["BORDER"], date, panel, {"min_listing_days": 60}) + assert out == ["BORDER"] + + +def test_age_above_min_is_allowed(): + date = pd.Timestamp("2024-03-01") + panel = _cross_panel({"OLD": "2010-01-01"}, date) + out = apply_tradable_filters(["OLD"], date, panel, {"min_listing_days": 60}) + assert out == ["OLD"] + + +def test_missing_list_date_is_kept_not_silently_dropped(): + date = pd.Timestamp("2024-03-01") + panel = _cross_panel({"UNKNOWN": None}, date) # NaT list_date + out = apply_tradable_filters(["UNKNOWN"], date, panel, {"min_listing_days": 60}) + assert out == ["UNKNOWN"] # data gap must not exclude the name + + +def test_zero_or_absent_min_is_noop(): + date = pd.Timestamp("2024-03-01") + panel = _cross_panel({"YOUNG": "2024-02-29"}, date) + assert apply_tradable_filters(["YOUNG"], date, panel, {"min_listing_days": 0}) == ["YOUNG"] + assert apply_tradable_filters(["YOUNG"], date, panel, {}) == ["YOUNG"] + + +def test_no_list_date_column_is_noop(): + # panel without a list_date column at all (e.g. demo) -> filter is a no-op. + date = pd.Timestamp("2024-03-01") + idx = pd.MultiIndex.from_tuples([(date, "X")], names=["date", "symbol"]) + panel = pd.DataFrame({"close": [10.0]}, index=idx) + assert apply_tradable_filters(["X"], date, panel, {"min_listing_days": 60}) == ["X"] diff --git a/tests/test_phase2_baseline.py b/tests/test_phase2_baseline.py index 74682bb..0f71414 100644 --- a/tests/test_phase2_baseline.py +++ b/tests/test_phase2_baseline.py @@ -291,6 +291,11 @@ def _synthetic_result() -> Phase2Result: cov = pd.DataFrame( {"date": [date], "n_members": [2], "n_covered": [1], "coverage": [0.5]} ) + feas = pd.DataFrame( + {"blocked_buys": [1], "blocked_sells": [0], "cash_constrained_buys": [0], + "carried": [1], "executed_turnover": [1.0], "invested": [0.5]}, + index=pd.Index([date], name="date"), + ) qret = pd.DataFrame({1: [0.01], 2: [0.02]}, index=[date]) return Phase2Result( config=cfg, @@ -305,6 +310,7 @@ def _synthetic_result() -> Phase2Result: financial_coverage_overall=0.5, financial_coverage_by_rebalance=cov, tradability_hits=hits, + feasibility_log=feas, rebalance_dates=(date,), candidate_rebalance_dates=(date, pd.Timestamp("2024-02-29")), skipped_terminal_dates=(pd.Timestamp("2024-02-29"),), diff --git a/universe/filters.py b/universe/filters.py index 62eec76..9f6f0fe 100644 --- a/universe/filters.py +++ b/universe/filters.py @@ -36,6 +36,9 @@ def apply_tradable_filters( except KeyError: return [] # no market data for that date -> nothing tradable + min_days = int(flt.get("min_listing_days") or 0) + has_list_date = "list_date" in cross.columns + out: list[str] = [] for symbol in members: if symbol not in cross.index: @@ -51,5 +54,12 @@ def apply_tradable_filters( bool(row.get("at_up_limit", False)) or bool(row.get("at_down_limit", False)) ): continue + # min_listing_days (UNI-008): exclude names younger than min_days as of + # this date. A missing/NaT list_date is a DATA GAP, not a young name, so + # the name is kept (never silently dropped); callers disclose the gap. + if min_days > 0 and has_list_date: + ld = row.get("list_date") + if pd.notna(ld) and (target - pd.Timestamp(ld)).days < min_days: + continue out.append(symbol) return out From c50ea06d34bd5c2f88ac57511c372b635f3f6031 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Tue, 9 Jun 2026 16:43:26 +0800 Subject: [PATCH 2/2] fix(phase2): report ACHIEVED holdings (not desired target) + disclose list_date coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for the P2-2 auditable report (report/doc correctness, not strategy): 1. HIGH — Holdings per period reported the constructor's DESIRED target (reconstruct_holdings -> constructor.build), so once a real trade is blocked the report would show the wanted book, not the held one — breaking P2-2 auditability. BacktestDriver now records the ACHIEVED book per settled rebalance (holdings_log()); the phase2 report uses it. A blocked sell shows the carried name, a blocked buy is absent. reconstruct_holdings removed. Regression test drives a down-limit blocked sell and asserts the reported holdings are the carried name (achieved), never the desired target, and equal the execution book. 2. MEDIUM — the real run logged "list_date 67/68 known" but the report only stated the generic missing-list_date policy. list_date coverage (known / missing this run) is now in Phase2Result and the report, with a test. 3. Doc consistency — the holdings downgrade text, module docstring, and RUNBOOK still described holdings as a constructor.build reconstruction; updated to the driver's achieved (post-feasibility) book. RUNBOOK downgrade section updated so direction-aware limits and enforced min_listing_days are no longer listed as deferred. Gates: 205 passed, ruff clean. Real re-run (SSE50, ~11.7 min) regenerated the report from final code: ACHIEVED-holdings note, list_date 67/68 (1 disclosed gap), no stale constructor.build text; numbers unchanged (no blocks on blue-chips). --- RUNBOOK.md | 21 ++++++++--- qt/phase2_baseline.py | 63 +++++++++++++------------------- qt/reports.py | 13 ++++++- runtime/backtest/driver.py | 29 +++++++++++++++ tests/test_driver_feasibility.py | 15 ++++++++ tests/test_phase2_baseline.py | 38 +++++-------------- 6 files changed, 106 insertions(+), 73 deletions(-) diff --git a/RUNBOOK.md b/RUNBOOK.md index d26ebb6..bb5cb53 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -146,9 +146,10 @@ Guards (correctness / honesty): - **Demo source is refused.** `run-phase2-baseline` raises a readable error on `data.source != 'tushare'` — a "baseline" on offline demo data carries no PIT / ann_date / tradability meaning and must not masquerade as a real validation. -- **Holdings are reconstructed read-only** (universe → scores → `constructor.build`, - the same chain the driver runs); the reporting never sees forward returns at the - factor stage and never re-derives returns. +- **Holdings are the driver's ACHIEVED book** (`BacktestDriver.holdings_log()`, + post execution-feasibility) — the actual positions held each period, NOT the + constructor's desired target (a blocked sell shows the carried name, a blocked + buy is absent). The reporting never sees forward returns at the factor stage. - **No secret leak.** The report echoes only non-sensitive config (window, universe, factor); the token / secret file path is never written into it. @@ -203,11 +204,19 @@ Implemented in P1 (real path): - **ann_date financial alignment** — figures used only after disclosure date. - **Industry + size neutralization** — per-date OLS residual. +Resolved in P2-2 (was deferred): + +- **Direction-aware limits/suspension** — now in the execution layer (up-limit + blocks buys, down-limit blocks sells, suspended/missing blocks both; blocked + trades carry forward, executed-only turnover). No longer a crude both-direction + selection drop. +- **min_listing_days** — enforced on the real path (`stock_basic.list_date`) as a + buy/selection filter; demo stays a disclosed no-op. + 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). - **Industry tag is current** (`stock_basic`), not point-in-time — mild downgrade. -- **min_listing_days** configured but not enforced (no-op). -- **Daily bars only**; **simple IC/perf** (not alphalens/quantstats); - limit filter is not yet trade-direction-aware (P2). +- **Daily bars only**; **simple IC / performance** (numpy/pandas, not + alphalens-reloaded / quantstats). diff --git a/qt/phase2_baseline.py b/qt/phase2_baseline.py index d5645b0..e5a7596 100644 --- a/qt/phase2_baseline.py +++ b/qt/phase2_baseline.py @@ -11,9 +11,11 @@ turnover / cost · IC / quantile returns · performance · P2 downgrades. It reuses :mod:`qt.pipeline`'s step helpers verbatim (same universe / panel / -factor / neutralization / backtest), so "baseline == the real pipeline". The -extra reporting (holdings, filter hits, coverage) is **read-only**: it never -changes the strategy and never sees forward returns at the factor stage. +factor / neutralization / backtest), so "baseline == the real pipeline". +Per-period holdings are the ACHIEVED book recorded by the driver during the run +(``BacktestDriver.holdings_log()``, post execution-feasibility); the other extra +reporting (filter hits, ann_date coverage) is read-only diagnostics. Nothing here +changes the strategy or sees forward returns at the factor stage. Guard: the baseline REQUIRES ``data.source='tushare'``. Running it on the demo source is a category error (no PIT / ann_date / tradability meaning) and raises @@ -75,6 +77,9 @@ class Phase2Result: panel_symbols: int # universe / PIT membership universe_summary: dict + # min_listing_days list_date coverage (known vs disclosed data gap, this run) + list_date_known: int + list_date_total: int # ann_date financial coverage (diagnostic) financial_field: str financial_coverage_overall: float @@ -227,36 +232,6 @@ def tradability_hit_stats( return frame -def reconstruct_holdings( - scores: _FrameScores, - universe, - panel: pd.DataFrame, - constructor: TopNEqualWeight, - rebalance_dates: list[pd.Timestamp], -) -> pd.DataFrame: - """Rebuild per-period target holdings READ-ONLY (mirrors the driver's step). - - For each rebalance date: tradable members -> scores -> constructor.build, the - exact same chain :meth:`BacktestDriver._step` runs. We only read the resulting - target weights (the holdings); we never settle or see forward returns, so this - is a faithful, side-effect-free view of what the backtest held each period. - Returns a long-form frame with columns ``[date, symbol, weight, rank]``. - """ - rows: list[dict] = [] - for date in rebalance_dates: - target_date = pd.Timestamp(date).normalize() - tradable = list(universe.tradable(target_date, panel)) - if not tradable: - continue - cross_scores = scores.get(target_date, tradable) - weights = constructor.build(cross_scores) - for rank, (sym, w) in enumerate(weights.items(), start=1): - rows.append( - {"date": target_date, "symbol": str(sym), "weight": float(w), "rank": rank} - ) - return pd.DataFrame(rows, columns=["date", "symbol", "weight", "rank"]) - - def financial_coverage_at_dates( aligned_col: pd.Series, universe, @@ -309,9 +284,9 @@ def _phase2_downgrades(cfg: RootConfig, financial_field: str) -> tuple[str, ...] 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.", - "Per-period holdings are RECONSTRUCTED read-only (universe -> scores -> " - "constructor.build), the same chain the backtest driver runs; they are a " - "faithful view, not an independent recomputation of returns.", + "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.", "This is a SMALL-SCALE baseline (a small index over a short window) for " "reproducibility/plumbing validation, NOT a performance claim; numbers are " "not optimized and must not be read as a strategy result.", @@ -416,7 +391,19 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: hits = tradability_hit_stats( universe, panel, settled_dates, cfg.universe.filters.model_dump() ) - holdings = reconstruct_holdings(scores, universe, panel, constructor, settled_dates) + # ACHIEVED holdings (post-feasibility) from the driver — the actual book held, + # NOT the constructor's desired target (which differs once a trade is blocked). + holdings = driver.holdings_log() + + # list_date coverage for the min_listing_days disclosure (how many names had a + # known listing date this run vs a disclosed data gap). + if "list_date" in panel.columns: + _ld = panel["list_date"].groupby(level="symbol").first() + list_date_known = int(_ld.notna().sum()) + list_date_total = int(len(_ld)) + else: + list_date_known = 0 + list_date_total = 0 dates = panel.index.get_level_values("date") result = Phase2Result( @@ -430,6 +417,8 @@ def run_phase2_baseline(config_path: str) -> Phase2Result: universe_summary=summarize_universe( universe, cfg.universe.type, cfg.data.start, cfg.data.end ), + list_date_known=list_date_known, + list_date_total=list_date_total, financial_field=financial_field, financial_coverage_overall=coverage_overall, financial_coverage_by_rebalance=coverage_by_rebalance, diff --git a/qt/reports.py b/qt/reports.py index 405296e..6372947 100644 --- a/qt/reports.py +++ b/qt/reports.py @@ -299,6 +299,13 @@ def render_phase2_baseline(result: "Phase2Result") -> str: lines.append("\n## Universe / PIT membership\n") lines.append(_universe_block(result.universe_summary)) + if result.list_date_total: + missing = result.list_date_total - result.list_date_known + lines.append( + f"- min_listing_days `list_date` coverage (this run): " + f"**{result.list_date_known}/{result.list_date_total}** known, " + f"**{missing}** missing (kept as a disclosed data gap, never excluded)\n" + ) lines.append("\n## Financial ann_date coverage\n") lines.append( @@ -334,8 +341,10 @@ def render_phase2_baseline(result: "Phase2Result") -> str: lines.append("\n## Holdings per period\n") lines.append( - "_Holdings are listed for the settled rebalance dates only, so they match " - "the NAV/turnover table 1:1 (no phantom terminal period)._\n\n" + "_ACHIEVED holdings (the actual book held after execution feasibility), NOT " + "the constructor's desired target: a name whose sell was blocked appears " + "carried here, a name whose buy was blocked is absent. Settled dates only, " + "so they match the NAV/turnover table 1:1._\n\n" ) lines.append(_holdings_block(result.holdings)) diff --git a/runtime/backtest/driver.py b/runtime/backtest/driver.py index 47213be..c8d8967 100644 --- a/runtime/backtest/driver.py +++ b/runtime/backtest/driver.py @@ -85,6 +85,7 @@ def __init__( self._initial_nav = float(initial_nav) self._cash_return = float(cash_return) self._feasibility_log: list[dict] = [] + self._holdings_log: list[dict] = [] # -- calendar --------------------------------------------------------- # def _calendar(self) -> pd.DatetimeIndex: @@ -145,6 +146,7 @@ def run(self) -> pd.DataFrame: rows: list[dict] = [] nav = self._initial_nav self._feasibility_log = [] # re-entrant: fresh per run + self._holdings_log = [] for i, date in enumerate(reb): end = reb[i + 1] if i + 1 < len(reb) else cal[-1] # A rebalance on the final trading day has no forward holding window @@ -206,6 +208,7 @@ def _step( cost = self._execution.last_cost gross = net + cost self._record_feasibility(date, achieved) + self._record_holdings(date, achieved) return (turnover, cost, gross, net) # -- execution feasibility ------------------------------------------- # @@ -253,3 +256,29 @@ def feasibility_log(self) -> pd.DataFrame: if not self._feasibility_log: return pd.DataFrame(columns=cols, index=pd.Index([], name="date")) return pd.DataFrame(self._feasibility_log).set_index("date") + + def _record_holdings(self, date: pd.Timestamp, achieved: pd.Series) -> None: + """Record the ACHIEVED book (post-feasibility) held for this period.""" + for sym, weight in achieved.items(): + self._holdings_log.append( + {"date": date, "symbol": str(sym), "weight": float(weight)} + ) + + def holdings_log(self) -> pd.DataFrame: + """Per-settled-rebalance ACHIEVED holdings (long-form date,symbol,weight,rank). + + These are the ACTUAL positions held after execution feasibility — a name + whose sell was blocked appears here carried at its old weight, and a name + whose buy was blocked is absent. This is the auditable book, NOT the + constructor's desired target (which can differ once a trade is blocked). + Ranked by weight desc then symbol for determinism; aligns with the NAV / + feasibility log's settled dates. + """ + cols = ["date", "symbol", "weight", "rank"] + if not self._holdings_log: + return pd.DataFrame(columns=cols) + df = pd.DataFrame(self._holdings_log).sort_values( + ["date", "weight", "symbol"], ascending=[True, False, True] + ) + df["rank"] = df.groupby("date").cumcount() + 1 + return df.reset_index(drop=True)[cols] diff --git a/tests/test_driver_feasibility.py b/tests/test_driver_feasibility.py index 8071886..9232346 100644 --- a/tests/test_driver_feasibility.py +++ b/tests/test_driver_feasibility.py @@ -116,3 +116,18 @@ def test_feasibility_log_aligns_with_nav_index(): nav = driver.run() log = driver.feasibility_log() assert list(log.index) == list(nav.index) # settled dates only, 1:1 + + +def test_holdings_log_reports_achieved_not_desired_target(): + # The auditability red-line (review HIGH): when A's sell is blocked at the + # down-limit, the DESIRED target is B but the ACHIEVED book is the carried A. + # holdings_log must report A (what was held), never B (what was wanted). + panel = _panel({(_FEB, "A"): {"at_down_limit": True}}) + driver = _driver(panel) + driver.run() + h = driver.holdings_log() + feb = h[h["date"] == _FEB] + assert list(feb["symbol"]) == ["A"] # carried (achieved), NOT desired B + assert "B" not in set(feb["symbol"]) + # and it equals the execution's actual end-of-run book (Feb is the last step) + assert set(feb["symbol"]) == set(driver._execution.positions().index) diff --git a/tests/test_phase2_baseline.py b/tests/test_phase2_baseline.py index 0f71414..ee705ac 100644 --- a/tests/test_phase2_baseline.py +++ b/tests/test_phase2_baseline.py @@ -17,7 +17,6 @@ from qt.phase2_baseline import ( Phase2Result, financial_coverage_at_dates, - reconstruct_holdings, summarize_universe, tradability_hit_stats, ) @@ -184,33 +183,8 @@ def test_tradability_hit_stats_missing_close_counted(): # --------------------------------------------------------------------------- # -# reconstruct_holdings +# driver achieved holdings (the source the report uses) # --------------------------------------------------------------------------- # -def test_reconstruct_holdings_mirrors_topn_build(): - date = pd.Timestamp("2024-01-31") - syms = ["000001.SZ", "000002.SZ", "000003.SZ"] - panel = pd.DataFrame( - {"close": [1.0, 1.0, 1.0]}, - index=pd.MultiIndex.from_product([[date], syms], names=["date", "symbol"]), - ) - score_panel = pd.Series( - [0.3, 0.1, 0.2], - index=pd.MultiIndex.from_product([[date], syms], names=["date", "symbol"]), - name="score", - ) - uni = StaticUniverse(syms, {}) - holdings = reconstruct_holdings( - _FrameScores(score_panel), uni, panel, TopNEqualWeight(2), [date] - ) - assert len(holdings) == 2 # top_n=2 - held = set(holdings["symbol"]) - assert held == {"000001.SZ", "000003.SZ"} # two highest scores - assert holdings["weight"].tolist() == [0.5, 0.5] - # rank 1 is the highest score (000001.SZ @ 0.3) - top = holdings.sort_values("rank").iloc[0] - assert top["symbol"] == "000001.SZ" - - def test_holdings_dates_equal_settled_nav_index_not_candidates(): # Regression for the HIGH finding: the driver SKIPS a terminal rebalance with # no forward holding period, so the diagnostics must key off nav_table.index @@ -243,7 +217,7 @@ def test_holdings_dates_equal_settled_nav_index_not_candidates(): nav = driver.run() assert len(nav) == len(candidate_dates) - 1 # terminal date skipped (BT-003) - holdings = reconstruct_holdings(scores, uni, panel, constructor, list(nav.index)) + holdings = driver.holdings_log() # the ACHIEVED book the report uses assert set(holdings["date"]) == set(nav.index) assert candidate_dates[-1] not in set(holdings["date"]) # no phantom terminal @@ -306,6 +280,8 @@ def _synthetic_result() -> Phase2Result: panel_rows=2, panel_symbols=2, universe_summary=summarize_universe(_pit_universe(), "index", "2024-01-01", "2024-03-01"), + list_date_known=67, + list_date_total=68, financial_field="roe", financial_coverage_overall=0.5, financial_coverage_by_rebalance=cov, @@ -349,3 +325,9 @@ 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 assert "2024-01-31" in md # rebalance date listed + + +def test_render_discloses_list_date_coverage_this_run(): + md = render_phase2_baseline(_synthetic_result()) + assert "67/68" in md # known/total for THIS run + assert "missing" in md.lower() # the data gap is disclosed, not just generic