feat(backtest): clickable K-line popups for trade & picks rows

回测结果页三处标的/交易现在都能点开K线弹窗:

- 交易明细每行可点击,复用 TradeKlineModal 打开该笔交易的「交易回放」(买卖价线 + 持仓区间)。
- 选股分析每行(按标的聚合)新增 PicksSymbolKlineModal:展示该标的整个回测期的日K,逐笔标注买入/卖出箭头并显示成交价;同日同方向多笔合并为一个箭头,价格相同显示 ×n,不同则显示区间。
- 原两个弹窗状态合并为单个 union 覆盖层状态,类型上保证同一时刻只打开一个,消除各 opener 的手工互斥。
- 图表组件透传 visibleBars:'all',标的回放初始即适配显示整段区间,避免买卖点被默认尾窗遮挡。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
richard
2026-09-04 11:15:36 +08:00
co-authored by Claude
parent 4cb30e48aa
commit 4bb26713fc
5 changed files with 218 additions and 17 deletions
@@ -334,8 +334,8 @@ interface Props {
linkedPrice?: number | null
onDateClick?: (date: string) => void
onPriceDoubleClick?: (price: number, currentPrice: number) => void
/** 默认可见蜡烛根数, 默认 60 */
visibleBars?: number
/** 默认可见蜡烛根数, 默认 60; 'all' = 初始适配显示全部返回数据 */
visibleBars?: number | 'all'
/** 已激活的子图 key 列表 (含 vol, 按点击顺序) */
activeIndicators?: string[]
/** 成交量柱相对前 N 个交易日均量的显示设置 */
@@ -891,11 +891,13 @@ export function EChartsCandlestick({
return m
}, [dates])
// 计算 dataZoom 初始范围
const initialZoom = useMemo(() => ({
start: Math.max(0, 100 - (visibleBars / Math.max(data.length, 1)) * 100),
end: 100,
}), [visibleBars, data.length])
// dataZoom 初始范围: 'all' = 显示整段数据, 否则取末尾 visibleBars 根
const initialZoom = useMemo(() => {
const start = visibleBars === 'all'
? 0
: Math.max(0, 100 - (visibleBars / Math.max(data.length, 1)) * 100)
return { start, end: 100 }
}, [visibleBars, data.length])
// ===== 信息栏 HTML 内容 (基于 infoIdxRef.current) =====
const getInfoBarHTML = useCallback(() => {
+2 -1
View File
@@ -38,7 +38,8 @@ interface Props {
showMarkerToggle?: boolean
showMA?: boolean
showInfoBar?: boolean
visibleBars?: number
/** 初始可见蜡烛根数; 'all' = 适配显示全部数据 */
visibleBars?: number | 'all'
linkedPrice?: number | null
onDateClick?: (date: string) => void
onPriceDoubleClick?: (price: number, currentPrice: number) => void
+4 -1
View File
@@ -48,6 +48,8 @@ interface Props {
intradayDays?: number
/** 日K/分时并排时日K图占宽 (默认 1:1; 弹窗内图表信息栏较宽需更多空间时传 flex-[1.4] 之类) */
dailyKlineFlex?: string
/** 初始可见蜡烛根数 (默认 60); 'all' = 初始适配显示全部数据 (用于全区间回放) */
visibleBars?: number | 'all'
}
export { getDefaultRange }
@@ -75,6 +77,7 @@ export function StockPanel({
prefetchSymbols,
intradayDays = DEFAULT_INTRADAY_DAYS,
dailyKlineFlex = 'flex-1',
visibleBars,
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
@@ -213,7 +216,7 @@ export function StockPanel({
linkedPrice={linkedPrice}
onDateClick={handleDateClick}
onPriceDoubleClick={onPriceDoubleClick}
visibleBars={showIntraday ? 40 : 60}
visibleBars={visibleBars ?? (showIntraday ? 40 : 60)}
extColumns={extColumns}
refetchIntervalMs={refetchIntervalMs}
/>
@@ -30,6 +30,7 @@ import { toast } from '@/components/Toast'
import { StrategyNavChart } from './charts/StrategyNavChart'
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
import { TradeKlineModal } from './components/TradeKlineModal'
import { PicksSymbolKlineModal } from './components/PicksSymbolKlineModal'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
import { ScoringEditor } from '@/components/ScoringEditor'
@@ -1011,7 +1012,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
const [dailyPage, setDailyPage] = useState(0)
const [tradePage, setTradePage] = useState(0)
const [tradePageSize, setTradePageSize] = useState(10)
const [selectedTrade, setSelectedTrade] = useState<StrategyBacktestTrade | null>(null)
/** 回测K线覆盖层: 单笔交易回放 / 标的全区间回放, 由 union 保证至多开一个 */
const [chartOverlay, setChartOverlay] = useState<
| { kind: 'trade'; trade: StrategyBacktestTrade }
| { kind: 'symbol'; symbol: string }
| null
>(null)
const selectedTrade = chartOverlay?.kind === 'trade' ? chartOverlay.trade : null
const picksSymbol = chartOverlay?.kind === 'symbol' ? chartOverlay.symbol : null
const loadedStrategyRef = useRef<string | null>(null)
const strategies = useQuery({
@@ -2355,7 +2363,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
) : (
<div className="flex flex-wrap gap-1.5">
{row.buys.map((t, i) => (
<DailyTradeChip key={`buy-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="buy" strategyName={result?.strategy_info?.name ?? selectedStrategyName} onClick={() => setSelectedTrade(t)} signalNames={signalNames} />
<DailyTradeChip key={`buy-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="buy" strategyName={result?.strategy_info?.name ?? selectedStrategyName} onClick={() => setChartOverlay({ kind: 'trade', trade: t })} signalNames={signalNames} />
))}
</div>
)}
@@ -2366,7 +2374,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
) : (
<div className="flex flex-wrap gap-1.5">
{row.sells.map((t, i) => (
<DailyTradeChip key={`sell-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="sell" onClick={() => setSelectedTrade(t)} signalNames={signalNames} />
<DailyTradeChip key={`sell-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="sell" onClick={() => setChartOverlay({ kind: 'trade', trade: t })} signalNames={signalNames} />
))}
</div>
)}
@@ -2429,9 +2437,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
</thead>
<tbody>
{visibleTrades.map((t: StrategyBacktestTrade, i: number) => (
<tr key={`${t.symbol}-${t.entry_date}-${tradeStart + i}`} className="border-t border-border hover:bg-elevated/50 transition-colors group">
<tr
key={`${t.symbol}-${t.entry_date}-${tradeStart + i}`}
onClick={() => setChartOverlay({ kind: 'trade', trade: t })}
title="点击查看该笔交易的K线回放"
className="border-t border-border hover:bg-elevated/50 transition-colors group cursor-pointer"
>
<td className="px-4 py-2.5">
<div className="font-medium text-foreground group-hover:text-accent transition-colors">
<div className="font-medium text-foreground transition-colors group-hover:text-accent">
{t.name || t.symbol}
</div>
<div className="mt-0.5 font-mono text-[11px] text-muted">{t.symbol}</div>
@@ -2523,9 +2536,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
</thead>
<tbody>
{result.per_symbol_stats.map((r) => (
<tr key={r.symbol} className="border-t border-border hover:bg-elevated/50 transition-colors group">
<tr
key={r.symbol}
onClick={() => setChartOverlay({ kind: 'symbol', symbol: r.symbol })}
title="点击查看该标的在回测期的K线 (标注每次买卖)"
className="border-t border-border hover:bg-elevated/50 transition-colors group cursor-pointer"
>
<td className="px-4 py-2">
<div className="font-medium text-foreground group-hover:text-accent transition-colors">
<div className="font-medium text-foreground transition-colors group-hover:text-accent">
{symbolNames[r.symbol] || r.symbol}
</div>
<div className="mt-0.5 font-mono text-[11px] text-muted">{r.symbol}</div>
@@ -2886,7 +2904,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
</>
)}
<TradeKlineModal trade={selectedTrade} onClose={() => setSelectedTrade(null)} />
<TradeKlineModal trade={selectedTrade} onClose={() => setChartOverlay(null)} />
<PicksSymbolKlineModal
symbol={picksSymbol}
result={result}
periodStart={resultStartDate}
periodEnd={resultEndDate}
onClose={() => setChartOverlay(null)}
/>
</div>
)
}
@@ -0,0 +1,170 @@
import { useEffect, useMemo } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { X } from 'lucide-react'
import { StockPanel } from '@/components/StockPanel'
import type { ChartMarker } from '@/components/EChartsCandlestick'
import type { StrategyBacktestResult, StrategyBacktestTrade } from '@/lib/api'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { boardTag } from '@/lib/board'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
/** 选中的标的 (null = 关闭) */
symbol: string | null
/** 回测结果, 用于取该标的所有交易与聚合统计 */
result: StrategyBacktestResult | null
/** 回测有效区间 (图默认展示范围) */
periodStart: string
periodEnd: string
onClose: () => void
}
/** 「选股分析」行的标的级K线弹窗 (单笔回放见 TradeKlineModal) */
export function PicksSymbolKlineModal({ symbol, result, periodStart, periodEnd, onClose }: Props) {
const backdrop = useDialogBackdrop(onClose)
useEffect(() => {
if (!symbol) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [symbol, onClose])
const view = useMemo(() => {
if (!symbol || !result) return { trades: [] as StrategyBacktestTrade[], stat: null, name: '' }
const trades = result.trades.filter(t => t.symbol === symbol)
return {
trades,
stat: result.per_symbol_stats.find(p => p.symbol === symbol) ?? null,
name: trades.find(t => t.name)?.name ?? '',
}
}, [symbol, result])
const { trades, stat, name } = view
const markers = useMemo<ChartMarker[]>(() => {
type Agg = { count: number; lo: number; hi: number }
// 同日同方向多笔合成单个箭头 (多箭头会重叠); 价格异则标签显示区间
const byDate = new Map<string, { buy?: Agg; sell?: Agg }>()
const add = (date: string, side: 'buy' | 'sell', price: number) => {
let g = byDate.get(date)
if (!g) { g = {}; byDate.set(date, g) }
const s = g[side]
if (s) {
s.count += 1
s.lo = Math.min(s.lo, price)
s.hi = Math.max(s.hi, price)
} else {
g[side] = { count: 1, lo: price, hi: price }
}
}
const label = (s: Agg) => {
const range = s.lo === s.hi ? fmtPrice(s.lo) : `${fmtPrice(s.lo)}~${fmtPrice(s.hi)}`
return s.count > 1 ? `${range} ×${s.count}` : range
}
for (const t of trades) {
add(String(t.entry_date).slice(0, 10), 'buy', Number(t.entry_price))
add(String(t.exit_date).slice(0, 10), 'sell', Number(t.exit_price))
}
const out: ChartMarker[] = []
for (const [date, g] of byDate) {
if (g.buy) out.push({ date, kind: 'buy', label: label(g.buy) })
if (g.sell) out.push({ date, kind: 'sell', label: label(g.sell) })
}
return out.sort((a, b) => a.date.localeCompare(b.date))
}, [trades])
const tag = symbol ? boardTag(symbol) : ''
const dateRange = useMemo(() => ({ start: periodStart, end: periodEnd }), [periodStart, periodEnd])
return (
<AnimatePresence>
{symbol && (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
{...backdrop}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative flex max-h-[94vh] w-[92vw] max-w-[1120px] flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
>
<div className="border-b border-border px-5 py-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-foreground">{symbol}</span>
{name && <span className="truncate text-sm text-foreground">{name}</span>}
{tag && (
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
{tag}
</span>
)}
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>
</div>
<div className="mt-1 text-[11px] text-muted">
{periodStart} ~ {periodEnd} · {trades.length}
</div>
<div className="mt-1 text-[10px] text-muted/70">
() · (绿) · ·
</div>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
{stat && (
<div className="flex items-center gap-3">
<div className="text-right">
<div className="text-muted"></div>
<div className={`num font-semibold ${priceColorClass(stat.total_return)}`}>{fmtPct(stat.total_return)}</div>
</div>
<div className="text-right">
<div className="text-muted"></div>
<div className="num text-secondary">{fmtPct(stat.win_rate)}</div>
</div>
<div className="text-right">
<div className="text-muted"> / </div>
<div className="num">
<span className="text-bull">{fmtPct(stat.best)}</span>
<span className="text-muted"> / </span>
<span className="text-bear">{fmtPct(stat.worst)}</span>
</div>
</div>
</div>
)}
<button
onClick={onClose}
className="rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
aria-label="关闭标的回放"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<StockPanel
symbol={symbol}
height={520}
dateRange={dateRange}
markers={markers}
showLimitMarkers={false}
showMarkerToggle={false}
showIntraday={false}
visibleBars="all"
/>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}