docs(factor): 工程坑清单#10月数学1-based减法归零(window_start产2026-00-01案,20a3b89根治)+B班plan落档 [nas]
CI/CD / test (push) Successful in 46s
CI/CD / nas-deploy (push) Successful in 0s
CI/CD / nas-verify (push) Successful in 17s

This commit is contained in:
2026-10-11 10:01:39 +08:00
parent 20a3b891b9
commit cdeea605a3
3 changed files with 1068 additions and 0 deletions
@@ -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 月)必测 |
---
@@ -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 <file> -q`(项目根目录)
- 前端测试跑法:`cd frontend && npx vitest run <file>`;类型检查 `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
<StatusChip :label="HYPOTHESIS_LABEL[h.state]" :tone="HYPOTHESIS_TONE[h.state]" />
<span v-if="deadFactorCount(h)" class="skull-badge"
:title="`卡下 ${deadFactorCount(h)}/${h.factors!.length} 因子已判死(卡留活跃池)`">
☠{{ deadFactorCount(h) }}/{{ h.factors!.length }}
</span>
```
3c. fac-row 死因子行态(:266-270 改造——fac-name 分死活两分支):
```html
<div class="fac-row">
<span v-if="f.status === 'graveyard'" class="fac-name dead"
:title="`☠ ${f.cause ?? '未注死因'}`">☠ {{ f.name }}</span>
<span v-else class="fac-name" title="看出生档案"
@click="router.push(`/pipeline/factors/${f.name}`)">{{ f.name }}</span>
<code class="fac-expr">{{ f.expression }}</code>
<span class="fac-date">{{ f.createdAt }}</span>
</div>
```
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
<div v-if="canDecompose(h) || isDecomposing(h) || killable(h)" class="hcard-ops">
<button v-if="canDecompose(h) || isDecomposing(h)" class="wiz-btn"
:disabled="isDecomposing(h)"
@click="runDecompose(h)">
{{ isDecomposing(h) ? '分解中(后台执行,可离开本页)…' : '⚡ AI 分解' }}
</button>
<button v-if="killable(h)" class="wiz-btn danger"
@click="openKill(h)">☠ 判死卡片</button>
</div>
```
配套 helper(deadFactorCount 旁):
```typescript
const killable = (h: Hypothesis): boolean =>
h.state !== 'graveyard' && h.state !== 'promoted'
```
2c. 弹窗(template 根部列表之后追加):
```html
<el-dialog :model-value="kill !== null" title="☠ 判死卡片(危险区)"
width="480px" @update:model-value="kill = null">
<template v-if="kill">
<div class="kill-warn">
强断言「想法死了」——卡下活因子将全部陪葬入墓园(终态不可逆,重推想法=开新卡)。
</div>
<div v-if="killBuriable.length" class="kill-list">
<div class="kill-list-h">陪葬清单({{ killBuriable.length }} 条):</div>
<div v-for="f in killBuriable" :key="f.name" class="kill-item">
<span class="mono">{{ f.name }}</span>
<span class="dim">{{ f.status }}</span>
</div>
</div>
<div v-else class="kill-list dim">本卡无因子——纯想法毙掉,无陪葬。</div>
<el-input v-model="kill.reason" type="textarea" :rows="2"
placeholder="死因(必填,可检索防重复造轮)" style="margin-top: 10px" />
</template>
<template #footer>
<el-button @click="kill = null">取消</el-button>
<el-button type="danger" :loading="kill?.loading"
@click="confirmKill">确认判死</el-button>
</template>
</el-dialog>
```
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[]` 端点↔前端一致
@@ -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", <tmp>)`(既有 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`:`<span class="k">末次月度批</span>` → `<span class="k">末次评估批</span>`。检查 `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 验证(部署等用户口令或搭下班车)