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.
- Module
- 0.4 · Intermediate Python
- Duration
- 45 min
- Prerequisites
- 0.3.5 · Functions: arguments, *args and **kwargs, lambdas and scope, 0.3.6 · Exceptions; modules and packages, with Python 3.15 lazy imports
- 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
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:
| Object | What it holds | Does it change? | What it does |
|---|---|---|---|
| Order | symbol, side, quantity, price | No: once sent, it is canceled or replaced | Computes its notional, validates itself |
| Position | symbol, quantity, average price, realized P&L | Yes: with each fill | Applies fills, computes its value |
| Portfolio | cash and positions | Yes | Executes 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.
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)OrdenManual('SINT', 'compra', 100, 142.22)
Notional: 14222.00
True FalseWithout __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.
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}")Orden(simbolo='SINT', lado='compra', cantidad=100, precio=142.22)
True
14222.00The guarantees are checked as soon as someone tries to get around them. Modifying a field of a frozen order raises
FrozenInstanceError:
orden.cantidad = 200dataclasses.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"):
Orden("SINT", "comprar", 100, 142.22)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:
reducida = replace(orden, cantidad=50)
print(reducida)
print(orden.cantidad)
pendientes = {orden, Orden("SINT", "compra", 100, 142.22), reducida}
print(len(pendientes))Orden(simbolo='SINT', lado='compra', cantidad=50, precio=142.22)
100
2The 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):
@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)v2 142.25 2 300
v5 142.25 5 100
v1 142.3 1 50
v3 142.3 3 200For 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):
@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)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:
@dataclass
class CarteraMal:
efectivo: float
posiciones: dict = {}ValueError: mutable default <class 'dict'> for field posiciones is not allowed: use default_factoryThe correct form is field(default_factory=dict), which creates a new dictionary for each instance:
@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}")Cash: 32,026.00
Total value: 52,102.50
Realized: 1,013.33
Unrealized: 1,093.17
Commissions: 4.00The 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:
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)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:
OrdenBase("SINT", "compra", 10)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:
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:
@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])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
- Add to
Ordenan optional fieldetiqueta: 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
@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))True TrueWith 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.
- 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
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)c6 142.25 6
c1 142.2 1
c4 142.2 4The 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.
- 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
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}")Posicion(simbolo='TECH', cantidad=0, precio_medio=0.0, pnl_realizado=87.20000000000027)
Cash: 10,085.20The 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
An order already sent to the broker must not change its quantity by accident. Which option guarantees that in Python?
- AA dictionary with the
cantidadkey - BA dataclass with
frozen=True: assigning a field raisesFrozenInstanceError - 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 raisesFrozenInstanceErrorfrozen=Truemakes assigning a field after creating the object raise an exception. To “change” an order you create another one withdataclasses.replace, and the original stays intact.order=Trueonly adds comparisons.- AA dictionary with the
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 withfield(default_factory=dict) - DIt raises
TypeErrorwhen the first portfolio is created
Show answer
Answer C. It raises
ValueError: mutable defaults are declared withfield(default_factory=dict)A default
{}would be the same object for every instance. The dataclass detects it when the class is defined and raisesValueError;default_factorycreates a new dictionary per instance.With
@dataclass(order=True)and the fieldsprecioandllegada(in that order), how doessorted()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=Truecompares the fields as a tuple, in the order in which they are declared. Fields withfield(compare=False)are left out of the comparison.A strategy needs a portfolio and a risk manager. Which design follows the lesson's rule?
- A
class Estrategia(Cartera, GestorRiesgo) - B
class 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.
- A
Key points
- A class brings data and behavior together.
__repr__and__eq__decide how it prints and when two objects are equal. @dataclasswrites__init__,__repr__and__eq__for you;frozen=Trueprevents modifying an object and makes it usable in sets and as a dictionary key.order=Truecompares 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).