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:
@@ -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
|
||||
|
||||
@@ -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('暂无等你决策')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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 "")
|
||||
|
||||
Reference in New Issue
Block a user