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 datesaapl <- p |>filter(Ticker =="AAA") |>arrange(Date) # one ticker, oldest firstclose <- aapl$Closer <-diff(close) /head(close, -1) # simple returns, from Lesson 8sharpe <-function(x) mean(x) /sd(x) *sqrt(252) # annualised, risk free rate zeroprint(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 renderdraw <-sample(r, length(r), replace =TRUE) # 1565 days drawn back, repeats allowedprint(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:
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 runreplicate(n_draws, # n_draws repeats of the line belowsharpe(sample(x, length(x), replace =TRUE))) # Sharpe of a fresh resample}draws <-boot_sharpe(r) # 2000 Sharpes for buy and holdprint(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 drawsprint(round(ci, 3))
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 21signal <-ifelse(close > sma50, 1, 0) # 1 above the average, 0 belowposition <-lag(signal) # today I hold yesterday's signals <- position[-1] * r # drop row 1 so position lines up with the return it earnss <- s[!is.na(s)] # drop the 50 warm-up rows the average neededprint(length(s)) # -> [1] 1516
boot_sharpe takes any vector of returns, so it runs on the strategy unchanged.
sdraws <-boot_sharpe(s) # same 2000 draws, strategy returnssci <-quantile(sdraws, c(0.025, 0.975)) # 95% interval for the strategycat(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: TRUEprint(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 fromreplicate(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 endsharpe(x[idx]) # Sharpe of the stitched series })}bdraws <-block_sharpe(s) # strategy, resampled in blocksbci <-quantile(bdraws, c(0.025, 0.975)) # 95% interval from those drawscat(sprintf("single days [%.3f, %.3f] width %.3f\n", sci[1], sci[2], sci[2] - sci[1]))
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?