Design an online stock brokerage
Accounts, orders, positions and a watchlist with notifications.
- Observer and event listeners — Telling interested objects something happened, without knowing who they are.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Strategy — Swapping an algorithm at runtime by passing an object.
01The design, in the six-step framework
1. Requirements and use cases
A brokerage is the customer-facing side of trading: accounts, orders that go to a market, positions that result, and watchlists that notify. The engine that matches orders is a separate component (see the matching engine); here it is a dependency.
- A customer deposits cash and places buy or sell orders for a symbol.
- The system validates an order against buying power or held quantity before sending it.
- The system updates positions when fills arrive, including average cost per lot.
- A customer maintains a watchlist and is notified when a watched price crosses a threshold.
- A customer cancels an open order and views a portfolio report.
Out of scope: margin, options, tax lots beyond average cost, and the market itself. Non-functional constraints: an order moves through a strict state machine; validation rules are added without touching order placement; price alerts fire once per crossing.
2. Core entities and relationships
- Account: cash, positions by symbol and the customer's orders.
- Position: quantity and average cost for one symbol in one account.
- Order: symbol, side, quantity, limit price, filled quantity and a state.
- OrderValidator: an interface; concrete rules for buying power and held quantity.
- MarketGateway: an interface to send and cancel orders; it calls back with fills.
- Watchlist and PriceAlert: symbols a customer follows and thresholds that notify.
- PriceFeed: publishes ticks; watchlists subscribe.
- Brokerage: the orchestrator.
Positions and watchlists are composed by the account: they only exist for that customer. Orders are aggregated because the gateway and the brokerage also hold references to them by id.
3. Interfaces and key methods
class OrderValidator(Protocol):
def validate(self, account: Account, order: Order) -> None:
"""Raise OrderRejected with a reason; otherwise return."""
class MarketGateway(Protocol):
def send(self, order: Order) -> None: ...
def cancel(self, order_id: str) -> bool: ...
class Brokerage:
def place(self, account_id: str, symbol: str, side: Side, qty: int, limit: int) -> Order:
"""Validate, reserve cash or shares, move to SENT, hand to the gateway."""
def cancel(self, account_id: str, order_id: str) -> bool: ...
def on_fill(self, order_id: str, qty: int, price: int) -> None:
"""Gateway callback: update order, position and cash; may complete the order."""
class Position:
def apply_fill(self, side: Side, qty: int, price: int) -> None:
"""Buy raises qty and re-averages cost; sell lowers qty and realises P&L."""
class Watchlist:
def add_alert(self, symbol: str, above: Optional[int] = None, below: Optional[int] = None) -> None: ...
def on_tick(self, symbol: str, price: int) -> list[str]:
"""Return messages for alerts that fired; each fires once until re-armed."""
Invariants: reserved cash plus free cash equals total cash; order.filled <= order.qty; position quantity is never negative without a short-selling rule; an alert fires at most once per crossing.
4. Design patterns and the choices behind them
Order lifecycle as a state machine
NEW, SENT, PARTIALLY_FILLED, FILLED, CANCELLED, REJECTED, with cancellation only allowed before FILLED. One transition table in the Order class is the lightest form of the State pattern; the alternative of status strings checked ad hoc is how a fill after a cancel corrupts a position.
Validators as a strategy list
Buying power, held quantity, market hours and position limits are rules that change independently of placement. Each is a strategy behind OrderValidator, run in order; adding a rule is adding a class, the open-closed seam this question is really testing.
Observer for prices and alerts
The price feed does not know who cares about a tick. Watchlists subscribe and the feed publishes: the observer pattern, with the subtlety that an alert must be edge-triggered (fire when crossing, not while above), otherwise a customer gets a message per tick.
Nouns from the use cases
The class list comes straight from the verbs: deposit (Account), place (Order), fill (Position), watch (Watchlist). The noun-and-verb walk is fast here because the domain is familiar; the trap is inventing a Portfolio class that duplicates Account.
5. Core flow and code
placebuilds anOrder, runs every validator, reserves cash for a buy, moves the order to SENT and calls the gateway.- The gateway later calls
on_fillwith a quantity and price. on_fillupdatesfilled, applies the fill to the position (re-averaging cost on buys), settles cash against the reservation, and moves to PARTIALLY_FILLED or FILLED.- Independently, the
PriceFeedpublishes ticks; each account's watchlist evaluates its alerts and returns messages for the notifier.
from __future__ import annotations
import itertools
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional, Protocol
class OrderRejected(Exception): ...
class IllegalTransition(Exception): ...
class Side(Enum):
BUY = "buy"
SELL = "sell"
class OrderState(Enum):
NEW = "new"; SENT = "sent"; PARTIALLY_FILLED = "partial"
FILLED = "filled"; CANCELLED = "cancelled"; REJECTED = "rejected"
ALLOWED = {
OrderState.NEW: {OrderState.SENT, OrderState.REJECTED},
OrderState.SENT: {OrderState.PARTIALLY_FILLED, OrderState.FILLED, OrderState.CANCELLED},
OrderState.PARTIALLY_FILLED: {OrderState.PARTIALLY_FILLED, OrderState.FILLED, OrderState.CANCELLED},
OrderState.FILLED: set(), OrderState.CANCELLED: set(), OrderState.REJECTED: set(),
}
@dataclass
class Order:
id: str
account_id: str
symbol: str
side: Side
qty: int
limit: int
filled: int = 0
state: OrderState = OrderState.NEW
def move(self, to: OrderState) -> None:
if to not in ALLOWED[self.state]:
raise IllegalTransition(f"{self.state.value} -> {to.value}")
self.state = to
@dataclass
class Position:
symbol: str
qty: int = 0
avg_cost: float = 0.0
realised: int = 0
def apply_fill(self, side: Side, qty: int, price: int) -> None:
if side is Side.BUY:
self.avg_cost = (self.avg_cost * self.qty + price * qty) / (self.qty + qty)
self.qty += qty
else:
self.realised += int((price - self.avg_cost) * qty)
self.qty -= qty
@dataclass
class PriceAlert:
symbol: str
above: Optional[int]
below: Optional[int]
armed: bool = True
class Watchlist:
def __init__(self) -> None:
self.alerts: list[PriceAlert] = []
def add_alert(self, symbol: str, above: Optional[int] = None, below: Optional[int] = None) -> None:
self.alerts.append(PriceAlert(symbol, above, below))
def on_tick(self, symbol: str, price: int) -> list[str]:
fired = []
for a in self.alerts:
if a.symbol != symbol:
continue
crossed = (a.above is not None and price >= a.above) or (a.below is not None and price <= a.below)
if crossed and a.armed:
a.armed = False
fired.append(f"{symbol} at {price}")
elif not crossed:
a.armed = True # re-arm once price is back inside
return fired
@dataclass
class Account:
id: str
cash: int = 0
reserved: int = 0
positions: dict[str, Position] = field(default_factory=dict)
orders: dict[str, Order] = field(default_factory=dict)
watchlist: Watchlist = field(default_factory=Watchlist)
def buying_power(self) -> int: return self.cash - self.reserved
def position(self, symbol: str) -> Position:
return self.positions.setdefault(symbol, Position(symbol))
class OrderValidator(Protocol):
def validate(self, account: Account, order: Order) -> None: ...
class BuyingPowerRule:
def validate(self, account: Account, order: Order) -> None:
if order.side is Side.BUY and order.qty * order.limit > account.buying_power():
raise OrderRejected("insufficient buying power")
class HeldQuantityRule:
def validate(self, account: Account, order: Order) -> None:
if order.side is Side.SELL and order.qty > account.position(order.symbol).qty:
raise OrderRejected("not enough shares")
class MarketGateway(Protocol):
def send(self, order: Order) -> None: ...
def cancel(self, order_id: str) -> bool: ...
class Brokerage:
def __init__(self, gateway: MarketGateway, validators: list[OrderValidator]) -> None:
self.gateway, self.validators = gateway, validators
self.accounts: dict[str, Account] = {}
self.orders: dict[str, Order] = {}
self._ids = itertools.count(1)
def place(self, account_id: str, symbol: str, side: Side, qty: int, limit: int) -> Order:
acct = self.accounts[account_id]
order = Order(f"o{next(self._ids)}", account_id, symbol, side, qty, limit)
try:
for v in self.validators:
v.validate(acct, order)
except OrderRejected:
order.move(OrderState.REJECTED)
raise
if side is Side.BUY:
acct.reserved += qty * limit
acct.orders[order.id] = order
self.orders[order.id] = order
order.move(OrderState.SENT)
self.gateway.send(order)
return order
def cancel(self, account_id: str, order_id: str) -> bool:
order = self.accounts[account_id].orders[order_id]
if not self.gateway.cancel(order_id):
return False
order.move(OrderState.CANCELLED)
if order.side is Side.BUY:
self.accounts[account_id].reserved -= (order.qty - order.filled) * order.limit
return True
def on_fill(self, order_id: str, qty: int, price: int) -> None:
order = self.orders[order_id]
acct = self.accounts[order.account_id]
order.filled += qty
acct.position(order.symbol).apply_fill(order.side, qty, price)
if order.side is Side.BUY:
acct.reserved -= qty * order.limit
acct.cash -= qty * price
else:
acct.cash += qty * price
order.move(OrderState.FILLED if order.filled == order.qty else OrderState.PARTIALLY_FILLED)
6. Extensions, concurrency and what interviewers probe
- Tax lots: replace the single average cost with a list of
Lot(qty, cost, date)insidePositionand a lot-selection strategy (FIFO, highest cost);apply_fillis the only method that changes. - Market hours and position limits are two more validator classes; nothing else moves. Interviewers tagged this Hard mostly to see whether validation leaks into
place. - Concurrency: fills arrive on a gateway thread while customers place orders on another. Put a lock on
Accountaround reservation and settlement, and makeon_fillidempotent by fill id, because gateways redeliver. - Reports: a portfolio report is a read over positions and the price feed's last prices; keep it out of
Accountso the account does not depend on the feed. - Test first: a buy larger than buying power is rejected and leaves no reservation; a partial fill then a cancel releases exactly the unfilled reservation; an alert at 100 fires once as the price goes 99, 101, 102, and again after 98, 101.
- Common mistake: debiting cash at placement rather than reserving it, which breaks when the fill price is better than the limit.
- What to say in the first minute: that the brokerage owns accounts, orders and positions, that the market is a dependency behind an interface, and that the order lifecycle is a state machine. Candidates who start by drawing the matching engine spend the round on the wrong component; the interviewer's tag of Hard refers to the breadth of the account model, not to matching.
02Where this question was reported
- Educative · Grokking LLD chapter 23
- DesignGurus · DesignGurus case study
- dev.to · Educative top-10: watchlists, lots, reports
- GitHub · awesome-low-level-design, Hard
- Codemia · Codemia Hard 'Stock Portfolio Manager'