Files
AlphaMaster/backtest_viz/chart.py
T

679 lines
26 KiB
Python
Raw Normal View History

2026-07-03 01:11:00 +08:00
"""
backtest_viz/chart.py — K 线图 + 出入场标注 + PnL 子图
依赖 matplotlib(项目 requirements-optional.txt 里有)。
在 matplotlib 无法显示 GUI 时自动切换到 Agg 后端,直接保存为 PNG/HTML。
"""
from __future__ import annotations
import os
import warnings
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import numpy as np
# ── matplotlib 后端自动选择 ─────────────────────────────────────────────
try:
import matplotlib
_DISPLAY = os.environ.get("DISPLAY") or os.name == "nt" # Windows 有 GUI
if not _DISPLAY:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.gridspec import GridSpec
from matplotlib.ticker import MaxNLocator
_MPL_OK = True
except ImportError:
_MPL_OK = False
from .engine import SymbolResult, Trade
def _ts_to_label(ts: int, fmt: str = "%m-%d %H:%M") -> str:
"""Unix 秒 → 可读字符串(UTC)"""
try:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime(fmt)
except Exception:
return str(ts)
class BacktestChart:
"""可视化回测图表生成器。
为每个品种生成一张包含以下子图的综合图:
① K 线图(蜡烛图 + 出入场三角标记 + 仓位背景色)
② 因子 / 信号强度折线
③ 逐 bar PnL 直方图
④ 累计 PnL 曲线
另可为每笔交易生成独立缩放图(plot_trade_zoom),
只展示入场前后局部K线,入场/出场价格一目了然。
用法:
chart = BacktestChart()
chart.plot(result, save_path="out/XAUUSD.png")
chart.plot_all_trade_zooms(result, output_dir="out/")
"""
# 颜色方案
_LONG_ENTRY_COLOR = "#26a69a" # 绿色:开多
_SHORT_ENTRY_COLOR = "#ef5350" # 红色:开空
_EXIT_COLOR = "#ffa726" # 橙色:平仓
_LONG_BG = "#e8f5e9" # 浅绿背景:多头持仓期
_SHORT_BG = "#ffebee" # 浅红背景:空头持仓期
def __init__(
self,
figsize: tuple[int, int] = (22, 14),
max_bars: int = 120, # 全局图最多显示的 bar 数,120根更清晰
dpi: int = 120,
):
if not _MPL_OK:
raise ImportError(
"matplotlib 未安装。请运行: pip install matplotlib"
)
self.figsize = figsize
self.max_bars = max_bars
self.dpi = dpi
# ─────────────────────────────────────────────────────────────────────
# 公开接口
# ─────────────────────────────────────────────────────────────────────
def plot(
self,
result: SymbolResult,
save_path: Optional[str] = None,
show: bool = False,
title_suffix: str = "",
) -> Optional[str]:
"""为单个品种生成完整图表。
Args:
result: BacktestEngine.run() 返回的 SymbolResult。
save_path: 保存路径(.png / .svg);None 则不保存。
show: 是否调用 plt.show()(交互环境下使用)。
title_suffix: 附加到标题的额外说明。
Returns:
实际保存路径字符串,未保存时返回 None。
"""
T = len(result.times)
# 显示最后 max_bars 个 bar
start_idx = max(0, T - self.max_bars)
sl = slice(start_idx, T)
fig = plt.figure(figsize=self.figsize, dpi=self.dpi)
gs = GridSpec(
4, 1, figure=fig,
height_ratios=[4, 1.2, 1.2, 1.5],
hspace=0.08,
)
ax_candle = fig.add_subplot(gs[0])
ax_factor = fig.add_subplot(gs[1], sharex=ax_candle)
ax_pnl = fig.add_subplot(gs[2], sharex=ax_candle)
ax_cum = fig.add_subplot(gs[3], sharex=ax_candle)
x = np.arange(T)[sl]
# ── ① K 线图 ─────────────────────────────────────────────────
self._draw_candles(ax_candle, result, sl, x)
self._draw_trade_markers(ax_candle, result, start_idx, T)
self._draw_position_background(ax_candle, result, start_idx, T, x)
self._draw_trade_labels(ax_candle, result, start_idx, T)
ax_candle.set_ylabel("Price", fontsize=9)
ax_candle.legend(
handles=self._legend_handles(), loc="upper left", fontsize=8,
framealpha=0.7,
)
ax_candle.grid(alpha=0.3)
2026-07-11 14:04:56 +08:00
pl_txt = (
f"{result.profit_loss_ratio:.3f}"
if result.profit_loss_ratio is not None
else "—"
)
2026-07-03 01:11:00 +08:00
ax_candle.set_title(
f"{result.symbol} | "
f"Sortino={result.sortino:.2f} "
f"TotalRet={result.total_return:.4f} "
f"Trades={result.n_trades} "
f"WinRate={result.win_rate:.1%} "
2026-07-11 14:04:56 +08:00
f"PLRatio={pl_txt} "
2026-07-03 01:11:00 +08:00
f"AvgHold={result.avg_hold_bars:.1f}bars"
+ (f" | {title_suffix}" if title_suffix else ""),
fontsize=10, pad=6,
)
# ── ② 因子强度 ────────────────────────────────────────────────
ax_factor.plot(x, result.signal[sl], color="#7e57c2", linewidth=0.8,
label="signal (tanh)")
ax_factor.axhline(0, color="gray", linewidth=0.6, linestyle="--")
ax_factor.fill_between(x, result.signal[sl], 0,
where=result.signal[sl] > 0,
alpha=0.15, color=self._LONG_ENTRY_COLOR)
ax_factor.fill_between(x, result.signal[sl], 0,
where=result.signal[sl] < 0,
alpha=0.15, color=self._SHORT_ENTRY_COLOR)
ax_factor.set_ylabel("Signal", fontsize=8)
ax_factor.legend(fontsize=7, loc="upper left")
ax_factor.grid(alpha=0.25)
# ── ③ 逐 bar PnL 直方图 ──────────────────────────────────────
pnl_sl = result.pnl[sl]
colors_bar = [
self._LONG_ENTRY_COLOR if v >= 0 else self._SHORT_ENTRY_COLOR
for v in pnl_sl
]
ax_pnl.bar(x, pnl_sl, color=colors_bar, width=0.8, alpha=0.7)
ax_pnl.axhline(0, color="gray", linewidth=0.6)
ax_pnl.set_ylabel("Bar PnL", fontsize=8)
ax_pnl.grid(alpha=0.25)
# ── ④ 累计 PnL 曲线 ──────────────────────────────────────────
cum_sl = result.cum_pnl[sl]
ax_cum.plot(x, cum_sl, color="#1565c0", linewidth=1.2, label="Cum PnL")
ax_cum.fill_between(x, cum_sl, 0,
where=cum_sl >= 0, alpha=0.12,
color=self._LONG_ENTRY_COLOR)
ax_cum.fill_between(x, cum_sl, 0,
where=cum_sl < 0, alpha=0.12,
color=self._SHORT_ENTRY_COLOR)
ax_cum.axhline(0, color="gray", linewidth=0.6)
ax_cum.set_ylabel("Cum PnL", fontsize=8)
ax_cum.legend(fontsize=7, loc="upper left")
ax_cum.grid(alpha=0.25)
# ── X 轴刻度(时间标签)─────────────────────────────────────
self._set_time_ticks(ax_cum, result.times[sl], x, n_ticks=10)
plt.setp(ax_candle.get_xticklabels(), visible=False)
plt.setp(ax_factor.get_xticklabels(), visible=False)
plt.setp(ax_pnl.get_xticklabels(), visible=False)
ax_cum.tick_params(axis="x", labelsize=7, rotation=30)
# ── 保存 / 展示 ──────────────────────────────────────────────
saved_path: Optional[str] = None
if save_path:
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
fig.savefig(save_path, bbox_inches="tight")
saved_path = save_path
print(f" 图表已保存 → {save_path}")
if show:
plt.show()
plt.close(fig)
return saved_path
def plot_all(
self,
results: list[SymbolResult],
output_dir: str = "backtest_output",
show: bool = False,
title_suffix: str = "",
) -> list[str]:
"""为所有品种批量生成图表。
Returns:
已保存的文件路径列表。
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
saved = []
for r in results:
path = str(Path(output_dir) / f"{r.symbol}.png")
p = self.plot(r, save_path=path, show=show,
title_suffix=title_suffix)
if p:
saved.append(p)
return saved
def plot_trade_zoom(
self,
result: SymbolResult,
trade_idx: int,
pre_bars: int = 20, # 入场前显示的K线数
post_bars: int = 10, # 出场后显示的K线数
save_path: Optional[str] = None,
show: bool = False,
) -> Optional[str]:
"""为单笔交易生成局部缩放K线图。
只显示入场前 pre_bars 根 + 持仓期 + 出场后 post_bars 根,
清晰标注入场/出场价格水平线和具体价格数值。
Args:
result: SymbolResult 回测结果。
trade_idx: result.trades 中的交易索引(0-based)。
pre_bars: 入场前显示的K线数量,默认20根。
post_bars: 出场后显示的K线数量,默认10根。
save_path: 保存路径;None 则不保存。
show: 是否调用 plt.show()。
Returns:
实际保存路径,未保存时返回 None。
"""
if trade_idx >= len(result.trades):
raise IndexError(
f"trade_idx={trade_idx} 超出范围(共 {len(result.trades)} 笔交易)"
)
trade = result.trades[trade_idx]
T = len(result.times)
# 实际成交在信号 bar 的下一根(与 target_ret 时间对齐)
entry_exec_bar = min(trade.entry_bar + 1, T - 1)
exit_exec_bar = min(trade.exit_bar + 1, T - 1) if trade.exit_bar is not None else None
# 计算显示窗口(围绕实际成交 bar 展开)
win_start = max(0, entry_exec_bar - pre_bars)
ref_end = exit_exec_bar if exit_exec_bar is not None else entry_exec_bar
win_end = min(T, ref_end + post_bars + 1)
sl = slice(win_start, win_end)
x = np.arange(win_end - win_start)
# 入场/出场在 x 轴的位置(用实际成交 bar)
entry_xi = entry_exec_bar - win_start
exit_xi = (exit_exec_bar - win_start) if exit_exec_bar is not None else None
fig, (ax_candle, ax_cum) = plt.subplots(
2, 1, figsize=(14, 8), dpi=self.dpi,
gridspec_kw={"height_ratios": [3, 1], "hspace": 0.08},
)
# ── K 线图 ───────────────────────────────────────────────────
self._draw_candles(ax_candle, result, sl, x)
self._draw_position_background(ax_candle, result, win_start, win_end, x)
# 入场标记(三角)
lo = result.low[sl]
hi = result.high[sl]
cl = result.close[sl]
offset = (hi.max() - lo.min()) * 0.008
if trade.direction == 1:
ax_candle.plot(
entry_xi, lo[entry_xi] - offset,
marker="^", color=self._LONG_ENTRY_COLOR,
markersize=14, zorder=6, markeredgewidth=1,
markeredgecolor="white",
)
else:
ax_candle.plot(
entry_xi, hi[entry_xi] + offset,
marker="v", color=self._SHORT_ENTRY_COLOR,
markersize=14, zorder=6, markeredgewidth=1,
markeredgecolor="white",
)
# 出场标记(菱形)
if exit_xi is not None:
ax_candle.plot(
exit_xi, cl[exit_xi],
marker="D", color=self._EXIT_COLOR,
markersize=10, zorder=6, markeredgewidth=1,
markeredgecolor="white",
)
# 入场价格水平虚线
entry_price = trade.entry_price
ax_candle.axhline(
entry_price, color=self._LONG_ENTRY_COLOR if trade.direction == 1
else self._SHORT_ENTRY_COLOR,
linewidth=1.2, linestyle="--", alpha=0.8, zorder=4,
)
ax_candle.annotate(
f"Entry {entry_price:.5f}",
xy=(x[-1], entry_price),
xytext=(-4, 4), textcoords="offset points",
fontsize=8, color=self._LONG_ENTRY_COLOR if trade.direction == 1
else self._SHORT_ENTRY_COLOR,
ha="right", fontweight="bold",
)
# 出场价格水平虚线
if trade.exit_price is not None and exit_xi is not None:
ax_candle.axhline(
trade.exit_price, color=self._EXIT_COLOR,
linewidth=1.2, linestyle="--", alpha=0.8, zorder=4,
)
ax_candle.annotate(
f"Exit {trade.exit_price:.5f}",
xy=(x[-1], trade.exit_price),
xytext=(-4, -8), textcoords="offset points",
fontsize=8, color=self._EXIT_COLOR,
ha="right", fontweight="bold",
)
# 入场→出场垂直价差连线
ax_candle.annotate(
"",
xy=(exit_xi, trade.exit_price),
xytext=(entry_xi, entry_price),
arrowprops=dict(
arrowstyle="->",
color="#9e9e9e",
lw=1.2,
connectionstyle="arc3,rad=0.15",
),
zorder=3,
)
# PnL 标注框
pnl_color = "#1b5e20" if trade.pnl > 0 else "#b71c1c"
direction_str = "Long ▲" if trade.direction == 1 else "Short ▼"
hold_bars = (trade.exit_bar - trade.entry_bar) if trade.exit_bar is not None else 0
entry_time_str = _ts_to_label(trade.entry_time, "%Y-%m-%d %H:%M")
exit_time_str = (
_ts_to_label(trade.exit_time, "%Y-%m-%d %H:%M")
if trade.exit_time else "open"
)
exit_price_str = f"{trade.exit_price:.5f}" if trade.exit_price else "-"
info_text = (
f"{direction_str} PnL: {trade.pnl:+.5f}\n"
f"Entry: {entry_time_str} @ {entry_price:.5f}\n"
f"Exit : {exit_time_str} @ {exit_price_str}\n"
f"Hold : {hold_bars} bars"
)
ax_candle.text(
0.01, 0.97, info_text,
transform=ax_candle.transAxes,
fontsize=8.5, verticalalignment="top",
bbox=dict(
boxstyle="round,pad=0.4",
facecolor="white", alpha=0.85,
edgecolor=pnl_color, linewidth=1.5,
),
color=pnl_color,
)
# 标题
trade_no = trade_idx + 1
ax_candle.set_title(
f"{result.symbol} Trade {trade_no}/{result.n_trades} "
f"{'Long' if trade.direction == 1 else 'Short'} "
f"PnL={trade.pnl:+.5f} WinRate={result.win_rate:.1%}",
fontsize=10, pad=6,
)
ax_candle.set_ylabel("Price", fontsize=9)
ax_candle.grid(alpha=0.3)
ax_candle.legend(
handles=self._legend_handles(), loc="upper right",
fontsize=7, framealpha=0.7,
)
# ── 累计 PnL 曲线(全局,标注当前交易位置)─────────────────
cum_all = result.cum_pnl
x_all = np.arange(T)
ax_cum.plot(x_all, cum_all, color="#1565c0", linewidth=1.0, label="Cum PnL")
ax_cum.fill_between(
x_all, cum_all, 0,
where=cum_all >= 0, alpha=0.10, color=self._LONG_ENTRY_COLOR,
)
ax_cum.fill_between(
x_all, cum_all, 0,
where=cum_all < 0, alpha=0.10, color=self._SHORT_ENTRY_COLOR,
)
# 标注当前交易在全局 PnL 上的位置
if trade.exit_bar is not None:
ax_cum.axvspan(
trade.entry_bar, trade.exit_bar,
alpha=0.25,
color=self._LONG_BG if trade.direction == 1 else self._SHORT_BG,
)
ax_cum.axhline(0, color="gray", linewidth=0.6)
ax_cum.set_ylabel("Cum PnL", fontsize=8)
ax_cum.legend(fontsize=7, loc="upper left")
ax_cum.grid(alpha=0.25)
self._set_time_ticks(ax_cum, result.times, x_all, n_ticks=8)
ax_cum.tick_params(axis="x", labelsize=7, rotation=30)
# X 轴时间刻度(局部 K 线图)
self._set_time_ticks(ax_candle, result.times[sl], x, n_ticks=6)
ax_candle.tick_params(axis="x", labelsize=7, rotation=20)
# 保存 / 展示
saved_path: Optional[str] = None
if save_path:
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
fig.savefig(save_path, bbox_inches="tight")
saved_path = save_path
print(f" 缩放图已保存 → {save_path}")
if show:
plt.show()
plt.close(fig)
return saved_path
def plot_all_trade_zooms(
self,
result: SymbolResult,
output_dir: str = "backtest_output",
pre_bars: int = 20,
post_bars: int = 10,
max_trades: int = 30, # 最多生成多少张缩放图(避免文件爆炸)
show: bool = False,
) -> list[str]:
"""为一个品种的所有交易批量生成缩放图。
Args:
result: SymbolResult 回测结果。
output_dir: 输出目录。
pre_bars: 入场前显示K线数量。
post_bars: 出场后显示K线数量。
max_trades: 最多生成张数(按 |PnL| 降序选 top-N)。
show: 是否调用 plt.show()。
Returns:
已保存的文件路径列表。
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
saved = []
trades = result.trades
if not trades:
print(f" {result.symbol} 无交易记录,跳过缩放图生成。")
return saved
# 选取最重要的 max_trades 笔(按 |PnL| 降序)
indexed = sorted(
enumerate(trades),
key=lambda kv: abs(kv[1].pnl),
reverse=True,
)[:max_trades]
# 按时间顺序重新排序,编号更直观
indexed.sort(key=lambda kv: kv[1].entry_bar)
for rank, (orig_idx, _) in enumerate(indexed, start=1):
fname = f"{result.symbol}_trade{rank}_zoom.png"
path = str(Path(output_dir) / fname)
p = self.plot_trade_zoom(
result, orig_idx,
pre_bars=pre_bars, post_bars=post_bars,
save_path=path, show=show,
)
if p:
saved.append(p)
return saved
# ─────────────────────────────────────────────────────────────────────
# 内部绘图方法
# ─────────────────────────────────────────────────────────────────────
def _draw_candles(
self,
ax: "plt.Axes",
result: SymbolResult,
sl: slice,
x: np.ndarray,
) -> None:
"""绘制蜡烛图(用矩形 + 线段模拟,不依赖 mplfinance)"""
op = result.open[sl]
hi = result.high[sl]
lo = result.low[sl]
cl = result.close[sl]
bar_w = 0.6
for i, xi in enumerate(x):
is_bull = cl[i] >= op[i]
color = self._LONG_ENTRY_COLOR if is_bull else self._SHORT_ENTRY_COLOR
# 上下影线
ax.plot([xi, xi], [lo[i], hi[i]], color=color, linewidth=0.7)
# 实体
body_lo = min(op[i], cl[i])
body_hi = max(op[i], cl[i])
body_h = max(body_hi - body_lo, (hi[i] - lo[i]) * 0.01)
rect = mpatches.Rectangle(
(xi - bar_w / 2, body_lo), bar_w, body_h,
linewidth=0, facecolor=color, alpha=0.85,
)
ax.add_patch(rect)
ax.set_xlim(x[0] - 1, x[-1] + 1)
ax.set_ylim(result.low[sl].min() * 0.9995,
result.high[sl].max() * 1.0005)
def _draw_position_background(
self,
ax: "plt.Axes",
result: SymbolResult,
start_idx: int,
T: int,
x: np.ndarray,
) -> None:
"""在持仓期间填充背景色(多头绿/空头红)。
背景从实际成交 bar 开始(信号 bar + 1)。
"""
for trade in result.trades:
entry_exec = min(trade.entry_bar + 1, T - 1)
exit_exec = min(trade.exit_bar + 1, T - 1) if trade.exit_bar is not None else T - 1
# 转换为 x 轴坐标(相对于 start_idx)
x_entry = max(entry_exec - start_idx, 0)
x_exit = min(exit_exec - start_idx, len(x) - 1)
if x_entry >= len(x) or x_exit < 0:
continue
color = self._LONG_BG if trade.direction == 1 else self._SHORT_BG
ax.axvspan(x[x_entry], x[x_exit], alpha=0.25, color=color, linewidth=0)
def _draw_trade_markers(
self,
ax: "plt.Axes",
result: SymbolResult,
start_idx: int,
T: int,
) -> None:
"""绘制开平仓三角标记。
标记打在实际成交 bar(信号 bar + 1),与 entry_price/exit_price 对齐。
多头开仓:绿色向上三角(▲),标注在 low 下方
空头开仓:红色向下三角(▼),标注在 high 上方
平仓/反手:橙色菱形(◆),标注在 close 附近
"""
lo = result.low
hi = result.high
cl = result.close
total = T
for trade in result.trades:
# 实际成交 bar = 信号 bar + 1
eb = min(trade.entry_bar + 1, total - 1)
xb = min(trade.exit_bar + 1, total - 1) if trade.exit_bar is not None else None
if eb >= start_idx:
xi = eb - start_idx
offset = (hi[eb] - lo[eb]) * 0.3 + (hi[eb] - lo[eb]) * 0.05
if trade.direction == 1:
ax.plot(xi, lo[eb] - offset,
marker="^", color=self._LONG_ENTRY_COLOR,
markersize=8, zorder=5, markeredgewidth=0.5,
markeredgecolor="white")
else:
ax.plot(xi, hi[eb] + offset,
marker="v", color=self._SHORT_ENTRY_COLOR,
markersize=8, zorder=5, markeredgewidth=0.5,
markeredgecolor="white")
if xb is not None and xb >= start_idx:
xi = xb - start_idx
ax.plot(xi, cl[xb],
marker="D", color=self._EXIT_COLOR,
markersize=6, zorder=5, markeredgewidth=0.5,
markeredgecolor="white")
def _draw_trade_labels(
self,
ax: "plt.Axes",
result: SymbolResult,
start_idx: int,
T: int,
) -> None:
"""在每笔交易标注 PnL 数值(仅盈亏超过阈值时显示,避免文字过密)。"""
hi = result.high
lo = result.low
price_range = hi.max() - lo.min()
threshold = price_range * 0.001
visible = [
t for t in result.trades
if abs(t.pnl) > threshold
and t.exit_bar is not None
and min(t.exit_bar + 1, T - 1) >= start_idx
]
if len(visible) > 20:
visible = sorted(visible, key=lambda t: abs(t.pnl), reverse=True)[:20]
for trade in visible:
if trade.exit_bar is None:
continue
# 标注在实际成交(出场)的 bar 上
xb = min(trade.exit_bar + 1, T - 1)
xi = xb - start_idx
price = result.close[xb]
label = f"{trade.pnl:+.4f}"
color = "#1b5e20" if trade.pnl > 0 else "#b71c1c"
ax.annotate(
label,
xy=(xi, price),
xytext=(0, 12 if trade.pnl > 0 else -16),
textcoords="offset points",
fontsize=6, color=color,
ha="center",
bbox=dict(
boxstyle="round,pad=0.1", fc="white",
alpha=0.6, edgecolor="none",
),
)
@staticmethod
def _set_time_ticks(
ax: "plt.Axes",
times: np.ndarray,
x: np.ndarray,
n_ticks: int = 10,
) -> None:
"""设置 X 轴时间刻度标签。"""
step = max(1, len(x) // n_ticks)
ticks = x[::step]
labels = [_ts_to_label(int(times[i]), fmt="%y-%m-%d\n%H:%M")
for i in range(0, len(times), step)]
ax.set_xticks(ticks)
ax.set_xticklabels(labels[:len(ticks)])
def _legend_handles(self) -> list:
"""构建图例 handles"""
return [
mpatches.Patch(color=self._LONG_ENTRY_COLOR, label="▲ Long entry"),
mpatches.Patch(color=self._SHORT_ENTRY_COLOR, label="▼ Short entry"),
mpatches.Patch(color=self._EXIT_COLOR, label="◆ Exit"),
mpatches.Patch(color=self._LONG_BG, alpha=0.4, label="Long position"),
mpatches.Patch(color=self._SHORT_BG, alpha=0.4, label="Short position"),
]