Lab 01: From Zero to BacktestHands-on: build your first end-to-end NautilusTrader project
Objectives
In this lab, you will start from an empty directory and build a working EMA crossover backtest step by step. You will learn how to:
- Implement best practices for project structuring.
- Ingest raw CSV data and serialize it into Parquet format.
- Configure
DataCatalogfor efficient data management. - Implement the EMA crossover trading logic.
- Configure the
BacktestEngineand execute a backtest. - Generate tearsheets for performance analysis.
Ensure you have successfully configured your Python environment and installed NautilusTrader, as detailed in Module 00.
Step 1: Establish Project Structure
Within your working directory (e.g., my_quant_project), create the following
directories and files:
my_quant_project/
├── data/ # Directory for raw CSV data
├── catalog/ # Directory for Parquet DataCatalog
├── strategies/ # Strategy implementations
│ ├── __init__.py
│ └── ema_cross.py # The strategy file
├── config.py # Global configuration
└── run_backtest.py # Backtest execution script
Step 2: Data Preparation (CSV → Parquet)
NautilusTrader works best with Parquet for efficient data loading. In this step, you will write a small utility to convert Binance-style CSV data into a Parquet-backed DataCatalog.
Assuming your data/ETHUSDT.csv file uses the following schema:
timestamp,open,high,low,close,volume,... 1609459200000,736.42,739.00,736.00,737.95,123.45,...
Create convert_data.py:
import pandas as pd
from nautilus_trader.model.data import Bar, BarType
from nautilus_trader.model.enums import BarAggregation, PriceType
from nautilus_trader.model.instruments import InstrumentId
from nautilus_trader.persistence.catalog import ParquetDataCatalog
def convert_csv_to_parquet():
# 1. Read CSV
df = pd.read_csv("data/ETHUSDT.csv")
# 2. Instantiate Bar objects for each row (Computationally expensive, but only required once)
bars = []
instrument_id = InstrumentId.from_str("ETHUSDT.BINANCE")
bar_type = BarType.from_str("ETHUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL")
for _, row in df.iterrows():
bar = Bar(
bar_type=bar_type,
ts_event=int(row["timestamp"] * 1_000_000), # Convert milliseconds to nanoseconds
ts_init=int(row["timestamp"] * 1_000_000),
open=float(row["open"]),
high=float(row["high"]),
low=float(row["low"]),
close=float(row["close"]),
volume=float(row["volume"]),
)
bars.append(bar)
# 3. Write to DataCatalog
catalog = ParquetDataCatalog("./catalog")
catalog.write_data(bars)
print("Data conversion complete!")
if __name__ == "__main__":
convert_csv_to_parquet()Step 3: Strategy Implementation (strategies/ema_cross.py)
from decimal import Decimal
from nautilus_trader.trading.strategy import Strategy
from nautilus_trader.config import StrategyConfig
from nautilus_trader.indicators.average.ema import ExponentialMovingAverage
from nautilus_trader.model.enums import OrderSide
class EmaCrossConfig(StrategyConfig):
instrument_id: str
fast_period: int = 10
slow_period: int = 20
trade_size: str = "0.01"
class EmaCrossStrategy(Strategy):
def __init__(self, config: EmaCrossConfig):
super().__init__(config)
self.instrument_id = config.instrument_id
# Define indicators
self.fast_ema = ExponentialMovingAverage(config.fast_period)
self.slow_ema = ExponentialMovingAverage(config.slow_period)
def on_bar(self, bar):
# Update indicators
self.fast_ema.update(bar.close)
self.slow_ema.update(bar.close)
# Ensure indicators are initialized
if not self.fast_ema.initialized or not self.slow_ema.initialized:
return
# Retrieve current position
pos = self.portfolio.position(self.instrument_id)
# Trading logic
if self.fast_ema.value > self.slow_ema.value:
# Bullish crossover: Go long
if not pos:
self.buy()
elif self.fast_ema.value < self.slow_ema.value:
# Bearish crossover: Flatten position
if pos:
self.sell_all()
def buy(self):
order = self.order_factory.market(
instrument_id=self.instrument_id,
order_side=OrderSide.BUY,
quantity=self.config.trade_size,
)
self.submit_order(order)
def sell_all(self):
self.close_all_positions(self.instrument_id)Step 4: Execute the Backtest (run_backtest.py)
from decimal import Decimal
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
from nautilus_trader.model.instruments import InstrumentId
from nautilus_trader.model.currencies import Currency
from nautilus_trader.model.objects import Money
from nautilus_trader.model.enums import AccountType, OmsType
from strategies.ema_cross import EmaCrossStrategy, EmaCrossConfig
# Configure backtest engine
config = BacktestEngineConfig(
strategies=[
EmaCrossConfig(
instrument_id="ETHUSDT.BINANCE",
fast_period=10,
slow_period=20,
trade_size="0.1",
)
]
)
engine = BacktestEngine(config=config)
# 1. Configure venue and account
engine.add_venue(
venue="BINANCE",
oms_type=OmsType.NETTING,
account_type=AccountType.MARGIN,
starting_balances=[Money(10_000, Currency.from_str("USDT"))],
)
# 2. Load data (from the previously generated DataCatalog)
engine.add_data_from_catalog("./catalog")
# 3. Execute backtest
engine.run()
# 4. Output results
engine.trader.generate_account_report("BINANCE")
print("Backtest execution completed!")
# 5. Generate tearsheet (Optional, requires quantstats)
# Displaying simplified results here
portfolio = engine.trader.portfolio
balance = portfolio.account('BINANCE').balance_total(Currency.from_str('USDT'))
print(f"Final Equity: {balance}")
Exercises
1. Modify the execution script to incorporate a FeeModel (Maker: 0.02%, Taker:
0.04%).
2. Enhance the strategy to include a stop-loss mechanism (e.g., trigger a market order to flatten the position if the mark-to-market drawdown exceeds 2%).
3. Expand the trading universe: Ingest BTCUSDT data and modify the backtest configuration to trade both ETH and BTC concurrently.