Lesson 0.4.2 · 45 min
Typing: type hints, mypy or ty, and Pydantic for configurations
Python does not check types at run time: a price read as text slips through until something blows up, or worse, until nothing blows up. Annotations document what each function expects, a static checker finds errors before running, and Pydantic validates at the boundary, where data and configurations come in.
- Module
- 0.4 · Intermediate Python
- Duration
- 45 min
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
What Python does not check
Python is a dynamically typed language: every value knows its own type, but a variable or an argument can receive anything. The function from lesson 0.4.1 that computed the amount of an order works with numbers and also, without complaint, with text. All it takes is a price read from a CSV without converting it:
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'>}The second call raises no error: 2 * "142.22" repeats the text. The result will travel through the program until
something tries to add it to a number, or it will be written as is into a report. The type annotations (type
hints), what comes after the colons and the arrow, are there; Python stores them in __annotations__ and does nothing
else with them.
So what are they for:
- They document.
precio: floatsays more than a comment and does not go stale without anyone noticing. - Tools read them. The editor completes methods and flags errors as you type (lesson 0.2.3), and a static checker such as mypy or ty reviews the whole program without running it.
- Some libraries use them. The dataclasses from lesson 0.4.1 read the annotations to create the fields; Pydantic, at the end of this lesson, uses them to validate data for real.
There are, therefore, two different moments to catch a type error, and it pays to have both covered:
Mermaid source
flowchart LR C[Annotated code] --> S[mypy or ty] S --> E[Errors before running] D[External data] --> P[Pydantic] P --> V[Error on load]
The static checker looks at the code; Pydantic looks at the data coming in (a YAML file, a broker's response). The first cannot know what a file will contain, and the second does not review internal functions.
Annotation syntax
Annotations use Python's own types and a few extra symbols. The ones that show up in trading code:
| Annotation | Meaning | Example |
|---|---|---|
int, float, str, bool | A value of that type | cantidad: int |
list[float] | List of floats | cierres: list[float] |
dict[str, float] | Dictionary from text to float | precios: dict[str, float] |
tuple[float, float] | Tuple of two floats | -> tuple[float, float] (realized, unrealized) |
float | None | A float or None | A price that may be missing |
Callable[[float], bool] | A function that takes a float and returns a bool | A price filter |
type Precio = float | Type alias (Python 3.12+) | Meaningful names |
The union with None is the most useful of all: it forces you to handle the case where the data does not exist. A
dictionary of closes does not necessarily contain every symbol, and the function that looks it up says so in its
signature:
type Precio = float
def rentabilidades(cierres: list[Precio]) -> list[float]:
"""Simple returns between consecutive closes."""
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 NoneWith -> Precio | None, the checker warns if the code that calls ultimo does arithmetic with the result without
first checking whether it is None. That is exactly the bug of a system that values a position whose price never
arrived: without the annotation, it shows up in production as TypeError: unsupported operand type(s) for *: 'int' and 'NoneType'.
In Python 3.14 annotations are evaluated lazily (PEP 649): a function can be annotated with a class defined further
down in the same file without putting the name in quotes.1Note 1 · PEP 649 and PEP 749, included in Python 3.14: annotations are stored unevaluated and computed when requested.
Details in the annotationlib documentation.
Allowed values and shaped dictionaries: Literal and TypedDict
str accepts any text, and the side of an order only accepts two. Literal lists the valid values. TypedDict
describes a dictionary with fixed keys and a type per key, the usual shape of a broker's JSON messages. NotRequired
marks optional keys, such as the limit of a market order:
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')
FalseThe mala order is created without trouble: at run time, a TypedDict is an ordinary dictionary and a Literal is
ordinary text. What catches it is the static checker, which flags the line with "comprar" (a typo for "compra",
buy) before running anything. If the value comes from outside (a file, an API), it has to be validated at run time;
get_args returns the values of the Literal to do it by hand, and Pydantic does it on its own, as shown below.
Interfaces without inheritance: Protocol
In lesson 0.4.1, the executor accepted any object with a permite(orden) method. That "common signature" is declared
with a Protocol: a class that only describes methods. Classes that have those methods satisfy the protocol without
inheriting from it. It is what Python calls duck typing ("if it walks like a duck…"), now checkable:
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 and SimbolosPermitidos do not inherit from ReglaRiesgo and do not know about each other. The checker
knows they satisfy the protocol because they have permite with the right signature; if someone writes a rule with
permite(self, orden, precio), the error shows up at check time, not the first time that rule rejects a real order.
@runtime_checkable also allows isinstance, which only checks that the method exists, not its signature.
mypy or ty: checking without running
Annotations are of little use if nobody checks them. The two usual tools are mypy, the reference for years, and ty, from Astral (the authors of uv), written in Rust and much faster. Neither runs the program: they read the code, infer the type of each expression and compare it with the annotations. They are installed as development dependencies of the project (lesson 0.2.2):
# mypy as a development dependency
uv add --dev mypy
uv run mypy src/
# strict mode: requires annotating everything and forbids implicit Any
uv run mypy --strict src/
# ty, without installing it in the project
uvx ty checkOn a file with the cells of this lesson, both tools flag the call importe(2, "142.22") and the dictionary with
"lado": "comprar": they report the file, the line and the expected type versus the one received, and exit with a
non-zero exit code. That exit code is what lets you use them in a Git hook or in continuous integration: if the checker
fails, the change does not go in. (With Pydantic installed, mypy also flags the media_rapida="50" in the next section:
it knows the model's types, even though Pydantic would convert the text at run time. External data is loaded with
model_validate, which takes a dictionary and does not have that problem.)
The configuration lives in pyproject.toml, alongside the rest of the project:
[tool.mypy]
python_version = "3.14"
strict = trueTwo practical tips. Start by annotating the boundaries: each module's public functions, the ones that receive
outside data and the ones that return results used by other code. Inside a function, the checker infers almost
everything on its own. And do not overuse Any or # type: ignore: each one switches off checking exactly where you
put it, which is usually where it was needed.
Pydantic: configurations validated on load
A strategy has parameters: symbols, moving average windows, risk per trade, costs. They usually live in a configuration
file, and an error in that file is among the most expensive: a riesgo_por_operacion: 0.2 instead of 0.02 multiplies
the size of every position by ten, and no function test catches it.
Pydantic turns annotations into run-time validation. A model is a class that inherits from BaseModel; each field
carries its type and, with Field, its constraints (gt, greater than; le, less than or equal; min_length, minimum
length). Validators add custom rules: field_validator for one field and model_validator for cross-field rules, such
as the fast moving average being shorter than the slow one.
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 must be < 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 has done three things: applied the symbol validator (no spaces and upper case), converted "50" into the
integer 50 and filled in the default values. Converting text to a number is the default mode, called lax: it accepts
a value if it unambiguously represents the requested type. With ConfigDict(strict=True) the text would be rejected.
model_config sets two important options for a configuration. extra="forbid" turns an unknown key into an error, and
frozen=True prevents modifying the object once created, just like the frozen dataclasses from lesson 0.4.1.
Validation errors, all at once
When something fails, Pydantic raises ValidationError with all the field errors, not just the first. The full
message is long (it includes a link to the documentation for each error); in a program it is better to iterate over
errors(), which gives a list of dictionaries with the location (loc) and the message (msg) of each failure:
def resumen_errores(datos: dict) -> None:
try:
ConfigEstrategia(**datos)
except ValidationError as e:
print(f"Errors in {e.title}: {e.error_count()}")
for err in e.errors():
campo = ".".join(str(p) for p in err["loc"]) or "(model)"
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})Errors in 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 permittedAll three errors are the kind that slip through in real life: a risk ten times larger than intended, a value that is
almost right and a misspelled key. Without extra="forbid", coste_pbs would be silently ignored and the strategy
would trade with the default cost of 5 bp while believing it uses 3.
The cross-field rule is only evaluated if the fields have passed their own validation (mode="after" receives the
already built model). With correct fields and the averages the wrong way round:
resumen_errores({"nombre": "cruce", "simbolos": ["SINT"],
"media_rapida": 200, "media_lenta": 50,
"riesgo_por_operacion": 0.01})Errors in ConfigEstrategia: 1
(model): Value error, media_rapida must be < media_lentaPydantic's messages are in English; the custom rule's message is in whatever language it was written in. The empty location means the error belongs to the whole model, not to a field.
Loading the configuration from YAML
YAML is the usual format for configurations because it is easy to read and edit by hand (file formats are covered in
lesson 0.4.4). The flow has two steps: yaml.safe_load turns the text into dictionaries and lists, and
model_validate validates it.
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'}Always use safe_load: it only creates basic types. yaml.load with the full loader can build arbitrary Python
objects from the file, and a configuration must not be able to run code. model_dump() goes the other way, from model
to dictionary, which is useful for saving, next to a backtest's results, the exact configuration that produced them.
A trap: model_copy(update=…) creates a copy with changes without validating. It is fine for values you already
know are correct; for everything else, you have to validate again:
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.02The copy has a risk of 50% per trade and no error prevented it. In a real system, the configuration is validated once at startup, frozen and left alone; if a parameter changes, it is loaded again from the file.
Exercises
- Add to
ConfigEstrategiaa fieldmarcothat only accepts"1d","1h"and"5m", with"1d"as the default. Check that a configuration withmarco: "15m"gives an error on that field.
Solution
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'A Pydantic model can be subclassed like any class: the subclass keeps the fields, the model_config options and the
validators of the base, and adds its own.
- Define a
TypedDictEjecucionwithsimbolo,cantidad(signed) andprecio, and an annotated functionnocional(ejecuciones: list[Ejecucion]) -> floatthat sums the absolute value of each fill. Test it with a buy of 100 at 142.22 and a sell of 40 at 145.10.
Solution
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. The traded notional is the base on which proportional commissions are computed.
- Real configurations group parameters. Create a
Costesmodel (comision_pbanddeslizamiento_pb, both greater than or equal to zero) and aConfigmodel withnombreandcostes: Costes. Load it from a YAML with the nestedcostessection and compute the total cost in basis points.
Solution
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 validates the nested model with its own rules, and an error in it shows up with the full path in loc, for
example ('costes', 'comision_pb').
Footnotes
-
PEP 649 and PEP 749, included in Python 3.14: annotations are stored unevaluated and computed when requested. Details in the
annotationlibdocumentation. ↩
Each answer is folded under its question
Self-assessment
A function is annotated as
def importe(cantidad: int, precio: float) -> float. What happens when you callimporte(2, "142.22")?- APython raises
TypeErrorbecause"142.22"is not afloat - BPython converts the text to
floatand returns 284.44 - CPython runs the function without looking at the annotations and returns
"142.22142.22" - DThe call does not compile
Show answer
Answer C. Python runs the function without looking at the annotations and returns
"142.22142.22"Annotations are not checked at run time.
2 * "142.22"repeats the text. The error is only caught by a static checker (mypy, ty) or by explicit validation such as Pydantic's.- APython raises
What advantage does
Lado = Literal["compra", "venta"]have overlado: str?- ANone: at run time they are the same, and the checker treats them the same too
- BThe static checker flags any other text, such as
"comprar", before running - CPython raises an exception if another text is passed
- DIt makes the code faster
Show answer
Answer B. The static checker flags any other text, such as
"comprar", before runningTo Python, a
Literalis an ordinarystr; to mypy or ty, only those two values are valid. At run time it has to be validated separately, for example withget_args(Lado)or with Pydantic.A configuration has
media_rapida: int = Field(gt=0)and the YAML containsmedia_rapida: "50". What does Pydantic do in its default mode?- ARaises
ValidationErrorbecause the value is text - BAccepts the value and stores it as text
- CConverts
"50"into the integer 50, because the text represents a valid integer - DIgnores the field
Show answer
Answer C. Converts
"50"into the integer 50, because the text represents a valid integerThe default (lax) mode converts values that unambiguously represent the requested type. With
strict=Truethe text would be rejected.- ARaises
With a valid Pydantic configuration
cfg, what doescfg.model_copy(update={"riesgo_por_operacion": 0.5})do if the field hasle=0.02?- ARaises
ValidationError - BReturns a copy with 0.5, unvalidated:
model_copydoes not run the validators - CReturns a copy with the value clipped to 0.02
- DModifies
cfgin place
Show answer
Answer B. Returns a copy with 0.5, unvalidated:
model_copydoes not run the validatorsmodel_copy(update=…)copies and replaces without validating. To change a value safely you have to validate again, for example withmodel_validateon the modified dictionary.- ARaises
Key points
- Annotations document and enable checking, but Python does not enforce them at run time:
2 * "142.22"does not fail. Literal,TypedDictandProtocoldescribe allowed values, dictionaries with fixed keys and common interfaces without inheritance.- mypy or ty read the code without running it and find type errors before they reach a live account.
- Pydantic validates at run time, at the boundary: configurations, API responses and files. With
extra="forbid"a misspelled key is an error, not a silent default. - A strategy configuration is validated when loaded, in full, and frozen.
model_copy(update=…)does not validate.