Lección 7.1.1 · 70 min

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.

Fig. — Catorce defectos sembrados en el bot de la lección y cuántos atrapa cada capa: Ruff y los tests unitarios, cuatro cada uno; la validación de la configuración, tres; los tests de contrato, dos; dos no los atrapa ninguna
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

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:

RutaQué contienePuede importar
src/ppt_bot/dominio.pyórdenes, ejecuciones, cartera, cálculo de órdenessolo la biblioteca estándar
src/ppt_bot/puertos.pylo que el servicio necesita del exterior (Broker)dominio
src/ppt_bot/servicio.pyel caso de uso: rebalancear con límitesdominio, puertos
src/ppt_bot/broker_sim.pyun broker simulado para tests y paperdominio, puertos
src/ppt_bot/adaptadores.pymensajes de cada broker traducidos al dominiodominio
src/ppt_bot/config.pyconfiguración validadapydantic
config/*.yamlvalores por perfil, sin secretos—
tests/tests y mensajes grabadostodo
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:

config.pyservicio.pydominio.pypuertos.pybroker_sim.pyadaptadores.py
Fuente 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 argumento1Nota 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.. 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:

IN [01]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()))
OUT [01]El esqueleto: pyproject.toml y perfiles
['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:

IN [02]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:

IN [03]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 prioridad2Nota 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.: 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.

IN [04]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):

IN [05]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))
OUT [05]Qué valor gana y de dónde sale
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.

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.

IN [06]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"}))
OUT [06]Tres configuraciones que no deben cargar
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 otros3Nota 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.. 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:

IN [07]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)
OUT [07]Ruff sobre código de notebook
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:

ReglaQué pasa en un sistema de trading
B006historial=[] se crea una vez y lo comparten todas las llamadas: el historial de un día se mezcla con el del siguiente
DTZ005datetime.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
S101assert desaparece con python -O: un límite escrito así deja de existir según cómo se lance el proceso
E722except: desnudo atrapa también KeyboardInterrupt y los errores de programación: el bot sigue con datos que no ha cargado
PD002inplace=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
NPY002np.random.normal usa el estado global: un resultado que no se puede reproducir con una semilla local
B023las tres funciones usan la k del final del bucle (2), no la de su vuelta
F401, E401, I001, F541, E712importaciones 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:

IN [08]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())
OUT [08]Ruff sobre el paquete
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:

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

IN [10]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:

IN [11]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("= "))
OUT [11]pytest en un proceso aparte
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 falla4Nota 4 · pre-commit (https://pre-commit.com/): hooks repo: local y language: system. Consultada el 23 de octubre de 2026.. 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.

git commitruff checkruff formatvalidar configpytestcommit creado
Fuente Mermaid
flowchart TB
  G[git commit] --> R[ruff check]
  R --> F[ruff format]
  F --> V[validar config]
  V --> T[pytest]
  T --> C[commit creado]
IN [12]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)
OUT [12]El hook y un commit con un defecto
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:

IN [13]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])
OUT [13]Corregido: el commit pasa
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:

IN [14]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)
OUT [14]La matriz de defectos y capas
                               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

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
IN [15]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])
OUT [15]El test que faltaba
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.

  1. 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
IN [16]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)
OUT [16]Un rango razonable en la configuración
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).

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

Footnotes

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

Respuesta plegada en cada pregunta

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?

    • ASe combinan los dos bloques campo a campo
    • BEl 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)
    • CGana base.yaml porque se lee primero
    • DPydantic Settings lanza un aviso y usa los dos valores
    Ver respuesta

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

    • APorque assert es más lento que if
    • BPorque pytest no admite assert
    • CPorque 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
    • DPorque S101 solo funciona en ficheros de tests
    Ver respuesta

    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?

    • ALos tests de contrato: con el mensaje grabado, la suma de las ejecuciones da −434 en vez de 434
    • BRuff
    • CLa validación de la configuración
    • DNinguna
    Ver respuesta

    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?

    • ASon errores de sintaxis
    • BSolo aparecen con Python 3.15
    • CSon errores de estilo que Ruff ignora
    • DNinguna 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
    Ver respuesta

    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?

    • AProhíbe variables de entorno
    • BRechaza claves que el modelo no conoce: una errata como perdida_diaria_maxima es un error al cargar, no un campo ignorado
    • CImpide cambiar la configuración después de cargarla
    • DOculta los secretos al imprimir
    Ver respuesta

    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