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

pandas groupby, merge, and reshaping

pythonpandasgroupbymergereshapingmulti-asset

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

You merge daily SPY prices (NYSE calendar, no weekends) with daily BTC prices (trades 7 days a week) using an inner join on the date column. What happens to BTC's weekend rows?

Multi-asset data: wide vs. long

C2-01 was one ticker. Real work involves many, and there are two ways to lay them out:

Wide — one column per ticker, dates as the index:

              AAPL    MSFT    SPY
Date
2024-01-02  185.64  370.87  472.65
2024-01-03  184.25  370.60  466.31

Long (or "tidy") — one row per (date, ticker) observation:

        date ticker   close
0 2024-01-02   AAPL  185.64
1 2024-01-02   MSFT  370.87
2 2024-01-02    SPY  472.65
3 2024-01-03   AAPL  184.25

Rules of thumb: wide wins for cross-asset math — returns, correlations, portfolio arithmetic — because aligned columns let you write prices.pct_change() once for everything. Long wins for storage, databases, and heterogeneous metadata (add a sector column and it just works), and it's what most data vendors deliver. A huge fraction of practical pandas work is converting one into the other.

groupby: split, apply, combine

groupby splits rows into groups, applies a computation per group, and combines the results. On a long table:

# Mean daily return per ticker
long_df.groupby("ticker")["ret"].mean()

# Several stats at once
long_df.groupby("ticker")["ret"].agg(["mean", "std", "count"])

# Annualized vol per ticker, via a custom function
long_df.groupby("ticker")["ret"].apply(lambda x: x.std(ddof=1) * 252 ** 0.5)

Time-based grouping is everywhere in finance — per-month aggregation, per-year performance tables:

df["month"] = df.index.to_period("M")
monthly = df.groupby("month")["ret"].apply(lambda x: (1 + x).prod() - 1)

(For a DatetimeIndex, resample("ME") from C2-01 is the cleaner spelling of the same idea; groupby generalizes it to any key — ticker, sector, volatility bucket, signal decile. Grouping by signal decile is the backbone of factor analysis in D2.) Two habits that prevent bugs: check .size() first so you know how many observations each group has, and remember that groupby output is indexed by the group key — you'll often want .reset_index() before merging it back.

merge: where alignment bugs are born

pd.merge is SQL join for DataFrames:

merged = pd.merge(prices, signals, on="date", how="inner")

The traps, in descending order of blood spilled:

  1. how changes your dataset silently. inner drops non-overlapping dates (the pretest); outer keeps everything and fills NaN; left keeps the left table's calendar. Print len() before and after every merge. A merge that changes row count in a way you can't explain is a bug you haven't found yet.
  2. Duplicate keys multiply rows. If a date appears 3 times in one table and 2 in the other, the merge produces 3 × 2 = 6 rows for it. A stray duplicated date in a signals file can silently double positions in a backtest. Guard with validate="one_to_one" — pandas then raises instead of exploding.
  3. Timezone-naive vs. aware timestamps never match. 2024-01-02 00:00:00 and 2024-01-02 00:00:00+00:00 are different keys: the merge doesn't error, it just finds no overlap and hands you an empty or NaN-riddled result. Normalize with tz_localize(None) (or localize both) before merging.

For two DataFrames that share a date index, a.join(b) is merge-on-index shorthand — same semantics, same traps.

pivot and melt: switching layouts

# long -> wide: dates as rows, tickers as columns
wide = long_df.pivot(index="date", columns="ticker", values="close")

# wide -> long
tidy = wide.reset_index().melt(id_vars="date", var_name="ticker", value_name="close")

pivot raises if any (date, ticker) pair is duplicated — annoying but protective, for exactly the row-explosion reason above. (pivot_table aggregates duplicates instead; only use it when aggregation is what you mean.)

The mini-workflow you'll run a thousand times

Vendor gives you long; correlation math wants wide. The pipeline is three lines:

wide_prices = long_df.pivot(index="date", columns="ticker", values="close")
returns = wide_prices.pct_change().dropna()
corr = returns.corr()

The pivot step is also doing silent calendar alignment: any ticker missing a date gets NaN there, pct_change propagates it, and .dropna() (row-wise by default) then keeps only dates where every ticker traded — an implicit inner join. For NYSE stocks that's fine; mix in crypto or foreign listings and you're back at the pretest, deciding on purpose this time.

Practice locally (Jupyter)

This lesson has no in-browser exercise — the embedded runtime has NumPy only. Run this in a local Jupyter notebook with pandas and yfinance installed.

import pandas as pd
import yfinance as yf

# 1. Download three tickers (wide), then melt to long to simulate vendor data
raw = yf.download(["AAPL", "MSFT", "SPY"], start="2022-01-01",
                  end="2024-01-01", auto_adjust=False)["Adj Close"]
long_df = raw.reset_index().melt(id_vars="Date", var_name="ticker",
                                 value_name="close")

# 2. groupby: per-ticker observation counts and mean close
print(long_df.groupby("ticker")["close"].agg(["count", "mean"]))

# 3. long -> wide -> returns -> correlation
wide = long_df.pivot(index="Date", columns="ticker", values="close")
rets = wide.pct_change().dropna()
print(rets.groupby(rets.index.to_period("M")).apply(lambda x: (1 + x).prod() - 1).head())
print(rets.corr())

What you should see: (1) ~501 rows per ticker with plausible mean prices; (2) a monthly-returns table whose first row is January 2022 (all three negative — it was a bad month); (3) a symmetric 3×3 correlation matrix, ones on the diagonal, AAPL–MSFT and each–SPY correlations somewhere around 0.6–0.9. Then break it on purpose: duplicate one row of long_df and watch pivot raise, and re-run the merge of rets["AAPL"] against a 7-day-calendar series with how="inner" vs how="outer" and compare len().

⧉ Review card
Wide vs. long format — when do you want each?
Wide (tickers as columns, dates as index) for cross-asset math: returns, correlations, portfolio arithmetic. Long (one row per date-ticker) for storage, vendors, and metadata. pivot converts long to wide; melt goes back.
⧉ Review card
What does groupby do, in three words, and what's the canonical finance use?
Split, apply, combine. Split rows by a key (ticker, month, signal decile), apply an aggregation per group, combine results. Per-ticker stats and signal-decile analysis are the quant staples.
⧉ Review card
What are the three classic merge traps?
1) how= silently drops or NaN-fills non-overlapping dates — check row counts. 2) Duplicate keys multiply rows (3x2=6) — use validate='one_to_one'. 3) tz-naive vs tz-aware timestamps never match — normalize timezones first.
⧉ Review card
Why does an inner join on dates distort a stocks-vs-crypto comparison?
Crypto trades 7 days a week; an inner join with NYSE-calendar data silently drops ~2/7 of the crypto rows, changing its measured volatility and correlation. Choose inner vs outer consciously.

Explain it in your own words

Your generative activity: explain to a colleague, in plain speech, why a merge that silently changes your row count is dangerous in a backtest, and which two checks (one about len, one about duplicate keys) you'd make routine. No code — just the reasoning.

◈ Calibration check

Could you take a long price table of 3 tickers and produce a correlation matrix, explaining each alignment decision along the way?

1 = guessing · 5 = could teach it

⏻ End of lesson

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

Sources & further reading