use std::collections::BTreeMap;
use optionstratlib::Side;
use optionstratlib::backtesting::ExitReason;
use crate::domain::{Cents, ContractKey, PositionId, PriceCents, Quantity, TradeId};
use crate::error::BacktestError;
#[derive(Debug, Clone, PartialEq)]
pub struct ClosedTrade {
pub trade_id: TradeId,
pub position_id: PositionId,
pub contract: ContractKey,
pub side: Side,
pub quantity: Quantity,
pub contract_multiplier: u32,
pub entry_premium: PriceCents,
pub exit_price: PriceCents,
pub close_fees: Cents,
pub close_slippage: Cents,
pub realized_pnl: Cents,
pub entry_ts: i64,
pub exit_ts: i64,
pub exit_reason: ExitReason,
}
#[derive(Debug, Clone)]
struct OpenLeg {
trade_id: TradeId,
contract: ContractKey,
side: Side,
entry_premium: PriceCents,
entry_ts: i64,
remaining: u32,
}
#[derive(Debug)]
pub(crate) struct TradeLogCollector {
open: BTreeMap<PositionId, OpenLeg>,
closed: Vec<ClosedTrade>,
}
impl TradeLogCollector {
#[must_use]
pub fn with_capacity(open_capacity: usize) -> Self {
Self {
open: BTreeMap::new(),
closed: Vec::with_capacity(open_capacity),
}
}
pub fn reserve(&mut self, expected_closes: usize) {
if expected_closes > self.closed.capacity() {
self.closed.reserve(expected_closes - self.closed.len());
}
}
#[allow(
clippy::too_many_arguments,
reason = "one argument per open-time signal (ids, contract, side, size, premium, ts); the collector centralises leg bookkeeping in one place"
)]
pub fn record_open(
&mut self,
position_id: PositionId,
trade_id: TradeId,
contract: ContractKey,
side: Side,
quantity: Quantity,
entry_premium: PriceCents,
entry_ts: i64,
) {
self.open.insert(
position_id,
OpenLeg {
trade_id,
contract,
side,
entry_premium,
entry_ts,
remaining: quantity.value(),
},
);
}
pub fn record_open_extend(
&mut self,
position_id: PositionId,
added: Quantity,
new_entry_premium: PriceCents,
) -> Result<(), BacktestError> {
let Some(leg) = self.open.get_mut(&position_id) else {
return Err(BacktestError::Execution(format!(
"open-extend targets position {} which is not tracked",
position_id.value()
)));
};
leg.remaining = leg
.remaining
.checked_add(added.value())
.ok_or(BacktestError::ArithmeticOverflow)?;
leg.entry_premium = new_entry_premium;
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "one argument per close-time signal (id, price, fees, slippage, ts, size, reason); the collector centralises the realised-P&L math in one place"
)]
pub fn record_close(
&mut self,
position_id: PositionId,
exit_price: PriceCents,
close_fees: Cents,
close_slippage: Cents,
exit_ts: i64,
quantity: Quantity,
contract_multiplier: u32,
exit_reason: ExitReason,
) -> Result<(), BacktestError> {
let leg = self.open.get_mut(&position_id).ok_or_else(|| {
BacktestError::Execution(format!(
"trade-log close targets position {} which is not open",
position_id.value()
))
})?;
let realized_pnl = realized_pnl_cents(
leg.side,
leg.entry_premium,
exit_price,
quantity,
contract_multiplier,
close_fees,
)?;
self.closed.push(ClosedTrade {
trade_id: leg.trade_id,
position_id,
contract: leg.contract.clone(),
side: leg.side,
quantity,
contract_multiplier,
entry_premium: leg.entry_premium,
exit_price,
close_fees,
close_slippage,
realized_pnl,
entry_ts: leg.entry_ts,
exit_ts,
exit_reason,
});
let closed = quantity.value();
if closed >= leg.remaining {
self.open.remove(&position_id);
} else {
leg.remaining = leg
.remaining
.checked_sub(closed)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
Ok(())
}
#[must_use]
pub fn into_log(self) -> Vec<ClosedTrade> {
self.closed
}
}
fn realized_pnl_cents(
side: Side,
entry_premium: PriceCents,
exit_price: PriceCents,
quantity: Quantity,
contract_multiplier: u32,
close_fees: Cents,
) -> Result<Cents, BacktestError> {
let entry = i128::from(entry_premium.value());
let exit = i128::from(exit_price.value());
let per_contract = match side {
Side::Long => exit
.checked_sub(entry)
.ok_or(BacktestError::ArithmeticOverflow)?,
Side::Short => entry
.checked_sub(exit)
.ok_or(BacktestError::ArithmeticOverflow)?,
};
let gross = per_contract
.checked_mul(i128::from(quantity.value()))
.and_then(|g| g.checked_mul(i128::from(contract_multiplier)))
.ok_or(BacktestError::ArithmeticOverflow)?;
let net = gross
.checked_sub(i128::from(close_fees.value()))
.ok_or(BacktestError::ArithmeticOverflow)?;
let net = i64::try_from(net).map_err(|_| BacktestError::ArithmeticOverflow)?;
Ok(Cents::new(net))
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use optionstratlib::backtesting::ExitReason;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::{TradeLogCollector, realized_pnl_cents};
use crate::domain::{
Cents, ContractKey, PositionId, PriceCents, Quantity, TradeId, Underlying,
};
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, style: OptionStyle) -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
TS0 + 30 * NANOS_PER_DAY,
)),
strike: PriceCents::new(strike),
style,
}
}
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is valid");
};
q
}
#[test]
fn test_realized_pnl_short_leg_bought_back_cheaper_is_a_gain() {
let pnl = realized_pnl_cents(
Side::Short,
PriceCents::new(200),
PriceCents::new(150),
qty(1),
100,
Cents::new(65),
);
assert!(matches!(pnl, Ok(c) if c.value() == 4_935));
}
#[test]
fn test_realized_pnl_long_leg_sold_lower_is_a_loss() {
let pnl = realized_pnl_cents(
Side::Long,
PriceCents::new(200),
PriceCents::new(150),
qty(2),
100,
Cents::new(30),
);
assert!(matches!(pnl, Ok(c) if c.value() == -10_030));
}
#[test]
fn test_collector_pairs_open_and_close_into_realised_trade() {
let mut collector = TradeLogCollector::with_capacity(4);
collector.reserve(4);
collector.record_open(
PositionId::new(1),
TradeId::new(1),
key(510_000, OptionStyle::Call),
Side::Short,
qty(1),
PriceCents::new(2_000),
TS0,
);
let Ok(()) = collector.record_close(
PositionId::new(1),
PriceCents::new(1_500),
Cents::new(65),
Cents::new(0),
TS0 + NANOS_PER_DAY,
qty(1),
100,
ExitReason::TargetReached,
) else {
panic!("close pairs with the open");
};
let log = collector.into_log();
assert_eq!(log.len(), 1);
let Some(trade) = log.first() else {
panic!("one closed trade");
};
assert_eq!(trade.position_id, PositionId::new(1));
assert_eq!(trade.trade_id, TradeId::new(1));
assert_eq!(trade.side, Side::Short);
assert_eq!(trade.realized_pnl, Cents::new(49_935));
assert_eq!(trade.entry_ts, TS0);
assert_eq!(trade.exit_ts, TS0 + NANOS_PER_DAY);
assert_eq!(trade.exit_reason, ExitReason::TargetReached);
}
#[test]
fn test_collector_partial_close_keeps_leg_open_then_full_close_removes_it() {
let mut collector = TradeLogCollector::with_capacity(1);
collector.record_open(
PositionId::new(7),
TradeId::new(3),
key(490_000, OptionStyle::Put),
Side::Short,
qty(3),
PriceCents::new(1_000),
TS0,
);
let Ok(()) = collector.record_close(
PositionId::new(7),
PriceCents::new(900),
Cents::new(10),
Cents::new(0),
TS0 + NANOS_PER_DAY,
qty(1),
100,
ExitReason::ManualClose,
) else {
panic!("partial close records a trade");
};
let Ok(()) = collector.record_close(
PositionId::new(7),
PriceCents::new(800),
Cents::new(20),
Cents::new(0),
TS0 + 2 * NANOS_PER_DAY,
qty(2),
100,
ExitReason::Expiration,
) else {
panic!("full close records a trade");
};
let log = collector.into_log();
assert_eq!(log.len(), 2, "each close event is one record");
let mut empty = collector_after(&log);
let err = empty.record_close(
PositionId::new(7),
PriceCents::new(800),
Cents::new(0),
Cents::new(0),
TS0,
qty(1),
100,
ExitReason::Expiration,
);
assert!(matches!(
err,
Err(crate::error::BacktestError::Execution(_))
));
}
fn collector_after(_log: &[super::ClosedTrade]) -> TradeLogCollector {
TradeLogCollector::with_capacity(0)
}
}