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"])    # Date read as timestamps, not text

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

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

g = df.groupby("Ticker")    # split only, nothing computed yet

print(type(g))      # -> <class 'pandas.core.groupby.generic.DataFrameGroupBy'>
print(g.size())     # rows found in each group
# -> Ticker
# -> AAA    40
# -> CCC    40
# -> DDD    40
# -> dtype: int64
<class 'pandas.core.groupby.generic.DataFrameGroupBy'>
Ticker
AAA    40
CCC    40
DDD    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))    # average close inside each ticker
# -> Ticker
# -> AAA    183.69
# -> CCC    437.15
# -> DDD    112.36
# -> Name: Close, dtype: float64

print(g["Close"].last())             # the final close of each ticker
# -> Ticker
# -> AAA    169.42
# -> CCC    435.94
# -> DDD    107.57
# -> Name: Close, dtype: float64
Ticker
AAA    183.69
CCC    437.15
DDD    112.36
Name: Close, dtype: float64
Ticker
AAA    169.42
CCC    435.94
DDD    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 AAA answer worked by hand, the way you would do it without groupby.

aapl = df[df["Ticker"] == "AAA"]       # the AAA 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))    # two columns at once comes back as a table
# ->          Close      Volume
# -> Ticker
# -> AAA     183.69  5932120.05
# -> CCC     437.15  5255052.88
# -> DDD     112.36  6236481.92
         Close      Volume
Ticker                    
AAA     183.69  5932120.05
CCC     437.15  5255052.88
DDD     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)    # each ticker's days together, in date order

print(df.loc[38:42, ["Date", "Ticker", "Close"]])    # the rows where AAA ends and CCC begins
# ->          Date Ticker   Close
# -> 38 2026-02-25    AAA  171.42
# -> 39 2026-02-26    AAA  169.42
# -> 40 2026-01-02    CCC  416.98
# -> 41 2026-01-05    CCC  423.19
# -> 42 2026-01-06    CCC  423.95
         Date Ticker   Close
38 2026-02-25    AAA  171.42
39 2026-02-26    AAA  169.42
40 2026-01-02    CCC  416.98
41 2026-01-05    CCC  423.19
42 2026-01-06    CCC  423.95

Row 39 is AAA’s last day. Row 40 is CCC’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"]])    # first row of each ticker only
# ->          Date Ticker   Close      flat  ret
# -> 0  2026-01-02    AAA  186.66       NaN  NaN
# -> 40 2026-01-02    CCC  416.98  1.461221  NaN
# -> 80 2026-01-02    DDD  125.18 -0.712850  NaN
         Date Ticker   Close      flat  ret
0  2026-01-02    AAA  186.66       NaN  NaN
40 2026-01-02    CCC  416.98  1.461221  NaN
80 2026-01-02    DDD  125.18 -0.712850  NaN

flat reports a 146% gain on CCC’s first day and a 71% loss on DDD’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

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

Side by side across the boundary:

print(df.loc[38:41, ["Date", "Ticker", "Close", "flat", "ret"]])    # the same boundary, both columns
# ->          Date Ticker   Close      flat       ret
# -> 38 2026-02-25    AAA  171.42 -0.007067 -0.007067
# -> 39 2026-02-26    AAA  169.42 -0.011667 -0.011667
# -> 40 2026-01-02    CCC  416.98  1.461221       NaN
# -> 41 2026-01-05    CCC  423.19  0.014893  0.014893
         Date Ticker   Close      flat       ret
38 2026-02-25    AAA  171.42 -0.007067 -0.007067
39 2026-02-26    AAA  169.42 -0.011667 -0.011667
40 2026-01-02    CCC  416.98  1.461221       NaN
41 2026-01-05    CCC  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")    # one mean per ticker, copied to all 40 rows

print(df.loc[38:41, ["Date", "Ticker", "Close", "mean_close"]])    # the mean only moves when the ticker does
# ->          Date Ticker   Close  mean_close
# -> 38 2026-02-25    AAA  171.42   183.68600
# -> 39 2026-02-26    AAA  169.42   183.68600
# -> 40 2026-01-02    CCC  416.98   437.15275
# -> 41 2026-01-05    CCC  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    AAA  171.42   183.68600
39 2026-02-26    AAA  169.42   183.68600
40 2026-01-02    CCC  416.98   437.15275
41 2026-01-05    CCC  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
# -> AAA   -0.2413
# -> CCC    0.1199
# -> DDD   -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.