How do I compute returns in pandas?

Turn a column of prices into a column of returns with pct_change, and handle the NaN it leaves in the first row.

.pct_change() takes a Series of prices and gives back a Series of returns. It does in one call what Lesson 8 did with a loop, an index counter and an append.

In this lesson I run it on five closes, check one return by hand, then read prices.csv and put a return column next to the closes for a single ticker.

Step 1. Five closes, one call

Each value is compared with the value on the line above it.

import pandas as pd

closes = pd.Series([185.40, 187.20, 184.90, 188.10, 190.50])   # five closes, oldest first
rets   = closes.pct_change()                                   # each close against the one above it

print(rets)
# -> 0         NaN
# -> 1    0.009709
# -> 2   -0.012286
# -> 3    0.017307
# -> 4    0.012759
# -> dtype: float64

print(len(closes), "prices ->", len(rets), "returns")
# -> 5 prices -> 5 returns
0         NaN
1    0.009709
2   -0.012286
3    0.017307
4    0.012759
dtype: float64
5 prices -> 5 returns

Position 0 is NaN, which is pandas for “no value here”. There is no price before 185.40, so there is no return to put on that row. pct_change() keeps the row and marks it empty rather than returning a shorter Series, so the returns stay lined up with the prices they came from.

Step 2. The same arithmetic as Lesson 8

Row 1 is the return from 185.40 to 187.20. Compute it by hand and compare.

print(f"{(187.20 - 185.40) / 185.40:.6f}")   # -> 0.009709   <- today minus yesterday, over yesterday
print(f"{rets.iloc[1]:.6f}")                 # -> 0.009709
print(rets.isna().sum())                     # -> 1   <- how many NaN in the Series
0.009709
0.009709
1

.dropna() throws the empty row away, and what is left is the list the Lesson 8 loop built.

vals = [185.40, 187.20, 184.90, 188.10, 190.50]              # the same five closes, as a plain list

loop = []                                                    # returns land here one at a time
for i in range(1, len(vals)):                                # the Lesson 8 version
    loop.append((vals[i] - vals[i - 1]) / vals[i - 1])       # start at 1: row 0 has nothing before it

print([round(r, 6) for r in loop])           # -> [0.009709, -0.012286, 0.017307, 0.012759]
print([round(r, 6) for r in rets.dropna()])  # -> [0.009709, -0.012286, 0.017307, 0.012759]
print(len(rets.dropna()))                    # -> 4
[0.009709, -0.012286, 0.017307, 0.012759]
[0.009709, -0.012286, 0.017307, 0.012759]
4

Four returns either way. The loop never produced a fifth, empty entry; pct_change() did, and .dropna() removed it.

Step 3. A return column on real prices

prices.csv holds simulated closes for three tickers over 40 business days.

df = pd.read_csv("prices.csv")         # 40 business days for each of three tickers

print(df.shape)                        # -> (120, 4)
print(df["Ticker"].unique().tolist())  # -> ['AAA', 'CCC', 'DDD']
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)
['AAA', 'CCC', 'DDD']
         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

The file stacks the three tickers on top of each other, one date at a time. pct_change() compares each row with the row above it and knows nothing about the Ticker column, so run on the file as it stands it would divide an CCC close by an AAA close:

print(df["Close"].pct_change().head(4))   # row against row, Ticker ignored
# -> 0         NaN
# -> 1    1.233901
# -> 2   -0.699794
# -> 3    0.488896
# -> Name: Close, dtype: float64
0         NaN
1    1.233901
2   -0.699794
3    0.488896
Name: Close, dtype: float64

Row 2 says minus 70%: that is DDD’s 125.18 over CCC’s 416.98, two different companies on the same day. Splitting the frame by ticker first and applying pct_change() within each group is groupby, which is Lesson 22. Here I take one ticker and work on that.

aapl = df[df["Ticker"] == "AAA"].copy()     # 40 rows, one company
aapl["Date"] = pd.to_datetime(aapl["Date"])  # text dates become real dates
aapl = aapl.sort_values("Date").set_index("Date")  # oldest first, Date becomes the row label

print(aapl.shape)                            # -> (40, 3)
print(aapl.head(3))
# ->            Ticker   Close   Volume
# -> Date
# -> 2026-01-02    AAA  186.66  2183494
# -> 2026-01-05    AAA  186.38  8463199
# -> 2026-01-06    AAA  188.51  5299573
(40, 3)
           Ticker   Close   Volume
Date                              
2026-01-02    AAA  186.66  2183494
2026-01-05    AAA  186.38  8463199
2026-01-06    AAA  188.51  5299573

pct_change() works down the rows in the order it finds them, so the frame is sorted by date before the call. Now the return column.

aapl["Return"] = aapl["Close"].pct_change()  # new column, NaN on the first row

print(aapl[["Close", "Return"]].head())
# ->              Close    Return
# -> Date
# -> 2026-01-02  186.66       NaN
# -> 2026-01-05  186.38 -0.001500
# -> 2026-01-06  188.51  0.011428
# -> 2026-01-07  187.24 -0.006737
# -> 2026-01-08  189.27  0.010842
             Close    Return
Date                        
2026-01-02  186.66       NaN
2026-01-05  186.38 -0.001500
2026-01-06  188.51  0.011428
2026-01-07  187.24 -0.006737
2026-01-08  189.27  0.010842

186.38 over 186.66 is a fall of 0.15%, sitting on 5 January, the later of the two days it was computed from.

Step 4. Summarise the column

.dropna() drops the first row. .describe() prints eight numbers about what is left.

r = aapl["Return"].dropna()                      # the empty first row goes

print(len(aapl), "rows ->", len(r), "returns")   # -> 40 rows -> 39 returns
print(r.describe())
# -> count    39.000000
# -> mean     -0.002413
# -> std       0.011881
# -> min      -0.035732
# -> 25%      -0.009436
# -> 50%      -0.003167
# -> 75%       0.005669
# -> max       0.027240
# -> Name: Return, dtype: float64
40 rows -> 39 returns
count    39.000000
mean     -0.002413
std       0.011881
min      -0.035732
25%      -0.009436
50%      -0.003167
75%       0.005669
max       0.027240
Name: Return, dtype: float64

Read as percentages:

print(f"mean    {r.mean():.4%}")                     # -> mean    -0.2413%
print(f"worst   {r.min():.2%}")                      # -> worst   -3.57%
print(f"best    {r.max():.2%}")                      # -> best    2.72%
print(f"up days {(r > 0).sum()} of {len(r)}")        # -> up days 16 of 39
mean    -0.2413%
worst   -3.57%
best    2.72%
up days 16 of 39

39 daily returns, 16 of them positive, averaging minus 0.24% a day. That average is a mean of daily returns, not the return over the 40 days: simple returns do not add up across time. Lesson 21 chains them with cumprod.

Your turn

Take CCC out of prices.csv instead of AAA, put a date index on it, and add a Return column. How many rows, how many returns, and how many of the returns are positive?

import pandas as pd

df = pd.read_csv("prices.csv")

msft = df[df["Ticker"] == "CCC"].copy()
msft["Date"] = pd.to_datetime(msft["Date"])
msft = msft.sort_values("Date").set_index("Date")

msft["Return"] = msft["Close"].pct_change()
r = msft["Return"].dropna()

print(len(msft), "rows ->", len(r), "returns")   # -> 40 rows -> 39 returns
print(f"up days {(r > 0).sum()} of {len(r)}")    # -> up days 21 of 39
print(f"mean    {r.mean():.4%}")                 # -> mean    0.1199%

NaN stands for “not a number”. It is the marker pandas puts in a cell that has no value. sum() and mean() skip it, arithmetic passes it through, and a comparison against it is False.

s = pd.Series([1.0, None, 3.0])   # None lands as NaN in a float Series

print(s.sum())        # -> 4.0    <- sum() skips it
print(s.mean())       # -> 2.0    <- and so does mean(), dividing by 2, not 3
print(s + 1)
# -> 0    2.0
# -> 1    NaN
# -> 2    4.0
# -> dtype: float64
print((s > 0).tolist())   # -> [True, False, True]    <- the NaN row is False, not NaN
print(s.isna().sum())     # -> 1
4.0
2.0
0    2.0
1    NaN
2    4.0
dtype: float64
[True, False, True]
1

.dropna() removes those rows. .fillna(0) would replace them with zero, which on a return column would claim the first day was flat instead of unknown.