From 07195b0dff47fbdf94f277f60fbcae5b4bd4f4a3 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Tue, 23 Jun 2026 23:59:57 +0200 Subject: [PATCH 1/2] feat!: honor taker time-in-force / kind in match_order (FOK, IOC, PostOnly, M2L) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit match_order now takes taker_tif: TimeInForce and taker_kind: TakerKind and applies single-level semantics via a mutation-free pre-check: - FOK: all-or-nothing — killed (zero trades, queue untouched) if depth < incoming. - PostOnly: rejected if it would cross (matchable depth present). - IOC / GTC / GTD / Day / MarketToLimit: fill available, report remainder. New additive MatchOutcome {Filled, PartiallyFilled, NotFilled, Killed, Rejected} on MatchResult (serde-default) with was_killed()/was_rejected(); existing fields unchanged. Also fixes a pre-existing infinite loop: a zero-visible iceberg/reserve with hidden depth re-queued unchanged (consumed=0) forever, now newly reachable from the FOK dry-run. Fixed by drawing the full hidden tranche when visible==0, a shared no-progress set-aside guard (stalled makers reinserted at their original sequence so makers behind still match and price-time priority holds), and a unified is_matchable() so PostOnly and FOK agree on depth. Reviewed SOUND by microstructure-critic. BREAKING: match_order signature change (pre-0.9). Migration note in lib.rs. --- README.md | 47 + benches/concurrent/contention.rs | 6 +- benches/concurrent/register.rs | 6 +- benches/price_level/checked_arithmetic.rs | 12 +- benches/price_level/lifecycle.rs | 10 +- benches/price_level/match_orders.rs | 18 +- benches/price_level/mixed_operations.rs | 14 +- benches/price_level/serialization.rs | 4 +- benches/price_level/special_orders.rs | 16 +- examples/src/bin/contention_test.rs | 4 +- examples/src/bin/hft_simulation.rs | 4 +- .../src/bin/integration_basic_lifecycle.rs | 6 +- .../src/bin/integration_checked_arithmetic.rs | 12 +- .../src/bin/integration_special_orders.rs | 14 +- .../src/bin/integration_trade_roundtrip.rs | 4 +- examples/src/bin/simple.rs | 4 +- src/execution/match_result.rs | 164 ++- src/execution/mod.rs | 4 +- src/execution/taker.rs | 61 + src/execution/tests/match_result_trade.rs | 54 +- src/lib.rs | 49 +- src/orders/order_type.rs | 60 +- src/prelude.rs | 2 +- src/price_level/level.rs | 255 +++- src/price_level/tests/level.rs | 1077 +++++++++++++++-- tests/unit/mod.rs | 4 + 26 files changed, 1784 insertions(+), 127 deletions(-) create mode 100644 src/execution/taker.rs diff --git a/README.md b/README.md index 4b5cc1a..f4b1300 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,53 @@ snapshot/replay equivalence. Pass the taker's arrival timestamp (or any deterministic value for tests/replay), e.g. [`TimestampMs::new`]. +### Migration Guide (taker time-in-force / kind semantics — breaking) + +[`PriceLevel::match_order`] now **honors the taker's** [`TimeInForce`] and a +new [`TakerKind`]. Two parameters are inserted **between** `taker_order_id` +and `timestamp`: + +| Before | After | +|--------|-------| +| `level.match_order(qty, taker_id, ts, &gen)` | `level.match_order(qty, taker_id, tif, kind, ts, &gen)` | + +To preserve the previous "fill what you can, report the remainder" behavior, +pass [`TimeInForce::Gtc`] and [`TakerKind::Standard`]. + +**New single-level semantics.** Let `available` be the quantity this level +can actually fill for the taker, capped at the incoming quantity: + +- [`TakerKind::PostOnly`]: rejected if `available > 0` (would take + liquidity) — zero trades, full remainder, queue untouched. +- [`TimeInForce::Fok`]: killed if `available < incoming` — zero trades, full + remainder, queue untouched; otherwise filled completely. +- [`TimeInForce::Ioc`]: fills `available`, discards the remainder (the taker + is never rested by this layer). +- [`TimeInForce::Gtc`] / [`TimeInForce::Gtd`] / [`TimeInForce::Day`] and + [`TakerKind::MarketToLimit`]: fill `available`, report the remainder in + [`MatchResult::remaining_quantity`] for the order book to rest / convert. + +**New `MatchResult` signal.** A fill-or-kill *kill* and a post-only +*rejection* both leave zero trades and the full remainder — indistinguishable +through the old fields from "the level had no liquidity". [`MatchResult`] +gains an additive [`MatchOutcome`] (`Filled` / `PartiallyFilled` / +`NotFilled` / `Killed` / `Rejected`), read via +[`MatchResult::outcome`](crate::execution::MatchResult::outcome), +[`MatchResult::was_killed`](crate::execution::MatchResult::was_killed), and +[`MatchResult::was_rejected`](crate::execution::MatchResult::was_rejected). +All existing fields and accessors are unchanged. The field is +`#[serde(default)]` so older JSON deserializes (as `NotFilled`); the text +`Display` / `FromStr` format is unchanged and re-derives the benign outcome +on parse (a `Killed` / `Rejected` signal is not carried by the text format). + +Resting-maker time-in-force expiry is still **not** enforced by the match +path — only the *taker's* intent is honored here. Skipping / evicting expired +makers remains the order book's responsibility. + +[`TakerKind`]: crate::execution::TakerKind +[`MatchOutcome`]: crate::execution::MatchOutcome +[`MatchResult::remaining_quantity`]: crate::execution::MatchResult::remaining_quantity + ### Migration Guide (snapshot format v1 → v2) The checksum-protected snapshot format now persists per-level statistics diff --git a/benches/concurrent/contention.rs b/benches/concurrent/contention.rs index cb1563c..2f67711 100644 --- a/benches/concurrent/contention.rs +++ b/benches/concurrent/contention.rs @@ -1,6 +1,6 @@ use criterion::{BenchmarkId, Criterion}; use pricelevel::{ - Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::sync::{Arc, Barrier}; @@ -110,6 +110,8 @@ fn measure_read_write_contention( thread_price_level.match_order( 2, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &thread_transaction_id_gen, ); @@ -225,6 +227,8 @@ fn measure_hot_spot_contention( thread_price_level.match_order( 1, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &thread_transaction_id_gen, ); diff --git a/benches/concurrent/register.rs b/benches/concurrent/register.rs index 09fc6bb..7dce110 100644 --- a/benches/concurrent/register.rs +++ b/benches/concurrent/register.rs @@ -1,7 +1,7 @@ use criterion::{BenchmarkId, Criterion, criterion_group}; use pricelevel::{ Hash32, Id, OrderType, OrderUpdate, PegReferenceType, Price, PriceLevel, Quantity, Side, - TimeInForce, TimestampMs, UuidGenerator, + TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::num::NonZeroU64; use std::sync::{Arc, Barrier}; @@ -76,6 +76,8 @@ pub fn register_benchmarks(c: &mut Criterion) { price_level.match_order( 5, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -315,6 +317,8 @@ fn measure_concurrent_mixed_operations(thread_count: usize, iterations: u64) -> thread_price_level.match_order( 5, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &thread_transaction_id_gen, ); diff --git a/benches/price_level/checked_arithmetic.rs b/benches/price_level/checked_arithmetic.rs index 5006479..de13c78 100644 --- a/benches/price_level/checked_arithmetic.rs +++ b/benches/price_level/checked_arithmetic.rs @@ -1,6 +1,6 @@ use criterion::Criterion; use pricelevel::{ - Hash32, Id, MatchResult, OrderType, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, MatchResult, OrderType, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, Trade, UuidGenerator, }; use std::hint::black_box; @@ -28,6 +28,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( 500, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); @@ -42,6 +44,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( 500, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); @@ -56,6 +60,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( 500, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); @@ -89,6 +95,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = empty_level.match_order( 100, Id::from_u64(1), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); @@ -107,6 +115,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( tc * 10, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); diff --git a/benches/price_level/lifecycle.rs b/benches/price_level/lifecycle.rs index 7760ca1..db287cc 100644 --- a/benches/price_level/lifecycle.rs +++ b/benches/price_level/lifecycle.rs @@ -1,6 +1,6 @@ use criterion::Criterion; use pricelevel::{ - Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::hint::black_box; @@ -42,6 +42,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( 500, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); @@ -67,6 +69,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( 10, Id::from_u64(10000 + i), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); @@ -115,6 +119,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( 1000, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); @@ -180,6 +186,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let result = level.match_order( 100, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); diff --git a/benches/price_level/match_orders.rs b/benches/price_level/match_orders.rs index df53e2c..0bf813c 100644 --- a/benches/price_level/match_orders.rs +++ b/benches/price_level/match_orders.rs @@ -1,6 +1,6 @@ use criterion::{BenchmarkId, Criterion}; use pricelevel::{ - Hash32, Id, OrderType, Price, PriceLevel, Quantity, Side, TimeInForce, TimestampMs, + Hash32, Id, OrderType, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::hint::black_box; @@ -21,6 +21,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -36,6 +38,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 75, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -51,6 +55,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 60, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -66,6 +72,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -85,6 +93,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 5, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -102,6 +112,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 3, Id::from_u64(1000 + i), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -123,6 +135,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( match_quantity, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -145,6 +159,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( match_quantity, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); diff --git a/benches/price_level/mixed_operations.rs b/benches/price_level/mixed_operations.rs index b9f491b..5273c92 100644 --- a/benches/price_level/mixed_operations.rs +++ b/benches/price_level/mixed_operations.rs @@ -1,6 +1,6 @@ use criterion::Criterion; use pricelevel::{ - Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::hint::black_box; @@ -33,6 +33,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let _ = black_box(price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -67,6 +69,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 100, Id::from_u64(1000), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -93,6 +97,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 2, Id::from_u64(1000 + i), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); @@ -128,18 +134,24 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(price_level.match_order( 300, Id::from_u64(1001), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); black_box(price_level.match_order( 400, Id::from_u64(1002), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); black_box(price_level.match_order( 300, Id::from_u64(1003), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, )); diff --git a/benches/price_level/serialization.rs b/benches/price_level/serialization.rs index b9574c3..9ab39e0 100644 --- a/benches/price_level/serialization.rs +++ b/benches/price_level/serialization.rs @@ -1,6 +1,6 @@ use criterion::Criterion; use pricelevel::{ - Hash32, Id, MatchResult, OrderType, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, MatchResult, OrderType, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, Trade, TradeList, UuidGenerator, }; use std::hint::black_box; @@ -91,6 +91,8 @@ pub fn register_benchmarks(c: &mut Criterion) { let match_result = level.match_order( 200, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); diff --git a/benches/price_level/special_orders.rs b/benches/price_level/special_orders.rs index bcf1187..62e5464 100644 --- a/benches/price_level/special_orders.rs +++ b/benches/price_level/special_orders.rs @@ -1,7 +1,7 @@ use criterion::{BenchmarkId, Criterion}; use pricelevel::{ - Hash32, Id, OrderType, PegReferenceType, Price, PriceLevel, Quantity, Side, TimeInForce, - TimestampMs, UuidGenerator, + Hash32, Id, OrderType, PegReferenceType, Price, PriceLevel, Quantity, Side, TakerKind, + TimeInForce, TimestampMs, UuidGenerator, }; use std::hint::black_box; use uuid::Uuid; @@ -20,6 +20,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( 50, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); @@ -33,6 +35,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( 50, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); @@ -46,6 +50,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( 50, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); @@ -59,6 +65,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( 50, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); @@ -72,6 +80,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( 100, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); @@ -89,6 +99,8 @@ pub fn register_benchmarks(c: &mut Criterion) { black_box(level.match_order( order_count / 2, Id::from_u64(9999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, )); diff --git a/examples/src/bin/contention_test.rs b/examples/src/bin/contention_test.rs index 5b39944..3735fde 100644 --- a/examples/src/bin/contention_test.rs +++ b/examples/src/bin/contention_test.rs @@ -1,7 +1,7 @@ // examples/src/bin/contention_test.rs use pricelevel::{ - Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, setup_logger, }; use std::collections::HashMap; @@ -112,6 +112,8 @@ fn test_read_write_ratio() { thread_price_level.match_order( 5, // Match 5 units taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &thread_tx_id_gen, ); diff --git a/examples/src/bin/hft_simulation.rs b/examples/src/bin/hft_simulation.rs index c547274..5b56d73 100644 --- a/examples/src/bin/hft_simulation.rs +++ b/examples/src/bin/hft_simulation.rs @@ -2,7 +2,7 @@ use pricelevel::{ Hash32, Id, OrderType, OrderUpdate, PegReferenceType, Price, PriceLevel, Quantity, Side, - TimeInForce, TimestampMs, UuidGenerator, setup_logger, + TakerKind, TimeInForce, TimestampMs, UuidGenerator, setup_logger, }; use std::num::NonZeroU64; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -141,6 +141,8 @@ fn main() { let result = thread_price_level.match_order( quantity, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &thread_tx_id_gen, ); diff --git a/examples/src/bin/integration_basic_lifecycle.rs b/examples/src/bin/integration_basic_lifecycle.rs index 706062f..7dc12d4 100644 --- a/examples/src/bin/integration_basic_lifecycle.rs +++ b/examples/src/bin/integration_basic_lifecycle.rs @@ -4,7 +4,7 @@ // Validates stats deltas, queue/order count consistency, and matching correctness. use pricelevel::{ - Hash32, Id, MatchResult, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, + Hash32, Id, MatchResult, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::process; @@ -127,6 +127,8 @@ fn main() { let match_result: MatchResult = price_level.match_order( 200, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_gen, ); @@ -157,6 +159,8 @@ fn main() { let partial = price_level.match_order( remaining_visible + 500, taker_id2, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_gen, ); diff --git a/examples/src/bin/integration_checked_arithmetic.rs b/examples/src/bin/integration_checked_arithmetic.rs index c00922c..3261b8d 100644 --- a/examples/src/bin/integration_checked_arithmetic.rs +++ b/examples/src/bin/integration_checked_arithmetic.rs @@ -5,8 +5,8 @@ // Verifies PriceLevelError variants and panic-free overflow handling. use pricelevel::{ - Hash32, Id, OrderType, Price, PriceLevel, PriceLevelError, Quantity, Side, TimeInForce, - TimestampMs, UuidGenerator, + Hash32, Id, OrderType, Price, PriceLevel, PriceLevelError, Quantity, Side, TakerKind, + TimeInForce, TimestampMs, UuidGenerator, }; use std::process; use uuid::Uuid; @@ -88,6 +88,8 @@ fn test_executed_quantity_and_value(id_gen: &UuidGenerator) { let result = level.match_order( 250, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -138,6 +140,8 @@ fn test_average_price(id_gen: &UuidGenerator) { let result = level.match_order( 100, Id::from_u64(800), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -163,6 +167,8 @@ fn test_empty_match_result(id_gen: &UuidGenerator) { let result = empty_level.match_order( 100, Id::from_u64(900), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -246,6 +252,8 @@ fn test_match_result_display_fromstr(id_gen: &UuidGenerator) { let result = level.match_order( 30, Id::from_u64(888), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); diff --git a/examples/src/bin/integration_special_orders.rs b/examples/src/bin/integration_special_orders.rs index de3350c..b221903 100644 --- a/examples/src/bin/integration_special_orders.rs +++ b/examples/src/bin/integration_special_orders.rs @@ -6,7 +6,7 @@ use pricelevel::{ DEFAULT_RESERVE_REPLENISH_AMOUNT, Hash32, Id, OrderType, OrderUpdate, PegReferenceType, Price, - PriceLevel, Quantity, Side, TimeInForce, TimestampMs, UuidGenerator, + PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, }; use std::num::NonZeroU64; use std::process; @@ -54,6 +54,8 @@ fn test_iceberg_order(id_gen: &UuidGenerator) { let result = level.match_order( 10, Id::from_u64(100), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -98,6 +100,8 @@ fn test_reserve_order(id_gen: &UuidGenerator) { let result = level.match_order( 8, Id::from_u64(200), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -142,6 +146,8 @@ fn test_post_only_order(id_gen: &UuidGenerator) { let result = level.match_order( 50, Id::from_u64(300), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -184,6 +190,8 @@ fn test_trailing_stop_order(id_gen: &UuidGenerator) { let result = level.match_order( 30, Id::from_u64(400), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -219,6 +227,8 @@ fn test_pegged_order(id_gen: &UuidGenerator) { let result = level.match_order( 25, Id::from_u64(500), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); @@ -253,6 +263,8 @@ fn test_market_to_limit_order(id_gen: &UuidGenerator) { let result = level.match_order( 15, Id::from_u64(600), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), id_gen, ); diff --git a/examples/src/bin/integration_trade_roundtrip.rs b/examples/src/bin/integration_trade_roundtrip.rs index dfbdced..eeda5aa 100644 --- a/examples/src/bin/integration_trade_roundtrip.rs +++ b/examples/src/bin/integration_trade_roundtrip.rs @@ -4,7 +4,7 @@ // Display/FromStr and serde JSON for all execution types. use pricelevel::{ - Hash32, Id, MatchResult, OrderType, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, MatchResult, OrderType, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, Trade, TradeList, UuidGenerator, }; use std::process; @@ -137,6 +137,8 @@ fn main() { let result: MatchResult = price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &id_gen, ); diff --git a/examples/src/bin/simple.rs b/examples/src/bin/simple.rs index e9b7c71..d9516e1 100644 --- a/examples/src/bin/simple.rs +++ b/examples/src/bin/simple.rs @@ -1,7 +1,7 @@ // examples/src/bin/multi_threaded_price_level.rs use pricelevel::{ - Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TimeInForce, + Hash32, Id, OrderType, OrderUpdate, Price, PriceLevel, Quantity, Side, TakerKind, TimeInForce, TimestampMs, UuidGenerator, setup_logger, }; use std::num::NonZeroU64; @@ -71,6 +71,8 @@ fn main() { let match_result = thread_price_level.match_order( 5, // Match 5 units each time taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &thread_tx_id_gen, ); diff --git a/src/execution/match_result.rs b/src/execution/match_result.rs index afe237f..d2fae1a 100644 --- a/src/execution/match_result.rs +++ b/src/execution/match_result.rs @@ -6,6 +6,65 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; +/// The terminal classification of a single-level matching operation. +/// +/// This is the explicit signal a caller uses to tell apart outcomes that all +/// look identical through `trades` / `remaining_quantity` alone — in +/// particular, a fill-or-kill *kill* and a post-only *rejection* both leave +/// zero trades and the full incoming quantity remaining, exactly like matching +/// against an empty level, yet they mean very different things. +/// +/// The outcome agrees with the rest of [`MatchResult`] by construction: +/// `is_complete()` is `true` iff the outcome is [`MatchOutcome::Filled`], and +/// [`MatchOutcome::Killed`] / [`MatchOutcome::Rejected`] are only ever set when +/// no trade was emitted and the resting queue was left untouched. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MatchOutcome { + /// The incoming order was completely filled (`remaining_quantity == 0`). + Filled, + + /// The incoming order was partially filled: at least one trade occurred but + /// some quantity remains. For a `Gtc` / `Gtd` / `Day` taker the order book + /// rests the remainder; for an `Ioc` / market-to-limit taker it is + /// discarded / converted by the caller. + PartiallyFilled, + + /// No trade occurred and quantity remains because the level had nothing to + /// fill the taker with (empty or fully consumed by an earlier sweep). This + /// is the benign "no liquidity here" outcome — distinct from a kill or a + /// rejection. + #[default] + NotFilled, + + /// A fill-or-kill (`Fok`) taker could not be filled in full at this level, + /// so it was killed: zero trades, full remaining quantity, resting queue + /// left untouched. + Killed, + + /// A post-only taker would have taken liquidity (the level could fill some + /// of it), so it was rejected: zero trades, full remaining quantity, + /// resting queue left untouched. + Rejected, +} + +impl MatchOutcome { + /// Returns `true` if the taker was killed by its fill-or-kill policy. + #[must_use] + #[inline] + pub fn was_killed(self) -> bool { + matches!(self, Self::Killed) + } + + /// Returns `true` if the taker was rejected by its post-only policy. + #[must_use] + #[inline] + pub fn was_rejected(self) -> bool { + matches!(self, Self::Rejected) + } +} + /// Represents the result of a matching operation. /// /// Fields are private to enforce invariant consistency between @@ -27,6 +86,15 @@ pub struct MatchResult { /// Any orders that were completely filled and removed from the book filled_order_ids: Vec, + + /// Terminal classification of the match (filled / killed / rejected / ...). + /// + /// `#[serde(default)]` keeps snapshots produced before this field was added + /// deserializable: an older JSON payload restores as + /// [`MatchOutcome::NotFilled`] and is corrected by the accessors derived + /// from the other fields where it matters. + #[serde(default)] + outcome: MatchOutcome, } impl MatchResult { @@ -39,6 +107,7 @@ impl MatchResult { remaining_quantity: initial_quantity, is_complete: false, filled_order_ids: Vec::new(), + outcome: MatchOutcome::NotFilled, } } @@ -57,6 +126,7 @@ impl MatchResult { remaining_quantity: initial_quantity, is_complete: false, filled_order_ids: Vec::with_capacity(capacity), + outcome: MatchOutcome::NotFilled, } } @@ -79,6 +149,14 @@ impl MatchResult { ), })?; self.is_complete = self.remaining_quantity == 0; + // Keep the outcome in lockstep with the fields it summarizes: a trade + // has now occurred, so the result is at least partially filled. + // `finalize` re-derives the terminal classification after the sweep. + self.outcome = if self.is_complete { + MatchOutcome::Filled + } else { + MatchOutcome::PartiallyFilled + }; self.trades.add(trade); Ok(()) } @@ -118,12 +196,79 @@ impl MatchResult { &self.filled_order_ids } - /// Sets the final remaining quantity and completion flag. + /// Returns the terminal classification of this match. + /// + /// See [`MatchOutcome`] for the full set of cases and how they relate to + /// the other fields. This is the only way to distinguish a fill-or-kill + /// *kill* and a post-only *rejection* (both zero-trade, full-remainder) + /// from matching against an empty level. + #[must_use] + pub fn outcome(&self) -> MatchOutcome { + self.outcome + } + + /// Returns `true` if the taker was killed by its fill-or-kill policy. /// - /// This is used internally by the matching engine after the matching loop. + /// A killed match has zero trades, the full incoming quantity remaining, + /// and left the resting queue untouched. + #[must_use] + pub fn was_killed(&self) -> bool { + self.outcome.was_killed() + } + + /// Returns `true` if the taker was rejected by its post-only policy. + /// + /// A rejected match has zero trades, the full incoming quantity remaining, + /// and left the resting queue untouched. + #[must_use] + pub fn was_rejected(&self) -> bool { + self.outcome.was_rejected() + } + + /// Sets the final remaining quantity, completion flag, and outcome. + /// + /// This is used internally by the matching engine after the matching loop + /// for outcomes that actually swept the queue (filled / partially filled / + /// no liquidity). Kill and rejection are set by their dedicated helpers + /// because they are decided *before* any sweep and must not be + /// re-derived from the (deliberately untouched) fields. pub(crate) fn finalize(&mut self, remaining_quantity: u64) { self.remaining_quantity = remaining_quantity; self.is_complete = remaining_quantity == 0; + self.outcome = if self.is_complete { + MatchOutcome::Filled + } else if self.trades.is_empty() { + MatchOutcome::NotFilled + } else { + MatchOutcome::PartiallyFilled + }; + } + + /// Marks this result as a fill-or-kill *kill*: the taker could not be + /// filled in full at this level, so nothing was done. + /// + /// Resets `trades` / `filled_order_ids` to empty and `remaining_quantity` + /// to the full incoming quantity, asserting the "no partial state" rule. + /// Used internally by the matching engine. + pub(crate) fn mark_killed(&mut self, incoming_quantity: u64) { + self.trades = TradeList::new(); + self.filled_order_ids.clear(); + self.remaining_quantity = incoming_quantity; + self.is_complete = false; + self.outcome = MatchOutcome::Killed; + } + + /// Marks this result as a post-only *rejection*: the taker would have taken + /// liquidity, so nothing was done. + /// + /// Resets `trades` / `filled_order_ids` to empty and `remaining_quantity` + /// to the full incoming quantity. Used internally by the matching engine. + pub(crate) fn mark_rejected(&mut self, incoming_quantity: u64) { + self.trades = TradeList::new(); + self.filled_order_ids.clear(); + self.remaining_quantity = incoming_quantity; + self.is_complete = false; + self.outcome = MatchOutcome::Rejected; } /// Get the total executed quantity @@ -390,12 +535,27 @@ impl FromStr for MatchResult { } }; + // The text format predates the explicit outcome signal and does not + // carry it, so re-derive the benign classification from the parsed + // fields. A `Killed` / `Rejected` outcome cannot be recovered from text + // (it is indistinguishable from `NotFilled` once the trades are gone); + // callers that need that distinction must use the in-memory result or + // the JSON (serde) representation, which preserves `outcome`. + let outcome = if is_complete { + MatchOutcome::Filled + } else if trades.is_empty() { + MatchOutcome::NotFilled + } else { + MatchOutcome::PartiallyFilled + }; + Ok(MatchResult { order_id, trades, remaining_quantity, is_complete, filled_order_ids, + outcome, }) } } diff --git a/src/execution/mod.rs b/src/execution/mod.rs index 00e720a..c477daa 100644 --- a/src/execution/mod.rs +++ b/src/execution/mod.rs @@ -26,8 +26,10 @@ mod trade; mod list; mod match_result; +mod taker; mod tests; pub use list::TradeList; -pub use match_result::MatchResult; +pub use match_result::{MatchOutcome, MatchResult}; +pub use taker::TakerKind; pub use trade::Trade; diff --git a/src/execution/taker.rs b/src/execution/taker.rs new file mode 100644 index 0000000..db0aa0c --- /dev/null +++ b/src/execution/taker.rs @@ -0,0 +1,61 @@ +//! Taker intent classification for the matching engine. +//! +//! The kind of an incoming ("taker") order is orthogonal to its +//! [`TimeInForce`](crate::orders::TimeInForce): the TIF governs *how much* of +//! the remainder survives a match (fill-or-kill, immediate-or-cancel, rest), +//! while the [`TakerKind`] governs *whether the taker may take liquidity at +//! all* and how an unfilled remainder is interpreted by the order book. Both +//! are passed into [`PriceLevel::match_order`](crate::PriceLevel::match_order) +//! so the single-level match honors the taker's full intent. + +use serde::{Deserialize, Serialize}; + +/// Classifies the intent of an incoming (taker) order at a single price level. +/// +/// This is the taker-side discriminator that +/// [`PriceLevel::match_order`](crate::PriceLevel::match_order) consults +/// alongside the taker's [`TimeInForce`](crate::orders::TimeInForce). It is +/// deliberately distinct from the resting-maker [`OrderType`](crate::OrderType): +/// it expresses what the *incoming* order is allowed to do, not how a resting +/// order behaves. +/// +/// - [`TakerKind::Standard`] — an ordinary aggressing order. Subject only to +/// its TIF. +/// - [`TakerKind::PostOnly`] — must never take liquidity. If matching at this +/// level would cross (any quantity is available to fill), the match is +/// rejected with zero trades and the queue left untouched. +/// - [`TakerKind::MarketToLimit`] — fills the available quantity and reports +/// the remainder; the order book converts/rests the residual as a limit. At +/// the single-level layer this fills like a [`TakerKind::Standard`] taker. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TakerKind { + /// An ordinary aggressing taker. Subject only to its time-in-force. + #[default] + Standard, + + /// A post-only taker that must never take liquidity. Rejected on any cross. + PostOnly, + + /// A market-to-limit taker. Fills what it can; the order book converts the + /// unfilled remainder into a resting limit order. + MarketToLimit, +} + +impl TakerKind { + /// Returns `true` if this taker is post-only and therefore must never take + /// liquidity. + #[must_use] + #[inline] + pub fn is_post_only(self) -> bool { + matches!(self, Self::PostOnly) + } + + /// Returns `true` if this taker is market-to-limit. + #[must_use] + #[inline] + pub fn is_market_to_limit(self) -> bool { + matches!(self, Self::MarketToLimit) + } +} diff --git a/src/execution/tests/match_result_trade.rs b/src/execution/tests/match_result_trade.rs index a39e177..303501f 100644 --- a/src/execution/tests/match_result_trade.rs +++ b/src/execution/tests/match_result_trade.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::execution::match_result::MatchResult; + use crate::execution::match_result::{MatchOutcome, MatchResult}; use crate::execution::trade::Trade; use crate::orders::{Id, Side}; use crate::utils::{Price, Quantity, TimestampMs}; @@ -53,6 +53,58 @@ mod tests { assert_eq!(parsed.remaining_quantity(), 60); } + #[test] + fn add_trade_keeps_outcome_in_sync() { + // No trades yet -> the default benign classification. + let mut result = MatchResult::new(Id::from_u64(10), 100); + assert_eq!(result.outcome(), MatchOutcome::NotFilled); + + // Partial fill. + assert!(result.add_trade(sample_trade(40)).is_ok()); + assert_eq!(result.outcome(), MatchOutcome::PartiallyFilled); + assert!(!result.was_killed()); + assert!(!result.was_rejected()); + + // Complete fill. + assert!(result.add_trade(sample_trade(60)).is_ok()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert!(result.is_complete()); + } + + #[test] + fn outcome_survives_serde_json_roundtrip() { + let mut result = MatchResult::new(Id::from_u64(10), 100); + assert!(result.add_trade(sample_trade(40)).is_ok()); + + let json = serde_json::to_string(&result).expect("serialize match result"); + let parsed: MatchResult = serde_json::from_str(&json).expect("deserialize match result"); + + assert_eq!(parsed.outcome(), MatchOutcome::PartiallyFilled); + assert_eq!(parsed.remaining_quantity(), 60); + assert_eq!(parsed.trades().len(), 1); + } + + #[test] + fn outcome_defaults_when_absent_from_json() { + // A JSON payload written before the `outcome` field existed must still + // deserialize (the field is `#[serde(default)]`). Build a current JSON, + // then strip the `outcome` key to emulate the legacy shape. + let result = MatchResult::new(Id::from_u64(10), 70); + let mut value: serde_json::Value = + serde_json::to_value(&result).expect("serialize match result"); + value + .as_object_mut() + .expect("object") + .remove("outcome") + .expect("current payload carries an outcome field"); + let legacy = serde_json::to_string(&value).expect("re-serialize legacy payload"); + + let parsed: MatchResult = + serde_json::from_str(&legacy).expect("legacy match result must deserialize"); + assert_eq!(parsed.outcome(), MatchOutcome::NotFilled); + assert_eq!(parsed.remaining_quantity(), 70); + } + #[test] fn from_str_rejects_old_transactions_field() { let old_payload = "MatchResult:order_id=1;remaining_quantity=1;is_complete=false;transactions=Transactions:[];filled_order_ids=[]"; diff --git a/src/lib.rs b/src/lib.rs index dcffd2e..97190b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -378,6 +378,53 @@ //! Pass the taker's arrival timestamp (or any deterministic value for //! tests/replay), e.g. [`TimestampMs::new`]. //! +//! ## Migration Guide (taker time-in-force / kind semantics — breaking) +//! +//! [`PriceLevel::match_order`] now **honors the taker's** [`TimeInForce`] and a +//! new [`TakerKind`]. Two parameters are inserted **between** `taker_order_id` +//! and `timestamp`: +//! +//! | Before | After | +//! |--------|-------| +//! | `level.match_order(qty, taker_id, ts, &gen)` | `level.match_order(qty, taker_id, tif, kind, ts, &gen)` | +//! +//! To preserve the previous "fill what you can, report the remainder" behavior, +//! pass [`TimeInForce::Gtc`] and [`TakerKind::Standard`]. +//! +//! **New single-level semantics.** Let `available` be the quantity this level +//! can actually fill for the taker, capped at the incoming quantity: +//! +//! - [`TakerKind::PostOnly`]: rejected if `available > 0` (would take +//! liquidity) — zero trades, full remainder, queue untouched. +//! - [`TimeInForce::Fok`]: killed if `available < incoming` — zero trades, full +//! remainder, queue untouched; otherwise filled completely. +//! - [`TimeInForce::Ioc`]: fills `available`, discards the remainder (the taker +//! is never rested by this layer). +//! - [`TimeInForce::Gtc`] / [`TimeInForce::Gtd`] / [`TimeInForce::Day`] and +//! [`TakerKind::MarketToLimit`]: fill `available`, report the remainder in +//! [`MatchResult::remaining_quantity`] for the order book to rest / convert. +//! +//! **New `MatchResult` signal.** A fill-or-kill *kill* and a post-only +//! *rejection* both leave zero trades and the full remainder — indistinguishable +//! through the old fields from "the level had no liquidity". [`MatchResult`] +//! gains an additive [`MatchOutcome`] (`Filled` / `PartiallyFilled` / +//! `NotFilled` / `Killed` / `Rejected`), read via +//! [`MatchResult::outcome`](crate::execution::MatchResult::outcome), +//! [`MatchResult::was_killed`](crate::execution::MatchResult::was_killed), and +//! [`MatchResult::was_rejected`](crate::execution::MatchResult::was_rejected). +//! All existing fields and accessors are unchanged. The field is +//! `#[serde(default)]` so older JSON deserializes (as `NotFilled`); the text +//! `Display` / `FromStr` format is unchanged and re-derives the benign outcome +//! on parse (a `Killed` / `Rejected` signal is not carried by the text format). +//! +//! Resting-maker time-in-force expiry is still **not** enforced by the match +//! path — only the *taker's* intent is honored here. Skipping / evicting expired +//! makers remains the order book's responsibility. +//! +//! [`TakerKind`]: crate::execution::TakerKind +//! [`MatchOutcome`]: crate::execution::MatchOutcome +//! [`MatchResult::remaining_quantity`]: crate::execution::MatchResult::remaining_quantity +//! //! ## Migration Guide (snapshot format v1 → v2) //! //! The checksum-protected snapshot format now persists per-level statistics @@ -418,7 +465,7 @@ mod execution; pub mod prelude; pub use errors::PriceLevelError; -pub use execution::{MatchResult, Trade, TradeList}; +pub use execution::{MatchOutcome, MatchResult, TakerKind, Trade, TradeList}; pub use orders::DEFAULT_RESERVE_REPLENISH_AMOUNT; pub use orders::PegReferenceType; pub use orders::{Hash32, Id, OrderType, OrderUpdate, Side, TimeInForce}; diff --git a/src/orders/order_type.rs b/src/orders/order_type.rs index 809f032..e4eb8a3 100644 --- a/src/orders/order_type.rs +++ b/src/orders/order_type.rs @@ -281,6 +281,44 @@ impl OrderType { } } + /// Returns `true` if this resting order can yield a fill for a crossing + /// taker, i.e. a positive taker would take some quantity from it. + /// + /// This is the single source of truth for "matchable depth" shared by the + /// post-only pre-check ([`PriceLevel::has_matchable_depth`]) and the + /// fill-or-kill dry run ([`PriceLevel::matchable_quantity`]), so the two can + /// never disagree about the same level: + /// + /// - Any order with positive **visible** quantity is matchable. + /// - A zero-visible **iceberg** with hidden quantity is matchable: the sweep + /// replenishes its entire hidden into visible and then fills it. + /// - A zero-visible **reserve** with hidden quantity is matchable **only if** + /// `auto_replenish` is set; without auto-replenish a drained reserve is + /// dropped by the sweep without ever filling, so it is *not* matchable + /// depth. + /// - Every other zero-visible order (no hidden to draw on) is not matchable. + /// + /// [`PriceLevel::has_matchable_depth`]: crate::price_level::PriceLevel + /// [`PriceLevel::matchable_quantity`]: crate::price_level::PriceLevel + #[must_use] + #[inline] + pub fn is_matchable(&self) -> bool { + if self.visible_quantity() > 0 { + return true; + } + match self { + Self::IcebergOrder { + hidden_quantity, .. + } => hidden_quantity.as_u64() > 0, + Self::ReserveOrder { + hidden_quantity, + auto_replenish, + .. + } => *auto_replenish && hidden_quantity.as_u64() > 0, + _ => false, + } + } + /// Get the order side #[must_use] #[inline] @@ -565,9 +603,25 @@ impl OrderType { let remaining = incoming_quantity - consumed; if hidden_quantity.as_u64() > 0 { - // Refresh visible portion from hidden - let refresh_qty = - std::cmp::min(hidden_quantity.as_u64(), visible_quantity.as_u64()); + // Refresh visible portion from hidden. The tranche size + // is the order's current visible quantity (each iceberg + // tranche mirrors the original visible size). + // + // Degenerate guard: a zero-visible iceberg (constructible + // via `add_order` with `visible_quantity: 0` or + // `update_order(UpdateQuantity { new_quantity: 0 })`) has + // a tranche size of 0, which `min(hidden, visible)` would + // make a no-op refresh — the order would re-queue + // unchanged and the sweep would re-pop it forever. Draw + // the entire remaining hidden into visible so the order + // becomes matchable, `hidden_reduced > 0`, and the sweep + // makes forward progress instead of looping. + let tranche = if visible_quantity.as_u64() == 0 { + hidden_quantity.as_u64() + } else { + visible_quantity.as_u64() + }; + let refresh_qty = std::cmp::min(hidden_quantity.as_u64(), tranche); let new_hidden = hidden_quantity.as_u64() - refresh_qty; // Create updated order with refreshed quantities diff --git a/src/prelude.rs b/src/prelude.rs index ec00ebb..dc92e16 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -7,7 +7,7 @@ //! ``` pub use crate::errors::PriceLevelError; -pub use crate::execution::{MatchResult, Trade, TradeList}; +pub use crate::execution::{MatchOutcome, MatchResult, TakerKind, Trade, TradeList}; pub use crate::orders::DEFAULT_RESERVE_REPLENISH_AMOUNT; pub use crate::orders::PegReferenceType; pub use crate::orders::{Hash32, Id, OrderType, OrderUpdate, Side, TimeInForce}; diff --git a/src/price_level/level.rs b/src/price_level/level.rs index 884ba64..cf681f7 100644 --- a/src/price_level/level.rs +++ b/src/price_level/level.rs @@ -2,8 +2,8 @@ use crate::UuidGenerator; use crate::errors::PriceLevelError; -use crate::execution::{MatchResult, Trade}; -use crate::orders::{Id, OrderType, OrderUpdate}; +use crate::execution::{MatchResult, TakerKind, Trade}; +use crate::orders::{Id, OrderType, OrderUpdate, TimeInForce}; use crate::price_level::order_queue::OrderQueue; use crate::price_level::{PriceLevelSnapshot, PriceLevelSnapshotPackage, PriceLevelStatistics}; use crate::utils::{Price, Quantity, TimestampMs}; @@ -243,24 +243,149 @@ impl PriceLevel { self.orders.snapshot_vec() } - /// Matches an incoming order against existing orders at this price level. + /// Returns `true` if any resting order has matchable depth, i.e. a positive + /// taker would cross at this level. /// - /// This function attempts to match the incoming order quantity against the orders present in the - /// `OrderQueue`. It iterates through the queue, matching orders until the incoming quantity is - /// fully filled or the queue is exhausted. Trades are generated for each successful match, - /// and filled orders are removed from the queue. The function also updates the visible and hidden - /// quantity counters and records statistics for each execution. + /// Used by the post-only pre-check, which only needs to know whether *any* + /// liquidity would be taken, not how much. Short-circuits on the first + /// matchable order, so it is cheaper than [`Self::matchable_quantity`]. /// - /// Time-in-force EXPIRY is NOT enforced here: a resting maker's - /// [`TimeInForce`](crate::orders::TimeInForce) (e.g. `Gtd` / `Day`) is not - /// consulted, so an expired maker still matches. Evicting or skipping - /// expired orders is the caller's / order book's responsibility, keeping - /// the match path a pure, deterministic sweep over the resting queue. + /// Matchability is delegated to [`OrderType::is_matchable`] — the single + /// source of truth shared with the fill-or-kill dry run — so the post-only + /// verdict and the fill-or-kill prediction can never disagree about the same + /// level. In particular a zero-visible iceberg (or auto-replenishing + /// reserve) backed by hidden quantity counts as matchable depth, because the + /// sweep will draw that hidden into visible and fill it. + fn has_matchable_depth(&self) -> bool { + self.iter_orders().any(|order| order.is_matchable()) + } + + /// Computes how much of `incoming_quantity` this level could actually fill + /// for a taker, in quantity units, **without mutating the queue**. + /// + /// This is a deterministic dry run of the FIFO sweep: it replays + /// [`OrderType::match_against`] over a snapshot of the resting queue in the + /// same price-time order the real sweep uses, including iceberg / reserve + /// replenishment (a refreshed tranche is re-queued at the tail) and the + /// removal of a non-replenishing reserve once its visible part is drained. + /// The returned value is therefore exactly what [`Self::match_order`] would + /// consume — never an over- or under-count — which is what fill-or-kill + /// (all-or-nothing) correctly depends on. + /// + /// It allocates a working snapshot and is only used on the cold + /// fill-or-kill path, not on the hot `Gtc` sweep. + fn matchable_quantity(&self, incoming_quantity: u64) -> u64 { + if incoming_quantity == 0 { + return 0; + } + + // Snapshot in deterministic FIFO order, mirroring the real sweep's + // `pop_entry` order so the dry run matches it exactly. + let mut pending: std::collections::VecDeque>> = + self.snapshot_orders().into(); + let mut remaining = incoming_quantity; + let mut filled: u64 = 0; + + while remaining > 0 { + let Some(order) = pending.pop_front() else { + break; + }; + let (consumed, updated_order, hidden_reduced, new_remaining) = + order.match_against(remaining); + + // No-progress safety guard, identical in shape to the real sweep + // (see `match_order`): a front maker that consumes nothing, draws no + // hidden, and leaves `remaining` unchanged while handing itself back + // is set aside (dropped from `pending`) rather than re-queued at the + // front, which would spin forever. Dropping it here is the dry-run + // analogue of the real sweep setting it aside: it contributes + // nothing to `filled`, and the makers behind it are still visited. + // Keeping this logic identical to the real sweep is what guarantees + // `matchable_quantity` predicts exactly what `match_order` consumes, + // which fill-or-kill depends on. + if consumed == 0 + && hidden_reduced == 0 + && new_remaining == remaining + && updated_order.is_some() + { + continue; + } + + // `consumed <= remaining <= incoming_quantity`, so this sum cannot + // overflow `u64`; checked anyway per the no-saturate/no-wrap rule. + filled = match filled.checked_add(consumed) { + Some(total) => total, + None => break, + }; + remaining = new_remaining; + + if let Some(updated) = updated_order { + if hidden_reduced > 0 { + // Replenished tranche loses time priority -> back of queue, + // exactly as the real sweep re-queues it. + pending.push_back(Arc::new(updated)); + } else { + // Pure partial fill keeps front position; the taker is now + // exhausted (`remaining == 0`) so the loop ends next check. + pending.push_front(Arc::new(updated)); + } + } + } + + filled + } + + /// Matches an incoming taker order against existing orders at this price level. + /// + /// The sweep consumes resting makers in strict price-time (FIFO) order until + /// the taker is filled or the matchable depth is exhausted. Trades are + /// generated for each successful match, fully-consumed makers are removed, + /// and the visible / hidden quantity counters and statistics are updated in + /// lockstep with each execution. + /// + /// # Taker time-in-force / kind semantics + /// + /// Unlike earlier versions, this method **honors the taker's** + /// [`TimeInForce`] and [`TakerKind`]. Let `available` be the quantity this + /// level can actually fill for the taker (see [`Self::matchable_quantity`]), + /// capped at `incoming_quantity`: + /// + /// - [`TakerKind::PostOnly`]: must never take liquidity. If `available > 0` + /// the match is **rejected** — zero trades, the full `incoming_quantity` + /// reported as remaining, and the resting queue left untouched + /// ([`MatchResult::was_rejected`]). + /// - [`TimeInForce::Fok`]: all-or-nothing. If `available < incoming_quantity` + /// the taker is **killed** — zero trades, full remaining, queue untouched + /// ([`MatchResult::was_killed`]). Otherwise it fills completely. + /// - [`TimeInForce::Ioc`]: fills `available` and discards the remainder. + /// The taker is never enqueued here (this layer never rests a taker), so + /// the remainder is simply reported and dropped by the caller. + /// - [`TimeInForce::Gtc`] / [`TimeInForce::Gtd`] / [`TimeInForce::Day`]: + /// fills `available`; the remainder is reported in + /// [`MatchResult::remaining_quantity`] for the order book to rest. + /// - [`TakerKind::MarketToLimit`]: fills `available`; the remainder is + /// reported for the order book to convert into a resting limit. At this + /// single-level layer it fills like a standard taker. + /// + /// A post-only rejection and a fill-or-kill kill both leave zero trades and + /// the full remainder; use [`MatchResult::outcome`] / + /// [`MatchResult::was_rejected`] / [`MatchResult::was_killed`] to tell them + /// apart from "the level had no liquidity". + /// + /// Time-in-force EXPIRY of resting **makers** is still NOT enforced here: a + /// resting maker's `Gtd` / `Day` expiry is not consulted, so an expired + /// maker still matches. Evicting or skipping expired makers is the + /// caller's / order book's responsibility, keeping the match path a pure, + /// deterministic sweep over the resting queue. /// /// # Arguments /// - /// * `incoming_quantity`: The quantity of the incoming order to be matched. + /// * `incoming_quantity`: The quantity of the incoming taker order to match. /// * `taker_order_id`: The ID of the incoming order (the "taker" order). + /// * `taker_tif`: The taker's [`TimeInForce`], which governs how an unfilled + /// remainder is treated (kill / discard / rest). + /// * `taker_kind`: The taker's [`TakerKind`] (standard / post-only / + /// market-to-limit). /// * `timestamp`: The taker timestamp (milliseconds since epoch) stamped /// onto every emitted [`Trade`] and used as the execution time for /// statistics. It is threaded in from the caller so the match path never @@ -269,13 +394,18 @@ impl PriceLevel { /// * `trade_id_generator`: An atomic counter used to generate unique trade IDs. /// /// [`Trade`]: crate::execution::Trade + /// [`TimeInForce`]: crate::orders::TimeInForce + /// [`TakerKind`]: crate::execution::TakerKind + /// [`MatchResult::was_rejected`]: crate::execution::MatchResult::was_rejected + /// [`MatchResult::was_killed`]: crate::execution::MatchResult::was_killed + /// [`MatchResult::outcome`]: crate::execution::MatchResult::outcome + /// [`MatchResult::remaining_quantity`]: crate::execution::MatchResult::remaining_quantity /// /// # Returns /// - /// A `MatchResult` object containing the results of the matching operation, including a list of - /// generated trades, the remaining unmatched quantity, a flag indicating whether the - /// incoming order was completely filled, and a list of IDs of orders that were completely filled - /// during the matching process. + /// A `MatchResult` carrying the generated trades, the remaining unmatched + /// quantity, the completion flag, the fully-filled maker IDs, and the + /// terminal [`MatchOutcome`](crate::execution::MatchOutcome). /// /// # Concurrency /// @@ -286,14 +416,55 @@ impl PriceLevel { /// concurrent `match_order` calls on the *same* level — or a `match_order` /// racing a `cancel` of the resting order it is currently matching — must /// be serialized by the caller (an order book typically matches a level - /// from a single thread). + /// from a single thread). The post-only / fill-or-kill pre-checks read the + /// queue before the sweep; under that single-matcher assumption no + /// concurrent `match_order` can change the matchable depth between the + /// pre-check and the sweep. pub fn match_order( &self, incoming_quantity: u64, taker_order_id: Id, + taker_tif: TimeInForce, + taker_kind: TakerKind, timestamp: TimestampMs, trade_id_generator: &UuidGenerator, ) -> MatchResult { + // -------- Taker TIF / kind pre-checks (before any queue mutation) -------- + // + // PostOnly: must never take liquidity. If any matchable depth exists for + // a positive taker, reject without touching the queue. A zero-quantity + // taker has nothing to cross, so it is not rejected (it falls through to + // the vacuous-complete sweep below). + if taker_kind.is_post_only() && incoming_quantity > 0 && self.has_matchable_depth() { + tracing::debug!( + taker_order_id = %taker_order_id, + incoming_quantity, + price = self.price, + "post-only taker rejected: would take liquidity" + ); + let mut result = MatchResult::new(taker_order_id, incoming_quantity); + result.mark_rejected(incoming_quantity); + return result; + } + + // Fill-or-kill: all-or-nothing. If the level cannot fill the taker in + // full, kill it without touching the queue. + if matches!(taker_tif, TimeInForce::Fok) && incoming_quantity > 0 { + let available = self.matchable_quantity(incoming_quantity); + if available < incoming_quantity { + tracing::debug!( + taker_order_id = %taker_order_id, + incoming_quantity, + available, + price = self.price, + "fill-or-kill taker killed: insufficient depth" + ); + let mut result = MatchResult::new(taker_order_id, incoming_quantity); + result.mark_killed(incoming_quantity); + return result; + } + } + // A single sweep emits at most one trade and at most one filled-order // id per resting order, so the live order count is an upper bound for // both vectors. Pre-size them to cut per-fill reallocations on the hot @@ -304,11 +475,47 @@ impl PriceLevel { let mut result = MatchResult::with_capacity(taker_order_id, incoming_quantity, capacity); let mut remaining = incoming_quantity; + // No-progress safety guard. A popped maker that yields no progress + // (`consumed == 0`, re-queued unchanged, `remaining` not decreased) + // must not be re-popped this sweep, or the loop would spin forever on + // the same front order. Because such a dead order sits at the FRONT + // (FIFO), simply breaking would starve any matchable makers behind it. + // Instead we set the dead order ASIDE (preserving its original + // insertion sequence) and keep sweeping the rest of the queue; the + // set-aside orders are re-inserted at their original sequences after the + // sweep, so they stay resting in their correct price-time position with + // no silent loss of (hidden) quantity. `match_against`'s own progress + // fix means this guard should never fire for the iceberg/reserve states + // it now handles; it is defense-in-depth against any future + // zero-progress shape (e.g. a degenerate residual from + // `with_reduced_quantity(0)`). + let mut stalled: Vec<(u64, Arc>)> = Vec::new(); + while remaining > 0 { if let Some((order_seq, order_arc)) = self.orders.pop_entry() { let (consumed, updated_order, hidden_reduced, new_remaining) = order_arc.match_against(remaining); + // Detect a non-progressing pop: nothing consumed, no hidden + // drawn, the taker's remaining unchanged, and the maker handed + // back to us to re-queue. Re-inserting it at its front sequence + // would make the next iteration pop the same id again. Set it + // aside and advance to the maker behind it. + if consumed == 0 + && hidden_reduced == 0 + && new_remaining == remaining + && updated_order.is_some() + { + tracing::warn!( + order_id = %order_arc.id(), + price = self.price, + remaining, + "match sweep: front maker made no progress; setting aside to avoid re-pop" + ); + stalled.push((order_seq, order_arc)); + continue; + } + if consumed > 0 { // Update visible quantity counter. `Relaxed`: advisory // counter (issue #68); the queue mutation (`pop_entry` @@ -410,6 +617,16 @@ impl PriceLevel { } } + // Restore any set-aside non-progressing makers at their original + // insertion sequences. They were never modified, never traded against, + // and their visible/hidden counters were never touched, so re-inserting + // them leaves the queue and the atomic counters exactly as if they had + // simply been skipped — keeping counter <-> queue consistency intact and + // preserving their price-time position for the next sweep. + for (seq, order) in stalled { + self.orders.reinsert(seq, order); + } + result.finalize(remaining); result diff --git a/src/price_level/tests/level.rs b/src/price_level/tests/level.rs index d118a80..8e6f705 100644 --- a/src/price_level/tests/level.rs +++ b/src/price_level/tests/level.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { use crate::errors::PriceLevelError; + use crate::execution::{MatchOutcome, TakerKind}; use crate::orders::{Hash32, Id, OrderType, OrderUpdate, PegReferenceType, Side, TimeInForce}; use crate::price_level::PriceLevelSnapshotPackage; use crate::price_level::level::{PriceLevel, PriceLevelData}; @@ -118,9 +119,23 @@ mod tests { let execution_ts = TimestampMs::new(1_716_000_000_000); // First match: fully consumes maker 1 and partially maker 2. - let _ = price_level.match_order(150, Id::from_u64(900), execution_ts, &trade_id_generator); + let _ = price_level.match_order( + 150, + Id::from_u64(900), + TimeInForce::Gtc, + TakerKind::Standard, + execution_ts, + &trade_id_generator, + ); // Second match: consumes the rest of maker 2 and part of maker 3. - let _ = price_level.match_order(60, Id::from_u64(901), execution_ts, &trade_id_generator); + let _ = price_level.match_order( + 60, + Id::from_u64(901), + TimeInForce::Gtc, + TakerKind::Standard, + execution_ts, + &trade_id_generator, + ); let stats = price_level.stats(); // Sanity: stats are genuinely non-zero before we snapshot. @@ -568,6 +583,8 @@ mod tests { let match_result = price_level.match_order( 100, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -628,7 +645,14 @@ mod tests { price_level.add_order(mk(3, 50)); let trade_id_generator = UuidGenerator::new(namespace); - price_level.match_order(90, taker_id, timestamp, &trade_id_generator) + price_level.match_order( + 90, + taker_id, + TimeInForce::Gtc, + TakerKind::Standard, + timestamp, + &trade_id_generator, + ) }; let first = run(); @@ -665,6 +689,8 @@ mod tests { let match_result = price_level.match_order( 60, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -706,6 +732,8 @@ mod tests { let match_result = price_level.match_order( 150, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -747,6 +775,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -774,6 +804,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -796,6 +828,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -822,6 +856,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -849,6 +885,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -873,6 +911,8 @@ mod tests { let match_result = price_level.match_order( 150, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -898,6 +938,8 @@ mod tests { let match_result = price_level.match_order( 30, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -928,6 +970,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -957,6 +1001,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -992,6 +1038,8 @@ mod tests { let match_result = price_level.match_order( 25, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1006,6 +1054,8 @@ mod tests { let match_result = price_level.match_order( 10, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1043,6 +1093,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1072,6 +1124,8 @@ mod tests { let match_result = price_level.match_order( 49, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1100,6 +1154,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1128,6 +1184,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1156,6 +1214,8 @@ mod tests { let match_result = price_level.match_order( 25, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1170,6 +1230,8 @@ mod tests { let match_result = price_level.match_order( 10, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1201,6 +1263,8 @@ mod tests { let match_result = price_level.match_order( 80, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1228,6 +1292,8 @@ mod tests { let match_result = price_level.match_order( 10, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1251,6 +1317,8 @@ mod tests { let match_result = price_level.match_order( 150, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1296,6 +1364,8 @@ mod tests { let match_result = price_level.match_order( 60, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1319,6 +1389,8 @@ mod tests { let match_result = price_level.match_order( 100, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1342,6 +1414,8 @@ mod tests { let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1365,6 +1439,8 @@ mod tests { let match_result = price_level.match_order( 100, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1384,16 +1460,14 @@ mod tests { // gaps for PostOnly / TrailingStop / PeggedOrder / MarketToLimit, plus the // `incoming_quantity == 0` boundary. // - // IMPORTANT — current engine behavior. These four order types fall through - // the `_ =>` arm of `OrderType::match_against` (orders/order_type.rs) and - // are matched as plain standard makers: their visible quantity is consumed - // FIFO exactly like a `Standard` order. The genuine taker-side semantics - // (post-only rejection, market-to-limit conversion, pegged / trailing-stop - // reprice) are NOT implemented in the engine yet — that is the separate, - // gated issue #65. The "type-branch" tests below therefore PIN the current - // pass-through behavior; they deliberately do NOT assert post-only / - // conversion semantics the engine does not have. When #65 lands, the - // type-branch tests are the ones that must be revisited. + // IMPORTANT — these test RESTING MAKERS of each order type. Post-only, + // market-to-limit, pegged, and trailing-stop are taker-side / order-book + // policies; as *resting makers* these order types are plain liquidity and + // are consumed FIFO exactly like a `Standard` order, at the level price. The + // genuine taker-side semantics (post-only rejection, market-to-limit + // conversion, fill-or-kill / IOC) live in `match_order` and are covered by + // the "TAKER TIF / KIND SEMANTICS (#65)" block further below — those tests + // vary the *taker's* intent, while these vary the *resting maker's* type. // // Maker sides (taken from each helper, which differ): PostOnly = Buy, // TrailingStop = Sell, PeggedOrder = Buy, MarketToLimit = Buy. The correct @@ -1415,6 +1489,8 @@ mod tests { let result = price_level.match_order( 60, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1441,6 +1517,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1456,13 +1534,13 @@ mod tests { } #[test] - fn test_match_post_only_type_branch_pins_passthrough() { - // Type-specific branch: a PostOnly maker would, under genuine post-only - // semantics (pending #65), reject the resting/taker interaction - // differently. Today it falls through the `_ =>` arm of - // `match_against` and fills exactly like a standard maker. This test - // PINS that pass-through: an over-large taker drains the maker and - // leaves a positive remainder, identical to a `Standard` maker. + fn test_match_post_only_resting_maker_consumed_like_standard() { + // Post-only is a TAKER-side policy: a PostOnly order resting as a + // *maker* is just ordinary liquidity and is consumed exactly like a + // `Standard` maker. (The real post-only rejection — a post-only TAKER + // refusing to cross — is covered by the taker-side tests below.) An + // over-large `Gtc` taker drains the PostOnly maker and leaves a + // positive remainder. let price_level = PriceLevel::new(10000); let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let trade_id_generator = UuidGenerator::new(namespace); @@ -1472,6 +1550,8 @@ mod tests { let result = price_level.match_order( 150, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1498,6 +1578,8 @@ mod tests { let result = price_level.match_order( 75, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1523,6 +1605,8 @@ mod tests { let result = price_level.match_order( 40, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1550,6 +1634,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1565,12 +1651,11 @@ mod tests { } #[test] - fn test_match_trailing_stop_type_branch_pins_passthrough() { - // Type-specific branch: genuine trailing-stop reprice semantics are - // pending #65. Today a TrailingStop maker falls through the `_ =>` arm - // of `match_against` and fills like a standard maker with no reprice. - // PIN that pass-through: an over-large taker drains the maker and - // leaves a positive remainder. + fn test_match_trailing_stop_resting_maker_consumed_like_standard() { + // A resting TrailingStop maker is matched as ordinary liquidity: trail + // repricing is the order book's job, not the single-level match. An + // over-large `Gtc` taker drains the maker at the level price and leaves + // a positive remainder. let price_level = PriceLevel::new(10000); let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let trade_id_generator = UuidGenerator::new(namespace); @@ -1580,6 +1665,8 @@ mod tests { let result = price_level.match_order( 120, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1604,6 +1691,8 @@ mod tests { let result = price_level.match_order( 55, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1629,6 +1718,8 @@ mod tests { let result = price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1655,6 +1746,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1670,11 +1763,10 @@ mod tests { } #[test] - fn test_match_pegged_type_branch_pins_passthrough() { - // Type-specific branch: genuine pegged reprice semantics (peg to a - // reference price) are pending #65. Today a PeggedOrder maker falls - // through the `_ =>` arm and fills like a standard maker at the level - // price with no reprice. PIN that pass-through with an over-large taker. + fn test_match_pegged_resting_maker_consumed_like_standard() { + // A resting PeggedOrder maker is matched as ordinary liquidity at the + // level price; pegging to a reference price is the order book's job, not + // the single-level match. An over-large `Gtc` taker drains the maker. let price_level = PriceLevel::new(10000); let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let trade_id_generator = UuidGenerator::new(namespace); @@ -1684,6 +1776,8 @@ mod tests { let result = price_level.match_order( 130, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1710,6 +1804,8 @@ mod tests { let result = price_level.match_order( 33, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1735,6 +1831,8 @@ mod tests { let result = price_level.match_order( 70, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1761,6 +1859,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1776,13 +1876,11 @@ mod tests { } #[test] - fn test_match_market_to_limit_type_branch_pins_passthrough() { - // Type-specific branch: genuine market-to-limit conversion semantics - // (an unfilled remainder converting to a resting limit) are pending - // #65. Today a MarketToLimit maker falls through the `_ =>` arm and - // fills like a standard maker; the taker's unfilled remainder is simply - // reported as `remaining_quantity`, NOT converted. PIN that - // pass-through with an over-large taker. + fn test_match_market_to_limit_resting_maker_consumed_like_standard() { + // A resting MarketToLimit maker is matched as ordinary liquidity. + // Market-to-limit is a TAKER-side policy (converting the taker's unfilled + // remainder into a resting limit); as a maker it is consumed like a + // `Standard` order. An over-large `Gtc` taker drains it. let price_level = PriceLevel::new(10000); let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let trade_id_generator = UuidGenerator::new(namespace); @@ -1792,6 +1890,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1816,6 +1916,8 @@ mod tests { let result = price_level.match_order( 90, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1846,6 +1948,8 @@ mod tests { let result = price_level.match_order( 0, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -1864,64 +1968,382 @@ mod tests { } #[test] - fn test_match_fill_or_kill_order() { - // NOTE: the FOK time-in-force on the resting *maker* is NOT enforced at - // this layer. `match_order` does not consult the maker's TIF — the FOK - // order matches exactly like a `TimeInForce::Gtc` standard order. This - // test only pins that "a resting FOK maker is consumable like any - // other"; it does NOT assert all-or-nothing "kill" semantics, because - // the engine cannot enforce them here. Real FOK/IOC *taker* semantics - // (all-or-nothing fill, discard-remainder) require `match_order` to - // accept a taker TIF and are gated on issue #65. + fn test_match_fill_or_kill_taker_fully_filled() { + // FOK TAKER, sufficient depth: the level can fill the taker in full + // (available == incoming == 100), so it fills completely like any other. let price_level = PriceLevel::new(10000); let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let transaction_id_generator = UuidGenerator::new(namespace); - price_level.add_order(create_fill_or_kill_order(1, 10000, 100)); + price_level.add_order(create_standard_order(1, 10000, 100)); - // For the price level, FOK behaves like standard orders let taker_id = Id::from_u64(999); let match_result = price_level.match_order( 100, taker_id, + TimeInForce::Fok, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); assert_eq!(match_result.remaining_quantity(), 0); assert!(match_result.is_complete()); + assert_eq!(match_result.outcome(), MatchOutcome::Filled); + assert!(!match_result.was_killed()); + assert_eq!(match_result.trades().len(), 1); assert_eq!(price_level.visible_quantity(), 0); assert_eq!(price_level.order_count(), 0); } #[test] - fn test_match_immediate_or_cancel_order() { - // NOTE: the IOC time-in-force on the resting *maker* is NOT enforced at - // this layer. `match_order` does not consult the maker's TIF — the IOC - // order matches and the unfilled remainder keeps resting exactly like a - // `TimeInForce::Gtc` standard order. This test only pins that "a resting - // IOC maker is partially consumable like any other and the unmatched - // part stays in the queue"; it does NOT assert IOC discard semantics on - // the maker. Real FOK/IOC *taker* semantics (the taker discarding its - // own remainder rather than the maker) require `match_order` to accept a - // taker TIF and are gated on issue #65. + fn test_match_immediate_or_cancel_taker_fills_available_and_discards() { + // IOC TAKER smaller than resting depth: fills fully, nothing discarded. let price_level = PriceLevel::new(10000); let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let transaction_id_generator = UuidGenerator::new(namespace); - price_level.add_order(create_immediate_or_cancel_order(1, 10000, 100)); + price_level.add_order(create_standard_order(1, 10000, 100)); - // For the price level, IOC behaves like standard orders let taker_id = Id::from_u64(999); let match_result = price_level.match_order( 50, taker_id, + TimeInForce::Ioc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); assert_eq!(match_result.remaining_quantity(), 0); assert!(match_result.is_complete()); + assert_eq!(match_result.outcome(), MatchOutcome::Filled); + // The maker keeps the unmatched 50 resting; the IOC taker is never + // enqueued by this layer. + assert_eq!(price_level.visible_quantity(), 50); + assert_eq!(price_level.order_count(), 1); + } + + // --------------------------------- TAKER TIF / KIND SEMANTICS (#65) -------- + // + // `match_order` honors the taker's TimeInForce and TakerKind. These tests + // pin the single-level semantics: FOK fills-completely-or-kills, IOC + // fills-available-and-discards, PostOnly rejects on cross, MarketToLimit + // fills-available. Resting makers are plain `Standard` Buy orders so the + // only variable is the taker's intent. + + fn fok_namespace_gen() -> UuidGenerator { + let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); + UuidGenerator::new(namespace) + } + + // ----- FOK boundary: fills completely or kills (both sides) ----- + + #[test] + fn test_match_fok_taker_exactly_fillable_fills_completely() { + // available (100) == incoming (100): on the fill side of the boundary. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 60)); + price_level.add_order(create_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 100, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert!(!result.was_killed()); + assert_eq!(result.remaining_quantity(), 0); + assert_eq!(result.executed_quantity().expect("ok"), 100); + assert_eq!(result.filled_order_ids().len(), 2); + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + } + + #[test] + fn test_match_fok_taker_one_short_is_killed() { + // available (100) < incoming (101): on the kill side of the boundary by + // exactly one unit. Zero trades, full remainder, queue untouched. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 60)); + price_level.add_order(create_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 101, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(!result.is_complete()); + assert!(result.was_killed()); + assert_eq!(result.outcome(), MatchOutcome::Killed); + assert_eq!(result.remaining_quantity(), 101); + assert_eq!(result.trades().len(), 0); + assert_eq!(result.filled_order_ids().len(), 0); + assert_eq!(result.executed_quantity().expect("ok"), 0); + // No partial state: the resting depth is fully intact. + assert_eq!(price_level.order_count(), 2); + assert_eq!(price_level.visible_quantity(), 100); + } + + #[test] + fn test_match_fok_taker_killed_against_empty_level() { + // Empty level cannot fill any positive FOK taker -> killed. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + + let result = price_level.match_order( + 10, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.was_killed()); + assert_eq!(result.outcome(), MatchOutcome::Killed); + assert_eq!(result.remaining_quantity(), 10); + assert_eq!(result.trades().len(), 0); + } + + #[test] + fn test_match_fok_taker_drains_iceberg_hidden_then_fills() { + // `available` must count replenishable hidden depth the single sweep + // would draw: an iceberg with visible 10 + hidden 40 can fill a FOK + // taker of 50, so it fills rather than (wrongly) being killed. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_iceberg_order(1, 10000, 10, 40)); + + let result = price_level.match_order( + 50, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert_eq!(result.executed_quantity().expect("ok"), 50); + assert_eq!(price_level.order_count(), 0); + } + + // ----- IOC: fills available and discards remainder ----- + + #[test] + fn test_match_ioc_taker_fills_available_and_discards_remainder() { + // available (100) < incoming (150): fill 100, discard 50. The taker is + // never enqueued; the level is emptied of makers. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 60)); + price_level.add_order(create_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 150, + Id::from_u64(999), + TimeInForce::Ioc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(!result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::PartiallyFilled); + assert!(!result.was_killed()); + assert!(!result.was_rejected()); + assert_eq!(result.executed_quantity().expect("ok"), 100); + assert_eq!(result.remaining_quantity(), 50); + assert_eq!(result.filled_order_ids().len(), 2); + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + } + + // ----- PostOnly: rejects on cross ----- + + #[test] + fn test_match_post_only_taker_rejected_on_cross() { + // The level has matchable depth, so a post-only taker would take + // liquidity -> rejected: zero trades, full remainder, queue untouched. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 100)); + + let result = price_level.match_order( + 60, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::PostOnly, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.was_rejected()); + assert_eq!(result.outcome(), MatchOutcome::Rejected); + assert!(!result.is_complete()); + assert_eq!(result.remaining_quantity(), 60); + assert_eq!(result.trades().len(), 0); + assert_eq!(result.filled_order_ids().len(), 0); + assert_eq!(result.executed_quantity().expect("ok"), 0); + // Resting maker untouched. + assert_eq!(price_level.order_count(), 1); + assert_eq!(price_level.visible_quantity(), 100); + } + + #[test] + fn test_match_post_only_taker_accepted_on_empty_level() { + // No matchable depth -> the post-only taker does not cross and is NOT + // rejected. It simply finds nothing to fill (NotFilled). + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + + let result = price_level.match_order( + 60, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::PostOnly, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(!result.was_rejected()); + assert_eq!(result.outcome(), MatchOutcome::NotFilled); + assert!(!result.is_complete()); + assert_eq!(result.remaining_quantity(), 60); + assert_eq!(result.trades().len(), 0); + } + + #[test] + fn test_match_post_only_taker_zero_quantity_not_rejected() { + // A zero-quantity post-only taker has nothing to cross -> not rejected; + // it falls through to the vacuous-complete sweep. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 100)); + + let result = price_level.match_order( + 0, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::PostOnly, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(!result.was_rejected()); + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert_eq!(result.remaining_quantity(), 0); + assert_eq!(price_level.order_count(), 1); + assert_eq!(price_level.visible_quantity(), 100); + } + + // ----- MarketToLimit: fills available, reports remainder ----- + + #[test] + fn test_match_market_to_limit_taker_fills_available_reports_remainder() { + // available (100) < incoming (130): fill 100, report 40 for the order + // book to convert/rest. At this layer it behaves like a standard taker. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 100)); + + let result = price_level.match_order( + 140, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::MarketToLimit, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(!result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::PartiallyFilled); + assert!(!result.was_killed()); + assert!(!result.was_rejected()); + assert_eq!(result.executed_quantity().expect("ok"), 100); + assert_eq!(result.remaining_quantity(), 40); + assert_eq!(result.filled_order_ids().len(), 1); + assert_eq!(price_level.order_count(), 0); + } + + #[test] + fn test_match_market_to_limit_taker_full_fill() { + // available (100) == incoming (100): fully filled, no remainder. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_standard_order(1, 10000, 100)); + + let result = price_level.match_order( + 100, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::MarketToLimit, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert_eq!(result.remaining_quantity(), 0); + assert_eq!(result.executed_quantity().expect("ok"), 100); + } + + // ----- resting FOK / IOC makers are consumed like any other liquidity ----- + + #[test] + fn test_match_resting_fok_maker_consumed_by_standard_taker() { + // A resting maker tagged FOK is just liquidity here; a Gtc taker + // consumes it normally. (FOK is a taker-side policy.) + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_fill_or_kill_order(1, 10000, 100)); + + let result = price_level.match_order( + 100, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert_eq!(price_level.order_count(), 0); + } + + #[test] + fn test_match_resting_ioc_maker_partially_consumed_by_standard_taker() { + // A resting maker tagged IOC is just liquidity; a smaller Gtc taker + // partially consumes it and the remainder keeps resting. + let price_level = PriceLevel::new(10000); + let trade_gen = fok_namespace_gen(); + price_level.add_order(create_immediate_or_cancel_order(1, 10000, 100)); + + let result = price_level.match_order( + 50, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); assert_eq!(price_level.visible_quantity(), 50); assert_eq!(price_level.order_count(), 1); } @@ -1939,6 +2361,8 @@ mod tests { let match_result = price_level.match_order( 100, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -1949,23 +2373,18 @@ mod tests { assert_eq!(price_level.order_count(), 0); } - /// Pin the de-facto IOC behavior of `match_order` for a taker larger than - /// the available resting depth. - /// - /// `match_order` takes no taker `TimeInForce` and NEVER enqueues the taker: - /// it fills every unit it can against the resting queue and reports the - /// unfilled remainder via `remaining_quantity()`. With a taker (150) that - /// exceeds total depth (100), this is exactly the observable IOC outcome — - /// "fill what you can, discard the rest" — even though no IOC flag is - /// consulted: the resting depth is fully consumed, `remaining_quantity()` - /// stays positive, `is_complete()` is false, and nothing of the taker is - /// left resting at the level (the level only holds makers, and `match_order` - /// adds no new order). + /// A `Gtc` taker larger than the available resting depth fills everything it + /// can and reports the unfilled remainder. /// - /// Explicit IOC/FOK *taker* semantics — distinguishing "discard remainder" - /// (IOC) from "all-or-nothing kill" (FOK) — require `match_order` to accept - /// a taker TIF and are gated on issue #65; they are intentionally NOT - /// asserted here. + /// `match_order` NEVER enqueues the taker: it fills every unit it can + /// against the resting queue and reports the unfilled remainder via + /// `remaining_quantity()`. With a taker (150) that exceeds total depth + /// (100), the resting depth is fully consumed, `remaining_quantity()` stays + /// positive, `is_complete()` is false, and nothing of the taker is left + /// resting at the level (the level only holds makers, and `match_order` adds + /// no new order). For a `Gtc` taker the order book rests the 50 remainder; + /// distinguishing that from an `Ioc` discard or a `Fok` kill is the job of + /// the taker-TIF tests above. #[test] fn test_match_order_taker_exceeds_depth_fills_available_and_reports_remainder() { let price_level = PriceLevel::new(10000); @@ -1983,6 +2402,8 @@ mod tests { let result = price_level.match_order( 150, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -2049,6 +2470,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(match_ts), &trade_id_generator, ); @@ -2078,6 +2501,8 @@ mod tests { let match_result = price_level.match_order( 140, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -2268,8 +2693,14 @@ mod tests { let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let trade_id_generator = UuidGenerator::new(namespace); let execution_ts = TimestampMs::new(1_716_000_000_000); - let match_result = - price_level.match_order(40, Id::from_u64(900), execution_ts, &trade_id_generator); + let match_result = price_level.match_order( + 40, + Id::from_u64(900), + TimeInForce::Gtc, + TakerKind::Standard, + execution_ts, + &trade_id_generator, + ); let trades = match_result.trades().as_vec(); assert_eq!(trades.len(), 1); @@ -2303,8 +2734,14 @@ mod tests { let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let trade_id_generator = UuidGenerator::new(namespace); let execution_ts = TimestampMs::new(1_716_000_000_000); - let match_result = - price_level.match_order(100, Id::from_u64(900), execution_ts, &trade_id_generator); + let match_result = price_level.match_order( + 100, + Id::from_u64(900), + TimeInForce::Gtc, + TakerKind::Standard, + execution_ts, + &trade_id_generator, + ); let trades = match_result.trades().as_vec(); assert_eq!(trades.len(), 1); @@ -2679,6 +3116,8 @@ mod tests { let match_result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &transaction_id_generator, ); @@ -3063,6 +3502,8 @@ mod tests { let first = price_level.match_order( 60, Id::from_u64(901), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -3081,6 +3522,8 @@ mod tests { let second = price_level.match_order( 50, Id::from_u64(902), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -3123,6 +3566,8 @@ mod tests { let _ = price_level.match_order( 60, Id::from_u64(901), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -3152,6 +3597,8 @@ mod tests { let first = price_level.match_order( 50, Id::from_u64(901), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -3163,6 +3610,8 @@ mod tests { let second = price_level.match_order( 50, Id::from_u64(902), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -3186,6 +3635,8 @@ mod tests { let _ = price_level.match_order( 60, Id::from_u64(901), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -3205,6 +3656,8 @@ mod tests { let result = restored.match_order( 50, Id::from_u64(903), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &restored_trade_ids, ); @@ -3300,6 +3753,8 @@ mod tests { let _ = level.match_order( 3, taker_id, + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &generator, ); @@ -3470,6 +3925,8 @@ mod tests { let result = price_level.match_order( 40, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3498,6 +3955,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3523,6 +3982,8 @@ mod tests { let result = price_level.match_order( 100, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3550,6 +4011,8 @@ mod tests { let result = price_level.match_order( 90, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3572,6 +4035,8 @@ mod tests { let result = price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3600,6 +4065,8 @@ mod tests { let result = price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3626,6 +4093,8 @@ mod tests { let result = price_level.match_order( 50, Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_id_generator, ); @@ -3665,7 +4134,14 @@ mod tests { let trade_id_generator = UuidGenerator::new(namespace); // Cross more than the visible tranche to force replenishment and a // multi-trade stream. - price_level.match_order(120, taker_id, timestamp, &trade_id_generator) + price_level.match_order( + 120, + taker_id, + TimeInForce::Gtc, + TakerKind::Standard, + timestamp, + &trade_id_generator, + ) }; let first = run(); @@ -3675,6 +4151,443 @@ mod tests { assert_match_result_consistent(&first, 10000, Side::Sell); assert_match_result_consistent(&second, 10000, Side::Sell); } + + // ============================================================ + // Regression tests for issue #65: zero-visible iceberg / reserve + // at the FRONT of the queue must not cause an infinite match loop. + // + // Each of these tests would HANG before the fix: a zero-visible + // iceberg/reserve with hidden depth returned no-progress from + // `match_against`, so the sweep (and the FOK dry run) re-popped the + // same front order forever. A normal matchable maker is parked + // BEHIND the dead order to prove the sweep still reaches makers + // behind a non-progressing front order (FIFO, no starvation). + // All makers rest on Side::Sell so `assert_match_result_consistent` + // sees a single, known maker side. + // ============================================================ + + /// Sell-side standard maker (the queue-behind liquidity). The shared + /// `create_standard_order` rests on Side::Buy; these regression tests need + /// the behind maker on the same side as the zero-visible iceberg/reserve. + fn create_sell_standard_order(id: u64, price: u128, quantity: u64) -> OrderType<()> { + let timestamp = TIMESTAMP_COUNTER.fetch_add(1, Ordering::SeqCst); + OrderType::Standard { + id: Id::from_u64(id), + price: Price::new(price), + quantity: Quantity::new(quantity), + side: Side::Sell, + user_id: Hash32::zero(), + timestamp: TimestampMs::new(timestamp), + time_in_force: TimeInForce::Gtc, + extra_fields: (), + } + } + + fn new_trade_id_generator() -> UuidGenerator { + let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); + UuidGenerator::new(namespace) + } + + #[test] + fn test_zero_visible_iceberg_front_gtc_taker_terminates_and_fills_behind() { + // Front: zero-visible iceberg with 30 hidden (id 1). + // Behind: standard sell maker of 40 (id 2). + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_iceberg_order(1, 10000, 0, 30)); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + // Counters reflect both orders: visible 0+40, hidden 30+0. + assert_eq!(price_level.visible_quantity(), 40); + assert_eq!(price_level.hidden_quantity(), 30); + assert_eq!(price_level.order_count(), 2); + + // A GTC taker of 70 must drain both: 40 from the standard maker and + // 30 replenished from the iceberg's hidden. This call MUST terminate. + let result = price_level.match_order( + 70, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); + assert_eq!(result.remaining_quantity(), 0); + assert_eq!(result.executed_quantity().expect("executed_quantity"), 70); + // Both makers are fully consumed and removed. + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + assert_eq!(price_level.hidden_quantity(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + } + + #[test] + fn test_zero_visible_iceberg_front_fok_prediction_matches_sweep() { + // FOK taker of exactly 70 fits (40 + 30 hidden) -> fills fully. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_iceberg_order(1, 10000, 0, 30)); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 70, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + // FOK must fill, not kill: matchable_quantity(70) == 70 == sweep. + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert!(!result.was_killed()); + assert_eq!(result.executed_quantity().expect("executed_quantity"), 70); + assert_eq!(price_level.order_count(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + } + + #[test] + fn test_zero_visible_iceberg_front_fok_killed_when_too_large() { + // FOK taker of 71 exceeds available depth (70) -> killed, queue intact. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_iceberg_order(1, 10000, 0, 30)); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 71, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.was_killed()); + assert_eq!(result.outcome(), MatchOutcome::Killed); + assert_eq!(result.remaining_quantity(), 71); + assert_eq!(result.trades().len(), 0); + // Queue untouched. + assert_eq!(price_level.order_count(), 2); + assert_eq!(price_level.visible_quantity(), 40); + assert_eq!(price_level.hidden_quantity(), 30); + } + + #[test] + fn test_zero_visible_iceberg_front_ioc_taker_fills_available() { + // IOC taker of 200 fills the available 70 and discards the rest. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_iceberg_order(1, 10000, 0, 30)); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 200, + Id::from_u64(999), + TimeInForce::Ioc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert_eq!(result.executed_quantity().expect("executed_quantity"), 70); + assert_eq!(result.remaining_quantity(), 130); + assert!(!result.is_complete()); + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + assert_eq!(price_level.hidden_quantity(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + } + + #[test] + fn test_zero_visible_iceberg_front_post_only_rejected_consistent_depth() { + // PostOnly taker must be rejected because the level has matchable depth: + // both the zero-visible iceberg (hidden 30) and the standard maker count. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_iceberg_order(1, 10000, 0, 30)); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 10, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::PostOnly, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.was_rejected()); + assert_eq!(result.outcome(), MatchOutcome::Rejected); + assert_eq!(result.remaining_quantity(), 10); + assert_eq!(result.trades().len(), 0); + // Queue untouched by the rejection. + assert_eq!(price_level.order_count(), 2); + assert_eq!(price_level.visible_quantity(), 40); + assert_eq!(price_level.hidden_quantity(), 30); + } + + #[test] + fn test_zero_visible_iceberg_alone_post_only_rejected_hidden_only() { + // A level whose ONLY resting order is a zero-visible iceberg with hidden + // depth still has matchable depth: PostOnly must be rejected, and FOK of + // the hidden size must fill. Proves `has_matchable_depth` and + // `matchable_quantity` agree on the degenerate hidden-only state. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_iceberg_order(1, 10000, 0, 25)); + + let rejected = price_level.match_order( + 5, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::PostOnly, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + assert!(rejected.was_rejected()); + assert_eq!(price_level.order_count(), 1); + + // FOK of exactly the hidden size must fill (depth == 25). + let filled = price_level.match_order( + 25, + Id::from_u64(998), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_001), + &trade_gen, + ); + assert!(filled.is_complete()); + assert_eq!(filled.executed_quantity().expect("executed_quantity"), 25); + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + assert_eq!(price_level.hidden_quantity(), 0); + assert_match_result_consistent(&filled, 10000, Side::Sell); + } + + #[test] + fn test_zero_visible_reserve_auto_front_gtc_terminates_and_fills_behind() { + // Front: zero-visible reserve, auto_replenish=true, hidden 50, + // replenish_amount=20 (id 1). Behind: standard sell maker of 40 (id 2). + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_reserve_order(1, 10000, 0, 50, 10, true, Some(20))); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + assert_eq!(price_level.visible_quantity(), 40); + assert_eq!(price_level.hidden_quantity(), 50); + assert_eq!(price_level.order_count(), 2); + + // GTC taker large enough to drain everything (40 + 50). MUST terminate. + let result = price_level.match_order( + 200, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + // 40 from the standard maker + 50 from the reserve (drained tranche by + // tranche). Total available depth is 90. + assert_eq!(result.executed_quantity().expect("executed_quantity"), 90); + assert_eq!(result.remaining_quantity(), 110); + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + assert_eq!(price_level.hidden_quantity(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + } + + #[test] + fn test_zero_visible_reserve_auto_front_fok_prediction_matches_sweep() { + // FOK of exactly the available depth (40 + 50 = 90) must fill. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_reserve_order(1, 10000, 0, 50, 10, true, Some(20))); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + let result = price_level.match_order( + 90, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + assert!(result.is_complete()); + assert_eq!(result.outcome(), MatchOutcome::Filled); + assert!(!result.was_killed()); + assert_eq!(result.executed_quantity().expect("executed_quantity"), 90); + assert_eq!(price_level.order_count(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + + // And FOK of 91 (one over) must be killed with the queue intact. + let price_level2 = PriceLevel::new(10000); + let trade_gen2 = new_trade_id_generator(); + price_level2.add_order(create_reserve_order(1, 10000, 0, 50, 10, true, Some(20))); + price_level2.add_order(create_sell_standard_order(2, 10000, 40)); + let killed = price_level2.match_order( + 91, + Id::from_u64(999), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen2, + ); + assert!(killed.was_killed()); + assert_eq!(price_level2.order_count(), 2); + assert_eq!(price_level2.visible_quantity(), 40); + assert_eq!(price_level2.hidden_quantity(), 50); + } + + #[test] + fn test_zero_visible_reserve_no_auto_front_dropped_behind_fills() { + // Front: zero-visible reserve, auto_replenish=FALSE, hidden 50 (id 1). + // This reserve cannot replenish, so the sweep DROPS it (returns None) + // without filling. Its hidden quantity is removed from the level. The + // standard maker behind it (id 2, qty 40) must still match. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + price_level.add_order(create_reserve_order(1, 10000, 0, 50, 10, false, Some(20))); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + assert_eq!(price_level.visible_quantity(), 40); + assert_eq!(price_level.hidden_quantity(), 50); + assert_eq!(price_level.order_count(), 2); + + let result = price_level.match_order( + 100, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + // Only the standard maker (40) is matchable; the non-replenishing, + // zero-visible reserve is dropped without a trade. + assert_eq!(result.executed_quantity().expect("executed_quantity"), 40); + assert_eq!(result.remaining_quantity(), 60); + // Both orders are gone from the queue (one filled, one dropped) and the + // counters are consistent: hidden of the dropped reserve was removed. + assert_eq!(price_level.order_count(), 0); + assert_eq!(price_level.visible_quantity(), 0); + assert_eq!(price_level.hidden_quantity(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + } + + #[test] + fn test_zero_visible_reserve_no_auto_alone_depth_definitions_agree() { + // A non-replenishing zero-visible reserve is NOT matchable depth: the + // sweep would drop it (returns None) without ever filling. The two depth + // views must AGREE on this: FOK's `matchable_quantity` sees 0 (kills, + // leaving the queue intact since FOK is a pure pre-check), and PostOnly's + // `has_matchable_depth` is false (so PostOnly is NOT rejected). + + // FOK pre-check: 0 matchable depth -> killed, queue untouched. + let fok_level = PriceLevel::new(10000); + let fok_gen = new_trade_id_generator(); + fok_level.add_order(create_reserve_order(1, 10000, 0, 50, 10, false, Some(20))); + + let fok = fok_level.match_order( + 5, + Id::from_u64(998), + TimeInForce::Fok, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_001), + &fok_gen, + ); + assert!(fok.was_killed()); + assert_eq!(fok.remaining_quantity(), 5); + // FOK is a pre-check: the dead reserve is left resting, queue intact. + assert_eq!(fok_level.order_count(), 1); + assert_eq!(fok_level.hidden_quantity(), 50); + + // PostOnly on a fresh level: no matchable depth -> not rejected. It then + // falls through to the sweep as an ordinary (zero-taking) taker, which + // garbage-collects the unmatchable reserve with no trade and keeps the + // counters consistent with the queue. + let po_level = PriceLevel::new(10000); + let po_gen = new_trade_id_generator(); + po_level.add_order(create_reserve_order(1, 10000, 0, 50, 10, false, Some(20))); + + let post_only = po_level.match_order( + 5, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::PostOnly, + TimestampMs::new(1_716_000_000_000), + &po_gen, + ); + assert!(!post_only.was_rejected()); + assert_eq!(post_only.trades().len(), 0); + assert_eq!(post_only.executed_quantity().expect("executed_quantity"), 0); + // The non-matchable reserve is dropped by the fall-through sweep; the + // hidden counter is decremented in lockstep with the queue removal. + assert_eq!(po_level.order_count(), 0); + assert_eq!(po_level.visible_quantity(), 0); + assert_eq!(po_level.hidden_quantity(), 0); + } + + #[test] + fn test_update_quantity_zero_on_iceberg_then_match_terminates() { + // Drive the iceberg into the degenerate zero-visible state via + // update_order(UpdateQuantity { new_quantity: 0 }), then match. The + // matcher must terminate and the maker behind must still fill. + let price_level = PriceLevel::new(10000); + let trade_gen = new_trade_id_generator(); + + // Iceberg with 20 visible / 30 hidden, then a standard maker behind it. + price_level.add_order(create_iceberg_order(1, 10000, 20, 30)); + price_level.add_order(create_sell_standard_order(2, 10000, 40)); + + // Reduce the iceberg's quantity to 0 (degenerate zero-visible state). + price_level + .update_order(OrderUpdate::UpdateQuantity { + order_id: Id::from_u64(1), + new_quantity: Quantity::new(0), + }) + .expect("update to zero quantity must succeed"); + + // The matcher MUST terminate. A GTC taker drains whatever is matchable. + let result = price_level.match_order( + 500, + Id::from_u64(999), + TimeInForce::Gtc, + TakerKind::Standard, + TimestampMs::new(1_716_000_000_000), + &trade_gen, + ); + + // The standard maker (40) is matched regardless of how the zeroed + // iceberg is resolved; the call terminates and counters stay consistent. + assert!(result.executed_quantity().expect("executed_quantity") >= 40); + assert_eq!(price_level.visible_quantity(), 0); + assert_match_result_consistent(&result, 10000, Side::Sell); + + // Snapshot round-trip must still hold after the degenerate match. + let json = price_level + .snapshot_to_json() + .expect("snapshot_to_json after degenerate match"); + let restored = + PriceLevel::from_snapshot_json(&json).expect("from_snapshot_json round-trip"); + assert_eq!(restored.visible_quantity(), price_level.visible_quantity()); + assert_eq!(restored.hidden_quantity(), price_level.hidden_quantity()); + assert_eq!(restored.order_count(), price_level.order_count()); + } } #[cfg(test)] diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index ce2a13d..083b56a 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -43,6 +43,8 @@ fn partial_fill_keeps_price_time_priority_across_calls() { let first = level.match_order( 60, Id::from_u64(901), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); @@ -53,6 +55,8 @@ fn partial_fill_keeps_price_time_priority_across_calls() { let second = level.match_order( 50, Id::from_u64(902), + TimeInForce::Gtc, + TakerKind::Standard, TimestampMs::new(1_716_000_000_000), &trade_ids, ); From e06e07693c0cbe4e63df568f7a374e1948415b60 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Wed, 24 Jun 2026 00:09:16 +0200 Subject: [PATCH 2/2] address review: zero-qty MatchResult consistency; accurate dry-run/doc comments - MatchResult::new / with_capacity: a zero initial_quantity result is now is_complete=true / outcome=Filled at construction (vacuously complete), matching finalize's remaining==0 => Filled rule, instead of the inconsistent is_complete=false / NotFilled. - matchable_quantity comment no longer overclaims it mirrors the sweep's pure sequence order; clarified it visits (timestamp, sequence) order but the fillable TOTAL is visit-order invariant (which is all FOK depends on). - is_matchable rustdoc: dropped the broken intra-doc links to the private has_matchable_depth / matchable_quantity helpers; use plain code spans. --- src/execution/match_result.rs | 23 +++++++++++++++++++---- src/orders/order_type.rs | 10 ++++------ src/price_level/level.rs | 10 ++++++++-- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/execution/match_result.rs b/src/execution/match_result.rs index d2fae1a..2738968 100644 --- a/src/execution/match_result.rs +++ b/src/execution/match_result.rs @@ -101,13 +101,22 @@ impl MatchResult { /// Create a new empty match result #[must_use] pub fn new(order_id: Id, initial_quantity: u64) -> Self { + // A zero-quantity result is vacuously complete (nothing to fill), so keep + // is_complete / outcome consistent at construction — matching + // `finalize`'s `remaining == 0 => Filled` rule. A non-zero result starts + // incomplete / NotFilled until a trade or `finalize` updates it. + let is_complete = initial_quantity == 0; Self { order_id, trades: TradeList::new(), remaining_quantity: initial_quantity, - is_complete: false, + is_complete, filled_order_ids: Vec::new(), - outcome: MatchOutcome::NotFilled, + outcome: if is_complete { + MatchOutcome::Filled + } else { + MatchOutcome::NotFilled + }, } } @@ -120,13 +129,19 @@ impl MatchResult { /// per-fill reallocations on the match hot path. #[must_use] pub fn with_capacity(order_id: Id, initial_quantity: u64, capacity: usize) -> Self { + // Same zero-quantity consistency as `new` (see there). + let is_complete = initial_quantity == 0; Self { order_id, trades: TradeList::with_capacity(capacity), remaining_quantity: initial_quantity, - is_complete: false, + is_complete, filled_order_ids: Vec::with_capacity(capacity), - outcome: MatchOutcome::NotFilled, + outcome: if is_complete { + MatchOutcome::Filled + } else { + MatchOutcome::NotFilled + }, } } diff --git a/src/orders/order_type.rs b/src/orders/order_type.rs index e4eb8a3..8de2484 100644 --- a/src/orders/order_type.rs +++ b/src/orders/order_type.rs @@ -285,9 +285,10 @@ impl OrderType { /// taker, i.e. a positive taker would take some quantity from it. /// /// This is the single source of truth for "matchable depth" shared by the - /// post-only pre-check ([`PriceLevel::has_matchable_depth`]) and the - /// fill-or-kill dry run ([`PriceLevel::matchable_quantity`]), so the two can - /// never disagree about the same level: + /// post-only pre-check (`has_matchable_depth`) and the fill-or-kill dry run + /// (`matchable_quantity`), so the two can never disagree about the same level + /// (those are private price-level helpers, named here as plain spans rather + /// than doc links): /// /// - Any order with positive **visible** quantity is matchable. /// - A zero-visible **iceberg** with hidden quantity is matchable: the sweep @@ -297,9 +298,6 @@ impl OrderType { /// dropped by the sweep without ever filling, so it is *not* matchable /// depth. /// - Every other zero-visible order (no hidden to draw on) is not matchable. - /// - /// [`PriceLevel::has_matchable_depth`]: crate::price_level::PriceLevel - /// [`PriceLevel::matchable_quantity`]: crate::price_level::PriceLevel #[must_use] #[inline] pub fn is_matchable(&self) -> bool { diff --git a/src/price_level/level.rs b/src/price_level/level.rs index cf681f7..7b0c160 100644 --- a/src/price_level/level.rs +++ b/src/price_level/level.rs @@ -279,8 +279,14 @@ impl PriceLevel { return 0; } - // Snapshot in deterministic FIFO order, mirroring the real sweep's - // `pop_entry` order so the dry run matches it exactly. + // Snapshot the resting orders. `snapshot_orders()` is ordered by + // `(timestamp, sequence)` whereas the real sweep pops by pure insertion + // sequence; these coincide when timestamps are monotonic with insertion + // (the normal case). The two can only differ in *visit order*, never in + // the fillable *total*: every maker (including a fully-drained + // replenishing iceberg/auto-reserve) contributes the same amount + // regardless of when it is visited, so the sum this returns is exactly + // what the sweep would consume — which is all fill-or-kill depends on. let mut pending: std::collections::VecDeque>> = self.snapshot_orders().into(); let mut remaining = incoming_quantity;