---
title: "6.5.2 Revisión automática de backtests y migraciones"
description: "Revisor estático y dinámico probado con seis backtests con errores conocidos; migración de pandas 2 a 3 con prueba diferencial y de backtrader a Nautilus."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/revision-automatica-de-backtests-y-migraciones/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/revision-automatica-de-backtests-y-migraciones.png"
---

# 6.5.2 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- 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](https://pythonparatrading.com/curso-python-trading/nivel-2/sesgos-de-backtest/), [2.4.4 · backtesting.py y backtrader: dos motores didácticos y su estado](https://pythonparatrading.com/curso-python-trading/nivel-2/backtesting-py-y-backtrader/), [2.4.5 · NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real](https://pythonparatrading.com/curso-python-trading/nivel-2/nautilustrader-arquitectura-y-estrategias/), [6.5.1 · Asistentes de código con un flujo de tests primero](https://pythonparatrading.com/curso-python-trading/nivel-6/asistentes-de-codigo-con-tests-primero/)

## Al terminar esta lección

- Escribir un revisor estático con el árbol sintáctico de Python que detecte desplazamientos negativos, normalizaciones con toda la muestra, rellenos hacia atrás, ejecución en la barra de la señal y universos de supervivientes.
- Ejecutar dos pruebas dinámicas sobre cualquier backtest: el truncado del futuro y un placebo con precios sin señal, y saber qué errores ve cada una.
- Medir la cobertura de cada capa contra un banco de backtests con errores conocidos y con sus versiones corregidas.
- Migrar código de pandas 2 a pandas 3 con las notas de la versión, un escáner de patrones y una prueba diferencial que ejecuta el mismo script con las dos versiones.
- Traducir una estrategia de backtrader a NautilusTrader con una tabla de equivalencias y reconciliar sus órdenes con una referencia, incluida la diferencia en el precio de ejecución.

## 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 |

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

```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()))
```
```output
(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.

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

```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])
```
```output

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:

```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}")
```
```output
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:

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

```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 '-'}")
```
```output
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:

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

```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}")
```
```output
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 ($\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:

```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
```
```output
          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.* Tabla de 6 filas por 3 columnas. B1 shift(-1): estática sí, truncado no, placebo sí. B2 z global: sí, sí, no. B3 bfill: sí, sí, sí. B4 misma barra: sí, no, sí. B5 supervivientes: sí, sí, no. B6 mes completo: estática no (destacado), truncado sí, placebo sí.

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:

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

```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])}")
```
```output
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:

```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}")
```
```output
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.

```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))
```
```output
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):

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

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

```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")
```
```output
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:

```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)
```
```output
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:

```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}")
```
```output
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

| 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

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

```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)))
```
```output
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.

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

```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)
```
```output
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.

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

## 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?

   - A. Ninguno: el truncado detecta cualquier uso del futuro
   - B. Un universo de supervivientes
   - C. Una normalización con toda la muestra
   - 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

   *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")`)?

   - 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
   - B. Porque B6 no tiene error
   - C. Porque el revisor no analiza funciones con `groupby`
   - D. Porque el error está en los datos, no en el código

   *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?

   - A. pandas 3 calcula mejor los rendimientos
   - 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
   - C. El remuestreo con `ME` cambia las fechas
   - D. Es un error de redondeo

   *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é?

   - A. Porque NautilusTrader cobra comisiones por defecto
   - B. Por el redondeo a dos decimales de `Price`
   - 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
   - D. Porque las medias móviles de las dos librerías son distintas

   *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?

   - A. Que el código nuevo se ejecute sin errores ni avisos
   - B. Que el asistente diga que ha revisado los cambios
   - C. Que los tests unitarios pasen con la versión nueva
   - 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

   *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

### Research

- [Tu primer sistema en Python: cruce de medias móviles sobre SPY](https://pythonparatrading.com/primer-sistema-cruce-de-medias/) · Research-0018 · 7 min

### Documentación

- [pandas: novedades de la versión 3.0.0 (21 de enero de 2026)](https://pandas.pydata.org/docs/whatsnew/v3.0.0.html) · pandas.pydata.org
- [pandas: guías de migración (copy-on-write y el nuevo tipo de cadenas)](https://pandas.pydata.org/docs/user_guide/migration.html) · pandas.pydata.org
- [NautilusTrader: ejecución con barras en el backtest](https://nautilustrader.io/docs/latest/concepts/backtesting/bar-execution/) · nautilustrader.io
- [backtrader: creación y ejecución de órdenes](https://www.backtrader.com/docu/order-creation-execution/order-creation-execution/) · backtrader.com
- [Python: módulo ast (árboles de sintaxis abstracta)](https://docs.python.org/3/library/ast.html) · docs.python.org

### Datos

- [Datos del nivel 2: precios ajustados del universo (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/universo2_ajustados.parquet) · Descarga · universo2_ajustados.parquet

Lección anterior: [6.5.1 · Asistentes de código con un flujo de tests primero](https://pythonparatrading.com/curso-python-trading/nivel-6/asistentes-de-codigo-con-tests-primero/)

Lección siguiente: [6.5.3 · Riesgos del código generado por IA](https://pythonparatrading.com/curso-python-trading/nivel-6/riesgos-del-codigo-generado-por-ia/)
