use std::collections::BTreeMap;
use optionstratlib::Side;
use optionstratlib::backtesting::ExitReason;
use crate::domain::execution::sign_convention;
use crate::domain::{ContractKey, Fill, PositionId, PriceCents, TradeId};
use crate::engine::ledger::PositionMark;
use crate::error::BacktestError;
#[derive(Debug, Clone, PartialEq)]
pub struct FillRecord {
pub fill: Fill,
pub trade_id: u64,
pub position_id: u64,
pub order_id: u64,
pub fill_seq: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PositionSnapshot {
pub step: u32,
pub ts_ns: i64,
pub position_id: u64,
pub trade_id: u64,
pub contract: ContractKey,
pub side: Side,
pub quantity: u32,
pub avg_price_cents: u64,
pub mark_cents: u64,
pub unrealized_cents: i64,
pub stale_mark: bool,
pub exit_reason: Option<ExitReason>,
pub open_at_end: bool,
}
#[derive(Debug, Clone, Copy)]
struct LegMeta {
trade_id: TradeId,
entry_premium: PriceCents,
side: Side,
}
#[derive(Debug)]
pub(crate) struct BundleCollector {
fills: Vec<FillRecord>,
positions: Vec<PositionSnapshot>,
open_legs: BTreeMap<PositionId, LegMeta>,
}
impl BundleCollector {
#[must_use]
pub fn with_capacity(fill_capacity: usize) -> Self {
Self {
fills: Vec::with_capacity(fill_capacity),
positions: Vec::new(),
open_legs: BTreeMap::new(),
}
}
pub fn reserve(&mut self, open_leg_count: usize, step_capacity: usize) {
if let Some(want) = open_leg_count.checked_mul(2)
&& want > self.fills.capacity()
{
self.fills.reserve(want - self.fills.len());
}
if let Some(want) = step_capacity
.checked_mul(open_leg_count)
.and_then(|open_rows| open_rows.checked_add(open_leg_count))
&& want > self.positions.capacity()
{
self.positions.reserve(want - self.positions.len());
}
}
pub fn register_open_leg(
&mut self,
position_id: PositionId,
trade_id: TradeId,
avg_price: PriceCents,
side: Side,
) {
self.open_legs.insert(
position_id,
LegMeta {
trade_id,
entry_premium: avg_price,
side,
},
);
}
pub fn record_open_fill(
&mut self,
fill: &Fill,
order_id: u64,
trade_id: TradeId,
position_id: PositionId,
fill_seq: u32,
) {
self.fills.push(FillRecord {
fill: fill.clone(),
trade_id: trade_id.value(),
position_id: position_id.value(),
order_id,
fill_seq,
});
}
pub fn record_close_fill(
&mut self,
fill: &Fill,
order_id: u64,
position_id: PositionId,
fill_seq: u32,
) -> Result<(), BacktestError> {
let leg = *self.open_legs.get(&position_id).ok_or_else(|| {
BacktestError::Execution(format!(
"bundle close targets position {} which is not open",
position_id.value()
))
})?;
self.fills.push(FillRecord {
fill: fill.clone(),
trade_id: leg.trade_id.value(),
position_id: position_id.value(),
order_id,
fill_seq,
});
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "one argument per terminal-row signal (contract, step, ts, leg id, closed size, snapshot mark, multiplier, reason); the collector centralises the terminal-row bookkeeping in one place"
)]
pub fn record_close_terminal(
&mut self,
contract: &ContractKey,
step: u32,
ts_ns: i64,
position_id: PositionId,
closed_quantity: u32,
snapshot_mark: Option<PriceCents>,
contract_multiplier: u32,
exit_reason: ExitReason,
) -> Result<(), BacktestError> {
let leg = *self.open_legs.get(&position_id).ok_or_else(|| {
BacktestError::Execution(format!(
"bundle terminal-row for position {} which is not open",
position_id.value()
))
})?;
let (mark, stale) = match snapshot_mark {
Some(mid) => (mid, false),
None => (leg.entry_premium, true),
};
let unrealized = unrealized_cents(
mark,
leg.entry_premium,
closed_quantity,
contract_multiplier,
leg.side,
)?;
self.positions.push(PositionSnapshot {
step,
ts_ns,
position_id: position_id.value(),
trade_id: leg.trade_id.value(),
contract: contract.clone(),
side: leg.side,
quantity: closed_quantity,
avg_price_cents: leg.entry_premium.value(),
mark_cents: mark.value(),
unrealized_cents: unrealized,
stale_mark: stale,
exit_reason: Some(exit_reason),
open_at_end: false,
});
self.open_legs.remove(&position_id);
Ok(())
}
pub fn collect_step(
&mut self,
step: u32,
ts_ns: i64,
marks: &[PositionMark],
open_at_end: bool,
) -> Result<(), BacktestError> {
for mark in marks {
let leg = *self.open_legs.get(&mark.position_id).ok_or_else(|| {
BacktestError::Execution(format!(
"position snapshot for leg {} has no recorded open",
mark.position_id.value()
))
})?;
let unrealized = unrealized_cents(
mark.mark,
leg.entry_premium,
mark.quantity.value(),
mark.contract_multiplier,
mark.side,
)?;
self.positions.push(PositionSnapshot {
step,
ts_ns,
position_id: mark.position_id.value(),
trade_id: leg.trade_id.value(),
contract: mark.contract.clone(),
side: mark.side,
quantity: mark.quantity.value(),
avg_price_cents: leg.entry_premium.value(),
mark_cents: mark.mark.value(),
unrealized_cents: unrealized,
stale_mark: mark.stale_mark,
exit_reason: None,
open_at_end,
});
}
Ok(())
}
#[must_use]
pub fn into_parts(self) -> (Vec<FillRecord>, Vec<PositionSnapshot>) {
(self.fills, self.positions)
}
}
fn unrealized_cents(
mark: PriceCents,
entry: PriceCents,
quantity: u32,
contract_multiplier: u32,
side: Side,
) -> Result<i64, BacktestError> {
let diff = i128::from(mark.value())
.checked_sub(i128::from(entry.value()))
.ok_or(BacktestError::ArithmeticOverflow)?;
let scaled = diff
.checked_mul(i128::from(quantity))
.and_then(|v| v.checked_mul(i128::from(contract_multiplier)))
.and_then(|v| v.checked_mul(i128::from(sign_convention::side_sign(side))))
.ok_or(BacktestError::ArithmeticOverflow)?;
i64::try_from(scaled).map_err(|_| BacktestError::ArithmeticOverflow)
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use optionstratlib::backtesting::ExitReason;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::{BundleCollector, unrealized_cents};
use crate::domain::{
Cents, ContractKey, ExecutionMode, Fill, PositionId, PriceCents, Quantity, TradeId,
Underlying,
};
use crate::engine::ledger::PositionMark;
const TS0: i64 = 1_750_291_200_000_000_000;
const NANOS_PER_DAY: i64 = 86_400_000_000_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 valid");
};
q
}
fn key(strike: u64) -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
TS0 + 30 * NANOS_PER_DAY,
)),
strike: PriceCents::new(strike),
style: OptionStyle::Call,
}
}
fn open_fill(price: u64, step: u32) -> Fill {
Fill {
ts: crate::domain::SimTime::new(TS0 + i64::from(step) * NANOS_PER_DAY),
step: crate::domain::StepIndex::new(step),
contract: key(510_000),
side: Side::Short,
quantity: qty(2),
price: PriceCents::new(price),
fees: Cents::new(65),
slippage: Cents::new(0),
mode: ExecutionMode::Naive,
}
}
fn close_fill(price: u64, step: u32, quantity: u32) -> Fill {
Fill {
ts: crate::domain::SimTime::new(TS0 + i64::from(step) * NANOS_PER_DAY),
step: crate::domain::StepIndex::new(step),
contract: key(510_000),
side: Side::Long, quantity: qty(quantity),
price: PriceCents::new(price),
fees: Cents::new(65),
slippage: Cents::new(0),
mode: ExecutionMode::Naive,
}
}
fn mark(mark_cents: u64, quantity: u32, stale: bool) -> PositionMark {
PositionMark {
position_id: PositionId::new(1),
contract: key(510_000),
side: Side::Short,
quantity: qty(quantity),
contract_multiplier: 100,
mark: PriceCents::new(mark_cents),
stale_mark: stale,
prior_greeks: None,
}
}
#[test]
fn test_unrealized_cents_short_leg_mark_below_entry_is_a_gain() {
let pnl = unrealized_cents(
PriceCents::new(1_500),
PriceCents::new(2_000),
2,
100,
Side::Short,
);
assert!(matches!(pnl, Ok(100_000)));
}
#[test]
fn test_record_open_pushes_one_fill_and_no_position_row() {
let mut collector = BundleCollector::with_capacity(4);
collector.record_open_fill(
&open_fill(2_000, 0),
1,
TradeId::new(1),
PositionId::new(1),
0,
);
let (fills, positions) = collector.into_parts();
assert_eq!(fills.len(), 1);
assert!(
positions.is_empty(),
"an open pushes no position row itself"
);
let Some(record) = fills.first() else {
panic!("one fill record");
};
assert_eq!(record.trade_id, 1);
assert_eq!(record.position_id, 1);
assert_eq!(record.order_id, 1);
assert_eq!(record.fill_seq, 0);
}
#[test]
fn test_collect_step_emits_open_row_per_marked_leg() {
let mut collector = BundleCollector::with_capacity(4);
collector.register_open_leg(
PositionId::new(1),
TradeId::new(7),
PriceCents::new(2_000),
Side::Short,
);
collector.record_open_fill(
&open_fill(2_000, 0),
1,
TradeId::new(7),
PositionId::new(1),
0,
);
let Ok(()) = collector.collect_step(0, TS0, &[mark(1_900, 2, false)], false) else {
panic!("collect step");
};
let (_fills, positions) = collector.into_parts();
assert_eq!(positions.len(), 1);
let Some(row) = positions.first() else {
panic!("one open row");
};
assert_eq!(row.position_id, 1);
assert_eq!(row.trade_id, 7);
assert_eq!(row.avg_price_cents, 2_000);
assert_eq!(row.mark_cents, 1_900);
assert_eq!(row.unrealized_cents, 20_000);
assert!(row.exit_reason.is_none(), "open rows have no exit reason");
assert!(!row.open_at_end);
}
#[test]
fn test_full_close_writes_terminal_row_with_exit_reason() {
let mut collector = BundleCollector::with_capacity(4);
collector.register_open_leg(
PositionId::new(1),
TradeId::new(7),
PriceCents::new(2_000),
Side::Short,
);
collector.record_open_fill(
&open_fill(2_000, 0),
1,
TradeId::new(7),
PositionId::new(1),
0,
);
let cf = close_fill(1_750, 3, 2);
let Ok(()) = collector.record_close_fill(&cf, 2, PositionId::new(1), 0) else {
panic!("record close fill");
};
let Ok(()) = collector.record_close_terminal(
&cf.contract,
cf.step.value(),
cf.ts.value(),
PositionId::new(1),
cf.quantity.value(),
Some(PriceCents::new(1_800)),
100,
ExitReason::Other("end_of_data".to_string()),
) else {
panic!("record close terminal");
};
let Ok(()) = collector.collect_step(3, TS0, &[], true) else {
panic!("collect step after close");
};
let (fills, positions) = collector.into_parts();
assert_eq!(fills.len(), 2, "one open + one close fill");
assert_eq!(positions.len(), 1, "exactly one terminal row");
let Some(term) = positions.first() else {
panic!("terminal row");
};
assert_eq!(term.step, 3);
assert_eq!(term.quantity, 2, "contracts closed before the close");
assert_eq!(
term.mark_cents, 1_800,
"the snapshot mid, not the fill price"
);
assert_eq!(
term.side,
Side::Short,
"the leg's opened side, not the close"
);
assert_eq!(term.unrealized_cents, 40_000);
assert!(!term.open_at_end);
assert!(matches!(
term.exit_reason.as_ref(),
Some(ExitReason::Other(reason)) if reason == "end_of_data"
));
}
#[test]
fn test_partial_close_writes_no_terminal_row_and_leg_stays_open() {
let mut collector = BundleCollector::with_capacity(4);
collector.register_open_leg(
PositionId::new(1),
TradeId::new(7),
PriceCents::new(2_000),
Side::Short,
);
collector.record_open_fill(
&open_fill(2_000, 0),
1,
TradeId::new(7),
PositionId::new(1),
0,
);
let Ok(()) =
collector.record_close_fill(&close_fill(1_900, 2, 1), 2, PositionId::new(1), 0)
else {
panic!("partial close");
};
let Ok(()) = collector.collect_step(2, TS0, &[mark(1_900, 1, false)], false) else {
panic!("collect step");
};
let (fills, positions) = collector.into_parts();
assert_eq!(fills.len(), 2);
assert_eq!(positions.len(), 1, "only the surviving-leg open row");
let Some(row) = positions.first() else {
panic!("open row");
};
assert!(
row.exit_reason.is_none(),
"a partial close writes no terminal row"
);
assert_eq!(row.quantity, 1, "the reduced open size");
}
#[test]
fn test_open_at_end_flag_set_on_final_step_open_rows() {
let mut collector = BundleCollector::with_capacity(4);
collector.register_open_leg(
PositionId::new(1),
TradeId::new(7),
PriceCents::new(2_000),
Side::Short,
);
collector.record_open_fill(
&open_fill(2_000, 0),
1,
TradeId::new(7),
PositionId::new(1),
0,
);
let Ok(()) = collector.collect_step(5, TS0, &[mark(2_100, 2, false)], true) else {
panic!("collect final step");
};
let (_fills, positions) = collector.into_parts();
let Some(row) = positions.first() else {
panic!("open row");
};
assert!(row.open_at_end, "a leg open at feed exhaustion is flagged");
assert!(
row.exit_reason.is_none(),
"no terminal row for an open_at_end leg"
);
}
#[test]
fn test_reserve_grows_capacity_without_changing_len() {
let mut collector = BundleCollector::with_capacity(0);
collector.reserve(4, 64);
assert!(collector.positions.capacity() >= 64 * 4);
assert_eq!(collector.positions.len(), 0);
assert_eq!(collector.fills.len(), 0);
}
}