use optionstratlib::Side;
use rust_decimal::Decimal;
use crate::domain::{Cents, ChainSnapshot};
use crate::engine::ledger::{PositionMark, UnitGreeks};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LegAttributionSample {
pub prior_greeks: Option<UnitGreeks>,
pub current_iv: Decimal,
pub quantity: u32,
pub contract_multiplier: u32,
pub side: Side,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StepAttributionScalars {
pub step: u32,
pub ts_ns: i64,
pub prior_ts_ns: i64,
pub underlying_cents: u64,
pub prior_underlying_cents: u64,
pub spread_capture_cents: i64,
pub fees_cents: i64,
pub step_pnl_cents: i64,
pub leg_count: usize,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AttributionSubstrate {
pub steps: Vec<StepAttributionScalars>,
pub legs: Vec<LegAttributionSample>,
}
#[derive(Debug)]
pub(crate) struct AttributionCollector {
steps: Vec<StepAttributionScalars>,
legs: Vec<LegAttributionSample>,
prev_ts: Option<i64>,
prev_underlying: Option<u64>,
}
impl AttributionCollector {
#[must_use]
pub fn with_capacity(step_capacity: usize) -> Self {
Self {
steps: Vec::with_capacity(step_capacity),
legs: Vec::new(),
prev_ts: None,
prev_underlying: None,
}
}
pub fn reserve_legs(&mut self, step_capacity: usize, legs_per_step: usize) {
let Some(want) = step_capacity.checked_mul(legs_per_step) else {
return;
};
if want > self.legs.capacity() {
self.legs.reserve(want - self.legs.len());
}
}
pub fn collect(
&mut self,
snapshot: &ChainSnapshot,
marks: &[PositionMark],
spread_capture: Cents,
fees: Cents,
step_pnl: Cents,
) {
let ts_ns = snapshot.ts.value();
let underlying_cents = snapshot.underlying_price.value();
let prior_ts_ns = self.prev_ts.unwrap_or(ts_ns);
let prior_underlying_cents = self.prev_underlying.unwrap_or(underlying_cents);
for mark in marks {
let current_iv = snapshot
.quotes
.get(&mark.contract)
.map(|quote| quote.implied_volatility)
.or_else(|| mark.prior_greeks.map(|g| g.implied_volatility))
.unwrap_or(Decimal::ZERO);
self.legs.push(LegAttributionSample {
prior_greeks: mark.prior_greeks,
current_iv,
quantity: mark.quantity.value(),
contract_multiplier: mark.contract_multiplier,
side: mark.side,
});
}
self.steps.push(StepAttributionScalars {
step: snapshot.step.value(),
ts_ns,
prior_ts_ns,
underlying_cents,
prior_underlying_cents,
spread_capture_cents: spread_capture.value(),
fees_cents: fees.value(),
step_pnl_cents: step_pnl.value(),
leg_count: marks.len(),
});
self.prev_ts = Some(ts_ns);
self.prev_underlying = Some(underlying_cents);
}
#[must_use]
pub fn into_substrate(self) -> AttributionSubstrate {
AttributionSubstrate {
steps: self.steps,
legs: self.legs,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use rust_decimal_macros::dec;
use super::AttributionCollector;
use crate::domain::{
Cents, ChainSnapshot, ContractKey, InstrumentSpec, PositionId, PriceCents, Quantity,
QuoteView, SimTime, StepIndex, Underlying,
};
use crate::engine::ledger::{PositionMark, UnitGreeks};
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 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 qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is valid");
};
q
}
fn snapshot(step: u32, ts: i64, underlying: u64, iv: rust_decimal::Decimal) -> ChainSnapshot {
let mut quotes = BTreeMap::new();
let quote = QuoteView {
contract: key(510_000),
bid: PriceCents::new(199),
ask: PriceCents::new(201),
mid: PriceCents::new(200),
bid_size: qty(10),
ask_size: qty(10),
implied_volatility: iv,
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
};
quotes.insert(quote.contract.clone(), quote);
let Ok(spec) = InstrumentSpec::new(PriceCents::new(1), 100) else {
panic!("valid spec");
};
ChainSnapshot {
ts: SimTime::new(ts),
step: StepIndex::new(step),
underlying: und(),
underlying_price: PriceCents::new(underlying),
spec,
quotes,
}
}
fn mark(prior: Option<UnitGreeks>) -> PositionMark {
PositionMark {
position_id: PositionId::new(1),
contract: key(510_000),
side: Side::Short,
quantity: qty(2),
contract_multiplier: 100,
mark: PriceCents::new(200),
stale_mark: false,
prior_greeks: prior,
}
}
#[test]
fn test_collect_step_zero_endpoints_equal_so_market_deltas_are_zero() {
let mut collector = AttributionCollector::with_capacity(4);
let snap = snapshot(0, TS0, 500_000, dec!(0.20));
collector.collect(
&snap,
&[mark(None)],
Cents::new(-60),
Cents::new(520),
Cents::new(1_480),
);
let sub = collector.into_substrate();
let Some(step) = sub.steps.first() else {
panic!("one step scalars");
};
assert_eq!(step.prior_ts_ns, step.ts_ns);
assert_eq!(step.prior_underlying_cents, step.underlying_cents);
assert_eq!(step.leg_count, 1);
assert_eq!(step.step_pnl_cents, 1_480);
assert_eq!(step.spread_capture_cents, -60);
assert_eq!(step.fees_cents, 520);
let Some(leg) = sub.legs.first() else {
panic!("one leg sample");
};
assert!(leg.prior_greeks.is_none());
assert_eq!(leg.quantity, 2);
assert_eq!(leg.contract_multiplier, 100);
}
#[test]
fn test_collect_tracks_prior_endpoints_across_steps() {
let mut collector = AttributionCollector::with_capacity(4);
let prior = UnitGreeks {
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
implied_volatility: dec!(0.20),
};
let snap0 = snapshot(0, TS0, 500_000, dec!(0.20));
collector.collect(
&snap0,
&[mark(None)],
Cents::new(0),
Cents::new(0),
Cents::new(0),
);
let snap1 = snapshot(1, TS0 + NANOS_PER_DAY, 500_100, dec!(0.21));
collector.collect(
&snap1,
&[mark(Some(prior))],
Cents::new(0),
Cents::new(0),
Cents::new(50),
);
let sub = collector.into_substrate();
let Some(step1) = sub.steps.get(1) else {
panic!("two step scalars");
};
assert_eq!(step1.prior_ts_ns, TS0);
assert_eq!(step1.ts_ns, TS0 + NANOS_PER_DAY);
assert_eq!(step1.prior_underlying_cents, 500_000);
assert_eq!(step1.underlying_cents, 500_100);
let Some(leg1) = sub.legs.get(1) else {
panic!("two leg samples");
};
assert_eq!(leg1.prior_greeks, Some(prior));
assert_eq!(leg1.current_iv, dec!(0.21));
}
#[test]
fn test_collect_absent_leg_carries_iv_forward_so_delta_iv_is_zero() {
let mut collector = AttributionCollector::with_capacity(2);
let prior = UnitGreeks {
delta: dec!(0.5),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.1),
implied_volatility: dec!(0.20),
};
let Ok(spec) = InstrumentSpec::new(PriceCents::new(1), 100) else {
panic!("valid spec");
};
let snap = ChainSnapshot {
ts: SimTime::new(TS0 + NANOS_PER_DAY),
step: StepIndex::new(1),
underlying: und(),
underlying_price: PriceCents::new(500_000),
spec,
quotes: BTreeMap::new(),
};
collector.collect(
&snap,
&[mark(Some(prior))],
Cents::new(0),
Cents::new(0),
Cents::new(0),
);
let sub = collector.into_substrate();
let Some(leg) = sub.legs.first() else {
panic!("one leg sample");
};
assert_eq!(leg.current_iv, dec!(0.20));
}
#[test]
fn test_reserve_legs_grows_capacity_without_changing_len() {
let mut collector = AttributionCollector::with_capacity(64);
collector.reserve_legs(64, 4);
assert!(
collector.legs.capacity() >= 256,
"leg capacity covers the steady-state run"
);
assert_eq!(collector.legs.len(), 0, "reservation does not push");
}
}