Options-chain CSV ingestion — the same work, six different mechanisms.
src/csv_to_pandas.py · csv_to_pandas()
def filter_dates(df, col, first_date, last_date): first_date = pd.to_datetime(first_date) last_date = pd.to_datetime(last_date) date_col = df[col] mask = date_col.between( first_date, last_date, inclusive='both') filtered_df = df[mask] # the whole file already exists in RAM # by the time this line runs return filtered_df def add_mid(df): df['mid'] = df[['bid','ask']].mean(axis=1) return df # row-wise reduction def add_spread(df): df['spread'] = (df['ask'] - df['bid']) / df['mid'] return df options = ( pd.read_csv( csv_path, parse_dates=['t_date', 'expiration_date'] ) .pipe(filter_dates, 't_date', first_date, last_date) .pipe(add_mid) .pipe(add_spread) )
options = ( pl.scan_csv( csv_path, try_parse_dates=True, schema_overrides={ "strike": pl.Float32, "bid": pl.Float32, "ask": pl.Float32, ... # 9 columns }, ) .filter(pl.col("t_date").is_between(...)) .with_columns(((bid + ask) / 2).alias("mid")) .with_columns(((ask - bid) / mid).alias("spread")) .collect() .to_pandas() )
read_csv is eager: it parses every row of the file into memory,
and only then does filter_dates throw most of them away.
scan_csv returns a LazyFrame. Nothing executes until
.collect(). By that point the query optimizer has pushed the date
predicate down into the reader, so rows outside the backtest window are
discarded as they are parsed and never become part of a DataFrame at all.
The more selective the date window, the bigger this gets. It is the reason the lazy API exists.
pandas' CSV reader is a single-threaded C parser. One core does all the byte scanning, delimiter splitting and numeric conversion, while the rest of the machine idles.
Polars splits the file into byte ranges, parses them concurrently across every available core, and stitches the chunks together. For a large CSV — where parsing is the workload — this scales close to core count.
pandas infers dtypes: sampling values, sometimes promoting a column's type
partway through the file, and defaulting every float to float64.
schema_overrides removes the guessing entirely — Polars is told exactly
what nine columns are before it reads a byte. Declaring them Float32
also halves the memory footprint of the greeks, bid, ask
and strike.
That matters more than it sounds. These operations are memory-bandwidth bound, not compute bound — adding two float arrays is limited by how fast they can be pulled through cache, not by the addition. Half the width is close to twice the throughput.
| Column type | pandas default | declared | bytes/value |
|---|---|---|---|
| bid, ask, strike | float64 | Float32 | 8 → 4 |
| iv, delta, gamma, theta, vega | float64 | Float32 | 8 → 4 |
df[['bid','ask']].mean(axis=1) is the expensive direction in pandas. It
slices out a two-column sub-frame, then reduces across columns — cutting
against the columnar grain, routed through the block manager, carrying NaN-handling
machinery per row.
(bid + ask) / 2 is a straight vectorised operation on two contiguous
arrays.
A pandas DataFrame is a BlockManager: columns sharing a dtype are consolidated
into 2-D numpy blocks. Assigning a new column can force reconsolidation —
reallocating and copying an entire block. The old chain did this twice, for
mid and spread, on the unfiltered frame.
Polars stores each column as an independent Arrow chunked array.
with_columns appends a column; nothing existing is touched, moved or
copied.
In pandas, ticker and call_put land as object
dtype — an array of pointers to individual Python str objects
scattered across the heap. Every touch is a dereference plus a reference-count
update, under the GIL.
Polars uses Arrow string arrays: one contiguous byte buffer plus an offsets array. Cache-friendly, no Python objects involved, no GIL.
.collect().to_pandas() converts Arrow
buffers back into numpy-backed pandas structures — a real copy, single-threaded, and it
reintroduces object dtype for the string
columns.
That is a deliberate trade. Every downstream module — option_strategy_simulator,
pnl_analyzer, delta_hedger —
relies on pandas semantics like .loc[start:end]
and .at[]. Converting once at the boundary
keeps the change contained to a single function while the savings stay intact, because
the conversion runs on the already filtered frame — a fraction of the rows the
old path had to build just to reach the same point.