---
title: "0.4.2 Typing: type hints, mypy or ty, Pydantic"
description: "Type annotations for prices and orders, Literal, TypedDict and Protocol, mypy or ty, and strategy configurations validated with Pydantic."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/python-type-hints-and-pydantic/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/python-type-hints-and-pydantic.png"
---

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

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.4 · Intermediate Python
- Duration: 45 min
- Prerequisites: [0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses](https://pythonparatrading.com/en/python-trading-course/level-0/python-classes-inheritance-dataclasses/)

## By the end of this lesson

- Annotate functions with basic types, collections, unions with `None`, `Literal` and `TypedDict`.
- Declare a common interface with `Protocol` and check the code with mypy or ty from the terminal.
- Define a strategy configuration with Pydantic v2, with per-field constraints and a cross-field rule.
- Load a configuration from YAML and read validation errors in a controlled way.

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

```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'>}
```

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:

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

```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"))
```
```output
[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.[^1]

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

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

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:

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

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

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

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

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

```python
resumen_errores({"nombre": "cruce", "simbolos": ["SINT"],
                 "media_rapida": 200, "media_lenta": 50,
                 "riesgo_por_operacion": 0.01})
```
```output
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.

```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'}
```

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:

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

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.

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

Validate the configuration **before** connecting to the broker, not when the first order needs it. A system that starts,
connects and fails mid-session because of a misspelled parameter can leave orders open with nobody to manage them.

</aside>

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

## 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.**

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

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.

2. 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.**

```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. The traded notional is the base on which proportional commissions are computed.

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

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

## Self-assessment

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

   - A. Python raises `TypeError` because `"142.22"` is not a `float`
   - B. Python converts the text to `float` and returns 284.44
   - C. Python runs the function without looking at the annotations and returns `"142.22142.22"`
   - D. The call does not compile

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

   - A. None: at run time they are the same, and the checker treats them the same too
   - B. The static checker flags any other text, such as `"comprar"`, before running
   - C. Python raises an exception if another text is passed
   - D. It makes the code faster

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

   - A. Raises `ValidationError` because the value is text
   - B. Accepts the value and stores it as text
   - C. Converts `"50"` into the integer 50, because the text represents a valid integer
   - D. Ignores the field

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

   - A. Raises `ValidationError`
   - B. Returns a copy with 0.5, unvalidated: `model_copy` does not run the validators
   - C. Returns a copy with the value clipped to 0.02
   - D. Modifies `cfg` in place

   *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

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

Previous lesson: [0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses](https://pythonparatrading.com/en/python-trading-course/level-0/python-classes-inheritance-dataclasses/)

Next lesson: [0.4.3 · Iterators, generators, decorators and context managers](https://pythonparatrading.com/en/python-trading-course/level-0/python-iterators-generators-decorators/)
