feat(pipeline): D1 地铁图融总览落地——GET /pipeline/stations 七站聚合+SubwayBar 站点条+Home 改版(用户指认 v4 稿完整形态)

新端点 stations:七站计数(假设池=活跃卡+琥珀 pending/因子工厂非graveyard/
晋级评审 assessable+t≥2 同门/合成层固定2/策略出生诚实0/孵化梯+实盘 stage
计数)+HEALTH 五格(衰减告警/集体水位/数据缺口/数据链路/as_of)+edges 六条
迁移边(D4 数据先备,渲染二期);前端 SubwayBar(dot 灯/badge/琥珀+N/who
hover/点站跳转)+Home 流水线全景卡+等你决策卡(tag 徽标+灯色)+零待办安静态;
测试=TestStations 两件(隔离须连 ensure_runtime_registry 自举 mock,种子
merge 会向 tmp 空档灌真条目)+Home spec 三件 [vps]
This commit is contained in:
2026-10-10 20:31:45 +08:00
parent 67edf26024
commit 5e0f2ae373
6 changed files with 357 additions and 41 deletions
+27
View File
@@ -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<LadderStrategy[]> {
const { data } = await apiClient.get<{ items: LadderStrategy[] }>('/pipeline/ladder')
return data.items
+46 -5
View File
@@ -8,7 +8,7 @@ import { todosMock } from '@/mock/pipelineData'
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
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: '<div/>' } }],
})
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('暂无等你决策')
})
})
+52 -36
View File
@@ -1,65 +1,81 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getTodos } from '@/api/pipeline'
import type { TodoItem, Touchpoint } from '@/api/pipeline'
import { getTodos, getStations } from '@/api/pipeline'
import type { TodoItem, Touchpoint, HealthCell } from '@/api/pipeline'
import PanelCard from './PanelCard.vue'
import SubwayBar from './SubwayBar.vue'
// v4 高保真稿首页落地(D1 地铁图融总览):站点条+健康水位行+等你决策卡
const router = useRouter()
const items = ref<TodoItem[]>([])
const health = ref<HealthCell[]>([])
onMounted(async () => {
// todos 端点失败=降级空列表,不裸抛 unhandled rejection 白屏(P5.4③)
items.value = await getTodos().catch(() => [])
const r = await getStations().catch(() => ({ stations: [], edges: [], health: [] as HealthCell[] }))
health.value = r.health || []
})
const GROUPS: Array<{ key: Touchpoint; label: string }> = [
{ key: 'hypothesis', label: '触点① 假设输入' },
{ key: 'promotion', label: '触点② 晋级判定' },
{ key: 'composite', label: '触点③ 合成层升级' },
{ key: 'ladder', label: '触点④ Promotion+初始分配' },
{ key: 'alert', label: '触点⑤ 告警响应' },
]
const TAG: Record<Touchpoint, string> = {
hypothesis: '假设', promotion: '评审', composite: '合成',
ladder: '出生', alert: '告警',
}
const SEV_LAMP: Record<TodoItem['severity'], string> = {
info: 'lamp lamp-ok', warn: 'lamp lamp-warn', critical: 'lamp lamp-critical',
}
const ordered = computed(() => {
const w: Record<string, number> = { critical: 0, warn: 1, info: 2 }
return [...items.value].sort((a, b) => (w[a.severity] ?? 9) - (w[b.severity] ?? 9))
})
const HEALTH_TONE: Record<HealthCell['state'], string> = {
ok: 'h-ok', warn: 'h-warn', idle: 'h-idle',
}
</script>
<template>
<div class="pl-home">
<PanelCard title="我的待办" :sub="`五个触点 · ${items.length} 件等你决策 · 其余时间流水线自动跑`">
<div class="groups">
<div v-for="g in GROUPS" :key="g.key" class="group">
<div class="group-head">
<span>{{ g.label }}</span>
<span class="group-count">{{ items.filter((t) => t.touchpoint === g.key).length }}</span>
</div>
<div
v-for="t in items.filter((x) => x.touchpoint === g.key)"
:key="t.id"
class="todo"
@click="router.push(t.path)"
>
<span :class="SEV_LAMP[t.severity]"></span>
<div class="todo-main">
<span class="todo-title">{{ t.title }}</span>
<span class="todo-detail">{{ t.detail }}</span>
</div>
<span class="todo-go">→</span>
</div>
<PanelCard title="流水线全景" sub="站点进度 · 健康水位 · 等你决策 · 其余时间流水线自动跑">
<SubwayBar />
<div v-if="health.length" class="health">
<div v-for="h in health" :key="h.name" class="hcell" :class="HEALTH_TONE[h.state]">
<span class="hk">{{ h.name }}</span>
<span class="hv">{{ h.value }}</span>
</div>
</div>
</PanelCard>
<PanelCard title="等你决策" :sub="`${items.length} 件 · 灯色=轻重 · 点卡进入处理`">
<div v-if="!ordered.length" class="allquiet">
暂无等你决策——流水线自动跑,有事它叫你(琥珀站点=有人工卡点)
</div>
<div v-for="t in ordered" :key="t.id" class="todo" @click="router.push(t.path)">
<span class="tag">{{ TAG[t.touchpoint] }}</span>
<span :class="SEV_LAMP[t.severity]"></span>
<div class="todo-main">
<span class="todo-title">{{ t.title }}</span>
<span class="todo-detail">{{ t.detail }}</span>
</div>
<span class="todo-go">→</span>
</div>
</PanelCard>
</div>
</template>
<style scoped>
.pl-home { max-width: 1080px; margin: 0 auto; }
.groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
.group { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 10px 12px; }
.group-head { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 11px; color: var(--text-3); letter-spacing: 1px; margin-bottom: 6px; }
.group-count { color: var(--brand); }
.todo { display: flex; align-items: center; gap: 10px; padding: 8px 6px; border-top: 1px solid var(--border); cursor: pointer; transition: background 0.15s var(--ease); }
.pl-home { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
.health { display: flex; gap: 10px; flex-wrap: wrap; border-top: 1px dashed var(--border); padding-top: 8px; }
.hcell { display: inline-flex; align-items: baseline; gap: 6px; border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px 10px; }
.hk { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
.hv { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.h-ok .hv { color: var(--lamp-ok); }
.h-warn { border-color: rgba(255, 184, 77, 0.45); }
.h-warn .hv { color: #ffb84d; }
.h-idle .hv { color: var(--text-3); font-weight: 400; }
.allquiet { font-size: 12px; color: var(--text-3); padding: 14px 0; border: 1px dashed var(--border); border-radius: var(--r-sm); text-align: center; }
.todo { display: flex; align-items: center; gap: 10px; padding: 9px 6px; border-top: 1px solid var(--border); cursor: pointer; transition: background 0.15s var(--ease); }
.todo:hover { background: var(--cyan-soft); }
.tag { font-family: var(--mono); font-size: 10px; color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.35); border-radius: var(--r-sm); padding: 0 6px; flex-shrink: 0; }
.todo-main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.todo-title { font-size: 12.5px; color: var(--text); }
.todo-detail { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getStations } from '@/api/pipeline'
import type { PipelineStation } from '@/api/pipeline'
// v4 高保真稿 SUBWAY 落地(D1 地铁图融总览):七站横条+计数徽标+
// 琥珀 pending(人工卡点等你)+hover who;站可点跳对应页
const router = useRouter()
const stations = ref<PipelineStation[]>([])
onMounted(async () => {
const r = await getStations().catch(() => ({ stations: [] as PipelineStation[] }))
stations.value = r.stations
})
const ROUTE: Record<string, string> = {
假设池: '/pipeline/hypotheses', 因子工厂: '/pipeline/factors',
晋级评审: '/pipeline/review', 合成层: '/pipeline/composite',
策略出生: '/pipeline/incubation', 孵化梯: '/pipeline/incubation',
实盘: '/strategy',
}
const go = (name: string) => ROUTE[name] && router.push(ROUTE[name])
const totalPending = computed(() =>
stations.value.reduce((a, s) => a + (s.pending || 0), 0))
</script>
<template>
<div class="subway">
<div class="subway-line">
<template v-for="(s, i) in stations" :key="s.name">
<div v-if="i > 0" class="seg" :class="{ lit: s.count > 0 }" />
<div
class="station"
:class="{ empty: s.count === 0, hot: s.pending > 0 }"
:title="s.who ? `等你: ${s.who}` : `${s.name} ${s.count} 项`"
@click="go(s.name)"
>
<span class="dot" />
<span class="badge" :class="{ amber: s.pending > 0 }">{{ s.count }}</span>
<span v-if="s.pending > 0" class="pend">+{{ s.pending }}</span>
<span class="nm">{{ s.name }}</span>
</div>
</template>
</div>
<div class="subway-note mono">
琥珀 =人工卡点等你({{ totalPending }} 件)· 点站进入 · 青灯=自动在跑
</div>
</div>
</template>
<style scoped>
.subway { margin-bottom: 4px; }
.subway-line { display: flex; align-items: center; gap: 0; flex-wrap: wrap; padding: 6px 0 2px; }
.seg { width: 26px; height: 2px; background: var(--border); flex-shrink: 0; }
.seg.lit { background: linear-gradient(90deg, rgba(0, 229, 255, 0.7), rgba(0, 229, 255, 0.25)); }
.station { position: relative; display: flex; align-items: center; gap: 5px; cursor: pointer; padding: 3px 4px; border-radius: var(--r-sm); transition: background 0.15s var(--ease); }
.station:hover { background: var(--cyan-soft); }
.station.empty { opacity: 0.45; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--brand); box-shadow: 0 0 8px rgba(0, 229, 255, 0.5); flex-shrink: 0; }
.station.empty .dot { background: var(--text-3); box-shadow: none; }
.station.hot .dot { background: #ffb84d; box-shadow: 0 0 8px rgba(255, 184, 77, 0.6); }
.badge { font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--brand); font-variant-numeric: tabular-nums; }
.badge.amber { color: #ffb84d; }
.pend { font-family: var(--mono); font-size: 10px; color: #ffb84d; }
.nm { font-size: 11px; color: var(--text-3); }
.subway-note { font-size: 10px; color: var(--text-3); padding: 2px 4px 6px; }
</style>
+104
View File
@@ -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.
@@ -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 "")