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.
arena.Trader ABC and implementing one method.docs/uml/
Target: Level 1
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.
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.
A global pos dict that any function may write. Two writers, no owner.
Portfolio owns positions; only confirmed fills change it.Orders are dicts; the side is whatever string the author typed: 'buy', 'BUY', 'b'.
Signal) that refuses a bad side when it is created.momo_aapl() and momo_nvda(): the same strategy, pasted, then edited separately.
Strategy class, one instance per symbol; subclass only for a real difference.The fee rate is typed as a literal in two functions, written in two different ways.
FeeModel object. One fact, one place.The strategy calls the socket directly and reads globals.
A teammate (or an AI assistant) cannot tell which copy is live or which keys an order dict needs.
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.
AAPL 185.00 · NVDA 120.00
| bot thinks | venue says |
|---|
# 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)
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.
"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.
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:
ErrorMsg(code='PARSE_ERROR').
If the bot never reads it, or swallows it as in step 5 above, the order simply never happened.'sell' if side == 'buy' else 'buy' turns
'BUY' into 'buy', so the "closing" order adds to the position.RATE_LIMITED, and the
cancel you actually needed is refused too.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.
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 ask | what typically happens in the procedural bot.py | in 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.
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:
on_tick(); it never touches sockets, auth or matching.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.
Every OOP concept reduces to these four ideas. Pick one; the diagram and the code change with it.
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.
Portfolio.net_worth is a
property. Marking positions changed three times during development; no bot ever noticed.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
An abstraction is a promise about behaviour with the mechanism deleted. Your bot's promise is one line: given the market and my portfolio, return a Signal or None. Underneath that line sit a WebSocket client, a token handshake, JSON serialisation, reconnection with backoff, a matching engine and a fee model — none of which you import, mention or can break.
arena package is the
only thing a student imports. Everything in exchange/, shared/ and
trader/ is behind it.# what you write — the whole surface area from arena import Trader, Signal class MyBot(Trader): def on_tick(self, market, portfolio): ask = market.best_ask('AAPL') return Signal('AAPL', 'buy', 1, ask) if ask else None MyBot().run() # sockets, auth, retries: not your problem
A subclass reuses a base class and specialises it; super() calls back into
the parent, and Python resolves which method runs via the MRO. Inheritance is the right tool when the
sentence "a Momentum IS-A Strategy" is true. It is the wrong tool when you only
wanted to borrow some code — that is what composition is for, and it is the single most common design
error we will see in your Week-1 diagrams.
class Strategy: def __init__(self, name): self.name = name def signal(self, prices): ... # base behaviour class Momentum(Strategy): # Momentum IS-A Strategy def __init__(self, name, lookback): super().__init__(name) # call parent __init__ self.lookback = lookback def signal(self, prices): # specialise return prices[-1] - prices[-self.lookback]
The engine holds a list of bots. It calls on_tick() on each of them and
has no idea which class it is talking to. That is polymorphism: one interface, many implementations,
chosen at runtime. Python does not even require a shared base type — if it has
on_tick(), it is a trader. Duck typing is powerful and it is a contract you must honour
by hand, which is exactly why the arena pins it down with an ABC.
def run_all(bots, market, portfolio): for bot in bots: # each bot is a different class... sig = bot.on_tick(market, portfolio) # ...same call, different logic if sig: submit(sig) run_all([Momentum(...), MeanReversion(...), MLBot(...)], mkt, pf) # pinned down by a contract: from abc import ABC, abstractmethod class Trader(ABC): @abstractmethod def on_tick(self, market, portfolio): ... Trader() # TypeError: can't instantiate abstract class
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.
| Glyph | Name | Means |
|---|---|---|
| ▷—— | Generalization | IS-A. Momentum is a Strategy. |
| ◆—— | Composition | OWNS, shared fate. A Bot owns its Portfolio. |
| ◇—— | Aggregation | HAS-A, separate lifetime. A venue has bots that outlive it. |
| —— | Association | Uses. A Trader uses MarketData. |
| - - › | Dependency | Transient use. A method returns a Signal. |
Multiplicity goes on the ends: 1, 0..1,
*. An OrderBook holds * Orders.
docs/uml/ on your W1 feature branch.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.
Nothing selected. Click a box in the diagram to start a link.
# add classes to see the code
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
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, ...)
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.
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.
| bot | P&L | trades | fees |
|---|
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
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.
Every one of these has been marked down in a real Week-1 deliverable. Open them.
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.
BotOne 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.
_private is enforcedPython 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.
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.
"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.
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?
make install.docs/uml/ on a W1 feature branch.arena/trader.py and shared/messages.py in full.trader/trader.py — the async loop underneath on_tick. That is Week 2.make install.on_tick — and how to never block it.