fix(api): 控制台归因页崩修复+合成层版本树接真——两波同修(用户拍板 factor 域) [vps]
CI/CD / test (push) Failing after 39s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

①归因页契位修复(infra 派单): 020e610 产物 factorContribution=月度长表
  {factor,month,contribution_ret}(1008 条含 market/size/industry/specific
  三桶行)直通撞前端 P0 短表契约 {factorId,contributionPct}——首行
  contributionPct undefined → AttributionResearch.vue toFixed 整页崩
  (双机实锤)。修=_aggregate_monthly_contribution 聚合适配:
  - 口径=近 12 月 Σ contribution_ret×100(滚动年窗;全期 market +65%
    溢出前端横条 3.2×;量级 0.1~6% 恰配)
  - 三桶(market/size/industry/specific)与 12 源同板上板(研判核心结构)
  - 不足 12 月(挑战者件 8 月)退化全窗 Σ;脏行跳过;降序返回
  - 真件端到端验证: 1008 行→16 行与实探量级一致(market+5.85/kup+2.22)
  - 前端补 typeof 兜底 filter(缺字段行滤掉防再整页崩)
②合成层版本树接真(追加派单): /pipeline/composite 真端点+mock 条目退役
  - 两节点: quant12_v2a(L1 档案 resolved 权重=在位者 szneul_reb63 钉住,
    releasedAt=档案 created)/quant12_v1(等权基线=#69 挑战者)
  - 影子对照挂挑战者节点: 读 szneul_eq_reb63 周报 weeks_counted/
    te_annual; 09-28 首跑前无周报→如实 0/4 周+TE null+进行中(不造数);
    满 4 周 TE≤影子阈值达标/超阈
  - 因子侧坏档 fail-soft items=[](宁空勿 500,同归因页纪律)
  - 前端: 标题接 family 真名(告别 fund8 硬编码)/cand+gate 条件改
    shadowCompare(告别 v3 硬编码)/TE null 显示「未算」/类型
    trackingError number|null
  - spec §4.7 端点节+控制台功能表两行同步(档随码走)
- 测试: tests/api +13(attribution 7+composite 6,含 jwt secret 每测试
  现签防全量套件串扰); tests/api 202 全绿+tests/factor 438 全绿
  +前端 vitest 17 过+vue-tsc 过

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-27 17:54:20 +08:00
parent b7ce364b11
commit fdbbb488dc
9 changed files with 339 additions and 20 deletions
+4 -4
View File
@@ -47,7 +47,7 @@ export interface CompositeVersion {
releasedAt: string
note: string
members: CompositeMember[]
shadowCompare?: { versus: string; week: number; totalWeeks: number; trackingError: number; verdict: '进行中' | '达标' | '超阈' }
shadowCompare?: { versus: string; week: number; totalWeeks: number; trackingError: number | null; verdict: '进行中' | '达标' | '超阈' }
}
export type LadderStage = 'backtest' | 'paper' | 'shadow' | 'live'
@@ -120,9 +120,9 @@ export async function getMonthlyReview(): Promise<MonthlyReview> {
return data
}
export async function getCompositeVersions(): Promise<CompositeVersion[]> {
const { data } = await apiClient.get<{ items: CompositeVersion[] }>('/pipeline/composite')
return data.items
export async function getCompositeVersions(): Promise<{ family: string; versions: CompositeVersion[] }> {
const { data } = await apiClient.get<{ family: string; items: CompositeVersion[] }>('/pipeline/composite')
return { family: data.family, versions: data.items }
}
export async function getLadder(): Promise<LadderStrategy[]> {
+1 -1
View File
@@ -121,7 +121,7 @@ export const PIPELINE_MOCK = true
const pipelineRoutes: Route[] = [
{ m: 'get', re: /^\/pipeline\/hypotheses$/, build: () => ({ items: P.hypothesesMock }) },
{ m: 'get', re: /^\/pipeline\/monthly-review$/, build: () => P.monthlyReviewMock },
{ m: 'get', re: /^\/pipeline\/composite$/, build: () => ({ items: P.compositeMock }) },
// /pipeline/composite 已接真端点(P1, 09-27): 不再 mock, 走 sanguo_api
{ m: 'get', re: /^\/pipeline\/factors\/[^/]+\/ic-trend$/, build: (url) => P.icTrendMock(url.split('/')[3]) },
]
@@ -79,7 +79,7 @@ async function submitRuling() {
</div>
<div class="attr-block">
<div class="attr-h">因子贡献分解</div>
<div v-for="c in [...report.factorContribution].sort((a, b) => b.contributionPct - a.contributionPct)" :key="c.factorId" class="contrib">
<div v-for="c in report.factorContribution.filter(c => typeof c.factorId === 'string' && typeof c.contributionPct === 'number').sort((a, b) => b.contributionPct - a.contributionPct)" :key="c.factorId" class="contrib">
<span class="mono c-name">{{ c.factorId }}</span>
<div class="c-bar-wrap"><div class="c-bar" :class="{ neg: c.contributionPct < 0 }" :style="{ width: `${Math.abs(c.contributionPct) * 3.2}%` }"></div></div>
<span class="c-val">{{ c.contributionPct > 0 ? '+' : '' }}{{ c.contributionPct.toFixed(1) }}%</span>
@@ -12,21 +12,35 @@ vi.mock('@/api/pipeline', async (importOriginal) => {
describe('CompositeTree.vue', () => {
beforeEach(() => vi.clearAllMocks())
it('渲染版本时间线与 v3 影子对照进度', async () => {
vi.mocked(api.getCompositeVersions).mockResolvedValue(compositeMock)
it('渲染版本时间线与影子对照进度(标题接 family 真名)', async () => {
vi.mocked(api.getCompositeVersions).mockResolvedValue({
family: 'composite_quant12', versions: compositeMock,
})
const w = mount(CompositeTree, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('composite_fund8')
expect(w.text()).toContain('composite_quant12')
expect(w.text()).toContain('v2')
expect(w.text()).toContain('对照 v2')
expect(w.text()).toContain('达标')
expect(w.text()).toContain('升级口令')
})
it('v2 成员含晋级双源且等权 12.5%', async () => {
vi.mocked(api.getCompositeVersions).mockResolvedValue(compositeMock)
vi.mocked(api.getCompositeVersions).mockResolvedValue({
family: 'composite_quant12', versions: compositeMock,
})
const w = mount(CompositeTree, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('pledge_net_chg')
expect(w.text()).toContain('12.5%')
})
it('TE 未算(影子首跑前)显示「未算」不炸渲染', async () => {
const pending = [{ ...compositeMock[2], shadowCompare: { ...compositeMock[2].shadowCompare!, trackingError: null, verdict: '进行中' as const } }]
vi.mocked(api.getCompositeVersions).mockResolvedValue({
family: 'composite_quant12', versions: pending,
})
const w = mount(CompositeTree, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('未算')
expect(w.text()).toContain('进行中')
})
})
+11 -6
View File
@@ -4,18 +4,23 @@ import { getCompositeVersions } from '@/api/pipeline'
import type { CompositeVersion } from '@/api/pipeline'
import PanelCard from './PanelCard.vue'
const family = ref('')
const versions = ref<CompositeVersion[]>([])
onMounted(async () => {
versions.value = await getCompositeVersions()
const r = await getCompositeVersions()
family.value = r.family
versions.value = r.versions
})
const pct = (wgt: number): string => `${(wgt * 100).toFixed(1)}%`
const te = (v: CompositeVersion): string =>
v.shadowCompare?.trackingError != null ? `${(v.shadowCompare.trackingError * 100).toFixed(1)}%` : '未算'
</script>
<template>
<div class="pl-page">
<PanelCard title="合成层版本树 · composite_fund8" sub="策略钉住具体版本 · 变更走影子对照+口令,各策略择期升级(决议 A)">
<PanelCard :title="`合成层版本树 · ${family}`" sub="策略钉住具体版本 · 变更走影子对照+口令,各策略择期升级(决议 A)">
<div class="timeline">
<div v-for="v in versions" :key="v.version" class="ver" :class="{ cand: v.version === 'v3' }">
<div v-for="v in versions" :key="v.version" class="ver" :class="{ cand: !!v.shadowCompare }">
<div class="ver-head">
<span class="ver-tag">{{ v.version }}</span>
<span class="ver-date">{{ v.releasedAt }}</span>
@@ -28,12 +33,12 @@ const pct = (wgt: number): string => `${(wgt * 100).toFixed(1)}%`
</div>
<div v-if="v.shadowCompare" class="shadow">
<span>影子对照 {{ v.shadowCompare.versus }} · 第 {{ v.shadowCompare.week }}/{{ v.shadowCompare.totalWeeks }} 周</span>
<span>跟踪误差 {{ (v.shadowCompare.trackingError * 100).toFixed(1) }}%</span>
<span>跟踪误差 {{ te(v) }}</span>
<span class="verdict" :class="{ ok: v.shadowCompare.verdict === '达标' }">{{ v.shadowCompare.verdict }}</span>
</div>
<div v-if="v.version === 'v3'" class="gate">
<div v-if="v.shadowCompare" class="gate">
<el-button size="small" type="primary" plain disabled>发起升级口令</el-button>
<span class="gate-note">P0 原型:口令闸门在 P1 接真——此处只展示流程位</span>
<span class="gate-note">口令闸门流程位——影子对照期满由人发起升级口令</span>
</div>
</div>
</div>