Lesson 6.5.1 · 65 min
Coding assistants with a test-first workflow
A coding assistant writes a position sizing function that looks correct in seconds. This lesson first writes the tests that define it, watches them fail, hands the contract to the assistant, reads the failures of its first version (a rounding that overshoots the lot, a division by zero and a fill with data from the future) and iterates until green. Then it tests the tests themselves: whether the assistant touches them and whether they catch the errors that have not been made yet.
- Module
- 6.5 · AI as the quant's copilot
- Duration
- 65 min
- Prerequisites
- 0.2.6 · Introduction to AI coding assistants: prompts, review and tests, 0.4.5 · Professional logging and testing with pytest, 4.2.2 · Kelly criterion and volatility targeting: how much to risk
- 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
Four assistants, according to their documentation
Lesson 0.2.6 introduced coding assistants and their main risk in trading: plausible code that uses data from the future without it showing. Six levels later, the use is different: the assistant no longer completes a line, it reads the repository, edits several files, runs the tests and opens a pull request. This lesson proposes a workflow for that use, with tests first, and runs it end to end.
First, what the four most cited assistants are, according to their own documentation (consulted on October 23, 2026; the products change every month, so the documentation takes precedence over this table). There is no quality comparison here: the capabilities overlap and the choice depends on the editor, the model provider and the company's policies.
| Assistant | What its documentation says | Where it works | Project instructions |
|---|---|---|---|
| GitHub Copilot | Code suggestions in the editor, a chat that also generates unit tests, an agent mode that works autonomously in the editor, a cloud agent and pull request review | Supported editors and GitHub | .github/copilot-instructions.md, .github/instructions/*.instructions.md and AGENTS.md files |
| Claude Code | Agentic coding tool that reads the codebase, edits files, runs commands and integrates with development tools | Terminal, IDE, desktop app and browser | CLAUDE.md (it can read an AGENTS.md instead) |
| Codex (OpenAI) | Codex CLI is “a coding agent from OpenAI that runs locally” (README of github.com/openai/codex, Apache-2.0 license); there is also an IDE extension and Codex Cloud | Terminal, IDE, cloud and ChatGPT | AGENTS.md, global (~/.codex) and per directory |
| Cursor | “A coding agent” to understand the codebase, plan and build, fix bugs and review changes | Its editor and other integrations | Rules in .cursor/rules (.mdc files) and AGENTS.md |
AGENTS.md presents itself as “a simple, open format for guiding coding agents”, and its website lists Codex, Cursor
and GitHub Copilot, among others, as supporting it. In practice, a trading repository with a clear instructions file
(how the tests are run, what must not be touched, the look-ahead rules) serves any of the four.
Two ideas from the Claude Code documentation sum up this lesson's workflow. The first: “give Claude something it can run that comes back pass or fail” (a test file, a build…), because without it “looks done” is all it has to go on and the person becomes the verification loop. The second: the tests and the code can be written by different agents, so that whoever does the work is not whoever grades it. In trading the second is almost mandatory: the error that matters (look-ahead) is precisely the one a test written while looking at the code does not see.
The workflow: tests first
Mermaid source
flowchart TB
E[Specification] --> T[Tests]
T --> R[Red: they fail]
R --> P[Prompt and contract]
P --> C[Proposed code]
C --> X{pytest}
X -->|fails| L[Read failures]
L --> C
X -->|green| V[Diff and tests]
V --> M[Commit]It is Kent Beck's cycle (Test-Driven Development: By Example, 2002) with an assistant in the code step: red (the tests fail because the code does not exist), green (the minimum code that passes them) and refactor (improving the code with the tests green):
Mermaid source
stateDiagram-v2 [*] --> RED: new test RED --> GREEN: minimum code GREEN --> REFACTOR: tests green REFACTOR --> GREEN: still green GREEN --> RED: next case
What changes with an assistant is who writes each part:
| Step | Who | Why |
|---|---|---|
| Specification and tests | You (you can ask the assistant for case ideas, but you decide them) | They are the contract |
| Implementation | The assistant | It is the mechanical part |
| Reading failures and iterating | The assistant, with the pytest output | Closed loop |
| Review of the diff, the tests and the commit | You | Nobody else answers for the code |
The specification
The function is the volatility-target position sizing of lesson 4.2.2: how many shares to buy so that a position contributes a target annual volatility to the portfolio. With capital , price , estimated annual volatility and target :
with a weight cap per position. And the volatility, a rolling standard deviation of the simple returns of the last 20 sessions, annualized with and known at each day's close. Written as a contract:
tamano_posicion(capital, precio, vol_anual, objetivo=0.10, peso_max=0.20, lote=1) -> int: shares, rounding down to the lot; 0 if the volatility is not a positive finite number (no data, no position);ValueErrorif the capital or the price are not positive or the lot is less than 1.volatilidad_movil(cierre, ventana=20) -> pd.Series:NaNuntil there areventanareturns; each day's value only depends on the closes up to that day.
The tests, before the code
The tests are written from the specification, with hand-computed figures in the comments. They cover the normal case,
the edge cases (rounding, cap, zero or missing volatility), the invalid parameters and look-ahead. The cell writes the
file test_riesgo.py in the working folder, as you would save it in tests/:
from pathlib import Path
TESTS = '''
import math
import numpy as np
import pandas as pd
import pytest
from riesgo import tamano_posicion, volatilidad_movil
def test_caso_a_mano():
# 100,000 x 10% / 80% = 12,500 USD; at 50 USD, 250 shares
assert tamano_posicion(100_000, 50.0, 0.80) == 250
def test_redondea_hacia_abajo_al_lote():
# 12,500 / 70 = 178.6 shares: in lots of 10, 170 (not 180)
assert tamano_posicion(100_000, 70.0, 0.80, lote=10) == 170
def test_respeta_el_peso_maximo():
# vol 2%: 500,000 USD uncapped; with 20%, 20,000 = 400 shares
assert tamano_posicion(100_000, 50.0, 0.02) == 400
@pytest.mark.parametrize("vol", [0.0, -0.1, math.nan, math.inf])
def test_sin_volatilidad_valida_no_hay_posicion(vol):
assert tamano_posicion(100_000, 50.0, vol) == 0
@pytest.mark.parametrize("capital, precio, lote",
[(0, 50.0, 1), (100_000, 0.0, 1),
(100_000, 50.0, 0)])
def test_parametros_invalidos(capital, precio, lote):
with pytest.raises(ValueError):
tamano_posicion(capital, precio, 0.2, lote=lote)
def test_devuelve_un_entero():
assert type(tamano_posicion(100_000, 50.0, 0.80)) is int
@pytest.fixture
def cierre():
return pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")["cierre"]
def test_volatilidad_a_mano():
s = pd.Series([100.0, 101.0, 99.0, 102.0, 100.0])
r = s.pct_change().iloc[1:]
esperado = r.std(ddof=1) * math.sqrt(252)
v = volatilidad_movil(s, ventana=4)
assert v.iloc[-1] == pytest.approx(esperado)
def test_nan_hasta_completar_la_ventana(cierre):
v = volatilidad_movil(cierre, 20)
assert v.iloc[:20].isna().all() and v.iloc[20:].notna().all()
@pytest.mark.parametrize("corte", [10, 20, 600])
def test_sin_anticipacion(cierre, corte):
completa = volatilidad_movil(cierre, 20)
parcial = volatilidad_movil(cierre.iloc[:corte], 20)
pd.testing.assert_series_equal(parcial, completa.iloc[:corte])
'''
Path("test_riesgo.py").write_text(TESTS, encoding="utf-8")
print(sum(l.startswith("def test_") for l in TESTS.splitlines()),
"test functions")9 test functionsNine functions, which with the parametrize make 16 cases. Some details that are not a matter of style:
- The rounding case uses 70 USD and lots of 10. At 50 USD the notional gives exactly 250 shares and any rounding
passes. A rounding test has to land on a case where rounding to the nearest and rounding down give different
results: 178.6 shares are 17.86 lots, which
roundturns into 18 and rounding down into 17. math.nanandmath.infin the volatility: they are what really arrives in the first days or after a broken data point.- Three cutoffs in the look-ahead test, one before the window is complete. You will see why.
type(...) is int: a share quantity of typenumpy.float64ends up in an order with decimals.
The first step of the cycle is to watch them fail. An empty riesgo.py module, with the signatures and
NotImplementedError:
import hashlib
import os
import re
import subprocess
import sys
STUB = '''
def tamano_posicion(capital, precio, vol_anual, objetivo=0.10,
peso_max=0.20, lote=1):
raise NotImplementedError
def volatilidad_movil(cierre, ventana=20):
raise NotImplementedError
'''
Path("riesgo.py").write_text(STUB, encoding="utf-8")
def pytest_q(*extra):
"""pytest in a separate process; without the duration, which varies."""
entorno = dict(os.environ, PYTEST_DISABLE_PLUGIN_AUTOLOAD="1")
r = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "--no-header",
"-p", "no:cacheprovider", "--tb=no", "-rfEs", *extra,
"test_riesgo.py"],
capture_output=True, text=True, env=entorno)
salida = re.sub(r" in [0-9.]+s", "", r.stdout).strip()
return salida.splitlines()[-1], salida
huella_tests = hashlib.sha256(
Path("test_riesgo.py").read_bytes()).hexdigest()
resumen, _ = pytest_q()
print(resumen)16 failedAll 16 cases fail, as they should: a test that passes before the code exists checks nothing. pytest runs in another
process, as the assistant would launch it from the terminal, without loading installed plugins
(PYTEST_DISABLE_PLUGIN_AUTOLOAD), and the cell removes from the summary the duration (“in 0.31s”), which changes on
every run. The cell also saves the fingerprint of the test file: it will be needed later.
The prompt and the first proposal
With the tests red, the prompt to the assistant is short, because the contract is in the tests:
Implement
tamano_posicionandvolatilidad_movilinriesgo.pyso that the tests intest_riesgo.pypass. Read the tests before starting. Do not modify the tests or addskip. Use only numpy, pandas and the standard library. Runpytest -q test_riesgo.pyand paste the full output. If a test seems wrong to you, say so and explain why, without changing it.
The first version below brings together typical errors of generated code in this kind of function (it is an example
built for the lesson, not the output of a specific product): an innocent-looking round, no validation and a fill of
the first days so that the series “has no gaps”.
V1 = '''
import numpy as np
def tamano_posicion(capital, precio, vol_anual, objetivo=0.10,
peso_max=0.20, lote=1):
"""Shares to contribute `objetivo` of annual volatility."""
nocional = capital * objetivo / vol_anual
nocional = min(nocional, peso_max * capital)
return round(nocional / precio / lote) * lote
def volatilidad_movil(cierre, ventana=20):
"""Annualized volatility; fills the first days."""
r = cierre.pct_change()
vol = r.rolling(ventana).std() * np.sqrt(252)
return vol.bfill()
'''
Path("riesgo.py").write_text(V1, encoding="utf-8")
resumen, salida = pytest_q()
print(salida).F.FFF.FFF..FFF. [100%]
===================================== short test summary info ======================================
FAILED test_riesgo.py::test_redondea_hacia_abajo_al_lote - assert 180 == 170
FAILED test_riesgo.py::test_sin_volatilidad_valida_no_hay_posicion[0.0] - ZeroDivisionError: divi...
FAILED test_riesgo.py::test_sin_volatilidad_valida_no_hay_posicion[-0.1] - assert -2000 == 0
FAILED test_riesgo.py::test_sin_volatilidad_valida_no_hay_posicion[nan] - ValueError: cannot conv...
FAILED test_riesgo.py::test_parametros_invalidos[0-50.0-1] - Failed: DID NOT RAISE ValueError
FAILED test_riesgo.py::test_parametros_invalidos[100000-0.0-1] - ZeroDivisionError: division by zero
FAILED test_riesgo.py::test_parametros_invalidos[100000-50.0-0] - ZeroDivisionError: division by ...
FAILED test_riesgo.py::test_nan_hasta_completar_la_ventana - assert (np.False_)
FAILED test_riesgo.py::test_sin_anticipacion[10] - AssertionError: Series are different
FAILED test_riesgo.py::test_sin_anticipacion[20] - AssertionError: Series are different
10 failed, 6 passedReading the failures
The pytest output is half the value of the workflow: each FAILED line says which part of the contract is not met,
and it is exactly what the assistant needs to iterate (in an agent, it reads it itself).
Version 1 passes 6 of the 16 cases and fails 10, which group into three errors:
| Error | Failing cases | What pytest says |
|---|---|---|
round instead of rounding down | 1 | assert 180 == 170: 18 lots, one too many |
| No input validation | 6 | ZeroDivisionError, a quantity of −2,000 shares with negative volatility, a ValueError when rounding a NaN and a capital of 0 that raises nothing |
bfill in the first days | 3 | The series has values where there should be NaN, and the cutoffs at 10 and 20 sessions do not match the full series |
Three observations. The hand-computed case passes: 250 shares is an exact result, and that is why the 70 USD case
was needed. The bfill passes the look-ahead test with the cutoff at session 600 and only fails it with the
cutoffs at 10 and 20, before the window is complete: the value copied backwards is the first one that can be computed
(that of session 21), and if the cutoff is later it is the same in both series. With a single distant cutoff, like the
one anyone would write, the look-ahead would not have been seen. And the NaN test catches it by another route: the
specification says the first days have no data, and that is true.
Backward filling is not a detail: in a backtest, those first days would have a position computed with a volatility
that was not yet known. It is the same error as the center=True of lesson 0.2.6, with another face.
Green
The assistant receives the pytest output and proposes version 2:
V2 = '''
import math
import numpy as np
def tamano_posicion(capital, precio, vol_anual, objetivo=0.10,
peso_max=0.20, lote=1):
"""Shares to contribute `objetivo` of annual volatility."""
if capital <= 0 or precio <= 0 or lote < 1:
raise ValueError("capital, precio and lote must be > 0")
if not math.isfinite(vol_anual) or vol_anual <= 0:
return 0 # no data, no position
nocional = min(capital * objetivo / vol_anual, peso_max * capital)
return int(nocional // (precio * lote)) * lote
def volatilidad_movil(cierre, ventana=20):
"""Annualized volatility known at each day's close."""
r = cierre.pct_change()
return r.rolling(ventana).std() * np.sqrt(252)
'''
Path("riesgo.py").write_text(V2, encoding="utf-8")
print(pytest_q()[0])16 passedAll 16 cases pass. The header figure summarizes the whole cycle, from red to the 18 cases in green with the two tests added further below. Green does not mean finished: it remains to check that the tests are still the ones you wrote and that they are good enough for green to mean something.
The tests are yours
An assistant asked to “make the tests green” has at hand a solution that is not the one being sought: changing the
tests. Sometimes it does so explicitly (it changes a 170 into a 180 because “the test seems wrong”), and sometimes
discreetly (a skip, an xfail, an approx with a huge tolerance). The cell simulates the second case on version 1:
tocado = TESTS.replace(
"def test_redondea_hacia_abajo_al_lote",
"@pytest.mark.skip(reason='dubious rounding')\n"
"def test_redondea_hacia_abajo_al_lote")
Path("test_riesgo.py").write_text(tocado, encoding="utf-8")
Path("riesgo.py").write_text(V1, encoding="utf-8")
print(pytest_q("-k", "redondea")[0])
actual = hashlib.sha256(Path("test_riesgo.py").read_bytes()).hexdigest()
print("tests intact:", actual == huella_tests)
Path("test_riesgo.py").write_text(TESTS, encoding="utf-8")
Path("riesgo.py").write_text(V2, encoding="utf-8");1 skipped, 15 deselected
tests intact: FalseThe summary no longer says “failed”, it says “skipped”, and the fingerprint of the file does not match the one saved when it was written. In a repository, the equivalent is to review the diff of the tests folder in every pull request from the assistant (or protect it with a mandatory review rule), and to read the “skipped” and “xfailed” in the summary for what they are: tests that have not been passed.
Testing the tests: mutants and properties
Tests of specific cases check what occurred to you. Two techniques check whether that is enough.
Mutants. A small error is deliberately introduced into the code that passes the tests and it is checked that some test fails. If none fails, the mutant “survives”: there is a possible error the tests would not see. Tools such as mutmut automate it; the idea fits in a loop:
MUTANTES = {
"round instead of floor": (
"int(nocional // (precio * lote))",
"round(nocional / (precio * lote))"),
"annualizes with 365": ("np.sqrt(252)", "np.sqrt(365)"),
"bfill comes back": (
"return r.rolling(ventana).std() * np.sqrt(252)",
"return (r.rolling(ventana).std() * np.sqrt(252)).bfill()"),
"accepts zero vol": ("vol_anual <= 0", "vol_anual < 0"),
"ignores peso_max": ("peso_max * capital", "0.20 * capital"),
}
Path("test_riesgo.py").write_text(TESTS, encoding="utf-8")
resultado = {}
for nombre, (antes, despues) in MUTANTES.items():
codigo = V2.replace(antes, despues)
assert codigo != V2
Path("riesgo.py").write_text(codigo, encoding="utf-8")
resumen, _ = pytest_q()
resultado[nombre] = "killed" if "failed" in resumen else "ALIVE"
print(f"{nombre:<28} {resultado[nombre]:<7} {resumen}")
Path("riesgo.py").write_text(V2, encoding="utf-8");round instead of floor killed 1 failed, 15 passed
annualizes with 365 killed 1 failed, 15 passed
bfill comes back killed 3 failed, 13 passed
accepts zero vol killed 1 failed, 15 passed
ignores peso_max ALIVE 16 passedFour mutants are killed: each one makes at least one test fail (the bfill one, three). The fifth, which ignores the
peso_max parameter and always uses 0.20, survives: it passes the 16 cases, because no test uses a peso_max
other than the default. Any implementation that ignores the parameter, proposed by an assistant or written by hand,
would pass. A test is missing, and it is added:
NUEVO = '''
def test_peso_max_es_un_parametro():
# vol 2% and peso_max 5%: 5,000 USD at 50 USD, 100 shares
assert tamano_posicion(100_000, 50.0, 0.02, peso_max=0.05) == 100
'''
Path("test_riesgo.py").write_text(TESTS + NUEVO, encoding="utf-8")
antes, despues = MUTANTES["ignores peso_max"]
Path("riesgo.py").write_text(V2.replace(antes, despues),
encoding="utf-8")
print("mutant: ", pytest_q()[0])
Path("riesgo.py").write_text(V2, encoding="utf-8")
print("version 2:", pytest_q()[0])mutant: 1 failed, 16 passed
version 2: 17 passedProperties. Instead of a specific result, a rule that has to hold for any valid input: the notional never exceeds
the weight cap, the quantity is a multiple of the lot, it is never negative and it does not rise when volatility rises.
They are tested with many random inputs with a seed (the Hypothesis library automates it; it is not in the course
environment, and a loop with numpy is enough for the idea):
PROPIEDADES = '''
def test_propiedades_del_dimensionamiento():
rng = np.random.default_rng(651)
for _ in range(2_000):
capital = float(rng.uniform(1e3, 1e7))
precio = float(rng.uniform(1, 2_000))
vol = float(rng.uniform(0.01, 1.5))
lote = int(rng.choice([1, 10, 100]))
peso = float(rng.uniform(0.01, 0.5))
n = tamano_posicion(capital, precio, vol, peso_max=peso,
lote=lote)
assert n >= 0 and n % lote == 0
assert n * precio <= peso * capital + 1e-6
assert tamano_posicion(capital, precio, vol * 1.5,
peso_max=peso, lote=lote) <= n
'''
Path("test_riesgo.py").write_text(TESTS + NUEVO + PROPIEDADES,
encoding="utf-8")
print("version 2:", pytest_q()[0])
antes, despues = MUTANTES["ignores peso_max"]
Path("riesgo.py").write_text(V2.replace(antes, despues),
encoding="utf-8")
print("mutant: ", pytest_q()[0])
Path("riesgo.py").write_text(V2, encoding="utf-8");version 2: 18 passed
mutant: 2 failed, 16 passedThe 2,000 random combinations meet the four rules with version 2, and the weight cap property also kills the
peso_max mutant (now two tests fail: the new one and the properties one). A property covers at once many cases
nobody would write by hand; a specific case documents a figure that can be checked with a pencil. Both are needed.
Reviewing what the assistant delivers
With the tests green and tested, the reading of the full diff remains, as in any pull request (lesson 0.2.5). A checklist for trading code generated by an assistant:
| Check | What to look for |
|---|---|
| Tests intact | Diff of tests/ empty or justified; no new skip, xfail or tolerance |
| Look-ahead | center=True, shift(-k), bfill, normalizations with the whole sample, joins by date without time (lessons 0.2.6 and 6.4.6) |
| Rounding and units | Down for quantities; percentages versus fractions; annualization (252 sessions) |
| Missing data | What happens with NaN, inf, zero and empty series; no data, no position |
| Dependencies | No new package without checking on PyPI that it exists and is the one intended (lesson 6.5.3) |
| Secrets and side effects | No keys in the code and no network calls or writes beyond what was asked (lesson 4.4.5) |
| Scope | The diff only touches what was asked |
And a project instructions file (AGENTS.md, CLAUDE.md or whichever your assistant reads) that puts it in writing
for every session. An example for a research repository:
# Instructions for coding assistants
- Tests: `uv run pytest -q`. Before calling anything done, paste the output.
- The tests in `tests/` are written by a person: do not modify them or add
skip or xfail. If one seems wrong to you, say so and explain why.
- No signal may use data after the decision instant:
no center=True, negative shift, bfill or whole-sample statistics.
- Order quantities: integers, rounded down to the lot.
- Do not add dependencies without asking. Do not write keys or call the network.Exercises
- Write, before the code, the tests for a function
media_movil_exponencial(cierre, semivida)(exponential moving average with a half-life) without look-ahead: a hand-computed case with three values, the truncation test with several cutoffs and the behavior with aNaNin the middle of the series. Then implement the function and check that it passes.
Solution
EJ = '''
import math
import numpy as np
import pandas as pd
import pytest
def media_movil_exponencial(cierre, semivida):
return cierre.ewm(halflife=semivida, adjust=False).mean()
def test_a_mano():
s = pd.Series([10.0, 20.0, 30.0])
a = 1 - math.exp(math.log(0.5) / 1) # half-life 1: a = 0.5
esperado = [10.0, 15.0, 22.5]
assert a == pytest.approx(0.5)
assert list(media_movil_exponencial(s, 1)) == pytest.approx(
esperado)
@pytest.mark.parametrize("corte", [1, 5, 50])
def test_sin_anticipacion(corte):
s = pd.Series(np.random.default_rng(1).normal(100, 1, 80))
completa = media_movil_exponencial(s, 10)
parcial = media_movil_exponencial(s.iloc[:corte], 10)
pd.testing.assert_series_equal(parcial, completa.iloc[:corte])
def test_nan_en_medio_no_se_propaga():
s = pd.Series([10.0, np.nan, 30.0])
m = media_movil_exponencial(s, 1)
assert m.notna().all()
'''
Path("test_ema.py").write_text(EJ, encoding="utf-8")
r = subprocess.run([sys.executable, "-m", "pytest", "-q",
"--no-header", "-p", "no:cacheprovider",
"test_ema.py"], capture_output=True, text=True,
env=dict(os.environ,
PYTEST_DISABLE_PLUGIN_AUTOLOAD="1"))
print(re.sub(r" in [0-9.]+s", "", r.stdout).strip().splitlines()[-1])5 passedHere the function goes in the same file so that the exercise fits in one cell; in a project, it would go in its own
module. With adjust=False, a day's average is and only depends on the past: the truncation
confirms it at all three cutoffs. The NaN test documents a decision: with ignore_na=False (the default), pandas
still gives a value on the day of the gap with the previous average. If the decision were different, the test is the
place to write it down.
- Add a mutant that uses
ddof=0in the standard deviation ofvolatilidad_movil. Does it survive? Which test kills it?
Solution
Path("test_riesgo.py").write_text(TESTS + NUEVO + PROPIEDADES,
encoding="utf-8")
Path("riesgo.py").write_text(
V2.replace(".std()", ".std(ddof=0)"), encoding="utf-8")
resumen, salida = pytest_q()
for linea in salida.splitlines():
if linea.startswith("FAILED"):
print(linea[:90])
Path("riesgo.py").write_text(V2, encoding="utf-8");FAILED test_riesgo.py::test_volatilidad_a_mano - assert np.float64(0....4929406223153) ==It dies in the hand-computed test, and only there: with four returns, dividing by 4 instead of 3 gives a standard
deviation 13% smaller, which pytest.approx detects easily. The other tests would not see it: neither the look-ahead
test nor the NaN one depends on the scale. A single hand-computed case protects a whole decision.
- Write the prompt you would give an assistant to implement a liquidity filter (exclude the stocks whose 20-session average dollar volume is below a threshold) with the lesson's workflow.
Solution
First the tests, written by you and saved in tests/test_liquidez.py: a hand-computed case with three stocks and a
threshold; an exact tie at the threshold (is it in or not? decide and write it down); a stock with no volume in the
first days (no data, out); the truncation test with cutoffs before and after the window is complete; and a property
(raising the threshold never adds stocks). Then, the prompt:
Implement
filtro_liquidez(cierre, volumen, umbral_usd, ventana=20)inuniverso.pyso that the tests intests/test_liquidez.pypass. Return a boolean DataFrame with the same dates and tickers. Each day's value may only use data up to that close. Do not modify the tests or add skip or xfail; if one seems wrong to you, say so. No new dependencies. Runuv run pytest -q tests/test_liquidez.pyand paste the full output.
And on receiving it: check that the diff of tests/ is empty, read the code looking for center, negative shift,
bfill and whole-sample statistics, run the tests yourself and, if there is time, a couple of mutants.
Each answer is folded under its question
Self-assessment
Why are the tests written before asking the assistant for the code?
- ABecause pytest does not work with code that is already written
- BBecause that way the assistant writes faster
- CBecause the tests fix the contract independently of the implementation: if the same assistant writes them after the code, they tend to check what the code does, errors included
- DBecause the tests replace code review
Show answer
Answer C. Because the tests fix the contract independently of the implementation: if the same assistant writes them after the code, they tend to check what the code does, errors included
A test written while looking at the implementation tends to copy its behavior. Written beforehand, from the specification (hand calculations, edge cases, prohibitions), it is an independent check. The Claude Code documentation even suggests that one agent write the tests and another the code.
The assistant's first version uses
round()to go from notional to lots. Which test catches it?- AThe one that asks for 170 shares in lots of 10 at 70 USD: 12,500 / 70 = 178.6 shares, and
roundgoes up to 180, above the allowed notional - BThe hand-computed case of 250 shares
- CThe invalid parameters one
- DNone:
roundis correct
Show answer
Answer A. The one that asks for 170 shares in lots of 10 at 70 USD: 12,500 / 70 = 178.6 shares, and
roundgoes up to 180, above the allowed notionalWith lots of 10, 178.6 shares are 17.86 lots:
roundgives 18 lots (180 shares, 12,600 USD) and exceeds the target notional. Position sizing always rounds down; the hand-computed case of 250 does not detect it because it is exact.- AThe one that asks for 170 shares in lots of 10 at 70 USD: 12,500 / 70 = 178.6 shares, and
Version 1 fills the first days of the volatility with
bfill(). Why does the truncation test with a cutoff at session 600 not detect it?- ABecause
bfilldoes not use data from the future - BBecause the tests do not run that function
- CBecause truncation only works with intraday data
- DBecause with 600 sessions the value copied backwards (the first one that can be computed) is in both series: cutoffs before the window is complete are needed, like those at 10 and 20 sessions
Show answer
Answer D. Because with 600 sessions the value copied backwards (the first one that can be computed) is in both series: cutoffs before the window is complete are needed, like those at 10 and 20 sessions
Backward filling copies into the first days a value that is computed with later data. If the cutoff leaves that value inside, the two series match. That is why the lesson's test uses several cutoffs, including one before the window, and also requires NaN until it is complete.
- ABecause
After being asked to make the tests green, the assistant marks one with
@pytest.mark.skip. How is it detected without reading the whole diff?- AIt cannot be detected
- BBy comparing the fingerprint (sha256) of the test file with the one saved when writing it, and by looking at the “skipped” in the pytest summary
- CBy running pytest twice
- DBy asking the assistant whether it has changed anything
Show answer
Answer B. By comparing the fingerprint (sha256) of the test file with the one saved when writing it, and by looking at the “skipped” in the pytest summary
The tests are yours: any change to them is a decision you make. A saved fingerprint (or, in a repository, the diff of the tests folder in review) makes it visible instantly. The pytest summary also gives it away: a skipped test is not a passed test.
A mutant that ignores the
peso_maxparameter and always uses 0.20 survives the tests. What does it mean?- AThat the tests never use a
peso_maxother than the default: a test is missing, and it is added before calling the work done - BThat the mutant is correct
- CThat the parameter has to be removed
- DThat pytest has a bug
Show answer
Answer A. That the tests never use a
peso_maxother than the default: a test is missing, and it is added before calling the work doneA mutant that survives is an error the tests would not see. Here, any implementation that ignores
peso_maxwould pass. The new test withpeso_max=0.05kills it.- AThat the tests never use a
Key points
- Copilot, Claude Code, Codex and Cursor read project instructions from files in the repository (
.github/copilot-instructions.md,CLAUDE.md,AGENTS.md,.cursor/rules);AGENTS.mdis an open format recognized by several of them. - Tests first: the specification is written as tests (hand-computed cases, edge cases, invalid parameters, look-ahead) and they are seen failing before the code exists.
- The assistant's first version passed 6 of 16 cases:
roundinstead of rounding down, no input validation and abfillthat fills with future data. - The look-ahead test needs cutoffs before the window is complete: with a single cutoff at session 600, the
bfillpasses. - The tests are protected (file fingerprint, “skipped” in the summary) and tested: properties with random inputs with a seed, and mutants. The mutant that ignores
peso_maxsurvived until its test was added. - The assistant proposes; the person writes the tests, reads the diff, runs the tests and decides the commit.