use chrono::{DateTime, Utc};
use rand_chacha::ChaCha8Rng;
use rand_chacha::rand_core::SeedableRng;
use rust_decimal::Decimal;
use optionstratlib::backtesting::{BacktestResult, ExitReason};
use crate::config::BacktestConfig;
use crate::data::{DataFeed, DataSourceSpec};
use crate::domain::{
Cents, ChainSnapshot, EquityPoint, Fill, GreeksAttributionRow, OpenPosition, OrderCommand,
OrderId, OrderIntent, PendingOrder, PositionAction, PositionId, PriceCents, Quantity,
TimeInForce, TradeId,
};
use crate::engine::bundle_collector::{BundleCollector, FillRecord, PositionSnapshot};
use crate::engine::clock::SimClock;
use crate::engine::ledger::Ledger;
use crate::engine::strategy::{ChainContext, Strategy};
use crate::engine::substrate::{AttributionCollector, AttributionSubstrate};
use crate::engine::tradelog::{ClosedTrade, TradeLogCollector};
use crate::error::BacktestError;
use crate::execution::{CarryGroup, ExecutionModel, FillGroup};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy)]
struct IdCounters {
position: u64,
order: u64,
trade: u64,
}
impl IdCounters {
const fn new() -> Self {
Self {
position: 1,
order: 1,
trade: 1,
}
}
fn mint_position(&mut self) -> Result<PositionId, BacktestError> {
let id = self.position;
self.position = self
.position
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
Ok(PositionId::new(id))
}
fn mint_order(&mut self) -> Result<OrderId, BacktestError> {
let id = self.order;
self.order = self
.order
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
Ok(OrderId::new(id))
}
fn mint_trade(&mut self) -> Result<TradeId, BacktestError> {
let id = self.trade;
self.trade = self
.trade
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
Ok(TradeId::new(id))
}
}
#[derive(Debug)]
#[must_use = "a BacktestRun carries the run's results and should be consumed"]
pub struct BacktestRun {
pub result: BacktestResult,
pub equity_curve: Vec<EquityPoint>,
pub open_at_end: Vec<OpenPosition>,
pub trade_log: Vec<ClosedTrade>,
pub attribution_substrate: AttributionSubstrate,
pub greeks_attribution: Vec<GreeksAttributionRow>,
pub fills: Vec<FillRecord>,
pub positions: Vec<PositionSnapshot>,
pub data_source: DataSourceSpec,
pub data_identity: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BacktestEngine;
impl BacktestEngine {
pub fn run<F, X, S>(
config: &BacktestConfig,
mut feed: F,
execution: X,
strategy: S,
strategy_name: &str,
) -> Result<BacktestRun, BacktestError>
where
F: DataFeed,
X: ExecutionModel,
S: Strategy,
{
let initial_cents = i64::try_from(config.initial_capital).map_err(|_| {
BacktestError::Config("initial capital exceeds the i64 cents range".to_string())
})?;
if initial_cents <= 0 {
return Err(BacktestError::Config(
"initial capital must be positive".to_string(),
));
}
let tape_meta = feed.tape_meta().clone();
if !tape_meta.non_empty {
return Err(BacktestError::Data("tape is empty".to_string()));
}
let final_step = tape_meta.final_step;
let first_ts = tape_meta.first_ts;
let data_source = feed.meta();
let data_identity = tape_meta.data_identity.clone();
let steps =
usize::try_from(final_step.value()).map_err(|_| BacktestError::ArithmeticOverflow)?;
let capacity = steps
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
let mut state = RunState {
strategy,
execution,
clock: SimClock::new(),
rng: ChaCha8Rng::seed_from_u64(config.seed),
ids: IdCounters::new(),
ledger: Ledger::new(Cents::new(initial_cents)),
inventory: Vec::new(),
begin_scratch: Vec::with_capacity(16),
cmds: Vec::with_capacity(16),
fills: Vec::with_capacity(16),
equity_curve: Vec::with_capacity(capacity),
attribution: AttributionCollector::with_capacity(capacity),
trade_log: TradeLogCollector::with_capacity(16),
bundle: BundleCollector::with_capacity(16),
pending_orders: BTreeMap::new(),
pending_scratch: Vec::new(),
submit_ids_scratch: Vec::with_capacity(16),
};
let mut current = feed.next()?.ok_or_else(|| {
BacktestError::Data(
"tape metadata reports a non-empty tape but the feed yielded no first snapshot"
.to_string(),
)
})?;
state.on_start(¤t)?;
state
.attribution
.reserve_legs(capacity, state.inventory.len());
state.begin_scratch.reserve(state.inventory.len());
state.trade_log.reserve(state.inventory.len());
state.bundle.reserve(state.inventory.len(), capacity);
let last_ts;
loop {
let is_last = current.step == final_step;
state.step(¤t, is_last)?;
if is_last {
last_ts = current.ts;
break;
}
current = feed.next()?.ok_or_else(|| {
BacktestError::Data(
"feed exhausted before reaching the declared final step".to_string(),
)
})?;
}
let final_equity = state
.equity_curve
.last()
.map_or(initial_cents, |point| point.equity_cents);
let result = BacktestResult {
strategy_name: strategy_name.to_string(),
initial_capital: dollars(initial_cents),
final_capital: dollars(final_equity),
test_period_start: DateTime::<Utc>::from_timestamp_nanos(first_ts.value()),
test_period_end: DateTime::<Utc>::from_timestamp_nanos(last_ts.value()),
..BacktestResult::default()
};
let RunState {
equity_curve,
inventory,
attribution,
trade_log,
bundle,
..
} = state;
let (fills, positions) = bundle.into_parts();
Ok(BacktestRun {
result,
equity_curve,
open_at_end: inventory,
trade_log: trade_log.into_log(),
attribution_substrate: attribution.into_substrate(),
greeks_attribution: Vec::new(),
fills,
positions,
data_source,
data_identity,
})
}
}
struct RunState<S: Strategy, X: ExecutionModel> {
strategy: S,
execution: X,
clock: SimClock,
rng: ChaCha8Rng,
ids: IdCounters,
ledger: Ledger,
inventory: Vec<OpenPosition>,
begin_scratch: Vec<OpenPosition>,
cmds: Vec<OrderCommand>,
fills: Vec<Fill>,
equity_curve: Vec<EquityPoint>,
attribution: AttributionCollector,
trade_log: TradeLogCollector,
bundle: BundleCollector,
pending_orders: BTreeMap<OrderId, PendingEntry>,
pending_scratch: Vec<PendingOrder>,
submit_ids_scratch: Vec<OrderId>,
}
impl<S: Strategy, X: ExecutionModel> RunState<S, X> {
fn on_start(&mut self, snapshot: &ChainSnapshot) -> Result<(), BacktestError> {
let Self {
strategy,
execution,
rng,
ids,
ledger,
inventory,
cmds,
fills,
trade_log,
bundle,
pending_orders,
submit_ids_scratch,
..
} = self;
cmds.clear();
{
let mut ctx = ChainContext {
snapshot,
open: inventory.as_slice(),
pending: &[],
marks: ledger.marks(),
rng: &mut *rng,
step: snapshot.step,
};
strategy.on_start(&mut ctx, cmds)?;
}
fills.clear();
mint_submit_ids(cmds.as_slice(), ids, submit_ids_scratch)?;
execution.fill(
cmds.as_slice(),
submit_ids_scratch.as_slice(),
snapshot,
fills,
)?;
let groups = execution.fill_groups();
let carry = execution.carry_fills();
let reasons = CloseReasons {
exits_end: 0,
on_end_start: cmds.len(),
policy_reason: ExitReason::ManualClose,
};
apply_step_fills(
cmds.as_slice(),
fills.as_slice(),
groups,
carry,
submit_ids_scratch.as_slice(),
snapshot,
inventory,
ids,
ledger,
trade_log,
bundle,
pending_orders,
&reasons,
)?;
Ok(())
}
fn step(&mut self, snapshot: &ChainSnapshot, is_last: bool) -> Result<(), BacktestError> {
let Self {
strategy,
execution,
clock,
rng,
ids,
ledger,
inventory,
begin_scratch,
cmds,
fills,
equity_curve,
attribution,
trade_log,
bundle,
pending_orders,
pending_scratch,
submit_ids_scratch,
} = self;
clock.advance_to(snapshot.ts, snapshot.step)?;
rebuild_pending_view(pending_orders, pending_scratch);
cmds.clear();
{
let ctx = ChainContext {
snapshot,
open: inventory.as_slice(),
pending: pending_scratch.as_slice(),
marks: ledger.marks(),
rng: &mut *rng,
step: snapshot.step,
};
strategy.exits(&ctx, cmds)?;
}
let exits_end = cmds.len();
{
let mut ctx = ChainContext {
snapshot,
open: inventory.as_slice(),
pending: pending_scratch.as_slice(),
marks: ledger.marks(),
rng: &mut *rng,
step: snapshot.step,
};
strategy.on_snapshot(&mut ctx, cmds)?;
}
let on_end_start = cmds.len();
if is_last {
{
let mut ctx = ChainContext {
snapshot,
open: inventory.as_slice(),
pending: pending_scratch.as_slice(),
marks: ledger.marks(),
rng: &mut *rng,
step: snapshot.step,
};
strategy.on_end(&mut ctx, cmds)?;
}
reject_non_close(cmds.get(on_end_start..).unwrap_or(&[]))?;
}
let policy_reason = if exits_end > 0 {
strategy.exit_reason()
} else {
ExitReason::ManualClose
};
let reasons = CloseReasons {
exits_end,
on_end_start,
policy_reason,
};
fills.clear();
mint_submit_ids(cmds.as_slice(), ids, submit_ids_scratch)?;
execution.fill(
cmds.as_slice(),
submit_ids_scratch.as_slice(),
snapshot,
fills,
)?;
let groups = execution.fill_groups();
let carry = execution.carry_fills();
begin_scratch.clear();
begin_scratch.extend_from_slice(inventory.as_slice());
apply_step_fills(
cmds.as_slice(),
fills.as_slice(),
groups,
carry,
submit_ids_scratch.as_slice(),
snapshot,
inventory,
ids,
ledger,
trade_log,
bundle,
pending_orders,
&reasons,
)?;
ledger
.collect_attribution_marks(begin_scratch.as_slice(), snapshot.spec.contract_multiplier);
let point = ledger.settle(snapshot.step, snapshot.ts, inventory.as_slice(), snapshot)?;
equity_curve.push(point);
let marks = ledger.position_marks();
let spread_capture = ledger.spread_capture();
let fees = ledger.fees();
let step_pnl = ledger.step_pnl();
attribution.collect(
snapshot,
ledger.attribution_marks(),
spread_capture,
fees,
step_pnl,
);
bundle.collect_step(snapshot.step.value(), snapshot.ts.value(), marks, is_last)?;
Ok(())
}
}
fn reject_non_close(on_end_cmds: &[OrderCommand]) -> Result<(), BacktestError> {
for cmd in on_end_cmds {
let is_close = matches!(
cmd,
OrderCommand::Submit(OrderIntent {
action: PositionAction::Close(_),
..
})
);
if !is_close {
return Err(BacktestError::Execution(
"on_end may emit only close commands; a submit(open), cancel, or replace from on_end is rejected"
.to_string(),
));
}
}
Ok(())
}
fn mint_submit_ids(
cmds: &[OrderCommand],
ids: &mut IdCounters,
scratch: &mut Vec<OrderId>,
) -> Result<(), BacktestError> {
scratch.clear();
for cmd in cmds {
if matches!(cmd, OrderCommand::Submit(_) | OrderCommand::Replace { .. }) {
scratch.push(ids.mint_order()?);
}
}
Ok(())
}
fn rebuild_pending_view(
pending: &BTreeMap<OrderId, PendingEntry>,
scratch: &mut Vec<PendingOrder>,
) {
scratch.clear();
scratch.extend(pending.values().map(|entry| entry.pending.clone()));
}
#[allow(
clippy::too_many_arguments,
reason = "one argument per registration-time signal; the registry insert centralises the shape"
)]
fn register_pending(
pending: &mut BTreeMap<OrderId, PendingEntry>,
order_id: OrderId,
intent: &OrderIntent,
remaining: Quantity,
fills_so_far: u32,
trade_id: Option<TradeId>,
position_id: Option<PositionId>,
reason: Option<ExitReason>,
) {
let mut resting = intent.clone();
resting.quantity = remaining;
pending.insert(
order_id,
PendingEntry {
pending: PendingOrder {
order_id,
intent: resting,
},
trade_id,
position_id,
fills_so_far,
reason,
},
);
}
fn extend_open_leg(
inventory: &mut [OpenPosition],
position_id: PositionId,
order_fills: &[Fill],
agg: &FillAggregate,
) -> Result<(), BacktestError> {
let Some(leg) = inventory.iter_mut().find(|l| l.position_id == position_id) else {
return Err(BacktestError::Execution(format!(
"carry extension targets position {} which is not open",
position_id.value()
)));
};
let old_qty = u64::from(leg.quantity.value());
let old_notional = u128::from(leg.entry_premium.value())
.checked_mul(u128::from(old_qty))
.ok_or(BacktestError::ArithmeticOverflow)?;
let mut carry_notional: u128 = 0;
for fill in order_fills {
let leg_notional = u128::from(fill.price.value())
.checked_mul(u128::from(u64::from(fill.quantity.value())))
.ok_or(BacktestError::ArithmeticOverflow)?;
carry_notional = carry_notional
.checked_add(leg_notional)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
let total_qty = old_qty
.checked_add(u64::from(agg.quantity.value()))
.ok_or(BacktestError::ArithmeticOverflow)?;
let total_notional = old_notional
.checked_add(carry_notional)
.ok_or(BacktestError::ArithmeticOverflow)?;
leg.quantity =
Quantity::new(u32::try_from(total_qty).map_err(|_| BacktestError::ArithmeticOverflow)?)?;
leg.entry_premium = vwap_cents(total_notional, total_qty)?;
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "the carry application threads the same per-step state as apply_step_fills"
)]
fn apply_carry_group(
order_id: OrderId,
order_fills: &[Fill],
snapshot: &ChainSnapshot,
inventory: &mut Vec<OpenPosition>,
ids: &mut IdCounters,
ledger: &mut Ledger,
trade_log: &mut TradeLogCollector,
bundle: &mut BundleCollector,
pending: &mut BTreeMap<OrderId, PendingEntry>,
multiplier: u32,
ts_ns: i64,
) -> Result<(), BacktestError> {
let Some(entry) = pending.get_mut(&order_id) else {
return Err(BacktestError::Execution(format!(
"carry group names order {} which is not pending",
order_id.value()
)));
};
let agg = aggregate_fills(order_fills)?;
let first = order_fills
.first()
.ok_or_else(|| BacktestError::Execution("a carry group's fill run is empty".to_string()))?;
let count = u32::try_from(order_fills.len()).map_err(|_| BacktestError::ArithmeticOverflow)?;
match entry.pending.intent.action {
PositionAction::Open => {
let trade_id = match entry.trade_id {
Some(existing) => existing,
None => {
let minted = ids.mint_trade()?;
entry.trade_id = Some(minted);
minted
}
};
let position_id = match entry.position_id {
Some(existing) => {
extend_open_leg(inventory, existing, order_fills, &agg)?;
let Some(leg) = inventory.iter().find(|l| l.position_id == existing) else {
return Err(BacktestError::Execution(format!(
"carry extension lost leg {}",
existing.value()
)));
};
trade_log.record_open_extend(existing, agg.quantity, leg.entry_premium)?;
bundle.register_open_leg(existing, trade_id, leg.entry_premium, first.side);
existing
}
None => {
let minted = ids.mint_position()?;
entry.position_id = Some(minted);
inventory.push(OpenPosition {
position_id: minted,
contract: first.contract.clone(),
side: first.side,
quantity: agg.quantity,
entry_premium: agg.vwap,
});
trade_log.record_open(
minted,
trade_id,
first.contract.clone(),
first.side,
agg.quantity,
agg.vwap,
ts_ns,
);
bundle.register_open_leg(minted, trade_id, agg.vwap, first.side);
minted
}
};
for (level, fill) in order_fills.iter().enumerate() {
let level = u32::try_from(level).map_err(|_| BacktestError::ArithmeticOverflow)?;
let fill_seq = entry
.fills_so_far
.checked_add(level)
.ok_or(BacktestError::ArithmeticOverflow)?;
bundle.record_open_fill(fill, order_id.value(), trade_id, position_id, fill_seq);
ledger.apply_fill(fill, multiplier)?;
}
}
PositionAction::Close(position_id) => {
let full_close = reduce_leg(inventory, position_id, agg.quantity)?;
let reason = entry.reason.clone().unwrap_or(ExitReason::ManualClose);
trade_log.record_close(
position_id,
agg.vwap,
agg.fees,
agg.slippage,
ts_ns,
agg.quantity,
multiplier,
reason.clone(),
)?;
let snapshot_mark = snapshot.quotes.get(&first.contract).map(|quote| quote.mid);
for (level, fill) in order_fills.iter().enumerate() {
let level = u32::try_from(level).map_err(|_| BacktestError::ArithmeticOverflow)?;
let fill_seq = entry
.fills_so_far
.checked_add(level)
.ok_or(BacktestError::ArithmeticOverflow)?;
bundle.record_close_fill(fill, order_id.value(), position_id, fill_seq)?;
ledger.apply_fill(fill, multiplier)?;
}
if full_close {
bundle.record_close_terminal(
&first.contract,
first.step.value(),
first.ts.value(),
position_id,
agg.quantity.value(),
snapshot_mark,
multiplier,
reason,
)?;
}
}
}
entry.fills_so_far = entry
.fills_so_far
.checked_add(count)
.ok_or(BacktestError::ArithmeticOverflow)?;
let remaining = entry
.pending
.intent
.quantity
.value()
.checked_sub(agg.quantity.value())
.ok_or_else(|| {
BacktestError::Execution(format!(
"carry fills exceed order {}'s resting size",
order_id.value()
))
})?;
if remaining == 0 {
pending.remove(&order_id);
} else {
entry.pending.intent.quantity = Quantity::new(remaining)?;
}
Ok(())
}
#[derive(Debug, Clone)]
struct PendingEntry {
pending: PendingOrder,
trade_id: Option<TradeId>,
position_id: Option<PositionId>,
fills_so_far: u32,
reason: Option<ExitReason>,
}
struct CloseReasons {
exits_end: usize,
on_end_start: usize,
policy_reason: ExitReason,
}
impl CloseReasons {
fn reason_for(&self, idx: usize) -> ExitReason {
if idx < self.exits_end {
self.policy_reason.clone()
} else if idx >= self.on_end_start {
ExitReason::Other("end_of_data".to_string())
} else {
ExitReason::ManualClose
}
}
}
struct FillAggregate {
quantity: Quantity,
vwap: PriceCents,
fees: Cents,
slippage: Cents,
}
fn aggregate_fills(fills: &[Fill]) -> Result<FillAggregate, BacktestError> {
let mut total_qty: u64 = 0;
let mut notional: u128 = 0;
let mut total_fees: i64 = 0;
let mut total_slippage: i64 = 0;
for fill in fills {
let q = u64::from(fill.quantity.value());
total_qty = total_qty
.checked_add(q)
.ok_or(BacktestError::ArithmeticOverflow)?;
let leg_notional = u128::from(fill.price.value())
.checked_mul(u128::from(q))
.ok_or(BacktestError::ArithmeticOverflow)?;
notional = notional
.checked_add(leg_notional)
.ok_or(BacktestError::ArithmeticOverflow)?;
total_fees = total_fees
.checked_add(fill.fees.value())
.ok_or(BacktestError::ArithmeticOverflow)?;
total_slippage = total_slippage
.checked_add(fill.slippage.value())
.ok_or(BacktestError::ArithmeticOverflow)?;
}
let quantity =
Quantity::new(u32::try_from(total_qty).map_err(|_| BacktestError::ArithmeticOverflow)?)?;
let vwap = vwap_cents(notional, total_qty)?;
Ok(FillAggregate {
quantity,
vwap,
fees: Cents::new(total_fees),
slippage: Cents::new(total_slippage),
})
}
#[must_use = "the computed VWAP is the leg's reference entry/exit premium"]
fn vwap_cents(notional: u128, total_qty: u64) -> Result<PriceCents, BacktestError> {
if total_qty == 0 {
return Err(BacktestError::Execution(
"cannot average a zero-quantity order".to_string(),
));
}
let divisor = u128::from(total_qty);
let quotient = notional / divisor;
let remainder = notional % divisor;
let twice_rem = remainder
.checked_mul(2)
.ok_or(BacktestError::ArithmeticOverflow)?;
let rounded = if twice_rem < divisor {
quotient
} else if twice_rem > divisor {
quotient
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?
} else if quotient.is_multiple_of(2) {
quotient
} else {
quotient
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?
};
let cents = u64::try_from(rounded).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(PriceCents::new(cents))
}
#[allow(
clippy::too_many_arguments,
reason = "the correlation step threads the loop's per-step buffers, fill grouping, id counters, ledger, trade log, bundle collector, and close-phase ranges; splitting it would fragment the single fill-correlation pass"
)]
fn apply_step_fills(
cmds: &[OrderCommand],
fills: &[Fill],
groups: Option<&[FillGroup]>,
carry: &[CarryGroup],
submit_ids: &[OrderId],
snapshot: &ChainSnapshot,
inventory: &mut Vec<OpenPosition>,
ids: &mut IdCounters,
ledger: &mut Ledger,
trade_log: &mut TradeLogCollector,
bundle: &mut BundleCollector,
pending: &mut BTreeMap<OrderId, PendingEntry>,
reasons: &CloseReasons,
) -> Result<(), BacktestError> {
let multiplier = snapshot.spec.contract_multiplier;
let ts_ns = snapshot.ts.value();
for cmd in cmds {
let (OrderCommand::Cancel(order_id) | OrderCommand::Replace { order_id, .. }) = cmd else {
continue;
};
if !pending.contains_key(order_id) {
return Err(BacktestError::Execution(format!(
"cancel/replace targets order {} which is not pending",
order_id.value()
)));
}
}
if groups.is_none() && !carry.is_empty() {
return Err(BacktestError::Execution(
"carry fills require the grouped correlation mode".to_string(),
));
}
let mut carry_total: usize = 0;
for group in carry {
let count =
usize::try_from(group.fill_count).map_err(|_| BacktestError::ArithmeticOverflow)?;
carry_total = carry_total
.checked_add(count)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
let command_fills = match groups {
None => cmds
.iter()
.filter(|cmd| matches!(cmd, OrderCommand::Submit(_)))
.count(),
Some(groups) => {
let mut total: usize = 0;
for group in groups {
let count = usize::try_from(group.fill_count)
.map_err(|_| BacktestError::ArithmeticOverflow)?;
total = total
.checked_add(count)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
total
}
};
let expected_fills = carry_total
.checked_add(command_fills)
.ok_or(BacktestError::ArithmeticOverflow)?;
if expected_fills != fills.len() {
return Err(BacktestError::Execution(format!(
"execution fills are not fully correlated to submitted orders \
({} fills, {expected_fills} expected from the order grouping; a surplus is an \
uncorrelated refresh fill and a shortfall is a submit that did not fill)",
fills.len()
)));
}
let mut cursor: usize = 0;
for group in carry {
let count =
usize::try_from(group.fill_count).map_err(|_| BacktestError::ArithmeticOverflow)?;
let end = cursor
.checked_add(count)
.ok_or(BacktestError::ArithmeticOverflow)?;
let order_fills = fills.get(cursor..end).ok_or_else(|| {
BacktestError::Execution("carry correlation ran past the produced fills".to_string())
})?;
cursor = end;
apply_carry_group(
group.order_id,
order_fills,
snapshot,
inventory,
ids,
ledger,
trade_log,
bundle,
pending,
multiplier,
ts_ns,
)?;
}
let mut groups_iter = groups.map(|g| g.iter().peekable());
let mut step_trade: Option<TradeId> = None;
let mut submit_ordinal: usize = 0;
for (idx, cmd) in cmds.iter().enumerate() {
let intent = match cmd {
OrderCommand::Submit(intent) => intent,
OrderCommand::Cancel(order_id) => {
pending.remove(order_id);
continue;
}
OrderCommand::Replace {
order_id,
replacement,
} => {
pending.remove(order_id);
replacement
}
};
{
{
let order_id = submit_ids.get(submit_ordinal).copied().ok_or_else(|| {
BacktestError::Execution(
"pre-minted submit_ids under-cover the step's commands".to_string(),
)
})?;
submit_ordinal = submit_ordinal
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
let count = match groups_iter.as_mut() {
None => 1,
Some(iter) => match iter.peek() {
Some(group) if group.command_index == idx => {
let count = usize::try_from(group.fill_count)
.map_err(|_| BacktestError::ArithmeticOverflow)?;
iter.next();
count
}
_ => 0,
},
};
if count == 0 {
if matches!(intent.tif, TimeInForce::Gtc) {
let (trade, reason) = match intent.action {
PositionAction::Open => {
let trade = match step_trade {
Some(existing) => existing,
None => {
let minted = ids.mint_trade()?;
step_trade = Some(minted);
minted
}
};
(Some(trade), None)
}
PositionAction::Close(_) => (None, Some(reasons.reason_for(idx))),
};
register_pending(
pending,
order_id,
intent,
intent.quantity,
0,
trade,
None,
reason,
);
continue;
}
return Err(BacktestError::Execution(format!(
"submitted order at command index {idx} produced no fill \
(a zero-fill order cannot open or close a leg)"
)));
}
let end = cursor
.checked_add(count)
.ok_or(BacktestError::ArithmeticOverflow)?;
let order_fills = fills.get(cursor..end).ok_or_else(|| {
BacktestError::Execution(
"fill correlation ran past the produced fills".to_string(),
)
})?;
cursor = end;
let first = order_fills.first().ok_or_else(|| {
BacktestError::Execution("an order's fill run is empty".to_string())
})?;
debug_assert!(
order_fills.iter().all(|f| f.contract == intent.contract),
"a fill must echo its submitted contract"
);
let agg = aggregate_fills(order_fills)?;
let mut opened_position: Option<PositionId> = None;
match intent.action {
PositionAction::Open => {
let trade_id = match step_trade {
Some(existing) => existing,
None => {
let minted = ids.mint_trade()?;
step_trade = Some(minted);
minted
}
};
let position_id = ids.mint_position()?;
opened_position = Some(position_id);
inventory.push(OpenPosition {
position_id,
contract: first.contract.clone(),
side: first.side,
quantity: agg.quantity,
entry_premium: agg.vwap,
});
trade_log.record_open(
position_id,
trade_id,
first.contract.clone(),
first.side,
agg.quantity,
agg.vwap,
ts_ns,
);
bundle.register_open_leg(position_id, trade_id, agg.vwap, first.side);
for (level, fill) in order_fills.iter().enumerate() {
let fill_seq = u32::try_from(level)
.map_err(|_| BacktestError::ArithmeticOverflow)?;
bundle.record_open_fill(
fill,
order_id.value(),
trade_id,
position_id,
fill_seq,
);
ledger.apply_fill(fill, multiplier)?;
}
}
PositionAction::Close(position_id) => {
let full_close = reduce_leg(inventory, position_id, agg.quantity)?;
let reason = reasons.reason_for(idx);
trade_log.record_close(
position_id,
agg.vwap,
agg.fees,
agg.slippage,
ts_ns,
agg.quantity,
multiplier,
reason.clone(),
)?;
let snapshot_mark =
snapshot.quotes.get(&first.contract).map(|quote| quote.mid);
for (level, fill) in order_fills.iter().enumerate() {
let fill_seq = u32::try_from(level)
.map_err(|_| BacktestError::ArithmeticOverflow)?;
bundle.record_close_fill(
fill,
order_id.value(),
position_id,
fill_seq,
)?;
ledger.apply_fill(fill, multiplier)?;
}
if full_close {
bundle.record_close_terminal(
&first.contract,
first.step.value(),
first.ts.value(),
position_id,
agg.quantity.value(),
snapshot_mark,
multiplier,
reason,
)?;
}
}
}
if matches!(intent.tif, TimeInForce::Gtc) {
let filled = u64::from(agg.quantity.value());
let total = u64::from(intent.quantity.value());
if filled < total {
let remainder = total
.checked_sub(filled)
.ok_or(BacktestError::ArithmeticOverflow)?;
let remainder = Quantity::new(
u32::try_from(remainder)
.map_err(|_| BacktestError::ArithmeticOverflow)?,
)?;
let (trade, reason) = match intent.action {
PositionAction::Open => (step_trade, None),
PositionAction::Close(_) => (None, Some(reasons.reason_for(idx))),
};
let produced = u32::try_from(order_fills.len())
.map_err(|_| BacktestError::ArithmeticOverflow)?;
register_pending(
pending,
order_id,
intent,
remainder,
produced,
trade,
opened_position,
reason,
);
}
}
}
}
}
let groups_unconsumed = groups_iter.is_some_and(|mut iter| iter.next().is_some());
if cursor != fills.len() || groups_unconsumed {
return Err(BacktestError::Execution(
"execution fills were not fully correlated to submitted orders".to_string(),
));
}
Ok(())
}
fn reduce_leg(
inventory: &mut Vec<OpenPosition>,
position_id: PositionId,
close_qty: Quantity,
) -> Result<bool, BacktestError> {
let idx = inventory
.iter()
.position(|leg| leg.position_id == position_id)
.ok_or_else(|| {
BacktestError::Execution(format!(
"close targets position {} which is not open",
position_id.value()
))
})?;
let open_qty = inventory
.get(idx)
.ok_or_else(|| BacktestError::Execution("open leg vanished from inventory".to_string()))?
.quantity
.value();
let requested = close_qty.value();
if requested > open_qty {
return Err(BacktestError::Execution(format!(
"close quantity {requested} exceeds open size {open_qty} for position {}",
position_id.value()
)));
}
if requested == open_qty {
inventory.remove(idx); Ok(true)
} else {
let remaining = open_qty - requested;
let leg = inventory.get_mut(idx).ok_or_else(|| {
BacktestError::Execution("open leg vanished from inventory".to_string())
})?;
leg.quantity = Quantity::new(remaining)?;
Ok(false)
}
}
#[must_use]
fn dollars(cents: i64) -> Decimal {
Decimal::from_i128_with_scale(i128::from(cents), 2)
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use rand_chacha::rand_core::RngCore;
use rust_decimal_macros::dec;
use super::{BacktestEngine, BacktestRun, vwap_cents};
use crate::config::{BacktestConfig, FeeSchedule, SlippageModel};
use crate::data::DataSourceSpec;
use crate::data::feed::InMemoryFeed;
use crate::domain::{
Cents, ChainSnapshot, ContractKey, ExecutionMode, Fill, InstrumentSpec, OrderCommand,
OrderId, OrderIntent, PositionAction, PositionId, PriceCents, Quantity, QuoteView, SimTime,
StepIndex, Underlying,
};
use crate::engine::strategy::{ChainContext, Strategy};
use crate::error::BacktestError;
use crate::execution::{ExecutionModel, FillGroup, NaiveFill};
const TS0: i64 = 1_750_291_200_000_000_000;
const STRIKE: u64 = 510_000;
fn und() -> Underlying {
let Ok(u) = Underlying::new("SPX") else {
panic!("SPX is valid");
};
u
}
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is a valid quantity");
};
q
}
fn call_key() -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(TS0)),
strike: PriceCents::new(STRIKE),
style: OptionStyle::Call,
}
}
fn quote(mid: u64) -> QuoteView {
debug_assert!(mid >= 10, "fixtures use a mid of at least 10c");
QuoteView {
contract: call_key(),
bid: PriceCents::new(mid - 10),
ask: PriceCents::new(mid + 10),
mid: PriceCents::new(mid),
bid_size: qty(50),
ask_size: qty(50),
implied_volatility: dec!(0.2),
delta: dec!(0.3),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
}
}
fn snapshot(step: u32, mid: u64) -> ChainSnapshot {
let mut quotes = BTreeMap::new();
let q = quote(mid);
quotes.insert(q.contract.clone(), q);
let Ok(spec) = InstrumentSpec::new(PriceCents::new(5), 100) else {
panic!("valid spec");
};
ChainSnapshot {
ts: SimTime::new(TS0 + i64::from(step) * 1_000),
step: StepIndex::new(step),
underlying: und(),
underlying_price: PriceCents::new(500_000),
spec,
quotes,
}
}
fn feed_of(mids: &[u64]) -> InMemoryFeed {
let tape: Vec<ChainSnapshot> = mids
.iter()
.enumerate()
.map(|(step, &mid)| {
let step = u32::try_from(step).unwrap_or(u32::MAX);
snapshot(step, mid)
})
.collect();
let source = DataSourceSpec::Parquet {
path: "test.parquet".to_string(),
sha256: "test-sha".to_string(),
};
let Ok(feed) = InMemoryFeed::new("test-sha".to_string(), tape, source) else {
panic!("a strictly-ordered non-empty tape builds a feed");
};
feed
}
fn config() -> BacktestConfig {
BacktestConfig {
data_source: DataSourceSpec::Parquet {
path: "test.parquet".to_string(),
sha256: "test-sha".to_string(),
},
mode: ExecutionMode::Naive,
seed: 7,
initial_capital: 10_000_000,
fees: FeeSchedule {
per_contract_cents: 0,
per_order_cents: 0,
},
slippage: SlippageModel::None,
marketable_cap_ticks: 10,
liquidity_profile: crate::config::LiquidityProfile::default(),
limits: crate::config::ResourceLimits::default(),
output_dir: "runs/out".into(),
overwrite: false,
}
}
fn naive() -> NaiveFill {
NaiveFill::new(
SlippageModel::None,
FeeSchedule {
per_contract_cents: 0,
per_order_cents: 0,
},
)
}
fn open_call(snapshot: &ChainSnapshot) -> OrderCommand {
let Some(quote) = snapshot.quotes.get(&call_key()) else {
panic!("the call is quoted in the fixture");
};
OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(1),
limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: quote.mid,
})
}
struct HoldOpen {
opened: bool,
seen_open_lens: Rc<RefCell<Vec<usize>>>,
}
impl Strategy for HoldOpen {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
out.push(open_call(ctx.snapshot));
self.opened = true;
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
self.seen_open_lens.borrow_mut().push(ctx.open.len());
if !self.opened {
out.push(open_call(ctx.snapshot));
self.opened = true;
}
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
struct CloseUnknown;
impl Strategy for CloseUnknown {
fn on_start(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let Some(quote) = ctx.snapshot.quotes.get(&call_key()) else {
panic!("the call is quoted");
};
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Close(PositionId::new(999)),
side: Side::Short,
quantity: qty(1),
limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: quote.mid,
}));
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
struct CloseOversized;
impl Strategy for CloseOversized {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
out.push(open_call(ctx.snapshot));
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let Some(leg) = ctx.open.first() else {
return Ok(());
};
let Some(quote) = ctx.snapshot.quotes.get(&call_key()) else {
panic!("the call is quoted");
};
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Close(leg.position_id),
side: Side::Short,
quantity: qty(2), limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: quote.mid,
}));
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
struct OnEndOpen;
impl Strategy for OnEndOpen {
fn on_start(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_end(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
out.push(open_call(ctx.snapshot));
Ok(())
}
}
struct RngProbe {
opened: bool,
}
impl Strategy for RngProbe {
fn on_start(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let draw = ctx.rng.next_u32();
if !self.opened && draw.is_multiple_of(2) {
out.push(open_call(ctx.snapshot));
self.opened = true;
}
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
struct Passive;
impl Strategy for Passive {
fn on_start(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
struct GroupedRefreshOnly {
groups: Vec<FillGroup>,
}
impl ExecutionModel for GroupedRefreshOnly {
fn fill(
&mut self,
_commands: &[OrderCommand],
_submit_ids: &[OrderId],
snap: &ChainSnapshot,
out_fills: &mut Vec<Fill>,
) -> Result<(), BacktestError> {
out_fills.push(Fill {
ts: snap.ts,
step: snap.step,
contract: call_key(),
side: Side::Short,
quantity: qty(1),
price: PriceCents::new(100),
fees: Cents::new(0),
slippage: Cents::new(0),
mode: ExecutionMode::Realistic,
});
Ok(())
}
fn fill_groups(&self) -> Option<&[FillGroup]> {
Some(&self.groups)
}
fn mode(&self) -> ExecutionMode {
ExecutionMode::Realistic
}
}
#[test]
fn test_grouped_no_groups_surplus_fill_is_typed_error_not_silent() {
let feed = feed_of(&[105, 106]);
let config = config();
let execution = GroupedRefreshOnly { groups: Vec::new() };
let result = BacktestEngine::run(&config, feed, execution, Passive, "passive");
assert!(
matches!(result, Err(BacktestError::Execution(_))),
"a surplus refresh fill under the grouped contract is a typed error"
);
}
#[test]
fn test_on_start_positions_are_visible_in_open_at_step_zero() {
let seen = Rc::new(RefCell::new(Vec::new()));
let strategy = HoldOpen {
opened: false,
seen_open_lens: Rc::clone(&seen),
};
let run = BacktestEngine::run(
&config(),
feed_of(&[200, 210, 220]),
naive(),
strategy,
"hold",
);
let Ok(_run) = run else {
panic!("the run succeeds");
};
let lens = seen.borrow();
assert!(
matches!(lens.first(), Some(1)),
"step 0 on_snapshot sees the startup leg"
);
assert!(
lens.iter().all(|&n| n == 1),
"the single leg stays open every step (never re-opened)"
);
}
#[test]
fn test_run_emits_exactly_one_equity_point_per_step_including_last() {
let seen = Rc::new(RefCell::new(Vec::new()));
let strategy = HoldOpen {
opened: false,
seen_open_lens: Rc::clone(&seen),
};
let Ok(run) = BacktestEngine::run(
&config(),
feed_of(&[200, 210, 220, 230]),
naive(),
strategy,
"hold",
) else {
panic!("the run succeeds");
};
assert_eq!(run.equity_curve.len(), 4, "one equity point per snapshot");
let steps: Vec<u32> = run.equity_curve.iter().map(|p| p.step).collect();
assert_eq!(steps, vec![0, 1, 2, 3], "points are in step order");
}
#[test]
fn test_leg_left_open_is_open_at_end_without_synthetic_terminal_fill() {
let seen = Rc::new(RefCell::new(Vec::new()));
let strategy = HoldOpen {
opened: false,
seen_open_lens: Rc::clone(&seen),
};
let Ok(run) =
BacktestEngine::run(&config(), feed_of(&[200, 300]), naive(), strategy, "hold")
else {
panic!("the run succeeds");
};
assert_eq!(run.open_at_end.len(), 1);
let Some(leg) = run.open_at_end.first() else {
panic!("one leg open at end");
};
assert_eq!(leg.position_id, PositionId::new(1));
let Some(last) = run.equity_curve.last() else {
panic!("a final equity point exists");
};
assert_eq!(last.position_value_cents, 30_000);
}
#[test]
fn test_close_unknown_leg_is_execution_error() {
let run = BacktestEngine::run(
&config(),
feed_of(&[200, 210]),
naive(),
CloseUnknown,
"close",
);
assert!(matches!(run, Err(BacktestError::Execution(_))));
}
#[test]
fn test_close_oversized_quantity_is_execution_error() {
let run = BacktestEngine::run(
&config(),
feed_of(&[200, 210]),
naive(),
CloseOversized,
"close",
);
assert!(matches!(run, Err(BacktestError::Execution(_))));
}
#[test]
fn test_open_from_on_end_is_execution_error() {
let run = BacktestEngine::run(&config(), feed_of(&[200]), naive(), OnEndOpen, "onend");
assert!(matches!(run, Err(BacktestError::Execution(_))));
}
#[test]
fn test_same_seed_same_result_with_rng_consuming_strategy() {
let curves = |seed: u64| -> BacktestRun {
let mut cfg = config();
cfg.seed = seed;
let Ok(run) = BacktestEngine::run(
&cfg,
feed_of(&[200, 210, 220, 230, 240]),
naive(),
RngProbe { opened: false },
"rng",
) else {
panic!("the run succeeds");
};
run
};
let a = curves(42);
let b = curves(42);
assert_eq!(a.equity_curve, b.equity_curve);
assert_eq!(a.open_at_end, b.open_at_end);
assert_eq!(a.result.final_capital, b.result.final_capital);
}
#[test]
fn test_initial_capital_zero_is_config_error() {
let mut cfg = config();
cfg.initial_capital = 0;
let strategy = HoldOpen {
opened: false,
seen_open_lens: Rc::new(RefCell::new(Vec::new())),
};
let run = BacktestEngine::run(&cfg, feed_of(&[200]), naive(), strategy, "hold");
assert!(matches!(run, Err(BacktestError::Config(_))));
}
#[cfg(feature = "orderbook")]
#[test]
fn test_realistic_marketable_open_walks_two_levels_into_one_correlated_order() {
use crate::config::{LiquidityProfile, TouchSize};
use crate::execution::RealisticFill;
struct OpenLots {
lots: u32,
}
impl Strategy for OpenLots {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let Some(quote) = ctx.snapshot.quotes.get(&call_key()) else {
panic!("the call is quoted in the fixture");
};
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(self.lots),
limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: quote.mid,
}));
Ok(())
}
fn on_snapshot(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
let profile = LiquidityProfile {
touch_size: TouchSize::Flat { contracts: 3 },
depth_levels: 2,
decay: dec!(1),
};
let fees = FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
};
let run_once = || {
let execution = RealisticFill::with_liquidity_profile(fees, 10, 7, profile);
let Ok(run) = BacktestEngine::run(
&config(),
feed_of(&[200, 200]),
execution,
OpenLots { lots: 5 },
"multilevel",
) else {
panic!("the multi-level realistic run succeeds");
};
run
};
let run = run_once();
assert_eq!(run.fills.len(), 2, "the buy for 5 walks two ask levels");
let (Some(f0), Some(f1)) = (run.fills.first(), run.fills.get(1)) else {
panic!("two fill records expected");
};
assert_eq!(f0.order_id, f1.order_id, "one order across both levels");
assert_eq!(f0.position_id, f1.position_id, "one leg across both levels");
assert_eq!(f0.trade_id, f1.trade_id, "one trade across both levels");
assert_eq!(f0.fill_seq, 0);
assert_eq!(f1.fill_seq, 1);
assert_eq!((f0.fill.price.value(), f0.fill.quantity.value()), (210, 3));
assert_eq!((f1.fill.price.value(), f1.fill.quantity.value()), (215, 2));
assert!(
f1.fill.price.value() > f0.fill.price.value(),
"each deeper level fills worse for a buy"
);
assert!(
run.fills
.iter()
.all(|r| r.fill.mode == ExecutionMode::Realistic)
);
assert_eq!(f0.fill.fees.value(), 3 * 65 + 100);
assert_eq!(f1.fill.fees.value(), 2 * 65);
let mut keys: Vec<(u32, u64, u32)> = run
.fills
.iter()
.map(|r| (r.fill.step.value(), r.order_id, r.fill_seq))
.collect();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(
keys.len(),
total,
"the (step, order_id, fill_seq) key must be unique"
);
assert_eq!(run.open_at_end.len(), 1, "the single leg is held open");
let Some(leg) = run.open_at_end.first() else {
panic!("one open leg at end");
};
assert_eq!(leg.quantity.value(), 5, "aggregate quantity across levels");
assert_eq!(leg.entry_premium.value(), 212, "VWAP entry premium");
assert_eq!(leg.side, Side::Long);
let Some(last) = run.equity_curve.last() else {
panic!("a final equity point exists");
};
assert_eq!(last.position_value_cents, 100_000);
assert_eq!(last.equity_cents, 10_000_000 - 106_425 + 100_000);
let again = run_once();
assert_eq!(run.fills, again.fills, "the fill stream is deterministic");
assert_eq!(run.equity_curve, again.equity_curve);
}
#[test]
fn test_vwap_cents_half_to_even_ties_match_money_policy() {
use rust_decimal::Decimal;
let oracle = |notional: u128, total_qty: u64| -> u64 {
let Ok(n) = i64::try_from(notional) else {
panic!("fixture notional fits i64");
};
let dollars = Decimal::from(n) / Decimal::from(total_qty) / Decimal::ONE_HUNDRED;
let Ok(price) = PriceCents::from_decimal_dollars(dollars) else {
panic!("the oracle price must convert");
};
price.value()
};
let Ok(down) = vwap_cents(842, 4) else {
panic!("vwap must compute");
};
assert_eq!(down.value(), 210, "210.5 ties to the even 210, not 211");
assert_eq!(
down.value(),
oracle(842, 4),
"the tie-down cent must equal the money policy"
);
let Ok(up) = vwap_cents(846, 4) else {
panic!("vwap must compute");
};
assert_eq!(up.value(), 212, "211.5 ties to the even 212, not 211");
assert_eq!(
up.value(),
oracle(846, 4),
"the tie-up cent must equal the money policy"
);
let Ok(below) = vwap_cents(841, 4) else {
panic!("vwap must compute");
};
assert_eq!(below.value(), 210, "210.25 rounds down to 210");
let Ok(above) = vwap_cents(843, 4) else {
panic!("vwap must compute");
};
assert_eq!(above.value(), 211, "210.75 rounds up to 211");
}
#[cfg(feature = "orderbook")]
#[test]
fn test_realistic_marketable_close_walks_two_levels_into_one_terminal_row() {
use crate::config::{LiquidityProfile, TouchSize};
use crate::execution::RealisticFill;
struct OpenThenCloseLots {
lots: u32,
closed: bool,
}
impl Strategy for OpenThenCloseLots {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let Some(quote) = ctx.snapshot.quotes.get(&call_key()) else {
panic!("the call is quoted in the fixture");
};
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(self.lots),
limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: quote.mid,
}));
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
if self.closed {
return Ok(());
}
let Some(leg) = ctx.open.first() else {
return Ok(());
};
let Some(quote) = ctx.snapshot.quotes.get(&call_key()) else {
panic!("the call is quoted in the fixture");
};
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Close(leg.position_id),
side: Side::Short, quantity: leg.quantity,
limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: quote.mid,
}));
self.closed = true;
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
}
let profile = LiquidityProfile {
touch_size: TouchSize::Flat { contracts: 3 },
depth_levels: 2,
decay: dec!(1),
};
let fees = FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
};
let execution = RealisticFill::with_liquidity_profile(fees, 10, 7, profile);
let Ok(run) = BacktestEngine::run(
&config(),
feed_of(&[200]),
execution,
OpenThenCloseLots {
lots: 5,
closed: false,
},
"close-walk",
) else {
panic!("the multi-level close run succeeds");
};
let closes: Vec<_> = run
.fills
.iter()
.filter(|r| r.fill.side == Side::Short)
.collect();
assert_eq!(closes.len(), 2, "the close for 5 walks two bid levels");
let (Some(c0), Some(c1)) = (closes.first(), closes.get(1)) else {
panic!("two close fill records expected");
};
assert_eq!(
c0.order_id, c1.order_id,
"one close order across both levels"
);
assert_eq!(c0.position_id, 1);
assert_eq!(c1.position_id, 1);
assert_eq!(c0.fill_seq, 0);
assert_eq!(c1.fill_seq, 1);
assert_eq!((c0.fill.price.value(), c0.fill.quantity.value()), (190, 3));
assert_eq!((c1.fill.price.value(), c1.fill.quantity.value()), (185, 2));
assert!(
c1.fill.price.value() < c0.fill.price.value(),
"each deeper level fills worse for a sell"
);
assert!(run.open_at_end.is_empty(), "the whole leg closed");
let terminals: Vec<_> = run
.positions
.iter()
.filter(|p| p.exit_reason.is_some())
.collect();
assert_eq!(
terminals.len(),
1,
"one terminal row for the multi-level close, not one per level"
);
let Some(term) = terminals.first() else {
panic!("one terminal row");
};
assert_eq!(term.position_id, 1);
assert_eq!(
term.quantity, 5,
"the terminal row aggregates the total closed quantity"
);
let Some(last) = run.equity_curve.last() else {
panic!("a final equity point exists");
};
assert_eq!(last.position_value_cents, 0);
assert_eq!(last.equity_cents, 10_000_000 - 106_425 + 93_575);
}
#[cfg(feature = "orderbook")]
struct GtcRest {
limit: u64,
quantity: u32,
cancel_at_step: Option<u32>,
}
#[cfg(feature = "orderbook")]
impl Strategy for GtcRest {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Open,
side: Side::Long,
quantity: Quantity::new(self.quantity)?,
limit: Some(PriceCents::new(self.limit)),
tif: crate::domain::TimeInForce::Gtc,
decision_mid: ctx
.snapshot
.quotes
.get(&call_key())
.map_or(PriceCents::new(self.limit), |q| q.mid),
}));
Ok(())
}
fn exits(
&mut self,
_ctx: &ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
if let Some(step) = self.cancel_at_step
&& ctx.step.value() == step
&& let Some(pending) = ctx.pending.first()
{
out.push(OrderCommand::Cancel(pending.order_id));
}
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn exit_reason(&self) -> super::ExitReason {
super::ExitReason::ManualClose
}
}
#[cfg(feature = "orderbook")]
fn realistic() -> crate::execution::RealisticFill {
let cfg = config();
crate::execution::RealisticFill::with_liquidity_profile(
cfg.fees,
cfg.marketable_cap_ticks,
cfg.seed,
cfg.liquidity_profile,
)
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_resting_gtc_opens_via_carry_with_original_order_id() {
let run = BacktestEngine::run(
&config(),
feed_of(&[600, 490, 490]),
realistic(),
GtcRest {
limit: 500,
quantity: 2,
cancel_at_step: None,
},
"gtc-rest",
);
let Ok(run) = run else {
panic!("the carry-path run succeeds: {run:?}");
};
assert_eq!(run.fills.len(), 1, "exactly the carried fill");
let Some(fill) = run.fills.first() else {
panic!("one fill record");
};
assert_eq!(fill.order_id, 1, "the step-0 pre-minted order id");
assert_eq!(fill.fill.step.value(), 1, "filled by step 1's refresh");
assert_eq!(fill.fill_seq, 0, "the order's first fill");
assert_eq!(fill.fill.price.value(), 500);
assert_eq!(run.open_at_end.len(), 1, "the carried open stays open");
assert_eq!(run.equity_curve.len(), 3);
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_cancel_before_cross_never_fills() {
let run = BacktestEngine::run(
&config(),
feed_of(&[600, 600, 490]),
realistic(),
GtcRest {
limit: 500,
quantity: 2,
cancel_at_step: Some(1),
},
"gtc-cancel",
);
let Ok(run) = run else {
panic!("the cancel run succeeds: {run:?}");
};
assert!(run.fills.is_empty(), "a cancelled order never fills");
assert!(run.open_at_end.is_empty());
assert!(run.trade_log.is_empty());
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_pending_open_at_end_is_dropped_cleanly() {
let run = BacktestEngine::run(
&config(),
feed_of(&[600, 600]),
realistic(),
GtcRest {
limit: 500,
quantity: 2,
cancel_at_step: None,
},
"gtc-unfilled",
);
let Ok(run) = run else {
panic!("the unfilled run succeeds: {run:?}");
};
assert!(run.fills.is_empty(), "no fill was invented at end of data");
assert!(run.open_at_end.is_empty());
assert!(run.trade_log.is_empty());
}
#[cfg(feature = "orderbook")]
fn sized_feed(specs: &[(u64, u32)]) -> InMemoryFeed {
let tape: Vec<ChainSnapshot> = specs
.iter()
.enumerate()
.map(|(step, &(mid, size))| {
let step = u32::try_from(step).unwrap_or(u32::MAX);
let mut snap = snapshot(step, mid);
if let Some(q) = snap.quotes.get_mut(&call_key()) {
q.ask_size = qty(size);
q.bid_size = qty(size);
}
snap
})
.collect();
let source = DataSourceSpec::Parquet {
path: "test.parquet".to_string(),
sha256: "test-sha".to_string(),
};
let Ok(feed) = InMemoryFeed::new("test-sha".to_string(), tape, source) else {
panic!("a strictly-ordered non-empty tape builds a feed");
};
feed
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_partial_fill_across_snapshots_extends_one_position() {
let run = BacktestEngine::run(
&config(),
sized_feed(&[(490, 2), (490, 2), (490, 2)]),
realistic(),
GtcRest {
limit: 500,
quantity: 4,
cancel_at_step: None,
},
"gtc-partial",
);
let Ok(run) = run else {
panic!("the partial-fill run succeeds: {run:?}");
};
assert_eq!(run.fills.len(), 2, "submit fill + carried fill");
let (Some(first), Some(second)) = (run.fills.first(), run.fills.get(1)) else {
panic!("two fill records");
};
assert_eq!(first.order_id, second.order_id, "one order");
assert_eq!(
first.position_id, second.position_id,
"ONE position — the carry extends the leg, never mints a second"
);
assert_eq!(first.trade_id, second.trade_id, "one trade");
assert_eq!(
(first.fill_seq, second.fill_seq),
(0, 1),
"fill_seq continues"
);
assert_eq!(run.open_at_end.len(), 1);
let Some(leg) = run.open_at_end.first() else {
panic!("one leg");
};
assert_eq!(leg.quantity.value(), 4, "the leg carries the full size");
}
#[cfg(feature = "orderbook")]
struct TwoGtcRest;
#[cfg(feature = "orderbook")]
impl Strategy for TwoGtcRest {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let mid = ctx
.snapshot
.quotes
.get(&call_key())
.map_or(PriceCents::new(500), |q| q.mid);
for _ in 0..2 {
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(1),
limit: Some(PriceCents::new(500)),
tif: crate::domain::TimeInForce::Gtc,
decision_mid: mid,
}));
}
Ok(())
}
fn exits(
&mut self,
_ctx: &ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn exit_reason(&self) -> super::ExitReason {
super::ExitReason::ManualClose
}
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_all_resting_multi_leg_entry_shares_one_trade() {
let run = BacktestEngine::run(
&config(),
feed_of(&[600, 490, 490]),
realistic(),
TwoGtcRest,
"two-gtc",
);
let Ok(run) = run else {
panic!("the all-resting run succeeds: {run:?}");
};
assert_eq!(run.fills.len(), 2, "both resting opens filled");
let (Some(first), Some(second)) = (run.fills.first(), run.fills.get(1)) else {
panic!("two fill records");
};
assert_ne!(first.order_id, second.order_id, "two orders");
assert_ne!(first.position_id, second.position_id, "two legs");
assert_eq!(
first.trade_id, second.trade_id,
"opens emitted together share ONE step-wide trade"
);
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_carry_path_run_twice_is_identical() {
let go = || {
let run = BacktestEngine::run(
&config(),
feed_of(&[600, 490, 490]),
realistic(),
GtcRest {
limit: 500,
quantity: 2,
cancel_at_step: None,
},
"gtc-rest",
);
let Ok(run) = run else {
panic!("the run succeeds");
};
let fill_ids: Vec<(u64, u32, u32)> = run
.fills
.iter()
.map(|f| (f.order_id, f.fill.step.value(), f.fill_seq))
.collect();
(run.equity_curve, fill_ids)
};
let (equity_a, fills_a) = go();
let (equity_b, fills_b) = go();
assert_eq!(equity_a, equity_b, "byte-identical equity curves");
assert_eq!(fills_a, fills_b, "identical fill identities");
}
#[cfg(feature = "orderbook")]
struct OpenThenRestClose {
rested: bool,
}
#[cfg(feature = "orderbook")]
impl Strategy for OpenThenRestClose {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
out.push(OrderCommand::Submit(OrderIntent {
contract: call_key(),
action: PositionAction::Open,
side: Side::Long,
quantity: qty(1),
limit: None,
tif: crate::domain::TimeInForce::Ioc,
decision_mid: ctx
.snapshot
.quotes
.get(&call_key())
.map_or(PriceCents::new(500), |q| q.mid),
}));
Ok(())
}
fn exits(
&mut self,
_ctx: &ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
if !self.rested
&& let Some(leg) = ctx.open.first()
{
self.rested = true;
out.push(OrderCommand::Submit(OrderIntent {
contract: leg.contract.clone(),
action: PositionAction::Close(leg.position_id),
side: Side::Short,
quantity: leg.quantity,
limit: Some(PriceCents::new(100_000)),
tif: crate::domain::TimeInForce::Gtc,
decision_mid: PriceCents::new(600),
}));
}
Ok(())
}
fn on_end(
&mut self,
_ctx: &mut ChainContext,
_out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Ok(())
}
fn exit_reason(&self) -> super::ExitReason {
super::ExitReason::ManualClose
}
}
#[test]
#[cfg(feature = "orderbook")]
fn test_engine_resting_close_that_never_fills_leaves_leg_open_at_end() {
let run = BacktestEngine::run(
&config(),
feed_of(&[600, 600, 600]),
realistic(),
OpenThenRestClose { rested: false },
"rest-close",
);
let Ok(run) = run else {
panic!("the resting-close run succeeds: {run:?}");
};
assert_eq!(run.open_at_end.len(), 1, "the leg ends honestly open");
assert!(
run.trade_log.is_empty(),
"no close was realised — the resting close never filled"
);
assert!(
run.fills.iter().all(|f| f.fill.step.value() == 0),
"every fill is the step-0 open"
);
}
}