Resample a return series with replacement a few 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. Do that a few thousand times and the spread of the results is the range the estimate could have landed in.
In this lesson I bootstrap the Sharpe ratio of AAPL, then the Sharpe of the 50 day SMA strategy from Lesson 34, compare the two intervals, and finish by drawing contiguous blocks of days instead of single days.
Step 1. One resample
prices.csv sits next to this lesson and holds simulated data for four tickers over six years. I take the AAPL daily returns and the annualised Sharpe from Lesson 33: the mean over the standard deviation, times the square root of 252.
rng.integers(0, n, size=n) draws n row numbers between 0 and n - 1. Some rows come up twice, some not at all, which is what “with replacement” means. Indexing the array with those numbers gives a new series of the same length made only of days that really happened.
The real sample gave 0.787. This draw gave 0.632. Three more draws:
print(f"{sharpe(r[rng.integers(0, n, size=n)]):.3f}") # -> 0.693print(f"{sharpe(r[rng.integers(0, n, size=n)]):.3f}") # -> 0.807print(f"{sharpe(r[rng.integers(0, n, size=n)]):.3f}") # -> 1.584
0.693
0.807
1.584
Step 2. Four thousand of them
The same draw in a loop. np.random.default_rng(0) fixes the seed, so the numbers below come out the same every time the page is rendered.
def bootstrap_sharpe(r, n_draws=4000, seed=0): rng = np.random.default_rng(seed) n =len(r) out = np.empty(n_draws)for i inrange(n_draws): out[i] = sharpe(r[rng.integers(0, n, size=n)])return outdraws = bootstrap_sharpe(r)print(len(draws)) # -> 4000print(f"{draws.mean():.3f}") # -> 0.780print(f"{draws.std():.3f}") # -> 0.400
4000
0.780
0.400
The mean of the draws, 0.780, sits next to the sample figure of 0.787. The standard deviation of the draws, 0.400, is the standard error of the estimate. np.percentile cuts off the lowest and highest 2.5% of the draws, which leaves the middle 95%.
The point estimate is 0.787 and the interval runs from -0.002 to 1.562. It is 1.565 wide, twice the estimate itself, and its lower end is on the far side of zero.
print(f"{(draws <0).mean():.2%}") # -> 2.55%
2.55%
2.55% of the four thousand draws came out negative.
Step 3. The 50 day SMA strategy
Now the same on a strategy instead of a stock. This is the rule from Lesson 34: hold AAPL when the close is above its 50 day average, lagged one day, no costs.
bootstrap_sharpe takes any array of returns, so it runs on the strategy unchanged.
s = strat.to_numpy()sdraws = bootstrap_sharpe(s)slo, shi = np.percentile(sdraws, [2.5, 97.5])print(f"buy and hold {sharpe(r):.3f} [{lo:.3f}, {hi:.3f}] zero inside: {lo <0< hi}")print(f"50 day SMA {sharpe(s):.3f} [{slo:.3f}, {shi:.3f}] zero inside: {slo <0< shi}")# -> buy and hold 0.787 [-0.002, 1.562] zero inside: True# -> 50 day SMA 0.504 [-0.284, 1.295] zero inside: Trueprint(f"{(sdraws <0).mean():.2%}") # -> 10.78%
buy and hold 0.787 [-0.002, 1.562] zero inside: True
50 day SMA 0.504 [-0.284, 1.295] zero inside: True
10.78%
Both intervals contain zero. The strategy interval is shifted down and the two overlap over almost their whole length, so this sample does not separate a Sharpe of 0.504 from a Sharpe of 0.787. 10.78% of the strategy draws came out negative, against 2.55% for buy and hold.
Step 4. Blocks of twenty days
Drawing single days puts them in a random order, so any relationship 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 and the interval can come out too narrow. Drawing contiguous blocks of, say, 20 days keeps whatever happens inside a block.
def block_bootstrap_sharpe(r, block=20, n_draws=4000, seed=0): rng = np.random.default_rng(seed) n =len(r) n_blocks =int(np.ceil(n / block)) out = np.empty(n_draws)for i inrange(n_draws): starts = rng.integers(0, n - block +1, size=n_blocks) # one start per block idx = (starts[:, None] + np.arange(block)).ravel()[:n] # start, start+1, ... start+19 out[i] = sharpe(r[idx])return outbdraws = block_bootstrap_sharpe(s)blo, bhi = np.percentile(bdraws, [2.5, 97.5])print(f"single days [{slo:.3f}, {shi:.3f}] width {shi - slo:.3f}")print(f"20 day blocks [{blo:.3f}, {bhi:.3f}] width {bhi - blo:.3f}")# -> single days [-0.284, 1.295] width 1.579# -> 20 day blocks [-0.292, 1.286] width 1.578print(f"{(bdraws <0).mean():.2%}") # -> 10.05%
single days [-0.284, 1.295] width 1.579
20 day blocks [-0.292, 1.286] width 1.578
10.05%
The two intervals almost coincide here, because there is very little day to day dependence left in this series to preserve.
On a series where consecutive days line up more strongly, the block version widens. I take the blocks further, on strategies picked out of a simulated market with no edge in it, in Why You Need to Bootstrap Your Trading Strategy.
Your turn
Bootstrap the Sharpe ratio of JNJ buy and hold from prices.csv, 4000 draws, seed 0. What is the 95% interval, and does it contain zero?