Lesson 2.5.8 · 70 min

“Honest backtest” checklist before moving to paper trading

A backtest is not approved by its Sharpe, but by the tests it has passed. This lesson gathers the module's tests into a list, codes an auditor that runs eight of them on a result object and applies it to two strategies: one with a planted effect and an optimized placebo that, looked at one test at a time, seems almost as good.

Fig. — Auditor result for the two strategies: blue square, pass; orange, fail; next to each square, the measured value
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

A gate before the money

The lessons of this module have presented the errors of a backtest one by one: look-ahead and survivorship (2.5.1), in-sample selection (2.5.2 and 2.5.3), number of tests (2.5.4), the chance of ordering (2.5.5), parameter peaks (2.5.6) and scenarios the sample does not have (2.5.7). This lesson turns them into a gate: a list that every backtest must pass before a strategy touches money, first simulated and then real. Harvey and Liu (2015)1Note 1 · Harvey, C. R. and Liu, Y. (2015), “Backtesting”, The Journal of Portfolio Management 42(1), 13-28, doi:10.3905/jpm.2015.42.1.013. put it in a similar way: a backtest result has to be adjusted for everything that was tried before arriving at it.

failspassesfailspassesnoyesCandidate backtestAuditorResearch moreManual reviewPaper tradingDoes it match?Live, small
Mermaid source
flowchart TB
  B[Candidate backtest] --> A{Auditor}
  A -- fails --> I[Research more]
  A -- passes --> M{Manual review}
  M -- fails --> I
  M -- passes --> P[Paper trading]
  P --> C{Does it match?}
  C -- no --> I
  C -- yes --> R[Live, small]

The gate has two parts. One can be automated: they are tests computed with the data and the code of the backtest, and they are best run the same way every time, without deciding case by case. The other requires judgment: the hypothesis, the quality of the data, the code review. Both are on the list.

The list

#CheckQuestionHowGuideline thresholdAuditorLesson
1Prior hypothesisWhere does the edge come from and who loses on the other side?Written before seeing resultsOne verifiable sentenceManual2.2.1
2Experiment journalHow many things were really tried?Record of every test, including the discarded onesTotal number of testsManual2.2.4
3Point-in-time universeDoes the universe include the companies that disappeared?Delisted stocks and their final returnNone left outYes1.4.4 and 2.5.1
4Next-bar executionDoes the signal use only past data?Data truncation and one more period of lagTruncation identical; Sharpe with lag ≥ 50%Yes2.5.1
5CostsDoes it survive costs worse than planned?Costs ×2Sharpe ≥ 0.5Yes2.4.7
6FillsAre limit orders, stops and gaps realistic?Fill modelNo impossible fillsManual2.4.8
7Out of sampleDoes what was chosen hold on data not used to choose it?Split or walk-forwardOut ≥ 50% of inYes2.5.2
8Number of testsDoes it beat the maximum expected by chance?Deflated SharpeDSR ≥ 0.95Yes2.5.4
9SelectionDoes choosing the best help, or does it do harm?PBO (CSCV) and probability of lossLoss ≤ 10%Yes2.5.3 and 2.5.4
10PlateauDo neighboring parameters give something similar?Neighbors of the chosen one≥ 70% of the chosen oneYes2.5.6
11RobustnessDoes it work on other assets and periods?By market, sector and segmentMajority positiveManual2.5.6
12CapacityDoes the planned capital fit?Orders against daily volumep95 ≤ 10% of volumeYes2.2.3
13RiskWhat drawdown and what size?Block bootstrap, fan chartAcceptable risk of ruinManual2.5.5
14StressWhere does it lose?Synthetic scenariosTolerable lossManual2.5.7
15ReproducibilityCan someone else get the same figures?Versioned code, fingerprinted data, seedsSame resultManual1.4.8

The thresholds are guidelines: each team sets its own, before seeing the results and for all strategies alike. A threshold adjusted afterward so that a strategy passes is another form of overfitting.

Two backtests to audit

Both strategies are monthly long-short on the 120 fictitious stocks of level 2: they buy the top bucket of a score and sell the bottom one, equally weighted, with the weights set at each month's close and the return of the following month. First, the monthly returns. The detail that matters is the month in which a company stops trading: you have to include its last price and the delisting return (−100% in a bankruptcy), or the backtest ignores the worst loss of each bankruptcy (lesson 1.4.4):

IN [01]python
import itertools
import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from scipy import stats

aj = pd.read_parquet("universo2_ajustados.parquet")
px = aj.pivot(index="fecha", columns="ticker", values="cierre_ajustado")
fin_mes = px.index.to_series().groupby(px.index.to_period("M")).max()
mensual = px.loc[fin_mes.to_numpy()]
R = mensual.pct_change(fill_method=None)

# the month of a delisting: up to the last price and then the payout
eventos = pd.read_csv("universo2_eventos.csv", parse_dates=["fecha"])
deslist = eventos.dropna(subset=["rentabilidad_deslistado"])
for _, ev in deslist.iterrows():
    tk = ev["ticker"]
    ultima = px[tk].last_valid_index()
    mes = R.index[R.index >= ultima][0]
    antes = R.index[R.index < ultima][-1]
    R.loc[mes, tk] = (px.loc[ultima, tk] / mensual.loc[antes, tk]
                      * (1 + ev["rentabilidad_deslistado"]) - 1)
print(R.shape, f"{len(deslist)} delisted stocks with their final return")
R.loc["2008-10", ["QXQT"]]
OUT [01]Monthly returns with the delisting returns
(240, 120) 10 delisted stocks with their final return
ticker      QXQT
fecha
2008-10-31  -1.0

The characteristics are those of universo2_caracteristicas.parquet, which the README defines as point-in-time: what was known at each month's close. Value is the log of book equity over market capitalization (only with positive equity):

IN [02]python
car = pd.read_parquet("universo2_caracteristicas.parquet").set_index(
    ["fecha", "ticker"])
def panel(col):
    return car[col].unstack().reindex(R.index)

pn, cap = panel("patrimonio_neto"), panel("capitalizacion")
datos = {
    "R": R,
    "value": np.log(pn.where(pn > 0) / cap),
    "esg": panel("puntuacion_esg").astype(float),
    "horquilla": panel("horquilla_pb"),
    "adv_usd": panel("volumen_medio_21d") * panel("cierre"),
    "cotiza": panel("cierre").notna(),
}
datos["value"].iloc[-1].describe().round(2)
OUT [02]Point-in-time characteristics
count    110.00
mean      -0.54
std        0.68
min       -2.22
25%       -0.99
50%       -0.60
75%        0.00
max        0.81
Name: 2025-12-31 00:00:00, dtype: float64

The engine: rank-based normal scores, momentum that skips the last month, quantile long-short and a cost of half the spread per unit of turnover, with the horquilla_pb of each stock and month (lesson 2.4.7). The retardo parameter allows executing one month later, and the auditor will use it.

IN [03]python
def z_rango(df):
    """Rank-based normal score on each date."""
    rk = df.rank(axis=1)
    n = df.notna().sum(axis=1)
    u = (rk - 0.5).div(n, axis=0).to_numpy()
    return pd.DataFrame(stats.norm.ppf(u), index=df.index,
                        columns=df.columns)

def momentum(R, L):
    """Return of months m-L+1 to m-1 (skips the last one)."""
    with np.errstate(divide="ignore"):    # log1p(-1) = -inf: bankruptcy
        lr = np.log1p(R)
    return np.expm1(lr.shift(1).rolling(L - 1).sum())

def largo_corto(puntos, puede, q):
    """Long the top q and short the bottom q, equally weighted."""
    rk = puntos.where(puede).rank(axis=1, pct=True)
    lar, cor = (rk > 1 - q).astype(float), (rk <= q).astype(float)
    return (lar.div(lar.sum(axis=1), axis=0)
            - cor.div(cor.sum(axis=1), axis=0)).fillna(0)

INICIO = "2007-08"   # the longest window (18 months) already exists

def backtest(d, puntos, q, universo, coste=1.0, retardo=1):
    """Weights at the close of month t; return of month t + retardo."""
    puede = d["cotiza"] & d["cotiza"].columns.isin(list(universo))
    w = largo_corto(puntos, puede, q)
    bruto = (w.shift(retardo) * d["R"]).sum(axis=1)
    giro = (w - w.shift(1)).abs()
    pb = d["horquilla"].fillna(20) / 2 / 1e4      # half spread
    gasto = (giro * pb).sum(axis=1).shift(retardo) * coste
    return (bruto - gasto).loc[INICIO:], w.loc[INICIO:]

def sharpe(x):
    x = np.asarray(x, dtype=float)
    return x.mean() / x.std(ddof=1) * np.sqrt(12)

Strategy A combines momentum and value. The researcher tries six momentum windows and five quantiles, 30 combinations, and chooses the best in the first half of the data (from 2007-08 to 2016-07):

IN [04]python
def senal_vm(d, L):
    return z_rango(momentum(d["R"], L)) + z_rango(d["value"])

TODOS = set(R.columns)                       # point-in-time universe
rejilla_a = pd.DataFrame({
    (L, q): backtest(datos, senal_vm(datos, L), q, TODOS)[0]
    for L in (3, 6, 9, 12, 15, 18) for q in (0.1, 0.2, 0.3, 0.4, 0.5)})
CORTE = "2016-07"                            # in sample: 2007-08 to 2016-07
sr_in = rejilla_a.loc[:CORTE].apply(sharpe)
elegido_a = sr_in.idxmax()
print(f"Chosen in sample: L = {elegido_a[0]} months, "
      f"q = {elegido_a[1]} (Sharpe {sr_in.max():.2f})")
sr_in.unstack().round(2)
OUT [04]Strategy A: value + momentum (planted effect)
Chosen in sample: L = 6 months, q = 0.4 (Sharpe 0.97)
     0.1   0.2   0.3   0.4   0.5
3   0.42  0.69  0.90  0.74  0.85
6   0.69  0.86  0.82  0.97  0.90
9   0.58  0.63  0.50  0.55  0.65
12  0.86  0.72  0.74  0.80  0.87
15  0.47  0.67  0.61  0.64  0.65
18  0.36  0.61  0.73  0.73  0.54

It chooses 6 months and 40%, with an in-sample Sharpe of 0.97, on a grid in which all 30 combinations are positive: a plateau. Strategy B is what comes out of a less careful process. The researcher tries the smoothed ESG score with six windows, with both signs (buying the best-scored or the worst-scored) and five quantiles, 60 combinations, and does it on the companies that trade today, because that is the list at hand:

IN [05]python
def senal_esg(d, k, signo):
    return signo * d["esg"].rolling(k).mean()

sect = pd.read_csv("universo2_sectores.csv")
SUPERVIVIENTES = set(sect.loc[sect["ultima_sesion"].isna(), "ticker"])
rejilla_b = pd.DataFrame({
    (k, s, q): backtest(datos, senal_esg(datos, k, s), q,
                        SUPERVIVIENTES)[0]
    for k in (1, 2, 3, 6, 9, 12) for s in (1, -1)
    for q in (0.1, 0.2, 0.3, 0.4, 0.5)})
sr_in_b = rejilla_b.loc[:CORTE].apply(sharpe)
elegido_b = sr_in_b.idxmax()
print(f"{len(SUPERVIVIENTES)} survivors out of {len(TODOS)}")
print(f"Chosen: {elegido_b[0]}-month average, sign {elegido_b[1]}, "
      f"q = {elegido_b[2]} (Sharpe {sr_in_b.max():.2f})")
sr_in_b.unstack([1, 2]).round(2).iloc[:, :5]
OUT [05]Strategy B: the ESG placebo optimized on survivors
110 survivors out of 120
Chosen: 9-month average, sign 1, q = 0.2 (Sharpe 0.53)
       1
     0.1   0.2   0.3   0.4   0.5
1   0.27  0.32  0.17  0.16  0.17
2   0.35  0.38  0.11  0.16  0.08
3   0.35  0.50  0.20  0.10  0.21
6   0.22  0.43  0.17  0.01  0.22
9   0.41  0.53  0.24  0.16  0.09
12  0.30  0.26  0.15  0.11 -0.00

An in-sample Sharpe of 0.53, with a sign and a window chosen after looking at the results. Told this way, B looks like a weaker strategy than A, but not an absurd one. The auditor has to say whether it is.

The object being audited

Auditing a backtest takes more than its equity curve. The ResultadoBacktest object stores everything: the net returns, the weights, a function to run it again with other costs or lags, the signal function (for truncation), the full grid of everything that was tried (for the DSR, the PBO and the plateau), the chosen combination, the in-sample and out-of-sample split, the number of tests, the universe and the planned capital. If the researcher cannot fill in a field (does not know how many tests were run, did not keep the grid), that is already an audit finding.

IN [06]python
@dataclass
class ResultadoBacktest:
    nombre: str
    rend: pd.Series             # monthly, net of costs
    pesos: pd.DataFrame         # at each month's close
    ejecutar: object            # (datos, coste, retardo) -> rend
    senal: object               # datos -> scores
    rejilla: pd.DataFrame       # ALL the combinations tried
    elegido: tuple
    corte: str                  # last in-sample month
    n_pruebas: int              # including those not written down
    universo: set
    fijos: tuple = ()           # grid axes that have no order
    capital: float = 50e6       # planned capital (USD)

def resultado(nombre, senal, q, universo, rejilla, elegido, n,
              fijos=()):
    rend, w = backtest(datos, senal(datos), q, universo)
    return ResultadoBacktest(
        nombre, rend, w,
        lambda d, c=1.0, t=1: backtest(d, senal(d), q, universo,
                                       c, t)[0],
        senal, rejilla, elegido, CORTE, n, universo, fijos)

L, q = elegido_a
A = resultado("value + momentum", lambda d: senal_vm(d, L), q,
              TODOS, rejilla_a, elegido_a, rejilla_a.shape[1])
k, s, qb = elegido_b
B = resultado("placebo ESG", lambda d: senal_esg(d, k, s), qb,
              SUPERVIVIENTES, rejilla_b, elegido_b, rejilla_b.shape[1],
              fijos=(1,))                    # the sign has no neighbors
for res in (A, B):
    print(f"{res.nombre}: {len(res.rend)} months, Sharpe "
          f"{sharpe(res.rend):.2f}, {res.n_pruebas} tests")
OUT [06]The object the auditor audits
value + momentum: 221 months, Sharpe 1.12, 30 tests
placebo ESG: 221 months, Sharpe 0.54, 60 tests

fijos=(1,) tells the auditor that the second axis of B's grid (the sign) has no order: buying the best-scored is not a “neighbor” of buying the worst-scored.

The eight automatic tests

Each test returns a value, its threshold and whether it passes. The first three run the backtest again.

Next-bar execution. Two checks. Truncation: all data after a date are deleted and the signal is recomputed; if the score for that date changes, the signal uses the future. And lag: with one more month of delay in execution, an edge with a monthly horizon should lose something, not disappear. Costs: the backtest with double the costs. Out of sample: the Sharpe of the chosen combination after the split against the one before.

IN [07]python
def prueba(valor, umbral, pasa, texto):
    return {"valor": valor, "umbral": umbral, "pasa": bool(pasa),
            "detalle": texto}

def p_ejecucion(res, fechas=("2010-06", "2015-12", "2020-03")):
    """Truncation: the signal for d does not change if what comes after d
    is deleted. Lag: with one more month of delay, the edge does not vanish."""
    completa = res.senal(datos)
    iguales = all(
        np.allclose(res.senal({c: v.loc[:f] for c, v in datos.items()})
                    .loc[f].to_numpy(), completa.loc[f].to_numpy(),
                    equal_nan=True) for f in fechas)
    s1, s2 = sharpe(res.rend), sharpe(res.ejecutar(datos, 1.0, 2))
    return prueba(s2 / s1, ">= 0.5 and truncation identical",
                  iguales and s2 >= 0.5 * s1,
                  f"truncation {'identical' if iguales else 'DIFFERENT'}; "
                  f"Sharpe t+1 {s1:.2f}, t+2 {s2:.2f}")

def p_costes(res):
    s = sharpe(res.ejecutar(datos, 2.0, 1))
    return prueba(s, ">= 0.5", s >= 0.5,
                  f"Sharpe with costs x2 {s:.2f}")

def p_fuera(res):
    r = res.rejilla[res.elegido]
    s_in, s_out = sharpe(r.loc[:res.corte]), sharpe(r.loc[res.corte:]
                                                     .iloc[1:])
    return prueba(s_out / s_in, ">= 0.5", s_out >= 0.5 * s_in > 0,
                  f"in sample {s_in:.2f}, out of sample {s_out:.2f}")

for res in (A, B):
    for f in (p_ejecucion, p_costes, p_fuera):
        print(f"{res.nombre:>16} | {f.__name__:<11} | "
              f"{f(res)['detalle']}")
OUT [07]Tests 1 to 3: execution, costs and out of sample
value + momentum | p_ejecucion | truncation identical; Sharpe t+1 1.12, t+2 0.94
value + momentum | p_costes    | Sharpe with costs x2 1.08
value + momentum | p_fuera     | in sample 0.97, out of sample 1.27
     placebo ESG | p_ejecucion | truncation identical; Sharpe t+1 0.54, t+2 0.36
     placebo ESG | p_costes    | Sharpe with costs x2 0.53
     placebo ESG | p_fuera     | in sample 0.53, out of sample 0.56

Both strategies pass all three. A loses 0.18 of Sharpe with a one-month delay (from 1.12 to 0.94), almost nothing with double the costs (1.08) and improves out of sample (from 0.97 to 1.27). B also passes: 0.36 with the delay, 0.53 with double the costs and 0.56 out of sample against 0.53 in sample. This deserves a pause. The placebo has no edge by construction, and even so its out-of-sample Sharpe is as good as its in-sample one. The reason: in these data, by chance, the ESG score is related to returns throughout the whole sample, and a stable chance relationship does not break when the sample is split in two. The out-of-sample check detects overfitting to an era; it does not detect a spurious effect that lasts the whole available history or the selection among many tests.

Deflated Sharpe (lesson 2.5.4): the probability that the true Sharpe exceeds the maximum that could be expected by chance among NN tests,

SR0=V[SR^n][(1−γ) Φ−1 ⁣(1−1N)+γ Φ−1 ⁣(1−1Ne)]SR_0 = \sqrt{V[\widehat{SR}_n]}\left[(1-\gamma)\,\Phi^{-1}\!\left(1-\tfrac{1}{N}\right) + \gamma\,\Phi^{-1}\!\left(1-\tfrac{1}{N e}\right)\right]

with V[SR^n]V[\widehat{SR}_n] the variance of the Sharpes of all the tests and γ\gamma the Euler-Mascheroni constant. PBO by combinatorially symmetric cross-validation (CSCV): the sample is split into 10 blocks, the 252 ways of choosing 5 as “in sample” are taken, and in each one the out-of-sample rank of the best in-sample combination is checked. The auditor also computes the probability of loss: in what fraction of the partitions the chosen one loses out of sample.

IN [08]python
EULER = np.euler_gamma    # Euler-Mascheroni constant

def p_dsr(res):
    """Deflated Sharpe Ratio (Bailey and López de Prado, 2014)."""
    r = res.rend.to_numpy()
    T, sr = len(r), r.mean() / r.std(ddof=1)        # per month
    g3, g4 = stats.skew(r), stats.kurtosis(r, fisher=False)
    pruebas = res.rejilla.apply(lambda c: c.mean() / c.std(ddof=1))
    N = res.n_pruebas
    sr0 = np.sqrt(pruebas.var(ddof=1)) * (
        (1 - EULER) * stats.norm.ppf(1 - 1 / N)
        + EULER * stats.norm.ppf(1 - 1 / (N * np.e)))
    z = (sr - sr0) * np.sqrt(T - 1) / np.sqrt(
        1 - g3 * sr + (g4 - 1) / 4 * sr ** 2)
    dsr = stats.norm.cdf(z)
    return prueba(dsr, ">= 0.95", dsr >= 0.95,
                  f"Sharpe {sr * np.sqrt(12):.2f} against a maximum "
                  f"expected by chance of {sr0 * np.sqrt(12):.2f}")

def p_pbo(res, S=10):
    """CSCV: half of the blocks in sample, the other half out of sample."""
    M = res.rejilla.to_numpy()
    T, N = M.shape
    bloques = np.array_split(np.arange(T), S)
    logits, pierde = [], []
    for dentro in itertools.combinations(range(S), S // 2):
        i = np.concatenate([bloques[j] for j in dentro])
        o = np.concatenate([bloques[j] for j in range(S)
                            if j not in dentro])
        sr_i = M[i].mean(0) / M[i].std(0, ddof=1)
        sr_o = M[o].mean(0) / M[o].std(0, ddof=1)
        n = sr_i.argmax()
        w = stats.rankdata(sr_o)[n] / (N + 1)
        logits.append(np.log(w / (1 - w)))
        pierde.append(sr_o[n] <= 0)
    pbo, p_perd = np.mean(np.array(logits) <= 0), np.mean(pierde)
    return prueba(p_perd, "loss <= 10%", p_perd <= 0.10,
                  f"PBO {pbo:.2f}; the chosen one loses out of sample in "
                  f"{p_perd:.0%} of the {len(logits)} partitions")

for res in (A, B):
    for f in (p_dsr, p_pbo):
        print(f"{res.nombre:>16} | {f.__name__:<5} | "
              f"{f(res)['detalle']}")
OUT [08]Tests 4 and 5: Deflated Sharpe and PBO
value + momentum | p_dsr | Sharpe 1.12 against a maximum expected by chance of 0.30
value + momentum | p_pbo | PBO 0.55; the chosen one loses out of sample in 0% of the 252 partitions
     placebo ESG | p_dsr | Sharpe 0.54 against a maximum expected by chance of 0.85
     placebo ESG | p_pbo | PBO 0.14; the chosen one loses out of sample in 17% of the 252 partitions
Fig. — Deflated Sharpe: measured Sharpe of each strategy (dot) against the maximum expected by chance given its number of trials (tick)

Here they part ways. A's Sharpe, 1.12, is well above the 0.30 that 30 tests with no edge would give with the dispersion of its grid: DSR of 1.00. B's, 0.54, is below the 0.85 that can be expected as the maximum of 60 tests with no edge, because its grid has a lot of dispersion (it includes both signs): DSR of 0.09. With all the tests counted, the placebo cannot be distinguished from chance.

PBO gives a result that is confusing if read on its own: 0.55 for A and 0.14 for B. In A's grid all the combinations are good and alike, so which one comes first out of sample is a lottery and the PBO is around 0.5; but the chosen one does not lose out of sample in any of the 252 partitions. In B, the chance relationship is stable and the best in sample tends to be good out of sample (low PBO), but it loses in 17% of the partitions. That is why the auditor decides with the probability of loss and shows the PBO as context, as lesson 2.5.4 recommends.

Plateau (lesson 2.5.6): the mean of the neighbors of the chosen combination in the in-sample grid, against the chosen one. Capacity: the capital at which the 95th percentile of the rebalancing orders reaches 10% of the stock's average daily volume, against the planned capital (USD 50 M). Survivorship: whether the universe leaves out any stock that stopped trading.

IN [09]python
def p_meseta(res):
    """Neighbors of the chosen one in the in-sample grid."""
    sr_in = res.rejilla.loc[:res.corte].apply(sharpe)
    ejes = [sorted({k[i] for k in sr_in.index})
            for i in range(len(res.elegido))]
    pos = {k: [ejes[i].index(v) for i, v in enumerate(k)]
           for k in sr_in.index}
    yo = pos[res.elegido]
    vec = [sr_in[k] for k, p in pos.items() if k != res.elegido
           and max(abs(a - b) for a, b in zip(p, yo)) <= 1
           and all(k[i] == res.elegido[i] for i in res.fijos)]
    ratio = np.mean(vec) / sr_in[res.elegido]
    return prueba(ratio, ">= 0.7", ratio >= 0.7,
                  f"{len(vec)} neighbors: mean {np.mean(vec):.2f} "
                  f"against {sr_in[res.elegido]:.2f}")

def p_capacidad(res, limite=0.10):
    """Capital at which the 95th percentile of the orders is 10%
    of the stock's average daily volume."""
    giro = (res.pesos - res.pesos.shift(1)).abs()
    part = (giro / datos["adv_usd"].loc[INICIO:]).stack()
    p95 = part[part > 0].quantile(0.95)
    cap = limite / p95
    return prueba(cap / 1e6, f">= {res.capital / 1e6:.0f} M USD",
                  cap >= res.capital,
                  f"capacity {cap / 1e6:.0f} M USD")

def p_supervivencia(res):
    """Does the universe include the stocks that stopped trading?"""
    fuera = set(deslist["ticker"]) - res.universo
    return prueba(len(fuera), "0 excluded", not fuera,
                  f"{len(fuera)} delisted stocks outside the universe")

for res in (A, B):
    for f in (p_meseta, p_capacidad, p_supervivencia):
        print(f"{res.nombre:>16} | {f.__name__:<15} | "
              f"{f(res)['detalle']}")
OUT [09]Tests 6 to 8: plateau, capacity and survivorship
value + momentum | p_meseta        | 8 neighbors: mean 0.74 against 0.97
value + momentum | p_capacidad     | capacity 54 M USD
value + momentum | p_supervivencia | 0 delisted stocks outside the universe
     placebo ESG | p_meseta        | 8 neighbors: mean 0.27 against 0.53
     placebo ESG | p_capacidad     | capacity 56 M USD
     placebo ESG | p_supervivencia | 10 delisted stocks outside the universe

A passes all three: its neighbors keep 76% of the chosen one's Sharpe (0.74 against 0.97), a capital of USD 54 M fits and its universe includes the 10 delisted stocks. B fails the plateau (0.27 against 0.53: the chosen window and quantile stand out from their neighbors) and survivorship (the 10 delisted stocks are left out), and passes capacity (USD 56 M).

The verdict

The full auditor runs the eight tests and only approves a strategy that passes all of them:

IN [10]python
PRUEBAS = {"ejecución t+1": p_ejecucion, "costes x2": p_costes,
           "fuera de muestra": p_fuera, "Deflated Sharpe": p_dsr,
           "PBO y pérdida": p_pbo, "meseta": p_meseta,
           "capacidad": p_capacidad, "supervivencia": p_supervivencia}

def auditar(res):
    filas = {n: f(res) for n, f in PRUEBAS.items()}
    tabla = pd.DataFrame(filas).T[["valor", "umbral", "pasa"]]
    veredicto = "APPROVED" if tabla["pasa"].all() else "NOT APPROVED"
    return tabla, veredicto

informe = {}
for res in (A, B):
    tabla, veredicto = auditar(res)
    informe[res.nombre] = tabla
    print(f"{res.nombre}: {veredicto} "
          f"({tabla['pasa'].sum()} of {len(tabla)} tests)")
pd.concat({n: t["pasa"].map({True: "pass", False: "FAIL"})
           for n, t in informe.items()}, axis=1)
OUT [10]The full auditor
value + momentum: APPROVED (8 of 8 tests)
placebo ESG: NOT APPROVED (4 of 8 tests)
                 value + momentum placebo ESG
ejecución t+1                pass        pass
costes x2                    pass        pass
fuera de muestra             pass        pass
Deflated Sharpe              pass        FAIL
PBO y pérdida                pass        FAIL
meseta                       pass        FAIL
capacidad                    pass        pass
supervivencia                pass        FAIL

The header figure summarizes the result. A, the strategy with the planted effect, passes all eight. B, the placebo, passes four and fails four, and none of the ones it fails would have been enough on its own if it had been looked at in isolation: the DSR requires knowing how many tests were run, the plateau requires keeping the grid, survivorship requires knowing the universe. B's full report:

IN [11]python
t = informe["placebo ESG"].copy()
t["valor"] = t["valor"].astype(float).round(2)
t
OUT [11]The placebo's report, with the values
                  valor                           umbral   pasa
ejecución t+1      0.66  >= 0.5 and truncation identical   True
costes x2          0.53                           >= 0.5   True
fuera de muestra   1.05                           >= 0.5   True
Deflated Sharpe    0.09                          >= 0.95  False
PBO y pérdida      0.17                      loss <= 10%  False
meseta             0.51                           >= 0.7  False
capacidad         56.04                      >= 50 M USD   True
supervivencia     10.00                       0 excluded  False

The survivorship test is about procedure: it fails even if the effect on the result were small, because a backtest on survivors cannot be reproduced in real time, when it is not known which companies are going to disappear. In this case, moreover, the survivor universe inflated the placebo:

IN [12]python
con_todos, _ = backtest(datos, senal_esg(datos, k, s), qb, TODOS)
print(f"Placebo on survivors: {sharpe(B.rend):.2f}; "
      f"with the full universe: {sharpe(con_todos):.2f}")
OUT [12]How much does survivorship add to the placebo?
Placebo on survivors: 0.54; with the full universe: 0.43
Fig. — Cumulative growth, in logs, of the two audited strategies, net of costs; the vertical line separates the selection period (to July 2016) from the test period

The figure shows the problem of judging by the curve: the placebo, in amber, also rises after the split. Seen alone, and with the survivor universe, it is a strategy “that works out of sample”. The tests that give it away cannot be seen in the curve.

What the auditor does not see, and paper trading

Eight automatic tests cover part of the list. The rest requires judgment, and it is best done in writing:

  • Hypothesis (lesson 2.2.1): an explanation of why the edge exists, written before looking at results. B did not have one: “companies with better ESG go up” does not say who loses on the other side or why the market has not corrected it.
  • Data: errors, gaps, adjustments for splits and dividends, time zones (module 1.4).
  • Code: review by another person, unit tests of the engine, comparison with an independent engine (lessons 2.4.1 and 2.4.2).
  • Risk and stress (lessons 2.5.5 and 2.5.7): expected drawdown, size, scenarios where it loses.

And after that, paper trading: the strategy runs in real time, with live data and simulated orders, for a period fixed in advance. It is not for measuring the Sharpe (a few months are not enough, lesson 2.5.5) but for checking that what the backtest assumes actually happens: that the signal computed live matches the one the backtest would give on the same data, that real fills and costs resemble the modeled ones and that operations (data arriving late, rejected orders, holidays) do not break anything. The differences between paper and backtest are the last finding of the audit; if they are large, it is back to research. Only then comes real money, with a small size.

Exercises

  1. A colleague computes momentum with np.log1p(R).shift(-1) by mistake (a backward shift). Build their result with everything else the same as strategy A and run the execution test on it.
Solution
IN [13]python
def momentum_malo(R, L):
    with np.errstate(divide="ignore"):
        lr = np.log1p(R)
    return np.expm1(lr.shift(-1).rolling(L - 1).sum())

def senal_mala(d):
    return z_rango(momentum_malo(d["R"], L)) + z_rango(d["value"])

M = resultado("with look-ahead", senal_mala, q, TODOS, rejilla_a,
              elegido_a, rejilla_a.shape[1])
print(f"Sharpe: {sharpe(M.rend):.2f}")
print(p_ejecucion(M)["detalle"])
OUT [13]A look-ahead error
Sharpe: 8.79
truncation DIFFERENT; Sharpe t+1 8.79, t+2 1.24

With the error, each month's momentum includes the following month's return, and the Sharpe rises to 8.79: a number that should already be enough of an alarm. The truncation test confirms it: when the data after each date are deleted, the score changes. The lag test flags it too, because with one more month of delay the Sharpe falls to 1.24 (from 8.79), well below 50%. A look-ahead error is almost never this obvious; that is why the truncation test is always run, also when the Sharpe looks reasonable.

  1. Repeat strategy A on the survivors and audit it. Which test fails and how much does the Sharpe change?
Solution
IN [14]python
rejilla_s = pd.DataFrame({
    (Ls, qs): backtest(datos, senal_vm(datos, Ls), qs,
                       SUPERVIVIENTES)[0]
    for Ls in (3, 6, 9, 12, 15, 18)
    for qs in (0.1, 0.2, 0.3, 0.4, 0.5)})
S = resultado("A on survivors", lambda d: senal_vm(d, L), q,
              SUPERVIVIENTES, rejilla_s, elegido_a, 30)
tabla_s, veredicto_s = auditar(S)
print(f"Sharpe {sharpe(S.rend):.2f} against {sharpe(A.rend):.2f}: "
      f"{veredicto_s}")
print(tabla_s.loc[~tabla_s["pasa"].astype(bool)].index.tolist())
OUT [14]Value + momentum on survivors
Sharpe 1.18 against 1.12: NOT APPROVED
['supervivencia']

On the survivors the Sharpe barely changes (1.18 against 1.12) and only the survivorship test fails. The strategy still has its planted effect, but the procedure is no longer possible in real time, and the auditor does not approve it until it is redone with the point-in-time universe. That the difference is small here guarantees nothing: in the placebo, the same error raised the Sharpe from 0.43 to 0.54, and in a strategy that buys companies in distress it can flip the sign of the result.

  1. What would the auditor need to add to cover robustness across assets and periods (item 11 of the list)? Describe the test, its threshold and what new fields the ResultadoBacktest object would need.
Solution

It would need the returns per asset (or per group: sector, country, asset class) of the chosen combination, and a field with the time partition (for example, five-year segments). The test would compute the Sharpe in each group and in each segment and would pass if most are positive (for example, at least two thirds of the groups and of the segments) and if no group explains most of the result on its own. As in lesson 2.5.6, the threshold has to be set within the domain of the hypothesis: if the edge should only exist in one type of asset, the test is applied to the assets of that type. And, like all thresholds, it is set before seeing the results.

Footnotes

  1. Harvey, C. R. and Liu, Y. (2015), “Backtesting”, The Journal of Portfolio Management 42(1), 13-28, doi:10.3905/jpm.2015.42.1.013. ↩

Each answer is folded under its question

Self-assessment

  1. The ESG placebo passes the out-of-sample test: Sharpe of 0.53 in sample and 0.56 out of sample. Why is that not enough?

    • ABecause a chance relationship that holds over the whole sample also holds outside it: the out-of-sample check detects overfitting to an era, not a stable spurious effect or the selection among 60 tests
    • BBecause the out-of-sample period is too short
    • CBecause the test is badly coded
    • DIt is enough: the placebo is a valid strategy
    Show answer

    Answer A. Because a chance relationship that holds over the whole sample also holds outside it: the out-of-sample check detects overfitting to an era, not a stable spurious effect or the selection among 60 tests

    The ESG score is independent of everything by construction, but in these data its relationship with returns is positive by chance in both periods. It is given away by the Deflated Sharpe (0.09: its Sharpe of 0.54 is below the maximum expected by chance with 60 tests, 0.85), the plateau and the probability of an out-of-sample loss.

  2. The value + momentum strategy has a PBO of 0.55 and the placebo, 0.14. How is that explained?

    • AThe placebo is less overfitted than the real strategy
    • BThere is an error in the rank calculation
    • CIn the value + momentum grid all the combinations are good and similar: which one comes first out of sample is chance and the PBO is around 0.5, without the choice doing any harm (the chosen one loses in no partition). That is why PBO is read together with the probability of loss
    • DPBO only works with daily data
    Show answer

    Answer C. In the value + momentum grid all the combinations are good and similar: which one comes first out of sample is chance and the PBO is around 0.5, without the choice doing any harm (the chosen one loses in no partition). That is why PBO is read together with the probability of loss

    PBO measures whether choosing the best in sample helps to end up above the median out of sample. On a plateau, all are equally good and choosing adds nothing: PBO ≈ 0.5. What matters then is whether the chosen one loses out of sample: 0% of the 252 partitions in the real strategy, 17% in the placebo.

  3. What does the signal truncation test detect?

    • AThat costs are correctly computed
    • BThat the strategy has enough capacity
    • CThat the universe includes the delisted stocks
    • DThat the score of a date uses later data: if deleting everything that comes after the date changes the score, the signal looks into the future
    Show answer

    Answer D. That the score of a date uses later data: if deleting everything that comes after the date changes the score, the signal looks into the future

    It is the direct look-ahead test. In exercise 1, a mistaken shift(-1) gives a Sharpe of 8.79; the truncation test flags it as different. A lag test (executing one period later) complements it: an edge that disappears with a one-month delay is suspicious.

  4. The value + momentum strategy recomputed on the survivors has a Sharpe of 1.18 instead of 1.12, and the auditor rejects it. Why does it fail if the Sharpe is similar?

    • ABecause the Sharpe has gone up
    • BBecause its universe excludes the 10 stocks that stopped trading: the result cannot be reproduced in real time, when it is not known which companies are going to disappear. The test is about procedure, not about the result
    • CBecause the plateau changes
    • DBecause costs are higher
    Show answer

    Answer B. Because its universe excludes the 10 stocks that stopped trading: the result cannot be reproduced in real time, when it is not known which companies are going to disappear. The test is about procedure, not about the result

    A backtest on survivors uses information from the future (who survives), even if in a specific case the effect on the Sharpe is small. In the placebo, the survivor universe raises the Sharpe from 0.43 to 0.54. The test does not ask how much the result changes, but whether the procedure is possible in real time.

  5. The value + momentum strategy passes all eight tests. What comes next?

    • ATrade it with all the planned capital
    • BOptimize more parameters to raise the Sharpe
    • CReviewing what the auditor does not see (hypothesis, data, code, stress) and a paper trading period that compares signals, fills and costs with what the backtest says, before trading live with a small size
    • DNothing: it is proven to work
    Show answer

    Answer C. Reviewing what the auditor does not see (hypothesis, data, code, stress) and a paper trading period that compares signals, fills and costs with what the backtest says, before trading live with a small size

    The auditor is a necessary gate, not a sufficient one. In the course data, moreover, the strategy wins because the effect is planted; on real data, passing the auditor only says that the backtest does not have the errors the auditor looks for.

Key points

  • An honest backtest is shown with tests, not with a Sharpe: next-bar execution, costs, out of sample, number of tests (DSR), PBO and loss, plateau, capacity and a point-in-time universe.
  • The tests are coded on a result object that stores everything needed: returns, weights, the full grid of what was tried, the in-sample and out-of-sample split, the universe and the planned capital.
  • The strategy with a planted effect passes all eight; the optimized ESG placebo passes four (execution, costs, out of sample and capacity) and fails the Deflated Sharpe (0.09), the out-of-sample loss (17%), the plateau (0.51) and survivorship.
  • No single test is enough: each one detects a different error, and a false positive that is stable over time can pass the out-of-sample check and have a low PBO.
  • The auditor does not replace manual review (hypothesis, data, code, stress) or paper trading, which checks that signals, fills and costs in real time match the backtest.

Resources

Documentation