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.
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 yesterdayprint(f"{rets.iloc[1]:.6f}") # -> 0.009709print(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 inrange(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
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:
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 companyaapl["Date"] = pd.to_datetime(aapl["Date"]) # text dates become real datesaapl = 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.
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?
TipShow answer
import pandas as pddf = 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 returnsprint(f"up days {(r >0).sum()} of {len(r)}") # -> up days 21 of 39print(f"mean {r.mean():.4%}") # -> mean 0.1199%
NoteWhat NaN is
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 itprint(s.mean()) # -> 2.0 <- and so does mean(), dividing by 2, not 3print(s +1)# -> 0 2.0# -> 1 NaN# -> 2 4.0# -> dtype: float64print((s >0).tolist()) # -> [True, False, True] <- the NaN row is False, not NaNprint(s.isna().sum()) # -> 1