From 1f865ced7983cf82aaa88bead3fad5f0d606ae4f Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 2 Aug 2026 18:22:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E9=AB=98=E7=BA=A7=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=AE=BE=E7=BD=AE=E6=96=B0=E5=A2=9E=E3=80=8C=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=88=B0=E7=AD=96=E7=95=A5=E3=80=8D+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=B5=AE=E7=82=B9=E6=98=BE=E7=A4=BA=E4=B8=8E=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「应用到策略」按钮 - 底部按钮区(恢复默认/应用到策略/完成)新增按钮, 调用 api.strategySaveConfig 把当前弹窗的 overrides+params 持久化为策略定义(落盘 strategy_overrides/{id}.json) - 所有页面加载该策略时都用这些参数(策略页加载的就是这里应用过去的) - 成功后 toast 提示 + 刷新 detail + 关闭弹窗 - 仅用户自有策略(custom/ai/composite)显示, 内置策略不显示 修复浮点显示(止损 7.0000000000000001% → 7%) - 根因: 5 个百分比派生变量用 Math.abs(x)*100, 浮点运算产生长尾 - 新增 round4() 辅助截断, 应用到 stopLossPct/takeProfitPct/trailingStopPct/ trailingTakeProfitActivatePct/trailingTakeProfitDrawdownPct 修复「删除即跳最小值」输入体验 - 根因: 受控 input 的 onChange 立即 clamp(n,min,max), 用户键入低于 min 的 中间值时立刻被钳到 min, 无法平滑输入/删除重输 - 新增 NumberField 组件: 输入时只更新文本草稿(不钳制), 失焦时才校正到 [min,max] - 替换抽屉内所有数字输入框: risk tab 6个 + scoring tab 2个 + params tab 动态参数 + filter tab 6个 - 每个 onChange 移除立即 clamp, 业务逻辑(止损负号/回撤联动/整数取整)保留 验证: tsc + pnpm build 通过 --- .../src/pages/backtest/StrategyBacktest.tsx | 230 ++++++++++-------- 1 file changed, 126 insertions(+), 104 deletions(-) diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index dc38aef..58f966f 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -24,6 +24,7 @@ import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries' import { EmptyState } from '@/components/EmptyState' import { WarmupBadge } from '@/components/WarmupBadge' import { DatePicker } from '@/components/DatePicker' +import { toast } from '@/components/Toast' import { StrategyNavChart } from './charts/StrategyNavChart' import { ReturnDistributionChart } from './charts/ReturnDistributionChart' import { TradeKlineModal } from './components/TradeKlineModal' @@ -207,6 +208,54 @@ const clamp = (v: number, min?: number, max?: number) => { if (max != null) next = Math.min(next, max) return next } +// 截断浮点长尾(如 0.07*100=7.000000000000001 → 7)。用于百分比派生显示。 +const round4 = (v: number) => Math.round(v * 10000) / 10000 + +/** + * 数字输入框 — 解决"删除即跳最小值"问题。 + * 受控 input 的 onChange 立即 clamp 会让用户键入低于 min 的中间值时被钳到 min, + * 无法平滑输入/删除重输。本组件: 输入时只更新文本草稿(不钳制), 失焦时才校正到 [min,max]。 + */ +function NumberField({ value, onChange, min, max, step, className, placeholder }: +{ + value: number | null + onChange: (v: number | null) => void + min?: number + max?: number + step?: number + className?: string + placeholder?: string +}) { + // 文本草稿: null 表示与外部 value 同步(无未提交编辑); 非 null 表示用户正在输入 + const [draft, setDraft] = useState(null) + // 显示值: 有草稿用草稿, 否则用外部 value (null 显示空) + const display = draft !== null ? draft : (value == null ? '' : String(value)) + return ( + { + // 输入时只更新草稿 + 把原始数字推给父级(不钳制), 让用户自由编辑 + setDraft(e.target.value) + onChange(numOrNull(e.target.value)) + }} + onBlur={() => { + // 失焦时校正: 空值保持 null; 否则钳制到 [min,max] + const n = numOrNull(draft ?? '') + if (n != null && (min != null || max != null)) { + const clamped = clamp(n, min, max) + if (clamped !== n) onChange(clamped) + } + setDraft(null) // 清除草稿, 回到外部 value 同步 + }} + className={className} + /> + ) +} const strategyDefaultParams = (detail: StrategyDetail) => { const values: Record = { ...detail.params_defaults } detail.params.forEach(p => { @@ -693,18 +742,12 @@ function StrategyParamInput({ param, value, onChange }: { return ( @@ -963,6 +1006,26 @@ export function StrategyBacktest() { setOverrides(buildDefaultOverrides(detail)) } + // 「应用到策略」: 把弹窗里当前编辑的 overrides + params 持久化为策略定义, + // 使所有页面加载该策略时都用这些参数(后端 save_config → 落盘 strategy_overrides/{id}.json)。 + const [applying, setApplying] = useState(false) + const handleApplyToStrategy = async () => { + if (!detail || !selectedStrategy) return + setApplying(true) + try { + // 合并 params 进 overrides(后端 _strategy_detail 会把 params 合并进 params_defaults) + const payload = { ...normalizeStrategyOverrides(detail, overrides), params: strategyParams } + await api.strategySaveConfig(selectedStrategy, payload) + toast('已应用到策略定义', 'success') + await strategyDetail.refetch() + setSettingsOpen(false) + } catch (e) { + toast(`应用失败 · ${String((e as Error)?.message || e)}`, 'error') + } finally { + setApplying(false) + } + } + // 刷新页面后: 从 localStorage 恢复未完成的回测任务 useEffect(() => { tryReconnect() @@ -1228,11 +1291,11 @@ export function StrategyBacktest() { const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring]) const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min) const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max) - const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100) - const takeProfitPct = overrides.take_profit == null ? '' : String(Math.abs(Number(overrides.take_profit)) * 100) - const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100) - const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100) - const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100) + const stopLossPct = overrides.stop_loss == null ? '' : String(round4(Math.abs(Number(overrides.stop_loss)) * 100)) + const takeProfitPct = overrides.take_profit == null ? '' : String(round4(Math.abs(Number(overrides.take_profit)) * 100)) + const trailingStopPct = overrides.trailing_stop == null ? '' : String(round4(Math.abs(Number(overrides.trailing_stop)) * 100)) + const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(round4(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)) + const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(round4(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)) const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days) const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0 @@ -2397,19 +2460,15 @@ export function StrategyBacktest() {
{BASIC_FILTER_FIELDS.map(field => { const scale = field.scale ?? 1 - const value = basicFilter[field.key] == null ? '' : Number(basicFilter[field.key]) / scale + const raw = basicFilter[field.key] return ( @@ -2527,33 +2586,19 @@ export function StrategyBacktest() {
@@ -2568,61 +2613,40 @@ export function StrategyBacktest() {
@@ -2675,6 +2686,17 @@ export function StrategyBacktest() { > 恢复默认 + {/* 应用到策略: 把当前配置持久化为策略定义(仅用户自有策略可改) */} + {(detail.source === 'custom' || detail.source === 'ai' || detail.source === 'composite') && ( + + )}