feat(walkforward): 前端 walk-forward 面板 + 抽出共享参数扫描组件

PR2b 前端 (完成 PR2b):

抽共享组件 (DRY, 优化器与 walk-forward 复用):
- components/paramSweep.tsx: Sweep 类型/helper + useParamSweep hook (策略选择 +
  各参数扫描配置 + 组合数 + buildGrid) + StrategySelect/SweepParamList/CombosHint
  组件 + OBJECTIVES/INPUT_CLS 常量。
- StrategyOptimizer.tsx 重构复用该组件 (删除本地重复的 sweep 逻辑与 JSX)。

Walk-forward:
- lib/walkforwardTask.ts: SSE 客户端 (镜像 optimizerTask + job_key 回吐 + 重连)。
- StrategyWalkForward.tsx: 配置 (策略/目标/日期/训练-测试-步进窗口/可扫参数) +
  结果 (汇总卡: OOS复利收益/IS→OOS退化/一致性/折数; 每折表: 测试区间/最优参数/
  IS目标/OOS目标(退化标红)/OOS收益; 过拟合告警条)。
- Backtest.tsx: 新增 'Walk-forward' 第四 tab。

前端 tsc 无新增类型错误。
This commit is contained in:
im47cn
2026-07-11 19:20:31 +08:00
parent 586d9a4699
commit 4c40c6b1bf
5 changed files with 682 additions and 167 deletions
+223
View File
@@ -0,0 +1,223 @@
import { useSyncExternalStore } from 'react'
/** Walk-forward 任务管理 (SSE + job_key 回吐 + 重连)。镜像 optimizerTask。 */
export interface WFProgress {
type: string
done: number
total: number
fold: number
}
export interface WFFold {
index: number
train_start: string
train_end: string
test_start: string
test_end: string
best_params: Record<string, any> | null
is_score: number | null
oos_objective: number | null
oos_stats: Record<string, any>
}
export interface WFSummary {
n_folds: number
compounded_oos_return: number
avg_is_objective: number | null
avg_oos_objective: number | null
degradation: number | null
consistency: number
oos_equity_curve: { fold: number; date: string; value: number }[]
}
export interface WalkForwardResult {
objective: string
n_folds: number
n_planned_folds: number
folds: WFFold[]
summary: WFSummary
elapsed_ms: number
}
export interface WalkForwardTask {
id: number
isPending: boolean
result: WalkForwardResult | null
progress: WFProgress | null
error: string | null
}
export interface StartWalkForwardParams {
strategy_id: string
param_grid: Record<string, any>
objective: string
train_days: number
test_days: number
step_days: number
symbols?: string[] | null
start?: string | null
end?: string | null
mode?: 'position' | 'full'
}
let current: WalkForwardTask | null = null
const listeners = new Set<() => void>()
let taskSeq = 0
let eventSource: EventSource | null = null
let currentJobKey: string | null = null
const RECONNECT_KEY = 'walkforward_reconnect'
const JOB_KEY_KEY = 'walkforward_job_key'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') sp.set(k, String(v))
}
return sp.toString()
}
function connectSSE(url: string): void {
const id = current?.id ?? ++taskSeq
if (eventSource) {
eventSource.close()
eventSource = null
}
const es = new EventSource(url)
eventSource = es
es.addEventListener('job', (e: MessageEvent) => {
try {
const key = JSON.parse(e.data)?.key
if (key) {
currentJobKey = key
localStorage.setItem(JOB_KEY_KEY, key)
}
} catch { /* ignore */ }
})
es.addEventListener('progress', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const prog = JSON.parse(e.data) as WFProgress
current = { ...current, progress: prog }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as WalkForwardResult
current = { ...current, isPending: false, result, error: null }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败' }
emit()
}
es.close()
eventSource = null
currentJobKey = null
localStorage.removeItem(RECONNECT_KEY)
localStorage.removeItem(JOB_KEY_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? 'walk-forward 出错'
current = { ...current, isPending: false, error: msg }
emit()
} catch {
current = { ...current, isPending: false, error: 'walk-forward 出错' }
emit()
}
es.close()
eventSource = null
currentJobKey = null
localStorage.removeItem(RECONNECT_KEY)
localStorage.removeItem(JOB_KEY_KEY)
}
})
}
export function startWalkForward(params: StartWalkForwardParams): void {
if (eventSource) {
eventSource.close()
eventSource = null
}
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
const qs = buildQuery({
strategy_id: params.strategy_id,
param_grid: JSON.stringify(params.param_grid),
objective: params.objective,
train_days: params.train_days,
test_days: params.test_days,
step_days: params.step_days,
symbols: params.symbols?.join(','),
start: params.start ?? undefined,
end: params.end ?? undefined,
mode: params.mode,
})
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/walkforward/stream?${qs}`)
}
export async function stopWalkForward(): Promise<void> {
const jobKey = currentJobKey ?? localStorage.getItem(JOB_KEY_KEY)
if (jobKey) {
await fetch('/api/backtest/walkforward/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_key: jobKey }),
}).catch(() => {})
}
if (eventSource) {
eventSource.close()
eventSource = null
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消' }
emit()
}
currentJobKey = null
localStorage.removeItem(RECONNECT_KEY)
localStorage.removeItem(JOB_KEY_KEY)
}
export function clearWalkForward(): void {
current = null
emit()
}
export function tryReconnectWalkForward(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
connectSSE(`/api/backtest/walkforward/stream?${qs}`)
return true
}
export function useWalkForwardTask(): WalkForwardTask | null {
return useSyncExternalStore(subscribe, () => current, () => null)
}
+11 -3
View File
@@ -3,9 +3,10 @@ import { PageHeader } from '@/components/PageHeader'
import { FactorBacktest } from './backtest/FactorBacktest'
import { StrategyBacktest } from './backtest/StrategyBacktest'
import { StrategyOptimizer } from './backtest/StrategyOptimizer'
import { BarChart3, FlaskConical, SlidersHorizontal } from 'lucide-react'
import { StrategyWalkForward } from './backtest/StrategyWalkForward'
import { BarChart3, FlaskConical, SlidersHorizontal, Waypoints } from 'lucide-react'
type Tab = 'factor' | 'strategy' | 'optimizer'
type Tab = 'factor' | 'strategy' | 'optimizer' | 'walkforward'
const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
factor: {
@@ -23,12 +24,18 @@ const MODES: Record<Tab, { title: string; subtitle: string; hint: string }> = {
subtitle: '网格搜索最优参数组合',
hint: '并行回测所有参数组合,按夏普/索提诺等目标排序,找到最优参数。',
},
walkforward: {
title: 'Walk-forward',
subtitle: '滚动窗口样本外验证',
hint: '每折训练区间优化、测试区间验证,看样本外是否退化以识别过拟合。',
},
}
const TAB_ICONS: Record<Tab, typeof BarChart3> = {
factor: BarChart3,
strategy: FlaskConical,
optimizer: SlidersHorizontal,
walkforward: Waypoints,
}
export function Backtest() {
@@ -36,7 +43,7 @@ export function Backtest() {
const modeSwitch = (
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
{(['factor', 'strategy', 'optimizer'] as const).map(tab => {
{(['factor', 'strategy', 'optimizer', 'walkforward'] as const).map(tab => {
const Icon = TAB_ICONS[tab]
const active = activeTab === tab
return (
@@ -77,6 +84,7 @@ export function Backtest() {
{activeTab === 'factor' && <FactorBacktest />}
{activeTab === 'strategy' && <StrategyBacktest />}
{activeTab === 'optimizer' && <StrategyOptimizer />}
{activeTab === 'walkforward' && <StrategyWalkForward />}
</main>
</div>
)
+25 -164
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Play, Square, Trophy } from 'lucide-react'
import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { api, type StrategyDetail } from '@/lib/api'
import { fmtPct } from '@/lib/format'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
@@ -13,65 +13,15 @@ import {
useOptimizerTask,
} from '@/lib/optimizerTask'
import { buildDefaultOverrides } from '@/lib/strategyOverrides'
const INPUT_CLS = 'w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs focus:outline-none focus:border-accent'
// 可选优化目标 (对齐后端 VALID_OBJECTIVES) + 中文标签 + 是否越小越好
const OBJECTIVES: { id: string; label: string; min?: boolean }[] = [
{ id: 'sortino', label: '索提诺比率' },
{ id: 'sharpe', label: '夏普比率' },
{ id: 'calmar', label: 'Calmar 比率' },
{ id: 'total_return', label: '总收益' },
{ id: 'annual_return', label: '年化收益' },
{ id: 'win_rate', label: '胜率' },
{ id: 'profit_factor', label: '盈亏比' },
{ id: 'max_drawdown', label: '最大回撤(越小越好)' },
{ id: 'mc_maxdd_p95', label: '蒙卡回撤P95(越小越好)' },
{ id: 'avg_holding_days', label: '平均持仓天数', min: true },
]
// 单个可扫参数的网格配置
interface Sweep {
enabled: boolean
min: string
max: string
step: string
}
function defaultSweep(p: StrategyParamDef): Sweep {
return {
enabled: false,
min: String(p.min ?? p.default ?? 0),
max: String(p.max ?? p.default ?? 1),
step: String(p.step ?? (p.type === 'int' ? 1 : 0.01)),
}
}
/** 从 sweep 配置估算某参数候选值个数 (与后端整数计数一致) */
function candidateCount(p: StrategyParamDef, s: Sweep): number {
if (p.type === 'bool') return 2
if (p.type === 'select') return p.options?.length ?? 1
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
if (!(step > 0) || hi < lo) return 0
return Math.round((hi - lo) / step) + 1
}
/** 校验某数值参数的 sweep 是否会被后端拒绝 (与后端 _candidates_for 同口径)。
* 后端按 lo+i*step 生成 (i=0..round((hi-lo)/step)), 任一值超出 [min,max] 即报错。 */
function sweepError(p: StrategyParamDef, s: Sweep): string | null {
if (p.type === 'bool' || p.type === 'select') return null
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
if (Number.isNaN(lo) || Number.isNaN(hi) || Number.isNaN(step)) return `${p.label}: 范围/步长非法`
if (!(step > 0)) return `${p.label}: 步长必须为正`
if (hi < lo) return `${p.label}: max < min`
if (p.min != null && lo < p.min - 1e-9) return `${p.label}: min 小于允许下限 ${p.min}`
if (p.max != null && hi > p.max + 1e-9) return `${p.label}: max 超出允许上限 ${p.max}`
// 后端生成的末值 lo + round((hi-lo)/step)*step 若 > max, 会被拒
const nSteps = Math.round((hi - lo) / step)
const last = lo + nSteps * step
if (last > hi + 1e-9) return `${p.label}: 步长 ${step} 不整除区间, 末值 ${last.toFixed(4)} 超出 max ${hi}`
return null
}
import {
INPUT_CLS,
OBJECTIVES,
GRID_MAX_COMBINATIONS,
useParamSweep,
StrategySelect,
SweepParamList,
CombosHint,
} from './components/paramSweep'
const TODAY = new Date().toISOString().slice(0, 10)
const ONE_YEAR_AGO = new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10)
@@ -81,15 +31,15 @@ export function StrategyOptimizer() {
const { data: stratData } = useQuery({ queryKey: ['strategies'], queryFn: api.strategyList })
const strategies: StrategyDetail[] = stratData?.strategies ?? []
const [strategyId, setStrategyId] = useState<string>('')
// 切策略: 有任务在跑时先真正取消 (关 SSE + 后端 cancel + 清 localStorage), 不能静默丢
const sweep = useParamSweep(strategies, () => {
if (task?.isPending) stopOptimize()
else clearOptimize()
})
const [objective, setObjective] = useState('sortino')
const [start, setStart] = useState(ONE_YEAR_AGO)
const [end, setEnd] = useState(TODAY)
const [mode, setMode] = useState<'position' | 'full'>('position')
const [sweeps, setSweeps] = useState<Record<string, Sweep>>({})
const selected = strategies.find(s => s.id === strategyId)
const params = selected?.params ?? []
// 刷新/切页后: 恢复未完成的优化任务
useEffect(() => {
@@ -97,62 +47,20 @@ export function StrategyOptimizer() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 切策略: 若有任务在跑, 先真正取消 (关 SSE + 后端 cancel + 清 localStorage), 不能静默丢。
const onSelectStrategy = (id: string) => {
if (task?.isPending) stopOptimize()
else clearOptimize()
setStrategyId(id)
const s = strategies.find(x => x.id === id)
const init: Record<string, Sweep> = {}
for (const p of s?.params ?? []) init[p.id] = defaultSweep(p)
setSweeps(init)
}
const updateSweep = (pid: string, patch: Partial<Sweep>) =>
setSweeps(prev => ({ ...prev, [pid]: { ...prev[pid], ...patch } }))
// 组合数预估
const combos = useMemo(() => {
const enabled = params.filter(p => sweeps[p.id]?.enabled)
if (!enabled.length) return 0
return enabled.reduce((acc, p) => acc * candidateCount(p, sweeps[p.id]), 1)
}, [params, sweeps])
// 网格合法性 (与后端展开同口径): 步长不整除/越界会被后端拒, 前端提前拦。
const gridError = useMemo(() => {
for (const p of params) {
if (!sweeps[p.id]?.enabled) continue
const err = sweepError(p, sweeps[p.id])
if (err) return err
}
return null
}, [params, sweeps])
const buildGrid = (): Record<string, any> => {
const grid: Record<string, any> = {}
for (const p of params) {
const s = sweeps[p.id]
if (!s?.enabled) continue
if (p.type === 'bool') grid[p.id] = [true, false]
else if (p.type === 'select') grid[p.id] = p.options ?? []
else grid[p.id] = { min: Number(s.min), max: Number(s.max), step: Number(s.step) }
}
return grid
}
const canRun = strategyId && combos > 0 && combos <= 2000 && !gridError && !task?.isPending
const canRun = sweep.strategyId && sweep.combos > 0 && sweep.combos <= GRID_MAX_COMBINATIONS
&& !sweep.gridError && !task?.isPending
const onRun = () => {
if (!canRun) return
clearOptimize()
startOptimize({
strategy_id: strategyId,
param_grid: buildGrid(),
strategy_id: sweep.strategyId,
param_grid: sweep.buildGrid(),
objective,
// 未扫描参数固定为策略当前默认值; overrides 让 basic_filter/信号/风控按当前策略参与,
// 保证优化的就是用户实际回测的策略 (而非被剥离配置的裸策略)。
params: selected?.params_defaults,
overrides: selected ? buildDefaultOverrides(selected) : undefined,
params: sweep.selected?.params_defaults,
overrides: sweep.selected ? buildDefaultOverrides(sweep.selected) : undefined,
start,
end,
mode,
@@ -168,10 +76,7 @@ export function StrategyOptimizer() {
<div className="space-y-3 rounded-card border border-border bg-surface p-4 overflow-y-auto min-h-0">
<div>
<label className="mb-1.5 block text-xs font-medium text-secondary"></label>
<select value={strategyId} onChange={e => onSelectStrategy(e.target.value)} className={INPUT_CLS}>
<option value=""></option>
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<StrategySelect strategies={strategies} value={sweep.strategyId} onChange={sweep.selectStrategy} />
</div>
<div>
@@ -200,50 +105,8 @@ export function StrategyOptimizer() {
</select>
</div>
{/* 可扫参数 */}
{params.length > 0 && (
<div>
<div className="mb-1.5 text-xs font-medium text-secondary"> ()</div>
<div className="space-y-2">
{params.map(p => {
const s = sweeps[p.id] ?? defaultSweep(p)
const numeric = p.type === 'float' || p.type === 'int'
return (
<div key={p.id} className="rounded-input border border-border/60 p-2">
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={s.enabled} onChange={e => updateSweep(p.id, { enabled: e.target.checked })} />
<span className="font-medium text-foreground">{p.label}</span>
<span className="text-secondary">({p.type})</span>
</label>
{s.enabled && numeric && (
<div className="mt-2 grid grid-cols-3 gap-1.5">
<input type="number" value={s.min} onChange={e => updateSweep(p.id, { min: e.target.value })} placeholder="min" className={INPUT_CLS} />
<input type="number" value={s.max} onChange={e => updateSweep(p.id, { max: e.target.value })} placeholder="max" className={INPUT_CLS} />
<input type="number" value={s.step} onChange={e => updateSweep(p.id, { step: e.target.value })} placeholder="step" className={INPUT_CLS} />
</div>
)}
{s.enabled && !numeric && (
<div className="mt-1 text-[11px] text-secondary">
{p.type === 'bool' ? '扫描 [是 / 否]' : `扫描全部选项 (${p.options?.length ?? 0})`}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* 组合数 / 校验提示 */}
{strategyId && (
<div className={`text-xs ${(combos > 2000 || gridError) ? 'text-red-400' : 'text-secondary'}`}>
{gridError
? gridError
: combos === 0
? '请至少勾选一个参数'
: `${combos} 组参数组合${combos > 2000 ? ' — 超过上限 2000, 请增大 step 或缩小范围' : ''}`}
</div>
)}
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} />
<CombosHint show={!!sweep.strategyId} combos={sweep.combos} gridError={sweep.gridError} />
{task?.isPending ? (
<button onClick={stopOptimize} 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">
@@ -280,7 +143,6 @@ export function StrategyOptimizer() {
{result && (
<div className="space-y-4">
{/* 最优参数 */}
{result.best_params && (
<div className="rounded-card border border-accent/30 bg-accent/5 p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-accent">
@@ -298,7 +160,6 @@ export function StrategyOptimizer() {
{result.n_completed}/{result.n_combinations} · {(result.elapsed_ms / 1000).toFixed(1)}s
</div>
{/* 排名表 */}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -0,0 +1,238 @@
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 {
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>
)
}
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),
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 优化"
description="每折在训练区间网格优化选最优参数,再在紧邻的测试区间做样本外(OOS)验证。样本内漂亮、样本外崩溃即过拟合。"
/>
)}
{result && summary && (
<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={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>
{/* 每折表 */}
<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
const degraded = is != null && oos != null && oos < is
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>
)
}
@@ -0,0 +1,185 @@
import { useMemo, useState } from 'react'
import type { StrategyDetail, StrategyParamDef } from '@/lib/api'
/** 参数扫描配置的共享逻辑与 UI — 优化器与 walk-forward 复用。 */
export const INPUT_CLS =
'w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs focus:outline-none focus:border-accent'
// 可选优化目标 (对齐后端 VALID_OBJECTIVES) + 中文标签
export const OBJECTIVES: { id: string; label: string }[] = [
{ id: 'sortino', label: '索提诺比率' },
{ id: 'sharpe', label: '夏普比率' },
{ id: 'calmar', label: 'Calmar 比率' },
{ id: 'total_return', label: '总收益' },
{ id: 'annual_return', label: '年化收益' },
{ id: 'win_rate', label: '胜率' },
{ id: 'profit_factor', label: '盈亏比' },
{ id: 'max_drawdown', label: '最大回撤(越小越好)' },
{ id: 'mc_maxdd_p95', label: '蒙卡回撤P95(越小越好)' },
{ id: 'avg_holding_days', label: '平均持仓天数' },
]
export const GRID_MAX_COMBINATIONS = 2000
export interface Sweep {
enabled: boolean
min: string
max: string
step: string
}
function defaultSweep(p: StrategyParamDef): Sweep {
return {
enabled: false,
min: String(p.min ?? p.default ?? 0),
max: String(p.max ?? p.default ?? 1),
step: String(p.step ?? (p.type === 'int' ? 1 : 0.01)),
}
}
/** 某参数候选值个数 (与后端整数计数一致)。 */
function candidateCount(p: StrategyParamDef, s: Sweep): number {
if (p.type === 'bool') return 2
if (p.type === 'select') return p.options?.length ?? 1
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
if (!(step > 0) || hi < lo) return 0
return Math.round((hi - lo) / step) + 1
}
/** 校验某数值参数的 sweep 是否会被后端拒绝 (与后端 _candidates_for 同口径)。
* 后端按 lo+i*step 生成 (i=0..round((hi-lo)/step)), 任一值超出 [min,max] 即报错。 */
function sweepError(p: StrategyParamDef, s: Sweep): string | null {
if (p.type === 'bool' || p.type === 'select') return null
const lo = Number(s.min), hi = Number(s.max), step = Number(s.step)
if (Number.isNaN(lo) || Number.isNaN(hi) || Number.isNaN(step)) return `${p.label}: 范围/步长非法`
if (!(step > 0)) return `${p.label}: 步长必须为正`
if (hi < lo) return `${p.label}: max < min`
if (p.min != null && lo < p.min - 1e-9) return `${p.label}: min 小于允许下限 ${p.min}`
if (p.max != null && hi > p.max + 1e-9) return `${p.label}: max 超出允许上限 ${p.max}`
const nSteps = Math.round((hi - lo) / step)
const last = lo + nSteps * step
if (last > hi + 1e-9) return `${p.label}: 步长 ${step} 不整除区间, 末值 ${last.toFixed(4)} 超出 max ${hi}`
return null
}
/** 管理策略选择 + 各参数扫描配置, 派生组合数 / 校验 / param_grid。 */
export function useParamSweep(strategies: StrategyDetail[], onStrategyChange?: () => void) {
const [strategyId, setStrategyId] = useState<string>('')
const [sweeps, setSweeps] = useState<Record<string, Sweep>>({})
const selected = strategies.find(s => s.id === strategyId)
const params = selected?.params ?? []
const selectStrategy = (id: string) => {
setStrategyId(id)
onStrategyChange?.()
const s = strategies.find(x => x.id === id)
const init: Record<string, Sweep> = {}
for (const p of s?.params ?? []) init[p.id] = defaultSweep(p)
setSweeps(init)
}
const updateSweep = (pid: string, patch: Partial<Sweep>) =>
setSweeps(prev => ({ ...prev, [pid]: { ...prev[pid], ...patch } }))
const combos = useMemo(() => {
const enabled = params.filter(p => sweeps[p.id]?.enabled)
if (!enabled.length) return 0
return enabled.reduce((acc, p) => acc * candidateCount(p, sweeps[p.id]), 1)
}, [params, sweeps])
// 网格合法性 (与后端展开同口径): 步长不整除/越界会被后端拒, 前端提前拦。
const gridError = useMemo(() => {
for (const p of params) {
if (!sweeps[p.id]?.enabled) continue
const err = sweepError(p, sweeps[p.id])
if (err) return err
}
return null
}, [params, sweeps])
const buildGrid = (): Record<string, any> => {
const grid: Record<string, any> = {}
for (const p of params) {
const s = sweeps[p.id]
if (!s?.enabled) continue
if (p.type === 'bool') grid[p.id] = [true, false]
else if (p.type === 'select') grid[p.id] = p.options ?? []
else grid[p.id] = { min: Number(s.min), max: Number(s.max), step: Number(s.step) }
}
return grid
}
return { strategyId, selected, selectStrategy, params, sweeps, updateSweep, combos, gridError, buildGrid }
}
/** 策略选择器。 */
export function StrategySelect({ strategies, value, onChange }: {
strategies: StrategyDetail[]
value: string
onChange: (id: string) => void
}) {
return (
<select value={value} onChange={e => onChange(e.target.value)} className={INPUT_CLS}>
<option value=""></option>
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)
}
/** 可扫参数列表 (勾选 + min/max/step)。 */
export function SweepParamList({ params, sweeps, updateSweep }: {
params: StrategyParamDef[]
sweeps: Record<string, Sweep>
updateSweep: (pid: string, patch: Partial<Sweep>) => void
}) {
if (!params.length) return null
return (
<div>
<div className="mb-1.5 text-xs font-medium text-secondary"> ()</div>
<div className="space-y-2">
{params.map(p => {
const s = sweeps[p.id] ?? defaultSweep(p)
const numeric = p.type === 'float' || p.type === 'int'
return (
<div key={p.id} className="rounded-input border border-border/60 p-2">
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={s.enabled} onChange={e => updateSweep(p.id, { enabled: e.target.checked })} />
<span className="font-medium text-foreground">{p.label}</span>
<span className="text-secondary">({p.type})</span>
</label>
{s.enabled && numeric && (
<div className="mt-2 grid grid-cols-3 gap-1.5">
<input type="number" value={s.min} onChange={e => updateSweep(p.id, { min: e.target.value })} placeholder="min" className={INPUT_CLS} />
<input type="number" value={s.max} onChange={e => updateSweep(p.id, { max: e.target.value })} placeholder="max" className={INPUT_CLS} />
<input type="number" value={s.step} onChange={e => updateSweep(p.id, { step: e.target.value })} placeholder="step" className={INPUT_CLS} />
</div>
)}
{s.enabled && !numeric && (
<div className="mt-1 text-[11px] text-secondary">
{p.type === 'bool' ? '扫描 [是 / 否]' : `扫描全部选项 (${p.options?.length ?? 0})`}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
/** 组合数 / 校验提示 (含上限与网格错误告警)。 */
export function CombosHint({ show, combos, gridError }: { show: boolean; combos: number; gridError?: string | null }) {
if (!show) return null
const bad = combos > GRID_MAX_COMBINATIONS || !!gridError
return (
<div className={`text-xs ${bad ? 'text-red-400' : 'text-secondary'}`}>
{gridError
? gridError
: combos === 0
? '请至少勾选一个参数'
: `${combos} 组参数组合${combos > GRID_MAX_COMBINATIONS ? ` — 超过上限 ${GRID_MAX_COMBINATIONS}, 请增大 step 或缩小范围` : ''}`}
</div>
)
}