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
33 changes: 26 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri
## 数据:tushare
- **token**:`/home/shaofl/Projects/financial_projects/.config.json`(key `tushare.token`)。
⚠️ **绝不打印、绝不写进 repo、绝不 commit。** 代码里从该文件读取,不硬编码。
- **权限**:实测充足——个股日线 / 分钟(`stk_mins`) / 复权(`adj_factor`) / 成分股(`index_weight`) / 申万行业(`index_classify`) / 财务含`ann_date`(`income`,`fina_indicator`) 全可取。分钟级可直接上,无需先退回日线。
- **权限**:实测充足——个股日线 / 分钟(`stk_mins`) / 复权(`adj_factor`) / 成分股(`index_weight`) / 申万行业历史(`index_member_all`/`index_classify`) / 财务含`ann_date`(`income`,`fina_indicator`) 全可取。分钟级可直接上,无需先退回日线。
- **MCP(可选开发工具)**:`financial_projects/.mcp.json` 有 tushare MCP,仅供开发期交互查数;**从 `financial_projects/` 启动 Codex 才加载**。
- **数据层 ETL 一律用 Python SDK(批量/增量),不要建在 MCP 上。** 注意 tushare 各接口有每分钟调用上限,批量拉取需限流+重试。

Expand All @@ -57,14 +57,33 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri

## 开发约定
- **交流中文**;代码/注释/commit message 用**英文**。
- **Git**:feature 分支 + PR。main 已有骨架;当前在 `data` 分支搭数据层。commit 用 conventional 格式,**无 attribution**(不加 Co-Authored-By)。
- **Git**:feature 分支 + PR。**PR #1(P0+P1)、#2(P2-1)、#3(P2-2)、#4(进度文档)均已 merge 到 `main`;PR #5(P2-3)OPEN,已按用户决定改为「默认 SW-L1 可配置」版(industry_level=L1),待用户验收/合并,勿 as-is 合并。** commit 用 conventional 格式,**无 attribution**(不加 Co-Authored-By)。
- **不过度设计**:按路线图 MVP 先打通一条端到端链路,再加层(architecture.html §11,Phase 0→3)。
- **secrets** 一律走外部 `.config.json`;repo `.gitignore` 已排除数据产物(`*.parquet`等)、缓存、`tmp/`(仅留架构文档)。
- 文件小而专(<800 行),immutable 优先。

## 当前进度
- ✅ 7 层骨架 + 架构文档(已在 `main`)
- ✅ Phase 0 MVP(`data` 分支,未提交):DemoFeed → PanelStore → StaticUniverse → momentum_20 → zscore → EqualWeightAlpha → TopN 等权 → 月度回测(成本/换手)→ IC/绩效报告。
- ✅ 质量门:`python -m pytest` 93 passed;`python -m ruff check .` clean;`python -m qt.cli run-phase0 --config config/example.yaml` 可复现。
- ⚠️ P0 显式降级:静态 universe 非 PIT、简版 IC/绩效未走 alphalens/quantstats、`min_listing_days` 配置未生效、持有期末缺价按 0% 结算、tushare 路径接入但未实网验证。
- 下一步 P1:接真 tushare 日线/复权/PIT 成分/可交易过滤,然后再扩因子和中性化。
- ✅ 7 层骨架 + 架构文档(`main`)
- ✅ **Phase 0 MVP**(PR #1):DemoFeed → PanelStore → StaticUniverse → momentum_20 → zscore → EqualWeightAlpha → TopN 等权 → 月度回测(成本/换手)→ IC/绩效报告,单命令可复现。
- ✅ **Phase 1 偏差边界**(PR #1,全部真数据实证):
- 前复权(qfq;store 存 raw,内存复权 → batch≡incremental 安全)
- PIT 指数成分(`index_weight` as-of,survivorship-safe;370 天 pre-start 回看 + 90 天分页)
- 可交易过滤(停牌 / ST / 涨跌停;**涨跌停用未复权 raw close** 比 `stk_limit`)
- 财务 `ann_date` 披露日 as-of(绝不按 end_date;500 天 lookback carry forward)
- 行业 + 市值中性化(按 date 截面 OLS 残差;欠定/无自由度截面 → NaN)
- 路径感知降级披露(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 真实数据可复现基准**(PR #2 已 merge):新 run mode `run-phase2-baseline` + `config/phase2_real_baseline.yaml`(上证50 `000016.SH`,2023-07~2024-06)。复用 P0/P1 全套机器,不扩因子、不调参。一次真实跑约 11 min,输出 `artifacts/reports/phase2_real_baseline.md`(gitignored):数据窗口 / PIT 成分摘要 / ann_date 覆盖率 / 可交易过滤 / 每期持仓 / 换手成本 / IC / 绩效 / 全部 P2 降级。
- ✅ **Phase 2-2 执行真实性**(PR #3 已 merge):拆分 selection(选谁)与 execution feasibility(能否成交)。
- `runtime/fills.py::simulate_fills` 方向感知执行:涨停挡买 / 跌停挡卖 / 停牌·缺收盘双向挡;按 panel flag 实时判定,与选股 toggle 无关。
- 现金一致 sell-then-buy:卖在前释放现金、买在后,现金不足按比例部分成交 → 无杠杆;被挡交易 carry forward;换手/成本只算实际成交;闲置现金按 driver 的 `cash_return` 计息。
- `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** 小节。
- 🔧 **Phase 2-3 历史 PIT 行业**(PR #5,分支 `p2-pit-industry`,**默认 SW-L1 可配置版,待用户验收/合并**):把行业中性化协变量从 `stock_basic.industry` 当前标签升级为按 trade_date as-of 的历史申万行业。
- `tushare_covariates.pit_sw_intervals(symbols, level)` 读 `index_member_all` 的 `in_date`/`out_date` 区间;`data/clean/pit_industry.py::asof_industry` 按 `[in_date, out_date)` 覆盖该日取行业,改分类日新行业生效,PIT-safe,起始日前成分 carry forward 到窗口开始。
- **SW 层级可配置** `processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,中性化标准 + 小截面自由度更稳)。**关键实证**:旧 tag 年化 −17.6% / SW-L1 −10.2% / SW-L2 −9.3% → **L1≈L2**,−17.6→−10 大跳主因是 **tushare→SW 分类切换**(补 PIT 必然:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**。(曾误判粒度、默认 L2,经 L2 实测推翻,改默认 L1。)
- 不静默退回 current:无 SW 历史的票 → 行业 NaN,被 neutralize 按截面丢弃;每次运行 **实际 level + PIT 覆盖率**进 phase2 报告。
- 真实 baseline(SW-L1,默认):`run-phase2-baseline` OK,symbols=68,settled=11,**PIT SW-L1 coverage 98.53%**(67/68),IC 0.0083,**annual −10.19%**。
- ✅ 当前质量门:`pytest -p no:cacheprovider` **223 passed**;`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)OK;`run-phase0`(demo)OK。
- ⚠️ 剩余 P2(已显式披露):日线 only、简版 IC/绩效未走 alphalens/quantstats、demo 路径非真数据。
- 路线图下一步:财务因子组合 / 分钟级 / alphalens·quantstats 接入(architecture.html §11)。
4 changes: 3 additions & 1 deletion BIAS_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@
- 状态: **行业 + 市值中性化已实现(P1)**。
- `factors.process.neutralize.neutralize_by_date`:每个 date 截面把因子对 `[log(market_cap), one-hot(industry)]` 做 OLS,取残差,移除规模与行业暴露。缺行业 / 市值,或**残差自由度 ≤ 0**(名称数 ≤ 1+行业数,饱和拟合会给出无意义的伪 0 残差)时返回 **NaN**,绝不静默乱算;`processing.neutralize` 开启但协变量缺失(如 demo 路径)直接报可读错误。
- 实证:12 只票横跨 4 行业(2024-09-30),corr(原始 momentum, log市值) = -0.617 → 中性化后 -0.000,各行业残差均值 ≈ 0,确认规模/行业暴露被移除。
- **降级**:行业来自 `stock_basic.industry` 的**当前**行业标签,非按历史时点,故行业中性化带有轻微成分前视(市值 `daily_basic.total_mv` 为逐日真值)。PIT 行业历史是后续项,此降级在此显式披露(INV-007)。
- **行业 PIT(UNI-010,P2-3,已实现)**:行业协变量从 `stock_basic.industry` 的**当前**标签,升级为按 trade_date **as-of** 的历史申万行业。`data.feed.tushare_covariates.pit_sw_intervals(symbols, level)` 读 `index_member_all` 拿每股 SW 行业的 `in_date`/`out_date` 区间;`data.clean.pit_industry.asof_industry` 按`[in_date, out_date)` 覆盖该日的区间取行业(改分类日**新行业**生效,PIT-safe,绝不用未来行业)。起始日前已有的成分能 carry forward 到窗口开始。
- **SW 层级可配置**:`processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,行业中性化业界标准,小截面自由度更稳)。**实测**:旧 tag 年化 −17.6%、SW-L1 −10.2%、SW-L2 −9.3% —— L1≈L2,−17.6→−10 的大跳是 **tushare→SW 分类切换**(补 PIT 的必然代价:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**;报告披露实际 level 与覆盖率。
- **缺失处理(不静默退回 current)**:无 SW 历史的票 → 行业 **NaN**,被 neutralize 按截面丢弃;每次运行的 **PIT 行业覆盖率**在 `phase2_real_baseline.md` 披露。市值 `daily_basic.total_mv` 为逐日真值。
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ data → universe → factors(特征) → alpha(合成/预测) → portfolio(+ri
- `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)。
- ✅ **Phase 2-3 历史 PIT 行业**(PR #5,**默认 SW-L1 可配置版**,等验收):把行业中性化协变量从 `stock_basic.industry` **当前**标签升级为按 trade_date **as-of** 的历史申万行业。
- `tushare_covariates.pit_sw_intervals(symbols, level)` 读 `index_member_all` 取每股 SW 行业 `in_date`/`out_date` 区间;`data/clean/pit_industry.py::asof_industry` 按 `[in_date,out_date)` 覆盖该日取行业(改分类日新行业生效,PIT-safe;起始日前成分 carry forward 到窗口开始)。
- **SW 层级可配置** `processing.neutralize.industry_level`(L1/L2/L3,**默认 L1**=31 宽板块,中性化标准 + 小截面自由度更稳)。**关键实证**:旧 tag 年化 −17.6% / SW-L1 −10.2% / SW-L2 −9.3% → **L1≈L2**,大跳的主因是 **tushare→SW 分类切换**(补 PIT 的必然:只有 SW 有 in/out 历史可 PIT 化,旧 tag 无法),**与粒度无关**。(注:曾误以为是粒度、默认 L2,经 L2 实测推翻,改默认 L1。)
- **不静默退回 current**:无 SW 历史的票 → 行业 NaN,被 neutralize 按截面丢弃(neutralize 数学不变,本就丢 NaN 行);每次运行 **实际 level + PIT 覆盖率**进 phase2 报告。
- ✅ 质量门:`pytest` **223 passed**(P0=95 / P1=77 / P2-1=15 / P2-2=22 / P2-3=14);`ruff` clean;`validate-config`(demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`)+ `run-phase0`(demo)均 OK。
- ⚠️ 剩余 P2(已显式披露):日线 only、简版 IC/绩效未走 alphalens/quantstats、demo 路径非真数据。
- 路线图下一步:财务因子组合 / 分钟级 / alphalens·quantstats 接入(architecture.html §11)。
17 changes: 13 additions & 4 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,28 @@ 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):
Resolved in P2-2 / P2-3 (was deferred):

- **Direction-aware limits/suspension** — now in the execution layer (up-limit
- **Direction-aware limits/suspension** (P2-2) — 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
- **min_listing_days** (P2-2) — enforced on the real path (`stock_basic.list_date`) as a
buy/selection filter; demo stays a disclosed no-op.
- **PIT industry** (P2-3) — the neutralization industry covariate is now point-in-time
SW (as-of trade date via `index_member_all` in/out dates, `data/clean/pit_industry.py`),
not the current `stock_basic.industry` tag. The SW level is configurable
(`processing.neutralize.industry_level`, L1/L2/L3, **default L1** = 31 broad sectors, the
standard for neutralization and DOF-safe on small cross-sections). Real runs: old tag
annual −17.6%, SW-L1 −10.2%, SW-L2 −9.3% — L1 ≈ L2, so the −17.6→−10 jump is the
tushare→SW taxonomy switch (inherent to going PIT: only SW carries in/out-date history),
NOT granularity. Names with no SW history get NaN (a disclosed coverage gap the
neutralizer drops) — never a silent current-tag fallback; the actual level + PIT coverage
are reported in `phase2_real_baseline.md`.

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.
- **Daily bars only**; **simple IC / performance** (numpy/pandas, not
alphalens-reloaded / quantstats).
47 changes: 33 additions & 14 deletions TEST_REPORT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 (bias-boundary → execution realism)
# TEST_REPORT — Phase 0 + Phase 1 + Phase 2 (bias-boundary → execution realism → PIT industry)

## Commands

Expand All @@ -17,14 +17,14 @@ Run from the repo root with the project python (env `quant_mf`):

| Gate | Command | Result |
|---|---|---|
| Unit + integration | `pytest -q` | **204 passed, 0 failed** |
| Unit + integration | `pytest -q` | **223 passed, 0 failed** |
| Lint | `ruff check .` | **All checks passed** |
| Config validation | `validate-config` (demo + `example_tushare.yaml` + `phase2_real_baseline.yaml`) | exit `0`, prints `OK` |
| End-to-end run | `run-phase0` (demo) | exit `0`, writes `artifacts/reports/phase0_summary.md` |

Counts below are the actual per-file `pytest` numbers (sum = 204).
Counts below are the actual per-file `pytest` numbers (sum = 223).

## Per-file breakdown — Phase 0 core (94)
## Per-file breakdown — Phase 0 core (95)

| Test file | Tests | Area |
|---|---|---|
Expand All @@ -43,9 +43,9 @@ Counts below are the actual per-file `pytest` numbers (sum = 204).
| `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` | 5 | bias audit doc (+1: P2-2 disclosures) |
| `test_bias_audit_report.py` | 6 | bias audit doc (+2: P2-2 + P2-3 disclosures) |

## Per-file breakdown — Phase 1 bias-boundary (75)
## Per-file breakdown — Phase 1 bias-boundary (79)

| Test file | Tests | Red-line / feature |
|---|---|---|
Expand All @@ -65,23 +65,29 @@ Counts below are the actual per-file `pytest` numbers (sum = 204).
| `test_neutralize.py` | 5 | industry+size residual orthogonality |
| `test_processing_neutralize.py` | 2 | neutralize wiring (covariates required) |
| `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 |
| `test_tushare_covariates.py` | 5 | stock_basic + daily_basic + index_member_all SW feed (level L1/L2/L3 select, bad level) |
| `test_real_path_config.py` | 4 | demo vs real-path downgrade disclosure (+ PIT industry) |

## Per-file breakdown — Phase 2-1 real-data baseline (14)
## Per-file breakdown — Phase 2-1 real-data baseline (15)

| 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 |
| `test_phase2_baseline.py` | 15 | collectors, demo/real guard, report-field contract, no-secret-leak, settled-vs-candidate dates, loaded-vs-in-window membership, list_date + PIT-industry coverage |

## Per-file breakdown — Phase 2-2 execution realism (21)
## Per-file breakdown — Phase 2-2 execution realism (22)

| 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_driver_feasibility.py` | 6 | end-to-end: down-limit carries, up-limit blocks buy, suspended no-trade, **holdings == achieved (not desired)**, 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** | |

## Per-file breakdown — Phase 2-3 PIT industry (12)

| Test file | Tests | Red-line / feature |
|---|---|---|
| `test_pit_industry.py` | 12 | SW **as-of** industry (switch at reclassification, carry-forward pre-start, missing → NaN, latest-in_date on overlap) + `enrich_pit_industry` + pipeline wiring (per-date industry, configured SW level passed, no current-tag fallback) + `industry_level` config (default L1, accepts L1/L2/L3, rejects invalid, phase2 config = L1) |
| **Total (P0 + P1 + P2-1 + P2-2 + P2-3)** | **223** | |

## Real-data validation (manual, not in CI — TEST-002 keeps the suite network-free)

Expand All @@ -103,5 +109,18 @@ Counts below are the actual per-file `pytest` numbers (sum = 204).
- `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.
- **P2-3 PIT industry (locked by tests):** the neutralization industry covariate is
point-in-time SW (as-of trade date via `index_member_all` in/out dates), not the
current `stock_basic.industry` tag. The SW level is configurable
(`processing.neutralize.industry_level`, **default L1** = 31 broad sectors, the standard
for neutralization and DOF-safe on small cross-sections). Real runs: old tag annual
−17.6%, SW-L1 −10.2%, SW-L2 −9.3% — **L1 ≈ L2**, so the −17.6→−10 jump is the
**tushare→SW taxonomy switch** (inherent to going PIT — only SW carries in/out-date
history; the old tag cannot be PIT-aligned), NOT a granularity choice. Names with no SW
history get NaN (the neutralizer drops them) — never a silent current-tag fallback; the
actual level + PIT coverage are disclosed in the phase2 report. The neutralize math is
unchanged.
- 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).
(it had been silently shadowing one test in the full-suite run). A second, harmless
duplicate (`test_enrich_does_not_mutate_input` in two files) was verified NOT to drop
a test (per-file sum == full-run total = 217).
Loading