Module 3

Typing, dataclasses & protocols

Python is dynamically typed, and annotations don't change that. def f(x: int) -> str runs exactly the same with or without the hints — the interpreter stores them and otherwise ignores them. So what are they for? Type hints are documentation the tooling enforces. A checker like mypy or pyrightruns in CI and fails the build when a call site and a signature disagree — turning “the docstring drifted out of date” into a red build instead of a 2 a.m. page. No runtime cost, because nothing happens at runtime.

The modern syntax is compact:

def realized_beta(
    asset: list[float],            # built-in generics: list[str], dict[str, float]
    market: list[float],
    window: int = 252,
) -> float: ...

price: float | None = None         # "float or None" — replaces Optional[float]

class BetaResponse(TypedDict):     # the shape of a JSON-ish dict, checkable
    ticker: str
    beta: float
    window: int

The senior stance: hints at module boundaries are non-negotiable in team code — public functions, class fields, anything another file imports. Inside a ten-line helper where the types are obvious? Judgment call. The boundary is where the next engineer (or six-months-later you) reads the contract instead of the implementation.

@dataclass — Python's answer to Java records

Declare the fields, and @dataclass generates __init__, __eq__ and __repr__for you — the same “transparent carrier of data” idea as the Java records we used in the FE track's records module. Add frozen=True and assignment after construction raises — immutability. And __post_init__ is the validation gate: it runs right after the generated __init__, so a bad record never exists:

from dataclasses import dataclass
import datetime
import math

@dataclass(frozen=True, slots=True)
class PriceRecord:
    date: datetime.date
    ticker: str
    close: float

    def __post_init__(self) -> None:
        # frozen=True blocks normal assignment even here, so sanctioned
        # normalization goes through object.__setattr__ — the one idiom
        object.__setattr__(self, "ticker", self.ticker.strip().upper())
        if not self.ticker:
            raise ValueError("ticker must not be blank")
        if not (math.isfinite(self.close) and self.close > 0):
            raise ValueError(f"closePrice must be positive and finite, got {self.close}")

Three details seniors reach for: frozen=True makes instances safe to share and cache (mutation raises FrozenInstanceError); the object.__setattr__ call is the standard escape hatch for normalizing a frozen field inside __post_init__; and slots=True replaces the per-instance __dict__ with fixed slots — meaningfully less memory when you hold millions of rows.

🎛 Frozen dataclass validator

Class definition:

Constructed — __post_init__ passed

PriceRecord(date=datetime.date(2024, 6, 3), ticker='MSFT', close=417.32)

date

2024-06-03

ticker (normalized)

MSFT

close

417.32

This mirrors a frozen dataclass whose __post_init__ normalizes the ticker (via object.__setattr__, the sanctioned idiom for frozen fields) and rejects bad prices — so construction either yields a fully valid, immutable row or raises. The same gate the Java service runs in its record's compact constructor; here the freeze also makes instances safe to share across threads and caches.

Protocols — duck typing, formalized

Python culture says “if it quacks, it's a duck”: any object with the right methods will do. typing.Protocol writes that down so the checker can enforce it — an interface satisfied by shape, not by inheritance. Anything with a matching estimate() method conforms, whether or not it has ever heard of the protocol:

from typing import Protocol

class BetaEstimator(Protocol):
    def estimate(self, asset: list[float], market: list[float]) -> float: ...

class WelfordEstimator:            # no inheritance, no import of the protocol
    def estimate(self, asset: list[float], market: list[float]) -> float:
        ...

def run(est: BetaEstimator) -> float:   # accepts anything with a matching estimate()
    return est.estimate(asset, market)

This is structural typing; an ABC (abc.ABC) is nominal — conformance by explicit subclassing, checkable at runtime with isinstance. Protocols shine at the edges of a codebase (accept anything shaped right, including classes you don't own); ABCs shine inside one (a closed family with shared behavior). If this pattern looks familiar, it should: it's the Python twin of the Java BetaEstimator strategy interface from the FE track's composition & DI module — same design, minus the ceremony.

Enums, and the pydantic boundary

For a closed vocabulary — window sizes, order sides, calculation modes — use an Enum rather than magic strings. Typos become AttributeError at import time instead of silent misroutes, and exhaustiveness is checkable:

from enum import Enum

class Window(Enum):
    MONTH = 20
    YEAR = 252
    FIVE_YEAR = 1260

One line on pydantic: it does what dataclasses do plus runtime validation and coercion, which is exactly what you want at system boundaries — parsing untrusted JSON in an API handler. The common senior split: pydantic models at the edges, plain (frozen) dataclasses for everything inside, where the type checker already guards the types.

Things to try

  • • Type a lowercase ticker like msft — the constructed repr shows MSFT. That's __post_init__ normalizing through object.__setattr__.
  • • Set close to -5 — construction raises ValueError, so no invalid record ever exists downstream.
  • • Press try rec.close = 0 with frozen=True — you get the real FrozenInstanceError.
  • • Now toggle without frozen=True and mutate again — it silently succeeds. Imagine that record sitting in a shared cache.