---
title: "7.1.1 Estructura de proyecto, configuración y calidad"
description: "Del notebook a un repositorio con src/, Pydantic Settings por capas, Ruff, pre-commit y tests con un broker simulado y mensajes grabados."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/estructura-de-proyecto-configuracion-y-calidad/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/estructura-de-proyecto-configuracion-y-calidad.png"
---

# 7.1.1 · Estructura de proyecto, configuración y calidad: del notebook al repositorio

> Un bot que vive en un notebook funciona hasta que alguien lo toca. Esta lección lo convierte en un repositorio: paquete en src/, configuración por perfiles validada con Pydantic Settings, Ruff, pre-commit y tests con un broker simulado y con mensajes grabados de tres brokers. Después siembra catorce defectos y mide cuáles atrapa cada red y cuáles no atrapa ninguna.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.1 · Ingeniería de software para trading
- 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.5 · Git y GitHub: commits, ramas, pull requests y .gitignore](https://pythonparatrading.com/curso-python-trading/nivel-0/git-y-github-para-trading/), [0.4.2 · Tipado: type hints, mypy o ty y Pydantic para configuraciones](https://pythonparatrading.com/curso-python-trading/nivel-0/tipado-type-hints-y-pydantic/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/), [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/), [4.5.5 · Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar](https://pythonparatrading.com/curso-python-trading/nivel-4/tolerancia-a-fallos-idempotencia-y-reconciliacion/)

## Al terminar esta lección

- Organizar un proyecto de trading con el layout src/, un único pyproject.toml y una regla de dependencias entre módulos.
- Cargar la configuración por capas (YAML base, perfil, entorno) con Pydantic Settings, saber de qué fuente sale cada valor y rechazar claves desconocidas.
- Configurar Ruff con las familias de reglas que atrapan errores reales en código de trading (fechas sin zona, assert, except desnudo, valores por defecto mutables).
- Escribir tests con un broker simulado determinista y tests de contrato con mensajes grabados de varios brokers.
- Encadenar las comprobaciones en pre-commit y medir qué defectos atrapa cada una.

## Del notebook al repositorio

Un bot nace en un notebook: una celda descarga precios, otra calcula la señal, otra manda órdenes. Funciona mientras
lo ejecuta su autor, en orden y con el estado que dejaron las celdas anteriores. Deja de funcionar cuando hay que
probarlo sin enviar órdenes, cambiar un límite sin tocar código, ejecutarlo en un servidor o que lo revise otra
persona. Esta lección convierte un bot de rebalanceo pequeño en un repositorio con cuatro propiedades: se instala igual
en cualquier máquina (uv y el lockfile, lección 0.2.2), su configuración vive fuera del código y se valida al cargar,
el estilo y los errores comunes los vigila una herramienta y no una persona, y cada pieza tiene tests que no necesitan
un broker real.

El bot es deliberadamente sencillo: recibe un objetivo de nocional por instrumento, calcula las órdenes en lotes
enteros, comprueba dos límites de riesgo, las envía a un broker y actualiza la cartera con las ejecuciones. Su
estructura es la de cualquier sistema mayor:

| Ruta | Qué contiene | Puede importar |
|---|---|---|
| `src/ppt_bot/dominio.py` | órdenes, ejecuciones, cartera, cálculo de órdenes | solo la biblioteca estándar |
| `src/ppt_bot/puertos.py` | lo que el servicio necesita del exterior (`Broker`) | dominio |
| `src/ppt_bot/servicio.py` | el caso de uso: rebalancear con límites | dominio, puertos |
| `src/ppt_bot/broker_sim.py` | un broker simulado para tests y paper | dominio, puertos |
| `src/ppt_bot/adaptadores.py` | mensajes de cada broker traducidos al dominio | dominio |
| `src/ppt_bot/config.py` | configuración validada | pydantic |
| `config/*.yaml` | valores por perfil, sin secretos | — |
| `tests/` | tests y mensajes grabados | todo |
| `scripts/` | utilidades (validar la configuración) | todo |

La columna de la derecha es la regla que importa. El dominio no sabe que existen los brokers, YAML ni el reloj del
sistema, así que se prueba con datos en memoria; todo lo que habla con el exterior depende del dominio y no al revés.
La lección 7.1.3 desarrolla esta idea (arquitectura hexagonal); aquí basta con respetarla:

```mermaid
flowchart LR
  C[config.py] --> S[servicio.py]
  S --> D[dominio.py]
  S --> P[puertos.py]
  P --> D
  B[broker_sim.py] --> P
  A[adaptadores.py] --> D
```

El código vive en `src/ppt_bot/` y no en `ppt_bot/` en la raíz. Con el layout `src`, los tests y los scripts importan
el paquete instalado (o el que señala `pythonpath`), no la carpeta que casualmente está en el directorio de trabajo: un
fichero que falta en el paquete falla en los tests y no en producción. La guía de empaquetado de Python lo explica con
ese mismo argumento[^1]. Un solo `pyproject.toml` declara las dependencias, las de desarrollo (en un grupo aparte) y la
configuración de Ruff y pytest. La celda crea el esqueleto en la carpeta temporal de la lección:

```python
from pathlib import Path

RAIZ = Path("ppt-bot").resolve()

def escribir(ruta, texto):
    p = RAIZ / ruta
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(texto.lstrip("\n"))

escribir("pyproject.toml", '''
[project]
name = "ppt-bot"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = ["pydantic-settings>=2.16", "pyyaml>=6.0"]

[dependency-groups]
dev = ["pytest>=9.1", "ruff>=0.17", "pre-commit>=4.7"]

[tool.ruff]
line-length = 72
target-version = "py314"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "DTZ", "S", "SIM",
          "PD", "NPY"]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-p no:cacheprovider"
''')
escribir("src/ppt_bot/__init__.py", "")
escribir("config/base.yaml", '''
modo: paper
riesgo:
  nocional_max_orden: 50000
  perdida_diaria_max: 2000
broker:
  nombre: simulado
''')
escribir("config/paper.yaml", '''
# paper: lo de base.yaml
''')
escribir("config/real.yaml", '''
modo: real
riesgo:
  nocional_max_orden: 20000
broker:
  nombre: alpaca
  cuenta: U-0418272
''')
print(sorted(str(p.relative_to(RAIZ)) for p in RAIZ.rglob("*")
             if p.is_file()))
```
```output
['config/base.yaml', 'config/paper.yaml', 'config/real.yaml', 'pyproject.toml', 'src/ppt_bot/__init__.py']
```

`line-length = 72` es la medida de las celdas de este curso; en un repositorio propio, 88 (el valor por defecto de
Ruff) o 100 son habituales. `pythonpath = ["src"]` hace que pytest encuentre el paquete sin instalarlo, y
`-p no:cacheprovider` evita la carpeta `.pytest_cache`. La regla S101 se desactiva solo en `tests/`, por una razón que
se verá con Ruff.

Los cinco módulos del paquete, en dos celdas. Primero el dominio, el puerto y el broker simulado:

```python
escribir("src/ppt_bot/dominio.py", '''
"""Dominio: órdenes, ejecuciones y cartera. Sin E/S ni reloj."""

import math
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Orden:
    clave: str  # idempotencia: sale de la intención (4.5.5)
    instrumento: str
    cantidad: int  # > 0 compra, < 0 venta


@dataclass(frozen=True)
class Ejecucion:
    id: str
    orden: str
    instrumento: str
    cantidad: int
    precio: float
    comision: float = 0.0


def ordenes_para_objetivo(objetivo, posiciones, precios, fecha, lote=1):
    """Órdenes que llevan cada posición al nocional objetivo,
    en lotes enteros y redondeando hacia cero."""
    ordenes = []
    for ins in sorted(objetivo):
        x = objetivo[ins] / precios[ins]
        deseada = math.copysign(math.floor(abs(x) / lote) * lote, x)
        delta = int(deseada) - posiciones.get(ins, 0)
        if delta:
            ordenes.append(Orden(f"{fecha}-{ins}", ins, delta))
    return ordenes


@dataclass
class Cartera:
    efectivo: float
    posiciones: dict = field(default_factory=dict)
    vistas: set = field(default_factory=set)

    def aplicar(self, e):
        """Aplica una ejecución una sola vez (por su id)."""
        if e.id in self.vistas:
            return False
        self.vistas.add(e.id)
        q = self.posiciones.get(e.instrumento, 0) + e.cantidad
        self.posiciones[e.instrumento] = q
        self.efectivo -= e.cantidad * e.precio + e.comision
        return True

    def valor(self, precios):
        return self.efectivo + sum(
            q * precios[i] for i, q in self.posiciones.items()
        )
''')
escribir("src/ppt_bot/puertos.py", '''
"""Lo que el servicio necesita del exterior (7.1.3)."""

from typing import Protocol

from ppt_bot.dominio import Ejecucion, Orden


class OrdenRechazada(Exception):
    pass


class Broker(Protocol):
    def enviar(self, orden: Orden) -> list[Ejecucion]: ...
''')
escribir("src/ppt_bot/broker_sim.py", '''
"""Broker simulado para tests: determinista y configurable."""

from ppt_bot.dominio import Ejecucion
from ppt_bot.puertos import OrdenRechazada


class BrokerSimulado:
    def __init__(
        self,
        precios,
        partes=1,
        llena=1.0,
        comision=0.0,
        rechaza=(),
        duplica=False,
    ):
        self.precios, self.partes, self.llena = precios, partes, llena
        self.comision, self.rechaza = comision, set(rechaza)
        self.duplica, self.enviadas = duplica, []

    def enviar(self, orden):
        if orden.instrumento in self.rechaza:
            raise OrdenRechazada(orden.clave)
        self.enviadas.append(orden)
        n = int(abs(orden.cantidad) * self.llena)
        trozos = [n // self.partes] * self.partes
        trozos[-1] += n - sum(trozos)
        signo = 1 if orden.cantidad > 0 else -1
        ejecs = [
            Ejecucion(
                f"{orden.clave}.{i:02d}",
                orden.clave,
                orden.instrumento,
                signo * q,
                self.precios[orden.instrumento],
                self.comision,
            )
            for i, q in enumerate(trozos, 1)
            if q
        ]
        if self.duplica and ejecs:
            ejecs.append(ejecs[-1])  # el broker reenvía la última
        return ejecs
''')
```

La clave de cada orden sale de la fecha y el instrumento, no de un número aleatorio: es la regla de idempotencia de
la lección 4.5.5. `Cartera.aplicar` ignora una ejecución cuyo identificador ya ha visto, y el broker simulado puede
partir una orden en varias ejecuciones, llenarla solo en parte, cobrar comisión, rechazar instrumentos y reenviar la
última ejecución. Todo es determinista: el mismo test da siempre el mismo resultado.

Después, el servicio que rebalancea y los adaptadores que traducen los mensajes de tres brokers:

```python
escribir("src/ppt_bot/servicio.py", '''
"""Caso de uso: rebalancear la cartera a través de un broker."""

from datetime import UTC, datetime

from ppt_bot.dominio import ordenes_para_objetivo
from ppt_bot.puertos import OrdenRechazada


class LimiteExcedido(Exception):
    pass


def ahora():
    return datetime.now(UTC)


def rebalancear(
    broker,
    cartera,
    objetivo,
    precios,
    riesgo,
    fecha,
    valor_inicio,
    lote=1,
    reloj=ahora,
    historial=None,
):
    perdida = valor_inicio - cartera.valor(precios)
    if perdida > riesgo.perdida_diaria_max:
        raise LimiteExcedido(f"pérdida del día {perdida:.0f}")
    informe = {
        "hora": reloj().isoformat(),
        "enviadas": 0,
        "rechazadas": 0,
    }
    ordenes = ordenes_para_objetivo(
        objetivo, cartera.posiciones, precios, fecha, lote
    )
    for o in ordenes:
        nocional = abs(o.cantidad) * precios[o.instrumento]
        if nocional > riesgo.nocional_max_orden:
            raise LimiteExcedido(f"{o.clave}: {nocional:.0f}")
        try:
            ejecuciones = broker.enviar(o)
        except OrdenRechazada:
            informe["rechazadas"] += 1
            continue
        for e in ejecuciones:
            cartera.aplicar(e)
        informe["enviadas"] += 1
    if historial is None:
        historial = []
    historial.append(informe)
    return informe
''')
escribir("src/ppt_bot/adaptadores.py", '''
"""Mensajes de cada broker -> ejecuciones del dominio."""

from ppt_bot.dominio import Ejecucion


def de_alpaca(msg, orden):
    out = []
    for m in msg["trade_updates"]:
        d = m["data"]
        if d["event"] not in ("partial_fill", "fill"):
            continue
        o = d["order"]
        signo = 1 if o["side"] == "buy" else -1
        out.append(
            Ejecucion(
                d["execution_id"],
                o["client_order_id"],
                o["symbol"],
                signo * int(d["qty"]),
                float(d["price"]),
            )
        )
    return out


def de_ib(msg, orden):
    out = []
    for f in msg["fills"]:
        x, c = f["execution"], f["commissionReport"]
        signo = 1 if x["side"] == "BOT" else -1
        out.append(
            Ejecucion(
                x["execId"],
                x["orderRef"],
                orden.instrumento,
                signo * int(x["shares"]),
                x["price"],
                c["commission"],
            )
        )
    return out


def de_tradier(msg, orden):
    o = msg["order"]
    signo = 1 if o["side"] == "buy" else -1
    return [
        Ejecucion(
            str(o["id"]),
            orden.clave,
            o["symbol"],
            signo * int(o["exec_quantity"]),
            o["avg_fill_price"],
        )
    ]


ADAPTADORES = {
    "alpaca": de_alpaca,
    "interactive_brokers": de_ib,
    "tradier": de_tradier,
}
''')
```

`rebalancear` recibe el broker, el reloj y los límites como argumentos: no crea nada por su cuenta. Es lo que permite
probarlo con un broker simulado y una hora fija. Los adaptadores reciben la orden que se envió porque el mensaje de
ejecución de Interactive Brokers no trae el símbolo (va en el contrato, aparte) y el de Tradier solo resume la orden.

## Configuración por capas con Pydantic Settings

La lección 0.4.2 validó una configuración leída de un YAML con Pydantic. Un sistema que corre en paper y en real
necesita más: valores comunes, valores por perfil, cambios puntuales por variable de entorno (en el servidor, en el
contenedor de la lección 7.2.1) y secretos que nunca están en un fichero del repositorio (lección 4.4.5). Pydantic
Settings resuelve eso con fuentes ordenadas por prioridad[^2]: argumentos al crear el objeto, variables de entorno,
el fichero `.env`, el directorio de secretos y, al final, los valores por defecto del modelo; se pueden quitar fuentes y
añadir otras, como un YAML.

```python
escribir("src/ppt_bot/config.py", '''
"""Configuración: YAML por perfiles, entorno y validación."""

import os
from pathlib import Path
from typing import Literal

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    SecretStr,
    model_validator,
)
from pydantic_settings import (
    BaseSettings,
    SettingsConfigDict,
    YamlConfigSettingsSource,
)

CONFIG = Path("config")


class Riesgo(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    nocional_max_orden: float = Field(gt=0)
    perdida_diaria_max: float = Field(gt=0)


class Broker(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    nombre: Literal["simulado", "alpaca", "ib"]
    cuenta: str = "PAPER"
    api_key: SecretStr | None = None


class Ajustes(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="PPT_",
        env_nested_delimiter="__",
        extra="forbid",
        frozen=True,
    )
    modo: Literal["paper", "real"] = "paper"
    riesgo: Riesgo
    broker: Broker

    @model_validator(mode="after")
    def real_exige_broker_real(self):
        if self.modo == "real" and self.broker.nombre == "simulado":
            raise ValueError("modo real con el broker simulado")
        if self.modo == "real" and self.broker.api_key is None:
            raise ValueError("modo real sin api_key")
        return self

    @classmethod
    def settings_customise_sources(
        cls, settings_cls, init_settings, env_settings, **_
    ):
        """Fuentes de más a menos prioridad."""
        return (init_settings, env_settings, fuente_yaml(settings_cls))


def ficheros_yaml():
    perfil = os.environ.get("PPT_PERFIL", "paper")
    return [CONFIG / "base.yaml", CONFIG / f"{perfil}.yaml"]


def fuente_yaml(settings_cls):
    return YamlConfigSettingsSource(
        settings_cls, yaml_file=ficheros_yaml(), deep_merge=True
    )
''')
```

Cinco decisiones de diseño, cada una contra un error concreto:

- **Fuentes**: `settings_customise_sources` devuelve las fuentes de más a menos prioridad. Aquí, argumentos, entorno y
  YAML; el `.env` queda fuera a propósito (un `.env` olvidado en el servidor es una configuración que nadie ve).
- **Perfiles**: `PPT_PERFIL` elige el segundo YAML (`paper.yaml` o `real.yaml`), que se superpone a `base.yaml`.
- **Fusión profunda**: `deep_merge=True`. Sin ella, el bloque `riesgo:` de `real.yaml` sustituye entero al de
  `base.yaml`.
- **Claves desconocidas prohibidas** (`extra="forbid"`) y **campos de riesgo sin valor por defecto**: una errata o un
  bloque incompleto son errores al cargar, no valores por defecto silenciosos.
- **Reglas entre campos**: el modo real exige un broker real y una clave de API. La clave se guarda como `SecretStr`,
  que no se imprime.

La celda carga los dos perfiles e imprime, para cada valor, la fuente que lo ha decidido. Para eso consulta cada fuente
por separado, que es lo que hace Pydantic Settings por dentro antes de fusionarlas (con la variable de entorno
`PYDANTIC_SETTINGS_DEBUG=1` y el logging a nivel DEBUG, la propia biblioteca escribe lo que aporta cada fuente):

```python
import hashlib
import json
import os
import sys

sys.path.insert(0, str(RAIZ / "src"))
os.chdir(RAIZ)
from pydantic_settings import EnvSettingsSource

from ppt_bot import config as cfg

def hojas(d, prefijo=()):
    for k, v in d.items():
        if isinstance(v, dict):
            yield from hojas(v, prefijo + (k,))
        else:
            yield prefijo + (k,), v

def procedencia():
    fuentes = [("entorno", EnvSettingsSource(cfg.Ajustes)()),
               ("yaml", cfg.fuente_yaml(cfg.Ajustes)())]
    final = cfg.Ajustes().model_dump(mode="json")
    for ruta, valor in hojas(final):
        origen = "por defecto"
        for nombre, datos in fuentes:
            d = datos
            for k in ruta:
                d = d.get(k, {}) if isinstance(d, dict) else {}
            if d != {}:
                origen = nombre
                break
        print(f"  {'.'.join(ruta):28} {str(valor):12} {origen}")

def huella(ajustes):
    texto = json.dumps(ajustes.model_dump(mode="json"),
                       sort_keys=True)
    return hashlib.sha256(texto.encode()).hexdigest()[:12]

print("perfil paper")
procedencia()
os.environ |= {"PPT_PERFIL": "real",
               "PPT_BROKER__API_KEY": "clave-de-juguete",
               "PPT_RIESGO__NOCIONAL_MAX_ORDEN": "15000"}
print("perfil real, con dos variables de entorno")
procedencia()
real = cfg.Ajustes()
print(repr(real.broker.api_key), "| huella:", huella(real))
```
```output
perfil paper
  modo                         paper        yaml
  riesgo.nocional_max_orden    50000.0      yaml
  riesgo.perdida_diaria_max    2000.0       yaml
  broker.nombre                simulado     yaml
  broker.cuenta                PAPER        por defecto
  broker.api_key               None         por defecto
perfil real, con dos variables de entorno
  modo                         real         yaml
  riesgo.nocional_max_orden    15000.0      entorno
  riesgo.perdida_diaria_max    2000.0       yaml
  broker.nombre                alpaca       yaml
  broker.cuenta                U-0418272    yaml
  broker.api_key               **********   entorno
SecretStr('**********') | huella: 02200b892309
```

En el perfil real, `modo`, `broker.nombre` y `broker.cuenta` vienen de `real.yaml`; `riesgo.perdida_diaria_max`
sigue viniendo de `base.yaml` gracias a la fusión profunda; el nocional máximo lo fija la variable de entorno, que
gana a los dos YAML, y la clave de API solo existe en el entorno. En paper, `cuenta` y `api_key` toman el valor por
defecto del código. La huella es un hash de la configuración efectiva, con la clave sustituida por asteriscos: se
anota en cada log y en cada experimento (lección 7.3.1) para saber, meses después, con qué configuración se ejecutó
algo, sin guardar el secreto.

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

La clave de API de la celda es de juguete y llega por una variable de entorno puesta en el propio código. En un sistema
real llega del gestor de secretos, de un fichero de secretos montado en el contenedor o del entorno del servicio, nunca
de un YAML del repositorio ni de una celda (lección 4.4.5). `SecretStr` evita que se imprima por accidente; no la
protege de quien puede leer el entorno del proceso.

</aside>

Los errores de configuración se ven al cargar, todos a la vez. La celda siguiente prueba tres: un bloque incompleto
cuando no hay fusión profunda, una errata en una clave y el modo real con el broker simulado.

```python
from pydantic import ValidationError
from pydantic_settings import YamlConfigSettingsSource

def intentar(crear):
    try:
        crear()
        print("  carga")
    except ValidationError as e:
        for err in e.errors():
            campo = ".".join(map(str, err["loc"])) or "-"
            print(f"  {campo}: {err['msg']}")

for k in ("PPT_RIESGO__NOCIONAL_MAX_ORDEN",):
    del os.environ[k]

class SinFusion(cfg.Ajustes):
    @classmethod
    def settings_customise_sources(cls, settings_cls, **_):
        return (YamlConfigSettingsSource(
            settings_cls, yaml_file=cfg.ficheros_yaml()),)

print("real.yaml sin fusión profunda:")
intentar(SinFusion)

original = Path("config/real.yaml").read_text()
Path("config/real.yaml").write_text(original.replace(
    "riesgo:\n", "riesgo:\n  perdida_diaria_maxima: 1500\n"))
print("errata en una clave:")
intentar(cfg.Ajustes)
Path("config/real.yaml").write_text(original)
print("modo real con el broker simulado:")
intentar(lambda: cfg.Ajustes(broker={"nombre": "simulado"}))
```
```output
real.yaml sin fusión profunda:
  riesgo.perdida_diaria_max: Field required
errata en una clave:
  riesgo.perdida_diaria_maxima: Extra inputs are not permitted
modo real con el broker simulado:
  -: Value error, modo real con el broker simulado
```

El primer caso es el más peligroso de los tres en un proyecto real. Aquí falla porque `perdida_diaria_max` no tiene
valor por defecto; si lo tuviera, el perfil real cargaría con el límite de pérdida por defecto y nadie lo notaría.
La errata la detecta `extra="forbid"`: sin esa opción, `perdida_diaria_maxima` se ignoraría y seguiría mandando el
valor de `base.yaml`. La regla entre campos usa el argumento de creación, la fuente de más prioridad, para simular a
alguien que fuerza el broker simulado en el perfil real.

## Ruff: errores que no son de estilo

Ruff es un linter y formateador escrito en Rust que implementa, en un solo programa, las reglas de flake8 y de muchos
de sus complementos, isort y pyupgrade, entre otros[^3]. Lo útil para un sistema de trading no es el estilo sino las
reglas que señalan errores. Esta es la función típica de un notebook de research:

```python
import shutil
import subprocess

escribir("notebook/senal.py", '''
import os, json
from datetime import datetime
import numpy as np
import pandas as pd


def registrar(orden, historial=[]):
    historial.append((datetime.now(), orden))
    return historial


def tamano(capital, precio, riesgo=0.01):
    assert precio > 0
    return int(capital * riesgo / precio)


def cargar(ruta):
    try:
        df = pd.read_csv(ruta)
    except:
        return None
    df.dropna(inplace=True)
    ruido = np.random.normal(size=len(df))
    if df.empty == True:
        print(f"vacío")
    return df, ruido


def umbrales():
    fs = []
    for k in range(3):
        fs.append(lambda x: x * k)
    return fs
''')
r = subprocess.run(["ruff", "check", "--no-cache", "--output-format",
                    "concise", "notebook"], capture_output=True,
                   text=True, cwd=RAIZ)
print(r.stdout)
```
```output
notebook/senal.py:1:1: E401 [*] Multiple imports on one line
notebook/senal.py:1:1: I001 [*] Import block is un-sorted or un-formatted
notebook/senal.py:1:8: F401 [*] `os` imported but unused
notebook/senal.py:1:12: F401 [*] `json` imported but unused
notebook/senal.py:7:32: B006 Do not use mutable data structures for argument defaults
notebook/senal.py:8:23: DTZ005 `datetime.datetime.now()` called without a `tz` argument
notebook/senal.py:13:5: S101 Use of `assert` detected
notebook/senal.py:20:5: E722 Do not use bare `except`
notebook/senal.py:22:15: PD002 `inplace=True` should be avoided; it has inconsistent behavior
notebook/senal.py:23:13: NPY002 Replace legacy `np.random.normal` call with `np.random.Generator`
notebook/senal.py:24:8: E712 Avoid equality comparisons to `True`; use `df.empty:` for truth checks
notebook/senal.py:25:15: F541 [*] f-string without any placeholders
notebook/senal.py:32:33: B023 Function definition does not bind loop variable `k`
Found 13 errors.
[*] 5 fixable with `--fix` (3 unsafe fixes available with `--fix --unsafe-fixes`).
```

Trece avisos, y la mayoría no son manías de estilo:

| Regla | Qué pasa en un sistema de trading |
|---|---|
| B006 | `historial=[]` se crea una vez y lo comparten todas las llamadas: el historial de un día se mezcla con el del siguiente |
| DTZ005 | `datetime.now()` sin zona da la hora local de la máquina; en el servidor (UTC) y en el portátil (Europa/Madrid) la misma orden lleva horas distintas |
| S101 | `assert` desaparece con `python -O`: un límite escrito así deja de existir según cómo se lance el proceso |
| E722 | `except:` desnudo atrapa también `KeyboardInterrupt` y los errores de programación: el bot sigue con datos que no ha cargado |
| PD002 | `inplace=True` no ahorra memoria, y con el Copy-on-Write de pandas 3 (lección 0.5.2) `df["x"].fillna(0, inplace=True)` ya no modifica `df` |
| NPY002 | `np.random.normal` usa el estado global: un resultado que no se puede reproducir con una semilla local |
| B023 | las tres funciones usan la `k` del final del bucle (2), no la de su vuelta |
| F401, E401, I001, F541, E712 | importaciones sin usar o desordenadas, f-string sin variables, comparación con `True` |

Las cinco marcadas con `[*]` se corrigen solas con `ruff check --fix`; las demás exigen decidir qué se quería hacer.
La selección de familias del `pyproject.toml` (`E`, `F`, `W`, `I`, `UP`, `B`, `DTZ`, `S`, `SIM`, `PD`, `NPY`) es la de
la lección; Ruff trae muchas más (la lista completa está en su documentación), y conviene añadirlas de una en una y
corregir lo que salga, en vez de activar todas y silenciar cientos de avisos. Sobre el paquete del bot, Ruff no
encuentra nada y el formato ya es el de `ruff format`:

```python
shutil.rmtree(RAIZ / "notebook")  # no entra en el repositorio
for cmd in (["ruff", "check", "--no-cache", "src"],
            ["ruff", "format", "--no-cache", "--check", "src"]):
    r = subprocess.run(cmd, capture_output=True, text=True, cwd=RAIZ)
    print(" ".join(cmd[:2]), "->", r.returncode, r.stdout.strip())
```
```output
ruff check -> 0 All checks passed!
ruff format -> 0 7 files already formatted
```

## Tests con un broker simulado

La lección 0.4.5 presentó pytest. Lo que cambia en un sistema de trading es qué se prueba y contra qué. El dominio se
prueba con datos en memoria. El servicio, con un broker simulado que hace lo que hacen los brokers reales en los
peores días: ejecuta en partes, cobra comisión, rechaza, reenvía. Y nada en un test depende del reloj del sistema: la
hora se inyecta con una fixture. `conftest.py` reúne las fixtures que comparten los ficheros de tests:

```python
escribir("tests/conftest.py", '''
import json
from datetime import UTC, datetime
from pathlib import Path

import pytest

from ppt_bot.config import Riesgo
from ppt_bot.dominio import Cartera


@pytest.fixture
def riesgo():
    return Riesgo(nocional_max_orden=50_000, perdida_diaria_max=2_000)


@pytest.fixture
def cartera():
    return Cartera(efectivo=100_000.0)


@pytest.fixture
def reloj():
    return lambda: datetime(2026, 1, 2, 15, 30, tzinfo=UTC)


@pytest.fixture(scope="session")
def grabados():
    ruta = Path(__file__).parent / "datos" / "brokers4_ejemplos.json"
    return json.loads(ruta.read_text())
''')
escribir("tests/test_dominio.py", '''
import pytest

from ppt_bot.broker_sim import BrokerSimulado
from ppt_bot.dominio import ordenes_para_objetivo
from ppt_bot.servicio import LimiteExcedido, rebalancear

PRECIOS = {"ORSC": 234.84, "ORUS": 716.82}


def correr(broker, cartera, objetivo, riesgo, reloj):
    return rebalancear(
        broker,
        cartera,
        objetivo,
        PRECIOS,
        riesgo,
        "2026-01-02",
        valor_inicio=100_000.0,
        reloj=reloj,
    )


@pytest.mark.parametrize(
    ("nocional", "lote", "esperada"),
    [
        (10_000, 1, 42),
        (10_000, 10, 40),
        (-10_000, 10, -40),
        (100, 1, 0),
    ],
)
def test_lotes_hacia_cero(nocional, lote, esperada):
    o = ordenes_para_objetivo(
        {"ORSC": nocional}, {}, PRECIOS, "d", lote
    )
    assert sum(x.cantidad for x in o) == esperada


def test_parciales_y_comision(riesgo, cartera, reloj):
    b = BrokerSimulado(PRECIOS, partes=3, comision=0.35)
    correr(b, cartera, {"ORSC": 10_000}, riesgo, reloj)
    assert cartera.posiciones == {"ORSC": 42}
    assert cartera.efectivo == pytest.approx(
        100_000 - 42 * 234.84 - 3 * 0.35
    )


def test_ejecucion_repetida_cuenta_una_vez(riesgo, cartera, reloj):
    b = BrokerSimulado(PRECIOS, partes=2, duplica=True)
    correr(b, cartera, {"ORSC": 10_000}, riesgo, reloj)
    assert cartera.posiciones == {"ORSC": 42}


def test_limite_de_nocional(riesgo, cartera, reloj):
    b = BrokerSimulado(PRECIOS)
    with pytest.raises(LimiteExcedido):
        correr(b, cartera, {"ORUS": 60_000}, riesgo, reloj)
    assert b.enviadas == []


def test_rechazo_no_mueve_la_cartera(riesgo, cartera, reloj):
    b = BrokerSimulado(PRECIOS, rechaza={"ORUS"})
    inf = correr(
        b, cartera, {"ORSC": 10_000, "ORUS": 10_000}, riesgo, reloj
    )
    assert inf["rechazadas"] == 1
    assert cartera.posiciones == {"ORSC": 42}


def test_hora_del_reloj_inyectado(riesgo, cartera, reloj):
    inf = correr(BrokerSimulado(PRECIOS), cartera, {}, riesgo, reloj)
    assert inf["hora"] == "2026-01-02T15:30:00+00:00"
''')
```

Cada test describe una regla del sistema con un caso concreto: las cantidades se redondean hacia cero en lotes
enteros (también las ventas); las ejecuciones parciales suman la orden y cada una paga su comisión; una ejecución
repetida cuenta una vez; una orden por encima del límite no llega al broker; un rechazo no mueve la cartera; la hora
del informe es la del reloj inyectado. `parametrize` convierte una tabla de casos en cuatro tests con nombre propio.

## Tests de contrato con mensajes grabados

El broker simulado habla el idioma del dominio, así que nunca encontrará un error de traducción: un campo que llega
como texto, un lado de la orden con otro nombre, una cantidad acumulada confundida con la de la ejecución. Para eso
están los tests de contrato: mensajes con la forma de las API reales, guardados en el repositorio, que pasan por cada
adaptador. Los datos del nivel 4 traen la misma orden (`R00005`, compra de 434 ORSC en tres ejecuciones) contada por
Alpaca, Interactive Brokers y Tradier; el contrato es que los tres adaptadores devuelvan lo mismo:

```python
escribir("tests/test_contrato.py", '''
import pytest

from ppt_bot.adaptadores import ADAPTADORES
from ppt_bot.dominio import Orden

ORDEN = Orden("R00005", "ORSC", 434)


@pytest.mark.parametrize("broker", sorted(ADAPTADORES))
def test_la_misma_orden_en_cada_broker(broker, grabados):
    ejecs = ADAPTADORES[broker](grabados[broker], ORDEN)
    total = sum(e.cantidad for e in ejecs)
    vwap = sum(e.cantidad * e.precio for e in ejecs) / total
    assert {e.instrumento for e in ejecs} == {"ORSC"}
    assert all(isinstance(e.cantidad, int) for e in ejecs)
    assert total == ORDEN.cantidad
    assert vwap == pytest.approx(234.8353, abs=5e-5)
    assert len({e.id for e in ejecs}) == len(ejecs)
''')
(Path("tests") / "datos").mkdir(exist_ok=True)
shutil.copy(Path("..") / "brokers4_ejemplos.json",
            Path("tests") / "datos")
```

Los mensajes grabados son un subconjunto de campos con valores ficticios (el README de los datos del nivel 4 lo
advierte): antes de escribir un adaptador real, los campos se comprueban en la documentación de cada API y, mejor
aún, se graban mensajes de la cuenta de paper del propio broker. Con los tests escritos, pytest se ejecuta como lo
haría pre-commit o la integración continua (lección 7.2.2), en un proceso aparte. La celda quita de la salida la
duración, que cambia en cada ejecución:

```python
import re

ENTORNO = os.environ | {"PYTHONPATH": "src",
                        "PYTHONDONTWRITEBYTECODE": "1"}

def ejecutar(*cmd):
    r = subprocess.run(cmd, capture_output=True, text=True,
                       env=ENTORNO)
    salida = re.sub(r" in \d+\.\d+s", "", r.stdout + r.stderr)
    return r.returncode, salida.strip()

codigo, salida = ejecutar("python", "-m", "pytest", "-v")
print(codigo)
print("\n".join(l.split(" ", 1)[0].split("::", 1)[1]
                for l in salida.splitlines() if "PASSED" in l))
print(salida.splitlines()[-1].strip("= "))
```
```output
0
test_la_misma_orden_en_cada_broker[alpaca]
test_la_misma_orden_en_cada_broker[interactive_brokers]
test_la_misma_orden_en_cada_broker[tradier]
test_lotes_hacia_cero[10000-1-42]
test_lotes_hacia_cero[10000-10-40]
test_lotes_hacia_cero[-10000-10--40]
test_lotes_hacia_cero[100-1-0]
test_parciales_y_comision
test_ejecucion_repetida_cuenta_una_vez
test_limite_de_nocional
test_rechazo_no_mueve_la_cartera
test_hora_del_reloj_inyectado
12 passed
```

Doce tests en verde. `PYTHONDONTWRITEBYTECODE=1` evita que Python guarde los `.pyc` en `__pycache__`; en esta
lección importa porque la última sección modifica los ficheros y vuelve a ejecutar los tests en el mismo segundo, y
Python reutiliza un `.pyc` si la fecha de modificación y el tamaño del fuente coinciden.

## pre-commit: las comprobaciones en cada commit

Herramientas que hay que acordarse de ejecutar no se ejecutan. pre-commit instala un hook de git que lanza una lista
de comprobaciones antes de cada commit y lo cancela si alguna falla[^4]. Los hooks se descargan normalmente de
repositorios públicos; aquí son todos locales (`repo: local`, `language: system`): usan las herramientas del entorno del
proyecto, las mismas versiones que fija el lockfile, y no necesitan red.

```mermaid
flowchart TB
  G[git commit] --> R[ruff check]
  R --> F[ruff format]
  F --> V[validar config]
  V --> T[pytest]
  T --> C[commit creado]
```

```python
escribir("scripts/validar_config.py", '''
"""Carga cada perfil de config/ con Ajustes (pre-commit y CI)."""

import os
import sys

from pydantic import ValidationError

from ppt_bot.config import Ajustes

os.environ.setdefault("PPT_BROKER__API_KEY", "solo-para-validar")
fallos = 0
for perfil in ("paper", "real"):
    os.environ["PPT_PERFIL"] = perfil
    try:
        Ajustes()
    except ValidationError as e:
        fallos += 1
        for err in e.errors():
            campo = ".".join(map(str, err["loc"])) or "-"
            print(f"{perfil}: {campo}: {err['msg']}")
sys.exit(1 if fallos else 0)
''')
escribir(".pre-commit-config.yaml", '''
repos:
  - repo: local
    hooks:
      - id: ruff
        name: ruff check
        entry: ruff check --no-cache --output-format concise
        language: system
        types: [python]
        require_serial: true
      - id: ruff-format
        name: ruff format
        entry: ruff format --no-cache --check
        language: system
        types: [python]
        require_serial: true
      - id: config
        name: validar config
        entry: env PYTHONPATH=src python scripts/validar_config.py
        language: system
        pass_filenames: false
        always_run: true
      - id: pytest
        name: pytest
        entry: python -m pytest -q
        language: system
        pass_filenames: false
        always_run: true
''')
escribir(".gitignore", "__pycache__/\n")
for k in ("PPT_PERFIL", "PPT_BROKER__API_KEY"):
    os.environ.pop(k, None)
ENTORNO = os.environ | {"PYTHONPATH": "src",
                        "PYTHONDONTWRITEBYTECODE": "1"}

ejecutar("git", "init", "-q")
print(ejecutar("pre-commit", "install")[1])
servicio = Path("src/ppt_bot/servicio.py")
limpio = servicio.read_text()
servicio.write_text(limpio.replace(
    "datetime.now(UTC)", "datetime.now()"))
ejecutar("git", "add", "-A")
codigo, salida = ejecutar("git", "commit", "-qm", "Bot de rebalanceo")
print("commit:", codigo)
print(salida)
```
```output
pre-commit installed at .git/hooks/pre-commit
commit: 1
ruff check...............................................................Failed
- hook id: ruff
- exit code: 1

src/ppt_bot/servicio.py:3:22: F401 [*] `datetime.UTC` imported but unused
src/ppt_bot/servicio.py:14:12: DTZ005 `datetime.datetime.now()` called without a `tz` argument
Found 2 errors.
[*] 1 fixable with `--fix`.

ruff format..............................................................Passed
validar config...........................................................Passed
pytest...................................................................Passed
```

El commit no se crea: Ruff encuentra la hora sin zona y, de paso, la importación de `UTC` que ya no se usa. Los demás
hooks pasan (pre-commit ejecuta todos y resume). Con el fichero corregido, el commit sale y git le da un hash; aquí
siempre el mismo, porque el entorno de la lección fija autor y fecha:

```python
servicio.write_text(limpio)
ejecutar("git", "add", "-A")
codigo, salida = ejecutar("git", "commit", "-qm", "Bot de rebalanceo")
print("commit:", codigo)
print(salida)
print(ejecutar("git", "log", "--oneline")[1])
```
```output
commit: 0
ruff check...............................................................Passed
ruff format..............................................................Passed
validar config...........................................................Passed
pytest...................................................................Passed
06ca6d1 Bot de rebalanceo
```

pre-commit protege el repositorio de quien lo tiene instalado; `git commit --no-verify` se lo salta. Por eso las mismas
comprobaciones se repiten en la integración continua, donde nadie puede saltárselas (lección 7.2.2), y el hook local
es solo la forma de enterarse antes. Conviene que el hook sea rápido: los tests lentos (un backtest completo, una
descarga) van a la integración continua, no al commit.

## Catorce defectos, cuatro redes

¿Cuánto protege todo esto? La forma honesta de medirlo es sembrar defectos y ver cuáles atrapa cada capa. Diez son
cambios de código que alguien podría escribir con prisa; cuatro son errores en `real.yaml`. Cada defecto se aplica
sobre el proyecto limpio, se ejecutan las cuatro comprobaciones y se deshace:

```python
from contextlib import contextmanager

S, DOM = "src/ppt_bot/servicio.py", "src/ppt_bot/dominio.py"
AD, REAL = "src/ppt_bot/adaptadores.py", "config/real.yaml"
LIMITE = '''        if nocional > riesgo.nocional_max_orden:
            raise LimiteExcedido(f"{o.clave}: {nocional:.0f}")
'''
PERDIDA = '''    perdida = valor_inicio - cartera.valor(precios)
    if perdida > riesgo.perdida_diaria_max:
        raise LimiteExcedido(f"pérdida del día {perdida:.0f}")
'''
DEFECTOS = {
    "D01 hora sin zona": [(S, "now(UTC)", "now()")],
    "D02 límite con assert": [(S, LIMITE, "        assert nocional"
                               " <= riesgo.nocional_max_orden\n")],
    "D03 except desnudo": [(S, "except OrdenRechazada:", "except:")],
    "D04 historial=[]": [(S, "historial=None,", "historial=[],")],
    "D05 lotes con round": [(DOM, "math.floor(abs(x) / lote)",
                             "round(abs(x) / lote)")],
    "D06 comisión sumada": [(DOM, "e.precio + e.comision",
                             "e.precio - e.comision")],
    "D07 sin deduplicar": [(DOM, "        if e.id in self.vistas:\n"
                            "            return False\n", "")],
    "D08 cantidad acumulada": [(AD, 'int(d["qty"])',
                                'int(o["filled_qty"])')],
    "D09 lado BOT/BUY": [(AD, '"BOT"', '"BUY"')],
    "D10 sin pérdida diaria": [(S, PERDIDA, "")],
    "C01 modo: live": [(REAL, "modo: real", "modo: live")],
    "C02 20.000 en YAML": [(REAL, "20000", "20.000")],
    "C03 errata en clave": [(REAL, "nocional_max_orden",
                             "nocional_maximo_orden")],
    "C04 real con simulado": [(REAL, "nombre: alpaca",
                               "nombre: simulado")],
}
CAPAS = {
    "ruff": ["ruff", "check", "--no-cache", "-q", "."],
    "unitarios": ["python", "-m", "pytest", "tests/test_dominio.py"],
    "contrato": ["python", "-m", "pytest", "tests/test_contrato.py"],
    "config": ["python", "scripts/validar_config.py"],
}

@contextmanager
def sembrado(cambios):
    copias = {}
    for ruta, antes, despues in cambios:
        p = Path(ruta)
        copias.setdefault(p, p.read_text())
        assert p.read_text().count(antes) == 1, (ruta, antes)
        p.write_text(p.read_text().replace(antes, despues))
    try:
        yield
    finally:
        for p, texto in copias.items():
            p.write_text(texto)

assert all(ejecutar(*cmd)[0] == 0 for cmd in CAPAS.values())
matriz = {}
for nombre, cambios in DEFECTOS.items():
    with sembrado(cambios):
        matriz[nombre] = [ejecutar(*c)[0] != 0
                          for c in CAPAS.values()]
print(f"{'':24}" + "".join(f"{c:>11}" for c in CAPAS))
for nombre, fila in matriz.items():
    print(f"{nombre:24}" + "".join(
        f"{'X' if x else '·':>11}" for x in fila))
totales = [sum(f[i] for f in matriz.values())
           for i in range(len(CAPAS))]
nadie = [n for n, f in matriz.items() if not any(f)]
print("por capa:", dict(zip(CAPAS, totales)))
print("ninguna:", nadie)
```
```output
                               ruff  unitarios   contrato     config
D01 hora sin zona                 X          ·          ·          ·
D02 límite con assert             X          X          ·          ·
D03 except desnudo                X          ·          ·          ·
D04 historial=[]                  X          ·          ·          ·
D05 lotes con round               ·          X          ·          ·
D06 comisión sumada               ·          X          ·          ·
D07 sin deduplicar                ·          X          ·          ·
D08 cantidad acumulada            ·          ·          X          ·
D09 lado BOT/BUY                  ·          ·          X          ·
D10 sin pérdida diaria            ·          ·          ·          ·
C01 modo: live                    ·          ·          ·          X
C02 20.000 en YAML                ·          ·          ·          ·
C03 errata en clave               ·          ·          ·          X
C04 real con simulado             ·          ·          ·          X
por capa: {'ruff': 4, 'unitarios': 4, 'contrato': 2, 'config': 3}
ninguna: ['D10 sin pérdida diaria', 'C02 20.000 en YAML']
```

*Fig. — La matriz completa: qué capa atrapa cada defecto sembrado. Cada capa cubre su parte y solo uno, el assert en el límite de riesgo, lo atrapan dos.* Matriz de 14 defectos por 5 columnas. Ruff atrapa D01 hora sin zona, D02 assert, D03 except desnudo y D04 historial mutable; los tests unitarios, D02, D05 lotes con round, D06 comisión sumada y D07 sin deduplicar; los tests de contrato, D08 cantidad acumulada y D09 lado BOT/BUY; la validación de config, C01 modo live, C03 errata y C04 real con simulado. En la columna ninguna, destacados, D10 sin pérdida diaria y C02 20.000 en YAML.

La figura de la cabecera resume la matriz; la de arriba la muestra entera. Cuatro lecturas:

- **Ruff atrapa lo que se reconoce por la forma**: la hora sin zona, el `assert`, el `except` desnudo y el valor por
  defecto mutable. En D03 avisa además de que `OrdenRechazada` ya no se usa. No sabe nada de lotes, comisiones ni
  brokers.
- **Los tests atrapan lo que contradice una regla escrita**. D02 lo atrapan las dos capas: el test esperaba
  `LimiteExcedido` y recibe `AssertionError`. Los tests unitarios no ven los errores de los adaptadores, y los de
  contrato no ven los del dominio: cada uno cubre su parte.
- **La validación de la configuración atrapa tres de cuatro**: el modo que no existe, la errata y la regla entre
  campos.
- **Dos defectos pasan por todo.** D10 elimina el límite de pérdida diaria y ningún test lo echa de menos, porque
  ninguno lo comprueba. C02 escribe `20.000` pensando en veinte mil; para YAML es el número 20,0, un valor positivo
  perfectamente válido. El bot cargaría y rechazaría todas las órdenes, en producción. Ninguna herramienta adivina lo
  que nadie escribió como regla.

La conclusión práctica no es añadir herramientas, sino reglas: un test por cada límite de riesgo, rangos razonables en
la configuración (un nocional máximo por debajo de mil es, casi seguro, un error) y, para lo que sigue escapando, la
revisión de otra persona y el paper trading antes del real (lección 4.5.3). Los dos primeros ejercicios cierran los
dos huecos.

## Ejercicios

1. Escribe un test que atrape D10: con una cartera que ha perdido más que `perdida_diaria_max` desde el inicio del día,
   `rebalancear` debe lanzar `LimiteExcedido` sin enviar ninguna orden. Comprueba que pasa con el código limpio y falla
   con el defecto sembrado.

**Solución.**

```python
escribir("tests/test_perdida.py", '''
import pytest

from ppt_bot.broker_sim import BrokerSimulado
from ppt_bot.servicio import LimiteExcedido, rebalancear


def test_perdida_diaria_bloquea(riesgo, cartera, reloj):
    b = BrokerSimulado({"ORSC": 234.84})
    with pytest.raises(LimiteExcedido):
        rebalancear(
            b, cartera, {"ORSC": 10_000}, {"ORSC": 234.84},
            riesgo, "2026-01-02", valor_inicio=103_000.0,
            reloj=reloj,
        )
    assert b.enviadas == []
''')
test = ["python", "-m", "pytest", "-q", "tests/test_perdida.py"]
print("limpio:", ejecutar(*test)[1].splitlines()[-1])
with sembrado(DEFECTOS["D10 sin pérdida diaria"]):
    print("con D10:", ejecutar(*test)[1].splitlines()[-1])
```
```output
limpio: 1 passed
con D10: 1 failed
```

El valor inicial de 103.000 frente a una cartera de 100.000 es una pérdida de 3.000, por encima del límite de 2.000.
Con el código limpio, `rebalancear` se para antes de calcular órdenes y el broker no recibe nada; con D10, la orden se
envía y el test falla. Un test por límite convierte cada regla de riesgo en algo que no se puede borrar sin que se
note.

2. Añade a `Riesgo` una regla que rechace un `nocional_max_orden` por debajo de 1.000 y comprueba que C02 deja de
   pasar la validación.

**Solución.**

```python
config_py = Path("src/ppt_bot/config.py")
antes = config_py.read_text()
config_py.write_text(antes.replace(
    "nocional_max_orden: float = Field(gt=0)",
    "nocional_max_orden: float = Field(ge=1000)"))
with sembrado(DEFECTOS["C02 20.000 en YAML"]):
    salida = ejecutar("python", "scripts/validar_config.py")[1]
config_py.write_text(antes)
print(salida)
```
```output
real: riesgo.nocional_max_orden: Input should be greater than or equal to 1000
```

Con `ge=1000`, el perfil real con `20.000` falla con un mensaje que nombra el campo. El umbral es una decisión de
negocio, no técnica: lo que se busca es un rango fuera del cual el valor es casi seguro un error de escritura. Otra
defensa, complementaria, es escribir los importes en YAML sin separadores de miles (`20000` o `20_000`, que YAML 1.1
también lee como entero; el cargador de PyYAML lo hace).

3. Un compañero propone quitar los tests de contrato porque «el broker simulado ya prueba todo el flujo». Con la
   matriz de la lección, ¿qué le responderías?

**Solución.**

Que el broker simulado habla el idioma del dominio y por eso no puede encontrar errores de traducción. En la matriz,
D08 (cantidad acumulada en vez de la de la ejecución) y D09 (`BUY` en vez de `BOT`) solo los atrapan los tests de
contrato; los unitarios pasan con los dos. Los dos errores son de los que aparecen con el primer mensaje real: una
posición que suma 27 + 416 + 434 títulos o una compra que se registra como venta. Los mensajes grabados cuestan poco de
mantener y son el único sitio, antes del paper trading, donde el código ve la forma real de los datos del broker.

[^1]: Python Packaging User Guide, «src layout vs flat layout»
    (https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). Consultada el 23 de octubre de
    2026.

[^2]: Pydantic, «Settings Management» (https://docs.pydantic.dev/latest/concepts/pydantic_settings/): prioridad de las
    fuentes, `settings_customise_sources`, `env_nested_delimiter`, `YamlConfigSettingsSource`, la fusión superficial
    de varios ficheros por defecto y `deep_merge=True` en la propia fuente, y `PYDANTIC_SETTINGS_DEBUG`. Comprobado con
    pydantic-settings 2.16.1, la versión del entorno. Consultada el 23 de octubre de 2026.

[^3]: Ruff (https://docs.astral.sh/ruff/) y su lista de reglas (https://docs.astral.sh/ruff/rules/). Consultada el 23
    de octubre de 2026.

[^4]: pre-commit (https://pre-commit.com/): hooks `repo: local` y `language: system`. Consultada el 23 de octubre de
    2026.

## Autoevaluación

1. `base.yaml` define `riesgo.nocional_max_orden` y `riesgo.perdida_diaria_max`; `real.yaml` solo redefine `riesgo.nocional_max_orden`. Con `YamlConfigSettingsSource` y `deep_merge=False` (el valor por defecto), ¿qué pasa al cargar el perfil real?

   - A. Se combinan los dos bloques campo a campo
   - B. El bloque `riesgo` de `real.yaml` sustituye entero al de `base.yaml`: falta `perdida_diaria_max` y la validación falla (o, si el campo tuviera valor por defecto, se usaría ese en silencio)
   - C. Gana `base.yaml` porque se lee primero
   - D. Pydantic Settings lanza un aviso y usa los dos valores

   *Respuesta B: El bloque `riesgo` de `real.yaml` sustituye entero al de `base.yaml`: falta `perdida_diaria_max` y la validación falla (o, si el campo tuviera valor por defecto, se usaría ese en silencio).* Sin fusión profunda, cada clave de primer nivel del último fichero sustituye a la del anterior. En la lección, el error es explícito porque `perdida_diaria_max` no tiene valor por defecto; con uno, el límite de pérdida volvería a su valor por defecto sin que nadie lo note. Por eso `deep_merge=True` y campos de riesgo sin valores por defecto.

2. ¿Por qué la lección activa la regla S101 de Ruff (uso de `assert`) en `src/` y la desactiva en `tests/`?

   - A. Porque `assert` es más lento que `if`
   - B. Porque pytest no admite `assert`
   - C. Porque Python elimina los `assert` al ejecutar con `-O`: un límite de riesgo escrito con `assert` desaparece; en los tests, `assert` es justo la herramienta de pytest
   - D. Porque S101 solo funciona en ficheros de tests

   *Respuesta C: Porque Python elimina los `assert` al ejecutar con `-O`: un límite de riesgo escrito con `assert` desaparece; en los tests, `assert` es justo la herramienta de pytest.* Con `python -O` los `assert` no se ejecutan. En código de producción, una comprobación de riesgo no puede depender de cómo se lanza el intérprete: se escribe con `if` y una excepción propia. En el experimento de la lección, el defecto D02 lo atrapan Ruff y también un test, porque el test espera `LimiteExcedido` y recibe `AssertionError`.

3. El adaptador de Interactive Brokers compara `side == "BUY"`, pero las ejecuciones de IB dicen `BOT`. ¿Qué capa lo atrapa en la lección?

   - A. Los tests de contrato: con el mensaje grabado, la suma de las ejecuciones da −434 en vez de 434
   - B. Ruff
   - C. La validación de la configuración
   - D. Ninguna

   *Respuesta A: Los tests de contrato: con el mensaje grabado, la suma de las ejecuciones da −434 en vez de 434.* Es un error de traducción entre el formato del broker y el dominio. Solo lo ve un test que pasa por el adaptador mensajes con la forma real; el broker simulado habla ya el idioma del dominio y nunca lo detectaría.

4. De los catorce defectos sembrados, dos no los atrapa ninguna capa. ¿Qué tienen en común?

   - A. Son errores de sintaxis
   - B. Solo aparecen con Python 3.15
   - C. Son errores de estilo que Ruff ignora
   - D. Ninguna comprobación describe lo que debería pasar: no hay test del límite de pérdida diaria y `20.000` es un número válido para YAML (20,0) aunque el autor quisiera veinte mil

   *Respuesta D: Ninguna comprobación describe lo que debería pasar: no hay test del límite de pérdida diaria y `20.000` es un número válido para YAML (20,0) aunque el autor quisiera veinte mil.* Las herramientas comprueban lo que alguien ha escrito como regla. Un límite sin test puede desaparecer, y un valor con la forma correcta pero absurdo pasa la validación de tipos. Los ejercicios añaden las dos reglas que faltan.

5. ¿Qué hace `extra="forbid"` en el modelo de configuración?

   - A. Prohíbe variables de entorno
   - B. Rechaza claves que el modelo no conoce: una errata como `perdida_diaria_maxima` es un error al cargar, no un campo ignorado
   - C. Impide cambiar la configuración después de cargarla
   - D. Oculta los secretos al imprimir

   *Respuesta B: Rechaza claves que el modelo no conoce: una errata como `perdida_diaria_maxima` es un error al cargar, no un campo ignorado.* Sin él, la clave mal escrita se ignora y el valor que el autor quería cambiar sigue siendo el de `base.yaml`. Impedir cambios después de cargar es `frozen=True`; ocultar secretos, `SecretStr`.

## Ideas clave

- Un proyecto de trading es un paquete en `src/` con un solo `pyproject.toml` (dependencias, Ruff, pytest) y una regla: el dominio no importa nada del exterior; los adaptadores y la configuración dependen del dominio, no al revés.
- La configuración se carga por capas (YAML base, YAML del perfil, entorno), con fusión profunda, claves desconocidas prohibidas, campos de riesgo sin valores por defecto y una huella que se anota en cada ejecución; los secretos llegan por el entorno y se guardan como `SecretStr`.
- Ruff con las familias E, F, B, DTZ, S, SIM, PD y NPY encuentra errores que no son de estilo: fechas sin zona, `assert` en límites de riesgo, `except` desnudos, valores por defecto mutables, closures en bucles.
- Los tests usan un broker simulado determinista para el dominio y mensajes grabados de cada broker para los adaptadores; el reloj se inyecta.
- pre-commit encadena Ruff, el formato, la validación de la configuración y los tests antes de cada commit. En el experimento, las cuatro redes atrapan doce de catorce defectos; los dos que escapan son reglas que nadie escribió.

## Recursos

### Documentación

- [Pydantic: Settings Management (fuentes, prioridad, YAML)](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) · docs.pydantic.dev
- [Ruff: reglas](https://docs.astral.sh/ruff/rules/) · docs.astral.sh
- [pre-commit: documentación (hooks locales, `language: system`)](https://pre-commit.com/) · pre-commit.com
- [pytest: fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html) · docs.pytest.org
- [Python Packaging User Guide: src layout vs flat layout](https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/) · packaging.python.org

### Datos

- [Datos del nivel 4: mensajes de ejemplo de varios brokers (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Descarga · brokers4_ejemplos.json

Lección siguiente: [7.1.2 · Rendimiento: medir, perfilar y compilar solo lo que hace falta](https://pythonparatrading.com/curso-python-trading/nivel-7/rendimiento-profiling-numba-cython-y-rust/)
