Lección 6.5.3 · 70 min

Riesgos del código generado por IA

El código que escribe un asistente se ejecuta, parece razonable y a veces está mal de formas que no dan errores. Esta lección reúne los tres riesgos que más cuestan en un repositorio de trading: funciones plausibles pero incorrectas, dependencias que no existen o que alguien registró después de que un modelo las inventara, y secretos que acaban en el contexto, en el código o en los registros. Cada uno con su evidencia publicada y una defensa que se ejecuta.

Fig. — Caída desde el máximo de la cartera equiponderada del universo del nivel 2 calculada con la función del asistente (suma de rendimientos) y con la correcta (rendimientos compuestos): la peor caída pasa del 63,1 % al 95,3 %
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

Tres riesgos que los tests de siempre no ven

Las lecciones 6.5.1 y 6.5.2 se ocuparon de que el código de un asistente haga lo que se pidió y no mire al futuro. Quedan tres riesgos que no dependen de la estrategia, sino de cómo se produce el código:

AsistenteFuncionesDependenciasContexto y diffPlausible y malPaquete inventadoClave filtradaTests a manoÍndice y lockfileEscaneo del diff
Fuente Mermaid
flowchart TB
  A[Asistente] --> F[Funciones]
  A --> D[Dependencias]
  A --> S[Contexto y diff]
  F --> F1[Plausible y mal]
  D --> D1[Paquete inventado]
  S --> S1[Clave filtrada]
  F1 --> G[Tests a mano]
  D1 --> H[Índice y lockfile]
  S1 --> K[Escaneo del diff]

No son riesgos teóricos. Hay estudios publicados con cifras, todos con modelos que hoy están superados, así que sirven para dimensionar el problema, no para medir a los asistentes actuales:

EstudioQué midióResultado
Pearce et al., IEEE S&P 20221.689 programas generados por GitHub Copilot en 89 escenarios de seguridadEn torno al 40 %, vulnerables
Perry et al., ACM CCS 2023Un estudio con usuarios, con y sin asistenteCon asistente escribieron código menos seguro y creían más a menudo que era seguro
Spracklen et al., USENIX Security 2025576.000 muestras de código de 16 modelos en dos lenguajesPorcentaje medio de paquetes inventados de al menos el 5,2 % en modelos comerciales y el 21,7 % en abiertos; 205.474 nombres inventados distintos
Lanyado, Lasso Security, 2024Registró un paquete vacío con un nombre que los modelos inventabanMás de 30.000 descargas reales en tres meses

La segunda fila es la que más importa en la práctica: el riesgo no es solo que el código esté mal, sino que quien lo recibe confía más en él.

Código plausible pero incorrecto

Cuatro funciones que un asistente propone para una librería de métricas. Todas se ejecutan, todas devuelven números razonables y todas tienen un error que no produce ningún fallo:

IN [01]python
import numpy as np
import pandas as pd

def max_drawdown(r):
    """Máxima caída desde un máximo, en tanto por uno."""
    curva = r.cumsum()
    return float((curva - curva.cummax()).min())

def sharpe_anual(r, rf_anual=0.0):
    """Sharpe anualizado de rendimientos diarios."""
    exceso = r - rf_anual
    return float(exceso.mean() / exceso.std() * np.sqrt(252))

def vwap_intradia(op):
    """VWAP acumulado de cada sesión (columnas ts, precio,
    volumen; ts en UTC)."""
    pv = (op["precio"] * op["volumen"]).cumsum()
    return pv / op["volumen"].cumsum()

def a_utc(ts):
    """Horas de Nueva York sin zona, a UTC."""
    return ts.dt.tz_localize("UTC")

r = pd.Series([0.10, -0.10, -0.10])
print(f"drawdown {max_drawdown(r):.4f}  ·  "
      f"Sharpe {sharpe_anual(r):.2f}")
OUT [01]Las cuatro funciones que propone el asistente
drawdown -0.2000  ·  Sharpe -4.58

Los errores, de uno en uno: el drawdown suma rendimientos simples en vez de componerlos; el Sharpe resta un tipo libre anual a rendimientos diarios; el VWAP acumula desde el principio del DataFrame y no se reinicia en cada sesión; y la conversión a UTC declara que las horas ya son UTC en vez de localizarlas en Nueva York y convertirlas. El drawdown de la celda, −0,2000, parece correcto. La caída real de 1,10 a 0,891 es del 19 %.

Los tests que los cazan se escriben como en la lección 6.5.1: a mano, a partir de la definición, y en los bordes. Para cada función, un caso calculado a mano y, cuando existe, una propiedad que tiene que cumplirse siempre:

IN [02]python
def ops(*filas):
    return pd.DataFrame(filas, columns=["ts", "precio", "volumen"]
                        ).assign(ts=lambda d: pd.to_datetime(
                            d["ts"], utc=True))

TESTS = {
    "drawdown: tres días a mano": lambda f: np.isclose(
        f["max_drawdown"](pd.Series([0.10, -0.10, -0.10])),
        0.891 / 1.1 - 1),
    "drawdown: primer día negativo": lambda f: np.isclose(
        f["max_drawdown"](pd.Series([-0.10, 0.05])), -0.10),
    "drawdown: entre -1 y 0": lambda f: -1 <= f["max_drawdown"](
        pd.Series([0.5, -0.6, -0.6])) <= 0,
    "Sharpe: exceso medio nulo": lambda f: abs(f["sharpe_anual"](
        pd.Series([0.03 / 252 + 0.01, 0.03 / 252 - 0.01] * 50),
        rf_anual=0.03)) < 1e-9,
    "VWAP: se reinicia cada sesión": lambda f: np.isclose(
        f["vwap_intradia"](ops(
            ("2024-01-02 15:00", 10.0, 100),
            ("2024-01-02 16:00", 11.0, 100),
            ("2024-01-03 15:00", 20.0, 50))).iloc[-1], 20.0),
    "UTC: invierno y verano": lambda f: list(
        f["a_utc"](pd.Series(pd.to_datetime(
            ["2024-01-02 09:30", "2024-03-11 09:30"])))
        .dt.strftime("%H:%M")) == ["14:30", "13:30"],
}

def pasar(funciones):
    return {nombre: bool(t(funciones)) for nombre, t in TESTS.items()}

propuesta = {"max_drawdown": max_drawdown,
             "sharpe_anual": sharpe_anual,
             "vwap_intradia": vwap_intradia, "a_utc": a_utc}
for nombre, ok in pasar(propuesta).items():
    print(f"{'pasa ' if ok else 'FALLA'}  {nombre}")
OUT [02]Tests escritos a mano
FALLA  drawdown: tres días a mano
FALLA  drawdown: primer día negativo
FALLA  drawdown: entre -1 y 0
FALLA  Sharpe: exceso medio nulo
FALLA  VWAP: se reinicia cada sesión
FALLA  UTC: invierno y verano

Las cuatro funciones fallan los seis tests. Ninguno es sofisticado: tres días de rendimientos calculados a mano, un primer día negativo, la propiedad de que un drawdown está entre −1 y 0 (con +50 %, −60 % y −60 %, la función del asistente da −120 %), dos rendimientos simétricos alrededor del tipo libre diario (Sharpe cero), una segunda sesión con una sola operación (su VWAP es su precio) y dos fechas a ambos lados del cambio de horario de Estados Unidos del 10 de marzo de 2024 (las 9:30 de Nueva York son las 14:30 UTC en enero y las 13:30 en marzo). Lo que tienen en común es que se escriben desde la definición, sin mirar el código.

La primera corrección del drawdown que se le ocurre a casi todo el mundo, y a casi todos los asistentes, es componer:

IN [03]python
def max_drawdown_v2(r):
    curva = (1 + r).cumprod()
    return float((curva / curva.cummax() - 1).min())

v2 = dict(propuesta, max_drawdown=max_drawdown_v2)
{k: v for k, v in pasar(v2).items() if k.startswith("drawdown")}
OUT [03]Primera corrección del drawdown
{'drawdown: tres días a mano': True, 'drawdown: primer día negativo': False, 'drawdown: entre -1 y 0': True}

Pasa dos de los tres tests del drawdown y falla el del primer día negativo: la curva empieza en 0,9 y su máximo acumulado también, así que la caída desde el capital inicial no se ve. Es un error dentro de la corrección de otro, y en un backtest que empieza perdiendo subestima la peor caída. La versión buena añade el punto de partida (capital 1) antes de componer. Las cuatro corregidas pasan los seis tests:

IN [04]python
def max_drawdown_ok(r):
    curva = pd.concat([pd.Series([1.0]),
                       (1 + r).cumprod()], ignore_index=True)
    return float((curva / curva.cummax() - 1).min())

def sharpe_anual_ok(r, rf_anual=0.0):
    exceso = r - rf_anual / 252
    return float(exceso.mean() / exceso.std() * np.sqrt(252))

def vwap_intradia_ok(op):
    sesion = (op["ts"].dt.tz_convert("America/New_York")
              .dt.normalize())
    pv = (op["precio"] * op["volumen"]).groupby(sesion).cumsum()
    return pv / op["volumen"].groupby(sesion).cumsum()

def a_utc_ok(ts):
    return (ts.dt.tz_localize("America/New_York")
            .dt.tz_convert("UTC"))

corregidas = {"max_drawdown": max_drawdown_ok,
              "sharpe_anual": sharpe_anual_ok,
              "vwap_intradia": vwap_intradia_ok, "a_utc": a_utc_ok}
print(all(pasar(corregidas).values()))
OUT [04]Las cuatro funciones corregidas
True

¿Cuánto importan estos errores con datos? La cartera equiponderada de las acciones del universo del nivel 2 (datos sintéticos, de 2006 a 2025, con la crisis de 2008), con un tipo libre del 2 % anual:

IN [05]python
ajustados = pd.read_parquet("universo2_ajustados.parquet")
P = ajustados.pivot(index="fecha", columns="ticker",
                    values="cierre_ajustado")
cartera = P.pct_change(fill_method=None).mean(axis=1).iloc[1:]

for nombre, mal, bien in [
        ("peor caída", max_drawdown(cartera),
         max_drawdown_ok(cartera)),
        ("Sharpe con rf 2 %", sharpe_anual(cartera, 0.02),
         sharpe_anual_ok(cartera, 0.02))]:
    print(f"{nombre:<18} asistente {mal:8.3f}   correcto {bien:7.3f}")
OUT [05]Los errores con la cartera del nivel 2
peor caída         asistente   -0.953   correcto  -0.631
Sharpe con rf 2 %  asistente  -24.659   correcto   0.290

Con datos, los dos errores dejan de ser detalles (la figura de la cabecera muestra la caída de toda la serie). La peor caída de la cartera con la función del asistente es del 95,3 %, frente al 63,1 % real: sumar rendimientos simples en una caída larga exagera la pérdida (y en las subidas largas la subestima, como enseña la figura entre 2010 y 2018). Y el Sharpe con un tipo libre del 2 % anual pasa de 0,290 a −24,659: restar el 2 % a cada rendimiento diario es restar un 504 % al año. El segundo error es tan grande que alguien lo vería; el primero, en un informe con muchas cifras, no necesariamente.

Hay un quinto tipo de error, el más fácil: la API inventada. Un asistente escribe una llamada con un nombre verosímil que la librería no tiene. Es el caso bueno, porque falla en cuanto se ejecuta:

IN [06]python
cartera.rolling(20).zscore()
OUT [06]Una API que no existe
AttributeError: 'Rolling' object has no attribute 'zscore'

Lo peligroso no es el código que falla, sino el que funciona. Por eso el orden de las lecciones 6.5.1 a 6.5.3: los tests se escriben antes y a mano, los casos se eligen en los bordes (primer día, cambio de horario, segunda sesión) y las propiedades se comprueban siempre (un drawdown no puede pasar de −100 %).

Dependencias inventadas

Un modelo que genera código también genera sus import. Si el nombre que inventa suena bien, la persona que lo recibe hace lo natural: pip install con ese nombre. Si el paquete no existe, el comando falla y no pasa nada. El riesgo empieza cuando alguien registra ese nombre en el índice público con código malicioso, sabiendo que los modelos lo repiten. Spracklen et al. describen las alucinaciones de paquetes como un fenómeno «persistente y sistemático» de los modelos que generan código, no como un accidente raro. Es una variante del typosquatting (registrar nombres parecidos a los de paquetes populares para aprovechar las erratas) que no necesita errata: el nombre lo pone el modelo.

Hay una trampa más antigua y más sutil: el nombre del import no siempre es el del paquete. import sklearn es de scikit-learn; el paquete sklearn de PyPI existe, pero su descripción dice «paquete sklearn obsoleto, usa scikit-learn en su lugar» (consultado el 23 de octubre de 2026).

La defensa es comprobar cada dependencia nueva antes de instalarla. PyPI publica los metadatos de cada proyecto en una API JSON (/pypi/<proyecto>/json, con la descripción, las versiones y la fecha de subida de cada fichero), y un proyecto que no existe devuelve 404. La celda levanta un índice simulado con esa misma forma de respuesta, sin red. Los seis paquetes reales llevan sus datos de PyPI del 23 de octubre de 2026 (primera subida y número de versiones); quantmetrics-ext y pandsa son ficticios, registrados en el simulador hace 6 y 20 días. En el PyPI real, en esa fecha, no existía ninguno de los dos.

IN [07]python
import json
import threading
from datetime import date, timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer

HOY = date(2026, 10, 23)        # fecha fija: nada depende del reloj

def proyecto(nombre, resumen, primera, versiones):
    dias = np.linspace(0, (HOY - primera).days, versiones)
    return {"info": {"name": nombre, "summary": resumen},
            "releases": {f"0.{i}": [{"upload_time_iso_8601":
                         f"{primera + timedelta(days=int(d))}"
                         "T12:00:00Z"}] for i, d in enumerate(dias)}}

INDICE = {p["info"]["name"]: p for p in [
    proyecto("pandas", "Powerful data structures for data "
             "analysis", date(2009, 12, 25), 120),
    proyecto("numpy", "Fundamental package for array computing",
             date(2006, 12, 2), 152),
    proyecto("scikit-learn", "A set of python modules for machine "
             "learning", date(2011, 9, 22), 76),
    proyecto("sklearn", "deprecated sklearn package, use "
             "scikit-learn instead", date(2015, 7, 15), 10),
    proyecto("quantstats", "Portfolio analytics for quants",
             date(2019, 5, 1), 82),
    proyecto("pyarrow", "Python library for Apache Arrow",
             date(2017, 3, 16), 66),
    proyecto("quantmetrics-ext", "Quant metrics extensions",
             HOY - timedelta(days=6), 1),
    proyecto("pandsa", "Data analysis toolkit",
             HOY - timedelta(days=20), 1),
]}

class Indice(BaseHTTPRequestHandler):
    def do_GET(self):
        partes = self.path.strip("/").split("/")   # pypi/<p>/json
        datos = INDICE.get(partes[1]) if len(partes) == 3 else None
        cuerpo = json.dumps(datos).encode() if datos else b"{}"
        self.send_response(200 if datos else 404)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(cuerpo)

    def log_message(self, *args):
        pass

servidor = HTTPServer(("127.0.0.1", 0), Indice)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
URL = f"http://127.0.0.1:{servidor.server_port}"
print(len(INDICE), "proyectos en el índice simulado")
OUT [07]Un índice de paquetes simulado
8 proyectos en el índice simulado

El revisor de dependencias consulta cada nombre y aplica cinco comprobaciones: que exista, que no sea demasiado nuevo, que tenga historia (más de una versión), que no esté marcado como obsoleto y que no se parezca demasiado al nombre de un paquete popular sin serlo:

IN [08]python
import difflib
import urllib.error
import urllib.request

POPULARES = ["pandas", "numpy", "scikit-learn", "scipy",
             "matplotlib", "pyarrow", "polars", "requests"]

def consultar(nombre):
    try:
        with urllib.request.urlopen(f"{URL}/pypi/{nombre}/json",
                                    timeout=5) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        raise

def revisar(nombre, dias_minimos=90):
    datos = consultar(nombre)
    if datos is None:
        return "NO EXISTE: no instalar (¿nombre inventado?)"
    subidas = [f["upload_time_iso_8601"][:10]
               for fs in datos["releases"].values() for f in fs]
    edad = (HOY - date.fromisoformat(min(subidas))).days
    avisos = []
    if edad < dias_minimos:
        avisos.append(f"publicado hace {edad} días")
    if len(datos["releases"]) < 2:
        avisos.append("una sola versión")
    if "deprecated" in datos["info"]["summary"].lower():
        avisos.append("obsoleto: " + datos["info"]["summary"])
    parecido = difflib.get_close_matches(nombre, POPULARES, 1, 0.8)
    if parecido and parecido[0] != nombre:
        avisos.append(f"se parece a {parecido[0]}")
    return "; ".join(avisos) or f"ok ({edad // 365} años)"

PROPUESTA = ["pandas", "numpy", "sklearn", "quantmetrics-ext",
             "finsignal-toolkit", "pandsa", "pyarrow"]
for nombre in PROPUESTA:
    print(f"{nombre:<18} {revisar(nombre)}")
OUT [08]Revisar las dependencias que propone el asistente
pandas             ok (16 años)
numpy              ok (19 años)
sklearn            obsoleto: deprecated sklearn package, use scikit-learn instead
quantmetrics-ext   publicado hace 6 días; una sola versión
finsignal-toolkit  NO EXISTE: no instalar (¿nombre inventado?)
pandsa             publicado hace 20 días; una sola versión; se parece a pandas
pyarrow            ok (9 años)
Fig. — Las dependencias que propone el asistente en el índice simulado: antigüedad y número de versiones. Los paquetes consolidados quedan arriba a la derecha; los registrados hace días, con una versión, abajo a la izquierda. finsignal-toolkit no aparece: no existe

El revisor deja pasar los tres paquetes consolidados y para los otros cuatro, cada uno por una razón distinta. sklearn existe y tiene diez años, pero está marcado como obsoleto: el asistente quería scikit-learn. finsignal-toolkit no existe: es el caso inofensivo, mientras nadie lo registre. quantmetrics-ext es el caso del ataque: un nombre que un modelo inventaría, registrado hace 6 días, con una sola versión. Y pandsa, registrado hace 20 días con una versión, se parece a pandas: es una errata, venga de una persona o de un modelo.

Ninguna de esas señales prueba que un paquete sea malicioso, y un paquete nuevo puede ser legítimo. Lo que hacen es parar la instalación automática hasta que una persona mire de dónde sale: su repositorio, quién lo publica, si el proyecto existe fuera del índice.

Lockfiles, hashes y periodo de espera

La lección 0.2.2 presentó uv.lock: el fichero que fija la versión exacta de cada paquete del entorno, su origen y el sha256 de cada fichero; guarda además la fecha de subida de cada fichero al índice. Es lo que hace reproducible el curso: el uv.lock del entorno principal fija más de 160 paquetes. Para la seguridad, el lockfile tiene otra virtud: convierte cada cambio de dependencias en un diff revisable. Cuando un asistente añade un paquete, el lockfile nuevo dice exactamente qué entra, de dónde y de cuándo.

La celda audita un cambio de lockfile con la forma de uv.lock (tablas [[package]] con name, version, source y ficheros con hash y upload-time; los hashes están acortados):

IN [09]python
import tomllib

ANTES = """
[[package]]
name = "pandas"
version = "3.0.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
  { hash = "sha256:9f1c…", upload-time = "2026-09-30T10:01:12Z" },
]
"""
DESPUES = ANTES + """
[[package]]
name = "quantmetrics-ext"
version = "0.0.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
  { hash = "sha256:52ab…", upload-time = "2026-10-17T21:40:03Z" },
]

[[package]]
name = "finsignal"
version = "1.2.0"
source = { git = "https://example.com/u/finsignal.git" }
"""

def paquetes(texto):
    return {p["name"]: p for p in tomllib.loads(texto)["package"]}

def auditar(antes, despues, espera_dias=7):
    viejos, nuevos = paquetes(antes), paquetes(despues)
    for nombre in sorted(set(nuevos) - set(viejos)):
        p = nuevos[nombre]
        avisos = []
        if p["source"].get("registry") != "https://pypi.org/simple":
            avisos.append(f"origen fuera de PyPI: {p['source']}")
        ficheros = p.get("wheels", []) + (
            [p["sdist"]] if "sdist" in p else [])
        if not ficheros or not all("hash" in f for f in ficheros):
            avisos.append("sin hash")
        for f in ficheros:
            subida = date.fromisoformat(f["upload-time"][:10])
            if (HOY - subida).days < espera_dias:
                avisos.append(f"subido hace {(HOY - subida).days} "
                              "días")
        print(f"nuevo {nombre} {p['version']}: "
              f"{'; '.join(avisos) or 'ok'}")

auditar(ANTES, DESPUES)
OUT [09]Auditar el lockfile de una propuesta
nuevo finsignal 1.2.0: origen fuera de PyPI: {'git': 'https://example.com/u/finsignal.git'}; sin hash
nuevo quantmetrics-ext 0.0.1: subido hace 6 días

Dos paquetes nuevos y dos avisos distintos. finsignal no viene de PyPI sino de un repositorio git, sin hash: lo que se instale dependerá de lo que haya en ese repositorio el día de la instalación. quantmetrics-ext viene de PyPI con su hash, pero su único fichero se subió hace 6 días. El hash garantiza que todos instalan exactamente lo mismo; no dice nada de si eso es legítimo. Por eso las dos comprobaciones van juntas.

En un repositorio, esas comprobaciones se traducen en unas pocas órdenes y opciones de uv y pip, todas de su documentación:

uv add pandas                    # resuelve y escribe pyproject.toml y uv.lock
uv sync --locked                 # en la CI: falla si uv.lock no está al día
uv pip compile requirements.in --generate-hashes -o requirements.txt
pip install --require-hashes -r requirements.txt

Y en el pyproject.toml, un periodo de espera: uv solo tiene en cuenta los ficheros subidos antes de la fecha o de la antigüedad que se indique (exclude-newer, que admite duraciones como 7 days), así que una versión publicada ayer no entra en el entorno hasta que pase ese plazo:

IN [10]toml
[tool.uv]
exclude-newer = "7 days"

Secretos en el contexto, en el código y en los registros

La lección 4.4.5 enseñó dónde guardar las credenciales de un bróker, cómo tacharlas en los registros y cómo escanear un repositorio antes del commit. Un asistente de código añade caminos nuevos por los que una clave puede salir:

.env del proyectoContextoProveedorCódigo generadoRepositorioRegistros
Fuente Mermaid
flowchart TB
  E[.env del proyecto] --> C[Contexto]
  C --> P[Proveedor]
  C --> G[Código generado]
  G --> R[Repositorio]
  C --> L[Registros]
  • Contexto: un asistente lee ficheros del proyecto para entender el código. Si lee un .env, la clave viaja al proveedor del modelo dentro del contexto.
  • Código generado: si la clave estaba en el contexto, el modelo puede escribirla en el código «para que funcione», en un test o en un ejemplo.
  • Registros: las sesiones de los agentes guardan lo que leyeron y lo que ejecutaron; un env o un print de la configuración deja la clave en esos registros.

Las herramientas tienen reglas para excluir ficheros, con matices que conviene leer en su documentación (consultada el 23 de octubre de 2026). La de Claude Code muestra cómo impedir que lea los .env con reglas de denegación en su configuración ("deny": ["Read(./.env)", "Read(./.env.*)"]). La de Cursor describe .cursorignore, que bloquea ficheros para sus funciones de IA, pero advierte de que la protección completa no está garantizada y de que el terminal y las herramientas MCP del agente no pueden bloquear el acceso a esos ficheros. La conclusión es la de la lección 4.4.5: la clave no debe estar en el árbol del proyecto, sino en el gestor de secretos del sistema o de la nube, y lo que sí está en el árbol se trata como si fuera a salir.

Dos defensas ejecutables. La primera filtra el contexto antes de enviarlo a un modelo: no incluye los ficheros que coinciden con patrones de secretos y tacha en los demás lo que parece una clave. Las claves de juguete empiezan por FALSA_, como en la lección 4.4.5, y no se imprimen: solo su huella.

IN [11]python
import fnmatch
import hashlib
import math
import re
from collections import Counter

def falsa(nombre, largo=40):
    return "FALSA_" + hashlib.sha256(nombre.encode()).hexdigest()[
        : largo - 6]

def huella(valor):
    return hashlib.sha256(valor.encode()).hexdigest()[:8]

CLAVE = falsa("BROKER_SECRET")
REPO = {
    ".env": f"BROKER_SECRET={CLAVE}\n",
    "config.py": 'BROKER_URL = "https://api.broker.example"\n'
                 'BROKER_SECRET = os.environ["BROKER_SECRET"]\n',
    "senales.py": "def momentum(p, n=20):\n"
                  "    return p.pct_change(n)\n",
    "notas.md": f"probado con la clave {CLAVE[:30]}… y va\n",
}
EXCLUIR = [".env", ".env.*", "*.pem", "*secret*"]
TOKEN = re.compile(r"[A-Za-z0-9_+/=-]{20,}")

def entropia(t):
    n = len(t)
    return -sum(c / n * math.log2(c / n) for c in Counter(t).values())

def contexto_seguro(repo):
    partes, informe = [], []
    for nombre, texto in sorted(repo.items()):
        if any(fnmatch.fnmatch(nombre, g) for g in EXCLUIR):
            informe.append(f"{nombre}: excluido")
            continue
        def tachar(m):
            t = m.group(0)
            if t.startswith("FALSA_") or entropia(t) >= 3.5:
                informe.append(f"{nombre}: tachado {huella(t)}")
                return "[TACHADO]"
            return t
        partes.append(f"# {nombre}\n{TOKEN.sub(tachar, texto)}")
    return "\n".join(partes), informe

enviar, informe = contexto_seguro(REPO)
print("\n".join(informe))
print("¿alguna clave en lo que se envía?", CLAVE[:20] in enviar)
OUT [11]Filtrar el contexto antes de enviarlo
.env: excluido
notas.md: tachado 5ce46225
¿alguna clave en lo que se envía? False

El filtro excluye el .env entero y tacha la clave que alguien pegó en notas.md, una fuga habitual: notas, borradores y salidas de notebooks guardadas. config.py pasa sin cambios porque lee la clave del entorno y no la contiene. Lo que se envía no lleva ninguna clave. El informe identifica lo tachado por su huella, como en la lección 4.4.5, para poder buscar dónde más aparece sin volver a escribirla.

La segunda defensa revisa lo que el asistente devuelve: solo las líneas añadidas en su propuesta, que es lo que llegaría al repositorio. En el ejemplo, para «simplificar» la configuración, el asistente ha sustituido la lectura de la variable de entorno por el valor que vio en el contexto de otra sesión:

IN [12]python
import difflib as dl

propuesta_config = ('BROKER_URL = "https://api.broker.example"\n'
                    f'BROKER_SECRET = "{CLAVE}"  # simplificado\n')

def secretos_en_diff(antes, despues, nombre):
    hallazgos = []
    for linea in dl.unified_diff(antes.splitlines(),
                                 despues.splitlines(), lineterm=""):
        if not linea.startswith("+") or linea.startswith("+++"):
            continue
        for t in TOKEN.findall(linea):
            if t.startswith("FALSA_") or entropia(t) >= 3.5:
                hallazgos.append(f"{nombre}: +{len(t)} caracteres, "
                                 f"huella {huella(t)}")
    return hallazgos

print(secretos_en_diff(REPO["config.py"], propuesta_config,
                       "config.py") or "sin hallazgos")
print(secretos_en_diff(REPO["senales.py"], REPO["senales.py"]
                       + "def media(p, n=20):\n"
                         "    return p.rolling(n).mean()\n",
                       "senales.py") or "sin hallazgos")
OUT [12]Escanear el diff de la propuesta
['config.py: +40 caracteres, huella 3f1154ad']
sin hallazgos

El escáner del diff encuentra la clave en la línea añadida de config.py y no da avisos en el cambio legítimo de senales.py. Mirar solo las líneas añadidas tiene dos ventajas: es rápido y no repite los avisos ya aceptados del resto del repositorio. Si la clave llegó a escribirse en el repositorio, aunque se borre después, hay que rotarla: sigue en el historial (lección 4.4.5).

Una lista para cada propuesta

RiesgoComprobaciónQuién la hace
Código plausible y malTests a mano en los bordes y propiedades, escritos antes (lecciones 6.5.1 y esta)Persona; los ejecuta la CI
API inventadaEjecutar: falla solaCI
Sesgos del backtestRevisor estático, truncado y placebo (lección 6.5.2)CI; la persona lee el informe
Dependencia nuevaExiste, antigüedad, versiones, obsoleta, nombre parecido; leer su repositorioPersona, con ayuda de un script
LockfileDiff de uv.lock: origen, hashes, fecha de subida; uv sync --lockedCI
Versión recién publicadaPeriodo de espera (exclude-newer)Configuración
Vulnerabilidades conocidaspip-audit o equivalenteCI
Secretos en el contextoFuera del árbol; filtro del contexto; reglas del asistenteConfiguración y persona
Secretos en el diffEscaneo de las líneas añadidas antes del commit (lección 4.4.5)Gancho previo al commit y CI

Ninguna de estas comprobaciones necesita confiar en el asistente, y ninguna le pide que se revise a sí mismo. Es la misma regla que la de los agentes de la lección 6.4.5: el modelo propone y un proceso determinista decide.

Ejercicios

  1. Un asistente propone vol_anual = r.std() * np.sqrt(365) para acciones que cotizan unas 252 sesiones al año. Escribe un test a mano que lo cace y calcula cuánto se equivoca en la cartera del nivel 2.
Solución
IN [13]python
def vol_anual_asistente(r):
    return float(r.std() * np.sqrt(365))

def vol_anual(r, sesiones=252):
    return float(r.std() * np.sqrt(sesiones))

# a mano: rendimientos de +1 % y -1 % alternos -> desviación 0,01
r_test = pd.Series([0.01, -0.01] * 126)
d = r_test.std()
print(f"desviación diaria {d:.5f}; esperada anual "
      f"{d * np.sqrt(252):.4f}; asistente "
      f"{vol_anual_asistente(r_test):.4f}")
print(f"cartera: {vol_anual(cartera):.4f} frente a "
      f"{vol_anual_asistente(cartera):.4f} "
      f"({vol_anual_asistente(cartera) / vol_anual(cartera) - 1:.1%})")
OUT [13]Anualizar con 365
desviación diaria 0.01002; esperada anual 0.1591; asistente 0.1914
cartera: 0.2012 frente a 0.2422 (20.4%)

Con rendimientos alternos de +1 % y −1 %, la desviación diaria es 0,01002 y la volatilidad anual correcta, 0,1591; la del asistente, 0,1914. En la cartera del nivel 2, 0,2012 frente a 0,2422: un 20,4 % más, que es 365/252−1\sqrt{365/252} - 1. En cripto, que cotiza todos los días, 365 sería lo correcto; por eso el número de sesiones es un parámetro y el test fija el de acciones.

  1. Muchos nombres de import no coinciden con el nombre del paquete en PyPI (sklearn y scikit-learn, yaml y PyYAML, cv2 y opencv-python). Añade al revisor de dependencias una tabla de equivalencias que, antes de consultar el índice, traduzca el nombre del import al del paquete y avise del cambio.
Solución
IN [14]python
IMPORT_A_PAQUETE = {"sklearn": "scikit-learn", "yaml": "PyYAML",
                    "cv2": "opencv-python", "bs4": "beautifulsoup4"}

def revisar_import(nombre_import):
    paquete = IMPORT_A_PAQUETE.get(nombre_import, nombre_import)
    nota = (f" (import {nombre_import} -> paquete {paquete})"
            if paquete != nombre_import else "")
    return f"{paquete}: {revisar(paquete)}{nota}"

for n in ["sklearn", "pandas", "quantmetrics"]:
    print(revisar_import(n))
OUT [14]Del import al paquete
scikit-learn: ok (15 años) (import sklearn -> paquete scikit-learn)
pandas: ok (16 años)
quantmetrics: NO EXISTE: no instalar (¿nombre inventado?)

sklearn se traduce a scikit-learn y pasa; pandas no cambia; quantmetrics no está en la tabla y no existe en el índice. La tabla tiene que mantenerse a mano y es corta a propósito: solo los casos conocidos. Un nombre que no está en ella se consulta tal cual, y si no existe, se para. Lo que nunca hay que hacer es «arreglar» un nombre que no existe buscando en el índice el más parecido e instalándolo: es exactamente lo que explota el ataque.

  1. El filtro de contexto excluye .env por nombre. ¿Qué pasa si la clave está en config.local.json? Propón dos cambios: uno en el filtro y otro en el repositorio.
Solución

El filtro excluye por nombre de fichero, así que config.local.json pasaría entero. Si la clave tiene la forma de las de juguete o una entropía alta, el tachado por contenido la cazaría igualmente; si es corta o legible (una contraseña como trading2026), no. Dos cambios:

  • En el filtro: invertir la lógica. En lugar de una lista de exclusiones, una lista de inclusiones (*.py, *.md, *.toml del código fuente) y todo lo demás fuera. Lo que no se ha decidido enviar, no se envía.
  • En el repositorio: que la configuración local con secretos no viva en el árbol del proyecto, sino en el gestor de secretos o en un directorio del usuario fuera del repositorio (lección 4.4.5), y que config.local.json esté en .gitignore y en las reglas de denegación del asistente como segunda línea de defensa.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un asistente calcula el drawdown con curva = r.cumsum() y (curva - curva.cummax()).min(). ¿Por qué es peligroso?

    • ANo lo es: la suma de rendimientos y su producto son equivalentes
    • BPorque es lento con series largas
    • CPorque suma rendimientos simples en vez de componerlos: da caídas en puntos de rentabilidad acumulada, no en porcentaje del máximo, puede pasar de −100 % y en datos del curso exagera la peor caída de la cartera
    • DPorque cummax no admite valores ausentes
    Ver respuesta

    Respuesta C. Porque suma rendimientos simples en vez de componerlos: da caídas en puntos de rentabilidad acumulada, no en porcentaje del máximo, puede pasar de −100 % y en datos del curso exagera la peor caída de la cartera

    Con rendimientos de +50 %, −60 % y −60 %, la caída real es del 84 %; la función del asistente da −120 %, algo imposible. El test de la propiedad «el drawdown está entre −1 y 0» lo caza al instante. En la cartera equiponderada del nivel 2, la versión del asistente da una peor caída mucho mayor que la real.

  2. La versión corregida del drawdown usa (1 + r).cumprod() pero falla el test con un primer día de −10 %. ¿Por qué?

    • APorque la curva empieza en 0,9 y el máximo acumulado también: la caída desde el capital inicial de 1 no se ve; hay que añadir el punto de partida
    • BPorque cumprod redondea
    • CPorque el test está mal
    • DPorque los rendimientos negativos no se componen
    Ver respuesta

    Respuesta A. Porque la curva empieza en 0,9 y el máximo acumulado también: la caída desde el capital inicial de 1 no se ve; hay que añadir el punto de partida

    El máximo previo de la curva tiene que incluir el capital inicial. Es un segundo error dentro de la corrección del primero, y solo lo encuentra un caso escrito a mano para ese borde.

  3. ¿Qué son las dependencias «alucinadas» y por qué son un riesgo de seguridad?

    • APaquetes con errores de programación
    • BNombres de paquetes que un modelo inventa al generar código: si alguien registra ese nombre en PyPI con código malicioso, quien siga la sugerencia lo instala. Spracklen et al. (2025) midieron un porcentaje medio de paquetes inventados de al menos el 5,2 % en modelos comerciales y el 21,7 % en modelos abiertos
    • CPaquetes que el asistente instala sin permiso
    • DVersiones antiguas de paquetes reales
    Ver respuesta

    Respuesta B. Nombres de paquetes que un modelo inventa al generar código: si alguien registra ese nombre en PyPI con código malicioso, quien siga la sugerencia lo instala. Spracklen et al. (2025) midieron un porcentaje medio de paquetes inventados de al menos el 5,2 % en modelos comerciales y el 21,7 % en modelos abiertos

    El paquete no existe hasta que alguien lo registra. En 2024, un investigador registró un paquete vacío con un nombre que los modelos inventaban, huggingface-cli, y recibió más de 30.000 descargas reales en tres meses.

  4. Un asistente añade quantmetrics-ext a las dependencias. En el índice existe, con una sola versión publicada hace 6 días. ¿Qué haces?

    • AInstalarlo: existe en el índice
    • BInstalarlo con --require-hashes
    • CCambiarlo por la versión anterior
    • DNo instalarlo hasta saber de dónde sale: un paquete muy reciente, con una versión y con un nombre que coincide con lo que inventaría un modelo es la forma típica del ataque; buscar el proyecto original y su repositorio, o escribir la función
    Ver respuesta

    Respuesta D. No instalarlo hasta saber de dónde sale: un paquete muy reciente, con una versión y con un nombre que coincide con lo que inventaría un modelo es la forma típica del ataque; buscar el proyecto original y su repositorio, o escribir la función

    Existir en el índice no dice nada de quién lo publicó. Los hashes garantizan que instalas lo mismo que se fijó, no que lo fijado sea legítimo. Un periodo de espera (como exclude-newer de uv) evita además instalar lo que se publicó hace pocos días.

  5. ¿Basta con un .cursorignore o con una regla de denegación de lectura para que un asistente no vea el .env?

    • ASí, son garantías
    • BNo: la documentación de Cursor dice que la protección no está garantizada y que el terminal y las herramientas MCP del agente no respetan el fichero. Lo seguro es que las claves no estén en el directorio de trabajo y revisar los cambios antes del commit
    • CSolo si el repositorio es privado
    • DSí, si el .env también está en .gitignore
    Ver respuesta

    Respuesta B. No: la documentación de Cursor dice que la protección no está garantizada y que el terminal y las herramientas MCP del agente no respetan el fichero. Lo seguro es que las claves no estén en el directorio de trabajo y revisar los cambios antes del commit

    Las reglas reducen el riesgo, pero un agente con terminal puede leer cualquier fichero que el usuario pueda leer. Los secretos van fuera del árbol del proyecto (gestor del sistema o de la nube, lección 4.4.5) y el diff de cada propuesta se escanea antes del commit.

Ideas clave

  • El código plausible pero incorrecto no falla: un drawdown con cumsum, un tipo libre anual restado a rendimientos diarios, un VWAP que no se reinicia cada sesión o una zona horaria localizada como UTC dan números razonables y equivocados.
  • Los tests que los cazan son casos a mano en los bordes (primer día negativo, cambio de horario, segunda sesión) y propiedades (el drawdown entre −1 y 0). La corrección del drawdown tenía un segundo error que solo vio un caso a mano.
  • Las dependencias inventadas son un riesgo medido: un porcentaje medio de paquetes inventados de al menos el 5,2 % en modelos comerciales y el 21,7 % en abiertos (Spracklen et al., USENIX Security 2025). Un nombre inventado y registrado después recibió más de 30.000 descargas en tres meses.
  • Cada dependencia nueva se comprueba antes de instalarla: que exista, su antigüedad, sus versiones, si está obsoleta y si se parece a un paquete popular. Después, lockfile con versiones exactas y hashes (uv.lock), --locked en la integración continua y un periodo de espera para lo recién publicado.
  • Los secretos no se protegen con reglas del asistente sino sacándolos del árbol del proyecto; lo que se envía como contexto se filtra y el diff de cada propuesta se escanea antes del commit.

Recursos