Pick the right data structure, know its cost, and profile the hot path — before the market punishes you for guessing.
cProfile and name the one function that dominates the run.deque and Welford's method.Big-O describes how the work an algorithm does scales with the size of its input, ignoring constants. It answers one question: what breaks as n grows? It deliberately refuses to answer a different one: how many microseconds does this take on my laptop today.
Those are complementary. Big-O tells you which curve you are on; profiling tells you where you sit on it. You need both, and you need them in that order, because you cannot optimise your way out of the wrong curve. Complexity is a design decision made when you choose the container — not a tweak you apply afterwards.
The ladder below is the whole vocabulary. O(1) is a dict lookup or a deque append: it does not care
about n. O(log n) is a binary search or a heap push — doubling
n adds a single step. O(n) is scanning a list. O(n log n) is a sort, the realistic floor
for comparison sorting. O(n²) is a nested loop over the data: perfectly fine at
n = 100, and fatal at n = 10⁶.
| Class | ops at n | ops at 2n | × |
|---|
Constants still matter. O(n) with a tight inner loop can beat O(log n) with heavy per-call overhead at small n — but n grows, and the curve always wins eventually.
Worst versus average. A dict is O(1) average and O(n) worst under pathological collisions. You design for the common case and you guard the tail.
Amortised cost is the honest average over a sequence. list.append is O(1) amortised: the
occasional O(n) resize is averaged over many cheap appends. Judge the workload, not the worst single call.
Time complexity is what you usually optimise in a hot path — cycles are the scarce resource per tick.
Space complexity is how much memory grows with n. A cache or lookup table buys speed with memory: the classic space-for-time trade.
In a live market that trade is usually worth taking. Memory is cheap and refills overnight; a tick you were too slow to price is gone forever.
Know the cost of the operation you perform most often, then pick the structure that makes that one operation cheap. Everything else is negotiable.
| structure | access | search | insert | delete | the question it answers |
|---|---|---|---|---|---|
| list / array | O(1) idx | O(n) | O(n) mid | O(n) mid | ordered sequence; append is O(1) amortised |
| dict | O(1) key | O(1) avg | O(1) avg | O(1) avg | “what is X?” — order id → Order |
| set | — | O(1) avg | O(1) avg | O(1) avg | “have I seen X?” — dedup seen ids |
| deque | O(n) mid | O(n) | O(1) ends | O(1) ends | “last k things?” — maxlen is a free window |
| heap / pq | O(1) min | O(n) | O(log n) | O(log n) | “best of many?” — best bid/ask, top-k |
| binary search | O(log n) | O(log n) | — | — | lookup in a sorted sequence |
heapq is a min-heap over tuples — negate the key for a max-heap.
That single detail is why the bid side of a book stores -price.An order book must answer “what is the best price?” constantly, and it must match by
price–time priority: best price first, and within a price, earliest arrival first. Every design decision
in shared/orderbook.py follows from those two sentences.
The naive answer is a list of resting orders that you scan to find the best bid and ask. That scan is O(n), and you do it on every single incoming order. Book depth is not small and it grows during exactly the volatile moments when you need speed most.
The arena's answer is a min-heap plus a dict. The heap gives O(log n) insert and O(1) peek at the extreme
— exactly the operation repeated most. The heap is keyed on the tuple
(price, seq, order_id): price orders the book, seq breaks ties by strict
arrival order, and the id is the final tiebreak. It is seq and not a timestamp because
floats tie and a monotonic counter never does — that is what makes time priority true rather than
approximate.
The last trick is where students get surprised. A dict of live orders is the canonical source of
truth; the heaps are only a fast index into it. Cancelling deletes from the dict and leaves the heap entry
behind — lazy deletion — so cancels are O(1) and stale entries are discarded when they surface at
the top. Reads iterate the canonical set, so a lazily-stale heap entry can never leak into an answer.
on_tick fires on every price update. If it recomputes a mean and a volatility over the full window
each time, the per-tick cost is O(k) and the session cost is O(n·k) — the classic O(n²) trap when
the window is the whole history. The fix is to keep running state instead of rescanning: a running sum gives
an O(1) mean, and Welford's method gives mean and variance in one pass, in constant time, without ever
storing history.
Welford is not just faster, it is more trustworthy. The textbook E[x²] − E[x]² shortcut subtracts two large, nearly equal numbers and loses catastrophic precision on price levels far from zero — and every equity price is far from zero. Welford never forms the sum of squares at all.
# O(1) per tick, numerically stable class Welford: def __init__(self): self.n=0; self.mean=0.0; self.m2=0.0 def update(self, x): self.n += 1 d = x - self.mean self.mean += d / self.n self.m2 += d * (x - self.mean) @property def var(self): return self.m2/(self.n-1) if self.n>1 else 0.0
Put the two together and on_tick becomes cheap and flat for the whole
session. This is the pattern to copy into your bot this week:
class MyBot(Trader): def __init__(self): self.win = deque(maxlen=50) # O(1) window, free eviction self.stat = Welford() # O(1) mean + variance def on_tick(self, market, portfolio): px = market.mid('AAPL') self.win.append(px) # O(1) self.stat.update(px) # O(1) z = (px - self.stat.mean) / (self.stat.var**0.5 or 1) if z < -2: return Signal('AAPL','buy',5,px) return None
Prices move while you compute. A signal that takes 50 ms to produce is a signal acting on stale data — the edge you detected has already been taken by someone whose loop was tighter. Complexity in a hot path is not an engineering nicety; it is the difference between providing liquidity and donating it.
There are two distinct questions and they pull in opposite directions. Latency is the time for one operation, end to end — and for a trader, latency is edge. Throughput is operations per second — and for an exchange, throughput is capacity under load. Batching raises throughput and adds latency. Tune for the role you actually play.
Then there is the part that averages hide. A mean of 1 ms with a p99 of 200 ms means one tick in a hundred is catastrophic — and it will be one of the volatile ticks, because that is when queues are deepest and the book is thickest. Report p50, p95 and p99. The p99 is what gets you picked off during a shock.
Know your ceiling. At a five-second Yahoo poll you have enormous slack; inside a shock burst, when a hundred messages land in the same second, you do not. Measure the p99 rather than the mean, and sit well under the ceiling with room to spare.
In the arena this is directly scored: exchange/scoring.py ranks on risk-adjusted return, and a
bot that goes blind for 200 ms during the shock is the bot that takes the drawdown.
1. The right structure. The biggest lever by an order of magnitude. oid in seen_set is
O(1); oid in seen_list is O(n).
2. Hoist allocation out of the loop. Allocate the deque once and reuse it per tick.
3. Bind methods to locals in a tight loop to skip the attribute lookup.
4. Vectorise batch maths with numpy where it fits — batches, not per-tick scalars.
5. Then stop. Do not tune cold code.
Every one of these has been submitted, defended in class, and quietly walked back. Read them before you write your benchmark, not after.
A single call is pure noise: imports, cold caches and the interpreter settling all land inside your one measurement. Then the arithmetic mean drags in every unrelated OS hiccup that happened during the run, so the number you publish describes your machine's mood rather than your code.
The fix. Warm up and discard the first runs. Loop enough that the total sits well
above timer resolution. Repeat the whole measurement and report the minimum, noting the spread —
the minimum is the least-contaminated run. Use timeit for micro-comparisons and
time.perf_counter around a block in situ.
Shaving 15% off an O(n²) loop feels productive and accomplishes nothing: double the data and you have given the 15% back with interest. Local-name binding and loop unrolling are constant-factor tools, and constants cannot rescue you from the wrong complexity class.
The fix. Fix the curve first — change the data structure — then chase constants if the profile still says so. Algorithm beats micro-tuning, every time.
Programmer intuition about where time goes is famously, reliably wrong. The satisfying function to optimise is rarely the expensive one, and a function called twice a session is worth exactly zero seconds of your attention no matter how ugly it is.
The fix. Profile before you touch anything. cProfile gives call counts
and cumulative time per function and will name the dominator for you. Spend cycles only where they are
multiplied by millions of ticks. Measure, do not guess.
Appending every price to a list and calling sum(hist[-k:]) / k in on_tick looks
harmless and correct. It is correct. It is also O(k) per tick, which makes the whole session O(n·k), and
the slice allocates a fresh list on every single tick inside your hot loop.
The fix. Keep running state instead of history: a running sum for the mean, Welford
for the variance, and deque(maxlen=k) so the oldest observation evicts itself for free. Per-tick
cost stops growing with the session, and allocation leaves the loop.
A laptop with nothing else running, thermal headroom and a warm cache is not the server your bot competes on. Absolute microsecond figures measured there do not transfer, and a result that only reproduces on your hardware is not a result.
The fix. Compare relative numbers — before versus after, structure A versus structure B — on the same machine in the same session. Pin your seeds so the workload is identical across runs, and state your environment when you report. Relative speed-ups travel; absolute ones do not.
Instant feedback, nothing recorded. Pick the best answer.
shared/orderbook.py, cancelling an order removes it from the _orders
dict but leaves its entry in the heap. Why is that correct?on_tick averages 1 ms but has a p99 of 200 ms. Why is this worse than a flat
5 ms every tick?Deliverable: an algorithmic improvement plan for one hot path, backed by a before/after microbenchmark. Graded 40 / 30 / 20 / 10 — improvement plan with correct complexity analysis, benchmark method and numbers, one O(n)→O(1) or O(n²)→O(n log n) improvement actually landed, and a clean branch with a readable write-up.
make install.Reading before next class: shared/orderbook.py — the
heap-plus-dict CLOB with lazy deletion, exhaustively covered by tests/test_orderbook.py because the
book is the source of truth.