How certain is a Sharpe ratio?

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 AAA, 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 AAA daily returns and the annualised Sharpe from Lesson 33: the mean over the standard deviation, times the square root of 252.

import numpy as np
import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])       # six years of bars, four tickers
aapl   = prices[prices["Ticker"] == "AAA"].set_index("Date")  # AAA only, dates as the index
ret    = aapl["Close"].pct_change().dropna()                   # daily returns, first row dropped

def sharpe(r):                                                 # annualised Sharpe of a return array
    return r.mean() / r.std() * np.sqrt(252)                   # 252 trading days in a year

r = ret.to_numpy()                                             # plain array so I can index by row

print(len(r))                # -> 1565
print(f"{sharpe(r):.3f}")    # -> 0.787
1565
0.787

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.

rng  = np.random.default_rng(0)    # seeded generator, same draw every render
n    = len(r)                      # the resample is as long as the sample
pick = rng.integers(0, n, size=n)  # n row numbers, repeats allowed

print(pick[:8])              # -> [1331  996  799  422  481   64  117   25]
print(len(pick))             # -> 1565

print(f"{sharpe(r[pick]):.3f}")    # -> 0.632
[1331  996  799  422  481   64  117   25]
1565
0.632

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.693
print(f"{sharpe(r[rng.integers(0, n, size=n)]):.3f}")    # -> 0.807
print(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):           # 4000 resamples by default
    rng = np.random.default_rng(seed)                    # one generator for the whole run
    n   = len(r)                                         # draw length, fixed to the sample
    out = np.empty(n_draws)                              # room for one Sharpe per draw
    for i in range(n_draws):
        out[i] = sharpe(r[rng.integers(0, n, size=n)])   # Sharpe of a fresh resample
    return out                                           # the bootstrap distribution

draws = bootstrap_sharpe(r)                              # 4000 Sharpes for buy and hold

print(len(draws))                # -> 4000
print(f"{draws.mean():.3f}")     # -> 0.780
print(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%.

lo, hi = np.percentile(draws, [2.5, 97.5])    # the middle 95% of the draws

print(f"sample   {sharpe(r):.3f}")     # -> sample   0.787
print(f"2.5%     {lo:.3f}")            # -> 2.5%     -0.002
print(f"97.5%    {hi:.3f}")            # -> 97.5%    1.562
print(f"width    {hi - lo:.3f}")       # -> width    1.565
sample   0.787
2.5%     -0.002
97.5%    1.562
width    1.565

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 AAA when the close is above its 50-day average, lagged one day, no costs.

close  = aapl["Close"]                          # the AAA close column
sma    = close.rolling(50).mean()               # 50-day simple moving average
signal = (close > sma).astype(float)            # 1.0 on days the close sits above it
pos    = signal.shift(1)                        # yesterday's signal is today's position
strat  = (pos * close.pct_change()).dropna()    # return earned only on days held

print(len(strat))                             # -> 1565
print(f"{(1 + strat).prod() - 1:.2%}")        # -> 58.61%
print(f"{sharpe(strat.to_numpy()):.3f}")      # -> 0.504
1565
58.61%
0.504

bootstrap_sharpe takes any array of returns, so it runs on the strategy unchanged.

s = strat.to_numpy()                             # strategy returns as a plain array

sdraws   = bootstrap_sharpe(s)                   # same 4000 draws, strategy returns
slo, shi = np.percentile(sdraws, [2.5, 97.5])    # 95% interval for the strategy

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: True

print(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):     # runs of days, not single days
    rng      = np.random.default_rng(seed)                         # seeded, so the page repeats
    n        = len(r)                                              # length to rebuild each draw
    n_blocks = int(np.ceil(n / block))                             # blocks needed to cover n days
    out      = np.empty(n_draws)                                   # one Sharpe per draw
    for i in range(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])                                    # Sharpe of the stitched series
    return out                                                     # the block distribution

bdraws   = block_bootstrap_sharpe(s)                               # strategy, resampled in blocks
blo, bhi = np.percentile(bdraws, [2.5, 97.5])                      # 95% interval from those draws

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.578

print(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.

print(f"{pd.Series(r).autocorr(1):.4f}")     # -> 0.0368
print(f"{pd.Series(s).autocorr(1):.4f}")     # -> 0.0588
0.0368
0.0588

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 BBB buy and hold from prices.csv, 4000 draws, seed 0. What is the 95% interval, and does it contain zero?

jnj  = prices[prices["Ticker"] == "BBB"].set_index("Date")
jret = jnj["Close"].pct_change().dropna().to_numpy()

jdraws   = bootstrap_sharpe(jret)
jlo, jhi = np.percentile(jdraws, [2.5, 97.5])

print(f"{sharpe(jret):.3f}")               # -> 0.883
print(f"[{jlo:.3f}, {jhi:.3f}]")           # -> [0.114, 1.692]
print(f"zero inside: {jlo < 0 < jhi}")     # -> zero inside: False
print(f"{(jdraws < 0).mean():.2%}")        # -> 1.15%

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