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.

02/06
Module
0.4 · Intermediate Python
Duration
45 min
Updated
Level 0 lessons
  1. 0.1 · Welcome and expectations

    1. 0.1.1 Systematic, algorithmic and quantitative trading versus discretionary trading
    2. 0.1.2 Realistic expectations: costs, time and risk of loss
    3. 0.1.3 Course structure, entry tests, personal repository and learning with AI
  2. 0.2 · Modern working environment

    1. 0.2.1 Installing Python with uv: versions and support cycle
    2. 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
    3. 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Basic terminal, paths, environment variables and .env files
    5. 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
    6. 0.2.6 Introduction to AI coding assistants: prompts, review and tests
  3. 0.3 · Language fundamentals

    1. 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
    2. 0.3.2 Strings, f-strings and formatting numbers and dates
    3. 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
    4. 0.3.4 Control flow: if, elif, else, for, while and match-case
    5. 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
    6. 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
  4. 0.4 · Intermediate Python

    1. 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
    2. 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
    3. 0.4.3 Iterators, generators, decorators and context managers
    4. 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
    5. 0.4.5 Professional logging and testing with pytest
    6. 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
  5. 0.5 · Numerical computing and data

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
    2. 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
    3. 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
    4. 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
    5. 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
  6. 0.6 · Visualisation

    1. 0.6.1 Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
    2. 0.6.2 Quick dashboards with Streamlit: a monitoring dashboard in a single script
  7. 0.7 · Minimum mathematics and statistics

    1. 0.7.1 Simple and log returns, compound interest and descriptive statistics
    2. 0.7.2 Probability and distributions, correlation versus causation and simple regression
    3. 0.7.3 Intuitive linear algebra: vectors and matrices as groundwork for portfolios

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:

IN [01]python
def importe(cantidad: int, precio: float) -> float:
    return cantidad * precio

print(importe(2, 142.22))
print(importe(2, "142.22"))
print(importe.__annotations__)
OUT [01]A price that arrives as text
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: float says 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:

Annotated codemypy or tyErrors before runningExternal dataPydanticError on load
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:

AnnotationMeaningExample
int, float, str, boolA value of that typecantidad: int
list[float]List of floatscierres: list[float]
dict[str, float]Dictionary from text to floatprecios: dict[str, float]
tuple[float, float]Tuple of two floats-> tuple[float, float] (realized, unrealized)
float | NoneA float or NoneA price that may be missing
Callable[[float], bool]A function that takes a float and returns a boolA price filter
type Precio = floatType 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:

IN [02]python
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"))
OUT [02]Annotated functions
[0.015, -0.0246]
171.44 None

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

IN [03]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__))
OUT [03]Literal and TypedDict for orders
dict
('compra', 'venta')
False

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

IN [04]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))
OUT [04]Risk rules with Protocol
True
False
True

MaxCantidad 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 check

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

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

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

IN [06]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 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__)
OUT [06]A strategy configuration with Pydantic
nombre='cruce' simbolos=['SINT', 'RVAR'] media_rapida=50 media_lenta=200 riesgo_por_operacion=0.01 coste_pb=5.0 lado='largo'
int

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

IN [07]python
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})
OUT [07]Three errors in one configuration
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 permitted

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

IN [08]python
resumen_errores({"nombre": "cruce", "simbolos": ["SINT"],
                 "media_rapida": 200, "media_lenta": 50,
                 "riesgo_por_operacion": 0.01})
OUT [08]The cross-field rule
Errors in ConfigEstrategia: 1
  (model): Value error, media_rapida must be < media_lenta

Pydantic'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.

IN [09]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())
OUT [09]Configuration from YAML
['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:

IN [10]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"])
OUT [10]model_copy does not validate
0.5
Input should be less than or equal to 0.02

The 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

  1. Add to ConfigEstrategia a field marco that only accepts "1d", "1h" and "5m", with "1d" as the default. Check that a configuration with marco: "15m" gives an error on that field.
Solution
IN [11]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"])
OUT [11]A field with allowed values
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.

  1. Define a TypedDict Ejecucion with simbolo, cantidad (signed) and precio, and an annotated function nocional(ejecuciones: list[Ejecucion]) -> float that 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
IN [12]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}")
OUT [12]A TypedDict for fills
20,026.00

14,222 + 5,804 = 20,026. The traded notional is the base on which proportional commissions are computed.

  1. Real configurations group parameters. Create a Costes model (comision_pb and deslizamiento_pb, both greater than or equal to zero) and a Config model with nombre and costes: Costes. Load it from a YAML with the nested costes section and compute the total cost in basis points.
Solution
IN [13]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)
OUT [13]Nested models
comision_pb=1.5 deslizamiento_pb=3.0
4.5

Pydantic 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

  1. PEP 649 and PEP 749, included in Python 3.14: annotations are stored unevaluated and computed when requested. Details in the annotationlib documentation. ↩

Each answer is folded under its question

Self-assessment

  1. A function is annotated as def importe(cantidad: int, precio: float) -> float. What happens when you call importe(2, "142.22")?

    • APython raises TypeError because "142.22" is not a float
    • BPython converts the text to float and 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.

  2. What advantage does Lado = Literal["compra", "venta"] have over lado: 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 running

    To Python, a Literal is an ordinary str; to mypy or ty, only those two values are valid. At run time it has to be validated separately, for example with get_args(Lado) or with Pydantic.

  3. A configuration has media_rapida: int = Field(gt=0) and the YAML contains media_rapida: "50". What does Pydantic do in its default mode?

    • ARaises ValidationError because 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 integer

    The default (lax) mode converts values that unambiguously represent the requested type. With strict=True the text would be rejected.

  4. With a valid Pydantic configuration cfg, what does cfg.model_copy(update={"riesgo_por_operacion": 0.5}) do if the field has le=0.02?

    • ARaises ValidationError
    • BReturns a copy with 0.5, unvalidated: model_copy does not run the validators
    • CReturns a copy with the value clipped to 0.02
    • DModifies cfg in place
    Show answer

    Answer B. Returns a copy with 0.5, unvalidated: model_copy does not run the validators

    model_copy(update=…) copies and replaces without validating. To change a value safely you have to validate again, for example with model_validate on the modified dictionary.

Key points

  • Annotations document and enable checking, but Python does not enforce them at run time: 2 * "142.22" does not fail.
  • Literal, TypedDict and Protocol describe 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.

Resources

Documentation