Phase 2: Core Components

Module 05B: Data Management and PersistenceFrom Raw CSV to Native Data Structures

NautilusTrader expects market data in well-defined native types before you can run reliable backtests. This module walks through the end-to-end pipeline for loading, transforming, and storing high-fidelity data.

1. Global Data Pipeline Architecture

From flat files to the backtesting engine, data propagates through the following stages:

flowchart LR
                        subgraph source["📁 Data Sources"]
                        direction TB
                        csv["CSV Files"]
                        api["Exchange API"]
                        vendor["Data Vendors<br />(Databento/Tardis)"]
                        end

                        subgraph transform["🔄 Data Transformation"]
                        direction TB
                        wrangler["Wrangler<br />Data Normalizer"]
                        loader["DataLoader<br />Data Loader"]
                        end

                        subgraph storage["💾 Data Storage"]
                        direction TB
                        catalog["DataCatalog<br />(Parquet Format)"]
                        memory["In-Memory Load"]
                        end

                        subgraph engine["⚙️ Backtest Engine"]
                        direction TB
                        backtest["BacktestEngine"]
                        end

                        source ==> transform ==> storage ==> engine
Figure 1: End-to-end data pipeline from source to backtest engine

2. Wrangler — The Data Format Converter

💡 Why is a Wrangler required?

❌ Raw pandas DataFrames: NautilusTrader does not process native pandas DataFrames. It requires optimized Rust structs (QuoteTick, TradeTick, Bar), enabling execution throughput up to 100x faster than native Python.

✅ Wranglers: Wranglers parse and map pandas DataFrames into NautilusTrader’s native, memory-safe data objects with a single invocation.

Transforming CSV to Core Bar Objects

"""
Loading historical CSV data into NautilusTrader native objects.
"""
import pandas as pd
from nautilus_trader.persistence.wranglers import BarDataWrangler
from nautilus_trader.model.data import BarType
from nautilus_trader.model.identifiers import InstrumentId

# 1. Read CSV using pandas
df = pd.read_csv("my_btc_data.csv")
# Expected columns: timestamp, open, high, low, close, volume

# 2. Ensure timestamp column is datetime indexed
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp")

# 3. Define the BarType (instrument, timeframe, price type, origin)
bar_type = BarType.from_str("BTC-USDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL")
#                             Instrument             Timeframe PriceType Origin

# 4. Process using Wrangler
wrangler = BarDataWrangler(bar_type=bar_type, instrument=instrument)
bars = wrangler.process(df)  # Returns list[Bar]

print(f"✅ Loaded {len(bars)} bars")
print(f"First bar: {bars[0]}")
print(f"Last bar: {bars[-1]}")

Wrangler Types

Wrangler ClassRequired DataFrame SchemaTarget Output TypeAlgorithmic Context
QuoteTickDataWranglerbid, ask, bid_size, ask_sizeQuoteTickBest Bid and Offer (BBO) quotes (FX, Crypto)
TradeTickDataWranglerprice, quantity, aggressor_sideTradeTickTick-by-tick public trades
BarDataWrangleropen, high, low, close, volumeBarOHLCV bar data (Most common)
OrderBookDeltaDataWrangleraction, side, price, sizeOrderBookDeltaLevel 2 order book increments (HFT)

3. DataCatalog — The Data Warehouse

💡 Why use DataCatalog?

❌ Repeated CSV parsing: CSV parsing incurs expensive string-to-float decoding overhead. Loading 1 million bars can exceed 30 seconds and requires repetitive Wrangler conversions for each backtest pass.

✅ Parquet Persistence: DataCatalog serializes data to Parquet (a highly compressed, binary columnar format), accelerating I/O reads by 10-50x compared to CSV. The write-once, read-many pattern avoids repeated conversions.

flowchart LR
                        subgraph write["✍️ Write (One-time)"]
                        direction TB
                        w1["CSV → Wrangler → Bars"]
                        w2["catalog.write_data(bars)"]
                        w1 --> w2
                        end

                        subgraph read["📖 Read (Per Backtest)"]
                        direction TB
                        r1["catalog.bars()"]
                        r2["Returns list of objects"]
                        r1 --> r2
                        end

                        subgraph format["📦 Parquet Format"]
                        direction TB
                        f1["Binary Columnar Storage"]
                        f2["5-10x Compression"]
                        f3["Supports Time-range Queries"]
                        end

                        write --> format --> read

                        style format fill:#0f1419,stroke:#4fc3f7,stroke-width:2px
Figure 2: DataCatalog Write/Read Operations

Persisting Wrangler Output into the Catalog

"""
Persisting data to DataCatalog (One-time operation)
"""
from nautilus_trader.persistence.catalog import ParquetDataCatalog

# 1. Define Catalog storage directory
catalog = ParquetDataCatalog("./my_catalog")

# 2. Write data (bars processed from Wrangler)
catalog.write_data(bars)
print(f"✅ Successfully written {len(bars)} bars to Catalog")

# Directory structure generated:
# ./my_catalog/
# └── bar/
#     └── BTC-USDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL/
#         └── part-0.parquet      ← Binary data file

Querying Data from the Catalog

"""
Reading data from DataCatalog (Executed per backtest run)
"""
from nautilus_trader.persistence.catalog import ParquetDataCatalog

catalog = ParquetDataCatalog("./my_catalog")

# Retrieve all bars
bars = catalog.bars()

# Query by time range (e.g., only January 2024 data)
from datetime import datetime
bars_jan = catalog.bars(
    start=datetime(2024, 1, 1),
    end=datetime(2024, 2, 1),
)

# Query by specific instrument
bars_btc = catalog.bars(
    bar_types=["BTC-USDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL"],
)

print(f"📊 Retrieved {len(bars)} bars in milliseconds")

4. Bar Aggregation — Custom Timeframes

💡 Why is Bar Aggregation necessary?

❌ Relying solely on source timeframes: If your data source provides 1-minute bars but your strategy requires 15-minute or 4-hour bars, implementing custom resampling logic in pandas is prone to look-ahead bias and errors.

✅ BarAggregator: NautilusTrader natively embeds a BarAggregator to autonomously construct custom higher-timeframe bars from lower-resolution data. It supports time-, volume-, tick-, and notional value-based aggregation.

"""
Subscribing to custom timeframe bars within a strategy
"""
class MyStrategy(Strategy):
    def on_start(self):
        # Subscribe to 15-minute bars (Engine aggregates autonomously from 1-minute bars)
        bar_type_15m = BarType.from_str(
            "BTC-USDT-PERP.BINANCE-15-MINUTE-LAST-INTERNAL"
            #                                            ^^^^^^^^
            # INTERNAL = Aggregated internally by NautilusTrader
            # EXTERNAL = Provided by an external data source
        )
        self.subscribe_bars(bar_type_15m)

        # You can even subscribe to volume-aggregated bars!
        bar_type_volume = BarType.from_str(
            "BTC-USDT-PERP.BINANCE-1000-VOLUME-LAST-INTERNAL"
            # Generates one bar per 1,000 units of accumulated volume
        )
        self.subscribe_bars(bar_type_volume)

    def on_bar(self, bar: Bar):
        # Triggered when the aggregated bar is generated
        self.log.info(f"Received bar: {bar}")

Bar Aggregation Types

Aggregation MetricBarType Keyword FormatDefinitionOptimal Strategy Archetype
Time Aggregation15-MINUTEOne bar per 15 minutesTrend Following Strategies
Volume Aggregation1000-VOLUMEOne bar per 1000 volume unitsVolume Breakout Strategies
Tick Aggregation500-TICKOne bar per 500 executionsHigh-Frequency Trading (HFT)
Notional Value Aggregation1000000-VALUEOne bar per 1M notional valueDerivatives Strategies

5. Custom Data Types

If you use alternative data (e.g., sentiment indices, macroeconomic events), you can define custom data types to integrate seamlessly into NautilusTrader:

"""
Define a custom data type: Market Sentiment Index
"""
from nautilus_trader.model.custom import customdataclass

# Define custom data using the @customdataclass decorator
@customdataclass
class SentimentData:
    instrument_id: str   # Target instrument ID
    score: float         # Sentiment score (-1.0 to 1.0)
    source: str          # Data source (Twitter, Reddit, etc.)

# Subscribe to and use inside a strategy
class MyStrategy(Strategy):
    def on_start(self):
        # Subscribe to custom data
        self.subscribe_data(
            data_type=SentimentData,
            metadata={"instrument_id": "BTC-USDT-PERP.BINANCE"},
        )

    def on_data(self, data):
        # Process custom data updates
        if isinstance(data, SentimentData):
            if data.score > 0.8:
                self.log.info("🟢 Extreme bullish sentiment detected!")
            elif data.score < -0.8:
                self.log.info("🔴 Extreme bearish sentiment detected!")

6. Serialization Formats

NautilusTrader supports multiple serialization formats optimized for specific architectural requirements:

FormatPrimary Use CaseSpeedDescription
Apache Arrow / ParquetData Persistence (DataCatalog)⚡⚡⚡Columnar storage, extreme compression ratio, ultra-fast analytical reads.
MsgPackNetwork Transport, Redis Cache⚡⚡Binary JSON representation, minimal payload footprint.
JSONConfigurations, Logging⚡Human-readable, facilitates debugging.
⚠️ Why Avoid JSON for Market Data Storage?

❌ Storing 1M bars in JSON: Results in a ~500MB payload, requiring over 30 seconds for disk reads and deserialization.
✅ Persisting 1M bars in Parquet: Condenses the footprint to ~50MB (10x compression) and achieves full retrieval in 1-2 seconds.

7. Interactive Exercises

🧠 Exercise 1: Selecting the Appropriate Wrangler

Given the following three data payloads, which Wrangler should be instantiated for each?

  1. 1. Top-of-book data from Binance containing bid, ask, bid_size, ask_size.
  2. 2. Historical OHLCV bar data from TradingView.
  3. 3. Public execution feed from an exchange containing price, quantity, side.
  1. QuoteTickDataWrangler
    → Existence of bid/ask implies BBO quote data.
  2. BarDataWrangler
    → Existence of OHLCV dictates standard bar mapping.
  3. TradeTickDataWrangler
    → Existence of price/quantity/side represents tick-by-tick trade executions.

💡 Tip: Inspect the DataFrame column headers to determine the appropriate Wrangler classification.

🧠 Exercise 2: Decoding BarType Strings

Interpret the configuration embedded within the following BarType strings:

  1. ETH-USDT.BINANCE-5-MINUTE-LAST-EXTERNAL
  2. BTC-USD.COINBASE-1000-VOLUME-LAST-INTERNAL
  3. EUR-USD.SIM-1-HOUR-BID-INTERNAL
  1. ETH-USDT.BINANCE-5-MINUTE-LAST-EXTERNAL
    • Instrument: ETH-USDT at Binance
    • Timeframe: 5-Minute
    • Price Type: LAST (Last traded price)
    • Origin: EXTERNAL (Pre-aggregated by an external data source)
  2. BTC-USD.COINBASE-1000-VOLUME-LAST-INTERNAL
    • Instrument: BTC-USD at Coinbase
    • Aggregation: One bar per 1,000 traded volume units
    • Price Type: LAST
    • Origin: INTERNAL (Synthesized natively by NautilusTrader from underlying ticks)
  3. EUR-USD.SIM-1-HOUR-BID-INTERNAL
    • Instrument: EUR-USD in the Mock Simulator
    • Timeframe: 1-Hour
    • Price Type: BID (uses the best bid price)
    • Origin: INTERNAL (Engine aggregated)
🧠 Exercise 3: Designing the Data Pipeline

You possess a CSV file containing 1 year of 1-minute BTC historical bars (~520,000 rows). Your strategy logic dictates a 15-minute resolution. Describe the end-to-end data processing protocol.

  1. Step 1: Read CSV via pandas
    df = pd.read_csv("btc_1m.csv")
  2. Step 2: Normalize to Bar objects via BarDataWrangler
    wrangler = BarDataWrangler(bar_type=..., instrument=...)
    bars = wrangler.process(df)
  3. Step 3: Persist to DataCatalog (One-time operation)
    catalog = ParquetDataCatalog("./my_catalog")
    catalog.write_data(bars)
  4. Step 4: Subscribe to the target 15-minute timeframe within the strategy’s on_start() hook
    bar_type_15m = BarType.from_str(
        "BTC-USDT-PERP.BINANCE-15-MINUTE-LAST-INTERNAL"
    )
    self.subscribe_bars(bar_type_15m)

💡 Key Insight: Manual pandas resampling from 1-minute to 15-minute resolution is entirely unnecessary!

NautilusTrader’s BarAggregator autonomously handles the temporal synthesis. You solely need to inject the 1-minute primitives and subscribe to the 15-minute target.

8. Module Highlights

📋 Core Concepts to Retain
  1. Wrangler: Transforms generic pandas DataFrames into optimized, Rust-native data structs required by the execution engine.
  2. DataCatalog: Serializes and persists data via Parquet, yielding 10-50x read throughput improvements over raw CSV parsing.
  3. BarAggregator: Autonomously synthesizes higher-timeframe bars from granular primitives across temporal, volume, tick, or notional parameters.
  4. Custom Data: Facilitates deterministic injection of alternative datasets (e.g., sentiment, news) into the event-driven loop via dataclasses.
  5. Serialization Formats: Parquet for disk persistence, MsgPack for high-throughput network transport, and JSON for configuration and debugging.
  6. INTERNAL vs EXTERNAL: INTERNAL denotes aggregation executed directly by the matching engine; EXTERNAL flags pre-aggregated arrays provided by third-party feeds.