Lesson 5.4.4 · 70 min

Backtesting ML signals with costs and Deflated Sharpe

A positive, stable IC is not money. Between the prediction and the result lie the rule that turns scores into weights, the turnover that rule generates and what each trade costs. This lesson walks that path with the ridge from the walk-forward of 5.4.3, logs every variant it tries and, at the end, discounts the selection with the Deflated Sharpe from lesson 2.5.4.

Fig. — Cumulative return of the decile long-short portfolio of the walk-forward ridge on the level 5 panel, gross and net of 10 bp per side (2015-2025)
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

From IC to money

The walk-forward of lesson 5.4.3 ended with a ridge with an IC of 0.0185 (t = 4.9) over 574 weeks: a real, stable and statistically clear signal. The question of this lesson is the one that matters to whoever has to trade it: how much does a portfolio built on that signal earn, after paying to change it every week? The path has four steps, and a fifth that tends to be forgotten:

ScorePortfolio ruleWeights per weekTurnover and costsNet returnLogDeflated Sharpe
Mermaid source
flowchart TB
  S[Score] --> C[Portfolio rule]
  C --> W[Weights per week]
  W --> T[Turnover and costs]
  T --> N[Net return]
  N --> R[Log]
  R --> D[Deflated Sharpe]

The fifth is the log: every model and portfolio variant you try is a trial, and the Sharpe of the best one only means something after discounting how many trials there were (lesson 2.5.4).

The scores: the walk-forward of 5.4.3

Each lesson runs separately, so this one redoes in a single cell what it needs from 5.4.3: the panel as arrays, the ridge from sufficient statistics and the walk-forward with the time rule. It adds two reference models: the oracle and the ridge with revision_analistas, the leaky feature.

IN [01]python
import numpy as np
import pandas as pd
from scipy.stats import norm, rankdata

feat = pd.read_parquet("panel5_features.parquet")
feat = feat.sort_values(["fecha", "ticker"])
etiq = pd.read_parquet("panel5_etiquetas.parquet")
etiq = etiq.sort_values(["fecha", "ticker"])
verdad = pd.read_parquet("panel5_verdad.parquet")
verdad = verdad.sort_values(["fecha", "ticker"])
TODAS = [c for c in feat.columns if c not in
         ("fecha", "ticker", "sector")]
fechas = pd.DatetimeIndex(feat["fecha"].unique())
nW, nA = len(fechas), feat["ticker"].nunique()

F = feat[TODAS].to_numpy(dtype=float).reshape(nW, nA, -1)
with np.errstate(invalid="ignore", divide="ignore"):
    n = np.isfinite(F).sum(axis=1, keepdims=True)
    G = np.nan_to_num(norm.ppf(
        (rankdata(F, axis=1, nan_policy="omit") - 0.5) / n))
R = etiq["ret_sig_semana"].to_numpy().reshape(nW, nA)
fin = etiq["fin_sig_semana"].to_numpy().reshape(nW, nA)[:, 0]
con_feat = np.isfinite(F[:, :, 0]).all(axis=1)
valida = con_feat & np.isfinite(R).all(axis=1)
Yc = np.zeros_like(R)
Yc[valida] = R[valida] - R[valida].mean(axis=1, keepdims=True)
anyo = fechas.year.to_numpy()
semanas = np.flatnonzero(valida & (anyo >= 2015))  # backtest

def estadisticos(cols):
    X1 = np.concatenate([np.ones((nW, nA, 1)), G[:, :, cols]], 2)
    pv = valida.astype(float)
    return (np.einsum("wni,wnj->wij", X1, X1) * pv[:, None, None],
            np.einsum("wni,wn->wi", X1, Yc) * pv[:, None])

def walk_forward(cols, ventana=None, cada=None, anual=False,
                 semivida=None, alfa=10.0):
    S_xx, S_xy = estadisticos(cols)
    k = len(cols)
    pred, beta = np.full((nW, nA), np.nan), None
    for i, w in enumerate(semanas):
        if (beta is None or (cada and i % cada == 0) or
                (anual and anyo[w] != anyo[semanas[i - 1]])):
            u = int(np.searchsorted(fin, fechas[w])) - 1
            p = valida.astype(float)
            p[u + 1:] = 0.0
            if ventana:
                p[:max(0, u + 1 - ventana)] = 0.0
            if semivida:
                p *= 0.5 ** ((u - np.arange(nW)) / semivida)
            A, b = np.tensordot(p, S_xx, 1), np.tensordot(p, S_xy, 1)
            mx, my = A[0, 1:] / A[0, 0], b[0] / A[0, 0]
            C = A[1:, 1:] - A[0, 0] * np.outer(mx, mx)
            c = b[1:] - A[0, 0] * mx * my
            beta = np.linalg.solve(C + alfa * np.eye(k), c)
        pred[w] = G[w][:, cols] @ beta
    return pred

sin_fuga = [i for i, c in enumerate(TODAS)
            if c != "revision_analistas"]
modelos = {
    "exp. annual": walk_forward(sin_fuga, anual=True),
    "exp. weekly": walk_forward(sin_fuga, cada=1),
    "rolling 3 years": walk_forward(sin_fuga, ventana=156, cada=1),
    "half-life 2 years": walk_forward(sin_fuga, semivida=104, cada=1),
}
fuga = walk_forward(list(range(len(TODAS))), anual=True)
oraculo = verdad["alfa_verdadero"].to_numpy().reshape(nW, nA)
print(f"{len(semanas)} backtest weeks: "
      f"{fechas[semanas[0]].date()} to {fechas[semanas[-1]].date()}")
OUT [01]Panel, walk-forward ridge and reference models
574 backtest weeks: 2015-01-02 to 2025-12-26

From scores to weights, with costs

The portfolio rule is the README one: each week, long the top-score decile and short the bottom one, equally weighted, one dollar per leg (two of gross notional). Between one week and the next, weights drift with returns; turnover is the sum of the absolute weight changes needed to get back to the target portfolio, and the cost is 10 bp per unit of turnover:

turnovert=∑i∣wi,t−w~i,t−1∣,rtnet=∑iwi,t ri,t+1−c⋅turnovert\text{turnover}_t = \sum_i \left| w_{i,t} - \tilde w_{i,t-1} \right|, \qquad r^{\text{net}}_t = \sum_i w_{i,t}\, r_{i,t+1} - c \cdot \text{turnover}_t

with w~i,t−1=wi,t−1(1+ri,t)\tilde w_{i,t-1} = w_{i,t-1}(1 + r_{i,t}) the previous week's weight after moving with the market and cc = 0.001. The function supports three variants that reduce turnover and are used further down:

  • cada: rebalance only every n weeks (in between, the weights drift).
  • suavizado: trade on an exponential average of the score's rank, st=λ rankt+(1−λ) st−1s_t = \lambda\,\text{rank}_t + (1 - \lambda)\,s_{t-1}, instead of on that week's score. It is the idea of Gârleanu and Pedersen (2013): move only part of the way toward the target portfolio.
  • banda: enter in the extreme 10% and do not exit while still in the 30% (a band between buying and holding, like those studied by Novy-Marx and Velikov, 2016).
IN [02]python
def cartera(score, q=0.1, cada=1, suavizado=None, banda=None,
            coste=0.001):
    """Gross and net weekly returns and mean turnover."""
    w_ant = np.zeros(nA)
    s_prev = None
    bruto, neto, rot = [], [], []
    for i, w in enumerate(semanas):
        rk = (rankdata(score[w]) - 0.5) / nA
        if suavizado:
            s_prev = rk if s_prev is None else (
                suavizado * rk + (1 - suavizado) * s_prev)
            rk = (rankdata(s_prev) - 0.5) / nA
        if i % cada == 0:
            largo, corto = rk >= 1 - q, rk <= q
            if banda:
                largo |= (w_ant > 0) & (rk >= 1 - banda)
                corto |= (w_ant < 0) & (rk <= banda)
            peso = np.zeros(nA)
            peso[largo] = 1 / largo.sum()
            peso[corto] = -1 / corto.sum()
        else:
            peso = w_ant
        r = float(peso @ R[w])
        t = np.abs(peso - w_ant).sum()
        bruto.append(r)
        neto.append(r - coste * t)
        rot.append(t)
        w_ant = peso * (1 + R[w])
    return np.array(bruto), np.array(neto), float(np.mean(rot[1:]))

def sharpe(x):
    return x.mean() / x.std(ddof=1) * np.sqrt(52)

filas = {}
for nombre, sc in (("oracle", oraculo),
                   ("ridge (exp. annual)", modelos["exp. annual"]),
                   ("ridge + leak", fuga)):
    b, nt, rot = cartera(sc)
    filas[nombre] = {"gross ret.": f"{b.mean() * 52:.1%}",
                     "vol.": f"{b.std(ddof=1) * np.sqrt(52):.1%}",
                     "gross Sharpe": round(sharpe(b), 2),
                     "turnover": f"{rot:.0%}",
                     "net ret.": f"{nt.mean() * 52:.1%}",
                     "net Sharpe": round(sharpe(nt), 2)}
pd.DataFrame(filas).T
OUT [02]Decile long-short portfolio with turnover and costs
                    gross ret.  vol. gross Sharpe turnover net ret. net Sharpe
oracle                   17.1%  9.6%         1.78     259%     3.6%       0.38
ridge (exp. annual)      12.1%  9.7%         1.25     265%    -1.7%      -0.18
ridge + leak            136.5%  6.9%        19.89     287%   121.6%      17.74

The three rows are the README ones. The oracle, which knows the true alpha, has a gross Sharpe of 1.78 and a net one of 0.38: even the best possible information barely survives a weekly turnover of 259%. The honest ridge goes from 1.25 gross to −0.18 net. The leaky one, from 19.9 to 17.7: an impossible number, which in a real backtest should set off the alarm before any test.

The figure at the top shows the difference over time for the ridge: the gross curve rises steadily; the net one, with the same shape, ends below where it started.

The cost arithmetic

The arithmetic is simple and worth doing before any backtest. With a weekly turnover τ\tau and a cost cc per unit of turnover, the annual cost is 52 τ c52\,\tau\,c. The signal can bear the cost as long as its annual gross return exceeds it:

IN [03]python
b, nt, rot = cartera(modelos["exp. annual"])
coste_anual = 52 * rot * 0.001
equilibrio = b.mean() * 52 / (52 * rot)
print(f"annual gross return {b.mean() * 52:.1%}; "
      f"annual cost {coste_anual:.1%}")
print(f"break-even cost: {equilibrio * 1e4:.1f} bp per side")
OUT [03]Annual cost and break-even cost
annual gross return 12.1%; annual cost 13.8%
break-even cost: 8.8 bp per side

The ridge earns 12.1% gross a year and pays 13.8% in costs. It could bear about 8.8 bp per side: with the README's 10, it loses. There are two levers: more gross return per unit of turnover (a better signal) or less turnover for the same return (a better portfolio rule). The second is the one you can touch without modeling again.

An experiment log

Before trying variants, the log. Each experiment stores its full configuration, its metrics and its series of net returns: without the series you cannot compute the dispersion of the Sharpes or the PBO at the end (lesson 2.2.4). The log also stores the variants that do not work, which are the ones that tell you most about luck.

The grid is small and reasonable, the one anyone would try: the four walk-forward models of 5.4.3 (expanding annual and weekly, three-year rolling, weights with a two-year half-life) times five portfolio rules (deciles, quintiles, deciles every 4 weeks, smoothed ranks with λ = 0.25 and a 30% band). Twenty experiments.

IN [04]python
reglas = {"deciles": {}, "quintiles": {"q": 0.2},
          "every 4 weeks": {"cada": 4},
          "smoothed 0.25": {"suavizado": 0.25},
          "band 30%": {"banda": 0.3}}
registro, series = [], {}
for m, sc in modelos.items():
    for r, kw in reglas.items():
        b, nt, rot = cartera(sc, **kw)
        clave = f"{m} | {r}"
        registro.append({"experiment": clave, "model": m,
                         "rule": r, "params": kw,
                         "gross Sharpe": sharpe(b),
                         "net Sharpe": sharpe(nt), "turnover": rot})
        series[clave] = nt
registro = pd.DataFrame(registro).set_index("experiment")
netos = pd.DataFrame(series, index=fechas[semanas])
print(f"logged experiments: {len(registro)}")
(registro[["gross Sharpe", "net Sharpe", "turnover"]]
 .sort_values("net Sharpe", ascending=False).head(8).round(2))
OUT [04]Twenty logged experiments
logged experiments: 20
                                   gross Sharpe  net Sharpe  turnover
experiment
exp. annual | smoothed 0.25                0.88        0.37      0.86
exp. weekly | smoothed 0.25                0.79        0.25      0.88
exp. annual | every 4 weeks                0.59        0.18      0.73
exp. annual | band 30%                     1.09        0.12      1.44
exp. weekly | every 4 weeks                0.46        0.05      0.73
half-life 2 years | smoothed 0.25          0.56        0.05      0.86
exp. weekly | band 30%                     1.00        0.03      1.49
exp. annual | deciles                      1.25       -0.18      2.65
Fig. — The 20 logged experiments (4 walk-forward models by 5 portfolio rules) on the level 5 panel: weekly turnover against Sharpe net of 10 bp per side

The figure places the twenty experiments on the turnover-net Sharpe plane. The rule is visible: the high-turnover variants (weekly deciles and quintiles, on the right) lose money with any model; the low-turnover ones (rebalancing every 4 weeks, smoothing, band) stay close to zero or somewhat above it with the expanding models, and below it with the rolling window, which lesson 5.4.3 already ruled out because of its IC. The best is the annual expanding ridge with smoothed ranks: a net Sharpe of 0.37, with a turnover of 86%. Almost the same as the oracle with weekly deciles.

The Deflated Sharpe of the best one

Is 0.37 a result? Three questions, from least to most demanding. The functions are those of lesson 2.5.4, with the Sharpes at the frequency of the data (weekly):

PSR^(SR∗)=Φ((SR^−SR∗)T−11−γ^3 SR^+γ^4−14 SR^2),SR0=V[SR^n][(1−γ) Φ−1 ⁣(1−1N)+γ Φ−1 ⁣(1−1Ne)]\widehat{PSR}(SR^*) = \Phi\left(\frac{(\widehat{SR} - SR^*)\sqrt{T-1}}{\sqrt{1 - \widehat{\gamma}_3\,\widehat{SR} + \frac{\widehat{\gamma}_4 - 1}{4}\,\widehat{SR}^2}}\right), \qquad 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]
IN [05]python
def psr(sr, umbral, t, asim, curt):
    den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr ** 2)
    return float(norm.cdf((sr - umbral) * np.sqrt(t - 1) / den))

def sr_max_esperado(n, var_sr):
    g = np.euler_gamma
    return np.sqrt(var_sr) * ((1 - g) * norm.ppf(1 - 1 / n)
                              + g * norm.ppf(1 - 1 / (n * np.e)))

sr_sem = netos.mean() / netos.std()       # weekly Sharpes
mejor = sr_sem.idxmax()
x = netos[mejor]
T, asim, curt = len(x), x.skew(), x.kurt() + 3
sr = sr_sem[mejor]
et_anual = np.sqrt((1 + sr ** 2 / 2) / T) * np.sqrt(52)
print(f"best: {mejor}")
print(f"net Sharpe {sr * np.sqrt(52):.2f} ± {et_anual:.2f} "
      f"(standard error, {T} weeks)")
print(f"PSR(0) = {psr(sr, 0, T, asim, curt):.3f}")

autoval = np.linalg.eigvalsh(netos.corr().to_numpy())
n_ef = autoval.sum() ** 2 / (autoval ** 2).sum()
for etiqueta, n_int in (("effective trials", n_ef),
                        ("20 logged", 20),
                        ("with the 13 from 5.4.3 x 5 rules", 65)):
    sr0 = sr_max_esperado(n_int, sr_sem.var())
    print(f"N = {n_int:5.1f} ({etiqueta}): SR_0 = "
          f"{sr0 * np.sqrt(52):.2f} annual, "
          f"DSR = {psr(sr, sr0, T, asim, curt):.3f}")
OUT [05]Standard error, PSR and Deflated Sharpe of the best experiment
best: exp. annual | smoothed 0.25
net Sharpe 0.37 ± 0.30 (standard error, 574 weeks)
PSR(0) = 0.890
N =   2.0 (effective trials): SR_0 = 0.20 annual, DSR = 0.713
N =  20.0 (20 logged): SR_0 = 0.71 annual, DSR = 0.128
N =  65.0 (with the 13 from 5.4.3 x 5 rules): SR_0 = 0.89 annual, DSR = 0.042

First question: can it be told apart from zero, as if it were the only trial? The standard error of an annual Sharpe with eleven years of weekly data is ±0.30, almost as large as the Sharpe itself, and the PSR(0) is 0.89: even without correction it does not reach 95%.

Second: does it beat the best one expected among the trials? The 20 experiments are highly correlated (they share models and rules), and the participation ratio of the eigenvalues of their correlation matrix gives only 2 effective trials: with such a favorable N, the threshold is a net Sharpe of 0.20 and the DSR, 0.71. With the 20 declared, the threshold rises to 0.71 and the DSR drops to 0.13. And the honest count includes the trials of the previous lesson: the walk-forward of 5.4.3 tested thirteen schemes before choosing the four used here, and each one could have been combined with five rules; with 65, the DSR drops to 0.04. No way of counting reaches 0.95.

Third: what do you present? What the numbers allow: the honest ridge gives a net Sharpe compatible with zero, between −0.4 and 0.4 depending on the portfolio rule with the expanding models, with a standard error of ±0.3. It is the README's answer (“net Sharpe ≈ 0”) and it is the correct conclusion, even though the gross IC is clearly positive.

The figure sums up the second question: the DSR of the best experiment depending on how many trials are declared. No reasonable way of counting brings it close to the 0.95 line.

Fig. — Deflated Sharpe of the best of the 20 experiments (net Sharpe 0.37 over 574 weeks) by the number of declared trials

The DSR does not see leaks

The ridge with revision_analistas needs no correction to look good, and no correction uncovers it:

IN [06]python
_, nt_fuga, rot_fuga = cartera(fuga)
sr_f = nt_fuga.mean() / nt_fuga.std(ddof=1)
var_con = pd.concat([sr_sem, pd.Series({"leak": sr_f})]).var()
sr0 = sr_max_esperado(21, var_con)
xf = pd.Series(nt_fuga)
dsr_f = psr(sr_f, sr0, len(xf), xf.skew(), xf.kurt() + 3)
print(f"net Sharpe {sr_f * np.sqrt(52):.1f}; SR_0 with N = 21: "
      f"{sr0 * np.sqrt(52):.2f}; DSR = {dsr_f:.3f}")
OUT [06]The Deflated Sharpe of a model with a leak
net Sharpe 17.7; SR_0 with N = 21: 7.58; DSR = 1.000

A net Sharpe of 17.7 leaves any threshold behind, even the 7.6 that comes out when the leak itself is included in the dispersion of the trials: the DSR is 1.000. The DSR discounts selection (having chosen the best of N trials with no edge), not information from the future: with a leak, the edge is real inside the backtest, because the backtest sees it. The only things that give it away are the order of magnitude (a weekly net Sharpe of 17 in stocks does not exist) and, above all, the audit of when each piece of data is known (lesson 5.2.2). Validation (5.4.1-5.4.3) and the correction for selection (this lesson) are applied after that audit, not instead of it.

What to present

QuestionAnswer in this panel
Does the model have signal?Yes: IC 0.0185, t = 4.9 (lesson 5.4.3)
Does it make money with the starting rule?No: net Sharpe −0.18 with weekly deciles
And with the best portfolio rule?0.37 ± 0.30, PSR(0) 0.89
Does it survive selection?No: DSR 0.13 with 20 trials, 0.71 with the 2 effective ones
What is the ceiling?The oracle: 0.38 net with weekly deciles
ConclusionReal signal, net profit compatible with zero; work pending on costs and execution

The last row is not a failure: it is information. It says that the next effort is not in another model (the ridge already captures a sizable part of what the oracle captures) but in execution: lower costs (lesson 4.3.1), optimized portfolios with a turnover penalty (lesson 4.1.5) or a signal combined with other, less correlated ones (lesson 5.10.3).

Exercises

  1. Apply the best portfolio rule (smoothed ranks with λ = 0.25) to the oracle. What is the best net Sharpe this panel allows with that rule, and how far is the ridge from it?
Solution
IN [07]python
for nombre, sc in (("oracle", oraculo),
                   ("ridge exp. annual", modelos["exp. annual"])):
    b, nt, rot = cartera(sc, suavizado=0.25)
    print(f"{nombre:<18} gross {sharpe(b):.2f}  net {sharpe(nt):.2f}"
          f"  turnover {rot:.0%}")
OUT [07]The oracle with the smoothed rule
oracle             gross 1.14  net 0.64  turnover 91%
ridge exp. annual  gross 0.88  net 0.37  turnover 86%

With the smoothed rule, the oracle goes from 0.38 to 0.64 net Sharpe and the ridge stays at 0.37: the rule does not only help the ridge, it helps any slow signal. The distance between the two net figures is what remains to be gained with a better model; the distance between the oracle's net and gross figures, what remains to be gained with better execution. Always comparing with the ceiling tells you what to work on.

  1. Compute the break-even cost per side of the best variant (annual expanding ridge, smoothing 0.25). At what real costs would it be tradable?
Solution
IN [08]python
b, nt, rot = cartera(modelos["exp. annual"], suavizado=0.25)
print(f"turnover {rot:.0%} weekly; annual gross {b.mean() * 52:.1%};"
      f" break-even {b.mean() / rot * 1e4:.1f} bp per side")
OUT [08]Break-even cost of the best variant
turnover 86% weekly; annual gross 7.7%; break-even 17.2 bp per side

With a weekly turnover of 86% and 7.7% gross a year, the smoothed ridge can bear 17.2 bp per side, twice as much as the weekly decile version (8.8). That puts it within reach of portfolios in liquid stocks, where the total cost (commission, bid-ask spread and market impact) can stay below that; but the margin over the 10 bp is about 7 bp, impact grows with size, and the resulting Sharpe cannot be told apart from zero. The practical conclusion is the same: before trading it you have to measure the real costs with the real portfolio.

  1. Write down what fields the log of an experiment would have in a real project, beyond those in the cell.
Solution

Besides the configuration and the net return series: the data version (and its fingerprint, for example a hash of the file), the code version (commit), the seeds, the training and test periods, the validation scheme with its parameters (purging, embargo, window, frequency), the cost model, the date and the person who launched it, and a note on why it was tried. Also the experiments that failed or were aborted. With that, any number in the final report can be reproduced and the number of trials can be counted without relying on memory (lesson 2.2.4). Tools such as MLflow automate it, but a text file with one row per experiment is enough if it is maintained with discipline.

Each answer is folded under its question

Self-assessment

  1. The walk-forward ridge has a gross Sharpe of 1.25 with a weekly turnover of 265% and costs of 10 bp per side. Why is the net −0.18?

    • ABecause the model is overfitted
    • BBecause turnover costs 2.65 × 52 × 10 bp ≈ 13.8% a year and the portfolio earns 12.1% gross: the cost eats the whole edge and then some
    • CBecause costs of 10 bp are unrealistic
    • DBecause the net Sharpe is computed with log returns
    Show answer

    Answer B. Because turnover costs 2.65 × 52 × 10 bp ≈ 13.8% a year and the portfolio earns 12.1% gross: the cost eats the whole edge and then some

    The annual gross return is 12.1%; the annual cost, turnover times the cost per unit, 13.8%. The break-even point is about 8.8 bp per side: at 10, you lose.

  2. The best of the 20 variants in the log (rebalancing with smoothed scores) has a net Sharpe of 0.37. What does its Deflated Sharpe say with N = 20?

    • AThat, given the dispersion of the 20 trials, the best one expected by chance has a net Sharpe of around 0.71: 0.37 is well below that, and the DSR is 0.13
    • BThat it is significant at 95%
    • CThat the DSR cannot be computed with weekly data
    • DThat the result is negative
    Show answer

    Answer A. That, given the dispersion of the 20 trials, the best one expected by chance has a net Sharpe of around 0.71: 0.37 is well below that, and the DSR is 0.13

    Even without correction, its PSR(0) is 0.89, below 0.95. Corrected for having chosen the best of 20, the DSR falls to 0.13. It is a result compatible with zero.

  3. With revision_analistas, the same ridge has a net Sharpe of 17.7 and a DSR of 1.000 for any N. What do you conclude?

    • AThat the strategy is excellent
    • BThat the DSR is coded wrong
    • CThat more trials have to be declared
    • DThat the DSR discounts selection among trials, not information from the future: a leak passes any statistical correction. A net Sharpe of 17 is the alarm
    Show answer

    Answer D. That the DSR discounts selection among trials, not information from the future: a leak passes any statistical correction. A net Sharpe of 17 is the alarm

    The DSR answers “could this be the best of N trials with no edge?”. With a leak the edge is real inside the backtest, because the backtest sees the future. Only the point-in-time audit uncovers it.

  4. What must be saved from each experiment to be able to compute the DSR and the PBO at the end?

    • AThe Sharpe of the best one
    • BOnly the parameters of the final model
    • CThe full configuration and the net return series of every variant, including the discarded ones, and the total number of variants tried (including those from previous lessons or notebooks)
    • DNothing: the DSR only needs the final Sharpe
    Show answer

    Answer C. The full configuration and the net return series of every variant, including the discarded ones, and the total number of variants tried (including those from previous lessons or notebooks)

    The DSR needs N and the dispersion of the Sharpes of all the trials; the PBO, the full matrix of returns. What is not logged cannot be discounted.

  5. What do the smoothing and band rules do to improve the net result?

    • AThey increase the model's IC
    • BThey eliminate costs
    • CThey change the universe
    • DThey reduce turnover (from 265% to 86-144% a week) at the cost of following the signal with a lag: they lose gross Sharpe, but save more in costs than they lose
    Show answer

    Answer D. They reduce turnover (from 265% to 86-144% a week) at the cost of following the signal with a lag: they lose gross Sharpe, but save more in costs than they lose

    With a signal that changes little from one week to the next, trading every small change in rank is expensive. Smoothing or requiring a band to enter and exit keeps most of the exposure with a fraction of the trades.

Key points

  • The README is reproduced: oracle with a gross Sharpe of 1.78 and net 0.38; ridge with all the features, 1.25 gross and −0.18 net, with a weekly turnover of 265% and 10 bp per side.
  • The cost arithmetic is simple: turnover × 52 × cost per side. The ridge can bear about 8.8 bp per side before losing.
  • Reducing turnover (smoothing scores, bands, rebalancing every 4 weeks) lowers the gross Sharpe and raises the net. The best of 20 variants reaches 0.37 net.
  • That 0.37 has a standard error of ±0.30 over eleven years, a PSR(0) of 0.89 and a DSR of 0.13 with 20 trials: compatible with zero.
  • The experiment log is part of the result: without the series of each variant there is no DSR or PBO.
  • The DSR does not see leaks: the model with revision_analistas gives a net Sharpe of 17.7 and a DSR of 1.000.

Resources

Documentation

Data