Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions BIAS_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 财务对齐

Expand Down
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 优先。
Expand All @@ -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)。
51 changes: 45 additions & 6 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,43 @@ 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.

## 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
Expand All @@ -173,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).
71 changes: 38 additions & 33 deletions TEST_REPORT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# TEST_REPORT — Phase 1 (bias-boundary)
# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 (bias-boundary → execution realism)

## Commands

Expand All @@ -8,21 +8,23 @@ 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
```

## Results

| 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 |
|---|---|---|
Expand All @@ -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)

Expand All @@ -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).
16 changes: 16 additions & 0 deletions data/clean/covariates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions data/feed/tushare_covariates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading