Skip to content
All library documents

Backtesting a Bollinger Band and RSI EUR/USD Mean-Reversion Strategy

Code NautilusTrader

Summary

This script runs a NautilusTrader backtest of a Bollinger Band and RSI mean-reversion strategy on EUR/USD perpetual contract quotes. It reads TrueFX bid and ask ticks, builds a margin venue with a starting balance and maker/taker fees, and aggregates data into one-minute mid-price bars. The strategy parameters use a rolling Bollinger envelope and RSI thresholds; the script then extracts fills and positions, reconstructs entry and exit cycles, and calculates cumulative realized profit and loss.

Four plotting routines visualize the price and bands, a zoomed trading window with entries and exits, indicator decision space, and realized profit and loss. These charts are intended to help inspect signals and trade outcomes, rather than establish performance by themselves. The document is a rendering utility, not a full strategy specification: the strategy implementation is imported from elsewhere, while the provided text does not show the actual backtest output or report metrics such as drawdown and risk-adjusted return. Results also depend on the historical tick file, instrument assumptions, fees, and execution model.

Key ideas

  • The backtest replays EUR/USD quote ticks and constructs one-minute mid-price bars.
  • The strategy combines Bollinger Band deviations with RSI thresholds to identify mean-reversion entries.
  • The simulated venue includes margin, starting capital, and maker/taker fees.
  • The script reconstructs position cycles and plots realized P&L alongside price and signal diagnostics.
  • The rendering script does not provide performance results or independently describe the imported strategy logic.

Tags

Full text
# render_panels.py


```py
"""
Render the AX EURUSD-PERP mean reversion tutorial panels from a backtest run.

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

    make sync
    TRUEFX_CSV=test_data/local/truefx/EURUSD-2025-12.csv \
        uv run --project python --no-sync \
            python docs/tutorials/assets/fx_mean_reversion_ax/render_panels.py

Replays TrueFX EUR/USD ticks through the shipped ``BBMeanReversion`` strategy,
then writes four PNG panels using the ``nautilus_dark`` tearsheet theme.

"""

from __future__ import annotations

import os
import sys
from decimal import Decimal
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
from nautilus_trader.common import LogLevel
from nautilus_trader.config import BacktestEngineConfig
from nautilus_trader.backtest import BacktestEngine
from nautilus_trader.config import CacheConfig
from nautilus_trader.config import LoggerConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.model import AccountType
from nautilus_trader.model import AssetClass
from nautilus_trader.model import BarType
from nautilus_trader.model import Currency
from nautilus_trader.model import InstrumentId
from nautilus_trader.model import Money
from nautilus_trader.model import OmsType
from nautilus_trader.model import PerpetualContract
from nautilus_trader.model import Price
from nautilus_trader.model import Quantity
from nautilus_trader.model import QuoteTick
from nautilus_trader.model import Symbol
from nautilus_trader.model import TraderId
from nautilus_trader.model import Venue

sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "examples" / "live" / "architect_ax"))
from strategies import BBMeanReversion
from strategies import BBMeanReversionConfig


OUT = Path(__file__).resolve().parent
USD = Currency.from_str("USD")
TRUEFX_CSV = Path(
    os.environ.get("TRUEFX_CSV", "test_data/local/truefx/EURUSD-2025-12.csv"),
)

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"]

BB_PERIOD = 20
BB_STD = 2.0
RSI_PERIOD = 14
RSI_BUY = 30.0
RSI_SELL = 70.0

ZOOM_HOURS = 12


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 run_backtest() -> object:
    instrument_id = InstrumentId.from_str("EURUSD-PERP.AX")
    EURUSD_PERP = PerpetualContract(
        instrument_id=instrument_id,
        raw_symbol=Symbol("EURUSD-PERP"),
        underlying="EUR",
        asset_class=AssetClass.FX,
        quote_currency=USD,
        settlement_currency=USD,
        is_inverse=False,
        price_precision=5,
        size_precision=0,
        price_increment=Price.from_str("0.00001"),
        size_increment=Quantity.from_int(1),
        multiplier=Quantity.from_int(1000),
        lot_size=Quantity.from_int(1),
        margin_init=Decimal("0.05"),
        margin_maint=Decimal("0.025"),
        ts_event=0,
        ts_init=0,
    )

    df = pd.read_csv(
        TRUEFX_CSV,
        header=None,
        names=["pair", "timestamp", "bid", "ask"],
    )
    df["timestamp"] = pd.to_datetime(
        df["timestamp"],
        format="%Y%m%d %H:%M:%S.%f",
        utc=True,
    )
    df = df.set_index("timestamp")[["bid", "ask"]].sort_index()

    ticks = []

    for timestamp, row in df.iterrows():
        ts_ns = pd.Timestamp(str(timestamp)).value
        ticks.append(
            QuoteTick(
                instrument_id=instrument_id,
                bid_price=EURUSD_PERP.make_price(float(row.bid)),
                ask_price=EURUSD_PERP.make_price(float(row.ask)),
                bid_size=Quantity.from_int(1),
                ask_size=Quantity.from_int(1),
                ts_event=ts_ns,
                ts_init=ts_ns,
            ),
        )

    engine = BacktestEngine(
        BacktestEngineConfig(
            trader_id=TraderId("BACKTESTER-001"),
            logging=LoggerConfig(stdout_level=LogLevel.ERROR),
            cache=CacheConfig(bar_capacity=200_000, tick_capacity=10_000),
        ),
    )

    AX = Venue("AX")
    engine.add_venue(
        venue=AX,
        oms_type=OmsType.NETTING,
        account_type=AccountType.MARGIN,
        base_currency=USD,
        starting_balances=[Money(100_000, USD)],
        fee_model=MakerTakerFeeModel(
            maker_rate=Decimal("0.0002"),
            taker_rate=Decimal("0.0005"),
        ),
    )
    engine.add_instrument(EURUSD_PERP)
    engine.add_data(ticks)

    bar_type = BarType.from_str("EURUSD-PERP.AX-1-MINUTE-MID-INTERNAL")
    strategy = BBMeanReversion(
        BBMeanReversionConfig(
            instrument_id=instrument_id,
            bar_type=bar_type,
            trade_size=Decimal(1),
            bb_period=BB_PERIOD,
            bb_std=BB_STD,
            rsi_period=RSI_PERIOD,
            rsi_buy_threshold=RSI_BUY,
            rsi_sell_threshold=RSI_SELL,
        ),
    )
    engine.add_strategy(strategy)
    engine.run()

    bars = engine.cache.bars(bar_type)
    fills = engine.generate_fills_report()
    positions = engine.generate_positions_report()

    bars_df = (
        pd.DataFrame(
            [
                {
                    "ts": pd.Timestamp(b.ts_init, unit="ns", tz="UTC"),
                    "open": float(b.open),
                    "high": float(b.high),
                    "low": float(b.low),
                    "close": float(b.close),
                }
                for b in bars
            ],
        )
        .sort_values("ts")
        .reset_index(drop=True)
    )

    return bars_df, fills, positions


def add_indicators(bars: pd.DataFrame) -> pd.DataFrame:
    df = bars.copy()
    sma = df["close"].rolling(BB_PERIOD).mean()
    std = df["close"].rolling(BB_PERIOD).std(ddof=0)
    df["bb_mid"] = sma
    df["bb_upper"] = sma + BB_STD * std
    df["bb_lower"] = sma - BB_STD * std

    delta = df["close"].diff()
    gain = delta.clip(lower=0.0)
    loss = (-delta).clip(lower=0.0)
    avg_gain = gain.ewm(alpha=1.0 / RSI_PERIOD, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1.0 / RSI_PERIOD, adjust=False).mean()
    rs = avg_gain / avg_loss.replace(0.0, np.nan)
    df["rsi"] = 100.0 - 100.0 / (1.0 + rs)
    df["rsi"] = df["rsi"].fillna(50.0)
    return df


def fills_to_records(fills: pd.DataFrame) -> list[dict]:
    if fills.empty:
        return []
    df = fills.copy()
    df["ts_event"] = pd.to_datetime(df["ts_event"], utc=True)
    return [
        {
            "ts": row["ts_event"],
            "side": row["order_side"],
            "qty": float(row["last_qty"]),
            "price": float(row["last_px"]),
        }
        for _, row in df.iterrows()
    ]


def walk_fills_netting(fills: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
    entries: list[dict] = []
    closes: list[dict] = []
    cycles: list[dict] = []
    pos = 0.0
    open_side = 0
    open_ts = None
    open_price = 0.0
    EPS = 1e-9

    for f in fills:
        delta = f["qty"] if f["side"] == "BUY" else -f["qty"]
        new_pos = pos + delta
        if abs(pos) < EPS and abs(new_pos) >= EPS:
            open_side = 1 if new_pos > 0 else -1
            open_ts = f["ts"]
            open_price = f["price"]
            entries.append({**f, "side_sign": open_side})
        elif abs(new_pos) < EPS and abs(pos) >= EPS:
            closes.append({**f, "side_sign": -open_side})
            cycles.append(
                {
                    "open_ts": open_ts,
                    "close_ts": f["ts"],
                    "side": open_side,
                    "open_price": open_price,
                    "close_price": f["price"],
                    "qty": f["qty"],
                },
            )
            open_side = 0
            open_ts = None
        pos = new_pos
    return entries, closes, cycles


def panel_a_overview(bars: pd.DataFrame) -> go.Figure:
    fig = go.Figure()
    fig.add_trace(
        go.Scatter(
            x=bars["ts"],
            y=bars["close"],
            mode="lines",
            name="Close (mid)",
            line={"color": NEUTRAL, "width": 1.0},
        ),
    )
    fig.add_trace(
        go.Scatter(
            x=bars["ts"],
            y=bars["bb_mid"],
            mode="lines",
            name=f"BB middle ({BB_PERIOD})",
            line={"color": PRIMARY, "width": 1.4},
        ),
    )
    fig.add_trace(
        go.Scatter(
            x=bars["ts"],
            y=bars["bb_upper"],
            mode="lines",
            name=f"BB upper +{BB_STD}sd",
            line={"color": NEGATIVE, "width": 0.9, "dash": "dot"},
        ),
    )
    fig.add_trace(
        go.Scatter(
            x=bars["ts"],
            y=bars["bb_lower"],
            mode="lines",
            name=f"BB lower -{BB_STD}sd",
            line={"color": POSITIVE, "width": 0.9, "dash": "dot"},
        ),
    )
    apply_layout(
        fig,
        f"EUR/USD 1-minute mid bars (Dec 2025) with BB({BB_PERIOD},{BB_STD}sd) envelope",
        height=460,
    )
    fig.update_yaxes(title_text="USD")
    return fig


def panel_b_zoom(bars: pd.DataFrame, entries, closes) -> go.Figure:
    if bars.empty or "rsi" not in bars.columns:
        return go.Figure()

    pivot = bars.iloc[len(bars) // 2]["ts"]
    lo = pivot - pd.Timedelta(hours=ZOOM_HOURS // 2)
    hi = pivot + pd.Timedelta(hours=ZOOM_HOURS // 2)
    sel = (bars["ts"] >= lo) & (bars["ts"] <= hi)
    z = bars.loc[sel].reset_index(drop=True)

    e = [r for r in entries if lo <= r["ts"] <= hi]
    c = [r for r in closes if lo <= r["ts"] <= hi]

    fig = make_subplots(
        rows=2,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.06,
        row_heights=[0.7, 0.3],
        subplot_titles=("Mid + BB envelope with entries and exits", "RSI"),
    )

    fig.add_trace(
        go.Scatter(
            x=z["ts"],
            y=z["close"],
            mode="lines",
            name="Close",
            line={"color": NEUTRAL, "width": 1.0},
        ),
        row=1,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=z["ts"],
            y=z["bb_mid"],
            mode="lines",
            name="BB middle",
            line={"color": PRIMARY, "width": 1.4},
        ),
        row=1,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=z["ts"],
            y=z["bb_upper"],
            mode="lines",
            name="BB upper",
            line={"color": NEGATIVE, "width": 0.9, "dash": "dot"},
        ),
        row=1,
        col=1,
    )
    fig.add_trace(
        go.Scatter(
            x=z["ts"],
            y=z["bb_lower"],
            mode="lines",
            name="BB lower",
            line={"color": POSITIVE, "width": 0.9, "dash": "dot"},
        ),
        row=1,
        col=1,
    )

    longs = [r for r in e if r["side_sign"] == 1]
    shorts = [r for r in e if r["side_sign"] == -1]

    if longs:
        fig.add_trace(
            go.Scatter(
                x=[r["ts"] for r in longs],
                y=[r["price"] for r in longs],
                mode="markers",
                name="Long entry",
                marker={
                    "symbol": "triangle-up",
                    "size": 10,
                    "color": POSITIVE,
                    "line": {"color": "white", "width": 1},
                },
            ),
            row=1,
            col=1,
        )
    if shorts:
        fig.add_trace(
            go.Scatter(
                x=[r["ts"] for r in shorts],
                y=[r["price"] for r in shorts],
                mode="markers",
                name="Short entry",
                marker={
                    "symbol": "triangle-down",
                    "size": 10,
                    "color": NEGATIVE,
                    "line": {"color": "white", "width": 1},
                },
            ),
            row=1,
            col=1,
        )
    if c:
        fig.add_trace(
            go.Scatter(
                x=[r["ts"] for r in c],
                y=[r["price"] for r in c],
                mode="markers",
                name="Close",
                marker={"symbol": "x", "size": 9, "color": "#eeeeee"},
            ),
            row=1,
            col=1,
        )

    fig.add_trace(
        go.Scatter(
            x=z["ts"],
            y=z["rsi"],
            mode="lines",
            line={"color": PRIMARY, "width": 1.0},
            showlegend=False,
        ),
        row=2,
        col=1,
    )
    fig.add_hline(
        y=RSI_BUY,
        line={"color": POSITIVE, "dash": "dash", "width": 1},
        annotation_text=f"buy {RSI_BUY}",
        annotation_position="bottom right",
        annotation={"font": {"size": 11, "color": POSITIVE}},
        row=2,
        col=1,
    )
    fig.add_hline(
        y=RSI_SELL,
        line={"color": NEGATIVE, "dash": "dash", "width": 1},
        annotation_text=f"sell {RSI_SELL}",
        annotation_position="top right",
        annotation={"font": {"size": 11, "color": NEGATIVE}},
        row=2,
        col=1,
    )
    fig.add_hline(y=0.5, line={"color": GRID, "width": 1}, row=2, col=1)

    apply_layout(
        fig,
        f"Zoom {lo.strftime('%Y-%m-%d %H:%M')} to {hi.strftime('%H:%M')} UTC: "
        f"BB envelope, RSI({RSI_PERIOD}), entries and exits",
        height=720,
    )
    fig.update_layout(legend={"y": 1.06})
    fig.update_yaxes(title_text="USD", row=1, col=1)
    fig.update_yaxes(title_text="RSI", range=[0, 100], row=2, col=1)
    return fig


def panel_c_decision_scatter(bars: pd.DataFrame) -> go.Figure:
    fig = go.Figure()
    if "rsi" not in bars.columns or bars["rsi"].isna().all():
        apply_layout(fig, "Decision space: no indicator samples", height=520)
        return fig
    df = bars.dropna(subset=["bb_lower", "bb_upper", "rsi"])
    band_width = (df["bb_upper"] - df["bb_lower"]).replace(0, np.nan)
    df = df.assign(z=(df["close"] - df["bb_mid"]) / (band_width / 2.0))
    df = df.dropna(subset=["z"])

    fig.add_shape(
        type="rect",
        x0=-3.0,
        x1=-1.0,
        y0=0.0,
        y1=RSI_BUY,
        fillcolor=POSITIVE,
        opacity=0.15,
        line_width=0,
        layer="below",
    )
    fig.add_shape(
        type="rect",
        x0=1.0,
        x1=3.0,
        y0=RSI_SELL,
        y1=100.0,
        fillcolor=NEGATIVE,
        opacity=0.15,
        line_width=0,
        layer="below",
    )

    eligible_long = (df["z"] <= -1.0) & (df["rsi"] < RSI_BUY)
    eligible_short = (df["z"] >= 1.0) & (df["rsi"] > RSI_SELL)
    other = ~(eligible_long | eligible_short)

    for mask, color, name, size in (
        (other, NEUTRAL, "neutral bars", 4),
        (eligible_long, POSITIVE, "long-eligible bars", 7),
        (eligible_short, NEGATIVE, "short-eligible bars", 7),
    ):
        sel = df[mask]
        if not sel.empty:
            fig.add_trace(
                go.Scatter(
                    x=sel["z"],
                    y=sel["rsi"],
                    mode="markers",
                    name=name,
                    marker={"size": size, "color": color, "line": {"width": 0}},
                ),
            )

    fig.add_vline(x=-1.0, line={"color": POSITIVE, "dash": "dash", "width": 1})
    fig.add_vline(x=1.0, line={"color": NEGATIVE, "dash": "dash", "width": 1})
    fig.add_hline(y=RSI_BUY, line={"color": POSITIVE, "dash": "dash", "width": 1})
    fig.add_hline(y=RSI_SELL, line={"color": NEGATIVE, "dash": "dash", "width": 1})

    apply_layout(
        fig,
        "Decision space per bar: BB z-score vs RSI (shaded = entry-eligible)",
        height=520,
    )
    fig.update_xaxes(title_text="z = (close - mid) / sd", range=[-3.0, 3.0])
    fig.update_yaxes(title_text="RSI", range=[0.0, 100.0])
    return fig


def panel_d_pnl(positions: pd.DataFrame, cycles: list[dict]) -> go.Figure:
    fig = go.Figure()
    if not cycles:
        apply_layout(fig, "Cumulative realized pnl per closed cycle (no cycles)", height=420)
        return fig
    if "realized_pnl" in positions.columns:
        df = positions.copy()
        df = df[df["ts_closed"].notna()]
        df["pnl_usd"] = (
            df["realized_pnl"].astype(str).str.replace(" USD", "", regex=False).astype(float)
        )
        df = df.sort_values("ts_closed").reset_index(drop=True)
        df["cum_pnl"] = df["pnl_usd"].cumsum()
        ts_x = df["ts_closed"]
        cum_y = df["cum_pnl"]
        per_y = df["pnl_usd"]
    else:
        df = pd.DataFrame(cycles)
        df["pnl_usd"] = (df["close_price"] - df["open_price"]) * df["side"] * df["qty"]
        df["cum_pnl"] = df["pnl_usd"].cumsum()
        ts_x = df["close_ts"]
        cum_y = df["cum_pnl"]
        per_y = df["pnl_usd"]

    colors = [POSITIVE if p >= 0 else NEGATIVE for p in per_y]
    fig.add_trace(
        go.Scatter(
            x=ts_x,
            y=cum_y,
            mode="lines",
            line={"color": PRIMARY, "width": 1.6},
            showlegend=False,
        ),
    )
    fig.add_trace(
        go.Scatter(
            x=ts_x,
            y=cum_y,
            mode="markers",
            marker={
                "size": 5,
                "color": colors,
                "line": {"color": COLORS["background"], "width": 0.4},
            },
            showlegend=False,
        ),
    )
    fig.add_hline(y=0, line={"color": NEUTRAL, "dash": "dash", "width": 1})
    apply_layout(fig, "Cumulative realized pnl per closed position (USD)", height=420)
    fig.update_xaxes(title_text="position close time")
    fig.update_yaxes(title_text="USD")
    return fig


def main() -> None:
    bars, fills, positions = run_backtest()
    bars_ind = add_indicators(bars)
    fill_records = fills_to_records(fills)
    entries, closes, cycles = walk_fills_netting(fill_records)
    print(
        f"bars={len(bars_ind)} fill_rows={len(fills)} "
        f"entries={len(entries)} closes={len(closes)} cycles={len(cycles)} "
        f"positions={len(positions)}",
    )
    panels = {
        "panel_a_overview.png": panel_a_overview(bars_ind),
        "panel_b_zoom.png": panel_b_zoom(bars_ind, entries, closes),
        "panel_c_decision_scatter.png": panel_c_decision_scatter(bars_ind),
        "panel_d_pnl.png": panel_d_pnl(positions, cycles),
    }

    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.