A high-performance, thread-safe limit order book implementation written in Rust. This project provides a comprehensive order matching engine designed for low-latency trading systems, with a focus on concurrent access patterns and lock-free data structures.
-
Lock-Free Architecture: Built using atomics and lock-free data structures to minimize contention and maximize throughput in high-frequency trading scenarios.
-
Multiple Order Types: Support for various order types including standard limit orders, iceberg orders, post-only, fill-or-kill, immediate-or-cancel, good-till-date, trailing stop, pegged, market-to-limit, and reserve orders with custom replenishment logic. Warning (#286): trailing stops currently rest as ordinary limit liquidity at their stop price and do not trail on an uncrossed book; do not rely on them as protective stops.
-
Thread-Safe Price Levels: Each price level can be independently and concurrently modified by multiple threads without blocking.
-
Advanced Order Matching: Efficient matching algorithm for both market and limit orders, correctly handling complex order types and partial fills.
-
Performance Metrics: Built-in statistics tracking for benchmarking and monitoring system performance.
-
Memory Efficient: Designed to scale to millions of orders with minimal memory overhead.
This order book engine is built with the following design principles:
- Correctness: Ensure that all operations maintain the integrity of the order book, even under high concurrency.
- Performance: Optimize for low latency and high throughput in both write-heavy and read-heavy workloads.
- Scalability: Support for millions of orders and thousands of price levels without degradation.
- Flexibility: Easily extendable to support additional order types and matching algorithms.
- Trading Systems: Core component for building trading systems and exchanges
- Market Simulation: Tool for back-testing trading strategies with realistic market dynamics
- Research: Platform for studying market microstructure and order flow
- Educational: Reference implementation for understanding modern exchange architecture
0.14.0 is the panic-policy release: crate-owned code no longer initiates
panics and the gate enforcing it is absolute. The engine and state
failures the audit found clamped, ignored or silently recovered (matching,
fees and notionals, risk, modifies, mass cancels, snapshots and restore,
journals and replay, wire and bincode decoding) now surface as typed
errors. A few documented paths stay infallible by design and use an
explicit, logged or counted fallback instead: current_time_millis()
returns 0 / u64::MAX sentinels (use try_current_time_millis() for a
Result), the CountingAllocator diagnostic counters wrap, NATS builder
values are clamped with a WARN, undeliverable listener events are
counted in dropped_listener_events(), and the ()-guarded level-stripe,
outbox and eviction-queue locks recover from poison with a log. It is a
breaking release; see the migration table below.
- Aborted sweeps (#240). A price level that fails mid-sweep stops the
sweep: the committed prefix is published like a partial fill, the
remainder never rests, and the submit returns
OrderBookError::MatchAborted(taker stateCancelled { MatchAborted }). A fill-or-kill checks trade-id headroom and reserves its result buffers before any mutation; a shortfall rejects it untouched. With an exhausted trade-id generator every crossing submit / modify is rejected untouched (code 16), and a failed post-only probe is a cleanRejected, not an abort. Dead-book signals:OrderBook::match_aborts(),match_fold_failures()and the latchedtrade_ids_exhausted()(plusmetricscounters). - pricelevel 0.10.1 (#293). The dependency floor is
0.10.1. A poisoned price level stops a sweep withMatchAbortedinstead of being walked past (PriceLevel#217). The fill-or-kill preflight dry-runs every level withPriceLevel::match_requirementsfor the quantity the sweep will ask of it, and rejects untouched a poisoned level, an exhausted per-level counter (CounterExhausted) or a maker step that would stop the sweep (PriceLevel#218); its trade-id check is exact, so a FOK that fits the remaining trade ids is no longer refused. Levels are folded withMatchResult::try_absorband split reservations (PriceLevel#219): when a level is asked for exactly the aggregate's remaining quantity and the aggregate holds no trades or filled ids yet, it adopts the level's buffers without reserving or allocating. In practice that is the first traded level of a non-FOK base-quantity sweep; a FOK reserves its whole sweep up front, and quote-notional levels and STP pre-matches asked for less than the remainder are copied into a reserved aggregate. - Journaling aborted submits (#240).
add_order_with_committed,submit_market_order_with_committedandsubmit_market_order_by_amount_with_committedreturn aSubmitFailurecarrying the committedTradeResult;SequencerResult::from_submit_failurerecords it as the newSequencerResult::MatchAborted, and replay requires the same prefix (ReplayError::OutcomeMismatchotherwise). - Checked fee and notional arithmetic (#244). A taker whose worst-case
notional (worst reachable price × quantity, or its amount) overflows
u128or cannot be priced exactly by theFeeScheduleis rejected before the book is touched withOrderBookError::FeeOverflow(code 18) orNotionalOverflow(code 19), on every submission API and with or without a listener. Fees andquote_notionalare never clamped or dropped. Because the fee schedule now decides verdicts,ReplayBookConfig::fee_schedulemust match the source book's. - Modifications stop swallowing mutation errors (#247).
update_order(OrderUpdate::Cancel)is the same removal ascancel_order(errors propagated,Cancelledstate, risk released). A cancel-then-add modify whose re-add fails after the cancel restores the original at the back of its level (OrderBookError::ModifyRolledBack, code 20) or, if it traded or cannot be restored, reports it gone with consistent indices (ModifyOrderLost, code 21); replay re-executes both. A remainder that cannot rest after trades endsCancelled { RestFailed }, and a failed self-trade-prevention maker cancel aborts the sweep (MatchAborted). A modify never creates quantity when a fill races it, never restores into a locked book, and keepsfilled_quantitycumulative; an emptied price level is never removed while a concurrent submit is admitting into it. An IOC remainder'sInsufficientLiquiditynow reports the taker's total asrequestedand its executed quantity asavailable. - Gate-safe, failure-aware mass cancels (#248). Every mass cancel and
evict_expired_ordersholds the exclusive submit gate, so no order admitted concurrently is dropped without an event;cancel_all_ordersemits its events after the book is cleared.MassCancelResult::failures()/has_failures()report failures (MassCancelFailure): a mass cancel whose price level cannot be read cancels nothing and says so, and a refused order stays resting and tracked (seeMassCancelResult::is_refused).cancel_orderreturnsErrwhen a level refuses the removal (and completes, then reports asOrderBookError::OrderRemovedWithLevelFault, a removal the level committed before failing), andevict_expired_ordersreturns anEvictionResultwith the evicted orders and per-order failures, which replay reproduces by identity. - Listeners run after commit, outside the submit gate (#249). Trade,
price-level and order-state listener events are buffered during the
mutation, stamped with
engine_sequnder the gate at commit and delivered after the gate is released by one active dispatcher per book: one total order consistent with commit order, withengine_seqstrictly increasing also under concurrent submitters, and the same single-thread order as before. A listener may re-enter the book; a panicking listener leaves the book consistent and the gate unpoisoned (dropped_listener_events(),listener_panics(),flush_listener_events());pending_listener_events()gauges the unbounded backlog a slow listener builds.match_market_order*/match_limit_order*now publish their trades before the gate is released. A poisoned submit gate engages the kill switch (submit_gate_poisoned()) instead of being recovered silently. - Checked counters and untrusted-restore validation (#250).
next_engine_seq()refuses withOrderBookError::EngineSeqExhaustedinstead of wrapping; the engine's emission paths suppress (and latch,engine_seq_exhausted()) an event they cannot stamp (caller-owned results keep their fills, stampedUNSTAMPED_ENGINE_SEQ), and the book keeps matching. Restore rejects, before touching the live book, a crossed or locked snapshot (OrderBookError::SnapshotCrossed), an order whosevisible + hiddenoverflowsu64, and a package whoseengine_seqisu64::MAX; tick / lot alignment is deliberately not enforced.OrderBookSnapshotPackage::newpropagates a failed aggregate refresh.spread()/spread_bps()returnNonefor a crossed read. The strandable-maker count,StubClock(pinned at its ceiling,is_exhausted()) and the repricing counters use checked forms; the order-state tracker recovers a poisoned eviction queue and evicts an order's status and history atomically withDashMap::remove_if. - Hardened journals and identity replay of mass cancels (#252).
FileJournalnever truncates an existing segment on rotation (JournalError::SegmentExists), both journals refuse non-increasing sequences (JournalError::NonMonotonicSequence), a malformed entry header is an error instead of a silent end of data, reopen zeroes a torn tail and refuses (instead of truncating at) mid-segment corruption, and a poisoned lock isMutexPoisonedeverywhere. Replay compares a journaledMassCancelledwith the replayed result by order ids, in order, and failure outcomes (ReplayError::MassCancelMismatch). No on-disk format change. - Consistent indices under concurrent crossing adds (#288). An order's
location, user-index entry and resting state are published before its
level admits it, and every remover releases the location (the id's
ownership token) last, so a concurrent sweep that consumes the order
removes them instead of racing their insertion and a reused id never
touches a previous order's entries. A same-id submit racing a live order
is refused with
DuplicateOrderId(possibly after trading, so it is now journaled as may-have-mutated). The risk layer removes a fully filled maker's entry under the same lock that zeroes it. - Replay-safe post-trade risk rejections (#291). A taker whose residual
the risk layer refuses after it traded returns
OrderBookError::RiskRejectedAfterTrades(code 22, journaled as possibly mutating) instead of a pre-trade risk error replay skipped; replay re-runs the sweep and refuses the residual, reproducing the live trades without aRiskConfig. The repricers keep a reused id's special-order registration. - Core boundary gaps closed (#294). A panic under the shared side of
the submit gate (a
Clock, metrics recorder ortracingsubscriber running mid-mutation) engages the kill switch and latchessubmit_gate_poisoned()like one under the exclusive side; a sweep drain or rest path that unwinds leaves no ghost order location; the publicplace_order_in_bookbypass is gone; a standaloneOrderStateTrackerqueues a terminal id for eviction before its listener runs; the listener dispatcher always progresses when its buffer cannot grow;PriceSource::LastTradeno longer falls back to the mid. - Bounded journal recovery and NATS shutdown (#295). Replay reports
ReplayError::JournalTruncatedwhen the entries end beforelast_sequence(). The NATS publishers clampwith_max_retriestoMAX_PUBLISH_RETRIES(10), stop publishing once shutdown is requested and the link is down (the rest is counted indropped_events), have a cancel-safeshutdown()and a newshutdown_with_deadline(Duration). The book-change publisher sendsContent-Type: application/jsonand counts errors once per batch. Wire frames aboveMAX_FRAME_BODY(4096) are rejected, and the outbound encoders refuse values their decoders reject.
- pricelevel 0.10 (#239). Level snapshots, queue views and
match-result growth are fallible upstream; the book propagates those
errors instead of ignoring them.
create_snapshot,enriched_snapshot,enriched_snapshot_with_metricsandevict_expired_ordersreturnResult. - Book analytics return
Resultwith checked aggregates (#245). VWAP, market impact, simulation, micro price, imbalance, pressure, depth statistics / distribution, the depth-to-target and liquidity queries,total_quantity_at_price,get_volume_by_price,is_thin_book,find_level, theOrderBookSnapshottotals, theEnrichedSnapshotconstructors andOrderSimulation::total_costreturnResult<_, OrderBookError>;u128notionals andu64depth sums are checked (OrderBookError::ArithmeticOverflow) instead of panicking in debug, wrapping in release or saturating, and a level whosevisible + hiddentotal overflows surfaces asOrderBookError::PriceLevelErrorinstead of reading as an empty (oru64::MAX) level. The depth iterators yieldResult<LevelInfo, OrderBookError>and stop after the first error.depth_distributioncapsbinsatMAX_DEPTH_DISTRIBUTION_BINS(4096) and reserves fallibly (OrderBookError::AllocationFailed). Pegged orders referencing the mid price use the exact integer midpoint (rounded down) instead of anf64round trip. The matching path is unchanged. - Fee and trade construction (#244).
FeeSchedule::calculate_fee,TradeResult::new/with_fees/total_feesandTradeInfo::from_trade_resultreturnResult(FeeOverflow/TradeArithmeticError);try_calculate_feeis deprecated.with_maker_rebate(i32::MIN, _)and a pegged offset ofi64::MINno longer panic. OrderBook::peek_matchreturnsResult(#246). A level whose depth overflowsu64is reported instead of read as empty.- Journal surface (#252).
Journal::last_sequencereturnsResult<Option<u64>, JournalError>;InMemoryJournal::with_capacity,lenandis_emptyreturnResult. - NATS publishers (#253).
shutdown()returnsResult<(), NatsPublisherError>so a panicked or cancelled background task is reported; builder values are clamped with aWARN(batch window and publish interval at 60 s,max_batch_sizeinto1..=65_536so0now means1, channel capacity to Tokio's limit); retries use capped exponential backoff (5 s) with jitter. - Wire encoders return
Result(#254).encode_exec_report,encode_trade_printandencode_book_updatereserve withVec::try_reserveand returnResult<(), WireError>(newWireError::CapacityOverflow); the wire format is unchanged. - Book managers are runtime-safe and stoppable (#255).
BookManagerTokio::start_trade_processorreturnsManagerError::NoRuntimeoutside a Tokio runtime instead of panicking, andBookManagerStdreports a refused thread asManagerError::ThreadSpawn. Both managers gainstart_trade_processor_with(handler),stop_trade_processor()(joins or awaits the processor after it handles queued events; a panic isProcessorPanicked) anddropped_trade_events(), plus theorderbook_manager_trade_events_dropped_totalmetric. - Implied-volatility inputs are validated (#256).
SolverConfig::validateandIVConfig::validaterun at every solve entry point, so an inverted or NaN IV bound, a zero tolerance or a badprice_scalereturnsIVError::InvalidConfiginstead of panicking inf64::clamp. Black-Scholes and the Greeks returnResult<f64, IVError>and never hand back NaN or infinity.IVErroris#[non_exhaustive]. - Checked time helpers (#257).
try_current_time_millis()returnsResult<u64, TimeError>for a pre-epoch clock or au64overflow;current_time_millis()stays infallible with a documented, logged fallback.AllocSnapshot::since(featurealloc-counters) returnsOptionand rejects out-of-order snapshots instead of clamping. - Bounded bincode decoding (#251).
BincodeEventSerializeris no longer a unit struct (BincodeEventSerializer::new(),with_max_payload_bytes) andSerializationErrorgainsPayloadTooLargeandTruncated. ReplayErroris#[non_exhaustive](#260). 0.14 addsJournalTruncatedandMassCancelMismatch; later additions no longer break downstream matches.- Snapshot format v4. Level statistics carry a
u128value_executed; v2 and v3 packages still restore. - Wire break for bincode payloads. pricelevel's
MatchResultgained a positionalerrorfield andMassCancelResultafailuresfield, so bincodeTradeResult/MassCancelResultpayloads do not decode across 0.13 and 0.14; JSON payloads and journals stay compatible.
- Production Panic Policy (#242, #243 to #259, #265, #294, #295).
Crate-owned production code no longer initiates panics: no
unwrap/expect/panic!/assert!/ indexing / unchecked arithmetic / narrowing cast /saturating_*on state. Seedoc/panic-boundaries.mdfor what the gate cannot certify (dependencies, the two documentedunsafeexceptions, caller-supplied code). - Checked pre-trade risk (#243). Two orders whose notional sum
overflows
u128can no longer wrap the account counter and bypassmax_notional_per_account, and the price band no longer passes at extreme prices. Release-side underflows are logged and counted inOrderBook::risk_accounting_anomalies. - Panic-free matching loop (#246). A
CancelMakerfill-or-kill whose non-self depth sums pastu64::MAXis judged fillable instead of panicking (debug) or being killed on a wrapped sum (release); the thread-local matching pool degrades to fresh buffers during thread teardown or reentrancy; the #225 STP snapshot check logs instead of asserting; per-level budget arithmetic is checked and a breach aborts the sweep with its committed prefix. Valid inputs trade identically. - Bounded bincode decoding of untrusted payloads (#251). A string
length prefix is checked against the remaining input before anything is
allocated, so allocations are bounded by the input length, and payloads
over
DEFAULT_MAX_BINCODE_PAYLOAD_BYTES(8 MiB, configurable) are rejected withSerializationError::PayloadTooLarge. - Wire codec is panic-free on untrusted bytes (#254). Decoders read
through checked offsets instead of
copy_from_sliceand raw offset arithmetic. - Default trade-id namespace without OS entropy (#265). Constructors
that are not given a namespace derive a UUIDv5 from the symbol, process
id, wall-clock nanoseconds and a process-wide checked counter instead of
calling the panicking
Uuid::new_v4(). Namespaces are unique per book within a process and are designed to differ across restarts; a restart that reuses the same process id with the wall clock stepped back to the same nanosecond can repeat one (seedefault_trade_id_namespace), so inject a namespace when cross-restart uniqueness must be guaranteed. - Bounded journal reopen (#295). Reopening a
FileJournalwhose latest segment ends in garbage is linear in the segment size (cheap pre-checks, capped CRC probes); an empty latest segment left by a crash is grown on open; only canonical segment names are read.
- Trailing stops rest as limit liquidity (#286). A
TrailingStop(special_orders) is placed as an ordinary resting limit order at its stop price, not held off-book until triggered: it provides liquidity at that price (a sell stop is a resting sell) andreprice_trailing_stopscannot move it on an uncrossed book. Do not use it as a protective stop in production until #286 lands. - Level statistics are advisory under concurrent takers (#241).
pricelevel 0.10 supports one concurrent writer of a level's execution
statistics, while takers on the shared submit gate can sweep one level
at once. A snapshot taken meanwhile can hold a partially recorded
execution in
orders_executed/quantity_executed/value_executed. Trades, fees, quantities and order vectors are unaffected, totals are exact once the sweeps return, and single-threaded replay (snapshots_match) stays exact. - Replay of resource-exhaustion aborts (#240). A journal holding such
an abort replays at best from genesis, never from a mid-stream snapshot
(the trade-id generator is not in the snapshot); the committed-prefix
check only applies to submits recorded through
*_with_committed/SequencerResult::from_submit_failure, and aborted updates are reconciled by code only. - Replay of concurrency-dependent outcomes (#247, #288). A rolled-back
or lost modify whose cause does not reproduce, and the losing side of a
concurrent same-id submit, stop or diverge in replay (
OutcomeMismatch, or asnapshots_matchdifference). Unique order ids per submit are an ingress / sequencing obligation. - Old journals (#252). Journals written before 0.10.2 fail replay with
ReplayError::MassCancelMismatchat their first mass cancel; a latest segment holding mid-segment corruption now fails to open instead of being silently truncated.
See doc/panic-boundaries.md for the full statement of each.
- Absolute panic-policy gate (#242, #260). A clippy deny set in
Cargo.tomlplusscripts/check_panic_policy.pyinmake lint, with no allowlist: the temporary ratchet ledgers used during the cycle were removed. - Dependency floors (#237). Raised to the latest semver-compatible
releases;
bincodestays on 2.0.1. - Allocation budget test (#262).
tests/alloc_budget.rsasserts a seven-window median and runs in CI. - Performance measured against 0.13.1 (#259). A filled maker leaves
the user index by key (its location now carries the owner) instead of
a scan over every user, and a passive add to an existing level no
longer builds and drops a whole
PriceLevel: passive-add allocations fall from 6.3 / 18.3 KB to 3.3 / 0.95 KB per op,add_onlyp50 -46 %,mixed_70_20_10-50 %, snapshot restore -64 %, replay -56 %.snapshot_create_10k(+6.2 %), contended same-price adds with listeners at 8 threads (+15 %) and the cancel-miss path (+8 ns) are slower and accepted (maintainer decision on #259); seeBENCHMARKS.mdandBENCH.md.
| 0.13 | 0.14 |
|---|---|
OrderBook::create_snapshot(depth) -> OrderBookSnapshot |
-> Result<OrderBookSnapshot, OrderBookError> |
OrderBook::enriched_snapshot(depth) -> EnrichedSnapshot |
-> Result<EnrichedSnapshot, OrderBookError> |
OrderBook::enriched_snapshot_with_metrics(depth, flags) -> EnrichedSnapshot |
-> Result<EnrichedSnapshot, OrderBookError> |
OrderBook::evict_expired_orders(now_ms) -> Vec<Arc<OrderType<T>>> |
-> Result<EvictionResult<T>, OrderBookError> (iter(), len(), evicted_orders(), failures(), mass_cancel_result()) |
BookManager{Std,Tokio}::evict_expired_orders(symbol, now_ms) -> Option<Vec<..>> |
-> Option<Result<EvictionResult<T>, OrderBookError>> |
BookManager{Std,Tokio}::evict_expired_across_books(now_ms) -> HashMap<String, Vec<..>> |
-> HashMap<String, Result<EvictionResult<T>, OrderBookError>> |
MassCancelResult { cancelled_count, cancelled_order_ids } |
adds failures: Vec<MassCancelFailure> (#[serde(default)]); a JSON result carrying the new order_cancel_failed / level_fault_after_removal kinds does not decode on 0.13 (or pre-#248) readers |
OrderBook::cancel_order: level refusal → Ok(None) |
Err(OrderBookError::PriceLevelError(_)), order untouched |
cancel whose level removed the order, then failed: Ok(None), indices stale |
removal completed, Err(OrderBookError::OrderRemovedWithLevelFault { .. }) |
evict_expired_orders: per-order failure silently skipped |
recorded in EvictionResult::failures(); the rest is still evicted |
| journaled eviction replayed by re-running the sweep | replay evicts exactly the journaled ids (MassCancelled result) |
SequencerResult::from(&err) is always a rejection |
OrderRemovedWithLevelFault records OrderCancelled { order_id } |
| mass cancels on the shared submit gate | exclusive gate; cancel_all_orders emits after clearing |
| listeners run inline, often under the submit gate; re-entering the book can deadlock | run after commit and gate release, ordered by commit; re-entry allowed; may run on another submitter's thread |
| submit-gate poison recovered silently | kill switch engaged, submit_gate_poisoned() latched |
| panic under the shared submit gate: no trace | kill switch engaged, submit_gate_poisoned() latched (#294) |
OrderBook::place_order_in_book(order) (raw placement, no gate / risk / STP / state) |
removed; use add_order (#294) |
IV PriceSource::LastTrade with no trade: falls back to the mid |
Err(IVError::NoPriceAvailable) on a two-sided book (#294) |
ORDERBOOK_SNAPSHOT_FORMAT_VERSION == 3 |
== 4; reads 2..=4 |
AllocSnapshot::since(earlier) -> AllocSnapshot (saturating) |
-> Option<AllocSnapshot>; None when earlier is ahead |
wire::encode_{exec_report, trade_print, book_update}(msg, &mut Vec<u8>) (returns ()) |
-> Result<(), WireError>; WireError adds CapacityOverflow |
BookManagerStd::start_trade_processor() -> Result<std::thread::JoinHandle<()>, ManagerError> |
-> Result<(), ManagerError>; join with stop_trade_processor() |
BookManagerTokio::start_trade_processor() -> Result<tokio::task::JoinHandle<()>, ManagerError> (panics outside a runtime) |
-> Result<(), ManagerError> (NoRuntime outside a runtime); await stop_trade_processor() |
ManagerError { ProcessorAlreadyStarted, BookAlreadyExists } |
adds NoRuntime, ThreadSpawn, ProcessorNotRunning, ProcessorPanicked, ProcessorCancelled |
BincodeEventSerializer (unit struct) |
BincodeEventSerializer::new(); with_max_payload_bytes(n); SerializationError gains PayloadTooLarge, Truncated |
BlackScholes::{price, vega, delta, gamma, theta}(params, vol) -> f64 |
-> Result<f64, IVError> |
BlackScholes::d1(spot, strike, rate, time, vol) -> f64 |
-> Result<f64, IVError> |
BlackScholes::d2(d1, vol, time) -> f64 |
-> Result<f64, IVError> |
OrderBook::theoretical_price(params, vol) -> f64 |
-> Result<f64, IVError> |
OrderBook::option_{vega, delta, gamma, theta}(params, vol) -> f64 |
-> Result<f64, IVError> |
IVError (exhaustive) |
#[non_exhaustive]; adds InvalidConfig, NonFiniteResult, ArithmeticOverflow, PriceLevel |
solve_iv / solve_iv_bisection / implied_volatility* accept any config |
reject an invalid config with IVError::InvalidConfig |
sweep stopped by a level failure: Err(PriceLevelError) (prefix unreported) |
Err(MatchAborted { .. }), prefix published |
CancelReason (8 variants) |
adds MatchAborted (#240) and RestFailed (#247), appended (exhaustive matches need both arms) |
Nats{Trade,BookChange}Publisher::shutdown() -> () |
-> Result<(), NatsPublisherError> |
FeeSchedule::calculate_fee(n, maker) -> i128 (clamps) |
-> Result<i128, FeeOverflow>; try_calculate_fee deprecated |
TradeResult::new(symbol, mr) -> TradeResult |
-> Result<TradeResult, TradeArithmeticError> |
TradeResult::with_fees(symbol, mr, schedule) -> TradeResult |
-> Result<TradeResult, TradeArithmeticError> |
TradeResult::total_fees() -> i128 (clamps) |
-> Result<i128, TradeArithmeticError> |
TradeInfo::from_trade_result(tr, schedule) -> TradeInfo |
-> Result<TradeInfo, TradeArithmeticError> |
| taker with an unpriceable worst-case notional: trades with a clamped fee | rejected untouched: FeeOverflow (18) / NotionalOverflow (19) |
OrderBook::vwap(qty, side) -> Option<f64> |
-> Result<Option<f64>, OrderBookError> |
OrderBook::micro_price() -> Option<f64> |
-> Result<Option<f64>, OrderBookError> |
OrderBook::order_book_imbalance(levels) -> f64 |
-> Result<f64, OrderBookError> |
OrderBook::market_impact(qty, side) -> MarketImpact |
-> Result<MarketImpact, OrderBookError> |
OrderBook::simulate_market_order(qty, side) -> OrderSimulation |
-> Result<OrderSimulation, OrderBookError> |
OrderBook::{price_at_depth, price_at_depth_adjusted}(..) -> Option<u128> |
-> Result<Option<u128>, OrderBookError> |
OrderBook::cumulative_depth_to_target(..) -> Option<(u128, u64)> |
-> Result<Option<(u128, u64)>, OrderBookError> |
OrderBook::{total_depth_at_levels, liquidity_in_range}(..) -> u64 |
-> Result<u64, OrderBookError> |
OrderBook::total_quantity_at_price(price, side) -> Option<u64> (u64::MAX on level overflow) |
-> Result<Option<u64>, OrderBookError> (Err on level overflow) |
OrderBook::get_volume_by_price() -> (HashMap, HashMap) |
-> Result<(HashMap, HashMap), OrderBookError> |
OrderBook::depth_statistics(side, levels) -> DepthStats |
-> Result<DepthStats, OrderBookError> |
OrderBook::buy_sell_pressure() -> (u64, u64) |
-> Result<(u64, u64), OrderBookError> |
OrderBook::is_thin_book(threshold, levels) -> bool |
-> Result<bool, OrderBookError> |
OrderBook::depth_distribution(side, bins) -> Vec<DistributionBin> (any bins) |
-> Result<Vec<DistributionBin>, OrderBookError>; bins capped at MAX_DEPTH_DISTRIBUTION_BINS |
OrderBook::find_level(side, pred) -> Option<LevelInfo> |
-> Result<Option<LevelInfo>, OrderBookError> |
levels_with_cumulative_depth / levels_until_depth / levels_in_range: Item = LevelInfo |
Item = Result<LevelInfo, OrderBookError>; fused after the first Err |
OrderBookSnapshot::{total_bid_volume, total_ask_volume}() -> u64 |
-> Result<u64, OrderBookError> |
OrderBookSnapshot::{total_bid_value, total_ask_value}() -> u128 (saturating) |
-> Result<u128, OrderBookError> |
EnrichedSnapshot::{new, with_metrics}(..) -> EnrichedSnapshot |
-> Result<EnrichedSnapshot, OrderBookError> |
OrderSimulation::total_cost() -> u128 (saturating) |
-> Result<u128, OrderBookError> |
DistributionBin::width() -> u128 (saturating) |
-> Result<u128, OrderBookError> |
OrderBookError (0.13 variants) |
adds MatchAborted, FeeOverflow, NotionalOverflow, ArithmeticOverflow, AllocationFailed, EngineSeqExhausted, SnapshotCrossed, OrderRemovedWithLevelFault, ModifyRolledBack, ModifyOrderLost, OrderChangedDuringModify, RiskRejectedAfterTrades (#[non_exhaustive], so matches already have a wildcard arm) |
OrderBook::peek_match(side, qty, limit) -> u64 (overflowing level read as empty) |
-> Result<u64, OrderBookError> (PriceLevelError for an overflowing level) |
Journal::last_sequence() -> Option<u64> |
-> Result<Option<u64>, JournalError> |
InMemoryJournal::with_capacity(n) -> Self; len() -> usize; is_empty() -> bool |
-> Result<Self, JournalError>; -> Result<usize, JournalError>; -> Result<bool, JournalError> |
Journal::append accepts any sequence; rotation truncates an existing segment |
non-increasing sequence → JournalError::NonMonotonicSequence; existing segment → SegmentExists |
JournalError (hand-written Display) |
thiserror; adds NonMonotonicSequence, SegmentExists, AllocationFailed |
journaled MassCancelled checked by refusal / failures only |
reconciled by ids and failure outcomes; ReplayError::MassCancelMismatch (MassCancelDivergence) |
OrderBook::next_engine_seq() -> u64 (wraps at u64::MAX) |
-> Result<u64, OrderBookError>; EngineSeqExhausted at u64::MAX |
OrderBookSnapshot::refresh_aggregates() (errors ignored) |
-> Result<(), OrderBookError> |
restore of a crossed / locked snapshot or a package with engine_seq == u64::MAX: accepted |
rejected before any live state is touched |
OrderBook::spread() / spread_bps() / OrderBookSnapshot::spread() on a crossed read: Some(0) |
None |
OrderQuantity::total_quantity() -> u64 (saturating) |
-> Result<u64, OrderBookError> (QuantityOverflow) |
OrderBookError::PriceCrossing { opposite_price: u128 } (0 when empty) |
opposite_price: Option<u128> |
update_order(Cancel): level error ignored, indices removed anyway |
same removal as cancel_order; errors propagated |
modify re-add failing after the cancel: Err(..), original lost |
Err(ModifyRolledBack { .. }) (original restored, back of queue) or Err(ModifyOrderLost { .. }) |
RejectReason codes 1 to 14 |
adds MatchAborted (15), CapacityExceeded (16), CounterExhausted (17), FeeOverflow (18), NotionalOverflow (19), ModifyRolledBack (20), ModifyOrderLost (21), RiskRejectedAfterTrades (22); pricelevel 0.10's PriceLevelError::CapacityExceeded / CounterExhausted map to 16 / 17, other PriceLevelErrors stay Other(0); older readers decode the new codes as Other(n) |
risk refusal of a residual after trades: Err(RiskMaxNotional { .. }) / Err(RiskMaxOpenOrders { .. }), journaled as never mutating |
Err(RiskRejectedAfterTrades { source, .. }), journaled as may-have-mutated and replayed |
| remainder not rested after trades: no terminal state | Cancelled { filled_quantity, reason: RestFailed } |
| modify after a concurrent partial fill: re-add rested the quantity read before it | UpdatePrice moves the remainder; UpdatePriceAndQuantity / Replace: Err(ModifyRolledBack { source: OrderChangedDuringModify, .. }) |
re-priced partially filled order: state reset to Open |
PartiallyFilled with cumulative quantities |
ReplayError (exhaustive) |
#[non_exhaustive] (#260); adds JournalTruncated { expected_last, reached } (#295) and MassCancelMismatch { .. } (#252); matches need a wildcard arm |
a journal whose entries end before last_sequence() replays Ok on the prefix |
Err(ReplayError::JournalTruncated { .. }) |
FileJournal lists any segment-<u64>.journal name |
only canonical segment-<20 digits>.journal names |
bincode TradeResult / MassCancelResult payloads written by 0.13 |
do not decode under 0.14 and vice versa (positional MatchResult::error, MassCancelResult::failures): upgrade NATS producers and consumers together; JSON and journals unaffected |
SequencerResult (0.13 variants) |
adds MatchAborted { reason, code, committed } (appended, #[non_exhaustive]): journals carrying it do not decode on older binaries |
same-id submit racing a live order: overwrites its location (last writer wins); SequencerResult::from(DuplicateOrderId) never mutating |
refused with DuplicateOrderId, possibly after trading; journaled may_have_mutated: true (#288) |
IOC remainder InsufficientLiquidity { requested, available } read off the visible tranche |
requested = taker total, available = executed quantity (#247) |
ReplayBookConfig::fee_schedule only priced fees |
must match the source book's schedule: it decides FeeOverflow verdicts (#244) |
| journals written before 0.10.2 replay mass cancels on equal counts | ReplayError::MassCancelMismatch at the first such mass cancel; re-record, or replay with a pre-0.14 build (#252) |
| latest segment with mid-segment corruption: silently truncated at the damage on reopen | FileJournal refuses to open it (#252) |
Nats{Trade,BookChange}Publisher out-of-range builder values: later panic; with_max_batch_size(0) dropped buffered events on shutdown |
clamped with a WARN (max_batch_size into 1..=65_536, 0 means 1; batch window / interval at 60 s; channel capacity to Tokio's limit) (#253) |
match_market_order* / match_limit_order* publish their trades after releasing the submit gate |
before releasing it (engine_seq stamped at commit); the listener still runs after release (#249) |
Nats{Trade,BookChange}Publisher::with_max_retries(n): any u32 |
clamped to MAX_PUBLISH_RETRIES (10) with a WARN |
| shutdown drain with NATS down retries every buffered event | after the first exhausted publish the rest is counted in dropped_events |
NatsPublisherError { TaskPanicked, TaskCancelled } |
adds ShutdownTimedOut { timeout_ms } (shutdown_with_deadline) |
NatsBookChangePublisher::error_count(): one per failed subject |
one per failed batch |
wire::decode_frame: oversized len → Truncated |
len > MAX_FRAME_BODY (4096) → InvalidPayload; encode_frame refuses it |
wire::encode_exec_report / encode_book_update encode any status / _pad / side |
Err(WireError::InvalidPayload(..)) for values the decoders reject |
Re-exported pricelevel items change with pricelevel 0.10:
MatchResult carries the failure that stopped a level mid-match
(MatchResult::error(), the committed prefix is kept),
PriceLevel::snapshot() returns Result, Trade::new is gone (use
Trade::with_timestamp), UuidGenerator::next is now try_next,
OrderType::match_against / refresh_iceberg return Result, and
PriceLevelError has new variants (CapacityExceeded,
CounterExhausted, EntropyUnavailable). Callers of the snapshot
functions add ? (or handle the error); callers of evict_expired_orders
do the same; code that treated an empty MassCancelResult as "nothing
to cancel" should also check has_failures(). NATS users handle the
Result now returned by shutdown(). Callers of the book analytics add
? (or match the error); iterator consumers handle each item
(level?, or collect::<Result<Vec<_>, _>>()?).
v0.13.0 — the public API hands out no level handles (#228); exclusive submit gate under STP (#225); replay re-executes coded submit rejections (#224)
- Breaking (semver-minor under 0.x):
OrderBook::get_bidsandOrderBook::get_asksare removed (#228). Both cloned the book's liveArc<PriceLevel>handles into aDashMap, andPriceLevelexposesadd_order,update_orderandmatch_orderpublicly, so a caller holding one could mutate a price level behind the submit gate, theorder_locations/ user-order indices, the risk state, self-trade prevention, the kill switch, the order-state tracker and the trade / book-change listeners. Deprecating them would have left the bypass reachable, so they are gone and 0.13.0 is the release boundary for breaking changes. Migrate to the read-only APIs, which return values rather than handles:create_snapshot(depth)for a full snapshot of every level and order;levels_with_cumulative_depth,levels_until_depth,levels_in_rangeandfind_levelforLevelInfoviews;order_count_at_price,get_orders_at_price,get_all_ordersandtotal_depth_at_levelsfor per-price and per-book order data;best_bid/best_askfor the top of book. Every level mutation now goes throughOrderBook. - Breaking (semver-minor under 0.x): the level iterators'
newconstructors are crate-private (#228).LevelsWithCumulativeDepth::new,LevelsUntilDepth::newandLevelsInRange::neweach take a reference to the book's live price-level map, and withget_bids/get_asksgone no public API yields one. The iterator types stay public; obtain them fromOrderBook::levels_with_cumulative_depth,levels_until_depthandlevels_in_range. - Self-trade prevention holds under concurrent same-user admission
(#225). An STP-relevant submit decided a price level's
STPActionfrom a queue snapshot and then filled that level in a second operation, both under the shared side of the submit gate — so a concurrent same-user admission could land between the two and be filled by the very sweep the scan was protecting. STP-relevant submits and the cancel-then-add modify variants whose re-add can match (UpdatePrice,UpdatePriceAndQuantity,Replace) now take the exclusive side, so the scan and the fill it authorises observe the same queue. Cost: on an STP book every identified submit except post-only, and every matching-capable re-price, is serialized.STPMode::Nonebooks, post-only submits,UpdateQuantityandCancelkeep the shared, fully concurrent path. With no level handles left to bypass it (#228), the gate now covers every mutation. SequencerResult::RejectedWithCode { reason, code, may_have_mutated, stp_mode }.add_orderemits real fills and then returnsErrfor an IOC's unfillable remainder and for a taker STP cancels after non-self fills;ReplayEngineskipped every rejected event, so replay rebuilt liquidity the live book had consumed. Producers now opt in by recording the typed outcome —SequencerResult::from(&error)fills all four fields — and replay decides by the recorded code: a submit rejected under a code replay can reproduce from the book state andReplayBookConfigis re-executed and must fail the same way again, while codes whose trigger lives outside the config (kill switch, risk limits,Other) are skipped rather than re-executed, because a rejection that never touched the book is reproduced by doing nothing.last_applied_seq/ the applied count / the progress callback follow what was dispatched, so a re-executed rejection advances them.- The two facts the reject code cannot carry.
may_have_mutatedflags the errors the engine can return after changing the book, including the residual-admissionPriceLevelErrorthat maps toRejectReason::Other(0); a flagged submit is re-executed whatever its code says, so that rejection no longer replays as a no-op that resurrects consumed liquidity.stp_moderecords the mode that decided a self-trade-prevention rejection, and replay refuses a mismatchedReplayBookConfig. ReplayError::OutcomeMismatch { sequence_num, recorded, actual }aborts replay when a re-executed rejection succeeds or fails under a different code than the journal recorded;ReplayError::StpModeMismatch { sequence_num, recorded, actual }aborts it when a journaled STP rejection was decided under a differentSTPModethan the replay book uses.- Migration. The string-only
SequencerResult::Rejectedkeeps its historical skip, so a journal written with it keeps the pre-existing gap for traded-then-rejected submits; switch producers toRejectedWithCode. Journals carrying the new variant cannot be decoded by older readers (existing journals decode unchanged, as forMarketOrderByAmount). Limitations: only the reject code is reconciled, never the error's details or the fills behind it, so a discrepancy confined to them can go undetected —snapshots_match(directly, or viaReplayEngine::verify) is the check that catches a diverged book, andreplay_fromperforms none. Thestp_modeguard only fires on journals that recorded an STP rejection. Breaking (semver-minor under 0.x):ReplayErrorgained two variants, so exhaustive matches need new arms; 0.13.0 is the release boundary for them together with the #228 removal. No snapshot format change. - Reserve orders are lot-size validated per tranche and on their
replenishment transfer (#226). A
ReserveOrderused to be checked on its total only, so a 15 visible / 5 hidden reserve was admitted to a lot-10 book while the identical iceberg was rejected. It now takes the iceberg's per-tranche rule and, additionally, validates the capped quantity replenishment transfers from hidden into the visible tranche —min(replenish_amount.unwrap_or(DEFAULT_RESERVE_REPLENISH_AMOUNT), hidden), checked whilehidden > 0andauto_replenishis on. Admission is strictly tighter: a shape previously admitted on its total is now rejected withInvalidLotSize, carrying the offending tranche or transfer. - A reserve residual follows
auto_replenish(#230). The residual-resting helper behindOrderQuantity::set_total_remainingrefreshed an emptied visible tranche fromreplenish_amountalone, ignoringauto_replenish, falling back to a refresh of zero (which could rest a zero-visible order) and never consultingreplenish_threshold. It now appliespricelevel's rule: with automatic replenishment on and hidden left, a visible tranche belowmax(replenish_threshold, 1)grows by the explicit amount orDEFAULT_RESERVE_REPLENISH_AMOUNT, capped by hidden; with it off the residual does not rest at all and its hidden remainder is discarded, mirroring the removal of a depleted non-auto maker. A 10 visible / 20 hidden reserve withreplenish_amount = Some(10)and no automatic replenishment, filled for 10, used to rest 10 / 10 and now ends asFilled { filled_quantity: 10 }; the same order with automatic replenishment and a threshold of 5, filled for 8, used to rest 2 / 20 and now rests 12 / 10. The discard needs the visible tranche to be exhausted: with automatic replenishment off, a 10 / 20 reserve filled for 5 still rests 5 / 20. An explicitreplenish_amountis the transfer, added to whatever visible quantity survived, not a target display size. The accounting rule issubmitted = executed + resting (visible + hidden) + discarded, and discarded quantity is never counted as executed. A discard emits anINFOtrace and, under themetricsfeature, the neworderbook_reserve_discards_total/orderbook_reserve_hidden_discarded_totalcounters, carrying apathfield so the aggressive taker and the removed maker report the same discard the same way; the returned order handle carries both tranches at zero. Because the three cancel-then-add modify variants re-add the order as a taker, a validate-first pre-check now rejects a re-price that would exhaust such a reserve's visible tranche with the newOrderBookError::ReserveResidualWouldBeDiscardedbefore the original is cancelled, so a re-price of such a reserve cannot destroy the order it modifies: the exclusive guard covers the lookup, the validation, the cancel and the re-add, so the dry run is exact. That is the scope of the guarantee; it is not a claim about every possible modification failure. Crossing into depth smaller than the visible tranche, a non-crossing re-price and a projected full fill are all allowed through. The error carries both the projectedhidden_quantityand thediscarded_quantitythat would actually be destroyed.RejectReasongains the matching wire code 14; both enums are#[non_exhaustive]. In a book that holds such a reserve, every sweep now takes the exclusive submit gate in everySTPMode— matching-capable submits, cancel-then-add re-prices and the match-only entry points alike, plus the admission of the first one — so nothing can cancel, admit or replace an order inside a sweep's capture window: the sweep cannot consume a maker it never captured, nor report a captured maker after a cancel freed its id. Cancels and mass cancels keep the shared side. Those books serialize their sweeps; books holding none are unchanged. - A non-replenishing reserve must display a positive visible tranche
(#230). A
ReserveOrderwithauto_replenish == false,visible_quantity == 0andhidden_quantity > 0used to rest as a ghost: no visible depth, andpricelevelremoves it without a trade, stranding the whole hidden tranche, on the first taker to reach the level. Since #221 a zero quantity onUpdatePriceAndQuantity/Replacecould drive a healthy resting reserve into that shape too;UpdateQuantitywith a zero quantity cannot, because it is a removal taken before the validator runs (#223, below).validate_order_shapenow rejects it with the newOrderBookError::ZeroVisibleTranche, coveringadd_order, every modify projection and snapshot restore; a rejected modify leaves the original resting and a rejected restore leaves the book untouched. The rule is that shape only: a zero-visible iceberg draws its whole hidden tranche into visible on match, and a zero-visible auto-replenishing reserve refreshes and re-queues, so both execute and stay admissible. Single-tranche kinds are unaffected. Maps to the existingRejectReason::InvalidQuantity. OrderUpdate::UpdateQuantitywith a zero quantity cancels the order (#223). A zeronew_quantitywas accepted and applied as a resize:pricelevelkeeps a non-growing total in place, so the maker rested at zero depth, heldbest_bid/best_askon a level with nothing behind it and was later dropped by a sweep with no trade and no cancel event, leaking itsorder_locationsentry —cancel_orderthen returnedOk(None)while re-adding the id reportedDuplicateOrderId. The arm now routes to the sameUserRequestedcancelOrderBook::cancel_orderperforms, so the level-change event, theCancelled { UserRequested }transition, the per-account risk release, the location / user-index untrack and the empty-level removal happen in lockstep. A zero requested quantity is a removal, not a resize: it cancels the entire order, hidden depth of an iceberg or reserve included (a nonzeronew_quantitystill resizes only the visible tranche), and it runs neither the projected-order validator nor the modify-aware risk check, so neither a configuredmin_order_sizenor a risk limit vetoes it. The kill switch still refuses it, as it refuses every modify. The removal semantic isUpdateQuantity's alone: a zero quantity onReplace/UpdatePriceAndQuantityre-adds through validate-first (#230 above). Compatibility: a journal recorded before this change that contains a zeroUpdateQuantityreplays to the new outcome, so the replayed book legitimately differs from the one the original run produced.- Reserve
UpdatePriceAndQuantityhonours the requested visible quantity (#221).OrderQuantity::set_quantityread a reserve's argument as a total target and only ever reduced, so a requested increase was silently dropped (a 30 / 70 reserve asked to move to 80 ended at 10 / 70) and a decrease was drawn across both tranches. It now sets the visible tranche and leaves hidden untouched for both two-tranche kinds, matchingUpdateQuantity,Replaceand the upstreampricelevelcontract. CancelTakerandCancelBothfire only on a same-user maker the taker can reach (#222). Both arms used to cancel unconditionally once a same-user maker rested at a crossed level, even when the non-self depth queued ahead of it already satisfied the taker. A client sawSelfTradePreventedon an order that had in fact filled, andCancelBothdestroyed a maker the sweep never touched — silently on the market paths, which drop the taker-cancelled flag and returnOk. The arms now execute against the non-self depth first and cancel only if the taker could still execute at that price afterwards.STPMode::CancelMakeris deliberately unchanged: it still cancels every same-user order at a level the sweep touches, since it never destroys the taker. The modify pre-checkcheck_modify_stp_self_crossfollows the same per-level rule and sizes the pre-match withpricelevel's authoritative dry run rather than the counted visible depth, so it cannot admit a re-price the sweep would then kill after the original was cancelled.- A quote-notional sell walks past a bid it cannot afford (#222). A zero per-level quantity cap ended the whole sweep. That is right for a base-quantity budget, whose cap ignores the level price, and for a notional buy, which walks asks ascending so the cap only shrinks. It was wrong for a notional sell, which walks bids descending: a budget too small at one bid can fund a whole lot at a cheaper one. Selling 150 into bids of 100, 75 and 50 executed one unit instead of two. The sell walk now skips the unaffordable level and stops only on a spent budget, an exhausted side, or a remainder below one lot — the point at which no price could fund a lot. It may therefore visit every level on the bid side; each skipped level costs one division and mutates nothing.
pricelevel0.8.4 → 0.9.1. Major upstream hardening release: level admission validates before mutating (duplicate id, counter capacity, price/side topology), PostOnly / fill-or-kill decisions are atomic with the sweep, execution statistics are torn-read-safe, and level snapshots materialize orders in queue-consumption order. 0.9.1 fixes theMatchResultbincode round-trip (PriceLevel#135), keeping thebincodefeature's trade-event round-trip intact.- The upsize queue-priority demotion now survives a snapshot
round-trip (#205). Restoring a snapshot rebuilds each level's queue
exactly as matching would consume it, so an order demoted by a quantity
increase keeps its back-of-queue position after
restore_from_snapshot_package. Locked in by a proptest regression (tests/unit/props_quantity_update_priority.rs). Snapshots captured with pricelevel < 0.9 restore demoted orders at their old(timestamp, seq)position — re-snapshot to pin the corrected order. - Breaking (semver-minor under 0.x):
get_bt_bids/get_bt_asksnow returnResult<BTreeMap<u128, PriceLevel>, OrderBookError>(snapshot-to-level conversion is validating and fallible upstream), and the re-exported pricelevel surface changed —PriceLevel::add_orderreturnsResult,matchable_quantitytakes the taker id,PriceLevelErrorgainedDuplicateOrderId. - Atomic PostOnly / multi-level FOK (#209). PostOnly submits thread
TakerKind::PostOnlyinto every per-level match, making it structurally impossible for a post-only order to take liquidity under any interleaving; fill-or-kill submits hold a new book-level submit gate exclusively across feasibility + sweep, so multi-level all-or-nothing can no longer partially execute against concurrent cancels. Other mutating entry points take the gate's uncontended read side; the matching core stays lock-free. Full 0.11.0 → 0.12.0 HDR tail-latency comparison inBENCH.md: every scenario's median is unchanged by this release's book-level work; the one median shift (stp_sweep, from the pricelevel 0.9 hardening) is documented there with its bisection. - Atomic, observable mutation failures (#211).
UpdateQuantityis validate-first (projected tick / lot / min-max / representability / risk before touching the level), propagates upstreamPriceLevelErrors instead of returningOk(None), and updates risk counters on success; a taker whose residual cannot rest is rejected before the sweep trades; a failed racy admission cleans up any empty level it created. - Two-tranche quantity conservation (#210). An aggressive iceberg's
residual rests with exactly the unmatched total distributed across
tranches (
visible = min(display, remainder), rest hidden) instead of inflating the book, and avisible + hiddenoverflow is rejected at admission with the new typedOrderBookError::QuantityOverflowbefore any trade or mutation. Conservation (executed + resting == submitted) is property-tested. snapshots_matchcompares full maker state and FIFO (#208). The replay oracle now checks every level's order vector in queue-consumption order (ids, variants, users, quantities, timestamps, TIF, type-specific fields) and the deterministic statistics counters includingstats_degraded; only the wall-time statistics aggregates (first_arrival_time,last_execution_time,sum_waiting_time— see thesnapshots_matchdocs for why each is inherently divergent) and the capture timestamp stay excluded. Contract tightening: aggregate-equal books with reversed FIFO or different maker identity no longer certify as replay-equal.- Failure-atomic snapshot restore (#207).
restore_from_snapshotandrestore_from_snapshot_packagevalidate every level (and reject cross-level duplicate order ids withDuplicateOrderId) against off-book structures before clearing the live book, so a failed restore leaves the pre-restore state — orders, indices, config, risk, kill-switch, engine sequence — completely untouched. - Snapshot package format v3 (#206). Pricelevel 0.9 statistics can
serialize a
stats_degradedfield that 0.8 readers reject, so newly written packages are stampedORDERBOOK_SNAPSHOT_FORMAT_VERSION = 3. Reads acceptORDERBOOK_SNAPSHOT_MIN_READ_VERSION (2)..=3— legacy v2 packages still restore — while1and future versions stay rejected with the existing typed error.
ReplayBookConfig.trade_id_namespace: Option<Uuid>. v0.10.5 (#199) made the trade-ID namespace injectable onOrderBook, but everyReplayEngine::replay_from*entry point still built its book with a random namespace, so trade IDs produced through the shipped replay API were not reproducible. The config now carries the live book's namespace and applies it viaOrderBook::set_trade_id_namespacebefore any journal events are replayed; a*_with_configreplay under an injectedClockthen reproduces the live trade-ID stream byte-identically.ReplayBookConfig::newkeeps its six structural parameters (namespace defaults toNone) — chain the newwith_trade_id_namespace(namespace)builder to set it. Without a namespace the fresh book keeps a random one, as before.- Suffix replays with a namespace are rejected. Applying a
namespace restarts the trade-ID counter at 0, so a namespace-carrying
config with
from_sequence != 0would mint wrong or duplicate IDs; the*_with_configentry points return the new typedReplayError::NamespaceRequiresFullReplayinstead. Namespace-free suffix replay keeps working. - Breaking (semver-minor under 0.x):
ReplayBookConfiggained a public field, so exhaustive struct literals no longer compile — addtrade_id_namespace: Noneor use..Default::default(); andReplayErrorgained theNamespaceRequiresFullReplayvariant, so exhaustive matches need a new arm.ReplayBookConfig::new(...)callers are unaffected. No journal or snapshot format change, noORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump.
OrderBook::set_trade_id_namespace(&mut self, namespace: Uuid). Every constructor used to mint the trade-ID namespace internally withUuid::new_v4(), so trade IDs differed between a live run and its replay even with an injectedClockand an identical command stream — the namespace was the only entropy left in the trade-ID stream (pricelevel::UuidGeneratoris UUID v5 over namespace + counter). The new setter, symmetric withset_clock, replaces the generator (counter restarts at 0) and composes with every existing constructor. Call it before any orders are submitted.OrderBook::with_clock_and_namespace(symbol, clock, namespace). Convenience constructor for the fully deterministic setup (injected clock + injected namespace): the same command stream then produces byte-identical trade IDs across live/replay. A deterministic namespace choice such as UUID v5 of the symbol under a venue root gives every book a stable, distinct stream.- Default constructors are unchanged: without injection each book still
gets a fresh random namespace. No wire-format or snapshot change, no
ORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump. Note the guarantee currently applies to books you construct yourself; the sequencer'sReplayEngineentry points still build their books with a random namespace — wiring the seam intoReplayBookConfigis tracked in issue #200.
FeeSchedule::try_calculate_fee(notional, is_maker) -> Result<i128, FeeOverflow>. Fallible variant ofcalculate_feewith identical rounding (truncation toward zero, sign applied after the unsigned-domain magnitude) that returns the newFeeOverflowerror instead of clamping whennotional × |bps|overflowsu128. AnOkvalue is always the mathematically exact fee and equalscalculate_fee's output, so journaled / replayable venues can reject an order rather than record a clamped fee.- Published guaranteed-exact input bound.
FeeSchedule::max_guaranteed_exact_notional_for_bps(bps)(const fn) returns the multiplication-safety boundu128::MAX / |bps|(u128::MAXfor a zero rate) at or below which the fee is guaranteed exact, andFeeSchedule::max_guaranteed_exact_notional()takes the minimum over the maker and taker legs — a single venue-level admission bound that makes the saturating branch ofcalculate_feeprovably unreachable. The guarantee is sufficient, not tight: above the boundtry_calculate_feerejects conservatively even though the clampedcalculate_feevalue can coincide with the exact fee at isolated notionals. calculate_feebehavior is unchanged (bit-identical, including the saturated clamp of magnitudeu128::MAX / 10_000); its docs now state the exactness guarantee.FeeOverflowis re-exported at the crate root. No wire-format change, noORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump.
- Restored pegged / trailing-stop orders re-price again.
restore_from_snapshotrebuilt the resting book but left thespecial_order_tracker(thespecial_ordersfeature) freshly-initialized, so a restored pegged or trailing-stop order was never re-registered and never re-priced after a snapshot restore. The shared rebuild pass now re-registers every restored resting special order in the same deterministic price-then-insertion-sequence walk that repopulatesorder_locations/user_orders. The tracker holds only order ids — the trailing-stop watermark (last_reference_price) and the pegged / stop price live in the order data and survive the round-trip, so no re-pricing state is lost. - No wire-format or public-API change: no new fields, no event-shape change,
and no
ORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump.
cancel_orders_by_useris now byte-identical across restores.restore_from_snapshot_packagerebuilt theuser_ordersindex from each level's order-unstableiter_orders()view, so the per-userVec<Id>came back in a different order on every fresh book (theDashMaphasher is seeded per instance) and a post-restorecancel_orders_by_userdiverged across restores of the same package. The rebuild now walks price levels in the same fixed price-then-insertion-sequence order the mass-cancel sweeps use (PriceLevel::snapshot_by_seq_into), so the restored index — and any subsequent by-user cancel — is deterministic across every restore. The order reflects the resting book at snapshot time, not the original admission history (a snapshot cannot recover that). Pure journal replay was unaffected.- No wire-format or public-API change: no new fields, no event-shape change,
and no
ORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump.
- Deterministic
cancelled_order_idsordering.cancel_all_orders,cancel_orders_by_side, andcancel_orders_by_price_rangenow enumerate cancelled orders through the same fixed traversal the eviction sweep uses: bids first then asks; within a side, price levels in ascending price (theSkipMap's natural key order, no sort); within a level, ascending insertion sequence (PriceLevel::snapshot_by_seq_into, the exact order the matching engine consumes resting orders). Previously the ids were read from order-unstable structures (order_locations/ per-leveliter_orders) whoseDashMaphasher is seeded per instance, so two processes replaying the same command stream could journal divergentSequencerResult::MassCancelledpayloads. The cancelled set and count are unchanged — only the order ofcancelled_order_idsis now byte-identical across processes and replay. cancel_orders_by_useris unchanged and was already replay-stable: it drains theuser_ordersindex in admission-history order. That determinism contract is now documented alongside the others.- No wire-format or public-API change: no new fields, no event-shape change,
and no
ORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump.
- New
OrderBook::evict_expired_orders(now_ms)— a host-driven sweep that removes every resting order whose time-in-force has expired as of the caller-supplied timestamp.now_msis [TimestampMs] (Unix milliseconds, the same unitclock().now_millis()compares against) and is passed in by the caller — the sweep never reads the book's own clock — so a scheduler drives cadence and the sequencer can journal the exact cutoff. The matching hot path is untouched: there is no lazy per-match expiry check, so expiry is an explicit maintenance pass, not an implicit cost on every submit. The honest consequence: an expired-but-unsweptGtd/Dayorder remains resting and matchable — it can still trade until the host calls the sweep; the no-post-expiry-trade guarantee holds only after the sweep runs. Expiry uses the single boundary predicate that admission uses (now >= deadlineforGtd,now >= market_closeforDay), so an order admitted at a given instant is never simultaneously evictable at that instant. Returns the evicted orders asVec<Arc<OrderType<T>>>; a second sweep at the samenow_msis idempotent and returns empty. - Deterministic eviction order. Evicted orders — and the
Cancelled { reason: TimeInForceExpired }state transitions andPriceLevelChangedEvents emitted as a side effect — follow one fixed, replay-stable order: bids first then asks; within a side, price levels in ascending price (theSkipMap's natural key order, no sort); within a level, ascending insertion sequence (the exact order the matching engine consumes resting orders — not the non-deterministiciter_ordersview). Each order is removed through the same single-order cancel path ascancel_order, so the price-level cache, depth statistics,order_locations/user_ordersindices, risk state, special-order tracker, and order-state tracker all stay consistent. - Manager parity.
BookManagerStdandBookManagerTokiogainevict_expired_orders(symbol, now_ms)(per-symbol pass-through,Nonefor an unknown symbol) andevict_expired_across_books(now_ms)(all books, mirroring thecancel_*_across_booksidiom). - Journaled as a sequencer command. New
SequencerCommand::EvictExpiredOrders { now_ms }variant (appended, so existing journals' bincode variant indices are unchanged). Replay applies the journaled cutoff — never the replay clock — so the sweep reproduces byte-identically;snapshots_matchholds between a live book and its replay. Old journals replay unchanged; new journals carrying the variant fail on older binaries, consistent with theMarketOrderByAmountprecedent. NoORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump required (the version gates the snapshot package, not the journal command enum). - Breaking (the reason this is 0.10.0):
SequencerCommandandSequencerResultare now#[non_exhaustive]. Downstream code that matches them exhaustively must add a wildcard arm (_ => …) once and recompile; in exchange, future command/result additions are source-compatible instead of repeating this break. Adding theEvictExpiredOrdersvariant itself is what surfaced the hazard: on the previously-exhaustive enum it would have silently broken downstream matches inside the 0.9.x range. TimestampMsre-exported at the crate root and via [prelude], so the newnow_msparameter can be constructed without reaching intopriceleveldirectly.- Runnable example:
cargo run -p examples --bin gtd_expiry_sweep.
- Constant-work per-price aggregate accessors (#186) — an O(log N)
point lookup + O(1) counter read, with no per-order materialization. New
read-only methods on [
OrderBook<T>]:visible_quantity_at_price,hidden_quantity_at_price,total_quantity_at_price, andorder_count_at_price. Each does an O(log N)SkipMappoint lookup then reads the level's maintained atomic counter (one relaxed load; two fortotal_quantity_at_price, which sums visible + hidden) — no per-orderArcis materialized and noT: Defaultconversion runs, so they are the cheap way to poll one level's depth or count.order_count_at_priceis the counterpart toqueue_ahead_at_pricethat drops the per-order term: O(log N) here vs O(log N + K) for the queue-walking version. All four returnNonefor an absent level and read advisory, eventually-consistent counters — takecreate_snapshotfor a mutually-consistent view. - Per-call fill attribution, documented and proven (#185). The
add_order_with_resultguarantee is now explicit: concurrent submits on the same book each receive exactly their own fills, because theTradeResultis built from that call's privateMatchResultand the engine holds no shared trade accumulator. On the error-after-fills paths (an unfillable IOC remainder, or a self-trade-prevention cancellation after earlier non-self fills) the caller instead gets the typedErrand the executed fills reach only the trade listener. A multi-thread concurrency test pins it. New convenience wrappersadd_limit_order_with_resultandadd_limit_order_with_user_and_resultmirror the plainadd_limit_order*builders while returning theTradeResultdirectly. - GTD / market-close millisecond unit documented (#187).
has_expired,set_market_close_timestamp, and thetime_in_forceparameter docs now state that GTD deadlines and the market-close timestamp are milliseconds since the Unix epoch (the same unitclock().now_millis()compares against). A pinning test proves a seconds-form deadline reads as instantly expired.
- New public API on [
OrderBook<T>]:add_order_with_resultsubmits an order and returns theTradeResultproduced by the match directly —Ok((Arc<OrderType<T>>, Option<TradeResult>))— instead of relying on theTradeListenercallback.Nonewhen the order produced no fills; an installed listener still fires with the exact sameTradeResult.add_orderis unchanged in behavior and stays free of the extraMatchResultclone when no listener is installed.
- Price-time priority preserved across partial fills. Picks up the
upstream
pricelevelfix (PriceLevel#39) where a partially-filled resting maker keeps its place at the front of the level queue, resolving #88: a partial fill no longer demotes the maker behind later same-price arrivals. Locked in bytest_partial_fill_preserves_price_time_priority_issue_88. - Deterministic match timestamps.
PriceLevel::match_orderno longer reads the wall clock; the engine passes the book's [Clock] time as the taker timestamp, so trade timestamps follow the installed clock and replay stays deterministic. - Domain newtypes on the public surface (breaking). Through the
pricelevelre-exports andMatchResult/OrderTypeaccessors, several values now carryQuantity/Price/TimestampMsinstead of rawu64/u128(e.g.MatchResult::remaining_quantity()now returnsQuantity). OrderBook-rs's own snapshot / statistics queries are unchanged and still return raw integers; downstream code readingpriceleveltypes through the re-exports may need.as_u64()/.as_u128(). Minor bump under0.xsemver. - Dependency refresh:
pricelevel0.7→0.8,async-nats0.47→0.49,dashmap6.1→6.2,bitflags2.11→2.13,either1.15→1.16,crc32fast1→1.5,proptest1.7→1.11.
- New public API on [
OrderBook<T>]:match_market_order_by_amountand the STP-awarematch_market_order_by_amount_with_user, plus the conveniencesubmit_market_order_by_amountandsubmit_market_order_by_amount_with_userwrappers that run the kill-switch and pre-trade risk gates. - Binance
quoteOrderQtysemantics. Callers say "buy ~$1,000 of BTC" without converting to base quantity. The matching loop walks the opposite side until the requested quote-notionalamountis consumed, the book is exhausted, or — whenlot_sizeis configured on the book — the residual notional cannot fund another whole lot. Fees are exclusive: caller paysamount + taker_fee. - Lot enforcement preserved. Per-level base quantity is rounded
down to a multiple of
lot_size, so notional walks never emitqty=0trades when the budget falls below one full lot at the current level price.lot_size = Noneis equivalent tolot = 1. - New error variant
[
OrderBookError::InsufficientLiquidityNotional] — distinct fromInsufficientLiquidityso callers can pattern-match on quote-vs-base semantics. TradeResult.quote_notional: u128— populated for both the base-quantity and quote-notional market-order paths so consumers readΣ price × quantitydirectly without recomputing per-trade.#[serde(default)]keeps existing JSON / Bincode payloads parseable.- Additive
SequencerCommand::MarketOrderByAmount { id, amount, side }variant. Old journals replay byte-identical; new journals carrying this variant fail on older binaries — consistent with the precedent for priorSequencerCommandrollouts. NoORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump required. StopConditionrefactor of the matching loop — single inner implementation drives both base-qty and notional walks. Base-qty path stays allocation- and branch-light: the new helpers fold to the same arithmetic the previous loop emitted whenlot <= 1.- Runnable example:
cargo run -p examples --bin market_order_by_amount. - HDR bench:
notional_walk_hdrmirrorsaggressive_walk_hdrwith the notional path so p50/p99/p99.9/p99.99 can be compared.
- New feature
alloc-counters(default off). Exposes [CountingAllocator] and [AllocSnapshot] at the crate root. Wraps any innerGlobalAllocand tracks fourAtomicU64counters:allocs,deallocs,bytes_allocated,bytes_deallocated. - Bench / test binaries opt in via
#[global_allocator] static A: CountingAllocator<System> = .... The libraryrlibdoes not install a global allocator. bench_countbench +alloc_budget_testsintegration test run the mixed 70/20/10 workload; the bench reportsallocs_per_op, the test asserts a conservative ceiling for regression detection.BENCH.mdgains an "Allocation profile" section.
- New optional
metricsfeature wires Prometheus-style counters and gauges into the matching engine. Defaultoff; when enabled, every increment goes through the globalmetricsfacade so any compatible recorder (Prometheus exporter, OpenTelemetry bridge, etc.) can collect them. - Surface (stable across
0.7.x):orderbook_rejects_total{reason="..."}— counter, one increment per rejected order. Label value is the [RejectReason]Displaystring.orderbook_depth_levels_bid/orderbook_depth_levels_ask— gauges, current count of distinct price levels on each side. Updated on every structural mutation (add, cancel, modify, fill).orderbook_trades_total— counter, monotonic count of every emitted trade transaction (one increment perMatchResulttransaction).
- Determinism preserved. Metrics emission is out-of-band:
no allocation on the happy path, no influence on matching
outcomes, and
restore_from_snapshot_packagedeliberately does not rehydrate counters — they are operational only and live for the process lifetime. The integration testtests/metrics/proves byte-identical snapshots between two books with metrics enabled. - Compile-time no-op. When the feature is off every helper
in [
orderbook::metrics] compiles to an empty function so call-sites in the matching hot path stay unconditional. - Example:
examples/src/bin/prometheus_export.rs(run withcargo run --features metrics --bin prometheus_export) demonstrates installing themetrics-exporter-prometheusrecorder and dumping the exposition payload.
- New
wirefeature flag behind which a small, length-prefixed binary protocol lives — every frame is[len:u32 LE | kind:u8 | payload],lencoverskind + payload, and all multi-byte integers are little-endian. Disabled by default; the existing JSON and bincode paths are unchanged. The protocol is additive. MessageKind—#[repr(u8)]enum with stable explicit discriminants. Inbound:NewOrder = 0x01,CancelOrder = 0x02,CancelReplace = 0x03,MassCancel = 0x04. Outbound:ExecReport = 0x81,TradePrint = 0x82,BookUpdate = 0x83.- Zero-copy inbound —
NewOrderWire,CancelOrderWire,CancelReplaceWire,MassCancelWireare#[repr(C, packed)]withzerocopy::{FromBytes, IntoBytes, Unaligned, Immutable, KnownLayout}derives. Each ships aconst _: () = assert!(size_of::<…>() == N)guard. Decoding is safe —zerocopyperforms the layout validation, nounsafeis required at any wire call site. - Byte-cursor outbound —
ExecReport,TradePrintWire,BookUpdateWireare encoded via explicitextend_from_slicecalls. Outbound is I/O-dominated; this keeps the layout free to evolve. TryFrom<&NewOrderWire> for OrderType<()>— boundary mapping that copies each packed field into a stack local first (taking a reference to a packed field is UB), validates the side / TIF / order_type discriminants, and rejects negative prices viaWireError::InvalidPayload.doc/wire-protocol.mdwith per-message layout tables, discriminant table, framing rule, and endianness statement.- Round-trip
proptestcoverage in everysrc/wire/{inbound,outbound}/*.rsmodule. - Example:
examples/src/bin/wire_roundtrip.rs(required-features = ["wire"]).
- Six new
*_hdrbench binaries underbenches/order_book/:add_only,cancel_only,aggressive_walk,mixed_70_20_10,thin_book_sweep,mass_cancel_burst. Each records per-sample nanosecond latencies into anhdrhistogram::Histogramand emitsp50/p99/p99.9/p99.99+min/max. Coexists with the existing Criterion benches. make bench-hdrconvenience target.- Headline numbers + methodology in
BENCH.mdat the repo root, with a closed-loop disclosure block (the suite measures service time, not load-induced tail).
- New [
RejectReason] — closed#[non_exhaustive] #[repr(u16)]enum with stable explicit discriminants (1..13 +Other(u16)). The canonical wire-side reject taxonomy; consumers can route on the numeric code without parsing strings. OrderStatus::Rejected.reason: Stringis nowRejectReason— typed, machine-routable, and stable across0.7.x. Breaking change on a public variant shape; allowed under the0.6.x → 0.7.xminor delta in0.xsemver.impl From<&OrderBookError> for RejectReason— operational ergonomics. Exhaustive match — a futureOrderBookErrorvariant addition is caught at compile time.- Tracker emission on every reject path that already transitioned
the tracker. Kill switch, risk gates, and the three internal
sites in
modifications.rs(validation / post-only / missing user id) now record typed reasons. STP cancel-taker and IOC/FOKInsufficientLiquiditypaths still return typed errors without transitioning the tracker — deferred to a follow-up.
- New [
RiskConfig] with three opt-in guard-rails:max_open_orders_per_account,max_notional_per_account, andprice_band_bpsagainst a configurable [ReferencePriceSource] (LastTrade/Mid/FixedPrice). Builder pattern —RiskConfig::new().with_*(...)chained. - Three new typed reject variants on the existing
#[non_exhaustive]OrderBookError:RiskMaxOpenOrders,RiskMaxNotional,RiskPriceBand. Each carries enough context (account, current, limit, deviation) for downstream consumers to act without parsing a string. OrderBook::set_risk_config(...)/risk_config()/disable_risk()— operator-driven gating. Check ordering on submit/add:kill_switch → risk → STP → fees → match. Market orders bypass the risk layer (no submitted price, no rest); kill switch still gates them.- Allocation-free on the happy path. Per-account counters are
(AtomicU64, AtomicCell<u128>)pairs; per-order risk state is aDashMap<Id, RiskEntry>. OrderBookSnapshotPackage.risk_config: Option<RiskConfig>— config persists across snapshot/restore. On restore, per-account counters and the per-order map are rebuilt by walking the snapshot's resting orders. Snapshot format version stays at2; the field is additive via#[serde(default)].- Example:
examples/src/bin/risk_limits.rs.
- New
OrderBook::engage_kill_switch(),OrderBook::release_kill_switch(), andOrderBook::is_kill_switch_engaged()— atomic operational halt for new flow. While engaged, everysubmit_market_order*,add_order, and non-Cancelupdate_ordercall returns the new [OrderBookError::KillSwitchActive] variant before any matching, fee, or STP work happens. Cancel and mass-cancel paths are explicitly not gated so operators can drain the resting book. The flag persists across snapshot/restore. OrderBookError::KillSwitchActive— new typed reject variant on the existing#[non_exhaustive]enum.OrderBookSnapshotPackage.kill_switch_engaged: bool— operational state persists across snapshot/restore. Snapshot format version stays at2; the field is additive via#[serde(default)].- When an
OrderStateTrackeris configured, kill-switched rejections are recorded asOrderStatus::Rejected { reason: RejectReason::KillSwitchActive }. - Example:
examples/src/bin/kill_switch_drain.rs.
- New
OrderBook::next_engine_seq()andOrderBook::engine_seq()accessors backed by anAtomicU64counter. Every outbound emission (trade event, price-level change event) mints exactly one seq, in emission order, so external consumers can perform cross-stream gap detection and merge events fromTradeListenerandPriceLevelChangedListenerinto a single ordered view. engine_seq: u64field added to every outbound event type:TradeResult,TradeEvent,PriceLevelChangedEvent, and the NATSBookChangeEntry. JSON payloads are forward-compatible (#[serde(default)]falls back to0for v0.6.x payloads).- Snapshot format version bumped to
2.OrderBookSnapshotPackagecarriesengine_seqso thatrestore_from_snapshot_packageresumes monotonicity exactly from the snapshotted point.version: 1packages are rejected byvalidate(). BookChangeBatch.sequenceretains its existing per-batch publisher-counter semantics; cross-stream gap detection moves to the per-eventBookChangeEntry.engine_seq. Both fields ship in the same payload for incremental adoption.
- New [
Clock] trait with two implementations, [MonotonicClock] (production, wrapsSystemTime::now) and [StubClock] (replay / tests, monotonicAtomicU64counter with configurable start and step). Re-exported at the crate root and via [prelude]. - [
OrderBook::with_clock] constructor plusset_clockandclock()accessors. The default [OrderBook::new] keeps wrapping [MonotonicClock] internally — existing callers observe no behavioural change. ReplayEngine::replay_from_with_clockfor byte-identical replay tests and disaster-recovery pipelines that must reproduce engine timestamps deterministically.- Wall-clock reads are no longer present inside the matching core —
every stamp flows through
self.clock().now_millis(). - Behavioural change (same type signature):
OrderStateTracker::get_historyandOrderBook::get_order_historynow returnVec<(u64 /* milliseconds */, OrderStatus)>instead of nanoseconds; theClock::now_millisunit is the only one the trait exposes.
- Dependency refresh:
uuid1.23,tokio1.52,sha20.11,async-nats0.47,bincode2.0 (crates.iobincode 3.0.0is acompile_error!stub, so2.0is the current usable major). - Bincode API migration (feature
bincode): theBincodeEventSerializernow usesbincode::serde::encode_to_vec/decode_from_slicewithbincode::config::standard(). The public trait and type surface are unchanged. - Wire-format note: bincode 1.x and 2.x produce different byte
layouts on the NATS transport path. The on-disk journal uses
serde_jsonand is unaffected (ORDERBOOK_SNAPSHOT_FORMAT_VERSIONstays at1).
- NATS JetStream Publishers: Trade event and book change publishers with retry, batching, and throttling (
natsfeature) - Zero-Copy Serialization: Pluggable
EventSerializertrait with JSON and Bincode implementations (bincodefeature) - Sequencer Subsystem:
SequencerCommand,SequencerEvent,SequencerResulttypes for LMAX Disruptor-style total ordering - Append-Only Journal:
FileJournalwith memory-mapped segments, CRC32 checksums, and segment rotation (journalfeature) - In-Memory Journal:
InMemoryJournalfor testing and benchmarking - Deterministic Replay:
ReplayEnginefor disaster recovery and state verification from journal - Order State Machine:
OrderStatus,CancelReason,OrderStateTrackerfor explicit lifecycle tracking (Open → PartiallyFilled → Filled / Cancelled / Rejected) - Order Lifecycle Query API:
get_order_history(),active_order_count(),terminal_order_count(),purge_terminal_states() - Upgrade to pricelevel v0.7:
Id,Price,Quantity,TimestampMsnewtypes for stronger type safety
- Order Validation: Tick size, lot size, and min/max order size validation with configurable limits
- Self-Trade Prevention (STP):
CancelTaker,CancelMaker,CancelBothmodes with per-orderuser_idenforcement - Fee Model: Configurable
FeeSchedulewith maker/taker fees, fee fields inTradeResult - Mass Cancel Operations: Cancel all, by side, by user, by price range — with
MassCancelResulttracking - Cross-Book Mass Cancel:
cancel_all_across_books(),cancel_by_user_across_books(),cancel_by_side_across_books()onBookManager - Snapshot Config Preservation:
restore_from_snapshot_package()preserves fee schedule, STP mode, tick/lot size, and order size limits
- Performance Boost:
PriceLevelCachefor faster best bid/ask lookups,MatchingPoolto reduce matching engine allocations - Cleaner Architecture: Refactored modification and matching logic for better separation of concerns
- Enhanced Concurrency: Improved thread-safe operations under heavy load
This project is in active development. The core matching engine, order validation, STP, fees, mass cancel, NATS integration, sequencer journal, and order state tracking are production-ready. The Sequencer runtime (async event loop) is under development.
The order book provides comprehensive market analysis capabilities:
- VWAP Calculation: Volume-Weighted Average Price for analyzing true market price
- Spread Analysis: Absolute and basis point spread calculations
- Micro Price: Fair price estimation incorporating depth
- Order Book Imbalance: Buy/sell pressure indicators
- Market Impact Simulation: Pre-trade analysis for estimating slippage and execution costs
- Depth Analysis: Cumulative depth and liquidity distribution
Advanced utilities for market makers and algorithmic traders:
- Queue Analysis:
queue_ahead_at_price()- Check depth at specific price levels - Tick-Based Pricing:
price_n_ticks_inside()- Calculate prices N ticks from best bid/ask - Position Targeting:
price_for_queue_position()- Find prices for target queue positions - Depth-Based Strategy:
price_at_depth_adjusted()- Optimal prices based on cumulative depth
Memory-efficient, composable iterators for order book analysis:
- Cumulative Depth Iteration:
levels_with_cumulative_depth()- Lazy iteration with running depth totals - Depth-Limited Iteration:
levels_until_depth()- Auto-stop when target depth is reached - Range-Based Iteration:
levels_in_range()- Filter levels by price range - Predicate Search:
find_level()- Find first level matching custom conditions
Items are Result<LevelInfo, OrderBookError>: a level whose depth
overflows is yielded once as Err, then the iterator ends.
Benefits:
- Zero allocation - O(1) memory vs O(N) for vectors
- Lazy evaluation - compute only what's needed
- Composable - works with standard iterator combinators (
.map(),.filter(),.take()) - Short-circuit - stops early when conditions are met
Centralized trade event routing and multi-book orchestration:
- BookManager: Manage multiple order books with unified trade listener
- Standard & Tokio Support: Synchronous and async variants
- Event Routing: Centralized trade notifications across all books
Comprehensive statistical analysis for market condition detection:
- Depth Statistics:
depth_statistics()- Volume, average sizes, weighted prices, std dev - Market Pressure:
buy_sell_pressure()- Total volume on each side - Liquidity Health:
is_thin_book()- Detect insufficient liquidity - Distribution Analysis:
depth_distribution()- Histogram of liquidity concentration - Imbalance Detection:
order_book_imbalance()- Buy/sell pressure ratio (-1.0 to 1.0)
Use cases:
- Market condition detection and trend identification
- Risk management and liquidity monitoring
- Strategy adaptation based on real-time conditions
- Trading decision support and analytics
Pre-calculated metrics in snapshots for high-frequency trading:
- Enriched Snapshots:
enriched_snapshot()- Single-pass snapshot with all metrics - Custom Metrics:
enriched_snapshot_with_metrics()- Select specific metrics for optimization - Metric Flags: Bitflags for precise control over calculated metrics
Metrics included:
- Mid price and spread (in basis points)
- Total depth on each side
- VWAP for top N levels
- Order book imbalance
Benefits:
- Single pass through data vs multiple passes
- Better cache locality and performance
- Reduced computational overhead
- Flexibility with optional metric selection
This analyzes the performance of the OrderBook system based on tests conducted on an Apple M4 Max processor. The data comes from a High-Frequency Trading (HFT) simulation and price level distribution performance tests. The figures below are representative single-run numbers measured on orderbook-rs 0.9.0 with the bundled examples orderbook_hft_simulation and orderbook_contention_test (cargo run --release -p examples --bin <name>); absolute throughput is workload-, machine-, and run-dependent.
- Symbol: BTC/USD
- Duration: 5000 ms (5 seconds)
- Threads: 30 threads total
- 10 maker threads (order creators)
- 10 taker threads (order executors)
- 10 canceller threads (order cancellers)
- Initial orders: 1020 pre-loaded orders
| Metric | Total Operations | Operations/Second |
|---|---|---|
| Orders Added | 465,314 | 93,040.99 |
| Orders Matched | 191,555 | 38,302.02 |
| Orders Cancelled | 183,700 | 36,731.39 |
| Total Operations | 840,569 | 168,074.41 |
| Metric | Initial State | Final State |
|---|---|---|
| Best Bid | 9,900 | 9,840 |
| Best Ask | 10,000 | 10,010 |
| Spread | 100 | 170 |
| Mid Price | 9,950.00 | 9,925.00 |
| Total Orders | 1,020 | 44,987 |
| Bid Price Levels | 21 | 11 |
| Ask Price Levels | 21 | 12 |
| Total Bid Quantity | 7,750 | 346,031 |
| Total Ask Quantity | 7,750 | 473,092 |
- Threads: 12
- Test Duration: 3000 ms per sub-test
- Concurrent Operations: Multi-threaded lock-free architecture
Mixed read/write workload over 500 resting orders across 40 price levels;
the Read % is the fraction of operations that are read-only (snapshot /
best-price / depth queries) versus mutating (add / cancel / match).
| Read % | Operations/Second |
|---|---|
| 0% | 305,435.88 |
| 25% | 63,103.90 |
| 50% | 51,933.72 |
| 75% | 54,960.34 |
| 95% | 100,379.54 |
Throughput as the resting depth is spread across a varying number of price levels (100 orders per level, except the 5- and 1-level cases which pack the same orders into fewer levels).
| Price Levels | Operations/Second |
|---|---|
| 100 | 184,986.35 |
| 50 | 188,085.24 |
| 10 | 70,338.52 |
| 5 | 68,610.25 |
| 1 | 61,302.26 |
All threads hammer a single shared price level (20 hot-spot orders + 480
regular); higher hot-spot percentages concentrate more operations on that one
lock-free level, where the crossbeam-skiplist + dashmap + atomics design
shines.
| % Operations on Hot Spot | Operations/Second |
|---|---|
| 0% | 14,978,484.36 |
| 25% | 19,191,927.99 |
| 50% | 25,890,620.87 |
| 75% | 31,529,898.64 |
| 100% | 31,607,744.24 |
The significant performance gains, especially in the "Hot Spot Contention Test," and the resolution of the previous deadlocks are a direct result of refactoring the internal concurrency model of the PriceLevel.
-
Previous Bottleneck: The original implementation relied on a
crossbeam::queue::SegQueuefor storing orders. While the queue itself is lock-free, operations like finding or removing a specific order required draining the entire queue into a temporary list, performing the action, and then pushing all elements back. This process was inefficient and created a major point of contention, leading to deadlocks under heavy multi-threaded load. -
New Implementation: The
OrderQueuewas re-designed to use a combination of:- A
dashmap::DashMapfor storing orders, allowing for highly concurrent, O(1) average-case time complexity for insertions, lookups, and removals byId. - A sequence-keyed index (a
crossbeam_skiplist::SkipMap<sequence, Id>) that maintains the crucial First-In-First-Out (FIFO) order for matching while still allowing O(log n) ordered iteration and deterministic snapshots.
- A
This hybrid approach eliminates the previous bottleneck, allowing threads to operate on the order collection with minimal contention, which is reflected in the massive throughput increase in the hot spot tests.
The system demonstrates excellent capability to handle over 165,000 operations per second in the high-frequency trading simulation, distributed across order creations, matches, and cancellations.
- Optimal Performance Range: The system performs best with 50-100 price levels, achieving roughly 185,000-188,000 operations per second.
- Performance Degradation: Performance decreases with fewer price levels (more per-level contention), dropping to around 61,000-70,000 operations per second with 1-10 levels.
- Scalability: The lock-free architecture demonstrates excellent scalability characteristics across different price level distributions.
- Surprisingly, performance increases as more operations concentrate on a hot spot, reaching its maximum with 100% concentration (31,607,744 ops/s).
- This counter-intuitive behavior might indicate:
- Very efficient cache effects when operations are concentrated in one memory area
- Internal optimizations to handle high-contention cases
- Benefits of the system's lock-free architecture
- During the HFT simulation, the order book handled a significant increase in order volume (from 1,020 to 44,987).
- The spread increased from 100 to 170, reflecting realistic market behavior under pressure.
- The final state shows substantial liquidity with over 346,000 bid quantity and 473,000 ask quantity.
- The system is suitable for high-frequency trading environments with the capacity to process over 165,000 mixed operations per second (and tens of millions of operations per second on a single hot price level).
- The lock-free architecture proves to be extremely effective at handling contention, especially at hot spots.
- Optimal performance is achieved with moderate price level distribution (50-100 levels).
- For real-world use cases, the system demonstrates excellent scalability and maintains performance under concurrent load.
This analysis confirms that the system design is highly scalable and appropriate for demanding financial applications requiring high-speed processing with data consistency.
This project includes a Makefile with common tasks to simplify development. Here's a list of useful commands:
make build # Compile the project
make release # Build in release mode
make run # Run the main binarymake test # Run all tests
make fmt # Format code
make fmt-check # Check formatting without applying
make lint # Run clippy with warnings as errors
make lint-fix # Auto-fix lint issues
make fix # Auto-fix Rust compiler suggestions
make check # Run fmt-check + lint + testmake doc # Check for missing docs via clippy
make doc-open # Build and open Rust documentation
make create-doc # Generate internal docs
make readme # Regenerate README using cargo-readme
make publish # Prepare and publish crate to crates.iomake coverage # Generate code coverage report (XML)
make coverage-html # Generate HTML coverage report
make open-coverage # Open HTML report
make bench # Run benchmarks using Criterion
make bench-show # Open benchmark report
make bench-save # Save benchmark history snapshot
make bench-compare # Compare benchmark runs
make bench-json # Output benchmarks in JSON
make bench-clean # Remove benchmark datamake git-log # Show commits on current branch vs main
make check-spanish # Check for Spanish words in code
make zip # Create zip without target/ and temp files
make tree # Visualize project tree (excludes common clutter)make workflow-build # Simulate build workflow
make workflow-lint # Simulate lint workflow
make workflow-test # Simulate test workflow
make workflow-coverage # Simulate coverage workflow
make workflow # Run all workflowsℹ️ Requires act for local workflow simulation and cargo-tarpaulin for coverage.
We welcome contributions to this project! If you would like to contribute, please follow these steps:
- Fork the repository.
- Create a new branch for your feature or bug fix.
- Make your changes and ensure that the project still builds and all tests pass.
- Commit your changes and push your branch to your forked repository.
- Submit a pull request to the main repository.
If you have any questions, issues, or would like to provide feedback, please feel free to contact the project maintainer:
- Author: Joaquín Béjar García
- Email: jb@taunais.com
- Telegram: @joaquin_bejar
- Repository: https://github.com/joaquinbejar/OrderBook-rs
- Documentation: https://docs.rs/orderbook-rs
We appreciate your interest and look forward to your contributions!
License: MIT
Repositories by the same author that this project depends on, and repositories that depend on it.
| Repository | Description |
|---|---|
| PriceLevel · crates.io | Lock-free price level implementation for limit order books. |
| Repository | Description |
|---|---|
| hydra-amm · crates.io | Universal AMM engine: build, configure and operate any Automated Market Maker through one interface. |
| market-maker-rs | Quantitative market making strategies, starting with the Avellaneda-Stoikov model. |
| Option-Chain-OrderBook · crates.io | Option chain order book system (underlying, expiration, strike) built on OrderBook-rs, PriceLevel and OptionStratLib. |
| Option-Chain-OrderBook-Backend | REST and WebSocket backend service exposing Option-Chain-OrderBook. |