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.
- Módulo
- 6.5 · IA como copiloto del quant
- Duración
- 70 min
- Requisitos
- 0.2.2 · Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles, 0.2.6 · Introducción a los asistentes de código con IA: prompts, revisión y tests, 4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos, 6.5.1 · Asistentes de código con un flujo de tests primero
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
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:
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:
| Estudio | Qué midió | Resultado |
|---|---|---|
| Pearce et al., IEEE S&P 2022 | 1.689 programas generados por GitHub Copilot en 89 escenarios de seguridad | En torno al 40 %, vulnerables |
| Perry et al., ACM CCS 2023 | Un estudio con usuarios, con y sin asistente | Con asistente escribieron código menos seguro y creían más a menudo que era seguro |
| Spracklen et al., USENIX Security 2025 | 576.000 muestras de código de 16 modelos en dos lenguajes | Porcentaje 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, 2024 | Registró un paquete vacío con un nombre que los modelos inventaban | Má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:
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}")drawdown -0.2000 · Sharpe -4.58Los 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:
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}")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 veranoLas 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:
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")}{'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:
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()))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:
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}")peor caída asistente -0.953 correcto -0.631
Sharpe con rf 2 % asistente -24.659 correcto 0.290Con 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:
cartera.rolling(20).zscore()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.
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")8 proyectos en el índice simuladoEl 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:
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)}")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)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):
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)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íasDos 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.txtY 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:
[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:
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
envo unprintde 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.
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).env: excluido
notas.md: tachado 5ce46225
¿alguna clave en lo que se envía? FalseEl 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:
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")['config.py: +40 caracteres, huella 3f1154ad']
sin hallazgosEl 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
| Riesgo | Comprobación | Quién la hace |
|---|---|---|
| Código plausible y mal | Tests a mano en los bordes y propiedades, escritos antes (lecciones 6.5.1 y esta) | Persona; los ejecuta la CI |
| API inventada | Ejecutar: falla sola | CI |
| Sesgos del backtest | Revisor estático, truncado y placebo (lección 6.5.2) | CI; la persona lee el informe |
| Dependencia nueva | Existe, antigüedad, versiones, obsoleta, nombre parecido; leer su repositorio | Persona, con ayuda de un script |
| Lockfile | Diff de uv.lock: origen, hashes, fecha de subida; uv sync --locked | CI |
| Versión recién publicada | Periodo de espera (exclude-newer) | Configuración |
| Vulnerabilidades conocidas | pip-audit o equivalente | CI |
| Secretos en el contexto | Fuera del árbol; filtro del contexto; reglas del asistente | Configuración y persona |
| Secretos en el diff | Escaneo 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
- 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
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%})")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 . 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.
- Muchos nombres de
importno coinciden con el nombre del paquete en PyPI (sklearnyscikit-learn,yamlyPyYAML,cv2yopencv-python). Añade al revisor de dependencias una tabla de equivalencias que, antes de consultar el índice, traduzca el nombre delimportal del paquete y avise del cambio.
Solución
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))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.
- El filtro de contexto excluye
.envpor nombre. ¿Qué pasa si la clave está enconfig.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,*.tomldel 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.jsonesté en.gitignorey en las reglas de denegación del asistente como segunda línea de defensa.
Respuesta plegada en cada pregunta
Autoevaluación
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
cummaxno 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.
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
cumprodredondea - 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.
¿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.Un asistente añade
quantmetrics-exta 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-newerde uv) evita además instalar lo que se publicó hace pocos días.¿Basta con un
.cursorignoreo 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
.envtambié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),--lockeden 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
Documentación
Libros
- Spracklen, J. et al. (2025): We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs. USENIX Security 2025. arXiv:2406.10279
- Pearce, H. et al. (2022): Asleep at the Keyboard? Assessing the Security of GitHub Copilot's Code Contributions. IEEE S&P 2022. arXiv:2108.09293
- Perry, N. et al. (2023): Do Users Write More Insecure Code with AI Assistants? ACM CCS 2023. arXiv:2211.03622