diff --git a/frontend/src/lib/walkforwardTask.ts b/frontend/src/lib/walkforwardTask.ts new file mode 100644 index 0000000..7fdddde --- /dev/null +++ b/frontend/src/lib/walkforwardTask.ts @@ -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 | null + is_score: number | null + oos_objective: number | null + oos_stats: Record +} + +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 + 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 { + 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 { + 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) +} diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index 4227f3e..fa44702 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -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 = { factor: { @@ -23,12 +24,18 @@ const MODES: Record = { subtitle: '网格搜索最优参数组合', hint: '并行回测所有参数组合,按夏普/索提诺等目标排序,找到最优参数。', }, + walkforward: { + title: 'Walk-forward', + subtitle: '滚动窗口样本外验证', + hint: '每折训练区间优化、测试区间验证,看样本外是否退化以识别过拟合。', + }, } const TAB_ICONS: Record = { factor: BarChart3, strategy: FlaskConical, optimizer: SlidersHorizontal, + walkforward: Waypoints, } export function Backtest() { @@ -36,7 +43,7 @@ export function Backtest() { const modeSwitch = (
- {(['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' && } {activeTab === 'strategy' && } {activeTab === 'optimizer' && } + {activeTab === 'walkforward' && }
) diff --git a/frontend/src/pages/backtest/StrategyOptimizer.tsx b/frontend/src/pages/backtest/StrategyOptimizer.tsx index cb8398d..5b2f151 100644 --- a/frontend/src/pages/backtest/StrategyOptimizer.tsx +++ b/frontend/src/pages/backtest/StrategyOptimizer.tsx @@ -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('') + // 切策略: 有任务在跑时先真正取消 (关 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>({}) - - 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 = {} - for (const p of s?.params ?? []) init[p.id] = defaultSweep(p) - setSweeps(init) - } - - const updateSweep = (pid: string, patch: Partial) => - 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 => { - const grid: Record = {} - 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() {
- +
@@ -200,50 +105,8 @@ export function StrategyOptimizer() {
- {/* 可扫参数 */} - {params.length > 0 && ( -
-
扫描参数 (勾选后设范围)
-
- {params.map(p => { - const s = sweeps[p.id] ?? defaultSweep(p) - const numeric = p.type === 'float' || p.type === 'int' - return ( -
- - {s.enabled && numeric && ( -
- updateSweep(p.id, { min: e.target.value })} placeholder="min" className={INPUT_CLS} /> - updateSweep(p.id, { max: e.target.value })} placeholder="max" className={INPUT_CLS} /> - updateSweep(p.id, { step: e.target.value })} placeholder="step" className={INPUT_CLS} /> -
- )} - {s.enabled && !numeric && ( -
- {p.type === 'bool' ? '扫描 [是 / 否]' : `扫描全部选项 (${p.options?.length ?? 0})`} -
- )} -
- ) - })} -
-
- )} - - {/* 组合数 / 校验提示 */} - {strategyId && ( -
2000 || gridError) ? 'text-red-400' : 'text-secondary'}`}> - {gridError - ? gridError - : combos === 0 - ? '请至少勾选一个参数' - : `共 ${combos} 组参数组合${combos > 2000 ? ' — 超过上限 2000, 请增大 step 或缩小范围' : ''}`} -
- )} + + {task?.isPending ? (
+ ) +} diff --git a/frontend/src/pages/backtest/components/paramSweep.tsx b/frontend/src/pages/backtest/components/paramSweep.tsx new file mode 100644 index 0000000..4ab0215 --- /dev/null +++ b/frontend/src/pages/backtest/components/paramSweep.tsx @@ -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('') + const [sweeps, setSweeps] = useState>({}) + + 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 = {} + for (const p of s?.params ?? []) init[p.id] = defaultSweep(p) + setSweeps(init) + } + + const updateSweep = (pid: string, patch: Partial) => + 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 => { + const grid: Record = {} + 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 ( + + ) +} + +/** 可扫参数列表 (勾选 + min/max/step)。 */ +export function SweepParamList({ params, sweeps, updateSweep }: { + params: StrategyParamDef[] + sweeps: Record + updateSweep: (pid: string, patch: Partial) => void +}) { + if (!params.length) return null + return ( +
+
扫描参数 (勾选后设范围)
+
+ {params.map(p => { + const s = sweeps[p.id] ?? defaultSweep(p) + const numeric = p.type === 'float' || p.type === 'int' + return ( +
+ + {s.enabled && numeric && ( +
+ updateSweep(p.id, { min: e.target.value })} placeholder="min" className={INPUT_CLS} /> + updateSweep(p.id, { max: e.target.value })} placeholder="max" className={INPUT_CLS} /> + updateSweep(p.id, { step: e.target.value })} placeholder="step" className={INPUT_CLS} /> +
+ )} + {s.enabled && !numeric && ( +
+ {p.type === 'bool' ? '扫描 [是 / 否]' : `扫描全部选项 (${p.options?.length ?? 0})`} +
+ )} +
+ ) + })} +
+
+ ) +} + +/** 组合数 / 校验提示 (含上限与网格错误告警)。 */ +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 ( +
+ {gridError + ? gridError + : combos === 0 + ? '请至少勾选一个参数' + : `共 ${combos} 组参数组合${combos > GRID_MAX_COMBINATIONS ? ` — 超过上限 ${GRID_MAX_COMBINATIONS}, 请增大 step 或缩小范围` : ''}`} +
+ ) +}