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

import numpy as np
import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])
aapl   = prices[prices["Ticker"] == "AAPL"].set_index("Date")
ret    = aapl["Close"].pct_change().dropna()

def sharpe(r):
    return r.mean() / r.std() * np.sqrt(252)

r = ret.to_numpy()

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)
n    = len(r)
pick = rng.integers(0, n, size=n)

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):
    rng = np.random.default_rng(seed)
    n   = len(r)
    out = np.empty(n_draws)
    for i in range(n_draws):
        out[i] = sharpe(r[rng.integers(0, n, size=n)])
    return out

draws = bootstrap_sharpe(r)

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])

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

close  = aapl["Close"]
sma    = close.rolling(50).mean()
signal = (close > sma).astype(float)
pos    = signal.shift(1)
strat  = (pos * close.pct_change()).dropna()

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()

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: 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):
    rng      = np.random.default_rng(seed)
    n        = len(r)
    n_blocks = int(np.ceil(n / block))
    out      = np.empty(n_draws)
    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])
    return out

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

jnj  = prices[prices["Ticker"] == "JNJ"].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%

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