feat(api): monthly-review/ic-trend 接真——10-01 用户拍板「接真不删页」P1 件 [vps]
CI/CD / test (push) Successful in 46s
CI/CD / nas-deploy (push) Successful in 3s
CI/CD / nas-verify (push) Successful in 7s

审计 10-01 P1-12 三 mock 端点之二接真(读现成月度批评产物,hypotheses 留 P4):
- GET /pipeline/monthly-review: 本机 monthly dir 最新报告 JSON 原文→
  period/runDate/factorsEvaluated/consensusMedianT(collective.median)/
  consensusThreshold(collective.floor);首班 10-04 前 null(前端 PanelCard
  隐藏不造数);crossCheck 仅同 as_of 双 host 报告同 dir 才出双行(单机=空
  列表,不伪造"一致"),maxAbsDiff=同因子 t 差最大值,>0.05(起步默认)判分歧
- GET /pipeline/factors/{name}/ic-trend: 最新报告 monthly_points 月度 t
  序列(t=None 月如实跳过);baseline=晋级 promotion_t×0.5(相对降幅过半
  红线,衰减条件①),未晋级=2.0 绝对地板(条件②);无报告/无点 404
- mock/index.ts: 两端点条目退役(逐端点删条目纪律,composite 先例);
  PIPELINE_MOCK 仅剩 hypotheses(P4);全接真后删机制=在案退出条件
- spec §7 数据行接真进度同步(档随码走)
- 测试 tests/api/test_routes_pipeline_monthly.py 8 例(单机/双机一致/分歧/
  baseline 两态/404 两态)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:51:27 +08:00
parent 0ba59fa776
commit aa607d7e82
4 changed files with 248 additions and 4 deletions
+95
View File
@@ -394,6 +394,101 @@ def factors() -> dict:
return {"items": items}
_CROSS_DIFF_MAX = 0.05 # 双机对拍分歧阈(起步默认,首年校准;spec 对拍规则未定数)
def _monthly_reports() -> list[tuple[str, str, dict]]:
"""monthly dir 全部报告 [(host, as_of, doc)];按 as_of 降序,坏文件跳过.
文件名={host}_{as_of}.json(monthly_review.main 落盘原文)。
"""
d = _monthly_dir()
if not os.path.isdir(d):
return []
out: list[tuple[str, str, dict]] = []
for fn in os.listdir(d):
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(d, fn), encoding="utf-8") as f:
out.append((host, as_of, json.load(f)))
except (OSError, json.JSONDecodeError):
continue
out.sort(key=lambda t: t[1], reverse=True)
return out
def _machine_label(host: str) -> str:
return "NAS" if "nas" in host.lower() else "VPS"
@router.get("/pipeline/monthly-review")
def monthly_review_view() -> dict | None:
"""月度批评卡(10-01 用户拍板接真):读本机 monthly dir 最新报告 JSON 原文.
首班(10-04)未跑→null(前端 PanelCard 隐藏,不造数);
crossCheck 只在同 as_of 双 host 报告都落在本机 dir 时才出双行
(对拍件汇合后自动生效),单机=空列表(不伪造"一致");
maxAbsDiff=两机同因子 t 差绝对值最大值,> _CROSS_DIFF_MAX 判"分歧"。
"""
reports = _monthly_reports()
if not reports:
return None
host, as_of, doc = reports[0]
factors = doc.get("factors") or {}
col = doc.get("collective") or {}
peers = {h: d for h, a, d in reports if a == as_of}
cross: list[dict[str, Any]] = []
if len(peers) >= 2:
hosts = sorted(peers)
fa = peers[hosts[0]].get("factors") or {}
fb = peers[hosts[1]].get("factors") or {}
common = [f for f in fa.keys() & fb.keys()
if (fa.get(f) or {}).get("t") is not None
and (fb.get(f) or {}).get("t") is not None]
max_diff = max((abs(fa[f]["t"] - fb[f]["t"]) for f in common),
default=0.0)
verdict = "一致" if max_diff <= _CROSS_DIFF_MAX else "分歧"
cross = [{"machine": _machine_label(h),
"factors": len(peers[h].get("factors") or {}),
"maxAbsDiff": round(max_diff, 6), "verdict": verdict}
for h in hosts[:2]]
return {"period": as_of[:7], "runDate": as_of,
"factorsEvaluated": len(factors),
"consensusMedianT": col.get("median"),
"consensusThreshold": col.get("floor"),
"crossCheck": cross}
@router.get("/pipeline/factors/{name}/ic-trend")
def factor_ic_trend(name: str) -> dict:
"""IC 趋势图(10-01 用户拍板接真):最新报告 monthly_points 的月度 t 序列.
baseline 红线=已晋级因子 promotion_t×0.5(相对降幅过半位置,触点②
衰减条件①);未晋级/无基准=2.0 绝对地板(衰减条件②)——衰减告警需
两条同时满足+连续 3 月,t 为 None 的月如实跳过。
"""
reports = _monthly_reports()
if not reports:
raise HTTPException(404, "月度批评首班未跑,无 monthly_points")
_, _, doc = reports[0]
pts = (doc.get("monthly_points") or {}).get(name)
if not pts:
raise HTTPException(404, f"因子无月度点: {name}")
pairs = [(p["month"], p["t"]) for p in pts if p.get("t") is not None]
from sanguo_factor import version_registry as vr
from sanguo_portfolio.strategy_registry import ensure_runtime_registry
reg = vr.load_registry(ensure_runtime_registry(
_factor_registry_path(), os.path.join("config", "factor_registry.yaml")))
promotion_t = (reg["factors"].get(name) or {}).get("promotion_t")
baseline = round(promotion_t * 0.5, 4) if promotion_t else 2.0
return {"dates": [m for m, _ in pairs],
"rollingT": [t for _, t in pairs], "baseline": baseline}
@router.post("/pipeline/factors/{name}/verdict")
def factor_verdict(name: str, body: dict) -> dict:
"""晋级评审三按钮(触点②):promote/graveyard 走 version_registry 状态机;