Backtrader with Parquet market data
backtrader wants one DataFrame per instrument with a datetime index and open/high/low/close/volume columns. MarketParquet ships one Parquet file per trading day with every symbol inside. This guide is the bridge: extract one symbol, shape it, feed it, run a strategy, on daily and on 5-minute bars.
What the files look like
Files live under by_date/{asset}_{timeframe}/YYYY/YYYY-MM-DD.parquet.
Intraday files carry timestamp (US/Eastern bar open time), symbol,
asset_type, open, high, low, close, volume.
Daily files carry date instead of timestamp; daily files dated before
2026-03-27 still use the older timestamp name, so a multi-year daily read must
unify the two. Stock and ETF prices are split-adjusted; dividends are not embedded, which is
what you want for fills at the prices that actually traded.
Step 1: extract one symbol from daily files
DuckDB does the cross-file filter in one statement and returns a pandas DataFrame:
import duckdb
def load_daily(symbol, root="by_date/stock_daily"):
return duckdb.sql(f"""
SELECT COALESCE(date, CAST(timestamp AS DATE)) AS datetime,
open, high, low, close, volume
FROM read_parquet('{root}/*/*.parquet', union_by_name=true)
WHERE symbol = '{symbol}'
ORDER BY 1
""").df().set_index("datetime")
aapl = load_daily("AAPL")
print(aapl.tail())
union_by_name=true lets files with date and files with
timestamp sit in one scan; COALESCE picks whichever is present.
Reading a full 26-year history for one ticker takes seconds because DuckDB only
touches the columns it needs and skips row groups whose symbol range cannot match.
Step 2: feed it to backtrader
import backtrader as bt
class SmaCross(bt.Strategy):
params = dict(fast=20, slow=50)
def __init__(self):
fast = bt.ind.SMA(period=self.p.fast)
slow = bt.ind.SMA(period=self.p.slow)
self.cross = bt.ind.CrossOver(fast, slow)
def next(self):
if not self.position and self.cross > 0:
self.buy()
elif self.position and self.cross < 0:
self.close()
cerebro = bt.Cerebro()
cerebro.adddata(bt.feeds.PandasData(dataname=aapl, openinterest=None), name="AAPL")
cerebro.addstrategy(SmaCross)
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.0005)
result = cerebro.run()
print(f"final value: {cerebro.broker.getvalue():,.0f}")
PandasData maps columns by name, so the DataFrame needs exactly
open, high, low, close, volume and a datetime index. Passing
openinterest=None tells the feed the column does not exist.
Step 3: intraday, 5-minute bars
Intraday files are large, so scan lazily with Polars and filter before collecting. Drop pre-market and after-hours bars unless the strategy trades them, and tell backtrader the bar size:
import polars as pl
bars = (
pl.scan_parquet("by_date/stock_5min/2025/*.parquet")
.filter(pl.col("symbol") == "AAPL")
.select(["timestamp", "open", "high", "low", "close", "volume"])
.sort("timestamp")
.collect()
.to_pandas()
.set_index("timestamp")
)
regular = bars.between_time("09:30", "15:55") # bar open times
feed = bt.feeds.PandasData(
dataname=regular,
timeframe=bt.TimeFrame.Minutes,
compression=5,
openinterest=None,
)
cerebro = bt.Cerebro()
cerebro.adddata(feed, name="AAPL-5m")
cerebro.addstrategy(SmaCross, fast=12, slow=26)
cerebro.run()
Timestamps are bar open times in US/Eastern, so the last regular bar of a
5-minute file opens at 15:55. A 1-minute feed is the same code with
stock_1min and compression=1; expect roughly ten times the rows.
Step 4: more than one symbol
backtrader handles multiple feeds natively; add one PandasData per symbol
and index them in the strategy with self.datas. Extract them in a single
DuckDB query with WHERE symbol IN (...) and split the result with
groupby("symbol") rather than scanning the files once per ticker.
frame = duckdb.sql("""
SELECT symbol, COALESCE(date, CAST(timestamp AS DATE)) AS datetime,
open, high, low, close, volume
FROM read_parquet('by_date/stock_daily/*/*.parquet', union_by_name=true)
WHERE symbol IN ('AAPL', 'MSFT', 'NVDA')
ORDER BY 2
""").df()
for symbol, df in frame.groupby("symbol"):
feed = bt.feeds.PandasData(dataname=df.set_index("datetime").drop(columns="symbol"),
openinterest=None)
cerebro.adddata(feed, name=symbol)
Pitfalls
- Survivorship: the archive includes delisted tickers, so a universe built from today's index members is your choice, not a data limitation. Build the universe from the files for the backtest's start date.
- Session gaps: thin names skip days. backtrader tolerates that within a single feed; multi-feed strategies should align on the calendar of the most liquid feed.
- Adjusted vs traded prices: bars are split-adjusted only. Stops and limits execute at real traded levels; total return needs dividends added separately.
- Timezone: feed timestamps are naive US/Eastern. Do not localize to UTC before feeding, or session filters shift by four or five hours.
Related
vectorbt with Parquet data · DuckDB guide · Polars guide · AAPL data page · intraday stock data · pricing