#[cfg(test)]
mod tests {
use crate::orderbook::OrderBookError;
use crate::orderbook::book::OrderBook;
use pricelevel::{Hash32, Id, OrderType, Price, Quantity, Side, TimeInForce, TimestampMs};
fn setup_book() -> OrderBook<()> {
OrderBook::new("TEST_SYMBOL")
}
fn add_limit_order(book: &OrderBook, side: Side, price: u128, quantity: u64) -> Id {
let order = OrderType::Standard {
id: Id::new(),
side,
price: Price::new(price),
quantity: Quantity::new(quantity),
user_id: Hash32::zero(),
time_in_force: TimeInForce::Gtc, timestamp: TimestampMs::new(0), extra_fields: (),
};
let order_id = order.id();
book.add_order(order).unwrap();
order_id
}
#[test]
fn test_partial_fill_preserves_price_time_priority_issue_88() {
let book = setup_book();
let maker_a = add_limit_order(&book, Side::Sell, 100, 10);
let maker_b = add_limit_order(&book, Side::Sell, 100, 10);
let r1 = book
.match_order(Id::new(), Side::Buy, 4, Some(100))
.unwrap();
let t1 = r1.trades().as_vec();
assert_eq!(t1.len(), 1);
assert_eq!(t1[0].maker_order_id(), maker_a);
assert_eq!(t1[0].quantity(), Quantity::new(4));
let r2 = book
.match_order(Id::new(), Side::Buy, 4, Some(100))
.unwrap();
let t2 = r2.trades().as_vec();
assert_eq!(t2.len(), 1);
assert_eq!(
t2[0].maker_order_id(),
maker_a,
"partial fill must not demote the resting maker behind later arrivals"
);
let r3 = book
.match_order(Id::new(), Side::Buy, 5, Some(100))
.unwrap();
let t3 = r3.trades().as_vec();
assert_eq!(t3.len(), 2);
assert_eq!(t3[0].maker_order_id(), maker_a);
assert_eq!(t3[0].quantity(), Quantity::new(2));
assert_eq!(t3[1].maker_order_id(), maker_b);
assert_eq!(t3[1].quantity(), Quantity::new(3));
}
#[test]
fn test_fully_consumed_maker_records_true_filled_quantity_issue_104() {
use crate::orderbook::order_state::{OrderStateTracker, OrderStatus};
let mut book = setup_book();
book.set_order_state_tracker(OrderStateTracker::new());
let maker = add_limit_order(&book, Side::Sell, 100, 10);
let taker = Id::new();
let result = book.match_order(taker, Side::Buy, 10, None).unwrap();
assert!(result.is_complete());
match book.order_status(maker) {
Some(OrderStatus::Filled { filled_quantity }) => {
assert_eq!(
filled_quantity, 10,
"fully-consumed maker must record its true 10-unit fill, not 0"
);
}
other => panic!("expected Filled {{ filled_quantity: 10 }}, got {other:?}"),
}
}
#[test]
fn test_fok_with_lot_size_fills_when_depth_suffices_issue_96() {
let book: OrderBook<()> = OrderBook::with_lot_size("TEST", 5);
add_limit_order(&book, Side::Sell, 100, 5);
add_limit_order(&book, Side::Sell, 101, 5);
let fok = OrderType::Standard {
id: Id::new(),
price: Price::new(101),
quantity: Quantity::new(10),
side: Side::Buy,
user_id: Hash32::zero(),
time_in_force: TimeInForce::Fok,
timestamp: TimestampMs::new(0),
extra_fields: (),
};
let result = book.add_order(fok);
assert!(
result.is_ok(),
"lot-aligned FOK with sufficient depth must fill, got {result:?}"
);
assert!(book.has_traded.load(std::sync::atomic::Ordering::SeqCst));
assert!(book.asks.is_empty(), "both ask levels should be consumed");
}
#[test]
fn test_fok_excludes_non_replenish_reserve_hidden_issue_96() {
use std::num::NonZeroU64;
let book: OrderBook<()> = OrderBook::new("TEST");
let reserve_id = Id::new();
book.add_order(OrderType::ReserveOrder {
id: reserve_id,
price: Price::new(100),
visible_quantity: Quantity::new(5),
hidden_quantity: Quantity::new(5),
side: Side::Sell,
user_id: Hash32::zero(),
timestamp: TimestampMs::new(0),
time_in_force: TimeInForce::Gtc,
replenish_threshold: Quantity::new(0),
replenish_amount: Some(NonZeroU64::new(5).expect("nonzero")),
auto_replenish: false,
extra_fields: (),
})
.expect("reserve admitted");
let fok = OrderType::Standard {
id: Id::new(),
price: Price::new(100),
quantity: Quantity::new(10),
side: Side::Buy,
user_id: Hash32::zero(),
time_in_force: TimeInForce::Fok,
timestamp: TimestampMs::new(0),
extra_fields: (),
};
let result = book.add_order(fok);
assert!(
matches!(result, Err(OrderBookError::InsufficientLiquidity { .. })),
"FOK must be killed: a non-replenish reserve's hidden is not drawable, got {result:?}"
);
assert!(
!book.has_traded.load(std::sync::atomic::Ordering::SeqCst),
"FOK kill must emit no trades"
);
assert!(
book.get_order(reserve_id).is_some(),
"the reserve must be untouched by a killed FOK"
);
}
#[test]
fn test_fok_fills_against_iceberg_replenishable_hidden_issue_136() {
let book: OrderBook<()> = OrderBook::new("TEST");
book.add_order(OrderType::IcebergOrder {
id: Id::new(),
price: Price::new(100),
visible_quantity: Quantity::new(2),
hidden_quantity: Quantity::new(8),
side: Side::Sell,
user_id: Hash32::zero(),
timestamp: TimestampMs::new(0),
time_in_force: TimeInForce::Gtc,
extra_fields: (),
})
.expect("iceberg admitted");
let fok = OrderType::Standard {
id: Id::new(),
price: Price::new(100),
quantity: Quantity::new(10),
side: Side::Buy,
user_id: Hash32::zero(),
time_in_force: TimeInForce::Fok,
timestamp: TimestampMs::new(0),
extra_fields: (),
};
let result = book.add_order(fok);
assert!(
result.is_ok(),
"FOK must fill against an iceberg's replenishable depth, got {result:?}"
);
assert!(book.has_traded.load(std::sync::atomic::Ordering::SeqCst));
assert!(book.asks.is_empty(), "the iceberg is fully consumed");
}
#[test]
fn test_market_buy_full_match() {
let book = setup_book();
add_limit_order(&book, Side::Sell, 100, 50);
let taker_order_id = Id::new();
let result = book
.match_order(taker_order_id, Side::Buy, 50, None)
.unwrap();
assert_eq!(result.remaining_quantity(), Quantity::new(0));
assert!(result.is_complete());
assert_eq!(result.trades().as_vec().len(), 1);
assert_eq!(book.asks.len(), 0); assert!(book.has_traded.load(std::sync::atomic::Ordering::SeqCst));
assert_eq!(book.last_trade_price.load(), 100);
}
#[test]
fn test_market_sell_partial_match() {
let book = setup_book();
add_limit_order(&book, Side::Buy, 90, 30);
let taker_order_id = Id::new();
let result = book
.match_order(taker_order_id, Side::Sell, 50, None)
.unwrap();
assert_eq!(result.remaining_quantity(), Quantity::new(20));
assert!(!result.is_complete());
assert_eq!(result.trades().as_vec().len(), 1);
assert_eq!(book.bids.len(), 0); }
#[test]
fn test_limit_buy_favorable_price_match() {
let book = setup_book();
add_limit_order(&book, Side::Sell, 100, 50);
let taker_order_id = Id::new();
let result = book
.match_order(taker_order_id, Side::Buy, 50, Some(105))
.unwrap();
assert_eq!(result.remaining_quantity(), Quantity::new(0));
assert!(result.is_complete());
assert_eq!(book.asks.len(), 0);
}
#[test]
fn test_limit_sell_unfavorable_price_no_match() {
let book = setup_book();
add_limit_order(&book, Side::Buy, 90, 50);
let taker_order_id = Id::new();
let result = book
.match_order(taker_order_id, Side::Sell, 50, Some(95))
.unwrap();
assert_eq!(result.remaining_quantity(), Quantity::new(50));
assert!(!result.is_complete());
assert!(result.trades().as_vec().is_empty());
assert_eq!(book.bids.len(), 1); }
#[test]
fn test_market_order_no_liquidity_error() {
let book = setup_book();
let taker_order_id = Id::new();
let result = book.match_order(taker_order_id, Side::Buy, 50, None);
assert!(matches!(
result,
Err(OrderBookError::InsufficientLiquidity { .. })
));
}
#[test]
fn test_match_across_multiple_price_levels() {
let book = setup_book();
add_limit_order(&book, Side::Sell, 100, 20);
add_limit_order(&book, Side::Sell, 101, 30);
add_limit_order(&book, Side::Sell, 102, 40);
let taker_order_id = Id::new();
let result = book
.match_order(taker_order_id, Side::Buy, 70, None)
.unwrap();
assert_eq!(result.remaining_quantity(), Quantity::new(0));
assert!(result.is_complete());
assert_eq!(result.trades().as_vec().len(), 3);
assert_eq!(book.asks.len(), 1);
let remaining_level = book.asks.get(&102).unwrap();
assert_eq!(remaining_level.value().total_quantity().unwrap_or(0), 20); assert_eq!(book.last_trade_price.load(), 102);
}
#[test]
fn test_peek_match_buy_side_full_match() {
let book: OrderBook<()> = OrderBook::new("TEST");
book.add_limit_order(Id::new(), 101, 10, Side::Sell, TimeInForce::Gtc, None)
.unwrap();
book.add_limit_order(Id::new(), 102, 5, Side::Sell, TimeInForce::Gtc, None)
.unwrap();
let matched_quantity = book.peek_match(Side::Buy, 15, None);
assert_eq!(matched_quantity, 15);
}
#[test]
fn test_peek_match_buy_side_partial_match() {
let book: OrderBook<()> = OrderBook::new("TEST");
book.add_limit_order(Id::new(), 101, 10, Side::Sell, TimeInForce::Gtc, None)
.unwrap();
let matched_quantity = book.peek_match(Side::Buy, 20, None);
assert_eq!(matched_quantity, 10);
}
#[test]
fn test_peek_match_sell_side_with_price_limit() {
let book: OrderBook<()> = OrderBook::new("TEST");
book.add_limit_order(Id::new(), 98, 10, Side::Buy, TimeInForce::Gtc, None)
.unwrap();
book.add_limit_order(Id::new(), 99, 5, Side::Buy, TimeInForce::Gtc, None)
.unwrap();
book.add_limit_order(Id::new(), 100, 20, Side::Buy, TimeInForce::Gtc, None)
.unwrap();
let matched_quantity = book.peek_match(Side::Sell, 50, Some(99));
assert_eq!(matched_quantity, 25); }
#[test]
fn test_peek_match_no_liquidity() {
let book: OrderBook<()> = OrderBook::new("TEST");
let matched_quantity = book.peek_match(Side::Buy, 10, None);
assert_eq!(matched_quantity, 0);
}
}