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.

Fig. — Sharpe de los seis backtests del banco con su error y corregidos: los errores de un día dan Sharpes de 18 a 31; los de supervivencia y normalización apenas se notan
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

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:

CapaQué haceVentajaLímite
EstáticaLee el código sin ejecutarlo y busca patrones conocidosRápida, dice la línea exacta, no necesita datosSolo ve las formas que alguien escribió en una regla
DinámicaEjecuta el backtest con datos modificados y comparaNo depende de cómo esté escrito el códigoNecesita datos y tiempo; dice que hay un problema, no dónde
CódigoRevisión estáticaEjecuciónTruncadoPlacebo sin señalInformePersona que decide
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.

IN [01]python
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()))
OUT [01]Precios del universo
(5031, 120) · sesiones 2006-01-03 a 2025-12-31
dejan de cotizar antes del final: 10

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

BacktestEstrategiaError plantado
B1Momentum de 20 sesiones, largo-cortoshift(-1) en la señal: la alinea con la etiqueta en vez de con la decisión
B2Largo en las acciones con z de momentum mayor que 1z con la media y la desviación de toda la muestra
B3Momentum de 12 meses, quintil superiorSeñal mensual pasada a diaria con bfill() en vez de ffill()
B4Tendencia de 5 sesiones, largo-cortoPesos sin retrasar: se opera con el cierre que genera la señal
B5EquiponderadaUniverso = las acciones que cotizan el último día (supervivientes)
B6Rentabilidad del mes, largo-cortoRentabilidad del mes completo repartida a todos sus días
IN [02]python
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])
OUT [02]Seis backtests y sus correcciones

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:

IN [03]python
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}")
OUT [03]Lo que cuesta cada error
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.78

Los 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:

ReglaPatrónPor qué es sospechoso
Desplazamiento negativoshift(-k)Trae al día t un dato de t + k
Ventana centradarolling(..., center=True)La ventana incluye sesiones futuras
Relleno hacia atrásbfill(), backfill(), fillna(method="bfill")Copia hacia atrás un valor posterior
Toda la muestra.mean(), .std(), .min(), .max(), .quantile() sin ventana ni axis=1El estadístico usa todas las fechas
Misma barra* R sin shift en el otro factorOpera con el cierre que genera la señal
Supervivientes.iloc[-1] o dropna(axis=1)Elige el universo con el último dato
IN [04]python
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 '-'}")
OUT [04]El revisor estático
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:

IN [05]python
informe = """
def resumen(curva):
    final = curva.iloc[-1]
    return {"capital final": final, "media": curva.mean()}
"""
revision_estatica(informe)
OUT [05]Un falso positivo
['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 1/19≈0,231/\sqrt{19} \approx 0{,}23. Un Sharpe de más de 1 en el placebo no es un hallazgo, es un error.

IN [06]python
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}")
OUT [06]Truncado y placebo
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.51

Las 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 (σ2/2\sigma^2/2 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:

IN [07]python
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
OUT [07]Qué capa ve cada error
          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    True
Fig. — Qué capa del revisor detecta cada error del banco: la estática caza cinco de seis; el truncado y el placebo, cuatro cada uno; juntas, los seis, sin ninguna falsa alarma en las versiones corregidas

La 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:

CambioQué pasaba en pandas 2Qué pasa en pandas 3
Copy-on-writeUna selección podía ser una vista: modificarla modificaba el originalToda selección se comporta como una copia; la asignación encadenada no hace nada y emite ChainedAssignmentError
Tipo de cadenasLas cadenas eran objectTipo str por defecto (con PyArrow si está instalado); el valor ausente es siempre NaN
Resolución de fechasSiempre nanosegundosSe infiere: al leer texto, microsegundos
Alias de frecuenciaM, Q, Y, H, TME, QE, YE, h, min (los antiguos dan error)
API retiradasfillna(method=…), applymap, first/last por fecha, swapaxes…Retiradas: usar ffill/bfill, map, loc
Índices enteross[0] en una serie con fechas era posicional (con aviso)Una clave entera es siempre una etiqueta: KeyError
Zonas horariasObjetos de pytzzoneinfo 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:

IN [08]python
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])}")
OUT [08]Nueve construcciones de pandas 2, en las dos versiones
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.6

Cinco 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:

IN [09]python
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}")
OUT [09]El backtest antiguo y su escáner de migración
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 .iloc

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

IN [10]python
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))
OUT [10]Prueba diferencial de la migración
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.112132

La tabla cuenta la historia completa:

  1. El script antiguo funciona en pandas 2.3 (con FutureWarning): capital final 2,112132.
  2. En pandas 3 se para con TypeError.
  3. 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 ChainedAssignmentError en 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».
  4. 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):

backtraderNautilusTraderDiferencia que importa
CerebroBacktestEngine (o BacktestNode con configuración y catálogo)El motor se monta con venue, instrumento, datos y estrategia
bt.feeds.PandasDataBar + BarType + BarDataWranglerLa 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_startEl 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.positionself.portfolio.is_net_long(instrument_id)La posición se pregunta al Portfolio
notify_order / notify_tradeon_order_filled, on_order_event / on_position_closedEventos tipados
broker.setcash, setcommissionadd_venue(starting_balances=…), fee_modelComisiones por modelo (FixedFeeModel, MakerTakerFeeModel…)
Orden de mercado: apertura de la barra siguienteSin latencia: cierre de la barra en que se envíaNo 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».

Cierre de t: señalbacktraderNautilusApertura de t+1Cierre de t
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:

IN [11]python
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")
OUT [11]Barras diarias y de apertura de KAPA para Nautilus
1003 barras diarias; 1003 de apertura

La 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:

IN [12]python
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)
OUT [12]SmaCross traducido a NautilusTrader
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.52

La 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:

IN [13]python
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}")
OUT [13]Reconciliación orden a orden
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 33

La 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

PasoAutomáticoPara una persona
Patrones de anticipación conocidosRevisor estático en cada cambioDecidir si cada aviso es un error o un falso positivo
Uso del futuro con cualquier formaTruncado con varios cortesElegir cortes que crucen los eventos que importan (deslistados, rebalanceos)
Fugas de un día y ejecución en la barraPlacebo con precios sin señalFijar el umbral y repetir con otras semillas si queda cerca
Migración de versiónEscáner de patrones y prueba diferencialExplicar cada diferencia que quede
Migración de libreríaReconciliación orden a orden con una referenciaDecidir 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

  1. Añade al revisor estático una regla para el error de B6: un groupby(...).transform con "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
IN [14]python
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)))
OUT [14]Una regla para transform('last')
B1 1 0
B2 2 0
B3 1 0
B4 1 0
B5 1 0
B6 1 0

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

  1. 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
IN [15]python
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)
OUT [15]El placebo con cinco semillas
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.40

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

  1. 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 None en una columna de texto que compruebe el resultado con pd.isna, nunca comparando con None o 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

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

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

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

  3. 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 ME cambia 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 costes

    res["pnl"][cambios] -= coste modificaba 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 es res.loc[cambios, "pnl"] -= coste.

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

  5. ¿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 str por 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.

Recursos

Datos