How do I measure return and risk?

Compute CAGR, annualised volatility, Sharpe and Sortino on a return series, then put all of them in one function.

Four numbers summarise a return series: CAGR, annualised volatility, Sharpe and Sortino. CAGR is the growth rate, volatility is the spread, and the two ratios divide one by the other.

In this lesson I build each of the four on AAPL held from the first day to the last, one at a time, then wrap them plus Calmar into a function perf_stats and run it on all four tickers.

Step 1. The return series

prices.csv sits next to this lesson and holds simulated data: six years of daily closes for four tickers. I take AAPL, put the dates on the index, and turn the closes into daily returns.

import numpy as np
import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])

aapl  = prices[prices["Ticker"] == "AAPL"].sort_values("Date")
close = aapl.set_index("Date")["Close"]
ret   = close.pct_change().dropna()

print(len(ret))                                  # -> 1565
print(ret.index[0].date(), "to", ret.index[-1].date())
# -> 2020-01-02 to 2025-12-31
1565
2020-01-02 to 2025-12-31

Compounding those returns gives the total for the whole stretch.

total = (1 + ret).prod() - 1

print(f"{total:.2%}")                            # -> 165.45%
165.45%

Step 2. CAGR

CAGR is the constant annual growth rate that turns the first price into the last over the time that actually elapsed. So I need the elapsed years, and I get them from the dates.

days  = (ret.index[-1] - ret.index[0]).days
years = days / 365.25

print(days)                                      # -> 2190
print(round(years, 4))                           # -> 5.9959
2190
5.9959

Now raise the total growth factor to the power 1 / years.

cagr = (1 + total) ** (1 / years) - 1

print(f"{cagr:.2%}")                             # -> 17.68%
17.68%

Growing at 17.68% a year for 5.9959 years reproduces the 165.45%.

print(f"{(1 + cagr) ** years - 1:.2%}")          # -> 165.45%
165.45%

Counting the years as len(ret) / 252 instead gives a different answer, because 1565 trading days over 252 is 6.21 years, and the sample only ran 6.00 calendar years.

print(f"{(1 + total) ** (252 / len(ret)) - 1:.2%}")   # -> 17.02%
17.02%

Step 3. Annualised volatility

Annualised volatility is the standard deviation of the daily returns, restated as a one year figure. Multiply the daily standard deviation by sqrt(252).

ann_vol = ret.std() * np.sqrt(252)

print(round(ret.std(), 6))                       # -> 0.014805
print(f"{ann_vol:.2%}")                          # -> 23.50%
0.014805
23.50%

AAPL moved about 1.48% on an average day, which is 23.50% over a year.

Step 4. Sharpe

Sharpe is the annualised mean return divided by the annualised volatility. I set the risk free rate to zero, so nothing is subtracted from the mean. With a rate rf, the numerator is ann_mean - rf.

ann_mean = ret.mean() * 252

print(round(ret.mean(), 6))                      # -> 0.000734
print(f"{ann_mean:.2%}")                         # -> 18.48%

sharpe = ann_mean / ann_vol

print(round(sharpe, 4))                          # -> 0.7865
0.000734
18.48%
0.7865

The mean scales with 252 and the standard deviation with sqrt(252), because means add over days while variances add over days, so the ratio of the two carries exactly one sqrt(252).

print(round(ret.mean() / ret.std(), 6))                     # -> 0.049547
print(round(ret.mean() / ret.std() * np.sqrt(252), 4))      # -> 0.7865
0.049547
0.7865

The daily ratio is 0.049547, and multiplying it by sqrt(252) gives the same 0.7865.

Step 5. Sortino

Sortino keeps the same numerator and changes the denominator: instead of the standard deviation of all returns, it uses the downside deviation, which counts only the days that lost money. Replace every positive return with zero, square, average over all days, take the square root, then annualise with sqrt(252).

down = np.minimum(ret, 0)

print((ret < 0).sum(), "down days out of", len(ret))    # -> 748 down days out of 1565

downside = np.sqrt((down ** 2).mean()) * np.sqrt(252)

print(f"{downside:.2%}")                         # -> 16.09%

sortino = ann_mean / downside

print(round(sortino, 4))                         # -> 1.1489
748 down days out of 1565
16.09%
1.1489

The downside deviation is 16.09% against 23.50% for the full volatility, so Sortino comes out above Sharpe on the same series.

Step 6. All of it in one function

perf_stats takes a return series and its dates and returns a Series with one entry per measure. I add Calmar, which is CAGR divided by the absolute value of the maximum drawdown from Lesson 32.

def perf_stats(returns, dates):
    """CAGR, annualised vol, Sharpe, Sortino, max drawdown and Calmar."""
    years    = (dates[-1] - dates[0]).days / 365.25
    total    = (1 + returns).prod() - 1
    cagr     = (1 + total) ** (1 / years) - 1

    ann_mean = returns.mean() * 252
    ann_vol  = returns.std() * np.sqrt(252)
    downside = np.sqrt((np.minimum(returns, 0) ** 2).mean()) * np.sqrt(252)

    equity   = (1 + returns).cumprod()
    max_dd   = (equity / equity.cummax() - 1).min()

    return pd.Series({
        "CAGR":    cagr,
        "Ann vol": ann_vol,
        "Sharpe":  ann_mean / ann_vol,
        "Sortino": ann_mean / downside,
        "Max DD":  max_dd,
        "Calmar":  cagr / abs(max_dd),
    })

On AAPL it reproduces every number from the steps above.

print(perf_stats(ret, ret.index).round(4))
# -> CAGR       0.1768
# -> Ann vol    0.2350
# -> Sharpe     0.7865
# -> Sortino    1.1489
# -> Max DD    -0.2982
# -> Calmar     0.5930
# -> dtype: float64
CAGR       0.1768
Ann vol    0.2350
Sharpe     0.7865
Sortino    1.1489
Max DD    -0.2982
Calmar     0.5930
dtype: float64

One call per ticker, collected with pd.concat along the columns and transposed so each ticker gets a row.

rows = {}

for ticker in ["AAPL", "MSFT", "NVDA", "JNJ"]:
    one = prices[prices["Ticker"] == ticker].sort_values("Date")
    r   = one.set_index("Date")["Close"].pct_change().dropna()
    rows[ticker] = perf_stats(r, r.index)

table = pd.concat(rows, axis=1).T

print(table.round(4))
# ->         CAGR  Ann vol  Sharpe  Sortino  Max DD  Calmar
# -> AAPL  0.1768   0.2350  0.7865   1.1489 -0.2982  0.5930
# -> MSFT  0.3152   0.2081  1.3756   2.0619 -0.3295  0.9565
# -> NVDA  0.2453   0.3845  0.7429   1.1039 -0.5597  0.4382
# -> JNJ   0.1290   0.1445  0.8830   1.2981 -0.2017  0.6398
        CAGR  Ann vol  Sharpe  Sortino  Max DD  Calmar
AAPL  0.1768   0.2350  0.7865   1.1489 -0.2982  0.5930
MSFT  0.3152   0.2081  1.3756   2.0619 -0.3295  0.9565
NVDA  0.2453   0.3845  0.7429   1.1039 -0.5597  0.4382
JNJ   0.1290   0.1445  0.8830   1.2981 -0.2017  0.6398

NVDA grew at 24.53% a year and JNJ at 12.90%, but NVDA moved 38.45% a year against JNJ’s 14.45% and fell 55.97% from its peak against JNJ’s 20.17%. Ranking on Sharpe reverses the pair.

print(table["Sharpe"].sort_values(ascending=False).round(2))
# -> MSFT    1.38
# -> JNJ     0.88
# -> AAPL    0.79
# -> NVDA    0.74
# -> Name: Sharpe, dtype: float64
MSFT    1.38
JNJ     0.88
AAPL    0.79
NVDA    0.74
Name: Sharpe, dtype: float64

Your turn

Every Sharpe above uses a risk free rate of zero. Recompute the Sharpe of MSFT buy and hold with a risk free rate of 4% a year. How much does it drop?

import numpy as np
import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])

msft = prices[prices["Ticker"] == "MSFT"].sort_values("Date")
ret  = msft.set_index("Date")["Close"].pct_change().dropna()

ann_mean = ret.mean() * 252
ann_vol  = ret.std() * np.sqrt(252)

print(round(ann_mean / ann_vol, 4))              # -> 1.3756
print(round((ann_mean - 0.04) / ann_vol, 4))     # -> 1.1835

Subtracting 4% costs 0.19 of Sharpe, which is 0.04 divided by the 20.81% annualised volatility.