feat(composite): 三路对拍前端可见性——合成层页第二卡(三路对比表+规则判读+特征贡献+冗余chips+空态)+GET challenge 端点(最新件/坏件skip);修 challenger CLI 断链(__main__ guard 缺失=python -m 静默空跑,11-01 stage6 必空,部署验证现场抓获);spec §4.4 落账含 QA 无合成 UI 核实 [vps]
CI/CD / test (push) Failing after 5s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-10-10 18:57:25 +08:00
parent 0093c0c154
commit e109c06b0f
7 changed files with 271 additions and 2 deletions
@@ -109,6 +109,7 @@ F 环是「管道」与「飞轮」的分界:归因发现→可能=衰减触
- 判读:等权≈ICIR=因子相关性高、加权信息少,安心用简单;LGBM 明显跑赢两者=组合方式升级叙事,且 feature importance 天然产出「组合内各因子贡献」(对接 §4.7 归因);预期两三个完整月度周期才有判读力;
- 纪律:challenger 永远影子不进生产;若升级仍走决议 K 考场晋级+用户口令,与实盘升级同构。
- **班次二落地(2026-10-10)**:`sanguo_factor/challenger_lgbm.py`——v1 池=quant12 在役 12 源(扩池挂后续);三路=方向调整等权/ICIR 档案加权/LGBM(qlib 基准超参,固定 500 轮——无独立 valid 段不早停,注记在对拍件);walk-forward=前 11M 训练+末 1M 样外(test 隔离铁律:样外月绝不进训练;codex 复审加固=训练窗末 2 交易日 embargo——build_label 双 shift 前视,不 embargo 则窗口末样本的 label 吃样外月收盘=信息层泄漏;三路统一可评样本 mask、缺源/坏件整月 skip 不产半吊子件);对拍件=`challenger_lgbm/{host}_{as_of}.json`(三路样外 IC/ICIR/Q5-Q1 多空+feature importance+池冗余注记——`|corr|>0.7` 只记录不剔除,首年观察期剔除留人);挂月度链 stage6(失败不阻链);判读需累积≥2-3 个样外月。**宽表源现场核实修正**:月度批 `values/` 导出只含注册表因子(fa 族+composite),永不含 quant12 12 源——stage6 `--values-dir` 改指 stage4 截面盘 `/volume1/stock/factor_cross_section`(daily_section 幂等 merge 累积的 12 源主 parquet,12/12 在位实证),零重算同链上游供给。
- **对拍前端可见性(2026-10-10 补,用户质问「合成无 UI」驱动;QA frontend-v2 全端点核实=无合成视图可抄,QA 全自动进化人不下场故合成黑盒——我们在环决策必须有证据面)**:合成层页(/pipeline/composite)下方第二卡「三路对拍 · 加权方式之辩」——`GET /pipeline/composite/challenge` 读报告根 `challenger_lgbm/` 最新件(as_of 最大,坏件 skip);三路对比表(样外 IC/ICIR/Q5-Q1/有效天数,每列最优高亮)+规则判读行(|ΔIC|≤0.005→「等权≈ICIR 简单加权足够」;LGBM 超 max+0.005→「非线性有增量」)+LGBM 特征贡献条+冗余 chips+「永不进生产/单月不构成结论」免责行;无件=空态文案(11-01 首考)。**修复 CLI 断链(部署验证现场抓获)**:challenger_lgbm.py 尾部漏 `if __name__ == "__main__"` guard——`python -m` 静默导入即退 exit 0,11-01 stage6 会空跑;测试直调 main(argv) 函数全绿掩盖 CLI 缝隙,guard+import sys 已补。**产物双机可见**:challenger 依赖 stage4 截面盘(NAS 数据面,VPS 无)→只在 NAS 跑;VPS 前端展示靠产物件同步 NAS→VPS(验证期手动 scp,常态化挂 11-01 前小班)。
- **test 隔离条款(2026-10-10 成文,QuantaAlpha 附录 E 纪律借鉴)**:**合成层(含 challenger)权重/训练只用截至 as_of 的数据,表现评估只看 as_of 之后——权重窗与成绩窗永不重叠**。现状核实动作=审计 quant12_v2a 权重计算代码确认滚动结构天然合规(每月 T-12~T 算权、T+1 后出成绩),核实结论落本档。防什么=将来实盘与回测打架时,回测成绩有「未被自己污染」的底气;防将来改动(全历史拟合/未来数据调参类)悄悄破坏。
核实结论(2026-10-10 审计):quant12_v2a(quant12_icirfit_v1)fit_window=2018-01~2021-06、h2 考场窗=2021-07-01 起(exam_gate DEFAULT_START,同批同 run 对照)——**无重叠**(考场首日恰后于拟合窗末月,首尾相接);v2c 滚动天然合规:Y 年制度档案 fit [Y-3 年初, Y-1 年末]、考核 Y 年段(walk-forward),且注册路径只挂载档案冻结权重(load_profile→resolve_weights),评估数据不回流拟合。
- **版本钉住+影子对照闸门(决议 A)**:策略钉住合成层具体版本;合成层变更(晋级/退役/调权)→新旧版影子对照(起步默认 4 周,首年校准)→用户口令→各策略**择期**升级。可回滚、历史可复现,与推 vps 纪律同构。
+26
View File
@@ -110,6 +110,28 @@ export interface CompositeFamily {
note: string
}
// —— 三路对拍(challenger_lgbm 影子实验产物,月度链 stage6) ——
export interface ChallengeSignal {
ic_mean: number | null
icir: number | null
q5q1: number | null
days: number
}
export interface ChallengeDoc {
as_of: string
generated_at: string
host: string
file?: string
pool: { names: string[]; size: number }
split: { oos_month: string; note: string }
signals: Record<'equal' | 'icir' | 'lgbm', ChallengeSignal>
feature_importance: Record<string, number>
redundancy_flagged: Array<{ a: string; b: string; corr: number } | Record<string, unknown>>
lgbm_params: Record<string, unknown>
}
export interface ResearchCardOption { level: number; action: string; meaning: string; cost: string }
export interface Ruling { month: string; level: number; reason: string; decided_at: string }
@@ -226,6 +248,10 @@ export async function getCompositeVersions(family?: string): Promise<{ family: s
return { family: data.family, families: data.families, versions: data.items }
}
export async function getCompositeChallenge(): Promise<{ challenge: ChallengeDoc | null; available: string[]; note: string }> {
return apiClient.get('/pipeline/composite/challenge')
}
export async function getLadder(): Promise<LadderStrategy[]> {
const { data } = await apiClient.get<{ items: LadderStrategy[] }>('/pipeline/ladder')
return data.items
@@ -56,3 +56,50 @@ describe('CompositeTree.vue', () => {
expect(w.text()).toContain('该族暂无可展示的版本树')
})
})
// —— 三路对拍卡(challenger_lgbm 影子产物) ——
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getCompositeVersions: vi.fn(), getCompositeChallenge: vi.fn() }
})
import type { ChallengeDoc } from '@/api/pipeline'
const chalDoc: ChallengeDoc = {
as_of: '2026-09-30', generated_at: '2026-09-30T21:40:00', host: 'nas',
pool: { names: ['alpha12', 'klow'], size: 2 },
split: { oos_month: '2026-09', note: '' },
signals: {
equal: { ic_mean: 0.05, icir: 0.8, q5q1: 0.01, days: 20 },
icir: { ic_mean: 0.0505, icir: 0.81, q5q1: 0.011, days: 20 },
lgbm: { ic_mean: 0.062, icir: 0.9, q5q1: 0.012, days: 20 },
},
feature_importance: { alpha12: 0.6, klow: 0.3 },
redundancy_flagged: [{ a: 'alpha12', b: 'klow', corr: 0.73 }],
lgbm_params: {},
}
describe('CompositeTree.vue 三路对拍卡', () => {
beforeEach(() => vi.clearAllMocks())
it('有件:三路表+判读行+特征贡献+冗余 chip 全渲染', async () => {
vi.mocked(api.getCompositeVersions).mockResolvedValue({ family: 'composite_quant12', families: fams, versions: compositeMock })
vi.mocked(api.getCompositeChallenge).mockResolvedValue({ challenge: chalDoc, available: ['nas_2026-09-30.json'], note: '' })
const w = mount(CompositeTree, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('三路对拍 · 加权方式之辩')
expect(w.text()).toContain('样外月 2026-09')
expect(w.text()).toContain('LGBM 样外领先') // 0.062 > 0.0505+0.005 判读分支
expect(w.text()).toContain('alpha12') // 特征贡献
expect(w.text()).toContain('↔ klow') // 冗余 chip
expect(w.text()).toContain('challenger 永不进生产')
})
it('无件:空态文案(11-01 首考),版本树不受影响', async () => {
vi.mocked(api.getCompositeVersions).mockResolvedValue({ family: 'composite_quant12', families: fams, versions: compositeMock })
vi.mocked(api.getCompositeChallenge).mockResolvedValue({ challenge: null, available: [], note: '暂无对拍数据——月度链 stage6 每月产出(11-01 首考);challenger 永不进生产(影子实验)' })
const w = mount(CompositeTree, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('暂无对拍数据')
expect(w.text()).toContain('composite_quant12') // 版本树照常
})
})
+106 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getCompositeVersions } from '@/api/pipeline'
import type { CompositeFamily, CompositeVersion } from '@/api/pipeline'
import { getCompositeVersions, getCompositeChallenge } from '@/api/pipeline'
import type { CompositeFamily, CompositeVersion, ChallengeDoc, ChallengeSignal } from '@/api/pipeline'
import PanelCard from './PanelCard.vue'
const family = ref('')
@@ -20,6 +20,47 @@ async function load() {
loading.value = false
}
}
// —— 三路对拍(等权/ICIR/LGBM 影子实验,challenger_lgbm 月度产物) ——
const chal = ref<ChallengeDoc | null>(null)
const chalNote = ref('')
onMounted(async () => {
try {
const r = await getCompositeChallenge()
chal.value = r.challenge
chalNote.value = r.note
} catch { chal.value = null; chalNote.value = '对拍件读取失败(不阻断版本树)' }
})
const CHAL_ROWS: Array<{ key: 'equal' | 'icir' | 'lgbm'; label: string; note: string }> = [
{ key: 'equal', label: '等权', note: '12 源方向调整后等权(简单基线)' },
{ key: 'icir', label: 'ICIR', note: '档案权重×方向(在位 v2a 同款逻辑)' },
{ key: 'lgbm', label: 'LGBM', note: 'walk-forward 样外预测(挑战者)' },
]
const sig = (k: 'equal' | 'icir' | 'lgbm'): ChallengeSignal | null => chal.value?.signals?.[k] ?? null
const fmtNum = (v: number | null | undefined, digits = 4): string =>
v == null ? '—' : v.toFixed(digits)
// 每列最优者高亮:IC/ICIR/Q5Q1 越大越好(方向已调整)
const bestKey = (field: 'ic_mean' | 'icir' | 'q5q1'): string | null => {
const pairs = CHAL_ROWS.map(r => [r.key, sig(r.key)?.[field]] as const)
.filter(([, v]) => v != null) as Array<[keyof ChallengeDoc['signals'], number]>
if (pairs.length < 2) return null
return pairs.reduce((a, b) => (b[1] > a[1] ? b : a))[0]
}
const verdictLine = computed(() => {
const eq = sig('equal')?.ic_mean, ir = sig('icir')?.ic_mean, lg = sig('lgbm')?.ic_mean
if (eq == null || ir == null) return '样外月评分数据不足——下个月度批再看'
if (lg != null && lg > Math.max(eq, ir) + 0.005)
return 'LGBM 样外领先:非线性权重有增量信号,结合下方特征贡献定去留'
return `等权≈ICIR(ΔIC=${Math.abs(eq - ir).toFixed(4)}):因子相关性高,简单加权足够——证据支持现状`
})
const impSorted = computed(() =>
Object.entries(chal.value?.feature_importance ?? {})
.sort((a, b) => b[1] - a[1])
.map(([name, v]) => ({ name, v })))
const impMax = computed(() => (impSorted.value[0]?.v) || 1)
const redundancy = computed(() => (chal.value?.redundancy_flagged ?? []) as Array<{ a: string; b: string; corr: number }>)
const familyNote = computed(() => families.value.find(f => f.key === family.value)?.note ?? '')
const pct = (wgt: number): string => `${(wgt * 100).toFixed(1)}%`
const te = (v: CompositeVersion): string =>
@@ -64,6 +105,47 @@ const te = (v: CompositeVersion): string =>
</div>
</div>
</PanelCard>
<PanelCard title="三路对拍 · 加权方式之辩" sub="等权 vs ICIR vs LGBM 同池样外对拍(月度链 stage6 影子产物) · challenger 永不进生产——证据攒够才谈换权重,换法走版本钉住流程">
<div v-if="!chal" class="chal-empty">{{ chalNote || '暂无对拍数据——月度链每月产出(11-01 首考)' }}</div>
<div v-else class="chal">
<div class="chal-meta mono">
as_of {{ chal.as_of }} · 样外月 {{ chal.split?.oos_month }} · 池 {{ chal.pool?.size }} 源
· {{ chal.host }} · {{ (chal.generated_at || '').slice(0, 19) }}
</div>
<table class="chal-table">
<thead>
<tr><th>路</th><th>样外 IC</th><th>ICIR</th><th>Q5-Q1 多空</th><th>有效天数</th></tr>
</thead>
<tbody>
<tr v-for="r in CHAL_ROWS" :key="r.key">
<td>
<span class="chal-name" :class="{ lg: r.key === 'lgbm' }">{{ r.label }}</span>
<span class="chal-note">{{ r.note }}</span>
</td>
<td class="mono" :class="{ win: bestKey('ic_mean') === r.key }">{{ fmtNum(sig(r.key)?.ic_mean) }}</td>
<td class="mono" :class="{ win: bestKey('icir') === r.key }">{{ fmtNum(sig(r.key)?.icir) }}</td>
<td class="mono" :class="{ win: bestKey('q5q1') === r.key }">{{ fmtNum(sig(r.key)?.q5q1) }}</td>
<td class="mono dim">{{ sig(r.key)?.days ?? '—' }}</td>
</tr>
</tbody>
</table>
<div class="chal-verdict">{{ verdictLine }}</div>
<div class="chal-disclaimer mono">单样外月不构成结论,判读需 ≥2-3 样外月 · 训练窗=样外月前推 11 自然月(末 2 交易日 embargo)</div>
<div v-if="impSorted.length" class="imp">
<div class="imp-title">LGBM 特征贡献(top)</div>
<div v-for="it in impSorted.slice(0, 12)" :key="it.name" class="imp-row">
<span class="mono imp-name">{{ it.name }}</span>
<div class="imp-bar-bg"><div class="imp-bar" :style="{ width: `${(it.v / impMax) * 100}%` }" /></div>
<span class="mono imp-val">{{ it.v.toFixed(3) }}</span>
</div>
</div>
<div v-if="redundancy.length" class="redun">
<span class="redun-title">池冗余注记(|corr|&gt;0.7,只记录不剔除——首年观察期)</span>
<span v-for="(p, i) in redundancy" :key="i" class="redun-chip mono">{{ p.a }} ↔ {{ p.b }} {{ p.corr?.toFixed(2) }}</span>
</div>
</div>
</PanelCard>
</div>
</template>
@@ -93,4 +175,26 @@ const te = (v: CompositeVersion): string =>
.gate { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.gate-chip { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); border: 1px dashed var(--border); border-radius: var(--r-sm); padding: 2px 8px; }
.gate-note { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
/* 三路对拍卡 */
.chal-empty { font-size: 12px; color: var(--text-3); padding: 14px 0; border: 1px dashed var(--border); border-radius: var(--r-sm); text-align: center; }
.chal-meta { font-size: 10.5px; color: var(--text-3); margin-bottom: 10px; }
.chal-table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.chal-table th { text-align: left; font-size: 10.5px; font-weight: 400; color: var(--text-3); padding: 4px 10px 6px 0; border-bottom: 1px solid var(--border); }
.chal-table td { padding: 7px 10px 7px 0; border-bottom: 1px solid var(--border); font-size: 12px; vertical-align: baseline; }
.chal-table td.win { color: var(--lamp-ok); font-weight: 700; }
.chal-name { font-weight: 700; margin-right: 8px; }
.chal-name.lg { color: #ffb84d; }
.chal-note { font-size: 10.5px; color: var(--text-3); }
.chal-verdict { font-size: 12.5px; color: var(--text-2); background: rgba(0, 229, 255, 0.06); border-left: 2px solid var(--brand); padding: 8px 12px; border-radius: 0 var(--r-sm) var(--r-sm) 0; margin-bottom: 6px; }
.chal-disclaimer { font-size: 10px; color: var(--text-3); margin-bottom: 14px; }
.imp { margin-bottom: 12px; }
.imp-title, .redun-title { display: block; font-size: 11px; color: var(--text-3); margin-bottom: 8px; }
.imp-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.imp-name { width: 110px; font-size: 11px; color: var(--text-2); flex-shrink: 0; }
.imp-bar-bg { flex: 1; height: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 2px; overflow: hidden; }
.imp-bar { height: 100%; background: linear-gradient(90deg, rgba(0, 229, 255, 0.35), var(--brand)); border-radius: 2px; }
.imp-val { width: 46px; font-size: 10.5px; color: var(--text-3); text-align: right; flex-shrink: 0; }
.redun { margin-bottom: 4px; }
.redun-chip { display: inline-block; font-size: 10.5px; color: #ffb84d; border: 1px solid rgba(255, 184, 77, 0.4); border-radius: var(--r-sm); padding: 2px 8px; margin: 0 6px 6px 0; }
</style>
+33
View File
@@ -945,6 +945,39 @@ def composite(family: str | None = None) -> dict:
"items": [challenger, incumbent]}
@router.get("/pipeline/composite/challenge")
def composite_challenge() -> dict:
"""三路对拍最新件(等权/ICIR/LGBM 影子实验,spec §4.4 决议 O 前置可視性).
读报告根 challenger_lgbm/ 子目录(challenger_lgbm.py 产物,月度链 stage6
每月覆盖);多件取 as_of 最大,坏件 skip 留痕.无件=空态(11-01 首考),
前端显示"暂无对拍数据"——不造数.
"""
import glob as _glob
sub = os.path.join(_monthly_dir(), "challenger_lgbm")
docs: list[tuple[str, str, dict]] = [] # (as_of, file, doc)
for p in sorted(_glob.glob(os.path.join(sub, "*.json"))):
try:
with open(p, encoding="utf-8") as f:
d = json.load(f)
as_of = str(d.get("as_of") or "")
if as_of:
docs.append((as_of, os.path.basename(p), d))
except Exception as exc: # 坏件 skip: 对拍件非权威数据面,宁缺勿崩
logger.warning("challenge 对拍件坏件 skip %s: %s", p, exc)
if not docs:
return {"challenge": None,
"available": [],
"note": "暂无对拍数据——月度链 stage6 每月产出"
"(11-01 首考);challenger 永不进生产(影子实验)"}
docs.sort(key=lambda t: t[0]) # as_of 字典序=时间序
_, fname, doc = docs[-1]
doc["file"] = fname
return {"challenge": doc, "available": [f for _, f, _ in docs],
"note": ""}
# —— P4-1 假设卡片向导(决议 M:LLM 只做文字→结构,draft 不落库,确认才落) ——
_DRAFT_SENTENCE_MAX = 500
+5
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import numpy as np
@@ -321,3 +322,7 @@ def main(argv: list[str] | None = None) -> int:
return 0 # 无宽表=非错误(截面盘未建),不阻链
print(f"[challenger] 对拍件: {path}")
return 0
if __name__ == "__main__": # python -m 入口(guard 缺失=静默导入即退,月度链空跑)
sys.exit(main())
@@ -132,3 +132,56 @@ def test_unknown_family_fail_soft(client, env):
assert doc["family"] == "composite_nope"
assert doc["items"] == []
assert doc["families"] # 清单照常(切换器不空)
# —— GET /pipeline/composite/challenge 三路对拍件(challenger_lgbm 月度产物) ——
import json as _json
def _put_doc(monthly_dir, name, as_of, ic_eq=0.05, ic_lg=0.06):
import os
sub = os.path.join(monthly_dir, "challenger_lgbm")
os.makedirs(sub, exist_ok=True)
doc = {"as_of": as_of, "generated_at": f"{as_of}T21:40:00", "host": "nas",
"pool": {"names": ["alpha12"], "size": 1},
"split": {"oos_month": as_of[:7], "note": ""},
"signals": {"equal": {"ic_mean": ic_eq, "icir": 0.8, "q5q1": 0.01, "days": 20},
"icir": {"ic_mean": ic_eq, "icir": 0.8, "q5q1": 0.01, "days": 20},
"lgbm": {"ic_mean": ic_lg, "icir": 0.9, "q5q1": 0.012, "days": 20}},
"feature_importance": {"alpha12": 1.0},
"redundancy_flagged": [], "lgbm_params": {}}
with open(os.path.join(sub, name), "w", encoding="utf-8") as f:
_json.dump(doc, f)
return doc
def test_challenge_empty_when_no_dir(client, monkeypatch, tmp_path):
monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(tmp_path / "none"))
r = client.get("/api/v1/pipeline/composite/challenge")
assert r.status_code == 200
doc = r.json()
assert doc["challenge"] is None and doc["available"] == []
assert "暂无对拍数据" in doc["note"]
def test_challenge_latest_as_of_wins(client, monkeypatch, tmp_path):
monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(tmp_path))
_put_doc(tmp_path, "nas_2026-08-31.json", "2026-08-31")
_put_doc(tmp_path, "nas_2026-09-30.json", "2026-09-30")
doc = client.get("/api/v1/pipeline/composite/challenge").json()
assert doc["challenge"]["as_of"] == "2026-09-30"
assert doc["challenge"]["file"] == "nas_2026-09-30.json"
assert sorted(doc["available"]) == ["nas_2026-08-31.json", "nas_2026-09-30.json"]
assert doc["challenge"]["signals"]["lgbm"]["ic_mean"] == 0.06
def test_challenge_bad_json_skipped(client, monkeypatch, tmp_path):
import os
monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(tmp_path))
_put_doc(tmp_path, "nas_2026-09-30.json", "2026-09-30")
with open(os.path.join(tmp_path, "challenger_lgbm", "bad.json"), "w") as f:
f.write("{broken")
doc = client.get("/api/v1/pipeline/composite/challenge").json()
assert doc["challenge"]["as_of"] == "2026-09-30"
assert "bad.json" not in doc["available"]