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.

Fig. — The lesson's cycle in numbers: test cases passed and failed at each step, from red tests to 18 in green
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

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.

AssistantWhat its documentation saysWhere it worksProject instructions
GitHub CopilotCode 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 reviewSupported editors and GitHub.github/copilot-instructions.md, .github/instructions/*.instructions.md and AGENTS.md files
Claude CodeAgentic coding tool that reads the codebase, edits files, runs commands and integrates with development toolsTerminal, IDE, desktop app and browserCLAUDE.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 CloudTerminal, IDE, cloud and ChatGPTAGENTS.md, global (~/.codex) and per directory
Cursor“A coding agent” to understand the codebase, plan and build, fix bugs and review changesIts editor and other integrationsRules 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

failsgreenSpecificationTestsRed: they failPrompt and contractProposed codepytestRead failuresDiff and testsCommit
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):

new testminimum codetests greenstill greennext caseREDGREENREFACTOR
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:

StepWhoWhy
Specification and testsYou (you can ask the assistant for case ideas, but you decide them)They are the contract
ImplementationThe assistantIt is the mechanical part
Reading failures and iteratingThe assistant, with the pytest outputClosed loop
Review of the diff, the tests and the commitYouNobody 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 KK, price PP, estimated annual volatility σ\sigma and target σ∗\sigma^*:

notional=min⁡(K σ∗σ,  wmax⁡K),n=⌊notionalP⋅lot⌋⋅lot\text{notional} = \min\left(\frac{K\,\sigma^*}{\sigma},\; w_{\max} K\right), \qquad n = \left\lfloor \frac{\text{notional}}{P \cdot \text{lot}} \right\rfloor \cdot \text{lot}

with a weight cap wmax⁡w_{\max} per position. And the volatility, a rolling standard deviation of the simple returns of the last 20 sessions, annualized with 252\sqrt{252} 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); ValueError if the capital or the price are not positive or the lot is less than 1.
  • volatilidad_movil(cierre, ventana=20) -> pd.Series: NaN until there are ventana returns; 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/:

IN [01]python
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")
OUT [01]test_riesgo.py
9 test functions

Nine 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 round turns into 18 and rounding down into 17.
  • math.nan and math.inf in 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 type numpy.float64 ends 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:

IN [02]python
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)
OUT [02]Red: the module does not exist yet
16 failed

All 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_posicion and volatilidad_movil in riesgo.py so that the tests in test_riesgo.py pass. Read the tests before starting. Do not modify the tests or add skip. Use only numpy, pandas and the standard library. Run pytest -q test_riesgo.py and 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”.

IN [03]python
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)
OUT [03]Version 1, the one the assistant proposes
.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 passed

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

ErrorFailing casesWhat pytest says
round instead of rounding down1assert 180 == 170: 18 lots, one too many
No input validation6ZeroDivisionError, 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 days3The 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:

IN [04]python
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])
OUT [04]Version 2, after reading the failures
16 passed

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

IN [05]python
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");
OUT [05]A green that is not green
1 skipped, 15 deselected
tests intact: False

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

IN [06]python
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");
OUT [06]Five mutants of version 2
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 passed
Fig. — Five mutants of version 2 against the 16 original test cases: how many cases each one fails

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

IN [07]python
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])
OUT [07]One more test and the mutant dies
mutant:    1 failed, 16 passed
version 2: 17 passed

Properties. 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):

IN [08]python
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");
OUT [08]Properties with random inputs
version 2: 18 passed
mutant:    2 failed, 16 passed

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

CheckWhat to look for
Tests intactDiff of tests/ empty or justified; no new skip, xfail or tolerance
Look-aheadcenter=True, shift(-k), bfill, normalizations with the whole sample, joins by date without time (lessons 0.2.6 and 6.4.6)
Rounding and unitsDown for quantities; percentages versus fractions; annualization (252 sessions)
Missing dataWhat happens with NaN, inf, zero and empty series; no data, no position
DependenciesNo new package without checking on PyPI that it exists and is the one intended (lesson 6.5.3)
Secrets and side effectsNo keys in the code and no network calls or writes beyond what was asked (lesson 4.4.5)
ScopeThe 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:

IN [09]markdown
# 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

  1. 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 a NaN in the middle of the series. Then implement the function and check that it passes.
Solution
IN [10]python
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])
OUT [10]Tests first for an exponential average
5 passed

Here 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 a xt+(1−a) mt−1a\,x_t + (1-a)\,m_{t-1} 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.

  1. Add a mutant that uses ddof=0 in the standard deviation of volatilidad_movil. Does it survive? Which test kills it?
Solution
IN [11]python
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");
OUT [11]The ddof mutant
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.

  1. 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) in universo.py so that the tests in tests/test_liquidez.py pass. 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. Run uv run pytest -q tests/test_liquidez.py and 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

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

  2. 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 round goes up to 180, above the allowed notional
    • BThe hand-computed case of 250 shares
    • CThe invalid parameters one
    • DNone: round is 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 round goes up to 180, above the allowed notional

    With lots of 10, 178.6 shares are 17.86 lots: round gives 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.

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

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

  5. A mutant that ignores the peso_max parameter and always uses 0.20 survives the tests. What does it mean?

    • AThat the tests never use a peso_max other 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_max other than the default: a test is missing, and it is added before calling the work done

    A mutant that survives is an error the tests would not see. Here, any implementation that ignores peso_max would pass. The new test with peso_max=0.05 kills it.

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.md is 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: round instead of rounding down, no input validation and a bfill that fills with future data.
  • The look-ahead test needs cutoffs before the window is complete: with a single cutoff at session 600, the bfill passes.
  • 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_max survived until its test was added.
  • The assistant proposes; the person writes the tests, reads the diff, runs the tests and decides the commit.

Resources