Put a date and a ticker in the index, pull out one date or one ticker, and switch between the long and wide shapes.
A panel is a table with one row per date and per ticker. Three tickers over 40 days is 120 rows. The date alone does not name a row and the ticker alone does not either, so a panel needs both as its key.
In this lesson I put Date and Ticker into the index together, pull out one date and one ticker, then switch between the long shape and the wide shape and show which calculation each one suits.
The prices are simulated, not real market data.
Step 1. Two keys in the index
prices.csv is already stacked: a date, a ticker, and the numbers for that pair.
import pandas as pddf = pd.read_csv("prices.csv", parse_dates=["Date"]) # Date parsed as timestampsprint(df.shape) # -> (120, 4)print(df.head(4))# -> Date Ticker Close Volume# -> 0 2026-01-02 AAA 186.66 2183494# -> 1 2026-01-02 CCC 416.98 3117814# -> 2 2026-01-02 DDD 125.18 7447373# -> 3 2026-01-05 AAA 186.38 8463199
One row is named by a pair. The date prints once and the ticker repeats under it, which is display only: every row still carries both values.
Step 2. One date, one ticker, one cell
.loc with the outer level takes a slice of dates. Give it one date and you get that day’s three tickers, with Date used up and only Ticker left in the index.
print(panel.loc["2026-01-02"]) # one date, its three tickers# -> Close Volume# -> Ticker# -> AAA 186.66 2183494# -> CCC 416.98 3117814# -> DDD 125.18 7447373
120 rows of one column became 40 rows of three columns. 40 times 3 is 120, the same closes rearranged.
Step 4. The wide shape reads across a row
A cross-sectional calculation compares the tickers to each other on one date. In the wide shape one date is one row, so it is a calculation along a row.
rets = wide.pct_change() # return day over day, down each columnday = rets.loc["2026-01-08"] # one date is one row of three returnsprint(day)# -> Ticker# -> AAA 0.010842# -> CCC -0.006730# -> DDD 0.034838# -> Name: 2026-01-08 00:00:00, dtype: float64print(day.rank(ascending=False)) # 1 is the biggest riser that day# -> Ticker# -> AAA 2.0# -> CCC 3.0# -> DDD 1.0# -> Name: 2026-01-08 00:00:00, dtype: float64
DDD rose the most that day, so it ranks 1. axis=1 runs that same rank along every row at once.
print(rets.rank(axis=1, ascending=False).head()) # axis=1 ranks along each row# -> Ticker AAA CCC DDD# -> Date# -> 2026-01-02 NaN NaN NaN# -> 2026-01-05 2.0 1.0 3.0# -> 2026-01-06 1.0 2.0 3.0# -> 2026-01-07 3.0 1.0 2.0# -> 2026-01-08 2.0 3.0 1.0
Ticker AAA CCC DDD
Date
2026-01-02 NaN NaN NaN
2026-01-05 2.0 1.0 3.0
2026-01-06 1.0 2.0 3.0
2026-01-07 3.0 1.0 2.0
2026-01-08 2.0 3.0 1.0
The first row is all NaN because pct_change has no day before 2026-01-02. rets.mean(axis=1) averages along the row the same way, giving the average return of the three on each date.
Step 5. Wide to long with stack
stack() is the reverse. It folds the columns back down into an index level.
long= rets.stack() # columns fold back into an index levelprint(long.shape) # -> (117,)print(long.index.names) # -> ['Date', 'Ticker']print(long.head(4))# -> Date Ticker# -> 2026-01-05 AAA -0.001500# -> CCC 0.014893# -> DDD -0.004154# -> 2026-01-06 AAA 0.011428# -> dtype: float64
40 by 3 went back to rows keyed by date and ticker. stack drops the missing values as it folds, so the three NaN of the first date are gone and 117 rows are left. Now reset_index turns those two levels back into ordinary columns, which is the shape groupby from Lesson 22 works on.
tidy =long.rename("ret").reset_index() # name the column, keys back as columnsprint(tidy.head(3))# -> Date Ticker ret# -> 0 2026-01-05 AAA -0.001500# -> 1 2026-01-05 CCC 0.014893# -> 2 2026-01-05 DDD -0.004154print(tidy.groupby("Ticker")["ret"].std().round(4)) # spread of returns per ticker# -> Ticker# -> AAA 0.0119# -> CCC 0.0109# -> DDD 0.0239# -> Name: ret, dtype: float64
Down a column, per ticker, is groupby on the long shape. Across a row, per date, is one call on the wide shape. The long shape can do the cross-section too, by grouping on the other key:
tidy["rank"] = tidy.groupby("Date")["ret"].rank(ascending=False) # rank within each dateprint(tidy[tidy["Date"] =="2026-01-08"]) # the same day as Step 4# -> Date Ticker ret rank# -> 9 2026-01-08 AAA 0.010842 2.0# -> 10 2026-01-08 CCC -0.006730 3.0# -> 11 2026-01-08 DDD 0.034838 1.0
Date Ticker ret rank
9 2026-01-08 AAA 0.010842 2.0
10 2026-01-08 CCC -0.006730 3.0
11 2026-01-08 DDD 0.034838 1.0
Same numbers as day.rank(ascending=False) in Step 4.
Your turn
Using rets from Step 4, find which ticker had the highest return on each day, then count how many days each ticker won. Drop the first row first, because it is all NaN.
16 plus 13 plus 10 is 39, one winner for each of the 39 days that has a return.
NoteWhy sort_index() after set_index?
.loc["2026-01-02":"2026-01-06"] slices a range of dates on the outer level, and a slice needs the index in order. On an unsorted MultiIndex pandas raises UnsortedIndexError. Sorting once after set_index makes every later slice work.
panel.xs("DDD", level="Ticker") and groupby do not need the sort, since they match values rather than walk a range.
NoteWhich level goes on top?
set_index(["Date", "Ticker"]) puts dates on the outer level. set_index(["Ticker", "Date"]) puts tickers there instead, and then panel.loc["AAA"] gives you one ticker’s 40 days.
unstack takes the level you name whichever way round it sits, so unstack("Date") gives 3 rows and 40 columns rather than 40 by 3.