#[cfg(feature = "orderbook")]
pub(crate) mod liquidity;
pub mod naive;
#[cfg(feature = "orderbook")]
pub mod realistic;
pub use naive::NaiveFill;
#[cfg(feature = "orderbook")]
pub use realistic::RealisticFill;
use crate::config::FeeSchedule;
use crate::domain::execution::sign_convention;
use crate::domain::{
Cents, ChainSnapshot, ContractKey, ExecutionMode, Fill, OrderCommand, OrderId, PriceCents,
Quantity, SimTime, StepIndex,
};
use crate::error::BacktestError;
use optionstratlib::Side;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FillGroup {
pub command_index: usize,
pub fill_count: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CarryGroup {
pub order_id: OrderId,
pub fill_count: u32,
}
pub trait ExecutionModel {
fn fill(
&mut self,
commands: &[OrderCommand],
submit_ids: &[OrderId],
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError>;
#[must_use]
fn carry_fills(&self) -> &[CarryGroup] {
&[]
}
#[must_use]
fn fill_groups(&self) -> Option<&[FillGroup]> {
None
}
#[must_use]
fn mode(&self) -> ExecutionMode;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub(crate) enum FeeCharge {
FirstFill = 0,
#[allow(
dead_code,
reason = "constructed by realistic mode's multi-level walk (v0.2); NaiveFill is single-shot"
)]
LaterFill = 1,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FillDraft {
pub ts: SimTime,
pub step: StepIndex,
pub contract: ContractKey,
pub side: Side,
pub quantity: Quantity,
pub price: PriceCents,
pub decision_mid: PriceCents,
}
#[must_use = "the computed fee must be recorded on the fill"]
pub(crate) fn fee_for_fill(
schedule: &FeeSchedule,
quantity: Quantity,
charge: FeeCharge,
) -> Result<Cents, BacktestError> {
let per_contract_total = schedule
.per_contract_cents
.checked_mul(u64::from(quantity.value()))
.ok_or(BacktestError::ArithmeticOverflow)?;
let total = match charge {
FeeCharge::FirstFill => per_contract_total
.checked_add(schedule.per_order_cents)
.ok_or(BacktestError::ArithmeticOverflow)?,
FeeCharge::LaterFill => per_contract_total,
};
let cents = i64::try_from(total).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(Cents::new(cents))
}
#[must_use = "the assembled fill must be recorded"]
pub(crate) fn assemble_fill(
draft: FillDraft,
mode: ExecutionMode,
fee_schedule: &FeeSchedule,
charge: FeeCharge,
) -> Result<Fill, BacktestError> {
let slippage = sign_convention::slippage_cents(
draft.price,
draft.decision_mid,
draft.quantity,
draft.side,
)?;
let fees = fee_for_fill(fee_schedule, draft.quantity, charge)?;
Ok(Fill {
ts: draft.ts,
step: draft.step,
contract: draft.contract,
side: draft.side,
quantity: draft.quantity,
price: draft.price,
fees,
slippage,
mode,
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::{ExecutionModel, FeeCharge, FillDraft, assemble_fill, fee_for_fill};
use crate::config::FeeSchedule;
use crate::domain::{
ChainSnapshot, ContractKey, ExecutionMode, Fill, InstrumentSpec, OrderCommand, OrderId,
OrderIntent, PositionAction, PriceCents, Quantity, SimTime, StepIndex, TimeInForce,
Underlying,
};
use crate::error::BacktestError;
const TS0: i64 = 1_750_291_200_000_000_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(510_000),
style: OptionStyle::Call,
}
}
fn draft(side: Side, price: u64, decision_mid: u64, quantity: u32) -> FillDraft {
FillDraft {
ts: SimTime::new(TS0),
step: StepIndex::new(0),
contract: contract(),
side,
quantity: qty(quantity),
price: PriceCents::new(price),
decision_mid: PriceCents::new(decision_mid),
}
}
fn fees() -> FeeSchedule {
FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
}
}
fn empty_snapshot() -> 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");
};
ChainSnapshot {
ts: SimTime::new(TS0),
step: StepIndex::new(0),
underlying,
underlying_price: PriceCents::new(510_000),
spec,
quotes: BTreeMap::new(),
}
}
struct EchoModel {
mode: ExecutionMode,
fees: FeeSchedule,
}
impl ExecutionModel for EchoModel {
fn fill(
&mut self,
commands: &[OrderCommand],
_submit_ids: &[crate::domain::OrderId],
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
for command in commands {
if let OrderCommand::Submit(intent) = command {
let price = intent.limit.unwrap_or(intent.decision_mid);
let d = FillDraft {
ts: snap.ts,
step: snap.step,
contract: intent.contract.clone(),
side: intent.side,
quantity: intent.quantity,
price,
decision_mid: intent.decision_mid,
};
out_fills.push(assemble_fill(
d,
self.mode,
&self.fees,
FeeCharge::FirstFill,
)?);
}
}
Ok(())
}
fn mode(&self) -> ExecutionMode {
self.mode
}
}
fn submit(price: u64, decision_mid: u64) -> OrderCommand {
OrderCommand::Submit(OrderIntent {
contract: contract(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(1),
limit: Some(PriceCents::new(price)),
tif: TimeInForce::Ioc,
decision_mid: PriceCents::new(decision_mid),
})
}
#[test]
fn test_fill_report_shape_mode_agnostic() {
let d = draft(Side::Long, 152, 150, 3);
let naive = assemble_fill(
d.clone(),
ExecutionMode::Naive,
&fees(),
FeeCharge::FirstFill,
);
let realistic = assemble_fill(d, ExecutionMode::Realistic, &fees(), FeeCharge::FirstFill);
let (Ok(naive), Ok(realistic)) = (naive, realistic) else {
panic!("both assemblies must succeed");
};
assert_eq!(naive.mode, ExecutionMode::Naive);
assert_eq!(realistic.mode, ExecutionMode::Realistic);
assert_ne!(naive.mode, realistic.mode);
let mut rebadged = naive;
rebadged.mode = ExecutionMode::Realistic;
assert_eq!(rebadged, realistic);
}
#[test]
fn test_fees_per_order_charged_once_on_first_fill() {
let schedule = fees(); let first = fee_for_fill(&schedule, qty(3), FeeCharge::FirstFill);
let later = fee_for_fill(&schedule, qty(3), FeeCharge::LaterFill);
let (Ok(first), Ok(later)) = (first, later) else {
panic!("fee computation must succeed");
};
assert_eq!(later.value(), 195);
assert_eq!(first.value(), 295);
assert_eq!(first.value() - later.value(), 100);
assert!(first.value() >= 0 && later.value() >= 0);
}
#[test]
fn test_assemble_fill_slippage_positive_when_buy_adverse() {
let fill = assemble_fill(
draft(Side::Long, 152, 150, 1),
ExecutionMode::Naive,
&fees(),
FeeCharge::FirstFill,
);
assert!(matches!(fill, Ok(f) if f.slippage.value() == 2));
let fill = assemble_fill(
draft(Side::Long, 149, 150, 1),
ExecutionMode::Naive,
&fees(),
FeeCharge::FirstFill,
);
assert!(matches!(fill, Ok(f) if f.slippage.value() == -1));
}
#[test]
fn test_assemble_fill_slippage_positive_when_sell_adverse() {
let fill = assemble_fill(
draft(Side::Short, 148, 150, 1),
ExecutionMode::Realistic,
&fees(),
FeeCharge::FirstFill,
);
assert!(matches!(fill, Ok(f) if f.slippage.value() == 2));
let fill = assemble_fill(
draft(Side::Short, 153, 150, 1),
ExecutionMode::Realistic,
&fees(),
FeeCharge::FirstFill,
);
assert!(matches!(fill, Ok(f) if f.slippage.value() == -3));
}
#[test]
fn test_execution_model_mode_returns_correct_discriminant() {
let naive = EchoModel {
mode: ExecutionMode::Naive,
fees: fees(),
};
let realistic = EchoModel {
mode: ExecutionMode::Realistic,
fees: fees(),
};
assert_eq!(naive.mode(), ExecutionMode::Naive);
assert_eq!(realistic.mode(), ExecutionMode::Realistic);
}
#[test]
fn test_execution_model_fill_appends_into_caller_buffer() {
let mut model = EchoModel {
mode: ExecutionMode::Naive,
fees: fees(),
};
let snap = empty_snapshot();
let mut out = Vec::new();
let seed = assemble_fill(
draft(Side::Long, 150, 150, 1),
ExecutionMode::Naive,
&fees(),
FeeCharge::FirstFill,
);
let Ok(seed) = seed else {
panic!("seed fill must assemble");
};
out.push(seed);
let commands = [submit(152, 150), submit(151, 150)];
let ids = [OrderId::new(1), OrderId::new(2)];
let result = model.fill(&commands, &ids, &snap, &mut out);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 3);
assert!(
matches!(out.get(1), Some(f) if f.price.value() == 152 && f.mode == ExecutionMode::Naive)
);
assert!(matches!(out.get(2), Some(f) if f.price.value() == 151));
}
}