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.

Fig. — SINT daily returns, 2021–2025, against a normal with the same mean and standard deviation. Vertical axis on a square-root scale so the tails show
01/03
Module
0.7 · Minimum mathematics and statistics
Duration
50 min
Updated
Level 0 lessons
  1. 0.1 · Welcome and expectations

    1. 0.1.1 Systematic, algorithmic and quantitative trading versus discretionary trading
    2. 0.1.2 Realistic expectations: costs, time and risk of loss
    3. 0.1.3 Course structure, entry tests, personal repository and learning with AI
  2. 0.2 · Modern working environment

    1. 0.2.1 Installing Python with uv: versions and support cycle
    2. 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
    3. 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Basic terminal, paths, environment variables and .env files
    5. 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
    6. 0.2.6 Introduction to AI coding assistants: prompts, review and tests
  3. 0.3 · Language fundamentals

    1. 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
    2. 0.3.2 Strings, f-strings and formatting numbers and dates
    3. 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
    4. 0.3.4 Control flow: if, elif, else, for, while and match-case
    5. 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
    6. 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
  4. 0.4 · Intermediate Python

    1. 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
    2. 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
    3. 0.4.3 Iterators, generators, decorators and context managers
    4. 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
    5. 0.4.5 Professional logging and testing with pytest
    6. 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
  5. 0.5 · Numerical computing and data

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
    2. 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
    3. 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
    4. 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
    5. 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
  6. 0.6 · Visualisation

    1. 0.6.1 Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
    2. 0.6.2 Quick dashboards with Streamlit: a monitoring dashboard in a single script
  7. 0.7 · Minimum mathematics and statistics

    1. 0.7.1 Simple and log returns, compound interest and descriptive statistics
    2. 0.7.2 Probability and distributions, correlation versus causation and simple regression
    3. 0.7.3 Intuitive linear algebra: vectors and matrices as groundwork for portfolios

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:

Rt=PtPt−1−1R_t = \frac{P_t}{P_{t-1}} - 1

The log return (or continuously compounded return) is the natural log of the price ratio:

rt=ln⁡PtPt−1=ln⁡(1+Rt)r_t = \ln\frac{P_t}{P_{t-1}} = \ln(1 + R_t)

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:

IN [01]python
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)
OUT [01]Simple and log
             simple      log
fecha
2021-01-05  0.00111  0.00111
2021-01-06 -0.01884 -0.01902
2021-01-07  0.01212  0.01204

The 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:

IN [02]python
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%}")
OUT [02]Far from zero
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 ln⁡(1+R)\ln(1 + R) more precisely than np.log(1 + R) when RR 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:

1+R0→T=∏t=1T(1+Rt)r0→T=∑t=1Trt1 + R_{0 \to T} = \prod_{t=1}^{T} (1 + R_t) \qquad r_{0 \to T} = \sum_{t=1}^{T} r_t
IN [03]python
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%}")
OUT [03]Five-year return
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%:

IN [04]python
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%}")
OUT [04]A two-position portfolio
Portfolio (weighted simple):  +0.0000%
Weighted mean of the logs:    -2.0411%

The portfolio neither gains nor loses: 0%, and its log return is ln⁡(1)=0\ln(1) = 0. 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…UseBecause
Chain periods (days into years)Log (sum) or simple (product)Both work; the sum of logs is more convenient
Combine the assets of a portfolioSimpleThe portfolio's simple return is the weighted mean of the simple returns
Report to a personSimple“It made 7%” is understood; “0.068 in log”, not
Statistical models (lesson 0.7.2)Log, oftenThey 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 C0C_0 growing at a rate gg per year for nn years ends at C0(1+g)nC_0 (1 + g)^n:

IN [05]python
capital = 10_000
for anios in (10, 20, 30):
    print(f"{anios} years: {capital * 1.07 ** anios:>9,.0f} €")
OUT [05]Ten thousand euros at 7%
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, eg−1e^{g} - 1, which is precisely the relationship between log and simple returns:

IN [06]python
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%}")
OUT [06]Compounding frequency
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:

CAGR=(PTP0)1/years−1\text{CAGR} = \left(\frac{P_T}{P_0}\right)^{1/\text{years}} - 1
IN [07]python
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}")
OUT [07]SINT's CAGR
Years: 4.99 · CAGR: 7.50%
Sessions per year: 261

The 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:

μannual=252 μdailyσannual=252 σdaily\mu_{\text{annual}} = 252\,\mu_{\text{daily}} \qquad \sigma_{\text{annual}} = \sqrt{252}\,\sigma_{\text{daily}}

The annualized standard deviation is the volatility, the most widely used risk measure. It can be checked against monthly data, which are annualized with 12\sqrt{12}:

IN [08]python
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%}")
OUT [08]Daily and monthly volatility
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 252\sqrt{252}, 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:

r‾≈R‾−σ22\overline{r} \approx \overline{R} - \frac{\sigma^2}{2}
IN [09]python
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%}")
OUT [09]Arithmetic versus compound mean
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.
IN [10]python
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}")
OUT [10]Descriptive statistics
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.075

describe() 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 n−1n - 1); 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:

IN [11]python
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}")
OUT [11]Extreme days versus the normal
Days with |R| > 3.5%: 11 · normal: 2.8

Almost 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

  1. 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
IN [12]python
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%}")
OUT [12]There and back
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 e−0.0101−1=−1%e^{-0.0101} - 1 = -1\%. Recovering a −10% takes a +11.11%. np.expm1(x) is ex−1e^x - 1, the inverse of np.log1p.

  1. 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
IN [13]python
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)
OUT [13]The four assets
      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.147

DataFrame 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.

  1. 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
IN [14]python
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)
OUT [14]Year by year
       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.014

Each 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 (0.152/2≈1.1%0.15^2/2 \approx 1.1\%).

Each answer is folded under its question

Self-assessment

  1. 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.

  2. 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.

  3. 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%.

  4. 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.

Resources