---
title: "6.5.3 Riesgos del código generado por IA"
description: "Código plausible pero incorrecto cazado con tests, dependencias inventadas ante un índice simulado, lockfiles con hashes y secretos fuera del contexto."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/riesgos-del-codigo-generado-por-ia/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/riesgos-del-codigo-generado-por-ia.png"
---

# 6.5.3 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.5 · IA como copiloto del quant
- Duración: 70 min
- Requisitos: [0.2.2 · Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles](https://pythonparatrading.com/curso-python-trading/nivel-0/entornos-virtuales-y-dependencias-con-uv/), [0.2.6 · Introducción a los asistentes de código con IA: prompts, revisión y tests](https://pythonparatrading.com/curso-python-trading/nivel-0/asistentes-de-codigo-con-ia/), [4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos](https://pythonparatrading.com/curso-python-trading/nivel-4/credenciales-seguras-y-permisos-minimos/), [6.5.1 · Asistentes de código con un flujo de tests primero](https://pythonparatrading.com/curso-python-trading/nivel-6/asistentes-de-codigo-con-tests-primero/)

## Al terminar esta lección

- Reconocer los errores plausibles más comunes en código cuantitativo generado (drawdown aritmético, tipo libre sin desanualizar, VWAP sin reiniciar por sesión, zona horaria mal localizada) y escribir los tests que los cazan.
- Explicar el riesgo de las dependencias inventadas con su evidencia publicada y comprobar cada dependencia nueva contra un índice de paquetes antes de instalarla.
- Auditar un lockfile: versiones exactas, hashes, origen y fecha de publicación de cada paquete nuevo, como hace el curso con uv.
- Mantener los secretos fuera del contexto de un asistente y revisar sus cambios en busca de claves antes del commit.

## 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:

```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:

```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}")
```
```output
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:

```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}")
```
```output
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:

```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")}
```
```output
{'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:

```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()))
```
```output
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:

```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}")
```
```output
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:

```python raises
cartera.rolling(20).zscore()
```
```output
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.

```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")
```
```output
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:

```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)}")
```
```output
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.* Dispersión en escala logarítmica. pandas, numpy, scikit-learn, pyarrow y quantstats tienen entre 7 y 20 años y entre 66 y 152 versiones. sklearn, obsoleto, 11 años y 10 versiones. pandsa, 20 días y 1 versión. quantmetrics-ext, destacado, 6 días y 1 versión, a la izquierda de la línea de 90 días.

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.

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

Las comprobaciones de la celda reducen el riesgo; no lo eliminan. Un paquete antiguo, con cientos de versiones y
nombre correcto también puede publicar una versión maliciosa si roban las credenciales de quien lo mantiene. Contra
eso sirven el lockfile con hashes, un periodo de espera antes de aceptar versiones recién publicadas y las
herramientas que comparan las versiones instaladas con bases de datos de vulnerabilidades conocidas, como `pip-audit`
de la Python Packaging Authority.

</aside>

## 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):

```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)
```
```output
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:

```bash
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:

```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:

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

```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)
```
```output
.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:

```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")
```
```output
['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

| 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

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

```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%})")
```
```output
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 $\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.

2. 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.**

```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))
```
```output
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.

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

## Autoevaluación

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

   - A. No lo es: la suma de rendimientos y su producto son equivalentes
   - B. Porque es lento con series largas
   - 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
   - D. Porque `cummax` no admite valores ausentes

   *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é?

   - 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
   - B. Porque `cumprod` redondea
   - C. Porque el test está mal
   - D. Porque los rendimientos negativos no se componen

   *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?

   - A. Paquetes con errores de programación
   - 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
   - C. Paquetes que el asistente instala sin permiso
   - D. Versiones antiguas de paquetes reales

   *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?

   - A. Instalarlo: existe en el índice
   - B. Instalarlo con `--require-hashes`
   - C. Cambiarlo por la versión anterior
   - 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

   *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`?

   - A. Sí, son garantías
   - 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
   - C. Solo si el repositorio es privado
   - D. Sí, si el `.env` también está en `.gitignore`

   *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

### Documentación

- [Lanyado, B. (2024): Diving Deeper into AI Package Hallucinations. Lasso Security](https://lasso.security/blog/ai-package-hallucinations) · lasso.security
- [PyPI: API JSON del índice](https://docs.pypi.org/api/json/) · docs.pypi.org
- [uv: referencia de opciones (exclude-newer)](https://docs.astral.sh/uv/reference/settings/) · docs.astral.sh
- [pip: instalaciones seguras con hashes](https://pip.pypa.io/en/stable/topics/secure-installs/) · pip.pypa.io

### 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](https://arxiv.org/abs/2406.10279) · arxiv.org
- [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](https://arxiv.org/abs/2108.09293) · arxiv.org
- [Perry, N. et al. (2023): Do Users Write More Insecure Code with AI Assistants? ACM CCS 2023. arXiv:2211.03622](https://arxiv.org/abs/2211.03622) · arxiv.org

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

Lección siguiente: [6.6.1 · Cuándo los datos son «big»: ticks, libros, opciones y universos](https://pythonparatrading.com/curso-python-trading/nivel-6/cuando-los-datos-son-big/)
