Skip to content

turtle_quant_1.backtesting.viz

turtle_quant_1.backtesting.viz

Visualisation helpers for inspecting backtesting results in Jupyter notebooks.

plot_signal_distribution

plot_signal_distribution(
    results: TestCaseResults, symbol: str, height: int = 400
) -> Figure

Plot a timeline scatter of BUY / HOLD / SELL signals against the price index.

The x-axis uses the integer row index of the price DataFrame for the given symbol, eliminating time gaps caused by weekends and public holidays. Each signal is drawn as a coloured marker at its mapped row index; the y-axis shows the signal score. Aggregate counts per action are shown in the legend labels.

Parameters:

Name Type Description Default
results TestCaseResults

The TestCaseResults returned by BacktestingEngine.run_backtest().

required
symbol str

Ticker symbol used to resolve the integer price index. Must be present in results.price_history.

required
height int

Figure height in pixels.

400

Returns:

Type Description
Figure

A plotly Figure object. Call .show() in a notebook to render it.

Source code in src/turtle_quant_1/backtesting/viz.py
def plot_signal_distribution(
    results: TestCaseResults,
    symbol: str,
    height: int = 400,
) -> go.Figure:
    """Plot a timeline scatter of BUY / HOLD / SELL signals against the price index.

    The x-axis uses the integer row index of the price DataFrame for the given
    symbol, eliminating time gaps caused by weekends and public holidays. Each
    signal is drawn as a coloured marker at its mapped row index; the y-axis
    shows the signal score. Aggregate counts per action are shown in the legend
    labels.

    Args:
        results: The TestCaseResults returned by BacktestingEngine.run_backtest().
        symbol: Ticker symbol used to resolve the integer price index. Must be
            present in results.price_history.
        height: Figure height in pixels.

    Returns:
        A plotly Figure object. Call .show() in a notebook to render it.
    """
    if symbol not in results.price_history:
        raise ValueError(
            f"Symbol '{symbol}' not found in results.price_history. "
            f"Available symbols: {list(results.price_history.keys())}"
        )

    price_df = results.price_history[symbol].copy()

    sig_df = results.signal_history.copy()
    sig_df = sig_df[sig_df["symbol"] == symbol].copy()

    tick_positions, tick_labels = _build_tick_params(price_df)

    fig = go.Figure()

    for action in ["BUY", "HOLD", "SELL"]:
        subset = sig_df[sig_df["action"] == action]
        if subset.empty:
            fig.add_trace(
                go.Scatter(
                    x=[],
                    y=[],
                    mode="markers",
                    name=f"{action} (0)",
                    marker=dict(color=_SIGNAL_COLORS[action], size=6, opacity=0.7),
                )
            )
            continue

        sig_idx = _map_timestamps_to_index(subset["datetime"], price_df)
        sig_dates = pd.to_datetime(subset["datetime"], utc=True).dt.strftime(
            "%Y-%m-%d %H:%M"
        )

        fig.add_trace(
            go.Scatter(
                x=sig_idx,
                y=subset["score"].values,
                mode="markers",
                name=f"{action} ({len(subset)})",
                marker=dict(color=_SIGNAL_COLORS[action], size=6, opacity=0.7),
                customdata=sig_dates.values,
                hovertemplate="%{customdata}<br>"
                + f"{action}"
                + "<br>Score: %{y:.3f}<extra></extra>",
            )
        )

    fig.update_layout(
        title=f"Signal distribution ({symbol})",
        xaxis=dict(
            title="Row index",
            tickvals=tick_positions,
            ticktext=tick_labels,
        ),
        yaxis_title="Score",
        height=height,
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )

    return fig

plot_signals

plot_signals(
    results: TestCaseResults,
    symbol: str,
    show_portfolio_value: bool = True,
    title: Optional[str] = None,
    height: int = 600,
) -> Figure

Plot the close price for a symbol with buy/sell markers overlaid.

The x-axis uses the integer row index of the price DataFrame rather than wall-clock time, which eliminates visual gaps caused by weekends, public holidays, and other periods where no market data exists.

Optionally shows a second panel with portfolio value over time. The returned Plotly figure is interactive: zoom, pan, and hover are all enabled by default in Jupyter notebooks.

Parameters:

Name Type Description Default
results TestCaseResults

The TestCaseResults returned by BacktestingEngine.run_backtest().

required
symbol str

The ticker symbol to plot (must be present in results.price_history).

required
show_portfolio_value bool

Whether to show a second panel with portfolio value.

True
title Optional[str]

Optional figure title. Defaults to "{symbol} – Buy / Sell signals".

None
height int

Total figure height in pixels.

600

Returns:

Type Description
Figure

A plotly Figure object. Call .show() in a notebook to render it.

Example (Jupyter notebook)::

from turtle_quant_1.backtesting.viz import plot_signals

results = engine.run_backtest()
plot_signals(results, "AAPL").show()
Source code in src/turtle_quant_1/backtesting/viz.py
def plot_signals(
    results: TestCaseResults,
    symbol: str,
    show_portfolio_value: bool = True,
    title: Optional[str] = None,
    height: int = 600,
) -> go.Figure:
    """Plot the close price for a symbol with buy/sell markers overlaid.

    The x-axis uses the integer row index of the price DataFrame rather than
    wall-clock time, which eliminates visual gaps caused by weekends, public
    holidays, and other periods where no market data exists.

    Optionally shows a second panel with portfolio value over time.
    The returned Plotly figure is interactive: zoom, pan, and hover are all
    enabled by default in Jupyter notebooks.

    Args:
        results: The TestCaseResults returned by BacktestingEngine.run_backtest().
        symbol: The ticker symbol to plot (must be present in results.price_history).
        show_portfolio_value: Whether to show a second panel with portfolio value.
        title: Optional figure title. Defaults to "{symbol} – Buy / Sell signals".
        height: Total figure height in pixels.

    Returns:
        A plotly Figure object. Call .show() in a notebook to render it.

    Example (Jupyter notebook)::

        from turtle_quant_1.backtesting.viz import plot_signals

        results = engine.run_backtest()
        plot_signals(results, "AAPL").show()
    """
    if symbol not in results.price_history:
        raise ValueError(
            f"Symbol '{symbol}' not found in results.price_history. "
            f"Available symbols: {list(results.price_history.keys())}"
        )

    price_df = results.price_history[symbol].copy()
    # price_df already carries a clean integer index from reset_index(drop=True)
    # in BacktestingEngine.run_backtest() — use it directly as the x-axis.
    price_dates = pd.to_datetime(price_df["datetime"], utc=True).dt.strftime(
        "%Y-%m-%d %H:%M"
    )

    txn_df = results.transaction_history.copy()
    if not txn_df.empty:
        txn_df = txn_df[txn_df["symbol"] == symbol]
    buys = txn_df[txn_df["action"] == "BUY"] if not txn_df.empty else pd.DataFrame()
    sells = txn_df[txn_df["action"] == "SELL"] if not txn_df.empty else pd.DataFrame()

    tick_positions, tick_labels = _build_tick_params(price_df)

    n_rows = 2 if show_portfolio_value else 1
    row_heights = [0.7, 0.3] if show_portfolio_value else [1.0]

    fig = make_subplots(
        rows=n_rows,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.06,
        row_heights=row_heights,
    )

    # Close price line
    fig.add_trace(
        go.Scatter(
            x=price_df.index,
            y=price_df["Close"],
            mode="lines",
            name="Close",
            line=dict(color="steelblue", width=1),
            customdata=price_dates,
            hovertemplate="%{customdata}<br>Close: $%{y:.2f}<extra></extra>",
        ),
        row=1,
        col=1,
    )

    # Buy markers
    if not buys.empty:
        buy_idx = _map_timestamps_to_index(buys["timestamp"], price_df)
        buy_dates = pd.to_datetime(buys["timestamp"], utc=True).dt.strftime(
            "%Y-%m-%d %H:%M"
        )
        fig.add_trace(
            go.Scatter(
                x=buy_idx,
                y=buys["price"].values,
                mode="markers",
                name="BUY",
                marker=dict(symbol="triangle-up", color="limegreen", size=10),
                customdata=buy_dates.values,
                hovertemplate="%{customdata}<br>BUY @ $%{y:.2f}<extra></extra>",
            ),
            row=1,
            col=1,
        )

    # Sell markers
    if not sells.empty:
        sell_idx = _map_timestamps_to_index(sells["timestamp"], price_df)
        sell_dates = pd.to_datetime(sells["timestamp"], utc=True).dt.strftime(
            "%Y-%m-%d %H:%M"
        )
        fig.add_trace(
            go.Scatter(
                x=sell_idx,
                y=sells["price"].values,
                mode="markers",
                name="SELL",
                marker=dict(symbol="triangle-down", color="tomato", size=10),
                customdata=sell_dates.values,
                hovertemplate="%{customdata}<br>SELL @ $%{y:.2f}<extra></extra>",
            ),
            row=1,
            col=1,
        )

    fig.update_yaxes(title_text="Price ($)", row=1, col=1)
    fig.update_xaxes(
        tickvals=tick_positions,
        ticktext=tick_labels,
        row=1,
        col=1,
    )

    # Optional second panel: portfolio value
    if show_portfolio_value and not results.portfolio_value_history.empty:
        pv_df = results.portfolio_value_history.copy()
        pv_idx = _map_timestamps_to_index(pv_df["datetime"], price_df)
        pv_dates = pd.to_datetime(pv_df["datetime"], utc=True).dt.strftime(
            "%Y-%m-%d %H:%M"
        )

        fig.add_trace(
            go.Scatter(
                x=pv_idx,
                y=pv_df["portfolio_value"].values,
                mode="lines",
                name="Portfolio value",
                line=dict(color="darkorange", width=1),
                customdata=pv_dates.values,
                hovertemplate="%{customdata}<br>Portfolio: $%{y:.2f}<extra></extra>",
            ),
            row=2,
            col=1,
        )
        fig.update_yaxes(title_text="Portfolio ($)", row=2, col=1)
        fig.update_xaxes(
            tickvals=tick_positions,
            ticktext=tick_labels,
            row=2,
            col=1,
        )

    fig.update_layout(
        title=title or f"{symbol} – Buy / Sell signals",
        height=height,
        hovermode="x unified",
        xaxis_rangeslider_visible=False,
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )

    return fig