La mayoría de los backtests de los que me enorgullecí a primera vista resultó estar mal, y no por poco: mal justo de la manera que hace que un random walk parezca una máquina de plata. Una señal que mira una barra al futuro, un scaler ajustado con toda la muestra, una grilla de parámetros que nadie contó — cada una suma en silencio un Sharpe que nunca existió.
En este tutorial armo un backtest sin fugas en numpy y pandas puros, lo evalúo con splits de walk-forward con purga escritos a mano y corrijo el número final con el Deflated Sharpe Ratio. Todos los números salen de datos sintéticos sin ventaja alguna, así que la respuesta correcta se conoce: no hay nada que encontrar.
Metodología educativa, no asesoramiento financiero
Las series de acá son ruido aleatorio a propósito. Nada en este post es una estrategia para operar, ningún resultado describe un mercado real y ningún backtest, por limpio que sea, promete retornos futuros.
1. El Problema: La Mayoría de los Backtests Están Arreglados Sin Querer
Un backtest debería responder una sola pregunta: ¿esta regla habría ganado plata con datos que no vio? Tres cosas cambian la pregunta en silencio. La información del futuro se filtra en las decisiones. Se deja afuera el costo de operar. Y probás muchas variantes y reportás la mejor. Las dos primeras inflan cada corrida; la tercera infla a la ganadora, aun cuando ninguna variante tenga habilidad alguna.
El Error Más Común
Calcular una señal con el cierre de la barra t y dejar que gane el retorno de la barra t. Es un .shift(1) que falta, la línea se lee natural en el code review y, en la serie sintética de abajo, convierte una regla sin ventaja en un Sharpe anualizado por encima de 5.
La solución no es un modelo más inteligente. Son tres capas de disciplina: un pipeline donde el futuro no puede alcanzar al pasado, un esquema de evaluación que respeta el tiempo (walk-forward con purga y embargo) y un estadístico que sabe cuántas veces miraste, el Deflated Sharpe Ratio de Bailey y López de Prado.
2. Conceptos Mínimos Antes de Tocar Código
Seis ideas sostienen el resto del tutorial. Con esto claro, cada bloque de código se lee como castellano simple.
Look-ahead bias
la fuga clásica
Cualquier uso de información que no estaba disponible al momento de decidir: una señal sin desplazar, una ventana móvil centrada, un hueco rellenado hacia atrás, una barra diaria unida a filas intradía antes de cerrar.
Split antes de fit
orden de operaciones
Primero partís en orden temporal. Después, todo lo que se ajusta (scalers, umbrales, parámetros elegidos) sale solo de las filas de entrenamiento. El bloque de test se transforma, nunca se ajusta.
Purga
solapamiento de etiquetas
Si una etiqueta mira h barras adelante, las filas de entrenamiento a menos de h barras del bloque de test cargan información del período de test. La purga descarta toda fila cuya ventana de etiqueta se solape con el test.
Embargo
correlación serial
Unas barras más que se descartan del entrenamiento justo después de un bloque de test, porque los vecinos están correlacionados más allá del solapamiento. Solo importa cuando hay datos de entrenamiento después del test (k-fold, CPCV).
Walk-forward
respetá la flecha del tiempo
Entrenás con el pasado, testeás en el bloque siguiente, avanzás y unís los tramos fuera de muestra. Se parece más a correrlo en vivo, pero sigue siendo un solo camino por una sola historia.
Pruebas múltiples
contá tus trials
El mejor de N está sesgado hacia arriba incluso sin habilidad. El Deflated Sharpe Ratio compara tu Sharpe con el máximo que daría el azar tras N intentos; la PBO pregunta con qué frecuencia el ganador en muestra cae bajo la mediana fuera de muestra.
El modelo mental a conservar
Un backtest es una afirmación sobre el futuro hecha con datos del pasado. Las fugas abaratan hacer la afirmación; los trials extra abaratan encontrarla. Tu trabajo es volver a encarecer las dos cosas antes de creerle al número.
3. Paso 1 — Un Mercado Donde Conocés la Respuesta
Para probar un pipeline de validación necesitás datos donde la verdad se conoce. El laboratorio es un random walk con semilla cuyos log-retornos son independientes y de media cero: ninguna regla puede predecir la barra siguiente, así que toda ’ventaja’ que encuentre el código es un bug o una casualidad. Primera fuga: la señal sin desplazar.
Python — serie sintética, una señal sin desplazar y su gemela honesta
import numpy as np
import pandas as pd
ANN = np.sqrt(252) # per-bar Sharpe -> annualized (daily bars)
def make_close(seed, n=2520):
"""Zero-drift random walk: by construction there is NO edge to find."""
r = np.random.default_rng(seed).normal(0.0, 0.01, n)
idx = pd.bdate_range("2016-01-04", periods=n)
return pd.Series(100 * np.exp(np.cumsum(r)), index=idx)
def sharpe(x): # per bar, NOT annualized
return x.mean() / x.std(ddof=1)
close = make_close(3)
ret = close.pct_change()
ma = close.rolling(20).mean()
side = pd.Series(np.where(close > ma, 1.0, -1.0), index=close.index) # +1 / -1, known at the close
leaky = (side * ret).iloc[20:] # decided with close[t], earns ret[t]
honest = (side.shift(1) * ret).iloc[20:] # decided with close[t-1], earns ret[t]
print(f"leaky Sharpe: {sharpe(leaky) * ANN:5.2f}") # 5.09
print(f"honest Sharpe: {sharpe(honest) * ANN:5.2f}") # 0.08Con la semilla 3 (numpy 2.2.6, pandas 2.3.3, scipy 1.16.3) la versión con fuga imprime un Sharpe anualizado de 5.09 y la desplazada 0.08. Misma regla, mismos datos, un .shift(1) de diferencia. Todas las cifras de este post son sintéticas y se anualizan con la raíz de 252; los decimales pueden variar con otras versiones de las librerías, las conclusiones no deberían.
Tres fugas más que el shift no atrapa
- Ajustar con toda la muestra: scalers, PCA, selección de features y búsquedas de parámetros que ven el bloque de test. Los datos de test moldean el modelo que después se evalúa con ellos.
- Sesgo de supervivencia: un universo armado con lo que existe hoy. El paper del Deflated Sharpe lo lista entre las formas de sesgo de selección, como los índices que solo siguen a los fondos que no quebraron.
- Fills de cierre a cierre: decidir con el cierre de una barra y ejecutar a ese mismo cierre. Recién sabés el cierre cuando se imprime, así que un fill a ese precio es un modelo, no un hecho.
Python — sesgo de supervivencia en una docena de líneas
rng = np.random.default_rng(11)
panel = rng.normal(0.0, 0.02, (1000, 2000)) # 2,000 zero-drift assets x 1,000 days
final = np.cumprod(1 + panel, axis=0)[-1] # terminal value of 1 unit invested
alive = final > 0.5 # "delisted" if it lost more than half
print(f"whole universe : {final.mean():.2f}") # 1.01
print(f"survivors only : {final[alive].mean():.2f} ({alive.mean():.0%} survive)") # 1.19 (78%)2.000 activos construidos con deriva cero promedian un valor terminal de 1.01, como se diseñó. Quedate solo con el 78% que nunca perdió más de la mitad y el promedio pasa a 1.19. Ningún activo tiene habilidad; el filtro es todo el efecto.
Por qué la semilla 3, y por qué no es típica
La semilla 3 es la primera de las semillas 0 a 199 donde la mejor de la grilla de 36 configuraciones de la sección 6 pasa un test de significancia ingenuo (PSR sobre 0.99). La elegí a propósito para mostrar cómo luce un falso positivo tentador; la sección 6 corre después las 200 semillas para mostrar con qué frecuencia pasa.
4. Paso 2 — Un Pipeline Sin Fugas en pandas Puro
Tres reglas, en este orden: partí antes de ajustar, actuá una barra después de decidir y descontá el costo de cada unidad que operás.
Python — primero el split, ajustá solo con train
cut = int(len(ret) * 0.7) train, test = ret.iloc[1:cut], ret.iloc[cut:] # 1) split FIRST, in time order, no shuffling mu, sd = train.mean(), train.std() # 2) every fitted quantity comes from train only z_test = (test - mu) / sd # 3) test is only ever transformed, never fitted # leaky contrast: (ret - ret.mean()) / ret.std() lets the test block shape its own scaler
La segunda regla es el .shift(1) del paso 1, ahora dentro de una función que además cobra costos. fee_bps y slip_bps se aplican al turnover, las unidades operadas en cada barra; pasar de corto a largo son dos unidades. El argumento delay agrega barras entre la decisión y el fill cuando querés ser más conservador.
Python — señal, shift, turnover, costos
def side_of(close, fast, slow):
"""+1 / -1 from a moving-average crossover, known at the close of bar t."""
ma_f, ma_s = close.rolling(fast).mean(), close.rolling(slow).mean()
side = pd.Series(np.where(ma_f > ma_s, 1.0, -1.0), index=close.index)
return side.where(ma_s.notna(), 0.0) # flat until the slow window is warm
def backtest(close, fast, slow, fee_bps=5, slip_bps=5, delay=0):
ret = close.pct_change()
pos = side_of(close, fast, slow).shift(1 + delay).fillna(0.0) # act on the NEXT bar (+ delay)
turnover = pos.diff().abs().fillna(0.0) # units traded per bar
cost = turnover * (fee_bps + slip_bps) / 1e4
return (pos * ret - cost).iloc[1:]
gross = backtest(close, 10, 50, fee_bps=0, slip_bps=0)
net = backtest(close, 10, 50) # 5 bps fee + 5 bps slippage per unit traded
print(f"gross Sharpe: {sharpe(gross) * ANN:5.2f} net Sharpe: {sharpe(net) * ANN:5.2f}") # -0.51 / -0.60En la semilla 3 el cruce 10/50 imprime un Sharpe bruto de −0.51 y uno neto de −0.60: los costos solo restan, y una regla que cambia seguido los paga más seguido.
Cierre a cierre es un modelo, no un hecho
En barras diarias, ’decido al cierre, ejecuto a ese cierre’ asume que podés operar un precio que solo conocés cuando se imprime. Ejecutá en la apertura de la barra siguiente si la tenés, o sumá una barra con delay=1. El preprint de la sección 5 hace lo primero: las órdenes puestas al cierre se ejecutan en la apertura siguiente, con slippage.
Python — probá que no hay look-ahead con un test de truncamiento
def assert_no_lookahead(fn, close, cut=1500):
"""Positions on the truncated history must equal positions on the full history."""
full = fn(close).iloc[:cut]
partial = fn(close.iloc[:cut])
assert np.allclose(full, partial, equal_nan=True), "signal at t changed when the future was removed"
assert_no_lookahead(lambda c: side_of(c, 10, 50), close) # passes silently
centered = lambda c: np.sign(c - c.rolling(11, center=True).mean()) # peeks 5 bars ahead
try:
assert_no_lookahead(centered, close)
except AssertionError as e:
print("caught:", e)Recalculá las posiciones sobre una historia truncada y comparalas con las mismas filas de la historia completa. Si sacar el futuro cambia el pasado, la señal espía. La ventana centrada falla el test y el pipeline lo pasa. Dejo esta aserción en cada repo de investigación.
5. Paso 3 — Walk-Forward con Purga y Embargo, Escrito a Mano
Las librerías esconden la lógica de índices, y en la lógica de índices viven las fugas, así que escribila una vez. Cada fila i tiene una etiqueta que mira h barras adelante: un retorno futuro, un trade que cierra h barras después. Una fila de entrenamiento a menos de h barras antes del test lleva precios del período de test en su etiqueta, así que la purga la descarta. El embargo descarta e filas más justo después del bloque de test, porque los vecinos de un bloque de test están correlacionados con él.
Python — purged_splits: toda la idea en 15 líneas
def purged_splits(n, n_folds, horizon, embargo=0, forward_only=False, min_train=1):
"""Yield (train_idx, test_idx) as positional indices.
horizon : bars a label looks ahead (label of row i spans rows i..i+horizon)
embargo : bars dropped from train right AFTER each test block
forward_only : True = walk-forward (train strictly before the test block)"""
pos = np.arange(n)
bounds = np.linspace(0, n, n_folds + 1).astype(int)
for a, b in zip(bounds[:-1], bounds[1:]): # test block = rows a .. b-1
overlaps = (pos + horizon >= a) & (pos < b) # label window touches the test block
after = (pos >= b) & (pos < b + embargo) # serial-correlation buffer
keep = ~(overlaps | after)
if forward_only:
keep &= pos < a
if keep.sum() >= min_train:
yield pos[keep], pos[a:b]
for tr, te in purged_splits(20, 4, horizon=2, embargo=1):
print("test", te.tolist(), "train", tr.tolist())
# test [0, 1, 2, 3, 4] train [6 .. 19] (5 embargoed)
# test [5, 6, 7, 8, 9] train [0, 1, 2, 11 .. 19] (3, 4 purged; 10 embargoed)
# test [10, 11, 12, 13, 14] train [0 .. 7, 16 .. 19] (8, 9 purged; 15 embargoed)
# test [15, 16, 17, 18, 19] train [0 .. 12] (13, 14 purged)Mirá la salida del ejemplo chico. Con horizon=2 se purgan dos filas antes de cada bloque de test, y con embargo=1 también desaparece la fila inmediatamente posterior. Con forward_only=True el entrenamiento nunca queda después del test, que es el walk-forward: ahí el embargo no tiene nada que quitar y la purga hace el trabajo. TimeSeriesSplit de scikit-learn tiene un argumento gap, la cantidad de muestras excluidas del final de cada train antes del test, que cumple el papel de la purga: ponelo al menos igual a tu horizonte de etiqueta.
Python — walk-forward: elegí con filas de train, evaluá en filas nunca vistas
import itertools
GRID = list(itertools.product((3, 5, 8, 10, 15, 20), (30, 50, 75, 100, 150, 200))) # 36 trials
H = 5 # label horizon: forward 5-bar return
def walk_forward(close, grid=GRID, n_folds=10, horizon=H, embargo=0):
fwd = close.shift(-horizon) / close - 1 # label: looks H bars into the future
sides = {p: side_of(close, *p) for p in grid}
nets = {p: backtest(close, *p).reindex(close.index) for p in grid}
oos = []
for train, test in purged_splits(len(close), n_folds, horizon, embargo,
forward_only=True, min_train=252):
score = {p: np.nanmean(sides[p].iloc[train] * fwd.iloc[train]) for p in grid} # train rows only
best = max(score, key=score.get)
oos.append(nets[best].iloc[test]) # scored on rows it never saw
return pd.concat(oos).dropna()
oos = walk_forward(close)
print(f"walk-forward OOS Sharpe (net): {sharpe(oos) * ANN:5.2f} over {len(oos)} bars") # 0.68 / 2016Cada fold elige el mejor de los 36 cruces según qué tan bien su señal predijo el retorno futuro a 5 barras solo en filas de entrenamiento, y después opera el bloque siguiente. En la semilla 3 el Sharpe fuera de muestra unido es 0.68 sobre 2.016 barras, neto de costos.
El walk-forward arregla las fugas, no la suerte
0.68 se ve respetable y acá no significa nada: la serie no tiene ventaja. Con ocho años de datos fuera de muestra, el error estándar de un Sharpe anualizado cercano a cero es de unos 0.35, y tenés un solo camino por una sola historia. Sobre 200 semillas la mediana del Sharpe walk-forward es −0.05, la respuesta honesta, aunque igual el 5.5% de las corridas imprime más de 0.5.
Cómo luce un reporte honesto de walk-forward
El preprint de diciembre de 2025 arXiv:2512.12924 (Deep, Deep y Lamptey, v1, enviado a Quantitative Finance and Economics) corre ventanas móviles sobre 100 acciones de EE.UU. entre 2015 y 2024: 252 días de entrenamiento, 63 de test, paso de 63, 34 períodos de test, órdenes al cierre que se ejecutan en la apertura siguiente con 5 bps de slippage. Reporta un retorno anualizado de 0.55%, un Sharpe de 0.33 y un p-value de 0.34, no significativo, y dice que 34 folds dan alrededor de 12% de potencia. Lo cito como ejemplo de reporte, no como estrategia. Según mi lectura, su protocolo propio son ventanas ordenadas en el tiempo; la purga y el embargo aparecen en su revisión de literatura, no en su método.
6. Paso 4 — Contá Cada Trial y Después Deflactá el Sharpe
Nunca corrés un solo backtest. Corrés una grilla, movés un umbral, probás otro activo, sacás una feature. El Deflated Sharpe Ratio (Bailey y López de Prado, Journal of Portfolio Management, 2014) pregunta: dado que este Sharpe es el mejor de N trials, ¿qué tan probable es que el Sharpe verdadero esté por encima de lo que daría el azar solo? Dos ingredientes hacen el trabajo: SR0, el Sharpe que esperarías que alcance el mejor de N trials sin habilidad, y el error estándar de tu Sharpe, que crece con la asimetría negativa y las colas gordas.
Las ecuaciones, tal como están impresas en el paper
Eq. 1 with the zero-skill null E[{SR_n}] = 0:
SR0 = sqrt( V[{SR_n}] ) · ( (1 − γ) · Z⁻¹[ 1 − 1/N ] + γ · Z⁻¹[ 1 − 1/(N·e) ] )
Eq. 2:
DSR = PSR(SR0) = Z[ (SR − SR0) · sqrt(T − 1) / sqrt( 1 − γ3 · SR + ((γ4 − 1)/4) · SR² ) ]
Eq. 9 (implied independent trials from M correlated ones, ρ = average pairwise correlation):
N_hat = ρ + (1 − ρ) · MSímbolos
- • SR: Sharpe ratio estimado de la estrategia elegida, por período (sin anualizar)
- • SR0: Sharpe máximo esperado de N trials sin habilidad, el umbral de rechazo
- • N: cantidad de trials independientes; V[{SR_n}]: varianza de los Sharpe estimados de los trials
- • T: largo de la muestra; γ3 y γ4: asimetría y curtosis de los retornos de la estrategia elegida (Normal: 0 y 3)
- • Z: CDF Normal estándar y Z⁻¹ su inversa; γ ≈ 0.5772: constante de Euler–Mascheroni; e: número de Euler
Python — SR0 y DSR, verificados contra el ejemplo resuelto del paper
from scipy.stats import norm, skew, kurtosis
EULER_GAMMA = 0.5772156649015329
def expected_max_sharpe(var_sr, n_trials):
"""SR0: expected best Sharpe of n_trials independent zero-skill trials (Eq. 1, E[SR_n] = 0)."""
return np.sqrt(var_sr) * ((1 - EULER_GAMMA) * norm.ppf(1 - 1 / n_trials)
+ EULER_GAMMA * norm.ppf(1 - 1 / (n_trials * np.e)))
def deflated_sharpe_ratio(sr, sr0, T, g3, g4):
"""Eq. 2. sr, sr0 per bar (NOT annualized); g3 skewness; g4 NON-excess kurtosis (Normal = 3)."""
se = np.sqrt(1 - g3 * sr + (g4 - 1) / 4 * sr ** 2)
return norm.cdf((sr - sr0) * np.sqrt(T - 1) / se)
# sanity check: the paper's worked example (N=100, V=1/2 per year, T=1250, skew -3, kurtosis 10)
sr0 = expected_max_sharpe(1 / (2 * 250), 100)
dsr = deflated_sharpe_ratio(2.5 / np.sqrt(250), sr0, 1250, -3, 10)
print(f"SR0 = {sr0:.4f} DSR = {dsr:.4f}") # SR0 = 0.1132 DSR = 0.9004No te saltees la verificación. El ejemplo del paper (100 trials, varianza anual de los Sharpe de los trials 1/2, 1.250 observaciones, asimetría −3, curtosis 10, Sharpe anualizado 2.5) da SR0 ≈ 0.1132 y DSR ≈ 0.9004, por debajo de 0.95. Si tu implementación no reproduce esos números, está mal. La mía los reproduce.
Python — una sesión de investigación: 36 trials, un ganador, tres veredictos
def effective_trials(trials):
"""Eq. 9 of the paper: N_hat = rho + (1 - rho) * M, rho = average pairwise correlation."""
m = trials.shape[1]
rho = (trials.corr().to_numpy().sum() - m) / (m * (m - 1))
return rho + (1 - rho) * m
def research_session(close, grid=GRID):
trials = pd.DataFrame({p: backtest(close, *p) for p in grid}) # EVERY trial is kept
srs = trials.apply(sharpe) # per-bar Sharpe of each trial
best = srs.idxmax()
r, sr, T = trials[best], srs[best], len(trials)
g3, g4 = skew(r), kurtosis(r, fisher=False)
n_hat = effective_trials(trials)
sr0 = expected_max_sharpe(srs.var(ddof=1), len(grid))
sr0_hat = expected_max_sharpe(srs.var(ddof=1), max(n_hat, 2))
return {"best": tuple(int(x) for x in best), "sr_ann": sr * ANN,
"psr_vs_0": deflated_sharpe_ratio(sr, 0.0, T, g3, g4),
"sr0_ann": sr0 * ANN, "dsr": deflated_sharpe_ratio(sr, sr0, T, g3, g4),
"n_hat": n_hat, "dsr_n_hat": deflated_sharpe_ratio(sr, sr0_hat, T, g3, g4)}
for k, v in research_session(close).items():
print(f"{k:10s}", v if k == "best" else round(v, 3))
# best (20, 150) | sr_ann 0.772 | psr_vs_0 0.993 | sr0_ann 0.833
# dsr 0.424 | n_hat 15.53 | dsr_n_hat 0.598El veredicto sobre la semilla 3
La mejor configuración, un cruce 20/150, tiene un Sharpe anualizado de 0.77 sobre 2.519 barras diarias. El test ingenuo (PSR contra cero) dice 0.993, ’99% de confianza’. Pero, según el modelo del paper, 36 trials sin habilidad producen en promedio un mejor Sharpe de unos 0.83 anualizado, más alto que lo que encontramos. El DSR es 0.42 con N = 36 y 0.60 con el N̂ ≈ 15.5 basado en correlación. Ninguno llega a 0.95, así que esto no es un descubrimiento.
Python — 200 semillas de ruido puro: ¿cuántas veces grita lobo cada test?
rows = []
for seed in range(200):
c = make_close(seed)
res = research_session(c)
res["wf_sr_ann"] = sharpe(walk_forward(c)) * ANN
rows.append(res)
mc = pd.DataFrame(rows)
print("best-of-36 Sharpe, median :", round(mc.sr_ann.median(), 2)) # 0.3
print("naive test (PSR > 0.95) :", (mc.psr_vs_0 > 0.95).mean()) # 0.235
print("deflated (DSR > 0.95), N = 36 :", (mc.dsr > 0.95).mean()) # 0.01
print("deflated (DSR > 0.95), N_hat :", (mc.dsr_n_hat > 0.95).mean()) # 0.02
print("walk-forward Sharpe, median :", round(mc.wf_sr_ann.median(), 2)) # -0.05
print("walk-forward Sharpe > 0.5 :", (mc.wf_sr_ann > 0.5).mean()) # 0.055Sobre 200 semillas la mediana del mejor Sharpe anualizado de 36 es 0.30. El test ingenuo declara significativas al 95% el 23.5% de esas corridas. El DSR con N = 36 declara el 1.0% y con N̂ el 2.0%, por debajo del 5% que daría un único test honesto. El ganador en muestra está sesgado hacia arriba; la mediana walk-forward de −0.05 no.
Qué asume el DSR, y qué no arregla
1. Los Sharpe de los trials se modelan como Normales con media y varianza comunes (una ’clase de estrategia’), y la Eq. 1 es una aproximación para N mucho mayor que 1.2. N cuenta trials independientes. Las variantes correlacionadas lo sobrestiman; la Eq. 9 da N̂ a partir de la correlación promedio, y el paper advierte que esa correlación puede estar sobreajustada cuando hay más trials que observaciones. Usar M, la cuenta cruda, es la opción conservadora.3. El error estándar se apoya en trabajo que el paper cita (Lo; Mertens, cuya nota se titula ’Variance of the IID estimator in Lo (2002)’). Mi lectura, no una afirmación del paper: la fórmula trata los retornos como IID, así que no confíes ciegamente en ella con retornos fuertemente autocorrelacionados o períodos de tenencia solapados.4. Necesita el Sharpe de cada trial, no solo el del ganador. Si no registraste los descartados, no podés calcular V[{SR_n}] ni N con honestidad.5. Deflacta el sesgo de selección y la no-Normalidad. No arregla fugas, costos incorrectos, cambios de régimen ni una muestra no representativa: entra un Sharpe con fuga, sale un Sharpe con fuga bien deflactado.
7. Errores Comunes (Todos Arreglables en una Tarde)
Casi todo backtest inflado que debuggueé cae en uno de estos baldes. La columna izquierda es lo que hace la investigación confiable; la derecha produce una curva de equity hermosa y nada más.
✅ Hacé esto
- • Desplazá señales, no retornos, y probalo con un test de truncamiento
- • Partí primero en orden temporal; ajustá scalers, umbrales y parámetros solo con filas de entrenamiento
- • Purgá por horizonte de etiqueta; embargá cuando el entrenamiento puede quedar después del test
- • Cobrá comisiones y slippage sobre el turnover; estresalos al doble y sumá una barra de delay
- • Usá universos point-in-time que incluyan nombres deslistados y muertos
- • Registrá cada trial y después reportá N, la varianza de Sharpe, asimetría, curtosis y el DSR
❌ No esto
- • .bfill(), rolling(center=True) o unir una barra diaria a filas intradía antes de que cierre
- • train_test_split con su shuffle=True por defecto sobre una serie temporal
- • Ajustar sobre el bloque de test hasta que se vea bien y llamarlo fuera de muestra
- • Reportar el mejor de N como si fuera un único test
- • Leer un Sharpe walk-forward como prueba de ventaja sin un error estándar
- • Citar un Sharpe anualizado con uno o dos años de datos
Regla de oro
Cuando un backtest se ve demasiado bueno, tu prior tiene que ser un bug, no un descubrimiento. Cazá fugas primero, contá trials segundo, y recién entonces preguntate si la ventaja es real.
8. Un Checklist para Correr Antes de Creerle a un Backtest
Pegalo en el README de tu repo y hacé que el code review falle sin él. Los tres grupos siguen el momento en que cada chequeo sale más barato.
📝 Antes de codear
- 1. Escribí la hipótesis y la cantidad planeada de trials antes de ver resultados; el paper incluso plantea la parada como un problema de parada óptima (la regla 1/e, cerca del 37%)
- 2. Congelá un bloque de holdout final que tocás una sola vez
- 3. Definí de entrada comisiones, slippage y el modelo de fill
- 4. Conseguí datos point-in-time: deslistados, valores corregidos, composición histórica de índices
🧪 Mientras construís
- Test de shift: las posiciones calculadas sobre la historia truncada coinciden con las de la completa
- Split antes de fit: ningún scaler, selector ni búsqueda ve jamás el bloque de test
- Purga y embargo: horizonte al menos igual al largo de la etiqueta; embargo cuando el train puede seguir al test
- Costos: comisiones sobre turnover más slippage, con una barra de delay como prueba de estrés
🧹 Antes de creerle al número
- Corrida sobre ruido: corré todo el pipeline sobre un random walk; no debe encontrar nada
- Registro de trials: cada configuración evaluada, guardada con su serie de retornos
- Deflactá: reportá N, varianza de Sharpe, asimetría, curtosis, T y el DSR junto al Sharpe
- Más caminos: otros activos, otros períodos, splits combinatorios, siempre que puedas conseguirlos
Conclusión
Un backtest confiable es sobre todo resta: sacarle el futuro al pasado, sumar los costos que dejaste afuera y deflactar al ganador por la cantidad de veces que miraste. Construiste cada pieza sobre un mercado sin ventaja, el único lugar donde podés estar seguro de que el pipeline no inventa una.
La próxima vez que te guste un backtest, corré primero el pipeline sobre un random walk. Si ahí encuentra una ventaja, tenés un bug; si no encuentra nada, corrélo con tus datos y reportá N al lado del Sharpe. Los números se achican y sirven muchísimo más.
Si querés la otra mitad de la historia, construir y correr el bot en sí, empezá por mi guía para construir trading bots y después pasá cada backtest de ahí por el checklist de arriba.
Machete del Tutorial
Fugas
- • señales con shift(1), testealo
- • split antes de fit
- • universo point-in-time
Evaluación
- • purga por horizonte de etiqueta
- • embargo tras los bloques de test
- • walk-forward, varios caminos
Estadística
- • contá cada trial
- • DSR, no el Sharpe crudo
- • reportá N, T, asimetría, curtosis
Fuentes
- Bailey y López de Prado — The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting and Non-Normality (SSRN 2460551)
- El mismo paper, PDF de los autores (Eq. 1, Eq. 2, Eq. 9 y el ejemplo numérico usado arriba)
- Bailey, Borwein, López de Prado y Zhu — The Probability of Backtest Overfitting (CSCV y PBO)
- López de Prado — Advances in Financial Machine Learning, Wiley, 2018 (capítulo 7: k-fold purgado y embargo)
- Deep, Deep y Lamptey — Interpretable Hypothesis-Driven Trading: A Rigorous Walk-Forward Validation Framework (arXiv 2512.12924, v1)
- scikit-learn — TimeSeriesSplit (el argumento gap)
- SciPy — scipy.stats.kurtosis (fisher=False da la curtosis de Pearson, Normal = 3)



