The 100 problems, solved / Commerce and money / 080 · Design an online stock brokerage

Design an online stock brokerage

Accounts, orders, positions and a watchlist with notifications.

Concepts this design leans on

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.
Customer sideOrdersMarket and prices***subscribesAccountcash: intpositions: dictorders: dictbuying_power()Positionsymbol: strqty: intavg_cost: floatapply_fill(side, qty, price)Watchlistalerts: liston_tick(symbol, price)Brokerageaccounts: dictvalidators: listgateway: MarketGatewayplace(acct, order)cancel(order_id)on_fill(fill)Orderid: strsymbol: strside: Sideqty: intlimit: intfilled: intstate: OrderStateOrderValidatorvalidate(account, order)MarketGatewaysend(order)cancel(order_id)PriceFeedsubscribe(fn)publish(symbol, price)

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

  1. place builds an Order, runs every validator, reserves cash for a buy, moves the order to SENT and calls the gateway.
  2. The gateway later calls on_fill with a quantity and price.
  3. on_fill updates filled, applies the fill to the position (re-averaging cost on buys), settles cash against the reservation, and moves to PARTIALLY_FILLED or FILLED.
  4. Independently, the PriceFeed publishes 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) inside Position and a lot-selection strategy (FIFO, highest cost); apply_fill is 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 Account around reservation and settlement, and make on_fill idempotent 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 Account so 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'