diff --git a/frontend/src/api/pipeline.ts b/frontend/src/api/pipeline.ts index a25c3bf6..8c81930a 100644 --- a/frontend/src/api/pipeline.ts +++ b/frontend/src/api/pipeline.ts @@ -253,6 +253,33 @@ export async function getCompositeChallenge(): Promise<{ challenge: ChallengeDoc return data } +// —— 流水线站点条(D1 地铁图融总览,v4 稿 SUBWAY 落地) —— + +export interface PipelineStation { + name: string + count: number + pending: number + who: string | null +} + +export interface StationEdge { + from: string + to: string + cond: string + trig: '人工' | '自动' | '自动上板' | '人工+自动判' +} + +export interface HealthCell { + name: string + value: string + state: 'ok' | 'warn' | 'idle' +} + +export async function getStations(): Promise<{ stations: PipelineStation[]; edges: StationEdge[]; health: HealthCell[] }> { + const { data } = await apiClient.get<{ stations: PipelineStation[]; edges: StationEdge[]; health: HealthCell[] }>('/pipeline/stations') + return data +} + export async function getLadder(): Promise { const { data } = await apiClient.get<{ items: LadderStrategy[] }>('/pipeline/ladder') return data.items diff --git a/frontend/src/views/pipeline/Home.spec.ts b/frontend/src/views/pipeline/Home.spec.ts index 2178146d..ba0ddad1 100644 --- a/frontend/src/views/pipeline/Home.spec.ts +++ b/frontend/src/views/pipeline/Home.spec.ts @@ -8,7 +8,7 @@ import { todosMock } from '@/mock/pipelineData' vi.mock('@/api/pipeline', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, getTodos: vi.fn() } + return { ...actual, getTodos: vi.fn(), getStations: vi.fn() } }) const router = createRouter({ @@ -16,15 +16,56 @@ const router = createRouter({ routes: [{ path: '/:pathMatch(.*)*', component: { template: '
' } }], }) -describe('Home.vue 驾驶舱待办', () => { +const stationsMock = [ + { name: '假设池', count: 3, pending: 0, who: null }, + { name: '因子工厂', count: 8, pending: 0, who: null }, + { name: '晋级评审', count: 1, pending: 1, who: 'fa_hot 待五问' }, + { name: '合成层', count: 2, pending: 0, who: null }, + { name: '策略出生', count: 0, pending: 0, who: null }, + { name: '孵化梯', count: 2, pending: 0, who: null }, + { name: '实盘', count: 1, pending: 0, who: null }, +] + +const healthMock = [ + { name: '衰减告警', value: '0', state: 'ok' as const }, + { name: '集体水位', value: '正常', state: 'ok' as const }, + { name: '数据缺口', value: '1', state: 'warn' as const }, + { name: '数据链路', value: '—', state: 'idle' as const }, + { name: 'as_of', value: '2026-09-30', state: 'idle' as const }, +] + +describe('Home.vue 流水线全景(v4 落地:站点条+健康水位+等你决策)', () => { beforeEach(() => vi.clearAllMocks()) - it('按五触点分组渲染待办与告警明细', async () => { + it('站点条七站渲染+琥珀 pending 标注+健康水位五格', async () => { vi.mocked(api.getTodos).mockResolvedValue(todosMock) + vi.mocked(api.getStations).mockResolvedValue({ stations: stationsMock, edges: [], health: healthMock }) const w = mount(Home, { global: { plugins: [ElementPlus, router] } }) await flushPromises() - for (const g of ['触点① 假设输入', '触点② 晋级判定', '触点③ 合成层升级', '触点④ Promotion+初始分配', '触点⑤ 告警响应']) { - expect(w.text()).toContain(g) + for (const s of ['假设池', '因子工厂', '晋级评审', '合成层', '策略出生', '孵化梯', '实盘']) { + expect(w.text()).toContain(s) } + expect(w.text()).toContain('+1') // 晋级评审 pending 琥珀 + // who 走 hover title 属性(非文本节点),从 hot 站点元素断言 + const hot = w.find('.station.hot') + expect(hot.exists()).toBe(true) + expect(hot.attributes('title')).toContain('fa_hot 待五问') + expect(w.text()).toContain('衰减告警') + expect(w.text()).toContain('集体水位') + expect(w.text()).toContain('2026-09-30') + }) + it('等你决策卡:tag 徽标+灯+明细', async () => { + vi.mocked(api.getTodos).mockResolvedValue(todosMock) + vi.mocked(api.getStations).mockResolvedValue({ stations: [], edges: [], health: [] }) + const w = mount(Home, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(w.text()).toContain('等你决策') expect(w.text()).toContain('fa_gross_margin 衰减告警') }) + it('零待办=安静态文案(0 件是好消息)', async () => { + vi.mocked(api.getTodos).mockResolvedValue([]) + vi.mocked(api.getStations).mockResolvedValue({ stations: [], edges: [], health: [] }) + const w = mount(Home, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + expect(w.text()).toContain('暂无等你决策') + }) }) diff --git a/frontend/src/views/pipeline/Home.vue b/frontend/src/views/pipeline/Home.vue index 263ef64f..495d311e 100644 --- a/frontend/src/views/pipeline/Home.vue +++ b/frontend/src/views/pipeline/Home.vue @@ -1,65 +1,81 @@ diff --git a/sanguo_api/routes_pipeline.py b/sanguo_api/routes_pipeline.py index b76ce239..a7ac9377 100644 --- a/sanguo_api/routes_pipeline.py +++ b/sanguo_api/routes_pipeline.py @@ -394,6 +394,110 @@ def _factor_registry_path() -> str: os.path.join("data", "factor_registry.yaml")) +@router.get("/pipeline/stations") +def pipeline_stations() -> dict: + """流水线站点条(D1 地铁图融总览,v4 高保真稿 SUBWAY 落地):七站计数+人工卡点 pending. + + 每站 {name,count,pending,who}:count=站内在册量;pending=人工卡点等你 + (琥珀);who=等的是谁(hover 文案).数据全现成端点拼装,无新算. + """ + from sanguo_portfolio import pipeline_store + stations: list[dict[str, Any]] = [] + # 假设池:活跃卡(非终态);pending=已确认未分解(queued/data_check,同触点⑤门) + cards = pipeline_store.list_hypotheses(_pipeline_db()) + active = [c for c in cards if c["state"] not in ("graveyard", "promoted")] + pend_hypo = [c for c in active if c["state"] in ("queued", "data_check")] + stations.append({"name": "假设池", "count": len(active), + "pending": len(pend_hypo), + "who": "、".join(c["title"][:12] for c in pend_hypo[:3]) + or None}) + # 因子工厂:非 graveyard 因子数;pending 无(评审卡点在下一站) + try: + from sanguo_factor import version_registry as vr + freg = vr.load_registry(_ensure_factor_runtime_registry()) + n_fac = sum(1 for e in freg["factors"].values() + if e.get("status") != "graveyard") + except Exception: # noqa: BLE001 坏档如实 0,不拖垮站点条 + n_fac = 0 + stations.append({"name": "因子工厂", "count": n_fac, "pending": 0, + "who": None}) + # 晋级评审:可判且 t≥2(与触点②/评审页同门);who=候选因子名 + try: + points, _ = _eval_latest_points() + cands = [n for n, e in freg["factors"].items() + if e.get("status") == "assessable" + and (points.get(n) or {}).get("t") is not None + and (points.get(n) or {}).get("t", 0) >= REVIEW_T_GATE] + except Exception: # noqa: BLE001 + cands = [] + stations.append({"name": "晋级评审", "count": len(cands), + "pending": len(cands), + "who": "、".join(f"{n} 待五问" for n in cands[:3]) + or None}) + # 合成层:在位+挑战者(quant12 族两版) + stations.append({"name": "合成层", "count": 2, "pending": 0, "who": None}) + # 策略出生:合成层晋级后的出生卡点(暂无数据源,诚实 0) + stations.append({"name": "策略出生", "count": 0, "pending": 0, "who": None}) + # 孵化梯+实盘:strategy_registry stage 计数 + try: + sreg = _load_strategy_registry() + stages = [e.get("stage") for e in sreg["strategies"].values()] + n_ladder = sum(1 for s in stages + if s in ("backtest", "paper", "shadow")) + n_live = sum(1 for s in stages if s == "live") + except Exception: # noqa: BLE001 + n_ladder = n_live = 0 + stations.append({"name": "孵化梯", "count": n_ladder, "pending": 0, + "who": None}) + stations.append({"name": "实盘", "count": n_live, "pending": 0, + "who": None}) + # 健康水位五格(v4 HEALTH):衰减告警/集体水位/数据缺口/数据链路/as_of + from sanguo_factor.verdict_replay import load_latest_monthly + n_alert = n_gap = 0 + collective_txt, asof_txt, link_txt, link_state = "正常", "—", "—", "idle" + try: + rep = load_latest_monthly(_monthly_dir()) + if rep: + n_alert = sum(1 for v in (rep.get("verdicts") or {}).values() + if v.get("state") == "alert") + if (rep.get("collective") or {}).get("is_collective_decay"): + collective_txt = "跌破" + asof_txt = str(rep.get("as_of") or "—") + except Exception: # noqa: BLE001 + pass + gaps_path2 = os.path.join(_monthly_dir(), "data_gaps.json") + if os.path.exists(gaps_path2): + try: + with open(gaps_path2, encoding="utf-8") as f: + n_gap = len(json.load(f)) + except Exception: # noqa: BLE001 + pass + health = [ + {"name": "衰减告警", "value": str(n_alert), + "state": "warn" if n_alert else "ok"}, + {"name": "集体水位", "value": collective_txt, + "state": "warn" if collective_txt == "跌破" else "ok"}, + {"name": "数据缺口", "value": str(n_gap), + "state": "warn" if n_gap else "ok"}, + {"name": "数据链路", "value": link_txt, "state": link_state}, + {"name": "as_of", "value": asof_txt, "state": "idle"}, + ] + return {"stations": stations, "health": health, + "edges": [ + {"from": "假设池", "to": "因子工厂", "cond": "落成+注册", + "trig": "人工"}, + {"from": "因子工厂", "to": "晋级评审", "cond": "t≥2", + "trig": "自动上板"}, + {"from": "晋级评审", "to": "合成层", "cond": "五问晋级→入族登记", + "trig": "人工"}, + {"from": "合成层", "to": "策略出生", "cond": "族晋级→卡点生成", + "trig": "自动"}, + {"from": "策略出生", "to": "孵化梯", "cond": "登记+考卷", + "trig": "人工+自动判"}, + {"from": "孵化梯", "to": "实盘", "cond": "毕业口令", + "trig": "人工"}]} + + def _eval_latest_points() -> tuple[dict[str, dict], str]: """eval_db 最近批 per-factor 点(monthly_review.pick/extract 同款)+批 end. diff --git a/tests/api/test_routes_pipeline_composite.py b/tests/api/test_routes_pipeline_composite.py index 346049c5..90766014 100644 --- a/tests/api/test_routes_pipeline_composite.py +++ b/tests/api/test_routes_pipeline_composite.py @@ -185,3 +185,64 @@ def test_challenge_bad_json_skipped(client, monkeypatch, tmp_path): doc = client.get("/api/v1/pipeline/composite/challenge").json() assert doc["challenge"]["as_of"] == "2026-09-30" assert "bad.json" not in doc["available"] + + +# —— GET /pipeline/stations 七站聚合(D1 地铁图融总览,v4 SUBWAY 落地) —— + +class TestStations: + @pytest.fixture() + def isolated(self, monkeypatch, tmp_path): + """全数据源指 tmp(不读开发机真档):pipeline db/因子档/策略档/月报目录. + + 因子/策略档须连自举函数一起 mock——ensure_runtime_registry 会把 + config/ 种子条目 merge 进运行副本(生产防漏登记机制),纯 env 指空档 + 挡不住种子灌入(实证 9 因子漏出)。 + """ + import sanguo_api.routes_pipeline as rp + monkeypatch.setenv("SANGUO_PIPELINE_DB", str(tmp_path / "pipeline.db")) + (tmp_path / "reg.yaml").write_text("factors: {}\n", encoding="utf-8") + monkeypatch.setattr(rp, "_ensure_factor_runtime_registry", + lambda: str(tmp_path / "reg.yaml")) + monkeypatch.setattr(rp, "_load_strategy_registry", + lambda: {"strategies": {}}) + monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(tmp_path / "monthly")) + return tmp_path + + def test_stations_shape_seven_and_health(self, client, isolated): + r = client.get("/api/v1/pipeline/stations") + assert r.status_code == 200 + doc = r.json() + assert [s["name"] for s in doc["stations"]] == [ + "假设池", "因子工厂", "晋级评审", "合成层", "策略出生", "孵化梯", "实盘"] + for s in doc["stations"]: + assert set(s.keys()) == {"name", "count", "pending", "who"} + cnt = {s["name"]: s["count"] for s in doc["stations"]} + assert cnt == {"假设池": 0, "因子工厂": 0, "晋级评审": 0, + "合成层": 2, # 在位+挑战者固定 + "策略出生": 0, "孵化梯": 0, "实盘": 0} + assert {h["name"] for h in doc["health"]} == { + "衰减告警", "集体水位", "数据缺口", "数据链路", "as_of"} + hmap = {h["name"]: h for h in doc["health"]} + assert hmap["衰减告警"] == {"name": "衰减告警", "value": "0", "state": "ok"} + assert hmap["集体水位"]["value"] == "正常" + assert hmap["数据缺口"] == {"name": "数据缺口", "value": "0", "state": "ok"} + assert hmap["数据链路"]["state"] == "idle" + assert len(doc["edges"]) == 6 # 六条迁移边(前端二期渲染,数据先备) + + def test_stations_hypothesis_pool_counts_active_pending(self, client, isolated): + from sanguo_portfolio import pipeline_store as ps + card = {"title": "高管增持后跑赢", "logic": "x", "expected_sign": "positive", + "falsifiable": "y"} + ps.insert_hypothesis(str(isolated / "pipeline.db"), card) + ps.insert_hypothesis( + str(isolated / "pipeline.db"), {**card, "title": "龙虎榜游资反向"}) + # 第二张直改 graveyard(终态不进站;绕状态机,聚焦端点聚合) + import sqlite3 + with sqlite3.connect(str(isolated / "pipeline.db")) as conn: + conn.execute( + "UPDATE hypotheses SET state='graveyard' WHERE title='龙虎榜游资反向'") + doc = client.get("/api/v1/pipeline/stations").json() + pool = doc["stations"][0] + assert pool["count"] == 1 # 活跃=非终态 + assert pool["pending"] == 1 # queued/data_check=人工卡点 + assert "高管增持后跑赢" in (pool["who"] or "")