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.
- Module
- 6.5 · AI as the quant's copilot
- Duration
- 75 min
- Prerequisites
- 2.5.1 · Backtest biases: look-ahead, survivorship, selection and narrative, 2.4.4 · backtesting.py and backtrader: two teaching engines and their status, 2.4.5 · NautilusTrader: architecture, data, strategies and the same code in backtest and live, 6.5.1 · Coding assistants with a test-first workflow
- Author
- Jesús Cuesta
- Updated
Level 6 lessons
6.1 · Classical and modern financial NLP
6.2 · LLMs for research and trading
6.3 · Financial RAG
6.4 · AI agents for trading
- 6.4.1 Tool-calling agents: tool calling, ReAct and planning
- 6.4.2 Agent frameworks and MCP
- 6.4.3 TradingAgents-style multi-agent architectures
- 6.4.4 Evidence and limits of agents
- 6.4.5 Guardrails for agents: the agent proposes, the engine decides
- 6.4.6 Research agents: hypotheses, alpha mining and review
6.5 · AI as the quant's copilot
6.6 · Big data for trading
- 6.6.1 When data becomes “big”: ticks, order books, options and universes
- 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
- 6.6.3 Dask, Spark and Ray: from one machine to a cluster
- 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
- 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
6.7 · Alternative data
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:
| Layer | What it does | Advantage | Limit |
|---|---|---|---|
| Static | Reads the code without running it and looks for known patterns | Fast, gives the exact line, needs no data | Only sees the forms someone wrote into a rule |
| Dynamic | Runs the backtest with modified data and compares | Does not depend on how the code is written | Needs data and time; says there is a problem, not where |
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.
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()))(5031, 120) · sessions 2006-01-03 to 2025-12-31
stop trading before the end: 10All 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.
| Backtest | Strategy | Planted bug |
|---|---|---|
| B1 | 20-session momentum, long-short | shift(-1) on the signal: aligns it with the label instead of with the decision |
| B2 | Long the stocks with a momentum z above 1 | z with the mean and standard deviation of the full sample |
| B3 | 12-month momentum, top quintile | Monthly signal turned daily with bfill() instead of ffill() |
| B4 | 5-session trend, long-short | Weights not lagged: trades on the close that generates the signal |
| B5 | Equal-weighted | Universe = the stocks trading on the last day (survivors) |
| B6 | Month's return, long-short | Return of the full month spread over all its days |
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])
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:
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}")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.78The 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:
| Rule | Pattern | Why it is suspicious |
|---|---|---|
| Negative shift | shift(-k) | Brings a value from t + k to day t |
| Centered window | rolling(..., center=True) | The window includes future sessions |
| Backward fill | bfill(), backfill(), fillna(method="bfill") | Copies a later value backwards |
| Full sample | .mean(), .std(), .min(), .max(), .quantile() with no window and no axis=1 | The statistic uses every date |
| Same bar | * R with no shift on the other factor | Trades on the close that generates the signal |
| Survivors | .iloc[-1] or dropna(axis=1) | Chooses the universe with the last data point |
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 '-'}")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:
informe = """
def resumen(curva):
final = curva.iloc[-1]
return {"final capital": final, "mean": curva.mean()}
"""
revision_estatica(informe)['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 . A Sharpe above 1 on the placebo is not a finding, it is a bug.
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}")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.51The 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 ( 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:
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 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 TrueThe 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:
| Change | What happened in pandas 2 | What happens in pandas 3 |
|---|---|---|
| Copy-on-write | A selection could be a view: modifying it modified the original | Every selection behaves as a copy; chained assignment does nothing and emits ChainedAssignmentError |
| String type | Strings were object | str type by default (with PyArrow if installed); the missing value is always NaN |
| Date resolution | Always nanoseconds | Inferred: when parsing text, microseconds |
| Frequency aliases | M, Q, Y, H, T | ME, QE, YE, h, min (the old ones raise an error) |
| Removed APIs | fillna(method=…), applymap, first/last by date, swapaxes… | Removed: use ffill/bfill, map, loc |
| Integer keys | s[0] on a series with dates was positional (with a warning) | An integer key is always a label: KeyError |
| Time zones | pytz objects | The 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:
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])}")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.6Five 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:
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}")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 .ilocFour 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.
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))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.112132The table tells the whole story:
- The old script works in pandas 2.3 (with
FutureWarning): final capital 2.112132. - In pandas 3 it stops with
TypeError. - 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
ChainedAssignmentErroron 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”. - 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):
| backtrader | NautilusTrader | Difference that matters |
|---|---|---|
Cerebro | BacktestEngine (or BacktestNode with configuration and catalog) | The engine is assembled with venue, instrument, data and strategy |
bt.feeds.PandasData | Bar + BarType + BarDataWrangler | The 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_start | The 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.position | self.portfolio.is_net_long(instrument_id) | The position is queried from the Portfolio |
notify_order / notify_trade | on_order_filled, on_order_event / on_position_closed | Typed events |
broker.setcash, setcommission | add_venue(starting_balances=…), fee_model | Commissions through a model (FixedFeeModel, MakerTakerFeeModel…) |
| Market order: open of the next bar | Without latency: close of the bar in which it is sent | No 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”.
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:
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")1003 daily bars; 1003 opening barsThe 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:
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)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.52The 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:
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}")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 33The 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
| Step | Automated | For a person |
|---|---|---|
| Known look-ahead patterns | Static reviewer on every change | Decide whether each warning is a bug or a false positive |
| Use of the future in any form | Truncation with several cutoffs | Choose cutoffs that cross the events that matter (delistings, rebalances) |
| One-day leaks and execution on the bar | Placebo with signal-free prices | Set the threshold and repeat with other seeds if it is close |
| Version migration | Pattern scanner and differential test | Explain every remaining difference |
| Library migration | Order-by-order reconciliation with a reference | Decide 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
- Add to the static reviewer a rule for the bug in B6: a
groupby(...).transformwith"last"or"max"on a period grouper. Does it now detect B6 without raising warnings on its fixed version or on the others?
Solution
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)))B1 1 0
B2 2 0
B3 1 0
B4 1 0
B5 1 0
B6 1 0With 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.
- 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
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)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.40With 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.
- 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
Nonein a text column that checks the result withpd.isna, never comparing withNoneor 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
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 visibleWhen 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.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.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
MEchanges 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 costsres["pnl"][cambios] -= costemodified 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 isres.loc[cambios, "pnl"] -= coste.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
Priceto 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
cocmode (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.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
strtype 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.