Lesson 6.5.2 · 75 min

Automated review of backtests and code migrations

An assistant writes a backtest in a minute and ports it from one library to another in another minute. Reviewing it by hand takes longer. This lesson builds a two-layer reviewer, one layer that reads the code and another that runs it with the future cut off and with signal-free data, and tests it against six backtests with planted bugs and their fixed versions. Then it applies the same idea to two migrations: from pandas 2 to pandas 3, running the same script with both versions, and from backtrader to NautilusTrader, reconciling the orders.

Fig. — Sharpe of the six backtests in the bank with their bug and fixed: one-day leaks give Sharpes of 18 to 31; survivorship and normalisation barely show
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

Reviewing faster than code is written

A coding assistant (lesson 6.5.1) or a research agent (lesson 6.4.6) produces backtests at a pace that no person can review line by line. And the bugs that matter in a backtest are not the ones that make the program fail, but the ones that make it look good: using data from the future, filling at a price that was not available, choosing the universe by looking at who survives. Lesson 2.5.1 presented them one by one; this one looks for them automatically.

A useful reviewer has two layers, with opposite properties:

LayerWhat it doesAdvantageLimit
StaticReads the code without running it and looks for known patternsFast, gives the exact line, needs no dataOnly sees the forms someone wrote into a rule
DynamicRuns the backtest with modified data and comparesDoes not depend on how the code is writtenNeeds data and time; says there is a problem, not where
CodeStatic reviewExecutionTruncationSignal-free placeboReportPerson who decides
Mermaid source
flowchart TB
  C[Code] --> E[Static review]
  C --> D[Execution]
  D --> T[Truncation]
  D --> P[Signal-free placebo]
  E --> I[Report]
  T --> I
  P --> I
  I --> H[Person who decides]

The report does not approve or reject anything: it tells the reviewer where to look. The second half of the lesson uses the same idea, run and compare, for something an assistant does often and that is almost never reviewed well: migrating code to a new version of a library (pandas 2 to pandas 3) or to another library (backtrader to NautilusTrader).

A bank of backtests with known bugs

To measure a reviewer you need to know the answer: backtests with planted bugs and their fixed versions. The six in the lesson use the universe of 120 fictitious stocks from level 2 (universo2_ajustados.parquet: adjusted prices from 2006 to 2025, with ten stocks that stop trading, four of them through bankruptcy). The data are synthetic: they are not market data.

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, "· sessions", P.index[0].date(), "to",
      P.index[-1].date())
print("stop trading before the end:",
      int((ultima < P.index[-1]).sum()))
OUT [01]Prices of the universe
(5031, 120) · sessions 2006-01-03 to 2025-12-31
stop trading before the end: 10

All the backtests follow the same contract: they receive the price table P (sessions in rows, stocks in columns, empty when the stock is not trading) and return the daily return of the strategy. Each one has one one-line bug, of the kind that shows up in generated code or code written in a hurry, and its fixed version changes only that line.

BacktestStrategyPlanted bug
B120-session momentum, long-shortshift(-1) on the signal: aligns it with the label instead of with the decision
B2Long the stocks with a momentum z above 1z with the mean and standard deviation of the full sample
B312-month momentum, top quintileMonthly signal turned daily with bfill() instead of ffill()
B45-session trend, long-shortWeights not lagged: trades on the close that generates the signal
B5Equal-weightedUniverse = the stocks trading on the last day (survivors)
B6Month's return, long-shortReturn of the full month spread over all its days
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)"
# correct version of the body, correct line and line with the bug
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):
    """(code with the bug, fixed code) of a 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]Six backtests and their fixes

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)

The template is the one any assistant would produce: daily returns R, weights w normalized so that gross exposure is 1, and the P&L as the previous day's weights times today's returns. First, how much each bug changes the result:

IN [03]python
def sharpe(r):
    r = r.loc["2007":]                  # without the first year
    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 with bug {coste[nombre][0]:6.2f}"
          f"   fixed {coste[nombre][1]:6.2f}")
OUT [03]What each bug costs
B1  Sharpe with bug  18.77   fixed   0.29
B2  Sharpe with bug   0.41   fixed   0.36
B3  Sharpe with bug   2.12   fixed   0.48
B4  Sharpe with bug  30.69   fixed   0.34
B5  Sharpe with bug   0.41   fixed   0.38
B6  Sharpe with bug  17.67   fixed   0.78

The bugs fall into two very different groups, as the figure at the top shows. Those that use the information of a day to trade that same day (B1, B4) or that of the whole month (B6) turn strategies with a Sharpe of 0.3–0.8 into Sharpe ratios of 18 to 31: so absurd that nobody would believe them. B3, with 2.12 against 0.48, is already a result someone could show. And the two most dangerous are the ones that are barely noticeable: the full-sample normalization (B2) raises the Sharpe from 0.36 to 0.41 and the survivor universe (B5), from 0.38 to 0.41. Nobody suspects a 0.41. A reviewer has to find all of them, and especially those two, without raising warnings on the six fixed versions.

The static layer: reading the syntax tree

Searching for shift(- with a regular expression works until someone writes shift(periods=-1) or shift( -1 ). The standard library's ast module turns the code into a syntax tree: every call, every attribute and every argument is a node, however the author writes it. The reviewer walks the tree and applies six rules:

RulePatternWhy it is suspicious
Negative shiftshift(-k)Brings a value from t + k to day t
Centered windowrolling(..., center=True)The window includes future sessions
Backward fillbfill(), backfill(), fillna(method="bfill")Copies a later value backwards
Full sample.mean(), .std(), .min(), .max(), .quantile() with no window and no axis=1The statistic uses every date
Same bar* R with no shift on the other factorTrades on the close that generates the signal
Survivors.iloc[-1] or dropna(axis=1)Chooses the universe with the last data point
IN [04]python
VENTANAS = {"rolling", "expanding", "ewm", "groupby", "resample"}
TODA_MUESTRA = {"mean", "std", "min", "max", "quantile", "median"}

def metodo(nodo):
    """Name of the method if the node is a call x.method(...)."""
    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, "negative shift")
        if m == "rolling" and getattr(kw.get("center"),
                                      "value", False):
            aviso(n, "centered window")
        if m in ("bfill", "backfill") or getattr(
                kw.get("method"), "value", "") in ("bfill",
                                                   "backfill"):
            aviso(n, "backward fill")
        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}() over the full sample")
        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, "weights not lagged (same bar)")
        if (isinstance(n, ast.Subscript)
                and isinstance(n.value, ast.Attribute)
                and n.value.attr == "iloc" and negativo(n.slice)):
            aviso(n, "universe from the last data point")
        if m == "dropna" and getattr(kw.get("axis"), "value",
                                     0) in (1, "columns"):
            aviso(n, "universe from the last data point")
    return avisos

estatica = {}
for nombre in BANCO:
    mal, bien = codigos(nombre)
    estatica[nombre] = (revision_estatica(mal),
                        revision_estatica(bien))
    print(f"{nombre} with bug: {estatica[nombre][0] or '-'}")
    print(f"   fixed: {estatica[nombre][1] or '-'}")
OUT [04]The static reviewer
B1 with bug: ['l.4 negative shift']
   fixed: -
B2 with bug: ['l.5 .std() over the full sample', 'l.5 .mean() over the full sample']
   fixed: -
B3 with bug: ['l.5 backward fill']
   fixed: -
B4 with bug: ['l.7 weights not lagged (same bar)']
   fixed: -
B5 with bug: ['l.4 universe from the last data point']
   fixed: -
B6 with bug: -
   fixed: -

Five out of six, with the exact line (counted from def backtest): the shift(-1) of B1 on line 4, the full-sample mean and standard deviation of B2 on line 5, the bfill() of B3 on line 5, the multiplication without shift of B4 on line 7 and the iloc[-1] of B5 on line 4. No warnings on the fixed versions: the full-sample rule does not fire with s.mean(axis=1) (a cross-sectional mean of each day, which is legitimate) or with m.expanding(250).mean().

B6 passes clean. Its bug, g.transform("last") on a monthly grouper, gives every day of the month the last price of the month, which is not yet known. There is nothing suspicious about the form: grouping and spreading a value is everyday pandas. A rule could be written for that case (exercise 1), but there will always be another way of looking at the future that has no rule. That is what the second layer is for.

Rules come at a price: false positives. The same rule that catches the survivor universe fires on harmless code that reads the last value for a report:

IN [05]python
informe = """
def resumen(curva):
    final = curva.iloc[-1]
    return {"final capital": final, "mean": curva.mean()}
"""
revision_estatica(informe)
OUT [05]A false positive
['l.2 universe from the last data point', 'l.3 .mean() over the full sample']

Two warnings and no bug: the last value of an equity curve and its mean are legitimate in a report. That is why the reviewer is applied only to the code that decides (the backtest function), not to the whole repository, and why its warnings are questions for a person, not verdicts. A reviewer that warns about everything ends up ignored.

The dynamic layer: cutting off the future and removing the signal

The dynamic layer does not read the code: it runs it twice with different data and compares. The first test is the truncation of lessons 0.2.6 and 6.5.1, applied to a complete backtest: what the backtest computes up to date T cannot change if the data after T are removed. It is tested with several cutoffs, because a bug can depend on what happens after each one (the survivor universe only changes if some stock stops trading after the cutoff).

The second is a placebo: prices with the same shape as the real ones (the same stocks, the same dates on which they trade, the same volatility for each one) but with independent returns, in which there is nothing to predict. With them, the Sharpe of any bug-free strategy has to be compatible with zero: with 19 years, its standard error is 1/19≈0.231/\sqrt{19} \approx 0.23. A Sharpe above 1 on the placebo is not a finding, it is a bug.

IN [06]python
CORTES = ["2010-11-15", "2014-06-13", "2018-12-14", "2022-06-15"]

def prueba_truncado(f, P):
    """Sessions up to each cutoff in which the P&L changes."""
    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):
    """Independent zero-mean returns with the volatility of each
    stock and its same trading period."""
    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} with bug: truncation {t_mal:5d} sessions "
          f"changed, placebo {s_mal:6.2f} | fixed: "
          f"{t_bien} and {s_bien:5.2f}")
OUT [06]Truncation and placebo
B1 with bug: truncation     0 sessions changed, placebo  33.89 | fixed: 0 and  0.27
B2 with bug: truncation  7900 sessions changed, placebo  -0.42 | fixed: 0 and -0.22
B3 with bug: truncation  7853 sessions changed, placebo   6.03 | fixed: 0 and  0.27
B4 with bug: truncation     0 sessions changed, placebo  69.73 | fixed: 0 and -0.10
B5 with bug: truncation 10752 sessions changed, placebo  -0.08 | fixed: 0 and  0.06
B6 with bug: truncation    37 sessions changed, placebo  29.17 | fixed: 0 and  0.51

The two tests see different things:

  • Truncation finds B2, B3, B5 and B6, all the ones that use data after the cutoff: the full-sample mean changes when the sample is cut (7,900 sessions changed across the four cutoffs), as does the backward fill from the next month (7,853) and the universe of the stocks trading on the last day (10,752). B6 only changes in 37 sessions: those of the months split by the cutoffs, which is why the cutoffs fall mid-month. With month-end cutoffs it would not have seen it.
  • Truncation does not see B1 or B4: both use the close of session t for the P&L of session t itself, and when cutting at T session T is still there. The placebo catches them: on prices that have nothing to predict, B1 gives a Sharpe of 33.89 and B4 of 69.73. B3 (6.03) and B6 (29.17) too.

The fixed versions pass both tests: zero sessions changed and placebo Sharpe ratios between −0.22 and 0.51, within what chance gives. The placebo uses zero-mean arithmetic returns; with prices following a random walk in logarithms, the expected arithmetic return would be positive (σ2/2\sigma^2/2 per day) and a long-only portfolio would give a positive Sharpe without any bug.

Two layers together

The final report brings together the three tests for each backtest, with the false alarms on the fixed versions:

IN [07]python
filas = []
for nombre in BANCO:
    (t_mal, s_mal), (t_bien, s_bien) = dinamica[nombre]
    filas.append({
        "backtest": nombre,
        "static": bool(estatica[nombre][0]),
        "truncation": t_mal > 0,
        "placebo": abs(s_mal) > 1,
        "false alarms": int(bool(estatica[nombre][1]))
        + int(t_bien > 0) + int(abs(s_bien) > 1),
    })
matriz = pd.DataFrame(filas).set_index("backtest")
matriz["any"] = matriz[["static", "truncation",
                        "placebo"]].any(axis=1)
matriz
OUT [07]Which layer sees each bug
          static  truncation  placebo  false alarms   any
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. — Which reviewer layer catches each bug in the bank: the static layer catches five of six; truncation and the placebo, four each; together, all six, with no false alarm on the fixed versions

The static layer catches five out of six; truncation, four; the placebo, four; together, all six, and none raises a false alarm on the fixed versions. No layer is redundant: B6 is seen only by the dynamic ones, B1 and B4 only by the static layer and the placebo, and B2 and B5, the two subtle bugs, only by the static layer and truncation. The placebo cannot see them: in B2 and B5 the edge does not come from knowing future returns, but from using future data to choose which stocks or which threshold to use, and with signal-free returns that gives nothing (exercise 2).

It is a small, tailor-made bank: a real reviewer is measured with many more bugs, and its thresholds (the placebo Sharpe of 1, the four cutoffs) are tuned on fixed versions so as not to drown whoever uses it in warnings.

pandas 2 to 3: what changes

pandas 3.0.0 was released on January 21, 2026 and the course uses it from level 0 (in the main environment, version 3.0.6). Its release notes recommend a specific path: first upgrade to pandas 2.3 and get the code free of warnings, because 3.0 removes much of what 2.x marked as deprecated. The changes that most affect trading code, according to those notes and the official migration guide:

ChangeWhat happened in pandas 2What happens in pandas 3
Copy-on-writeA selection could be a view: modifying it modified the originalEvery selection behaves as a copy; chained assignment does nothing and emits ChainedAssignmentError
String typeStrings were objectstr type by default (with PyArrow if installed); the missing value is always NaN
Date resolutionAlways nanosecondsInferred: when parsing text, microseconds
Frequency aliasesM, Q, Y, H, TME, QE, YE, h, min (the old ones raise an error)
Removed APIsfillna(method=…), applymap, first/last by date, swapaxes…Removed: use ffill/bfill, map, loc
Integer keyss[0] on a series with dates was positional (with a warning)An integer key is always a label: KeyError
Time zonespytz objectsThe standard library's zoneinfo; pytz is no longer a dependency

A rule from the migration guide sums up the first row: if you want to modify an object, the only way is to modify that object directly. df["a"][mascara] = x modifies a temporary copy and does nothing; df.loc[mascara, "a"] = x modifies df.

The same script with both versions

The most reliable way to know what changes is to run the same code with both versions and compare. The course has both interpreters: the one in the main environment (pandas 3.0.6) and the one in the Zipline environment (pandas 2.3.3, lesson 2.4.6). On your machine any virtual environment with pandas==2.3.3 will do. The cell writes a script with nine constructs that are common in old code and runs it with each interpreter; warnings are collected and shown by category:

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] with dates", lambda: s[0])
caso("chained assignment", encadenada)
caso("dtype == object", lambda: df["tk"].dtype == object)
caso("astype(str) of None",
     lambda: repr(df["tk"].astype(str).iloc[2]))
caso("date to int64", lambda: int(pd.to_datetime(
    ["2024-01-02"]).astype("int64")[0]))
print(f"version|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]Nine pandas 2 constructs, in both versions
fillna(method=)       1.0 (Future)                       TypeError
applymap              10.0 (Future)                      AttributeError
resample('M')         1 (Future)                         ValueError
first('3D')           3 (Future)                         AttributeError
s[0] with dates       1.0 (Future)                       KeyError
chained assignment    [13.0, 0.0, 0.0] (SettingWithCopy) [13.0, 14.0, 15.0] (ChainedAssignmentError)
dtype == object       True                               False
astype(str) of None   'None'                             nan
date to int64         1704153600000000000                1704153600000000
version               pandas 2.3.3                       pandas 3.0.6

Five constructs fail in pandas 3 with an exception: fillna(method=) with TypeError, applymap and first() with AttributeError, the M alias with ValueError and s[0] on a date index with KeyError. They are the easy ones: the program stops and says where. pandas 2.3 warned about all of them with a FutureWarning, the path the release notes recommend.

The other four do not fail. The chained assignment does not modify the DataFrame (the last three values are still 13, 14 and 15) and only leaves a warning, ChainedAssignmentError, which in pandas 2.3 was SettingWithCopyWarning. And three changes give no warning in either version: dtype == object goes from True to False because the ticker column is now str; astype(str) leaves the missing value as nan instead of turning it into the text 'None'; and a date parsed from text converts to integers in microseconds, not nanoseconds: 1,000 times smaller. A filter on text columns that stops filtering, a None that is no longer a string and timestamps divided by a thousand break nothing; they change results.

Migrating a backtest and checking it

An old backtest of the monthly portfolio of KAPA (the synthetic ETF of lessons 2.4.4 and 2.4.5): long the next month if the month was positive, with a cost of 10 bp on each change of position. It is written for pandas 2 and does not work in pandas 3. Before touching it, a migration scanner built on the syntax tree, like the bias reviewer, lists what needs to change:

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"first close {precios[0]:.2f}, months {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=) removed: "
                           "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}' removed: "
                               f"'{ALIAS[f]}'"))
        if m == "applymap":
            avisos.append((n.lineno, "applymap removed: 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, "chained assignment: does not "
                           "assign under copy-on-write; use .loc"))
        if (isinstance(n, ast.Subscript)
                and isinstance(n.slice, ast.Constant)
                and type(n.slice.value) is int):
            avisos.append((n.lineno, "integer key: in pandas 3 it is "
                           "a label; use .iloc"))
    return sorted(avisos)

for linea, texto in escaner_migracion(ANTIGUO):
    print(f"line {linea}: {texto}")
OUT [09]The old backtest and its migration scanner
line 4: fillna(method=) removed: ffill() / bfill()
line 5: alias 'M' removed: 'ME'
line 10: chained assignment: does not assign under copy-on-write; use .loc
line 11: integer key: in pandas 3 it is a label; use .iloc

Four warnings with their line: the fillna(method=) on line 4, the M alias on line 5, the chained assignment on line 10, which subtracts the cost in the months with a change of position, and the integer key precios[0] on line 11. Now the differential test, with five runs: the old script with each version, a migration that only fixes what raises an error in pandas 3, and the full migration, in both versions.

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"):
    """Script output and categories of the warnings or the 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 [
        ("old, pandas 2.3", PY2, ANTIGUO),
        ("old, pandas 3.0", PY3, ANTIGUO),
        ("errors only, pandas 3.0", PY3, SOLO_ERRORES),
        ("migrated, pandas 3.0", PY3, MIGRADO),
        ("migrated, pandas 2.3", PY2, MIGRADO)]:
    salida, avisos = correr(python, codigo)
    print(f"{etiqueta:<25} {salida}")
    if avisos:
        print(" " * 26 + "warnings: " + ", ".join(avisos))
OUT [10]Differential test of the migration
old, pandas 2.3           first close 252.38, months 48, capital 2.112132
                          warnings: FutureWarning
old, pandas 3.0           TypeError
errors only, pandas 3.0   first close 252.38, months 48, capital 2.152774
                          warnings: ChainedAssignmentError
migrated, pandas 3.0      first close 252.38, months 48, capital 2.112132
migrated, pandas 2.3      first close 252.38, months 48, capital 2.112132

The table tells the whole story:

  1. The old script works in pandas 2.3 (with FutureWarning): final capital 2.112132.
  2. In pandas 3 it stops with TypeError.
  3. Fixing only what raises an error, it runs and gives 2.152774: more capital than the original. The chained assignment no longer subtracts the costs, the script does not fail and the only trace is a ChainedAssignmentError on the error output, which nobody reads in an unattended run. It is exactly the migration made by whoever (person or assistant) is chasing to make the code “work again”.
  4. The full migration, with res.loc[cambios, "pnl"] -= 0.001, gives 2.112132 in pandas 3 and in pandas 2.3: the same result as the original, with the same code in both versions.

That is the acceptance criterion for a migration: the same result as the old version, with the old version and with the new one, or each difference explained. For a real backtest, “the same result” means the same trades and the same equity curve up to rounding, not just the final number.

backtrader to NautilusTrader

The other frequent migration is between libraries: from backtrader, which has not published releases on PyPI since April 2023 (lesson 2.4.4), to NautilusTrader, an actively maintained event-driven engine (lesson 2.4.5). An assistant translates the syntax without trouble; what it does not translate on its own is the semantics: when an order is filled, at what price, with what default size. The table of equivalences, from both sets of documentation (consulted on October 23, 2026; NautilusTrader 1.231.0):

backtraderNautilusTraderDifference that matters
CerebroBacktestEngine (or BacktestNode with configuration and catalog)The engine is assembled with venue, instrument, data and strategy
bt.feeds.PandasDataBar + BarType + BarDataWranglerThe bar's timestamp has to be that of its close
params = (("rapida", 10),)StrategyConfig subclass (frozen=True)Immutable, typed configuration
Indicators in __init__Indicators in __init__ + register_indicator_for_bars in on_startThe engine updates them before on_bar
next()on_bar(bar)Same: one call per closed bar
self.data.close[0] / [-1]bar.close / self.cache.bar(bar_type, 1)The cache indexes the other way round: 0 is the latest
self.buy()self.order_factory.market(...) + self.submit_order(...)No default quantity: backtrader's sizer buys 1 unit
self.close()self.close_all_positions(instrument_id)
self.positionself.portfolio.is_net_long(instrument_id)The position is queried from the Portfolio
notify_order / notify_tradeon_order_filled, on_order_event / on_position_closedTyped events
broker.setcash, setcommissionadd_venue(starting_balances=…), fee_modelCommissions through a model (FixedFeeModel, MakerTakerFeeModel…)
Market order: open of the next barWithout latency: close of the bar in which it is sentNo native next-open mode

The last row is the one that changes the results. backtrader's documentation says that a market order is filled at the “opening price of the next bar”; NautilusTrader's, that without a latency model an order sent from on_bar is filled against the book left by that bar's close, and that the engine “does not offer a native mode for execution at the open of the next bar”.

Close of t: signalbacktraderNautilusOpen of t+1Close of t
Mermaid source
flowchart LR
  S[Close of t: signal] --> B[backtrader]
  S --> N[Nautilus]
  B --> A[Open of t+1]
  N --> C[Close of t]

The strategy is the SmaCross of lesson 2.4.4 (10- and 30-session averages, long only) on the daily bars of KAPA. There, with its own adapter, it gave 33 orders, and the first was created on March 10, 2022 and filled on the 11th at 250.74. The daily bars carry the 16:00 New York timestamp, their close. To be able to imitate the next open, a one-minute bar per session is also prepared, at 9:31, with the opening price in its four fields:

IN [11]python
import warnings

from pandas.errors import Pandas4Warning

# The engine calls Timestamp.utcnow(), deprecated in pandas 3:
# one warning per run, outside our control (lesson 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), "daily bars;", len(barras_ap),
      "opening bars")
OUT [11]Daily and opening bars of KAPA for Nautilus
1003 daily bars; 1003 opening bars

The translation of SmaCross. With barra_apertura empty it is the literal translation: it decides and sends in on_bar of the daily bar. With it, it decides on the daily bar and sends the order when it receives the opening bar of the next session:

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 would buy 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          # sign of fast - slow
        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 or None, like 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       # at the open
            elif lado is not None:
                self.enviar(lado)           # at the close
        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), "orders; imitated:", len(imitada))
imitada.head(3)
OUT [12]SmaCross ported to NautilusTrader
literal: 33 orders; imitated: 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

The reference, with pandas and the same rule as the reconciliation in lesson 2.4.4: crossovers of the 10- and 30-session averages, buy on an upward cross if out, sell on a downward cross if in, and the fill price according to the convention:

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 vs close", literal, referencia(True)),
        ("literal vs next open", literal,
         referencia(False)),
        ("imitated vs next open", imitada,
         referencia(False))]:
    f, p, n1, n2 = coinciden(nau, ref)
    print(f"{nombre:<31} {n1} and {n2} orders; date and side {f},"
          f" price too {p}")
OUT [13]Order-by-order reconciliation
literal vs close                33 and 33 orders; date and side 33, price too 33
literal vs next open            33 and 33 orders; date and side 0, price too 0
imitated vs next open           33 and 33 orders; date and side 33, price too 33

The literal translation matches order by order, in date, side and price, the reference at the close: all 33. Against the reference at the next open, backtrader's convention, none match (all carry the previous date). The version with the opening bar reproduces backtrader's in all 33 orders, in date and in price; its first buy is the one on March 11, 2022 at 250.74, the same one the adapter in lesson 2.4.4 gave.

Both translations are correct NautilusTrader code and run without a single warning. Only the reconciliation says which one reproduces what was there: it is the pandas differential test applied to a library migration. Which of the two conventions is the right one is not decided by the code, but by the market: filling at the close of the signal bar is only honest if the signal can be computed and the order sent before the close (lesson 1.1.4); otherwise, the next open is the prudent convention, and with large overnight gaps the difference can be the whole result. Two more details from the table of equivalences that an automatic translation skips: the size (backtrader buys 1 unit unless told otherwise; here, 100 shares set explicitly) and the commissions (none in either, so that they can be compared).

What can be automated and what cannot

StepAutomatedFor a person
Known look-ahead patternsStatic reviewer on every changeDecide whether each warning is a bug or a false positive
Use of the future in any formTruncation with several cutoffsChoose cutoffs that cross the events that matter (delistings, rebalances)
One-day leaks and execution on the barPlacebo with signal-free pricesSet the threshold and repeat with other seeds if it is close
Version migrationPattern scanner and differential testExplain every remaining difference
Library migrationOrder-by-order reconciliation with a referenceDecide which semantics is correct for the asset

Nothing in the table replaces thinking about the economic mechanism of the strategy (lesson 2.5.1), but everything in the “Automated” column can run in a repository's continuous integration, like the tests in lesson 6.5.1, and turns reviewing an assistant's proposal into reading a ten-line report. Lesson 6.5.3 covers the risks that none of these tests see: dependencies that do not exist, secrets in the code and code that looks correct and is not.

Exercises

  1. Add to the static reviewer a rule for the bug in B6: a groupby(...).transform with "last" or "max" on a period grouper. Does it now detect B6 without raising warnings on its fixed version or on the others?
Solution
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}') spreads a later "
                          "value")
    return avisos

for nombre in BANCO:
    mal, bien = codigos(nombre)
    print(nombre, len(revision_ampliada(mal)),
          len(revision_ampliada(bien)))
OUT [14]A rule for transform('last')
B1 1 0
B2 2 0
B3 1 0
B4 1 0
B5 1 0
B6 1 0

With the new rule, B6 gives one warning with the bug and none fixed, and the rest of the bank does not change: each version with a bug keeps its warnings (B2 with two) and no fixed one raises a false alarm. The rule only looks at "last", "max" and "min", which are the aggregates that look at the end of the group; "first", which B6 also uses, is harmless if the group starts before the decision. It is one more rule on a list that is never complete: the dynamic layer is still necessary.

  1. The placebo threshold (a Sharpe of 1) was set with a single seed. Repeat the placebo with five seeds for the fixed versions and for B2 and B5 with the bug. What is the largest |Sharpe| of a fixed version? Does the placebo detect B2 or B5 with any seed?
Solution
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} fixed 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" · with bug max |S| {max(map(abs, s_mal)):.2f}"
    print(linea)
OUT [15]The placebo with five seeds
B1 fixed max |S| 0.21
B2 fixed max |S| 0.45 · with bug max |S| 0.65
B3 fixed max |S| 0.45
B4 fixed max |S| 0.25
B5 fixed max |S| 0.31 · with bug max |S| 0.33
B6 fixed max |S| 0.40

With five seeds, the largest |Sharpe| of a fixed version is 0.45 (B2 and B3), far from the threshold of 1. And the placebo does not detect B2 or B5 with any seed (maximums of 0.65 and 0.33): as seen in the matrix, those two bugs do not depend on knowing future returns, and with signal-free returns they give no edge. That is why the placebo does not replace truncation. If a fixed version came close to the threshold, the right thing would be to repeat with more seeds and set the threshold from the distribution of those versions, not to raise it by eye.

  1. Read the table of the test with nine constructs. Which of the changes give no warning in pandas 2.3? What test would you write for each one?
Solution

Three changes give no warning in pandas 2.3 or in pandas 3: dtype == object (True becomes False), astype(str) with missing values ('None' becomes nan) and the conversion of dates to int64 (from nanoseconds to microseconds). The chained assignment does warn in both, but in pandas 3 it no longer does anything. One test for each:

  • Text columns: check with pd.api.types.is_string_dtype(columna), which the migration guide recommends because it works in both versions, and a test that verifies the column filter returns the expected ones.
  • Missing values: a test with a None in a text column that checks the result with pd.isna, never comparing with None or with the string 'None'.
  • Dates to integers: do not convert with astype("int64"); if needed, set the unit (.as_unit("ns")) and a test that checks a known value, for example that midnight on January 2, 2024 is 1,704,153,600 seconds since 1970.
  • Chained assignment: a test that checks that the P&L with costs is lower than without costs in a case with changes of position; the lesson's differential test would have found it anyway.

Each answer is folded under its question

Self-assessment

  1. A backtest returns the daily P&L and passes the truncation test: what it computes up to T does not change when the data after T are removed. What bug can it still have?

    • ANone: truncation detects any use of the future
    • BA survivor universe
    • CA full-sample normalization
    • DUsing the information of session t to trade in session t itself (execution on the signal bar, or a shift(-1) that moves a day forward): truncation keeps session T, so that leak is not visible
    Show answer

    Answer D. Using the information of session t to trade in session t itself (execution on the signal bar, or a shift(-1) that moves a day forward): truncation keeps session T, so that leak is not visible

    When cutting at T, the data of T are still there. A bug that uses the close of t for the P&L of t passes truncation. In the lesson, B1 (shift(-1)) and B4 (weights not lagged) pass it and are caught by the placebo with signal-free prices, where their Sharpe ratios rise to 34 and 70.

  2. Why does the static reviewer not detect the bug in B6 (the month's return with groupby(...).transform("last"))?

    • ABecause the code has none of the patterns on the list: grouping by month and spreading the last value is normal pandas usage; only running it with the future cut off reveals that it uses later data
    • BBecause B6 has no bug
    • CBecause the reviewer does not analyze functions with groupby
    • DBecause the bug is in the data, not in the code
    Show answer

    Answer A. Because the code has none of the patterns on the list: grouping by month and spreading the last value is normal pandas usage; only running it with the future cut off reveals that it uses later data

    Static rules recognize specific forms (shift(-k), bfill, .mean() without a window). There are infinitely many ways of looking at the future that have none of them. That is why the two layers complement each other: the static one is fast and says where the problem is; the dynamic one does not depend on the form.

  3. In the differential test, the old script with only the errors fixed gives a higher final capital in pandas 3 than in pandas 2.3. What happened?

    • Apandas 3 computes returns better
    • BThe chained assignment that subtracted the costs no longer does anything under copy-on-write: the script does not fail, it only emits a ChainedAssignmentError, and the backtest comes out without costs
    • CResampling with ME changes the dates
    • DIt is a rounding error
    Show answer

    Answer B. The chained assignment that subtracted the costs no longer does anything under copy-on-write: the script does not fail, it only emits a ChainedAssignmentError, and the backtest comes out without costs

    res["pnl"][cambios] -= coste modified the original DataFrame in pandas 2. With copy-on-write it modifies a copy that is discarded. It is the most dangerous silent change of the migration, and pandas 2.3 already warned about it; the correct form is res.loc[cambios, "pnl"] -= coste.

  4. You port backtrader's SmaCross to NautilusTrader by sending the market order in on_bar. The 33 orders match backtrader's in date, but not in price. Why?

    • ABecause NautilusTrader charges commissions by default
    • BBecause of the rounding of Price to two decimals
    • CBecause backtrader fills market orders at the open of the next bar and NautilusTrader, without a latency model, at the close of the bar in which they are sent; its documentation says it has no native mode for execution at the next open
    • DBecause the moving averages of the two libraries are different
    Show answer

    Answer C. Because backtrader fills market orders at the open of the next bar and NautilusTrader, without a latency model, at the close of the bar in which they are sent; its documentation says it has no native mode for execution at the next open

    The literal translation reproduces the coc mode (fill at the close of the signal bar). To imitate the next open, the lesson adds an opening bar and sends the order when it arrives: that way the 33 orders match in date and price.

  5. What is needed to accept a migration of a backtest?

    • AThat the new code runs without errors or warnings
    • BThat the assistant says it has reviewed the changes
    • CThat the unit tests pass with the new version
    • DA differential test: the same backtest with the old version and with the new one gives the same trades and the same result, or the differences are explained one by one
    Show answer

    Answer D. A differential test: the same backtest with the old version and with the new one gives the same trades and the same result, or the differences are explained one by one

    The most dangerous behavior changes raise no errors: the chained assignment that does not assign, the resolution of dates, the type of strings. They only show up when results are compared. Unit tests help, but they only cover what someone thought of testing.

Key points

  • A backtest reviewer has two layers: the static one reads the syntax tree and flags known patterns with their line; the dynamic one runs the backtest with the future cut off and with signal-free prices, without depending on how it is written.
  • Against six backtests with planted bugs, the static layer caught five, truncation four and the placebo four; together, all six. None raised a warning on the six fixed versions.
  • Truncation does not see one-day leaks (B1 and B4): the placebo catches them, where those backtests give Sharpe ratios of 34 and 70 on prices that have nothing to predict.
  • pandas 3.0 brings copy-on-write, the str type by default, dates with inferred resolution and the removal of many deprecated APIs. What fails with an error is easy; the danger is in what is silent: chained assignment, dtype == object, astype(str) and the conversion of dates to integers.
  • The differential test runs the same script with pandas 2.3 and with pandas 3 and compares the outputs: it detects the migration that fixes the errors but leaves the backtest without costs.
  • backtrader fills at the next open and NautilusTrader at the close of the order's bar: a literal translation changes every fill price. A migration is accepted when it reconciles with the reference, order by order.

Resources

Data