How do I measure return and risk?

Compute CAGR, annualised volatility, Sharpe and Sortino from a return series, then run all four on every ticker.

Four numbers summarise a return series. CAGR is the growth rate, annualised volatility is the spread, and Sharpe and Sortino divide the first by a version of the second.

In this lesson I build each one on AAA, then wrap all four in a function and run it on all four tickers.

Step 1. The return series

prices.csv sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025.

library(dplyr)

p      <- read.csv("prices.csv", stringsAsFactors = FALSE)
p$Date <- as.Date(p$Date)

aapl  <- p |> filter(Ticker == "AAA") |> arrange(Date)   # one ticker, oldest first
close <- aapl$Close
dates <- aapl$Date

r <- diff(close) / head(close, -1)                        # simple returns, from Lesson 8

print(length(close))            # -> [1] 1566
[1] 1566
print(length(r))                # -> [1] 1565    <- one fewer, the first day has no prior close
[1] 1565
print(round(mean(r), 6))        # -> [1] 0.000734
[1] 0.000734
print(round(sd(r), 6))          # -> [1] 0.014805
[1] 0.014805

Step 2. CAGR

CAGR is the constant yearly rate that would have produced the same total growth. Take the ending value of one unit of money, raise it to one over the number of years, and subtract 1.

The years come from the dates themselves, not from counting rows. A trading year is roughly 252 rows, but the real elapsed time is what compounding cares about.

equity <- cumprod(1 + r)                                  # growth of one unit, from Lesson 18
total  <- equity[length(equity)] - 1                      # total return over the whole sample

years  <- as.numeric(difftime(dates[length(dates)],       # last date minus first date
                              dates[1], units = "days")) / 365.25

cagr <- (1 + total)^(1 / years) - 1                       # the constant yearly rate

print(round(years, 4))          # -> [1] 5.9986
[1] 5.9986
print(round(100 * total, 2))    # -> [1] 165.45
[1] 165.45
print(round(100 * cagr, 2))     # -> [1] 17.67
[1] 17.67

165.45% over six years is 17.67% a year compounded.

Step 3. Annualised volatility

Volatility is the standard deviation of the returns. Daily standard deviations annualise by multiplying by the square root of 252, the usual count of trading days in a year.

ann_vol <- sd(r) * sqrt(252)                  # daily spread scaled up to a year

print(round(100 * ann_vol, 2))  # -> [1] 23.5
[1] 23.5

Step 4. Sharpe

Sharpe is the annualised mean return divided by the annualised volatility. I set the risk free rate to zero and say so, rather than leaving it unstated.

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

ann_mean <- mean(r) * 252                     # daily mean scaled up to a year
sharpe   <- ann_mean / ann_vol                # risk free rate taken as zero

print(round(ann_mean, 4))       # -> [1] 0.1848
[1] 0.1848
print(round(sharpe, 3))         # -> [1] 0.787
[1] 0.787
print(round(mean(r) / sd(r) * sqrt(252), 3))  # -> [1] 0.787   <- the same, in one step
[1] 0.787

The daily ratio times sqrt(252) gives the same number, which is what the scaling argument says.

Step 5. Sortino

Sharpe treats a big up day and a big down day as equally bad, because both widen the standard deviation. Sortino divides by the downside deviation instead, which counts only the days below zero.

pmin(r, 0) from Lesson 22 keeps the negative returns and turns the positive ones into zero.

downside <- sqrt(mean(pmin(r, 0)^2)) * sqrt(252)   # spread of the losing days only
sortino  <- ann_mean / downside                    # same numerator as Sharpe

print(round(100 * downside, 2))  # -> [1] 16.09
[1] 16.09
print(round(sortino, 3))         # -> [1] 1.149
[1] 1.149

The downside deviation is 16.09% against 23.5% for the full volatility, so Sortino comes out higher than Sharpe on this series.

Step 6. All four in one function

Every line above fits in one function, using the pattern from Lesson 9.

perf_stats <- function(close, dates, freq = 252) {
  r      <- diff(close) / head(close, -1)                 # returns from the price vector
  equity <- cumprod(1 + r)                                # one unit of money compounded
  years  <- as.numeric(difftime(dates[length(dates)],      # elapsed years from the dates
                                dates[1], units = "days")) / 365.25

  ann_mean <- mean(r) * freq                              # mean scales with freq
  ann_vol  <- sd(r) * sqrt(freq)                          # sd scales with sqrt(freq)
  downside <- sqrt(mean(pmin(r, 0)^2)) * sqrt(freq)       # losing days only

  c(CAGR    = equity[length(equity)]^(1 / years) - 1,
    Vol     = ann_vol,
    Sharpe  = ann_mean / ann_vol,
    Sortino = ann_mean / downside)
}

print(round(perf_stats(close, dates), 4))
   CAGR     Vol  Sharpe Sortino 
 0.1767  0.2350  0.7865  1.1489 
# ->    CAGR     Vol  Sharpe Sortino
# ->  0.1767  0.2350  0.7865  1.1489

Running it on each ticker and stacking the rows gives one table. do.call(rbind, ...) glues a list of named vectors into a matrix.

tickers <- c("AAA", "CCC", "DDD", "BBB")

tab <- do.call(rbind, lapply(tickers, function(tk) {      # one row per ticker
  d <- p |> filter(Ticker == tk) |> arrange(Date)         # that ticker, oldest first
  perf_stats(d$Close, d$Date)                             # the four numbers
}))
rownames(tab) <- tickers                                  # label the rows

print(round(tab, 4))
      CAGR    Vol Sharpe Sortino
AAA 0.1767 0.2350 0.7865  1.1489
CCC 0.3150 0.2081 1.3756  2.0619
DDD 0.2451 0.3845 0.7429  1.1039
BBB 0.1290 0.1445 0.8830  1.2981
# ->        CAGR    Vol Sharpe Sortino
# -> AAA 0.1767 0.2350 0.7865  1.1489
# -> CCC 0.3150 0.2081 1.3756  2.0619
# -> DDD 0.2451 0.3845 0.7429  1.1039
# -> BBB 0.1290 0.1445 0.8830  1.2981

DDD grew faster than AAA, at 24.51% a year against 17.67%, and its volatility is 38.45% against 23.50%. Divided out, their Sharpe ratios are 0.74 and 0.79. BBB grew slowest and has the highest Sharpe of the two, because its volatility is a third of DDD’s.

These are backtested figures on simulated prices over one sample. They describe what happened, not what comes next.

Your turn

Add Calmar to perf_stats. Calmar is CAGR divided by the absolute value of the maximum drawdown from Lesson 26. Run the new function on all four tickers.

perf_stats2 <- function(close, dates, freq = 252) {
  r      <- diff(close) / head(close, -1)
  equity <- cumprod(1 + r)
  years  <- as.numeric(difftime(dates[length(dates)], dates[1], units = "days")) / 365.25

  ann_mean <- mean(r) * freq
  ann_vol  <- sd(r) * sqrt(freq)
  downside <- sqrt(mean(pmin(r, 0)^2)) * sqrt(freq)
  max_dd   <- min(equity / cummax(equity) - 1)            # from Lesson 26
  cagr     <- equity[length(equity)]^(1 / years) - 1

  c(CAGR = cagr, Vol = ann_vol, Sharpe = ann_mean / ann_vol,
    Sortino = ann_mean / downside, MaxDD = max_dd, Calmar = cagr / abs(max_dd))
}

tab2 <- do.call(rbind, lapply(tickers, function(tk) {
  d <- p |> filter(Ticker == tk) |> arrange(Date)
  perf_stats2(d$Close, d$Date)
}))
rownames(tab2) <- tickers

print(round(tab2, 4))
# ->        CAGR    Vol Sharpe Sortino   MaxDD Calmar
# -> AAA 0.1767 0.2350 0.7865  1.1489 -0.2982 0.5927
# -> CCC 0.3150 0.2081 1.3756  2.0619 -0.3295 0.9560
# -> DDD 0.2451 0.3845 0.7429  1.1039 -0.5597 0.4380
# -> BBB  0.1290 0.1445 0.8830  1.2981 -0.2017 0.6395

Lesson 28 puts the signal, the costs and these metrics into one function.