wire L8 depth, recent trades, and live latency into the WS snapshot payload - #28
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 (
snapshotanddelta) with four nested sections per frame:tob,depth(up to L8 per side),trades(last 16 prints, oldest first), andlatency(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 onBookcovering an L8 depth array per side),TradeRing(a fixed-size ring of 16 prints with a monotonicseq), andLatencyHistogram(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 anobservabilityflag on theMatchingEngineconstructor (defaulttrue), so the bench binary continues to measure the v1 matching path without instrumentation;bench/baseline.jsonis 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.DepthOrderedAndDedupedandMatchingInvariants.DepthQuantityBoundedByRestingover 1000 cases per CI run, plus byte-for-byte diff against the Python reference's newl8_depth()method insideDifferential.RandomSequencesAgreeWithReference. The trade aggregator is verified end-to-end against the 26-case worked-example corpus intests/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.mdcarries paired bench measurements of the instrumented path (40 percent throughput cost, hence the flag);docs/risk/matching-semantics.mdgains sections 9.2 and 9.3 with the new invariants and the trade aggregation rule.Test plan
ctest --test-dir build-releaseshows 214/214 passing.pnpm tsc --noEmitclean.pnpm vitest runshows 21/21 passing across 2 files.bench/baseline.json: throughput +18% headroom, p50/p99/p99.9 all under their ceilings.