Backtesting·Backtesting Libraries·Intermediate

Nautilus Trader Backtest

Implement institutional-grade backtesting using NautilusTrader, a high-performance event-driven Python framework with realistic multi-venue exchange simulation, proper order management, and production-ready architectural patterns.

backtestingbacktesting-libraries

NautilusTrader Backtesting: A Comprehensive Guide

1. Introduction to Backtesting with NautilusTrader

Backtesting is a critical process in algorithmic trading, where a trading strategy is tested against historical data to evaluate its performance and validity before live deployment. It allows traders and developers to simulate how a strategy would have performed in the past, providing insights into its potential profitability, risks, and overall effectiveness.

NautilusTrader is an open-source, high-performance algorithmic trading platform designed for research, backtesting, and live trading. Its backtesting engine is built for speed and accuracy, supporting various asset classes and complex strategy logic.

Purpose and Importance:

  • Validation: Confirm if a strategy generates the expected returns under historical market conditions.
  • Optimization: Identify optimal parameters for a strategy by testing different configurations.
  • Risk Assessment: Quantify potential drawdowns, volatility, and other risk metrics.
  • Learning: Gain a deeper understanding of market dynamics and strategy behavior.
  • Decision Making: Provide data-driven evidence to decide whether to deploy a strategy live.

This notebook will guide you through the process of setting up, implementing, running, and analyzing a backtest using NautilusTrader.

2. Setting Up NautilusTrader for Backtesting

First, you need to install NautilusTrader. If you haven't already, you can do so using pip. For this notebook, we'll focus on the core backtesting components, often using mock data for demonstration purposes.

[ ]
# Clean installation for NautilusTrader - let pip handle dependencies automatically
!pip install --upgrade pip -q
!pip install nautilus_trader -q

import nautilus_trader
import pandas as pd

print(f" NautilusTrader {nautilus_trader.__version__} installed successfully.")
print(f" pandas {pd.__version__} installed successfully (automatically resolved).")
✅ NautilusTrader 1.228.0 installed successfully.
✅ pandas 2.3.3 installed successfully (automatically resolved).
[ ]
import datetime
import pytz
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 1. Core Engine & Configuration: Imports related to setting up and configuring the backtest engine.
from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.config import BacktestEngineConfig, StrategyConfig
from nautilus_trader.trading.strategy import Strategy

# 2. Test Kit Providers for mock data and instruments: Used to generate synthetic instruments for testing.
from nautilus_trader.test_kit.providers import TestInstrumentProvider

# 3. Core Structural Models, Data, & Identifiers: Fundamental data structures and identifiers for trading.
from nautilus_trader.model.data import Bar, BarType, BarSpecification
from nautilus_trader.model.objects import Price, Quantity, Money
from nautilus_trader.model.currencies import USD
from nautilus_trader.model.identifiers import Venue, InstrumentId

# 4. Enums: Enumerations for various trading concepts like order sides, types, and account types.
from nautilus_trader.model.enums import (
    OrderSide, OrderType, PositionSide, PriceType,
    BarAggregation, AggregationSource, OmsType, AccountType, TimeInForce
)
from nautilus_trader.core.datetime import dt_to_unix_nanos

# 5. Indicators: Technical indicators used in trading strategies.
from nautilus_trader.indicators import SimpleMovingAverage

print("NautilusTrader and dependencies imported successfully.")
NautilusTrader and dependencies imported successfully.
[ ]
# Initialize the backtest engine
config = BacktestEngineConfig(trader_id="BACKTESTER-001")
engine = BacktestEngine(config=config)

print(f"Successfully initialized NautilusTrader Engine")
Successfully initialized NautilusTrader Engine
[ ]
import nautilus_trader
print(f"NautilusTrader version: {nautilus_trader.__version__}")
print("Package structure verified.")
NautilusTrader version: 1.228.0
Package structure verified.

3. Core Components of a Backtest

A typical backtest simulation with NautilusTrader involves several key components:

  • Data Client: Provides historical market data (e.g., tick, bar data) to the strategy.
  • Instruments: Definition of the assets being traded (e.g., EUR/USD, AAPL).
  • Strategy: Contains the trading logic, including entry and exit conditions, and how orders are generated.
  • Risk Management: Rules to control exposure and losses (e.g., stop-loss, take-profit).
  • Execution Model: Simulates order execution, slippage, and commissions.
  • Portfolio Management: Tracks positions, cash, and calculates performance metrics.

3.1 Instruments and Data Generation

For demonstration purposes, we will generate synthetic bar data for a single instrument. This allows us to focus on the backtesting logic without needing external data sources.

3.2 Mock Data Client

NautilusTrader uses a Data Client to feed data to the backtest engine. For our generated data, we'll use MockDataClient and manually add our fake bars to it.

[ ]
import math
import numpy as np

# 1. Re-create engine (fresh state) to ensure a clean backtest environment.
engine = BacktestEngine(config=BacktestEngineConfig(trader_id="BACKTESTER-001"))

# 2. Define the simulation venue and add it to the engine.
# This sets up a virtual trading environment for the backtest.
SIM_VENUE = Venue("SIM")
engine.add_venue(
    venue=SIM_VENUE,
    oms_type=OmsType.NETTING,
    account_type=AccountType.MARGIN,
    starting_balances=[Money(10_000.0, USD)], # Starting capital for the backtest.
)

# 3. Generate a mock EUR/USD instrument and add it to the engine.
# This defines the asset we will be trading.
EURUSD = TestInstrumentProvider.default_fx_ccy("EUR/USD")
engine.add_instrument(EURUSD)
print(f"Instrument added: {EURUSD.id}")

# 4. Define the BarType for 1-minute LAST bars.
# This specifies the type and aggregation of the historical data.
bar_spec = BarSpecification(1, BarAggregation.MINUTE, PriceType.LAST)
bar_type = BarType(EURUSD.id, bar_spec, AggregationSource.EXTERNAL)
print(f"BarType: {bar_type}")

# 5. Generate synthetic bars with CYCLICAL price movement.
# This section creates mock historical price data for the backtest.
start_time_dt = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=pytz.utc)
end_time_dt = datetime.datetime(2023, 4, 1, 0, 0, 0, tzinfo=pytz.utc)  # 3 months of data

start_timestamp = dt_to_unix_nanos(start_time_dt)
end_timestamp = dt_to_unix_nanos(end_time_dt)

num_bars = int((end_time_dt - start_time_dt).total_seconds() / 60) # Calculate number of 1-minute bars.
initial_price = 1.08000
prec = EURUSD.price_precision
size_prec = EURUSD.size_precision

fake_bars = []
price = initial_price

# Create cyclical price movement with multiple up/down trends
# This loop generates each bar's open, high, low, close, and volume.
for i in range(num_bars):
    ts_ns = start_timestamp + i * 60 * 1_000_000_000 # Timestamp for the current bar.

    # Create multiple price cycles using sine waves to simulate realistic market fluctuations.
    # These cycles introduce varying trends and oscillations.
    cycle1 = math.sin(i / 1000) * 0.03      # Long cycle (60,000 bars = ~41 days)
    cycle2 = math.sin(i / 200) * 0.008       # Medium cycle (12,000 bars = ~8 days)
    cycle3 = math.sin(i / 50) * 0.002        # Short cycle (3,000 bars = ~2 days)
    trend = i / 100000 * 0.01                # Very slight overall uptrend over the period.
    noise = np.random.normal(0, 0.0003)      # Random noise to add unpredictability.

    price = initial_price + cycle1 + cycle2 + cycle3 + trend + noise # Calculate the base price.
    price = max(price, 1.00)  # Ensure price stays above 1.00 for realism.

    # Define open, high, low, close for the bar based on the calculated price and some small variations.
    open_val = round(price - 0.00005, prec)
    high_val = round(price + 0.00015 + abs(noise) * 0.5, prec)
    low_val = round(price - 0.00015 - abs(noise) * 0.5, prec)
    close_val = round(price, prec)

    # Append the newly created Bar object to the list.
    fake_bars.append(Bar(
        bar_type=bar_type,
        open=Price(open_val, prec),
        high=Price(high_val, prec),
        low=Price(low_val, prec),
        close=Price(close_val, prec),
        volume=Quantity(100, size_prec),
        ts_event=ts_ns,
        ts_init=ts_ns,
    ))

print(f"Generated {len(fake_bars)} mock bars with cyclical price movement.")

# 6. Add the generated bars to the backtest engine for simulation.
engine.add_data(fake_bars)
print("Data added to engine.")

# Preview the first few bars as a DataFrame for quick inspection.
df_bars = pd.DataFrame([
    {
        'timestamp': pd.to_datetime(bar.ts_event, unit='ns', utc=True),
        'open':   float(bar.open),
        'high':   float(bar.high),
        'low':    float(bar.low),
        'close':  float(bar.close),
        'volume': float(bar.volume),
    } for bar in fake_bars
])
df_bars.set_index('timestamp', inplace=True)
print("\nFirst 5 bars:")
display(df_bars.head())

# Plot price movement to visually verify the generated cyclical trends.
plt.figure(figsize=(14, 6))
plt.plot(df_bars.index, df_bars['close'], linewidth=0.5, alpha=0.7)
plt.title('Generated Price Data with Cyclical Movement (Multiple Trends)', fontsize=14)
plt.xlabel('Date')
plt.ylabel('EUR/USD Price')
plt.grid(True, alpha=0.3)
plt.show()
Instrument added: EUR/USD.SIM
BarType: EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL
Generated 129600 mock bars with cyclical price movement.
Data added to engine.

First 5 bars:
open high low close volume
timestamp
2023-01-01 00:00:00+00:00 1.08015 1.08045 1.07995 1.08020 100.0
2023-01-01 00:01:00+00:00 1.07973 1.08009 1.07946 1.07978 100.0
2023-01-01 00:02:00+00:00 1.08000 1.08029 1.07982 1.08005 100.0
2023-01-01 00:03:00+00:00 1.08023 1.08045 1.08010 1.08028 100.0
2023-01-01 00:04:00+00:00 1.08020 1.08049 1.08000 1.08025 100.0
cell output

4. Example Strategy Implementation: Simple Moving Average (SMA) Crossover

Let's implement a classic trading strategy: the Simple Moving Average (SMA) Crossover. This strategy generates a buy signal when a shorter-period SMA crosses above a longer-period SMA, and a sell signal when the shorter SMA crosses below the longer SMA.

Strategy Logic:

  • Inputs: fast_ma_period, slow_ma_period, instrument_id.
  • Entry Condition (Buy): fast_ma > slow_ma and fast_ma was <= slow_ma in the previous bar.
  • Entry Condition (Sell): fast_ma < slow_ma and fast_ma was >= slow_ma in the previous bar.
  • Exit Condition: When the opposite crossover occurs.

For simplicity, we'll keep a single position open at a time.

[ ]
class SMACrossoverConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    fast_ma_period: int = 10
    slow_ma_period: int = 20
    trade_size: float = 1000.0


class SMACrossoverStrategy(Strategy):
    """Simple Moving Average crossover strategy for NautilusTrader."""

    def __init__(self, config: SMACrossoverConfig):
        super().__init__(config)
        self.instrument_id = config.instrument_id
        self.bar_type = config.bar_type
        # Convert trade size to a Quantity object with appropriate precision.
        self.trade_size = Quantity(config.trade_size, precision=0)

        # Built-in SMA indicators from NautilusTrader for fast and slow moving averages.
        self.fast_ma = SimpleMovingAverage(config.fast_ma_period)
        self.slow_ma = SimpleMovingAverage(config.slow_ma_period)

    def on_start(self):
        """Register indicators and subscribe to bar data when the strategy starts.
        This ensures the strategy receives new bar data as it becomes available.
        """
        self.register_indicator_for_bars(self.bar_type, self.fast_ma)
        self.register_indicator_for_bars(self.bar_type, self.slow_ma)
        self.subscribe_bars(self.bar_type)

    def on_bar(self, bar: Bar):
        """Called on each new bar. This is where the core trading logic for SMA crossover is executed."""
        # Wait until both indicators have enough historical data to be initialized.
        if not self.fast_ma.initialized or not self.slow_ma.initialized:
            return

        fast_val = self.fast_ma.value # Get the current value of the fast SMA.
        slow_val = self.slow_ma.value # Get the current value of the slow SMA.

        # Determine the current net position for the instrument (long, short, or flat).
        net_pos = self.portfolio.net_position(self.instrument_id)

        # BUY signal logic: fast MA crosses above slow MA, and we are currently flat or short.
        if fast_val > slow_val and net_pos <= 0:
            if net_pos < 0:
                # If currently short, close the existing short position first.
                self.cancel_all_orders(self.instrument_id)
                self.close_all_positions(self.instrument_id)
            # Submit a market buy order for the defined trade size.
            order = self.order_factory.market(
                instrument_id=self.instrument_id,
                order_side=OrderSide.BUY,
                quantity=self.trade_size,
            )
            self.submit_order(order)
            self.log.info(f"BUY  @ {bar.close}  fast={fast_val:.5f} slow={slow_val:.5f}")

        # SELL signal logic: fast MA crosses below slow MA, and we are currently flat or long.
        elif fast_val < slow_val and net_pos >= 0:
            if net_pos > 0:
                # If currently long, close the existing long position first.
                self.cancel_all_orders(self.instrument_id)
                self.close_all_positions(self.instrument_id)
            # Submit a market sell order for the defined trade size.
            order = self.order_factory.market(
                instrument_id=self.instrument_id,
                order_side=OrderSide.SELL,
                quantity=self.trade_size,
            )
            self.submit_order(order)
            self.log.info(f"SELL @ {bar.close}  fast={fast_val:.5f} slow={slow_val:.5f}")

    def on_stop(self):
        """Close all open positions when the strategy stops to ensure a clean exit."""
        self.cancel_all_orders(self.instrument_id)
        self.close_all_positions(self.instrument_id)


print("SMACrossoverStrategy class defined successfully.")
SMACrossoverStrategy class defined successfully.

5. Running the Backtest

To run a backtest, we need to define a BacktestConfig and then instantiate and run the Backtest object. The configuration includes details like the start/end times, initial capital, instruments, and the strategy to use.

[ ]
# Instantiate the strategy with its config
strategy_config = SMACrossoverConfig(
    instrument_id=EURUSD.id,
    bar_type=bar_type,
    fast_ma_period=10,
    slow_ma_period=20,
    trade_size=1000.0,  # 1,000 units of EUR/USD
)
strategy = SMACrossoverStrategy(config=strategy_config)

# Add the strategy to the engine
engine.add_strategy(strategy)

# Run the backtest over the full data range
print("Starting backtest...")
engine.run()
print("Backtest finished.")
Starting backtest...
Backtest finished.

6. Analyzing Backtest Results

After the backtest completes, NautilusTrader provides a comprehensive Trade Analyzer to evaluate the strategy's performance. Key metrics include Profit & Loss (PnL), Sharpe Ratio, maximum drawdown, number of trades, and more.

6.1 Performance Metrics

Let's extract and display some of the crucial performance statistics.

[ ]
# --- Performance Summary ---
# NautilusTrader exposes trade reports directly from engine.trader

# Generate a report of all filled orders (trades).
fills_report = engine.trader.generate_order_fills_report()
# Generate a report of all positions taken by the strategy.
positions_report = engine.trader.generate_positions_report()
# Generate an account summary report for the specified venue.
account_report = engine.trader.generate_account_report(Venue("SIM"))

print("=== Account Report ===")
display(account_report)

print(f"\n=== Positions ({len(positions_report)} total) ===")
display(positions_report)

print(f"\n=== Fills ({len(fills_report)} total) ===")
display(fills_report.head(10))
=== Account Report ===
total locked free currency account_id account_type base_currency margins reported info
2023-01-01 00:00:00+00:00 10000.00 0.00 10000.00 USD SIM-001 MARGIN None [] True {}
2023-01-01 00:19:00+00:00 9999.98 0.08 9999.90 USD SIM-001 MARGIN None [{'type': 'MarginBalance', 'initial': '0.00', ... False {}
2023-01-01 00:19:00+00:00 9999.98 3.25 9996.73 USD SIM-001 MARGIN None [{'type': 'MarginBalance', 'initial': '0.00', ... False {}
2023-01-01 02:33:00+00:00 10000.17 3.17 9997.00 USD SIM-001 MARGIN None [{'type': 'MarginBalance', 'initial': '0.00', ... False {}
2023-01-01 02:33:00+00:00 10007.65 0.00 10007.65 USD SIM-001 MARGIN None [] False {}
... ... ... ... ... ... ... ... ... ... ...
2023-03-31 23:10:00+00:00 12472.80 0.00 12472.80 USD SIM-001 MARGIN None [] False {}
2023-03-31 23:10:00+00:00 12472.78 0.08 12472.70 USD SIM-001 MARGIN None [{'type': 'MarginBalance', 'initial': '0.00', ... False {}
2023-03-31 23:10:00+00:00 12472.78 3.23 12469.55 USD SIM-001 MARGIN None [{'type': 'MarginBalance', 'initial': '0.00', ... False {}
2023-03-31 23:59:00+00:00 12472.81 3.15 12469.66 USD SIM-001 MARGIN None [{'type': 'MarginBalance', 'initial': '0.00', ... False {}
2023-03-31 23:59:00+00:00 12473.85 0.00 12473.85 USD SIM-001 MARGIN None [] False {}

22769 rows × 10 columns


=== Positions (5692 total) ===
trader_id strategy_id instrument_id account_id opening_order_id closing_order_id entry side quantity peak_qty ... ts_opened ts_last ts_closed duration_ns avg_px_open avg_px_close commissions realized_return realized_pnl is_snapshot
position_id
EUR/USD.SIM-SMACrossoverStrategy-000-460cf551-1695-4e2a-b5fc-8a9e2cb56670 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230101-001900-001-000-1 O-20230101-023300-001-000-2 BUY FLAT 0 1000 ... 2023-01-01 00:19:00+00:00 1672540380000000000 2023-01-01 02:33:00+00:00 8040000000000 1.08222 1.08991 [0.04 USD] 0.00711 7.65 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-3afaad36-d892-478e-a1f7-7d68b88204e7 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230101-023300-001-000-3 O-20230101-023400-001-000-4 SELL FLAT 0 1000 ... 2023-01-01 02:33:00+00:00 1672540440000000000 2023-01-01 02:34:00+00:00 60000000000 1.08991 1.09074 [0.04 USD] -0.00076 -0.87 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-d2ee5c40-d85b-46de-b17d-209bcf666b30 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230101-023400-001-000-5 O-20230101-032400-001-000-6 BUY FLAT 0 1000 ... 2023-01-01 02:34:00+00:00 1672543440000000000 2023-01-01 03:24:00+00:00 3000000000000 1.09074 1.09111 [0.04 USD] 0.00034 0.33 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-cd29cae2-6654-4c98-86fc-335ed1313091 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230101-032400-001-000-7 O-20230101-032500-001-000-8 SELL FLAT 0 1000 ... 2023-01-01 03:24:00+00:00 1672543500000000000 2023-01-01 03:25:00+00:00 60000000000 1.09111 1.09131 [0.04 USD] -0.00018 -0.23 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-aa27cf0b-fe31-4886-a4b1-5d9bab603691 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230101-032500-001-000-9 O-20230101-064400-001-000-10 BUY FLAT 0 1000 ... 2023-01-01 03:25:00+00:00 1672555440000000000 2023-01-01 06:44:00+00:00 11940000000000 1.09131 1.10072 [0.04 USD] 0.00862 9.37 USD True
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
EUR/USD.SIM-SMACrossoverStrategy-000-a3972f77-ca5a-4803-8b89-8ec5ae6631c9 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230331-225000-001-000-11375 O-20230331-225600-001-000-11376 SELL FLAT 0 1000 ... 2023-03-31 22:50:00+00:00 1680303360000000000 2023-03-31 22:56:00+00:00 360000000000 1.07882 1.07895 [0.04 USD] -0.00012 -0.17 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-b549bcf6-57d1-4496-b99c-47b67c4280e9 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230331-225600-001-000-11377 O-20230331-225700-001-000-11378 BUY FLAT 0 1000 ... 2023-03-31 22:56:00+00:00 1680303420000000000 2023-03-31 22:57:00+00:00 60000000000 1.07895 1.07834 [0.04 USD] -0.00056 -0.64 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-f15f55ea-1c92-4add-be21-1dedcb56e948 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230331-225700-001-000-11379 O-20230331-225900-001-000-11380 SELL FLAT 0 1000 ... 2023-03-31 22:57:00+00:00 1680303540000000000 2023-03-31 22:59:00+00:00 120000000000 1.07834 1.07891 [0.04 USD] -0.00053 -0.61 USD True
EUR/USD.SIM-SMACrossoverStrategy-000-80b87877-f01d-47c4-a175-51e17fac5d42 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230331-225900-001-000-11381 O-20230331-231000-001-000-11382 BUY FLAT 0 1000 ... 2023-03-31 22:59:00+00:00 1680304200000000000 2023-03-31 23:10:00+00:00 660000000000 1.07891 1.07832 [0.04 USD] -0.00055 -0.63 USD True
EUR/USD.SIM-SMACrossoverStrategy-000 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-001 O-20230331-231000-001-000-11383 O-20230331-235900-001-000-11384 SELL FLAT 0 1000 ... 2023-03-31 23:10:00+00:00 1680307140000000000 2023-03-31 23:59:00+00:00 2940000000000 1.07832 1.07723 [0.04 USD] 0.00101 1.05 USD False

5692 rows × 21 columns


=== Fills (11384 total) ===
trader_id strategy_id instrument_id venue_order_id position_id account_id last_trade_id type side quantity ... order_list_id linked_order_ids parent_order_id exec_algorithm_id exec_algorithm_params exec_spawn_id tags init_id ts_init ts_last
client_order_id
O-20230101-001900-001-000-1 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-001 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-0e1a25dd15e3f48c-082 MARKET BUY 1000 ... None None None None None None None d8f22639-8b30-44b6-8a14-4667c42d7d83 2023-01-01 00:19:00+00:00 2023-01-01 00:19:00+00:00
O-20230101-023300-001-000-2 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-002 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-65b600aa5257ee66-620 MARKET SELL 1000 ... None None None None None None None 6bb145e1-bb93-42ab-a4c1-2f9f90d2a8a5 2023-01-01 02:33:00+00:00 2023-01-01 02:33:00+00:00
O-20230101-023300-001-000-3 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-003 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-65b600aa5257ee66-622 MARKET SELL 1000 ... None None None None None None None 0c0e349f-25c0-42d0-a4e1-795ed6710905 2023-01-01 02:33:00+00:00 2023-01-01 02:33:00+00:00
O-20230101-023400-001-000-4 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-004 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-9b62fa09babe3ba5-628 MARKET BUY 1000 ... None None None None None None None b3507bb2-b367-4b26-b743-d43f47851fb6 2023-01-01 02:34:00+00:00 2023-01-01 02:34:00+00:00
O-20230101-023400-001-000-5 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-005 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-9b62fa09babe3ba5-630 MARKET BUY 1000 ... None None None None None None None 90e6f33d-ae51-4918-98fb-89ddbeab3737 2023-01-01 02:34:00+00:00 2023-01-01 02:34:00+00:00
O-20230101-032400-001-000-6 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-006 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-44134f7c77626dd8-832 MARKET SELL 1000 ... None None None None None None None 483813ae-2001-4996-8d52-7f874e0795ea 2023-01-01 03:24:00+00:00 2023-01-01 03:24:00+00:00
O-20230101-032400-001-000-7 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-007 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-44134f7c77626dd8-834 MARKET SELL 1000 ... None None None None None None None 537e4b0c-373c-447b-94d0-9ebf7bd73c39 2023-01-01 03:24:00+00:00 2023-01-01 03:24:00+00:00
O-20230101-032500-001-000-8 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-008 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-19c4938d928e891d-840 MARKET BUY 1000 ... None None None None None None None 764f520c-3c43-497b-985b-410c036013b3 2023-01-01 03:25:00+00:00 2023-01-01 03:25:00+00:00
O-20230101-032500-001-000-9 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-009 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-19c4938d928e891d-842 MARKET BUY 1000 ... None None None None None None None 145d2923-4219-47fc-837f-5cab25ee9adc 2023-01-01 03:25:00+00:00 2023-01-01 03:25:00+00:00
O-20230101-064400-001-000-10 BACKTESTER-001 SMACrossoverStrategy-000 EUR/USD.SIM SIM-1-010 EUR/USD.SIM-SMACrossoverStrategy-000 SIM-001 T-5c01b63245073289-1640 MARKET SELL 1000 ... None None None None None None None 39408f31-538f-4726-8044-0811de9f8171 2023-01-01 06:44:00+00:00 2023-01-01 06:44:00+00:00

10 rows × 31 columns

6.2 Visualization 1: Equity Curve

The equity curve (or PnL curve) is one of the most important visualizations for a backtest. It plots the cumulative profit or loss of the strategy over time, giving a clear picture of its performance trend.

[ ]
# Build equity curve from positions report instead of fills for PnL calculation.
if len(positions_report) > 0:
    # Initialize lists to store PnL values and corresponding timestamps.
    pnl_values = []
    timestamps = []

    # Iterate through each row in the positions report to extract realized PnL.
    for idx, row in positions_report.iterrows():
        # Check if 'realized_pnl' exists and is not null.
        if 'realized_pnl' in row and pd.notna(row['realized_pnl']):
            # Extract the numeric value from the 'X.XX USD' string format.
            pnl_str = str(row['realized_pnl'])
            pnl_value = float(pnl_str.split()[0])
            pnl_values.append(pnl_value)
            # Convert the closing timestamp to a datetime object.
            timestamps.append(pd.to_datetime(row['ts_closed'], unit='ns', utc=True))

    if pnl_values:
        # Calculate the cumulative PnL over time.
        cum_pnl = np.cumsum(pnl_values)

        plt.figure(figsize=(14, 7))
        plt.plot(timestamps, cum_pnl, label='Cumulative PnL', color='blue', linewidth=2)
        plt.axhline(0, color='gray', linestyle='--', linewidth=0.8) # Add a horizontal line at 0 for reference.
        plt.title('Equity Curve — SMA Crossover Strategy', fontsize=14, fontweight='bold')
        plt.xlabel('Date', fontsize=12)
        plt.ylabel('Cumulative PnL (USD)', fontsize=12)
        plt.grid(True, linestyle='--', alpha=0.6)
        plt.legend(fontsize=12)
        plt.tight_layout()
        plt.show()

        print(f"Total Return: ${cum_pnl[-1]:.2f}")
        print(f"Number of Trades: {len(pnl_values)}")
    else:
        print("No PnL data found in positions report.")
else:
    print("No positions recorded — the strategy did not generate any trades.")
    equity_curve = pd.DataFrame(columns=['equity'])
cell output
Total Return: $2473.85
Number of Trades: 5692

6.3 Visualization 2: Drawdown Curve

Drawdown is the peak-to-trough decline in an investment, account, or fund during a specific period. The drawdown curve shows how much the strategy's equity has fallen from its previous peak, indicating periods of significant loss and risk.

[ ]
# Drawdown curve derived from the equity curve.
# Ensure cum_pnl exists and is not empty before proceeding.
if 'cum_pnl' in locals() and len(cum_pnl) > 0:
    # Create a pandas Series for the equity curve with timestamps as index.
    equity_series = pd.Series(cum_pnl, index=timestamps)

    # Calculate the running maximum (peak) of the equity curve.
    running_max = equity_series.expanding(min_periods=1).max()
    # Calculate the absolute drawdown: current equity minus the running maximum.
    drawdown = equity_series - running_max  # absolute drawdown in USD

    plt.figure(figsize=(14, 7))
    plt.plot(drawdown.index, drawdown, label='Drawdown (USD)', color='red', linewidth=2)
    plt.fill_between(drawdown.index, drawdown, 0, color='red', alpha=0.3) # Fill the area under the drawdown curve.
    plt.title('Drawdown Curve — SMA Crossover Strategy', fontsize=14, fontweight='bold')
    plt.xlabel('Date', fontsize=12)
    plt.ylabel('Drawdown (USD)', fontsize=12)
    plt.grid(True, linestyle='--', alpha=0.6)
    plt.legend(fontsize=12)
    plt.tight_layout()
    plt.show()

    max_drawdown = drawdown.min() # Find the maximum (most negative) drawdown value.
    print(f"Maximum Drawdown: ${max_drawdown:.2f}")

else:
    print("Equity curve is empty — no drawdown to display.")
cell output
Maximum Drawdown: $-13.55

7. Conclusion

This notebook provided a comprehensive overview of NautilusTrader Backtesting. We covered the fundamental concepts of backtesting, set up a basic backtesting environment with mock data, implemented a simple SMA Crossover strategy, executed the backtest, and analyzed its performance using key metrics and visualizations like the equity and drawdown curves.

NautilusTrader offers a robust and flexible framework for developing and testing complex trading strategies. By mastering its backtesting capabilities, you can systematically validate your trading ideas, optimize parameters, and gain confidence in your strategies before deploying them in live markets. Remember that backtesting is a continuous process, and results from historical data do not guarantee future performance.