The percentage change
Prices are hard to compare: a $5 move means one thing on a $20 stock and another on a $500 one. A simple return removes the scale. It is the change in price as a fraction of the previous price, so +2% means the same on any asset.
In pandas, prices.shift(1) moves every value one step down, so each row sees the previous price. Dividing by it computes all the returns at once, and the first one is NaN because there is no previous price. prices.pct_change() does the same in one call.
import pandas as pd
prices = pd.Series([100.0, 102.0, 100.98, 103.0], index=["Mon", "Tue", "Wed", "Thu"])
returns = prices / prices.shift(1) - 1
print(returns.round(4))
# Mon NaN
# Tue 0.02
# Wed -0.01
# Thu 0.02
# dtype: float64
print(prices.pct_change().round(4).tolist()) # [nan, 0.02, -0.01, 0.02]Returns compound, they do not add
A +2% day followed by a -1% day is not a +1% two-day return. Each return applies to a different starting price, so the growth factors 1 + r multiply: the total return is (1 + r1) * (1 + r2) * ... - 1. Skip the NaN of the first row (prod ignores it) and the result matches the end price divided by the start price, minus one. Summing the returns gets close but is wrong, and the gap grows with volatility.
# Compounding the returns gives the same total as the end price over the start price
returns = prices.pct_change().dropna()
print(round((1 + returns).prod() - 1, 6)) # 0.03
print(round(prices.iloc[-1] / prices.iloc[0] - 1, 6)) # 0.03
print(round(returns.sum(), 6)) # 0.030004 close, but not the same
print((1 + returns).cumprod().round(4).tolist()) # [1.02, 1.0098, 1.03] the growth of 1 dollar# Gains and losses are not symmetric: +10% then -10% does not get you back
up, down = 0.10, -0.10
print(round((1 + up) * (1 + down) - 1, 4)) # -0.01
# After a -50% loss you need +100% to recover
print(1 / (1 - 0.5) - 1) # 1.0Across assets and across years
Simple returns add up across assets: the return of a portfolio over one period is the sum of weight * return of its holdings. With Series, weights * returns matches the entries by label, so the order of the symbols does not matter (use .values and you lose that protection). Over time they do not add but compound, so to quote a yearly rate from a shorter period you raise the growth factor to the power trading_days / days.
# Weights times returns, matched by label (the order of the symbols differs on purpose)
weights = pd.Series({"AAPL": 0.5, "MSFT": 0.3, "TSLA": 0.2})
day = pd.Series({"TSLA": -0.03, "AAPL": 0.012, "MSFT": 0.004})
print(round((weights * day).sum(), 4)) # 0.0012 correct: AAPL with AAPL, TSLA with TSLA
print(round((weights.values * day.values).sum(), 4)) # -0.0106 wrong: position by position, TSLA met AAPL's weight# Annualising: 5% in 60 trading days, if it kept compounding at that pace for a year of 252 days
total, days = 0.05, 60
print(round((1 + total) ** (252 / days) - 1, 4)) # 0.2274daily_returns.- A simple return is
price / previous price - 1:prices / prices.shift(1) - 1orprices.pct_change(); the first entry isNaN. - Over time returns compound:
(1 + returns).prod() - 1, neverreturns.sum(). - Across assets they add with weights:
(weights * returns).sum(), matched by label.
Next up: log returns, an alternative that adds up over time and is the usual choice for statistics.
Lab · what's graded
- Turn prices into period returns with
simple_returns25 pts - Compound the returns into a total with
cumulative_return25 pts - Weight the asset returns into one number with
portfolio_return25 pts - Express a return as a yearly rate with
annualized_return25 pts