Skip to content

wire L8 depth, recent trades, and live latency into the WS snapshot payload - #28

Merged
MustafaNazeer merged 30 commits into
mainfrom
v1.1-ws-snapshot
May 13, 2026
Merged

wire L8 depth, recent trades, and live latency into the WS snapshot payload#28
MustafaNazeer merged 30 commits into
mainfrom
v1.1-ws-snapshot

Conversation

@MustafaNazeer

Copy link
Copy Markdown
Owner

Summary

The v1 demo shipped with four placeholder surfaces on the frontend (Ladder rows 2 through 8, Tape, DepthChart cumulative, PerfPanel engine latency) waiting on an extended WebSocket payload. This PR closes the v1.1 milestone by extending the existing two message kinds (snapshot and delta) with four nested sections per frame: tob, depth (up to L8 per side), trades (last 16 prints, oldest first), and latency (33-bucket histogram plus p50, p99, p99.9, max, samples). The 30 Hz wire cadence, the JSON-over-text-frames protocol, and the 4 Hz editorial throttle are all unchanged.

Engine-side, three new primitives carry the data: SeqlockDepth (a second seqlock on Book covering an L8 depth array per side), TradeRing (a fixed-size ring of 16 prints with a monotonic seq), and LatencyHistogram (33 log-spaced buckets, lock-free atomic increments). Each follows the project's existing one-writer-many-readers idiom. The instrumented path is gated behind an observability flag on the MatchingEngine constructor (default true), so the bench binary continues to measure the v1 matching path without instrumentation; bench/baseline.json is unchanged and the regression gate still passes (+18 percent throughput headroom on the desktop reference).

Two new property invariants protect the depth array: P11 sorted, deduped; P12 bounded by resting qty. They are tested via MatchingInvariants.DepthOrderedAndDeduped and MatchingInvariants.DepthQuantityBoundedByResting over 1000 cases per CI run, plus byte-for-byte diff against the Python reference's new l8_depth() method inside Differential.RandomSequencesAgreeWithReference. The trade aggregator is verified end-to-end against the 26-case worked-example corpus in tests/integration/test_trade_aggregation.cpp. Three new concurrency tests cover the new primitives under writer pressure (SeqlockDepthConcurrent, TradeRingConcurrent, LatencyHistogramConcurrent).

Test counts: 214 C++ tests (up from 182) and 21 frontend Vitest cases (up from 18), all green on release build.

ADR-0005 records the wire-shape decision, the three rejected alternatives (split message kinds, FlatBuffers, rolling-reservoir histogram), and the observability flag rationale. README Status now reads "all thirteen milestones landed"; docs/perf/budget.md carries paired bench measurements of the instrumented path (40 percent throughput cost, hence the flag); docs/risk/matching-semantics.md gains sections 9.2 and 9.3 with the new invariants and the trade aggregation rule.

Test plan

  • Full C++ test suite on release build: ctest --test-dir build-release shows 214/214 passing.
  • Frontend typecheck: pnpm tsc --noEmit clean.
  • Frontend tests: pnpm vitest run shows 21/21 passing across 2 files.
  • Bench regression check vs bench/baseline.json: throughput +18% headroom, p50/p99/p99.9 all under their ceilings.
  • CI green on both clang and gcc matrix jobs.
  • Manual smoke test of the live demo after merge: confirm Ladder shows 8 levels per side, Tape carries prints, DepthChart shows cumulative step function, PerfPanel histogram has varying bar heights.

Lock-free 33-bucket log-spaced histogram covering 30 ns to ~2 ms plus
overflow. Single writer, many readers, single relaxed atomic increment
per record(). Percentiles computed by CDF walk; bucket lower bound is
the conservative percentile value.

Unit tests cover empty, single-bucket, log spacing, overflow, and
known-CDF percentile shifts.
max_ns returns the lower bound of the last non-empty bucket, not the
upper bound. The previous comment said upper bound, which contradicted
the snapshot() implementation. Behaviour and assertions are unchanged.
Seqlock-protected DepthSnapshot carrying 8 levels per side (price, qty,
order_count) plus a per-side level count and an event timestamp. Same
idiom as SeqlockSnapshot, kept separate so TOB-only readers keep their
cheap path.
…ound trip

write()'s comment said the odd-seq release store keeps data_ ordered
after it, which misstates release semantics. The even-seq release
store is what anchors data_ before it; on x86 with GCC/Clang the
odd-seq store also acts as a compiler barrier, but that is a
compiler behaviour, not a C++20 guarantee. FullL8RoundTrip now also
verifies order_count, which Book::publish_depth will populate from
Level::order_count().
Fixed capacity 16, single writer, many readers. push() advances head
and bumps seq odd/even; read() returns an oldest-first snapshot of
populated entries. Wraps cleanly past capacity, drops the oldest
silently to keep the most-recent 16.
…ity boundary

OrderedByInsertionUpToCapacity now verifies all five TradePrint fields
(ts, price, qty, aggressor, seq) per slot rather than just two. Adds
EachPushAdvancesSeqByTwo to mirror the depth test, and
AtCapacityMinusOneUsesStraightIndexing to exercise the boundary
between the read()'s two branches. Adds a static_assert pinning
sizeof(TradePrint) at 32 bytes.
Book gains publish_depth(ts) (walks the first 8 entries of each price
map and writes the L8 picture through SeqlockDepth) and publish_trade
(assigns a monotonic per-Book seq and pushes into the trade ring).
Reader accessors depth() and trades(out, count) are safe from any
thread, matching the top_of_book() pattern.
depth_snapshot.hpp gains a header paragraph explaining the deliberate
choice of a non-atomic struct copy over per-field atomics for the
DepthSnapshot payload, distinguishing the depth case from the 24-byte
TOB case covered by ADR 0003. Adds a static_assert pinning the
kDepthLevels-to-uint8_t cast. Renames the three new Book tests to the
existing BookTest suite for consistent gtest filter behaviour.
…epth publish

apply now (a) times itself with steady_clock and records one sample
into a cumulative LatencyHistogram, (b) calls book->publish_depth
after publish_top_of_book on NewOrder paths and on cancel success
paths, and (c) walks the reports produced by the event to emit one
TradePrint per (maker fill, taker fill) pair into the book's trade
ring. Rejected FOK and other no-fill paths emit zero prints. The
matching loop itself (apply_limit / sweep / etc.) is unchanged.
apply_cancel now calls both publish_top_of_book and publish_depth on
the success path, removing the second registry_.book lookup the
outer apply was doing. NotFound reject still publishes neither
because the book state did not change. Adds a test that verifies a
successful cancel updates the depth seqlock.
Adds invariants P11 (depth strictly descending on bids, strictly
ascending on asks, no duplicates per side) and P12 (sum of per-level
qty bounded by total resting qty per side). Both run across all five
demo symbols for 1000 generated cases per invariant.

Extends the property harness so books survive past run_engine: the
returned unique_ptr<EngineRun> owns the OrderPool, BookRegistry,
OrderIndex, and MatchingEngine so post-run inspection of Book::depth()
works. Adds ShadowTracker::resting_qty_by_side for P12.
resting_qty_by_side's comment now names P12 (DepthQuantityBoundedByResting)
as its consumer instead of "L8". The file-level comment in
test_invariants.cpp no longer states a specific invariant count, so
it does not drift when the suite grows or shrinks.
Python reference grows l8_depth(symbol): returns up to 8 (px, qty,
order_count) triples per side ordered closest-to-spread first.
run_reference.py emits one depth_snapshot line per registered symbol
at the end of each run. test_differential.cpp partitions Python
output into reports and depth lines, diffs the report stream as
before, and diffs the depth lines against C++ Book::depth() for each
symbol. Integration tests filter depth_snapshot lines before the
existing report-stream diff so they continue to pass unchanged.
test_differential.cpp now derives the expected depth-line count from
run->symbols.size() rather than a magic literal, so adding a demo
symbol does not require a parallel edit here. The symbol-needle in
the per-symbol lookup is tightened with a trailing brace to avoid a
substring collision if a two-digit symbol id ever shares a prefix
with a single-digit one. The Python _Level.total_qty docstring now
names both callers (top_of_book and l8_depth) and notes both are
diff-checked.
Three integration scenarios exercise the trade-print aggregator
through MatchingEngine: a multi-leg limit sweep, a partial-fill IOC,
and a market that consumes the opposite side. Each asserts the
exact contents of the trade ring (price, qty, aggressor side,
monotonic seq) against the expected prints from the worked examples.
Verifies that under sustained writer pressure (one thread spinning
publishes of 32 rotating depth fingerprints) readers always observe
one of the published fingerprints, never a torn snapshot. Built
without ThreadSanitizer because SeqlockDepth deliberately uses a
non-atomic struct copy under the seqlock retry contract; TSAN would
flag the inner write even though the retry guarantees correctness
at the caller boundary. The design note is in the depth_snapshot
header.
Single writer publishes last_written before closing the seqlock so
readers that observe an entry in a consistent snapshot are guaranteed
to see last_written >= seq on an acquire load. Reader does 10,000
reads and asserts (a) seq is monotonic within each read, (b)
successive read maxima are non-decreasing, (c) no seq exceeds the
writer's announced value. Built without ThreadSanitizer for the same
reason as the SeqlockDepth concurrency test: the inner slots_ copy
is non-atomic under the seqlock retry contract.
The file gained two non-TSAN targets in Tasks 8 and 9 but the
file-level header still claimed all targets used ThreadSanitizer.
Updated the header to name which target uses TSAN and why the other
two do not.
…ters

Four writer threads each record 25,000 samples with rotating
durations across multiple buckets; the reader's final snapshot
returns samples == kWriters * kPerWriter and intermediate snapshots
are non-decreasing. Built with ThreadSanitizer because the histogram
uses per-bucket std::atomic counters with relaxed fetch_add; the
implementation is race-free by construction.
The mid-flight snapshot comment said "should never exceed the
running write total" which described an upper-bound check, but the
actual assertion is EXPECT_GE on the previous snapshot, a
non-decreasing lower-bound check. Updated the comment to match.
Sampler thread now reads four sources per 30 Hz tick (top of book,
L8 depth, trade ring, latency histogram) and emits one JSON
envelope per frame. Both snapshot and delta kinds share the same
shape. WS smoke test asserts presence of the four nested sections
and a 33-entry latency histogram.
Wire types nest under four sections; post-decode types add Depth,
Trade, Latency alongside TopOfBook. Decoders centralised in
types.ts. WS client tests rewritten against the new fixture shape.
DashboardState grows depth, displayedDepth, trades, latency,
displayedLatency. applySnapshot and applyDelta take five arguments
now (top, bytes, depth, trades, latency); flushDisplayed mirrors
top, depth, latency atomically. useMeridianStream wires the
decoded sections from each incoming frame.
Ladder reads displayedDepth and renders up to 8 rows per side with
per-row cumulative qty and a depth bar scaled to the per-side
cumulative max. Missing levels render as placeholder dashes. Section
aux label changes from the interim L1 notice to L8.
Tape reads the store's trades window and renders the newest 12
prints with engine-time stamps, side coloring by aggressor, price,
quantity, and computed notional (price in cents times quantity
divided by 100). Empty slots render as placeholders so the table
height stays stable.
formatPrice already divides cents by 100. The tape was pre-dividing
the notional too, displaying values 100x too small (a 5 share trade
at 100 dollars showed as 5 dollars notional instead of 500). Notional
now stays in cents and formatPrice does the single conversion.

The aggressor column was also duplicating the side column's
buy/sell label. Since every emitted print represents one match
where the new order is the taker, the column now reads "taker" for
every row, which is what wireframes specified.
Bid and ask sides each render a step function from the spread outward
to the deepest visible level, scaled by per-side cumulative quantity.
Imbalance and legend reflect cumulative L8 totals rather than top-of-book.
Section aux changed to "8 levels each side, cumulative".
PerfHistogram replaces the skeleton bars and renders 33 live buckets
scaled to the max bucket count. p50, p99, max read from the store's
displayedLatency; p99.9 gates on samples >= 1000 to avoid showing a
noisy percentile with too few samples. Replay copy no longer flags
the trade ring as a follow-up since it is now live.
MatchingEngine's constructor now takes a bool (default true) that
gates the v1.1 instrumentation (publish_depth, publish_trade, the
latency histogram). When false, apply() skips all three and emits
only the existing TOB publish; the matching loop itself is unchanged.

The bench passes false so its single-threaded throughput measurement
reflects the pure matching path. Tests and the server use the
default (true) so the live demo continues to carry depth, trades,
and latency on every frame.
…E status

matching-semantics.md gains sections 9.2 (P11/P12 depth invariants) and
9.3 (trade print aggregation rule) with the names of the property and
differential tests that exercise them. perf/budget.md records paired
bench measurements of the v1.1 observability path (40 percent throughput
cost; bench therefore builds with observability=false). ADR-0005
captures the wire schema decision, the three rejected alternatives
(split kinds, FlatBuffers, rolling reservoir), and the observability
flag rationale. README Status reads "all thirteen milestones landed"
and the L1/L8 bullet retires the placeholder framing.
@MustafaNazeer
MustafaNazeer merged commit 807d6d6 into main May 13, 2026
3 checks passed
@MustafaNazeer
MustafaNazeer deleted the v1.1-ws-snapshot branch May 13, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant