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 npimport pandas as pdprices = pd.read_csv("prices.csv", parse_dates=["Date"]) # dates parsed, not stringsaapl = prices[prices["Ticker"] =="AAA"].sort_values("Date") # one ticker, in date orderclose = aapl.set_index("Date")["Close"] # closes keyed by dateret = close.pct_change().dropna() # daily percent changesprint(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() -1# whole period growth, compoundedprint(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 daysyears = days /365.25# 365.25 absorbs the leap yearsprint(days) # -> 2190print(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 totalprint(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.
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 figureprint(round(ret.std(), 6)) # -> 0.014805print(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 daysprint(round(ret.mean(), 6)) # -> 0.000734print(f"{ann_mean:.2%}") # -> 18.48%sharpe = ann_mean / ann_vol # return earned per unit of total riskprint(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).
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 keptprint((ret <0).sum(), "down days out of", len(ret)) # -> 748 down days out of 1565downside = np.sqrt((down **2).mean()) * np.sqrt(252) # root mean square of the lossesprint(f"{downside:.2%}") # -> 16.09%sortino = ann_mean / downside # same return per unit of downside riskprint(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 peakreturn 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.
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 Seriesfor 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 ittable = pd.concat(rows, axis=1).T # tickers as rows after the Tprint(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
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.