Mean Reversion with Proxy FX Data (AX Exchange)
This tutorial backtests a Bollinger-band mean-reversion strategy on EURUSD-PERP at AX Exchange using TrueFX EUR/USD spot ticks as a proxy.
Introduction
The strategy combines two indicators on 1-minute mid bars:
- Bollinger Bands (
BBMeanReversion'sBB(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 requires both signals at once:
- A touch of the lower band with
RSI < 30opens a long position. - A touch of the upper band with
RSI > 70opens a short position.
Exit uses only the closing-price condition: any open position closes when the closing price crosses back through the BB middle. The strategy closes existing positions on the opposite side before a new entry.
The shipped BBMeanReversion strategy is intentionally simple and has no
edge.
Why proxy data
Historical data vendors do not yet cover AX Exchange. TrueFX publishes free, institutional-grade EUR/USD spot tick archives from the Integral and Jefferies pools. These archives provide proxy data for AX EURUSD-PERP backtests.
Prerequisites
- Python 3.13+
- NautilusTrader installed with the
visualizationextra, which provides pandas. - A source checkout of the repository. The backtest imports
BBMeanReversionfromexamples/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
- Go to the TrueFX historical downloads page.
- Select EUR/USD and a month, for example December 2025.
- Extract the ZIP. The CSV has no header and contains the columns
pair, timestamp, bid, ask.
Load into Nautilus quote ticks
Define EURUSD_PERP and instrument_id in the next section before running this snippet.
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. The engine therefore builds 1-minute MID bars internally from the tick stream.
Instrument definition
Proxy data needs a manual instrument definition. The multiplier of 1000
gives one contract a notional of 1,000 EUR.
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 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:
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()Get reports from the engine:
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.
What the run produces
A replay of TrueFX EUR/USD December 2025 through BBMeanReversion(20, 2sd, RSI 14) produces 44,591 1-minute
mid bars. It closes 1,089 positions across 2,178 fills. Cumulative realized pnl ends at -1,287 USD. The
strategy loses money steadily through the month, with no clear recovery from a change in regime. Mean
reversion without a regime filter pays the spread on every cycle. EUR/USD trends strongly upward through the
second half of December, and the strategy repeatedly trades against that trend.

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.

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.

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.

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 runs the backtest again and calculates BB and RSI on the captured bars. It writes
PNG panels with the shared nautilus_dark tearsheet theme.
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.pySet TRUEFX_CSV to the path of your EUR/USD archive.
Next steps
- Regime filter: The drawdown concentrates in trending sessions. Suppress entries when realized range or a slower trend filter indicates a directional market.
- Thresholds: A wider band (
bb_std=2.5) or stricter RSI cutoffs (25/75) reduce entries and require stronger confirmation. - Stops: Hard stop-loss orders cap downside per cycle. They prevent the strategy from holding a losing position until the price returns to the BB middle.
- AX sandbox: Connect to the AX sandbox for paper trading once the backtest behaves. See the AX Exchange integration guide for setup.
Running live
The same BBMeanReversion strategy runs live against AX Exchange. The launch script uses a LiveNode with AX
data and execution clients in place of the BacktestEngine. See the live example:
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.
Further reading
Tutorials
These tutorials give step-by-step instructions for NautilusTrader features and workflows.
Gold Perpetual Book Imbalance with Proxy Futures Data (AX Exchange)
This tutorial backtests a top-of-book imbalance strategy on XAU-PERP at AX Exchange using Databento CME gold futures (GC.v.0) mbp-1 quotes as a proxy.