DepthCharge

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

A narrative rendering of DepthCharge's golden-source documents — 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.

// the pipeline
engine/ — portable C++20 · no I/O · no ESP-IDF · no FreeRTOSvenue wsTLS · off-engineVenueAdapterper venueFeedEventthe one boundaryBookEnginewindow + tailDisplaySnapshot3-slot mailboxRendererHUB75 / consoletransporthost buildtarget buildharness/ — desktopreplay file → the same engine → console laddergolden expectations under doctest / ctestbench · captured traces are the ground truththis is where correctness livesfirmware/ — ESP32-S3core 0 · feed task: net + adapter + bookcore 1 · render task: HUB75 DMAlinks engine/ as-is — unchanged from the hostthey meet at DisplaySnapshot and nowhere elseone writer per state · the feed task is never blocked by the render task
One path from wire to pixels. 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.

VenueWhat it publishesThe new problem
AnvilFull top-N snapshots plus per-fill trades, over WebSocketNone — it is the test rig. The only venue controlled end-to-end, so it is where the transport, the book and the panel are shaken out first.
KrakenReal L2 deltasSelf-verification: a CRC32 checksum over the top ten levels. A mismatch is not an error to log — it is a gap, and the panel goes grey.
BinanceBuffered diff stream, bracketed against a REST snapshotSequence-gap recovery: the graduation exercise. Its partial-depth streams are an Anvil-shaped easy mode and may be used as a stepping stone.

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_size and qty_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 a SymbolSpec and 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 Snapshot discards 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 keeps FeedEvent flat, 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/u fields; Kraken has no sequence at all, so its adapter synthesises one and converts a CRC failure into Gap{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 is data, not an error
Gap{Disconnect}transport · socket close or RX watchdogGap{SeqGap}adapter · venue sequence discontinuityGap{ChecksumFail}adapter · Kraken CRC32 mismatch, M4Gap{Overflow}feed · delta-venue reassembly, M4/M5Gap{Resync}book · construction or rejected deltaGapone eventStale(reason)panel greys · banner names itLiveladder tracks the bookGap ⇒ staleSnapshot ⇒ liveno venue is required to send one:Anvil emits no gap frame at all, soGap{Disconnect} is synthesisedtransport-side, by close or watchdogstale until the next Snapshot
Every way the feed can stop being trustworthy — a dropped socket, a checksum mismatch, a sequence hole, a reassembly buffer overrun — arrives at the book as the same 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.

// the display budget — 64 rows
headersymbol · last px · tick directionasks — 27 levelsred, deepest at the top, stacking into the touchthe spreadthe dark gap; trade prints flash white herebids — 27 levelsgreen, best at the top, falling away belowsparklinelast price across the recent window64 rows · 64 columnsbar length = resting size
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-point from_chars and 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 DisplaySnapshot drops 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

DecisionWhy
A separate repo from AnvilThe boundary is a versioned wire contract, not shared code — which is exactly what makes DepthCharge the protocol's second independent client. Build and deploy isolation follows the portfolio-wide one-repo-per-deploy pattern.
One repo for engine + firmware + hardwareFirmware, carrier board and enclosure versions have to travel together (the MorayGlow pattern). What normally makes that hurt is untestable firmware-coupled logic, and the host-buildable engine seam removes it.
Anvil stays untouched in v1The things DepthCharge would like from Anvil — a chaos flag for deterministic gap testing, a sequenced incremental L2 feed — sit on Anvil's backlog as non-blockers. Even the WebSocket Origin allowlist turned out to need nothing: M0 confirmed the deployed upgrade accepts a client sending no Origin header, so neither the capture tool nor the firmware needs one, and a nominated header stays on the shelf as a fallback if that policy ever changes.
Portable C++ first, target secondEvery milestone proves on the host harness before it touches the ESP32. Captured replay files are the ground-truth artefacts; the panel is where working code is displayed, not where it is debugged.
doctest + ctest for the harnessFamiliarity with the Anvil toolchain. Notably, the JSON parsing choice is deliberately not fixed: the harness may use a heavyweight parser, but whatever parses on the hot firmware path has to respect the allocation-free invariant. That is decided in-milestone and recorded in the brief.

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.

PathOwns
engine/Portable C++20 library: types, FeedEvent, the book, and the adapters' logic (frame → events). No I/O, no ESP-IDF, no FreeRTOS.
harness/Host executables: replay runner, golden tests, console renderer, bench.
firmware/PlatformIO project: Wi-Fi, TLS WebSocket transport, tasks, HUB75 driver glue. Links engine/ as-is.
hardware/KiCad carrier board, enclosure CAD and STLs, BOM.
tools/Capture scripts and dev tooling — the one place Python is allowed.
docs/Milestone briefs (the work orders) and vendored protocol snapshots.

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

MilestoneWhat it deliversStatus
M0 · Trace + harnessLive Anvil traces captured and vendored; replay harness parses them; golden-test and CMake skeleton.Done
M1 · Console ladder off replayFeedEvent types real; Anvil adapter; phase-1 book; console ladder renders a replay; goldens green.Done
M2 · Panel smoke testESP32-S3 DevKit, 64×64 HUB75 and PSU wired; the HUB75 DMA demo runs on the bench — pin map, 2.6 A draw and signal integrity recorded.Done
M3 · Live Anvil on the panelFirmware net and render tasks; engine unchanged from M1; the pull-the-Wi-Fi test shows grey, then a clean resync. Stage A — the wait-free SnapshotChannel — is built and ThreadSanitizer-clean.Next · stage A done
M4–M5 · Kraken, then BinanceDelta application with CRC verification (the dense window lands here), then diff-stream bracketing and gap recovery.Planned
M6–M7 · Carrier PCB, enclosureKiCad carrier around a WROOM-1-N16R8 with level shifters and USB-C power; printed enclosure, smoked acrylic front, encoder modes.Planned
MP · Portfolio portalThe DepthCharge pages on this site — project page, concept art, and the two write-ups: this one and the detailed design — tracked as a roadmap milestone and executed in the portfolio repo.Stage 1 done

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