WEEK 9 · CI/CD, TESTING & UI + SEASON FINALE

CI/CD, Testing & UI — then the Season Finale

One evening, two parts. Part 1 makes your bot trustworthy: tests that pin its behaviour, a pipeline that refuses to merge red code, an image that runs identically anywhere, and a dashboard that tells you it is alive. Part 2 connects everything: three independent processes run as one market, the bugs that live in the seams come out, and the leaderboard locks on risk-adjusted return.

Friday Dec 4 · the live block is the season finale: season weeks 9 and 10 back to back (25 + 35 minutes). Jump to Part 1 · CI/CD, Testing & UI or Part 2 · Integration & Demo.

Part 1

CI/CD, Testing & UI

Proving it works, everywhere, every time — before the finale proves it for you.

What is it?

Production engineering: proving it works, everywhere, every time

Three habits, one goal — your bot behaves the same on your laptop, in CI, and on the live arena at 6:05pm with the class watching the leaderboard.

A test is an executable claim about your code. pytest discovers every function named test_*, runs it, and reports which claims still hold. That is all. The engineering judgement is not in writing asserts — it is in choosing which claim to make at which layer.

The pyramid is that judgement made visible. Many unit tests at the base, each pinning one function in milliseconds with no I/O. Fewer integration tests, wiring two or three components together with fakes instead of the network. A handful of end-to-end runs that drive the whole path — bot to exchange to fill to P&L — and catch the wiring bugs nothing else can see, slowly and a little flakily.

Push logic down. A bug caught by a unit test costs you seconds; the same bug caught end-to-end costs a minute of CI; the same bug caught live costs you a session, in public.

# tests/test_signal.py
from strategy import momentum_signal

def test_momentum_goes_long_on_uptrend():
    prices = [100, 101, 102, 103, 104]
    sig = momentum_signal(prices, lookback=3)
    assert sig.side == 'buy'
    assert sig.qty > 0

def test_momentum_flat_when_no_trend():
    assert momentum_signal([100, 100, 100], 3) is None
Name the test after the behaviour it pins. When it goes red six weeks from now, the name is the whole bug report. One behaviour per test, and @pytest.mark.parametrize for the variations.

The platform is built on its tests

~1,280

Why we can change the engine under you every week without breaking your bot.

100% on the source of truth
orderbook.py
The matching engine gets zero slack: a wrong fill corrupts every downstream P&L, leaderboard row and tearsheet.
Round-trip message tests
dump → parse
Every wire message in tests/test_messages.py is dumped to JSON, parsed back, and asserted identical. The schema is the contract.
Network-free simulator
sim_session
A full season in one process with no sockets — deterministic, CI-friendly, and the model for your own tests.
Part 1 · Widget 1

Test-pyramid explorer

Click a layer for an example test against a trading bot. Then inject a bug and see which layer would have caught it — and what the delay cost.

The layer is a cost decision. Every bug is catchable somewhere; the question is how many minutes — or how many sessions — pass before you find out. Pick the cheapest layer that can see it.
How does it work?

A pipeline is a gate, and determinism is what makes it mean something

Continuous integration runs your suite on every push, on a clean machine that is not yours. Continuous delivery ships what passes. The merge stays blocked until the checks are green.

A GitHub Actions workflow is YAML in .github/workflows/. An event (on: [push]) triggers jobs; each job runs ordered steps on a fresh runner; a matrix fans the same job out across Python versions. Nothing in it is clever — and that is the point. The runner has no internet by design, no cached state, and none of your local environment. If your suite passes there, it passes anywhere.

on: [push, pull_request]        # trigger
jobs:
  test:                         # a job
    runs-on: ubuntu-latest      # the runner
    strategy:
      matrix:
        python: ['3.11', '3.12']  # fan out
    steps:                      # ordered
      - uses: actions/checkout@v4
      - run: make install
      - run: pytest -q

Reproducibility is the other half. Same code plus same dependencies plus same OS equals same behaviour, so pin exact versions (==, never >=), isolate in a venv, and freeze the whole environment in an image. Order the Docker layers stable-to-volatile so the pip install layer stays cached and only your code layer rebuilds.

# requirements.txt — pin exact versions
websockets==12.0
pydantic==2.7.1

# Dockerfile — freeze the environment
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .         # cached layer
RUN pip install -r requirements.txt
COPY . .                        # code layer
CMD ["python", "-m", "team.trader"]
Part 1 · Widget 2

CI pipeline simulator

Push a commit and watch the gate. Break something and the pipeline stops at the first red stage — nothing downstream runs, and the merge is blocked.

no run yet
Runs
0
pushes to this branch
Red
0
blocked merges
Flaky failures
0
red without a code change
Push a commit to run the pipeline.
Part 1 · Widget 3

Determinism: same input, same result

The same strategy test, run repeatedly. On the left the randomness is seeded; on the right it is not. Both panels overlay every run you trigger.

assertion: final_pnl == 412
random.seed(0)
0 runs
distinct results: —
no seed
0 runs
distinct results: —
Run the test a few times in each mode.
Three sources of nondeterminism, three fixes. Randomness → seed it. The network → inject a fake or a recorded fixture; a CI runner has no internet anyway. The clock and IDs → pass them in, never call datetime.now() deep inside logic. A test that fails one run in twenty trains your team to ignore red, which defeats the entire point of having a suite.
Why does it matter in trading?

Live failures are public, and you cannot fix what you cannot see

A missing dependency, a hardcoded path, an untested edge case — any one of them crashes your bot mid-session while the dashboard shows the whole class your equity curve flatlining.

Shipping is not the end of the job. A running bot needs configuration you can change without a rebuild (ports, poll intervals — plain env vars), secrets that never enter git or the image (tokens injected at runtime), and observability: structured logs for what happened, metrics for what is happening now.

A dashboard is metrics made glanceable, and a trading dashboard has four non-negotiable panes. Positions — what do I own, and is it what I think I own. P&L — realized, unrealized, and the shape of the curve. Risk — how close am I to a limit, and what is my worst case right now. Connectivity — is the socket up, is the feed fresh, am I being throttled. Miss the fourth pane and you will confidently quote on a price that stopped updating four minutes ago.

The arena's own UI is exactly this: teacher/web_dashboard.py with MARKET (live books), FLOW (who trades with whom), STATS and SEASON (per-team analytics and standings). The deploy kit behind it — systemd units, Caddy for TLS, Docker for the environment — is how the hosted arena becomes reproducible from bare metal up.

The healthy baseline

You must know what normal looks like before the incident, or you cannot recognise abnormal during it. Write these down in calm markets.

Feed age< 6 s
Socket stateOPEN
Fills per minute2 – 9
Reject rate< 2%
Position vs limit< 70%
on_tick latency< 8 ms
Rate limits have been enforced since Week 8. A bot that over-sends gets throttled — which shows up first as a rising reject rate, not as an error.
Part 1 · Widget 4

Mini live dashboard

A mock of the four panes, ticking on a seeded feed. Pause it, step one tick at a time, and inject an incident — then ask which pane told you first.

tick 0
Positionsflat
P&L—
realized$0
unrealized$0
net worth$100,000
Riskwithin limits
worst-case loss if flat now$0
Connectivityidle
WebSocketCLOSED
feed age—
msgs / s0
on_tick latency—
rejects0
Nothing is wrong yet. Note what healthy looks like before you break it.
Common mistakes

Five ways a tested bot still dies live

Each of these has cost a team a session. Each expands.

Tests that reach the network
A unit test that calls yfinance is not a unit test: it is slow, it changes every second, it fails on a plane, and it fails in CI where there is no internet by design. Worse, it passes locally, so you only discover it after you push.
Fix: inject a fake feed (class FakeFeed: def price(self, sym): return 185.0) or monkeypatch the call. Use tests/sim_session.py when you need a whole session.
Tolerating a flaky test
One test fails roughly one run in twenty, so the team learns to click "re-run". Now red means nothing, and the day a real regression lands nobody notices. A flaky test is worse than no test because it destroys the signal value of the whole suite.
Fix: treat a flake as a P1 bug. Seed the randomness, freeze the clock, remove the sleep-based timing, or delete the test. Never leave it amber.
Chasing the coverage number
Coverage measures which lines executed, not whether anything was asserted. You can reach 95% with tests that call every function and check nothing. Meanwhile the two branches that matter — the reject path and the reconnect path — are the untested ones, because they are the awkward ones.
Fix: read the report to find the gaps you did not know you had, and hold the source of truth (your signal and risk logic) to a high bar. Do not pad the number with trivial tests.
Unpinned dependencies and "works on my machine"
Your requirements.txt says pydantic>=2. Three weeks later the runner resolves a new minor version, a validator becomes stricter, and your bot cannot parse a message it has parsed all season. The code did not change; the environment did.
Fix: pin exact versions, commit the pin, and containerise. Order Docker layers stable-to-volatile so the dependency layer stays cached and the rebuild only touches your code.
A dashboard with no connectivity pane
Positions, P&L and risk all look perfect — because they are computed from a price that stopped updating four minutes ago. The socket is open, the process is alive, nothing has thrown, and you are quoting into a market that has moved on. Partial failure is the normal failure mode of a live system.
Fix: show feed age, socket state and reject rate next to the money, and alarm on staleness rather than on exceptions. Detect, do not assume.
Check yourself

Three questions

Instant feedback, no grading.

Question 1
Your on_tick returns a buy signal when it should return None. Which test layer should have caught it, and why that one?
B. Arrange a fake market and a portfolio, call on_tick, assert the Signal (or the None). Pushing this claim down to the base of the pyramid is what makes it cheap enough to run on every push.
Question 2
A test in your suite fails about one run in twenty, always with a different P&L. What is the correct response?
D. The other three all preserve the flake and destroy the signal. Seed the RNG, inject the clock, fake the feed. With the seed fixed, a red test fails the same way every time — which is the only reason a failure is debuggable.
Question 3
Mid-session, your dashboard shows a flat P&L, no rejects, positions unchanged, and a socket in OPEN. What is the first thing you check?
A. This is the classic partial failure: the transport is fine, the data is dead. Everything downstream still computes, confidently, from a frozen price. That is why the connectivity pane sits next to the money, and why staleness is an alarm rather than an exception.
Part 2

Integration & Demo — Season Finale

Nothing new to build; everything to connect. Tonight the leaderboard locks.

What is it?

Integration correctness is a different claim from unit correctness

Every part passing its own tests does not mean the system works. Bugs live in the seams between components that are each, individually, correct.

Unit correctness means each object honours its own contract in isolation — roughly 1,280 tests do that for the platform. Integration correctness means the objects honour each other's contracts when wired together, live, under load. The second does not follow from the first.

The classic seam bugs all have the same shape: two correct components disagree about timing or units. The signal fires faster than risk can veto it. A fill arrives before its ack. Two messages race on the same book. One side sends a price as a float, the other rounds to ticks — and both are right by their own spec.

In AlgoArena the seams are explicit, which is the whole reason the wire protocol is Pydantic. Three processes speak only models from shared/messages.py; if a message round-trips, the seam holds. So test the conversation, not just the speakers.

The five pillars, already built

This week each maps to one live component and they all run on the same tick.

PillarBuilt inRuns as
SignalW3, W8on_tick()
ExecutionW2, W4quotes + CLOB
RiskW6check_order()
PortfolioW7cash + positions
EvaluationW9scoring.py
The parts have been unit-correct for weeks. The only open question tonight is whether they are correct together.
Part 2 · Widget 1

The integration surface

Click a box for the component, or click an arrow for the seam — its wire contract, the test that pins it, and how it fails at 6:05pm.

6 components · 5 seams
Every arrow is a seam you must test. A smoke test walks the whole path once before you go live: does it start, connect, place one order, get a fill, and score? That catches the obvious 90% — and the finale is not the place to discover the obvious.
How does it work?

Live systems fail partially. Design for degradation.

A live system rarely dies all at once. The socket stays up while the data goes stale; the order is rejected while the process is healthy. Detect, do not assume.

Part 2 · Widget 2

Failure-mode drill

The same failure, injected into two bots. Left: the naive bot that assumes the happy path. Right: the bot that was written for this. Same market, same signal, different survival.

Naive bot—
Robust bot—
Pick a failure and run the drill.
Fail loudly at the seam, fail safe at the edge. Log the anomaly where you detect it, and protect the capital where you act on it. Then write the run-book line: symptom → diagnosis → action. Written cold, used hot — under pressure you follow steps, you do not reason from first principles.
Part 2 · Widget 3

Risk & reconciliation

Your bot's ledger against the exchange's. They must agree at every instant — and the exchange is always the source of truth. Break them, then reconcile.

in agreement
Bot's net worth
—
what your P&L print says
Exchange's net worth
—
what the leaderboard scores
Idempotency is the fix for half of these. Tie every action to an order id so a retry is a no-op instead of a second fill. Reconcile on every reconnect, not only at the end of the session — a break you find at 18:40 is a bug; a break you find at the close is a scoring dispute.
Why does it matter in trading?

Raw return is the most misleading number in finance

The same +12% can be skill or a coin flip that landed right. exchange/scoring.py ranks return per unit of volatility, with a drawdown penalty — announced in Week 1, and it holds tonight.

A tearsheet is one page that answers two questions: did this make money, and how much risk did it take to do it? The equity curve tells the story before any number does — a smooth 20% beats a jagged 40%, because the second is one bad tick from a blow-up. Then the numbers: Sharpe (return per unit of risk), max drawdown (the worst pain an investor had to sit through), Calmar, turnover, exposure.

# exchange/scoring.py  (score_equity)
r      = returns(equity)        # per-snapshot returns
vol    = pstdev(r)              # volatility of the book
mdd    = max_drawdown(equity)   # worst peak-to-trough
excess = mean(r) - RISK_FREE    # above idle cash
ratio  = excess / max(vol, VOL_FLOOR)
score  = ratio * max(0, 1 - PENALTY*mdd)   # PENALTY = 2.0
# ranked PER ROLE: traders vs traders, brokers vs brokers
Excess return over idle cash is why parking your cash and doing nothing scores about zero rather than winning. The drawdown penalty always hurts: a 25% max drawdown halves your score.

How to present and defend it

A great system badly explained loses to an average one told well.

1 · Problemthe thesis, in one line
2 · Designthe trade-offs you chose
3 · Live runit is real
4 · Resultsthe tearsheet
5 · Nextwhat you would fix
Lead with the thesis — "I make markets and earn the spread" — then show the tearsheet that proves it. Numbers support a narrative; they are not one. Be ready for "why this and not that?": every choice, from spread width to stop level, was a trade-off. Name the trade-off. And keep a recorded rehearsal as your safety net for when the demo gods are unkind.
Part 2 · Widget 4

Season scoreboard — same P&L, different rank

All three books finish the season at +12.0%. Move your volatility and your drawdown and watch the ranking rearrange itself around you.

70 bp
8%
Consistency is the signal; luck is the spike. A repeatable edge shows up as a steady curve. Three winning trades prove nothing; a hundred with positive expectancy is an edge. Defend the process, not the P&L — a good process can have a bad week, and a bad process can get lucky.
Common mistakes

Five ways to lose the finale with working code

None of these is a logic bug. Each expands.

Debugging live for the first time
The finale is not a rehearsal. Teams that discover their config, their token or their EXCHANGE_HOST at 6:05pm spend the session reconnecting instead of trading, and the leaderboard does not wait.
Fix: rehearse against the headless sim and then a staging run that mirrors the live setup. Smoke-test the critical path — start, connect, one order, one fill, one score — before every session.
Crashing on ConnectionClosed
A socket blip is normal, not exceptional. A bot that lets the exception escape dies holding a position and becomes a spectator for the rest of the night. Nothing in your test suite noticed, because your tests never dropped the socket.
Fix: catch it, back off exponentially, reconnect, re-sync state from the exchange, resume. Then write the end-to-end test that kills the socket mid-session.
Retries that are not idempotent
The ack was slow, so the bot re-sent the order. Now it has two fills, twice the position it intended, and a P&L that disagrees with the exchange. The "five whys" chain ends at: there was no order id, so a replay was not a no-op.
Fix: tie every action to a client order id, make the handler idempotent, and reconcile against the exchange after any reconnect.
Unbounded queues under back-pressure
Messages arrive faster than on_tick processes them. The queue grows, latency grows with it, and your bot is soon trading on a market that existed thirty seconds ago — while memory climbs.
Fix: bound the queue and shed load deliberately. Dropping a stale book snapshot is correct; acting on it is not. Measure your per-tick budget and stay inside it.
Presenting the P&L instead of the process
"We made 14%" invites the one question you cannot answer: was that skill? Without the risk numbers and the reasoning behind each trade-off, a good result is indistinguishable from a lucky one — and the score is risk-adjusted anyway.
Fix: five acts, timed and rehearsed — problem, design, live run, results, next steps. Lead with the thesis, show the tearsheet, name the trade-offs, and say what you would fix with another week.
Check yourself

Three questions

Instant feedback, no grading.

Question 1
Your risk module and your router both pass all their unit tests, yet in the live session an order goes out that exceeds the position limit. What kind of bug is this?
C. This is the canonical integration failure: both speakers are correct and the conversation is not. Only a test that wires the two together — bot plus book, risk plus router — can see it. Test the conversation, not just the speakers.
Question 2
Mid-session your bot's position ledger says 240 lots of AAPL; the exchange says 300. What do you do first?
A. The venue's book is what settles and what the leaderboard scores; your ledger is a local cache of it. Adopt the exchange's number, re-derive your risk from it, then hunt the missed fill. Option C trades on a phantom position — that is how a reconciliation break becomes a real loss.
Question 3
Two traders both finish the season at +12% net worth. Team A's equity curve is smooth; team B's swings hard and had a 30% drawdown in week 6. How does exchange/scoring.py rank them?
B. ratio = excess / max(vol, VOL_FLOOR), then score = ratio × max(0, 1 − 2·mdd). B's higher volatility shrinks the ratio and its 30% drawdown cuts what is left by 60%. Same P&L, very different rank — the steadiest book wins, exactly as announced in Week 1.
Do the work

Demo day — and this week's work

Part 1 deliverables: a CI pipeline review, a Docker reproducibility sign-off, and your dashboard plan. Part 2 deliverables: the final integration checklist, a tearsheet from the season data, and a recorded demo rehearsal. Lock and tag your final branch — the leaderboard locks tonight.

Part 2 · Widget 5

Demo-day checklist

Tick what is genuinely true. The readiness dial is not a grade — it is the honest answer to "are we ready to run this in front of the class?"