mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
Merge pull request #239 from thinkbuf/feat/backtest-result-kline-click
feat(backtest): clickable K-line popups for trade & picks rows
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user