diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 3bebb57..0f6175d 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -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(() => { diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index 142a1a5..eafaf7f 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -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 diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 160b1e1..0bbedb0 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -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(null) const [selectedDate, setSelectedDate] = useState(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} /> diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index ebe8764..1f524ad 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -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(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(null) const strategies = useQuery({ @@ -2355,7 +2363,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: { ) : (
{row.buys.map((t, i) => ( - setSelectedTrade(t)} signalNames={signalNames} /> + setChartOverlay({ kind: 'trade', trade: t })} signalNames={signalNames} /> ))}
)} @@ -2366,7 +2374,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: { ) : (
{row.sells.map((t, i) => ( - setSelectedTrade(t)} signalNames={signalNames} /> + setChartOverlay({ kind: 'trade', trade: t })} signalNames={signalNames} /> ))}
)} @@ -2429,9 +2437,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: { {visibleTrades.map((t: StrategyBacktestTrade, i: number) => ( - + setChartOverlay({ kind: 'trade', trade: t })} + title="点击查看该笔交易的K线回放" + className="border-t border-border hover:bg-elevated/50 transition-colors group cursor-pointer" + > -
+
{t.name || t.symbol}
{t.symbol}
@@ -2523,9 +2536,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: { {result.per_symbol_stats.map((r) => ( - + setChartOverlay({ kind: 'symbol', symbol: r.symbol })} + title="点击查看该标的在回测期的K线 (标注每次买卖)" + className="border-t border-border hover:bg-elevated/50 transition-colors group cursor-pointer" + > -
+
{symbolNames[r.symbol] || r.symbol}
{r.symbol}
@@ -2886,7 +2904,14 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: { )} - setSelectedTrade(null)} /> + setChartOverlay(null)} /> + setChartOverlay(null)} + />
) } diff --git a/frontend/src/pages/backtest/components/PicksSymbolKlineModal.tsx b/frontend/src/pages/backtest/components/PicksSymbolKlineModal.tsx new file mode 100644 index 0000000..7b9f585 --- /dev/null +++ b/frontend/src/pages/backtest/components/PicksSymbolKlineModal.tsx @@ -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(() => { + type Agg = { count: number; lo: number; hi: number } + // 同日同方向多笔合成单个箭头 (多箭头会重叠); 价格异则标签显示区间 + const byDate = new Map() + 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 ( + + {symbol && ( +
+ + +
+
+
+
+ {symbol} + {name && {name}} + {tag && ( + + {tag} + + )} + 标的回放 +
+
+ 回测 {periodStart} ~ {periodEnd} · {trades.length} 笔交易 +
+
+ ▲ 买入(红) · ▼ 卖出(绿) · 箭头旁为成交价 · 同日多笔合并显示 +
+
+ +
+ {stat && ( +
+
+
总收益
+
{fmtPct(stat.total_return)}
+
+
+
胜率
+
{fmtPct(stat.win_rate)}
+
+
+
最佳 / 最差
+
+ {fmtPct(stat.best)} + / + {fmtPct(stat.worst)} +
+
+
+ )} + +
+
+
+ +
+ +
+
+
+ )} +
+ ) +}