mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(backtest): 交易记录显示具体触发信号 (不再只显示「信号」类别)
原问题: 多个信号在 _build_signal_mask 里 OR 成一个 mask 后, 只记录 exit_reason="信号", 具体哪个信号触发的信息丢失。回测里买入/卖出原因 只能看到笼统的「信号」, 不知道是 MA金叉还是 MACD死叉。 实现: - 后端 TradeRecord 加 entry_signal_id/exit_signal_id 字段 - 新增 _resolve_signal_id() 在触发行回查 panel 列, 找出为 True 的具体信号 - 三个 simulate 方法加 entry_signal_ids/exit_signal_ids 参数 - strategy.py 把信号列表传给 engine, _trade_to_dict 透传新字段 - 前端 useSignalNames() hook 合并内置 (BUILTIN_SIGNAL_DEFINITIONS) + 自定义 信号名称; TradeLegCell/ExitReasonBadge 显示具体信号名 - 信号名称前端解析 (复用 cnSignal), 后端只返回 signal_id, 不读磁盘 效果: 买入腿显示「MA5上穿MA20」, 卖出腿显示「MACD死叉」, 原因列从 通用「信号」变成具体信号名。止损/止盈/到期等非信号退出不受影响。 验证: 191 passed, 前端类型检查无错误
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> {
|
||||
const customQ = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
|
||||
return useMemo(() => {
|
||||
const names: Record<string, string> = {}
|
||||
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<string, string> }) {
|
||||
const config: Record<string, { label: string; cls: string }> = {
|
||||
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 (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${c.cls}`}>{c.label}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${c.cls} ${specific ? 'max-w-[7rem] truncate' : ''}`} title={specific ?? c.label}>
|
||||
{specific ?? c.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string> }) {
|
||||
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
|
||||
) : (
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="num text-secondary">{fmtPrice(price)}</span>
|
||||
<ExitReasonBadge reason={trade.exit_reason} />
|
||||
<ExitReasonBadge reason={trade.exit_reason} signalId={trade.exit_signal_id} signalNames={signalNames} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -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<string, string> }) {
|
||||
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 (
|
||||
<div className="min-w-[8.25rem] rounded-btn border border-border/60 bg-base/35 px-2 py-1 text-xs leading-4">
|
||||
@@ -375,7 +392,10 @@ function TradeLegCell({ trade, side }: { trade: StrategyBacktestTrade; side: 'bu
|
||||
<span className="num text-foreground">{fmtPrice(price)}</span>
|
||||
<span className="num font-medium text-foreground">{fmtMoney(amount)}</span>
|
||||
</div>
|
||||
{signalDate && signalDate !== date && (
|
||||
{signalLabel && (
|
||||
<div className="mt-0.5 text-[10px] text-accent/80 truncate" title={signalLabel}>{signalLabel}</div>
|
||||
)}
|
||||
{!signalLabel && signalDate && signalDate !== date && (
|
||||
<div className="mt-0.5 text-[10px] text-muted">信号 {signalDate}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<string | null>(saved?.selectedStrategy ?? null)
|
||||
const [strategyGroup, setStrategyGroup] = useState<StrategyGroup>('all')
|
||||
@@ -1751,7 +1772,7 @@ export function StrategyBacktest() {
|
||||
) : (
|
||||
<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)} />
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1762,7 +1783,7 @@ export function StrategyBacktest() {
|
||||
) : (
|
||||
<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)} />
|
||||
<DailyTradeChip key={`sell-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="sell" onClick={() => setSelectedTrade(t)} signalNames={signalNames} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1833,10 +1854,10 @@ export function StrategyBacktest() {
|
||||
<div className="mt-0.5 font-mono text-[11px] text-muted">{t.symbol}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<TradeLegCell trade={t} side="buy" />
|
||||
<TradeLegCell trade={t} side="buy" signalNames={signalNames} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<TradeLegCell trade={t} side="sell" />
|
||||
<TradeLegCell trade={t} side="sell" signalNames={signalNames} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="num text-foreground">{fmtPct(t.position_pct, 2)}</div>
|
||||
@@ -1853,7 +1874,7 @@ export function StrategyBacktest() {
|
||||
<div>{t.duration} 天</div>
|
||||
{!!t.blocked_exit_days && <div className="mt-0.5 text-[11px] text-amber-400">阻塞 {t.blocked_exit_days} 天</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5"><ExitReasonBadge reason={t.exit_reason} /></td>
|
||||
<td className="px-4 py-2.5"><ExitReasonBadge reason={t.exit_reason} signalId={t.exit_signal_id} signalNames={signalNames} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user