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.
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 |
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_maandfast_mawas <=slow_main the previous bar. - Entry Condition (Sell):
fast_ma<slow_maandfast_mawas >=slow_main 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'])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.")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.