Lesson 0.4.1 · 45 min

Object-oriented programming: classes, inheritance, composition and dataclasses

A trading system handles objects with state and rules: orders that must not change, positions that do change and portfolios that group positions. This lesson models them with classes and dataclasses, and explains when to inherit and when to compose.

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

Why classes

So far an order's data fit in a dictionary: {"simbolo": "SINT", "lado": "compra", "cantidad": 100} (symbol, side buy, quantity). It works, but it protects against nothing. A typo ("cantdad") silently creates a new key, nothing prevents a negative quantity and the notional calculation lives in a loose function you have to remember to call.

A class brings together in one place the data (attributes) and what can be done with them (methods). Each object created from the class is an instance. In a trading system there are three obvious candidates:

ObjectWhat it holdsDoes it change?What it does
Ordersymbol, side, quantity, priceNo: once sent, it is canceled or replacedComputes its notional, validates itself
Positionsymbol, quantity, average price, realized P&LYes: with each fillApplies fills, computes its value
Portfoliocash and positionsYesExecutes orders, computes value and P&L

The “Does it change?” column decides a good part of the design. Orders will be immutable; positions and the portfolio, not.

A class written by hand

The basic form of a class: __init__ receives the data and stores it in self, the instance itself. Methods are functions that receive self as their first argument. Those that start and end with a double underscore are special methods: Python calls them on its own. __repr__ decides how the object is displayed and __eq__, when two objects are equal.

IN [01]python
class OrdenManual:
    def __init__(self, simbolo, lado, cantidad, precio):
        self.simbolo = simbolo
        self.lado = lado
        self.cantidad = cantidad
        self.precio = precio

    def importe(self):
        return self.cantidad * self.precio

    def __repr__(self):
        return (f"OrdenManual({self.simbolo!r}, {self.lado!r}, "
                f"{self.cantidad}, {self.precio})")

    def __eq__(self, otra):
        if not isinstance(otra, OrdenManual):
            return NotImplemented
        return ((self.simbolo, self.lado, self.cantidad, self.precio)
                == (otra.simbolo, otra.lado, otra.cantidad,
                    otra.precio))

a = OrdenManual("SINT", "compra", 100, 142.22)
b = OrdenManual("SINT", "compra", 100, 142.22)
print(a)
print(f"Notional: {a.importe():.2f}")
print(a == b, a is b)
OUT [01]Order as a regular class
OrdenManual('SINT', 'compra', 100, 142.22)
Notional: 14222.00
True False

Without __repr__, print(a) would show something like <OrdenManual object at 0x…>, useless in a trade log. Without __eq__, a == b would compare identity (whether they are the same object in memory) and would give False for two identical orders. With both methods, a == b is True even though a is b is still False: they are two objects with the same content.

The notional (importe) is printed with two decimals as a precaution. 100 * 142.22 gives exactly 14222.0, but 3 * 142.22 gives 426.65999999999997: it is the floating-point problem from lesson 0.3.1, and the reason price accounting uses decimal.

There is a lot of repeated code: each field appears four times in __init__, once more in __repr__ and twice in __eq__. If tomorrow a field is added and forgotten in __eq__, two different orders will be “equal”.

Dataclasses: the same result without repetition

The @dataclass decorator (the dataclasses module, in the standard library) reads the class annotations and writes __init__, __repr__ and __eq__ for you. Annotations (simbolo: str) are covered in depth in lesson 0.4.2; here it is enough to know that each name: type line declares a field.

Two options fit an order:

  • frozen=True: the instance cannot be modified after it is created. A sent order does not change; if you want a different quantity, you create another order.
  • slots=True: attributes are stored in fixed slots instead of in a dictionary. It uses less memory and makes it impossible to create attributes by mistake.

Validation goes in __post_init__, which the dataclass calls at the end of its __init__. The notional is a property (@property): it is computed when read, without parentheses, and is not stored.

IN [02]python
from dataclasses import dataclass, field, replace

LADOS = ("compra", "venta")

@dataclass(frozen=True, slots=True)
class Orden:
    simbolo: str
    lado: str
    cantidad: int
    precio: float

    def __post_init__(self):
        if self.lado not in LADOS:
            raise ValueError(f"invalid side: {self.lado!r}")
        if self.cantidad <= 0:
            raise ValueError(f"non-positive quantity: {self.cantidad}")

    @property
    def importe(self) -> float:
        return self.cantidad * self.precio

orden = Orden("SINT", "compra", 100, 142.22)
print(orden)
print(orden == Orden("SINT", "compra", 100, 142.22))
print(f"{orden.importe:.2f}")
OUT [02]Order as an immutable dataclass
Orden(simbolo='SINT', lado='compra', cantidad=100, precio=142.22)
True
14222.00

The guarantees are checked as soon as someone tries to get around them. Modifying a field of a frozen order raises FrozenInstanceError:

IN [03]python
orden.cantidad = 200
OUT [03]An order is not modified
dataclasses.FrozenInstanceError: cannot assign to field 'cantidad'

And a badly built order never comes into existence ("comprar", “to buy”, is not one of the valid sides "compra" and "venta"):

IN [04]python
Orden("SINT", "comprar", 100, 142.22)
OUT [04]Validation on creation
ValueError: invalid side: 'comprar'

To change something you create a new order with replace, which copies all fields except the ones given and goes through __post_init__ again. Since frozen orders have __hash__, they can also be put in a set, which is useful, for example, to discard duplicate orders that a signal generated twice:

IN [05]python
reducida = replace(orden, cantidad=50)
print(reducida)
print(orden.cantidad)

pendientes = {orden, Orden("SINT", "compra", 100, 142.22), reducida}
print(len(pendientes))
OUT [05]replace and sets
Orden(simbolo='SINT', lado='compra', cantidad=50, precio=142.22)
100
2

The original still has 100 shares and the set holds two orders, not three: the first two are equal.

Order and priority: price-time

An order book executes first the order with the best price and, at equal price, the one that arrived first. That is price-time priority. With order=True, a dataclass compares its fields as a tuple, in the order in which they are declared, so it is enough to declare the price first and then the arrival (llegada). Fields that must not count in the comparison carry field(compare=False):

IN [06]python
@dataclass(frozen=True, order=True)
class OrdenLibro:
    precio: float
    llegada: int
    id: str = field(compare=False)
    cantidad: int = field(compare=False)

ventas = [
    OrdenLibro(142.30, 3, "v3", 200),
    OrdenLibro(142.25, 5, "v5", 100),
    OrdenLibro(142.30, 1, "v1", 50),
    OrdenLibro(142.25, 2, "v2", 300),
]
for o in sorted(ventas):
    print(o.id, o.precio, o.llegada, o.cantidad)
OUT [06]Priority of sell orders
v2 142.25 2 300
v5 142.25 5 100
v1 142.3 1 50
v3 142.3 3 200

For sells, the best price is the lowest, which is exactly the natural order. For buys, the best price is the highest and the trick does not work as is: exercise 2 solves it with a sort key.

Position and portfolio: composition

A position does change: each fill modifies its quantity and its average price, and reducing it realizes a profit or a loss. It is a regular dataclass, without frozen. The aplicar (apply) method receives a signed quantity (positive to buy, negative to sell):

IN [07]python
@dataclass
class Posicion:
    simbolo: str
    cantidad: int = 0
    precio_medio: float = 0.0
    pnl_realizado: float = 0.0

    def aplicar(self, cantidad: int, precio: float) -> None:
        """Adds a fill: cantidad > 0 buys; < 0 sells."""
        if self.cantidad == 0 or self.cantidad * cantidad > 0:
            total = self.cantidad + cantidad  # opens or increases
            self.precio_medio = (self.cantidad * self.precio_medio
                                 + cantidad * precio) / total
            self.cantidad = total
            return
        signo = 1 if self.cantidad > 0 else -1  # reduces
        cerrada = min(abs(cantidad), abs(self.cantidad))
        self.pnl_realizado += signo * cerrada * (
            precio - self.precio_medio)
        self.cantidad += cantidad
        if self.cantidad == 0:
            self.precio_medio = 0.0
        elif self.cantidad * signo < 0:  # it has flipped
            self.precio_medio = precio

    def valor(self, precio: float) -> float:
        return self.cantidad * precio

    def pnl_no_realizado(self, precio: float) -> float:
        return self.cantidad * (precio - self.precio_medio)

p = Posicion("RVAR")
p.aplicar(100, 150.0)
p.aplicar(50, 160.0)
p.aplicar(-80, 166.0)
print(p)
OUT [07]Position with average price and realized P&L
Posicion(simbolo='RVAR', cantidad=70, precio_medio=153.33333333333334, pnl_realizado=1013.3333333333326)

The two buys leave 150 shares at an average price of 153.33; selling 80 at 166 realizes 80 × (166 − 153.33) = 1,013.33 of profit, and 70 shares remain at the same average price. The dataclass repr shows the floats as they are (153.33333333333334, 1013.3333333333326): to read them, they are rounded when printed, as in the next cell.

The portfolio is not a position: it has positions, and cash. That is composition: an object that contains others and delegates work to them. The portfolio only knows how to turn an Orden into a signed fill, charge the commission and add up.

A detail of dataclasses: a mutable default value, such as an empty dictionary, would be the same object for all portfolios. The dataclass does not allow it:

IN [08]python
@dataclass
class CarteraMal:
    efectivo: float
    posiciones: dict = {}
OUT [08]Mutable default value
ValueError: mutable default <class 'dict'> for field posiciones is not allowed: use default_factory

The correct form is field(default_factory=dict), which creates a new dictionary for each instance:

IN [09]python
@dataclass
class Cartera:
    efectivo: float
    comision: float = 1.0  # euros per order
    posiciones: dict[str, Posicion] = field(default_factory=dict)

    def ejecutar(self, orden: Orden) -> None:
        signo = 1 if orden.lado == "compra" else -1
        if orden.simbolo not in self.posiciones:
            self.posiciones[orden.simbolo] = Posicion(orden.simbolo)
        self.posiciones[orden.simbolo].aplicar(
            signo * orden.cantidad, orden.precio)
        self.efectivo -= signo * orden.importe + self.comision

    def valor(self, precios: dict[str, float]) -> float:
        return self.efectivo + sum(
            p.valor(precios[s]) for s, p in self.posiciones.items())

    def pnl(self, precios: dict[str, float]) -> tuple[float, float]:
        realizado = sum(p.pnl_realizado
                        for p in self.posiciones.values())
        no_realizado = sum(p.pnl_no_realizado(precios[s])
                           for s, p in self.posiciones.items())
        return realizado, no_realizado

cartera = Cartera(efectivo=50_000)
for o in [Orden("RVAR", "compra", 100, 150.0),
          Orden("ORO", "compra", 50, 165.0),
          Orden("RVAR", "compra", 50, 160.0),
          Orden("RVAR", "venta", 80, 166.0)]:
    cartera.ejecutar(o)

# closes on 2025-12-31 in cartera_sint.csv
cierres = {"RVAR": 164.35, "RFIJ": 102.29, "ORO": 171.44,
           "TECH": 77.82}
realizado, no_realizado = cartera.pnl(cierres)
print(f"Cash:         {cartera.efectivo:>10,.2f}")
print(f"Total value:  {cartera.valor(cierres):>10,.2f}")
print(f"Realized:     {realizado:>10,.2f}")
print(f"Unrealized:   {no_realizado:>10,.2f}")
print(f"Commissions:  {4 * cartera.comision:>10,.2f}")
OUT [09]Portfolio composed of positions
Cash:          32,026.00
Total value:   52,102.50
Realized:       1,013.33
Unrealized:     1,093.17
Commissions:        4.00

The figures add up: the total value minus the initial 50,000 equals the realized P&L plus the unrealized P&L minus the four commissions. That identity is a good candidate for an automated test (lesson 0.4.5): if one day it stops holding, there is a bug in the accounting.

Inheritance: variants of an order

Inheritance creates a class from another: the subclass receives the attributes and methods of the base class and can add or override its own. It fits when an object is a particular case of another. A market order, a limit order and a stop order are orders; what changes between them is when they execute.

The base class declares the se_ejecuta (is executed) method as abstract (abc.ABC and @abstractmethod): you cannot create a generic order, only one of its variants. Each subclass adds its fields and its rule:

IN [10]python
from abc import ABC, abstractmethod

@dataclass(frozen=True)
class OrdenBase(ABC):
    simbolo: str
    lado: str
    cantidad: int

    @abstractmethod
    def se_ejecuta(self, precio: float) -> bool: ...

@dataclass(frozen=True)
class OrdenMercado(OrdenBase):
    def se_ejecuta(self, precio: float) -> bool:
        return True

@dataclass(frozen=True)
class OrdenLimitada(OrdenBase):
    limite: float

    def se_ejecuta(self, precio: float) -> bool:
        if self.lado == "compra":
            return precio <= self.limite
        return precio >= self.limite

@dataclass(frozen=True)
class OrdenStop(OrdenBase):
    stop: float

    def se_ejecuta(self, precio: float) -> bool:
        if self.lado == "venta":
            return precio <= self.stop
        return precio >= self.stop

ordenes = [OrdenMercado("SINT", "compra", 10),
           OrdenLimitada("SINT", "compra", 10, limite=140.0),
           OrdenStop("SINT", "venta", 10, stop=138.0)]
for precio in (142.0, 139.5, 137.8):
    activas = [type(o).__name__ for o in ordenes
               if o.se_ejecuta(precio)]
    print(precio, activas)
OUT [10]Order types by inheritance
142.0 ['OrdenMercado']
139.5 ['OrdenMercado', 'OrdenLimitada']
137.8 ['OrdenMercado', 'OrdenLimitada', 'OrdenStop']

The loop does not ask what type each order is: it calls se_ejecuta and each object answers with its rule. That is polymorphism, and it is what makes inheritance useful. Adding a stop-limit order does not require touching the loop, only writing one more subclass. Trying to create the base class fails, as intended:

IN [11]python
OrdenBase("SINT", "compra", 10)
OUT [11]The base class is abstract
TypeError: Can't instantiate abstract class OrdenBase without an implementation for abstract method 'se_ejecuta'

A dataclass trap with inheritance: the subclass fields go after the base fields. If the base had a field with a default value, the subclass could not add one without it (TypeError: non-default argument follows default argument). The way out is to declare the fields as keyword-only with @dataclass(kw_only=True), or not to put default values in the base.

Inheritance or composition

The two relationships are drawn differently. The “is a” arrows are inheritance; the “has” and “executes” arrows, composition:

is ais ais ahasexecutesOrdenMercadoOrdenBaseOrdenLimitadaOrdenStopCarteraPosicionOrden
Mermaid source
flowchart LR
  M[OrdenMercado] -->|is a| B[OrdenBase]
  L[OrdenLimitada] -->|is a| B
  S[OrdenStop] -->|is a| B
  C[Cartera] -->|has| P[Posicion]
  C -->|executes| O[Orden]

The practical rule: inherit only if the sentence “X is a Y” is true and will remain so; in any other case, compose. Typical mistakes in trading code almost always go in the same direction, inheriting too much:

  • class Estrategia(Cartera): a strategy is not a portfolio. If tomorrow two strategies share a portfolio, or one strategy splits capital between two, inheritance does not allow it.
  • class MiEstrategia(EstrategiaMediasMoviles, GestorRiesgo, ConectorBroker): multiple inheritance to “gather functions”. Each base class can override methods of the others, and testing the strategy requires a broker.
  • Five-level hierarchies (Estrategia → EstrategiaTendencia → EstrategiaMedias → …) in which a change at the top breaks something at the bottom.

With composition, the strategy receives its pieces when it is created. Changing the risk manager, or using a fake one in a test, does not require touching the strategy:

IN [12]python
@dataclass
class LimiteRiesgo:
    max_importe: float

    def permite(self, orden: Orden) -> bool:
        return orden.importe <= self.max_importe

@dataclass
class Ejecutor:
    cartera: Cartera
    riesgo: LimiteRiesgo
    rechazadas: list[Orden] = field(default_factory=list)

    def enviar(self, orden: Orden) -> bool:
        if not self.riesgo.permite(orden):
            self.rechazadas.append(orden)
            return False
        self.cartera.ejecutar(orden)
        return True

ejecutor = Ejecutor(Cartera(efectivo=20_000),
                    LimiteRiesgo(max_importe=10_000))
print(ejecutor.enviar(Orden("TECH", "compra", 100, 77.82)))
print(ejecutor.enviar(Orden("ORO", "compra", 100, 171.44)))
print(ejecutor.cartera.posiciones)
print([o.simbolo for o in ejecutor.rechazadas])
OUT [12]Composition: risk and portfolio as pieces
True
False
{'TECH': Posicion(simbolo='TECH', cantidad=100, precio_medio=77.82, pnl_realizado=0.0)}
['ORO']

The TECH buy (€7,782) passes the limit; the ORO buy (€17,144) does not, and is recorded as rejected. For a different limit, for example on the portfolio's total exposure, another class with a permite (allows) method with the same signature is enough. How to formally declare that “common signature” (a Protocol) is covered in lesson 0.4.2.

Exercises

  1. Add to Orden an optional field etiqueta: str = "" (a label, for example the name of the strategy that generated it) that does not count when comparing orders. Check that two orders that are equal except for the label are equal.
Solution
IN [13]python
@dataclass(frozen=True, slots=True)
class OrdenEtiquetada:
    simbolo: str
    lado: str
    cantidad: int
    precio: float
    etiqueta: str = field(default="", compare=False)

x = OrdenEtiquetada("SINT", "compra", 100, 142.22, "crossover")
y = OrdenEtiquetada("SINT", "compra", 100, 142.22, "breakout")
print(x == y, hash(x) == hash(y))
OUT [13]Field that does not count for equality
True True

With compare=False the field is left out of __eq__ and __hash__: the two orders are equal and land in the same slot of a set. The field with a default value goes at the end, after those without one.

  1. Sort these buy orders by price-time priority (best price = the highest; at equal price, the one that arrived first): OrdenLibro(142.20, 4, "c4", 100), OrdenLibro(142.25, 6, "c6", 50), OrdenLibro(142.20, 1, "c1", 200).
Solution
IN [14]python
compras = [OrdenLibro(142.20, 4, "c4", 100),
           OrdenLibro(142.25, 6, "c6", 50),
           OrdenLibro(142.20, 1, "c1", 200)]
for o in sorted(compras, key=lambda o: (-o.precio, o.llegada)):
    print(o.id, o.precio, o.llegada)
OUT [14]Priority of buy orders
c6 142.25 6
c1 142.2 1
c4 142.2 4

The key (-precio, llegada) inverts the price and keeps the time: first 142.25 and, at 142.20, the order that arrived at instant 1 before the one from instant 4.

  1. With the lesson's Cartera, open a short: sell 40 TECH at 80 and buy back 40 at 77.82. What is the realized P&L? What quantity and average price remain in the position?
Solution
IN [15]python
corta = Cartera(efectivo=10_000)
corta.ejecutar(Orden("TECH", "venta", 40, 80.0))
corta.ejecutar(Orden("TECH", "compra", 40, 77.82))
print(corta.posiciones["TECH"])
print(f"Cash: {corta.efectivo:,.2f}")
OUT [15]A short in the portfolio
Posicion(simbolo='TECH', cantidad=0, precio_medio=0.0, pnl_realizado=87.20000000000027)
Cash: 10,085.20

The short gains 40 × (80 − 77.82) = 87.20 and the position goes back to zero with a zero average price. The repr shows 87.20000000000027, not 87.2: 80 - 77.82 gives 2.180000000000007 in floating point (lesson 0.3.1). It is a representation error, not an accounting error, but it requires two things: rounding when displaying and comparing with a tolerance in tests (pytest.approx, lesson 0.4.5), never with ==. Cash goes up by those 87.20 minus the two €1 commissions. Posicion.aplicar treats longs and shorts the same because it works with signed quantities.

Each answer is folded under its question

Self-assessment

  1. An order already sent to the broker must not change its quantity by accident. Which option guarantees that in Python?

    • AA dictionary with the cantidad key
    • BA dataclass with frozen=True: assigning a field raises FrozenInstanceError
    • CA regular class with a comment saying “do not modify”
    • DA dataclass with order=True
    Show answer

    Answer B. A dataclass with frozen=True: assigning a field raises FrozenInstanceError

    frozen=True makes assigning a field after creating the object raise an exception. To “change” an order you create another one with dataclasses.replace, and the original stays intact. order=True only adds comparisons.

  2. A dataclass has the field posiciones: dict = {}. What happens when the class is defined?

    • ANothing: each portfolio will have its own empty dictionary
    • BAll portfolios share the same dictionary, without warning
    • CIt raises ValueError: mutable defaults are declared with field(default_factory=dict)
    • DIt raises TypeError when the first portfolio is created
    Show answer

    Answer C. It raises ValueError: mutable defaults are declared with field(default_factory=dict)

    A default {} would be the same object for every instance. The dataclass detects it when the class is defined and raises ValueError; default_factory creates a new dictionary per instance.

  3. With @dataclass(order=True) and the fields precio and llegada (in that order), how does sorted() sort a list of sell orders?

    • ABy price and, at equal price, by arrival: the price-time priority of an order book
    • BBy price only
    • CBy the order in which the objects were created
    • DRandomly, because the objects cannot be compared
    Show answer

    Answer A. By price and, at equal price, by arrival: the price-time priority of an order book

    order=True compares the fields as a tuple, in the order in which they are declared. Fields with field(compare=False) are left out of the comparison.

  4. A strategy needs a portfolio and a risk manager. Which design follows the lesson's rule?

    • Aclass Estrategia(Cartera, GestorRiesgo)
    • Bclass Estrategia(Cartera) and risk as methods of the strategy
    • CA strategy that has a portfolio and a risk manager as attributes (composition)
    • DGlobal variables shared by all modules
    Show answer

    Answer C. A strategy that has a portfolio and a risk manager as attributes (composition)

    A strategy is not a portfolio: it has one. With composition you can change the risk manager without touching the strategy, and each piece is tested separately.

Key points

  • A class brings data and behavior together. __repr__ and __eq__ decide how it prints and when two objects are equal.
  • @dataclass writes __init__, __repr__ and __eq__ for you; frozen=True prevents modifying an object and makes it usable in sets and as a dictionary key.
  • order=True compares the fields as a tuple: an order book's price-time priority comes from declaring the fields in that order.
  • Mutable defaults go with field(default_factory=…); the dataclass prevents the classic mistake of sharing a list or a dictionary between instances.
  • Inherit when the object is a variant of another (a limit order is an order); compose when it uses it (a strategy has a portfolio).

Resources