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 AAA 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 AAA, 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"])        # dates parsed, not strings

aapl  = prices[prices["Ticker"] == "AAA"].sort_values("Date")  # one ticker, in date order
close = aapl.set_index("Date")["Close"]                         # closes keyed by date
ret   = close.pct_change().dropna()                             # daily percent changes

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                     # whole period growth, compounded

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      # calendar days spanned, not trading days
years = days / 365.25                            # 365.25 absorbs the leap years

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            # the yearly rate that yields that total

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)               # daily spread restated as a one year figure

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

AAA 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                      # daily mean scaled by the trading days

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

sharpe = ann_mean / ann_vol                      # return earned per unit of total risk

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)                        # gains flattened to zero, losses kept

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)   # root mean square of the losses

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

sortino = ann_mean / downside                    # same return per unit of downside risk

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):                      # one series in, six measures out
    """CAGR, annualised vol, Sharpe, Sortino, max drawdown and Calmar."""
    years    = (dates[-1] - dates[0]).days / 365.25  # elapsed years from the two end dates
    total    = (1 + returns).prod() - 1              # growth over the whole sample
    cagr     = (1 + total) ** (1 / years) - 1        # that growth restated as a yearly rate

    ann_mean = returns.mean() * 252                  # numerator of both ratios
    ann_vol  = returns.std() * np.sqrt(252)          # denominator of Sharpe
    downside = np.sqrt((np.minimum(returns, 0) ** 2).mean()) * np.sqrt(252)

    equity   = (1 + returns).cumprod()               # one unit compounded through the series
    max_dd   = (equity / equity.cummax() - 1).min()  # worst fall from a running peak

    return pd.Series({                               # labelled, so the caller gets names
        "CAGR":    cagr,
        "Ann vol": ann_vol,
        "Sharpe":  ann_mean / ann_vol,
        "Sortino": ann_mean / downside,
        "Max DD":  max_dd,
        "Calmar":  cagr / abs(max_dd),               # growth per unit of worst drawdown
    })

On AAA 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 = {}                                                         # ticker to its stats Series

for ticker in ["AAA", "CCC", "DDD", "BBB"]:                    # same recipe, four times
    one = prices[prices["Ticker"] == ticker].sort_values("Date")  # this ticker, in date order
    r   = one.set_index("Date")["Close"].pct_change().dropna()    # its daily returns
    rows[ticker] = perf_stats(r, r.index)                         # keyed so concat can label it

table = pd.concat(rows, axis=1).T                                 # tickers as rows after the T

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

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

print(table["Sharpe"].sort_values(ascending=False).round(2))
# -> CCC    1.38
# -> BBB    0.88
# -> AAA    0.79
# -> DDD    0.74
# -> Name: Sharpe, dtype: float64
CCC    1.38
BBB    0.88
AAA    0.79
DDD    0.74
Name: Sharpe, dtype: float64

Your turn

Every Sharpe above uses a risk free rate of zero. Recompute the Sharpe of CCC 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"] == "CCC"].sort_values("Date")
ret  = msft.set_index("Date")["Close"].pct_change().dropna()

ann_mean = ret.mean() * 252                      # annualised return, nothing deducted yet
ann_vol  = ret.std() * np.sqrt(252)              # unchanged by the risk free rate

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.

These same measures run on actual market data in Are markets overvalued?.