WEEK 1

UML & Object-Oriented Programming

A trading system is a system of objects — so model it on paper before you model it in Python, and the design mistakes cost you minutes instead of weeks.

Paper trading week · nothing is scored yet Deliverable: Phase-1 UML under docs/uml/ Target: Level 1
Part 1

1What is object-oriented programming?

Start with the problem, not the vocabulary. A trading bot is a small program that changes very quickly, runs unattended, and spends real money every time it is wrong. Object-oriented programming is a set of tools for keeping that kind of program honest: each fact in one place, each piece behind a contract, and the same code running in a test, a backtest and the live arena.

1aWhat OOP solves, as a software-engineering problem

Six problems that every growing codebase hits. Each card gives the symptom, what it costs in a trading system, and the OOP tool that removes it. You will see all six fire in the bot below.

Shared mutable state

A global pos dict that any function may write. Two writers, no owner.

In trading: the position is counted twice, so the exit order is twice the size you meant.
OOP: encapsulation. One Portfolio owns positions; only confirmed fills change it.

No contracts between pieces

Orders are dicts; the side is whatever string the author typed: 'buy', 'BUY', 'b'.

In trading: the order is rejected, or a helper reads it as the wrong side.
OOP: a typed value object (Signal) that refuses a bad side when it is created.

Copy-paste variants

momo_aapl() and momo_nvda(): the same strategy, pasted, then edited separately.

In trading: a fix lands in one copy only, and the other one breaches the position limit.
OOP: one Strategy class, one instance per symbol; subclass only for a real difference.

Change here, break there

The fee rate is typed as a literal in two functions, written in two different ways.

In trading: the venue changes its fee, one copy gets updated, and your cash and P&L stop matching the venue's statement.
OOP: one FeeModel object. One fact, one place.

Untestable code

The strategy calls the socket directly and reads globals.

In trading: you cannot backtest the code you run live, so the backtest is a second copy that drifts.
OOP: the strategy is an object behind an interface; the simulator runs the same class offline.

Onboarding

A teammate (or an AI assistant) cannot tell which copy is live or which keys an order dict needs.

In trading: the next change goes into the wrong copy, two hours before the session.
OOP: classes, type hints and a UML diagram work as a map. That map is this week's deliverable.

1bHow bad can it get? One morning with a procedural bot

On the left is a realistic 42-line bot of the kind that "worked yesterday": globals, orders as raw dicts, the position updated in two places, a strategy pasted for a second symbol before a fix, the fee written twice, and a bare except that "keeps the bot alive". On the right is the same strategy with the same rules, written as objects on the arena SDK. It is not shorter: the difference is that each fact lives in exactly one place. Both files run: we replayed the same eight ticks through a venue that validates orders with the arena's real PlaceOrder schema, and every number below is that run's output.

Interactive · run the bad bot through one morning

step 0 / 7 · 09:30:00

AAPL 185.00 · NVDA 120.00

rejected orders
0
risk breaches
0
bot ≠ venue (shares)
0
P&L since open
$0.00
bot thinksvenue says
the open

Two symbols, one bot, $100,000

proceduralbot.py
# bot.py — "it worked yesterday"TEAM, MAX_POS = 'team07', 200        # max shares per symbolpos = {}                             # global: anyone can write itcash = 100_000.0hist = {'AAPL': [], 'NVDA': []}ws = None                            # live socket, set at startup def send(sym, side, qty, px):    global cash    ws.send({'type': 'place_order', 'team_id': TEAM,             'symbol': sym, 'side': side, 'order_type': 'limit',             'price': px, 'quantity': qty})       # raw dict    sgn = 1 if side == 'buy' else -1    pos[sym] = pos.get(sym, 0) + sgn * qty    cash -= sgn * qty * px + 0.001 * qty * px def on_message(msg):                 # everything the venue says    try:        if msg['type'] == 'trade_execution':            q = msg['quantity']            if msg['seller_id'] == TEAM:                q = -q            pos[msg['symbol']] = pos.get(msg['symbol'], 0) + q        elif msg['type'] == 'error':            print('REJECTED', msg['reason'])  # it is 'message'    except:                          # "keeps the bot alive"        pass def momo_aapl(px):                   # fixed Tuesday    h = hist['AAPL']; h.append(px); p = pos.get('AAPL', 0)    if len(h) > 1 and h[-1] > h[-2] and p + 100 <= MAX_POS \            and cash > 100 * px * 1.0015:        send('AAPL', 'buy', 100, px)    elif len(h) > 1 and h[-1] < h[-2] and p > 0:        send('AAPL', 'sell', p, px)          # exit everything def momo_nvda(px):                   # pasted Monday, before the fix    h = hist['NVDA']; h.append(px); p = pos.get('NVDA', 0)    if len(h) > 1 and h[-1] > h[-2]:        send('NVDA', 'buy', 100, px)    elif len(h) > 1 and h[-1] < h[-2] and p > 0:        send('NVDA', 'SELL', p, px)
object-orientedteam/trader.py
from abc import ABC, abstractmethodfrom arena import Trader, Signal class FeeModel:                      # the one place fees live    def __init__(self, taker: float = 0.0015) -> None:        self.taker = taker    def all_in(self, qty: int, px: float) -> float:        return qty * px * (1 + self.taker) class Strategy(ABC):                 # the contract for every signal    @abstractmethod    def side(self, prices: list[float]) -> str | None: ... class Momentum(Strategy):            # one class, one per symbol    def side(self, prices: list[float]) -> str | None:        if len(prices) < 2 or prices[-1] == prices[-2]:            return None        return 'buy' if prices[-1] > prices[-2] else 'sell' class Risk:                          # one rule set, every symbol    def __init__(self, max_pos: int, lot: int, fees: FeeModel):        self.max_pos, self.lot, self.fees = max_pos, lot, fees    def size(self, side, held: int, cash: float, px: float) -> int:        if side == 'sell':            return max(held, 0)      # exit what the venue holds        q = min(self.lot, self.max_pos - held) if side else 0        ok = q > 0 and cash >= self.fees.all_in(q, px)        return q if ok else 0 class MyBot(Trader):    def __init__(self, strategies: dict[str, Strategy], risk: Risk):        self.strategies, self.risk = strategies, risk    def on_tick(self, market, portfolio) -> Signal | None:        for sym, strat in self.strategies.items():            px = market.mid_price(sym)            if px is None:           # no book yet: sit out                continue            held = portfolio.positions.get(sym, 0)  # venue's number            side = strat.side(market.prices(sym))            qty = self.risk.size(side, held, portfolio.cash, px)            if qty:                  # validated here, in-process                return Signal(symbol=sym, side=side,                              quantity=qty, price=px)        return None bot = MyBot({'AAPL': Momentum(), 'NVDA': Momentum()},            Risk(max_pos=200, lot=100, fees=FeeModel()))

Simplifications, so the bugs are easy to see: every valid limit order fills in full at its price, the taker fee is 0.0015 of notional, and shorting is allowed (as on the arena venue). Momentum here is deliberately naive: buy on an uptick, exit on a downtick.

A real-world anchor: Knight Capital, 1 August 2012. While deploying new order-routing code, a technician did not copy it to one of Knight's eight routing servers. The new code reused a flag that had once switched on "Power Peg", a routine unused since 2003 but never deleted. On the eighth server the flag woke it up, and because the code that counted its fills had been moved years earlier, it kept sending child orders without noticing they were already filled. In about 45 minutes it sent millions of orders in 154 stocks, and Knight lost roughly $460 million (the figure in the SEC's 2013 order; Knight's own estimate that week was $440 million). The firm needed a rescue and was sold the next year. To be fair to the facts, this is mainly a story about deployment and dead code, not about OOP. But the ingredients are the ones you just stepped through: one copy that differs from the others, a shared flag with two meanings, and code that could not see its own fills.

1cWhy this matters when you talk to a bot

"Bot" means two different things in this course, and structure matters to both of them: (a) your trading bot talks to the exchange, and (b) you may talk to an AI coding assistant about your bot.

(a) Your bot talks to the exchange through a message contract

Every message on the arena's wire is a pydantic v2 model in shared/messages.py: Handshake to log in, PlaceOrder and CancelOrder to trade, and OrderAck, TradeExecution and ErrorMsg coming back. Raw dicts on the wire are forbidden. The reason is where a mistake is caught: an object is checked when it is built, on the line that built it, while a dict is checked by the venue, later and somewhere else, and the answer comes back as one more message your bot has to read.

What goes wrong for a bot that builds raw dicts:

  • Silent rejection. The venue answers ErrorMsg(code='PARSE_ERROR'). If the bot never reads it, or swallows it as in step 5 above, the order simply never happened.
  • Wrong side. Code like 'sell' if side == 'buy' else 'buy' turns 'BUY' into 'buy', so the "closing" order adds to the position.
  • Retry storms. A bot that cannot tell "rejected" from "not acknowledged yet" tends to resend. Resends of a valid order are real orders: duplicates fill.
  • Rate-limit hits. Each of those orders and cancels uses up the per-tick message quota (20 for a student team by default). After that the venue answers RATE_LIMITED, and the cancel you actually needed is refused too.
Be precise about what validation buys you. A wrong type or a missing required field raises. A misspelled optional field does not: pydantic ignores unknown keys by default, so stop_prise=181.0 leaves stop_price=None without complaint. Use extra='forbid' when that matters. Validation also checks types, not judgement: Signal will accept quantity=5000. Size limits are your Risk object's job.
from pydantic import ConfigDict, ValidationErrorfrom shared.messages import PlaceOrder   # venue's schema try:    PlaceOrder(team_id='team07', symbol='AAPL', side='BUY',               order_type='limit', price=185.40, quantity=5)except ValidationError as e:    print(e.errors()[0]['msg'])    # Input should be 'buy' or 'sell' o = PlaceOrder(team_id='team07', symbol='AAPL', side='buy',               order_type='stop', price=180.0, quantity=5,               stop_prise=181.0)   # typo, OPTIONAL fieldprint(o.stop_price)                # None: silently ignored class StrictOrder(PlaceOrder):     # opt in to strictness    model_config = ConfigDict(extra='forbid')try:    StrictOrder(**o.model_dump(), stop_prise=181.0)except ValidationError as e:    print(e.errors()[0]['msg'])    # Extra inputs are not permitted ws.send(o.model_dump_json())       # JSON from the model only

Runs as written against the arena's real schema; the printed messages are in the comments. In your own bot you will rarely build a PlaceOrder by hand: you return a Signal and the SDK builds the message.

(b) An AI coding assistant reasons from your code's structure

Using an AI assistant is optional in this course: any provider, or none. If you do use one, know what it works from. It sees your files, not your intentions. Clear classes, type hints and one interface give it a contract to work against and tests to run. Spaghetti code gives it three plausible places to edit and no way to know which one is live. When that happens it does not usually stop and ask; it confidently edits one of the copies. Nothing here is special to AI: the same is true of a human teammate reading your code for the first time. Here is the same request sent to both bots from the morning above:

you askwhat typically happens in the procedural bot.pyin the OOP team/trader.py
"Add a 2% stop-loss." There is no entry price anywhere, so it invents a global entry = {}, fills it in send() (before the venue confirms), and adds the check to momo_aapl, the copy it was shown. Following the local style, the stop sells pos[...], the double-counted number. The entry price comes from the on_fill() hook, which the SDK calls only on confirmed fills. Exit sizing already lives in Risk.size(), so the stop is one more rule there, or a StopLossRisk(Risk) subclass, applied to every symbol and sized from portfolio.positions. A unit test with held=200 runs in milliseconds with no socket.
"The venue's taker fee is now 0.002; update it." It searches for 0.001, changes send(), and misses the 1.0015 in momo_aapl, because that literal does not look like a fee. Cash and the affordability check now use different rates. FeeModel(taker=0.002): one argument in one place, and every caller picks it up.
"Add MSFT." It copies the nearest function, momo_nvda, which is the copy without the position cap and with 'SELL'. The repo now has three strategies and two bugs in each new one. {'MSFT': Momentum(), ...}: one dictionary entry. There is no second copy to go stale.

To be honest about it: an assistant can be wrong in a clean codebase too. Structure does not make it smarter. It narrows the set of plausible edits, and validation plus tests catch the wrong ones before they reach the venue. You still read every diff. The invariant "my position equals the venue's" is invisible in bot.py; in the OOP version it is written into the code, because only the portfolio can change a position.

1dNow the vocabulary: objects

Look at a market and you see nouns. An order with a side and a price. An order book that holds orders and matches them. A portfolio that owns cash and positions. A strategy that turns prices into intentions. Object-oriented programming is simply the decision to make each of those nouns a thing in your program that keeps its own state and knows how to behave.

The alternative — a pile of functions passing dictionaries around — works fine for a hundred lines and collapses at ten thousand. By Week 9 your bot will hold a portfolio, a risk stack, a signal model, a reconnecting socket client and a scoring hook. If those are five objects with clear contracts you can test them separately and swap one without touching the rest. If they are one 900-line main(), every change is a gamble.

There are four reasons the arena is built this way, and they are the four reasons the course starts here:

  • Model reality. Code that mirrors the domain is code you can reason about — and argue about with a colleague.
  • Manage complexity. A trader calls on_tick(); it never touches sockets, auth or matching.
  • Enable extension. A new security, shock or strategy is a new subclass or plugin, not a rewrite.
  • Make it testable. Small objects with clear contracts are unit-testable in isolation; the arena ships ~1,280 tests around them.

The vocabulary, once

class Order:
    exchange_fee = 0.001          # class attribute (shared)

    def __init__(self, symbol, side, qty, price):
        self.symbol = symbol      # instance attributes
        self.side   = side        # (per-object state)
        self.qty    = qty
        self.price  = price

    def notional(self):           # a method (behaviour)
        return self.qty * self.price

o = Order('AAPL', 'buy', 5, 185.50)  # an instance
o.notional()   # -> 927.5

A class is the blueprint; an object is one instance of it. __init__ sets up state, self is this particular object, and a method is behaviour that can see that state.

Interactive · the four pillars, each one live in the arena

Every OOP concept reduces to these four ideas. Pick one; the diagram and the code change with it.

Encapsulation — a stable interface over changeable internals

Bundle state and behaviour together, then hide the internals. Python has no hard access control: name is public API, _name means "internal, hands off", and __name is name-mangled. A @property lets you expose a computed value as if it were an attribute — so net_worth can start as a stored float and become a live mark-to-market calculation without breaking one caller.

In the arena: Portfolio.net_worth is a property. Marking positions changed three times during development; no bot ever noticed.
Portfolio + cash: float - _positions: dict - __ledger: list + net_worth: float public hidden
class Portfolio:
    def __init__(self, cash):
        self.cash = cash          # public
        self._positions = {}      # _protected (convention)
        self.__ledger = []        # __private (name-mangled)

    @property
    def net_worth(self):          # computed, read-only
        return self.cash + self._mark_positions()

p.net_worth        # attribute access, runs the method
p.net_worth = 10   # AttributeError — no setter
Part 2

2How does UML work? Build the diagram yourself

A UML class box is three compartments: name, attributes, methods. + is public, - is private, # is protected, and a dashed border marks an abstract class. The arrows are where the design actually lives — they say how objects know about, use, and own one another.

The five links you need this week

GlyphNameMeans
▷——GeneralizationIS-A. Momentum is a Strategy.
◆——CompositionOWNS, shared fate. A Bot owns its Portfolio.
◇——AggregationHAS-A, separate lifetime. A venue has bots that outlive it.
——AssociationUses. A Trader uses MarketData.
- - ›DependencyTransient use. A method returns a Signal.

Multiplicity goes on the ends: 1, 0..1, *. An OrderBook holds * Orders.

Why bother drawing it

  • It aligns humans. A picture settles an argument that three paragraphs of prose cannot.
  • It aligns your AI. Paste the diagram into Claude and the generated code lands in the right shape; describe it in prose and it invents its own.
  • It is cheap to be wrong. Moving an arrow costs a minute. Moving an inheritance relationship out of 2,000 lines costs a week.
  • It is a living artifact. You will extend this same diagram every week: async pipeline (W2), wire messages (W3), backtester (W4).
This week's deliverable is exactly the diagram you are about to build, committed under docs/uml/ on your W1 feature branch.

Interactive · UML class-diagram builder

Add classes from the palette, pick a link type, then click two boxes in the diagram (source first, target second) — or use the two dropdowns if you prefer the keyboard. The diagram, the generated Python skeleton and the design review all redraw as you go.

1 · Classes (click to add or remove)

2 · Link type

Nothing selected. Click a box in the diagram to start a link.

add a class from the palette →

Generated skeleton

# add classes to see the code

Design review

Empty diagram.
classes
0
links
0
is-a
0

The contract underneath it all: an ABC

An abstract base class turns "we agreed on the shape" into something Python enforces. arena.Trader has exactly one @abstractmethod. You cannot instantiate it until you implement that method — which is why a Level-1 bot cannot silently do nothing wrong.

from abc import ABC, abstractmethod

class Trader(ABC):
    @abstractmethod
    def on_tick(self, market, portfolio):
        ...            # every subclass MUST implement this

class MyBot(Trader):
    def on_tick(self, market, portfolio):
        return Signal('AAPL', 'buy', 5, market.best_ask('AAPL'))

Trader()      # TypeError: can't instantiate abstract class
MyBot()       # OK — the contract is satisfied

Value objects: dataclasses, and where pydantic takes over

A @dataclass writes __init__, __repr__ and __eq__ for you, and the type hints double as the schema. Use them for data-holding objects you create inside your own process. The moment an object goes on the wire, it needs validation as well as structure — that is why shared/messages.py uses pydantic v2 models instead (Week 3).

from dataclasses import dataclass

@dataclass
class Trade:
    symbol: str
    price: float
    quantity: int
    aggressor: str

t = Trade('AAPL', 185.5, 5, 'buy')   # __init__ free
print(t)   # Trade(symbol='AAPL', price=185.5, ...)
SOLID, one line each: Single responsibility · Open/closed (this is the plugin registry) · Liskov substitution · Interface segregation · Dependency inversion.
Part 3

3Why does this matter in trading?

Because the interface is the thing you keep and the strategy is the thing you throw away. Over nine weeks you will write and delete a dozen signals. If every one of them is a Strategy behind the same signal() call, swapping them is a one-line change and you can run them side by side on the same price path. If each one is welded into your socket loop, you get one shot per week.

Interactive · four strategies, one interface, one price path

All four bots below implement the identical signal(prices) -> float contract, so the runner code is a single loop that does not know which class it is holding. Press play and watch the P&L diverge — same market, same plumbing, different edge. Each position change pays the half-spread plus the 0.0015 taker fee, so churn is not free.

AAPL mid Momentum MeanReversion BuyHold CoinFlip
tick 0 / 240

Market

Final P&L per strategy

botP&Ltradesfees

Press play.

# the runner — polymorphic, and it never changes
for t in range(240):
    for bot in bots:            # 4 different classes
        target = bot.signal(px[:t+1])
        trade_to(bot, target)   # pays spread + fee

Interactive · coupling versus the cost of change

This is the same argument in money. Suppose your bot reaches directly into n pieces of plumbing — the socket, the parser, the fee table, the book. Every pair of those things can now break each other, so the wiring you have to reason about grows as n(n+1)/2. Route the same access through one interface and it grows as n. Drag the slider.

Design:
wires to reason about
15
hours to add a feature
7.5
blast radius
5 modules
Every direct call is a promise you cannot change later without asking permission from the other side.
The Week-9 version of this argument: the teams that finish the season are the ones whose Week-1 objects survived. A bot whose signal is one swappable object can absorb an ML model in Week 7. A bot whose signal is spread across its socket handler cannot, and its authors spend the finale rewriting instead of competing.
Part 4

4Five ways this goes wrong

Every one of these has been marked down in a real Week-1 deliverable. Open them.

Using inheritance where you meant composition

The classic: class Bot(Portfolio), because the bot needs cash and positions and inheriting them is fewer keystrokes. Now bot.apply_fill() is public API, your bot is a portfolio to every caller, and the day you want two portfolios (live and shadow) the design has no room for the second one.

Read the arrow out loud. "A Bot IS-A Portfolio" is false. "A Bot HAS-A Portfolio" is true.

Fix: self.portfolio = Portfolio(cash) — composition, one line, and now swappable and mockable in tests.

A god object called Bot

One class that polls the feed, computes the signal, sizes the position, checks risk, formats the order, manages the socket and prints the dashboard. It always starts innocently as 60 lines.

The symptom is that you cannot unit-test the signal without a network connection, so you stop testing the signal. By Week 5 you cannot profile it either, because everything is one function.

Fix: single responsibility. Signal generation, RiskManager sizing, and the transport are three objects. The arena SDK already owns the third one for you.

Believing _private is enforced

Python will happily let anyone touch bot._positions. The underscore is a message to humans, not a lock — and __name is only name-mangled, not sealed.

The real failure is the inverse: exposing mutable internals by accident. Returning self._positions from a "getter" hands the caller your live dictionary, and a bug three files away silently rewrites your book.

Fix: return copies or read-only views, and put computed state behind @property so you can change the implementation later.

A UML diagram with arrows but no meaning

Ten boxes joined by ten identical plain lines. It looks like a design and communicates nothing: the marker is what distinguishes "owns" from "inherits from" from "happens to call once".

Second version of the same mistake: arrows that point the wrong way. Generalization points from the subclass up to the base. Composition's filled diamond sits at the owner end.

Fix: every link gets a glyph and, where it helps, a multiplicity and a verb — Bot ◆— 1 Portfolio, OrderBook ◆— * Order.

Letting the AI own the design

"Write me a trading bot" produces a plausible 200-line file with a class hierarchy you did not choose and cannot defend in a design review — which is a graded part of this course.

Claude is excellent at the inverse task: give it your diagram and ask it to find the relationship that is wrong, generate the dataclass, or explain what arena.Trader's contract obliges you to implement.

Fix: you draw the boxes and arrows; the AI fills in bodies. If you cannot explain why a class exists, delete it.

Part 5

5Check yourself

Three questions, instant feedback, nothing recorded.

Q1Your bot needs cash and positions. Which relationship do you draw between Bot and Portfolio?

Q2arena.Trader is an ABC whose only @abstractmethod is on_tick. You write class MyBot(Trader) and forget to define on_tick. What happens?

Q3The engine holds bots: list and calls bot.on_tick(...) on each. You add a brand-new MLBot subclass. How much of the engine has to change?

Part 6

6Do the work

In class

  • Declare your team and your role: Exchange, Broker or Trader.
  • Scaffold the repo: "Use this template" → clone → make install.
  • Sketch the Phase-1 UML class diagram of the AlgoArena hierarchy.
  • Commit it under docs/uml/ on a W1 feature branch.
  • Reach Level 1 — your bot connects, places one order, appears in the dashboard.

Before next class

  • Push the W1 feature branch (UML plus bot skeleton).
  • Read arena/trader.py and shared/messages.py in full.
  • Read trader/trader.py — the async loop underneath on_tick. That is Week 2.
  • Come ready to defend your role choice.
Code
Get the code
Labs and homework live in your course’s LMS; the starter repo is public. Use it as a template, clone, make install.
Map
Course home
The nine-week map and the order-lifecycle walkthrough.
Next
Week 2 · Concurrency & Async →
The event loop that actually calls your on_tick — and how to never block it.