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 npimport pandas as pdprices = 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)) # -> 1565print(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() -1print(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]).daysyears = days /365.25print(days) # -> 2190print(round(years, 4)) # -> 5.9959
2190
5.9959
Now raise the total growth factor to the power 1 / years.
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.
Annualised volatility is the standard deviation of the daily returns, restated as a one year figure. Multiply the daily standard deviation by sqrt(252).
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.
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).
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 1565downside = np.sqrt((down **2).mean()) * np.sqrt(252)print(f"{downside:.2%}") # -> 16.09%sortino = ann_mean / downsideprint(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.
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.