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.

Fig. — El ciclo de la lección en cifras: casos de test superados y fallidos en cada paso, de los tests en rojo a los 18 en verde
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

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.

AsistenteQué dice su documentaciónDónde funcionaInstrucciones del proyecto
GitHub CopilotSugerencias 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 requestsEditores compatibles y GitHub.github/copilot-instructions.md, .github/instructions/*.instructions.md y ficheros AGENTS.md
Claude CodeHerramienta de programación agéntica que lee la base de código, edita ficheros, ejecuta comandos y se integra con las herramientas de desarrolloTerminal, IDE, aplicación de escritorio y navegadorCLAUDE.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 CloudTerminal, IDE, nube y ChatGPTAGENTS.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 cambiosSu editor y otras integracionesReglas 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

fallaverdeEspecificaciónTestsRojo: fallanPrompt y contratoCódigo propuestopytestLeer fallosDiff y testsCommit
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):

test nuevocódigo mínimotests en verdesiguen en verdesiguiente casoROJOVERDEREFACTOR
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:

PasoQuiénPor qué
Especificación y testsTú (puedes pedir ideas de casos al asistente, pero los decides tú)Son el contrato
ImplementaciónEl asistenteEs la parte mecánica
Lectura de fallos e iteraciónEl asistente, con la salida de pytestBucle cerrado
Revisión del diff, de los tests y commitTú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 KK, precio PP, volatilidad anual estimada σ\sigma y objetivo σ∗\sigma^*:

nocional=min⁡(K σ∗σ,  wmax⁡K),n=⌊nocionalP⋅lote⌋⋅lote\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 wmax⁡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 252\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/:

IN [01]python
from pathlib import Path

TESTS = '''
import math

import numpy as np
import pandas as pd
import pytest

from riesgo import tamano_posicion, volatilidad_movil


def test_caso_a_mano():
    # 100.000 x 10 % / 80 % = 12.500 USD; 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")
OUT [01]test_riesgo.py
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:

IN [02]python
import hashlib
import os
import re
import subprocess
import sys

STUB = '''
def tamano_posicion(capital, precio, vol_anual, objetivo=0.10,
                    peso_max=0.20, lote=1):
    raise NotImplementedError

def volatilidad_movil(cierre, ventana=20):
    raise NotImplementedError
'''
Path("riesgo.py").write_text(STUB, encoding="utf-8")


def pytest_q(*extra):
    """pytest 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)
OUT [02]Rojo: el módulo todavía no existe
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».

IN [03]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)
OUT [03]Versión 1, la que propone el asistente
.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:

ErrorCasos que fallanQué dice pytest
round en vez de redondear hacia abajo1assert 180 == 170: 18 lotes, uno de más
Ninguna validación de entradas6ZeroDivisionError, 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ías3La 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:

IN [04]python
V2 = '''
import math

import numpy as np


def tamano_posicion(capital, precio, vol_anual, objetivo=0.10,
                    peso_max=0.20, lote=1):
    """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])
OUT [04]Versión 2, tras leer los fallos
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:

IN [05]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");
OUT [05]Un verde que no lo es
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:

IN [06]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");
OUT [06]Cinco mutantes de la versión 2
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

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:

IN [07]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])
OUT [07]Un test más y el mutante muere
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):

IN [08]python
PROPIEDADES = '''

def test_propiedades_del_dimensionamiento():
    rng = np.random.default_rng(651)
    for _ in range(2_000):
        capital = float(rng.uniform(1e3, 1e7))
        precio = float(rng.uniform(1, 2_000))
        vol = float(rng.uniform(0.01, 1.5))
        lote = int(rng.choice([1, 10, 100]))
        peso = float(rng.uniform(0.01, 0.5))
        n = tamano_posicion(capital, precio, vol, peso_max=peso,
                            lote=lote)
        assert n >= 0 and n % lote == 0
        assert n * precio <= peso * capital + 1e-6
        assert tamano_posicion(capital, precio, vol * 1.5,
                               peso_max=peso, lote=lote) <= n
'''
Path("test_riesgo.py").write_text(TESTS + NUEVO + PROPIEDADES,
                                  encoding="utf-8")
print("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");
OUT [08]Propiedades con entradas aleatorias
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ónQué buscar
Tests intactosDiff de tests/ vacío o justificado; ningún skip, xfail ni tolerancia nueva
Anticipacióncenter=True, shift(-k), bfill, normalizaciones con toda la muestra, uniones por fecha sin hora (lecciones 0.2.6 y 6.4.6)
Redondeos y unidadesHacia abajo en cantidades; porcentajes frente a tanto por uno; anualización (252 sesiones)
Datos ausentesQué pasa con NaN, inf, cero y series vacías; sin dato, sin posición
DependenciasNingún paquete nuevo sin comprobar en PyPI que existe y es el que se busca (lección 6.5.3)
Secretos y efectosNi claves en el código ni llamadas de red o escrituras fuera de lo pedido (lección 4.4.5)
AlcanceEl 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:

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

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
IN [10]python
EJ = '''
import math

import numpy as np
import pandas as pd
import pytest


def media_movil_exponencial(cierre, semivida):
    return cierre.ewm(halflife=semivida, adjust=False).mean()


def test_a_mano():
    s = pd.Series([10.0, 20.0, 30.0])
    a = 1 - math.exp(math.log(0.5) / 1)     # 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])
OUT [10]Tests primero para una media exponencial
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 xt+(1−a) mt−1a\,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.

  1. Añade un mutante que use ddof=0 en la desviación típica de volatilidad_movil. ¿Sobrevive? ¿Qué test lo mata?
Solución
IN [11]python
Path("test_riesgo.py").write_text(TESTS + NUEVO + PROPIEDADES,
                                  encoding="utf-8")
Path("riesgo.py").write_text(
    V2.replace(".std()", ".std(ddof=0)"), encoding="utf-8")
resumen, salida = pytest_q()
for linea in salida.splitlines():
    if linea.startswith("FAILED"):
        print(linea[:90])
Path("riesgo.py").write_text(V2, encoding="utf-8");
OUT [11]El mutante de ddof
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.

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

Respuesta plegada en cada pregunta

Autoevaluación

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

  2. 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 round sube a 180, por encima del nocional permitido
    • BEl caso a mano de 250 acciones
    • CEl de parámetros inválidos
    • DNinguno: round es correcto
    Ver respuesta

    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?

    • APorque bfill no 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.

  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?

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

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

    • AQue 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
    • 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_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