docs(factor): 工程坑清单#10月数学1-based减法归零(window_start产2026-00-01案,20a3b89根治)+B班plan落档 [nas]
This commit is contained in:
@@ -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 验证(部署等用户口令或搭下班车)
|
||||
Reference in New Issue
Block a user