How do I do the same thing per ticker?

Split a stacked table into one group per ticker, run a calculation inside each group, and get the answers back.

df.groupby("Ticker") splits a table into one group per ticker, runs a calculation inside each group, and puts the answers back together. The calculation never reaches across from one ticker into the next.

In this lesson I read a stacked file of three tickers, take a few per-ticker summaries, then compute daily returns inside each ticker and show what happens at the row where one ticker ends and the next begins.

The prices are simulated, not real market data.

Step 1. Split, apply, combine

prices.csv holds 40 business days for three tickers, stacked on top of each other in one table.

import pandas as pd

df = pd.read_csv("prices.csv", parse_dates=["Date"])

print(df.shape)     # -> (120, 4)
print(df.head(3))
# ->         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
(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

groupby on its own does the split and nothing else. It hands back an object holding three groups.

g = df.groupby("Ticker")

print(type(g))      # -> <class 'pandas.api.typing.DataFrameGroupBy'>
print(g.size())
# -> Ticker
# -> AAPL    40
# -> MSFT    40
# -> NVDA    40
# -> dtype: int64
<class 'pandas.api.typing.DataFrameGroupBy'>
Ticker
AAPL    40
MSFT    40
NVDA    40
dtype: int64

The calculation comes next. Pick a column, call a method on it, and the method runs once per group.

print(g["Close"].mean().round(2))
# -> Ticker
# -> AAPL    183.69
# -> MSFT    437.15
# -> NVDA    112.36
# -> Name: Close, dtype: float64

print(g["Close"].last())
# -> Ticker
# -> AAPL    169.42
# -> MSFT    435.94
# -> NVDA    107.57
# -> Name: Close, dtype: float64
Ticker
AAPL    183.69
MSFT    437.15
NVDA    112.36
Name: Close, dtype: float64
Ticker
AAPL    169.42
MSFT    435.94
NVDA    107.57
Name: Close, dtype: float64

120 rows went in, three numbers came out. The tickers moved from a column into the index.

Here is the AAPL answer worked by hand, the way you would do it without groupby.

aapl = df[df["Ticker"] == "AAPL"]       # the AAPL group, filtered by hand

print(round(aapl["Close"].mean(), 2))   # -> 183.69
print(len(aapl))                        # -> 40
183.69
40

groupby does that filter for every ticker in the column and stacks the results. Ask for two columns and you get a table back.

print(g[["Close", "Volume"]].mean().round(2))
# ->          Close      Volume
# -> Ticker
# -> AAPL    183.69  5932120.05
# -> MSFT    437.15  5255052.88
# -> NVDA    112.36  6236481.92
         Close      Volume
Ticker                    
AAPL    183.69  5932120.05
MSFT    437.15  5255052.88
NVDA    112.36  6236481.92

Step 2. Returns inside each ticker

Sort by ticker, then by date, so each ticker’s 40 days sit together in order.

df = df.sort_values(["Ticker", "Date"]).reset_index(drop=True)

print(df.loc[38:42, ["Date", "Ticker", "Close"]])
# ->          Date Ticker   Close
# -> 38 2026-02-25   AAPL  171.42
# -> 39 2026-02-26   AAPL  169.42
# -> 40 2026-01-02   MSFT  416.98
# -> 41 2026-01-05   MSFT  423.19
# -> 42 2026-01-06   MSFT  423.95
         Date Ticker   Close
38 2026-02-25   AAPL  171.42
39 2026-02-26   AAPL  169.42
40 2026-01-02   MSFT  416.98
41 2026-01-05   MSFT  423.19
42 2026-01-06   MSFT  423.95

Row 39 is AAPL’s last day. Row 40 is MSFT’s first day. Now compute returns twice: once on the whole column, once per ticker.

df["flat"] = df["Close"].pct_change()                    # one column, top to bottom
df["ret"] = df.groupby("Ticker")["Close"].pct_change()   # restarts inside each ticker

print(df["flat"].isna().sum())    # -> 1
print(df["ret"].isna().sum())     # -> 3
1
3

One NaN against three. ret starts fresh for every ticker, so each of the three loses its first day. flat only loses row 0.

The rows where the ticker changes show what flat put there instead.

edge = df["Ticker"] != df["Ticker"].shift(1)      # True on each ticker's first row

print(df.loc[edge, ["Date", "Ticker", "Close", "flat", "ret"]])
# ->          Date Ticker   Close      flat  ret
# -> 0  2026-01-02   AAPL  186.66       NaN  NaN
# -> 40 2026-01-02   MSFT  416.98  1.461221  NaN
# -> 80 2026-01-02   NVDA  125.18 -0.712850  NaN
         Date Ticker   Close      flat  ret
0  2026-01-02   AAPL  186.66       NaN  NaN
40 2026-01-02   MSFT  416.98  1.461221  NaN
80 2026-01-02   NVDA  125.18 -0.712850  NaN

flat reports a 146% gain on MSFT’s first day and a 71% loss on NVDA’s first day. Those are the prices of two different companies divided by each other:

print(416.98 / 169.42 - 1)      # -> 1.461220635108016
1.461220635108016

AAPL closed at 169.42 on its last row, MSFT’s first row closed at 416.98, and pct_change divided one by the other. groupby puts NaN there because MSFT has no day before its first day.

Side by side across the boundary:

print(df.loc[38:41, ["Date", "Ticker", "Close", "flat", "ret"]])
# ->          Date Ticker   Close      flat       ret
# -> 38 2026-02-25   AAPL  171.42 -0.007067 -0.007067
# -> 39 2026-02-26   AAPL  169.42 -0.011667 -0.011667
# -> 40 2026-01-02   MSFT  416.98  1.461221       NaN
# -> 41 2026-01-05   MSFT  423.19  0.014893  0.014893
         Date Ticker   Close      flat       ret
38 2026-02-25   AAPL  171.42 -0.007067 -0.007067
39 2026-02-26   AAPL  169.42 -0.011667 -0.011667
40 2026-01-02   MSFT  416.98  1.461221       NaN
41 2026-01-05   MSFT  423.19  0.014893  0.014893

Inside a ticker the two columns agree. They differ only on the row where the ticker changes.

Step 3. transform keeps every row

g["Close"].mean() gives one number per ticker: three rows out of 120. transform("mean") computes the same number and then copies it onto every row of that ticker, so it fits back into the table.

df["mean_close"] = df.groupby("Ticker")["Close"].transform("mean")

print(df.loc[38:41, ["Date", "Ticker", "Close", "mean_close"]])
# ->          Date Ticker   Close  mean_close
# -> 38 2026-02-25   AAPL  171.42   183.68600
# -> 39 2026-02-26   AAPL  169.42   183.68600
# -> 40 2026-01-02   MSFT  416.98   437.15275
# -> 41 2026-01-05   MSFT  423.19   437.15275

print(df.groupby("Ticker")["Close"].mean().shape)   # -> (3,)
print(df["mean_close"].shape)                       # -> (120,)
         Date Ticker   Close  mean_close
38 2026-02-25   AAPL  171.42   183.68600
39 2026-02-26   AAPL  169.42   183.68600
40 2026-01-02   MSFT  416.98   437.15275
41 2026-01-05   MSFT  423.19   437.15275
(3,)
(120,)

Use .mean() when you want a summary table, and .transform("mean") when you want to compare each row against its own ticker’s average.

Your turn

Using df with the ret column from Step 2, print each ticker’s average daily return in percent.

print((df.groupby("Ticker")["ret"].mean() * 100).round(4))
# -> Ticker
# -> AAPL   -0.2413
# -> MSFT    0.1199
# -> NVDA   -0.3600
# -> Name: ret, dtype: float64

An average daily return is not the return over the whole stretch. Simple returns compound, they do not add.

groupby finds the rows belonging to each ticker wherever they sit, so g["Close"].mean() gives the same answer on the unsorted file. pct_change is different: it takes each row against the row above it inside the group. If the dates are shuffled, the groups are still correct, but each return is then taken against whatever row happens to sit above it. Sort by ticker and date before anything that reads a neighbouring row.