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
2. Wrangler — The Data Format Converter
❌ 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 Class | Required DataFrame Schema | Target Output Type | Algorithmic Context |
|---|---|---|---|
QuoteTickDataWrangler | bid, ask, bid_size, ask_size | QuoteTick | Best Bid and Offer (BBO) quotes (FX, Crypto) |
TradeTickDataWrangler | price, quantity, aggressor_side | TradeTick | Tick-by-tick public trades |
BarDataWrangler | open, high, low, close, volume | Bar | OHLCV bar data (Most common) |
OrderBookDeltaDataWrangler | action, side, price, size | OrderBookDelta | Level 2 order book increments (HFT) |
3. DataCatalog — The Data Warehouse
❌ 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
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 fileQuerying 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
❌ 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 Metric | BarType Keyword Format | Definition | Optimal Strategy Archetype |
|---|---|---|---|
| Time Aggregation | 15-MINUTE | One bar per 15 minutes | Trend Following Strategies |
| Volume Aggregation | 1000-VOLUME | One bar per 1000 volume units | Volume Breakout Strategies |
| Tick Aggregation | 500-TICK | One bar per 500 executions | High-Frequency Trading (HFT) |
| Notional Value Aggregation | 1000000-VALUE | One bar per 1M notional value | Derivatives 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:
| Format | Primary Use Case | Speed | Description |
|---|---|---|---|
| Apache Arrow / Parquet | Data Persistence (DataCatalog) | ⚡⚡⚡ | Columnar storage, extreme compression ratio, ultra-fast analytical reads. |
| MsgPack | Network Transport, Redis Cache | ⚡⚡ | Binary JSON representation, minimal payload footprint. |
| JSON | Configurations, Logging | ⚡ | Human-readable, facilitates debugging. |
❌ 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
Given the following three data payloads, which Wrangler should be instantiated for each?
- 1. Top-of-book data from Binance containing
bid, ask, bid_size, ask_size. - 2. Historical OHLCV bar data from TradingView.
- 3. Public execution feed from an exchange containing
price, quantity, side.
QuoteTickDataWrangler
→ Existence ofbid/askimplies BBO quote data.BarDataWrangler
→ Existence ofOHLCVdictates standard bar mapping.TradeTickDataWrangler
→ Existence ofprice/quantity/siderepresents tick-by-tick trade executions.
💡 Tip: Inspect the DataFrame column headers to determine the appropriate Wrangler classification.
Interpret the configuration embedded within the following BarType strings:
ETH-USDT.BINANCE-5-MINUTE-LAST-EXTERNALBTC-USD.COINBASE-1000-VOLUME-LAST-INTERNALEUR-USD.SIM-1-HOUR-BID-INTERNAL
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)
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)
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)
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.
- Step 1: Read CSV via pandas
df = pd.read_csv("btc_1m.csv") - Step 2: Normalize to
Barobjects viaBarDataWranglerwrangler = BarDataWrangler(bar_type=..., instrument=...) bars = wrangler.process(df) - Step 3: Persist to
DataCatalog(One-time operation)catalog = ParquetDataCatalog("./my_catalog") catalog.write_data(bars) - Step 4: Subscribe to the target 15-minute timeframe within the strategy’s
on_start()hookbar_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
- Wrangler: Transforms generic pandas DataFrames into optimized, Rust-native data structs required by the execution engine.
- DataCatalog: Serializes and persists data via Parquet, yielding 10-50x read throughput improvements over raw CSV parsing.
- BarAggregator: Autonomously synthesizes higher-timeframe bars from granular primitives across temporal, volume, tick, or notional parameters.
- Custom Data: Facilitates deterministic injection of alternative datasets (e.g., sentiment, news) into the event-driven loop via dataclasses.
- Serialization Formats: Parquet for disk persistence, MsgPack for high-throughput network transport, and JSON for configuration and debugging.
- INTERNAL vs EXTERNAL: INTERNAL denotes aggregation executed directly by the matching engine; EXTERNAL flags pre-aggregated arrays provided by third-party feeds.