Phase 5: Hands-on Labs

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:

  1. Implement best practices for project structuring.
  2. Ingest raw CSV data and serialize it into Parquet format.
  3. Configure DataCatalog for efficient data management.
  4. Implement the EMA crossover trading logic.
  5. Configure the BacktestEngine and execute a backtest.
  6. Generate tearsheets for performance analysis.
🛠️ Prerequisites

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

📝 Challenge Tasks

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.