Five processes, one order book, and a clock. This is the first week the system is real: your bot connects to a venue somebody else wrote, and from now on the only honest description of how well it works is a latency distribution with a tail.
An event-driven architecture has no main loop that “does the work”. Every process sits blocked on its socket until something arrives, handles it, and blocks again. There is no polling, no schedule, and no central brain — which is exactly why the arrows, not the boxes, are what you have to design, measure and defend.
Every process owns its state and publishes changes; nobody reaches into anybody else’s memory. The venue owns the book and the authoritative portfolios. Your bot owns its strategy state and a cache of the book. When those two disagree, the venue is right and you have a bug — that asymmetry is the whole reason the design works.
A polling loop asks “anything new?” on a schedule you invented. On average it is half an interval late; at worst a full interval; and if two things happen inside one interval it sees one of them. An event-driven handler is woken by the kernel when the data is actually there.
Part 3 makes both of those claims measurable. Polling is not slower in some vague way — it adds a known, quantifiable delay you did not need to pay.
The five processes of a live session and the messages between them. Click any box — or tab to it and press Enter — to see what it owns, what it sends, which file it lives in, and how it fails. Then filter the arrows down to one flow.
The central limit order book is the one piece of this system with no I/O, no clock and no opinions — which is why it is the piece that must be exactly right. Two sorted queues and one rule: best price first, then earliest arrival. Post orders below and watch the rule decide who gets filled.
# shared/orderbook.py — no sockets, no clock, 100% testable def add_order(self, o: Order) -> list[Trade]: book = self._asks if o.side == 'buy' else self._bids trades = [] while o.remaining and book and self._crosses(o, book[0]): r = book[0] # best price, earliest arrival qty = min(o.remaining, r.remaining) trades.append(Trade(price=r.price, qty=qty)) # RESTING price o.remaining -= qty; r.remaining -= qty if not r.remaining: book.pop(0) if o.remaining and o.order_type == 'limit': self._rest(o) # market/ioc remainder is cancelled return trades
Here is the habit this course is really about. You will be asked, every week from now on,
what your p50 and p99 are — and you will never again be allowed to report a mean. Add work to
on_tick below and watch the two numbers move by completely different
amounts.
| Stage | Typical | Owner | Can you fix it? |
|---|
on_tick?# The only instrumentation you need, and it is four lines. t0 = time.perf_counter_ns() sig = self.on_tick(market, portfolio) t1 = time.perf_counter_ns() self.hist.append((t1 - t0) / 1e6) # ms, per tick # At session close, print the percentiles — never the mean. p50, p99 = statistics.quantiles(self.hist, n=100)[49::49]
Same market, same twelve book updates. The top lane polls on a fixed interval; the bottom lane is woken by the kernel when the bytes arrive. Step through and watch the delay bars — and watch the polling lane miss updates entirely when two land inside one interval.
These are the five that cost marks in week 3, every time. Open them.
A pipeline that averages 3 ms against a 5 ms deadline sounds healthy and can still miss one deadline in fifty. The mean is a summary of the cases that went fine; the deadline only ever sees the cases that did not.
The second half of the same mistake is a percentile computed over the wrong window — a p99 across the whole session hides a two-minute period where it was p50. Report percentiles per window, and report the max separately, because the max is the one your safety case has to survive.
Fix: keep the raw samples, report p50/p99/max, and plot the histogram. If you only ever look at one number, look at p99.
Your bot keeps a local book so on_tick is fast. Fine — until you miss a snapshot,
mishandle a cancel, or reconnect without re-syncing. Now you are computing a signal from a book that
does not exist, and the venue will happily fill you at prices your model says are impossible.
The failure is silent and self-reinforcing: a wrong local book produces confident orders, and the fills you get back look like evidence that the book was right.
Fix: the venue is the source of truth. Reconcile on every snapshot, log the disagreement, and on reconnect throw your local state away and ask for a fresh snapshot rather than resuming.
OrderAck means “accepted, and here is your order id”. It does
not mean you traded. A limit order that rests gets an ack and no fill, possibly
forever. Treating the ack as a fill gives you a phantom position, and every risk check after that is
computed on a number that is wrong.
The inverse also bites: fills can arrive in pieces. One order, four TradeExecution
messages, four different prices — your average entry price is a weighted average, not the last
one you saw.
Fix: position changes only on TradeExecution. Track resting orders
separately from positions, accumulate partials, and reconcile both against
PortfolioUpdate.
on_tickRecomputing a 500-point rolling standard deviation in pure Python, building a
DataFrame, writing a log line to a slow terminal — each is a couple of milliseconds
that used to be a rounding error and is now your whole budget. Worse, each one is
bursty: it is the p99 that moves, not the p50, so a casual look at the average
tells you nothing happened.
Fix: keep incremental state instead of recomputing (a running sum, a ring buffer),
batch your logging, and move anything genuinely heavy off the hot path with
run_in_executor. Then measure again — the histogram in Part 3 is the deliverable
precisely because it makes this visible.
self.bids.append(o); self.bids.sort(key=lambda o: -o.price) is correct and is
O(n log n) on every single order. It also loses time priority the moment two
orders share a price and your sort is not stable in the way you assumed — and time priority is
the fairness rule of the venue, so getting it wrong is not a performance bug, it is a correctness
bug that pays somebody else your fill.
Fix: keep price levels in sorted order and a FIFO queue within each level; insert in the right place instead of re-sorting. Then write the invariant tests: best bid never exceeds best ask, total quantity is conserved across a match, and the earliest order at a price always fills first.
Three questions, instant feedback, nothing recorded.
Q1A sell limit rests at 184.00. Your buy limit arrives at 184.05. At what price does the trade print?
Q2You add a rolling-variance recompute to on_tick. It costs 2 ms, but only on the 1 tick in 20 where a price level changed. What happens to your numbers?
Q3Instead of handling pushed updates, your bot polls the venue for the book every 100 ms. What is the worst-case delay you have added, and what else have you lost?
AI-USE note on each deliverable.make install.