Two ways to measure a return
For a price that moves from to over one period:
The ratio is the gross return: what each dollar became. Both returns summarise it, in two different ways.
A return has no units, but it always belongs to a period: a day, a month, a year. Both are decimals in formulas, so 0.05 is 5%.
Try it
Here is the stock from the opening: 100, then 150, then 75.
The average simple return says 0%, but the total return is −25%. Simple returns do not add up across time.
The two side by side
Plot the log return against the simple return and three facts appear.
The log return against the simple return. They agree near 0, the log return is always the smaller, and it plunges as nears . A doubling and a halving have log returns of equal size and opposite sign.
- Near 0 they agree. Daily returns are usually well under 1%, so the two are almost identical from day to day.
- The log return is always the smaller. , with equality only at . The gap grows with the size of the move.
- Log returns are symmetric. Doubling gives and halving gives , so a move and its undoing cancel. In simple returns the same pair reads and .
A total loss, , has no log return at all: does not exist (lesson 1.4.3). Near it, the log return heads to minus infinity.
Try it
For small returns, . Lesson 2.7.2 shows where this comes from; for now, check it with numbers.
np.log1p(R) computes , accurately even for tiny .
Log returns add across time
Take a stock that goes from 100 to 110, then to 99. The two log returns are and . Their sum is the log return over the whole span:
That is the product rule of logs. It works for any number of periods, because the gross returns multiply and the middle prices cancel:
Try it
Check it with the 100, 110, 99 path. The sum of the one-period log returns equals the log of the total gross return.
Simple returns compound instead of adding: . Sums are easier to model than products, which is why the time-series models of the Financial Econometrics course work with log returns.
Average and annual returns
The hook's 0% is the arithmetic mean of the simple returns, and it overstates growth. The honest average is the steady return per period that gives the same total: the geometric mean.
Here is the number of periods and is the mean of the log returns. For the hook, a month: two months at that rate also lose 25%.
Annualising
Log returns scale with time because they add. Over 252 trading days, the usual count for daily stock data, a mean daily log return of 0.0004 becomes 0.1008 for the year. As a simple return that is .
Simple returns compound instead. The same daily return is as a simple return, and gives the same 10.61%. Multiplying it by 252 gives about 10.08%, which is wrong.
Try it
The cell computes the hook's geometric mean two ways, then annualises the daily return.
Simple returns add across assets
A portfolio's weights are the fractions of its starting value in each asset. They add up to 1. Each asset contributes its weight times its own return:
Rule: Portfolio return
This works for simple returns only. The log of a weighted sum is not the weighted sum of logs. So portfolio and risk totals, such as value at risk across desks, are built from simple returns.
Try it
Put $10,000 into a portfolio: 60% in a stock that returns +10% and 40% in a bond that returns −5%.
The stock grows from $6,000 to $6,600 and the bond falls from $4,000 to $3,800. The total is $10,400, a 4% return, which matches .
The weighted sum of log returns can be badly wrong. Split money equally between assets returning +30% and −20%. The portfolio's simple return is 5%, so its log return is . The weighted sum of the two log returns is only 0.0196.
Across assets and over time together
A desk report needs both at once: a portfolio's return over many periods. The recipe is to work across assets with simple returns in each period, then across time with log returns.
Keep the $10,000 portfolio for a second period, in which the stock returns −8% and the bond +3%. Nobody trades, so the weights drift: after period 1 the stock is $6,600 of $10,400, a weight of 63.46%, not 60%.
- Period 1: , and the portfolio is worth $10,400.
- Period 2, with the drifted weights: , and the portfolio is worth $9,986.
- Over both: , a total return of .
Using 60% and 40% again in period 2 would give . That is right only if someone rebalances back to 60/40 at the start of period 2.
Try it
The loop recomputes the weights from the current values at the start of each period, then grows each holding by its return.
The second line ends with 9986.0, the value after both periods. The last line adds the two log returns; they match the log of the total growth.
Why up and down loses money
A rise of followed by a fall of multiplies your money by . That is always less than 1, so you always end up poorer. The loss is : 25% for a 50% swing, but only 0.01% for a 1% swing.
Try it
Bigger swings cost far more, because the loss grows with the square of the swing. This is called volatility drag.
You will meet it again in the Stochastic Calculus course, where Itô's lemma produces a term for the same reason.
Bad inputs on a desk
Real price files break both formulas in predictable ways. Check for each of these before computing a single return.
- A price of 0. After a default the simple return is and the log return is minus infinity. NumPy prints
-inf. - A negative price. Some futures and price spreads can trade below zero. Then can be negative, the log return does not exist, and desks track price changes in dollars instead.
- Splits and dividends. A 2-for-1 split halves the price without anyone losing money. On raw prices it looks like a return of , a log return of . Use prices adjusted for splits and dividends.
- Missing prices. One missing price spoils the two returns on either side of it, and every sum that includes them.
Try it
The first line shows a fall to 0 and to a negative price: -inf and nan, for "not a number". np.errstate hides the warnings NumPy would print; on a desk you would want to see them.
The second line shows one missing price, written np.nan, spoiling two returns.
Returns from prices
Write three functions. Each takes a NumPy array of prices in time order.
simple_returns(prices)returns the array of one-period simple returns.log_returns(prices)returns the array of one-period log returns.total_log_return(prices)returns the log return from the first price to the last, as a single number.
Both arrays should be one element shorter than prices. NumPy slicing (prices[1:], prices[:-1]) avoids any loop.
See the tests
import numpy as np
PRICES = np.array([100.0, 110.0, 99.0, 105.0])
def test_simple_returns():
"""Simple returns of 100, 110, 99, 105"""
expect_close(simple_returns(PRICES), [0.10, -0.10, 105 / 99 - 1], what="simple_returns")
def test_log_returns():
"""Log returns of 100, 110, 99, 105"""
expect_close(log_returns(PRICES), np.log([1.10, 0.90, 105 / 99]), what="log_returns")
def test_lengths():
"""Both arrays are one shorter than the prices"""
expect_equal(len(simple_returns(PRICES)), 3, what="len(simple_returns)")
expect_equal(len(log_returns(PRICES)), 3, what="len(log_returns)")
def test_total_is_sum_of_logs():
"""The total log return equals the sum of the one-period log returns"""
expect_close(total_log_return(PRICES), float(np.sum(log_returns(PRICES))), what="total_log_return vs sum")
expect_close(total_log_return(PRICES), np.log(1.05), what="total_log_return(100 to 105)")
def test_random_prices():
"""Works on a long random price path"""
rng = np.random.default_rng(7)
path = 50 * np.exp(np.cumsum(rng.normal(0, 0.01, 500)))
expect_close(np.exp(np.sum(log_returns(path))), path[-1] / path[0], what="exp(sum of log returns)")
expect_close(np.prod(1 + simple_returns(path)), path[-1] / path[0], what="product of gross returns")
A portfolio's return
Write portfolio_return(weights, returns) returning the portfolio's simple return, given NumPy arrays of weights and of the assets' simple returns.
Then write portfolio_log_return(weights, returns) returning the portfolio's log return. Careful: it is not the weighted sum of the assets' log returns. Work out the simple return first, then convert.
See the tests
import numpy as np
def test_sixty_forty():
"""60% at +10% and 40% at -5% returns 4%"""
expect_close(portfolio_return(np.array([0.6, 0.4]), np.array([0.10, -0.05])), 0.04, what="portfolio_return")
def test_three_assets():
"""Three assets with weights 0.5, 0.3, 0.2"""
w = np.array([0.5, 0.3, 0.2])
r = np.array([0.02, -0.01, 0.05])
expect_close(portfolio_return(w, r), 0.5 * 0.02 - 0.3 * 0.01 + 0.2 * 0.05, what="portfolio_return")
def test_log_return_converts_at_the_end():
"""The portfolio log return is ln(1 + portfolio simple return)"""
w = np.array([0.6, 0.4])
r = np.array([0.10, -0.05])
expect_close(portfolio_log_return(w, r), np.log(1.04), what="portfolio_log_return")
def test_log_return_is_not_weighted_logs():
"""It differs from the weighted sum of log returns"""
w = np.array([0.5, 0.5])
r = np.array([0.30, -0.20])
wrong = float(np.dot(w, np.log1p(r)))
got = portfolio_log_return(w, r)
assert got is not None and abs(got - wrong) > 1e-3, (
f"got {got}, which matches the weighted sum of log returns ({wrong:.6f}); convert the portfolio simple return instead"
)
Summary card
- Simple return ; log return . They agree near 0; the log return is always the smaller.
- Log returns add across time: , by the product rule of logs.
- The geometric mean is the honest average return; annualise log returns by multiplying by the number of periods.
- Simple returns add across assets: , with start-of-period weights adding up to 1. Weights drift unless rebalanced.
- Over time and assets: simple returns across assets each period, then add the log returns over time.
- A rise and fall of leaves of your money: volatility drag.
- Zero or negative prices, splits and missing prices break returns: check the data first.