diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 06d8244..a9ad18a 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -101,6 +101,10 @@ class TradeRecord: entry_signal_date: date | str | None = None exit_signal_date: date | str | None = None blocked_exit_days: int = 0 + # 触发买入/卖出的具体信号列名 (如 signal_ma_golden_5_20 / csg_xxx); + # 仅当该腿由信号触发时填充, 止损/止盈/到期等非信号退出时 exit_signal_id 为 None。 + entry_signal_id: str | None = None + exit_signal_id: str | None = None @dataclass @@ -112,6 +116,26 @@ class SimResult: stats: dict +def _resolve_signal_id(panel: pl.DataFrame, idx: int, signal_ids: list[str] | None) -> str | None: + """在触发行 idx 上, 从候选信号里找出 panel 列为 True 的那个, 返回其列名。 + + 多个信号同时为 True 时返回第一个匹配的 (信号 OR 关系, 回测只记录其一即可)。 + signal_ids 元素可能带 signal_/csg_ 前缀, 也可能是裸名 (如 "ma_golden_5_20")。 + """ + if not signal_ids: + return None + for sid in signal_ids: + col = sid if (sid.startswith("signal_") or sid.startswith("csg_")) else f"signal_{sid}" + if col not in panel.columns: + continue + try: + if bool(panel[col][idx]): + return col + except (IndexError, TypeError): + continue + return None + + # ================================================================ # PanelCache — 避免重复 scan_parquet + compute_all # ================================================================ @@ -322,6 +346,8 @@ class BacktestEngine: entries: pl.Series | None, exits: pl.Series | None, config: MatcherConfig, + entry_signal_ids: list[str] | None = None, + exit_signal_ids: list[str] | None = None, ) -> SimResult: """纯 NumPy 撮合模拟 — 逐 symbol 状态机。""" if panel.is_empty(): @@ -459,6 +485,8 @@ class BacktestEngine: config: MatcherConfig, progress_cb: "Callable[[dict], None] | None" = None, cancel_event: "threading.Event | None" = None, + entry_signal_ids: list[str] | None = None, + exit_signal_ids: list[str] | None = None, ) -> SimResult: """全量候选独立执行:每个买入信号都是独立样本, 不受资金/仓位限制。""" if panel.is_empty(): @@ -736,6 +764,8 @@ class BacktestEngine: entry_signal_date=pos.get("entry_signal_date"), exit_signal_date=signal_date, blocked_exit_days=int(pos.get("blocked_exit_days", 0)), + entry_signal_id=pos.get("entry_signal_id"), + exit_signal_id=_resolve_signal_id(panel, idx, exit_signal_ids) if reason == "signal" else None, )) return True @@ -781,6 +811,7 @@ class BacktestEngine: "entry_idx": entry_idx, "entry_date": self._date_str(panel_dates[entry_idx]), "entry_signal_date": entry_signal_dates[entry_idx] or self._date_str(panel_dates[entry_idx]), + "entry_signal_id": _resolve_signal_id(panel, entry_idx, entry_signal_ids), "entry_price": entry_price, "entry_score": score, "hold_days": 0, @@ -955,6 +986,8 @@ class BacktestEngine: config: MatcherConfig, progress_cb: "Callable[[dict], None] | None" = None, cancel_event: "threading.Event | None" = None, + entry_signal_ids: list[str] | None = None, + exit_signal_ids: list[str] | None = None, ) -> SimResult: """账户级组合回测:日线信号 → 成交约束 → 仓位/现金撮合。""" if panel.is_empty(): @@ -1223,6 +1256,8 @@ class BacktestEngine: entry_signal_date=pos.get("entry_signal_date"), exit_signal_date=signal_date, blocked_exit_days=int(pos.get("blocked_exit_days", 0)), + entry_signal_id=pos.get("entry_signal_id"), + exit_signal_id=_resolve_signal_id(panel, idx, exit_signal_ids) if reason == "signal" else None, )) def _try_sell( @@ -1416,6 +1451,7 @@ class BacktestEngine: "name": str(names[idx] or ""), "entry_date": self._date_str(panel_dates[idx]), "entry_signal_date": entry_signal_dates[idx] or self._date_str(panel_dates[idx]), + "entry_signal_id": _resolve_signal_id(panel, idx, entry_signal_ids), "entry_price": entry_price, "entry_value": entry_value, "shares": shares, diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 8ad77a9..1ec1ddc 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -229,9 +229,15 @@ class StrategyBacktestService: matcher_config, progress_cb, cancel_event, + entry_signal_ids=entry_signals, + exit_signal_ids=exit_signals, ) else: - result = self.engine.simulate_portfolio(sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, progress_cb, cancel_event) + result = self.engine.simulate_portfolio( + sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, + progress_cb, cancel_event, + entry_signal_ids=entry_signals, exit_signal_ids=exit_signals, + ) timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1) # 检查是否被取消 @@ -640,6 +646,8 @@ class StrategyBacktestService: "entry_signal_date": str(t.entry_signal_date) if getattr(t, "entry_signal_date", None) is not None else None, "exit_signal_date": str(t.exit_signal_date) if getattr(t, "exit_signal_date", None) is not None else None, "blocked_exit_days": getattr(t, "blocked_exit_days", 0), + "entry_signal_id": getattr(t, "entry_signal_id", None), + "exit_signal_id": getattr(t, "exit_signal_id", None), } @staticmethod diff --git a/backend/tests/backtest/test_strategy_backtest_correctness.py b/backend/tests/backtest/test_strategy_backtest_correctness.py index 7907d22..a788fc1 100644 --- a/backend/tests/backtest/test_strategy_backtest_correctness.py +++ b/backend/tests/backtest/test_strategy_backtest_correctness.py @@ -58,7 +58,7 @@ class _EngineStub: self.load_asset_type = asset_type return self.panel - def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult: + def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None, entry_signal_ids=None, exit_signal_ids=None) -> SimResult: self.sim_panel = panel self.sim_entries = entries return SimResult( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6b550b0..e266f94 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -623,6 +623,8 @@ export interface StrategyBacktestTrade { entry_signal_date?: string | null exit_signal_date?: string | null blocked_exit_days?: number + entry_signal_id?: string | null + exit_signal_id?: string | null } export interface StrategyBacktestResult { diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index f3dffe9..20bb944 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -14,6 +14,7 @@ import { storage } from '@/lib/storage' import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format' import { boardTag } from '@/lib/board' import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns' +import { cnSignal } from '@/lib/signals' import { SignalPicker } from '@/components/screener/SignalPicker' import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask' import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries' @@ -245,7 +246,17 @@ const statValueColor = (v: number | null | undefined) => { return v > 0 ? '#f87171' : '#34d399' } -function ExitReasonBadge({ reason }: { reason: string }) { +/** 信号 ID → 可读名称映射 (内置 + 自定义), 供交易记录显示具体触发信号。 */ +function useSignalNames(): Record { + const customQ = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList }) + return useMemo(() => { + const names: Record = {} + for (const cs of customQ.data?.signals ?? []) names[`csg_${cs.id}`] = cs.name + return names + }, [customQ.data]) +} + +function ExitReasonBadge({ reason, signalId, signalNames }: { reason: string; signalId?: string | null; signalNames?: Record }) { const config: Record = { signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' }, stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' }, @@ -257,8 +268,12 @@ function ExitReasonBadge({ reason }: { reason: string }) { end: { label: '期末', cls: 'bg-secondary/10 text-secondary border-border' }, } const c = config[reason] ?? { label: reason, cls: 'bg-elevated text-muted border-border' } + // 信号类退出且能解析出具体信号名时, 显示具体信号而非笼统的"信号" + const specific = reason === 'signal' && signalId ? cnSignal(signalId, signalNames) : null return ( - {c.label} + + {specific ?? c.label} + ) } @@ -282,7 +297,7 @@ function fmtScore(v: number | null | undefined): string { return Number(v).toFixed(1) } -function DailyTradeChip({ trade, side, strategyName, onClick }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void }) { +function DailyTradeChip({ trade, side, strategyName, onClick, signalNames }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void; signalNames?: Record }) { const isBuy = side === 'buy' const tag = boardTag(trade.symbol) const price = isBuy ? trade.entry_price : trade.exit_price @@ -317,7 +332,7 @@ function DailyTradeChip({ trade, side, strategyName, onClick }: { trade: Strateg ) : ( {fmtPrice(price)} - + )} @@ -354,12 +369,14 @@ function DailyTradeChip({ trade, side, strategyName, onClick }: { trade: Strateg ) } -function TradeLegCell({ trade, side }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell' }) { +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) 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 + const signalId = isBuy ? trade.entry_signal_id : trade.exit_signal_id + const signalLabel = signalId ? cnSignal(signalId, signalNames) : null return (
@@ -375,7 +392,10 @@ function TradeLegCell({ trade, side }: { trade: StrategyBacktestTrade; side: 'bu {fmtPrice(price)} {fmtMoney(amount)}
- {signalDate && signalDate !== date && ( + {signalLabel && ( +
{signalLabel}
+ )} + {!signalLabel && signalDate && signalDate !== date && (
信号 {signalDate}
)} @@ -706,6 +726,7 @@ function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: stri } export function StrategyBacktest() { + const signalNames = useSignalNames() const [saved] = useState(() => storage.strategyBacktestLast.get(null)) const [selectedStrategy, setSelectedStrategy] = useState(saved?.selectedStrategy ?? null) const [strategyGroup, setStrategyGroup] = useState('all') @@ -1751,7 +1772,7 @@ export function StrategyBacktest() { ) : (
{row.buys.map((t, i) => ( - setSelectedTrade(t)} /> + setSelectedTrade(t)} signalNames={signalNames} /> ))}
)} @@ -1762,7 +1783,7 @@ export function StrategyBacktest() { ) : (
{row.sells.map((t, i) => ( - setSelectedTrade(t)} /> + setSelectedTrade(t)} signalNames={signalNames} /> ))}
)} @@ -1833,10 +1854,10 @@ export function StrategyBacktest() {
{t.symbol}
- + - +
{fmtPct(t.position_pct, 2)}
@@ -1853,7 +1874,7 @@ export function StrategyBacktest() {
{t.duration} 天
{!!t.blocked_exit_days &&
阻塞 {t.blocked_exit_days} 天
} - + ))}