Lesson 0.7.1 · 50 min
Simple and log returns, compound interest and descriptive statistics
Almost every figure in a backtest comes from a series of returns: the mean, the volatility, the annual return, the worst day. This lesson pins down how they are computed, which one is added and which one is multiplied, how they are annualized, and what skewness and kurtosis say about a distribution that is not normal.
- Module
- 0.7 · Minimum mathematics and statistics
- Duration
- 50 min
- Prerequisites
- 0.5.2 · pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
From price to return
A price on its own says nothing: 142.22 may be a lot or very little. What gets compared across assets, periods and strategies is the return, the relative change in price. There are two ways to measure it, and almost every calculation error in a beginner's backtest comes from mixing them up.
The simple return is the familiar percentage change:
The log return (or continuously compounded return) is the natural log of the price ratio:
In pandas, the first is pct_change() and the second, the difference of the log prices. With the closes of SINT, the
course's fictional ETF:
import numpy as np
import pandas as pd
precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")
cierre = precios["cierre"]
rent = pd.DataFrame({
"simple": cierre.pct_change(),
"log": np.log(cierre).diff(),
}).dropna()
rent.head(3).round(5) simple log
fecha
2021-01-05 0.00111 0.00111
2021-01-06 -0.01884 -0.01902
2021-01-07 0.01212 0.01204The first day disappears with dropna(): it has no previous day. For daily moves of a tenth of a percentage point or
a full point, the two measures differ by hundredths of a point: −1.884% versus −1.902% on the second day. They pull
apart when the move is large:
for R in (-0.5, -0.2, -0.05, -0.01, 0.01, 0.05, 0.2, 1.0):
print(f"simple {R:+7.0%} log {np.log1p(R):+8.2%}")simple -50% log -69.31%
simple -20% log -22.31%
simple -5% log -5.13%
simple -1% log -1.01%
simple +1% log +1.00%
simple +5% log +4.88%
simple +20% log +18.23%
simple +100% log +69.31%np.log1p(R) computes more precisely than np.log(1 + R) when is very small. The table shows the
asymmetry of the simple return: a 50% fall is −69.31% in log terms, and a 100% rise (the one needed to recover it,
lesson 0.1.2) is +69.31%. In log terms, losing and recovering what was lost weigh the same; in simple terms, they do
not.
Which one adds and which one multiplies
The practical difference between the two lies in how they aggregate.
Over time, simple returns compound (they multiply) and log returns add:
total_precios = cierre.iloc[-1] / cierre.iloc[0] - 1
total_simple = (1 + rent["simple"]).prod() - 1
total_log = np.exp(rent["log"].sum()) - 1
suma_simple = rent["simple"].sum()
print(f"First to last close: {total_precios:.4%}")
print(f"Product of (1 + R): {total_simple:.4%}")
print(f"exp(sum of r) - 1: {total_log:.4%}")
print(f"Sum of R (wrong): {suma_simple:.4%}")First to last close: 43.4537%
Product of (1 + R): 43.4537%
exp(sum of r) - 1: 43.4537%
Sum of R (wrong): 44.5425%The first three figures are the same number, computed three ways. The fourth, adding up simple returns, is a common mistake that here inflates the result by just over a point (44.54% versus 43.45%), and the error grows with volatility and with the number of periods: it ignores that each day's gain or loss is made on a different amount of capital.
Across assets, within the same period, it is the other way round: a portfolio's simple return is exactly the mean of its assets' simple returns, weighted by their weights at the start of the period. The log return does not have that property. One day with two positions of the same size, one rising 20% and the other falling 20%:
pesos = np.array([0.5, 0.5])
R_dia = np.array([0.20, -0.20])
print(f"Portfolio (weighted simple): {pesos @ R_dia:+.4%}")
print(f"Weighted mean of the logs: "
f"{pesos @ np.log1p(R_dia):+.4%}")Portfolio (weighted simple): +0.0000%
Weighted mean of the logs: -2.0411%The portfolio neither gains nor loses: 0%, and its log return is . The weighted mean of the logs gives
−2.04%, a loss that does not exist. The @ operator is the dot product from lesson 0.5.1; lesson 0.7.3 uses it for
real portfolios.
| To… | Use | Because |
|---|---|---|
| Chain periods (days into years) | Log (sum) or simple (product) | Both work; the sum of logs is more convenient |
| Combine the assets of a portfolio | Simple | The portfolio's simple return is the weighted mean of the simple returns |
| Report to a person | Simple | “It made 7%” is understood; “0.068 in log”, not |
| Statistical models (lesson 0.7.2) | Log, often | They add, and they are somewhat more symmetric |
Compound interest and CAGR
Compound interest is the rule of simple returns applied to capital: each period earns on what was earned before. Capital growing at a rate per year for years ends at :
capital = 10_000
for anios in (10, 20, 30):
print(f"{anios} years: {capital * 1.07 ** anios:>9,.0f} €")10 years: 19,672 €
20 years: 38,697 €
30 years: 76,123 €After thirty years the capital has grown more than sevenfold, and the last ten years contribute more than the first twenty. That is why annual costs and fees, small in any single year, are so expensive in the long run (lesson 0.1.2).
The compounding frequency also matters. A nominal 7% compounded monthly yields a little more than compounded once a year, and the limit is continuous compounding, , which is precisely the relationship between log and simple returns:
tasa = 0.07
for nombre, m in (("annual", 1), ("monthly", 12), ("daily", 252)):
print(f"{nombre:<11}{(1 + tasa / m) ** m - 1:.4%}")
print(f"{'continuous':<11}{np.exp(tasa) - 1:.4%}")annual 7.0000%
monthly 7.2290%
daily 7.2498%
continuous 7.2508%The inverse operation answers the usual question about a price history: at what constant annual rate would it have to grow to go from the first price to the last? That is the compound annual growth rate, CAGR:
anios = (cierre.index[-1] - cierre.index[0]).days / 365.25
cagr = (cierre.iloc[-1] / cierre.iloc[0]) ** (1 / anios) - 1
print(f"Years: {anios:.2f} · CAGR: {cagr:.2%}")
print(f"Sessions per year: {len(cierre) / anios:.0f}")Years: 4.99 · CAGR: 7.50%
Sessions per year: 261The years are counted from the dates: from January 4, 2021 to December 31, 2025 there are 4.99 years. The second line shows why it is not a good idea to count them by dividing sessions by 252: SINT has no holidays and trades 261 days a year, so dividing its 1,303 sessions by 252 would give 5.17 years and a CAGR lower than the real one.
Annualizing: mean, volatility and the cost of volatility
Daily returns are small numbers; to compare strategies they are annualized. If the returns of different days are independent, the mean and the variance grow with the number of days, and the standard deviation with its square root:
The annualized standard deviation is the volatility, the most widely used risk measure. It can be checked against monthly data, which are annualized with :
vol_d = rent["simple"].std()
mensual = cierre.resample("ME").last().pct_change().dropna()
vol_m = mensual.std()
print(f"Daily: {vol_d:.2%} · annualized {vol_d * np.sqrt(252):.2%}")
print(f"Monthly: {vol_m:.2%} · annualized {vol_m * np.sqrt(12):.2%}")Daily: 1.14% · annualized 18.10%
Monthly: 4.73% · annualized 16.39%The two annualized figures are similar but do not match. With 59 months, the monthly estimate carries far more error than the daily one, with 1,302 days; and if the days were not fully independent (streaks, volatility that clusters in episodes), the square-root rule would stop being exact. In practice, volatility is estimated from daily data and annualized with , knowing that it is an approximation.
The mean has one more trap. The arithmetic mean of the simple returns, annualized, is not what the capital grows by: what it grows by is the mean of the log returns. The difference is approximately half the variance:
media_R = rent["simple"].mean()
media_r = rent["log"].mean()
varianza = rent["simple"].var()
print(f"Simple mean ×252: {media_R * 252:.2%}")
print(f"Log mean ×252: {media_r * 252:.2%}")
print(f"Difference ×252: {(media_R - media_r) * 252:.2%}")
print(f"σ²/2 ×252: {varianza / 2 * 252:.2%}")Simple mean ×252: 8.62%
Log mean ×252: 6.98%
Difference ×252: 1.64%
σ²/2 ×252: 1.64%The approximation is accurate almost to the cent. It is the cost of volatility (volatility drag): two strategies with the same arithmetic mean grow differently if one is more volatile. A brochure that advertises “an average of 8.6% a year” using SINT's arithmetic mean promises more than the capital would actually have grown.
Describing the distribution
Mean and volatility summarize the center and the dispersion. Two more statistics describe the shape:
- Skewness: negative if the tail of losses is longer than the tail of gains (sharp falls and slow rises, typical of equity indices); positive in the opposite case (typical of trend-following strategies, with many small losses and a few large gains). The normal has 0.
- Kurtosis: measures the weight of the tails. The normal has a kurtosis of 3; the excess kurtosis subtracts that 3, and it is what pandas and, by default, SciPy return. A positive value means fatter tails than the normal: more extreme days than the normal expects.
from scipy import stats
r = rent["simple"]
print(r.describe().round(4).to_string())
print(f"Skewness pandas {r.skew():.3f} · scipy "
f"{stats.skew(r):.3f}")
print(f"Excess kurtosis pandas {r.kurt():.3f} · scipy "
f"{stats.kurtosis(r):.3f}")count 1302.0000
mean 0.0003
std 0.0114
min -0.0481
25% -0.0068
50% 0.0006
75% 0.0074
max 0.0508
Skewness pandas -0.015 · scipy -0.015
Excess kurtosis pandas 1.084 · scipy 1.075describe() gives the count, the mean, the standard deviation, the minimum, the quartiles and the maximum. The median
(50%) sits slightly above the mean, and the worst day (−4.81%) and the best (+5.08%) are more than four standard
deviations from the center. Skewness is practically zero: SINT has no bias toward falls. The excess kurtosis of 1.08
says the tails are fatter than those of the normal.
pandas and SciPy do not give exactly the same result. pandas corrects for sample bias (like the standard deviation
with ); SciPy, by default, does not (bias=True). With 1,302 data points the difference is in the second
decimal; with 60, it is not. When comparing figures from two sources, check which function computed them.
The figure at the top draws the full distribution: the bars are SINT's daily returns; the curve, a normal with the same mean and the same standard deviation. The central bar sticks out above the curve (more quiet days) and at the extremes there are bars where the curve barely reaches. Counted:
fuera = int((r.abs() > 0.035).sum())
normal = stats.norm(loc=r.mean(), scale=r.std())
esperados = len(r) * (normal.cdf(-0.035) + normal.sf(0.035))
print(f"Days with |R| > 3.5%: {fuera} · normal: {esperados:.1f}")Days with |R| > 3.5%: 11 · normal: 2.8Almost four times as many extreme days as a normal predicts. That is the form of kurtosis that matters in risk: a model that assumes normal returns underestimates how often bad days happen. Lesson 0.7.2 measures that gap with probabilities and tries a distribution with fatter tails, Student's t.
Exercises
- Check the self-assessment example in code: a +10% followed by a −10%. Compute the total return with simple returns (product) and with log returns (sum). What rise is needed after the −10% to get back to the starting point?
Solution
dias = np.array([0.10, -0.10])
print(f"Product of simple: {np.prod(1 + dias) - 1:+.2%}")
print(f"Sum of log: {np.log1p(dias).sum():+.4f}")
print(f"exp(sum) - 1: {np.expm1(np.log1p(dias).sum()):+.2%}")
print(f"Rise needed: {1 / 0.9 - 1:+.2%}")Product of simple: -1.00%
Sum of log: -0.0101
exp(sum) - 1: -1.00%
Rise needed: +11.11%Both routes give the same −1%: the sum of logs is −0.0101, and . Recovering a −10% takes a
+11.11%. np.expm1(x) is , the inverse of np.log1p.
- Compute the annualized volatility, skewness and excess kurtosis of the daily returns of the four assets in
cartera_sint.csv. Which one has the fattest tails?
Solution
cartera = pd.read_csv("cartera_sint.csv", parse_dates=["fecha"],
index_col="fecha")
rc = cartera.pct_change().dropna()
resumen = pd.DataFrame({
"vol_anual": rc.std() * np.sqrt(252),
"asimetria": rc.skew(),
"curtosis_exceso": rc.kurt(),
})
resumen.round(3) vol_anual asimetria curtosis_exceso
RVAR 0.168 0.013 0.087
RFIJ 0.050 -0.087 -0.197
ORO 0.151 0.048 -0.103
TECH 0.283 0.039 0.147DataFrame methods compute each statistic per column, so each column of the table comes out in one line. TECH, the
most volatile, has the highest kurtosis (0.147), but all four are close to zero: these assets were generated with
constant volatility and a normal distribution. SINT, with two episodes of high volatility, reaches 1.08. Mixing quiet
periods and turbulent periods is one of the causes of fat tails, and in real market series the tails are usually
fatter still than in SINT.
- Compute SINT's CAGR for each calendar year (from the last close of the previous year to the last close of the year, and for 2021 from the first close) and compare it with the annualized arithmetic mean of that year's daily returns. In which year is the difference largest, and why?
Solution
fin = cierre.groupby(cierre.index.year).last()
base = fin.shift(1).fillna(cierre.iloc[0])
anual = pd.DataFrame({
"rent_anual": fin / base - 1,
"media_x252": rent["simple"].groupby(
rent.index.year).mean() * 252,
"vol": rent["simple"].groupby(rent.index.year).std()
* np.sqrt(252),
})
anual["diferencia"] = anual["media_x252"] - anual["rent_anual"]
anual.round(3) rent_anual media_x252 vol diferencia
fecha
2021 0.152 0.150 0.160 -0.002
2022 -0.085 -0.055 0.248 0.030
2023 0.142 0.140 0.149 -0.002
2024 0.143 0.140 0.150 -0.003
2025 0.042 0.056 0.179 0.014Each year is a full year, so the year's return is its CAGR. In 2022, the most volatile year (24.8%), the annualized arithmetic mean exceeds the actual return by 3 points; in 2025 (17.9%), by 1.4. That is the cost of volatility, which grows with its square. In the quiet years the difference comes out almost zero or slightly negative: with 261 sessions, multiplying the daily mean by 252 falls short, and that error offsets a cost of volatility that, at 15%, is barely one point ().
Each answer is folded under its question
Self-assessment
A stock rises 10% one day and falls 10% the next. Where does it end up?
- ABack where it started
- B1% above
- CIt depends on the starting price
- D1% below: 1.10 × 0.90 = 0.99
Show answer
Answer D. 1% below: 1.10 × 0.90 = 0.99
Simple returns multiply, they do not add: (1 + 0.10) × (1 − 0.10) − 1 = −1%. Log returns do add, and simple returns of +10% and −10% are +9.53% and −10.54% in log terms.
You have the daily returns of two assets and a 50/50 portfolio rebalanced daily. How do you compute the portfolio's return for one day?
- AMean of the log returns
- BSum of the log returns
- CWeighted mean of the simple returns
- DProduct of the simple returns
Show answer
Answer C. Weighted mean of the simple returns
Across assets, within the same period, the portfolio's simple return is exactly the weighted mean of the simple returns. The weighted mean of the log returns is not the portfolio's log return.
An asset's daily volatility is 1%. What is its annualized volatility under the usual convention?
- A252%
- B15.9%, 1% multiplied by the square root of 252
- C12%, 1% times 12 months
- D1%, volatility is not annualized
Show answer
Answer B. 15.9%, 1% multiplied by the square root of 252
If daily returns are independent, the variance grows with the number of days and the standard deviation with its square root: 1% × √252 ≈ 15.9%.
A return series has an excess kurtosis of 1.1 according to
Series.kurt(). What does it mean?- AIt has more extreme days (and more quiet days) than a normal distribution with the same volatility
- BThe distribution is normal
- CThe mean is greater than the median
- DThere is a mistake: kurtosis cannot be positive
Show answer
Answer A. It has more extreme days (and more quiet days) than a normal distribution with the same volatility
pandas returns the excess kurtosis: 0 for a normal distribution. A positive value means fatter tails and a taller center; in risk terms, more extreme days than the normal predicts.
Key points
- Simple return: today's close divided by yesterday's, minus 1; log return: the natural log of that ratio, ln(1 + R). For small daily moves they almost match; for large moves, they do not.
- Over time, simple returns multiply and log returns add. Across assets, the portfolio's simple return is the weighted mean of the simple returns.
- CAGR is computed with the calendar years elapsed. The annualized arithmetic mean exceeds it by about σ²/2: volatility subtracts growth.
- The mean is annualized by 252 and the volatility by √252, assuming independent days and the calendar of a real market.
- Real returns have fatter tails than the normal: positive excess kurtosis and more extreme days than the normal expects.