mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
WF 前端 onRun 只传了 grid/objective/窗口, 没传当前策略的 params/overrides: - 未扫描参数不固定为用户当前值 - basic_filter/entry_signals/exit_signals/止损止盈/评分等覆盖不参与优化 结果 walk-forward 优化的策略与用户实际回测的策略不一致 (与 PR #82 优化器 阻塞项同类问题)。修复: onRun 补 params=selected.params_defaults + overrides=buildDefaultOverrides(selected), 复用 optimizer 同一工具。 附带: - StrategyWalkForward.tsx 用了 EmptyState description= (该组件只接受 title/hint), 导致 tsc -b 构建失败, 2 处改为 hint。 - _make_wf_job_key 已含 params/overrides, 补测试断言二者不同产出不同 job_key (stream 与 cancel 对齐前提) 且相同入参稳定一致。
279 lines
13 KiB
TypeScript
279 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { Play, Square, TrendingDown } from 'lucide-react'
|
||
import { api, type StrategyDetail } from '@/lib/api'
|
||
import { fmtPct } from '@/lib/format'
|
||
import { EmptyState } from '@/components/EmptyState'
|
||
import { DatePicker } from '@/components/DatePicker'
|
||
import {
|
||
startWalkForward,
|
||
stopWalkForward,
|
||
clearWalkForward,
|
||
tryReconnectWalkForward,
|
||
useWalkForwardTask,
|
||
} from '@/lib/walkforwardTask'
|
||
import { buildDefaultOverrides } from '@/lib/strategyOverrides'
|
||
import {
|
||
INPUT_CLS,
|
||
OBJECTIVES,
|
||
GRID_MAX_COMBINATIONS,
|
||
useParamSweep,
|
||
StrategySelect,
|
||
SweepParamList,
|
||
CombosHint,
|
||
} from './components/paramSweep'
|
||
|
||
const TODAY = new Date().toISOString().slice(0, 10)
|
||
const THREE_YEARS_AGO = new Date(Date.now() - 3 * 365 * 864e5).toISOString().slice(0, 10)
|
||
|
||
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
|
||
return (
|
||
<div className="rounded-input border border-border bg-elevated/40 p-2.5">
|
||
<div className="text-[11px] text-secondary">{label}</div>
|
||
<div className="mt-0.5 text-sm font-semibold" style={color ? { color } : undefined}>{value}</div>
|
||
{hint && <div className="mt-0.5 text-[10px] text-secondary">{hint}</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 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 })
|
||
const strategies: StrategyDetail[] = stratData?.strategies ?? []
|
||
|
||
const sweep = useParamSweep(strategies, clearWalkForward)
|
||
const [objective, setObjective] = useState('sortino')
|
||
const [start, setStart] = useState(THREE_YEARS_AGO)
|
||
const [end, setEnd] = useState(TODAY)
|
||
const [mode, setMode] = useState<'position' | 'full'>('position')
|
||
const [trainDays, setTrainDays] = useState('252')
|
||
const [testDays, setTestDays] = useState('63')
|
||
const [stepDays, setStepDays] = useState('63')
|
||
|
||
useEffect(() => {
|
||
tryReconnectWalkForward()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const canRun = sweep.strategyId && sweep.combos > 0 && sweep.combos <= GRID_MAX_COMBINATIONS
|
||
&& Number(trainDays) > 0 && Number(testDays) > 0 && Number(stepDays) > 0 && !task?.isPending
|
||
|
||
const onRun = () => {
|
||
if (!canRun) return
|
||
clearWalkForward()
|
||
startWalkForward({
|
||
strategy_id: sweep.strategyId,
|
||
param_grid: sweep.buildGrid(),
|
||
objective,
|
||
train_days: Number(trainDays),
|
||
test_days: Number(testDays),
|
||
step_days: Number(stepDays),
|
||
// 未扫描参数固定为策略当前默认值; overrides 让 basic_filter/信号/风控按当前策略参与,
|
||
// 否则 walk-forward 优化的策略与用户实际回测的不一致 (同 PR #82 优化器修复)。
|
||
params: sweep.selected?.params_defaults,
|
||
overrides: sweep.selected ? buildDefaultOverrides(sweep.selected) : undefined,
|
||
start,
|
||
end,
|
||
mode,
|
||
})
|
||
}
|
||
|
||
const result = task?.result
|
||
const progress = task?.progress
|
||
const summary = result?.summary
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[320px_1fr]">
|
||
{/* ── 配置面板 ── */}
|
||
<div className="space-y-3 rounded-card border border-border bg-surface p-4">
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-secondary">策略</label>
|
||
<StrategySelect strategies={strategies} value={sweep.strategyId} onChange={sweep.selectStrategy} />
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-secondary">优化目标</label>
|
||
<select value={objective} onChange={e => setObjective(e.target.value)} className={INPUT_CLS}>
|
||
{OBJECTIVES.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-secondary">起始</label>
|
||
<DatePicker value={start} onChange={setStart} />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-secondary">结束</label>
|
||
<DatePicker value={end} onChange={setEnd} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 滚动窗口 */}
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
<div>
|
||
<label className="mb-1 block text-[11px] text-secondary">训练(天)</label>
|
||
<input type="number" min={1} value={trainDays} onChange={e => setTrainDays(e.target.value)} className={INPUT_CLS} />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-[11px] text-secondary">测试(天)</label>
|
||
<input type="number" min={1} value={testDays} onChange={e => setTestDays(e.target.value)} className={INPUT_CLS} />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-[11px] text-secondary">步进(天)</label>
|
||
<input type="number" min={1} value={stepDays} onChange={e => setStepDays(e.target.value)} className={INPUT_CLS} />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1.5 block text-xs font-medium text-secondary">模式</label>
|
||
<select value={mode} onChange={e => setMode(e.target.value as any)} className={INPUT_CLS}>
|
||
<option value="position">组合仓位</option>
|
||
<option value="full">全量独立</option>
|
||
</select>
|
||
</div>
|
||
|
||
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} />
|
||
<CombosHint show={!!sweep.strategyId} combos={sweep.combos} />
|
||
<div className="text-[11px] text-secondary">每折跑 {sweep.combos || 0} 组优化 × N 折,耗时较长</div>
|
||
|
||
{task?.isPending ? (
|
||
<button onClick={stopWalkForward} className="inline-flex w-full items-center justify-center gap-1.5 rounded-btn bg-red-500/90 px-3 py-2 text-xs font-medium text-white hover:bg-red-500">
|
||
<Square className="h-3.5 w-3.5" /> 停止
|
||
</button>
|
||
) : (
|
||
<button onClick={onRun} disabled={!canRun} className="inline-flex w-full items-center justify-center gap-1.5 rounded-btn bg-accent px-3 py-2 text-xs font-medium text-white hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed">
|
||
<Play className="h-3.5 w-3.5" /> 开始 Walk-forward
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── 结果面板 ── */}
|
||
<div className="min-h-[300px] rounded-card border border-border bg-surface p-4">
|
||
{task?.error && (
|
||
<div className="mb-3 rounded-input border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">{task.error}</div>
|
||
)}
|
||
|
||
{task?.isPending && progress && (
|
||
<div className="mb-4">
|
||
<div className="mb-1 flex justify-between text-xs text-secondary">
|
||
<span>第 {progress.done}/{progress.total} 折</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-elevated">
|
||
<div className="h-full bg-accent transition-all" style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!result && !task?.isPending && (
|
||
<EmptyState
|
||
title="Walk-forward 优化"
|
||
hint="每折在训练区间网格优化选最优参数,再在紧邻的测试区间做样本外(OOS)验证。样本内漂亮、样本外崩溃即过拟合。"
|
||
/>
|
||
)}
|
||
|
||
{result && result.n_folds === 0 && (
|
||
<EmptyState title="未产生有效折"
|
||
hint={`计划 ${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">
|
||
<Stat label="OOS 复利收益" value={fmtPct(summary.compounded_oos_return)}
|
||
color={summary.compounded_oos_return >= 0 ? '#34d399' : '#f87171'} />
|
||
<Stat label="IS→OOS 退化"
|
||
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={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">
|
||
<thead>
|
||
<tr className="border-b border-border text-secondary">
|
||
<th className="px-2 py-1.5 text-left">折</th>
|
||
<th className="px-2 py-1.5 text-left">测试区间</th>
|
||
<th className="px-2 py-1.5 text-left">最优参数</th>
|
||
<th className="px-2 py-1.5 text-right">IS 目标</th>
|
||
<th className="px-2 py-1.5 text-right">OOS 目标</th>
|
||
<th className="px-2 py-1.5 text-right">OOS 收益</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{result.folds.map(f => {
|
||
const is = f.is_score
|
||
const oos = f.oos_objective
|
||
// 用后端方向感知的退化标志 (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>
|
||
<td className="px-2 py-1.5 text-secondary">{f.test_start} ~ {f.test_end}</td>
|
||
<td className="px-2 py-1.5 text-foreground">
|
||
{f.best_params ? Object.entries(f.best_params).map(([k, v]) => `${k}=${v}`).join(', ') : '—'}
|
||
</td>
|
||
<td className="px-2 py-1.5 text-right">{is != null ? is.toFixed(3) : '—'}</td>
|
||
<td className="px-2 py-1.5 text-right" style={degraded ? { color: '#f87171' } : undefined}>
|
||
{oos != null ? oos.toFixed(3) : '—'}
|
||
</td>
|
||
<td className="px-2 py-1.5 text-right">
|
||
{f.oos_stats?.total_return != null ? fmtPct(f.oos_stats.total_return) : '—'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{summary.degradation != null && summary.degradation > 0 && (
|
||
<div className="flex items-center gap-1.5 rounded-input border border-red-500/30 bg-red-500/5 px-3 py-2 text-[11px] text-red-400">
|
||
<TrendingDown className="h-3.5 w-3.5" />
|
||
样本外目标较样本内退化 {summary.degradation.toFixed(3)},提示参数可能过拟合训练区间。
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|