// Design & architecture
DepthCharge
A desk-top hardware market-data terminal: an ESP32-S3 driving a 64×64 HUB75 LED matrix behind smoked acrylic, rendering a live limit-order-book ladder from three venues through one portable C++20 book engine — with every behaviour proven on a desktop replay harness before it touches the panel.
ARCHITECTURE.md (the constitution: the event contract, the book, the frozen invariants and the decisions behind them), README.md (what it is and how to run it) and ROADMAP.md (milestones and status). Those repo documents are authoritative; this page consolidates them into one read. Status here reflects M3 stage A. For the mechanism underneath — the classes that exist today, what a call does in order, what each type costs in bytes, and where the design is under strain — see the companion detailed design page, which renders the repo's docs/DESIGN.html.What DepthCharge is
DepthCharge is an order book you can glance at. A 64×64 RGB LED matrix sits behind smoked acrylic — so the panel vanishes into black when idle — and renders a live ladder: asks stacking red downward into the touch, bids green below it, bar length carrying resting size, the spread showing as the dark gap between the two, trade prints flashing white where they land, and a last-price sparkline running along the bottom. A rotary encoder on the edge cycles symbol, venue and price zoom.
The last of those visual states is the one the whole design is organised around. When the data cannot be trusted — the socket dropped, a checksum failed, a sequence hole opened — the panel greys out and names the reason. An object that lives permanently on a desk is read in a glance, and a ladder frozen on last-known-good is indistinguishable from a quiet market. That is the single output DepthCharge refuses to produce.
DepthCharge is the consumer side of a wire whose producer side is Anvil, my own C++20 matching engine. The interface between them is Anvil's versioned protocol document, vendored into this repo as a snapshot — and DepthCharge v1 requires zero changes to Anvil. That is the point of building it as a separate repository: it makes DepthCharge the protocol's second independent client, which is a materially different claim from two programs sharing a header.
engine/ is portable C++20 with no I/O, no ESP-IDF and no FreeRTOS in it, so the identical adapter and book code runs under a desktop replay file and on the ESP32 — the seam that lets every behaviour be proven at the desk before it reaches the panel.Three venues, in ascending order of wire difficulty
The venue order is a curriculum, not a preference. Each one adds exactly one hard problem to the previous, and each is absorbed by an adapter rather than by the book.
Crypto venues, specifically, for three reasons that all point the same way: markets that run 24/7 suit an object that is permanently powered on a desk; L2 depth is free and unauthenticated, so no credentials ever live on the microcontroller; and Anvil's order flow being synthetic is irrelevant to correctness, because the panel consumes wire semantics rather than market truth.
The one boundary type
Three venues means three JSON dialects, three sequencing schemes and one checksum. All of it is quarantined inside the adapters, because FeedEvent is the only type permitted to cross from an adapter into the engine. The book is written once, against one vocabulary.
using PriceTicks = int64_t; // price in integer ticks; per-symbol tick size
using Qty = int64_t; // quantity in integer venue steps; per-symbol step
using Seq = uint64_t; // adapter-normalised, monotonic per (venue, symbol)
enum class Side : uint8_t { Bid, Ask };
enum class GapReason : uint8_t { SeqGap, ChecksumFail, Disconnect, Overflow, Resync };
struct BookLevel { PriceTicks px; Qty qty; };
// Borrowed view of a Snapshot's levels; valid only for the duration of the
// sink call that delivered the event. A consumer that defers copies.
struct LevelSpan { const BookLevel* data; uint32_t size; };
struct FeedEvent {
enum class Kind : uint8_t { Snapshot, Delta, Trade, Gap };
Kind kind; Seq seq;
PriceTicks px; Qty qty; Side side; GapReason reason;
LevelSpan bids, asks; // Snapshot only
};Four rules give that struct its shape:
- Integers, never floats. Prices and quantities are scaled by a per-symbol
tick_sizeandqty_step. Exact integer equality is the whole point — keys never drift and replays stay deterministic. Where a venue publishes no such metadata (Anvil does not), DepthCharge declares it in aSymbolSpecand the adapter verifies every wire price is exactly representable at that scale. A mismatch is a reported error, never a silent rounding. Floats appear only at the display-formatting edge. It is Anvil's rule, inherited verbatim. - Snapshot replaces; Delta amends. A
Snapshotdiscards all prior levels for the symbol. Depth beyond the venue's stated N is unknown, not zero — a distinction the book keeps even though the panel can only draw the top of the book. - Snapshot levels are borrowed, not owned. A snapshot conveys its levels as two
LevelSpans pointing into adapter-owned staging storage, valid only for the duration of the sink call that delivered them. That keepsFeedEventflat, trivially copyable and allocation-free while still being the only boundary type. The cost is an explicit lifetime rule: a consumer that defers must copy. - Seq is the adapter's problem. Each adapter normalises its venue's native scheme into one monotonic sequence per stream. Binance brackets with its
U/ufields; Kraken has no sequence at all, so its adapter synthesises one and converts a CRC failure intoGap{ChecksumFail}. Anvil turned out to need the same treatment, for a reason worth its own section.
What the wire actually did
The constitution was written before the first capture, and the first capture amended it twice. Both amendments came from measuring a real venue rather than reading its documentation, which is the argument for capturing traces before writing an adapter against them.
Anvil's sequence numbers do not sequence
Every Anvil frame carries a seq, and the original design read it as a per-stream ordering guarantee. It is not: it is a single global engine counter shared across every ticker and every frame type, so any one socket receives a sparse — and non-monotonic — subsequence of it. The M0 capture measured 42 backward steps in five minutes of entirely healthy data, with no reset across a reconnect. An adapter written to the documented intent would have declared a gap roughly nine times a minute and greyed the panel on a feed that was working perfectly.
So the Anvil adapter synthesises its own Seq from receive order and never raises Gap{SeqGap} at all. That is safe for exactly one reason, and it is worth being explicit about it: Anvil's snapshot and book frames are idempotent full replaces, so a missed frame costs one stale render tick, not a corrupted book. The same shortcut would be indefensible on a delta venue, where a missed frame silently corrupts the book — which is why Kraken's adapter leans on its CRC32 over the top ten levels and Binance's on real U/u bracketing.
Absence of data is the only disconnect signal
Anvil emits no gap or error frame of any kind, so Gap{Disconnect} has to be synthesised transport-side: either a socket close, or an RX watchdog whose timeout clears the venue's worst healthy inter-frame gap by a comfortable margin. Pinning that rule in the constitution — rather than leaving it to each implementation — is what makes the host replay and the eventual firmware transport provably the same contract. The replay reads the same rule off the captured rx_ns timestamps.
One boundary case had to be pinned rather than inferred, and it is a nice illustration of where a replay stops being a simulation. A capture that ends with thirty seconds of silence replays as live right up to the last frame, because the watchdog is edge-triggered by the arrival of the next frame and there is no next frame — while the panel, which has a clock, would have greyed. A file has no now, and the capture tool does not record when it stopped listening, so the replay genuinely cannot tell a dead feed from a finished recording. The rule is therefore that trailing silence is reported, never inferred: the end of a trace is the end of the timeline unless a caller that knows better says otherwise. It is off by default, so no committed golden moves.
Gap event and greys the panel until a fresh Snapshot lands. A ladder frozen on last-known-good is indistinguishable from a quiet market, which is the one output the design refuses to produce. Dashed is a reason nothing raises yet — three of the five are waiting on a delta venue, because the vocabulary is deliberately wider than the one venue that exists so far. Note what is not in this figure: the render hand-off. It drops superseded frames silently, because each one is a whole book state and the next is already on its way, so Overflow is feed-side by construction and never a hand-off event.The book engine, built in two stages
The target design is a tick-indexed dense window: a contiguous array of quantities addressed by (px − anchor) over the hot band around mid, re-anchored by a bounded copy when the touch drifts out of the window, with a cold tail in a map for levels outside it. On the ESP32 the window lives in internal SRAM and the tail in PSRAM; on the host it is all plain heap. It is deliberately the consumer-side twin of the windowed dense array named in Anvil's own future work.
None of that is built yet, on purpose. Phase 1 targets Anvil only, and a snapshots-only venue needs no book maintenance at all — adopt the latest snapshot, keep a ring of recent trades, and that is the engine. The dense window lands with the first delta venue at M4, when there is finally something to justify it. Building it earlier would have meant maintaining an unexercised data structure through three milestones of unrelated work.
The output is a DisplaySnapshot: top ~27 levels a side, a recent-trade ring, last price, symbol id, and a status of Live | Stale(reason). It reaches the render side through SnapshotChannel — declared in snapshot_channel.hpp — a wait-free single-producer/single-consumer mailbox holding three snapshot slots and one 32-bit atomic word. publish and consume each swap their own slot into that word with a single exchange, so the writer's slot, the reader's slot and the ready slot are always three distinct objects — the three indices are always a permutation of {0,1,2} — and neither task is ever inside the frame the other is using. That is what lets publish be a fixed-size copy plus one atomic operation: no loop, no lock, and nothing the render task can hold that the feed task has to wait for. On a microcontroller a render stall is the common case rather than the rare one.
That is a shipped and proven mechanism, not design intent — and it is deliberately neither a seqlock nor a two-slot double buffer. Both let the reader copy a slot the writer is writing and then discard the result: the tear happens, and the version check only stops it being drawn. The seqlock was not dismissed on theory but built and measured — one of exactly that shape delivered 4.8 million frames with zero tears reaching the consumer, and ThreadSanitizer flagged it on the first frame. So the objection was never that it misbehaves on today's compiler — it is that the copy is a data race, and the only way to keep both a seqlock and a clean sanitiser report is a suppression sitting on the single cross-core path in the project, on a target compiler generation nobody here controls. Two slots need no experiment to rule out: the reader is holding one, so a writer alternating across two must eventually land on the one being read. The third slot costs 2,352 bytes and removes the possibility rather than detecting it after the fact.
The evidence is committed alongside it: test_snapshot_channel.cpp races two threads over 100,000 frames whose every field is stamped from the frame's own version — so a delivered frame is either whole or provably spliced, and the checker names the field where it split — and harness/tsan.sh produces the committed clean ThreadSanitizer report. Invariant #4 moved from promise to measurement.
DisplaySnapshotis sized by the panel, not the venue: roughly 27 levels a side is what remains once the header, the spread band and the sparkline strip have taken their rows. Depth beyond the venue's stated N is unknown, not zero — the book keeps the distinction even though the panel can only draw the top of it.The frozen invariants
Eight rules are marked in the constitution as not refactorable through. They exist because the work is executed by a series of independent agentic sessions, each of which reads the constitution and none of which can see the others' reasoning — so the properties that must survive contact with a fresh context are written down with their justification attached, not left to be inferred from the code.
- engine/ builds on the host with zero ESP-IDF, FreeRTOS or Arduino includes — and, since M3, for the target too. Every line of book logic has to be exercisable by ctest on the desk; this is the seam that made Anvil's demo layer cheap to build, inherited deliberately. The host check proved the engine builds on the host, and that was quietly read as proving it builds — which does not follow. A generated per-header translation unit is now also compiled with the PlatformIO xtensa toolchain, which pins what the engine may use: today that is GCC 8.4, so no
<span>,<ranges>,<concepts>or<bit>, no floating-pointfrom_charsand no constexpr algorithms. All eight engine headers pass, and the hot path costs 1,541 bytes of .text at -Os. - FeedEvent is the only type crossing adapter → engine. Venue mess — JSON dialects, sequence schemes, checksums — stays quarantined; the book is written once against one vocabulary.
- Integer ticks and steps everywhere; no floating point ever touches book data. Exact equality, no key drift, deterministic replay. Floats may appear only at the display-formatting edge.
- The feed task is never blocked by the render task. The hand-off is a wait-free latest-value mailbox: publishing never waits on the render task, and a superseded
DisplaySnapshotdrops silently. Silently is correct, because it is lossless at the book level — every published frame is a complete render state rather than a delta, so a render side that skips v10 to v13 loses no book information, only intermediate trade-ring samples, which are best-effort.Gap{Overflow}is therefore a feed-side signal, reserved for a venue reassembly buffer at a delta venue, and is correctly raised nowhere in the Anvil-only phase. Per-event cost stays bounded independent of consumer speed. - Stale is a first-class rendered state. Any gap, disconnect or resync greys the panel until a fresh snapshot. A frozen ladder that looks live is the one unacceptable output — the entire honesty of the object depends on it.
- No feature merges without replay coverage. New adapter behaviour or book logic ships with a trace and a golden expectation. Multi-session agentic work converges only when red and green are objective.
- Allocation-free steady state. After connect and first snapshot, the feed-to-render path performs no heap allocation — for determinism, for embedded heap health, and to keep host benches honest about target behaviour.
- One writer per state. Only the feed task mutates the book; only the render task reads the display snapshot. No third participant, and no locks around the book itself.
Decisions already made
Where the code lives
The layout enforces the seam. Transport — sockets, TLS — lives outside the engine; adapters accept received frames as bytes and emit events, so the identical adapter logic runs under a Python-captured replay file, a host WebSocket client, or the ESP-IDF WebSocket client on the target.
How the work converges
DepthCharge is built by agentic sessions against a constitution. Each milestone gets a disposable brief in docs/briefs/ that specifies the work; the constitution is what every session reads first and must not violate; and the definition of done is the harness going green. That only functions if red and green are objective, which is where the strongest rule comes from: no feature merges without replay coverage. New adapter behaviour or book logic ships with a captured or synthesised trace and a golden expectation, or it does not ship.
The console ladder is what makes that legible. dc_ladder replays a captured trace through the real engine — adapter, book, DisplaySnapshot, renderer — and draws the ladder in a terminal, optionally paced by the capture clock. The trace worth watching is the reconnect one: 382 frames in, the feed goes quiet for 4.5 seconds, the ladder greys with a STALE — disconnect banner, and the resync snapshot brings it back. That is the panel's most important behaviour, demonstrated months before there is a panel.
Two tracks run in parallel and share no dependencies: the agentic software track, and a bench track where the soldering iron, KiCad and the printer are the tools. M1 and M2 could not block each other if they tried, which is what keeps the project moving in evening-sized pieces.
Where it is now
Explicitly out of scope for v1: order entry of any kind, historical persistence, more than three venues, any web UI — Anvil already has one — and battery power. Deliberately left unspecified, for sessions to decide and briefs to record: file decomposition inside the engine, class internals, and console renderer aesthetics. The constitution draws the line between what must not drift and what nobody should be arguing about.
← BACK TO
DepthCharge