How certain is a Sharpe ratio?

Resample a return series with replacement two thousand times to see the range of Sharpe ratios the same data could have produced.

A Sharpe ratio is computed from one sample of returns, and a different sample would have given a different number. The bootstrap builds those other samples out of the returns I already have: draw the same number of days, with replacement, and compute the Sharpe of the draw. Two thousand draws later, the spread of the results is the range the estimate could have landed in.

In this lesson I bootstrap the Sharpe ratio of AAA, then the Sharpe of the 50-day SMA strategy from Lesson 23, compare the two intervals, and finish by drawing contiguous blocks of days instead of single ones.

Step 1. The series and its Sharpe

prices.csv sits next to this lesson and holds simulated daily closes for four tickers from 2020 to 2025. The Sharpe is the one from Lesson 27: the mean over the standard deviation, times the square root of 252.

library(dplyr)
library(zoo)

p      <- read.csv("prices.csv", stringsAsFactors = FALSE)
p$Date <- as.Date(p$Date)                                 # text becomes real dates

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

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

sharpe <- function(x) mean(x) / sd(x) * sqrt(252)         # annualised, risk free rate zero

print(length(r))                # -> [1] 1565
[1] 1565
print(round(sharpe(r), 3))      # -> [1] 0.787
[1] 0.787

Step 2. One resample

sample(r, length(r), replace = TRUE) draws 1565 returns out of the 1565 I have, putting each one back before the next draw. Some days come up twice, some not at all. The result is a new series of the same length made only of days that really happened.

set.seed(0) fixes the stream of random numbers, so every figure below comes out the same each time the page is rendered.

set.seed(0)                                        # same draws on every render

draw <- sample(r, length(r), replace = TRUE)       # 1565 days drawn back, repeats allowed

print(length(draw))             # -> [1] 1565
[1] 1565
print(round(sharpe(draw), 3))   # -> [1] 0.804
[1] 0.804

The real sample gave 0.787. This draw gave 0.804. Three more:

print(round(sharpe(sample(r, length(r), replace = TRUE)), 3))   # -> [1] 0.677
[1] 0.677
print(round(sharpe(sample(r, length(r), replace = TRUE)), 3))   # -> [1] 0.425
[1] 0.425
print(round(sharpe(sample(r, length(r), replace = TRUE)), 3))   # -> [1] 0.71
[1] 0.71

Same data, same rule, four answers between 0.425 and 0.804.

Step 3. Two thousand of them

replicate() runs an expression a set number of times and collects the results into a vector. That is the whole loop.

boot_sharpe <- function(x, n_draws = 2000, seed = 0) {
  set.seed(seed)                                                   # one seed for the whole run
  replicate(n_draws,                                               # n_draws repeats of the line below
            sharpe(sample(x, length(x), replace = TRUE)))          # Sharpe of a fresh resample
}

draws <- boot_sharpe(r)                                            # 2000 Sharpes for buy and hold

print(length(draws))            # -> [1] 2000
[1] 2000
print(round(mean(draws), 3))    # -> [1] 0.795
[1] 0.795
print(round(sd(draws), 3))      # -> [1] 0.4
[1] 0.4

The mean of the draws, 0.795, sits next to the sample figure of 0.787. The standard deviation of the draws, 0.4, is the standard error of the estimate.

quantile() cuts the draws at the 2.5th and 97.5th percentile, which leaves the middle 95%.

ci <- quantile(draws, c(0.025, 0.975))       # the middle 95% of the draws

print(round(ci, 3))
 2.5% 97.5% 
0.026 1.603 
# ->  2.5% 97.5%
# -> 0.026 1.603
print(round(unname(ci[2] - ci[1]), 3))       # -> [1] 1.577
[1] 1.577
print(round(100 * mean(draws < 0), 2))       # -> [1] 2.25
[1] 2.25

The point estimate is 0.787 and the interval runs from 0.026 to 1.603. It is 1.577 wide, twice the estimate itself, and its lower end is a hair above zero. 2.25% of the two thousand draws came out negative.

Step 4. The 50-day SMA strategy

Now the same on a strategy instead of a stock. This is the rule from Lesson 23: hold AAA when the close is above its 50-day average. The signal is lagged one row before it meets a return, which is what Lesson 24 requires.

sma50    <- rollmean(close, 50, fill = NA, align = "right")   # 50-day average, from Lesson 21
signal   <- ifelse(close > sma50, 1, 0)                       # 1 above the average, 0 below
position <- lag(signal)                                       # today I hold yesterday's signal

s <- position[-1] * r          # drop row 1 so position lines up with the return it earns
s <- s[!is.na(s)]              # drop the 50 warm-up rows the average needed

print(length(s))                            # -> [1] 1516
[1] 1516
print(round(100 * (prod(1 + s) - 1), 2))    # -> [1] 58.61
[1] 58.61
print(round(sharpe(s), 3))                  # -> [1] 0.512
[1] 0.512

boot_sharpe takes any vector of returns, so it runs on the strategy unchanged.

sdraws <- boot_sharpe(s)                       # same 2000 draws, strategy returns
sci    <- quantile(sdraws, c(0.025, 0.975))    # 95% interval for the strategy

cat(sprintf("buy and hold  %.3f  [%.3f, %.3f]  zero inside: %s\n",
            sharpe(r), ci[1], ci[2], ci[1] < 0 & ci[2] > 0))
buy and hold  0.787  [0.026, 1.603]  zero inside: FALSE
cat(sprintf("50-day SMA    %.3f  [%.3f, %.3f]  zero inside: %s\n",
            sharpe(s), sci[1], sci[2], sci[1] < 0 & sci[2] > 0))
50-day SMA    0.512  [-0.279, 1.335]  zero inside: TRUE
# -> buy and hold  0.787  [0.026, 1.603]  zero inside: FALSE
# -> 50-day SMA    0.512  [-0.279, 1.335]  zero inside: TRUE

print(round(100 * mean(sdraws < 0), 2))        # -> [1] 10.6
[1] 10.6

The strategy interval contains zero and the buy and hold one just misses it. The two overlap over almost their whole length, and 0.512 sits well inside [0.026, 1.603], so this sample does not separate the two Sharpe ratios. 10.6% of the strategy draws came out negative, against 2.25% for buy and hold.

Step 5. Blocks of twenty days

Drawing single days puts them in a random order, so any link between one day and the next is gone. If returns cluster, calm stretches followed by loud ones, the resampled series has none of that clustering. Drawing contiguous blocks of, say, 20 days keeps whatever happens inside a block.

block_sharpe <- function(x, block = 20, n_draws = 2000, seed = 0) {
  set.seed(seed)
  n      <- length(x)                                       # length to rebuild in each draw
  nb     <- ceiling(n / block)                              # blocks needed to cover n days
  starts <- 0:(n - block)                                   # every start a full block fits from
  replicate(n_draws, {
    st  <- sample(starts, nb, replace = TRUE)               # one start per block
    idx <- as.vector(outer(1:block, st, "+"))[1:n]          # start+1 ... start+20, laid end to end
    sharpe(x[idx])                                          # Sharpe of the stitched series
  })
}

bdraws <- block_sharpe(s)                                   # strategy, resampled in blocks
bci    <- quantile(bdraws, c(0.025, 0.975))                 # 95% interval from those draws

cat(sprintf("single days    [%.3f, %.3f]  width %.3f\n", sci[1], sci[2], sci[2] - sci[1]))
single days    [-0.279, 1.335]  width 1.613
cat(sprintf("20-day blocks  [%.3f, %.3f]  width %.3f\n", bci[1], bci[2], bci[2] - bci[1]))
20-day blocks  [-0.180, 1.313]  width 1.492
# -> single days    [-0.279, 1.335]  width 1.613
# -> 20-day blocks  [-0.180, 1.313]  width 1.492

print(round(100 * mean(bdraws < 0), 2))                     # -> [1] 5.9
[1] 5.9

The block interval is not wider. It is slightly narrower, 1.492 against 1.613. The reason is that there is almost no day to day dependence in this series for a block to carry.

print(round(cor(head(r, -1), tail(r, -1)), 4))   # -> [1] 0.0368   <- each day against the one before
[1] 0.0368
print(round(cor(head(s, -1), tail(s, -1)), 4))   # -> [1] 0.0588   <- same, on the strategy returns
[1] 0.0588

The lag-1 correlation is 0.0368 for the stock and 0.0588 for the strategy. On a series where consecutive days line up more strongly, the block version widens.

Your turn

Bootstrap the Sharpe ratio of BBB buy and hold from prices.csv, 2000 draws, seed 0. What is the 95% interval, and does it contain zero?

jnj <- p |> filter(Ticker == "BBB") |> arrange(Date)
jr  <- diff(jnj$Close) / head(jnj$Close, -1)

jdraws <- boot_sharpe(jr)
jci    <- quantile(jdraws, c(0.025, 0.975))

cat(sprintf("BBB %.3f  [%.3f, %.3f]  zero inside: %s\n",
            sharpe(jr), jci[1], jci[2], jci[1] < 0 & jci[2] > 0))
# -> BBB 0.883  [0.096, 1.678]  zero inside: FALSE

print(round(100 * mean(jdraws < 0), 2))     # -> [1] 1.5

BBB has the highest Sharpe of the four tickers and the interval stays above zero, and it is still 1.582 wide.

These are backtested figures on simulated prices over one sample. Further reading: I put the same method on live market data in Why You Need to Bootstrap Your Trading Strategy.