diff --git a/docs/factor_research/factor-system-design.md b/docs/factor_research/factor-system-design.md index 7e5bb5b4..7329e190 100644 --- a/docs/factor_research/factor-system-design.md +++ b/docs/factor_research/factor-system-design.md @@ -278,6 +278,7 @@ | 7 | **pydantic v2 校验顺序**:字段类型校验先于 field_validator,`Optional[str]` 直接拒 Timestamp 输入 | 字段放宽 Union[str, datetime] | | 8 | **代码格式双轨**:截面/vnpy 侧 vt_symbol=SSE/SZSE,BulletTrade/聚宽侧=XSHG/XSHE | 跨界必转(`_to_jq_symbol`) | | 9 | **filter_kcbj_stock 误杀创业板** | 实盘宇宙用 `_filter_kcbj_keep_chinext` | +| 10 | **月数学 1-based 减法归零**(2026-10-11 双层分工 B 班连带发现):`window_start` 旧式 `total=y*12+m-(w-1); total%12` 对 end 月=11 产出 `"2026-00-01"`(月=00 非法)——11 月每夜班日度批+12 月月度班 start 全非法流入 vnpy 查询,12-01 首炸前修复(20a3b89 月数学 0-based 化) | 手写年月算术一律 0-based 进出(`total%12+1` 回 1-based),并钉 `window_start("2026-11-30")=="2025-12-01"` 回归锚;月边界(1 月/11 月/12 月)必测 | --- diff --git a/docs/superpowers/plans/2026-10-10-resolution-o-card-graveyard.md b/docs/superpowers/plans/2026-10-10-resolution-o-card-graveyard.md new file mode 100644 index 00000000..9cf9e63e --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-resolution-o-card-graveyard.md @@ -0,0 +1,748 @@ +# 决议 O 卡片墓园聚合语义 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 卡片死活=卡下因子聚合(全死才埋)+人工判卡死陪葬制+部分死角标/死因子行态+终态卡禁分解——修 D5「任一因子死埋整卡」在一卡多因子语境下的错位。 + +**Architecture:** 后端 4 件(writeback_card_death 聚合改造/hypotheses verdict 新端点/factors[] 透传 cause/decompose 422)+前端 4 件(角标☠n/N/死因子划线不可点/判死按钮/陪葬弹窗),原子性靠「全部 transition 在内存 reg 完成后才 save_registry」保证。spec=docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md §4.1 决议 O 块(0093c0c)。 + +**Tech Stack:** FastAPI+sqlite(sanguo_portfolio.pipeline_store)+yaml registry(sanguo_factor.version_registry)+Vue3+Element Plus。 + +## Global Constraints + +- 后端测试跑法:`venv310/bin/python -m pytest -q`(项目根目录) +- 前端测试跑法:`cd frontend && npx vitest run `;类型检查 `npx vue-tsc --noEmit` +- **凡测试触及 factor registry 必须 `monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path/…))`**(ensure_runtime_registry 的种子 merge 会向 data/ 运行副本写真数据) +- **commit 标签 [vps] 必须放 subject 行尾**(enforce-label 只扫 `%s` 首行;三犯在案) +- sanguo_factor/ 改动 → 同 push 更新 `docs/factor_research/factor-system-design.md`(design-doc CI 门) +- 卡片状态机:queued/data_check/building/evaluating→graveyard 合法;promoted/graveyard 终态(pipeline_store._VALID_HYP_TRANSITIONS) +- 因子状态机:incubating/assessable/promoted/decaying/retired→graveyard 全合法;graveyard 不可迁出(version_registry._VALID_TRANSITIONS) +- 共享 master 禁 amend/force-push + +--- + +### Task 1: writeback_card_death 聚合改造(全死才埋) + +**Files:** +- Modify: `sanguo_factor/version_registry.py:174-226`(writeback_card_death)+`:295`(CLI 调用方) +- Modify: `sanguo_api/routes_pipeline.py:785`(factor_verdict 调用方) +- Test: `tests/factor/test_version_registry.py`(追加) + +**Interfaces:** +- Produces: `writeback_card_death(pipeline_db: str | None, registry: dict[str, Any], entry: dict[str, Any], cause: str | None, events_path: str = "config/registry_events.jsonl") -> bool`——新增第二位置参数 `registry`(全量注册表 dict,调用方均在手);部分死=stderr 提示+return False(**不**附记 death_writeback_missed——部分死是正常路径非 miss);全死才埋,death_reason=「全灭:触发因子 {name} 入墓园: {cause or 未注死因}」 + +- [ ] **Step 1: 写失败测试**(追加到 tests/factor/test_version_registry.py,沿用文件既有 import 风格;若文件缺 tmp_path 风格先例,用如下自包含写法) + +```python +class TestWritebackAggregation: + """决议O:卡片死亡回写=全死才埋(任一因子死不再埋整卡).""" + + def _card_db(self, tmp_path): + from sanguo_portfolio import pipeline_store as ps + db = str(tmp_path / "pipeline.db") + ps.insert_hypothesis(db, { + "title": "高管增持后跑赢", "logic": "x", + "expected_sign": "positive", "falsifiable": "y"}) + with __import__("sqlite3").connect(db) as conn: + conn.execute( + "UPDATE hypotheses SET id='hyp-agg1' WHERE title='高管增持后跑赢'") + return db + + def test_partial_death_keeps_card_alive(self, tmp_path, capsys): + db = self._card_db(tmp_path) + reg = {"factors": { + "fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1", + "status": "graveyard"}, # 死的那个(触发者) + "fa_b": {"name": "fa_b", "hypothesis": "hyp-agg1", + "status": "incubating"},}} # 活着 → 不埋卡 + ok = writeback_card_death(db, reg, reg["factors"]["fa_a"], "IC 两窗反号") + assert ok is False + out = capsys.readouterr().err + assert "部分因子存活" in out and "fa_b" in out + from sanguo_portfolio import pipeline_store as ps + card = [c for c in ps.list_hypotheses(db) if c["id"] == "hyp-agg1"][0] + assert card["state"] == "queued" # 卡留活跃池 + + def test_all_dead_buries_card_with_new_reason(self, tmp_path): + db = self._card_db(tmp_path) + reg = {"factors": { + "fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1", + "status": "graveyard"}, + "fa_b": {"name": "fa_b", "hypothesis": "hyp-agg1", + "status": "graveyard"},}} # 全灭 → 埋 + ok = writeback_card_death(db, reg, reg["factors"]["fa_a"], "IC 两窗反号") + assert ok is True + from sanguo_portfolio import pipeline_store as ps + card = [c for c in ps.list_hypotheses(db) if c["id"] == "hyp-agg1"][0] + assert card["state"] == "graveyard" + assert card["deathReason"] == "全灭:触发因子 fa_a 入墓园: IC 两窗反号" + + def test_zero_sibling_factor_buries(self, tmp_path): + """卡下无其他因子(一卡一因子旧语境)→该因子死即全灭,照埋.""" + db = self._card_db(tmp_path) + reg = {"factors": { + "fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1", + "status": "graveyard"},}} + assert writeback_card_death(db, reg, reg["factors"]["fa_a"], "x") is True +``` + +(若 tests/factor/test_version_registry.py 顶部尚未 `from sanguo_factor.version_registry import writeback_card_death`,在 class 前补 import 或改用 `from sanguo_factor import version_registry as vr` 后 `vr.writeback_card_death`。) + +- [ ] **Step 2: 跑测试确认失败** + +Run: `venv310/bin/python -m pytest tests/factor/test_version_registry.py::TestWritebackAggregation -q` +Expected: FAIL(TypeError: writeback_card_death() takes … positional args——registry 参数不存在) + +- [ ] **Step 3: 实现——version_registry.py 三处** + +3a. 签名+聚合段(writeback_card_death 内,hyp-id 校验之后、pipeline_db 绝对化之前插入): + +```python +def writeback_card_death(pipeline_db: str | None, + registry: dict[str, Any], + entry: dict[str, Any], cause: str | None, + events_path: str = "config/registry_events.jsonl") -> bool: + """决议O:因子入墓园→卡片聚合回写(全死才埋). + + 卡 id=entry.hypothesis 以 hyp- 开头才回写(旧 H- 编号非卡片,静默跳过); + registry=全量注册表(调用方在手),反查卡下全部因子——部分死=卡留活跃池 + (stderr 提示,非 miss 不附记 events);全 graveyard 才回写,death_reason= + 「全灭:触发因子 {name} 入墓园:{cause}」.其余(P2-10 miss 附记/失败不阻断 + 转移/相对路径绝对化)语义不变. + """ + hyp_id = str(entry.get("hypothesis") or "") + if not hyp_id.startswith("hyp-"): + return False + # 决议O 聚合:反查卡下全部因子,全死才埋 + siblings = [e for e in registry["factors"].values() + if e.get("hypothesis") == hyp_id] + alive = [str(e.get("name")) for e in siblings + if e.get("status") != "graveyard"] + if alive: + print(f"[registry] ℹ️ 卡片 {hyp_id} 部分因子存活({len(alive)} 条)," + f"不埋卡(决议O 全死才埋): {', '.join(alive[:5])}", + file=sys.stderr) + return False +``` + +3b. death_reason 行(update_hypothesis_state 调用处): + +```python + result = pipeline_store.update_hypothesis_state( + pipeline_db, hyp_id, "graveyard", + death_reason=f"全灭:触发因子 {name} 入墓园: {cause or '未注死因'}", + factor_id=name) +``` + +3c. CLI 调用方(main 内 graveyard 分支): + +```python + if args.status == "graveyard": + writeback_card_death(args.pipeline_db, reg, entry, args.cause, + events_path=args.events) +``` + +- [ ] **Step 4: 改 routes_pipeline.py factor_verdict 调用方(:785)** + +```python + vr.writeback_card_death(None, reg, entry, note, events_path=events_path) +``` + +- [ ] **Step 5: 跑全量确认** + +Run: `venv310/bin/python -m pytest tests/factor/test_version_registry.py -q && venv310/bin/python -m pytest tests/api -q` +Expected: 全 PASS(若 tests/api 或 tests/factor 旧用例按旧签名调 writeback_card_death 报 TypeError,同班把测试调用改为新签名补第二参 registry——旧用例语义若断言「任一死即埋」,按决议 O 改断言为全死才埋) + +- [ ] **Step 6: Commit** + +```bash +git add sanguo_factor/version_registry.py sanguo_api/routes_pipeline.py tests/factor/test_version_registry.py +git commit -m "feat(factor): 决议O①卡片死亡回写聚合化——全死才埋(反查卡下全部因子,部分死=卡留活跃池只影响展示;death_reason=全灭:触发因子x入墓园:死因;修D5任一死埋整卡在一卡多因子语境错位) [vps]" +``` + +--- + +### Task 2: POST /pipeline/hypotheses/{id}/verdict 判卡死+陪葬端点 + +**Files:** +- Modify: `sanguo_api/routes_pipeline.py`(factor_verdict 端点后新增,约 :795) +- Test: `tests/api/test_routes_pipeline_hypotheses.py`(追加 class) + +**Interfaces:** +- Consumes: Task 1 的 `vr.transition/save_registry/append_event`(既有);`pipeline_store.update_hypothesis_state`(既有) +- Produces: `POST /pipeline/hypotheses/{hyp_id}/verdict` body `{reason: str}` → 200 `{ok: true, buried: string[], card: "graveyard"}`;422=reason 空/卡已 graveyard/卡 promoted(毕业语义挂账)/因子状态机拒绝;404=卡不存在。陪葬 cause=`{reason}(随卡判死)`。 + +- [ ] **Step 1: 写失败测试**(追加到 tests/api/test_routes_pipeline_hypotheses.py;复用该文件既有 client fixture 的 SANGUO_PIPELINE_DB/tmp_path 环境,registry 隔离用 setenv) + +```python +class TestCardVerdict: + """决议O②:人工判卡片死(强断言想法死)——活因子陪葬,cause 带(随卡判死).""" + + @pytest.fixture() + def env2(self, client, monkeypatch, tmp_path): + """registry 指 tmp(绕种子 merge)+预置一卡两因子(一活一死).""" + reg = tmp_path / "reg.yaml" + reg.write_text( + "factors:\n" + " fa_live:\n" + " name: fa_live\n status: incubating\n" + " hypothesis: hyp-kill1\n origin: decomposer\n" + " fa_dead:\n" + " name: fa_dead\n status: graveyard\n" + " hypothesis: hyp-kill1\n cause: 旧死\n" + " origin: decomposer\n", encoding="utf-8") + monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg)) + return reg + + def _mk_card(self, state="building"): + from sanguo_portfolio import pipeline_store as ps + import os + db = os.environ["SANGUO_PIPELINE_DB"] + ps.insert_hypothesis(db, { + "title": "判死测试卡", "logic": "x", + "expected_sign": "positive", "falsifiable": "y"}) + import sqlite3 + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hypotheses SET id='hyp-kill1', " + f"state='{state}' WHERE title='判死测试卡'") + return "hyp-kill1" + + def test_kill_card_buries_live_siblings(self, client, env2): + import yaml + hyp = self._mk_card("building") + r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/verdict", + json={"reason": "逻辑前提失效:增持数据口径变更"}) + assert r.status_code == 200 + doc = r.json() + assert doc["buried"] == ["fa_live"] # 死的不重复陪葬 + with open(env2, encoding="utf-8") as f: + reg = yaml.safe_load(f) + assert reg["factors"]["fa_live"]["status"] == "graveyard" + assert reg["factors"]["fa_live"]["cause"] == \ + "逻辑前提失效:增持数据口径变更(随卡判死)" + from sanguo_portfolio import pipeline_store as ps + import os + card = [c for c in ps.list_hypotheses(os.environ["SANGUO_PIPELINE_DB"]) + if c["id"] == hyp][0] + assert card["state"] == "graveyard" + assert card["deathReason"] == "逻辑前提失效:增持数据口径变更" + + def test_kill_requires_reason(self, client, env2): + self._mk_card() + r = client.post("/api/v1/pipeline/hypotheses/hyp-kill1/verdict", + json={"reason": " "}) + assert r.status_code == 422 + + def test_graveyard_card_reject_422(self, client, env2): + import sqlite3, os + self._mk_card("graveyard") + r = client.post("/api/v1/pipeline/hypotheses/hyp-kill1/verdict", + json={"reason": "再死一次"}) + assert r.status_code == 422 and "墓园" in r.json()["detail"] + + def test_promoted_card_reject_422(self, client, env2): + self._mk_card("promoted") + r = client.post("/api/v1/pipeline/hypotheses/hyp-kill1/verdict", + json={"reason": "毕业卡判死"}) + assert r.status_code == 422 and "promoted" in r.json()["detail"] + + def test_zero_factor_card_pure_idea_kill(self, client, monkeypatch, tmp_path): + """0 因子卡=纯想法毙掉,无陪葬也 200.""" + reg = tmp_path / "empty.yaml" + reg.write_text("factors: {}\n", encoding="utf-8") + monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg)) + hyp = self._mk_card("queued") + r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/verdict", + json={"reason": "想法被新文献证伪"}) + assert r.status_code == 200 and r.json()["buried"] == [] +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py::TestCardVerdict -q` +Expected: FAIL(404 detail 不符——路由不存在,FastAPI 返回 404 {"detail":"Not Found"}) + +- [ ] **Step 3: 实现端点**(routes_pipeline.py,factor_verdict 函数之后插入) + +```python +@router.post("/pipeline/hypotheses/{hyp_id}/verdict") +def hypothesis_verdict(hyp_id: str, body: dict) -> dict: + """决议O②:人工判卡片死(强断言「想法死了」,危险区操作)——卡下活因子陪葬. + + 原子性:全部 transition 在内存 reg 完成后才 save_registry(中途 ValueError + 零副作用);陪葬 cause=卡片死因+「(随卡判死)」后缀;incubating→graveyard + 状态机合法.0 因子卡=纯想法毙掉无陪葬.promoted 卡不可判死(毕业语义挂账, + 决议O 只动墓园). + """ + from sanguo_factor import version_registry as vr + reason = str(body.get("reason") or "").strip() + if not reason: + raise HTTPException(422, "判死必须带 reason(死因,可检索防重复造轮)") + items = pipeline_store.list_hypotheses(_pipeline_db()) + card = next((i for i in items if i["id"] == hyp_id), None) + if card is None: + raise HTTPException(404, "卡片不存在") + if card["state"] == "graveyard": + raise HTTPException(422, "卡片已在墓园(终态不可再判)") + if card["state"] == "promoted": + raise HTTPException(422, "promoted 卡不可判死(决议O 挂账:毕业语义待定)") + reg_path = _ensure_factor_runtime_registry() + events_path = os.environ.get("SANGUO_FACTOR_EVENTS", + os.path.join("data", "registry_events.jsonl")) + reg = vr.load_registry(reg_path) + buried: list[tuple[str, str, dict[str, Any]]] = [] # (name, old, entry) + try: + for name, e in sorted(reg["factors"].items()): + if e.get("hypothesis") != hyp_id or e.get("status") == "graveyard": + continue + old = str(e.get("status")) + entry = vr.transition(reg, name, "graveyard", + cause=f"{reason}(随卡判死)", + reviewed_via="console") + buried.append((name, old, entry)) + vr.save_registry(reg_path, reg) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc # 零副作用(未落盘) + for name, old, entry in buried: + vr.append_event(events_path, entry, old, "graveyard", + cause=f"{reason}(随卡判死)", reviewed_via="console") + pipeline_store.update_hypothesis_state( + _pipeline_db(), hyp_id, "graveyard", death_reason=reason) + return {"ok": True, "buried": [n for n, _, _ in buried], + "card": "graveyard"} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py -q` +Expected: 全 PASS + +- [ ] **Step 5: Commit** + +```bash +git add sanguo_api/routes_pipeline.py tests/api/test_routes_pipeline_hypotheses.py +git commit -m "feat(api): 决议O②人工判卡片死端点——POST hypotheses/{id}/verdict 活因子陪葬(cause=死因+随卡判死;内存全转后落盘=原子;promoted 卡422挂账;0因子卡纯想法毙掉) [vps]" +``` + +--- + +### Task 3: factors[] 透传 cause + decompose 终态 422 + +**Files:** +- Modify: `sanguo_api/routes_pipeline.py:1131-1137`(by_hyp 组装)+`:1228`(decompose 前置) +- Test: `tests/api/test_routes_pipeline_hypotheses.py`(追加) + +**Interfaces:** +- Produces: GET /pipeline/hypotheses 的 factors[] 每项新增 `cause: string | null`(graveyard 因子=死因,其余 null);POST decompose 对 graveyard 卡返回 422「终态卡(墓园)不可分解——重推想法=开新卡(决议O)」 + +- [ ] **Step 1: 写失败测试**(追加 class) + +```python +class TestFactorsCauseAndDecompose422: + def test_factors_carry_cause(self, client, monkeypatch, tmp_path): + reg = tmp_path / "reg.yaml" + reg.write_text( + "factors:\n" + " fa_d:\n name: fa_d\n status: graveyard\n" + " hypothesis: hyp-c1\n cause: IC 两窗反号\n" + " origin: decomposer\n" + " fa_l:\n name: fa_l\n status: incubating\n" + " hypothesis: hyp-c1\n origin: decomposer\n", + encoding="utf-8") + monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg)) + from sanguo_portfolio import pipeline_store as ps + import os, sqlite3 + db = os.environ["SANGUO_PIPELINE_DB"] + ps.insert_hypothesis(db, { + "title": "透传测试卡", "logic": "x", + "expected_sign": "positive", "falsifiable": "y"}) + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hypotheses SET id='hyp-c1' " + "WHERE title='透传测试卡'") + items = client.get("/api/v1/pipeline/hypotheses").json()["items"] + fs = next(i for i in items if i["id"] == "hyp-c1")["factors"] + cmap = {f["name"]: f.get("cause") for f in fs} + assert cmap["fa_d"] == "IC 两窗反号" + assert cmap["fa_l"] is None + + def test_decompose_graveyard_card_422(self, client, monkeypatch, tmp_path): + from sanguo_portfolio import pipeline_store as ps + import os, sqlite3 + db = os.environ["SANGUO_PIPELINE_DB"] + ps.insert_hypothesis(db, { + "title": "终态分解卡", "logic": "x", + "expected_sign": "positive", "falsifiable": "y"}) + with sqlite3.connect(db) as conn: + conn.execute("UPDATE hypotheses SET id='hyp-g1', state='graveyard', " + "death_reason='全灭' WHERE title='终态分解卡'") + monkeypatch.setenv("SANGUO_LLM_API_KEY", "sk-test") + r = client.post("/api/v1/pipeline/hypotheses/hyp-g1/decompose") + assert r.status_code == 422 + assert "终态" in r.json()["detail"] or "墓园" in r.json()["detail"] +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py::TestFactorsCauseAndDecompose422 -q` +Expected: FAIL(cause 键缺失 KeyError / decompose 返回 409 非 422) + +- [ ] **Step 3: 实现** + +3a. by_hyp 组装 dict(routes_pipeline.py:1131-1137,`"status": e.get("status")` 后加一行): + +```python + by_hyp.setdefault(hyp, []).append({ + "name": name, "expression": expr, + "origin": e.get("origin") or "manual", + "createdAt": (versions[0].get("effective_from") + if versions else None), + "status": e.get("status"), + "cause": e.get("cause")}) # 决议O③死因透传(死因子行tooltip) +``` + +3b. decompose 前置(:1228 通用 409 之前插入): + +```python + if card["state"] == "graveyard": + raise HTTPException(422, "终态卡(墓园)不可分解——重推想法=开新卡(决议O)") + if card["state"] not in _DECOMPOSE_ALLOWED_STATES: + raise HTTPException(409, f"卡片状态 {card['state']} 不可分解") +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py -q` +Expected: 全 PASS + +- [ ] **Step 5: Commit** + +```bash +git add sanguo_api/routes_pipeline.py tests/api/test_routes_pipeline_hypotheses.py +git commit -m "feat(api): 决议O③④hypotheses端点factors[]透传cause(死因子行tooltip数据源)+decompose终态卡前置422(墓园不可分解,重推=开新卡;原409通用态细化) [vps]" +``` + +--- + +### Task 4: 前端类型+卡角标☠n/N+死因子行态 + +**Files:** +- Modify: `frontend/src/api/pipeline.ts:31-38`(HypothesisFactor) +- Modify: `frontend/src/views/pipeline/HypothesisPool.vue`(hcard-head 角标+fac-row 行态+样式) +- Modify: `frontend/src/mock/pipelineData.ts`(hypothesesMock 补 factors 死例) +- Test: `frontend/src/views/pipeline/HypothesisPool.spec.ts`(追加用例) + +**Interfaces:** +- Consumes: Task 3 的 factors[].cause +- Produces: `HypothesisFactor.cause?: string | null`;组件函数 `deadFactorCount(h: Hypothesis): number` + +- [ ] **Step 1: 类型**(pipeline.ts HypothesisFactor 加字段) + +```typescript +export interface HypothesisFactor { + name: string + expression: string | null + origin: string + createdAt: string | null + status: string + cause?: string | null // 决议O③:graveyard 死因(死因子行 tooltip) + batch?: { jobId: string; startedAt: string } | null +} +``` + +- [ ] **Step 2: mock 补死例**(pipelineData.ts hypothesesMock 里「高管增持」卡补 factors 字段——找到该 mock 对象加) + +```typescript + factors: [ + { name: 'fa高管增持超额', expression: 'ts_rank(close, 60)', origin: 'decomposer', + createdAt: '2026-10-09', status: 'graveyard', cause: 'IC 两窗反号(2026-10 日报)', batch: null }, + { name: 'fa高管增持量比', expression: 'volume / ts_mean(volume, 20)', origin: 'decomposer', + createdAt: '2026-10-09', status: 'incubating', batch: null }, + ], +``` + +- [ ] **Step 3: 组件**(HypothesisPool.vue) + +3a. script 段加(canDecompose 附近): + +```typescript +// 决议O:部分死=卡留活跃池+角标☠n/N(n=死因子数,0 不显示) +const deadFactorCount = (h: Hypothesis): number => + (h.factors || []).filter((f) => f.status === 'graveyard').length +``` + +3b. hcard-head 内 StatusChip 之后插角标(:225-227 附近): + +```html + + + ☠{{ deadFactorCount(h) }}/{{ h.factors!.length }} + +``` + +3c. fac-row 死因子行态(:266-270 改造——fac-name 分死活两分支): + +```html +
+ ☠ {{ f.name }} + {{ f.name }} + {{ f.expression }} + {{ f.createdAt }} +
+``` + +3d. 样式(style 段 fac-name 相关附近加): + +```css +.fac-name.dead { text-decoration: line-through; color: var(--text-3); cursor: default; } +.skull-badge { font-family: var(--mono); font-size: 10px; color: #ff5f6d; + border: 1px solid rgba(255, 95, 109, 0.4); border-radius: var(--r-sm); + padding: 0 5px; line-height: 16px; flex-shrink: 0; } +``` + +- [ ] **Step 4: 写前端测试**(HypothesisPool.spec.ts 追加) + +```typescript + it('决议O:部分死角标☠n/N+死因子划线不可点+死因tooltip', async () => { + vi.mocked(api.getHypotheses).mockResolvedValue(hypothesesMock) // mock 已含 1 死 1 活 + const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } }) + await flushPromises() + const badge = w.find('.skull-badge') + expect(badge.exists()).toBe(true) + expect(badge.text()).toContain('☠1/2') + const dead = w.findAll('.fac-name.dead') + expect(dead.length).toBe(1) + expect(dead[0].text()).toContain('fa高管增持超额') + expect(dead[0].attributes('title')).toContain('IC 两窗反号') + }) +``` + +- [ ] **Step 5: 跑测试+类型检查** + +Run: `cd frontend && npx vitest run src/views/pipeline/HypothesisPool.spec.ts && npx vue-tsc --noEmit` +Expected: 全 PASS+零类型错 + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/api/pipeline.ts frontend/src/views/pipeline/HypothesisPool.vue frontend/src/mock/pipelineData.ts frontend/src/views/pipeline/HypothesisPool.spec.ts +git commit -m "feat(frontend): 决议O③卡角标☠n/N(部分死=卡留活跃池)+死因子行态(划线/☠前缀/死因tooltip/不可点修404) [vps]" +``` + +--- + +### Task 5: 判死卡片按钮+陪葬确认弹窗 + +**Files:** +- Modify: `frontend/src/api/pipeline.ts`(postHypothesisVerdict) +- Modify: `frontend/src/views/pipeline/HypothesisPool.vue`(危险区按钮+el-dialog+逻辑) +- Test: `frontend/src/views/pipeline/HypothesisPool.spec.ts`(追加用例) + +**Interfaces:** +- Consumes: Task 2 的 POST /pipeline/hypotheses/{id}/verdict +- Produces: `postHypothesisVerdict(id: string, reason: string): Promise<{ ok: boolean; buried: string[] }>` + +- [ ] **Step 1: api 函数**(pipeline.ts,saveHypothesis 附近加) + +```typescript +// 决议O②:人工判卡片死(危险区)——卡下活因子陪葬,cause=reason+(随卡判死) +export async function postHypothesisVerdict( + id: string, reason: string, +): Promise<{ ok: boolean; buried: string[] }> { + const { data } = await apiClient.post<{ ok: boolean; buried: string[] }>( + `/pipeline/hypotheses/${id}/verdict`, { reason }) + return data +} +``` + +- [ ] **Step 2: 组件逻辑+按钮+弹窗**(HypothesisPool.vue) + +2a. script 段(import 补 postHypothesisVerdict、ElMessage/ElMessageBox 若未引入则从 element-plus 引;状态段加): + +```typescript +// 决议O②:判死卡片(危险区)——强断言想法死,活因子陪葬(终态不可逆) +const kill = ref<{ card: Hypothesis; reason: string; loading: boolean } | null>(null) +const killBuriable = computed(() => + (kill.value?.card.factors || []).filter((f) => f.status !== 'graveyard')) +const openKill = (h: Hypothesis) => { + kill.value = { card: h, reason: '', loading: false } +} +const confirmKill = async () => { + if (!kill.value) return + const reason = kill.value.reason.trim() + if (!reason) { ElMessage.warning('死因必填(可检索防重复造轮)') ; return } + kill.value.loading = true + try { + const r = await postHypothesisVerdict(kill.value.card.id, reason) + ElMessage.success( + r.buried.length ? `卡片已判死,陪葬 ${r.buried.length} 条因子` : '卡片已判死(纯想法,无陪葬)') + kill.value = null + await load() // 复用组件既有刷新函数(以实际名为准) + } catch (e: unknown) { + ElMessage.error(e instanceof Error ? e.message : '判死失败') + if (kill.value) kill.value.loading = false + } +} +``` + +(`load()` 若组件内刷新函数名不同——如 `refresh()`/`fetchAll()`——以实际为准替换。) + +2b. 卡片操作区(hcard-ops 处,与分解按钮同区,分危险按钮): + +```html +
+ + +
+``` + +配套 helper(deadFactorCount 旁): + +```typescript +const killable = (h: Hypothesis): boolean => + h.state !== 'graveyard' && h.state !== 'promoted' +``` + +2c. 弹窗(template 根部列表之后追加): + +```html + + + + +``` + +2d. 样式: + +```css +.wiz-btn.danger { color: #ff5f6d; border-color: rgba(255, 95, 109, 0.5); } +.wiz-btn.danger:hover { background: rgba(255, 95, 109, 0.12); } +.kill-warn { font-size: 12px; color: #ffb84d; padding: 8px 10px; + border: 1px solid rgba(255, 184, 77, 0.4); border-radius: var(--r-sm); } +.kill-list { margin-top: 10px; font-size: 12px; } +.kill-list-h { color: var(--text-2); margin-bottom: 4px; } +.kill-item { display: flex; justify-content: space-between; + padding: 3px 8px; border-bottom: 1px dashed var(--border); } +.kill-item .dim { color: var(--text-3); font-size: 11px; } +``` + +- [ ] **Step 3: 写前端测试**(HypothesisPool.spec.ts 追加;mock 顶部 vi.mock 工厂补 `postHypothesisVerdict: vi.fn()`) + +```typescript + it('决议O②:判死卡片弹窗列陪葬清单+确认后调端点', async () => { + vi.mocked(api.getHypotheses).mockResolvedValue(hypothesesMock) + vi.mocked(api.postHypothesisVerdict).mockResolvedValue( + { ok: true, buried: ['fa高管增持量比'] }) + const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } }) + await flushPromises() + await w.findAll('.wiz-btn.danger')[0].trigger('click') + await flushPromises() + const dlg = document.querySelector('.el-dialog') + expect(dlg?.textContent).toContain('判死卡片') + expect(dlg?.textContent).toContain('陪葬清单(1 条)') // mock 1 死 1 活,只列活 + expect(dlg?.textContent).toContain('fa高管增持量比') + // 填死因+确认 + const ta = dlg?.querySelector('textarea') as HTMLTextAreaElement + ta.value = '逻辑前提失效' + ta.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + const btns = Array.from(document.querySelectorAll('.el-dialog button')) + .find((b) => b.textContent?.includes('确认判死')) + btns?.click() + await flushPromises() + expect(api.postHypothesisVerdict).toHaveBeenCalledWith( + expect.any(String), '逻辑前提失效') + }) +``` + +(el-dialog 挂 body 需从 document 查——与 Element Plus 测试惯用法一致;若 mount attachTo 不够,加 `attachTo: document.body`。) + +- [ ] **Step 4: 跑测试+类型检查** + +Run: `cd frontend && npx vitest run src/views/pipeline/HypothesisPool.spec.ts && npx vue-tsc --noEmit` +Expected: 全 PASS+零类型错 + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/api/pipeline.ts frontend/src/views/pipeline/HypothesisPool.vue frontend/src/views/pipeline/HypothesisPool.spec.ts +git commit -m "feat(frontend): 决议O②判死卡片危险区按钮+陪葬确认弹窗(列活因子清单+死因必填;强断言想法死,实现陪葬) [vps]" +``` + +--- + +### Task 6: 全量回归+双 spec 落账 + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md`(决议 O 块尾加实现状态行) +- Modify: `docs/factor_research/factor-system-design.md`(writeback 语义变更锚点,design-doc 门) +- Modify: `docs/superpowers/specs/2026-10-08-pipeline-console-ux-redesign.md`(变更记录一笔) + +- [ ] **Step 1: 全量回归** + +Run: `venv310/bin/python -m pytest tests/factor tests/api -q && cd frontend && npx vitest run && npx vue-tsc --noEmit` +Expected: 全 PASS + +- [ ] **Step 2: spec 落账**(三处各一小笔) + +2026-09-22 spec §4.1 决议 O 块「挂账」行前加: + +```markdown + - **✅ 实现(2026-10-10)**:Task 链=writeback 聚合改造/`POST /pipeline/hypotheses/{id}/verdict` 陪葬端点/factors[] 透传 cause+decompose 终态 422/前端角标☠n/N+死因子行态+判死危险区+陪葬弹窗;commit 见 registry_events 同班车。 +``` + +factor-system-design.md(§7 因子域档案中死亡回写小节,无则 §7 末尾追加): + +```markdown +- **卡片死亡回写聚合化(决议 O,2026-10-10)**:`writeback_card_death` 新增 registry 参数,反查卡下全部因子——全 graveyard 才回写卡片(death_reason=「全灭:触发因子 {name} 入墓园:{cause}」);部分死=卡留活跃池(只影响卡上展示,角标☠n/N)。CLI 与 factor_verdict 端点两调用方同步改签名。设计全貌归流水线 spec §4.1。 +``` + +UX spec 变更记录追加: + +```markdown +- 2026-10-10:**决议 O 前端落地**——卡角标☠n/N(部分死=卡留活跃池)+死因子行态(划线/☠/死因 tooltip/不可点,修卡上死因子点击 404)+判死卡片危险区(陪葬清单弹窗+死因必填,对齐因子详情页危险区交互) +``` + +- [ ] **Step 3: Commit+push** + +```bash +git add docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md docs/factor_research/factor-system-design.md docs/superpowers/specs/2026-10-08-pipeline-console-ux-redesign.md +git commit -m "docs: 决议O实现落账三处——流水线spec状态行+因子域writeback聚合语义锚点(design-doc门)+UX变更记录 [nas]" +git push origin master +``` + +- [ ] **Step 4: 等 CI 绿**(`http://192.168.2.154:3000/sanguo/sanguo_vnpy_v2/actions`)→ nas-verify 绿后报用户「待推 VPS」清单,等「推vps」口令 dispatch(sha 用 `git rev-parse` 输出原样粘贴) + +--- + +## Self-Review 结论 + +- **Spec 覆盖**:决议 O 五要点→①Task1 ②Task2/Task5 ③Task3/Task4 ④Task3b ⑤不动项零改✓;挂账(promoted 触发点)明示不动✓ +- **占位符扫描**:唯一「以实际为准」点=Task5 的 `load()` 刷新函数名与 ElMessage import——执行时 30 秒可确认,非设计缺口 +- **类型一致**:`cause?: string | null`(TS)/`"cause": e.get("cause")`(PY dict)对齐;`buried: string[]` 端点↔前端一致 diff --git a/docs/superpowers/plans/2026-10-11-factor-dual-layer-b.md b/docs/superpowers/plans/2026-10-11-factor-dual-layer-b.md new file mode 100644 index 00000000..569c1588 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-factor-dual-layer-b.md @@ -0,0 +1,319 @@ +# 因子日度/月度双层分工 B 班实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 落地 2026-10-11 拍板的双层分工补丁(spec 4b4fc16 设计已载):①速览读最新(icStats/icFullT 数据源=根层∪daily/ 取 as_of 新者,判定层不动)②月度链①段复用同 as_of 日度批(检测存在→跳过重算)。 + +**Architecture:** B1 只改速览读取面——`_monthly_reports()` 加 `include_daily` 参数合并 daily/ 子目录件,`factors()`/`factor_detail()` 两个速览端点启用,判定层三端点(monthly-review/ic-trend/graveyard)调用形态不变仍只读根层。B2 在 `monthly_batch.main()` 月度模式下、`run_batch_eval` 之前检测 eval_db 已有「end=月末 且 label=daily_{月末} 且因子集一致且完成」的日度批→跳过 stage1 重算与 ic_gate,只留复用日志。 + +**Tech Stack:** FastAPI + pytest(tests/api),Python CLI + pytest(tests/factor),Vue3 + vitest(标签一处)。 + +## Global Constraints + +- 判定层读取路径一行不动:`monthly_review_view`/`factor_ic_trend`(衰减判定/研判卡/墓园触发语义=月末快照,只读根层)。它们对 `_monthly_reports()` 的调用**不得**传新参。 +- daily/ 子目录件与根层件同命名 `{host}_{as_of}.json` 同 schema(run_factor_daily_wrapper.ps1 用同一 monthly_review.main 落盘)。 +- 测试触及 factor registry 必须 `monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", )`(既有 conftest 模式)。 +- 前端验证权威=`cd frontend && npm run build`(vue-tsc -b 含 spec 文件);vitest 同跑。 +- 本仓 commit 不加 Co-Authored-By;commit subject 行尾打标签(本班两 task 均为纯实现,设计已在 4b4fc16 落档 → 标签=`[vps] [no-doc]`,两标签都在 subject 行尾)。 +- B2 复用条件必须同时满足:`label == f"daily_{plan['end']}"`、`end == plan['end']`、`factors_total == len(plan['factor_names'])` 且 `factors_done == factors_total`(完整批),并经 `eval_store.get_rows` 校验因子名集合与 plan 集合一致——因子集漂移(日度批后注册了新因子)时必须回退自算,不许部分复用。 +- B2 跳过时 stdout 打一行 `[monthly_batch] 复用日度批 run_id=... label=...(决议H:物理分开≠逻辑重叠,重算白烧),跳过 stage1 重算与 ic_gate`;exit code 0 不变。 + +--- + +### Task 1: 速览读最新——icStats/icFullT 根层∪daily/ 取 as_of 新者 + +**Files:** +- Modify: `sanguo_api/routes_pipeline.py`(`_monthly_reports` ~:602、`factors` ~:547、`factor_detail` ~:722) +- Modify: `frontend/src/views/pipeline/FactorDetail.vue:151` +- Test: `tests/api/test_routes_pipeline_factors.py`(追加用例) + +**Interfaces:** +- Consumes: `_monthly_dir()`(env `SANGUO_FACTOR_MONTHLY_DIR` 可覆写,测试用) +- Produces: `_monthly_reports(include_daily: bool = False)`——判定层三处不传参=行为不变;速览两处传 `include_daily=True` + +- [ ] **Step 1: 写失败测试(后端)** + +在 `tests/api/test_routes_pipeline_factors.py` 末尾追加(沿用 `test_factor_detail_ic_stats` 的既有模式——`_seed_factor_card` 建 fa_x、`SANGUO_FACTOR_MONTHLY_DIR` 指 tmp): + +```python +def test_factor_detail_ic_stats_prefers_newer_daily(client, tmp_path, monkeypatch): + """2026-10-11 双层分工补丁:icStats 读根层∪daily/ 取 as_of 新者—— + 根层 10-04 旧 schema 无 factor_stats,daily 件有真值→速览出真值.""" + import json as _json + name, _ = _seed_factor_card(client, name="fa_x") + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + (rep_dir / "vps_2026-10-04.json").write_text( + _json.dumps({"as_of": "2026-10-04", "monthly_points": {}}), "utf-8") # 旧 schema + daily_dir = rep_dir / "daily" + daily_dir.mkdir() + (daily_dir / "vps_2026-10-09.json").write_text(_json.dumps({ + "as_of": "2026-10-09", + "factor_stats": {"fa_x": {"icAll": 0.03, "tAll": 2.5, + "positiveRatio": 0.8, + "byYear": {"2026": 0.03}}}}), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + got = client.get(f"/api/v1/pipeline/factors/{name}/detail").json()["factor"]["icStats"] + assert got and got["tAll"] == 2.5 # daily 件新者胜,不再被旧根层件挡住 + + +def test_factor_detail_ic_stats_root_newer_wins(client, tmp_path, monkeypatch): + """根层件更新时仍胜 daily(取 as_of 新者,不是 daily 优先).""" + import json as _json + name, _ = _seed_factor_card(client, name="fa_x") + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + (rep_dir / "vps_2026-11-04.json").write_text(_json.dumps({ + "as_of": "2026-11-04", + "factor_stats": {"fa_x": {"icAll": 0.05, "tAll": 3.3, + "positiveRatio": 0.9, "byYear": {}}}}), "utf-8") + daily_dir = rep_dir / "daily" + daily_dir.mkdir() + (daily_dir / "vps_2026-10-20.json").write_text(_json.dumps({ + "as_of": "2026-10-20", + "factor_stats": {"fa_x": {"icAll": 0.01, "tAll": 1.1, + "positiveRatio": 0.5, "byYear": {}}}}), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + got = client.get(f"/api/v1/pipeline/factors/{name}/detail").json()["factor"]["icStats"] + assert got and got["tAll"] == 3.3 + + +def test_judgement_layer_ignores_daily_dir(client, tmp_path, monkeypatch): + """判定层不动:monthly-review 仍只读根层,daily 件不得泄漏进速览卡/对拍.""" + import json as _json + rep_dir = tmp_path / "reports" / "factor_monthly" + rep_dir.mkdir(parents=True) + (rep_dir / "vps_2026-10-04.json").write_text(_json.dumps({ + "as_of": "2026-10-04", "factors": {"fa_old": {"t": 1.0}}, + "collective": {"median": 2.1, "floor": 2.0}}), "utf-8") + daily_dir = rep_dir / "daily" + daily_dir.mkdir() + (daily_dir / "vps_2026-10-09.json").write_text(_json.dumps({ + "as_of": "2026-10-09", "factors": {"fa_new": {"t": 9.9}}, + "collective": {"median": 9.9, "floor": 2.0}}), "utf-8") + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(rep_dir)) + got = client.get("/api/v1/pipeline/monthly-review").json() + assert got["runDate"] == "2026-10-04" # 仍是根层最新,不被 daily 顶掉 + assert got["factorsEvaluated"] == 1 # daily 的 fa_new 不算进来 +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `venv310/bin/python -m pytest tests/api/test_routes_pipeline_factors.py -q -k "daily or root_newer or ignores_daily"` +Expected: 前两例 FAIL(icStats 为 None/被旧根层挡)、第三例 FAIL(runDate 变成 2026-10-09——若 include_daily 默认误开)或 PASS(默认关时——那是防回归锚,保留)。 + +- [ ] **Step 3: 实现 `_monthly_reports(include_daily)` + 两速览端点接线** + +`routes_pipeline.py` `_monthly_reports` 改为: + +```python +def _monthly_reports(include_daily: bool = False) -> list[tuple[str, str, dict]]: + """monthly dir 全部报告 [(host, as_of, doc)];按 as_of 降序,坏文件跳过. + + 文件名={host}_{as_of}.json(monthly_review.main 落盘原文)。 + include_daily=True 时并入 daily/ 子目录件(2026-10-11 双层分工补丁: + 速览读最新——根层∪daily 取 as_of 新者);判定层(月末快照语义)不传参, + 仍只读根层,一行不动。 + """ + d = _monthly_dir() + if not os.path.isdir(d): + return [] + out: list[tuple[str, str, dict]] = [] + + def _scan(folder: str) -> None: + for fn in os.listdir(folder): + if not fn.endswith(".json"): + continue + host, _, as_of = fn[:-5].rpartition("_") + if not host or len(as_of) != 10 or as_of[4] != "-": + continue + try: + with open(os.path.join(folder, fn), encoding="utf-8") as f: + out.append((host, as_of, json.load(f))) + except (OSError, json.JSONDecodeError): + continue + + _scan(d) + if include_daily: + daily = os.path.join(d, "daily") + if os.path.isdir(daily): + _scan(daily) + out.sort(key=lambda t: t[1], reverse=True) + return out +``` + +`factors()` 内 `reports = _monthly_reports()` → `reports = _monthly_reports(include_daily=True)`(连同上方注释补一句「∪daily/ 取新(2026-10-11 补丁)」);`factor_detail()` 内同样改传 `include_daily=True`。`monthly_review_view`/`factor_ic_trend` 两处调用**不改**。 + +- [ ] **Step 4: 跑后端测试全绿** + +Run: `venv310/bin/python -m pytest tests/api/test_routes_pipeline_factors.py tests/api/test_routes_pipeline_monthly.py -q` +Expected: 全 PASS(含既有 `test_factor_detail_ic_stats`/`test_factors_list_ic_full_t_from_stats`——根层单件形态行为不变)。 + +- [ ] **Step 5: 前端标签改「末次评估批」** + +`frontend/src/views/pipeline/FactorDetail.vue:151`:`末次月度批` → `末次评估批`。检查 `frontend/src/views/pipeline/` 下有无其他「末次月度批」字样(`grep -rn 末次月度批 frontend/src/` 应零残留)与 FactorDetail.spec.ts 是否断言该文案(有则同步改「末次评估批」)。 + +- [ ] **Step 6: 前端验证(权威=build)** + +Run: `cd frontend && npm run build && npx vitest run src/views/pipeline/FactorDetail.spec.ts 2>/dev/null || npx vitest run` +Expected: build 绿 + vitest 绿。 + +- [ ] **Step 7: Commit** + +```bash +git add sanguo_api/routes_pipeline.py frontend/src/views/pipeline/FactorDetail.vue tests/api/test_routes_pipeline_factors.py frontend/src/views/pipeline/FactorDetail.spec.ts 2>/dev/null || git add -A +git commit -m "feat(api): 弹药速览读最新——icStats/icFullT数据源改根层∪daily/子目录取as_of新者(根层10-04旧schema无factor_stats不再挡住daily真值),判定层三端点仍只读根层一行不动;前端末次月度批标签改末次评估批(2026-10-11双层分工补丁B1,设计见4b4fc16) [vps] [no-doc]" +``` + +(commit 前 `git log --format=%s -1` 自查标签在 subject 行尾。) + +--- + +### Task 2: 月度链①段复用同 as_of 日度批——检测存在跳过重算 + +**Files:** +- Modify: `sanguo_factor/monthly_batch.py`(main 内 `run_batch_eval` 调用段 ~:207-231) +- Test: `tests/factor/test_monthly_batch.py`(追加用例) + +**Interfaces:** +- Consumes: `eval_store.list_runs(db)`(dict 含 label/end/factors_total/factors_done/run_id)、`eval_store.get_rows(db, run_id)`(dict 含 factor)、`plan` dict(`factor_names`/`start`/`end`/`label`) +- Produces: 模块级函数 `_reusable_daily_run(db: str, plan: dict) -> dict | None` + +- [ ] **Step 1: 写失败测试** + +在 `tests/factor/test_monthly_batch.py` 末尾追加(沿用既有 `registry`/`tmp_path` fixture 与 `monkeypatch.setattr(monthly_batch, "run_batch_eval", ...)` 模式;用真实 sqlite eval_db 造日度批): + +```python +def test_monthly_reuses_daily_batch_same_end(monkeypatch, registry, tmp_path, capsys): + """2026-10-11 双层分工补丁:月度模式检测同 end 日度批已存在→跳过重算 + (物理分开≠逻辑重叠,40min 重算白烧);ic_gate 一并跳过(日度链已跑).""" + from sanguo_factor import eval_store + db = str(tmp_path / "eval.db") + names = sorted(f["name"] for f in registry["factors"].values()) + rid = eval_store.create_run(db, "daily_2026-09-30", "csi300", 500, + len(names), "2025-10-01", "2026-09-30", {}) + eval_store.save_results(db, rid, [{"factor": n, "category": "c", + "expression": "e", "ic_mean": 0.01, + "ic_ir": 0.1} for n in names]) + eval_store.finish_run(db, rid, "ok", len(names)) + called = [] + monkeypatch.setattr(monthly_batch, "run_batch_eval", + lambda *a, **k: called.append(a) or {}) + rc = monthly_batch.main(["--as-of", "2026-10-04", "--registry", + os.environ["SANGUO_FACTOR_REGISTRY"], + "--eval-db", db, "--report-dir", + str(tmp_path / "rep")]) + assert rc == 0 and not called # 未重算 + assert "复用日度批" in capsys.readouterr().out + + +def test_monthly_reruns_when_daily_factor_set_drifts(monkeypatch, registry, tmp_path, capsys): + """因子集漂移(日度批后注册新因子)→不许部分复用,回退自算.""" + from sanguo_factor import eval_store + db = str(tmp_path / "eval.db") + names = sorted(f["name"] for f in registry["factors"].values()) + partial = names[:-1] # 少一个=覆盖不全 + rid = eval_store.create_run(db, "daily_2026-09-30", "csi300", 500, + len(partial), "2025-10-01", "2026-09-30", {}) + eval_store.save_results(db, rid, [{"factor": n, "category": "c", + "expression": "e", "ic_mean": 0.01, + "ic_ir": 0.1} for n in partial]) + eval_store.finish_run(db, rid, "ok", len(partial)) + called = [] + monkeypatch.setattr(monthly_batch, "run_batch_eval", + lambda *a, **k: called.append(a) or {"status": "ok"}) + rc = monthly_batch.main(["--as-of", "2026-10-04", "--registry", + os.environ["SANGUO_FACTOR_REGISTRY"], + "--eval-db", db, "--report-dir", + str(tmp_path / "rep")]) + assert rc == 0 and called # 回退自算 + + +def test_daily_mode_never_reuses(monkeypatch, registry, tmp_path): + """日度模式自身不进复用分支(同 label 重跑=幂等覆盖,不查旧批).""" + from sanguo_factor import eval_store + db = str(tmp_path / "eval.db") + names = sorted(f["name"] for f in registry["factors"].values()) + rid = eval_store.create_run(db, "daily_2026-09-30", "csi300", 500, + len(names), "2025-10-01", "2026-09-30", {}) + eval_store.save_results(db, rid, [{"factor": n, "category": "c", + "expression": "e", "ic_mean": 0.01, + "ic_ir": 0.1} for n in names]) + eval_store.finish_run(db, rid, "ok", len(names)) + called = [] + monkeypatch.setattr(monthly_batch, "run_batch_eval", + lambda *a, **k: called.append(a) or {}) + monthly_batch.main(["--as-of", "2026-09-30", "--daily", "--registry", + os.environ["SANGUO_FACTOR_REGISTRY"], + "--eval-db", db, "--report-dir", + str(tmp_path / "rep")]) + assert called +``` + +注意:`create_run`/`save_results`/`finish_run` 的参数名以 `sanguo_factor/eval_store.py` 实签名为准(行内值不对就照实际改);`registry` fixture 的因子若含 retired/graveyard 全被剔则 plan 因子集=被剔后的名单——测试断言前先看 `plan_batch` 实际产出的 names(必要时打 print 调整)。若既有测试里 main 调用还须其他参数(vnpy-db/fund-data-dir 缺省路径),照 `test_main_daily_flag_reaches_plan` 抄全参数形态。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `venv310/bin/python -m pytest tests/factor/test_monthly_batch.py -q -k "reuses or drifts or never_reuses"` +Expected: `test_monthly_reuses_daily_batch_same_end` FAIL(当前必重算)、`test_daily_mode_never_reuses` PASS(现状即如此,防回归锚)。 + +- [ ] **Step 3: 实现 `_reusable_daily_run` + main 接线** + +`monthly_batch.py` 模块级加: + +```python +def _reusable_daily_run(db: str, plan: dict[str, Any]) -> dict | None: + """月末批可复用的同 end 日度批(2026-10-11 双层分工补丁,决议H: + 物理分开≠逻辑重叠——次月1日夜班已产出 end=月末 的日度批时,月度链 + stage1 不重算 40min,直接复用)。全条件满足才复用:label/end 钉住同 + as_of、因子集一致(漂移=回退自算,不许部分复用)、完整批。 + """ + from sanguo_factor import eval_store + want = f"daily_{plan['end']}" + plan_names = set(plan["factor_names"]) + for r in eval_store.list_runs(db): + if (r.get("label") == want and r.get("end") == plan["end"] + and r.get("factors_total") == len(plan_names) + and r.get("factors_done") == len(plan_names)): + got = {row["factor"] for row in eval_store.get_rows(db, r["run_id"])} + if got == plan_names: + return r + return None +``` + +`main()` 内 stage1 段改造——`print(f"[monthly_batch] {len(plan['factor_names'])} 因子 ...")` 之后、`values_dir = ...` 之前插: + +```python + if args.daily: + summary = run_batch_eval(...) # 原调用原样保留 + elif (reuse := _reusable_daily_run(db, plan)) is not None: + print(f"[monthly_batch] 复用日度批 run_id={reuse['run_id']} " + f"label={reuse['label']}(决议H:物理分开≠逻辑重叠,重算白烧)," + f"跳过 stage1 重算与 ic_gate") + # stage2 monthly_review 经 pick_eval_run 自然选中该批(end<=as_of 最新) + return 0 + else: + summary = run_batch_eval(...) # 原调用原样 +``` + +(实现时把原 `run_batch_eval` 调用抽成局部变量/保持单处调用点+前置分支均可,以最小 diff 为准;跳过路径**不写** values/ic_gate 产物——日度链跑时已出过同 as_of 的。)`return 0` 前确认函数尾部没有其他必须执行的收尾(对比原 main 的 return 路径)。 + +- [ ] **Step 4: 跑 factor 域测试全绿** + +Run: `venv310/bin/python -m pytest tests/factor/test_monthly_batch.py tests/factor/test_monthly_review.py -q` +Expected: 全 PASS(含既有 `test_main_invokes_run_batch_eval_with_plan`——空 eval_db 时回退自算路径不变)。 + +- [ ] **Step 5: Commit** + +```bash +git add sanguo_factor/monthly_batch.py tests/factor/test_monthly_batch.py +git commit -m "feat(factor): 月度链①段复用同as_of日度批——月末批stage1检测eval_db已有end=月末且label=daily_的完整批(因子集一致才复用,漂移回退自算)则跳过40min重算与ic_gate(2026-10-11双层分工补丁B2,设计见4b4fc16;11-01月度班首受益) [vps] [no-doc]" +``` + +--- + +## 收尾(controller 做) + +- `venv310/bin/python -m pytest tests/api tests/factor -q` 全量回归 +- `cd frontend && npm run build` 终验 +- push → CI(nas-verify)绿后报用户:弹药速览有真值可去 VPS 验证(部署等用户口令或搭下班车)