What is a panel?

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 pd

df = pd.read_csv("prices.csv", parse_dates=["Date"])    # Date parsed as timestamps

print(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
(120, 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

set_index takes a list of two columns and moves both of them out of the body of the table and into the index.

panel = df.set_index(["Date", "Ticker"]).sort_index()    # two keys into the index, sorted once

print(panel.shape)                                       # -> (120, 2)
print(panel.index.names)                                 # -> ['Date', 'Ticker']
print(panel.index.nlevels)                               # -> 2
print(panel.head(6))
# ->                     Close   Volume
# -> Date       Ticker
# -> 2026-01-02 AAA     186.66  2183494
# ->            CCC     416.98  3117814
# ->            DDD     125.18  7447373
# -> 2026-01-05 AAA     186.38  8463199
# ->            CCC     423.19  3345056
# ->            DDD     124.66  7380472
(120, 2)
['Date', 'Ticker']
2
                    Close   Volume
Date       Ticker                 
2026-01-02 AAA     186.66  2183494
           CCC     416.98  3117814
           DDD     125.18  7447373
2026-01-05 AAA     186.38  8463199
           CCC     423.19  3345056
           DDD     124.66  7380472

Still 120 rows, now with two data columns instead of four. The two that left became the index, and an index with more than one level is a MultiIndex.

print(type(panel.index))    # -> <class 'pandas.core.indexes.multi.MultiIndex'>
print(panel.index[0])       # -> (Timestamp('2026-01-02 00:00:00'), 'AAA')
<class 'pandas.core.indexes.multi.MultiIndex'>
(Timestamp('2026-01-02 00:00:00'), 'AAA')

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
         Close   Volume
Ticker                 
AAA     186.66  2183494
CCC     416.98  3117814
DDD     125.18  7447373

Going the other way, across all dates for one ticker, means cutting through the inner level. .xs takes the value and the level it belongs to.

print(panel.xs("DDD", level="Ticker").head())    # one ticker cut from the inner level
# ->              Close   Volume
# -> Date
# -> 2026-01-02  125.18  7447373
# -> 2026-01-05  124.66  7380472
# -> 2026-01-06  122.78  8060541
# -> 2026-01-07  122.28  6445385
# -> 2026-01-08  126.54  8048473
             Close   Volume
Date                       
2026-01-02  125.18  7447373
2026-01-05  124.66  7380472
2026-01-06  122.78  8060541
2026-01-07  122.28  6445385
2026-01-08  126.54  8048473

40 rows of DDD indexed by date, with Ticker used up. .xs names the level, so it works on either one:

print(panel.xs("2026-01-08", level="Date"))    # the level is named, so either works
# ->          Close   Volume
# -> Ticker
# -> AAA     189.27  7949369
# -> CCC     432.41  4516528
# -> DDD     126.54  8048473
         Close   Volume
Ticker                 
AAA     189.27  7949369
CCC     432.41  4516528
DDD     126.54  8048473

Both keys together, plus a column name, reach a single number.

print(panel.loc[("2026-01-02", "DDD"), "Close"])   # -> 125.18
125.18

Step 3. Long to wide with unstack

unstack("Ticker") takes the ticker level out of the index and spreads it across the columns. One column per ticker, one row per date.

wide = panel["Close"].unstack("Ticker")    # tickers spread across the columns

print(wide.shape)                          # -> (40, 3)
print(list(wide.columns))                  # -> ['AAA', 'CCC', 'DDD']
print(wide.head())
# -> Ticker         AAA     CCC     DDD
# -> Date
# -> 2026-01-02  186.66  416.98  125.18
# -> 2026-01-05  186.38  423.19  124.66
# -> 2026-01-06  188.51  423.95  122.78
# -> 2026-01-07  187.24  435.34  122.28
# -> 2026-01-08  189.27  432.41  126.54
(40, 3)
['AAA', 'CCC', 'DDD']
Ticker         AAA     CCC     DDD
Date                              
2026-01-02  186.66  416.98  125.18
2026-01-05  186.38  423.19  124.66
2026-01-06  188.51  423.95  122.78
2026-01-07  187.24  435.34  122.28
2026-01-08  189.27  432.41  126.54

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 column

day = rets.loc["2026-01-08"]       # one date is one row of three returns

print(day)
# -> Ticker
# -> AAA    0.010842
# -> CCC   -0.006730
# -> DDD    0.034838
# -> Name: 2026-01-08 00:00:00, dtype: float64

print(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
Ticker
AAA    0.010842
CCC   -0.006730
DDD    0.034838
Name: 2026-01-08 00:00:00, dtype: float64
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 level

print(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
(117,)
['Date', 'Ticker']
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 columns

print(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.004154

print(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
        Date Ticker       ret
0 2026-01-05    AAA -0.001500
1 2026-01-05    CCC  0.014893
2 2026-01-05    DDD -0.004154
Ticker
AAA    0.0119
CCC    0.0109
DDD    0.0239
Name: ret, dtype: float64

You do not have to reset the index to group. groupby(level=...) reads a level of the MultiIndex directly.

print(panel.groupby(level="Ticker")["Close"].mean().round(2))    # groups a level directly
# -> Ticker
# -> AAA    183.69
# -> CCC    437.15
# -> DDD    112.36
# -> Name: Close, dtype: float64
Ticker
AAA    183.69
CCC    437.15
DDD    112.36
Name: Close, 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 date

print(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.

winner = rets.dropna().idxmax(axis=1)     # idxmax along a row gives the column name

print(winner.head())
# -> Date
# -> 2026-01-05    CCC
# -> 2026-01-06    AAA
# -> 2026-01-07    CCC
# -> 2026-01-08    DDD
# -> 2026-01-09    CCC
# -> dtype: object

print(winner.value_counts())
# -> CCC    16
# -> DDD    13
# -> AAA    10
# -> Name: count, dtype: int64

16 plus 13 plus 10 is 39, one winner for each of the 39 days that has a return.

.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.

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.