Lesson 7.1.1 · 70 min
Project structure, configuration and quality: from notebook to repository
A bot that lives in a notebook works until someone touches it. This lesson turns it into a repository: a package in src/, per-profile configuration validated with Pydantic Settings, Ruff, pre-commit and tests with a simulated broker and with recorded messages from three brokers. Then it seeds fourteen defects and measures which ones each safety net catches and which ones none of them catches.
- Module
- 7.1 · Software engineering for trading
- Duration
- 70 min
- Prerequisites
- 0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles, 0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore, 0.4.2 · Typing: type hints, mypy or ty, and Pydantic for configurations, 0.4.5 · Professional logging and testing with pytest, 4.4.5 · Secure credentials: environment variables, secret managers and least privilege, 4.5.5 · Fault tolerance: idempotency, persistent state and reconciliation at startup
- Author
- Jesús Cuesta
- Updated
Level 7 lessons
7.1 · Software engineering for trading
7.2 · Infrastructure and deployment
7.3 · MLOps for trading models
7.4 · Regulation, ethics and taxation
- 7.4.1 MiFID II: clients, suitability and algorithmic trading
- 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
- 7.4.3 PRIIPs, the KID and US ETFs
- 7.4.4 MiCA after the transitional period: checking a provider in the registers
- 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
- 7.4.6 Trading taxes in Spain
- 7.4.7 Ethics: personal data, artificial intelligence and selling signals
7.5 · Quant career
7.6 · Final capstone
- 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
- 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
- 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
- 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
- 7.6.5 Capstone C4: a research platform with LLMs and alternative data
- 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control
From notebook to repository
A bot is born in a notebook: one cell downloads prices, another computes the signal, another sends orders. It works while its author runs it, in order and with the state the previous cells left behind. It stops working when it has to be tested without sending orders, when a limit has to change without touching code, when it has to run on a server or when someone else has to review it. This lesson turns a small rebalancing bot into a repository with four properties: it installs the same way on any machine (uv and the lockfile, lesson 0.2.2), its configuration lives outside the code and is validated at load time, style and common bugs are watched by a tool and not by a person, and every piece has tests that do not need a real broker.
The bot is deliberately simple: it receives a target notional per instrument, computes the orders in whole lots, checks two risk limits, sends them to a broker and updates the portfolio with the fills. Its structure is that of any larger system:
| Path | What it contains | May import |
|---|---|---|
src/ppt_bot/dominio.py | orders, fills, portfolio, order calculation | only the standard library |
src/ppt_bot/puertos.py | what the service needs from the outside (Broker) | domain |
src/ppt_bot/servicio.py | the use case: rebalance within limits | domain, ports |
src/ppt_bot/broker_sim.py | a simulated broker for tests and paper | domain, ports |
src/ppt_bot/adaptadores.py | each broker's messages translated into the domain | domain |
src/ppt_bot/config.py | validated configuration | pydantic |
config/*.yaml | values per profile, no secrets | — |
tests/ | tests and recorded messages | everything |
scripts/ | utilities (validate the configuration) | everything |
The right-hand column is the rule that matters. The domain does not know that brokers, YAML or the system clock exist, so it is tested with in-memory data; everything that talks to the outside depends on the domain and not the other way around. Lesson 7.1.3 develops this idea (hexagonal architecture); here it is enough to respect it:
Mermaid source
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
The code lives in src/ppt_bot/ and not in ppt_bot/ at the root. With the src layout, tests and scripts import
the installed package (or the one pythonpath points to), not the folder that happens to be in the working directory:
a file missing from the package fails in the tests and not in production. The Python packaging guide makes the same
argument1Note 1 · Python Packaging User Guide, “src layout vs flat layout”
(https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). Accessed on October 23, 2026.. A single pyproject.toml declares the dependencies, the development ones (in a separate group) and the
Ruff and pytest configuration. The cell creates the skeleton in the lesson's temporary folder:
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: same as 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 is the width of this course's cells; in a repository of your own, 88 (Ruff's default) or 100 are
common. pythonpath = ["src"] lets pytest find the package without installing it, and -p no:cacheprovider avoids the
.pytest_cache folder. Rule S101 is disabled only in tests/, for a reason that will become clear with Ruff.
The package's five modules, in two cells. First the domain, the port and the simulated broker:
escribir("src/ppt_bot/dominio.py", '''
"""Domain: orders, fills and portfolio. No I/O, no clock."""
import math
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Orden:
clave: str # idempotency: derived from the intent (4.5.5)
instrumento: str
cantidad: int # > 0 buy, < 0 sell
@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):
"""Orders that take each position to the target notional,
in whole lots, rounding toward zero."""
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):
"""Applies a fill only once (by its 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", '''
"""What the service needs from the outside (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", '''
"""Simulated broker for tests: deterministic and 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]) # the broker resends the last one
return ejecs
''')Each order's key comes from the date and the instrument, not from a random number: it is the idempotency rule of
lesson 4.5.5. Cartera.aplicar ignores a fill whose identifier it has already seen, and the simulated broker can
split an order into several fills, fill it only partially, charge commission, reject instruments and resend the last
fill. Everything is deterministic: the same test always gives the same result.
Next, the service that rebalances and the adapters that translate the messages of three brokers:
escribir("src/ppt_bot/servicio.py", '''
"""Use case: rebalance the portfolio through a 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"daily loss {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", '''
"""Each broker's messages -> domain fills."""
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 receives the broker, the clock and the limits as arguments: it creates nothing on its own. That is what
allows testing it with a simulated broker and a fixed time. The adapters receive the order that was sent because
Interactive Brokers' execution message does not carry the symbol (it is in the contract, separately) and Tradier's only
summarizes the order.
Layered configuration with Pydantic Settings
Lesson 0.4.2 validated a configuration read from a YAML file with Pydantic. A system that runs in paper and live
needs more: common values, per-profile values, one-off overrides via environment variables (on the server, in the
container of lesson 7.2.1) and secrets that are never in a file in the repository (lesson 4.4.5). Pydantic Settings
solves this with sources ordered by priority2Note 2 · Pydantic, “Settings Management” (https://docs.pydantic.dev/latest/concepts/pydantic_settings/): source
priority, settings_customise_sources, env_nested_delimiter, YamlConfigSettingsSource, the shallow merge of
several files by default and deep_merge=True on the source itself, and PYDANTIC_SETTINGS_DEBUG. Checked with
pydantic-settings 2.16.1, the version in the environment. Accessed on October 23, 2026.: arguments when creating the object, environment variables, the
.env file, the secrets directory and, last, the model's default values; sources can be removed and others added,
such as a YAML file.
escribir("src/ppt_bot/config.py", '''
"""Configuration: YAML per profile, environment and validation."""
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("real mode with the simulated broker")
if self.modo == "real" and self.broker.api_key is None:
raise ValueError("real mode without api_key")
return self
@classmethod
def settings_customise_sources(
cls, settings_cls, init_settings, env_settings, **_
):
"""Sources from highest to lowest priority."""
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
)
''')Five design decisions, each one against a specific mistake:
- Sources:
settings_customise_sourcesreturns the sources from highest to lowest priority. Here, arguments, environment and YAML;.envis left out on purpose (a forgotten.envon the server is a configuration nobody sees). - Profiles:
PPT_PERFILchooses the second YAML file (paper.yamlorreal.yaml), which is layered overbase.yaml. - Deep merge:
deep_merge=True. Without it, theriesgo:block ofreal.yamlreplaces the one inbase.yamlentirely. - Unknown keys forbidden (
extra="forbid") and risk fields without a default value: a typo or an incomplete block are errors at load time, not silent default values. - Cross-field rules: real mode requires a real broker and an API key. The key is stored as
SecretStr, which is not printed.
The cell loads both profiles and prints, for each value, the source that decided it. To do so it queries each source
separately, which is what Pydantic Settings does internally before merging them (with the environment variable
PYDANTIC_SETTINGS_DEBUG=1 and logging at DEBUG level, the library itself writes what each source contributes):
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 = [("env", EnvSettingsSource(cfg.Ajustes)()),
("yaml", cfg.fuente_yaml(cfg.Ajustes)())]
final = cfg.Ajustes().model_dump(mode="json")
for ruta, valor in hojas(final):
origen = "default"
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("paper profile")
procedencia()
os.environ |= {"PPT_PERFIL": "real",
"PPT_BROKER__API_KEY": "toy-key",
"PPT_RIESGO__NOCIONAL_MAX_ORDEN": "15000"}
print("real profile, with two environment variables")
procedencia()
real = cfg.Ajustes()
print(repr(real.broker.api_key), "| fingerprint:", huella(real))paper profile
modo paper yaml
riesgo.nocional_max_orden 50000.0 yaml
riesgo.perdida_diaria_max 2000.0 yaml
broker.nombre simulado yaml
broker.cuenta PAPER default
broker.api_key None default
real profile, with two environment variables
modo real yaml
riesgo.nocional_max_orden 15000.0 env
riesgo.perdida_diaria_max 2000.0 yaml
broker.nombre alpaca yaml
broker.cuenta U-0418272 yaml
broker.api_key ********** env
SecretStr('**********') | fingerprint: 02200b892309In the real profile, modo, broker.nombre and broker.cuenta come from real.yaml; riesgo.perdida_diaria_max
still comes from base.yaml thanks to deep merging; the maximum notional is set by the environment variable, which
wins over both YAML files, and the API key only exists in the environment. In paper, cuenta and api_key take the
code's default value. The fingerprint is a hash of the effective configuration, with the key replaced by asterisks: it
is written to every log and every experiment (lesson 7.3.1) so that, months later, you know which configuration
something ran with, without storing the secret.
Configuration errors show up at load time, all at once. The next cell tries three: an incomplete block when there is no deep merge, a typo in a key and real mode with the simulated broker.
from pydantic import ValidationError
from pydantic_settings import YamlConfigSettingsSource
def intentar(crear):
try:
crear()
print(" loads")
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 without deep merge:")
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("typo in a key:")
intentar(cfg.Ajustes)
Path("config/real.yaml").write_text(original)
print("real mode with the simulated broker:")
intentar(lambda: cfg.Ajustes(broker={"nombre": "simulado"}))real.yaml without deep merge:
riesgo.perdida_diaria_max: Field required
typo in a key:
riesgo.perdida_diaria_maxima: Extra inputs are not permitted
real mode with the simulated broker:
-: Value error, real mode with the simulated brokerThe first case is the most dangerous of the three in a real project. Here it fails because perdida_diaria_max has no
default value; if it had one, the real profile would load with the default loss limit and nobody would notice. The
typo is detected by extra="forbid": without that option, perdida_diaria_maxima would be ignored and the value from
base.yaml would still be in charge. The cross-field rule uses the creation argument, the highest-priority source, to
simulate someone forcing the simulated broker in the real profile.
Ruff: bugs that are not about style
Ruff is a linter and formatter written in Rust that implements, in a single program, the rules of flake8 and many of its plugins, isort and pyupgrade, among others3Note 3 · Ruff (https://docs.astral.sh/ruff/) and its list of rules (https://docs.astral.sh/ruff/rules/). Accessed on October 23, 2026.. What is useful for a trading system is not the style but the rules that flag bugs. This is the typical function from a research notebook:
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"empty")
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`).Thirteen warnings, and most of them are not style quirks:
| Rule | What happens in a trading system |
|---|---|
| B006 | historial=[] is created once and shared by every call: one day's history gets mixed with the next day's |
| DTZ005 | datetime.now() without a time zone gives the machine's local time; on the server (UTC) and on the laptop (Europe/Madrid) the same order carries different times |
| S101 | assert disappears with python -O: a limit written this way ceases to exist depending on how the process is launched |
| E722 | a bare except: also catches KeyboardInterrupt and programming errors: the bot carries on with data it has not loaded |
| PD002 | inplace=True does not save memory, and with pandas 3's Copy-on-Write (lesson 0.5.2) df["x"].fillna(0, inplace=True) no longer modifies df |
| NPY002 | np.random.normal uses the global state: a result that cannot be reproduced with a local seed |
| B023 | the three functions use the k from the end of the loop (2), not the one from their iteration |
| F401, E401, I001, F541, E712 | unused or unsorted imports, f-string without variables, comparison with True |
The five marked with [*] are fixed automatically with ruff check --fix; the others require deciding what was
intended. The selection of families in the pyproject.toml (E, F, W, I, UP, B, DTZ, S, SIM, PD,
NPY) is the lesson's; Ruff has many more (the full list is in its documentation), and it is better to add them one at
a time and fix whatever comes up than to enable all of them and silence hundreds of warnings. On the bot's package,
Ruff finds nothing and the formatting is already ruff format's:
shutil.rmtree(RAIZ / "notebook") # not part of the repository
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 with a simulated broker
Lesson 0.4.5 introduced pytest. What changes in a trading system is what gets tested and against what. The domain is
tested with in-memory data. The service, with a simulated broker that does what real brokers do on their worst days:
it fills in parts, charges commission, rejects, resends. And nothing in a test depends on the system clock: the time is
injected with a fixture. conftest.py gathers the fixtures shared by the test files:
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"
''')Each test describes a rule of the system with a concrete case: quantities are rounded toward zero in whole lots (sells
too); partial fills add up to the order and each one pays its commission; a repeated fill counts once; an order above
the limit does not reach the broker; a rejection does not move the portfolio; the report's time is the injected
clock's. parametrize turns a table of cases into four tests, each with its own name.
Contract tests with recorded messages
The simulated broker speaks the domain's language, so it will never find a translation error: a field that arrives as
text, an order side with a different name, a cumulative quantity mistaken for the fill's quantity. That is what
contract tests are for: messages with the shape of the real APIs, stored in the repository, fed through each adapter.
The level 4 data contain the same order (R00005, a buy of 434 ORSC in three fills) as reported by Alpaca,
Interactive Brokers and Tradier; the contract is that the three adapters return the same thing:
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")The recorded messages are a subset of fields with fictitious values (the README of the level 4 data warns about it): before writing a real adapter, the fields are checked against each API's documentation and, better still, messages are recorded from the broker's own paper account. With the tests written, pytest is run the way pre-commit or continuous integration would run it (lesson 7.2.2), in a separate process. The cell strips the duration from the output, since it changes on every run:
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 passedTwelve green tests. PYTHONDONTWRITEBYTECODE=1 stops Python from saving .pyc files in __pycache__; in this lesson
it matters because the last section modifies the files and reruns the tests within the same second, and Python reuses a
.pyc if the source's modification time and size match.
pre-commit: the checks on every commit
Tools you have to remember to run do not get run. pre-commit installs a git hook that launches a list of checks before
every commit and cancels it if any of them fails4Note 4 · pre-commit (https://pre-commit.com/): repo: local and language: system hooks. Accessed on October 23, 2026.. Hooks are usually downloaded from public repositories; here they
are all local (repo: local, language: system): they use the tools in the project's environment, the same versions
the lockfile pins, and they need no network.
Mermaid source
flowchart TB G[git commit] --> R[ruff check] R --> F[ruff format] F --> V[validate config] V --> T[pytest] T --> C[commit created]
escribir("scripts/validar_config.py", '''
"""Loads each config/ profile with Ajustes (pre-commit and CI)."""
import os
import sys
from pydantic import ValidationError
from ppt_bot.config import Ajustes
os.environ.setdefault("PPT_BROKER__API_KEY", "validation-only")
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: validate 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", "Rebalancing bot")
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
validate config..........................................................Passed
pytest...................................................................PassedThe commit is not created: Ruff finds the naive time and, along the way, the UTC import that is no longer used. The
other hooks pass (pre-commit runs all of them and summarizes). With the file fixed, the commit goes through and git
gives it a hash; here always the same one, because the lesson's environment pins author and date:
servicio.write_text(limpio)
ejecutar("git", "add", "-A")
codigo, salida = ejecutar("git", "commit", "-qm", "Rebalancing bot")
print("commit:", codigo)
print(salida)
print(ejecutar("git", "log", "--oneline")[1])commit: 0
ruff check...............................................................Passed
ruff format..............................................................Passed
validate config..........................................................Passed
pytest...................................................................Passed
9a98f3b Rebalancing botpre-commit protects the repository for whoever has it installed; git commit --no-verify bypasses it. That is why the
same checks are repeated in continuous integration, where nobody can skip them (lesson 7.2.2), and the local hook is
just the way to find out earlier. The hook should be fast: slow tests (a full backtest, a download) go to continuous
integration, not to the commit.
Fourteen defects, four safety nets
How much does all this protect? The honest way to measure it is to seed defects and see which ones each layer catches.
Ten are code changes someone might write in a hurry; four are errors in real.yaml. Each defect is applied to the
clean project, the four checks are run and the change is undone:
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"daily loss {perdida:.0f}")
'''
DEFECTOS = {
"D01 naive datetime": [(S, "now(UTC)", "now()")],
"D02 limit with assert": [(S, LIMITE, " assert nocional"
" <= riesgo.nocional_max_orden\n")],
"D03 bare except": [(S, "except OrdenRechazada:", "except:")],
"D04 historial=[]": [(S, "historial=None,", "historial=[],")],
"D05 lots with round": [(DOM, "math.floor(abs(x) / lote)",
"round(abs(x) / lote)")],
"D06 commission added": [(DOM, "e.precio + e.comision",
"e.precio - e.comision")],
"D07 no deduplication": [(DOM, " if e.id in self.vistas:\n"
" return False\n", "")],
"D08 cumulative quantity": [(AD, 'int(d["qty"])',
'int(o["filled_qty"])')],
"D09 side BOT/BUY": [(AD, '"BOT"', '"BUY"')],
"D10 no daily loss limit": [(S, PERDIDA, "")],
"C01 modo: live": [(REAL, "modo: real", "modo: live")],
"C02 20.000 in YAML": [(REAL, "20000", "20.000")],
"C03 typo in key": [(REAL, "nocional_max_orden",
"nocional_maximo_orden")],
"C04 real with simulated": [(REAL, "nombre: alpaca",
"nombre: simulado")],
}
CAPAS = {
"ruff": ["ruff", "check", "--no-cache", "-q", "."],
"unit": ["python", "-m", "pytest", "tests/test_dominio.py"],
"contract": ["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("per layer:", dict(zip(CAPAS, totales)))
print("none:", nadie) ruff unit contract config
D01 naive datetime X · · ·
D02 limit with assert X X · ·
D03 bare except X · · ·
D04 historial=[] X · · ·
D05 lots with round · X · ·
D06 commission added · X · ·
D07 no deduplication · X · ·
D08 cumulative quantity · · X ·
D09 side BOT/BUY · · X ·
D10 no daily loss limit · · · ·
C01 modo: live · · · X
C02 20.000 in YAML · · · ·
C03 typo in key · · · X
C04 real with simulated · · · X
per layer: {'ruff': 4, 'unit': 4, 'contract': 2, 'config': 3}
none: ['D10 no daily loss limit', 'C02 20.000 in YAML']The header figure summarizes the matrix; the one above shows it in full. Four readings:
- Ruff catches what can be recognized by its shape: the naive time, the
assert, the bareexceptand the mutable default value. In D03 it also warns thatOrdenRechazadais no longer used. It knows nothing about lots, commissions or brokers. - Tests catch what contradicts a written rule. D02 is caught by both layers: the test expected
LimiteExcedidoand getsAssertionError. Unit tests do not see the adapters' errors, and contract tests do not see the domain's: each one covers its own part. - Configuration validation catches three out of four: the mode that does not exist, the typo and the cross-field rule.
- Two defects get through everything. D10 removes the daily loss limit and no test misses it, because none checks
it. C02 writes
20.000meaning twenty thousand, with the dot as a thousands separator as is usual in Spain; for YAML it is the number 20.0, a perfectly valid positive value. The bot would load and reject every order, in production. No tool guesses what nobody wrote down as a rule.
The practical conclusion is not to add tools but rules: one test per risk limit, reasonable ranges in the configuration (a maximum notional below a thousand is almost certainly a mistake) and, for whatever still slips through, review by another person and paper trading before going live (lesson 4.5.3). The first two exercises close the two gaps.
Exercises
- Write a test that catches D10: with a portfolio that has lost more than
perdida_diaria_maxsince the start of the day,rebalancearmust raiseLimiteExcedidowithout sending any order. Check that it passes with the clean code and fails with the seeded defect.
Solution
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("clean:", ejecutar(*test)[1].splitlines()[-1])
with sembrado(DEFECTOS["D10 no daily loss limit"]):
print("with D10:", ejecutar(*test)[1].splitlines()[-1])clean: 1 passed
with D10: 1 failedA starting value of 103,000 against a portfolio of 100,000 is a loss of 3,000, above the 2,000 limit. With the clean
code, rebalancear stops before computing orders and the broker receives nothing; with D10, the order is sent and the
test fails. One test per limit turns every risk rule into something that cannot be deleted without anyone noticing.
- Add to
Riesgoa rule that rejects anocional_max_ordenbelow 1,000 and check that C02 no longer passes validation.
Solution
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 in 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 1000With ge=1000, the real profile with 20.000 fails with a message that names the field. The threshold is a business
decision, not a technical one: what you want is a range outside of which the value is almost certainly a typing
mistake. Another, complementary defense is to write amounts in YAML without thousands separators (20000 or
20_000, which YAML 1.1 also reads as an integer; PyYAML's loader does).
- A colleague proposes removing the contract tests because “the simulated broker already tests the whole flow”. With the lesson's matrix, what would you answer?
Solution
That the simulated broker speaks the domain's language and therefore cannot find translation errors. In the matrix,
D08 (cumulative quantity instead of the fill's quantity) and D09 (BUY instead of BOT) are caught only by the
contract tests; the unit tests pass with both. Both errors are of the kind that show up with the first real message: a
position that adds up 27 + 416 + 434 shares, or a buy recorded as a sell. Recorded messages are cheap to maintain and
are the only place, before paper trading, where the code sees the real shape of the broker's data.
Footnotes
-
Python Packaging User Guide, “src layout vs flat layout” (https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/). Accessed on October 23, 2026. ↩
-
Pydantic, “Settings Management” (https://docs.pydantic.dev/latest/concepts/pydantic_settings/): source priority,
settings_customise_sources,env_nested_delimiter,YamlConfigSettingsSource, the shallow merge of several files by default anddeep_merge=Trueon the source itself, andPYDANTIC_SETTINGS_DEBUG. Checked with pydantic-settings 2.16.1, the version in the environment. Accessed on October 23, 2026. ↩ -
Ruff (https://docs.astral.sh/ruff/) and its list of rules (https://docs.astral.sh/ruff/rules/). Accessed on October 23, 2026. ↩
-
pre-commit (https://pre-commit.com/):
repo: localandlanguage: systemhooks. Accessed on October 23, 2026. ↩
Each answer is folded under its question
Self-assessment
base.yamldefinesriesgo.nocional_max_ordenandriesgo.perdida_diaria_max;real.yamlonly redefinesriesgo.nocional_max_orden. WithYamlConfigSettingsSourceanddeep_merge=False(the default), what happens when the real profile is loaded?- AThe two blocks are combined field by field
- BThe
riesgoblock ofreal.yamlreplaces the one inbase.yamlentirely:perdida_diaria_maxis missing and validation fails (or, if the field had a default value, that value would be used silently) - C
base.yamlwins because it is read first - DPydantic Settings issues a warning and uses both values
Show answer
Answer B. The
riesgoblock ofreal.yamlreplaces the one inbase.yamlentirely:perdida_diaria_maxis missing and validation fails (or, if the field had a default value, that value would be used silently)Without deep merging, each top-level key of the last file replaces the one from the previous file. In the lesson the error is explicit because
perdida_diaria_maxhas no default value; with one, the loss limit would go back to its default without anyone noticing. Hencedeep_merge=Trueand risk fields without default values.Why does the lesson enable Ruff's S101 rule (use of
assert) insrc/and disable it intests/?- ABecause
assertis slower thanif - BBecause pytest does not support
assert - CBecause Python strips
assertstatements when run with-O: a risk limit written withassertdisappears; in tests,assertis exactly pytest's tool - DBecause S101 only works on test files
Show answer
Answer C. Because Python strips
assertstatements when run with-O: a risk limit written withassertdisappears; in tests,assertis exactly pytest's toolWith
python -O,assertstatements are not executed. In production code, a risk check cannot depend on how the interpreter is launched: it is written withifand a custom exception. In the lesson's experiment, defect D02 is caught by Ruff and also by a test, because the test expectsLimiteExcedidoand getsAssertionError.- ABecause
The Interactive Brokers adapter compares
side == "BUY", but IB's executions sayBOT. Which layer catches it in the lesson?- AThe contract tests: with the recorded message, the sum of the fills gives −434 instead of 434
- BRuff
- CThe configuration validation
- DNone
Show answer
Answer A. The contract tests: with the recorded message, the sum of the fills gives −434 instead of 434
It is a translation error between the broker's format and the domain. Only a test that feeds the adapter messages with the real shape sees it; the simulated broker already speaks the domain's language and would never detect it.
Of the fourteen seeded defects, two are caught by no layer. What do they have in common?
- AThey are syntax errors
- BThey only show up with Python 3.15
- CThey are style errors that Ruff ignores
- DNo check describes what should happen: there is no test for the daily loss limit, and
20.000is a valid YAML number (20.0) even though the author meant twenty thousand, with the dot as a thousands separator
Show answer
Answer D. No check describes what should happen: there is no test for the daily loss limit, and
20.000is a valid YAML number (20.0) even though the author meant twenty thousand, with the dot as a thousands separatorTools check what someone has written down as a rule. A limit without a test can disappear, and a value with the right shape but an absurd magnitude passes type validation. The exercises add the two missing rules.
What does
extra="forbid"do in the configuration model?- AIt forbids environment variables
- BIt rejects keys the model does not know: a typo such as
perdida_diaria_maximais an error at load time, not an ignored field - CIt prevents changing the configuration after loading it
- DIt hides secrets when printing
Show answer
Answer B. It rejects keys the model does not know: a typo such as
perdida_diaria_maximais an error at load time, not an ignored fieldWithout it, the misspelled key is ignored and the value the author wanted to change is still the one from
base.yaml. Preventing changes after loading isfrozen=True; hiding secrets,SecretStr.
Key points
- A trading project is a package in
src/with a singlepyproject.toml(dependencies, Ruff, pytest) and one rule: the domain imports nothing from the outside; adapters and configuration depend on the domain, not the other way around. - Configuration is loaded in layers (base YAML, profile YAML, environment), with deep merging, unknown keys forbidden, risk fields without default values and a fingerprint logged on every run; secrets arrive through the environment and are stored as
SecretStr. - Ruff with the E, F, B, DTZ, S, SIM, PD and NPY families finds bugs that are not about style: naive datetimes,
assertin risk limits, bareexcept, mutable default values, closures in loops. - Tests use a deterministic simulated broker for the domain and recorded messages from each broker for the adapters; the clock is injected.
- pre-commit chains Ruff, formatting, configuration validation and the tests before every commit. In the experiment, the four safety nets catch twelve of fourteen defects; the two that escape are rules nobody wrote.