Lección 6.5.1 · 65 min
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.
- Módulo
- 6.5 · IA como copiloto del quant
- Duración
- 65 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
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
Fuente 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):
Fuente 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 , precio , volatilidad anual estimada y objetivo :
con un tope de peso por posición. Y la volatilidad, una desviación típica móvil de los rendimientos simples de las últimas 20 sesiones, anualizada con 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);ValueErrorsi el capital o el precio no son positivos o el lote es menor que 1.volatilidad_movil(cierre, ventana=20) -> pd.Series:NaNhasta tenerventanarendimientos; 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/:
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")9 funciones de testNueve 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
roundconvierte en 18 y el redondeo hacia abajo en 17. math.nanymath.infen 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 accionesnumpy.float64acaba 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:
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)16 failedLos 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_posicionyvolatilidad_movilenriesgo.pypara que pasen los tests detest_riesgo.py. Lee los tests antes de empezar. No modifiques los tests ni añadasskip. Usa solo numpy, pandas y la biblioteca estándar. Ejecutapytest -q test_riesgo.pyy 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».
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).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 passedLeer 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:
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])16 passedLos 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:
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");1 skipped, 15 deselected
tests intactos: FalseEl 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:
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");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 passedCuatro 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:
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])mutante: 1 failed, 16 passed
versión 2: 17 passedPropiedades. 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):
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");versión 2: 18 passed
mutante: 2 failed, 16 passedLas 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:
# 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.Ejercicios
- 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 unNaNen medio de la serie. Implementa después la función y comprueba que pasa.
Solución
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])5 passedAquí 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 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.
- Añade un mutante que use
ddof=0en la desviación típica devolatilidad_movil. ¿Sobrevive? ¿Qué test lo mata?
Solución
Path("test_riesgo.py").write_text(TESTS + NUEVO + PROPIEDADES,
encoding="utf-8")
Path("riesgo.py").write_text(
V2.replace(".std()", ".std(ddof=0)"), encoding="utf-8")
resumen, salida = pytest_q()
for linea in salida.splitlines():
if linea.startswith("FAILED"):
print(linea[:90])
Path("riesgo.py").write_text(V2, encoding="utf-8");FAILED test_riesgo.py::test_volatilidad_a_mano - assert np.float64(0....4929406223153) ==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.
- 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)enuniverso.pypara que pasen los tests detests/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. Ejecutauv run pytest -q tests/test_liquidez.pyy 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.
Respuesta plegada en cada pregunta
Autoevaluación
¿Por qué se escriben los tests antes de pedir el código al asistente?
- APorque pytest no funciona con código ya escrito
- BPorque así el asistente escribe más deprisa
- CPorque 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
- DPorque los tests sustituyen a la revisión del código
Ver respuesta
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.
La primera versión del asistente usa
round()para pasar del nocional a lotes. ¿Qué test lo caza?- AEl que pide 170 acciones en lotes de 10 a 70 USD: 12.500 / 70 = 178,6 acciones, y
roundsube a 180, por encima del nocional permitido - BEl caso a mano de 250 acciones
- CEl de parámetros inválidos
- DNinguno:
roundes correcto
Ver respuesta
Respuesta A. El que pide 170 acciones en lotes de 10 a 70 USD: 12.500 / 70 = 178,6 acciones, y
roundsube a 180, por encima del nocional permitidoCon lotes de 10, 178,6 acciones son 17,86 lotes:
roundda 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.- AEl que pide 170 acciones en lotes de 10 a 70 USD: 12.500 / 70 = 178,6 acciones, y
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?- APorque
bfillno usa datos del futuro - BPorque los tests no ejecutan esa función
- CPorque el truncado solo funciona con datos intradía
- DPorque 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
Ver respuesta
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.
- APorque
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?- ANo se puede detectar
- BComparando la huella (sha256) del fichero de tests con la guardada al escribirlos, y mirando los «skipped» del resumen de pytest
- CEjecutando pytest dos veces
- DPreguntándole al asistente si ha cambiado algo
Ver respuesta
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.
Un mutante que ignora el parámetro
peso_maxy usa siempre 0,20 sobrevive a los tests. ¿Qué significa?- AQue los tests nunca usan un
peso_maxdistinto del de por defecto: falta un test, y se añade antes de dar el trabajo por terminado - BQue el mutante es correcto
- CQue hay que borrar el parámetro
- DQue pytest tiene un error
Ver respuesta
Respuesta A. Que los tests nunca usan un
peso_maxdistinto del de por defecto: falta un test, y se añade antes de dar el trabajo por terminadoUn mutante que sobrevive es un error que los tests no verían. Aquí, cualquier implementación que ignore
peso_maxpasaría. El test nuevo conpeso_max=0,05lo mata.- AQue los tests nunca usan un
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.mdes 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:
rounden vez de redondear hacia abajo, ninguna validación de entradas y unbfillque 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
bfillpasa. - 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_maxsobrevivió 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
Documentación
- GitHub Docs: documentación de GitHub Copilot
- GitHub Docs: instrucciones personalizadas del repositorio para Copilot
- Claude Code: buenas prácticas (dar a Claude una forma de verificar su trabajo)
- OpenAI: instrucciones personalizadas con AGENTS.md en Codex
- Cursor Docs: reglas del proyecto
- Documentación de pytest