Week 6 · Performance

Algorithmic Complexity
& Data Structures

Pick the right data structure, know its cost, and profile the hot path — before the market punishes you for guessing.

3 hours Fri Nov 6 · live block: season week 6 Big-O dict · deque · heap Welford O(1) shared/orderbook.py p99 tail latency
What is it?

Big-O is a statement about growth, not a stopwatch

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⁶.

The Big-O race

Widget 1 · drag n
1 ms · 25,000 ops
inside budget at the edge blown — you are trading stale prices 1 ms budget marker

Double n and see who notices

Classops at nops at 2n×
Wall times assume a flat 40 ns per elementary operation — roughly interpreted Python. The constant is a guess; the shape is not. Switch to the linear axis to see why O(n²) is a different kind of animal, then switch back to log to read all five at once.

Reading Big-O correctly

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 versus space

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.

Choosing the container is choosing the complexity

Know the cost of the operation you perform most often, then pick the structure that makes that one operation cheap. Everything else is negotiable.

Core structures & their costs

the four that carry trading code
structureaccesssearchinsertdeletethe question it answers
list / arrayO(1) idxO(n)O(n) midO(n) midordered sequence; append is O(1) amortised
dictO(1) keyO(1) avgO(1) avgO(1) avg“what is X?” — order id → Order
set—O(1) avgO(1) avgO(1) avg“have I seen X?” — dedup seen ids
dequeO(n) midO(n)O(1) endsO(1) ends“last k things?” — maxlen is a free window
heap / pqO(1) minO(n)O(log n)O(log n)“best of many?” — best bid/ask, top-k
binary searchO(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.
How does it work?

The order book is a data-structure problem

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.

Order-book structure explorer

Widget 2 · same command stream, two structures

The book — canonical state (the dict)

bids asks best bid / best ask

Operation log

Cumulative elementary operations

heap + dict
0
sorted list
0
resting orders
0
book depth n
ops this action
—
heap+dict / list
list overhead
—
× the heap design

Bid heap — top entries (-price, seq, id)

Costs are counted as elementary operations, not seconds. heap + dict: one dict write plus ⌈log₂n⌉ sift steps on insert, a single dict delete on cancel (lazy), and a sift per stale entry discarded at the top. sorted list: a binary search to find the slot plus the O(n) shift to make room, and a linear scan plus shift to cancel. Push the depth up and watch only one of the two counters care.

The other hot path: statistics that recompute themselves

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.

Rolling statistics: full recompute vs Welford

Widget 3 · same numbers, different curve

Cumulative operations across the session

naive full-window recompute — O(k) per tick deque + Welford — O(1) per tick
naive
—
O(1) rolling
—
ops / tick naive
—
grows with k
ops / tick O(1)
—
flat, forever
speed-up
—
total ops ratio

Both methods agree — that is the point

mean — naive rescan— mean — Welford— variance — naive rescan— variance — Welford— absolute disagreement—

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
Same 40 ns per operation. The naive lane charges 2 × min(t, k) operations per tick — one pass for the mean, one for the variance — while the rolling lane charges a constant four. Nothing about the answer changes; only the curve does.

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
Why does it matter in trading?

Slow code is not a style problem. It is a P&L problem.

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.

Ticks per second you can sustain — and the tail that kills you

Widget 4 · pick a structure, then hit it with a shock
service cost
—
per message
sustainable
—
ticks / second
utilisation ρ
—
arrival / capacity
mean
—
looks fine
p50
—
median tick
p95
—
1 in 20
p99
—
this is the one
response-time distribution p50 p95 p99 past the 1 ms budget — picked off
A single-server queue: each message costs ops × 40 ns to service, and response time inflates by 1/(1−ρ) as arrivals approach capacity. That is why the tail explodes long before the mean does — and why “it was fast on my laptop” is not a defence. Samples are drawn from a fixed seed, so the picture is reproducible.

Budget per tick, tail included

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.

The levers, in order of payoff

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.

Common mistakes

Five ways this week goes wrong

Every one of these has been submitted, defended in class, and quietly walked back. Read them before you write your benchmark, not after.

Benchmarking once, cold, and reporting the mean

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.

Micro-tuning a function that is on the wrong curve

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.

Optimising code that runs twice per session

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.

Keeping full history so you can “recompute properly”

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.

Trusting one machine, and one run, on an idle laptop

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.

Check yourself

Three questions

Instant feedback, nothing recorded. Pick the best answer.

Question 1
In shared/orderbook.py, cancelling an order removes it from the _orders dict but leaves its entry in the heap. Why is that correct?
Question 2
Your teammate replaces an O(n) list scan with an O(log n) structure and the benchmark gets slower at the book depth you actually run. What is the most defensible reading?
Question 3
Your on_tick averages 1 ms but has a p99 of 200 ms. Why is this worse than a flat 5 ms every tick?
Take it further

This week's work

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.

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.