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.
- 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, 0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore, 0.4.2 · Tipado: type hints, mypy o ty y Pydantic para configuraciones, 0.4.5 · Logging profesional y testing con pytest, 4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos, 4.5.5 · Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
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:
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:
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()))['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:
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:
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.
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_sourcesdevuelve las fuentes de más a menos prioridad. Aquí, argumentos, entorno y YAML; el.envqueda fuera a propósito (un.envolvidado en el servidor es una configuración que nadie ve). - Perfiles:
PPT_PERFILelige el segundo YAML (paper.yamloreal.yaml), que se superpone abase.yaml. - Fusión profunda:
deep_merge=True. Sin ella, el bloqueriesgo:dereal.yamlsustituye entero al debase.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):
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))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: 02200b892309En 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.
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"}))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 simuladoEl 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:
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)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:
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())ruff check -> 0 All checks passed!
ruff format -> 0 7 files already formattedTests 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:
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:
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:
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("= "))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 passedDoce 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.
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]
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)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...................................................................PassedEl 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:
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])commit: 0
ruff check...............................................................Passed
ruff format..............................................................Passed
validar config...........................................................Passed
pytest...................................................................Passed
06ca6d1 Bot de rebalanceopre-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:
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) 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']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, elexceptdesnudo y el valor por defecto mutable. En D03 avisa además de queOrdenRechazadaya 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
LimiteExcedidoy recibeAssertionError. 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.000pensando 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
- Escribe un test que atrape D10: con una cartera que ha perdido más que
perdida_diaria_maxdesde el inicio del día,rebalanceardebe lanzarLimiteExcedidosin enviar ninguna orden. Comprueba que pasa con el código limpio y falla con el defecto sembrado.
Solución
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])limpio: 1 passed
con D10: 1 failedEl 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.
- Añade a
Riesgouna regla que rechace unnocional_max_ordenpor debajo de 1.000 y comprueba que C02 deja de pasar la validación.
Solución
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)real: riesgo.nocional_max_orden: Input should be greater than or equal to 1000Con 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).
- 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
-
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. ↩
-
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 ydeep_merge=Trueen la propia fuente, yPYDANTIC_SETTINGS_DEBUG. Comprobado con pydantic-settings 2.16.1, la versión del entorno. Consultada el 23 de octubre de 2026. ↩ -
Ruff (https://docs.astral.sh/ruff/) y su lista de reglas (https://docs.astral.sh/ruff/rules/). Consultada el 23 de octubre de 2026. ↩
-
pre-commit (https://pre-commit.com/): hooks
repo: localylanguage: system. Consultada el 23 de octubre de 2026. ↩
Respuesta plegada en cada pregunta
Autoevaluación
base.yamldefineriesgo.nocional_max_ordenyriesgo.perdida_diaria_max;real.yamlsolo redefineriesgo.nocional_max_orden. ConYamlConfigSettingsSourceydeep_merge=False(el valor por defecto), ¿qué pasa al cargar el perfil real?- ASe combinan los dos bloques campo a campo
- BEl bloque
riesgodereal.yamlsustituye entero al debase.yaml: faltaperdida_diaria_maxy la validación falla (o, si el campo tuviera valor por defecto, se usaría ese en silencio) - CGana
base.yamlporque se lee primero - DPydantic Settings lanza un aviso y usa los dos valores
Ver respuesta
Respuesta B. El bloque
riesgodereal.yamlsustituye entero al debase.yaml: faltaperdida_diaria_maxy 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_maxno 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 esodeep_merge=Truey campos de riesgo sin valores por defecto.¿Por qué la lección activa la regla S101 de Ruff (uso de
assert) ensrc/y la desactiva entests/?- APorque
assertes más lento queif - BPorque pytest no admite
assert - CPorque Python elimina los
assertal ejecutar con-O: un límite de riesgo escrito conassertdesaparece; en los tests,assertes justo la herramienta de pytest - DPorque S101 solo funciona en ficheros de tests
Ver respuesta
Respuesta C. Porque Python elimina los
assertal ejecutar con-O: un límite de riesgo escrito conassertdesaparece; en los tests,assertes justo la herramienta de pytestCon
python -Olosassertno 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 conify 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 esperaLimiteExcedidoy recibeAssertionError.- APorque
El adaptador de Interactive Brokers compara
side == "BUY", pero las ejecuciones de IB dicenBOT. ¿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.
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.000es 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.000es un número válido para YAML (20,0) aunque el autor quisiera veinte milLas 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.
¿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_maximaes 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_maximaes un error al cargar, no un campo ignoradoSin é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 esfrozen=True; ocultar secretos,SecretStr.
Ideas clave
- Un proyecto de trading es un paquete en
src/con un solopyproject.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,
asserten límites de riesgo,exceptdesnudos, 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ó.