Skip to content
All library documents

Testing Bollinger Band and RSI Mean Reversion on Proxy FX Data

Article NautilusTrader

Summary

The tutorial explains how to backtest a mean-reversion strategy on EUR/USD perpetual futures using TrueFX spot ticks as proxy data. It builds one-minute mid-price bars, then combines a 20-period Bollinger Band with a 14-period RSI: a lower-band touch with RSI below 30 triggers a long, and an upper-band touch with RSI above 70 triggers a short. Positions close when price crosses the Bollinger middle line, with opposite positions flattened before a new entry.

The December 2025 replay produced 44,591 bars and closed 1,089 positions across 2,178 fills, ending with realized P&L of -1,287 USD. The tutorial attributes the decline to spread costs and repeated short trades during a pronounced EUR/USD uptrend. It presents this as a deliberately simple strategy without an edge and suggests testing regime filters, stricter thresholds, and stops. Results depend on proxy data, assumed fees and margin, and one month of observations; they do not establish how the strategy performs on AX Exchange or in other market conditions.

Key ideas

  • The strategy enters when a Bollinger Band extreme coincides with an RSI threshold.
  • Positions exit when price crosses back through the Bollinger middle line.
  • TrueFX EUR/USD spot ticks serve as proxy inputs for EUR/USD perpetual futures.
  • The documented December replay lost money amid spread costs and a sustained uptrend.
  • A single month of proxy-data results cannot establish performance across venues or regimes.

Tags

Full text
# Mean Reversion with Proxy FX Data (AX Exchange)


# Mean Reversion with Proxy FX Data (AX Exchange)

This tutorial backtests a Bollinger-band mean-reversion strategy on
**EURUSD-PERP** at [AX Exchange](https://architect.exchange) using
[TrueFX](https://www.truefx.com) EUR/USD spot ticks as a proxy.

## Introduction

The strategy combines two indicators on 1-minute mid bars:

- **Bollinger Bands** (`BBMeanReversion`'s `BB(20, 2.0sd)`): a rolling
  20-bar mean and a +/-2sd envelope. The bands flag price as overextended
  relative to recent volatility.
- **Relative Strength Index** (`RSI(14)`): a 14-bar momentum oscillator on
  `[0, 100]`, used with the conventional 30/70 thresholds.

Entry needs both signals at once: a touch of the lower band with `RSI < 30`
opens a long; a touch of the upper band with `RSI > 70` opens a short.
Exit is one-sided: any open position closes when the close crosses back
through the BB middle. Existing positions on the opposite side are flattened
before a new entry.

The shipped `BBMeanReversion` strategy is intentionally simple and has no
edge.

```mermaid
flowchart LR
    subgraph Inputs ["Data"]
        Q["TrueFX bid/ask ticks"]
    end

    subgraph Engine ["BacktestEngine"]
        W["QuoteTick construction"]
        AGG["1-minute MID INTERNAL aggregator"]
        BAR["Bar close"]
    end

    subgraph Indicators
        BB(("BB(20, 2.0sd)"))
        RSI(("RSI(14)"))
    end

    subgraph Decision ["Decision"]
        EX{{"Net long AND close >= mid<br/>OR<br/>net short AND close <= mid"}}
        ENL{{"close <= lower<br/>AND RSI < 30"}}
        ENS{{"close >= upper<br/>AND RSI > 70"}}
    end

    subgraph Orders
        CL["Close all positions"]
        BUY["BUY market"]
        SELL["SELL market"]
    end

    Q --> W --> AGG --> BAR
    BAR --> BB
    BAR --> RSI
    BB --> ENL
    BB --> ENS
    RSI --> ENL
    RSI --> ENS
    BB --> EX
    EX -->|yes| CL
    ENL -->|yes| BUY
    ENS -->|yes| SELL
    CL --> BUY
    CL --> SELL
```

### Why proxy data

AX Exchange is a new venue not yet covered by historical data vendors.
[TrueFX](https://www.truefx.com) publishes free, institutional-grade EUR/USD
spot tick archives (Integral and Jefferies pools) that stand in cleanly
for AX EURUSD-PERP backtests.

## Prerequisites

- Python 3.13+
- [NautilusTrader installed](../getting_started/installation.md) with the
  [`visualization` extra](../getting_started/installation.md#extras), which
  provides pandas.
- A source checkout of the repository. The backtest imports
  `BBMeanReversion` from `examples/live/architect_ax/strategies.py`, which
  the installed package does not include.
- A free TrueFX account, used to download a monthly tick archive.

## Data preparation

### Download TrueFX EUR/USD ticks

1. Go to the [TrueFX historical downloads page](https://www.truefx.com/truefx-historical-downloads/).
2. Pick **EUR/USD** and a month, for example **December 2025**.
3. Extract the ZIP. The CSV is headerless with columns
   `pair, timestamp, bid, ask`.

### Load into Nautilus quote ticks

Define `EURUSD_PERP` and `instrument_id` in the next section before running this snippet.

```python
from pathlib import Path

import pandas as pd

from nautilus_trader.model import Quantity
from nautilus_trader.model import QuoteTick

df = pd.read_csv(
    Path("EURUSD-2025-12.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,
        ),
    )
```

Each quote carries the proxy instrument ID and one unit of bid and ask size.
The strategy declares
`1-MINUTE-MID-INTERNAL`, so the engine builds 1-minute MID bars from the
tick stream internally.

## Instrument definition

Proxy data needs a manual instrument definition. The multiplier of `1000`
gives one contract a notional of 1,000 EUR.

```python
from decimal import Decimal

from nautilus_trader.model import AssetClass
from nautilus_trader.model import Currency
from nautilus_trader.model import InstrumentId
from nautilus_trader.model import PerpetualContract
from nautilus_trader.model import Price
from nautilus_trader.model import Quantity
from nautilus_trader.model import Symbol

USD = Currency.from_str("USD")

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,
)
```

Fees and margin are explicit backtest assumptions. Check the
[AX Exchange documentation](https://docs.architect.exchange/) for current
rates.

## Configuration

| Parameter            | Value  | Description                                                |
| -------------------- | ------ | ---------------------------------------------------------- |
| `bb_period`          | `20`   | Rolling window for the BB mean and the standard deviation. |
| `bb_std`             | `2.0`  | Band width in standard deviations.                         |
| `rsi_period`         | `14`   | RSI lookback in bars.                                      |
| `rsi_buy_threshold`  | `30.0` | Long entry confirmation (RSI is on `[0, 100]`).            |
| `rsi_sell_threshold` | `70.0` | Short entry confirmation.                                  |
| `trade_size`         | `1`    | One contract per trade (1,000 EUR notional).               |

## Backtest setup

From the repository root:

```python
import sys
from decimal import Decimal
from pathlib import Path

from nautilus_trader.backtest import BacktestEngine
from nautilus_trader.common import LogLevel
from nautilus_trader.config import BacktestEngineConfig
from nautilus_trader.config import LoggerConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.model import AccountType
from nautilus_trader.model import BarType
from nautilus_trader.model import Money
from nautilus_trader.model import OmsType
from nautilus_trader.model import TraderId
from nautilus_trader.model import Venue

sys.path.insert(0, str(Path("examples/live/architect_ax")))

from strategies import BBMeanReversion
from strategies import BBMeanReversionConfig

engine = BacktestEngine(
    BacktestEngineConfig(
        trader_id=TraderId.from_str("BACKTESTER-001"),
        logging=LoggerConfig(stdout_level=LogLevel.INFO),
    ),
)

AX = Venue("AX")
engine.add_venue(
    venue=AX,
    oms_type=OmsType.NETTING,
    account_type=AccountType.MARGIN,
    base_currency=USD,
    starting_balances=[Money.from_str("100000 USD")],
    fee_model=MakerTakerFeeModel(
        maker_rate=Decimal("0.0002"),
        taker_rate=Decimal("0.0005"),
    ),
)

engine.add_instrument(EURUSD_PERP)
engine.add_data(ticks)

strategy = BBMeanReversion(
    config=BBMeanReversionConfig(
        instrument_id=instrument_id,
        bar_type=BarType.from_str("EURUSD-PERP.AX-1-MINUTE-MID-INTERNAL"),
        trade_size=Decimal("1"),
        bb_period=20,
        bb_std=2.0,
        rsi_period=14,
        rsi_buy_threshold=30.0,
        rsi_sell_threshold=70.0,
    ),
)
engine.add_strategy(strategy)
engine.run()
```

Reports come off the engine:

```python
print(engine.generate_account_report(venue=AX))
print(engine.generate_order_fills_report())
print(engine.generate_positions_report())

engine.reset()
engine.dispose()
```

The self-contained runnable example uses the bundled AUD/USD fixture with the
same strategy and setup pattern. It is at
[`architect_ax_mean_reversion.py`](https://github.com/nautechsystems/nautilus_trader/tree/develop/examples/backtest/architect_ax_mean_reversion.py).

## What the run produces

Replaying TrueFX EUR/USD December 2025 through `BBMeanReversion(20, 2sd, RSI 14)`
prints 44,591 1-minute mid bars and closes 1,089 positions across 2,178 fills.
Cumulative realized pnl ends at **-1,287 USD**: the strategy bleeds steadily
through the month with no clear regime-driven recovery. Mean reversion
without a regime filter pays the spread on every cycle, and EUR/USD ran a
pronounced uptrend through the second half of December which the strategy
fought repeatedly.

![EUR/USD 1-minute mid bars across December 2025 with BB envelope](./assets/fx_mean_reversion_ax/panel_a_overview.png)

**Figure 1.** *EUR/USD 1-minute mid bars across December 2025 with the BB
middle and +/-2sd envelope. Long flat patches are weekend gaps in the TrueFX
feed.*

![Twelve-hour zoom on entries, exits, and RSI](./assets/fx_mean_reversion_ax/panel_b_zoom.png)

**Figure 2.** *Twelve-hour zoom around the dataset midpoint. Top: mid with
BB envelope, long entries (triangles up), short entries (triangles down),
and closing fills (crosses). Bottom: RSI(14) with the 30 buy / 70 sell
thresholds.*

![Decision space scatter](./assets/fx_mean_reversion_ax/panel_c_decision_scatter.png)

**Figure 3.** *Per-bar BB z-score against RSI for the whole month. Shaded
regions mark the entry-eligible quadrants: lower-left (long) and upper-right
(short). The diagonal lobe is the natural co-movement of band-relative price
and RSI.*

![Cumulative realized pnl per closed position](./assets/fx_mean_reversion_ax/panel_d_pnl.png)

**Figure 4.** *Cumulative realized USD pnl across closed positions. The
curve declines roughly linearly, dominated by spread and small adverse
moves on each cycle.*

### Regenerate the panels

A self-contained renderer re-runs the backtest, computes BB and RSI on the
captured bars, and writes PNG panels using the shared `nautilus_dark`
tearsheet theme.

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

```bash
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
```

Set `TRUEFX_CSV` to wherever you saved the EUR/USD archive.

## Next steps

- **Add a regime filter**. The drawdown is concentrated in trending sessions.
  Suppress entries when realized range or a slower trend filter says the
  market is directional.
- **Tune thresholds**. A wider band (`bb_std=2.5`) or stricter RSI cutoffs
  (`25` / `75`) cut entries but raise the bar for confirmation.
- **Add stops**. Hard stop-loss orders cap downside per cycle and prevent
  carrying a losing position to the BB middle reversion.
- **Go live on the AX sandbox**. Connect to the AX sandbox for paper
  trading once the backtest behaves. See the
  [AX Exchange integration guide](../integrations/architect_ax.md) for
  setup.

## Running live

The same `BBMeanReversion` strategy runs live against AX Exchange. The
launch script swaps the `BacktestEngine` for a `LiveNode` with the AX
data and execution clients configured. See the live example:
[`ax_mean_reversion.py`](https://github.com/nautechsystems/nautilus_trader/tree/develop/examples/live/architect_ax/ax_mean_reversion.py).
The script targets the AX sandbox (`AxEnvironment.SANDBOX` on both client
configs) and places live sandbox orders. It also sets
`LiveRiskEngineConfig(bypass=True)`, which skips pre-trade risk checks and
order rate limits.

For connection setup and API key configuration, see the
[AX Exchange integration guide](../integrations/architect_ax.md).

## Further reading

- [`BBMeanReversion` strategy source](https://github.com/nautechsystems/nautilus_trader/blob/develop/examples/live/architect_ax/strategies.py)
- [Gold perpetual book imbalance tutorial](gold_book_imbalance_ax.md)
- [Architect Exchange documentation](https://docs.architect.exchange/)

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.