""" Visualization utilities for backtest results. plot_backtest(result, *, title="Backtest", show=True, return_fig=False) Generate an interactive Plotly chart with: - Top panel: equity curve (normalized to 1.0) - Middle panel: drawdown series (negative values, shaded red) - Bottom panel: position/signal over time Optional trade markers: entry (green triangle up) and exit (red triangle down) on equity curve. Requires plotly -- raises ImportError with install hint if not available. """ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: pass __all__ = ["plot_backtest"] def plot_backtest( result, # AdvancedBacktestResult *, title: str = "Backtest", show: bool = True, return_fig: bool = False, benchmark: bool = True, ): """Plot equity curve, drawdown, and positions. Parameters ---------- result : AdvancedBacktestResult Backtest result object with equity, drawdown_series, positions, and trades. title : str Chart title. show : bool Call fig.show() if True. return_fig : bool Return the plotly Figure object. benchmark : bool Overlay benchmark equity curve if result has benchmark returns. Returns ------- plotly.graph_objects.Figure if return_fig=True, else None. Raises ------ ImportError If plotly is not installed. """ try: import plotly.graph_objects as go from plotly.subplots import make_subplots except ImportError: raise ImportError( "plotly is required for visualization. Install with: pip install plotly" ) import numpy as np # ------------------------------------------------------------------ # Extract result fields # ------------------------------------------------------------------ equity = np.asarray(result.equity, dtype=np.float64) n = len(equity) bars = np.arange(n) # Drawdown: prefer pre-computed drawdown_series, else compute from equity if hasattr(result, "drawdown_series") and result.drawdown_series is not None: drawdown = np.asarray(result.drawdown_series, dtype=np.float64) else: cum_max = np.maximum.accumulate(equity) drawdown = np.where(cum_max > 0, equity / cum_max - 1.0, 0.0) positions = ( np.asarray(result.positions, dtype=np.float64) if hasattr(result, "positions") else np.zeros(n) ) # Trades (may be empty or None) trades = getattr(result, "trades", None) # Benchmark equity (optional) benchmark_equity = None if ( benchmark and hasattr(result, "benchmark_equity") and result.benchmark_equity is not None ): benchmark_equity = np.asarray(result.benchmark_equity, dtype=np.float64) # ------------------------------------------------------------------ # Build 3-panel subplot # ------------------------------------------------------------------ fig = make_subplots( rows=3, cols=1, shared_xaxes=True, row_heights=[0.5, 0.25, 0.25], vertical_spacing=0.04, subplot_titles=("Equity Curve", "Drawdown", "Positions"), ) # ---- Panel 1: Equity curve ---------------------------------------- fig.add_trace( go.Scatter( x=bars, y=equity, name="Strategy", line=dict(color="#00d4ff", width=1.5), hovertemplate="Bar %{x}
Equity: %{y:.4f}", ), row=1, col=1, ) # Benchmark overlay if benchmark_equity is not None: fig.add_trace( go.Scatter( x=bars[: len(benchmark_equity)], y=benchmark_equity, name="Benchmark", line=dict(color="#f0a500", width=1.2, dash="dot"), hovertemplate="Bar %{x}
Benchmark: %{y:.4f}", ), row=1, col=1, ) # Trade markers if trades is not None and hasattr(trades, "__len__") and len(trades) > 0: # trades may be a pd.DataFrame or a list of dicts try: # pandas DataFrame path entry_bars = trades["entry_bar"].values exit_bars = trades["exit_bar"].values except (TypeError, KeyError, AttributeError): # list-of-dicts path try: entry_bars = np.array([t["entry_bar"] for t in trades]) exit_bars = np.array([t["exit_bar"] for t in trades]) except (KeyError, TypeError): entry_bars = np.array([]) exit_bars = np.array([]) if len(entry_bars) > 0: # Clip indices to equity length entry_bars = np.clip(entry_bars.astype(int), 0, n - 1) exit_bars = np.clip(exit_bars.astype(int), 0, n - 1) fig.add_trace( go.Scatter( x=entry_bars, y=equity[entry_bars], mode="markers", name="Entry", marker=dict( symbol="triangle-up", size=10, color="lime", line=dict(color="darkgreen", width=1), ), hovertemplate="Entry Bar %{x}
Equity: %{y:.4f}", ), row=1, col=1, ) fig.add_trace( go.Scatter( x=exit_bars, y=equity[exit_bars], mode="markers", name="Exit", marker=dict( symbol="triangle-down", size=10, color="red", line=dict(color="darkred", width=1), ), hovertemplate="Exit Bar %{x}
Equity: %{y:.4f}", ), row=1, col=1, ) # ---- Panel 2: Drawdown ------------------------------------------- fig.add_trace( go.Scatter( x=bars, y=drawdown, name="Drawdown", fill="tozeroy", fillcolor="rgba(220, 50, 50, 0.25)", line=dict(color="rgba(220, 50, 50, 0.8)", width=1.0), hovertemplate="Bar %{x}
Drawdown: %{y:.2%}", ), row=2, col=1, ) # ---- Panel 3: Positions ------------------------------------------ fig.add_trace( go.Scatter( x=bars, y=positions, name="Position", fill="tozeroy", fillcolor="rgba(0, 150, 255, 0.2)", line=dict(color="rgba(0, 150, 255, 0.7)", width=1.0), hovertemplate="Bar %{x}
Position: %{y:.2f}", ), row=3, col=1, ) # ------------------------------------------------------------------ # Styling: dark theme + ferro-ta branding # ------------------------------------------------------------------ metrics = getattr(result, "metrics", {}) sharpe_str = f"Sharpe: {metrics.get('sharpe', float('nan')):.2f}" if metrics else "" dd_str = ( f"Max DD: {metrics.get('max_drawdown', float('nan')):.1%}" if metrics else "" ) subtitle = " | ".join(filter(None, [sharpe_str, dd_str])) fig.update_layout( title=dict( text=f"{title}" + (f"
{subtitle}" if subtitle else ""), font=dict(size=18, color="#e0e0e0"), ), template="plotly_dark", paper_bgcolor="#0e1117", plot_bgcolor="#0e1117", font=dict(color="#b0b8c1", size=11), legend=dict( orientation="h", yanchor="bottom", y=1.01, xanchor="right", x=1, bgcolor="rgba(0,0,0,0)", ), hovermode="x unified", height=700, margin=dict(l=60, r=40, t=80, b=40), ) # Axis styling axis_style = dict( gridcolor="rgba(255,255,255,0.07)", zerolinecolor="rgba(255,255,255,0.15)", tickfont=dict(size=10), ) fig.update_xaxes(**axis_style) fig.update_yaxes(**axis_style) # Y-axis labels fig.update_yaxes(title_text="Equity (norm.)", row=1, col=1) fig.update_yaxes(title_text="Drawdown", tickformat=".1%", row=2, col=1) fig.update_yaxes(title_text="Position", row=3, col=1) fig.update_xaxes(title_text="Bar", row=3, col=1) # ------------------------------------------------------------------ if show: fig.show() if return_fig: return fig return None