---
title: "6.5.1 Asistentes de código con tests primero"
description: "Copilot, Claude Code, Codex y Cursor según su documentación, y un flujo con tests primero ejecutado: pytest en rojo, código propuesto, fallos y verde."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/asistentes-de-codigo-con-tests-primero/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/asistentes-de-codigo-con-tests-primero.png"
---

# 6.5.1 · Asistentes de código con un flujo de tests primero

> Un asistente de código escribe en segundos una función de dimensionamiento que parece correcta. Esta lección escribe antes los tests que la definen, los ve fallar, le entrega al asistente el contrato, lee los fallos de su primera versión (un redondeo que se pasa del lote, una división por cero y un relleno con datos del futuro) e itera hasta el verde. Después prueba los propios tests: si el asistente los toca y si cazan los errores que todavía no se han cometido.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.5 · IA como copiloto del quant
- Duración: 65 min
- Requisitos: [0.2.6 · Introducción a los asistentes de código con IA: prompts, revisión y tests](https://pythonparatrading.com/curso-python-trading/nivel-0/asistentes-de-codigo-con-ia/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/), [4.2.2 · Criterio de Kelly y volatility targeting: cuánto arriesgar](https://pythonparatrading.com/curso-python-trading/nivel-4/kelly-y-volatility-targeting/)

## Al terminar esta lección

- Describir qué hacen Copilot, Claude Code, Codex y Cursor y cómo reciben instrucciones de proyecto, solo con su documentación oficial.
- Escribir con pytest los tests de una función de trading antes que la función: casos a mano, bordes, parámetros inválidos y una prueba de anticipación.
- Ejecutar el ciclo rojo, verde y refactorización con una implementación propuesta y leer los fallos para corregirla.
- Proteger los tests de un asistente que los modifica y comprobar su calidad con propiedades y mutantes.
- Redactar un prompt y una lista de revisión para pedir código a un asistente contra unos tests.

## Cuatro asistentes, según su documentación

La lección 0.2.6 presentó los asistentes de código y su riesgo principal en trading: el código **plausible** que usa
datos del futuro sin que se note. Seis niveles después, el uso es otro: el asistente ya no completa una línea, sino que
lee el repositorio, edita varios ficheros, ejecuta los tests y abre una pull request. Esta lección propone un flujo de
trabajo para ese uso, con tests primero, y lo ejecuta de punta a punta.

Antes, qué son los cuatro asistentes más citados, **según su propia documentación** (consultada el 23 de octubre de
2026; los productos cambian cada mes, así que la documentación manda sobre esta tabla). No hay aquí ninguna comparación
de calidad: las capacidades se solapan y la elección depende del editor, del proveedor del modelo y de las políticas de
la empresa.

| Asistente | Qué dice su documentación | Dónde funciona | Instrucciones del proyecto |
|---|---|---|---|
| GitHub Copilot | Sugerencias de código en el editor, chat que también genera tests unitarios, un modo agente que trabaja de forma autónoma en el editor, un agente en la nube y revisión de pull requests | Editores compatibles y GitHub | `.github/copilot-instructions.md`, `.github/instructions/*.instructions.md` y ficheros `AGENTS.md` |
| Claude Code | Herramienta de programación agéntica que lee la base de código, edita ficheros, ejecuta comandos y se integra con las herramientas de desarrollo | Terminal, IDE, aplicación de escritorio y navegador | `CLAUDE.md` (puede leer un `AGENTS.md` en su lugar) |
| Codex (OpenAI) | Codex CLI es «un agente de programación de OpenAI que se ejecuta en local» (README de `github.com/openai/codex`, licencia Apache-2.0); también hay extensión para el IDE y Codex Cloud | Terminal, IDE, nube y ChatGPT | `AGENTS.md`, global (`~/.codex`) y por directorio |
| Cursor | «Un agente de programación» para entender la base de código, planificar y construir, corregir errores y revisar cambios | Su editor y otras integraciones | Reglas en `.cursor/rules` (ficheros `.mdc`) y `AGENTS.md` |

`AGENTS.md` se presenta como «un formato abierto y sencillo para guiar a agentes de programación» y su web lista entre
quienes lo admiten a Codex, Cursor y GitHub Copilot, entre otros. En la práctica, un repositorio de trading con un
fichero de instrucciones claro (cómo se ejecutan los tests, qué no se toca, las reglas de anticipación) sirve a
cualquiera de los cuatro.

Dos ideas de la documentación de Claude Code resumen el flujo de esta lección. La primera: «dale a Claude una
comprobación que pueda ejecutar: tests, un build…», porque sin ella «parece terminado» es la única señal disponible y la
persona se convierte en el bucle de verificación. La segunda: los tests y el código pueden escribirlos agentes
distintos, para que quien hace el trabajo no sea quien lo califica. En trading la segunda es casi obligatoria: el
error que importa (la anticipación) es justo el que un test escrito a la vista del código no ve.

## El flujo: tests primero

```mermaid
flowchart TB
  E[Especificación] --> T[Tests]
  T --> R[Rojo: fallan]
  R --> P[Prompt y contrato]
  P --> C[Código propuesto]
  C --> X{pytest}
  X -->|falla| L[Leer fallos]
  L --> C
  X -->|verde| V[Diff y tests]
  V --> M[Commit]
```

Es el ciclo de Kent Beck (*Test-Driven Development: By Example*, 2002) con un asistente en el paso del código: rojo
(los tests fallan porque el código no existe), verde (el código mínimo que los pasa) y refactorización (mejorar el código
con los tests en verde):

```mermaid
stateDiagram-v2
  [*] --> ROJO: test nuevo
  ROJO --> VERDE: código mínimo
  VERDE --> REFACTOR: tests en verde
  REFACTOR --> VERDE: siguen en verde
  VERDE --> ROJO: siguiente caso
```

Lo que cambia con un asistente es quién escribe cada parte:

| Paso | Quién | Por qué |
|---|---|---|
| Especificación y tests | Tú (puedes pedir ideas de casos al asistente, pero los decides tú) | Son el contrato |
| Implementación | El asistente | Es la parte mecánica |
| Lectura de fallos e iteración | El asistente, con la salida de pytest | Bucle cerrado |
| Revisión del diff, de los tests y commit | Tú | Nadie más responde del código |

## La especificación

La función es la del dimensionamiento por objetivo de volatilidad de la lección 4.2.2: cuántas acciones comprar para
que una posición aporte una volatilidad anual objetivo a la cartera. Con capital $K$, precio $P$, volatilidad anual
estimada $\sigma$ y objetivo $\sigma^*$:

$$
\text{nocional} = \min\left(\frac{K\,\sigma^*}{\sigma},\; w_{\max} K\right), \qquad
n = \left\lfloor \frac{\text{nocional}}{P \cdot \text{lote}} \right\rfloor \cdot \text{lote}
$$

con un tope de peso $w_{\max}$ por posición. Y la volatilidad, una desviación típica móvil de los rendimientos simples
de las últimas 20 sesiones, anualizada con $\sqrt{252}$ y **conocida al cierre de cada día**. Escrita como contrato:

- `tamano_posicion(capital, precio, vol_anual, objetivo=0.10, peso_max=0.20, lote=1) -> int`: acciones, redondeando
  **hacia abajo** al lote; 0 si la volatilidad no es un número finito positivo (sin dato no hay posición); `ValueError`
  si el capital o el precio no son positivos o el lote es menor que 1.
- `volatilidad_movil(cierre, ventana=20) -> pd.Series`: `NaN` hasta tener `ventana` rendimientos; el valor de cada día
  solo depende de los cierres hasta ese día.

## Los tests, antes que el código

Los tests se escriben a partir de la especificación, con cifras calculadas a mano en los comentarios. Cubren el caso
normal, los bordes (redondeo, tope, volatilidad nula o ausente), los parámetros inválidos y la anticipación. La celda
escribe el fichero `test_riesgo.py` en la carpeta de trabajo, como lo guardarías en `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; a 50 USD, 250 acciones
    assert tamano_posicion(100_000, 50.0, 0.80) == 250


def test_redondea_hacia_abajo_al_lote():
    # 12.500 / 70 = 178,6 acciones: en lotes de 10, 170 (no 180)
    assert tamano_posicion(100_000, 70.0, 0.80, lote=10) == 170


def test_respeta_el_peso_maximo():
    # vol 2 %: 500.000 USD sin tope; con el 20 %, 20.000 = 400 acc.
    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()),
      "funciones de test")
```
```output
9 funciones de test
```

Nueve funciones, que con los `parametrize` son 16 casos. Algunos detalles que no son de estilo:

- **El caso del redondeo usa 70 USD y lotes de 10.** Con 50 USD el nocional da 250 acciones exactas y cualquier
  redondeo pasa. Un test de redondeo tiene que caer en un caso donde redondear al más cercano y hacia abajo den
  resultados distintos: 178,6 acciones son 17,86 lotes, que `round` convierte en 18 y el redondeo hacia abajo en 17.
- **`math.nan` y `math.inf` en la volatilidad**: son lo que llega de verdad en los primeros días o tras un dato roto.
- **Tres cortes en la prueba de anticipación**, uno antes de completar la ventana. Se verá por qué.
- **`type(...) is int`**: una cantidad de acciones `numpy.float64` acaba en una orden con decimales.

El primer paso del ciclo es verlos fallar. Un módulo `riesgo.py` vacío, con las firmas y `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 en un proceso aparte; sin la duración, que varía."""
    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
```

Los 16 casos fallan, como debe ser: un test que pasa antes de que exista el código no comprueba nada. pytest se
ejecuta en otro proceso, como lo lanzaría el asistente desde la terminal, sin cargar complementos instalados
(`PYTEST_DISABLE_PLUGIN_AUTOLOAD`), y la celda quita del resumen la duración («in 0.31s»), que cambia en cada
ejecución. La celda guarda también la **huella** del fichero de tests: hará
falta más adelante.

## El prompt y la primera propuesta

Con los tests en rojo, el prompt al asistente es corto, porque el contrato está en los tests:

> Implementa `tamano_posicion` y `volatilidad_movil` en `riesgo.py` para que pasen los tests de `test_riesgo.py`. Lee
> los tests antes de empezar. No modifiques los tests ni añadas `skip`. Usa solo numpy, pandas y la biblioteca
> estándar. Ejecuta `pytest -q test_riesgo.py` y pega la salida completa. Si un test te parece incorrecto, dilo y
> explica por qué, sin cambiarlo.

La primera versión que sigue reúne errores típicos del código generado en este tipo de función (es un ejemplo
construido para la lección, no la salida de un producto concreto): un `round` que parece inocente, ninguna validación y
un relleno de los primeros días para que la serie «no tenga huecos».

```python
V1 = '''
import numpy as np


def tamano_posicion(capital, precio, vol_anual, objetivo=0.10,
                    peso_max=0.20, lote=1):
    """Acciones para aportar `objetivo` de volatilidad anual."""
    nocional = capital * objetivo / vol_anual
    nocional = min(nocional, peso_max * capital)
    return round(nocional / precio / lote) * lote


def volatilidad_movil(cierre, ventana=20):
    """Volatilidad anualizada; rellena los primeros días."""
    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
```

## Leer los fallos

La salida de pytest es la mitad del valor del flujo: cada línea `FAILED` dice qué parte del contrato no se cumple, y es
exactamente lo que el asistente necesita para iterar (en un agente, la lee él mismo).

La versión 1 pasa 6 de los 16 casos y falla en 10, que se agrupan en tres errores:

| Error | Casos que fallan | Qué dice pytest |
|---|---|---|
| `round` en vez de redondear hacia abajo | 1 | `assert 180 == 170`: 18 lotes, uno de más |
| Ninguna validación de entradas | 6 | `ZeroDivisionError`, una cantidad de −2.000 acciones con volatilidad negativa, `ValueError` al redondear un `NaN` y un capital 0 que no lanza nada |
| `bfill` en los primeros días | 3 | La serie tiene valores donde debería haber `NaN`, y los cortes de 10 y 20 sesiones no coinciden con la serie completa |

Tres observaciones. **El caso a mano pasa**: 250 acciones es un resultado exacto, y por eso hacía falta el caso de 70
USD. **El `bfill` pasa la prueba de anticipación con el corte de la sesión 600** y solo la suspende con los cortes de 10
y 20, anteriores a completar la ventana: el valor que se copia hacia atrás es el primero que se puede calcular (el de la
sesión 21), y si el corte es posterior está igual en las dos series. Con un único corte lejano, como el que escribiría
cualquiera, la anticipación no se habría visto. Y el test de los `NaN` la caza por otro camino: la especificación dice
que los primeros días no tienen dato, y es verdad.

El relleno hacia atrás no es un detalle: en un backtest, esos primeros días tendrían una posición calculada con una
volatilidad que todavía no se conocía. Es el mismo error que el `center=True` de la lección 0.2.6, con otra cara.

## Verde

El asistente recibe la salida de pytest y propone la versión 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):
    """Acciones para aportar `objetivo` de volatilidad anual."""
    if capital <= 0 or precio <= 0 or lote < 1:
        raise ValueError("capital, precio y lote deben ser > 0")
    if not math.isfinite(vol_anual) or vol_anual <= 0:
        return 0                       # sin dato, sin posición
    nocional = min(capital * objetivo / vol_anual, peso_max * capital)
    return int(nocional // (precio * lote)) * lote


def volatilidad_movil(cierre, ventana=20):
    """Volatilidad anualizada conocida al cierre de cada día."""
    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
```

Los 16 casos pasan. La figura de la cabecera resume el ciclo completo, del rojo a los 18 casos en verde con los dos
tests que se añaden más abajo. Verde no significa terminado: falta comprobar que los tests siguen siendo los que
escribiste y que son lo bastante buenos para que el verde signifique algo.

## Los tests son tuyos

Un asistente al que se le pide «deja los tests en verde» tiene a mano una solución que no es la que se busca: cambiar
los tests. A veces lo hace de forma explícita (cambia un 170 por un 180 porque «el test parece incorrecto»), y a veces
discreta (un `skip`, un `xfail`, un `approx` con una tolerancia enorme). La celda simula el segundo caso sobre la
versión 1:

```python
tocado = TESTS.replace(
    "def test_redondea_hacia_abajo_al_lote",
    "@pytest.mark.skip(reason='redondeo dudoso')\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 intactos:", 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 intactos: False
```

El resumen ya no dice «failed», dice «skipped», y la huella del fichero no coincide con la guardada al escribirlo. En
un repositorio, lo equivalente es revisar el diff de la carpeta de tests en cada pull request del asistente (o
protegerla con una regla de revisión obligatoria), y leer los «skipped» y «xfailed» del resumen como lo que son: tests
que no se han superado.

## Probar los tests: mutantes y propiedades

Los tests de casos concretos comprueban lo que se te ocurrió. Dos técnicas comprueban si eso es suficiente.

**Mutantes.** Se introduce a propósito un error pequeño en el código que pasa los tests y se comprueba que algún test
falla. Si ninguno falla, el mutante «sobrevive»: hay un error posible que los tests no verían. Herramientas como
mutmut lo automatizan; la idea cabe en un bucle:

```python
MUTANTES = {
    "round en vez de hacia abajo": (
        "int(nocional // (precio * lote))",
        "round(nocional / (precio * lote))"),
    "anualiza con 365": ("np.sqrt(252)", "np.sqrt(365)"),
    "vuelve el bfill": (
        "return r.rolling(ventana).std() * np.sqrt(252)",
        "return (r.rolling(ventana).std() * np.sqrt(252)).bfill()"),
    "acepta vol cero": ("vol_anual <= 0", "vol_anual < 0"),
    "ignora 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] = "muerto" if "failed" in resumen else "VIVO"
    print(f"{nombre:<28} {resultado[nombre]:<7} {resumen}")
Path("riesgo.py").write_text(V2, encoding="utf-8");
```
```output
round en vez de hacia abajo  muerto  1 failed, 15 passed
anualiza con 365             muerto  1 failed, 15 passed
vuelve el bfill              muerto  3 failed, 13 passed
acepta vol cero              muerto  1 failed, 15 passed
ignora peso_max              VIVO    16 passed
```

*Fig. — Cinco mutantes de la versión 2 contra los 16 casos de test originales: cuántos casos falla cada uno.* Cinco barras horizontales. Usar round, anualizar con 365 y aceptar volatilidad cero hacen fallar un caso cada uno; volver a poner el bfill, tres. Ignorar el parámetro peso_max no hace fallar ninguno: el mutante sobrevive (destacado).

Cuatro mutantes mueren: cada uno hace fallar al menos un test (el `bfill`, tres). El quinto, que ignora el parámetro
`peso_max` y usa siempre 0,20, **sobrevive**: pasa los 16 casos, porque ningún test usa un `peso_max` distinto del de
por defecto. Cualquier implementación que ignore el parámetro, propuesta por un asistente o escrita a mano, pasaría.
Falta un test, y se añade:

```python
NUEVO = '''

def test_peso_max_es_un_parametro():
    # vol 2 % y peso_max 5 %: 5.000 USD a 50 USD, 100 acciones
    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["ignora peso_max"]
Path("riesgo.py").write_text(V2.replace(antes, despues),
                             encoding="utf-8")
print("mutante:  ", pytest_q()[0])
Path("riesgo.py").write_text(V2, encoding="utf-8")
print("versión 2:", pytest_q()[0])
```
```output
mutante:   1 failed, 16 passed
versión 2: 17 passed
```

**Propiedades.** En lugar de un resultado concreto, una regla que tiene que cumplirse con cualquier entrada válida: el
nocional nunca supera el tope de peso, la cantidad es múltiplo del lote, nunca es negativa y no sube cuando sube la
volatilidad. Se prueban con muchas entradas aleatorias con semilla (la biblioteca Hypothesis lo automatiza; no está en
el entorno del curso, y un bucle con `numpy` basta para la 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("versión 2:", pytest_q()[0])
antes, despues = MUTANTES["ignora peso_max"]
Path("riesgo.py").write_text(V2.replace(antes, despues),
                             encoding="utf-8")
print("mutante:  ", pytest_q()[0])
Path("riesgo.py").write_text(V2, encoding="utf-8");
```
```output
versión 2: 18 passed
mutante:   2 failed, 16 passed
```

Las 2.000 combinaciones aleatorias cumplen las cuatro reglas con la versión 2, y la propiedad del tope de peso mata
también al mutante de `peso_max` (ahora fallan dos tests: el nuevo y el de propiedades). Una propiedad cubre a la vez
muchos casos que nadie escribiría a mano; un caso concreto documenta una cifra que se puede comprobar con lápiz. Se
necesitan los dos.

## Revisar lo que entrega el asistente

Con los tests en verde y probados, queda la lectura del diff completo, como en cualquier pull request (lección 0.2.5).
Una lista para código de trading generado por un asistente:

| Comprobación | Qué buscar |
|---|---|
| Tests intactos | Diff de `tests/` vacío o justificado; ningún `skip`, `xfail` ni tolerancia nueva |
| Anticipación | `center=True`, `shift(-k)`, `bfill`, normalizaciones con toda la muestra, uniones por fecha sin hora (lecciones 0.2.6 y 6.4.6) |
| Redondeos y unidades | Hacia abajo en cantidades; porcentajes frente a tanto por uno; anualización (252 sesiones) |
| Datos ausentes | Qué pasa con `NaN`, `inf`, cero y series vacías; sin dato, sin posición |
| Dependencias | Ningún paquete nuevo sin comprobar en PyPI que existe y es el que se busca (lección 6.5.3) |
| Secretos y efectos | Ni claves en el código ni llamadas de red o escrituras fuera de lo pedido (lección 4.4.5) |
| Alcance | El diff toca solo lo que se pidió |

Y un fichero de instrucciones del proyecto (`AGENTS.md`, `CLAUDE.md` o el que lea tu asistente) que lo deje por
escrito para todas las sesiones. Un ejemplo para un repositorio de investigación:

```markdown
# Instrucciones para asistentes de código
- Tests: `uv run pytest -q`. Antes de dar algo por terminado, pega la salida.
- Los tests de `tests/` los escribe una persona: no los modifiques ni añadas
  skip o xfail. Si uno te parece incorrecto, dilo y explica por qué.
- Ninguna señal puede usar datos posteriores al instante de la decisión:
  nada de center=True, shift negativo, bfill ni estadísticos de toda la muestra.
- Cantidades de órdenes: enteras y redondeadas hacia abajo al lote.
- No añadas dependencias sin preguntar. No escribas claves ni llames a la red.
```

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

Un fichero de instrucciones es una petición, no una garantía: la propia documentación de Claude Code dice que trata
esos ficheros como contexto, no como configuración obligatoria. Lo que tiene que cumplirse siempre (los tests en
verde, el diff de `tests/` vacío, ningún secreto en el código) se comprueba con herramientas deterministas: integración
continua, ganchos antes del commit y revisión obligatoria. Lo mismo que en la lección 6.4.5: el asistente propone y el
código decide.

</aside>

## Ejercicios

1. Escribe, antes que el código, los tests de una función `media_movil_exponencial(cierre, semivida)` sin anticipación:
   un caso a mano con tres valores, la prueba de truncado con varios cortes y el comportamiento con un `NaN` en medio
   de la serie. Implementa después la función y comprueba que pasa.

**Solución.**

```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)     # semivida 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
```

Aquí la función va en el mismo fichero para que el ejercicio quepa en una celda; en un proyecto, iría en su módulo.
Con `adjust=False`, la media de un día es $a\,x_t + (1-a)\,m_{t-1}$ y solo depende del pasado: el truncado lo confirma
en los tres cortes. El test del `NaN` documenta una decisión: con `ignore_na=False` (el valor por defecto), pandas
sigue dando valor el día del hueco con la media anterior. Si la decisión fuera otra, el test es el sitio donde
escribirla.

2. Añade un mutante que use `ddof=0` en la desviación típica de `volatilidad_movil`. ¿Sobrevive? ¿Qué test lo mata?

**Solución.**

```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) ==
```

Muere en el test calculado a mano, y solo en él: con cuatro rendimientos, dividir por 4 en vez de por 3 da una
desviación típica un 13 % menor, que `pytest.approx` detecta de sobra. Los demás tests no lo verían: ni la prueba de
anticipación ni la de los `NaN` dependen de la escala. Un único caso calculado a mano protege una decisión entera.

3. Escribe el prompt que darías a un asistente para que implemente un filtro de liquidez (excluir las acciones cuyo
   volumen medio en dólares de 20 sesiones sea menor que un umbral) con el flujo de la lección.

**Solución.**

Primero los tests, escritos por ti y guardados en `tests/test_liquidez.py`: un caso a mano con tres acciones y un
umbral; un empate exacto en el umbral (¿entra o no? decídelo y escríbelo); una acción sin volumen los primeros días
(sin dato, fuera); la prueba de truncado con cortes antes y después de completar la ventana; y una propiedad (subir el
umbral nunca añade acciones). Después, el prompt:

> Implementa `filtro_liquidez(cierre, volumen, umbral_usd, ventana=20)` en `universo.py` para que pasen los tests de
> `tests/test_liquidez.py`. Devuelve un DataFrame booleano con las mismas fechas y tickers. El valor de cada día solo
> puede usar datos hasta ese cierre. No modifiques los tests ni añadas skip o xfail; si uno te parece incorrecto, dilo.
> Sin dependencias nuevas. Ejecuta `uv run pytest -q tests/test_liquidez.py` y pega la salida completa.

Y al recibirlo: comprobar que el diff de `tests/` está vacío, leer el código buscando `center`, `shift` negativo,
`bfill` y estadísticos de toda la muestra, ejecutar los tests tú mismo y, si hay tiempo, un par de mutantes.

## Autoevaluación

1. ¿Por qué se escriben los tests antes de pedir el código al asistente?

   - A. Porque pytest no funciona con código ya escrito
   - B. Porque así el asistente escribe más deprisa
   - C. Porque los tests fijan el contrato con independencia de la implementación: si los escribe el mismo asistente después del código, tienden a comprobar lo que el código hace, errores incluidos
   - D. Porque los tests sustituyen a la revisión del código

   *Respuesta C: Porque los tests fijan el contrato con independencia de la implementación: si los escribe el mismo asistente después del código, tienden a comprobar lo que el código hace, errores incluidos.* Un test escrito a la vista de la implementación suele copiar su comportamiento. Escrito antes, a partir de la especificación (cálculos a mano, bordes, prohibiciones), es una comprobación independiente. La documentación de Claude Code propone incluso que un agente escriba los tests y otro el código.

2. La primera versión del asistente usa `round()` para pasar del nocional a lotes. ¿Qué test lo caza?

   - A. El que pide 170 acciones en lotes de 10 a 70 USD: 12.500 / 70 = 178,6 acciones, y `round` sube a 180, por encima del nocional permitido
   - B. El caso a mano de 250 acciones
   - C. El de parámetros inválidos
   - D. Ninguno: `round` es correcto

   *Respuesta A: El que pide 170 acciones en lotes de 10 a 70 USD: 12.500 / 70 = 178,6 acciones, y `round` sube a 180, por encima del nocional permitido.* Con lotes de 10, 178,6 acciones son 17,86 lotes: `round` da 18 lotes (180 acciones, 12.600 USD) y supera el nocional objetivo. Un dimensionamiento redondea siempre hacia abajo; el caso a mano de 250 no lo detecta porque es exacto.

3. La versión 1 rellena con `bfill()` los primeros días de la volatilidad. ¿Por qué la prueba de truncado con un corte en la sesión 600 no lo detecta?

   - A. Porque `bfill` no usa datos del futuro
   - B. Porque los tests no ejecutan esa función
   - C. Porque el truncado solo funciona con datos intradía
   - D. Porque con 600 sesiones el valor que se copia hacia atrás (el primero que se puede calcular) está en las dos series: hacen falta cortes antes de completar la ventana, como los de 10 y 20 sesiones

   *Respuesta D: Porque con 600 sesiones el valor que se copia hacia atrás (el primero que se puede calcular) está en las dos series: hacen falta cortes antes de completar la ventana, como los de 10 y 20 sesiones.* El relleno hacia atrás copia en los primeros días un valor que se calcula con datos posteriores. Si el corte deja ese valor dentro, las dos series coinciden. Por eso la prueba de la lección usa varios cortes, incluido uno antes de la ventana, y además exige NaN hasta completarla.

4. Tras pedirle que deje los tests en verde, el asistente marca uno con `@pytest.mark.skip`. ¿Cómo se detecta sin leer el diff entero?

   - A. No se puede detectar
   - B. Comparando la huella (sha256) del fichero de tests con la guardada al escribirlos, y mirando los «skipped» del resumen de pytest
   - C. Ejecutando pytest dos veces
   - D. Preguntándole al asistente si ha cambiado algo

   *Respuesta B: Comparando la huella (sha256) del fichero de tests con la guardada al escribirlos, y mirando los «skipped» del resumen de pytest.* Los tests son tuyos: cualquier cambio en ellos es una decisión que tomas tú. Una huella guardada (o, en un repositorio, el diff de la carpeta de tests en la revisión) lo hace visible al instante. El resumen de pytest también lo delata: un test saltado no es un test superado.

5. Un mutante que ignora el parámetro `peso_max` y usa siempre 0,20 sobrevive a los tests. ¿Qué significa?

   - A. Que los tests nunca usan un `peso_max` distinto del de por defecto: falta un test, y se añade antes de dar el trabajo por terminado
   - B. Que el mutante es correcto
   - C. Que hay que borrar el parámetro
   - D. Que pytest tiene un error

   *Respuesta A: Que los tests nunca usan un `peso_max` distinto del de por defecto: falta un test, y se añade antes de dar el trabajo por terminado.* Un mutante que sobrevive es un error que los tests no verían. Aquí, cualquier implementación que ignore `peso_max` pasaría. El test nuevo con `peso_max=0,05` lo mata.

## Ideas clave

- Copilot, Claude Code, Codex y Cursor leen instrucciones de proyecto en ficheros del repositorio (`.github/copilot-instructions.md`, `CLAUDE.md`, `AGENTS.md`, `.cursor/rules`); `AGENTS.md` es un formato abierto que reconocen varios.
- Tests primero: la especificación se escribe como tests (casos a mano, bordes, parámetros inválidos, anticipación) y se ven fallar antes de que exista el código.
- La primera versión del asistente pasó 6 de 16 casos: `round` en vez de redondear hacia abajo, ninguna validación de entradas y un `bfill` que rellena con datos futuros.
- La prueba de anticipación necesita cortes antes de completar la ventana: con un único corte en la sesión 600, el `bfill` pasa.
- Los tests se protegen (huella del fichero, «skipped» en el resumen) y se prueban: propiedades con entradas aleatorias con semilla y mutantes. El mutante que ignora `peso_max` sobrevivió hasta añadir su test.
- El asistente propone; la persona escribe los tests, lee el diff, ejecuta las pruebas y decide el commit.

## Recursos

### Research

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

### Documentación

- [GitHub Docs: documentación de GitHub Copilot](https://docs.github.com/en/copilot) · docs.github.com
- [GitHub Docs: instrucciones personalizadas del repositorio para Copilot](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions) · docs.github.com
- [Claude Code: buenas prácticas (dar a Claude una forma de verificar su trabajo)](https://code.claude.com/docs/en/best-practices) · code.claude.com
- [OpenAI: instrucciones personalizadas con AGENTS.md en Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md) · learn.chatgpt.com
- [Cursor Docs: reglas del proyecto](https://cursor.com/docs/context/rules) · cursor.com
- [Documentación de pytest](https://docs.pytest.org/en/stable/) · docs.pytest.org

Lección anterior: [6.4.6 · Agentes de investigación: hipótesis, alpha mining y revisión](https://pythonparatrading.com/curso-python-trading/nivel-6/agentes-de-investigacion-y-alpha-mining/)

Lección siguiente: [6.5.2 · Revisión automática de backtests y migraciones de código](https://pythonparatrading.com/curso-python-trading/nivel-6/revision-automatica-de-backtests-y-migraciones/)
