---
title: "7.1.1 Project structure, configuration and quality"
description: "From notebook to a repository with src/, layered Pydantic Settings, Ruff, pre-commit and tests with a simulated broker and recorded messages."
url: "https://pythonparatrading.com/en/python-trading-course/level-7/python-project-structure-configuration-quality/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-7/python-project-structure-configuration-quality.png"
---

# 7.1.1 · 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.

- Course: [Level 7: Production, MLOps, regulation, career and capstone](https://pythonparatrading.com/en/python-trading-course/level-7/)
- 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](https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/), [0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore](https://pythonparatrading.com/en/python-trading-course/level-0/git-and-github-for-trading/), [0.4.2 · Typing: type hints, mypy or ty, and Pydantic for configurations](https://pythonparatrading.com/en/python-trading-course/level-0/python-type-hints-and-pydantic/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/), [4.4.5 · Secure credentials: environment variables, secret managers and least privilege](https://pythonparatrading.com/en/python-trading-course/level-4/secure-api-credentials-and-least-privilege/), [4.5.5 · Fault tolerance: idempotency, persistent state and reconciliation at startup](https://pythonparatrading.com/en/python-trading-course/level-4/fault-tolerance-idempotency-and-reconciliation/)

## By the end of this lesson

- Organize a trading project with the src/ layout, a single pyproject.toml and a dependency rule between modules.
- Load the configuration in layers (base YAML, profile, environment) with Pydantic Settings, know which source each value comes from and reject unknown keys.
- Configure Ruff with the rule families that catch real bugs in trading code (naive datetimes, assert, bare except, mutable default values).
- Write tests with a deterministic simulated broker and contract tests with recorded messages from several brokers.
- Chain the checks in pre-commit and measure which defects each one catches.

## 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
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
argument[^1]. 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:

```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()))
```
```output
['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:

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

```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 priority[^2]: 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.

```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):

```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))
```
```output
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.

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

The API key in the cell is a toy key and arrives through an environment variable set in the code itself. In a real
system it comes from the secrets manager, from a secrets file mounted in the container or from the service's
environment, never from a YAML file in the repository or from a cell (lesson 4.4.5). `SecretStr` keeps it from being
printed by accident; it does not protect it from anyone who can read the process environment.

</aside>

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.

```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"}))
```
```output
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 others[^3]. 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:

```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)
```
```output
notebook/senal.py:1:1: E401 [*] Multiple imports on one line
notebook/senal.py:1:1: I001 [*] Import block is un-sorted or un-formatted
notebook/senal.py:1:8: F401 [*] `os` imported but unused
notebook/senal.py:1:12: F401 [*] `json` imported but unused
notebook/senal.py:7:32: B006 Do not use mutable data structures for argument defaults
notebook/senal.py:8:23: DTZ005 `datetime.datetime.now()` called without a `tz` argument
notebook/senal.py:13:5: S101 Use of `assert` detected
notebook/senal.py:20:5: E722 Do not use bare `except`
notebook/senal.py:22:15: PD002 `inplace=True` should be avoided; it has inconsistent behavior
notebook/senal.py:23:13: NPY002 Replace legacy `np.random.normal` call with `np.random.Generator`
notebook/senal.py:24:8: E712 Avoid equality comparisons to `True`; use `df.empty:` for truth checks
notebook/senal.py:25:15: F541 [*] f-string without any placeholders
notebook/senal.py:32:33: B023 Function definition does not bind loop variable `k`
Found 13 errors.
[*] 5 fixable with `--fix` (3 unsafe fixes available with `--fix --unsafe-fixes`).
```

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:

```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())
```
```output
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:

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

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

```python
import re

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

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

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

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 fails[^4]. 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
flowchart TB
  G[git commit] --> R[ruff check]
  R --> F[ruff format]
  F --> V[validate config]
  V --> T[pytest]
  T --> C[commit created]
```

```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)
```
```output
pre-commit installed at .git/hooks/pre-commit
commit: 1
ruff check...............................................................Failed
- hook id: ruff
- exit code: 1

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

ruff format..............................................................Passed
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:

```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])
```
```output
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:

```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)
```
```output
                               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.* Matrix of 14 defects by 5 columns. Ruff catches D01 naive datetime, D02 assert, D03 bare except and D04 mutable history; unit tests catch D02, D05 lots with round, D06 commission added and D07 no dedup; contract tests catch D08 cumulative quantity and D09 side BOT/BUY; config validation catches C01 mode live, C03 key typo and C04 live with sim. In the none column, highlighted, D10 no daily loss and 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 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.**

```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])
```
```output
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.

2. Add to `Riesgo` a rule that rejects a `nocional_max_orden` below 1,000 and check that C02 no longer passes
   validation.

**Solution.**

```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)
```
```output
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).

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

[^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.

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

   - A. The two blocks are combined field by field
   - 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)
   - C. `base.yaml` wins because it is read first
   - D. Pydantic Settings issues a warning and uses both values

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

   - A. Because `assert` is slower than `if`
   - B. Because pytest does not support `assert`
   - 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
   - D. Because S101 only works on test files

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

   - A. The contract tests: with the recorded message, the sum of the fills gives −434 instead of 434
   - B. Ruff
   - C. The configuration validation
   - D. None

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

   - A. They are syntax errors
   - B. They only show up with Python 3.15
   - C. They are style errors that Ruff ignores
   - 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

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

   - A. It forbids environment variables
   - 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
   - C. It prevents changing the configuration after loading it
   - D. It hides secrets when printing

   *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

### Documentation

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

### Data

- [Level 4 data: sample messages from several brokers (JSON)](https://pythonparatrading.com/curso/datos/nivel-4/brokers4_ejemplos.json) · Download · brokers4_ejemplos.json

Next lesson: [7.1.2 · Performance: measure, profile and compile only what is needed](https://pythonparatrading.com/en/python-trading-course/level-7/python-performance-profiling-numba-cython-rust/)
