use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use option_chain_orderbook::{OptionOrderBook, OrderId as ObOrderId, Side as ObSide, TradeResult};
use optionstratlib::{OptionStyle, Side};
use optionstratlib_ob::OptionStyle as ObOptionStyle;
use crate::config::{FeeSchedule, LiquidityProfile};
use crate::domain::{
ChainSnapshot, ContractKey, ExecutionMode, Fill, OrderCommand, OrderId, OrderIntent,
PriceCents, Quantity, QuoteView, TimeInForce,
};
use crate::error::BacktestError;
use super::{
CarryGroup, ExecutionModel, FeeCharge, FillDraft, FillGroup, assemble_fill, liquidity,
};
const STRATEGY_ID_BASE: u64 = 1;
pub(crate) const MAKER_ID_BASE: u64 = 1 << 48;
pub struct RealisticFill {
fees: FeeSchedule,
marketable_cap_ticks: u32,
seed: u64,
next_strategy_id: u64,
next_maker_id: u64,
liquidity_profile: Option<LiquidityProfile>,
resting_seed_ids: BTreeMap<ContractKey, Vec<u64>>,
resting_strategy: BTreeMap<u64, RestingStrategyOrder>,
order_index: BTreeMap<OrderId, u64>,
carry_groups: Vec<CarryGroup>,
live_orders: BTreeMap<ContractKey, u32>,
seed_plan: Vec<liquidity::SeedOrder>,
fill_groups: Vec<FillGroup>,
books: BTreeMap<ContractKey, OptionOrderBook>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RestingStrategyOrder {
order_id: OrderId,
contract: ContractKey,
side: Side,
decision_mid: PriceCents,
remaining: u64,
first_fill_done: bool,
}
impl std::fmt::Debug for RealisticFill {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RealisticFill")
.field("fees", &self.fees)
.field("marketable_cap_ticks", &self.marketable_cap_ticks)
.field("seed", &self.seed)
.field("next_strategy_id", &self.next_strategy_id)
.field("next_maker_id", &self.next_maker_id)
.field("liquidity_profile", &self.liquidity_profile)
.field("resting_seed_ids", &self.resting_seed_ids.len())
.field("resting_strategy", &self.resting_strategy.len())
.field("order_index", &self.order_index.len())
.field("carry_groups", &self.carry_groups.len())
.field("fill_groups", &self.fill_groups.len())
.field("books", &self.books.len())
.finish()
}
}
impl RealisticFill {
#[must_use = "the constructed fill model must be used to produce fills"]
pub fn new(fees: FeeSchedule, marketable_cap_ticks: u32, seed: u64) -> Self {
Self {
fees,
marketable_cap_ticks,
seed,
next_strategy_id: STRATEGY_ID_BASE,
next_maker_id: MAKER_ID_BASE,
liquidity_profile: None,
resting_seed_ids: BTreeMap::new(),
resting_strategy: BTreeMap::new(),
order_index: BTreeMap::new(),
carry_groups: Vec::new(),
live_orders: BTreeMap::new(),
seed_plan: Vec::new(),
fill_groups: Vec::new(),
books: BTreeMap::new(),
}
}
#[must_use = "the constructed fill model must be used to produce fills"]
pub fn with_liquidity_profile(
fees: FeeSchedule,
marketable_cap_ticks: u32,
seed: u64,
profile: LiquidityProfile,
) -> Self {
Self {
fees,
marketable_cap_ticks,
seed,
next_strategy_id: STRATEGY_ID_BASE,
next_maker_id: MAKER_ID_BASE,
liquidity_profile: Some(profile),
resting_seed_ids: BTreeMap::new(),
resting_strategy: BTreeMap::new(),
order_index: BTreeMap::new(),
carry_groups: Vec::new(),
live_orders: BTreeMap::new(),
seed_plan: Vec::new(),
fill_groups: Vec::new(),
books: BTreeMap::new(),
}
}
#[must_use]
pub const fn seed(&self) -> u64 {
self.seed
}
fn next_strategy_order_id(&mut self) -> Result<ObOrderId, BacktestError> {
let id = self.next_strategy_id;
if id >= MAKER_ID_BASE {
return Err(BacktestError::Execution(format!(
"strategy order id range exhausted at {id} (maker range begins at {MAKER_ID_BASE})"
)));
}
self.next_strategy_id = id + 1;
Ok(ObOrderId::Sequential(id))
}
pub(crate) fn next_maker_order_id(&mut self) -> Result<ObOrderId, BacktestError> {
let id = self.next_maker_id;
let next = id.checked_add(1).ok_or_else(|| {
BacktestError::Execution("seeded-maker order id range exhausted".to_string())
})?;
self.next_maker_id = next;
Ok(ObOrderId::Sequential(id))
}
fn leaf_book(&mut self, contract: &ContractKey) -> Result<&OptionOrderBook, BacktestError> {
leaf_book_in(&mut self.books, contract)
}
pub fn seed_maker_limit(
&mut self,
contract: &ContractKey,
is_ask: bool,
price: PriceCents,
quantity: Quantity,
tick_size_cents: PriceCents,
) -> Result<(), BacktestError> {
let tick = tick_size_cents.value();
let price_ticks = cents_to_ticks(price, tick)?;
let qty = u64::from(quantity.value());
let side = if is_ask { ObSide::Sell } else { ObSide::Buy };
let id = self.next_maker_order_id()?;
let book = self.leaf_book(contract)?;
book.add_limit_order(id, side, price_ticks, qty)?;
Ok(())
}
fn refresh_books(
&mut self,
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
let Some(profile) = self.liquidity_profile else {
return Ok(());
};
let tick = snap.spec.tick_size_cents.value();
if tick == 0 {
return Err(BacktestError::Execution(
"instrument tick_size_cents is zero at the realistic refresh".to_string(),
));
}
self.cancel_stale_seed()?;
{
let Self {
books,
resting_seed_ids,
live_orders,
..
} = self;
books.retain(|contract, _| {
snap.quotes.contains_key(contract) || live_orders.contains_key(contract)
});
resting_seed_ids.retain(|contract, _| {
snap.quotes.contains_key(contract) || live_orders.contains_key(contract)
});
}
let mut plan = std::mem::take(&mut self.seed_plan);
let outcome = self.reseed(&mut plan, snap, &profile, tick, out_fills);
self.seed_plan = plan;
outcome
}
fn cancel_stale_seed(&mut self) -> Result<(), BacktestError> {
let Self {
books,
resting_seed_ids,
..
} = self;
for (contract, ids) in resting_seed_ids.iter_mut() {
if let Some(book) = books.get(contract) {
for &id in ids.iter() {
let _cancelled = book.cancel_order(ObOrderId::Sequential(id))?;
}
}
ids.clear();
}
Ok(())
}
fn reseed(
&mut self,
plan: &mut Vec<liquidity::SeedOrder>,
snap: &ChainSnapshot,
profile: &LiquidityProfile,
tick: u64,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
liquidity::plan_seed_into(snap, profile, plan)?;
for order in plan.iter() {
let price_ticks = cents_to_ticks(order.price, tick)?;
let side = if order.is_ask {
ObSide::Sell
} else {
ObSide::Buy
};
let qty = u64::from(order.size.value());
let id = self.next_maker_order_id()?;
let trade_result: TradeResult = {
let book = leaf_book_in(&mut self.books, &order.contract)?;
book.add_limit_order_full(id, side, price_ticks, qty)?
};
for trade in trade_result.match_result.trades().as_vec() {
self.capture_refresh_fill(
trade.maker_order_id(),
trade.price().as_u128(),
trade.quantity().as_u64(),
&order.contract,
tick,
snap,
out_fills,
)?;
}
if trade_result.match_result.remaining_quantity().as_u64() > 0
&& let ObOrderId::Sequential(seq) = id
{
self.resting_seed_ids
.entry(order.contract.clone())
.or_default()
.push(seq);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn capture_refresh_fill(
&mut self,
maker_id: ObOrderId,
trade_price_ticks: u128,
trade_qty: u64,
contract: &ContractKey,
tick: u64,
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
let ObOrderId::Sequential(maker_seq) = maker_id else {
return Ok(());
};
if !self.resting_strategy.contains_key(&maker_seq) {
return Ok(());
}
let exec_price = ticks_to_cents(trade_price_ticks, tick)?;
let matched = u32::try_from(trade_qty).map_err(|_| BacktestError::ArithmeticOverflow)?;
let quantity = Quantity::new(matched)?;
let (engine_order_id, side, decision_mid, charge, exhausted) = {
let Some(meta) = self.resting_strategy.get_mut(&maker_seq) else {
return Ok(());
};
let charge = if meta.first_fill_done {
FeeCharge::LaterFill
} else {
FeeCharge::FirstFill
};
meta.first_fill_done = true;
meta.remaining = meta.remaining.checked_sub(trade_qty).ok_or_else(|| {
BacktestError::Execution(
"refresh matched more than the strategy order's resting size \
(book/tracking desync)"
.to_string(),
)
})?;
(
meta.order_id,
meta.side,
meta.decision_mid,
charge,
meta.remaining == 0,
)
};
let draft = FillDraft {
ts: snap.ts,
step: snap.step,
contract: contract.clone(),
side,
quantity,
price: exec_price,
decision_mid,
};
out_fills.push(assemble_fill(
draft,
ExecutionMode::Realistic,
&self.fees,
charge,
)?);
if let Some(last) = self.carry_groups.last_mut()
&& last.order_id == engine_order_id
{
last.fill_count = last
.fill_count
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
} else {
self.carry_groups.push(CarryGroup {
order_id: engine_order_id,
fill_count: 1,
});
}
if exhausted {
if let Some(meta) = self.resting_strategy.remove(&maker_seq) {
Self::release_live_order(&mut self.live_orders, &meta.contract)?;
}
self.order_index.remove(&engine_order_id);
}
Ok(())
}
fn fill_submit(
&mut self,
intent: &OrderIntent,
engine_order_id: OrderId,
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
let tick = snap.spec.tick_size_cents.value();
if tick == 0 {
return Err(BacktestError::Execution(
"instrument tick_size_cents is zero at the realistic seam".to_string(),
));
}
let ob_side = ob_side(intent.side);
let qty = u64::from(intent.quantity.value());
let limit_cents = match intent.limit {
Some(limit) => limit,
None => {
let quote = snap.quotes.get(&intent.contract).ok_or_else(|| {
BacktestError::Execution(format!(
"realistic fill: marketable intent for strike {} not quoted at step {}",
intent.contract.strike.value(),
snap.step.value()
))
})?;
marketable_limit_cents(intent.side, quote, tick, self.marketable_cap_ticks)?
}
};
let price_ticks = cents_to_ticks(limit_cents, tick)?;
let order_id = self.next_strategy_order_id()?;
let trade_result: TradeResult = {
let book = self.leaf_book(&intent.contract)?;
book.add_limit_order_full(order_id, ob_side, price_ticks, qty)?
};
let remaining = trade_result.match_result.remaining_quantity().as_u64();
if matches!(intent.tif, TimeInForce::Ioc) && remaining > 0 {
let book = self.leaf_book(&intent.contract)?;
let _cancelled = book.cancel_order(order_id)?;
}
for trade in trade_result.match_result.trades().as_vec() {
if let ObOrderId::Sequential(maker_seq) = trade.maker_order_id()
&& self.resting_strategy.contains_key(&maker_seq)
{
return Err(BacktestError::Execution(
"taker intent crossed a resting strategy order (strategy \
self-cross); maker-side e2 capture is unsupported"
.to_string(),
));
}
}
for (level, trade) in trade_result
.match_result
.trades()
.as_vec()
.iter()
.enumerate()
{
let exec_price = ticks_to_cents(trade.price().as_u128(), tick)?;
let matched = u32::try_from(trade.quantity().as_u64())
.map_err(|_| BacktestError::ArithmeticOverflow)?;
let quantity = Quantity::new(matched)?;
let charge = if level == 0 {
FeeCharge::FirstFill
} else {
FeeCharge::LaterFill
};
let draft = FillDraft {
ts: snap.ts,
step: snap.step,
contract: intent.contract.clone(),
side: intent.side,
quantity,
price: exec_price,
decision_mid: intent.decision_mid,
};
out_fills.push(assemble_fill(
draft,
ExecutionMode::Realistic,
&self.fees,
charge,
)?);
}
if matches!(intent.tif, TimeInForce::Gtc)
&& remaining > 0
&& let ObOrderId::Sequential(seq) = order_id
{
self.resting_strategy.insert(
seq,
RestingStrategyOrder {
order_id: engine_order_id,
contract: intent.contract.clone(),
side: intent.side,
decision_mid: intent.decision_mid,
remaining,
first_fill_done: remaining < qty,
},
);
self.order_index.insert(engine_order_id, seq);
let count = self.live_orders.entry(intent.contract.clone()).or_insert(0);
*count = count
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
Ok(())
}
fn cancel_resting(&mut self, order_id: OrderId) -> Result<(), BacktestError> {
let Some(seq) = self.order_index.get(&order_id).copied() else {
return Ok(());
};
if let Some(meta) = self.resting_strategy.get(&seq)
&& let Some(book) = self.books.get(&meta.contract)
{
let _cancelled = book.cancel_order(ObOrderId::Sequential(seq))?;
}
if let Some(meta) = self.resting_strategy.remove(&seq) {
Self::release_live_order(&mut self.live_orders, &meta.contract)?;
}
self.order_index.remove(&order_id);
Ok(())
}
fn release_live_order(
live_orders: &mut BTreeMap<ContractKey, u32>,
contract: &ContractKey,
) -> Result<(), BacktestError> {
let Some(count) = live_orders.get_mut(contract) else {
return Err(BacktestError::Execution(
"live-order index missing an entry for a tracked resting order".to_string(),
));
};
*count = count.checked_sub(1).ok_or_else(|| {
BacktestError::Execution(
"live-order index underflow for a tracked resting order".to_string(),
)
})?;
if *count == 0 {
live_orders.remove(contract);
}
Ok(())
}
}
impl ExecutionModel for RealisticFill {
fn fill(
&mut self,
commands: &[OrderCommand],
submit_ids: &[OrderId],
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
self.fill_groups.clear();
self.carry_groups.clear();
self.refresh_books(snap, out_fills)?;
for command in commands {
match command {
OrderCommand::Cancel(order_id) | OrderCommand::Replace { order_id, .. } => {
self.cancel_resting(*order_id)?;
}
OrderCommand::Submit(_) => {}
}
}
let mut next_submit_id: usize = 0;
for (command_index, command) in commands.iter().enumerate() {
let intent = match command {
OrderCommand::Submit(intent) => intent,
OrderCommand::Replace { replacement, .. } => replacement,
OrderCommand::Cancel(_) => continue,
};
let engine_order_id = submit_ids.get(next_submit_id).copied().ok_or_else(|| {
BacktestError::Execution(
"submit_ids under-covers the step's Submit/Replace commands".to_string(),
)
})?;
next_submit_id = next_submit_id
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
let before = out_fills.len();
self.fill_submit(intent, engine_order_id, snap, out_fills)?;
let produced = out_fills.len().checked_sub(before).ok_or_else(|| {
BacktestError::Execution("fill buffer shrank during fill_submit".to_string())
})?;
if produced > 0 {
let fill_count =
u32::try_from(produced).map_err(|_| BacktestError::ArithmeticOverflow)?;
self.fill_groups.push(FillGroup {
command_index,
fill_count,
});
}
}
Ok(())
}
#[inline]
fn carry_fills(&self) -> &[CarryGroup] {
&self.carry_groups
}
#[inline]
fn fill_groups(&self) -> Option<&[FillGroup]> {
Some(&self.fill_groups)
}
#[inline]
fn mode(&self) -> ExecutionMode {
ExecutionMode::Realistic
}
}
fn leaf_book_in<'a>(
books: &'a mut BTreeMap<ContractKey, OptionOrderBook>,
contract: &ContractKey,
) -> Result<&'a OptionOrderBook, BacktestError> {
match books.entry(contract.clone()) {
Entry::Occupied(e) => Ok(&*e.into_mut()),
Entry::Vacant(e) => {
let symbol = contract.to_contract_id()?;
let book = OptionOrderBook::new(symbol, ob_option_style(contract.style));
Ok(&*e.insert(book))
}
}
}
#[must_use]
const fn ob_side(side: Side) -> ObSide {
match side {
Side::Long => ObSide::Buy,
Side::Short => ObSide::Sell,
}
}
#[must_use]
fn ob_option_style(style: OptionStyle) -> ObOptionStyle {
match style {
OptionStyle::Call => ObOptionStyle::Call,
OptionStyle::Put => ObOptionStyle::Put,
}
}
#[must_use = "the scaled tick price must be submitted"]
fn cents_to_ticks(price: PriceCents, tick: u64) -> Result<u128, BacktestError> {
let price = price.value();
if tick == 0 {
return Err(BacktestError::Execution(
"tick_size_cents is zero in cents→tick scaling".to_string(),
));
}
if !price.is_multiple_of(tick) {
return Err(BacktestError::PriceNotTickAligned { price, tick });
}
Ok(u128::from(price / tick))
}
#[must_use = "the scaled cents price must be recorded on the fill"]
fn ticks_to_cents(ticks: u128, tick: u64) -> Result<PriceCents, BacktestError> {
let cents = ticks
.checked_mul(u128::from(tick))
.ok_or(BacktestError::ArithmeticOverflow)?;
let cents = u64::try_from(cents).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(PriceCents::new(cents))
}
#[must_use = "the marketable limit price must be submitted"]
fn marketable_limit_cents(
side: Side,
quote: &QuoteView,
tick: u64,
cap: u32,
) -> Result<PriceCents, BacktestError> {
let cap_offset = tick
.checked_mul(u64::from(cap))
.ok_or(BacktestError::ArithmeticOverflow)?;
match ob_side(side) {
ObSide::Buy => {
let price = quote
.ask
.value()
.checked_add(cap_offset)
.ok_or(BacktestError::ArithmeticOverflow)?;
Ok(PriceCents::new(price))
}
ObSide::Sell => {
let price = i128::from(quote.bid.value()) - i128::from(cap_offset);
let price =
u64::try_from(price.max(0)).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(PriceCents::new(price))
}
}
}
#[cfg(test)]
mod tests {
const TEST_SUBMIT_IDS: &[OrderId] = &[
OrderId::new(9001),
OrderId::new(9002),
OrderId::new(9003),
OrderId::new(9004),
OrderId::new(9005),
OrderId::new(9006),
OrderId::new(9007),
OrderId::new(9008),
];
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use rust_decimal_macros::dec;
use option_chain_orderbook::{OrderId as ObOrderId, Side as ObSide};
use super::{
MAKER_ID_BASE, RealisticFill, cents_to_ticks, marketable_limit_cents, ob_side,
ticks_to_cents,
};
use crate::config::{FeeSchedule, LiquidityProfile, TouchSize};
use crate::domain::{
ChainSnapshot, ContractKey, ExecutionMode, Fill, InstrumentSpec, OrderCommand, OrderId,
OrderIntent, PositionAction, PositionId, PriceCents, Quantity, QuoteView, SimTime,
StepIndex, TimeInForce, Underlying,
};
use crate::error::BacktestError;
use crate::execution::{CarryGroup, ExecutionModel};
const TS0: i64 = 1_750_291_200_000_000_000;
const TICK: u64 = 5;
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is a valid quantity");
};
q
}
fn contract() -> ContractKey {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
ContractKey {
underlying,
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(TS0)),
strike: PriceCents::new(510_000),
style: OptionStyle::Call,
}
}
fn fees() -> FeeSchedule {
FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
}
}
fn quote(bid: u64, ask: u64) -> QuoteView {
QuoteView {
contract: contract(),
bid: PriceCents::new(bid),
ask: PriceCents::new(ask),
mid: PriceCents::new((bid + ask) / 2),
bid_size: qty(10),
ask_size: qty(10),
implied_volatility: dec!(0.2),
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
}
}
fn snapshot(bid: u64, ask: u64) -> ChainSnapshot {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
let Ok(spec) = InstrumentSpec::new(PriceCents::new(TICK), 100) else {
panic!("valid spec");
};
let mut quotes = BTreeMap::new();
quotes.insert(contract(), quote(bid, ask));
ChainSnapshot {
ts: SimTime::new(TS0),
step: StepIndex::new(0),
underlying,
underlying_price: PriceCents::new(510_000),
spec,
quotes,
}
}
fn seq(id: ObOrderId) -> u64 {
let ObOrderId::Sequential(n) = id else {
panic!("adapter must mint Id::Sequential, never a random id");
};
n
}
fn quote_full(bid: u64, ask: u64, bid_size: u32, ask_size: u32) -> QuoteView {
QuoteView {
contract: contract(),
bid: PriceCents::new(bid),
ask: PriceCents::new(ask),
mid: PriceCents::new((bid + ask) / 2),
bid_size: qty(bid_size),
ask_size: qty(ask_size),
implied_volatility: dec!(0.2),
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
}
}
fn snapshot_full(step: u32, bid: u64, ask: u64, bid_size: u32, ask_size: u32) -> ChainSnapshot {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
let Ok(spec) = InstrumentSpec::new(PriceCents::new(TICK), 100) else {
panic!("valid spec");
};
let mut quotes = BTreeMap::new();
quotes.insert(contract(), quote_full(bid, ask, bid_size, ask_size));
ChainSnapshot {
ts: SimTime::new(TS0 + i64::from(step)),
step: StepIndex::new(step),
underlying,
underlying_price: PriceCents::new(510_000),
spec,
quotes,
}
}
fn empty_snapshot_step(step: u32) -> ChainSnapshot {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
let Ok(spec) = InstrumentSpec::new(PriceCents::new(TICK), 100) else {
panic!("valid spec");
};
ChainSnapshot {
ts: SimTime::new(TS0 + i64::from(step)),
step: StepIndex::new(step),
underlying,
underlying_price: PriceCents::new(510_000),
spec,
quotes: BTreeMap::new(),
}
}
fn gtc_buy(limit: u64, quantity: u32, decision_mid: u64) -> OrderCommand {
OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(quantity),
limit: Some(PriceCents::new(limit)),
tif: TimeInForce::Gtc,
decision_mid: PriceCents::new(decision_mid),
})
}
fn touch_only_profile() -> LiquidityProfile {
profile(TouchSize::QuotedSize, 0, dec!(0.5))
}
fn marketable(
side: Side,
action: PositionAction,
quantity: u32,
decision_mid: u64,
) -> OrderCommand {
OrderCommand::Submit(OrderIntent {
contract: contract(),
action,
side,
quantity: qty(quantity),
limit: None,
tif: TimeInForce::Ioc,
decision_mid: PriceCents::new(decision_mid),
})
}
#[test]
fn test_ob_side_long_is_buy() {
assert_eq!(ob_side(Side::Long), ObSide::Buy);
}
#[test]
fn test_ob_side_short_is_sell() {
assert_eq!(ob_side(Side::Short), ObSide::Sell);
}
#[test]
fn test_cents_to_ticks_exact_multiple_ok() {
assert!(matches!(
cents_to_ticks(PriceCents::new(500), TICK),
Ok(100)
));
assert!(matches!(ticks_to_cents(100, TICK), Ok(p) if p.value() == 500));
}
#[test]
fn test_cents_to_ticks_non_aligned_rejected() {
assert!(matches!(
cents_to_ticks(PriceCents::new(501), TICK),
Err(BacktestError::PriceNotTickAligned {
price: 501,
tick: 5
})
));
}
#[test]
fn test_ticks_to_cents_overflow_is_typed_error() {
assert!(matches!(
ticks_to_cents(u128::MAX, TICK),
Err(BacktestError::ArithmeticOverflow)
));
}
#[test]
fn test_seeded_strategy_ids_are_deterministic_across_same_seed() {
let mut a = RealisticFill::new(fees(), 10, 42);
let mut b = RealisticFill::new(fees(), 10, 42);
let mut ids_a = Vec::new();
let mut ids_b = Vec::new();
for _ in 0..5 {
let (Ok(ia), Ok(ib)) = (a.next_strategy_order_id(), b.next_strategy_order_id()) else {
panic!("strategy ids must mint");
};
ids_a.push(seq(ia));
ids_b.push(seq(ib));
}
assert_eq!(ids_a, ids_b);
assert_eq!(ids_a, vec![1, 2, 3, 4, 5]);
}
#[test]
fn test_strategy_and_maker_id_ranges_are_disjoint() {
let mut model = RealisticFill::new(fees(), 10, 1);
for _ in 0..8 {
let (Ok(sid), Ok(mid)) = (model.next_strategy_order_id(), model.next_maker_order_id())
else {
panic!("both ranges must mint");
};
assert!(seq(sid) < MAKER_ID_BASE);
assert!(seq(mid) >= MAKER_ID_BASE);
}
}
#[test]
fn test_marketable_limit_buy_is_ask_plus_cap_ticks() {
let px = marketable_limit_cents(Side::Long, "e(490, 500), TICK, 10);
assert!(matches!(px, Ok(p) if p.value() == 550));
}
#[test]
fn test_marketable_limit_sell_is_bid_minus_cap_ticks() {
let px = marketable_limit_cents(Side::Short, "e(490, 500), TICK, 10);
assert!(matches!(px, Ok(p) if p.value() == 440));
}
#[test]
fn test_marketable_limit_sell_floors_at_zero() {
let px = marketable_limit_cents(Side::Short, "e(30, 40), TICK, 10);
assert!(matches!(px, Ok(p) if p.value() == 0));
}
#[test]
fn test_marketable_buy_walks_two_levels_two_fills() {
let mut model = RealisticFill::new(fees(), 10, 3);
let seeds = [(PriceCents::new(500), 3u32), (PriceCents::new(505), 2u32)];
for (price, size) in seeds {
let seeded =
model.seed_maker_limit(&contract(), true, price, qty(size), PriceCents::new(TICK));
assert!(matches!(seeded, Ok(())), "seed must rest");
}
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 5, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 2, "two levels walked ⇒ two fills");
let (Some(first), Some(second)) = (out.first(), out.get(1)) else {
panic!("two fills expected");
};
assert_eq!(first.price.value(), 500);
assert_eq!(first.quantity.value(), 3);
assert_eq!(first.fees.value(), 295);
assert_eq!(first.mode, ExecutionMode::Realistic);
assert_eq!(second.price.value(), 505);
assert_eq!(second.quantity.value(), 2);
assert_eq!(second.fees.value(), 130);
assert_eq!(first.contract, second.contract);
assert_eq!(first.side, Side::Long);
}
#[test]
fn test_marketable_cap_stops_walk_and_discards_remainder() {
let mut model = RealisticFill::new(fees(), 1, 9); for (price, size) in [(500u64, 3u32), (505, 3), (510, 3)] {
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(price),
qty(size),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
}
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 9, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 2, "only two levels within the cap fill");
assert!(out.iter().all(|f| f.price.value() <= 505));
}
#[test]
fn test_close_of_short_crosses_ask_not_bid() {
let mut model = RealisticFill::new(fees(), 10, 3);
let bid = model.seed_maker_limit(
&contract(),
false,
PriceCents::new(490),
qty(5),
PriceCents::new(TICK),
);
let ask = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(510),
qty(5),
PriceCents::new(TICK),
);
assert!(matches!((bid, ask), (Ok(()), Ok(()))));
let close = OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Close(PositionId::new(1)),
side: Side::Long,
quantity: qty(1),
limit: None,
tif: TimeInForce::Ioc,
decision_mid: PriceCents::new(500),
});
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(&[close], TEST_SUBMIT_IDS, &snapshot(490, 510), &mut out);
assert!(matches!(result, Ok(())));
let Some(fill) = out.first() else {
panic!("the buy-to-close must fill against the ask");
};
assert_eq!(fill.price.value(), 510);
assert_eq!(fill.side, Side::Long);
assert_eq!(fill.slippage.value(), 10);
}
#[test]
fn test_close_of_long_crosses_bid_not_ask() {
let mut model = RealisticFill::new(fees(), 10, 3);
let bid = model.seed_maker_limit(
&contract(),
false,
PriceCents::new(490),
qty(5),
PriceCents::new(TICK),
);
let ask = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(510),
qty(5),
PriceCents::new(TICK),
);
assert!(matches!((bid, ask), (Ok(()), Ok(()))));
let close = OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Close(PositionId::new(1)),
side: Side::Short,
quantity: qty(1),
limit: None,
tif: TimeInForce::Ioc,
decision_mid: PriceCents::new(500),
});
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(&[close], TEST_SUBMIT_IDS, &snapshot(490, 510), &mut out);
assert!(matches!(result, Ok(())));
let Some(fill) = out.first() else {
panic!("the sell-to-close must fill against the bid");
};
assert_eq!(fill.price.value(), 490);
assert_eq!(fill.side, Side::Short);
assert_eq!(fill.slippage.value(), 10);
}
#[test]
fn test_queue_position_strategy_limit_fills_behind_seeded_depth() {
let mut model = RealisticFill::new(fees(), 10, 5);
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(500),
qty(3),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
let rest_then_walk = [
OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side: Side::Short, quantity: qty(2),
limit: Some(PriceCents::new(500)),
tif: TimeInForce::Gtc,
decision_mid: PriceCents::new(500),
}),
marketable(Side::Long, PositionAction::Open, 5, 500),
];
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&rest_then_walk,
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
let Err(BacktestError::Execution(message)) = result else {
panic!("a strategy self-cross must fail closed, got {result:?}");
};
assert!(
message.contains("self-cross"),
"the error names the self-cross: {message}"
);
}
#[test]
fn test_thin_strike_partial_fill_matched_less_than_intent() {
let mut model = RealisticFill::new(fees(), 10, 5);
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(500),
qty(2),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 5, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 1, "only the seeded depth fills");
let matched: u32 = out.iter().map(|f| f.quantity.value()).sum();
assert_eq!(matched, 2, "partial: 2 of 5 filled, 3 discarded (IOC)");
}
#[test]
fn test_empty_strike_yields_zero_fills() {
let mut model = RealisticFill::new(fees(), 10, 5);
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 5, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert!(out.is_empty(), "no seeded depth ⇒ zero fills");
}
#[test]
fn test_deep_strike_fills_full_intent_single_level() {
let mut model = RealisticFill::new(fees(), 10, 5);
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(500),
qty(100),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 5, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 1, "deep touch fills the whole intent at once");
let Some(fill) = out.first() else {
panic!("one fill expected");
};
assert_eq!((fill.price.value(), fill.quantity.value()), (500, 5));
}
#[test]
fn test_realistic_per_level_slippage_is_progressively_adverse() {
let mut model = RealisticFill::new(fees(), 10, 5);
for (price, size) in [(500u64, 2u32), (505, 2), (510, 2)] {
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(price),
qty(size),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
}
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 6, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
let slippage: Vec<i64> = out.iter().map(|f| f.slippage.value()).collect();
assert_eq!(slippage, vec![0, 10, 20]);
assert!(slippage.windows(2).all(|w| w[1] >= w[0]));
}
#[test]
fn test_realistic_fees_match_naive_for_same_filled_contracts() {
let mut model = RealisticFill::new(fees(), 10, 5);
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(500),
qty(10),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 4, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
let Some(fill) = out.first() else {
panic!("one fill of 4 contracts expected");
};
assert_eq!(fill.quantity.value(), 4);
assert_eq!(fill.fees.value(), 4 * 65 + 100);
}
fn profile(
touch: TouchSize,
depth_levels: u32,
decay: rust_decimal::Decimal,
) -> LiquidityProfile {
LiquidityProfile {
touch_size: touch,
depth_levels,
decay,
}
}
#[test]
fn test_with_liquidity_profile_auto_seeds_ask_ladder_before_routing() {
let mut model = RealisticFill::with_liquidity_profile(
fees(),
10,
7,
profile(TouchSize::QuotedSize, 2, dec!(0.5)),
);
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 12, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 2, "the buy walks two auto-seeded ask levels");
let (Some(first), Some(second)) = (out.first(), out.get(1)) else {
panic!("two fills expected");
};
assert_eq!((first.price.value(), first.quantity.value()), (500, 10));
assert_eq!((second.price.value(), second.quantity.value()), (505, 2));
}
#[test]
fn test_new_raw_adapter_does_not_auto_seed() {
let mut model = RealisticFill::new(fees(), 10, 7);
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 5, 500)],
TEST_SUBMIT_IDS,
&snapshot(490, 500),
&mut out,
);
assert!(matches!(result, Ok(())));
assert!(out.is_empty(), "no seeded depth ⇒ nothing to fill");
}
#[test]
fn test_auto_seed_consumes_only_maker_ids() {
let mut model = RealisticFill::with_liquidity_profile(
fees(),
10,
7,
profile(TouchSize::QuotedSize, 2, dec!(0.5)),
);
let mut out: Vec<Fill> = Vec::new();
let submit = OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(1),
limit: Some(PriceCents::new(400)), tif: TimeInForce::Gtc,
decision_mid: PriceCents::new(495),
});
let result = model.fill(&[submit], TEST_SUBMIT_IDS, &snapshot(490, 500), &mut out);
assert!(matches!(result, Ok(())));
let (Ok(next_maker), Ok(next_strategy)) =
(model.next_maker_order_id(), model.next_strategy_order_id())
else {
panic!("both id ranges must still mint");
};
assert!(
seq(next_maker) > MAKER_ID_BASE,
"maker ids were consumed by seeding"
);
assert!(
seq(next_strategy) < MAKER_ID_BASE,
"strategy ids stay in the low range"
);
}
#[test]
fn test_resting_gtc_limit_below_ask_appends_no_fill() {
let mut model = RealisticFill::new(fees(), 10, 4);
let seeded = model.seed_maker_limit(
&contract(),
true,
PriceCents::new(505),
qty(2),
PriceCents::new(TICK),
);
assert!(matches!(seeded, Ok(())));
let submit = OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(2),
limit: Some(PriceCents::new(500)),
tif: TimeInForce::Gtc,
decision_mid: PriceCents::new(502),
});
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(&[submit], TEST_SUBMIT_IDS, &snapshot(490, 505), &mut out);
assert!(matches!(result, Ok(())));
assert!(out.is_empty(), "a non-crossing resting limit does not fill");
}
#[test]
fn test_submit_non_tick_aligned_limit_rejected() {
let mut model = RealisticFill::new(fees(), 10, 5);
let submit = OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(1),
limit: Some(PriceCents::new(501)), tif: TimeInForce::Ioc,
decision_mid: PriceCents::new(500),
});
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(&[submit], TEST_SUBMIT_IDS, &snapshot(490, 500), &mut out);
assert!(matches!(
result,
Err(BacktestError::PriceNotTickAligned {
price: 501,
tick: 5
})
));
assert!(out.is_empty());
}
#[test]
fn test_marketable_unquoted_contract_execution_error() {
let mut model = RealisticFill::new(fees(), 10, 6);
let mut snap = snapshot(490, 500);
snap.quotes.clear();
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(
&[marketable(Side::Long, PositionAction::Open, 1, 500)],
TEST_SUBMIT_IDS,
&snap,
&mut out,
);
assert!(matches!(result, Err(BacktestError::Execution(_))));
assert!(out.is_empty());
}
#[test]
fn test_orderbook_error_maps_to_backtest_orderbook() {
let err = option_chain_orderbook::Error::NoDataAvailable {
message: "seeded strike empty".to_string(),
};
let mapped = BacktestError::from(err);
assert!(
matches!(&mapped, BacktestError::OrderBook(msg) if msg.contains("no data available"))
);
}
#[test]
fn test_realistic_mode_returns_realistic() {
let model = RealisticFill::new(fees(), 10, 1);
assert_eq!(model.mode(), ExecutionMode::Realistic);
}
#[test]
fn test_cancel_and_replace_append_no_fills_in_issue_22() {
use crate::domain::OrderId;
let mut model = RealisticFill::new(fees(), 10, 1);
let commands = [
OrderCommand::Cancel(OrderId::new(1)),
OrderCommand::Replace {
order_id: OrderId::new(2),
replacement: OrderIntent {
contract: contract(),
action: PositionAction::Close(PositionId::new(9)),
side: Side::Short,
quantity: qty(1),
limit: Some(PriceCents::new(500)),
tif: TimeInForce::Gtc,
decision_mid: PriceCents::new(500),
},
},
];
let mut out: Vec<Fill> = Vec::new();
let result = model.fill(&commands, TEST_SUBMIT_IDS, &snapshot(490, 500), &mut out);
assert!(matches!(result, Ok(())));
assert!(
out.is_empty(),
"cancel/replace remain deferred; they append no fills"
);
}
#[test]
fn test_refresh_cancels_stale_seed_no_leak_into_next_snapshot() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r1 = model.fill(
&[],
TEST_SUBMIT_IDS,
&snapshot_full(0, 495, 505, 100, 100),
&mut out,
);
assert!(matches!(r1, Ok(())));
assert!(out.is_empty(), "seeding snap1 alone produces no fill");
out.clear();
let r2 = model.fill(
&[marketable(Side::Long, PositionAction::Open, 10, 500)],
TEST_SUBMIT_IDS,
&snapshot_full(1, 490, 500, 3, 3),
&mut out,
);
assert!(matches!(r2, Ok(())));
let matched: u32 = out.iter().map(|f| f.quantity.value()).sum();
assert_eq!(
matched, 3,
"only snap2's 3 @ 500 fills; every snap1 seeded order was cancelled"
);
assert_eq!(out.len(), 1, "one level — the stale 505 depth did not leak");
let Some(fill) = out.first() else {
panic!("one fill expected");
};
assert_eq!(fill.price.value(), 500);
}
#[test]
fn test_resting_strategy_limit_fills_exactly_when_later_snapshot_crosses() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r1 = model.fill(
&[gtc_buy(500, 1, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 490, 600, 20, 20),
&mut out,
);
assert!(matches!(r1, Ok(())));
assert!(out.is_empty(), "the buy at 500 does not cross the 600 ask");
out.clear();
let r2 = model.fill(
&[],
TEST_SUBMIT_IDS,
&snapshot_full(1, 490, 500, 20, 20),
&mut out,
);
assert!(matches!(r2, Ok(())));
assert_eq!(
out.len(),
1,
"exactly one refresh fill when the market crosses"
);
let Some(fill) = out.first() else {
panic!("one refresh fill expected");
};
assert_eq!(fill.side, Side::Long);
assert_eq!(
fill.price.value(),
500,
"fills at the resting limit's price"
);
assert_eq!(fill.quantity.value(), 1);
assert_eq!(fill.step.value(), 1, "a step-1 fill, against snap2");
assert_eq!(fill.mode, ExecutionMode::Realistic);
assert_eq!(fill.slippage.value(), -50);
}
#[test]
fn test_refresh_leaves_uncrossed_strategy_order_resting() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r0 = model.fill(
&[gtc_buy(500, 1, 560)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 490, 600, 20, 20),
&mut out,
);
assert!(matches!(r0, Ok(())));
assert!(out.is_empty());
out.clear();
let r1 = model.fill(
&[],
TEST_SUBMIT_IDS,
&snapshot_full(1, 490, 580, 20, 20),
&mut out,
);
assert!(matches!(r1, Ok(())));
assert!(
out.is_empty(),
"the uncrossed strategy order survives the refresh unfilled"
);
out.clear();
let r2 = model.fill(
&[],
TEST_SUBMIT_IDS,
&snapshot_full(2, 490, 500, 20, 20),
&mut out,
);
assert!(matches!(r2, Ok(())));
assert_eq!(out.len(), 1);
let Some(fill) = out.first() else {
panic!("one fill expected");
};
assert_eq!(
(fill.side, fill.price.value(), fill.step.value()),
(Side::Long, 500, 2)
);
}
#[test]
fn test_refresh_fill_precedes_intent_fill_in_out_fills() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r1 = model.fill(
&[gtc_buy(500, 1, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 490, 600, 20, 20),
&mut out,
);
assert!(matches!(r1, Ok(())));
assert!(out.is_empty());
out.clear();
let r2 = model.fill(
&[marketable(Side::Short, PositionAction::Open, 2, 500)],
TEST_SUBMIT_IDS,
&snapshot_full(1, 490, 500, 20, 20),
&mut out,
);
assert!(matches!(r2, Ok(())));
assert_eq!(
out.len(),
2,
"one refresh fill (e1) then one intent fill (e2)"
);
let (Some(first), Some(second)) = (out.first(), out.get(1)) else {
panic!("two fills expected");
};
assert_eq!((first.side, first.price.value()), (Side::Long, 500));
assert_eq!((second.side, second.price.value()), (Side::Short, 490));
}
#[test]
fn test_refresh_is_deterministic_across_two_multi_snapshot_runs() {
let run = || -> Vec<(u32, u64, u32, i64)> {
let mut model =
RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let snaps = [
snapshot_full(0, 490, 600, 20, 20),
snapshot_full(1, 490, 540, 20, 20),
snapshot_full(2, 490, 500, 20, 20),
];
let cmds = [gtc_buy(500, 1, 550)];
for (i, snap) in snaps.iter().enumerate() {
out.clear();
let step_cmds: &[OrderCommand] = if i == 0 { &cmds } else { &[] };
match model.fill(step_cmds, TEST_SUBMIT_IDS, snap, &mut out) {
Ok(()) => {}
Err(e) => panic!("the refresh run must succeed: {e}"),
}
}
out.iter()
.map(|f| {
(
f.step.value(),
f.price.value(),
f.quantity.value(),
f.slippage.value(),
)
})
.collect()
};
assert_eq!(
run(),
run(),
"the same tape yields byte-identical refresh fills"
);
}
#[test]
fn test_cancel_then_cross_never_fills() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r0 = model.fill(
&[gtc_buy(500, 2, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 480, 600, 5, 5),
&mut out,
);
assert!(matches!(r0, Ok(())));
assert!(out.is_empty(), "the buy rests, no fill");
let cancel = [OrderCommand::Cancel(TEST_SUBMIT_IDS[0])];
let r1 = model.fill(&cancel, &[], &snapshot_full(1, 480, 600, 5, 5), &mut out);
assert!(matches!(r1, Ok(())));
assert!(out.is_empty());
assert!(
model.resting_strategy.is_empty(),
"the mirror entry is gone"
);
assert!(model.order_index.is_empty(), "the id bridge entry is gone");
let r2 = model.fill(&[], &[], &snapshot_full(2, 480, 500, 5, 5), &mut out);
assert!(matches!(r2, Ok(())));
assert!(out.is_empty(), "a cancelled order never fills");
assert!(model.carry_fills().is_empty());
}
#[test]
fn test_replace_moves_the_resting_order() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r0 = model.fill(
&[gtc_buy(500, 2, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 480, 600, 5, 5),
&mut out,
);
assert!(matches!(r0, Ok(())));
let OrderCommand::Submit(replacement_intent) = gtc_buy(480, 2, 550) else {
panic!("gtc_buy builds a Submit");
};
let replace = [OrderCommand::Replace {
order_id: TEST_SUBMIT_IDS[0],
replacement: replacement_intent,
}];
let step1_ids = [TEST_SUBMIT_IDS[1]];
let r1 = model.fill(
&replace,
&step1_ids,
&snapshot_full(1, 460, 600, 5, 5),
&mut out,
);
assert!(matches!(r1, Ok(())));
assert!(out.is_empty(), "the replacement rests at 480");
let r2 = model.fill(&[], &[], &snapshot_full(2, 460, 500, 5, 5), &mut out);
assert!(matches!(r2, Ok(())));
assert!(out.is_empty(), "the replaced-away 500 order is dead");
let r3 = model.fill(&[], &[], &snapshot_full(3, 460, 480, 5, 5), &mut out);
assert!(matches!(r3, Ok(())));
assert_eq!(out.len(), 1, "the replacement fills once");
let carries = model.carry_fills();
assert_eq!(carries.len(), 1);
let Some(carry) = carries.first() else {
panic!("one carry group");
};
assert_eq!(carry.order_id, TEST_SUBMIT_IDS[1], "the REPLACEMENT id");
assert_eq!(carry.fill_count, 1);
}
#[test]
fn test_eviction_departed_contract_book_removed_live_order_book_retained() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r0 = model.fill(
&[gtc_buy(500, 2, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 480, 600, 5, 5),
&mut out,
);
assert!(matches!(r0, Ok(())));
assert_eq!(model.books.len(), 1);
let r1 = model.fill(&[], &[], &empty_snapshot_step(1), &mut out);
assert!(matches!(r1, Ok(())));
assert_eq!(model.books.len(), 1, "a live-order book is never evicted");
let cancel = [OrderCommand::Cancel(TEST_SUBMIT_IDS[0])];
let r2 = model.fill(&cancel, &[], &empty_snapshot_step(2), &mut out);
assert!(matches!(r2, Ok(())));
let r3 = model.fill(&[], &[], &empty_snapshot_step(3), &mut out);
assert!(matches!(r3, Ok(())));
assert!(model.books.is_empty(), "the dead book is evicted");
assert!(model.resting_seed_ids.is_empty());
assert!(out.is_empty(), "no fill was ever produced");
}
#[test]
fn test_multi_level_refresh_cross_coalesces_into_one_carry_group() {
let ladder = profile(TouchSize::QuotedSize, 1, dec!(1));
let mut model = RealisticFill::with_liquidity_profile(fees(), 10, 5, ladder);
let mut out: Vec<Fill> = Vec::new();
let r0 = model.fill(
&[gtc_buy(510, 4, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 480, 600, 2, 2),
&mut out,
);
assert!(matches!(r0, Ok(())));
assert!(out.is_empty(), "the buy rests");
let r1 = model.fill(&[], &[], &snapshot_full(1, 480, 500, 2, 2), &mut out);
assert!(matches!(r1, Ok(())));
assert_eq!(out.len(), 2, "two levels crossed ⇒ two fills");
let carries = model.carry_fills();
assert_eq!(carries.len(), 1, "contiguous same-order fills coalesce");
let Some(carry) = carries.first() else {
panic!("one carry group");
};
assert_eq!(carry.order_id, TEST_SUBMIT_IDS[0]);
assert_eq!(carry.fill_count, 2);
}
#[test]
fn test_mixed_queue_cancel_applies_before_submit() {
let mut model = RealisticFill::with_liquidity_profile(fees(), 30, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let OrderCommand::Submit(mut sell) = gtc_buy(500, 2, 550) else {
panic!("gtc_buy builds a Submit");
};
sell.side = Side::Short;
let r0 = model.fill(
&[OrderCommand::Submit(sell)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 480, 600, 5, 5),
&mut out,
);
assert!(matches!(r0, Ok(())));
assert!(out.is_empty(), "the sell rests");
let cmds = [
marketable(Side::Long, PositionAction::Open, 2, 550),
OrderCommand::Cancel(TEST_SUBMIT_IDS[0]),
];
let step1_ids = [TEST_SUBMIT_IDS[1]];
let r1 = model.fill(
&cmds,
&step1_ids,
&snapshot_full(1, 480, 600, 5, 5),
&mut out,
);
assert!(matches!(r1, Ok(())), "no self-cross: {r1:?}");
assert_eq!(out.len(), 1, "the buy fills against seeded depth");
let Some(fill) = out.first() else {
panic!("one fill");
};
assert_eq!(fill.price.value(), 600, "filled at the seeded ask, not 500");
assert!(model.resting_strategy.is_empty(), "the sell was cancelled");
}
#[test]
fn test_lifecycle_sequence_is_deterministic_across_two_runs() {
let run = || -> (Vec<Fill>, Vec<CarryGroup>) {
let mut model =
RealisticFill::with_liquidity_profile(fees(), 10, 5, touch_only_profile());
let mut out: Vec<Fill> = Vec::new();
let r0 = model.fill(
&[gtc_buy(500, 3, 550)],
TEST_SUBMIT_IDS,
&snapshot_full(0, 480, 600, 5, 5),
&mut out,
);
assert!(matches!(r0, Ok(())));
let r1 = model.fill(&[], &[], &snapshot_full(1, 480, 500, 5, 5), &mut out);
assert!(matches!(r1, Ok(())));
(out, model.carry_fills().to_vec())
};
let (fills_a, carries_a) = run();
let (fills_b, carries_b) = run();
assert_eq!(fills_a, fills_b, "byte-identical fills");
assert_eq!(carries_a, carries_b, "byte-identical carry groups");
}
}