Lección 6.5.2 · 75 min
Revisión automática de backtests y migraciones de código
Un asistente escribe un backtest en un minuto y lo traduce de una librería a otra en otro. Revisarlo a mano cuesta más. Esta lección construye un revisor con dos capas, una que lee el código y otra que lo ejecuta con el futuro recortado y con datos sin señal, y lo prueba contra seis backtests con errores plantados y sus versiones corregidas. Después aplica la misma idea a dos migraciones: de pandas 2 a pandas 3, ejecutando el mismo script con las dos versiones, y de backtrader a NautilusTrader, reconciliando las órdenes.
- Módulo
- 6.5 · IA como copiloto del quant
- Duración
- 75 min
- Requisitos
- 2.5.1 · Sesgos de backtest: anticipación, supervivencia, selección y narrativa, 2.4.4 · backtesting.py y backtrader: dos motores didácticos y su estado, 2.4.5 · NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real, 6.5.1 · Asistentes de código con un flujo de tests primero
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
Revisar más deprisa de lo que se escribe
Un asistente de código (lección 6.5.1) o un agente de investigación (lección 6.4.6) produce backtests a un ritmo que ninguna persona puede revisar línea a línea. Y los errores que importan en un backtest no son los que hacen fallar al programa, sino los que lo hacen parecer bueno: usar datos del futuro, ejecutar con un precio que no estaba disponible, elegir el universo mirando quién sobrevive. La lección 2.5.1 los presentó uno a uno; esta los busca de forma automática.
Un revisor útil tiene dos capas, con propiedades opuestas:
| Capa | Qué hace | Ventaja | Límite |
|---|---|---|---|
| Estática | Lee el código sin ejecutarlo y busca patrones conocidos | Rápida, dice la línea exacta, no necesita datos | Solo ve las formas que alguien escribió en una regla |
| Dinámica | Ejecuta el backtest con datos modificados y compara | No depende de cómo esté escrito el código | Necesita datos y tiempo; dice que hay un problema, no dónde |
Fuente Mermaid
flowchart TB C[Código] --> E[Revisión estática] C --> D[Ejecución] D --> T[Truncado] D --> P[Placebo sin señal] E --> I[Informe] T --> I P --> I I --> H[Persona que decide]
El informe no aprueba ni rechaza nada: le dice a quien revisa dónde mirar. La segunda mitad de la lección usa la misma idea, ejecutar y comparar, para algo que un asistente hace a menudo y casi nunca se revisa bien: migrar código a una versión nueva de una librería (pandas 2 a pandas 3) o a otra librería (backtrader a NautilusTrader).
Un banco de backtests con errores conocidos
Para medir un revisor hace falta saber la respuesta: backtests con errores plantados y sus versiones corregidas. Los
seis de la lección usan el universo de 120 acciones ficticias del nivel 2 (universo2_ajustados.parquet: precios
ajustados de 2006 a 2025, con diez acciones que dejan de cotizar, cuatro de ellas por quiebra). Los datos son
sintéticos: no son datos de mercado.
import ast
import textwrap
import numpy as np
import pandas as pd
ajustados = pd.read_parquet("universo2_ajustados.parquet")
P = ajustados.pivot(index="fecha", columns="ticker",
values="cierre_ajustado")
ultima = P.apply(pd.Series.last_valid_index)
print(P.shape, "· sesiones", P.index[0].date(), "a",
P.index[-1].date())
print("dejan de cotizar antes del final:",
int((ultima < P.index[-1]).sum()))(5031, 120) · sesiones 2006-01-03 a 2025-12-31
dejan de cotizar antes del final: 10Todos los backtests cumplen el mismo contrato: reciben la tabla de precios P (sesiones en filas, acciones en
columnas, vacía cuando la acción no cotiza) y devuelven la rentabilidad diaria de la estrategia. Cada uno tiene un
error de una línea, del tipo que aparece en código generado o escrito con prisa, y su versión corregida cambia solo esa
línea.
| Backtest | Estrategia | Error plantado |
|---|---|---|
| B1 | Momentum de 20 sesiones, largo-corto | shift(-1) en la señal: la alinea con la etiqueta en vez de con la decisión |
| B2 | Largo en las acciones con z de momentum mayor que 1 | z con la media y la desviación de toda la muestra |
| B3 | Momentum de 12 meses, quintil superior | Señal mensual pasada a diaria con bfill() en vez de ffill() |
| B4 | Tendencia de 5 sesiones, largo-corto | Pesos sin retrasar: se opera con el cierre que genera la señal |
| B5 | Equiponderada | Universo = las acciones que cotizan el último día (supervivientes) |
| B6 | Rentabilidad del mes, largo-corto | Rentabilidad del mes completo repartida a todos sus días |
BASE = """
def backtest(P):
R = P.pct_change(fill_method=None)
{cuerpo}
w = w.div(w.abs().sum(axis=1), axis=0)
return (w.shift(1) * R).sum(axis=1)
"""
LARGO_CORTO = " w = s.sub(s.mean(axis=1), axis=0)"
# versión correcta del cuerpo, línea correcta y línea con el error
BANCO = {
"B1": ("""
s = P.pct_change(20, fill_method=None)
""" + LARGO_CORTO, "(20, fill_method=None)",
"(20, fill_method=None).shift(-1)"),
"B2": ("""
m = P.pct_change(120, fill_method=None)
z = (m - m.expanding(250).mean()) / m.expanding(250).std()
w = (z > 1).astype(float).where(P.notna())""",
"(m - m.expanding(250).mean()) / m.expanding(250).std()",
"(m - m.mean()) / m.std()"),
"B3": ("""
s = P.resample("ME").last().pct_change(12, fill_method=None)
s = s.reindex(P.index).ffill()
w = s.rank(axis=1, pct=True).ge(0.8).astype(float)
w = w.where(P.notna())""", ".ffill()", ".bfill()"),
"B4": ("""
s = P.pct_change(5, fill_method=None)
""" + LARGO_CORTO, "(w.shift(1) * R)", "(w * R)"),
"B5": ("""
vivas = P.columns
w = P[vivas].notna().astype(float)""",
"vivas = P.columns",
"vivas = P.columns[P.iloc[-1].notna()]"),
"B6": ("""
s = P.resample("ME").last().pct_change(fill_method=None)
s = s.reindex(P.index, method="ffill")
""" + LARGO_CORTO,
"""s = P.resample("ME").last().pct_change(fill_method=None)
s = s.reindex(P.index, method="ffill")""",
"""g = P.groupby(P.index.to_period("M"))
s = g.transform("last") / g.transform("first") - 1"""),
}
def codigos(nombre):
"""(código con el error, código corregido) de un backtest."""
cuerpo, buena, mala = BANCO[nombre]
bien = BASE.format(cuerpo=cuerpo)
return bien.replace(buena, mala), bien
def compilar(codigo):
espacio = {"np": np, "pd": pd}
exec(textwrap.dedent(codigo), espacio)
return espacio["backtest"]
print(codigos("B3")[0])
def backtest(P):
R = P.pct_change(fill_method=None)
s = P.resample("ME").last().pct_change(12, fill_method=None)
s = s.reindex(P.index).bfill()
w = s.rank(axis=1, pct=True).ge(0.8).astype(float)
w = w.where(P.notna())
w = w.div(w.abs().sum(axis=1), axis=0)
return (w.shift(1) * R).sum(axis=1)La plantilla es la que cualquier asistente produciría: rendimientos diarios R, pesos w normalizados para que la
exposición bruta sea 1, y la P&L como los pesos del día anterior por los rendimientos de hoy. Primero, cuánto cambia
cada error el resultado:
def sharpe(r):
r = r.loc["2007":] # sin el primer año
return float(r.mean() / r.std() * np.sqrt(252))
coste = {}
for nombre in BANCO:
mal, bien = (compilar(c)(P) for c in codigos(nombre))
coste[nombre] = (sharpe(mal), sharpe(bien))
print(f"{nombre} Sharpe con error {coste[nombre][0]:6.2f}"
f" corregido {coste[nombre][1]:6.2f}")B1 Sharpe con error 18.77 corregido 0.29
B2 Sharpe con error 0.41 corregido 0.36
B3 Sharpe con error 2.12 corregido 0.48
B4 Sharpe con error 30.69 corregido 0.34
B5 Sharpe con error 0.41 corregido 0.38
B6 Sharpe con error 17.67 corregido 0.78Los errores se reparten en dos grupos muy distintos, como enseña la figura de la cabecera. Los que usan la información de un día para operar ese mismo día (B1, B4) o la del mes entero (B6) convierten estrategias de Sharpe 0,3-0,8 en Sharpes de 18 a 31: tan absurdos que nadie los creería. B3, con 2,12 frente a 0,48, ya es un resultado que alguien podría enseñar. Y los dos más peligrosos son los que apenas se notan: la normalización con toda la muestra (B2) sube el Sharpe de 0,36 a 0,41 y el universo de supervivientes (B5), de 0,38 a 0,41. Nadie sospecha de un 0,41. Un revisor tiene que encontrarlos a todos, y especialmente a esos dos, sin dar avisos en las seis versiones corregidas.
La capa estática: leer el árbol sintáctico
Buscar shift(- con una expresión regular funciona hasta que alguien escribe shift(periods=-1) o shift( -1 ).
El módulo ast de la biblioteca estándar convierte el código en un árbol sintáctico: cada llamada, cada atributo
y cada argumento es un nodo, escriba como escriba el autor. El revisor recorre el árbol y aplica seis reglas:
| Regla | Patrón | Por qué es sospechoso |
|---|---|---|
| Desplazamiento negativo | shift(-k) | Trae al día t un dato de t + k |
| Ventana centrada | rolling(..., center=True) | La ventana incluye sesiones futuras |
| Relleno hacia atrás | bfill(), backfill(), fillna(method="bfill") | Copia hacia atrás un valor posterior |
| Toda la muestra | .mean(), .std(), .min(), .max(), .quantile() sin ventana ni axis=1 | El estadístico usa todas las fechas |
| Misma barra | * R sin shift en el otro factor | Opera con el cierre que genera la señal |
| Supervivientes | .iloc[-1] o dropna(axis=1) | Elige el universo con el último dato |
VENTANAS = {"rolling", "expanding", "ewm", "groupby", "resample"}
TODA_MUESTRA = {"mean", "std", "min", "max", "quantile", "median"}
def metodo(nodo):
"""Nombre del método si el nodo es una llamada x.metodo(...)."""
if (isinstance(nodo, ast.Call)
and isinstance(nodo.func, ast.Attribute)):
return nodo.func.attr
return None
def negativo(nodo):
return (isinstance(nodo, ast.UnaryOp)
and isinstance(nodo.op, ast.USub))
def revision_estatica(codigo):
avisos = []
def aviso(nodo, texto):
avisos.append(f"l.{nodo.lineno - 1} {texto}")
for n in ast.walk(ast.parse(textwrap.dedent(codigo))):
m = metodo(n)
kw = {k.arg: k.value for k in getattr(n, "keywords", [])}
if m == "shift" and (n.args and negativo(n.args[0])
or negativo(kw.get("periods"))):
aviso(n, "desplazamiento negativo")
if m == "rolling" and getattr(kw.get("center"),
"value", False):
aviso(n, "ventana centrada")
if m in ("bfill", "backfill") or getattr(
kw.get("method"), "value", "") in ("bfill",
"backfill"):
aviso(n, "relleno hacia atrás")
if m in TODA_MUESTRA and metodo(n.func.value) not in VENTANAS:
eje = getattr(kw.get("axis"), "value", 0)
if eje not in (1, "columns"):
aviso(n, f".{m}() con toda la muestra")
if (isinstance(n, ast.BinOp) and isinstance(n.op, ast.Mult)
and any(isinstance(x, ast.Name) and x.id == "R"
for x in (n.left, n.right))):
otro = n.right if isinstance(n.left, ast.Name) else n.left
if metodo(otro) != "shift":
aviso(n, "pesos sin retrasar (misma barra)")
if (isinstance(n, ast.Subscript)
and isinstance(n.value, ast.Attribute)
and n.value.attr == "iloc" and negativo(n.slice)):
aviso(n, "universo con el último dato")
if m == "dropna" and getattr(kw.get("axis"), "value",
0) in (1, "columns"):
aviso(n, "universo con el último dato")
return avisos
estatica = {}
for nombre in BANCO:
mal, bien = codigos(nombre)
estatica[nombre] = (revision_estatica(mal),
revision_estatica(bien))
print(f"{nombre} con error: {estatica[nombre][0] or '-'}")
print(f" corregido: {estatica[nombre][1] or '-'}")B1 con error: ['l.4 desplazamiento negativo']
corregido: -
B2 con error: ['l.5 .std() con toda la muestra', 'l.5 .mean() con toda la muestra']
corregido: -
B3 con error: ['l.5 relleno hacia atrás']
corregido: -
B4 con error: ['l.7 pesos sin retrasar (misma barra)']
corregido: -
B5 con error: ['l.4 universo con el último dato']
corregido: -
B6 con error: -
corregido: -Cinco de seis, con la línea exacta (contada desde def backtest): el shift(-1) de B1 en la línea 4, la media y la
desviación de toda la muestra de B2 en la 5, el bfill() de B3 en la 5, la multiplicación sin shift de B4 en la 7
y el iloc[-1] de B5 en la 4. Ningún aviso en las versiones corregidas: la regla de toda la muestra no salta con
s.mean(axis=1) (una media transversal de cada día, que es legítima) ni con m.expanding(250).mean().
B6 pasa limpio. Su error, g.transform("last") sobre un agrupador por mes, reparte a cada día del mes el último
precio del mes, que todavía no se conoce. No hay nada sospechoso en la forma: agrupar y repartir un valor es pandas
corriente. Se podría escribir una regla para ese caso (ejercicio 1), pero siempre habrá otra forma de mirar el futuro
que no tenga regla. Para eso está la segunda capa.
Las reglas tienen un precio: los falsos positivos. La misma regla que caza el universo de supervivientes salta con un código inofensivo que lee el último valor para un informe:
informe = """
def resumen(curva):
final = curva.iloc[-1]
return {"capital final": final, "media": curva.mean()}
"""
revision_estatica(informe)['l.2 universo con el último dato', 'l.3 .mean() con toda la muestra']Dos avisos y ningún error: el último valor de una curva de capital y su media son legítimos en un informe. Por eso el
revisor se aplica solo al código que decide (la función backtest), no a todo el repositorio, y por eso sus avisos
son preguntas para una persona, no veredictos. Un revisor que avisa de todo acaba ignorado.
La capa dinámica: recortar el futuro y quitar la señal
La capa dinámica no lee el código: lo ejecuta dos veces con datos distintos y compara. La primera prueba es el truncado de las lecciones 0.2.6 y 6.5.1, aplicado a un backtest completo: lo que el backtest calcula hasta la fecha T no puede cambiar si se le quitan los datos posteriores a T. Se prueba con varios cortes, porque un error puede depender de lo que pasa después de cada uno (el universo de supervivientes solo cambia si alguna acción deja de cotizar después del corte).
La segunda es un placebo: precios con la misma forma que los reales (las mismas acciones, las mismas fechas en que cotizan, la misma volatilidad de cada una) pero con rendimientos independientes, en los que no hay nada que predecir. Con ellos, el Sharpe de cualquier estrategia sin errores tiene que ser compatible con cero: con 19 años, su error típico es . Un Sharpe de más de 1 en el placebo no es un hallazgo, es un error.
CORTES = ["2010-11-15", "2014-06-13", "2018-12-14", "2022-06-15"]
def prueba_truncado(f, P):
"""Sesiones hasta cada corte en las que la P&L cambia."""
completo = f(P)
distintas = 0
for corte in CORTES:
parcial = f(P.loc[:corte])
a, b = completo.loc[:corte], parcial
distintas += int((~np.isclose(a, b, equal_nan=True)).sum())
return distintas
def placebo(P, semilla=652):
"""Rendimientos independientes de media cero con la volatilidad
de cada acción y su mismo periodo de cotización."""
rng = np.random.default_rng(semilla)
vol = P.pct_change(fill_method=None).std().to_numpy()
r = rng.standard_normal(P.shape) * vol
falso = 50 * np.cumprod(1 + r, axis=0)
return pd.DataFrame(falso, P.index, P.columns).where(P.notna())
P0 = placebo(P)
dinamica = {}
for nombre in BANCO:
fila = []
for codigo in codigos(nombre):
f = compilar(codigo)
fila.append((prueba_truncado(f, P), sharpe(f(P0))))
dinamica[nombre] = fila
(t_mal, s_mal), (t_bien, s_bien) = fila
print(f"{nombre} con error: truncado {t_mal:5d} sesiones "
f"distintas, placebo {s_mal:6.2f} | corregido: "
f"{t_bien} y {s_bien:5.2f}")B1 con error: truncado 0 sesiones distintas, placebo 33.89 | corregido: 0 y 0.27
B2 con error: truncado 7900 sesiones distintas, placebo -0.42 | corregido: 0 y -0.22
B3 con error: truncado 7853 sesiones distintas, placebo 6.03 | corregido: 0 y 0.27
B4 con error: truncado 0 sesiones distintas, placebo 69.73 | corregido: 0 y -0.10
B5 con error: truncado 10752 sesiones distintas, placebo -0.08 | corregido: 0 y 0.06
B6 con error: truncado 37 sesiones distintas, placebo 29.17 | corregido: 0 y 0.51Las dos pruebas ven cosas distintas:
- El truncado encuentra B2, B3, B5 y B6, todos los que usan datos posteriores al corte: la media de toda la muestra cambia al recortarla (7.900 sesiones distintas en los cuatro cortes), igual que el relleno hacia atrás del mes siguiente (7.853) y el universo de los que cotizan el último día (10.752). B6 solo cambia en 37 sesiones: las de los meses partidos por los cortes, que por eso están a mitad de mes. Con cortes a fin de mes no lo habría visto.
- El truncado no ve B1 ni B4: los dos usan el cierre de la sesión t para la P&L de la propia sesión t, y al recortar en T la sesión T sigue ahí. Los caza el placebo: con precios que no tienen nada que predecir, B1 da un Sharpe de 33,89 y B4 de 69,73. También B3 (6,03) y B6 (29,17).
Las versiones corregidas pasan las dos pruebas: cero sesiones distintas y Sharpes de placebo entre −0,22 y 0,51, dentro de lo que da el azar. El placebo usa rendimientos aritméticos de media cero; con precios que siguen un paseo aleatorio en logaritmos, la rentabilidad aritmética esperada sería positiva ( al día) y una cartera solo larga daría un Sharpe positivo sin ningún error.
Dos capas juntas
El informe final reúne las tres pruebas por backtest, con las falsas alarmas de las versiones corregidas:
filas = []
for nombre in BANCO:
(t_mal, s_mal), (t_bien, s_bien) = dinamica[nombre]
filas.append({
"backtest": nombre,
"estática": bool(estatica[nombre][0]),
"truncado": t_mal > 0,
"placebo": abs(s_mal) > 1,
"falsas alarmas": int(bool(estatica[nombre][1]))
+ int(t_bien > 0) + int(abs(s_bien) > 1),
})
matriz = pd.DataFrame(filas).set_index("backtest")
matriz["alguna"] = matriz[["estática", "truncado",
"placebo"]].any(axis=1)
matriz estática truncado placebo falsas alarmas alguna
backtest
B1 True False True 0 True
B2 True True False 0 True
B3 True True True 0 True
B4 True False True 0 True
B5 True True False 0 True
B6 False True True 0 TrueLa capa estática caza cinco de seis; el truncado, cuatro; el placebo, cuatro; juntas, los seis, y ninguna da una falsa alarma en las versiones corregidas. Ninguna capa es redundante: B6 solo lo ven las dinámicas, B1 y B4 solo la estática y el placebo, y B2 y B5, los dos errores sutiles, solo la estática y el truncado. El placebo no los puede ver: en B2 y B5 la ventaja no sale de conocer los rendimientos futuros, sino de elegir con datos futuros qué acciones o qué umbral usar, y con rendimientos sin señal eso no da nada (ejercicio 2).
Es un banco pequeño y hecho a medida: un revisor real se mide con muchos más errores, y sus umbrales (el Sharpe de 1 del placebo, los cuatro cortes) se ajustan con versiones corregidas para no ahogar en avisos a quien lo usa.
pandas 2 a 3: lo que cambia
pandas 3.0.0 se publicó el 21 de enero de 2026 y el curso lo usa desde el nivel 0 (en el entorno principal, la versión 3.0.6). Sus notas de la versión recomiendan un camino concreto: actualizar primero a pandas 2.3 y dejar el código sin avisos, porque la 3.0 retira mucho de lo que la 2.x marcaba como obsoleto. Los cambios que más afectan a código de trading, según esas notas y la guía de migración oficial:
| Cambio | Qué pasaba en pandas 2 | Qué pasa en pandas 3 |
|---|---|---|
| Copy-on-write | Una selección podía ser una vista: modificarla modificaba el original | Toda selección se comporta como una copia; la asignación encadenada no hace nada y emite ChainedAssignmentError |
| Tipo de cadenas | Las cadenas eran object | Tipo str por defecto (con PyArrow si está instalado); el valor ausente es siempre NaN |
| Resolución de fechas | Siempre nanosegundos | Se infiere: al leer texto, microsegundos |
| Alias de frecuencia | M, Q, Y, H, T | ME, QE, YE, h, min (los antiguos dan error) |
| API retiradas | fillna(method=…), applymap, first/last por fecha, swapaxes… | Retiradas: usar ffill/bfill, map, loc |
| Índices enteros | s[0] en una serie con fechas era posicional (con aviso) | Una clave entera es siempre una etiqueta: KeyError |
| Zonas horarias | Objetos de pytz | zoneinfo de la biblioteca estándar; pytz deja de ser dependencia |
Una regla de la guía de migración resume la primera fila: si quieres modificar un objeto, la única forma es modificar
ese objeto directamente. df["a"][mascara] = x modifica una copia temporal y no hace nada; df.loc[mascara, "a"] = x
modifica df.
El mismo script con las dos versiones
La forma más fiable de saber qué cambia es ejecutar el mismo código con las dos versiones y comparar. El curso tiene
los dos intérpretes: el del entorno principal (pandas 3.0.6) y el del entorno de Zipline (pandas 2.3.3, lección
2.4.6). En tu máquina vale cualquier entorno virtual con pandas==2.3.3. La celda escribe un script con nueve
construcciones habituales en código antiguo y lo ejecuta con cada intérprete; los avisos se recogen y se muestran por
categoría:
import subprocess
import sys
from pathlib import Path
PY3 = sys.executable
PY2 = str(Path(sys.executable).parents[2]
/ "entornos" / "zipline" / ".venv" / "bin" / "python")
Path("casos.py").write_text('''
import warnings
import numpy as np
import pandas as pd
def caso(nombre, f):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
try:
r = f()
except Exception as e:
r = type(e).__name__
avisos = sorted({x.category.__name__ for x in w})
print(f"{nombre}|{r}|{' '.join(avisos)}")
idx = pd.date_range("2024-01-02", periods=6, freq="B")
s = pd.Series([1.0, np.nan, 3.0, np.nan, 5.0, 6.0], idx)
df = pd.DataFrame({"tk": ["AAA", "BBB", None, "AAA", "BBB", "AAA"],
"p": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0]}, idx)
def encadenada():
d = df.copy()
d["p"][d["p"] > 13] = 0.0
return d["p"].tolist()[-3:]
caso("fillna(method=)", lambda: s.fillna(method="ffill").iloc[1])
caso("applymap", lambda: df[["p"]].applymap(abs).iloc[0, 0])
caso("resample('M')", lambda: len(s.resample("M").last()))
caso("first('3D')", lambda: len(s.first("3D")))
caso("s[0] con fechas", lambda: s[0])
caso("asignación encadenada", encadenada)
caso("dtype == object", lambda: df["tk"].dtype == object)
caso("astype(str) de None",
lambda: repr(df["tk"].astype(str).iloc[2]))
caso("fecha a int64", lambda: int(pd.to_datetime(
["2024-01-02"]).astype("int64")[0]))
print(f"versión|pandas {pd.__version__}|")
''')
def ejecutar(python, script):
r = subprocess.run([python, "-I", script], capture_output=True,
text=True, check=True)
return [linea.split("|") for linea in r.stdout.splitlines()]
def celda(valor, aviso):
aviso = aviso.removesuffix("Warning")
return f"{valor} ({aviso})" if aviso else valor
for a, b in zip(ejecutar(PY2, "casos.py"),
ejecutar(PY3, "casos.py")):
print(f"{a[0]:<22}{celda(a[1], a[2]):<35}{celda(b[1], b[2])}")fillna(method=) 1.0 (Future) TypeError
applymap 10.0 (Future) AttributeError
resample('M') 1 (Future) ValueError
first('3D') 3 (Future) AttributeError
s[0] con fechas 1.0 (Future) KeyError
asignación encadenada [13.0, 0.0, 0.0] (SettingWithCopy) [13.0, 14.0, 15.0] (ChainedAssignmentError)
dtype == object True False
astype(str) de None 'None' nan
fecha a int64 1704153600000000000 1704153600000000
versión pandas 2.3.3 pandas 3.0.6Cinco construcciones fallan en pandas 3 con una excepción: fillna(method=) con TypeError, applymap y
first() con AttributeError, el alias M con ValueError y s[0] sobre un índice de fechas con KeyError. Son las
fáciles: el programa se para y dice dónde. pandas 2.3 avisaba de todas con un FutureWarning, el camino que recomiendan
las notas de la versión.
Las otras cuatro no fallan. La asignación encadenada no modifica el DataFrame (los tres últimos valores siguen en 13,
14 y 15) y solo deja un aviso, ChainedAssignmentError, que en pandas 2.3 era SettingWithCopyWarning. Y tres
cambios no dan ningún aviso en ninguna de las dos versiones: dtype == object pasa de True a False porque la
columna de tickers ahora es str; astype(str) deja el valor ausente como nan en vez de convertirlo en el texto
'None'; y una fecha leída de un texto pasa a enteros en microsegundos, no en nanosegundos: 1.000 veces menos. Un
filtro de columnas de texto que deja de filtrar, un None que deja de ser una cadena y unas marcas de tiempo
divididas por mil no rompen nada; cambian resultados.
Migrar un backtest y comprobarlo
Un backtest antiguo de la cartera mensual de KAPA (el ETF sintético de las lecciones 2.4.4 y 2.4.5): largo el mes
siguiente si el mes fue positivo, con un coste de 10 pb en cada cambio de posición. Está escrito para pandas 2 y no
funciona en pandas 3. Antes de tocarlo, un escáner de migración con el árbol sintáctico, como el revisor de
sesgos, lista lo que hay que cambiar:
b5 = pd.read_parquet("indice2_5min.parquet")
sesion = (b5["ts"].dt.tz_convert("America/New_York")
.dt.tz_localize(None).dt.normalize())
kapa = b5.groupby(sesion)["cierre"].last().rename("close")
kapa.index.name = "fecha"
kapa.to_csv("kapa_diario.csv")
ANTIGUO = '''import pandas as pd
precios = pd.read_csv("kapa_diario.csv", index_col=0,
parse_dates=True)["close"]
precios = precios.fillna(method="ffill")
mensual = precios.resample("M").last()
res = pd.DataFrame({"ret": mensual.pct_change()})
res["pos"] = (res["ret"] > 0).astype(float).shift(1)
res["pnl"] = res["pos"] * res["ret"]
cambios = res["pos"].diff().abs() > 0
res["pnl"][cambios] -= 0.001
print(f"primer cierre {precios[0]:.2f}, meses {len(res)}, "
f"capital {(1 + res['pnl'].fillna(0)).prod():.6f}")
'''
ALIAS = {"M": "ME", "Q": "QE", "Y": "YE", "A": "YE", "H": "h",
"T": "min"}
def escaner_migracion(codigo):
avisos = []
for n in ast.walk(ast.parse(codigo)):
m = metodo(n)
kw = {k.arg: k.value for k in getattr(n, "keywords", [])}
if m == "fillna" and "method" in kw:
avisos.append((n.lineno, "fillna(method=) retirado: "
"ffill() / bfill()"))
if m in ("resample", "asfreq", "date_range") and n.args:
f = getattr(n.args[0], "value", None)
if f in ALIAS:
avisos.append((n.lineno, f"alias '{f}' retirado: "
f"'{ALIAS[f]}'"))
if m == "applymap":
avisos.append((n.lineno, "applymap retirado: map"))
if isinstance(n, ast.Assign):
destinos = n.targets
else:
destinos = [getattr(n, "target", None)]
if any(isinstance(d, ast.Subscript)
and isinstance(d.value, ast.Subscript)
for d in destinos):
avisos.append((n.lineno, "asignación encadenada: "
"no asigna con copy-on-write; usar .loc"))
if (isinstance(n, ast.Subscript)
and isinstance(n.slice, ast.Constant)
and type(n.slice.value) is int):
avisos.append((n.lineno, "clave entera: en pandas 3 es "
"una etiqueta; usar .iloc"))
return sorted(avisos)
for linea, texto in escaner_migracion(ANTIGUO):
print(f"línea {linea}: {texto}")línea 4: fillna(method=) retirado: ffill() / bfill()
línea 5: alias 'M' retirado: 'ME'
línea 10: asignación encadenada: no asigna con copy-on-write; usar .loc
línea 11: clave entera: en pandas 3 es una etiqueta; usar .ilocCuatro avisos con su línea: el fillna(method=) de la 4, el alias M de la 5, la asignación encadenada de la 10, que
resta el coste en los meses con cambio de posición, y la clave entera precios[0] de la 11. Ahora la prueba
diferencial, con cinco ejecuciones: el script antiguo con cada versión, una migración que solo corrige lo que da error
en pandas 3 y la migración completa, en las dos versiones.
import re
SOLO_ERRORES = (ANTIGUO
.replace('precios.fillna(method="ffill")', "precios.ffill()")
.replace('resample("M")', 'resample("ME")')
.replace("precios[0]", "precios.iloc[0]"))
MIGRADO = SOLO_ERRORES.replace('res["pnl"][cambios] -= 0.001',
'res.loc[cambios, "pnl"] -= 0.001')
def correr(python, codigo, nombre="bt.py"):
"""Salida del script y categorías de los avisos o del error."""
Path(nombre).write_text(codigo)
r = subprocess.run([python, "-I", nombre], capture_output=True,
text=True)
avisos = sorted(set(re.findall(r": (\w+(?:Warning|Error)): ",
r.stderr)))
if r.returncode:
return r.stderr.strip().splitlines()[-1].split(":")[0], []
return r.stdout.strip(), avisos
for etiqueta, python, codigo in [
("antiguo, pandas 2.3", PY2, ANTIGUO),
("antiguo, pandas 3.0", PY3, ANTIGUO),
("solo errores, pandas 3.0", PY3, SOLO_ERRORES),
("migrado, pandas 3.0", PY3, MIGRADO),
("migrado, pandas 2.3", PY2, MIGRADO)]:
salida, avisos = correr(python, codigo)
print(f"{etiqueta:<25} {salida}")
if avisos:
print(" " * 26 + "avisos: " + ", ".join(avisos))antiguo, pandas 2.3 primer cierre 252.38, meses 48, capital 2.112132
avisos: FutureWarning
antiguo, pandas 3.0 TypeError
solo errores, pandas 3.0 primer cierre 252.38, meses 48, capital 2.152774
avisos: ChainedAssignmentError
migrado, pandas 3.0 primer cierre 252.38, meses 48, capital 2.112132
migrado, pandas 2.3 primer cierre 252.38, meses 48, capital 2.112132La tabla cuenta la historia completa:
- El script antiguo funciona en pandas 2.3 (con
FutureWarning): capital final 2,112132. - En pandas 3 se para con
TypeError. - Corrigiendo solo lo que da error se ejecuta y da 2,152774: más capital que el original. La asignación
encadenada ya no resta los costes, el script no falla y el único rastro es un
ChainedAssignmentErroren la salida de errores, que en una ejecución desatendida nadie lee. Es exactamente la migración que hace quien (persona o asistente) persigue que el código «vuelva a funcionar». - La migración completa, con
res.loc[cambios, "pnl"] -= 0.001, da 2,112132 en pandas 3 y en pandas 2.3: el mismo resultado que el original, con el mismo código en las dos versiones.
Ese es el criterio de aceptación de una migración: mismo resultado que la versión antigua, con la versión antigua y con la nueva, o cada diferencia explicada. Para un backtest de verdad, «mismo resultado» quiere decir las mismas operaciones y la misma curva de capital hasta el redondeo, no solo el número final.
backtrader a NautilusTrader
La otra migración frecuente es de librería: de backtrader, que no publica versiones en PyPI desde abril de 2023 (lección 2.4.4), a NautilusTrader, un motor por eventos que se mantiene activamente (lección 2.4.5). Un asistente traduce la sintaxis sin problema; lo que no traduce solo es la semántica: cuándo se ejecuta una orden, a qué precio, con qué tamaño por defecto. La tabla de equivalencias, con las dos documentaciones (consultadas el 23 de octubre de 2026; NautilusTrader 1.231.0):
| backtrader | NautilusTrader | Diferencia que importa |
|---|---|---|
Cerebro | BacktestEngine (o BacktestNode con configuración y catálogo) | El motor se monta con venue, instrumento, datos y estrategia |
bt.feeds.PandasData | Bar + BarType + BarDataWrangler | La marca de tiempo de la barra tiene que ser la de su cierre |
params = (("rapida", 10),) | Subclase de StrategyConfig (frozen=True) | Configuración inmutable y tipada |
Indicadores en __init__ | Indicadores en __init__ + register_indicator_for_bars en on_start | El motor los actualiza antes de on_bar |
next() | on_bar(bar) | Igual: una llamada por barra cerrada |
self.data.close[0] / [-1] | bar.close / self.cache.bar(bar_type, 1) | La caché indexa al revés: 0 es la última |
self.buy() | self.order_factory.market(...) + self.submit_order(...) | Sin cantidad por defecto: el sizer de backtrader compra 1 unidad |
self.close() | self.close_all_positions(instrument_id) | |
self.position | self.portfolio.is_net_long(instrument_id) | La posición se pregunta al Portfolio |
notify_order / notify_trade | on_order_filled, on_order_event / on_position_closed | Eventos tipados |
broker.setcash, setcommission | add_venue(starting_balances=…), fee_model | Comisiones por modelo (FixedFeeModel, MakerTakerFeeModel…) |
| Orden de mercado: apertura de la barra siguiente | Sin latencia: cierre de la barra en que se envía | No hay modo nativo de apertura siguiente |
La última fila es la que cambia los resultados. La documentación de backtrader dice que una orden de mercado se
ejecuta al «precio de apertura de la siguiente barra»; la de NautilusTrader, que sin modelo de latencia una orden
enviada desde on_bar se ejecuta contra el libro que dejó el cierre de esa barra, y que el motor «no ofrece un modo
nativo de ejecución a la apertura de la barra siguiente».
Fuente Mermaid
flowchart LR S[Cierre de t: señal] --> B[backtrader] S --> N[Nautilus] B --> A[Apertura de t+1] N --> C[Cierre de t]
La estrategia es el SmaCross de la lección 2.4.4 (medias de 10 y 30 sesiones, solo largo) sobre las barras diarias
de KAPA. Allí, con el adaptador propio, dio 33 órdenes y la primera se creó el 10 de marzo de 2022 y se ejecutó el
11 a 250,74. Las barras diarias llevan la marca de las 16:00 de Nueva York, su cierre. Para poder imitar la apertura
siguiente se prepara además una barra de un minuto por sesión, a las 9:31, con el precio de apertura en sus cuatro
campos:
import warnings
from pandas.errors import Pandas4Warning
# El motor llama a Timestamp.utcnow(), obsoleto en pandas 3:
# un aviso por ejecución, fuera de nuestro control (lección 2.4.5)
warnings.filterwarnings("ignore", message="Timestamp.utcnow",
category=Pandas4Warning)
from nautilus_trader.model.currencies import USD
from nautilus_trader.model.data import BarType
from nautilus_trader.model.identifiers import (
InstrumentId, Symbol, Venue)
from nautilus_trader.model.instruments import Equity
from nautilus_trader.model.objects import Money, Price, Quantity
from nautilus_trader.persistence.wranglers import BarDataWrangler
diario = b5.groupby(sesion).agg(
open=("apertura", "first"), high=("maximo", "max"),
low=("minimo", "min"), close=("cierre", "last"),
volume=("volumen", "sum"))
def en_utc(fechas, hora, minuto):
return ((fechas + pd.Timedelta(hours=hora, minutes=minuto))
.tz_localize("America/New_York").tz_convert("UTC"))
VENUE = Venue("XNYS")
kapa_n = Equity(
instrument_id=InstrumentId(Symbol("KAPA"), VENUE),
raw_symbol=Symbol("KAPA"), currency=USD, price_precision=2,
price_increment=Price.from_str("0.01"),
lot_size=Quantity.from_int(1), ts_event=0, ts_init=0)
dia = diario.set_axis(en_utc(diario.index, 16, 0))
BT_DIA = BarType.from_str("KAPA.XNYS-1-DAY-LAST-EXTERNAL")
barras_dia = BarDataWrangler(BT_DIA, kapa_n).process(dia)
apertura = pd.DataFrame(
{c: diario["open"] for c in ("open", "high", "low", "close")})
apertura["volume"] = 1_000.0
apertura.index = en_utc(diario.index, 9, 31)
BT_AP = BarType.from_str("KAPA.XNYS-1-MINUTE-LAST-EXTERNAL")
barras_ap = BarDataWrangler(BT_AP, kapa_n).process(apertura)
print(len(barras_dia), "barras diarias;", len(barras_ap),
"de apertura")1003 barras diarias; 1003 de aperturaLa traducción del SmaCross. Con barra_apertura vacía es la traducción literal: decide y envía en on_bar de la
barra diaria. Con ella, decide en la barra diaria y envía la orden al recibir la barra de apertura de la sesión
siguiente:
from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.config import (
BacktestEngineConfig, LoggingConfig, StrategyConfig)
from nautilus_trader.indicators import SimpleMovingAverage
from nautilus_trader.model.enums import (
AccountType, OmsType, OrderSide)
from nautilus_trader.trading.strategy import Strategy
class SmaCrossConfig(StrategyConfig, frozen=True):
instrument_id: InstrumentId
bar_type: BarType
barra_apertura: BarType | None = None
rapida: int = 10
lenta: int = 30
acciones: int = 100 # backtrader compraría 1 (SizerFix)
class SmaCross(Strategy):
def __init__(self, config: SmaCrossConfig) -> None:
super().__init__(config)
self.rapida = SimpleMovingAverage(config.rapida)
self.lenta = SimpleMovingAverage(config.lenta)
self.previo = None # signo de rápida - lenta
self.pendiente = None
def on_start(self) -> None:
cfg = self.config
self.register_indicator_for_bars(cfg.bar_type, self.rapida)
self.register_indicator_for_bars(cfg.bar_type, self.lenta)
self.subscribe_bars(cfg.bar_type)
if cfg.barra_apertura is not None:
self.subscribe_bars(cfg.barra_apertura)
def cruce(self):
"""BUY, SELL o None, como bt.ind.CrossOver."""
if not self.indicators_initialized():
return None
d = np.sign(self.rapida.value - self.lenta.value)
previo, self.previo = self.previo, d
largo = self.portfolio.is_net_long(self.config.instrument_id)
if previo is None:
return None
if d > 0 and previo <= 0 and not largo:
return OrderSide.BUY
if d < 0 and previo >= 0 and largo:
return OrderSide.SELL
return None
def on_bar(self, bar) -> None:
if bar.bar_type == self.config.bar_type:
lado = self.cruce()
con_apertura = self.config.barra_apertura is not None
if lado is not None and con_apertura:
self.pendiente = lado # a la apertura
elif lado is not None:
self.enviar(lado) # al cierre
elif self.pendiente is not None:
self.enviar(self.pendiente)
self.pendiente = None
def enviar(self, lado) -> None:
self.submit_order(self.order_factory.market(
self.config.instrument_id, lado,
Quantity.from_int(self.config.acciones)))
def ejecutar_nautilus(con_apertura):
motor = BacktestEngine(BacktestEngineConfig(
logging=LoggingConfig(log_level="ERROR")))
motor.add_venue(venue=VENUE, oms_type=OmsType.NETTING,
account_type=AccountType.CASH,
base_currency=USD,
starting_balances=[Money(100_000, USD)])
motor.add_instrument(kapa_n)
motor.add_data(barras_dia)
if con_apertura:
motor.add_data(barras_ap)
motor.add_strategy(SmaCross(SmaCrossConfig(
instrument_id=kapa_n.id, bar_type=BT_DIA,
barra_apertura=BT_AP if con_apertura else None)))
motor.run()
f = motor.trader.generate_order_fills_report()
sesion_ny = (pd.to_datetime(f["ts_last"])
.dt.tz_convert("America/New_York")
.dt.tz_localize(None).dt.normalize())
return pd.DataFrame({"fecha": sesion_ny.to_numpy(),
"lado": f["side"].astype(str).to_numpy(),
"precio": f["avg_px"].astype(float)
.to_numpy()})
literal = ejecutar_nautilus(False)
imitada = ejecutar_nautilus(True)
print("literal:", len(literal), "órdenes; imitada:", len(imitada))
imitada.head(3)literal: 33 órdenes; imitada: 33
fecha lado precio
0 2022-03-11 BUY 250.74
1 2022-03-31 SELL 232.00
2 2022-05-10 BUY 241.52La referencia, con pandas y la misma regla que la reconciliación de la lección 2.4.4: cruces de las medias de 10 y 30 sesiones, compra al cruzar al alza si está fuera, venta al cruzar a la baja si está dentro, y el precio de ejecución según la convención:
c, o = diario["close"], diario["open"]
x = np.sign(c.rolling(10).mean() - c.rolling(30).mean())
alza = (x > 0) & (x.shift(1) <= 0)
baja = (x < 0) & (x.shift(1) >= 0)
filas, dentro = [], False
for i in range(len(c)):
if (alza.iloc[i] and not dentro) or (baja.iloc[i] and dentro):
dentro = not dentro
filas.append((i, "BUY" if dentro else "SELL"))
def referencia(al_cierre):
out = []
for i, lado in filas:
j = i if al_cierre else i + 1
if j < len(c):
precio = c.iloc[j] if al_cierre else o.iloc[j]
out.append((c.index[j], lado, round(float(precio), 2)))
return pd.DataFrame(out, columns=["fecha", "lado", "precio"])
def coinciden(a, b):
n = min(len(a), len(b))
a, b = a.iloc[:n].reset_index(drop=True), b.iloc[:n]
b = b.reset_index(drop=True)
fecha = (a["fecha"] == b["fecha"]) & (a["lado"] == b["lado"])
precio = fecha & np.isclose(a["precio"], b["precio"])
return int(fecha.sum()), int(precio.sum()), len(a), len(b)
for nombre, nau, ref in [
("literal frente a cierre", literal, referencia(True)),
("literal frente a apertura sig.", literal,
referencia(False)),
("imitada frente a apertura sig.", imitada,
referencia(False))]:
f, p, n1, n2 = coinciden(nau, ref)
print(f"{nombre:<31} {n1} y {n2} órdenes; fecha y lado {f},"
f" también precio {p}")literal frente a cierre 33 y 33 órdenes; fecha y lado 33, también precio 33
literal frente a apertura sig. 33 y 33 órdenes; fecha y lado 0, también precio 0
imitada frente a apertura sig. 33 y 33 órdenes; fecha y lado 33, también precio 33La traducción literal coincide orden a orden, en fecha, lado y precio, con la referencia al cierre: las 33. Con la referencia a la apertura siguiente, la convención de backtrader, no coincide ninguna (todas llevan la fecha anterior). La versión con la barra de apertura reproduce la de backtrader en las 33 órdenes, en fecha y en precio; su primera compra es la del 11 de marzo de 2022 a 250,74, la misma que dio el adaptador de la lección 2.4.4.
Las dos traducciones son código correcto de NautilusTrader y se ejecutan sin un aviso. Solo la reconciliación dice cuál reproduce lo que había: es la prueba diferencial de pandas aplicada a una migración de librería. Cuál de las dos convenciones es la buena no lo decide el código, sino el mercado: ejecutar al cierre de la señal solo es honesto si la señal se puede calcular y la orden enviar antes del cierre (lección 1.1.4); si no, la apertura siguiente es la convención prudente, y con huecos nocturnos grandes la diferencia puede ser todo el resultado. Dos detalles más de la tabla de equivalencias que una traducción automática se salta: el tamaño (backtrader compra 1 unidad si no se le dice otra cosa; aquí, 100 acciones explícitas) y las comisiones (ninguna en las dos, para poder comparar).
Lo que se puede automatizar y lo que no
| Paso | Automático | Para una persona |
|---|---|---|
| Patrones de anticipación conocidos | Revisor estático en cada cambio | Decidir si cada aviso es un error o un falso positivo |
| Uso del futuro con cualquier forma | Truncado con varios cortes | Elegir cortes que crucen los eventos que importan (deslistados, rebalanceos) |
| Fugas de un día y ejecución en la barra | Placebo con precios sin señal | Fijar el umbral y repetir con otras semillas si queda cerca |
| Migración de versión | Escáner de patrones y prueba diferencial | Explicar cada diferencia que quede |
| Migración de librería | Reconciliación orden a orden con una referencia | Decidir qué semántica es la correcta para el activo |
Nada de la tabla sustituye a pensar en el mecanismo económico de la estrategia (lección 2.5.1), pero todo lo de la columna «Automático» se puede ejecutar en la integración continua de un repositorio, como los tests de la lección 6.5.1, y convierte la revisión de una propuesta de un asistente en leer un informe de diez líneas. La lección 6.5.3 trata los riesgos que ninguna de estas pruebas ve: dependencias que no existen, secretos en el código y código que parece correcto y no lo es.
Ejercicios
- Añade al revisor estático una regla para el error de B6: un
groupby(...).transformcon"last"o"max"sobre un agrupador por periodo. ¿Detecta ahora B6 sin dar avisos en su versión corregida ni en las demás?
Solución
def revision_ampliada(codigo):
avisos = revision_estatica(codigo)
for n in ast.walk(ast.parse(textwrap.dedent(codigo))):
if (metodo(n) == "transform" and n.args
and getattr(n.args[0], "value", None)
in ("last", "max", "min")):
avisos.append(f"l.{n.lineno - 1} transform('"
f"{n.args[0].value}') reparte un valor "
"posterior")
return avisos
for nombre in BANCO:
mal, bien = codigos(nombre)
print(nombre, len(revision_ampliada(mal)),
len(revision_ampliada(bien)))B1 1 0
B2 2 0
B3 1 0
B4 1 0
B5 1 0
B6 1 0Con la regla nueva, B6 da un aviso con el error y ninguno corregido, y el resto del banco no cambia: cada versión con
error sigue con sus avisos (B2 con dos) y ninguna corregida da falsa alarma. La regla solo mira "last", "max" y
"min", que son los agregados que miran al final del grupo; "first", que B6 también usa, es inofensivo si el grupo
empieza antes de la decisión. Es una regla más en una lista que nunca se completa: la capa dinámica sigue siendo
necesaria.
- El umbral del placebo (Sharpe de 1) se fijó con una sola semilla. Repite el placebo con cinco semillas para las versiones corregidas y para B2 y B5 con error. ¿Cuál es el mayor |Sharpe| de una versión corregida? ¿Detecta el placebo B2 o B5 con alguna semilla?
Solución
for nombre in BANCO:
mal, bien = (compilar(c) for c in codigos(nombre))
s_bien = [sharpe(bien(placebo(P, s))) for s in range(5)]
linea = f"{nombre} corregido max |S| {max(map(abs, s_bien)):.2f}"
if nombre in ("B2", "B5"):
s_mal = [sharpe(mal(placebo(P, s))) for s in range(5)]
linea += f" · con error max |S| {max(map(abs, s_mal)):.2f}"
print(linea)B1 corregido max |S| 0.21
B2 corregido max |S| 0.45 · con error max |S| 0.65
B3 corregido max |S| 0.45
B4 corregido max |S| 0.25
B5 corregido max |S| 0.31 · con error max |S| 0.33
B6 corregido max |S| 0.40Con cinco semillas, el mayor |Sharpe| de una versión corregida es 0,45 (B2 y B3), lejos del umbral de 1. Y el placebo no detecta B2 ni B5 con ninguna semilla (máximos de 0,65 y 0,33): como se vio en la matriz, esos dos errores no dependen de conocer rendimientos futuros, y con rendimientos sin señal no dan ventaja. Por eso el placebo no sustituye al truncado. Si una versión corregida quedara cerca del umbral, lo correcto sería repetir con más semillas y fijar el umbral con la distribución de esas versiones, no subirlo a ojo.
- Lee la tabla de la prueba con nueve construcciones. ¿Cuáles de los cambios no dan ningún aviso en pandas 2.3? ¿Qué test escribirías para cada uno?
Solución
Tres cambios no dan ningún aviso en pandas 2.3 ni en pandas 3: dtype == object (True pasa a False), astype(str)
con valores ausentes ('None' pasa a nan) y la conversión de fechas a int64 (de nanosegundos a microsegundos).
La asignación encadenada sí avisa en las dos, pero en pandas 3 ya no hace nada. Un test por cada uno:
- Columnas de texto: comprobar con
pd.api.types.is_string_dtype(columna), que la guía de migración recomienda porque funciona en las dos versiones, y un test que verifique que el filtro de columnas devuelve las esperadas. - Valores ausentes: un test con un
Noneen una columna de texto que compruebe el resultado conpd.isna, nunca comparando conNoneo con la cadena'None'. - Fechas a enteros: no convertir con
astype("int64"); si hace falta, fijar la unidad (.as_unit("ns")) y un test que compruebe un valor conocido, por ejemplo que el 2 de enero de 2024 a medianoche son 1.704.153.600 segundos desde 1970. - Asignación encadenada: un test que compruebe que la P&L con costes es menor que sin costes en un caso con cambios de posición; la prueba diferencial de la lección lo habría encontrado igual.
Respuesta plegada en cada pregunta
Autoevaluación
Un backtest devuelve la P&L diaria y pasa la prueba de truncado: lo calculado hasta T no cambia al quitar los datos posteriores a T. ¿Qué error puede tener todavía?
- ANinguno: el truncado detecta cualquier uso del futuro
- BUn universo de supervivientes
- CUna normalización con toda la muestra
- DUsar la información de la sesión t para operar en la propia sesión t (ejecución en la barra de la señal o un
shift(-1)que adelanta un día): el truncado conserva la sesión T, así que esa fuga no se ve
Ver respuesta
Respuesta D. Usar la información de la sesión t para operar en la propia sesión t (ejecución en la barra de la señal o un
shift(-1)que adelanta un día): el truncado conserva la sesión T, así que esa fuga no se veAl recortar en T, los datos de T siguen ahí. Un error que usa el cierre de t para la P&L de t pasa el truncado. En la lección, B1 (
shift(-1)) y B4 (posiciones sin retrasar) lo pasan y los caza el placebo con precios sin señal, en el que sus Sharpes suben a 34 y 70.¿Por qué el revisor estático no detecta el error de B6 (la rentabilidad del mes con
groupby(...).transform("last"))?- APorque el código no tiene ningún patrón de la lista: agrupar por mes y repartir el último valor es un uso normal de pandas; solo ejecutarlo con el futuro recortado delata que usa datos posteriores
- BPorque B6 no tiene error
- CPorque el revisor no analiza funciones con
groupby - DPorque el error está en los datos, no en el código
Ver respuesta
Respuesta A. Porque el código no tiene ningún patrón de la lista: agrupar por mes y repartir el último valor es un uso normal de pandas; solo ejecutarlo con el futuro recortado delata que usa datos posteriores
Las reglas estáticas reconocen formas concretas (
shift(-k),bfill,.mean()sin ventana). Hay infinitas formas de mirar el futuro que no tienen ninguna de ellas. Por eso las dos capas se complementan: la estática es rápida y dice dónde está el problema; la dinámica no depende de la forma.En la prueba diferencial, el script antiguo con solo los errores corregidos da en pandas 3 un capital final mayor que en pandas 2.3. ¿Qué ha pasado?
- Apandas 3 calcula mejor los rendimientos
- BLa asignación encadenada que restaba los costes ya no hace nada con copy-on-write: el script no falla, solo emite un
ChainedAssignmentError, y el backtest sale sin costes - CEl remuestreo con
MEcambia las fechas - DEs un error de redondeo
Ver respuesta
Respuesta B. La asignación encadenada que restaba los costes ya no hace nada con copy-on-write: el script no falla, solo emite un
ChainedAssignmentError, y el backtest sale sin costesres["pnl"][cambios] -= costemodificaba en pandas 2 el DataFrame original. Con copy-on-write modifica una copia que se descarta. Es el cambio silencioso más peligroso de la migración, y pandas 2.3 ya avisaba; la forma correcta esres.loc[cambios, "pnl"] -= coste.Traduces el SmaCross de backtrader a NautilusTrader enviando la orden de mercado en
on_bar. Las 33 órdenes coinciden en fecha con las de backtrader, pero no en precio. ¿Por qué?- APorque NautilusTrader cobra comisiones por defecto
- BPor el redondeo a dos decimales de
Price - CPorque backtrader ejecuta las órdenes de mercado a la apertura de la barra siguiente y NautilusTrader, sin modelo de latencia, al cierre de la barra en la que se envían; su documentación dice que no tiene un modo nativo de ejecución a la apertura siguiente
- DPorque las medias móviles de las dos librerías son distintas
Ver respuesta
Respuesta C. Porque backtrader ejecuta las órdenes de mercado a la apertura de la barra siguiente y NautilusTrader, sin modelo de latencia, al cierre de la barra en la que se envían; su documentación dice que no tiene un modo nativo de ejecución a la apertura siguiente
La traducción literal reproduce el modo
coc(ejecutar al cierre de la señal). Para imitar la apertura siguiente, la lección añade una barra de apertura y envía la orden al recibirla: así las 33 órdenes coinciden en fecha y precio.¿Qué hace falta para dar por buena una migración de un backtest?
- AQue el código nuevo se ejecute sin errores ni avisos
- BQue el asistente diga que ha revisado los cambios
- CQue los tests unitarios pasen con la versión nueva
- DUna prueba diferencial: el mismo backtest con la versión antigua y con la nueva da las mismas operaciones y el mismo resultado, o las diferencias están explicadas una por una
Ver respuesta
Respuesta D. Una prueba diferencial: el mismo backtest con la versión antigua y con la nueva da las mismas operaciones y el mismo resultado, o las diferencias están explicadas una por una
Los cambios de comportamiento más peligrosos no dan errores: la asignación encadenada que no asigna, la resolución de las fechas, el tipo de las cadenas. Solo comparando resultados se ven. Los tests unitarios ayudan, pero solo cubren lo que alguien pensó en probar.
Ideas clave
- Un revisor de backtests tiene dos capas: la estática lee el árbol sintáctico y señala patrones conocidos con su línea; la dinámica ejecuta el backtest con el futuro recortado y con precios sin señal, sin depender de cómo esté escrito.
- Contra seis backtests con errores plantados, la capa estática cazó cinco, el truncado cuatro y el placebo cuatro; juntas, los seis. Ninguna dio avisos en las seis versiones corregidas.
- El truncado no ve las fugas de un día (B1 y B4): las caza el placebo, en el que esos backtests dan Sharpes de 34 y 70 con precios que no tienen nada que predecir.
- pandas 3.0 trae copy-on-write, el tipo
strpor defecto, fechas con resolución inferida y la retirada de muchas API obsoletas. Lo que falla con error es fácil; lo peligroso es lo silencioso: la asignación encadenada,dtype == object,astype(str)y la conversión de fechas a enteros. - La prueba diferencial ejecuta el mismo script con pandas 2.3 y con pandas 3 y compara las salidas: detecta la migración que corrige los errores pero deja el backtest sin costes.
- backtrader ejecuta a la apertura siguiente y NautilusTrader al cierre de la barra de la orden: una traducción literal cambia todos los precios de ejecución. Una migración se acepta cuando reconcilia con la referencia, orden a orden.