Skip to content
All library documents

Visualizing Betfair Order Book Imbalance Across Backtest Updates

Code NautilusTrader

Summary

This document describes a plotting workflow for examining Betfair backtest logs that record bid and ask volumes by runner. It extracts periodic batch volumes and cumulative imbalance, then creates three visual views: imbalance over successive updates, the distribution of signed bid-versus-ask ratios, and cumulative bid and ask volume. The imbalance measure is normalized as the difference between bid and ask volume divided by their sum, allowing values to be compared around a zero baseline.

These panels can help inspect how quoted liquidity is distributed and how imbalances evolve across runners, but they are descriptive diagnostics rather than a trading rule. The document provides no backtest results or evidence that imbalance predicts outcomes. Interpretation depends on the logging interval, the quality and completeness of the log, and the selected runners; the plots alone do not account for execution, market impact, or whether displayed liquidity is actionable.

Key ideas

  • The workflow parses periodic bid and ask volume updates and runner-level summary lines from a backtest log.
  • Cumulative imbalance is measured as the difference between bid and ask volume divided by their sum.
  • A histogram of per-batch signed-flow ratios shows how often batches favor bids or asks.
  • Separate cumulative volume plots track bid and ask liquidity across updates.
  • These visualizations describe order-book activity but do not demonstrate predictive power or trading profitability.

Tags

Full text
# render_panels.py


```py
"""
Render the Betfair book imbalance tutorial panels from a backtest log.

After building NautilusTrader from source, run these commands from the repository root:

    make sync

    # Set LOG_INTERVAL to 200 in crates/adapters/betfair/examples/betfair_backtest.rs
    # first, so the panels have detail.
    cargo run -p nautilus-betfair --features examples --release \
        --example betfair-backtest > /tmp/betfair.log 2>&1

    BETFAIR_LOG=/tmp/betfair.log \
        uv run --project python --no-sync \
            python docs/tutorials/assets/backtest_book_imbalance_betfair/render_panels.py

The actor logs ``[runner] update #N: batch bid=B ask=A cumulative imbalance=I``
on every Nth update. The renderer parses those lines and writes three PNG
panels using the ``nautilus_dark`` tearsheet theme.

"""

from __future__ import annotations

import os
import re
from pathlib import Path

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

from nautilus_trader.analysis.tearsheet import _write_figure
from nautilus_trader.analysis.themes import get_theme


OUT = Path(__file__).resolve().parent
LOG_PATH = Path(os.environ.get("BETFAIR_LOG", "/tmp/betfair.log"))  # noqa: S108

THEME = get_theme("nautilus_dark")
TEMPLATE = THEME["template"]
COLORS = THEME["colors"]
PRIMARY = COLORS["primary"]
POSITIVE = COLORS["positive"]
NEGATIVE = COLORS["negative"]
NEUTRAL = COLORS["neutral"]
GRID = COLORS["grid"]

ANSI = re.compile(r"\x1b\[[0-9;]*m")
BATCH = re.compile(
    r"\[(?P<inst>[^\]]+)\] update #(?P<n>\d+): "
    r"batch bid=(?P<bid>[\-0-9.]+) ask=(?P<ask>[\-0-9.]+)\s+cumulative imbalance=(?P<imb>[\-0-9.]+)",
)
SUMMARY = re.compile(
    r"\s+(?P<inst>[^\s]+\.BETFAIR)\s+updates:\s*(?P<u>\d+)\s+bid_vol:\s*(?P<b>[\-0-9.]+)\s+ask_vol:\s*(?P<a>[\-0-9.]+)\s+imbalance:\s*(?P<i>[\-0-9.]+)",
)


def parse_log(path: Path) -> tuple[pd.DataFrame, pd.DataFrame]:
    batches: list[dict] = []
    summary: list[dict] = []

    for line in path.read_text(encoding="utf-8").splitlines():
        line = ANSI.sub("", line)
        m = BATCH.search(line)
        if m:
            batches.append(
                {
                    "instrument": m.group("inst"),
                    "n": int(m.group("n")),
                    "batch_bid": float(m.group("bid")),
                    "batch_ask": float(m.group("ask")),
                    "imbalance": float(m.group("imb")),
                },
            )
            continue
        s = SUMMARY.search(line)
        if s:
            summary.append(
                {
                    "instrument": s.group("inst"),
                    "updates": int(s.group("u")),
                    "bid_vol": float(s.group("b")),
                    "ask_vol": float(s.group("a")),
                    "imbalance": float(s.group("i")),
                },
            )
    return pd.DataFrame(batches), pd.DataFrame(summary)


def apply_layout(fig: go.Figure, title: str, height: int = 480) -> None:
    fig.update_layout(
        template=TEMPLATE,
        title={"text": title, "x": 0.02, "xanchor": "left"},
        paper_bgcolor=COLORS["background"],
        plot_bgcolor=COLORS["background"],
        font={"family": "Inter, system-ui, sans-serif", "size": 13},
        margin={"l": 60, "r": 30, "t": 70, "b": 50},
        height=height,
        width=1200,
        legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1.0},
    )
    fig.update_xaxes(gridcolor=GRID, zeroline=False)
    fig.update_yaxes(gridcolor=GRID, zeroline=False)


def panel_a_imbalance_lines(batches: pd.DataFrame, summary: pd.DataFrame) -> go.Figure:
    fig = go.Figure()
    if batches.empty:
        apply_layout(fig, "Cumulative imbalance per runner (no data)", height=480)
        return fig
    palette = [PRIMARY, POSITIVE, NEGATIVE, NEUTRAL]
    runners = sorted(batches["instrument"].unique())
    for color, inst in zip(palette, runners, strict=False):
        sel = batches[batches["instrument"] == inst].sort_values("n")
        fig.add_trace(
            go.Scatter(
                x=sel["n"],
                y=sel["imbalance"],
                mode="lines+markers",
                name=inst,
                line={"color": color, "width": 1.4},
                marker={"size": 4, "color": color},
            ),
        )
    fig.add_hline(y=0.0, line={"color": GRID, "width": 1})
    if not summary.empty:
        for color, (_, row) in zip(
            palette,
            summary.sort_values("instrument").iterrows(),
            strict=False,
        ):
            fig.add_hline(
                y=row["imbalance"],
                line={"color": color, "dash": "dash", "width": 1},
                annotation_text=f"final {row['imbalance']:+.3f}",
                annotation_position="top right",
                annotation={"font": {"size": 11, "color": color}},
            )
    apply_layout(
        fig,
        "Cumulative quoted-volume imbalance per runner across all updates",
        height=520,
    )
    fig.update_xaxes(title_text="cumulative update count")
    fig.update_yaxes(title_text="imbalance = (bid - ask) / (bid + ask)", range=[-1.05, 1.05])
    return fig


def panel_b_batch_distribution(batches: pd.DataFrame) -> go.Figure:
    fig = go.Figure()
    if batches.empty:
        apply_layout(fig, "Per-batch signed flow distribution (no data)", height=420)
        return fig
    df = batches.copy()
    df["batch_signed"] = df["batch_bid"] - df["batch_ask"]
    df["batch_total"] = df["batch_bid"] + df["batch_ask"]
    df["batch_ratio"] = np.where(
        df["batch_total"] > 0,
        df["batch_signed"] / df["batch_total"],
        np.nan,
    )
    runners = sorted(df["instrument"].unique())
    palette = [PRIMARY, POSITIVE, NEGATIVE, NEUTRAL]
    for color, inst in zip(palette, runners, strict=False):
        sel = df[df["instrument"] == inst].dropna(subset=["batch_ratio"])
        fig.add_trace(
            go.Histogram(
                x=sel["batch_ratio"],
                nbinsx=30,
                name=inst,
                marker={"color": color, "line": {"color": COLORS["background"], "width": 0.4}},
                opacity=0.7,
            ),
        )
    fig.update_layout(barmode="overlay")
    fig.add_vline(x=0.0, line={"color": GRID, "width": 1})
    apply_layout(fig, "Per-batch signed-flow ratio per runner", height=420)
    fig.update_xaxes(title_text="(bid - ask) / (bid + ask)")
    fig.update_yaxes(title_text="batches")
    return fig


def panel_c_cumulative_volume(batches: pd.DataFrame) -> go.Figure:
    fig = make_subplots(
        rows=1,
        cols=2,
        subplot_titles=("Cumulative bid volume", "Cumulative ask volume"),
        shared_yaxes=False,
    )

    if batches.empty:
        apply_layout(fig, "Cumulative volume per runner (no data)", height=480)
        return fig
    df = batches.copy().sort_values(["instrument", "n"])
    df["cum_bid"] = df.groupby("instrument")["batch_bid"].cumsum()
    df["cum_ask"] = df.groupby("instrument")["batch_ask"].cumsum()
    palette = [PRIMARY, POSITIVE, NEGATIVE, NEUTRAL]
    for color, inst in zip(palette, sorted(df["instrument"].unique()), strict=False):
        sel = df[df["instrument"] == inst]
        fig.add_trace(
            go.Scatter(
                x=sel["n"],
                y=sel["cum_bid"],
                mode="lines",
                name=inst,
                line={"color": color, "width": 1.4},
                showlegend=True,
            ),
            row=1,
            col=1,
        )
        fig.add_trace(
            go.Scatter(
                x=sel["n"],
                y=sel["cum_ask"],
                mode="lines",
                line={"color": color, "width": 1.4},
                showlegend=False,
            ),
            row=1,
            col=2,
        )
    apply_layout(fig, "Cumulative bid (back) and ask (lay) volume per runner", height=480)
    fig.update_xaxes(title_text="cumulative update count", row=1, col=1)
    fig.update_xaxes(title_text="cumulative update count", row=1, col=2)
    fig.update_yaxes(title_text="GBP", row=1, col=1)
    fig.update_yaxes(title_text="GBP", row=1, col=2)
    return fig


def main() -> None:
    batches, summary = parse_log(LOG_PATH)
    print(f"batches={len(batches)} summary_rows={len(summary)}")
    if not summary.empty:
        print(summary.to_string(index=False))
    panels = {
        "panel_a_imbalance_lines.png": panel_a_imbalance_lines(batches, summary),
        "panel_b_batch_distribution.png": panel_b_batch_distribution(batches),
        "panel_c_cumulative_volume.png": panel_c_cumulative_volume(batches),
    }

    for name, fig in panels.items():
        path = OUT / name
        _write_figure(fig, str(path))
        print(f"wrote {path} ({path.stat().st_size / 1024:.1f} KB)")


if __name__ == "__main__":
    main()

```

Shown in full with attribution under the source's licence. Licence: LGPL-3.0

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.