use rust_decimal::prelude::ToPrimitive;
use rust_decimal::{Decimal, RoundingStrategy};
use optionstratlib::Side;
use crate::config::{FeeSchedule, SlippageModel};
use crate::domain::{
ChainSnapshot, ExecutionMode, Fill, OrderCommand, OrderIntent, PriceCents, Quantity, QuoteView,
};
use crate::error::BacktestError;
use super::{ExecutionModel, FeeCharge, FillDraft, assemble_fill};
#[derive(Debug, Clone, PartialEq)]
pub struct NaiveFill {
slippage: SlippageModel,
fees: FeeSchedule,
}
impl NaiveFill {
#[must_use = "the constructed fill model must be used to produce fills"]
pub const fn new(slippage: SlippageModel, fees: FeeSchedule) -> Self {
Self { slippage, fees }
}
#[must_use = "the computed adverse offset must be applied to the fill price"]
#[inline]
fn adverse_offset_cents(
&self,
quote: &QuoteView,
quantity: Quantity,
) -> Result<u64, BacktestError> {
match &self.slippage {
SlippageModel::None => Ok(0),
SlippageModel::FixedCents { cents } => Ok(*cents),
SlippageModel::SpreadFraction { fraction } => {
spread_fraction_offset_cents(quote.bid, quote.ask, *fraction)
}
SlippageModel::SizeProportional { cents_per_contract } => cents_per_contract
.checked_mul(u64::from(quantity.value()))
.ok_or(BacktestError::ArithmeticOverflow),
}
}
#[inline]
fn fill_intent(
&self,
intent: &OrderIntent,
snap: &ChainSnapshot,
) -> Result<Fill, BacktestError> {
let quote = snap.quotes.get(&intent.contract).ok_or_else(|| {
BacktestError::Execution(format!(
"naive fill: contract with strike {} not quoted in snapshot at step {}",
intent.contract.strike.value(),
snap.step.value()
))
})?;
let offset = self.adverse_offset_cents(quote, intent.quantity)?;
let price = naive_fill_price(quote.mid, intent.side, offset)?;
let draft = FillDraft {
ts: snap.ts,
step: snap.step,
contract: intent.contract.clone(),
side: intent.side,
quantity: intent.quantity,
price,
decision_mid: intent.decision_mid,
};
assemble_fill(
draft,
ExecutionMode::Naive,
&self.fees,
FeeCharge::FirstFill,
)
}
}
impl ExecutionModel for NaiveFill {
fn fill(
&mut self,
commands: &[OrderCommand],
_submit_ids: &[crate::domain::OrderId],
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
for command in commands {
match command {
OrderCommand::Submit(intent) => {
out_fills.push(self.fill_intent(intent, snap)?);
}
OrderCommand::Cancel(_) | OrderCommand::Replace { .. } => {}
}
}
Ok(())
}
#[inline]
fn mode(&self) -> ExecutionMode {
ExecutionMode::Naive
}
}
#[must_use = "the computed spread-fraction offset must be applied to the fill price"]
#[inline]
fn spread_fraction_offset_cents(
bid: PriceCents,
ask: PriceCents,
fraction: Decimal,
) -> Result<u64, BacktestError> {
let spread = ask
.value()
.checked_sub(bid.value())
.ok_or(BacktestError::CrossedQuote {
bid: bid.value(),
ask: ask.value(),
})?;
let scaled = Decimal::from(spread)
.checked_mul(fraction)
.ok_or(BacktestError::ArithmeticOverflow)?
.checked_div(Decimal::TWO)
.ok_or(BacktestError::ArithmeticOverflow)?
.round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven);
scaled.to_u64().ok_or_else(|| {
BacktestError::Execution(format!(
"naive spread-fraction slippage {scaled} out of range for u64 cents"
))
})
}
#[must_use = "the computed fill price must be recorded on the fill"]
#[inline]
fn naive_fill_price(
reference: PriceCents,
side: Side,
offset: u64,
) -> Result<PriceCents, BacktestError> {
let signed = match side {
Side::Long => i128::from(reference.value()) + i128::from(offset),
Side::Short => i128::from(reference.value()) - i128::from(offset),
};
let floored = signed.max(0);
let cents = u64::try_from(floored).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(PriceCents::new(cents))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use rust_decimal_macros::dec;
use super::{NaiveFill, naive_fill_price, spread_fraction_offset_cents};
use crate::config::{FeeSchedule, SlippageModel};
use crate::domain::{
ChainSnapshot, ContractKey, ExecutionMode, InstrumentSpec, OrderCommand, OrderId,
OrderIntent, PositionAction, PositionId, PriceCents, Quantity, QuoteView, SimTime,
StepIndex, TimeInForce, Underlying,
};
use crate::execution::ExecutionModel;
const TS0: i64 = 1_750_291_200_000_000_000;
const STRIKE: u64 = 510_000;
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(STRIKE),
style: OptionStyle::Call,
}
}
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_with(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(5), 100) else {
panic!("5-cent tick and 100x multiplier are valid");
};
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(STRIKE),
spec,
quotes,
}
}
fn empty_snapshot() -> ChainSnapshot {
let mut snap = snapshot_with(100, 110);
snap.quotes.clear();
snap
}
fn submit(side: Side, quantity: u32, decision_mid: u64) -> OrderCommand {
OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side,
quantity: qty(quantity),
limit: None,
tif: TimeInForce::Ioc,
decision_mid: PriceCents::new(decision_mid),
})
}
fn model(slippage: SlippageModel) -> NaiveFill {
NaiveFill::new(
slippage,
FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
},
)
}
fn run(
model: &mut NaiveFill,
commands: &[OrderCommand],
snap: &ChainSnapshot,
) -> Vec<crate::domain::Fill> {
let mut out = Vec::new();
let result = model.fill(commands, &[], snap, &mut out);
assert!(matches!(result, Ok(())), "naive fill must succeed");
out
}
#[test]
fn test_naive_fill_applies_slippage_vs_mid() {
let mut m = model(SlippageModel::FixedCents { cents: 3 });
let out = run(
&mut m,
&[submit(Side::Long, 1, 105)],
&snapshot_with(100, 110),
);
let Some(fill) = out.first() else {
panic!("expected one fill");
};
assert_eq!(fill.price.value(), 108);
assert_eq!(fill.slippage.value(), 3);
assert_eq!(fill.mode, ExecutionMode::Naive);
}
#[test]
fn test_naive_fill_at_touch_when_spread_fraction_one() {
let snap = snapshot_with(100, 110);
let mut m = model(SlippageModel::SpreadFraction { fraction: dec!(1) });
let buy = run(&mut m, &[submit(Side::Long, 1, 105)], &snap);
assert!(matches!(buy.first(), Some(f) if f.price.value() == 110));
let sell = run(&mut m, &[submit(Side::Short, 1, 105)], &snap);
assert!(matches!(sell.first(), Some(f) if f.price.value() == 100));
}
#[test]
fn test_naive_fill_is_single_shot_fill_seq_zero() {
let mut m = model(SlippageModel::None);
let out = run(
&mut m,
&[submit(Side::Long, 7, 105), submit(Side::Short, 4, 105)],
&snapshot_with(100, 110),
);
assert_eq!(out.len(), 2);
assert!(matches!(out.first(), Some(f) if f.quantity.value() == 7));
assert!(matches!(out.get(1), Some(f) if f.quantity.value() == 4));
}
#[test]
fn test_naive_fill_none_slippage_fills_at_mid() {
let mut m = model(SlippageModel::None);
let out = run(
&mut m,
&[submit(Side::Long, 1, 105)],
&snapshot_with(100, 110),
);
let Some(fill) = out.first() else {
panic!("expected one fill");
};
assert_eq!(fill.price.value(), 105);
assert_eq!(fill.slippage.value(), 0);
}
#[test]
fn test_naive_fill_fixed_cents_offset_adverse() {
let snap = snapshot_with(100, 110);
let mut m = model(SlippageModel::FixedCents { cents: 7 });
let buy = run(&mut m, &[submit(Side::Long, 1, 105)], &snap);
assert!(matches!(buy.first(), Some(f) if f.price.value() == 112));
let sell = run(&mut m, &[submit(Side::Short, 1, 105)], &snap);
assert!(matches!(sell.first(), Some(f) if f.price.value() == 98));
}
#[test]
fn test_naive_fill_spread_fraction_half_spread_adverse() {
let mut m = model(SlippageModel::SpreadFraction {
fraction: dec!(0.5),
});
let out = run(
&mut m,
&[submit(Side::Long, 1, 105)],
&snapshot_with(100, 110),
);
assert!(matches!(out.first(), Some(f) if f.price.value() == 107));
}
#[test]
fn test_naive_fill_size_proportional_scales_with_quantity() {
let mut m = model(SlippageModel::SizeProportional {
cents_per_contract: 2,
});
let out = run(
&mut m,
&[submit(Side::Long, 4, 105)],
&snapshot_with(100, 110),
);
let Some(fill) = out.first() else {
panic!("expected one fill");
};
assert_eq!(fill.price.value(), 113);
assert_eq!(fill.slippage.value(), 32);
}
#[test]
fn test_naive_fill_buy_records_positive_adverse_slippage() {
let mut m = model(SlippageModel::FixedCents { cents: 5 });
let out = run(
&mut m,
&[submit(Side::Long, 3, 105)],
&snapshot_with(100, 110),
);
assert!(
matches!(out.first(), Some(f) if f.slippage.value() == 15 && f.slippage.value() > 0)
);
}
#[test]
fn test_naive_fill_sell_records_positive_adverse_slippage() {
let mut m = model(SlippageModel::FixedCents { cents: 5 });
let out = run(
&mut m,
&[submit(Side::Short, 3, 105)],
&snapshot_with(100, 110),
);
assert!(
matches!(out.first(), Some(f) if f.slippage.value() == 15 && f.slippage.value() > 0)
);
}
#[test]
fn test_naive_fill_sell_slippage_floors_price_at_zero() {
let mut m = model(SlippageModel::FixedCents { cents: 200 });
let out = run(
&mut m,
&[submit(Side::Short, 1, 105)],
&snapshot_with(100, 110),
);
let Some(fill) = out.first() else {
panic!("expected one fill");
};
assert_eq!(fill.price.value(), 0);
assert_eq!(fill.slippage.value(), 105);
}
#[test]
fn test_naive_fill_is_deterministic_across_identical_calls() {
let snap = snapshot_with(100, 110);
let commands = [submit(Side::Long, 2, 105), submit(Side::Short, 3, 105)];
let mut a = model(SlippageModel::FixedCents { cents: 4 });
let mut b = model(SlippageModel::FixedCents { cents: 4 });
let out_a = run(&mut a, &commands, &snap);
let out_b = run(&mut b, &commands, &snap);
assert_eq!(out_a, out_b);
}
#[test]
fn test_naive_fill_cancel_and_replace_append_no_fills() {
let mut m = model(SlippageModel::None);
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(100)),
tif: TimeInForce::Gtc,
decision_mid: PriceCents::new(105),
},
},
submit(Side::Long, 1, 105),
];
let out = run(&mut m, &commands, &snapshot_with(100, 110));
assert_eq!(out.len(), 1, "only the Submit fills");
assert!(matches!(out.first(), Some(f) if f.side == Side::Long));
}
#[test]
fn test_naive_fill_missing_quote_execution_error() {
let mut m = model(SlippageModel::None);
let mut out = Vec::new();
let result = m.fill(
&[submit(Side::Long, 1, 105)],
&[],
&empty_snapshot(),
&mut out,
);
assert!(matches!(
result,
Err(crate::error::BacktestError::Execution(_))
));
assert!(out.is_empty(), "a failed fill appends nothing");
}
#[test]
fn test_naive_fill_mode_returns_naive() {
let m = model(SlippageModel::None);
assert_eq!(m.mode(), ExecutionMode::Naive);
}
#[test]
fn test_naive_fill_appends_into_caller_buffer_without_clearing() {
let mut m = model(SlippageModel::None);
let snap = snapshot_with(100, 110);
let mut out = run(&mut m, &[submit(Side::Long, 1, 105)], &snap);
let before = out.len();
let result = m.fill(&[submit(Side::Short, 1, 105)], &[], &snap, &mut out);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), before + 1);
}
#[test]
fn test_spread_fraction_offset_rounds_half_to_even() {
assert!(matches!(
spread_fraction_offset_cents(PriceCents::new(100), PriceCents::new(101), dec!(1)),
Ok(0)
));
assert!(matches!(
spread_fraction_offset_cents(PriceCents::new(100), PriceCents::new(103), dec!(1)),
Ok(2)
));
assert!(matches!(
spread_fraction_offset_cents(PriceCents::new(100), PriceCents::new(105), dec!(1)),
Ok(2)
));
}
#[test]
fn test_spread_fraction_offset_rejects_crossed_quote() {
assert!(matches!(
spread_fraction_offset_cents(PriceCents::new(110), PriceCents::new(100), dec!(0.5)),
Err(crate::error::BacktestError::CrossedQuote { bid: 110, ask: 100 })
));
}
#[test]
fn test_naive_fill_price_buy_and_sell_directions() {
assert!(matches!(
naive_fill_price(PriceCents::new(105), Side::Long, 5),
Ok(p) if p.value() == 110
));
assert!(matches!(
naive_fill_price(PriceCents::new(105), Side::Short, 5),
Ok(p) if p.value() == 100
));
assert!(matches!(
naive_fill_price(PriceCents::new(105), Side::Short, 200),
Ok(p) if p.value() == 0
));
}
#[test]
fn test_naive_fill_price_buy_overflow_is_typed_error() {
assert!(matches!(
naive_fill_price(PriceCents::new(u64::MAX), Side::Long, 1),
Err(crate::error::BacktestError::ArithmeticOverflow)
));
}
}