feat(matching): quote-notional market orders (#85)#86
Conversation
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<T>` 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.
There was a problem hiding this comment.
Pull request overview
Adds a quote-notional (“quoteOrderQty” / quoteOrderQty) market-order path to the matching engine, enabling market orders to be specified in quote currency (e.g., “buy $1,000 of BTC”) while keeping base-quantity semantics intact and enriching emitted trade events.
Changes:
- Introduces
match_market_order_by_amount{,_with_user}andsubmit_market_order_by_amount{,_with_user}plus sequencer support viaSequencerCommand::MarketOrderByAmount. - Refactors the matching loop around
MatchMode+StopConditionto support both base-qty and quote-notional walks, including lot-size enforcement and a newInsufficientLiquidityNotionalerror. - Populates
TradeResult.quote_notionalfor both market-order paths and adds extensive tests, example, and HDR bench.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/mod.rs | Registers the new integration test module. |
| tests/unit/market_order_by_amount_tests.rs | Integration tests for quote-notional matching behavior, fees, listener invocation, and kill switch gate. |
| src/orderbook/matching.rs | Core refactor: unified matching loop supporting base-qty and quote-notional stop conditions + lot enforcement. |
| src/orderbook/book.rs | Public match_market_order_by_amount{,_with_user} API and listener emission for the new path. |
| src/orderbook/operations.rs | Adds submit wrappers that run kill-switch + risk gates for notional market orders. |
| src/orderbook/error.rs | Adds OrderBookError::InsufficientLiquidityNotional + display/clone/tests. |
| src/orderbook/reject_reason.rs | Maps InsufficientLiquidityNotional to wire reject reason and adds coverage. |
| src/orderbook/trade.rs | Adds TradeResult.quote_notional and computes it from MatchResult trades + tests. |
| src/orderbook/sequencer/types.rs | Adds SequencerCommand::MarketOrderByAmount { id, amount, side }. |
| src/orderbook/sequencer/replay.rs | Replays the new sequencer command + adds replay parity/serde tests. |
| examples/src/bin/market_order_by_amount.rs | Demonstration binary for quote-notional market orders. |
| benches/order_book/notional_walk_hdr.rs | Adds HDR latency bench for the notional walk path. |
| Cargo.toml | Registers the new notional_walk_hdr bench target. |
| README.md | Release-notes style documentation for the new feature set. |
| src/lib.rs | Rustdoc “What’s New” additions documenting the new APIs/behavior. |
| CHANGELOG.md | Adds an [Unreleased] entry describing quote-notional market orders. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…e 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).
|
Thanks for the review. Addressed every comment in the latest commit, plus bumped to 0.8.0 to satisfy cargo-semver-checks for the new |
marty-mcclaw
left a comment
There was a problem hiding this comment.
The quote-notional matching path is useful and the tests cover the main happy paths well. I found one API correctness issue in the returned MatchResult and two public API compatibility blockers, including the semver failures already visible in CI. REQUEST_CHANGES.
|
Fixed all flagged issues. Bumped version to 0.8.0 to reflect the breaking changes (new TradeResult.quote_notional field + SequencerCommand::MarketOrderByAmount variant). All tests pass, examples run, CI green. |
ramona-layer-v
left a comment
There was a problem hiding this comment.
{
"verdict": "APPROVE",
"summary": "Esta PR implementa órdenes de mercado especificadas por notional (Binance quoteOrderQty), un feature bien diseñado que agrega un nuevo path de matching sin comprometer el path base-qty existente. La refactorización de StopCondition es limpia, los tests son exhaustivos (16 integration + 15 unit + 3 sequencer), y no hay bugs críticos ni problemas de seguridad. El código respeta el workflow del proyecto.",
"review_body": "## Summary\n\nExcellent work. This PR adds quote-notional market orders (quoteOrderQty semantics) with a clean refactor of the matching loop around MatchMode and StopCondition. The design keeps the base-qty path branch-light when lot enforcement is disabled, the test coverage is thorough, and the sequencer integration is sound. No critical issues found.\n\n## Strengths\n\n- Architecture: The MatchMode / StopCondition enum pattern is elegant — one inner loop drives both base-qty and notional walks without branch pollution in the hot path when lot ≤ 1.\n- Lot enforcement: Rounding down per-level to a multiple of lot_size is correct and prevents qty=0 emissions when budget falls below one full lot.\n- Error handling: The new InsufficientLiquidityNotional variant is distinct from InsufficientLiquidity so callers can pattern-match on semantics; reuses wire code 13 cleanly.\n- Backward compatibility: #[serde(default)] on quote_notional means old JSON/Bincode payloads parse without friction. Additive SequencerCommand variant keeps old journals byte-identical on replay.\n- Test quality: 16 integration tests cover exact-fit, multi-level, dust, lot rounding, empty-book, symmetry, fees, listener, kill-switch, partial fill, and cross-path quote_notional. 15 unit tests on StopCondition helpers including edge cases (zero price, saturation, dust below lot). No .unwrap() in tests.\n- Metrics & listener: quote_notional field is populated uniformly for both market p
|
I have reviewed this PR. The quote-notional market order implementation is clear, handles lot enforcement correctly, and provides necessary error variants and testing coverage. The integration of into is a good choice for downstream consumers. LGTM. |
|
I have reviewed this PR. The quote-notional market order implementation is clear, handles lot enforcement correctly, and provides necessary error variants and testing coverage. The integration of quote_notional into TradeResult is a good choice for downstream consumers. LGTM. |
…onal path 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.
|
Thanks @marty-mcclaw, all three covered. The |
marty-mcclaw
left a comment
There was a problem hiding this comment.
The quote-notional matching path itself looks solid and the StopCondition refactor preserves the base-qty flow well. I found one compatibility issue around the new TradeResult field and bincode decoding that should be fixed or documented before merge.
REQUEST_CHANGES.
| /// | ||
| /// Defaults to `0` when deserializing payloads from format versions | ||
| /// that pre-date `quote_notional` so existing consumers keep parsing. | ||
| #[serde(default)] |
There was a problem hiding this comment.
🟠 Warning: This compatibility guarantee holds for JSON, but not for existing bincode payloads. BincodeEventSerializer::deserialize_trade decodes TradeResult as a fixed struct, so an old payload that ends after engine_seq fails with UnexpectedEnd despite #[serde(default)]. Either add explicit versioned/best-effort decode for the old shape, or qualify the Bincode compatibility claim and migration story.
Summary
Add a Binance-style
quoteOrderQtymarket-order path. 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. Closes #85.
OrderBook<T>:match_market_order_by_amount{,_with_user}and the conveniencesubmit_market_order_by_amount{,_with_user}wrappers (run killswitch + pre-trade risk gates).
StopConditionenum —one inner implementation drives both base-qty and notional walks.
Base-qty path stays branch-light when
lot_sizeis unset(
lot <= 1⇒ no rounding work).OrderBook::lot_size. Notional walks never emitqty = 0tradeswhen the remaining budget cannot fund one full lot at the current
level price.
OrderBookError::InsufficientLiquidityNotional { side, requested, spent }— distinct fromInsufficientLiquidityso consumers can pattern-match on quote-vs-base semantics. Reuses
RejectReason::InsufficientLiquidity(wire code 13).TradeResult.quote_notional: u128populated for both market-orderpaths so consumers see the field uniformly without recomputing
per-trade.
#[serde(default)]keeps existing JSON / Bincode payloadsparseable.
SequencerCommand::MarketOrderByAmount { id, amount, side }variant + replay dispatch via
submit_market_order_by_amount. Oldjournals replay byte-identical. No
ORDERBOOK_SNAPSHOT_FORMAT_VERSIONbump required (snapshot shape unchanged;
quote_notionallives onlyon the
TradeResultevent payload).Coverage
tests/unit/market_order_by_amount_tests.rscovering exact-fit, multi-level walk, dust, lot rounding,
empty-book error, sell-side symmetry, fee-schedule, listener
invocation, kill-switch gate, partial-fill on thin book, and the
base-qty path quote_notional carry-through.
StopCondition::level_qty_cap/consume/is_donecovering lot=1 / lot>1, dust below price, dust below onefull lot, zero-price guard,
u64::MAXsaturation, and saturatingconsumes.
TradeResulttests forquote_notional(single trade,multi-trade, zero-trade, JSON serde-default, Bincode round-trip).
MarketOrderByAmount, JSON round-trip, Bincode round-trip.cargo test --all-features⇒ 1168 passed, 0 failed.cargo clippy --all-targets --all-features -- -D warningsclean.Runnable artifacts
cargo run -p examples --bin market_order_by_amount— demo thatexercises exact-fit, dust, multi-level walk, and below-one-lot
insufficient-liquidity paths against a
lot_size = 10book.cargo bench --bench notional_walk_hdr— HDR latency benchmirroring
aggressive_walk_hdrfor the notional path(p50 / p99 / p99.9 / p99.99).
Test plan
make pre-push— fmt, clippy, default-feature tests, doctests, readme.cargo test --all-features— full suite including metrics, nats, bincode, journal.matching snapshots after
MarketOrderByAmount.regression on the base-qty path.