---
title: "6.5.1 Test-first AI coding assistants"
description: "Copilot, Claude Code, Codex and Cursor according to their docs, and a test-first workflow run end to end: pytest red, proposed code, failures and green."
url: "https://pythonparatrading.com/en/python-trading-course/level-6/test-first-ai-coding-assistants/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-6/test-first-ai-coding-assistants.png"
---

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

- Course: [Level 6: Generative AI, LLMs, agents, big data and alternative data](https://pythonparatrading.com/en/python-trading-course/level-6/)
- 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](https://pythonparatrading.com/en/python-trading-course/level-0/ai-coding-assistants/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/), [4.2.2 · Kelly criterion and volatility targeting: how much to risk](https://pythonparatrading.com/en/python-trading-course/level-4/kelly-criterion-and-volatility-targeting/)

## By the end of this lesson

- Describe what Copilot, Claude Code, Codex and Cursor do and how they receive project instructions, using only their official documentation.
- Write pytest tests for a trading function before the function: hand-computed cases, edge cases, invalid parameters and a look-ahead test.
- Run the red, green and refactor cycle with a proposed implementation and read the failures to correct it.
- Protect the tests from an assistant that modifies them and check their quality with properties and mutants.
- Write a prompt and a review checklist to ask an assistant for code against a set of tests.

## 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
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
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 $K$, price $P$, estimated annual volatility
$\sigma$ and target $\sigma^*$:

$$
\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 $w_{\max}$ per position. And the volatility, a rolling standard deviation of the simple returns of
the last 20 sessions, annualized with $\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/`:

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

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

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

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

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

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

```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");
```
```output
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.* Five horizontal bars. Rounding with round, annualising with 365 and accepting zero volatility each make one case fail; bringing back bfill, three. Ignoring the peso_max parameter makes none fail: the mutant survives (highlighted).

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:

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

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

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

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

<aside class="callout" data-kind="riesgo">

An instructions file is a request, not a guarantee: the Claude Code documentation itself says it treats those files as
context, not as enforced configuration. What must always hold (tests green, the diff of `tests/` empty, no secret in
the code) is checked with deterministic tools: continuous integration, pre-commit hooks and mandatory review. The same
as in lesson 6.4.5: the assistant proposes and the code decides.

</aside>

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

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

2. Add a mutant that uses `ddof=0` in the standard deviation of `volatilidad_movil`. Does it survive? Which test kills
   it?

**Solution.**

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

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

## Self-assessment

1. Why are the tests written before asking the assistant for the code?

   - A. Because pytest does not work with code that is already written
   - B. Because that way the assistant writes faster
   - 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
   - D. Because the tests replace code review

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

   - 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
   - B. The hand-computed case of 250 shares
   - C. The invalid parameters one
   - D. None: `round` is correct

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

   - A. Because `bfill` does not use data from the future
   - B. Because the tests do not run that function
   - C. Because truncation only works with intraday data
   - 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

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

   - A. It cannot be detected
   - 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
   - C. By running pytest twice
   - D. By asking the assistant whether it has changed anything

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

   - 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
   - B. That the mutant is correct
   - C. That the parameter has to be removed
   - D. That pytest has a bug

   *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

### Research

- [Volatility Targeting in Python](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min

### Documentation

- [GitHub Docs: GitHub Copilot documentation](https://docs.github.com/en/copilot) · docs.github.com
- [GitHub Docs: adding repository custom instructions for GitHub Copilot](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions) · docs.github.com
- [Claude Code: best practices (give Claude a way to verify its work)](https://code.claude.com/docs/en/best-practices) · code.claude.com
- [OpenAI: custom instructions with AGENTS.md in Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md) · learn.chatgpt.com
- [Cursor Docs: project rules](https://cursor.com/docs/context/rules) · cursor.com
- [pytest documentation](https://docs.pytest.org/en/stable/) · docs.pytest.org

Previous lesson: [6.4.6 · Research agents: hypotheses, alpha mining and review](https://pythonparatrading.com/en/python-trading-course/level-6/research-agents-and-alpha-mining/)

Next lesson: [6.5.2 · Automated review of backtests and code migrations](https://pythonparatrading.com/en/python-trading-course/level-6/automated-backtest-review-and-code-migration/)
