From aa607d7e827926a06ed8b6a69b4ceeaafcd9e4e3 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 1 Oct 2026 09:51:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20monthly-review/ic-trend=20?= =?UTF-8?q?=E6=8E=A5=E7=9C=9F=E2=80=94=E2=80=9410-01=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=8B=8D=E6=9D=BF=E3=80=8C=E6=8E=A5=E7=9C=9F=E4=B8=8D=E5=88=A0?= =?UTF-8?q?=E9=A1=B5=E3=80=8DP1=20=E4=BB=B6=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计 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 --- ...-22-research-to-trading-pipeline-design.md | 2 +- frontend/src/mock/index.ts | 5 +- sanguo_api/routes_pipeline.py | 95 +++++++++++ tests/api/test_routes_pipeline_monthly.py | 150 ++++++++++++++++++ 4 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 tests/api/test_routes_pipeline_monthly.py diff --git a/docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md b/docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md index 198dd17a..c3624e31 100644 --- a/docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md +++ b/docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md @@ -212,7 +212,7 @@ corpus 新增语料 → 第一层确定性加工(词表/规则,S 族范式,零 |--------|------| | 定位 | 全流程体验+设计验证+未来驾驶舱;spec 定稿后**第一实现项** | | 风格 | 与 vnpy.mysanguo 完全一致:同栈(Vue+TS,随 nas-frontend 构建)、同组件库、同布局语言与主题 | -| 数据 | 高仿真 mock:真实因子名(pledge_net_chg/res_target_upside…)、真实策略名(value/all_weather/momentum/channel)、IC/告警量级仿真真值。10-01 审计 P1-12 后 mock 改 DEV 门(`PIPELINE_MOCK=import.meta.env.DEV`):生产构建不再短路——VPS 已挂生产控制台,假数据混真页面会误导晋级判读;接真进度=composite/ladder/todos/factors/attribution/research-card 已真,hypotheses/monthly-review/ic-trend 待 P1/P4(未接真页面生产退化空态) | +| 数据 | 高仿真 mock:真实因子名(pledge_net_chg/res_target_upside…)、真实策略名(value/all_weather/momentum/channel)、IC/告警量级仿真真值。10-01 审计 P1-12 后 mock 改 DEV 门(`PIPELINE_MOCK=import.meta.env.DEV`):生产构建不再短路——VPS 已挂生产控制台,假数据混真页面会误导晋级判读;同日用户拍板「接真不删页」后 monthly-review/ic-trend 接真(读 monthly dir 报告原文;首班 10-04 前如实 null/404,crossCheck 仅双 host 对拍件汇合才出双行);接真进度=composite/ladder/todos/factors/attribution/research-card/monthly-review/ic-trend 已真,唯 hypotheses 待 P4(生产退化空态;全接真后删 mock 机制=在案退出条件) | | 边界 | 数据层=adapter 接口(mock 实现↔未来真后端实现可切换),页面代码不因换数据源重写 | **七页清单**: diff --git a/frontend/src/mock/index.ts b/frontend/src/mock/index.ts index 48520b9b..26bcb62d 100644 --- a/frontend/src/mock/index.ts +++ b/frontend/src/mock/index.ts @@ -121,9 +121,8 @@ export const PIPELINE_MOCK = import.meta.env.DEV const pipelineRoutes: Route[] = [ { m: 'get', re: /^\/pipeline\/hypotheses$/, build: () => ({ items: P.hypothesesMock }) }, - { m: 'get', re: /^\/pipeline\/monthly-review$/, build: () => P.monthlyReviewMock }, - // /pipeline/composite 已接真端点(P1, 09-27): 不再 mock, 走 sanguo_api - { m: 'get', re: /^\/pipeline\/factors\/[^/]+\/ic-trend$/, build: (url) => P.icTrendMock(url.split('/')[3]) }, + // /pipeline/composite 已接真(P1, 09-27);monthly-review/ic-trend 已接真 + // (P1, 10-01 用户拍板「接真不删页」):均不再 mock,走 sanguo_api ] export function matchPipelineMock(method: string, url: string): unknown | undefined { diff --git a/sanguo_api/routes_pipeline.py b/sanguo_api/routes_pipeline.py index f7cb9e25..0958357c 100644 --- a/sanguo_api/routes_pipeline.py +++ b/sanguo_api/routes_pipeline.py @@ -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 状态机; diff --git a/tests/api/test_routes_pipeline_monthly.py b/tests/api/test_routes_pipeline_monthly.py new file mode 100644 index 00000000..635c66d0 --- /dev/null +++ b/tests/api/test_routes_pipeline_monthly.py @@ -0,0 +1,150 @@ +"""GET /pipeline/monthly-review + ic-trend 真端点单测(10-01 用户拍板接真). + +契约=前端 MonthlyReview {period,runDate,factorsEvaluated,consensusMedianT, +consensusThreshold,crossCheck[{machine,factors,maxAbsDiff,verdict}]} 与 +IcTrend {dates,rollingT,baseline};数据源=monthly dir 报告 JSON 原文 +(monthly_review.main 落盘)。首班(10-04)前无报告→monthly-review null/ +ic-trend 404(前端 PanelCard 隐藏,不造数)。 +""" +import json + +import pytest +import yaml +from fastapi.testclient import TestClient + +from sanguo_api.app import create_app +from sanguo_api.auth import set_jwt_config, create_token + + +@pytest.fixture() +def client(tmp_path): + # jwt secret 每测试现设现签(全量套件中其它测试会改全局 secret) + set_jwt_config(secret="test", expire_minutes=60) + app = create_app(db_path=str(tmp_path / "t.db"), file_dir=None) + c = TestClient(app) + c.headers.update({"Authorization": f"Bearer {create_token('admin')}"}) + return c + + +@pytest.fixture() +def mdir(tmp_path, monkeypatch): + d = tmp_path / "factor_monthly" + d.mkdir() + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(d)) + return d + + +@pytest.fixture() +def freg(tmp_path, monkeypatch): + seed = tmp_path / "factor_registry.yaml" + seed.write_text(yaml.safe_dump({"factors": { + "fa_test_promoted": {"hypothesis": "H-t", "status": "promoted", + "promotion_t": 5.0}, + "s01_test_incubating": {"hypothesis": "H-t2", + "status": "incubating"}, + }}), encoding="utf-8") + monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(seed)) + return seed + + +def _report(host: str, as_of: str, factors: dict, monthly_points: dict, + median=1.35) -> dict: + return {"host": host, "as_of": as_of, + "generated_at": "2026-10-04T10:00:00", + "factors": factors, "verdicts": {}, "observations": {}, + "collective": {"median": median, "floor": 1.0, + "is_collective_decay": False, "n": 2}, + "monthly_points": monthly_points, "unregistered": [], + "pre_gate": []} + + +def _write(d, host: str, as_of: str, doc: dict) -> None: + (d / f"{host}_{as_of}.json").write_text( + json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +def test_monthly_review_no_dir_returns_null(client, tmp_path, monkeypatch): + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", + str(tmp_path / "nonexistent")) + r = client.get("/api/v1/pipeline/monthly-review") + assert r.status_code == 200 and r.json() is None + + +def test_monthly_review_single_host_cross_empty(client, mdir): + _write(mdir, "nas", "2026-09-30", + _report("nas", "2026-09-30", + {"fa_test_promoted": {"t": 3.9}, "s01": {"t": None}}, + {"fa_test_promoted": [{"month": "2026-09", "t": 3.9}]})) + r = client.get("/api/v1/pipeline/monthly-review") + assert r.status_code == 200 + body = r.json() + assert body["period"] == "2026-09" and body["runDate"] == "2026-09-30" + assert body["factorsEvaluated"] == 2 + assert body["consensusMedianT"] == 1.35 and body["consensusThreshold"] == 1.0 + assert body["crossCheck"] == [] # 单机不伪造"一致" + + +def test_monthly_review_two_hosts_agree(client, mdir): + pts = {"fa": {"t": 3.901}, "fb": {"t": 2.2}, "fc": {"t": None}} + _write(mdir, "nas", "2026-09-30", _report("nas", "2026-09-30", pts, {})) + _write(mdir, "vps-prod", "2026-09-30", + _report("vps-prod", "2026-09-30", + {"fa": {"t": 3.904}, "fb": {"t": 2.2}, "fc": {"t": 1.1}}, + {})) + r = client.get("/api/v1/pipeline/monthly-review") + rows = r.json()["crossCheck"] + assert len(rows) == 2 + assert {row["machine"] for row in rows} == {"NAS", "VPS"} + # fc 在 nas 侧 t=None 不入 common;最大差=fa 的 0.003 + assert all(row["maxAbsDiff"] == 0.003 and row["verdict"] == "一致" + for row in rows) + assert {row["factors"] for row in rows} == {3, 3} + + +def test_monthly_review_two_hosts_diverge(client, mdir): + _write(mdir, "nas", "2026-09-30", + _report("nas", "2026-09-30", {"fa": {"t": 3.0}}, {})) + _write(mdir, "vps-prod", "2026-09-30", + _report("vps-prod", "2026-09-30", {"fa": {"t": 2.0}}, {})) + r = client.get("/api/v1/pipeline/monthly-review") + assert all(row["verdict"] == "分歧" and row["maxAbsDiff"] == 1.0 + for row in r.json()["crossCheck"]) + + +def test_ic_trend_promoted_baseline_half_and_none_skipped( + client, mdir, freg): + _write(mdir, "nas", "2026-09-30", + _report("nas", "2026-09-30", {}, + {"fa_test_promoted": [ + {"month": "2026-07", "t": 5.1}, + {"month": "2026-08", "t": None}, + {"month": "2026-09", "t": 4.9}]})) + r = client.get("/api/v1/pipeline/factors/fa_test_promoted/ic-trend") + assert r.status_code == 200 + body = r.json() + assert body["dates"] == ["2026-07", "2026-09"] # None 月如实跳过 + assert body["rollingT"] == [5.1, 4.9] + assert body["baseline"] == 2.5 # promotion_t 5.0 × 0.5(相对降幅过半红线) + + +def test_ic_trend_incubating_baseline_floor(client, mdir, freg): + _write(mdir, "nas", "2026-09-30", + _report("nas", "2026-09-30", {}, + {"s01_test_incubating": [{"month": "2026-09", + "t": 0.8}]})) + r = client.get("/api/v1/pipeline/factors/s01_test_incubating/ic-trend") + assert r.status_code == 200 + body = r.json() + assert body["baseline"] == 2.0 # 未晋级=绝对地板(衰减条件②) + assert body["rollingT"] == [0.8] + + +def test_ic_trend_no_report_404(client, mdir, freg): + r = client.get("/api/v1/pipeline/factors/fa_test_promoted/ic-trend") + assert r.status_code == 404 + + +def test_ic_trend_unknown_factor_404(client, mdir, freg): + _write(mdir, "nas", "2026-09-30", _report("nas", "2026-09-30", {}, {})) + r = client.get("/api/v1/pipeline/factors/nope/ic-trend") + assert r.status_code == 404