feat(backtest): 回测页支持分钟策略与分钟级成交展示

- 策略下拉改取 'all' 时段, 分钟策略入列表并加 sky 色「分钟」徽章
- 选中分钟策略条件化 UI: 隐藏分钟成交开关与 signal_next_minute,
  建仓口径固定显示「信号分钟收盘」, 展示本地分钟K覆盖提示与起始日越界警告
- 交易明细买入列渲染 HH:MM 分钟徽章 (entry_date 携带 YYYY-MM-DD HH:MM)
- SSE 连接前先 fetch 探测, HTTP 400 直接展示后端 detail,
  不再误入 EventSource 无限重连
This commit is contained in:
shy3130
2026-08-30 19:05:17 +08:00
parent 0559632de6
commit d9f7bc645d
4 changed files with 89 additions and 19 deletions
+1 -1
View File
@@ -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
+21 -1
View File
@@ -66,7 +66,7 @@ function buildQuery(params: Record<string, string | number | boolean | undefined
}
/** 连接 SSE (新建或重连都用这个) */
function connectSSE(url: string): void {
async function connectSSE(url: string): Promise<void> {
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
+1 -1
View File
@@ -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,
@@ -476,7 +476,10 @@ function DailyTradeChip({ trade, side, strategyName, onClick, signalNames }: { t
function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; signalNames?: Record<string, string> }) {
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 (
<div className="min-w-[8.25rem] rounded-btn border border-border/60 bg-base/35 px-2 py-1 text-xs leading-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-secondary"> {date}</span>
<span className="font-mono text-secondary">
{date}
{minuteTime && (
<span className="ml-1 rounded border border-sky-500/30 bg-sky-500/10 px-1 py-px text-[9px] font-medium text-sky-400">{minuteTime}</span>
)}
</span>
<span className={`rounded px-1.5 py-px text-[10px] font-medium ${
isBuy ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'
}`}>
@@ -954,8 +962,8 @@ export function StrategyBacktest() {
const loadedStrategyRef = useRef<string | null>(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<string, number>, [overrides.scoring])
const scoringDirections = useMemo(
@@ -1395,7 +1414,8 @@ export function StrategyBacktest() {
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-secondary"></label>
{/* 分钟K成交 */}
{/* 分钟K成交 — 日线策略专属 (分钟策略入场天然按触发分钟成交) */}
{!isMinuteStrategy && (
<div className="flex items-center gap-1">
<Gauge className={`h-3 w-3 ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`} />
<button
@@ -1420,9 +1440,28 @@ export function StrategyBacktest() {
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">K</span>
)}
</div>
)}
</div>
{/* 分钟策略提示条: 数据窗口 + 成交语义 */}
{isMinuteStrategy && (
<div className="mb-2 flex items-start gap-1.5 rounded-btn border border-sky-500/30 bg-sky-500/5 px-2 py-1.5">
<Clock className="h-3 w-3 text-sky-400 shrink-0 mt-px" />
<div className="text-[10px] leading-snug text-sky-400/90">
<span className="font-medium"></span>
K线 T-1
{minuteDataStatus?.minute?.earliest_date
? ` 本地分钟K ${minuteDataStatus.minute.earliest_date} ~ ${minuteDataStatus.minute.latest_date}${minuteDataStatus.minute.trading_days} 个交易日),缺分区的日子自动跳过。`
: ' 本地暂无分钟K数据,请先在数据页拉取。'}
{minuteStartMismatch && (
<span className="mt-0.5 block text-amber-400">
{start} {minuteEarliest} {minuteEarliest} K历史
</span>
)}
</div>
</div>
)}
{/* 分钟K开启时的提示条 */}
{highGranularity && hasMinuteBatch && (
{highGranularity && hasMinuteBatch && !isMinuteStrategy && (
<div className="mb-2 flex items-start gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-2 py-1.5">
<Zap className="h-3 w-3 text-amber-400 shrink-0 mt-px" />
<div className="text-[10px] leading-snug text-amber-400/90">
@@ -1465,6 +1504,9 @@ export function StrategyBacktest() {
}`}
>
<span className="font-medium">{st.name}</span>
{st.timeframes?.includes('1m') && (
<span className="ml-1 text-[8px] px-1 py-px rounded border border-sky-500/30 bg-sky-500/10 text-sky-400"></span>
)}
{st.source && st.source !== 'builtin' && (
<span className={`ml-1 text-[8px] px-1 py-px rounded border ${BADGE_CLS_MAP[st.source] ?? ''}`}>
{SRC_MAP[st.source] ?? ''}
@@ -1619,10 +1661,18 @@ export function StrategyBacktest() {
<label className="text-xs font-medium text-secondary"></label>
<FillRuleHint />
</div>
<select value={entryFill} onChange={e => setEntryFill(e.target.value as 'close_t' | 'open_t+1')} className={INPUT_CLS}>
<option value="open_t+1"></option>
<option value="close_t"></option>
</select>
{isMinuteStrategy ? (
<div className={`${INPUT_CLS} flex items-center gap-1.5`} title="信号在盘中触发分钟成交,无次日开盘口径">
<Clock className="h-3 w-3 text-sky-400 shrink-0" />
<span className="text-secondary"></span>
<span className="text-[8px] px-1 py-px rounded border border-sky-500/30 bg-sky-500/10 text-sky-400"></span>
</div>
) : (
<select value={entryFill} onChange={e => setEntryFill(e.target.value as 'close_t' | 'open_t+1')} className={INPUT_CLS}>
<option value="open_t+1"></option>
<option value="close_t"></option>
</select>
)}
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-secondary"></label>
@@ -1633,12 +1683,12 @@ export function StrategyBacktest() {
>
<option value="close_t"></option>
<option value="open_t+1"></option>
{highGranularity && minuteExitTriggerSupported && (
{highGranularity && minuteExitTriggerSupported && !isMinuteStrategy && (
<option value="signal_next_minute"> BETA</option>
)}
</select>
</div>
{(entryFill === 'close_t' || exitFill === 'close_t') && (
{!isMinuteStrategy && (entryFill === 'close_t' || exitFill === 'close_t') && (
<div className="col-span-2 flex items-start gap-1 text-[10px] leading-4 text-warning">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span></span>