From d9f7bc645d68dffd89d84b1661c824528074148a Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E5=9B=9E=E6=B5=8B=E9=A1=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=86=E9=92=9F=E7=AD=96=E7=95=A5=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E9=92=9F=E7=BA=A7=E6=88=90=E4=BA=A4=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 策略下拉改取 'all' 时段, 分钟策略入列表并加 sky 色「分钟」徽章 - 选中分钟策略条件化 UI: 隐藏分钟成交开关与 signal_next_minute, 建仓口径固定显示「信号分钟收盘」, 展示本地分钟K覆盖提示与起始日越界警告 - 交易明细买入列渲染 HH:MM 分钟徽章 (entry_date 携带 YYYY-MM-DD HH:MM) - SSE 连接前先 fetch 探测, HTTP 400 直接展示后端 detail, 不再误入 EventSource 无限重连 --- frontend/src/lib/api.ts | 2 +- frontend/src/lib/backtestTask.ts | 22 ++++- frontend/src/lib/queryKeys.ts | 2 +- .../src/pages/backtest/StrategyBacktest.tsx | 82 +++++++++++++++---- 4 files changed, 89 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 33b504d..a9bcff3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -632,7 +632,7 @@ export interface StrategyDetail { description: string tags: string[] source: 'builtin' | 'custom' | 'ai' | 'composite' - execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite' + execution_backend: 'polars_expr' | 'matrix_native' | 'python_history_legacy' | 'composite' | 'minute_filter' asset_types: string[] timeframes: string[] version: string diff --git a/frontend/src/lib/backtestTask.ts b/frontend/src/lib/backtestTask.ts index 01ae568..515b50f 100644 --- a/frontend/src/lib/backtestTask.ts +++ b/frontend/src/lib/backtestTask.ts @@ -66,7 +66,7 @@ function buildQuery(params: Record { const id = current?.id ?? ++taskSeq // 关闭旧连接 @@ -75,6 +75,26 @@ function connectSSE(url: string): void { eventSource = null } + // 预检: 4xx 拒绝 (如分钟策略回测的分钟K覆盖守卫 400) 时 EventSource 只会无 data 地 + // onerror, 会被当成"连接中断"有界重试 — 先 fetch 一次把后端 detail 直接展示给用户。 + try { + const probe = await fetch(url, { headers: { Accept: 'text/event-stream' } }) + if (!probe.ok) { + let message = `回测请求失败 (${probe.status})` + try { + message = (await probe.json())?.detail ?? message + } catch { /* ignore */ } + await probe.body?.cancel().catch(() => {}) + if (current?.id === id) { + current = { ...current, isPending: false, error: message, reconnecting: false } + emit() + localStorage.removeItem(RECONNECT_KEY) + } + return + } + await probe.body?.cancel().catch(() => {}) + } catch { /* 网络层异常: 交给下方 EventSource 的重连逻辑 */ } + const es = new EventSource(url) eventSource = es diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 4f7f140..a706a88 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -40,7 +40,7 @@ export const QK = { // Screener screener: ['screener'] as const, - screenerStrategies: (assetType: string = 'stock') => ['screener-strategies', assetType] as const, + screenerStrategies: (assetType: string = 'stock', timeframe: '1d' | '1m' | 'all' = '1d') => ['screener-strategies', assetType, timeframe] as const, screenerCachedSummary: ['screener-cached', 'summary'] as const, screenerCachedResult: (strategyId: string, asOf?: string, ext?: string) => ['screener-cached', 'strategy', strategyId, asOf ?? '', ext ?? ''] as const, screenerCached: (asOf?: string, ext?: string) => ['screener-cached', 'all', asOf ?? '', ext ?? ''] as const, diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index c631035..930b16b 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -476,7 +476,10 @@ function DailyTradeChip({ trade, side, strategyName, onClick, signalNames }: { t function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; signalNames?: Record }) { const isBuy = side === 'buy' - const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10) + // 分钟策略入场携带 "YYYY-MM-DD HH:MM" (盘中触发分钟); 日线口径为纯日期 + const raw = String(isBuy ? trade.entry_date : trade.exit_date) + const date = raw.slice(0, 10) + const minuteTime = raw.length > 10 ? raw.slice(11, 16) : '' const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10) const price = isBuy ? trade.entry_price : trade.exit_price const amount = isBuy ? trade.entry_value : trade.exit_value @@ -487,7 +490,12 @@ function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTra return (
- 成交 {date} + + 成交 {date} + {minuteTime && ( + {minuteTime} + )} + @@ -954,8 +962,8 @@ export function StrategyBacktest() { const loadedStrategyRef = useRef(null) const strategies = useQuery({ - queryKey: QK.screenerStrategies(assetType), - queryFn: () => api.screenerStrategies(assetType), + queryKey: QK.screenerStrategies(assetType, 'all'), + queryFn: () => api.screenerStrategies(assetType, 'all'), }) const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data]) const filteredStrategyList = useMemo(() => ( @@ -1080,7 +1088,7 @@ export function StrategyBacktest() { positionSizing, mode: simMode, holdingDays, - minuteFill: highGranularity, + minuteFill: isMinuteStrategy ? false : highGranularity, regimeStates, regimeMinScore, params: strategyParams, @@ -1118,7 +1126,7 @@ export function StrategyBacktest() { overrides: requestOverrides, mode: simMode, holding_days: Number(holdingDays) || 5, - minute_fill: highGranularity, + minute_fill: isMinuteStrategy ? false : highGranularity, regime_filter: regimeStates.length > 0 || regimeMinScore !== '' ? { ...(regimeStates.length > 0 ? { states: regimeStates } : {}), @@ -1286,11 +1294,22 @@ export function StrategyBacktest() { const minuteTriggerSignals = detail?.minute_exit_trigger_supported_signals ?? [] const unsupportedMinuteExitSignals = effectiveExitSignals.filter(signal => !minuteTriggerSignals.includes(signal)) const minuteExitTriggerSupported = effectiveExitSignals.length > 0 && unsupportedMinuteExitSignals.length === 0 + // 分钟策略: 入场在盘中触发分钟成交, 日线专属的成交口径选项不适用 + const isMinuteStrategy = detail?.execution_backend === 'minute_filter' + const { data: minuteDataStatus } = useQuery({ + queryKey: QK.dataStatus, + queryFn: api.dataStatus, + enabled: isMinuteStrategy, + staleTime: 60_000, + }) + // 分钟回测窗口守卫: 开始日期早于本地分钟K起点会被后端拒绝, 前置警示 + const minuteEarliest = minuteDataStatus?.minute?.earliest_date + const minuteStartMismatch = isMinuteStrategy && !!minuteEarliest && start < minuteEarliest useEffect(() => { - if (highGranularity && minuteExitTriggerSupported) return + if (highGranularity && minuteExitTriggerSupported && !isMinuteStrategy) return if (exitFill === 'signal_next_minute') setExitFill('close_t') - }, [exitFill, highGranularity, minuteExitTriggerSupported]) + }, [exitFill, highGranularity, minuteExitTriggerSupported, isMinuteStrategy]) const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring]) const scoringDirections = useMemo( @@ -1395,7 +1414,8 @@ export function StrategyBacktest() {
- {/* 分钟K成交 */} + {/* 分钟K成交 — 日线策略专属 (分钟策略入场天然按触发分钟成交) */} + {!isMinuteStrategy && (
+ )}
+ {/* 分钟策略提示条: 数据窗口 + 成交语义 */} + {isMinuteStrategy && ( +
+ +
+ 分钟策略回测 + :逐日回放分钟K,信号分钟收盘价买入;日线条件按 T-1 完成态评估。 + {minuteDataStatus?.minute?.earliest_date + ? ` 本地分钟K ${minuteDataStatus.minute.earliest_date} ~ ${minuteDataStatus.minute.latest_date}(${minuteDataStatus.minute.trading_days} 个交易日),缺分区的日子自动跳过。` + : ' 本地暂无分钟K数据,请先在数据页拉取。'} + {minuteStartMismatch && ( + + 当前开始日期 {start} 早于分钟数据起点 {minuteEarliest},运行会被拒绝 — 请把开始日期调整到 {minuteEarliest} 之后,或先用「扩展分钟K历史」拉取。 + + )} +
+
+ )} {/* 分钟K开启时的提示条 */} - {highGranularity && hasMinuteBatch && ( + {highGranularity && hasMinuteBatch && !isMinuteStrategy && (
@@ -1465,6 +1504,9 @@ export function StrategyBacktest() { }`} > {st.name} + {st.timeframes?.includes('1m') && ( + 分钟 + )} {st.source && st.source !== 'builtin' && ( {SRC_MAP[st.source] ?? ''} @@ -1619,10 +1661,18 @@ export function StrategyBacktest() {
- + {isMinuteStrategy ? ( +
+ + 信号分钟收盘 + 分钟 +
+ ) : ( + + )}
@@ -1633,12 +1683,12 @@ export function StrategyBacktest() { > - {highGranularity && minuteExitTriggerSupported && ( + {highGranularity && minuteExitTriggerSupported && !isMinuteStrategy && ( )}
- {(entryFill === 'close_t' || exitFill === 'close_t') && ( + {!isMinuteStrategy && (entryFill === 'close_t' || exitFill === 'close_t') && (
信号日收盘仅适合收盘前已确认的信号