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.

Fig. — Fourteen defects seeded in the lesson's bot and how many each layer catches: Ruff and the unit tests, four each; config validation, three; contract tests, two; two are caught by none
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 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:

PathWhat it containsMay import
src/ppt_bot/dominio.pyorders, fills, portfolio, order calculationonly the standard library
src/ppt_bot/puertos.pywhat the service needs from the outside (Broker)domain
src/ppt_bot/servicio.pythe use case: rebalance within limitsdomain, ports
src/ppt_bot/broker_sim.pya simulated broker for tests and paperdomain, ports
src/ppt_bot/adaptadores.pyeach broker's messages translated into the domaindomain
src/ppt_bot/config.pyvalidated configurationpydantic
config/*.yamlvalues per profile, no secrets—
tests/tests and recorded messageseverything
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:

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

IN [01]python
from pathlib import Path

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

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

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

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

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

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

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

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-p no:cacheprovider"
''')
escribir("src/ppt_bot/__init__.py", "")
escribir("config/base.yaml", '''
modo: paper
riesgo:
  nocional_max_orden: 50000
  perdida_diaria_max: 2000
broker:
  nombre: simulado
''')
escribir("config/paper.yaml", '''
# paper: 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()))
OUT [01]The skeleton: pyproject.toml and profiles
['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:

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

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

IN [04]python
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_sources returns the sources from highest to lowest priority. Here, arguments, environment and YAML; .env is left out on purpose (a forgotten .env on the server is a configuration nobody sees).
  • Profiles: PPT_PERFIL chooses the second YAML file (paper.yaml or real.yaml), which is layered over base.yaml.
  • Deep merge: deep_merge=True. Without it, the riesgo: block of real.yaml replaces the one in base.yaml entirely.
  • 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):

IN [05]python
import hashlib
import json
import os
import sys

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

from ppt_bot import config as cfg

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

def procedencia():
    fuentes = [("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))
OUT [05]Which value wins and where it comes from
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: 02200b892309

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

IN [06]python
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"}))
OUT [06]Three configurations that must not load
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 broker

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

IN [07]python
import shutil
import subprocess

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


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


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


def cargar(ruta):
    try:
        df = pd.read_csv(ruta)
    except:
        return None
    df.dropna(inplace=True)
    ruido = np.random.normal(size=len(df))
    if df.empty == True:
        print(f"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)
OUT [07]Ruff on notebook code
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:

RuleWhat happens in a trading system
B006historial=[] is created once and shared by every call: one day's history gets mixed with the next day's
DTZ005datetime.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
S101assert disappears with python -O: a limit written this way ceases to exist depending on how the process is launched
E722a bare except: also catches KeyboardInterrupt and programming errors: the bot carries on with data it has not loaded
PD002inplace=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
NPY002np.random.normal uses the global state: a result that cannot be reproduced with a local seed
B023the three functions use the k from the end of the loop (2), not the one from their iteration
F401, E401, I001, F541, E712unused 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:

IN [08]python
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())
OUT [08]Ruff on the package
ruff check -> 0 All checks passed!
ruff format -> 0 7 files already formatted

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

IN [09]python
escribir("tests/conftest.py", '''
import json
from datetime import UTC, datetime
from pathlib import Path

import pytest

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


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


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


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


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

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

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


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


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


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


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


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


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


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

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:

IN [10]python
escribir("tests/test_contrato.py", '''
import pytest

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

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


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

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:

IN [11]python
import re

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

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

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

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

git commitruff checkruff formatvalidate configpytestcommit created
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]
IN [12]python
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)
OUT [12]The hook and a commit with a defect
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...................................................................Passed

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

IN [13]python
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])
OUT [13]Fixed: the commit passes
commit: 0
ruff check...............................................................Passed
ruff format..............................................................Passed
validate config..........................................................Passed
pytest...................................................................Passed
9a98f3b Rebalancing bot

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

IN [14]python
from contextlib import contextmanager

S, DOM = "src/ppt_bot/servicio.py", "src/ppt_bot/dominio.py"
AD, REAL = "src/ppt_bot/adaptadores.py", "config/real.yaml"
LIMITE = '''        if nocional > riesgo.nocional_max_orden:
            raise LimiteExcedido(f"{o.clave}: {nocional:.0f}")
'''
PERDIDA = '''    perdida = valor_inicio - cartera.valor(precios)
    if perdida > riesgo.perdida_diaria_max:
        raise LimiteExcedido(f"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)
OUT [14]The matrix of defects and layers
                               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']
Fig. — The full matrix: which layer catches each seeded defect. Each layer covers its own part and only one, the assert on the risk limit, is caught by two

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 bare except and the mutable default value. In D03 it also warns that OrdenRechazada is 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 LimiteExcedido and gets AssertionError. 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.000 meaning 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

  1. Write a test that catches D10: with a portfolio that has lost more than perdida_diaria_max since the start of the day, rebalancear must raise LimiteExcedido without sending any order. Check that it passes with the clean code and fails with the seeded defect.
Solution
IN [15]python
escribir("tests/test_perdida.py", '''
import pytest

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


def test_perdida_diaria_bloquea(riesgo, cartera, reloj):
    b = BrokerSimulado({"ORSC": 234.84})
    with pytest.raises(LimiteExcedido):
        rebalancear(
            b, cartera, {"ORSC": 10_000}, {"ORSC": 234.84},
            riesgo, "2026-01-02", valor_inicio=103_000.0,
            reloj=reloj,
        )
    assert b.enviadas == []
''')
test = ["python", "-m", "pytest", "-q", "tests/test_perdida.py"]
print("clean:", ejecutar(*test)[1].splitlines()[-1])
with sembrado(DEFECTOS["D10 no daily loss limit"]):
    print("with D10:", ejecutar(*test)[1].splitlines()[-1])
OUT [15]The missing test
clean: 1 passed
with D10: 1 failed

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

  1. Add to Riesgo a rule that rejects a nocional_max_orden below 1,000 and check that C02 no longer passes validation.
Solution
IN [16]python
config_py = Path("src/ppt_bot/config.py")
antes = config_py.read_text()
config_py.write_text(antes.replace(
    "nocional_max_orden: float = Field(gt=0)",
    "nocional_max_orden: float = Field(ge=1000)"))
with sembrado(DEFECTOS["C02 20.000 in YAML"]):
    salida = ejecutar("python", "scripts/validar_config.py")[1]
config_py.write_text(antes)
print(salida)
OUT [16]A reasonable range in the configuration
real: riesgo.nocional_max_orden: Input should be greater than or equal to 1000

With 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).

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

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

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

  3. Ruff (https://docs.astral.sh/ruff/) and its list of rules (https://docs.astral.sh/ruff/rules/). Accessed on October 23, 2026. ↩

  4. pre-commit (https://pre-commit.com/): repo: local and language: system hooks. Accessed on October 23, 2026. ↩

Each answer is folded under its question

Self-assessment

  1. base.yaml defines riesgo.nocional_max_orden and riesgo.perdida_diaria_max; real.yaml only redefines riesgo.nocional_max_orden. With YamlConfigSettingsSource and deep_merge=False (the default), what happens when the real profile is loaded?

    • AThe two blocks are combined field by field
    • BThe riesgo block of real.yaml replaces the one in base.yaml entirely: perdida_diaria_max is missing and validation fails (or, if the field had a default value, that value would be used silently)
    • Cbase.yaml wins because it is read first
    • DPydantic Settings issues a warning and uses both values
    Show answer

    Answer B. The riesgo block of real.yaml replaces the one in base.yaml entirely: perdida_diaria_max is 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_max has no default value; with one, the loss limit would go back to its default without anyone noticing. Hence deep_merge=True and risk fields without default values.

  2. Why does the lesson enable Ruff's S101 rule (use of assert) in src/ and disable it in tests/?

    • ABecause assert is slower than if
    • BBecause pytest does not support assert
    • CBecause Python strips assert statements when run with -O: a risk limit written with assert disappears; in tests, assert is exactly pytest's tool
    • DBecause S101 only works on test files
    Show answer

    Answer C. Because Python strips assert statements when run with -O: a risk limit written with assert disappears; in tests, assert is exactly pytest's tool

    With python -O, assert statements are not executed. In production code, a risk check cannot depend on how the interpreter is launched: it is written with if and a custom exception. In the lesson's experiment, defect D02 is caught by Ruff and also by a test, because the test expects LimiteExcedido and gets AssertionError.

  3. The Interactive Brokers adapter compares side == "BUY", but IB's executions say BOT. 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.

  4. 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.000 is 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.000 is a valid YAML number (20.0) even though the author meant twenty thousand, with the dot as a thousands separator

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

  5. 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_maxima is 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_maxima is an error at load time, not an ignored field

    Without 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 is frozen=True; hiding secrets, SecretStr.

Key points

  • A trading project is a package in src/ with a single pyproject.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, assert in risk limits, bare except, 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.

Resources

Data