use std::collections::BTreeMap;
use optionstratlib::Side;
use rust_decimal::Decimal;
use crate::domain::execution::sign_convention;
use crate::domain::{
Cents, ChainSnapshot, ContractKey, EquityPoint, Fill, OpenPosition, PositionId, PriceCents,
Quantity, SimTime, StepIndex,
};
use crate::error::BacktestError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitGreeks {
pub delta: Decimal,
pub gamma: Decimal,
pub theta: Decimal,
pub vega: Decimal,
pub implied_volatility: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PositionMark {
pub position_id: PositionId,
pub contract: ContractKey,
pub side: Side,
pub quantity: Quantity,
pub contract_multiplier: u32,
pub mark: PriceCents,
pub stale_mark: bool,
pub prior_greeks: Option<UnitGreeks>,
}
#[derive(Debug, Clone)]
pub struct Ledger {
cash: Cents,
running_peak: i64,
marks: BTreeMap<ContractKey, PriceCents>,
position_marks: Vec<PositionMark>,
attribution_marks: Vec<PositionMark>,
greeks: BTreeMap<ContractKey, UnitGreeks>,
prev_equity: i64,
step_slippage: i128,
step_fees: i128,
last_spread_capture: Cents,
last_fees: Cents,
last_step_pnl: Cents,
}
impl Ledger {
#[must_use]
pub fn new(initial_capital: Cents) -> Self {
Self {
cash: initial_capital,
running_peak: initial_capital.value(),
marks: BTreeMap::new(),
position_marks: Vec::new(),
attribution_marks: Vec::new(),
greeks: BTreeMap::new(),
prev_equity: initial_capital.value(),
step_slippage: 0,
step_fees: 0,
last_spread_capture: Cents::new(0),
last_fees: Cents::new(0),
last_step_pnl: Cents::new(0),
}
}
#[must_use]
pub const fn cash(&self) -> Cents {
self.cash
}
#[must_use]
pub const fn marks(&self) -> &BTreeMap<ContractKey, PriceCents> {
&self.marks
}
#[must_use]
pub fn position_marks(&self) -> &[PositionMark] {
&self.position_marks
}
#[must_use]
pub fn attribution_marks(&self) -> &[PositionMark] {
&self.attribution_marks
}
pub fn collect_attribution_marks(
&mut self,
begin_of_step: &[OpenPosition],
contract_multiplier: u32,
) {
self.attribution_marks.clear();
for leg in begin_of_step {
let prior_greeks = self.greeks.get(&leg.contract).copied();
self.attribution_marks.push(PositionMark {
position_id: leg.position_id,
contract: leg.contract.clone(),
side: leg.side,
quantity: leg.quantity,
contract_multiplier,
mark: leg.entry_premium,
stale_mark: false,
prior_greeks,
});
}
}
#[must_use]
pub const fn spread_capture(&self) -> Cents {
self.last_spread_capture
}
#[must_use]
pub const fn fees(&self) -> Cents {
self.last_fees
}
#[must_use]
pub const fn step_pnl(&self) -> Cents {
self.last_step_pnl
}
pub fn apply_fill(
&mut self,
fill: &Fill,
contract_multiplier: u32,
) -> Result<(), BacktestError> {
let gross = i128::from(fill.price.value())
.checked_mul(i128::from(fill.quantity.value()))
.and_then(|g| g.checked_mul(i128::from(contract_multiplier)))
.ok_or(BacktestError::ArithmeticOverflow)?;
let signed_flow = gross
.checked_mul(-i128::from(sign_convention::side_sign(fill.side)))
.ok_or(BacktestError::ArithmeticOverflow)?;
let net = signed_flow
.checked_sub(i128::from(fill.fees.value()))
.ok_or(BacktestError::ArithmeticOverflow)?;
let net = i64::try_from(net).map_err(|_| BacktestError::ArithmeticOverflow)?;
self.cash = self.cash.checked_add(Cents::new(net))?;
let scaled_slippage = i128::from(fill.slippage.value())
.checked_mul(i128::from(contract_multiplier))
.ok_or(BacktestError::ArithmeticOverflow)?;
self.step_slippage = self
.step_slippage
.checked_add(scaled_slippage)
.ok_or(BacktestError::ArithmeticOverflow)?;
self.step_fees = self
.step_fees
.checked_add(i128::from(fill.fees.value()))
.ok_or(BacktestError::ArithmeticOverflow)?;
Ok(())
}
pub fn settle(
&mut self,
step: StepIndex,
ts: SimTime,
open: &[OpenPosition],
snapshot: &ChainSnapshot,
) -> Result<EquityPoint, BacktestError> {
let contract_multiplier = snapshot.spec.contract_multiplier;
let multiplier = i128::from(contract_multiplier);
self.position_marks.clear();
let mut position_value: i128 = 0;
for leg in open {
let (mark, stale) = self.resolve_mark(leg, snapshot)?;
let prior_greeks = self.greeks.get(&leg.contract).copied();
self.position_marks.push(PositionMark {
position_id: leg.position_id,
contract: leg.contract.clone(),
side: leg.side,
quantity: leg.quantity,
contract_multiplier,
mark,
stale_mark: stale,
prior_greeks,
});
let leg_value = i128::from(mark.value())
.checked_mul(i128::from(leg.quantity.value()))
.and_then(|v| v.checked_mul(multiplier))
.and_then(|v| v.checked_mul(i128::from(sign_convention::side_sign(leg.side))))
.ok_or(BacktestError::ArithmeticOverflow)?;
position_value = position_value
.checked_add(leg_value)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
let position_value_cents =
i64::try_from(position_value).map_err(|_| BacktestError::ArithmeticOverflow)?;
let equity_cents = self
.cash
.value()
.checked_add(position_value_cents)
.ok_or(BacktestError::ArithmeticOverflow)?;
if equity_cents > self.running_peak {
self.running_peak = equity_cents;
}
let drawdown = self.drawdown(equity_cents);
self.last_step_pnl = Cents::new(
equity_cents
.checked_sub(self.prev_equity)
.ok_or(BacktestError::ArithmeticOverflow)?,
);
self.prev_equity = equity_cents;
let spread_capture = self
.step_slippage
.checked_neg()
.ok_or(BacktestError::ArithmeticOverflow)?;
self.last_spread_capture = Cents::new(
i64::try_from(spread_capture).map_err(|_| BacktestError::ArithmeticOverflow)?,
);
self.last_fees = Cents::new(
i64::try_from(self.step_fees).map_err(|_| BacktestError::ArithmeticOverflow)?,
);
self.step_slippage = 0;
self.step_fees = 0;
for leg in open {
if let Some(quote) = snapshot.quotes.get(&leg.contract) {
let current = UnitGreeks {
delta: quote.delta,
gamma: quote.gamma,
theta: quote.theta,
vega: quote.vega,
implied_volatility: quote.implied_volatility,
};
match self.greeks.get_mut(&leg.contract) {
Some(slot) => *slot = current,
None => {
self.greeks.insert(leg.contract.clone(), current);
}
}
}
}
Ok(EquityPoint::new(
step.value(),
ts.value(),
self.cash.value(),
position_value_cents,
equity_cents,
drawdown,
))
}
fn resolve_mark(
&mut self,
leg: &OpenPosition,
snapshot: &ChainSnapshot,
) -> Result<(PriceCents, bool), BacktestError> {
if let Some(quote) = snapshot.quotes.get(&leg.contract) {
match self.marks.get_mut(&leg.contract) {
Some(slot) => *slot = quote.mid,
None => {
self.marks.insert(leg.contract.clone(), quote.mid);
}
}
return Ok((quote.mid, false));
}
let expiration_ns = leg.contract.expiration_ns()?;
if snapshot.ts.value() >= expiration_ns {
return Err(BacktestError::DataOutOfOrder {
step: snapshot.step.value(),
ts: expiration_ns,
prev: snapshot.ts.value(),
});
}
let mark = self
.marks
.get(&leg.contract)
.copied()
.unwrap_or(leg.entry_premium);
Ok((mark, true))
}
#[allow(
clippy::cast_precision_loss,
reason = "drawdown is the one documented analytic float; f64 is its wire type (docs/01 §9)"
)]
fn drawdown(&self, equity_cents: i64) -> f64 {
if self.running_peak <= 0 {
return 0.0;
}
(equity_cents as f64 - self.running_peak as f64) / self.running_peak as f64
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use rust_decimal_macros::dec;
use super::{Ledger, UnitGreeks};
use crate::domain::{
Cents, ChainSnapshot, ContractKey, ExecutionMode, Fill, InstrumentSpec, OpenPosition,
PositionId, PriceCents, Quantity, QuoteView, SimTime, StepIndex, Underlying,
};
use crate::error::BacktestError;
const TS0: i64 = 1_750_291_200_000_000_000;
const NANOS_PER_DAY: i64 = 86_400_000_000_000;
const EXPIRY: i64 = TS0 + 30 * NANOS_PER_DAY;
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 key(strike: u64, style: OptionStyle) -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(EXPIRY)),
strike: PriceCents::new(strike),
style,
}
}
fn quote(strike: u64, style: OptionStyle, mid: u64) -> QuoteView {
debug_assert!(mid >= 1, "quote fixtures use a mid of at least 1c");
QuoteView {
contract: key(strike, style),
bid: PriceCents::new(mid - 1),
ask: PriceCents::new(mid + 1),
mid: PriceCents::new(mid),
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(step: u32, contract_mid: Option<(u64, OptionStyle, u64)>) -> ChainSnapshot {
let mut quotes = BTreeMap::new();
if let Some((strike, style, mid)) = contract_mid {
let q = quote(strike, style, mid);
quotes.insert(q.contract.clone(), q);
}
let Ok(spec) = InstrumentSpec::new(PriceCents::new(1), 100) else {
panic!("valid spec");
};
ChainSnapshot {
ts: SimTime::new(TS0 + i64::from(step)),
step: StepIndex::new(step),
underlying: und(),
underlying_price: PriceCents::new(500_000),
spec,
quotes,
}
}
fn short_call(mid_entry: u64) -> OpenPosition {
OpenPosition {
position_id: PositionId::new(1),
contract: key(510_000, OptionStyle::Call),
side: Side::Short,
quantity: qty(1),
entry_premium: PriceCents::new(mid_entry),
}
}
fn sell_fill(price: u64, fees: i64) -> Fill {
Fill {
ts: SimTime::new(TS0),
step: StepIndex::new(0),
contract: key(510_000, OptionStyle::Call),
side: Side::Short,
quantity: qty(1),
price: PriceCents::new(price),
fees: Cents::new(fees),
slippage: Cents::new(0),
mode: ExecutionMode::Naive,
}
}
fn sell_fill_signals(price: u64, fees: i64, slippage: i64) -> Fill {
let mut fill = sell_fill(price, fees);
fill.slippage = Cents::new(slippage);
fill
}
fn short_call_qty(mid_entry: u64, n: u32) -> OpenPosition {
OpenPosition {
position_id: PositionId::new(1),
contract: key(510_000, OptionStyle::Call),
side: Side::Short,
quantity: qty(n),
entry_premium: PriceCents::new(mid_entry),
}
}
fn default_unit_greeks() -> UnitGreeks {
UnitGreeks {
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
implied_volatility: dec!(0.2),
}
}
#[test]
fn test_ledger_sell_credits_cash_minus_fees() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let result = ledger.apply_fill(&sell_fill(200, 65), 100);
assert!(matches!(result, Ok(())));
assert_eq!(ledger.cash().value(), 10_000_000 + 20_000 - 65);
}
#[test]
fn test_ledger_settle_short_leg_marks_as_liability() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let Ok(()) = ledger.apply_fill(&sell_fill(200, 0), 100) else {
panic!("apply fill");
};
let snap = snapshot(0, Some((510_000, OptionStyle::Call, 200)));
let point = ledger.settle(StepIndex::new(0), snap.ts, &[short_call(200)], &snap);
let Ok(point) = point else {
panic!("settle succeeds");
};
assert_eq!(point.cash_cents, 10_000_000 + 20_000);
assert_eq!(point.position_value_cents, -20_000);
assert_eq!(point.equity_cents, 10_000_000);
assert!(
(point.drawdown - 0.0).abs() < f64::EPSILON,
"fresh peak → 0 drawdown"
);
}
#[test]
fn test_ledger_drawdown_is_negative_below_peak_never_clamped() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let snap = snapshot(1, Some((510_000, OptionStyle::Call, 300)));
let point = ledger.settle(StepIndex::new(1), snap.ts, &[short_call(200)], &snap);
let Ok(point) = point else {
panic!("settle succeeds");
};
assert_eq!(point.equity_cents, 10_000_000 - 30_000);
assert!(
point.drawdown < 0.0,
"equity below peak → negative drawdown"
);
}
#[test]
fn test_ledger_carries_last_known_mark_when_contract_absent() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call(200);
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let Ok(p0) = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
) else {
panic!("settle 0");
};
assert_eq!(p0.position_value_cents, -25_000);
let snap1 = snapshot(1, None); let Ok(p1) = ledger.settle(StepIndex::new(1), snap1.ts, &[leg], &snap1) else {
panic!("settle 1");
};
assert_eq!(p1.position_value_cents, -25_000);
}
#[test]
fn test_cash_unchanged_on_revaluation_only_step() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let Ok(()) = ledger.apply_fill(&sell_fill(200, 0), 100) else {
panic!("apply fill");
};
let cash_after_fill = ledger.cash().value();
assert_eq!(cash_after_fill, 10_000_000 + 20_000);
let leg = short_call(200);
let mut prev_position_value: Option<i64> = None;
for (step, mid) in [(0u32, 200u64), (1, 300), (2, 150)] {
let snap = snapshot(step, Some((510_000, OptionStyle::Call, mid)));
let Ok(point) = ledger.settle(
StepIndex::new(step),
snap.ts,
std::slice::from_ref(&leg),
&snap,
) else {
panic!("settle {step}");
};
assert_eq!(
point.cash_cents, cash_after_fill,
"cash unchanged by revaluation at step {step}"
);
assert_eq!(ledger.cash().value(), cash_after_fill);
if let Some(prev) = prev_position_value {
assert_ne!(
prev, point.position_value_cents,
"revaluation moved position_value at step {step}"
);
}
prev_position_value = Some(point.position_value_cents);
}
}
#[test]
fn test_drawdown_below_minus_one_on_negative_equity() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let snap = snapshot(1, Some((510_000, OptionStyle::Call, 200_000)));
let Ok(point) = ledger.settle(StepIndex::new(1), snap.ts, &[short_call(200)], &snap) else {
panic!("settle succeeds");
};
assert_eq!(point.equity_cents, 10_000_000 - 20_000_000);
assert!(point.equity_cents < 0, "equity is negative");
assert!(
point.drawdown < -1.0,
"negative equity → drawdown below -1, never clamped"
);
}
#[test]
fn test_stale_mark_carries_forward() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call(200);
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let settle0 = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
);
assert!(settle0.is_ok(), "settle 0 succeeds");
let Some(mark0) = ledger.position_marks().first() else {
panic!("one leg marked at step 0");
};
assert_eq!(mark0.position_id, PositionId::new(1));
assert!(!mark0.stale_mark, "a quoted leg is not stale");
assert_eq!(mark0.mark.value(), 250);
let snap1 = snapshot(1, None); let Ok(p1) = ledger.settle(
StepIndex::new(1),
snap1.ts,
std::slice::from_ref(&leg),
&snap1,
) else {
panic!("settle 1");
};
let Some(mark1) = ledger.position_marks().first() else {
panic!("one leg marked at step 1");
};
assert!(
mark1.stale_mark,
"an absent pre-expiry leg is flagged stale"
);
assert_eq!(
mark1.mark.value(),
250,
"carries the last-known 250c, not the 200c entry"
);
assert_eq!(p1.position_value_cents, -25_000);
}
#[test]
fn test_settle_enriched_leg_valuation_surfaces_all_inputs() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call_qty(200, 3);
let snap = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let Ok(_point) = ledger.settle(
StepIndex::new(0),
snap.ts,
std::slice::from_ref(&leg),
&snap,
) else {
panic!("settle 0 succeeds");
};
let Some(mark) = ledger.position_marks().first() else {
panic!("one leg in the hand-off");
};
assert_eq!(mark.position_id, PositionId::new(1));
assert_eq!(mark.contract, key(510_000, OptionStyle::Call));
assert_eq!(mark.side, Side::Short);
assert_eq!(
mark.quantity.value(),
3,
"quantity surfaced, not pre-weighted"
);
assert_eq!(
mark.contract_multiplier, 100,
"multiplier surfaced separately from the unit Greeks"
);
assert_eq!(mark.mark.value(), 250, "resolved mark is the snapshot mid");
assert!(!mark.stale_mark, "a quoted leg is not stale");
assert!(
mark.prior_greeks.is_none(),
"step 0 has no S_-1, so the prior-Greek endpoint is absent"
);
}
#[test]
fn test_prior_greeks_are_the_previous_snapshot_endpoint() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call(200);
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let Ok(_p0) = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
) else {
panic!("settle 0");
};
let mut snap1 = snapshot(1, Some((510_000, OptionStyle::Call, 260)));
if let Some(q) = snap1.quotes.get_mut(&key(510_000, OptionStyle::Call)) {
q.delta = dec!(0.3);
}
let Ok(_p1) = ledger.settle(
StepIndex::new(1),
snap1.ts,
std::slice::from_ref(&leg),
&snap1,
) else {
panic!("settle 1");
};
let Some(mark1) = ledger.position_marks().first() else {
panic!("one leg at step 1");
};
assert_eq!(
mark1.prior_greeks,
Some(default_unit_greeks()),
"prior_greeks is S_0's unit Greeks (delta 0.5), never S_1's (0.3)"
);
}
#[test]
fn test_prior_greeks_carry_forward_through_a_stale_step() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call(200);
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let Ok(_p0) = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
) else {
panic!("settle 0");
};
let snap1 = snapshot(1, None); let Ok(_p1) = ledger.settle(
StepIndex::new(1),
snap1.ts,
std::slice::from_ref(&leg),
&snap1,
) else {
panic!("settle 1");
};
let Some(mark1) = ledger.position_marks().first() else {
panic!("one leg at step 1");
};
assert_eq!(
mark1.prior_greeks,
Some(default_unit_greeks()),
"the stale step still sees S_0's Greeks as its prior endpoint"
);
let mut snap2 = snapshot(2, Some((510_000, OptionStyle::Call, 270)));
if let Some(q) = snap2.quotes.get_mut(&key(510_000, OptionStyle::Call)) {
q.delta = dec!(0.3);
}
let Ok(_p2) = ledger.settle(
StepIndex::new(2),
snap2.ts,
std::slice::from_ref(&leg),
&snap2,
) else {
panic!("settle 2");
};
let Some(mark2) = ledger.position_marks().first() else {
panic!("one leg at step 2");
};
assert_eq!(
mark2.prior_greeks,
Some(default_unit_greeks()),
"the absent step 1 did not overwrite the retained S_0 Greeks"
);
}
#[test]
fn test_attribution_marks_include_a_leg_closed_this_step() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call(200);
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let Ok(_p0) = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
) else {
panic!("settle 0");
};
let snap1 = snapshot(1, Some((510_000, OptionStyle::Call, 260)));
ledger.collect_attribution_marks(std::slice::from_ref(&leg), 100);
let Ok(_p1) = ledger.settle(StepIndex::new(1), snap1.ts, &[], &snap1) else {
panic!("settle 1");
};
let Some(mark) = ledger.attribution_marks().first() else {
panic!("the beginning-of-step leg is in the attribution hand-off");
};
assert_eq!(mark.position_id, PositionId::new(1));
assert_eq!(
mark.prior_greeks,
Some(default_unit_greeks()),
"the closed leg carries its S_0 unit Greeks as the interval endpoint"
);
assert_eq!(mark.contract_multiplier, 100);
assert!(
ledger.position_marks().is_empty(),
"no surviving legs after the close"
);
}
#[test]
fn test_step_pnl_uses_initial_capital_baseline_at_step_zero() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let Ok(()) = ledger.apply_fill(&sell_fill(200, 0), 100) else {
panic!("apply fill: cash = 10_020_000");
};
let leg = short_call(200);
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 250)));
let Ok(p0) = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
) else {
panic!("settle 0");
};
assert_eq!(p0.equity_cents, 9_995_000);
assert_eq!(
ledger.step_pnl().value(),
9_995_000 - 10_000_000,
"step 0 baseline is the initial capital"
);
let snap1 = snapshot(1, Some((510_000, OptionStyle::Call, 300)));
let Ok(p1) = ledger.settle(
StepIndex::new(1),
snap1.ts,
std::slice::from_ref(&leg),
&snap1,
) else {
panic!("settle 1");
};
assert_eq!(p1.equity_cents, 9_990_000);
assert_eq!(
ledger.step_pnl().value(),
9_990_000 - 9_995_000,
"step 1 is the mark-to-market delta vs step 0"
);
}
#[test]
fn test_settle_aggregates_and_resets_step_fill_signals() {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let leg = short_call(200);
let Ok(()) = ledger.apply_fill(&sell_fill_signals(200, 65, 20), 100) else {
panic!("apply fill 1");
};
let Ok(()) = ledger.apply_fill(&sell_fill_signals(200, 30, -5), 100) else {
panic!("apply fill 2");
};
let snap0 = snapshot(0, Some((510_000, OptionStyle::Call, 200)));
let Ok(_p0) = ledger.settle(
StepIndex::new(0),
snap0.ts,
std::slice::from_ref(&leg),
&snap0,
) else {
panic!("settle 0");
};
assert_eq!(
ledger.spread_capture().value(),
-1_500,
"spread_capture is −Σ (slippage × multiplier), the single sign flip"
);
assert_eq!(ledger.fees().value(), 95, "fees is Σ fees, always ≥ 0");
let snap1 = snapshot(1, Some((510_000, OptionStyle::Call, 210)));
let Ok(_p1) = ledger.settle(
StepIndex::new(1),
snap1.ts,
std::slice::from_ref(&leg),
&snap1,
) else {
panic!("settle 1");
};
assert_eq!(
ledger.spread_capture().value(),
0,
"no fills → spread_capture resets to 0"
);
assert_eq!(ledger.fees().value(), 0, "no fills → fees reset to 0");
}
#[test]
fn test_held_leg_missing_at_expiry_rejected() {
let leg = short_call(200); for offset in [0i64, 1, NANOS_PER_DAY] {
let mut ledger = Ledger::new(Cents::new(10_000_000));
let mut snap = snapshot(5, None); snap.ts = SimTime::new(EXPIRY + offset);
let result = ledger.settle(snap.step, snap.ts, std::slice::from_ref(&leg), &snap);
assert!(
matches!(result, Err(BacktestError::DataOutOfOrder { .. })),
"absent at/after expiry (offset {offset}) must be rejected"
);
}
}
}