---
title: "0.4.2 Tipado: type hints, mypy o ty, Pydantic"
description: "Anotaciones de tipo para precios y órdenes, Literal, TypedDict y Protocol, mypy o ty, y configuraciones de estrategia validadas con Pydantic."
url: "https://pythonparatrading.com/curso-python-trading/nivel-0/tipado-type-hints-y-pydantic/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-0/tipado-type-hints-y-pydantic.png"
---

# 0.4.2 · Tipado: type hints, mypy o ty y Pydantic para configuraciones

> Python no comprueba tipos al ejecutar: un precio leído como texto se cuela hasta que algo explota, o peor, hasta que no explota. Las anotaciones documentan qué espera cada función, un comprobador estático encuentra los errores antes de ejecutar y Pydantic valida en la frontera, donde entran los datos y las configuraciones.

- Curso: [Nivel 0: Python desde cero y mentalidad de datos](https://pythonparatrading.com/curso-python-trading/nivel-0/)
- Módulo: 0.4 · Python intermedio
- Duración: 45 min
- Requisitos: [0.4.1 · Programación orientada a objetos: clases, herencia, composición y dataclasses](https://pythonparatrading.com/curso-python-trading/nivel-0/clases-herencia-y-dataclasses/)

## Al terminar esta lección

- Anotar funciones con tipos básicos, colecciones, uniones con `None`, `Literal` y `TypedDict`.
- Declarar una interfaz común con `Protocol` y comprobar el código con mypy o ty desde la terminal.
- Definir la configuración de una estrategia con Pydantic v2, con restricciones por campo y una regla entre campos.
- Cargar una configuración desde YAML y leer los errores de validación de forma controlada.

## Lo que Python no comprueba

Python es un lenguaje de **tipado dinámico**: cada valor sabe de qué tipo es, pero una variable o un argumento pueden
recibir cualquier cosa. La función de la lección 0.4.1 que calculaba el importe de una orden funciona con números y
también, sin quejarse, con texto. Basta un precio leído de un CSV sin convertir:

```python
def importe(cantidad: int, precio: float) -> float:
    return cantidad * precio

print(importe(2, 142.22))
print(importe(2, "142.22"))
print(importe.__annotations__)
```
```output
284.44
142.22142.22
{'cantidad': <class 'int'>, 'precio': <class 'float'>, 'return': <class 'float'>}
```

La segunda llamada no lanza ningún error: `2 * "142.22"` repite el texto. El resultado viajará por el programa hasta que
algo intente sumarlo a un número, o se escribirá tal cual en un informe. Las **anotaciones de tipo** (*type hints*),
lo que va después de los dos puntos y de la flecha, están ahí, Python las guarda en `__annotations__` y no hace nada
más con ellas.

Para qué sirven, entonces:

- **Documentan.** `precio: float` dice más que un comentario y no se queda desactualizado sin que nadie lo note.
- **Las leen las herramientas.** El editor completa métodos y marca errores mientras se escribe (lección 0.2.3), y un
  **comprobador estático** como mypy o ty revisa el programa entero sin ejecutarlo.
- **Las usan algunas bibliotecas.** Las dataclasses de la lección 0.4.1 leen las anotaciones para crear los campos;
  Pydantic, al final de esta lección, las usa para validar datos de verdad.

Hay, por tanto, dos momentos distintos para encontrar un error de tipo, y conviene tener los dos cubiertos:

```mermaid
flowchart LR
  C[Código anotado] --> S[mypy o ty]
  S --> E[Errores antes]
  D[Datos externos] --> P[Pydantic]
  P --> V[Error al cargar]
```

El comprobador estático mira el código; Pydantic mira los datos que entran (un YAML, la respuesta de un bróker). Ni el
primero puede saber qué traerá un fichero ni el segundo revisa las funciones internas.

## La sintaxis de las anotaciones

Las anotaciones usan los propios tipos de Python y unos pocos símbolos más. Las que aparecen en código de trading:

| Anotación | Significa | Ejemplo |
|---|---|---|
| `int`, `float`, `str`, `bool` | Un valor de ese tipo | `cantidad: int` |
| `list[float]` | Lista de floats | `cierres: list[float]` |
| `dict[str, float]` | Diccionario de texto a float | `precios: dict[str, float]` |
| `tuple[float, float]` | Tupla de dos floats | `-> tuple[float, float]` (realizado, no realizado) |
| `float \| None` | Un float o `None` | Un precio que puede faltar |
| `Callable[[float], bool]` | Una función que recibe un float y devuelve un bool | Un filtro de precios |
| `type Precio = float` | Alias de tipo (Python 3.12+) | Nombres con significado |

La unión con `None` es la más útil de todas: obliga a tratar el caso en que el dato no existe. Un diccionario de
cierres no tiene por qué tener todos los símbolos, y la función que lo consulta lo dice en su firma:

```python
type Precio = float

def rentabilidades(cierres: list[Precio]) -> list[float]:
    """Rentabilidades simples entre cierres consecutivos."""
    return [b / a - 1 for a, b in zip(cierres, cierres[1:])]

def ultimo(cierres: dict[str, Precio], simbolo: str) -> Precio | None:
    return cierres.get(simbolo)

cierres = {"RVAR": 164.35, "ORO": 171.44}
print([round(r, 4) for r in rentabilidades([100.0, 101.5, 99.0])])
print(ultimo(cierres, "ORO"), ultimo(cierres, "TECH"))
```
```output
[0.015, -0.0246]
171.44 None
```

Con `-> Precio | None`, el comprobador avisa si el código que llama a `ultimo` hace cuentas con el resultado sin mirar
antes si es `None`. Es exactamente el error de un sistema que valora una posición cuyo precio no llegó: sin anotación,
aparece en producción como `TypeError: unsupported operand type(s) for *: 'int' and 'NoneType'`.

En Python 3.14 las anotaciones se evalúan de forma diferida (PEP 649): una función puede anotar con una clase que se
define más abajo en el mismo fichero sin poner el nombre entre comillas.[^1]

## Valores permitidos y diccionarios con forma: Literal y TypedDict

`str` admite cualquier texto, y el lado de una orden solo admite dos. `Literal` enumera los valores válidos.
`TypedDict` describe un diccionario con claves fijas y un tipo por clave, la forma habitual de los mensajes JSON de un
bróker. `NotRequired` marca las claves opcionales, como el límite de una orden de mercado:

```python
from typing import Literal, NotRequired, TypedDict, get_args

type Lado = Literal["compra", "venta"]

class OrdenDict(TypedDict):
    simbolo: str
    lado: Lado
    cantidad: int
    limite: NotRequired[float]

orden: OrdenDict = {"simbolo": "SINT", "lado": "compra",
                    "cantidad": 100, "limite": 141.5}
mala: OrdenDict = {"simbolo": "SINT", "lado": "comprar",
                   "cantidad": 100}
print(type(orden).__name__)
print(get_args(Lado.__value__))
print(mala["lado"] in get_args(Lado.__value__))
```
```output
dict
('compra', 'venta')
False
```

La orden `mala` se crea sin problema: en tiempo de ejecución, un `TypedDict` es un diccionario normal y un `Literal`
es un texto normal. Quien lo detecta es el comprobador estático, que marca la línea con `"comprar"` antes de ejecutar
nada. Si el valor viene de fuera (un fichero, una API), hay que validarlo al ejecutar; `get_args` devuelve los valores
del `Literal` para hacerlo a mano, y Pydantic lo hace solo, como se ve más abajo.

## Interfaces sin herencia: Protocol

En la lección 0.4.1, el ejecutor aceptaba cualquier objeto con un método `permite(orden)`. Esa «firma común» se
declara con un `Protocol`: una clase que solo describe métodos. Las clases que los tengan cumplen el protocolo sin
heredar de él. Es lo que en Python se llama *duck typing* («si camina como un pato…»), ahora comprobable:

```python
from dataclasses import dataclass
from typing import Protocol, runtime_checkable

@runtime_checkable
class ReglaRiesgo(Protocol):
    def permite(self, orden: OrdenDict) -> bool: ...

@dataclass
class MaxCantidad:
    maximo: int

    def permite(self, orden: OrdenDict) -> bool:
        return orden["cantidad"] <= self.maximo

@dataclass
class SimbolosPermitidos:
    simbolos: frozenset[str]

    def permite(self, orden: OrdenDict) -> bool:
        return orden["simbolo"] in self.simbolos

def aprobar(orden: OrdenDict, reglas: list[ReglaRiesgo]) -> bool:
    return all(r.permite(orden) for r in reglas)

reglas: list[ReglaRiesgo] = [MaxCantidad(500),
                             SimbolosPermitidos(frozenset({"SINT"}))]
print(aprobar(orden, reglas))
print(aprobar({"simbolo": "TECH", "lado": "venta",
               "cantidad": 10}, reglas))
print(isinstance(MaxCantidad(1), ReglaRiesgo))
```
```output
True
False
True
```

`MaxCantidad` y `SimbolosPermitidos` no heredan de `ReglaRiesgo` ni se conocen entre sí. El comprobador sabe que
cumplen el protocolo porque tienen `permite` con la firma correcta; si alguien escribe una regla con
`permite(self, orden, precio)`, el error aparece al comprobar, no la primera vez que esa regla rechace una orden real.
`@runtime_checkable` permite además usar `isinstance`, que solo mira si el método existe, no su firma.

## mypy o ty: comprobar sin ejecutar

Las anotaciones sirven de poco si nadie las comprueba. Las dos herramientas habituales son **mypy**, la de referencia
desde hace años, y **ty**, de Astral (los autores de uv), escrita en Rust y mucho más rápida. Ninguna ejecuta el
programa: leen el código, deducen el tipo de cada expresión y comparan con las anotaciones. Se instalan como
dependencias de desarrollo del proyecto (lección 0.2.2):

```bash
# mypy como dependencia de desarrollo
uv add --dev mypy
uv run mypy src/

# modo estricto: exige anotar todo y prohíbe Any implícito
uv run mypy --strict src/

# ty, sin instalarlo en el proyecto
uvx ty check
```

Sobre un fichero con las celdas de esta lección, las dos herramientas señalan la llamada `importe(2, "142.22")` y el
diccionario con `"lado": "comprar"`: indican el fichero, la línea y el tipo esperado frente al recibido, y terminan con
código de salida distinto de cero. Ese código de salida es lo que permite usarlas en un hook de Git o en la
integración continua: si el comprobador falla, el cambio no entra. (Con Pydantic instalado, mypy marca también el
`media_rapida="50"` de la sección siguiente: conoce los tipos del modelo, aunque Pydantic convertiría el texto al
ejecutar. Los datos externos se cargan con `model_validate`, que recibe un diccionario y no tiene ese problema.)

La configuración vive en `pyproject.toml`, junto al resto del proyecto:

```toml
[tool.mypy]
python_version = "3.14"
strict = true
```

Dos consejos prácticos. Empieza por anotar las **fronteras**: las funciones públicas de cada módulo, las que reciben
datos de fuera y las que devuelven resultados que usa otro código. Dentro de una función, el comprobador deduce casi
todo solo. Y no abuses de `Any` ni de `# type: ignore`: cada uno apaga la comprobación justo donde la pusiste, que
suele ser donde hacía falta.

## Pydantic: configuraciones que se validan al cargar

Una estrategia tiene parámetros: símbolos, ventanas de las medias, riesgo por operación, costes. Suelen vivir en un
fichero de configuración, y un error en ese fichero es de los más caros: un `riesgo_por_operacion: 0.2` en lugar de
`0.02` multiplica por diez el tamaño de cada posición y ningún test de las funciones lo detecta.

**Pydantic** convierte las anotaciones en validación en tiempo de ejecución. Un modelo es una clase que hereda de
`BaseModel`; cada campo lleva su tipo y, con `Field`, sus restricciones (`gt`, mayor que; `le`, menor o igual;
`min_length`, longitud mínima). Los validadores añaden reglas propias: `field_validator` para un campo y
`model_validator` para reglas entre campos, como que la media rápida sea más corta que la lenta.

```python
from pydantic import (BaseModel, ConfigDict, Field, ValidationError,
                      field_validator, model_validator)

class ConfigEstrategia(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    nombre: str
    simbolos: list[str] = Field(min_length=1)
    media_rapida: int = Field(gt=0)
    media_lenta: int = Field(gt=0)
    riesgo_por_operacion: float = Field(gt=0, le=0.02)
    coste_pb: float = Field(default=5.0, ge=0)
    lado: Literal["largo", "corto", "ambos"] = "largo"

    @field_validator("simbolos")
    @classmethod
    def mayusculas(cls, v: list[str]) -> list[str]:
        return [s.strip().upper() for s in v]

    @model_validator(mode="after")
    def rapida_menor(self) -> "ConfigEstrategia":
        if self.media_rapida >= self.media_lenta:
            raise ValueError("media_rapida debe ser < media_lenta")
        return self

cfg = ConfigEstrategia(nombre="cruce", simbolos=[" sint", "rvar"],
                       media_rapida="50", media_lenta=200,
                       riesgo_por_operacion=0.01)
print(cfg)
print(type(cfg.media_rapida).__name__)
```
```output
nombre='cruce' simbolos=['SINT', 'RVAR'] media_rapida=50 media_lenta=200 riesgo_por_operacion=0.01 coste_pb=5.0 lado='largo'
int
```

Pydantic ha hecho tres cosas: aplicar el validador de símbolos (sin espacios y en mayúsculas), convertir `"50"` en el
entero 50 y rellenar los valores por defecto. La conversión de texto a número es el modo por defecto, llamado *lax*:
acepta un valor si representa sin ambigüedad el tipo pedido. Con `ConfigDict(strict=True)` el texto se rechazaría.

`model_config` fija dos opciones importantes para una configuración. `extra="forbid"` convierte una clave desconocida
en error, y `frozen=True` impide modificar el objeto una vez creado, igual que las dataclasses congeladas de la lección
0.4.1.

## Errores de validación, todos a la vez

Cuando algo falla, Pydantic lanza `ValidationError` con **todos** los errores de los campos, no solo el primero. El
mensaje completo es largo (incluye un enlace a la documentación por cada error); en un programa conviene recorrer
`errors()`, que da una lista de diccionarios con la ubicación (`loc`) y el mensaje (`msg`) de cada fallo:

```python
def resumen_errores(datos: dict) -> None:
    try:
        ConfigEstrategia(**datos)
    except ValidationError as e:
        print(f"Errores en {e.title}: {e.error_count()}")
        for err in e.errors():
            campo = ".".join(str(p) for p in err["loc"]) or "(modelo)"
            print(f"  {campo}: {err['msg']}")

resumen_errores({"nombre": "cruce", "simbolos": ["SINT"],
                 "media_rapida": 50, "media_lenta": 200,
                 "riesgo_por_operacion": 0.2, "lado": "largos",
                 "coste_pbs": 3})
```
```output
Errores en ConfigEstrategia: 3
  riesgo_por_operacion: Input should be less than or equal to 0.02
  lado: Input should be 'largo', 'corto' or 'ambos'
  coste_pbs: Extra inputs are not permitted
```

Los tres errores son de los que se cuelan en la vida real: un riesgo diez veces mayor de lo previsto, un valor casi
correcto y una clave mal escrita. Sin `extra="forbid"`, `coste_pbs` se ignoraría en silencio y la estrategia operaría
con el coste por defecto de 5 pb creyendo que usa 3.

La regla entre campos solo se evalúa si los campos han pasado su propia validación (`mode="after"` recibe el modelo ya
construido). Con los campos correctos y las medias al revés:

```python
resumen_errores({"nombre": "cruce", "simbolos": ["SINT"],
                 "media_rapida": 200, "media_lenta": 50,
                 "riesgo_por_operacion": 0.01})
```
```output
Errores en ConfigEstrategia: 1
  (modelo): Value error, media_rapida debe ser < media_lenta
```

Los mensajes de Pydantic están en inglés; el de la regla propia, en el idioma en que se escribió. La ubicación vacía
indica que el error es del modelo entero, no de un campo.

## Cargar la configuración desde YAML

YAML es el formato habitual de las configuraciones porque se lee y se edita a mano con facilidad (los formatos de
fichero se ven en la lección 0.4.4). El flujo tiene dos pasos: `yaml.safe_load` convierte el texto en diccionarios y
listas, y `model_validate` lo valida.

```python
import yaml

TEXTO = """
nombre: cruce-medias
simbolos: [sint, rvar, oro]
media_rapida: 50
media_lenta: 200
riesgo_por_operacion: 0.01
coste_pb: 2.5
"""

cfg = ConfigEstrategia.model_validate(yaml.safe_load(TEXTO))
print(cfg.simbolos, cfg.coste_pb, cfg.lado)
print(cfg.model_dump())
```
```output
['SINT', 'RVAR', 'ORO'] 2.5 largo
{'nombre': 'cruce-medias', 'simbolos': ['SINT', 'RVAR', 'ORO'], 'media_rapida': 50, 'media_lenta': 200, 'riesgo_por_operacion': 0.01, 'coste_pb': 2.5, 'lado': 'largo'}
```

Usa siempre `safe_load`: crea solo tipos básicos. `yaml.load` con el cargador completo puede construir objetos
arbitrarios de Python a partir del fichero, y una configuración no debe poder ejecutar código. `model_dump()` hace el
camino inverso, de modelo a diccionario, útil para guardar junto a los resultados de un backtest la configuración
exacta que los produjo.

Una trampa: `model_copy(update=…)` crea una copia con cambios **sin validar**. Sirve para valores que ya sabes que son
correctos; para todo lo demás, hay que volver a validar:

```python
copia = cfg.model_copy(update={"riesgo_por_operacion": 0.5})
print(copia.riesgo_por_operacion)

nuevo = {**cfg.model_dump(), "riesgo_por_operacion": 0.5}
try:
    ConfigEstrategia.model_validate(nuevo)
except ValidationError as e:
    print(e.errors()[0]["msg"])
```
```output
0.5
Input should be less than or equal to 0.02
```

La copia tiene un riesgo del 50 % por operación y ningún error lo ha impedido. En un sistema real, la configuración se
valida una vez al arrancar, se congela y no se toca; si un parámetro cambia, se carga otra vez desde el fichero.

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

Valida la configuración **antes** de conectar con el bróker, no cuando la primera orden la necesite. Un sistema que
arranca, se conecta y falla a mitad de sesión por un parámetro mal escrito puede dejar órdenes abiertas sin nadie que
las gestione.

</aside>

[^1]: PEP 649 y PEP 749, incluidas en Python 3.14: las anotaciones se guardan sin evaluar y se calculan al pedirlas.
Detalles en la documentación de `annotationlib`.

## Ejercicios

1. Añade a `ConfigEstrategia` un campo `marco` que solo admita `"1d"`, `"1h"` y `"5m"`, con `"1d"` por defecto.
   Comprueba que una configuración con `marco: "15m"` da un error en ese campo.

**Solución.**

```python
class ConfigConMarco(ConfigEstrategia):
    marco: Literal["1d", "1h", "5m"] = "1d"

base = yaml.safe_load(TEXTO)
print(ConfigConMarco.model_validate(base).marco)
try:
    ConfigConMarco.model_validate({**base, "marco": "15m"})
except ValidationError as e:
    err = e.errors()[0]
    print(err["loc"], err["msg"])
```
```output
1d
('marco',) Input should be '1d', '1h' or '5m'
```

Un modelo de Pydantic se puede heredar como cualquier clase: la subclase conserva los campos, las opciones de
`model_config` y los validadores de la base, y añade los suyos.

2. Define un `TypedDict` `Ejecucion` con `simbolo`, `cantidad` (con signo) y `precio`, y una función anotada
   `nocional(ejecuciones: list[Ejecucion]) -> float` que sume el valor absoluto de cada ejecución. Pruébala con una
   compra de 100 a 142,22 y una venta de 40 a 145,10.

**Solución.**

```python
class Ejecucion(TypedDict):
    simbolo: str
    cantidad: int
    precio: float

def nocional(ejecuciones: list[Ejecucion]) -> float:
    return sum(abs(e["cantidad"]) * e["precio"]
               for e in ejecuciones)

fills: list[Ejecucion] = [
    {"simbolo": "SINT", "cantidad": 100, "precio": 142.22},
    {"simbolo": "SINT", "cantidad": -40, "precio": 145.10},
]
print(f"{nocional(fills):,.2f}")
```
```output
20,026.00
```

14.222 + 5.804 = 20.026. El nocional negociado es la base sobre la que se calculan las comisiones proporcionales.

3. Las configuraciones reales agrupan parámetros. Crea un modelo `Costes` (`comision_pb` y `deslizamiento_pb`, ambos
   mayores o iguales que cero) y un modelo `Config` con `nombre` y `costes: Costes`. Cárgalo desde un YAML con la
   sección `costes` anidada y calcula el coste total en puntos básicos.

**Solución.**

```python
class Costes(BaseModel):
    model_config = ConfigDict(extra="forbid")
    comision_pb: float = Field(ge=0)
    deslizamiento_pb: float = Field(ge=0)

class Config(BaseModel):
    nombre: str
    costes: Costes

texto = """
nombre: cruce
costes:
  comision_pb: 1.5
  deslizamiento_pb: 3
"""
c = Config.model_validate(yaml.safe_load(texto))
print(c.costes)
print(c.costes.comision_pb + c.costes.deslizamiento_pb)
```
```output
comision_pb=1.5 deslizamiento_pb=3.0
4.5
```

Pydantic valida el modelo anidado con sus propias reglas, y un error en él aparece con la ruta completa en `loc`, por
ejemplo `('costes', 'comision_pb')`.

## Autoevaluación

1. Una función está anotada como `def importe(cantidad: int, precio: float) -> float`. ¿Qué pasa al llamar a `importe(2, "142.22")`?

   - A. Python lanza `TypeError` porque `"142.22"` no es un `float`
   - B. Python convierte el texto a `float` y devuelve 284.44
   - C. Python ejecuta la función sin mirar las anotaciones y devuelve `"142.22142.22"`
   - D. La llamada no compila

   *Respuesta C: Python ejecuta la función sin mirar las anotaciones y devuelve `"142.22142.22"`.* Las anotaciones no se comprueban al ejecutar. `2 * "142.22"` repite el texto. El error solo lo detecta un comprobador estático (mypy, ty) o una validación explícita como la de Pydantic.

2. ¿Qué ventaja tiene `Lado = Literal["compra", "venta"]` frente a `lado: str`?

   - A. Ninguna: en tiempo de ejecución son iguales y el comprobador también los trata igual
   - B. El comprobador estático marca cualquier otro texto, como `"comprar"`, antes de ejecutar
   - C. Python lanza una excepción si se pasa otro texto
   - D. Hace el código más rápido

   *Respuesta B: El comprobador estático marca cualquier otro texto, como `"comprar"`, antes de ejecutar.* Para Python, un `Literal` es un `str` normal; para mypy o ty, solo valen esos dos valores. En tiempo de ejecución hay que validarlo aparte, por ejemplo con `get_args(Lado)` o con Pydantic.

3. Una configuración tiene `media_rapida: int = Field(gt=0)` y el YAML trae `media_rapida: "50"`. ¿Qué hace Pydantic en su modo por defecto?

   - A. Lanza `ValidationError` porque el valor es texto
   - B. Acepta el valor y lo guarda como texto
   - C. Convierte `"50"` en el entero 50, porque el texto representa un entero válido
   - D. Ignora el campo

   *Respuesta C: Convierte `"50"` en el entero 50, porque el texto representa un entero válido.* El modo por defecto (lax) convierte valores que representan sin ambigüedad el tipo pedido. Con `strict=True` el texto se rechazaría.

4. Con una configuración Pydantic válida `cfg`, ¿qué hace `cfg.model_copy(update={"riesgo_por_operacion": 0.5})` si el campo tiene `le=0.02`?

   - A. Lanza `ValidationError`
   - B. Devuelve una copia con 0,5 sin validar: `model_copy` no pasa por los validadores
   - C. Devuelve una copia con el valor recortado a 0,02
   - D. Modifica `cfg` en su sitio

   *Respuesta B: Devuelve una copia con 0,5 sin validar: `model_copy` no pasa por los validadores.* `model_copy(update=…)` copia y sustituye sin validar. Para cambiar un valor con garantías hay que volver a validar, por ejemplo con `model_validate` sobre el diccionario modificado.

## Ideas clave

- Las anotaciones documentan y permiten comprobar, pero Python no las aplica al ejecutar: `2 * "142.22"` no falla.
- `Literal`, `TypedDict` y `Protocol` describen valores permitidos, diccionarios con claves fijas e interfaces comunes sin herencia.
- mypy o ty leen el código sin ejecutarlo y encuentran los errores de tipo antes de que lleguen a una cuenta real.
- Pydantic valida en tiempo de ejecución, en la frontera: configuraciones, respuestas de APIs y ficheros. Con `extra="forbid"` una clave mal escrita es un error, no un valor por defecto silencioso.
- Una configuración de estrategia se valida al cargarla, entera, y se congela. `model_copy(update=…)` no valida.

## Recursos

### Documentación

- [Documentación de Python 3.14: typing](https://docs.python.org/3.14/library/typing.html) · docs.python.org
- [Documentación de Pydantic: modelos](https://docs.pydantic.dev/latest/concepts/models/) · docs.pydantic.dev
- [Documentación de mypy](https://mypy.readthedocs.io/en/stable/) · mypy.readthedocs.io
- [Documentación de ty (Astral)](https://docs.astral.sh/ty/) · docs.astral.sh

Lección anterior: [0.4.1 · Programación orientada a objetos: clases, herencia, composición y dataclasses](https://pythonparatrading.com/curso-python-trading/nivel-0/clases-herencia-y-dataclasses/)

Lección siguiente: [0.4.3 · Iteradores, generadores, decoradores y context managers](https://pythonparatrading.com/curso-python-trading/nivel-0/iteradores-generadores-y-decoradores/)
