mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
fix(walkforward): 子代理审查修复 — 折有效性/前视泄漏/方向退化/NaN序列化/净值曲线
三份子代理审查 (核心/API/前端) 发现的真实 bug: 核心正确性 (walkforward.py): - [高] 折有效性抽象: best_params=None (训练全组失败) 或 OOS error 的折原会用默认 参数硬跑/按0收益混入复利, 伪装成有效折污染 OOS 净值与退化指标。改为分流 —— 有效折进 folds, 无效折进 skipped(带原因), 聚合只看有效折。一次修掉 H1/H2/M1。 - [高] 前视泄漏: train_end==test_start 使该日 K 线同时进训练与 OOS 首日。test_start 后移一天隔断。 - [中] degradation 方向感知: 原 avg_is-avg_oos 对 min 类目标 (avg_holding_days) 符号 反了。归一到越大越好空间再相减; 每折加 oos_degraded 方向感知标志。 - consistency 改为 OOS 盈利折占比 (与目标方向无关, 更直观)。 API (backtest.py): - [高] 单折 guard: WF 缺 guard, start=None 默认拉 3 年, 每折训练窗口可能 OOM。按 单折窗口 (train/test) 而非总区间 guard —— 总区间长本是 WF 正常形态, 按总区间拦会误杀。 - [中] NaN/inf 序列化: json.dumps(default=str) 处理不了 nan/inf, 输出非法 JSON 崩前端。 加 _json_safe 递归清洗, 优化器与 WF 两处 done 分支都套上。 前端 (StrategyWalkForward.tsx): - n_folds=0 门控: 全跳过时不再渲染误导性全0卡, 改显示'未产生有效折'+跳过原因。 - 渲染 OOS 拼接净值曲线 (walk-forward 核心产出, 原后端算了前端没画)。 - 每折退化标红改用后端方向感知的 oos_degraded (min 类目标 oos<is 未必退化)。 测试新增: 前视泄漏隔断 / 方向感知退化(min目标) / best_params=None跳过 / OOS error跳过。后端 173 测试通过; 前端 tsc 无新增错误。
This commit is contained in:
@@ -18,6 +18,7 @@ export interface WFFold {
|
||||
best_params: Record<string, any> | null
|
||||
is_score: number | null
|
||||
oos_objective: number | null
|
||||
oos_degraded: boolean | null
|
||||
oos_stats: Record<string, any>
|
||||
}
|
||||
|
||||
@@ -33,9 +34,12 @@ export interface WFSummary {
|
||||
|
||||
export interface WalkForwardResult {
|
||||
objective: string
|
||||
direction: string
|
||||
n_folds: number
|
||||
n_skipped: number
|
||||
n_planned_folds: number
|
||||
folds: WFFold[]
|
||||
skipped: { index: number; test_start: string; test_end: string; reason: string }[]
|
||||
summary: WFSummary
|
||||
elapsed_ms: number
|
||||
}
|
||||
|
||||
@@ -35,6 +35,32 @@ function Stat({ label, value, hint, color }: { label: string; value: string; hin
|
||||
)
|
||||
}
|
||||
|
||||
/** OOS 拼接净值曲线 (逐折复利) — walk-forward 核心产出的极简 SVG 折线。 */
|
||||
function OosEquityChart({ curve }: { curve: { fold: number; date: string; value: number }[] }) {
|
||||
if (!curve.length) return null
|
||||
const W = 600, H = 120, pad = 8
|
||||
const vals = curve.map(p => p.value)
|
||||
const lo = Math.min(1, ...vals), hi = Math.max(1, ...vals)
|
||||
const span = hi - lo || 1
|
||||
// 起点补一个 value=1 基准, 让曲线从 1.0 起步
|
||||
const pts = [1, ...vals]
|
||||
const x = (i: number) => pad + (i / (pts.length - 1 || 1)) * (W - 2 * pad)
|
||||
const y = (v: number) => pad + (1 - (v - lo) / span) * (H - 2 * pad)
|
||||
const d = pts.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
const last = vals[vals.length - 1]
|
||||
const up = last >= 1
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-secondary">OOS 拼接净值 (逐折复利)</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" preserveAspectRatio="none" style={{ height: 120 }}>
|
||||
<line x1={pad} y1={y(1)} x2={W - pad} y2={y(1)} stroke="currentColor" strokeWidth="0.5" className="text-border" strokeDasharray="3 3" />
|
||||
<path d={d} fill="none" stroke={up ? '#34d399' : '#f87171'} strokeWidth="1.5" />
|
||||
</svg>
|
||||
<div className="mt-0.5 text-[10px] text-secondary">终值 {last.toFixed(4)} · {curve.length} 折</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StrategyWalkForward() {
|
||||
const task = useWalkForwardTask()
|
||||
const { data: stratData } = useQuery({ queryKey: ['strategies'], queryFn: api.strategyList })
|
||||
@@ -167,7 +193,12 @@ export function StrategyWalkForward() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && summary && (
|
||||
{result && result.n_folds === 0 && (
|
||||
<EmptyState title="未产生有效折"
|
||||
description={`计划 ${result.n_planned_folds} 折, 但 ${result.n_skipped} 折因训练区间未优化出参数或 OOS 回测失败被跳过。请检查数据范围或放宽参数网格。`} />
|
||||
)}
|
||||
|
||||
{result && summary && result.n_folds > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* 汇总卡 */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
@@ -177,14 +208,17 @@ export function StrategyWalkForward() {
|
||||
value={summary.degradation != null ? summary.degradation.toFixed(3) : '—'}
|
||||
hint={summary.degradation != null && summary.degradation > 0 ? '样本外退化=过拟合' : '样本外未退化'}
|
||||
color={summary.degradation != null && summary.degradation > 0 ? '#f87171' : '#34d399'} />
|
||||
<Stat label="一致性" value={fmtPct(summary.consistency)} hint="OOS 目标为正的折占比" />
|
||||
<Stat label="折数" value={String(result.n_folds)} />
|
||||
<Stat label="一致性" value={fmtPct(summary.consistency)} hint="OOS 盈利折占比" />
|
||||
<Stat label="有效折" value={result.n_skipped > 0 ? `${result.n_folds} (跳过${result.n_skipped})` : String(result.n_folds)} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-secondary">
|
||||
IS 目标均值 {summary.avg_is_objective ?? '—'} · OOS 目标均值 {summary.avg_oos_objective ?? '—'} · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s
|
||||
</div>
|
||||
|
||||
{/* OOS 拼接净值曲线 (walk-forward 核心产出) */}
|
||||
<OosEquityChart curve={summary.oos_equity_curve} />
|
||||
|
||||
{/* 每折表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
@@ -202,7 +236,8 @@ export function StrategyWalkForward() {
|
||||
{result.folds.map(f => {
|
||||
const is = f.is_score
|
||||
const oos = f.oos_objective
|
||||
const degraded = is != null && oos != null && oos < is
|
||||
// 用后端方向感知的退化标志 (min 类目标 oos<is 未必是退化)
|
||||
const degraded = f.oos_degraded === true
|
||||
return (
|
||||
<tr key={f.index} className="border-b border-border/40 hover:bg-elevated/50">
|
||||
<td className="px-2 py-1.5 text-secondary">{f.index + 1}</td>
|
||||
|
||||
Reference in New Issue
Block a user