vectorbt with Parquet market data
vectorbt runs a backtest as array arithmetic: one wide matrix of closes, one matrix of signals, one portfolio. MarketParquet's per-day files are the opposite shape, one file per date with every symbol inside. This guide turns the files into the matrix and runs a portfolio on it.
Step 1: a long table of closes
Pull the symbols you want from every daily file in one DuckDB query. The
COALESCE handles daily files from before 2026-03-27, which name the
date column timestamp:
import duckdb
universe = ["SPY", "QQQ", "IWM", "TLT", "GLD"]
quoted = ", ".join(f"'{s}'" for s in universe)
long = duckdb.sql(f"""
SELECT symbol,
COALESCE(date, CAST(timestamp AS DATE)) AS date,
close
FROM read_parquet('by_date/etf_daily/*/*.parquet', union_by_name=true)
WHERE symbol IN ({quoted})
ORDER BY 2, 1
""").df()
Step 2: pivot to the wide matrix
import pandas as pd
close = (long.pivot(index="date", columns="symbol", values="close")
.sort_index())
close.index = pd.to_datetime(close.index)
close = close.dropna(how="all")
print(close.shape) # (trading days, symbols)
Missing cells are real: an ETF that did not exist yet, or a stock that skipped a session. Leave them as NaN; vectorbt treats NaN closes as "not tradable that day". Forward-filling would invent prices and fills.
Step 3: signals and portfolio
import vectorbt as vbt
fast = vbt.MA.run(close, 20)
slow = vbt.MA.run(close, 50)
entries = fast.ma_crossed_above(slow)
exits = fast.ma_crossed_below(slow)
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=100_000,
fees=0.0005,
freq="1D",
)
print(pf.total_return())
print(pf.stats())
Every column of close becomes an independent backtest; pf.total_return()
is a Series indexed by symbol. For a single combined portfolio, pass
group_by=True and cash_sharing=True to from_signals.
Step 4: scale to the whole market
The same query with no WHERE symbol IN clause returns every ticker,
delisted names included. A 26-year daily matrix for all US stocks is roughly
6,800 rows by 23,000 columns of floats, about 1.2 GB in memory. Two habits keep it
practical:
- Filter the date range in SQL (
WHERE date >= '2015-01-01') before pivoting; DuckDB prunes files by their date column statistics. - Drop columns with fewer than, say, 250 observations before running indicators. Names that traded for a month add columns but no signal.
long = duckdb.sql("""
SELECT symbol, COALESCE(date, CAST(timestamp AS DATE)) AS date, close
FROM read_parquet('by_date/stock_daily/*/*.parquet', union_by_name=true)
WHERE COALESCE(date, CAST(timestamp AS DATE)) >= DATE '2015-01-01'
""").df()
close = long.pivot(index="date", columns="symbol", values="close")
close = close.loc[:, close.count() >= 250]
Intraday with vectorbt
For 5-minute or 1-minute bars, build the matrix from stock_5min files
the same way, using timestamp as the index and freq="5T" in
from_signals. Restrict to the regular session first
(between_time("09:30", "15:55") on bar open times), or the overnight
gaps distort volatility-based indicators.
Pitfalls
- Splits: closes are split-adjusted, so a 4-for-1 split is not a 75% drop. Dividends are not embedded; add them if the strategy's edge is total return.
- Survivorship: the matrix includes tickers that later delisted. That is the point; do not filter them out.
- Look-ahead in universe selection: choose the columns from data available at the backtest start, not from today's index membership.
Related
backtrader with Parquet data · DuckDB guide · SPY data page · delisted stock data · pricing