---
title: "0.7.1 Returns, compound interest and statistics"
description: "Simple and log returns, how they add up, compound interest and CAGR, annualizing with √252, and the mean, volatility, skewness and kurtosis of SINT."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/returns-and-descriptive-statistics/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/returns-and-descriptive-statistics.png"
---

# 0.7.1 · 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.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- 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()](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-3-series-and-dataframe/)

## By the end of this lesson

- Compute simple and log returns with pandas and choose the right one to aggregate over time or across assets.
- Compute compound interest and the compound annual growth rate (CAGR) of a price series with real dates.
- Annualize the mean and the volatility (×252 and ×√252) and explain why the arithmetic mean exceeds the compound return.
- Describe a return series with mean, standard deviation, skewness and excess kurtosis, using pandas and SciPy.

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

$$
R_t = \frac{P_t}{P_{t-1}} - 1
$$

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

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

```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)
```
```output
             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:

```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%}")
```
```output
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)$ more precisely than `np.log(1 + R)` when $R$ 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 + R_{0 \to T} = \prod_{t=1}^{T} (1 + R_t) \qquad r_{0 \to T} = \sum_{t=1}^{T} r_t
$$

```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%}")
```
```output
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%:

```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%}")
```
```output
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$. 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 $C_0$ growing at a rate $g$ per year for $n$ years ends at $C_0 (1 + g)^n$:

```python
capital = 10_000
for anios in (10, 20, 30):
    print(f"{anios} years: {capital * 1.07 ** anios:>9,.0f} €")
```
```output
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, $e^{g} - 1$, which is precisely the relationship between log and simple
returns:

```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%}")
```
```output
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:

$$
\text{CAGR} = \left(\frac{P_T}{P_0}\right)^{1/\text{years}} - 1
$$

```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}")
```
```output
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.

<aside class="callout" data-kind="aviso">

The 252 is a convention: the number of sessions in a typical year on the US stock market. A future that trades almost
every day, a cryptocurrency (365 days) or a series without holidays like SINT have a different number. To annualize
means and volatilities, the course uses 252 for consistency with the literature; for CAGR, always the dates.

</aside>

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

$$
\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 $\sqrt{12}$:

```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%}")
```
```output
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 $\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:

$$
\overline{r} \approx \overline{R} - \frac{\sigma^2}{2}
$$

```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%}")
```
```output
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.

```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}")
```
```output
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 - 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:

```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}")
```
```output
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.

<aside class="callout" data-kind="riesgo">

Mean, volatility and kurtosis are estimated from the past. Five years of data without a crisis give a volatility and
tails that the next crisis can disprove on its first day. A risk figure without the period it was computed over cannot
be assessed.

</aside>

## 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.**

```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%}")
```
```output
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\%$. Recovering a −10% takes a
+11.11%. `np.expm1(x)` is $e^x - 1$, the inverse of `np.log1p`.

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

```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)
```
```output
      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.

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

```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)
```
```output
       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.15^2/2 \approx 1.1\%$).

## Self-assessment

1. A stock rises 10% one day and falls 10% the next. Where does it end up?

   - A. Back where it started
   - B. 1% above
   - C. It depends on the starting price
   - D. 1% below: 1.10 × 0.90 = 0.99

   *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?

   - A. Mean of the log returns
   - B. Sum of the log returns
   - C. Weighted mean of the simple returns
   - D. Product of the simple returns

   *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?

   - A. 252%
   - B. 15.9%, 1% multiplied by the square root of 252
   - C. 12%, 1% times 12 months
   - D. 1%, volatility is not annualized

   *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?

   - A. It has more extreme days (and more quiet days) than a normal distribution with the same volatility
   - B. The distribution is normal
   - C. The mean is greater than the median
   - D. There is a mistake: kurtosis cannot be positive

   *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

### Research

- [Volatility Estimators for Trading](https://pythonparatrading.com/en/volatility-estimators-for-trading/) · Research-0002 · 4 min

### Documentation

- [pandas: Series.pct_change](https://pandas.pydata.org/docs/reference/api/pandas.Series.pct_change.html) · pandas.pydata.org
- [pandas: Series.kurt (Fisher's excess kurtosis)](https://pandas.pydata.org/docs/reference/api/pandas.Series.kurt.html) · pandas.pydata.org
- [SciPy: scipy.stats.kurtosis](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.kurtosis.html) · docs.scipy.org

### Data

- [Level 0 data: daily prices of the fictional ETF SINT (CSV)](https://pythonparatrading.com/curso/datos/nivel-0/sint_diario.csv) · Download · sint_diario.csv

Previous lesson: [0.6.2 · Quick dashboards with Streamlit: a monitoring dashboard in a single script](https://pythonparatrading.com/en/python-trading-course/level-0/streamlit-dashboards/)

Next lesson: [0.7.2 · Probability and distributions, correlation versus causation and simple regression](https://pythonparatrading.com/en/python-trading-course/level-0/probability-distributions-and-regression/)
