QTQuant Terminal
C2-01C2·intro·~22 min

pandas for time-indexed financial data

pythonpandastime-seriesdataframe

▸ Pretest — guess, even if you don't know

What does `df['returns'] = df['close'].pct_change()` produce as the first value?

Why pandas dominates quant finance

NumPy is fast at arithmetic on arrays. But financial data is labeled — each price has a date, each row has a ticker, each column has a meaning. Pandas adds:

For research and exploratory work, pandas is faster to write than raw NumPy. For production execution, you may move to Polars (faster) or duckdb (SQL-native) — but every quant team I've seen starts in pandas.

The basic shape

A standard "OHLCV" DataFrame from yfinance looks like:

                  Open       High        Low      Close   Adj Close       Volume
Date
2024-01-02  187.149994 188.440002 183.889999 185.640007 185.640007  82488700
2024-01-03  184.220001 185.880005 183.430000 184.250000 184.250000  58414500
2024-01-04  182.149994 183.089996 180.880005 181.910004 181.910004  71983600
...

The five operations you'll do constantly

1. Loading

import yfinance as yf

# Single ticker
spy = yf.download("SPY", start="2010-01-01", auto_adjust=False)

# Multiple tickers — wide format
tickers = yf.download(["SPY", "AAPL", "GOOG"], start="2010-01-01")

2. Computing returns

spy["returns"] = spy["Adj Close"].pct_change()           # simple returns
spy["log_returns"] = np.log(spy["Adj Close"]).diff()     # log returns

Always use Adj Close for return calculations. Raw Close is wrong on dividend days and split days.

3. Slicing by time

spy.loc["2020"]                       # all of 2020
spy.loc["2020-03"]                    # March 2020 only
spy.loc["2020-03-15":"2020-03-30"]   # specific range (inclusive both ends!)

The slicing semantics for DatetimeIndex differ from regular Python — both endpoints are inclusive. Easy to misread.

4. Rolling windows

spy["vol_20d"] = spy["returns"].rolling(20).std()
spy["sma_50"]  = spy["Adj Close"].rolling(50).mean()

The first 19 values of vol_20d will be NaN. Pandas does this for you correctly — but the NaN can propagate if you don't handle it.

5. Resampling to longer horizons

# Daily to monthly
monthly_returns = spy["returns"].resample("M").apply(lambda x: (1 + x).prod() - 1)

# Daily to weekly OHLC bars
weekly_bars = spy["Adj Close"].resample("W").ohlc()

resample is one of pandas' best features. It handles all the date arithmetic — weekday vs. calendar, month-end, etc.

The three pandas pitfalls that bite everyone

1. Look-ahead bias from .shift()

When you compute "yesterday's signal predicts today's return," you must .shift(1) correctly:

# WRONG — uses today's signal to predict today's return
df["pnl"] = df["signal"] * df["returns"]

# RIGHT — yesterday's signal predicts today's return
df["pnl"] = df["signal"].shift(1) * df["returns"]

A correct backtest always shifts the signal back relative to the return. Forgetting this single line is the most common source of inflated backtest results.

2. NaN propagation

NaN poisons most computations. If you compute df["pnl"].sum(), NaN values are silently skipped, which sounds nice but means an unexpected NaN can swing your reported numbers. Default to:

df["pnl"].sum()                        # skips NaN silently — be aware
df["pnl"].dropna().sum()              # explicit about ignoring NaN
df["pnl"].fillna(0).sum()              # explicit about treating NaN as zero

Pick one convention per project and document it.

3. Mixing series with different indexes

If you do series_a + series_b and they have different indexes, pandas aligns on the union and fills missing values with NaN. This can silently break a calculation. When in doubt:

a, b = a.align(b, join="inner")
result = a + b

A small but essential pattern: vectorize, don't loop

# WRONG — Python loop over 5000 rows
for i in range(1, len(df)):
    df.loc[i, "ma5"] = df["close"].iloc[i-5:i].mean()

# RIGHT — vectorized
df["ma5"] = df["close"].rolling(5).mean()

The vectorized version is 100–1000× faster. If you find yourself writing a loop over a DataFrame, stop and ask "is there a built-in?"

⧉ Review card
What does pct_change() produce as its first value?
NaN — no prior value to compute the percentage change from. Equivalent to (close / close.shift(1)) - 1, where the first shift is NaN.
⧉ Review card
Why use Adj Close (not Close) for return calculations?
Adj Close is adjusted for splits and dividends. Raw Close jumps artificially on those days, producing fake returns that ruin backtests.
⧉ Review card
What's the cardinal sin of backtest construction in pandas?
Forgetting to .shift() the signal back by one period. df['signal'].shift(1) * df['returns'] is correct; df['signal'] * df['returns'] uses today's signal to predict today's return — look-ahead bias.
⧉ Review card
How do you resample daily returns to monthly returns (correctly compounded)?
monthly = daily_returns.resample('M').apply(lambda x: (1+x).prod() - 1). Simple sums of daily simple returns is wrong; you must compound.

Predict before the next lesson

Tomorrow we cover the mean (A2-01) — what "average return" actually is, and why the arithmetic and geometric versions differ. Predict:

◈ Calibration check

Could you load OHLC data, compute returns, and slice by date in pandas without looking up the syntax?

1 = guessing · 5 = could teach it

⏻ End of lesson

Mark it read to book its 4 review cards into your deck.

Sources & further reading