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])
rets   = closes.pct_change()

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]

loop = []
for i in range(1, len(vals)):                                # the Lesson 8 version
    loop.append((vals[i] - vals[i - 1]) / vals[i - 1])

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

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

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 MSFT close by an AAPL close:

print(df["Close"].pct_change().head(4))
# -> 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 NVDA’s 125.18 over MSFT’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"] == "AAPL"].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")

print(aapl.shape)                            # -> (40, 3)
print(aapl.head(3))
# ->            Ticker   Close   Volume
# -> Date
# -> 2026-01-02   AAPL  186.66  2183494
# -> 2026-01-05   AAPL  186.38  8463199
# -> 2026-01-06   AAPL  188.51  5299573
(40, 3)
           Ticker   Close   Volume
Date                              
2026-01-02   AAPL  186.66  2183494
2026-01-05   AAPL  186.38  8463199
2026-01-06   AAPL  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()

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()

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 MSFT out of prices.csv instead of AAPL, 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"] == "MSFT"].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])

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.