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"])

print(df.shape)     # -> (120, 4)
print(df.head(4))
# ->         Date Ticker   Close   Volume
# -> 0 2026-01-02   AAPL  186.66  2183494
# -> 1 2026-01-02   MSFT  416.98  3117814
# -> 2 2026-01-02   NVDA  125.18  7447373
# -> 3 2026-01-05   AAPL  186.38  8463199
(120, 4)
        Date Ticker   Close   Volume
0 2026-01-02   AAPL  186.66  2183494
1 2026-01-02   MSFT  416.98  3117814
2 2026-01-02   NVDA  125.18  7447373
3 2026-01-05   AAPL  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()

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 AAPL    186.66  2183494
# ->            MSFT    416.98  3117814
# ->            NVDA    125.18  7447373
# -> 2026-01-05 AAPL    186.38  8463199
# ->            MSFT    423.19  3345056
# ->            NVDA    124.66  7380472
(120, 2)
['Date', 'Ticker']
2
                    Close   Volume
Date       Ticker                 
2026-01-02 AAPL    186.66  2183494
           MSFT    416.98  3117814
           NVDA    125.18  7447373
2026-01-05 AAPL    186.38  8463199
           MSFT    423.19  3345056
           NVDA    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.MultiIndex'>
print(panel.index[0])       # -> (Timestamp('2026-01-02 00:00:00'), 'AAPL')
<class 'pandas.MultiIndex'>
(Timestamp('2026-01-02 00:00:00'), 'AAPL')

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"])
# ->          Close   Volume
# -> Ticker
# -> AAPL    186.66  2183494
# -> MSFT    416.98  3117814
# -> NVDA    125.18  7447373
         Close   Volume
Ticker                 
AAPL    186.66  2183494
MSFT    416.98  3117814
NVDA    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("NVDA", level="Ticker").head())
# ->              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 NVDA 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"))
# ->          Close   Volume
# -> Ticker
# -> AAPL    189.27  7949369
# -> MSFT    432.41  4516528
# -> NVDA    126.54  8048473
         Close   Volume
Ticker                 
AAPL    189.27  7949369
MSFT    432.41  4516528
NVDA    126.54  8048473

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

print(panel.loc[("2026-01-02", "NVDA"), "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")

print(wide.shape)               # -> (40, 3)
print(list(wide.columns))       # -> ['AAPL', 'MSFT', 'NVDA']
print(wide.head())
# -> Ticker        AAPL    MSFT    NVDA
# -> 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)
['AAPL', 'MSFT', 'NVDA']
Ticker        AAPL    MSFT    NVDA
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()

day = rets.loc["2026-01-08"]

print(day)
# -> Ticker
# -> AAPL    0.010842
# -> MSFT   -0.006730
# -> NVDA    0.034838
# -> Name: 2026-01-08 00:00:00, dtype: float64

print(day.rank(ascending=False))
# -> Ticker
# -> AAPL    2.0
# -> MSFT    3.0
# -> NVDA    1.0
# -> Name: 2026-01-08 00:00:00, dtype: float64
Ticker
AAPL    0.010842
MSFT   -0.006730
NVDA    0.034838
Name: 2026-01-08 00:00:00, dtype: float64
Ticker
AAPL    2.0
MSFT    3.0
NVDA    1.0
Name: 2026-01-08 00:00:00, dtype: float64

NVDA 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())
# -> Ticker      AAPL  MSFT  NVDA
# -> 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      AAPL  MSFT  NVDA
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()

print(long.shape)           # -> (120,)
print(long.index.names)     # -> ['Date', 'Ticker']
print(long.head(4))
# -> Date        Ticker
# -> 2026-01-02  AAPL         NaN
# ->             MSFT         NaN
# ->             NVDA         NaN
# -> 2026-01-05  AAPL     -0.0015
# -> dtype: float64
(120,)
['Date', 'Ticker']
Date        Ticker
2026-01-02  AAPL         NaN
            MSFT         NaN
            NVDA         NaN
2026-01-05  AAPL     -0.0015
dtype: float64

40 by 3 went back to 120 rows keyed by date and ticker. 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()

print(tidy.head(3))
# ->         Date Ticker  ret
# -> 0 2026-01-02   AAPL  NaN
# -> 1 2026-01-02   MSFT  NaN
# -> 2 2026-01-02   NVDA  NaN

print(tidy.groupby("Ticker")["ret"].std().round(4))
# -> Ticker
# -> AAPL    0.0119
# -> MSFT    0.0109
# -> NVDA    0.0239
# -> Name: ret, dtype: float64
        Date Ticker  ret
0 2026-01-02   AAPL  NaN
1 2026-01-02   MSFT  NaN
2 2026-01-02   NVDA  NaN
Ticker
AAPL    0.0119
MSFT    0.0109
NVDA    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))
# -> Ticker
# -> AAPL    183.69
# -> MSFT    437.15
# -> NVDA    112.36
# -> Name: Close, dtype: float64
Ticker
AAPL    183.69
MSFT    437.15
NVDA    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)

print(tidy[tidy["Date"] == "2026-01-08"])
# ->          Date Ticker       ret  rank
# -> 12 2026-01-08   AAPL  0.010842   2.0
# -> 13 2026-01-08   MSFT -0.006730   3.0
# -> 14 2026-01-08   NVDA  0.034838   1.0
         Date Ticker       ret  rank
12 2026-01-08   AAPL  0.010842   2.0
13 2026-01-08   MSFT -0.006730   3.0
14 2026-01-08   NVDA  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    MSFT
# -> 2026-01-06    AAPL
# -> 2026-01-07    MSFT
# -> 2026-01-08    NVDA
# -> 2026-01-09    MSFT
# -> dtype: str

print(winner.value_counts())
# -> MSFT    16
# -> NVDA    13
# -> AAPL    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("NVDA", 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["AAPL"] 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.