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') && ( + + )}