From 89b38db597e2f5b22fb2a87e0a8831d6f0ecb647 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Thu, 30 Apr 2026 02:24:35 +0200 Subject: [PATCH 1/3] feat(matching): quote-notional market orders (#85) Add a Binance-style `quoteOrderQty` path to the matching engine. Callers say "buy ~$1,000 of BTC" without converting to base quantity; the loop walks the opposite side until the requested notional is consumed or the book runs out. * Public API: `match_market_order_by_amount{,_with_user}` on `OrderBook` and the convenience `submit_market_order_by_amount{,_with_user}` wrappers (kill-switch + risk gates). * Matching loop refactored around a private `StopCondition` enum so one inner implementation drives both base-qty and notional walks. Base-qty path stays branch-light when `lot_size` is unset. * Lot enforcement: per-level base qty rounded down to a multiple of `OrderBook::lot_size` so notional walks never emit `qty=0` trades. * New error variant `OrderBookError::InsufficientLiquidityNotional` (distinct from `InsufficientLiquidity` so callers can route on quote-vs-base semantics). * `TradeResult.quote_notional: u128` populated for both market-order paths; `#[serde(default)]` keeps existing payloads parseable. * Additive `SequencerCommand::MarketOrderByAmount` variant + replay dispatch; old journals still replay byte-identical and no `ORDERBOOK_SNAPSHOT_FORMAT_VERSION` bump is required. * Coverage: 16 integration tests under `tests/unit/`, 15 unit tests on `StopCondition`, 5 trade-result tests, 3 sequencer/replay tests. * Runnable example `market_order_by_amount` and HDR latency bench `notional_walk_hdr` mirroring `aggressive_walk_hdr`. Closes #85. --- CHANGELOG.md | 36 ++ Cargo.toml | 5 + README.md | 43 ++ benches/order_book/notional_walk_hdr.rs | 57 +++ examples/src/bin/market_order_by_amount.rs | 100 ++++ src/lib.rs | 43 ++ src/orderbook/book.rs | 83 ++++ src/orderbook/error.rs | 65 +++ src/orderbook/matching.rs | 531 +++++++++++++++++---- src/orderbook/operations.rs | 59 +++ src/orderbook/reject_reason.rs | 14 + src/orderbook/sequencer/replay.rs | 128 +++++ src/orderbook/sequencer/types.rs | 20 + src/orderbook/trade.rs | 111 ++++- tests/unit/market_order_by_amount_tests.rs | 326 +++++++++++++ tests/unit/mod.rs | 1 + 16 files changed, 1535 insertions(+), 87 deletions(-) create mode 100644 benches/order_book/notional_walk_hdr.rs create mode 100644 examples/src/bin/market_order_by_amount.rs create mode 100644 tests/unit/market_order_by_amount_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5833023..08526df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added — Quote-notional market orders (#85) + +- **New public API** on `OrderBook`: `match_market_order_by_amount` + and `match_market_order_by_amount_with_user`, plus the convenience + wrappers `submit_market_order_by_amount` and + `submit_market_order_by_amount_with_user` (run kill-switch and + pre-trade risk gates before matching). Implements Binance + `quoteOrderQty` semantics — callers say "buy ~$1,000 of BTC" without + converting to base quantity. Fees are exclusive: caller pays + `amount + taker_fee`. +- **Lot enforcement.** When `OrderBook::with_lot_size` is configured, + the per-level base quantity is rounded down to a multiple of + `lot_size`. Notional walks never emit `qty = 0` trades when the + remaining budget cannot fund one full lot at the current level. +- **New error variant `OrderBookError::InsufficientLiquidityNotional + { side, requested, spent }`** distinguishes notional from base-qty + insufficiencies. +- **`TradeResult.quote_notional: u128`** — populated for both + base-quantity and quote-notional market-order paths. Carries + `Σ price × quantity` so consumers do not recompute per-trade. + `#[serde(default)]` keeps pre-0.7.x-tail JSON / Bincode payloads + parseable. +- **Additive `SequencerCommand::MarketOrderByAmount { id, amount, side }`** + variant. Old journals replay byte-identical; the new variant ferries + through `submit_market_order_by_amount` on replay. No + `ORDERBOOK_SNAPSHOT_FORMAT_VERSION` bump required. +- **`StopCondition` refactor of the matching loop** — single inner + implementation drives both base-qty and notional walks. The base-qty + path retains its previous arithmetic profile when `lot_size` is unset + (`lot <= 1` ⇒ no rounding work). +- Runnable example: `cargo run -p examples --bin market_order_by_amount`. +- HDR latency bench: `notional_walk_hdr` mirrors `aggressive_walk_hdr` + on the notional path. + ## [0.7.0] — 2026-04-25 > 0.7.0 ships issues #51..#60 and the centralised `engine_seq` minting diff --git a/Cargo.toml b/Cargo.toml index 48edd6a..bf66b56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,11 @@ name = "aggressive_walk_hdr" path = "benches/order_book/aggressive_walk_hdr.rs" harness = false +[[bench]] +name = "notional_walk_hdr" +path = "benches/order_book/notional_walk_hdr.rs" +harness = false + [[bench]] name = "mixed_70_20_10_hdr" path = "benches/order_book/mixed_70_20_10_hdr.rs" diff --git a/README.md b/README.md index c1de9ee..310f4f1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,49 @@ This order book engine is built with the following design principles: ### What's New in Version 0.7.0 +#### v0.7.0 — Quote-notional market orders (#85) + +- **New public API** on [`OrderBook`]: + [`match_market_order_by_amount`](OrderBook::match_market_order_by_amount) + and the STP-aware + [`match_market_order_by_amount_with_user`](OrderBook::match_market_order_by_amount_with_user), + plus the convenience + [`submit_market_order_by_amount`](OrderBook::submit_market_order_by_amount) + and + [`submit_market_order_by_amount_with_user`](OrderBook::submit_market_order_by_amount_with_user) + wrappers that run the kill-switch and pre-trade risk gates. +- **Binance `quoteOrderQty` semantics.** Callers say "buy ~$1,000 of BTC" + without converting to base quantity. The matching loop walks the + opposite side until the requested quote-notional `amount` is + consumed, the book is exhausted, or — when `lot_size` is configured + on the book — the residual notional cannot fund another whole lot. + Fees are **exclusive**: caller pays `amount + taker_fee`. +- **Lot enforcement preserved.** Per-level base quantity is rounded + down to a multiple of `lot_size`, so notional walks never emit + `qty=0` trades when the budget falls below one full lot at the + current level price. `lot_size = None` is equivalent to `lot = 1`. +- **New error variant + [`OrderBookError::InsufficientLiquidityNotional`]** — distinct from + `InsufficientLiquidity` so 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 × quantity` directly 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 prior `SequencerCommand` rollouts. No + `ORDERBOOK_SNAPSHOT_FORMAT_VERSION` bump required. +- **`StopCondition` refactor 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 when `lot <= 1`. +- Runnable example: `cargo run -p examples --bin market_order_by_amount`. +- HDR bench: `notional_walk_hdr` mirrors `aggressive_walk_hdr` with + the notional path so p50/p99/p99.9/p99.99 can be compared. + #### v0.7.0 — Feature-gated allocation counter - **New feature `alloc-counters`** (default off). Exposes diff --git a/benches/order_book/notional_walk_hdr.rs b/benches/order_book/notional_walk_hdr.rs new file mode 100644 index 0000000..65e73e5 --- /dev/null +++ b/benches/order_book/notional_walk_hdr.rs @@ -0,0 +1,57 @@ +// notional_walk_hdr — taker market orders specified by quote-notional +// amount sweep multi-level book. Mirrors `aggressive_walk_hdr` but uses +// the `match_market_order_by_amount_with_user` path so we can compare +// p50 / p99 / p99.9 / p99.99 against the base-quantity sweep on the +// same book shape. + +#[path = "hdr_common.rs"] +mod common; + +use common::{Rng, new_histogram, owner, persist, record, report}; +use pricelevel::{Id, Side, TimeInForce}; + +const SCENARIO: &str = "notional_walk"; +const RESTING_PER_LEVEL: u64 = 100; +const NUM_LEVELS: u64 = 50; +const MEASURED_OPS: u64 = 100_000; +const SEED: u64 = 0xC1_C1_C1_C1_C1_C1_C1_C1; + +fn main() { + let book = common::fresh_book(); + let mut rng = Rng::new(SEED); + let mut hist = new_histogram(); + let maker = owner(0xAA); + let taker = owner(0xBB); + + // Seed RESTING_PER_LEVEL asks at each of NUM_LEVELS prices. + let mut next_id = 1u64; + for level in 0..NUM_LEVELS { + let price = (100 + level) as u128; + for _ in 0..RESTING_PER_LEVEL { + let _ = book.add_limit_order_with_user( + Id::from_u64(next_id), + price, + rng.range(1, 10), + Side::Sell, + TimeInForce::Gtc, + maker, + None, + ); + next_id += 1; + } + } + + // Aggressive notional Buy sweeps. Random budgets in [500, 2_000) + // quote ticks — usually clear a few orders at the best level or + // walk into the next. + for i in 0..MEASURED_OPS { + let amount = rng.range(500, 2_000) as u128; + let id = Id::from_u64(next_id + i); + record(&mut hist, || { + let _ = book.submit_market_order_by_amount_with_user(id, amount, Side::Buy, taker); + }); + } + + report(SCENARIO, &hist); + persist(SCENARIO, &hist).expect("persist hgrm"); +} diff --git a/examples/src/bin/market_order_by_amount.rs b/examples/src/bin/market_order_by_amount.rs new file mode 100644 index 0000000..f4411f5 --- /dev/null +++ b/examples/src/bin/market_order_by_amount.rs @@ -0,0 +1,100 @@ +// examples/src/bin/market_order_by_amount.rs +// +// Demonstrates the quote-notional market-order path +// (`match_market_order_by_amount`). +// +// Run with: +// +// cargo run --example market_order_by_amount +// +// (See `examples/Cargo.toml` for the binary registration. Locally: +// `cargo run -p examples --bin market_order_by_amount`.) + +use orderbook_rs::OrderBook; +use pricelevel::{Id, Side, TimeInForce, setup_logger}; +use tracing::info; + +fn main() { + let _ = setup_logger(); + info!("Quote-notional market order demo"); + + // Lot size = 10 base units. Resting orders must be multiples of 10. + let book: OrderBook<()> = OrderBook::with_lot_size("BTC/USDT", 10); + + seed_ask_wall(&book); + info!("Best ask: {:?}", book.best_ask()); + + notional_buy_exact_fit(&book); + notional_buy_with_dust(&book); + notional_buy_walks_levels(&book); + notional_buy_below_one_lot_errors(&book); +} + +fn seed_ask_wall(book: &OrderBook<()>) { + // Three ask levels: 100 @ 50, 101 @ 50, 102 @ 50 (all multiples of lot=10). + for &(price, qty) in &[(100u128, 50u64), (101, 50), (102, 50)] { + book.add_limit_order( + Id::new_uuid(), + price, + qty, + Side::Sell, + TimeInForce::Gtc, + None, + ) + .expect("seed ask"); + } + info!("Seeded asks: 100x50, 101x50, 102x50 (lot=10)"); +} + +fn notional_buy_exact_fit(book: &OrderBook<()>) { + info!("\n--- Notional buy: $5_000 (exact fit at best ask) ---"); + let result = book + .match_market_order_by_amount(Id::new_uuid(), 5_000, Side::Buy) + .expect("notional buy"); + info!( + " trades: {}, executed_qty: {:?}, executed_value: {:?}", + result.trades().len(), + result.executed_quantity(), + result.executed_value(), + ); +} + +fn notional_buy_with_dust(book: &OrderBook<()>) { + info!("\n--- Notional buy: $1_405 (lot=10 ⇒ rounds down to 10 units = $1_010) ---"); + let result = book + .match_market_order_by_amount(Id::new_uuid(), 1_405, Side::Buy) + .expect("notional buy"); + info!( + " trades: {}, executed_value: {:?}, residual dust = $1_405 - executed_value", + result.trades().len(), + result.executed_value(), + ); +} + +fn notional_buy_walks_levels(book: &OrderBook<()>) { + info!("\n--- Notional buy: $4_040 (walks two levels) ---"); + let result = book + .match_market_order_by_amount(Id::new_uuid(), 4_040, Side::Buy) + .expect("notional buy"); + info!( + " trades: {}, executed_qty: {:?}, executed_value: {:?}", + result.trades().len(), + result.executed_quantity(), + result.executed_value(), + ); + for trade in result.trades().as_vec() { + info!( + " fill price={} qty={}", + trade.price().as_u128(), + trade.quantity().as_u64() + ); + } +} + +fn notional_buy_below_one_lot_errors(book: &OrderBook<()>) { + info!("\n--- Notional buy: $50 (below 1 lot * best price ⇒ insufficient) ---"); + match book.match_market_order_by_amount(Id::new_uuid(), 50, Side::Buy) { + Ok(_) => info!(" unexpectedly succeeded"), + Err(e) => info!(" expected error: {e}"), + } +} diff --git a/src/lib.rs b/src/lib.rs index c24e727..25fe6c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,49 @@ //! //! ## What's New in Version 0.7.0 //! +//! ### v0.7.0 — Quote-notional market orders (#85) +//! +//! - **New public API** on [`OrderBook`]: +//! [`match_market_order_by_amount`](OrderBook::match_market_order_by_amount) +//! and the STP-aware +//! [`match_market_order_by_amount_with_user`](OrderBook::match_market_order_by_amount_with_user), +//! plus the convenience +//! [`submit_market_order_by_amount`](OrderBook::submit_market_order_by_amount) +//! and +//! [`submit_market_order_by_amount_with_user`](OrderBook::submit_market_order_by_amount_with_user) +//! wrappers that run the kill-switch and pre-trade risk gates. +//! - **Binance `quoteOrderQty` semantics.** Callers say "buy ~$1,000 of BTC" +//! without converting to base quantity. The matching loop walks the +//! opposite side until the requested quote-notional `amount` is +//! consumed, the book is exhausted, or — when `lot_size` is configured +//! on the book — the residual notional cannot fund another whole lot. +//! Fees are **exclusive**: caller pays `amount + taker_fee`. +//! - **Lot enforcement preserved.** Per-level base quantity is rounded +//! down to a multiple of `lot_size`, so notional walks never emit +//! `qty=0` trades when the budget falls below one full lot at the +//! current level price. `lot_size = None` is equivalent to `lot = 1`. +//! - **New error variant +//! [`OrderBookError::InsufficientLiquidityNotional`]** — distinct from +//! `InsufficientLiquidity` so 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 × quantity` directly 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 prior `SequencerCommand` rollouts. No +//! `ORDERBOOK_SNAPSHOT_FORMAT_VERSION` bump required. +//! - **`StopCondition` refactor 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 when `lot <= 1`. +//! - Runnable example: `cargo run -p examples --bin market_order_by_amount`. +//! - HDR bench: `notional_walk_hdr` mirrors `aggressive_walk_hdr` with +//! the notional path so p50/p99/p99.9/p99.99 can be compared. +//! //! ### v0.7.0 — Feature-gated allocation counter //! //! - **New feature `alloc-counters`** (default off). Exposes diff --git a/src/orderbook/book.rs b/src/orderbook/book.rs index 786593b..e03b3cb 100644 --- a/src/orderbook/book.rs +++ b/src/orderbook/book.rs @@ -2414,6 +2414,89 @@ where Ok(match_result) } + /// Match a market order specified by quote-notional amount. + /// + /// Walks the opposite side until the requested `amount` is consumed, + /// the book is exhausted, or — when [`Self::lot_size`] is configured — + /// the remaining notional cannot fund another whole lot. This is the + /// classic Binance-style `quoteOrderQty` semantics: callers say "buy + /// ~$1,000 of BTC" without converting to base quantity. + /// + /// Bypasses STP (uses `Hash32::zero()`); use + /// [`Self::match_market_order_by_amount_with_user`] when STP is + /// needed. + /// + /// # Fees + /// + /// Fees are **exclusive** of `amount`. The caller pays + /// `amount + taker_fee`; the book consumes exactly `amount` of quote + /// liquidity (modulo any residual dust below one lot). + /// + /// # Returns + /// + /// `Ok(MatchResult)` whenever at least one transaction occurred. + /// Inspect [`pricelevel::MatchResult::executed_value`] for the actual + /// notional consumed; the residual dust returned to the caller is + /// `requested - executed_value`. The accompanying `TradeResult` + /// emitted to the trade listener carries `quote_notional` populated. + /// + /// # Errors + /// + /// Returns [`OrderBookError::InsufficientLiquidityNotional`] when the + /// book had zero matchable depth (empty or all levels priced beyond + /// the per-level lot affordable from `amount`). + pub fn match_market_order_by_amount( + &self, + order_id: Id, + amount: u128, + side: Side, + ) -> Result { + self.match_market_order_by_amount_with_user(order_id, amount, side, Hash32::zero()) + } + + /// Match a quote-notional market order with Self-Trade Prevention. + /// + /// See [`Self::match_market_order_by_amount`] for the amount / lot / + /// fee semantics. When STP is enabled and `user_id` is non-zero, the + /// matching engine checks resting orders for same-user conflicts + /// before executing fills. + /// + /// # Errors + /// + /// Returns [`OrderBookError::InsufficientLiquidityNotional`] when no + /// liquidity is available, or [`OrderBookError::SelfTradePrevented`] + /// when STP cancels the taker before any fills occur. + pub fn match_market_order_by_amount_with_user( + &self, + order_id: Id, + amount: u128, + side: Side, + user_id: Hash32, + ) -> Result { + trace!( + "Order book {}: Matching notional market order {} for {} at side {:?}", + self.symbol, order_id, amount, side + ); + let match_result = + OrderBook::::match_order_by_amount_with_user(self, order_id, side, amount, user_id)?; + + let trades_emitted = match_result.trades().len() as u64; + if trades_emitted > 0 { + super::metrics::record_trades(trades_emitted); + if let Some(ref listener) = self.trade_listener { + let mut trade_result = TradeResult::with_fees( + self.symbol.clone(), + match_result.clone(), + self.fee_schedule, + ); + trade_result.engine_seq = self.next_engine_seq(); + listener(&trade_result); + } + } + + Ok(match_result) + } + /// Attempts to match a limit order in the order book. /// /// This is a convenience wrapper that bypasses STP (uses `Hash32::zero()`). diff --git a/src/orderbook/error.rs b/src/orderbook/error.rs index 07f3fdf..260f8aa 100644 --- a/src/orderbook/error.rs +++ b/src/orderbook/error.rs @@ -36,6 +36,22 @@ pub enum OrderBookError { available: u64, }, + /// Insufficient liquidity for a quote-notional market order. Returned by + /// the `*_by_amount` paths when the book cannot fund a single whole lot + /// against the requested notional. Distinct from + /// [`OrderBookError::InsufficientLiquidity`] so callers can pattern-match + /// on quote-vs-base semantics. + InsufficientLiquidityNotional { + /// The side of the market order + side: Side, + /// Notional (quote-asset value) requested + requested: u128, + /// Notional actually consumed before the walk gave up. Always `0` + /// when this error is constructed (a non-zero `spent` returns + /// `Ok(MatchResult)` with the partial fill instead). + spent: u128, + }, + /// Operation not permitted for specified order type InvalidOperation { /// Description of the error @@ -200,6 +216,16 @@ impl fmt::Display for OrderBookError { "Insufficient liquidity for {side} order: requested {requested}, available {available}" ) } + OrderBookError::InsufficientLiquidityNotional { + side, + requested, + spent, + } => { + write!( + f, + "Insufficient liquidity by notional for {side} order: requested {requested}, spent {spent}" + ) + } OrderBookError::InvalidOperation { message } => { write!(f, "Invalid operation: {message}") } @@ -371,6 +397,15 @@ impl Clone for OrderBookError { requested: *requested, available: *available, }, + OrderBookError::InsufficientLiquidityNotional { + side, + requested, + spent, + } => OrderBookError::InsufficientLiquidityNotional { + side: *side, + requested: *requested, + spent: *spent, + }, OrderBookError::InvalidOperation { message } => OrderBookError::InvalidOperation { message: message.clone(), }, @@ -540,6 +575,36 @@ mod tests { )); } + #[test] + fn test_clone_insufficient_liquidity_notional() { + let error = OrderBookError::InsufficientLiquidityNotional { + side: Side::Buy, + requested: 1_000_000, + spent: 0, + }; + let cloned = error.clone(); + assert!(matches!( + cloned, + OrderBookError::InsufficientLiquidityNotional { + side: Side::Buy, + requested: 1_000_000, + spent: 0 + } + )); + } + + #[test] + fn test_display_insufficient_liquidity_notional() { + let error = OrderBookError::InsufficientLiquidityNotional { + side: Side::Sell, + requested: 42, + spent: 0, + }; + let s = format!("{error}"); + assert!(s.contains("Insufficient liquidity by notional")); + assert!(s.contains("42")); + } + #[test] fn test_clone_invalid_operation() { let error = OrderBookError::InvalidOperation { diff --git a/src/orderbook/matching.rs b/src/orderbook/matching.rs index 20051e0..74dc824 100644 --- a/src/orderbook/matching.rs +++ b/src/orderbook/matching.rs @@ -13,6 +13,136 @@ use either::Either; use pricelevel::{Hash32, Id, MatchResult, OrderUpdate, Side}; use std::sync::atomic::Ordering; +/// Selects how the matching loop measures its budget. +/// +/// `BaseQty` is the legacy base-asset quantity path (existing market and +/// limit orders). `QuoteAmount` is the quote-notional path used by +/// `match_market_order_by_amount` (Binance `quoteOrderQty` semantics). +/// Always `None` limit price for the notional path — quote-notional is +/// market-only. +#[derive(Debug, Clone, Copy)] +pub(crate) enum MatchMode { + /// Base-quantity match. `limit_price = None` for market orders. + BaseQty { + /// Total base-asset quantity to match. + quantity: u64, + /// Optional price ceiling (Buy) / floor (Sell). `None` for + /// market orders. + limit_price: Option, + }, + /// Quote-notional match (market-only). + QuoteAmount { + /// Total quote-asset value to consume from the book. + amount: u128, + }, +} + +impl MatchMode { + /// Returns the limit-price guard used inside the level walk. `None` + /// for any market path (base-qty market or quote-notional). + #[inline] + #[must_use] + fn limit_price(&self) -> Option { + match self { + Self::BaseQty { limit_price, .. } => *limit_price, + Self::QuoteAmount { .. } => None, + } + } + + /// Returns the initial `MatchResult` quantity slot. For quote-notional + /// the actual base-qty filled is unknown upfront, so `u64::MAX` is + /// used as an upper bound (see `MatchResult::add_trade` invariants). + /// Consumers of the notional path should read trade totals via + /// `MatchResult::executed_quantity()` / `executed_value()` rather + /// than `remaining_quantity` for that mode. + #[inline] + #[must_use] + fn initial_match_quantity(&self) -> u64 { + match self { + Self::BaseQty { quantity, .. } => *quantity, + Self::QuoteAmount { .. } => u64::MAX, + } + } +} + +/// Tracks the matching loop's remaining budget against either base +/// quantity or quote notional. Designed to keep the base-qty hot path +/// allocation- and branch-light: the `BaseQty` arm of every helper is +/// a single arithmetic op the optimizer can fold. +#[derive(Debug, Clone)] +enum StopCondition { + /// Base-quantity remaining. + BaseQty { + /// Base-asset quantity left to fill. + remaining: u64, + }, + /// Quote-notional remaining. + QuoteAmount { + /// Quote-asset value left to consume. + remaining: u128, + }, +} + +impl StopCondition { + /// Build a fresh stop condition from the matching mode. + #[inline] + fn from_mode(mode: &MatchMode) -> Self { + match mode { + MatchMode::BaseQty { quantity, .. } => Self::BaseQty { + remaining: *quantity, + }, + MatchMode::QuoteAmount { amount } => Self::QuoteAmount { remaining: *amount }, + } + } + + /// Per-level base-qty cap respecting `lot_size`. A return of `0` + /// signals the caller to stop walking (dust below one full lot at + /// the current level price). + /// + /// `lot <= 1` ⇒ no rounding (single arithmetic path); preserves the + /// existing base-qty performance profile when lot enforcement is not + /// configured. + #[inline] + #[must_use] + fn level_qty_cap(&self, level_price: u128, lot: u64) -> u64 { + let raw = match self { + Self::BaseQty { remaining } => *remaining, + Self::QuoteAmount { remaining } => { + if level_price == 0 || *remaining < level_price { + return 0; + } + (*remaining / level_price).min(u128::from(u64::MAX)) as u64 + } + }; + if lot <= 1 { raw } else { raw - (raw % lot) } + } + + /// Decrement the remaining budget by what was actually executed at + /// the given price. + #[inline] + fn consume(&mut self, executed_qty: u64, level_price: u128) { + match self { + Self::BaseQty { remaining } => { + *remaining = remaining.saturating_sub(executed_qty); + } + Self::QuoteAmount { remaining } => { + let spent = level_price.saturating_mul(u128::from(executed_qty)); + *remaining = remaining.saturating_sub(spent); + } + } + } + + /// Returns `true` when no further fills are needed (budget exhausted). + #[inline] + #[must_use] + fn is_done(&self) -> bool { + match self { + Self::BaseQty { remaining } => *remaining == 0, + Self::QuoteAmount { remaining } => *remaining == 0, + } + } +} + impl OrderBook where T: Clone + Send + Sync + Default + 'static, @@ -63,10 +193,74 @@ where quantity: u64, limit_price: Option, taker_user_id: Hash32, + ) -> Result { + self.match_order_inner( + order_id, + side, + MatchMode::BaseQty { + quantity, + limit_price, + }, + taker_user_id, + ) + } + + /// Internal entry point for the quote-notional matching path. + /// + /// Public callers reach this through + /// [`OrderBook::match_market_order_by_amount_with_user`]; the function + /// here is the matching-loop seam that drives the unified inner loop + /// with `MatchMode::QuoteAmount`. Always market-only — there is no + /// `limit_price` analogue for notional orders. + /// + /// # Errors + /// Returns [`OrderBookError::InsufficientLiquidityNotional`] when no + /// liquidity could be consumed (empty book or budget below one full + /// lot at every reachable level), or + /// [`OrderBookError::SelfTradePrevented`] when STP cancels the taker + /// before any fills occur. + pub(crate) fn match_order_by_amount_with_user( + &self, + order_id: Id, + side: Side, + amount: u128, + taker_user_id: Hash32, + ) -> Result { + self.match_order_inner( + order_id, + side, + MatchMode::QuoteAmount { amount }, + taker_user_id, + ) + } + + /// Unified matching loop driven by [`MatchMode`] / [`StopCondition`]. + /// + /// One inner implementation handles both base-quantity and + /// quote-notional walks. The `BaseQty` path is identical in shape to + /// the previous implementation: `level_qty_cap` is a no-op for + /// `lot <= 1` and a single `% lot` otherwise; `consume` is one + /// `saturating_sub` per level. The `QuoteAmount` path adds one + /// `u128` divide per level (to derive the per-level qty cap) and one + /// `u128` multiply per fill (to deduct from the remaining notional). + /// + /// `lot_size`, when configured, is enforced uniformly: per-level qty + /// is rounded **down** to a multiple of `lot`. This complements the + /// admission-time validation in `modifications.rs` and ensures + /// notional walks never emit `qty=0` trades when budget is below one + /// full lot. + fn match_order_inner( + &self, + order_id: Id, + side: Side, + mode: MatchMode, + taker_user_id: Hash32, ) -> Result { self.cache.invalidate(); - let mut match_result = MatchResult::new(order_id, quantity); - let mut remaining_quantity = quantity; + let mut match_result = MatchResult::new(order_id, mode.initial_match_quantity()); + let mut stop = StopCondition::from_mode(&mode); + let limit_price = mode.limit_price(); + let lot = self.lot_size.unwrap_or(1); // Determine if STP checks are needed for this match let stp_active = self.stp_mode.is_enabled() && taker_user_id != Hash32::zero(); @@ -79,18 +273,7 @@ where // Early exit if the opposite side is empty if match_side.is_empty() { - if limit_price.is_none() { - crate::orderbook::metrics::record_reject( - crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity, - ); - return Err(OrderBookError::InsufficientLiquidity { - side, - requested: quantity, - available: 0, - }); - } - // remaining_quantity is managed by add_trade(); no manual update needed - return Ok(match_result); + return self.empty_book_result(side, &mode, match_result); } // Use static memory pool for better performance @@ -119,7 +302,7 @@ where // Process each price level for entry in price_iter { let price = *entry.key(); - // Check price limit constraint early + // Check price limit constraint early (only set for limit orders) if let Some(limit) = limit_price { match side { Side::Buy if price > limit => break, @@ -128,6 +311,14 @@ where } } + // Compute per-level base-qty cap respecting both the budget + // (base-qty or notional) and `lot_size`. A zero cap means + // dust-below-lot at the current price ⇒ stop walking. + let qty_cap = stop.level_qty_cap(price, lot); + if qty_cap == 0 { + break; + } + // Get price level value from the entry let price_level = entry.value(); @@ -146,29 +337,26 @@ where STPAction::CancelTaker { safe_quantity } => { // Match up to safe_quantity, then cancel the taker if safe_quantity > 0 { - let match_qty = remaining_quantity.min(safe_quantity); - let saved_remaining = remaining_quantity; - let price_level_match = price_level.match_order( - match_qty, - order_id, - &self.transaction_id_generator, - ); - // Compute actual executed from the sub-match - let executed = - match_qty.saturating_sub(price_level_match.remaining_quantity()); - self.process_level_match( - &mut match_result, - &price_level_match, - &mut filled_orders, - &mut remaining_quantity, - price, - price_level, - side, - &mut empty_price_levels, - ); - // Correct remaining: process_level_match set it to - // sub-match remaining, but we need overall remaining. - remaining_quantity = saved_remaining.saturating_sub(executed); + let match_qty = qty_cap.min(safe_quantity); + if match_qty > 0 { + let price_level_match = price_level.match_order( + match_qty, + order_id, + &self.transaction_id_generator, + ); + let executed = match_qty + .saturating_sub(price_level_match.remaining_quantity()); + self.process_level_match( + &mut match_result, + &price_level_match, + &mut filled_orders, + price, + price_level, + side, + &mut empty_price_levels, + ); + stop.consume(executed, price); + } } stp_taker_cancelled = true; break; @@ -204,28 +392,26 @@ where } => { // Match up to safe_quantity, cancel the maker, then cancel taker if safe_quantity > 0 { - let match_qty = remaining_quantity.min(safe_quantity); - let saved_remaining = remaining_quantity; - let price_level_match = price_level.match_order( - match_qty, - order_id, - &self.transaction_id_generator, - ); - let executed = - match_qty.saturating_sub(price_level_match.remaining_quantity()); - self.process_level_match( - &mut match_result, - &price_level_match, - &mut filled_orders, - &mut remaining_quantity, - price, - price_level, - side, - &mut empty_price_levels, - ); - // Correct remaining: process_level_match set it to - // sub-match remaining, but we need overall remaining. - remaining_quantity = saved_remaining.saturating_sub(executed); + let match_qty = qty_cap.min(safe_quantity); + if match_qty > 0 { + let price_level_match = price_level.match_order( + match_qty, + order_id, + &self.transaction_id_generator, + ); + let executed = match_qty + .saturating_sub(price_level_match.remaining_quantity()); + self.process_level_match( + &mut match_result, + &price_level_match, + &mut filled_orders, + price, + price_level, + side, + &mut empty_price_levels, + ); + stop.consume(executed, price); + } } // Cancel the maker order — look up its user_id from the // snapshot rather than assuming it equals taker_user_id. @@ -249,25 +435,23 @@ where } // --- Normal matching (no STP conflict or after CancelMaker cleanup) --- - let price_level_match = price_level.match_order( - remaining_quantity, - order_id, - &self.transaction_id_generator, - ); + let price_level_match = + price_level.match_order(qty_cap, order_id, &self.transaction_id_generator); + let executed = qty_cap.saturating_sub(price_level_match.remaining_quantity()); self.process_level_match( &mut match_result, &price_level_match, &mut filled_orders, - &mut remaining_quantity, price, price_level, side, &mut empty_price_levels, ); + stop.consume(executed, price); - // Early exit if order is fully matched - if remaining_quantity == 0 { + // Early exit if budget is exhausted + if stop.is_done() { break; } } @@ -299,10 +483,12 @@ where pool.return_price_vec(empty_price_levels); }); + let no_fills = match_result.trades().as_vec().is_empty(); + // If STP cancelled the taker and no fills occurred at all, return STP error. - // When partial fills happened (remaining < original quantity), return Ok - // with the partial result so the caller can see what was executed. - if stp_taker_cancelled && remaining_quantity == quantity { + // When partial fills happened, return Ok with the partial result so the + // caller can see what was executed. + if stp_taker_cancelled && no_fills { self.track_state( order_id, OrderStatus::Cancelled { @@ -320,22 +506,87 @@ where }); } - // Check for insufficient liquidity in market orders - if limit_price.is_none() && remaining_quantity == quantity { - crate::orderbook::metrics::record_reject( - crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity, - ); - return Err(OrderBookError::InsufficientLiquidity { - side, - requested: quantity, - available: 0, - }); + // Check for insufficient liquidity on market paths. + if no_fills { + match mode { + MatchMode::BaseQty { + quantity, + limit_price: None, + } => { + crate::orderbook::metrics::record_reject( + crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity, + ); + return Err(OrderBookError::InsufficientLiquidity { + side, + requested: quantity, + available: 0, + }); + } + MatchMode::QuoteAmount { amount } => { + crate::orderbook::metrics::record_reject( + crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity, + ); + return Err(OrderBookError::InsufficientLiquidityNotional { + side, + requested: amount, + spent: 0, + }); + } + MatchMode::BaseQty { + limit_price: Some(_), + .. + } => { + // Limit orders that fail to match return Ok with an + // empty result — the unfilled portion becomes resting + // depth in the caller-driven flow. + } + } } - // remaining_quantity is managed by add_trade(); no manual update needed Ok(match_result) } + /// Build the empty-book result. Market paths return a typed error; + /// limit paths return `Ok` with a zero-trade `MatchResult` so the + /// caller can rest the order. + #[cold] + fn empty_book_result( + &self, + side: Side, + mode: &MatchMode, + match_result: MatchResult, + ) -> Result { + match mode { + MatchMode::BaseQty { + quantity, + limit_price: None, + } => { + crate::orderbook::metrics::record_reject( + crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity, + ); + Err(OrderBookError::InsufficientLiquidity { + side, + requested: *quantity, + available: 0, + }) + } + MatchMode::QuoteAmount { amount } => { + crate::orderbook::metrics::record_reject( + crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity, + ); + Err(OrderBookError::InsufficientLiquidityNotional { + side, + requested: *amount, + spent: 0, + }) + } + MatchMode::BaseQty { + limit_price: Some(_), + .. + } => Ok(match_result), + } + } + /// Processes match results from a single price level, updating the /// aggregate match result and bookkeeping vectors. /// @@ -354,7 +605,6 @@ where match_result: &mut MatchResult, price_level_match: &MatchResult, filled_orders: &mut Vec, - remaining_quantity: &mut u64, price: u128, price_level: &std::sync::Arc, side: Side, @@ -397,9 +647,6 @@ where filled_orders.push(filled_order_id); } - // Update remaining quantity - *remaining_quantity = price_level_match.remaining_quantity(); - // Check if price level is empty and mark for removal if price_level.order_count() == 0 { empty_price_levels.push(price); @@ -473,3 +720,115 @@ where results } } + +#[cfg(test)] +mod stop_condition_tests { + use super::*; + + #[test] + fn test_base_qty_cap_no_lot_returns_remaining() { + let stop = StopCondition::BaseQty { remaining: 1_000 }; + assert_eq!(stop.level_qty_cap(100, 1), 1_000); + } + + #[test] + fn test_base_qty_cap_rounds_down_to_lot() { + let stop = StopCondition::BaseQty { remaining: 1_005 }; + // lot=100 ⇒ 1_005 - (1_005 % 100) = 1_005 - 5 = 1_000 + assert_eq!(stop.level_qty_cap(50, 100), 1_000); + } + + #[test] + fn test_base_qty_cap_zero_when_below_lot() { + let stop = StopCondition::BaseQty { remaining: 5 }; + assert_eq!(stop.level_qty_cap(50, 100), 0); + } + + #[test] + fn test_quote_amount_cap_basic() { + // 10_000 / 100 = 100 base + let stop = StopCondition::QuoteAmount { remaining: 10_000 }; + assert_eq!(stop.level_qty_cap(100, 1), 100); + } + + #[test] + fn test_quote_amount_cap_dust_below_one_unit() { + // remaining < level_price ⇒ 0 + let stop = StopCondition::QuoteAmount { remaining: 50 }; + assert_eq!(stop.level_qty_cap(100, 1), 0); + } + + #[test] + fn test_quote_amount_cap_lot_rounds_down() { + // 1_400 / 100 = 14, lot=10 ⇒ 14 - (14 % 10) = 10 + let stop = StopCondition::QuoteAmount { remaining: 1_400 }; + assert_eq!(stop.level_qty_cap(100, 10), 10); + } + + #[test] + fn test_quote_amount_cap_zero_when_below_one_full_lot() { + // 1_400 / 1_000 = 1, lot=10 ⇒ 1 - (1 % 10) = 0 + let stop = StopCondition::QuoteAmount { remaining: 1_400 }; + assert_eq!(stop.level_qty_cap(1_000, 10), 0); + } + + #[test] + fn test_quote_amount_cap_zero_price_is_zero_cap() { + // Adversarial input: zero price should not divide-by-zero. + let stop = StopCondition::QuoteAmount { remaining: 1_000 }; + assert_eq!(stop.level_qty_cap(0, 1), 0); + } + + #[test] + fn test_quote_amount_cap_saturates_to_u64_max() { + // remaining = u128::MAX, level_price = 1 ⇒ derived qty would + // exceed u64::MAX; must saturate at u64::MAX. + let stop = StopCondition::QuoteAmount { + remaining: u128::MAX, + }; + assert_eq!(stop.level_qty_cap(1, 1), u64::MAX); + } + + #[test] + fn test_consume_base_qty_subtracts_executed() { + let mut stop = StopCondition::BaseQty { remaining: 100 }; + stop.consume(30, 999); + assert!(matches!(stop, StopCondition::BaseQty { remaining: 70 })); + } + + #[test] + fn test_consume_base_qty_saturates() { + let mut stop = StopCondition::BaseQty { remaining: 5 }; + stop.consume(10, 999); + assert!(matches!(stop, StopCondition::BaseQty { remaining: 0 })); + } + + #[test] + fn test_consume_quote_amount_deducts_price_times_qty() { + let mut stop = StopCondition::QuoteAmount { remaining: 10_000 }; + stop.consume(30, 100); // spent = 100 * 30 = 3_000 + assert!(matches!( + stop, + StopCondition::QuoteAmount { remaining: 7_000 } + )); + } + + #[test] + fn test_consume_quote_amount_saturates() { + let mut stop = StopCondition::QuoteAmount { remaining: 100 }; + stop.consume(10, 1_000); // spent = 10_000 > 100, saturates + assert!(matches!(stop, StopCondition::QuoteAmount { remaining: 0 })); + } + + #[test] + fn test_is_done_base_qty() { + assert!(StopCondition::BaseQty { remaining: 0 }.is_done()); + assert!(!StopCondition::BaseQty { remaining: 1 }.is_done()); + } + + #[test] + fn test_is_done_quote_amount() { + assert!(StopCondition::QuoteAmount { remaining: 0 }.is_done()); + assert!(!StopCondition::QuoteAmount { remaining: 1 }.is_done()); + } +} diff --git a/src/orderbook/operations.rs b/src/orderbook/operations.rs index 34ddb3e..d356f69 100644 --- a/src/orderbook/operations.rs +++ b/src/orderbook/operations.rs @@ -300,4 +300,63 @@ where ); OrderBook::::match_market_order_with_user(self, id, quantity, side, user_id) } + + /// Submit a quote-notional market order. + /// + /// Convenience wrapper around + /// [`OrderBook::match_market_order_by_amount`] that runs the kill + /// switch and pre-trade risk gates before matching. Bypasses STP + /// (uses `Hash32::zero()`); use + /// [`Self::submit_market_order_by_amount_with_user`] when STP is + /// needed. + /// + /// # Errors + /// Returns [`OrderBookError::KillSwitchActive`] when the kill switch + /// is engaged. Propagates [`OrderBookError::InsufficientLiquidityNotional`] + /// from the matching engine when no liquidity is available. + pub fn submit_market_order_by_amount( + &self, + id: Id, + amount: u128, + side: Side, + ) -> Result { + self.check_kill_switch_or_reject(id)?; + // Pre-trade risk gate. Per design decision C, market orders + // currently bypass every check (no submitted price; no rest); + // the call exists to keep the gate ordering consistent across + // submit and add paths. + self.risk_state.check_market_admission(Hash32::zero())?; + trace!( + "Submitting notional market order {} amount={} {}", + id, amount, side + ); + OrderBook::::match_market_order_by_amount(self, id, amount, side) + } + + /// Submit a quote-notional market order with Self-Trade Prevention. + /// + /// See [`Self::submit_market_order_by_amount`] for the amount / lot / + /// fee semantics. + /// + /// # Errors + /// Returns [`OrderBookError::SelfTradePrevented`] when STP cancels + /// the taker before any fills occur. Returns + /// [`OrderBookError::KillSwitchActive`] when the kill switch is + /// engaged. Returns [`OrderBookError::InsufficientLiquidityNotional`] + /// when the book had zero matchable depth. + pub fn submit_market_order_by_amount_with_user( + &self, + id: Id, + amount: u128, + side: Side, + user_id: Hash32, + ) -> Result { + self.check_kill_switch_or_reject(id)?; + self.risk_state.check_market_admission(user_id)?; + trace!( + "Submitting notional market order {} amount={} {} (user: {})", + id, amount, side, user_id + ); + OrderBook::::match_market_order_by_amount_with_user(self, id, amount, side, user_id) + } } diff --git a/src/orderbook/reject_reason.rs b/src/orderbook/reject_reason.rs index a164642..1f6ce70 100644 --- a/src/orderbook/reject_reason.rs +++ b/src/orderbook/reject_reason.rs @@ -218,6 +218,7 @@ impl From<&OrderBookError> for RejectReason { OrderBookError::InvalidPriceLevel(_) => Self::InvalidPriceLevel, OrderBookError::PriceCrossing { .. } => Self::PostOnlyWouldCross, OrderBookError::InsufficientLiquidity { .. } => Self::InsufficientLiquidity, + OrderBookError::InsufficientLiquidityNotional { .. } => Self::InsufficientLiquidity, OrderBookError::InvalidTickSize { .. } => Self::InvalidPrice, OrderBookError::InvalidLotSize { .. } => Self::InvalidQuantity, OrderBookError::OrderSizeOutOfRange { .. } => Self::OrderSizeOutOfRange, @@ -413,6 +414,19 @@ mod tests { ); } + #[test] + fn test_from_order_book_error_insufficient_liquidity_notional() { + let err = OrderBookError::InsufficientLiquidityNotional { + side: Side::Buy, + requested: 1_000_000, + spent: 0, + }; + assert_eq!( + RejectReason::from(&err), + RejectReason::InsufficientLiquidity + ); + } + #[test] fn test_from_order_book_error_serialization_error_maps_to_other_zero() { let err = OrderBookError::SerializationError { diff --git a/src/orderbook/sequencer/replay.rs b/src/orderbook/sequencer/replay.rs index eb5cd33..b9dfe85 100644 --- a/src/orderbook/sequencer/replay.rs +++ b/src/orderbook/sequencer/replay.rs @@ -331,6 +331,13 @@ where source: e, })?; } + SequencerCommand::MarketOrderByAmount { id, amount, side } => { + book.submit_market_order_by_amount(*id, *amount, *side) + .map_err(|e| ReplayError::OrderBookError { + sequence_num: event.sequence_num, + source: e, + })?; + } SequencerCommand::CancelAll => { let _ = book.cancel_all_orders(); } @@ -514,4 +521,125 @@ mod tests { Ok(_) => panic!("expected SequenceGap {{ expected: 3, found: 4 }}, got Ok(_)"), } } + + #[test] + fn test_replay_market_order_by_amount_matches_live_book() { + // Build a journal: seed an ask wall, then take it with a + // notional market order. Replay against a fresh book and + // require the resulting snapshot to match the live one — proves + // the additive variant dispatches identically to the live path. + let journal: InMemoryJournal<()> = InMemoryJournal::new(); + let mut seq = 0u64; + + // Three asks at 100, 101, 102 — each size 10. + for price in [100u128, 101, 102] { + let ev = make_add_event(seq, Id::new_uuid(), price, 10, Side::Sell); + assert!(journal.append(&ev).is_ok()); + seq += 1; + } + + // Notional buy: $1500 sweeps 10@100 + 10@101 = $2010 total — but + // we cap at $1500 so only 10@100 + 4@101 (=$1404) lands. The + // residual $96 is dust < 1*101 = 101 still — actually it can buy + // 0 more at 101 → stop short of the third level. Exact behavior + // doesn't matter for this test; what matters is replay parity. + let taker_id = Id::new_uuid(); + let ev = SequencerEvent::<()> { + sequence_num: seq, + timestamp_ns: 0, + command: SequencerCommand::MarketOrderByAmount { + id: taker_id, + amount: 1_500, + side: Side::Buy, + }, + // Result is informational for replay — replay re-executes + // the command against a fresh book. + result: SequencerResult::Rejected { + reason: "ignored".to_string(), + }, + }; + // Replace the Rejected placeholder with something replay won't + // skip. SequencerResult::Rejected entries are explicitly skipped + // by replay (see replay_from inner match). + let ev = SequencerEvent::<()> { + result: SequencerResult::OrderAdded { order_id: taker_id }, + ..ev + }; + assert!(journal.append(&ev).is_ok()); + + // Drive the live book through the same sequence so we have a + // ground-truth snapshot. + let live_book: crate::OrderBook<()> = crate::OrderBook::new("TEST"); + for price in [100u128, 101, 102] { + live_book + .add_order(OrderType::Standard { + id: Id::new_uuid(), + price: Price::new(price), + quantity: Quantity::new(10), + side: Side::Sell, + time_in_force: TimeInForce::Gtc, + user_id: Hash32::zero(), + timestamp: TimestampMs::new(0), + extra_fields: (), + }) + .expect("seed ask"); + } + // Note: live_book seeds with fresh UUIDs, so the per-level + // visible_quantity post-match is what `snapshots_match` compares. + // Use the same notional amount so the residual book state matches. + let _ = live_book.match_market_order_by_amount(taker_id, 1_500, Side::Buy); + + // Replay journal into a fresh book. + let (replayed, last_seq) = + ReplayEngine::<()>::replay_from(&journal, 0, "TEST").expect("replay must succeed"); + assert_eq!(last_seq, seq); + + let live_snap = live_book.create_snapshot(usize::MAX); + let replayed_snap = replayed.create_snapshot(usize::MAX); + assert!( + snapshots_match(&live_snap, &replayed_snap), + "live and replayed snapshots must match after notional market order" + ); + } + + #[test] + fn test_market_order_by_amount_command_serde_json_roundtrip() { + let cmd: SequencerCommand<()> = SequencerCommand::MarketOrderByAmount { + id: Id::new_uuid(), + amount: 12_345_678, + side: Side::Buy, + }; + let json = serde_json::to_vec(&cmd).expect("serialize"); + let decoded: SequencerCommand<()> = serde_json::from_slice(&json).expect("deserialize"); + match decoded { + SequencerCommand::MarketOrderByAmount { amount, side, .. } => { + assert_eq!(amount, 12_345_678); + assert_eq!(side, Side::Buy); + } + other => panic!("expected MarketOrderByAmount, got {other:?}"), + } + } + + #[cfg(feature = "bincode")] + #[test] + fn test_market_order_by_amount_command_bincode_roundtrip() { + use bincode::config::standard; + use bincode::serde::{decode_from_slice, encode_to_vec}; + let cmd: SequencerCommand<()> = SequencerCommand::MarketOrderByAmount { + id: Id::new_uuid(), + amount: 999_999, + side: Side::Sell, + }; + let bytes = encode_to_vec(&cmd, standard()).expect("encode"); + let (decoded, n) = + decode_from_slice::, _>(&bytes, standard()).expect("decode"); + assert_eq!(n, bytes.len()); + match decoded { + SequencerCommand::MarketOrderByAmount { amount, side, .. } => { + assert_eq!(amount, 999_999); + assert_eq!(side, Side::Sell); + } + other => panic!("expected MarketOrderByAmount, got {other:?}"), + } + } } diff --git a/src/orderbook/sequencer/types.rs b/src/orderbook/sequencer/types.rs index b989f57..6b42a17 100644 --- a/src/orderbook/sequencer/types.rs +++ b/src/orderbook/sequencer/types.rs @@ -39,6 +39,26 @@ pub enum SequencerCommand { side: Side, }, + /// Submit an aggressive market order specified by quote-notional + /// amount. Walks the opposite side until `amount` is consumed, the + /// book is exhausted, or — when `lot_size` is configured on the + /// destination book — the residual notional cannot fund another + /// whole lot. This is the additive Binance-style `quoteOrderQty` + /// counterpart to [`Self::MarketOrder`]. + /// + /// Adding this variant is non-breaking: existing journals replay + /// unchanged. Journals carrying `MarketOrderByAmount` will fail to + /// decode against older binaries — this matches the precedent for + /// previous `SequencerCommand` variant rollouts. + MarketOrderByAmount { + /// The order identifier. + id: Id, + /// The quote-asset value to consume from the book. + amount: u128, + /// The side of the market order (Buy sweeps asks, Sell sweeps bids). + side: Side, + }, + /// Cancel all orders in the book. CancelAll, diff --git a/src/orderbook/trade.rs b/src/orderbook/trade.rs index 9f52e0c..a0b6382 100644 --- a/src/orderbook/trade.rs +++ b/src/orderbook/trade.rs @@ -35,20 +35,34 @@ pub struct TradeResult { /// that pre-date `engine_seq` so existing consumers keep parsing. #[serde(default)] pub engine_seq: u64, + /// Total quote-asset notional consumed by this trade, computed as + /// `Σ price × quantity` across every transaction. Populated for both + /// base-quantity (`match_market_order`) and quote-notional + /// (`match_market_order_by_amount`) market-order paths so consumers + /// have the field uniformly available without recomputing per-trade. + /// + /// Defaults to `0` when deserializing payloads from format versions + /// that pre-date `quote_notional` so existing consumers keep parsing. + #[serde(default)] + pub quote_notional: u128, } impl TradeResult { /// Create a new `TradeResult` with zero fees /// /// Use this constructor when no `FeeSchedule` is configured. - /// Fees default to zero for backward compatibility. + /// Fees default to zero for backward compatibility. The + /// `quote_notional` field is populated from the supplied + /// `match_result` (sum of `price × quantity` across every trade). pub fn new(symbol: String, match_result: MatchResult) -> Self { + let quote_notional = compute_quote_notional(&match_result); Self { symbol, match_result, total_maker_fees: 0, total_taker_fees: 0, engine_seq: 0, + quote_notional, } } @@ -89,12 +103,14 @@ impl TradeResult { _ => (0, 0), }; + let quote_notional = compute_quote_notional(&match_result); Self { symbol, match_result, total_maker_fees, total_taker_fees, engine_seq: 0, + quote_notional, } } @@ -111,6 +127,25 @@ impl TradeResult { } } +/// Sum of `price × quantity` across every trade in `match_result`. +/// +/// Saturates on overflow rather than panicking — overflow on `u128` can +/// only occur in adversarial fixtures with prices near `u128::MAX`, and +/// the matching path already saturates equivalent multiplications. +#[inline] +#[must_use] +fn compute_quote_notional(match_result: &MatchResult) -> u128 { + let mut total: u128 = 0; + for tx in match_result.trades().as_vec() { + let notional = tx + .price() + .as_u128() + .saturating_mul(u128::from(tx.quantity().as_u64())); + total = total.saturating_add(notional); + } + total +} + /// Trade listener specification using Arc for shared ownership pub type TradeListener = Arc; @@ -355,6 +390,80 @@ mod tests { ); } + #[test] + fn test_trade_result_new_populates_quote_notional() { + // single trade: 1000 * 10 = 10_000 + let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]); + let tr = TradeResult::new("BTC/USD".to_string(), mr); + assert_eq!(tr.quote_notional, 10_000); + } + + #[test] + fn test_trade_result_with_fees_populates_quote_notional_multi_trade() { + // 1000*10 + 2000*20 = 10_000 + 40_000 = 50_000 + let mr = make_match_result_with_trades(vec![make_trade(1000, 10), make_trade(2000, 20)]); + let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, None); + assert_eq!(tr.quote_notional, 50_000); + } + + #[test] + fn test_trade_result_quote_notional_zero_when_no_trades() { + let mr = make_match_result_with_trades(vec![]); + let tr = TradeResult::new("BTC/USD".to_string(), mr); + assert_eq!(tr.quote_notional, 0); + } + + #[test] + fn test_trade_result_json_missing_quote_notional_defaults_zero() { + // Pre-quote_notional payload: serialize, strip the field, decode. + let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]); + let tr = TradeResult::new("BTC/USD".to_string(), mr); + + let mut value: serde_json::Value = + serde_json::to_value(&tr).expect("serialize trade to value"); + if let Some(map) = value.as_object_mut() { + map.remove("quote_notional"); + } + let bytes = serde_json::to_vec(&value).expect("serialize stripped value"); + + let decoded: TradeResult = + serde_json::from_slice(&bytes).expect("deserialize stripped trade"); + assert_eq!( + decoded.quote_notional, 0, + "missing quote_notional must default to 0 via #[serde(default)]" + ); + } + + #[test] + fn test_trade_result_json_roundtrip_preserves_quote_notional() { + let mr = make_match_result_with_trades(vec![make_trade(1000, 10), make_trade(2000, 5)]); + let tr = TradeResult::new("BTC/USD".to_string(), mr); + let original = tr.quote_notional; + assert_eq!(original, 20_000); + + let json = serde_json::to_vec(&tr).expect("serialize trade"); + let decoded: TradeResult = serde_json::from_slice(&json).expect("deserialize trade"); + assert_eq!(decoded.quote_notional, original); + } + + #[cfg(feature = "bincode")] + #[test] + fn test_trade_result_bincode_roundtrip_preserves_quote_notional() { + use bincode::config::standard; + use bincode::serde::{decode_from_slice, encode_to_vec}; + + let mr = make_match_result_with_trades(vec![make_trade(1234, 7)]); + let tr = TradeResult::new("BTC/USD".to_string(), mr); + let original = tr.quote_notional; + assert_eq!(original, 8_638); + + let bytes = encode_to_vec(&tr, standard()).expect("bincode encode"); + let (decoded, consumed): (TradeResult, usize) = + decode_from_slice(&bytes, standard()).expect("bincode decode"); + assert_eq!(consumed, bytes.len()); + assert_eq!(decoded.quote_notional, original); + } + #[cfg(feature = "bincode")] #[test] fn test_trade_result_bincode_roundtrip_preserves_engine_seq() { diff --git a/tests/unit/market_order_by_amount_tests.rs b/tests/unit/market_order_by_amount_tests.rs new file mode 100644 index 0000000..479ba51 --- /dev/null +++ b/tests/unit/market_order_by_amount_tests.rs @@ -0,0 +1,326 @@ +//! Integration tests for the quote-notional market-order path +//! (`match_market_order_by_amount` and friends). +//! +//! Covers the acceptance criteria from issue #85: +//! +//! - Single- and multi-level fills against ask / bid walls. +//! - Dust-below-best-price stops the walk cleanly (no `qty=0` trade). +//! - Lot-size enforcement (per-level qty rounded down to multiple of lot). +//! - Empty / exhausted-book error paths +//! (`InsufficientLiquidityNotional`). +//! - Sell-side symmetry. +//! - Fee schedule applied to the resulting `TradeResult` +//! (`amount` is exclusive — caller pays `amount + taker_fee`). +//! - `TradeListener` invoked exactly once per match with +//! `quote_notional` populated. + +use orderbook_rs::orderbook::trade::{TradeListener, TradeResult}; +use orderbook_rs::{FeeSchedule, OrderBook, OrderBookError}; +use pricelevel::{Id, Side, TimeInForce}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +/// Seed an ask wall: three asks at the supplied prices, each `qty` size, +/// using fresh ids. Helpful for setting up most happy-path tests. +fn seed_asks(book: &OrderBook<()>, prices: &[u128], qty: u64) { + for &price in prices { + book.add_limit_order( + Id::new_uuid(), + price, + qty, + Side::Sell, + TimeInForce::Gtc, + None, + ) + .expect("seed ask"); + } +} + +/// Seed a bid wall mirroring `seed_asks` for sell-side tests. +fn seed_bids(book: &OrderBook<()>, prices: &[u128], qty: u64) { + for &price in prices { + book.add_limit_order( + Id::new_uuid(), + price, + qty, + Side::Buy, + TimeInForce::Gtc, + None, + ) + .expect("seed bid"); + } +} + +#[test] +fn test_buy_single_level_exact_fit() { + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100], 100); + + // 100 * 50 = 5_000 fills exactly at best ask, leaves 50 resting. + let result = book + .match_market_order_by_amount(Id::new_uuid(), 5_000, Side::Buy) + .expect("notional buy must succeed"); + + let trades = result.trades().as_vec(); + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].price().as_u128(), 100); + assert_eq!(trades[0].quantity().as_u64(), 50); + assert_eq!(result.executed_value().expect("executed value"), 5_000); +} + +#[test] +fn test_buy_walks_three_levels() { + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100, 101, 102], 10); + + // 100*10 + 101*10 + 102*10 = 1_000 + 1_010 + 1_020 = 3_030 sweeps all. + let result = book + .match_market_order_by_amount(Id::new_uuid(), 3_030, Side::Buy) + .expect("notional buy must succeed"); + + let trades = result.trades().as_vec(); + assert_eq!(trades.len(), 3); + assert_eq!(result.executed_value().expect("executed value"), 3_030); + assert_eq!(result.executed_quantity().expect("executed qty"), 30); +} + +#[test] +fn test_buy_with_dust_stops_short() { + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100], 100); + + // 5_050 / 100 = 50 (residual dust = 50). + let result = book + .match_market_order_by_amount(Id::new_uuid(), 5_050, Side::Buy) + .expect("notional buy must succeed"); + + let trades = result.trades().as_vec(); + assert_eq!(trades.len(), 1, "no qty=0 trade emitted for residual dust"); + assert_eq!(trades[0].quantity().as_u64(), 50); + let spent = result.executed_value().expect("executed value"); + assert_eq!(spent, 5_000); + assert_eq!(5_050u128 - spent, 50, "dust = requested - spent"); +} + +#[test] +fn test_buy_with_lot_size_rounds_down() { + let book: OrderBook<()> = OrderBook::with_lot_size("TEST", 10); + seed_asks(&book, &[100], 100); + + // budget allows 14 units (1_400 / 100 = 14); lot=10 ⇒ 14 - 4 = 10. + let result = book + .match_market_order_by_amount(Id::new_uuid(), 1_400, Side::Buy) + .expect("notional buy must succeed"); + + let trades = result.trades().as_vec(); + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].quantity().as_u64(), 10); + assert_eq!(result.executed_value().expect("executed value"), 1_000); +} + +#[test] +fn test_buy_lot_size_skips_levels_below_one_full_lot() { + let book: OrderBook<()> = OrderBook::with_lot_size("TEST", 10); + // Best ask 1000 (one lot = 10_000 quote); next 100 (one lot = 1_000). + seed_asks(&book, &[100, 1_000], 100); + + // budget = 5_000: + // level 100: 5_000/100 = 50; lot=10 ⇒ 50 (full lots) + // that's 50 units * 100 = 5_000 spent — exact fit. + let result = book + .match_market_order_by_amount(Id::new_uuid(), 5_000, Side::Buy) + .expect("notional buy must succeed"); + assert_eq!(result.executed_value().expect("executed value"), 5_000); +} + +#[test] +fn test_buy_empty_book_errors_with_notional_variant() { + let book: OrderBook<()> = OrderBook::new("TEST"); + let err = book + .match_market_order_by_amount(Id::new_uuid(), 1_000, Side::Buy) + .expect_err("empty book must return error"); + match err { + OrderBookError::InsufficientLiquidityNotional { + side, + requested, + spent, + } => { + assert_eq!(side, Side::Buy); + assert_eq!(requested, 1_000); + assert_eq!(spent, 0); + } + other => panic!("expected InsufficientLiquidityNotional, got {other:?}"), + } +} + +#[test] +fn test_buy_budget_below_one_full_lot_errors() { + let book: OrderBook<()> = OrderBook::with_lot_size("TEST", 10); + seed_asks(&book, &[100], 100); + + // budget = 500 ⇒ 5 units; lot=10 ⇒ qty_cap = 0 ⇒ no fills. + let err = book + .match_market_order_by_amount(Id::new_uuid(), 500, Side::Buy) + .expect_err("must error: budget below one full lot"); + match err { + OrderBookError::InsufficientLiquidityNotional { .. } => {} + other => panic!("expected InsufficientLiquidityNotional, got {other:?}"), + } +} + +#[test] +fn test_sell_symmetric_walk() { + let book: OrderBook<()> = OrderBook::new("TEST"); + // Best bid first (descending walk): 102, 101, 100. + seed_bids(&book, &[100, 101, 102], 10); + + // Sell sweeps highest bids first. budget 3_030 → 102*10 + 101*10 + 100*10 = 3_030 + let result = book + .match_market_order_by_amount(Id::new_uuid(), 3_030, Side::Sell) + .expect("notional sell must succeed"); + + let trades = result.trades().as_vec(); + assert_eq!(trades.len(), 3); + // First trade is at the highest bid (102). + assert_eq!(trades[0].price().as_u128(), 102); + assert_eq!(result.executed_value().expect("executed value"), 3_030); +} + +#[test] +fn test_sell_empty_book_errors_with_notional_variant() { + let book: OrderBook<()> = OrderBook::new("TEST"); + let err = book + .match_market_order_by_amount(Id::new_uuid(), 1_000, Side::Sell) + .expect_err("empty book must error"); + match err { + OrderBookError::InsufficientLiquidityNotional { side, .. } => { + assert_eq!(side, Side::Sell); + } + other => panic!("expected InsufficientLiquidityNotional, got {other:?}"), + } +} + +#[test] +fn test_quote_notional_carried_through_to_listener() { + let mut book: OrderBook<()> = OrderBook::new("TEST"); + let captured_notional = Arc::new(AtomicU64::new(0)); + let captured_count = Arc::new(AtomicUsize::new(0)); + let n_clone = captured_notional.clone(); + let c_clone = captured_count.clone(); + let listener: TradeListener = Arc::new(move |tr: &TradeResult| { + n_clone.store(tr.quote_notional as u64, Ordering::SeqCst); + c_clone.fetch_add(1, Ordering::SeqCst); + }); + book.set_trade_listener(listener); + + seed_asks(&book, &[100], 100); + book.match_market_order_by_amount(Id::new_uuid(), 5_000, Side::Buy) + .expect("notional buy"); + + assert_eq!( + captured_count.load(Ordering::SeqCst), + 1, + "listener invoked exactly once" + ); + assert_eq!( + captured_notional.load(Ordering::SeqCst), + 5_000, + "listener saw quote_notional = sum(price * qty)" + ); +} + +#[test] +fn test_fee_schedule_applies_to_notional_path() { + let mut book: OrderBook<()> = OrderBook::new("TEST"); + book.set_fee_schedule(Some(FeeSchedule::new(0, 5))); // 5 bps taker + let captured_taker_fee = Arc::new(AtomicU64::new(0)); + let f_clone = captured_taker_fee.clone(); + let listener: TradeListener = Arc::new(move |tr: &TradeResult| { + f_clone.store(tr.total_taker_fees as u64, Ordering::SeqCst); + }); + book.set_trade_listener(listener); + + seed_asks(&book, &[100], 1_000); + book.match_market_order_by_amount(Id::new_uuid(), 100_000, Side::Buy) + .expect("notional buy with fees"); + + // 5 bps on notional = 100_000 * 5 / 10_000 = 50. + assert_eq!(captured_taker_fee.load(Ordering::SeqCst), 50); +} + +#[test] +fn test_submit_market_order_by_amount_runs_kill_switch_gate() { + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100], 100); + book.engage_kill_switch(); + + let err = book + .submit_market_order_by_amount(Id::new_uuid(), 1_000, Side::Buy) + .expect_err("kill-switch must reject"); + assert!(matches!(err, OrderBookError::KillSwitchActive)); +} + +#[test] +fn test_existing_base_qty_path_unaffected_by_refactor() { + // Smoke test that the unified inner loop still drives base-qty + // semantics correctly. Single-level fill, single trade, exact qty. + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100], 50); + + let result = book + .submit_market_order(Id::new_uuid(), 30, Side::Buy) + .expect("base-qty market buy"); + let trades = result.trades().as_vec(); + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].quantity().as_u64(), 30); + assert_eq!(trades[0].price().as_u128(), 100); +} + +#[test] +fn test_quote_notional_populated_on_base_qty_path() { + // The new `quote_notional` field is populated for both market-order + // paths so consumers see it uniformly. + let mut book: OrderBook<()> = OrderBook::new("TEST"); + let captured = Arc::new(AtomicU64::new(0)); + let c_clone = captured.clone(); + let listener: TradeListener = Arc::new(move |tr: &TradeResult| { + c_clone.store(tr.quote_notional as u64, Ordering::SeqCst); + }); + book.set_trade_listener(listener); + + seed_asks(&book, &[100], 50); + book.submit_market_order(Id::new_uuid(), 30, Side::Buy) + .expect("base-qty market buy"); + + assert_eq!(captured.load(Ordering::SeqCst), 100 * 30); +} + +#[test] +fn test_buy_partial_fill_when_book_too_thin() { + // budget covers more than the book — we should fill what's there + // and return Ok (not an error) since at least one fill happened. + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100], 50); + + let result = book + .match_market_order_by_amount(Id::new_uuid(), 1_000_000, Side::Buy) + .expect("partial fill must return Ok"); + assert_eq!(result.executed_value().expect("executed value"), 50 * 100); + assert_eq!(result.executed_quantity().expect("executed qty"), 50); +} + +#[test] +fn test_buy_amount_zero_returns_no_fills_error() { + // Zero notional cannot fund anything ⇒ InsufficientLiquidityNotional + // (consistent with the existing base-qty `quantity = 0` semantics + // being treated as a degenerate market order). + let book: OrderBook<()> = OrderBook::new("TEST"); + seed_asks(&book, &[100], 50); + let err = book + .match_market_order_by_amount(Id::new_uuid(), 0, Side::Buy) + .expect_err("zero notional must error"); + assert!(matches!( + err, + OrderBookError::InsufficientLiquidityNotional { .. } + )); +} diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index ae8268f..ddc8588 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -9,6 +9,7 @@ mod implied_volatility_tests; mod integration_workflow_tests; mod kill_switch_tests; mod manager_coverage_tests; +mod market_order_by_amount_tests; mod mass_cancel_tests; mod matching_coverage_tests; mod matching_coverage_tests_extended; From 1c885655c2679528adb7a16cfe0f3660a0c43763 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Sun, 3 May 2026 05:01:28 +0200 Subject: [PATCH 2/3] address review: bump 0.8.0 + fix replay-test result variant + lot-size test + example docstring - Cargo.toml + CHANGELOG.md: bump to 0.8.0 (quote-notional adds new pub field on TradeResult and a new SequencerCommand variant; for a 0.x crate cargo-semver-checks treats either as a breaking change, so a minor bump is required). - README.md / src/lib.rs: relabel the quote-notional release notes under v0.8.0 and split the v0.7.0 entries under their own heading. - examples/src/bin/market_order_by_amount.rs: drop the misleading cargo run --example instruction; the binary lives under the examples workspace member. - src/orderbook/sequencer/replay.rs: replace the OrderAdded placeholder in the MarketOrderByAmount replay test with TradeExecuted{trade_result: ...} so the journal entry stays semantically consistent with a market-by-amount taker. - tests/unit/market_order_by_amount_tests.rs: rename and re-comment the lot-size test so it matches the actual behaviour (asks sort ascending, so best ask is 100 not 1_000). --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- README.md | 6 ++++-- examples/src/bin/market_order_by_amount.rs | 5 +---- src/lib.rs | 6 ++++-- src/orderbook/sequencer/replay.rs | 21 ++++++++++----------- tests/unit/market_order_by_amount_tests.rs | 9 +++++---- 7 files changed, 26 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08526df..5b06b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.8.0] — 2026-05-03 ### Added — Quote-notional market orders (#85) diff --git a/Cargo.toml b/Cargo.toml index bf66b56..e8ce03e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orderbook-rs" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["Joaquin Bejar "] description = "A high-performance, lock-free price level implementation for limit order books in Rust. This library provides the building blocks for creating efficient trading systems with support for multiple order types and concurrent access patterns." diff --git a/README.md b/README.md index 310f4f1..e90ef7a 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ This order book engine is built with the following design principles: - **Research**: Platform for studying market microstructure and order flow - **Educational**: Reference implementation for understanding modern exchange architecture -### What's New in Version 0.7.0 +### What's New in Version 0.8.0 -#### v0.7.0 — Quote-notional market orders (#85) +#### v0.8.0 — Quote-notional market orders (#85) - **New public API** on [`OrderBook`]: [`match_market_order_by_amount`](OrderBook::match_market_order_by_amount) @@ -91,6 +91,8 @@ This order book engine is built with the following design principles: - HDR bench: `notional_walk_hdr` mirrors `aggressive_walk_hdr` with the notional path so p50/p99/p99.9/p99.99 can be compared. +### What's New in Version 0.7.0 + #### v0.7.0 — Feature-gated allocation counter - **New feature `alloc-counters`** (default off). Exposes diff --git a/examples/src/bin/market_order_by_amount.rs b/examples/src/bin/market_order_by_amount.rs index f4411f5..d5be2c8 100644 --- a/examples/src/bin/market_order_by_amount.rs +++ b/examples/src/bin/market_order_by_amount.rs @@ -5,10 +5,7 @@ // // Run with: // -// cargo run --example market_order_by_amount -// -// (See `examples/Cargo.toml` for the binary registration. Locally: -// `cargo run -p examples --bin market_order_by_amount`.) +// cargo run -p examples --bin market_order_by_amount use orderbook_rs::OrderBook; use pricelevel::{Id, Side, TimeInForce, setup_logger}; diff --git a/src/lib.rs b/src/lib.rs index 25fe6c3..dbcb5b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,9 +32,9 @@ //! - **Research**: Platform for studying market microstructure and order flow //! - **Educational**: Reference implementation for understanding modern exchange architecture //! -//! ## What's New in Version 0.7.0 +//! ## What's New in Version 0.8.0 //! -//! ### v0.7.0 — Quote-notional market orders (#85) +//! ### v0.8.0 — Quote-notional market orders (#85) //! //! - **New public API** on [`OrderBook`]: //! [`match_market_order_by_amount`](OrderBook::match_market_order_by_amount) @@ -77,6 +77,8 @@ //! - HDR bench: `notional_walk_hdr` mirrors `aggressive_walk_hdr` with //! the notional path so p50/p99/p99.9/p99.99 can be compared. //! +//! ## What's New in Version 0.7.0 +//! //! ### v0.7.0 — Feature-gated allocation counter //! //! - **New feature `alloc-counters`** (default off). Exposes diff --git a/src/orderbook/sequencer/replay.rs b/src/orderbook/sequencer/replay.rs index b9dfe85..816d2bb 100644 --- a/src/orderbook/sequencer/replay.rs +++ b/src/orderbook/sequencer/replay.rs @@ -413,7 +413,10 @@ mod tests { use super::*; use crate::orderbook::clock::{MonotonicClock, StubClock}; use crate::orderbook::sequencer::InMemoryJournal; - use pricelevel::{Hash32, Id, OrderType, Price, Quantity, Side, TimeInForce, TimestampMs}; + use crate::orderbook::trade::TradeResult; + use pricelevel::{ + Hash32, Id, MatchResult, OrderType, Price, Quantity, Side, TimeInForce, TimestampMs, + }; fn make_add_event(seq: u64, id: Id, price: u128, qty: u64, side: Side) -> SequencerEvent<()> { let order = OrderType::Standard { @@ -553,18 +556,14 @@ mod tests { side: Side::Buy, }, // Result is informational for replay — replay re-executes - // the command against a fresh book. - result: SequencerResult::Rejected { - reason: "ignored".to_string(), + // the command against a fresh book. Use TradeExecuted with an + // empty match-result so the journal entry stays semantically + // consistent with a market-by-amount taker (and is not skipped + // by the Rejected branch in `replay_from`). + result: SequencerResult::TradeExecuted { + trade_result: TradeResult::new("TEST".to_string(), MatchResult::new(taker_id, 0)), }, }; - // Replace the Rejected placeholder with something replay won't - // skip. SequencerResult::Rejected entries are explicitly skipped - // by replay (see replay_from inner match). - let ev = SequencerEvent::<()> { - result: SequencerResult::OrderAdded { order_id: taker_id }, - ..ev - }; assert!(journal.append(&ev).is_ok()); // Drive the live book through the same sequence so we have a diff --git a/tests/unit/market_order_by_amount_tests.rs b/tests/unit/market_order_by_amount_tests.rs index 479ba51..ad3b904 100644 --- a/tests/unit/market_order_by_amount_tests.rs +++ b/tests/unit/market_order_by_amount_tests.rs @@ -119,14 +119,15 @@ fn test_buy_with_lot_size_rounds_down() { } #[test] -fn test_buy_lot_size_skips_levels_below_one_full_lot() { +fn test_buy_lot_size_fills_best_level_when_budget_allows_full_lots() { let book: OrderBook<()> = OrderBook::with_lot_size("TEST", 10); - // Best ask 1000 (one lot = 10_000 quote); next 100 (one lot = 1_000). + // Asks sort ascending, so with [100, 1_000] the best ask is 100. seed_asks(&book, &[100, 1_000], 100); // budget = 5_000: - // level 100: 5_000/100 = 50; lot=10 ⇒ 50 (full lots) - // that's 50 units * 100 = 5_000 spent — exact fit. + // best level 100: 5_000 / 100 = 50 units + // lot = 10 ⇒ 50 is already a whole number of lots + // so the order fills entirely at the best level. let result = book .match_market_order_by_amount(Id::new_uuid(), 5_000, Side::Buy) .expect("notional buy must succeed"); From d2278b2f88b0ee423a9d37a7f9d1df3693a68ce5 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Sun, 3 May 2026 05:09:53 +0200 Subject: [PATCH 3/3] address review: normalize MatchResult.remaining_quantity for the notional path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote-notional matching path seeded MatchResult with u64::MAX as a working upper bound and never reset it, which leaked the sentinel through the public `MatchResult::remaining_quantity()` accessor. A buy of 5_000 quote at price 100 returned remaining_quantity = u64::MAX - 50, which is unsafe for callers that read MatchResult uniformly across both market-order paths. Now after the QuoteAmount branch finishes the loop, the engine rebuilds MatchResult with `initial_quantity = sum(trade.quantity)`, re-adds every trade and filled-order id, and returns it — so remaining_quantity is 0 and is_complete is true on the public surface. Trade list, filled-order ids, and the engine_seq stamping on the wrapping TradeResult are unchanged. Adds a regression assertion to the single-level fill test so the sentinel never resurfaces. --- src/orderbook/matching.rs | 46 ++++++++++++++++++++-- tests/unit/market_order_by_amount_tests.rs | 6 +++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/orderbook/matching.rs b/src/orderbook/matching.rs index 74dc824..35e114b 100644 --- a/src/orderbook/matching.rs +++ b/src/orderbook/matching.rs @@ -51,10 +51,10 @@ impl MatchMode { /// Returns the initial `MatchResult` quantity slot. For quote-notional /// the actual base-qty filled is unknown upfront, so `u64::MAX` is - /// used as an upper bound (see `MatchResult::add_trade` invariants). - /// Consumers of the notional path should read trade totals via - /// `MatchResult::executed_quantity()` / `executed_value()` rather - /// than `remaining_quantity` for that mode. + /// used as a working upper bound during the loop (see + /// `MatchResult::add_trade` invariants). The notional path is + /// normalized at the end of `match_order_inner` so that the returned + /// `MatchResult.remaining_quantity()` is `0` rather than the sentinel. #[inline] #[must_use] fn initial_match_quantity(&self) -> u64 { @@ -543,9 +543,47 @@ where } } + // Normalize the quote-notional path so the public `MatchResult` + // does not leak the `u64::MAX` working sentinel through + // `remaining_quantity()`. The notional path measures progress in + // quote currency, not base qty, so the natural meaning of + // "remaining base qty" is zero — the residual the caller cares + // about is `requested - executed_value`, available directly on + // `MatchResult`. + if matches!(mode, MatchMode::QuoteAmount { .. }) { + match_result = Self::normalize_notional_match_result(order_id, match_result); + } + Ok(match_result) } + /// Rebuild a `MatchResult` produced by the quote-notional path so its + /// internal `remaining_quantity` is `0` (rather than + /// `u64::MAX - executed_qty`). Trade list, filled-order ids, and + /// monotonic engine sequence stamping are preserved. + fn normalize_notional_match_result(order_id: Id, src: MatchResult) -> MatchResult { + let executed_qty: u64 = src + .trades() + .as_vec() + .iter() + .map(|t| t.quantity().as_u64()) + .fold(0u64, u64::saturating_add); + let mut rebuilt = MatchResult::new(order_id, executed_qty); + for trade in src.trades().as_vec() { + // `add_trade` only fails on underflow; with `executed_qty` + // exactly equal to the sum of trade quantities this cannot + // underflow. Treat any error as a logic bug surfaced by + // returning the original (unnormalized) result. + if rebuilt.add_trade(*trade).is_err() { + return src; + } + } + for filled_id in src.filled_order_ids() { + rebuilt.add_filled_order_id(*filled_id); + } + rebuilt + } + /// Build the empty-book result. Market paths return a typed error; /// limit paths return `Ok` with a zero-trade `MatchResult` so the /// caller can rest the order. diff --git a/tests/unit/market_order_by_amount_tests.rs b/tests/unit/market_order_by_amount_tests.rs index ad3b904..a636789 100644 --- a/tests/unit/market_order_by_amount_tests.rs +++ b/tests/unit/market_order_by_amount_tests.rs @@ -66,6 +66,12 @@ fn test_buy_single_level_exact_fit() { assert_eq!(trades[0].price().as_u128(), 100); assert_eq!(trades[0].quantity().as_u64(), 50); assert_eq!(result.executed_value().expect("executed value"), 5_000); + assert_eq!( + result.remaining_quantity(), + 0, + "notional path must not leak the u64::MAX working sentinel" + ); + assert!(result.is_complete()); } #[test]