Objected Oriented Programming with Python

Intermediate Topics

Juan F. Imbet

2025-10-09

Course overview

  • What you’ll get today:
    • A practical map of OOP in Python vs other languages
    • Patterns and idioms you can use immediately
    • Short, focused examples (markets + risk/portfolio context)

Programming paradigms and Python

  • Major paradigms: Procedural, Object-Oriented, Functional, Event-Driven
    • Procedural: step-by-step instructions (scripts, functions)
    • Object-Oriented: encapsulate state + behavior in objects (classes)
    • Functional: treat functions as first-class citizens; avoid mutable state
    • Event-Driven: respond to events (callbacks, async programming)
  • Python is multi-paradigm: you can mix procedural, OOP, and functional styles
  • Why OOP? Organize state + behavior; reuse via inheritance/composition; polymorphism for flexible APIs
  • Trade-offs:
    • Pros: encapsulation, testability, extensibility, domain modeling
    • Cons: over-abstraction, class explosion, hidden state, sometimes slower than simple functions
  • Finance lens: Organize complex objects (Orders, Trades, Portfolios), and create relations between them.

OOP is a tool, not a religion. Use the minimum structure that makes change cheap.

Classes vs Objects (and why Python feels different)

  • Class: blueprint (attributes + methods); Object: an instance with its own state
  • Python classes are dynamic: attributes can be added at runtime; methods are just functions bound to an instance
  • No explicit “private” keyword; we rely on convention and properties
  • Duck typing: “If it quacks like a duck…” — behavior matters more than type
  • Bottom line: Focus on what an object can do, not its exact type or class

Quick example: Order vs Trade

class Order:
    def __init__(self, symbol: str, qty: int):
        self.symbol = symbol
        self.qty = qty

class Trade:
    def __init__(self, symbol: str, qty: int, price: float):
        self.symbol = symbol
        self.qty = qty
        self.price = price
  • Same attributes can exist in different classes; behavior differentiates them

Why self everywhere?

  • Instance methods receive the instance as the first parameter by convention called self
  • Python does not have an implicit this; explicit is better than implicit
  • Methods are just functions that expect the instance as first argument
class Position:
    def __init__(self, symbol: str):
        self.symbol = symbol
        self.qty = 0

    def add(self, qty: int):
        self.qty += qty  # `self` is the instance being mutated
  • Under the hood: Position.add(pos, 10) is how pos.add(10) is resolved

Object construction: what really happens

  • Flow when creating an object:
    1. __new__(cls, ...) allocates a new instance (rarely overridden)
    2. __init__(self, ...) initializes the instance state
  • __post_init__ exists for dataclasses to validate/compute derived fields
  • Tip: Keep __init__ simple; do not perform heavy I/O or network calls here
class Instrument:
    def __new__(cls, *args, **kwargs):
        obj = super().__new__(cls)
        return obj

    def __init__(self, symbol: str, currency: str):
        self.symbol = symbol
        self.currency = currency

Magic (dunder) methods: how they run

  • Special hooks Python uses for operators, iteration, context managers, etc.
  • Common ones you’ll actually use:
    • __repr__, __str__ — debugging and display
    • __len__, __iter__, __contains__ — collection-like behavior
    • __eq__, __lt__, __hash__ — comparisons and dict/set keys
    • __enter__, __exit__ — context manager protocol
class Quote:
    def __init__(self, bids: list[float], asks: list[float]):
        self.bids = bids
        self.asks = asks

    def __repr__(self):  # helpful for debugging
        return f"Quote(bid={max(self.bids, default=None)}, ask={min(self.asks, default=None)})"

    def __len__(self):  # treat as sized container of price levels
        return len(self.bids) + len(self.asks)
  • Design tip: implement __repr__ early—it pays off in logs and notebooks

Save time with dataclasses

  • Boilerplate killer for simple data holders with behavior
  • Auto-generates __init__, __repr__, comparisons; supports frozen=True for immutability
  • Great for market data messages, configs, or simple DTOs
from dataclasses import dataclass

@dataclass(frozen=True) # immutable
class Ticker:
    symbol: str
    exchange: str

@dataclass
class OrderRequest:
    symbol: str
    qty: int
    side: str  # "BUY" or "SELL"
  • Use slots=True in Python 3.10+ dataclasses for memory savings on many instances

Inheritance and polymorphism → Strategy Pattern

  • Inheritance: share behavior and specialize
  • Polymorphism: call the same method on different objects and get type-specific behavior
  • Strategy Pattern: inject swappable behavior without if-elif chains
  • ABC stands for Abstract Base Class; use abc module to define interfaces
  • An abstractmethod is a method that must be implemented by subclasses

Example: Fee models with Strategy Pattern

  • Imagine different brokerage fee models
  • Define a common interface with an abstract base class
  • Implement concrete strategies for each fee model
from abc import ABC, abstractmethod

class FeeModel(ABC):
    @abstractmethod
    def fee(self, notional: float) -> float: ...

class FixedBpsFee(FeeModel):
    def __init__(self, bps: float):
        self.bps = bps
    def fee(self, notional: float) -> float:
        return notional * self.bps / 10_000

class MinFloorFee(FeeModel):
    def __init__(self, bps: float, minimum: float):
        self.bps = bps
        self.minimum = minimum
    def fee(self, notional: float) -> float:
        return max(notional * self.bps / 10_000, self.minimum)

class Broker:
    def __init__(self, fee_model: FeeModel):
        self.fee_model = fee_model
    def commission(self, notional: float) -> float:
        return self.fee_model.fee(notional)
  • Swap strategies on the fly: Broker(FixedBpsFee(1.2)) vs Broker(MinFloorFee(0.8, 2.0))

Multiple inheritance, Mixins, and the MRO

  • Multiple inheritance: a class can inherit from more than one parent
  • Use Mixins for small, orthogonal capabilities (logging, serialization)
  • MRO (Method Resolution Order) defines how Python resolves attributes
class JsonMixin:
    def to_json(self) -> str:
        import json
        return json.dumps(self.__dict__)

class CsvMixin:
    def to_csv(self) -> str:
        return ",".join(f"{k}={v}" for k, v in self.__dict__.items())

class Portfolio(JsonMixin, CsvMixin):
    def __init__(self, name: str, positions: dict[str, int]):
        self.name = name
        self.positions = positions
  • Inspect MRO: Portfolio.mro()[Portfolio, JsonMixin, CsvMixin, object]
  • Rule of thumb: prefer composition; reach for mixins when behavior is stateless and reusable

Encapsulation for validation and controlled access

  • Python uses conventions and properties to guard state
  • Goal: make invalid states unrepresentable
  • Example: brokerage balance can only change via deposits/withdrawals; cannot be negative
class BrokerageAccount:
    def __init__(self):
        self._balance = 0.0  # protected by convention, _ attributes are considered "private"

    @property
    def balance(self) -> float:
        return self._balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount

    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("withdraw must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance -= amount
  • Interface guides usage; internal invariant: balance >= 0

Python vs other OOP languages (at a glance)

  • No explicit interfaces; rely on duck typing and ABCs when needed
  • Access modifiers are by convention (_protected, __name for name-mangling)
  • Multiple inheritance supported; careful with diamond problem—MRO solves lookup order
  • Functions are first-class; use composition and higher-order functions when simpler

Putting it together: small capstone

  • Build a micro order execution simulation:
    • OrderRequest (dataclass)
    • FeeModel strategies
    • BrokerageAccount for cash management
    • Simple engine function using the above

Execution engine example

from dataclasses import dataclass

@dataclass
class Fill: 
    """ A filled order/trade """
    symbol: str
    qty: int
    price: float

class ExecutionEngine:
    """ 
    Simple execution engine that processes orders through a broker and updates an account.
    """
    def __init__(self, broker: Broker, account: BrokerageAccount):
        self.broker = broker
        self.account = account

    def execute(self, req: OrderRequest, price: float) -> Fill:
        notional = abs(req.qty) * price
        fee = self.broker.commission(notional)
        cash_change = -notional - fee if req.side == "BUY" else notional - fee
        if self.account.balance + cash_change < 0:
            raise ValueError("order would make balance negative")
        if cash_change > 0:
            self.account.deposit(cash_change)
        else:
            self.account.withdraw(-cash_change)
        return Fill(req.symbol, req.qty, price)
  • Exercise: add a risk check strategy (max position size per symbol) and swap it in

An Advanced Backtester

What is a backtester?

  • A backtester simulates trading strategies on historical data to evaluate performance.
  • It helps traders understand how a strategy would have performed in the past, providing insights into its potential effectiveness.
  • Key components include data handling, order execution, portfolio management, and performance metrics.
  • A well-designed backtester allows for flexibility in strategy implementation and risk management.
  • Python’s OOP features can be leveraged to create a modular and extensible backtester.

What classes would you create for a backtester? Be creative

  • DataHandler: Manages historical market data, providing access to price and volume information.
  • Strategy: An abstract base class defining the interface for trading strategies, including methods for generating signals
  • Order: Represents a buy or sell order, including attributes like symbol, quantity, and price.
  • Portfolio: Manages the current holdings, cash balance, and overall portfolio value.
  • Broker: Simulates order execution, applying fees and updating the portfolio based on filled orders.
  • PerformanceMetrics: Calculates key performance indicators such as return, volatility, and drawdown.
  • Backtester: The main class that orchestrates the simulation, integrating all components and running the

The DataHandler class, example

from abc import ABC, abstractmethod
import pandas as pd
from typing import Dict, List # typing is a module that provides support for type hints

class DataHandler(ABC):
    """ Abstract base class for data handling """
    @abstractmethod
    def get_latest_bar(self, symbol: str) -> pd.Series:
        """ Returns the latest bar for a given symbol """
        pass

    @abstractmethod
    def get_latest_bars(self, symbol: str, N: int = 1) -> List[pd.Series]:
        """ Returns the latest N bars for a given symbol """
        pass

    @abstractmethod
    def update_bars(self) -> None:
        """ Updates the bars to the next time step """
        pass

Specific implementation of DataHandler

class HistoricCSVDataHandler(DataHandler):
    """ Handles data from CSV files """
    def __init__(self, csv_dir: str, symbols: List[str]):
        self.csv_dir = csv_dir
        self.symbols = symbols
        self.data: Dict[str, pd.DataFrame] = {} # type: ignore
        self.latest_data: Dict[str, List[pd.Series]] = {symbol: [] for symbol in symbols}
        self._load_data()

    def _load_data(self) -> None:
        """ Loads data from CSV files into the data dictionary """
        for symbol in self.symbols:
            file_path = f"{self.csv_dir}/{symbol}.csv"
            self.data[symbol] = pd.read_csv(file_path, index_col="date", parse_dates=True)
            self.data[symbol].sort_index(inplace=True)
            self.latest_data[symbol] = []
            self._update_latest_data(symbol)

    def _update_latest_data(self, symbol: str) -> None:
        """ Updates the latest data for a given symbol """
        self.latest_data[symbol] = self.data[symbol].iloc[-1].to_dict()

    def get_latest_bar(self, symbol: str) -> pd.Series:
        """ Returns the latest bar for a given symbol """
        return pd.Series(self.latest_data[symbol])

    def get_latest_bars(self, symbol: str, N: int = 1) -> List[pd.Series]:
        """ Returns the latest N bars for a given symbol """
        return [pd.Series(self.latest_data[symbol]) for _ in range(N)]

    def update_bars(self) -> None:
        """ Updates the bars to the next time step """
        for symbol in self.symbols:
            if not self.data[symbol].empty:
                self.latest_data[symbol] = self.data[symbol].iloc[-1].to_dict()
                self.data[symbol] = self.data[symbol].iloc[:-1]

Strategy class example

Let’s first focus on the Signal.

from abc import ABC, abstractmethod
from typing import Dict, List
import pandas as pd
from dataclasses import dataclass
from enum import Enum # Enum is a module that provides support for enumerations

class SignalType(Enum):
    """ Enumeration for signal types """
    BUY = "BUY"
    SELL = "SELL"
    HOLD = "HOLD"
    EXIT = "EXIT"

@dataclass
class Signal:
    """ Represents a trading signal """
    symbol: str
    signal_type: SignalType
    price: float
    volume: float

    def __post_init__(self):
        if self.volume <= 0:
            raise ValueError("Volume must be positive")

        if self.signal_type not in SignalType:
            raise ValueError(f"Invalid signal type: {self.signal_type}")

Strategy class example (continued)

class Strategy(ABC):
    """ Abstract base class for trading strategies """
    @abstractmethod
    def generate_signals(self, data: Dict[str, pd.DataFrame]) -> List[Signal]:
        """ Generates trading signals based on the provided data """
        pass

Example implementation of a simple moving average crossover strategy

class MovingAverageCrossoverStrategy(Strategy):
    """ Simple moving average crossover strategy """
    def __init__(self, short_window: int = 40, long_window: int = 100):
        self.short_window = short_window
        self.long_window = long_window

    def generate_signals(self, data: Dict[str, pd.DataFrame]) -> List[Signal]:
        signals = []
        for symbol, df in data.items():
            df['short_mavg'] = df['close'].rolling(window=self.short_window, min_periods=1).mean()
            df['long_mavg'] = df['close'].rolling(window=self.long_window, min_periods=1).mean()

            if df['short_mavg'].iloc[-1] > df['long_mavg'].iloc[-1]:
                signals.append(Signal(symbol=symbol, signal_type=SignalType.BUY, price=df['close'].iloc[-1], volume=100))
            elif df['short_mavg'].iloc[-1] < df['long_mavg'].iloc[-1]:
                signals.append(Signal(symbol=symbol, signal_type=SignalType.SELL, price=df['close'].iloc[-1], volume=100))
            else:
                signals.append(Signal(symbol=symbol, signal_type=SignalType.HOLD, price=df['close'].iloc[-1], volume=0))
        return signals

Portfolio class

  • Tracks cash and positions per symbol
  • Applies fills to update holdings and cash
  • Computes market value and equity from a price snapshot
from dataclasses import dataclass, field
from typing import Dict

@dataclass
class Portfolio:
    cash: float = 0.0
    positions: Dict[str, int] = field(default_factory=dict)

    def qty(self, symbol: str) -> int:
        return self.positions.get(symbol, 0)

    def apply_fill(self, fill: Fill, fee: float) -> None:
        sign = 1 if fill.qty > 0 else -1
        notional = abs(fill.qty) * fill.price
        # BUY reduces cash, SELL increases cash
        self.cash += (-notional - fee) if sign > 0 else (notional - fee)
        self.positions[fill.symbol] = self.qty(fill.symbol) + fill.qty
        if self.positions[fill.symbol] == 0:
            self.positions.pop(fill.symbol)

    def market_value(self, prices: Dict[str, float]) -> float:
        return sum(self.qty(sym) * prices.get(sym, 0.0) for sym in self.positions)

    def equity(self, prices: Dict[str, float]) -> float:
        return self.cash + self.market_value(prices)

Broker (execution) for backtests

  • Converts Signals into OrderRequests
  • Applies fee model and optional slippage
  • Returns a Fill and fee charged
from typing import Tuple

class BacktestBroker:
    def __init__(self, fee_model: FeeModel, slippage_bps: float = 0.0):
        self.fee_model = fee_model
        self.slippage_bps = slippage_bps

    def _apply_slippage(self, price: float, side: SignalType) -> float:
        # BUY pays up, SELL gets less
        adj = price * (self.slippage_bps / 10_000.0)
        return price + adj if side == SignalType.BUY else price - adj

    def order_for_signal(self, sig: Signal) -> OrderRequest:
        if sig.signal_type == SignalType.HOLD or sig.volume == 0:
            return OrderRequest(symbol=sig.symbol, qty=0, side="HOLD")
        side = "BUY" if sig.signal_type == SignalType.BUY else "SELL"
        qty = int(sig.volume) if side == "BUY" else -int(sig.volume)
        return OrderRequest(symbol=sig.symbol, qty=qty, side=side)

    def execute(self, req: OrderRequest, mkt_price: float) -> Tuple[Fill, float]:
        if req.qty == 0:
            return Fill(req.symbol, 0, mkt_price), 0.0
        traded_price = self._apply_slippage(mkt_price, SignalType.BUY if req.qty > 0 else SignalType.SELL)
        notional = abs(req.qty) * traded_price
        fee = self.fee_model.fee(notional)
        return Fill(req.symbol, req.qty, traded_price), fee

PerformanceMetrics

  • Computes basic KPIs from an equity curve
  • Keep it simple: daily returns, total return, vol, Sharpe, max drawdown
import math
from typing import List, Dict

class PerformanceMetrics:
    def __init__(self, equity_curve: List[float]):
        self.equity = equity_curve
        self.returns = [0.0] + [
            (self.equity[i] / self.equity[i-1] - 1.0) for i in range(1, len(self.equity))
        ]

    def summary(self, periods_per_year: int = 252) -> Dict[str, float]:
        if not self.equity or len(self.equity) < 2:
            return {"total_return": 0.0, "vol": 0.0, "sharpe": 0.0, "max_dd": 0.0}
        total_return = self.equity[-1] / self.equity[0] - 1.0
        mean = sum(self.returns[1:]) / max(1, len(self.returns) - 1)
        var = sum((r - mean) ** 2 for r in self.returns[1:]) / max(1, len(self.returns) - 2)
        vol = math.sqrt(var) * math.sqrt(periods_per_year)
        sharpe = (mean * periods_per_year) / vol if vol > 0 else 0.0
        # max drawdown
        peak = self.equity[0]
        max_dd = 0.0
        for v in self.equity:
            peak = max(peak, v)
            dd = (v / peak) - 1.0
            max_dd = min(max_dd, dd)
        return {
            "total_return": total_return,
            "vol": vol,
            "sharpe": sharpe,
            "max_dd": max_dd,
        }

Backtester: wiring everything together

  • Orchestrates data, strategy, broker, portfolio
  • Iterates over time, generates signals, executes, and records equity
from typing import Dict, List

class Backtester:
    def __init__(self, data: Dict[str, pd.DataFrame], strategy: Strategy, broker: BacktestBroker, portfolio: Portfolio):
        self.data = data
        self.strategy = strategy
        self.broker = broker
        self.portfolio = portfolio
        self.equity_curve: List[float] = []

    def _prices_snapshot(self, t: int) -> Dict[str, float]:
        return {sym: df['close'].iloc[t] for sym, df in self.data.items() if t < len(df)}

    def run(self) -> PerformanceMetrics:
        # assume all symbols share the same index length for brevity
        T = min(len(df) for df in self.data.values())
        for t in range(T):
            # 1) Generate signals using history up to t
            hist = {sym: df.iloc[: t + 1] for sym, df in self.data.items()}
            signals = self.strategy.generate_signals(hist)
            prices = self._prices_snapshot(t)

            # 2) Convert to order requests and execute
            for sig in signals:
                req = self.broker.order_for_signal(sig)
                if req.qty == 0:
                    continue
                mkt_px = prices.get(req.symbol)
                if mkt_px is None:
                    continue
                fill, fee = self.broker.execute(req, mkt_px)
                self.portfolio.apply_fill(fill, fee)

            # 3) Record equity
            self.equity_curve.append(self.portfolio.equity(prices))

        return PerformanceMetrics(self.equity_curve)

Tiny end-to-end example

  • Get from yahoo finance or use dummy data for quick tests
# Inputs
symbols = ["AAPL", "MSFT"]
data = {s: pd.DataFrame({"close": pd.Series([100, 101, 102, 101, 103])}) for s in symbols}

strategy = MovingAverageCrossoverStrategy(short_window=2, long_window=3)
broker = BacktestBroker(FixedBpsFee(1.0), slippage_bps=2.0)
portfolio = Portfolio(cash=100_000)

bt = Backtester(data, strategy, broker, portfolio)
metrics = bt.run().summary()

print({k: round(v, 4) for k, v in metrics.items()})

Tip: Swap the fee model or tweak slippage to stress test costs; or add a risk cap mixin that vetoes oversize orders.