mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user