Pandas and Polars¶
The hard part of market data is not the numbers — you handled those in NumPy and Vectorization. The hard part is the labels. A raw array does not know that 09:31 New York follows 09:30, that the market was closed for Thanksgiving, or that your two data vendors disagree about whether a bar's timestamp marks its open or its close. Misaligned labels are how lookahead sneaks into backtests, and lookahead is the most common way a beautiful research result dies on contact with live trading.
Pandas puts an index on top of NumPy's arrays and makes alignment the default behavior; Polars rebuilds the same idea on a modern parallel engine with a query optimizer. This lesson covers both — pandas as the daily driver, Polars for the workloads where pandas runs out of road.
DataFrames as labeled arrays¶
A Series is an ndarray plus an index; a DataFrame is a dictionary of Series sharing one index. Everything from the previous lesson — dtypes, views, vectorized arithmetic — still applies, but operations now align on labels first. Here is the canonical object of this course: one trading day of one-minute OHLCV bars, built from the same seeded generator as before.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
idx = pd.date_range("2024-01-02 09:30", periods=390, freq="min",
tz="America/New_York")
close = 100.0 * np.exp(np.cumsum(rng.normal(0, 4e-4, 390)))
open_ = np.concatenate(([100.0], close[:-1]))
high = np.maximum(open_, close) + rng.uniform(0, 0.03, 390)
low = np.minimum(open_, close) - rng.uniform(0, 0.03, 390)
df = pd.DataFrame({"open": open_, "high": high, "low": low, "close": close,
"volume": rng.integers(1_000, 50_000, 390)}, index=idx)
print(df.head(3).round(2))
# open high low close volume
# 2024-01-02 09:30:00-05:00 100.00 100.04 100.00 100.01 8951
# 2024-01-02 09:31:00-05:00 100.01 100.04 99.96 99.97 9591
# 2024-01-02 09:32:00-05:00 99.97 100.01 99.97 100.00 3152
Note that the frame is built in one shot from complete arrays — not row by row, which is the DataFrame equivalent of the Python loop you just learned to avoid. Dtypes deserve the same attention they got in NumPy, and the one addition that matters for market data is category: symbols, exchanges, and sector codes repeat endlessly, and storing each occurrence as a string wastes memory that a small integer code would not.
import numpy as np
import pandas as pd
sym = pd.Series(np.repeat(["AAA", "BBB", "CCC"], 130))
print(sym.memory_usage(deep=True)) # => 20412
print(sym.astype("category").memory_usage(deep=True)) # => 678
Thirty-to-one on a toy Series; on a universe of millions of rows the difference is whether the frame fits in memory at all.
Time on the index¶
A DatetimeIndex is what turns a DataFrame into a time series, and the first decision it forces is timezones. The house rule for the rest of this course: store timestamps timezone-aware, work in the exchange's timezone, convert to UTC at system boundaries. The two operations people confuse are tz_localize — attach a zone to naive timestamps that were always implicitly in that zone — and tz_convert, which re-expresses an aware timestamp in another zone without changing the instant it names.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
idx = pd.date_range("2024-01-02 09:30", periods=390, freq="min",
tz="America/New_York")
close = pd.Series(100.0 * np.exp(np.cumsum(rng.normal(0, 4e-4, 390))),
index=idx, name="close")
print(close.loc["2024-01-02 10"].shape) # => (60,) — partial strings slice
print(close.between_time("09:30", "09:32").round(2))
# 2024-01-02 09:30:00-05:00 100.01
# 2024-01-02 09:31:00-05:00 99.97
# 2024-01-02 09:32:00-05:00 100.00
# Freq: min, Name: close, dtype: float64
print(close.index[0].tz_convert("UTC")) # => 2024-01-02 14:30:00+00:00
naive = pd.Timestamp("2024-01-02 09:30")
print(naive.tz_localize("America/New_York")) # => 2024-01-02 09:30:00-05:00
Partial-string indexing — close.loc["2024-01-02 10"] returning the whole ten o'clock hour — and between_time for session slicing are the idioms you will use daily. The payoff for keeping the index aware is that daylight-saving transitions, half days, and cross-market joins stop being sources of silent one-hour errors and become things the library refuses to get wrong on your behalf.
Resampling to bars¶
Downsampling one-minute bars to five-minute bars is not one aggregation but five different ones, and getting any of them wrong produces bars that look plausible and are subtly fictional:
| Column | Rule |
|---|---|
open |
first |
high |
max |
low |
min |
close |
last |
volume |
sum |
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
idx = pd.date_range("2024-01-02 09:30", periods=390, freq="min",
tz="America/New_York")
close = 100.0 * np.exp(np.cumsum(rng.normal(0, 4e-4, 390)))
open_ = np.concatenate(([100.0], close[:-1]))
high = np.maximum(open_, close) + rng.uniform(0, 0.03, 390)
low = np.minimum(open_, close) - rng.uniform(0, 0.03, 390)
df = pd.DataFrame({"open": open_, "high": high, "low": low, "close": close,
"volume": rng.integers(1_000, 50_000, 390)}, index=idx)
bars5 = df.resample("5min", label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"})
print(bars5.head(3).round(2))
# open high low close volume
# 2024-01-02 09:30:00-05:00 100.00 100.06 99.94 99.96 67021
# 2024-01-02 09:35:00-05:00 99.96 99.99 99.85 99.87 155867
# 2024-01-02 09:40:00-05:00 99.87 100.00 99.86 100.00 161183
label and closed decide which boundary names the bar and which side of the interval it owns — here the 09:30 bar covers 09:30:00 through 09:34:59. That convention is not cosmetic: it must match your execution assumption. If your strategy "trades on the 09:35 bar," you need to know whether the information in that bar was complete at 09:35:00 or only at 09:39:59, because the difference is five minutes of the future.
Frequency aliases changed in pandas 2.2
The old offset aliases "T", "H", and "M" are deprecated in favor of "min", "h", and "ME". Code you find in older books and answers will use the old spellings; write the new ones.
Rolling windows without lookahead¶
Rolling statistics are the workhorse of signal construction, and rolling gets the mechanics right by default: the window ends at the current row and looks only backward. The two ways to break that guarantee are center=True, which openly centers the window on the future, and the subtler one — using the statistic computed through bar \(t\) to make a decision at bar \(t\), when in live trading it is only available after the bar closes. The fix is one shift.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
close = pd.Series(
100.0 * np.exp(np.cumsum(rng.normal(0, 4e-4, 390))),
index=pd.date_range("2024-01-02 09:30", periods=390, freq="min",
tz="America/New_York"), name="close")
mean20 = close.rolling(20, min_periods=20).mean()
std20 = close.rolling(20, min_periods=20).std()
z = (close - mean20.shift(1)) / std20.shift(1) # stats known BEFORE bar t
print(mean20.isna().sum()) # => 19 — no statistic until a full window
print(z.iloc[20:23].round(2))
# 2024-01-02 09:50:00-05:00 0.31
# 2024-01-02 09:51:00-05:00 -0.26
# 2024-01-02 09:52:00-05:00 0.92
# Freq: min, Name: close, dtype: float64
The z-score here is \(z_t = (P_t - \mu_{t-1}) / \sigma_{t-1}\): the price at \(t\) measured against a mean and standard deviation that were fully computable one bar earlier. Insist on min_periods equal to the window — a "20-bar average" computed from 3 bars is a different, noisier statistic wearing the same name — and let the leading NaNs stand; they are the honest statement that no valid window exists yet. What these rolling moments do and do not summarize is treated properly in the appendix's Descriptive Statistics.
Joins, merges, and as-of alignment¶
Ordinary joins answer "same key, same row." Market data usually needs a different question: what was the latest quote as of this trade? Timestamps from two feeds almost never match exactly, so an exact-key join returns nearly nothing, and an approximate one — merge_asof — is the correct tool. It matches each left row to the most recent right row at or before it, optionally bounded by a tolerance.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
quotes = pd.DataFrame({
"ts": pd.date_range("2024-01-02 09:30:00", periods=120, freq="s"),
"bid": (100.0 + np.cumsum(rng.normal(0, 0.005, 120))).round(3),
})
quotes["ask"] = quotes["bid"] + 0.02
trades = pd.DataFrame({
"ts": pd.to_datetime(["2024-01-02 09:29:59.100",
"2024-01-02 09:30:42.200",
"2024-01-02 09:31:33.900"]),
"px": [100.01, 99.98, 100.05],
})
merged = pd.merge_asof(trades, quotes, on="ts", direction="backward",
tolerance=pd.Timedelta("5s"))
print(merged)
# ts px bid ask
# 0 2024-01-02 09:29:59.100 100.01 NaN NaN
# 1 2024-01-02 09:30:42.200 99.98 100.011 100.031
# 2 2024-01-02 09:31:33.900 100.05 100.005 100.025
print(merged["bid"].isna().sum()) # => 1 — no quote existed yet: honest NaN
direction="backward" is the only choice that never looks into the future, which makes it the default for anything feeding a backtest; the first trade, printed before any quote existed, correctly gets NaN rather than a quote from one second later. The same pattern joins slow-moving reference data — shares outstanding, index membership, fundamentals — onto prices. And regardless of which join you use, adopt the verification habit: after every merge, check the row count against what you expected and count the nulls in the joined columns. An outer join that silently doubled your rows, or an inner join that silently dropped a third of them, is telling you something about your data that you want to hear now, not after the backtest. Where those quotes come from and why the bid–ask spread exists at all is Market Microstructure territory.
Polars and the lazy frame¶
Polars keeps the DataFrame idea but changes the execution model. You describe a computation as a chain of expressions on a LazyFrame; nothing runs until collect(), at which point a query optimizer reorders and prunes the plan — pushing filters down toward the data, reading only the columns the query touches, and running the rest on all cores. explain() shows you the optimized plan before you pay for it.
import numpy as np
import polars as pl
rng = np.random.default_rng(42)
ts = pl.datetime_range(
pl.datetime(2024, 1, 2, 9, 30), pl.datetime(2024, 1, 2, 15, 59),
interval="1m", time_zone="America/New_York", eager=True)
lf = (
pl.LazyFrame({"ts": ts,
"price": 100.0 * np.exp(np.cumsum(rng.normal(0, 4e-4, 390))),
"volume": rng.integers(1_000, 50_000, 390)})
.with_columns(pl.col("ts").set_sorted())
.filter(pl.col("ts").dt.hour() < 11)
.group_by_dynamic("ts", every="5m")
.agg(open=pl.col("price").first(), high=pl.col("price").max(),
low=pl.col("price").min(), close=pl.col("price").last(),
volume=pl.col("volume").sum())
)
print(lf.explain())
# AGGREGATE[maintain_order: true]
# [col("price").first().alias("open"), … ] BY []
# FROM
# …
# FILTER (col("ts").dt.hour() < 11)
# FROM
# DF ["ts", "price", "volume"]; PROJECT */3 COLUMNS
out = lf.collect()
print(out.select("ts", "open", "close", "volume").head(3))
# shape: (3, 4)
# ┌────────────────────────────────┬────────────┬───────────┬────────┐
# │ ts ┆ open ┆ close ┆ volume │
# │ --- ┆ --- ┆ --- ┆ --- │
# │ datetime[μs, America/New_York] ┆ f64 ┆ f64 ┆ i64 │
# ╞════════════════════════════════╪════════════╪═══════════╪════════╡
# │ 2024-01-02 09:30:00 EST ┆ 100.012189 ┆ 99.960196 ┆ 151793 │
# │ 2024-01-02 09:35:00 EST ┆ 99.908144 ┆ 99.865862 ┆ 105370 │
# │ 2024-01-02 09:40:00 EST ┆ 99.900996 ┆ 99.99849 ┆ 127058 │
# └────────────────────────────────┴────────────┴───────────┴────────┘
The pipeline is the same one you just built in pandas — filter, five-minute bars, OHLCV aggregation — expressed as a plan instead of a sequence of materialized intermediates. On one day of bars the difference is invisible; on a few years of tick files it is the difference between a scan that reads 40 GB and one that reads the two columns and three months you asked about. The scan_parquet entry point that makes that concrete appears in SQL and Data Storage.
When does each engine win? Honest benchmarks are workload-dependent, but the pattern is stable:
| Workload | Better fit |
|---|---|
| Interactive exploration, small-to-medium data | pandas — richest ecosystem, every tutorial speaks it |
| Scans over files bigger than memory | Polars — lazy execution, predicate and projection pushdown |
| Wide group-bys and joins on many cores | Polars — parallel engine, no interpreter bottleneck |
| Feeding statsmodels, scikit-learn, plotting | pandas — those libraries consume it natively |
Migration is not a rewrite decision. The practical pattern is to keep pandas as the interactive lingua franca, move the heavy loading and aggregation stages to Polars as data grows, and cross the boundary explicitly with pl.from_pandas and .to_pandas() — each conversion is a copy, so make the crossing once per pipeline stage, not per function call.
Key takeaways
- A DataFrame is NumPy plus labels, and label alignment — not positional arithmetic — is the default; build frames from whole arrays, and use
categorydtype for repeating symbols. - Keep timestamps timezone-aware:
tz_localizeattaches a zone to naive stamps,tz_convertre-expresses an instant; store aware, think in exchange time. - Resampling OHLCV is five aggregations (
first/max/min/last/sum), and thelabel/closedconvention must match your execution assumption or you are trading on the future. - Rolling statistics look backward by default; keep them honest with
min_periodsequal to the window and ashift(1)between computing a statistic and acting on it. merge_asofwithdirection="backward"is the join that respects time; verify every merge by checking row counts and null counts.- Polars expresses the same pipelines as lazy query plans that an optimizer prunes and parallelizes — adopt it at the loading/aggregation layer first and convert at explicit boundaries.
Where this goes next¶
You can now compute almost anything about a price series — which raises the question of what the code computing it should look like once it is more than a notebook cell. Typing, Dataclasses, and Code Structure is about making research code survive contact with a second reader, including the one you will be in six months.