Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [0.8.0] — 2026-05-03

### Added — Quote-notional market orders (#85)

- **New public API** on `OrderBook<T>`: `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
Expand Down
7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "orderbook-rs"
version = "0.7.0"
version = "0.8.0"
edition = "2024"
authors = ["Joaquin Bejar <jb@taunais.com>"]
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."
Expand Down Expand Up @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,51 @@ 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.8.0

#### v0.8.0 — Quote-notional market orders (#85)

- **New public API** on [`OrderBook<T>`]:
[`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.

### What's New in Version 0.7.0

#### v0.7.0 — Feature-gated allocation counter
Expand Down
57 changes: 57 additions & 0 deletions benches/order_book/notional_walk_hdr.rs
Original file line number Diff line number Diff line change
@@ -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");
}
97 changes: 97 additions & 0 deletions examples/src/bin/market_order_by_amount.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// examples/src/bin/market_order_by_amount.rs
//
// Demonstrates the quote-notional market-order path
// (`match_market_order_by_amount`).
//
// Run with:
//
// 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}"),
}
}
45 changes: 45 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,51 @@
//! - **Research**: Platform for studying market microstructure and order flow
//! - **Educational**: Reference implementation for understanding modern exchange architecture
//!
//! ## What's New in Version 0.8.0
//!
//! ### v0.8.0 — Quote-notional market orders (#85)
//!
//! - **New public API** on [`OrderBook<T>`]:
//! [`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.
//!
//! ## What's New in Version 0.7.0
//!
//! ### v0.7.0 — Feature-gated allocation counter
Expand Down
Loading