Lección 0.4.2 · 45 min
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.
- Módulo
- 0.4 · Python intermedio
- Duración
- 45 min
Lecciones del nivel 0
0.1 · Bienvenida y expectativas
0.2 · Entorno de trabajo moderno
- 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
- 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
- 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
- 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
- 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
0.3 · Fundamentos del lenguaje
- 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
- 0.3.2 Cadenas, f-strings y formato de números y fechas
- 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
- 0.3.4 Control de flujo: if, elif, else, for, while y match-case
- 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
- 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
0.4 · Python intermedio
- 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
- 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
- 0.4.3 Iteradores, generadores, decoradores y context managers
- 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
- 0.4.5 Logging profesional y testing con pytest
- 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
0.5 · Computación numérica y datos
- 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
- 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
- 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
- 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
- 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
0.6 · Visualización
0.7 · Matemáticas y estadística mínimas
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:
def importe(cantidad: int, precio: float) -> float:
return cantidad * precio
print(importe(2, 142.22))
print(importe(2, "142.22"))
print(importe.__annotations__)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: floatdice 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:
Fuente 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:
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"))[0.015, -0.0246]
171.44 NoneCon -> 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.1Nota 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.
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:
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__))dict
('compra', 'venta')
FalseLa 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:
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))True
False
TrueMaxCantidad 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):
# 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 checkSobre 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:
[tool.mypy]
python_version = "3.14"
strict = trueDos 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.
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__)nombre='cruce' simbolos=['SINT', 'RVAR'] media_rapida=50 media_lenta=200 riesgo_por_operacion=0.01 coste_pb=5.0 lado='largo'
intPydantic 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:
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})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 permittedLos 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:
resumen_errores({"nombre": "cruce", "simbolos": ["SINT"],
"media_rapida": 200, "media_lenta": 50,
"riesgo_por_operacion": 0.01})Errores en ConfigEstrategia: 1
(modelo): Value error, media_rapida debe ser < media_lentaLos 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.
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())['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:
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"])0.5
Input should be less than or equal to 0.02La 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.
Ejercicios
- Añade a
ConfigEstrategiaun campomarcoque solo admita"1d","1h"y"5m", con"1d"por defecto. Comprueba que una configuración conmarco: "15m"da un error en ese campo.
Solución
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"])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.
- Define un
TypedDictEjecucionconsimbolo,cantidad(con signo) yprecio, y una función anotadanocional(ejecuciones: list[Ejecucion]) -> floatque 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
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}")20,026.0014.222 + 5.804 = 20.026. El nocional negociado es la base sobre la que se calculan las comisiones proporcionales.
- Las configuraciones reales agrupan parámetros. Crea un modelo
Costes(comision_pbydeslizamiento_pb, ambos mayores o iguales que cero) y un modeloConfigconnombreycostes: Costes. Cárgalo desde un YAML con la seccióncostesanidada y calcula el coste total en puntos básicos.
Solución
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)comision_pb=1.5 deslizamiento_pb=3.0
4.5Pydantic 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').
Footnotes
-
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. ↩
Respuesta plegada en cada pregunta
Autoevaluación
Una función está anotada como
def importe(cantidad: int, precio: float) -> float. ¿Qué pasa al llamar aimporte(2, "142.22")?- APython lanza
TypeErrorporque"142.22"no es unfloat - BPython convierte el texto a
floaty devuelve 284.44 - CPython ejecuta la función sin mirar las anotaciones y devuelve
"142.22142.22" - DLa llamada no compila
Ver respuesta
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.- APython lanza
¿Qué ventaja tiene
Lado = Literal["compra", "venta"]frente alado: str?- ANinguna: en tiempo de ejecución son iguales y el comprobador también los trata igual
- BEl comprobador estático marca cualquier otro texto, como
"comprar", antes de ejecutar - CPython lanza una excepción si se pasa otro texto
- DHace el código más rápido
Ver respuesta
Respuesta B. El comprobador estático marca cualquier otro texto, como
"comprar", antes de ejecutarPara Python, un
Literales unstrnormal; para mypy o ty, solo valen esos dos valores. En tiempo de ejecución hay que validarlo aparte, por ejemplo conget_args(Lado)o con Pydantic.Una configuración tiene
media_rapida: int = Field(gt=0)y el YAML traemedia_rapida: "50". ¿Qué hace Pydantic en su modo por defecto?- ALanza
ValidationErrorporque el valor es texto - BAcepta el valor y lo guarda como texto
- CConvierte
"50"en el entero 50, porque el texto representa un entero válido - DIgnora el campo
Ver respuesta
Respuesta C. Convierte
"50"en el entero 50, porque el texto representa un entero válidoEl modo por defecto (lax) convierte valores que representan sin ambigüedad el tipo pedido. Con
strict=Trueel texto se rechazaría.- ALanza
Con una configuración Pydantic válida
cfg, ¿qué hacecfg.model_copy(update={"riesgo_por_operacion": 0.5})si el campo tienele=0.02?- ALanza
ValidationError - BDevuelve una copia con 0,5 sin validar:
model_copyno pasa por los validadores - CDevuelve una copia con el valor recortado a 0,02
- DModifica
cfgen su sitio
Ver respuesta
Respuesta B. Devuelve una copia con 0,5 sin validar:
model_copyno pasa por los validadoresmodel_copy(update=…)copia y sustituye sin validar. Para cambiar un valor con garantías hay que volver a validar, por ejemplo conmodel_validatesobre el diccionario modificado.- ALanza
Ideas clave
- Las anotaciones documentan y permiten comprobar, pero Python no las aplica al ejecutar:
2 * "142.22"no falla. Literal,TypedDictyProtocoldescriben 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.