use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use optionstratlib::backtesting::ExitReason;
use optionstratlib::prelude::Positive;
use optionstratlib::simulation::{ExitPolicy, check_exit_policy};
use optionstratlib::strategies::base::{Optimizable, Positionable};
use optionstratlib::strategies::{IronCondor, ShortStrangle, Strategies};
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use crate::data::convert::{positive_from_price, snapshot_to_option_chain};
use crate::domain::{
ChainSnapshot, ContractKey, IronCondorSpec, OpenPosition, OrderCommand, OrderId, OrderIntent,
PendingOrder, PositionAction, PriceCents, Quantity, QuoteView, ShortStrangleSpec, SimTime,
StepIndex, StrategySpec, TimeInForce,
};
use crate::error::BacktestError;
pub trait PositionableStrategy: Positionable + Strategies + Optimizable {}
impl<S: Positionable + Strategies + Optimizable> PositionableStrategy for S {}
pub struct ChainContext<'a> {
pub snapshot: &'a ChainSnapshot,
pub open: &'a [OpenPosition],
pub pending: &'a [PendingOrder],
pub marks: &'a std::collections::BTreeMap<ContractKey, PriceCents>,
pub rng: &'a mut ChaCha8Rng,
pub step: StepIndex,
}
pub trait Strategy {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError>;
fn exits(
&mut self,
ctx: &ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let _ = (ctx, out);
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError>;
fn on_end(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError>;
#[must_use]
fn exit_reason(&self) -> ExitReason {
ExitReason::Other("exit_policy".to_string())
}
}
const NANOS_PER_DAY: i128 = 86_400_000_000_000;
pub struct OptStratAdapter<S: PositionableStrategy> {
inner: S,
entered: bool,
exit: ExitPolicy,
}
impl<S: PositionableStrategy> OptStratAdapter<S> {
#[must_use]
pub const fn new(inner: S, exit: ExitPolicy) -> Self {
Self {
inner,
entered: false,
exit,
}
}
#[must_use]
pub const fn has_entered(&self) -> bool {
self.entered
}
pub fn request_cancel(
&self,
ctx: &ChainContext,
order_id: OrderId,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Self::assert_owned(ctx, order_id)?;
out.push(OrderCommand::Cancel(order_id));
Ok(())
}
pub fn request_replace(
&self,
ctx: &ChainContext,
order_id: OrderId,
replacement: OrderIntent,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Self::assert_owned(ctx, order_id)?;
out.push(OrderCommand::Replace {
order_id,
replacement,
});
Ok(())
}
fn assert_owned(ctx: &ChainContext, order_id: OrderId) -> Result<(), BacktestError> {
if ctx.pending.iter().any(|p| p.order_id == order_id) {
Ok(())
} else {
Err(BacktestError::Execution(format!(
"order {} is not a resting strategy order; a strategy may only cancel or replace its own orders",
order_id.value()
)))
}
}
fn open_entries(
&mut self,
snapshot: &ChainSnapshot,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
if self.entered {
return Ok(());
}
let legs = self
.inner
.get_positions()
.map_err(|e| BacktestError::Strategy(format!("get_positions failed: {e}")))?;
let leg_count = legs.len();
for leg in legs {
let opt = &leg.option;
let strike = PriceCents::from_decimal_dollars(opt.strike_price.to_dec())?;
let style = opt.option_style;
let quote = select_leg_quote(snapshot, strike, style, opt.expiration_date)?;
out.push(OrderCommand::Submit(OrderIntent {
contract: quote.contract.clone(),
action: PositionAction::Open,
side: opt.side,
quantity: quantity_from_positive(opt.quantity)?,
limit: None,
tif: TimeInForce::Ioc,
decision_mid: quote.mid,
}));
}
if leg_count > 0 {
self.entered = true;
}
Ok(())
}
fn close_all(
open: &[OpenPosition],
pending: &[PendingOrder],
snapshot: &ChainSnapshot,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
for leg in open {
let open_qty = leg.quantity.value();
let mut scheduled = scheduled_close_qty(out, leg.position_id)?;
for pend in pending {
if let PositionAction::Close(pid) = pend.intent.action
&& pid == leg.position_id
{
scheduled = scheduled
.checked_add(pend.intent.quantity.value())
.ok_or(BacktestError::ArithmeticOverflow)?;
}
}
if scheduled >= open_qty {
continue;
}
let remaining = open_qty
.checked_sub(scheduled)
.ok_or(BacktestError::ArithmeticOverflow)?;
let quantity = Quantity::new(remaining)?;
out.push(close_command_qty(snapshot, leg, quantity));
}
Ok(())
}
fn reprice_inner(&mut self, snapshot: &ChainSnapshot) -> Result<(), BacktestError> {
let chain = snapshot_to_option_chain(snapshot)?;
self.inner
.set_underlying_price(&chain.underlying_price)
.map_err(|e| BacktestError::Strategy(format!("reprice underlying failed: {e}")))?;
let iv = *chain
.get_atm_implied_volatility()
.map_err(|e| BacktestError::Strategy(format!("atm implied volatility failed: {e}")))?;
self.inner.set_implied_volatility(&iv).map_err(|e| {
BacktestError::Strategy(format!("reprice implied volatility failed: {e}"))
})?;
Ok(())
}
}
impl OptStratAdapter<IronCondor> {
pub fn from_spec(spec: &StrategySpec, exit: ExitPolicy) -> Result<Self, BacktestError> {
match spec {
StrategySpec::IronCondor(inner) => Ok(Self::new(build_iron_condor(inner)?, exit)),
StrategySpec::ShortStrangle(_) => Err(BacktestError::Strategy(format!(
"OptStratAdapter::<IronCondor>::from_spec received a {} spec; \
build it with OptStratAdapter::<ShortStrangle>::from_spec",
spec.kind()
))),
}
}
}
impl OptStratAdapter<ShortStrangle> {
pub fn from_spec(spec: &StrategySpec, exit: ExitPolicy) -> Result<Self, BacktestError> {
match spec {
StrategySpec::ShortStrangle(inner) => Ok(Self::new(build_short_strangle(inner)?, exit)),
StrategySpec::IronCondor(_) => Err(BacktestError::Strategy(format!(
"OptStratAdapter::<ShortStrangle>::from_spec received a {} spec; \
build it with OptStratAdapter::<IronCondor>::from_spec",
spec.kind()
))),
}
}
}
fn positive_dollars(price: PriceCents) -> Result<Positive, BacktestError> {
Positive::new_decimal(price.to_decimal_dollars()).map_err(|e| {
BacktestError::Conversion(format!(
"price {} cents is not a valid positive dollar amount: {e}",
price.value()
))
})
}
fn positive_rate(value: Decimal, field: &str) -> Result<Positive, BacktestError> {
Positive::new_decimal(value)
.map_err(|e| BacktestError::Strategy(format!("{field} {value} must be non-negative: {e}")))
}
fn build_iron_condor(spec: &IronCondorSpec) -> Result<IronCondor, BacktestError> {
let quantity = Positive::new_decimal(Decimal::from(spec.quantity.value())).map_err(|e| {
BacktestError::Strategy(format!(
"iron condor quantity {} is invalid: {e}",
spec.quantity.value()
))
})?;
IronCondor::new(
spec.underlying.as_str().to_string(),
positive_dollars(spec.underlying_price)?,
positive_dollars(spec.short_call_strike)?,
positive_dollars(spec.short_put_strike)?,
positive_dollars(spec.long_call_strike)?,
positive_dollars(spec.long_put_strike)?,
spec.expiration,
positive_rate(spec.implied_volatility, "iron condor implied volatility")?,
spec.risk_free_rate,
positive_rate(spec.dividend_yield, "iron condor dividend yield")?,
quantity,
positive_dollars(spec.premium_short_call)?,
positive_dollars(spec.premium_short_put)?,
positive_dollars(spec.premium_long_call)?,
positive_dollars(spec.premium_long_put)?,
positive_dollars(spec.open_fee)?,
positive_dollars(spec.close_fee)?,
)
.map_err(|e| BacktestError::Strategy(format!("iron condor construction rejected: {e}")))
}
fn build_short_strangle(spec: &ShortStrangleSpec) -> Result<ShortStrangle, BacktestError> {
let quantity = Positive::new_decimal(Decimal::from(spec.quantity.value())).map_err(|e| {
BacktestError::Strategy(format!(
"short strangle quantity {} is invalid: {e}",
spec.quantity.value()
))
})?;
ShortStrangle::new(
spec.underlying.as_str().to_string(),
positive_dollars(spec.underlying_price)?,
positive_dollars(spec.call_strike)?,
positive_dollars(spec.put_strike)?,
spec.expiration,
positive_rate(
spec.call_implied_volatility,
"short strangle call implied volatility",
)?,
positive_rate(
spec.put_implied_volatility,
"short strangle put implied volatility",
)?,
spec.risk_free_rate,
positive_rate(spec.dividend_yield, "short strangle dividend yield")?,
quantity,
positive_dollars(spec.premium_short_call)?,
positive_dollars(spec.premium_short_put)?,
positive_dollars(spec.open_fee_short_call)?,
positive_dollars(spec.close_fee_short_call)?,
positive_dollars(spec.open_fee_short_put)?,
positive_dollars(spec.close_fee_short_put)?,
)
.map_err(|e| BacktestError::Strategy(format!("short strangle construction rejected: {e}")))
}
impl<S: PositionableStrategy> Strategy for OptStratAdapter<S> {
fn on_start(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let snapshot = ctx.snapshot;
self.open_entries(snapshot, out)
}
fn exits(
&mut self,
ctx: &ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
if ctx.open.is_empty() {
return Ok(());
}
let underlying = positive_from_price("underlying", ctx.snapshot.underlying_price)?;
if policy_reads_inner(&self.exit) {
self.reprice_inner(ctx.snapshot)?;
}
let step = ctx.step.value() as usize;
let snapshot = ctx.snapshot;
for leg in ctx.open {
let days_left = days_to_expiry(&leg.contract, snapshot.ts)?;
let is_long = matches!(leg.side, Side::Long);
let initial = leg.entry_premium.to_decimal_dollars();
let current = current_premium(snapshot, ctx.marks, leg);
if policy_triggered(
&self.exit, initial, current, step, days_left, underlying, is_long,
) {
out.push(close_command(snapshot, leg));
}
}
Ok(())
}
fn on_snapshot(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
let snapshot = ctx.snapshot;
self.open_entries(snapshot, out)
}
fn on_end(
&mut self,
ctx: &mut ChainContext,
out: &mut Vec<OrderCommand>,
) -> Result<(), BacktestError> {
Self::close_all(ctx.open, ctx.pending, ctx.snapshot, out)
}
fn exit_reason(&self) -> ExitReason {
exit_policy_to_reason(&self.exit)
}
}
#[must_use]
fn exit_policy_to_reason(policy: &ExitPolicy) -> ExitReason {
match policy {
ExitPolicy::ProfitPercent(_) => ExitReason::TargetReached,
ExitPolicy::LossPercent(_) => ExitReason::StopLoss,
ExitPolicy::Expiration | ExitPolicy::DaysToExpiration(_) => ExitReason::Expiration,
ExitPolicy::TimeSteps(_) => ExitReason::Other("time_steps".to_string()),
ExitPolicy::MinPrice(_) => ExitReason::StopLoss,
ExitPolicy::MaxPrice(_) | ExitPolicy::FixedPrice(_) => ExitReason::TargetReached,
other => ExitReason::Other(other.to_string()),
}
}
fn quantity_from_positive(value: Positive) -> Result<Quantity, BacktestError> {
let count = value.to_dec().round().to_u32().ok_or_else(|| {
BacktestError::Strategy(format!(
"leg quantity {value} is not a valid u32 contract count"
))
})?;
Quantity::new(count)
}
fn select_leg_quote(
snapshot: &ChainSnapshot,
strike: PriceCents,
style: OptionStyle,
expiration: ExpirationDate,
) -> Result<&QuoteView, BacktestError> {
let mut candidates = snapshot
.quotes
.values()
.filter(|q| q.contract.strike == strike && q.contract.style == style);
let first = candidates.next().ok_or_else(|| {
BacktestError::Execution(format!(
"no snapshot quote for leg strike {} style {style:?}",
strike.value()
))
})?;
if candidates.next().is_none() {
return Ok(first);
}
let contract = ContractKey {
underlying: snapshot.underlying.clone(),
expiration,
strike,
style,
};
snapshot.quotes.get(&contract).ok_or_else(|| {
BacktestError::Execution(format!(
"no snapshot quote for leg strike {} style {style:?} at expiration \
{expiration:?} in a multi-expiry snapshot",
strike.value()
))
})
}
#[must_use]
fn current_premium(
snapshot: &ChainSnapshot,
marks: &std::collections::BTreeMap<ContractKey, PriceCents>,
leg: &OpenPosition,
) -> Decimal {
let cents = snapshot
.quotes
.get(&leg.contract)
.map(|q| q.mid)
.or_else(|| marks.get(&leg.contract).copied())
.unwrap_or(leg.entry_premium);
cents.to_decimal_dollars()
}
#[must_use]
fn close_command(snapshot: &ChainSnapshot, leg: &OpenPosition) -> OrderCommand {
close_command_qty(snapshot, leg, leg.quantity)
}
#[must_use]
fn close_command_qty(
snapshot: &ChainSnapshot,
leg: &OpenPosition,
quantity: Quantity,
) -> OrderCommand {
let decision_mid = snapshot
.quotes
.get(&leg.contract)
.map_or(leg.entry_premium, |q| q.mid);
OrderCommand::Submit(OrderIntent {
contract: leg.contract.clone(),
action: PositionAction::Close(leg.position_id),
side: flip_side(leg.side),
quantity,
limit: None,
tif: TimeInForce::Ioc,
decision_mid,
})
}
fn scheduled_close_qty(
out: &[OrderCommand],
position_id: crate::domain::PositionId,
) -> Result<u32, BacktestError> {
let mut total: u32 = 0;
for cmd in out {
if let OrderCommand::Submit(OrderIntent {
action: PositionAction::Close(pid),
quantity,
..
}) = cmd
&& *pid == position_id
{
total = total
.checked_add(quantity.value())
.ok_or(BacktestError::ArithmeticOverflow)?;
}
}
Ok(total)
}
#[must_use]
const fn flip_side(side: Side) -> Side {
match side {
Side::Long => Side::Short,
Side::Short => Side::Long,
}
}
fn days_to_expiry(contract: &ContractKey, ts: SimTime) -> Result<Positive, BacktestError> {
let expiration_ns = contract.expiration_ns()?;
let diff = i128::from(expiration_ns) - i128::from(ts.value());
if diff <= 0 {
return Ok(Positive::ZERO);
}
let days = Decimal::from_i128_with_scale(diff, 0)
.checked_div(Decimal::from_i128_with_scale(NANOS_PER_DAY, 0))
.ok_or(BacktestError::ArithmeticOverflow)?;
Positive::new_decimal(days)
.map_err(|e| BacktestError::Strategy(format!("days-to-expiry {days} is not positive: {e}")))
}
#[must_use]
fn policy_reads_inner(policy: &ExitPolicy) -> bool {
match policy {
ExitPolicy::And(policies) | ExitPolicy::Or(policies) => {
policies.iter().any(policy_reads_inner)
}
_ => false,
}
}
#[must_use]
fn policy_triggered(
policy: &ExitPolicy,
initial_premium: Decimal,
current_premium: Decimal,
step_num: usize,
days_left: Positive,
underlying_price: Positive,
is_long: bool,
) -> bool {
match policy {
ExitPolicy::And(policies) => policies.iter().all(|p| {
policy_triggered(
p,
initial_premium,
current_premium,
step_num,
days_left,
underlying_price,
is_long,
)
}),
ExitPolicy::Or(policies) => policies.iter().any(|p| {
policy_triggered(
p,
initial_premium,
current_premium,
step_num,
days_left,
underlying_price,
is_long,
)
}),
ExitPolicy::Expiration => days_left.to_dec().is_zero(),
ExitPolicy::DeltaThreshold(_) => false,
leaf => check_exit_policy(
leaf,
initial_premium,
current_premium,
step_num,
days_left,
underlying_price,
is_long,
)
.is_some(),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::DateTime;
use rand_chacha::ChaCha8Rng;
use rand_chacha::rand_core::SeedableRng;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use optionstratlib::prelude::Positive;
use optionstratlib::simulation::ExitPolicy;
use optionstratlib::strategies::{IronCondor, ShortStrangle};
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::{
ChainContext, OptStratAdapter, PositionableStrategy, Strategy, policy_reads_inner,
policy_triggered,
};
use crate::domain::{
ChainSnapshot, ContractKey, InstrumentSpec, IronCondorSpec, OpenPosition, OrderCommand,
OrderId, OrderIntent, PendingOrder, PositionAction, PositionId, PriceCents, Quantity,
QuoteView, ShortStrangleSpec, SimTime, StepIndex, StrategySpec, Underlying,
};
use crate::error::BacktestError;
static NO_MARKS: BTreeMap<ContractKey, PriceCents> = BTreeMap::new();
fn assert_positionable_strategy<S: PositionableStrategy>() {}
#[test]
fn test_positionable_strategy_composes_three_bounds_short_strangle() {
let _bound_holds: fn() = assert_positionable_strategy::<ShortStrangle>;
}
#[test]
fn test_positionable_strategy_composes_three_bounds_iron_condor() {
let _bound_holds: fn() = assert_positionable_strategy::<IronCondor>;
}
const TS_NS: i64 = 1_750_291_200_000_000_000;
const EXP_NS: i64 = TS_NS + 30 * super::NANOS_PER_DAY as i64;
fn pos(value: Decimal) -> Positive {
let Ok(p) = Positive::new_decimal(value) else {
panic!("{value} is a valid positive");
};
p
}
fn und() -> Underlying {
let Ok(u) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
u
}
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is a valid quantity");
};
q
}
fn key(strike_cents: u64, style: OptionStyle) -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(EXP_NS)),
strike: PriceCents::new(strike_cents),
style,
}
}
fn quote(strike_cents: u64, style: OptionStyle, mid_cents: u64) -> QuoteView {
debug_assert!(mid_cents >= 10, "quote fixtures use a mid of at least 10c");
QuoteView {
contract: key(strike_cents, style),
bid: PriceCents::new(mid_cents - 10),
ask: PriceCents::new(mid_cents + 10),
mid: PriceCents::new(mid_cents),
bid_size: qty(50),
ask_size: qty(50),
implied_volatility: dec!(0.20),
delta: dec!(0.30),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.10),
}
}
fn key_at(strike_cents: u64, style: OptionStyle, exp_ns: i64) -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(exp_ns)),
strike: PriceCents::new(strike_cents),
style,
}
}
fn quote_at(strike_cents: u64, style: OptionStyle, mid_cents: u64, exp_ns: i64) -> QuoteView {
debug_assert!(mid_cents >= 10, "quote fixtures use a mid of at least 10c");
QuoteView {
contract: key_at(strike_cents, style, exp_ns),
bid: PriceCents::new(mid_cents - 10),
ask: PriceCents::new(mid_cents + 10),
mid: PriceCents::new(mid_cents),
bid_size: qty(50),
ask_size: qty(50),
implied_volatility: dec!(0.20),
delta: dec!(0.30),
gamma: dec!(0.01),
theta: dec!(-0.05),
vega: dec!(0.10),
}
}
fn snapshot(step: u32) -> ChainSnapshot {
let mut quotes = BTreeMap::new();
for (strike, style, mid) in [
(490_000u64, OptionStyle::Put, 1_800u64),
(480_000, OptionStyle::Put, 700),
(510_000, OptionStyle::Call, 2_000),
(520_000, OptionStyle::Call, 800),
] {
let q = quote(strike, style, mid);
quotes.insert(q.contract.clone(), q);
}
let Ok(spec) = InstrumentSpec::new(PriceCents::new(5), 100) else {
panic!("valid instrument spec");
};
ChainSnapshot {
ts: SimTime::new(TS_NS),
step: StepIndex::new(step),
underlying: und(),
underlying_price: PriceCents::new(500_000),
spec,
quotes,
}
}
fn iron_condor() -> IronCondor {
let Ok(condor) = IronCondor::new(
"SPX".to_string(),
pos(dec!(5000)),
pos(dec!(5100)), pos(dec!(4900)), pos(dec!(5200)), pos(dec!(4800)), ExpirationDate::DateTime(DateTime::from_timestamp_nanos(EXP_NS)),
pos(dec!(0.20)),
dec!(0.05),
Positive::ZERO,
pos(dec!(1)),
pos(dec!(20)), pos(dec!(18)), pos(dec!(8)), pos(dec!(7)), pos(dec!(0.65)),
pos(dec!(0.65)),
) else {
panic!("valid iron condor construction");
};
condor
}
fn adapter(exit: ExitPolicy) -> OptStratAdapter<IronCondor> {
OptStratAdapter::new(iron_condor(), exit)
}
fn iron_condor_spec() -> StrategySpec {
StrategySpec::IronCondor(IronCondorSpec {
underlying: und(),
underlying_price: PriceCents::new(500_000),
short_call_strike: PriceCents::new(510_000),
short_put_strike: PriceCents::new(490_000),
long_call_strike: PriceCents::new(520_000),
long_put_strike: PriceCents::new(480_000),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(EXP_NS)),
implied_volatility: dec!(0.20),
risk_free_rate: dec!(0.05),
dividend_yield: Decimal::ZERO,
quantity: qty(1),
premium_short_call: PriceCents::new(2_000),
premium_short_put: PriceCents::new(1_800),
premium_long_call: PriceCents::new(800),
premium_long_put: PriceCents::new(700),
open_fee: PriceCents::new(65),
close_fee: PriceCents::new(65),
})
}
fn adapter_from_spec(exit: ExitPolicy) -> Result<OptStratAdapter<IronCondor>, BacktestError> {
OptStratAdapter::<IronCondor>::from_spec(&iron_condor_spec(), exit)
}
fn open_legs() -> Vec<OpenPosition> {
vec![
OpenPosition {
position_id: PositionId::new(1),
contract: key(510_000, OptionStyle::Call),
side: Side::Short,
quantity: qty(1),
entry_premium: PriceCents::new(2_000),
},
OpenPosition {
position_id: PositionId::new(2),
contract: key(520_000, OptionStyle::Call),
side: Side::Long,
quantity: qty(1),
entry_premium: PriceCents::new(800),
},
OpenPosition {
position_id: PositionId::new(3),
contract: key(490_000, OptionStyle::Put),
side: Side::Short,
quantity: qty(1),
entry_premium: PriceCents::new(1_800),
},
OpenPosition {
position_id: PositionId::new(4),
contract: key(480_000, OptionStyle::Put),
side: Side::Long,
quantity: qty(1),
entry_premium: PriceCents::new(700),
},
]
}
fn short_strangle() -> ShortStrangle {
let Ok(strangle) = ShortStrangle::new(
"SPX".to_string(),
pos(dec!(5000)),
pos(dec!(5200)), pos(dec!(4800)), ExpirationDate::DateTime(DateTime::from_timestamp_nanos(EXP_NS)),
pos(dec!(0.20)), pos(dec!(0.20)), dec!(0.05),
Positive::ZERO,
pos(dec!(1)),
pos(dec!(8)), pos(dec!(7)), pos(dec!(0.65)), pos(dec!(0.65)), pos(dec!(0.65)), pos(dec!(0.65)), ) else {
panic!("valid short strangle construction");
};
strangle
}
fn strangle_adapter(exit: ExitPolicy) -> OptStratAdapter<ShortStrangle> {
OptStratAdapter::new(short_strangle(), exit)
}
fn short_strangle_spec() -> StrategySpec {
StrategySpec::ShortStrangle(ShortStrangleSpec {
underlying: und(),
underlying_price: PriceCents::new(500_000),
call_strike: PriceCents::new(520_000),
put_strike: PriceCents::new(480_000),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(EXP_NS)),
call_implied_volatility: dec!(0.20),
put_implied_volatility: dec!(0.20),
risk_free_rate: dec!(0.05),
dividend_yield: Decimal::ZERO,
quantity: qty(1),
premium_short_call: PriceCents::new(800),
premium_short_put: PriceCents::new(700),
open_fee_short_call: PriceCents::new(65),
close_fee_short_call: PriceCents::new(65),
open_fee_short_put: PriceCents::new(65),
close_fee_short_put: PriceCents::new(65),
})
}
fn strangle_adapter_from_spec(
exit: ExitPolicy,
) -> Result<OptStratAdapter<ShortStrangle>, BacktestError> {
OptStratAdapter::<ShortStrangle>::from_spec(&short_strangle_spec(), exit)
}
fn strangle_open_legs() -> Vec<OpenPosition> {
vec![
OpenPosition {
position_id: PositionId::new(1),
contract: key(520_000, OptionStyle::Call),
side: Side::Short,
quantity: qty(1),
entry_premium: PriceCents::new(800),
},
OpenPosition {
position_id: PositionId::new(2),
contract: key(480_000, OptionStyle::Put),
side: Side::Short,
quantity: qty(1),
entry_premium: PriceCents::new(700),
},
]
}
fn is_open(cmd: &OrderCommand) -> bool {
matches!(
cmd,
OrderCommand::Submit(OrderIntent {
action: PositionAction::Open,
..
})
)
}
fn is_close(cmd: &OrderCommand) -> bool {
matches!(
cmd,
OrderCommand::Submit(OrderIntent {
action: PositionAction::Close(_),
..
})
)
}
#[test]
fn test_on_snapshot_first_call_opens_four_condor_legs() {
let mut rng = ChaCha8Rng::seed_from_u64(1);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 4, "one open per condor leg");
assert!(out.iter().all(is_open), "on_snapshot appends opens only");
assert!(adapter.has_entered());
}
#[test]
fn test_on_snapshot_second_call_is_noop_via_entered_guard() {
let mut rng = ChaCha8Rng::seed_from_u64(2);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
out.clear();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert!(out.is_empty(), "entered flag prevents a second entry");
}
#[test]
fn test_short_strangle_on_snapshot_opens_two_legs_through_same_seam() {
let mut rng = ChaCha8Rng::seed_from_u64(30);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = strangle_adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 2, "one open per strangle leg (short call + put)");
assert!(out.iter().all(is_open), "on_snapshot appends opens only");
assert!(adapter.has_entered());
}
#[test]
fn test_short_strangle_second_on_snapshot_is_noop_via_entered_guard() {
let mut rng = ChaCha8Rng::seed_from_u64(31);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = strangle_adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
out.clear();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert!(out.is_empty(), "entered flag prevents a second entry");
}
#[test]
fn test_short_strangle_exits_emits_two_closes_when_policy_triggers() {
let mut rng = ChaCha8Rng::seed_from_u64(32);
let snap = snapshot(7);
let legs = strangle_open_legs();
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(7),
};
let mut adapter = strangle_adapter(ExitPolicy::TimeSteps(0));
let mut out = Vec::new();
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
assert_eq!(out.len(), 2, "one close per open leg when the policy fires");
assert!(out.iter().all(is_close), "exits appends closes only");
assert!(out.iter().all(|c| matches!(
c,
OrderCommand::Submit(OrderIntent {
side: Side::Long,
action: PositionAction::Close(_),
..
})
)));
}
#[test]
fn test_short_strangle_on_end_appends_only_closes() {
let mut rng = ChaCha8Rng::seed_from_u64(33);
let snap = snapshot(11);
let legs = strangle_open_legs();
let mut ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(11),
};
let mut adapter = strangle_adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_end(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 2);
assert!(out.iter().all(is_close), "on_end closes only");
assert!(!out.iter().any(is_open), "on_end never opens");
}
#[test]
fn test_short_strangle_satisfies_positionable_strategy_bound() {
let _bound_holds: fn() = assert_positionable_strategy::<ShortStrangle>;
assert!(strangle_adapter_from_spec(ExitPolicy::Expiration).is_ok());
}
#[test]
fn test_short_strangle_from_spec_entry_emits_two_open_intents() {
let mut rng = ChaCha8Rng::seed_from_u64(34);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let Ok(mut adapter) = strangle_adapter_from_spec(ExitPolicy::Expiration) else {
panic!("the short strangle spec builds a valid adapter");
};
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 2, "the two strangle legs emit two opens");
assert!(out.iter().all(is_open), "entry appends opens only");
assert!(adapter.has_entered());
for cmd in &out {
let OrderCommand::Submit(intent) = cmd else {
panic!("entry emits Submit intents");
};
let Some(quote) = snap.quotes.get(&intent.contract) else {
panic!("each entry leg matches a snapshot quote");
};
assert_eq!(intent.decision_mid, quote.mid);
assert_eq!(intent.side, Side::Short, "both strangle legs are short");
}
}
#[test]
fn test_from_spec_rejects_mismatched_strategy_kind() {
let strangle_from_condor = OptStratAdapter::<ShortStrangle>::from_spec(
&iron_condor_spec(),
ExitPolicy::Expiration,
);
assert!(matches!(
strangle_from_condor,
Err(BacktestError::Strategy(_))
));
let condor_from_strangle = OptStratAdapter::<IronCondor>::from_spec(
&short_strangle_spec(),
ExitPolicy::Expiration,
);
assert!(matches!(
condor_from_strangle,
Err(BacktestError::Strategy(_))
));
}
#[test]
fn test_iron_condor_satisfies_positionable_strategy_bound() {
let _bound_holds: fn() = assert_positionable_strategy::<IronCondor>;
assert!(adapter_from_spec(ExitPolicy::Expiration).is_ok());
}
#[test]
fn test_iron_condor_entry_emits_four_open_intents() {
let mut rng = ChaCha8Rng::seed_from_u64(20);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let Ok(mut adapter) = adapter_from_spec(ExitPolicy::Expiration) else {
panic!("the iron condor spec builds a valid adapter");
};
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert_eq!(
out.len(),
4,
"the four condor legs emit four opens in one step"
);
assert!(out.iter().all(is_open), "entry appends opens only");
assert!(adapter.has_entered());
for cmd in &out {
let OrderCommand::Submit(intent) = cmd else {
panic!("entry emits Submit intents");
};
let Some(quote) = snap.quotes.get(&intent.contract) else {
panic!("each entry leg matches a snapshot quote");
};
assert_eq!(intent.decision_mid, quote.mid);
}
}
#[test]
fn test_open_entries_picks_the_legs_expiration_in_a_multi_expiry_snapshot() {
const EXP2_NS: i64 = EXP_NS + 7 * super::NANOS_PER_DAY as i64;
let mut quotes = BTreeMap::new();
for (strike, style, mid) in [
(490_000u64, OptionStyle::Put, 1_800u64),
(480_000, OptionStyle::Put, 700),
(510_000, OptionStyle::Call, 2_000),
(520_000, OptionStyle::Call, 800),
] {
for exp_ns in [EXP_NS, EXP2_NS] {
let q = quote_at(strike, style, mid, exp_ns);
quotes.insert(q.contract.clone(), q);
}
}
let Ok(spec) = InstrumentSpec::new(PriceCents::new(5), 100) else {
panic!("valid instrument spec");
};
let snap = ChainSnapshot {
ts: SimTime::new(TS_NS),
step: StepIndex::new(0),
underlying: und(),
underlying_price: PriceCents::new(500_000),
spec,
quotes,
};
let mut rng = ChaCha8Rng::seed_from_u64(50);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 4, "the four condor legs emit four opens");
for cmd in &out {
let OrderCommand::Submit(intent) = cmd else {
panic!("entry emits Submit intents");
};
assert_eq!(
intent.contract.expiration_ns().ok(),
Some(EXP_NS),
"each leg targets its own expiration, never the decoy EXP2_NS"
);
}
}
#[test]
fn test_iron_condor_from_spec_exits_emits_closes_when_policy_triggers() {
let mut rng = ChaCha8Rng::seed_from_u64(21);
let snap = snapshot(7);
let legs = open_legs();
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(7),
};
let Ok(mut adapter) = adapter_from_spec(ExitPolicy::TimeSteps(0)) else {
panic!("the iron condor spec builds a valid adapter");
};
let mut out = Vec::new();
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
assert_eq!(out.len(), 4, "one close per open leg when the policy fires");
assert!(out.iter().all(is_close), "exits appends closes only");
}
#[test]
fn test_strategy_error_maps_to_backtest_error() {
let StrategySpec::IronCondor(mut inner) = iron_condor_spec() else {
panic!("iron_condor_spec builds an IronCondor spec");
};
inner.implied_volatility = dec!(-0.20);
let spec = StrategySpec::IronCondor(inner);
let result = OptStratAdapter::<IronCondor>::from_spec(&spec, ExitPolicy::Expiration);
assert!(matches!(result, Err(BacktestError::Strategy(_))));
}
#[test]
fn test_on_start_opens_then_on_snapshot_is_noop() {
let mut rng = ChaCha8Rng::seed_from_u64(3);
let snap = snapshot(0);
let mut ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_start(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 4);
out.clear();
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
assert!(
out.is_empty(),
"on_start already entered; on_snapshot no-ops"
);
}
#[test]
fn test_exits_appends_only_closes_when_policy_triggers() {
let mut rng = ChaCha8Rng::seed_from_u64(4);
let snap = snapshot(7);
let legs = open_legs();
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(7),
};
let mut adapter = adapter(ExitPolicy::TimeSteps(0));
let mut out = Vec::new();
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
assert_eq!(out.len(), 4, "one close per open leg");
assert!(out.iter().all(is_close), "exits appends closes only");
let short_call_close = out.iter().find_map(|c| match c {
OrderCommand::Submit(i) if i.action == PositionAction::Close(PositionId::new(1)) => {
Some(i.side)
}
_ => None,
});
assert!(matches!(short_call_close, Some(Side::Long)));
}
#[test]
fn test_exits_appends_nothing_when_policy_not_triggered() {
let mut rng = ChaCha8Rng::seed_from_u64(5);
let snap = snapshot(0);
let legs = open_legs();
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let mut adapter = adapter(ExitPolicy::TimeSteps(1000));
let mut out = Vec::new();
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
assert!(out.is_empty(), "no leg triggers; nothing appended");
}
#[test]
fn test_exits_empty_open_is_noop() {
let mut rng = ChaCha8Rng::seed_from_u64(6);
let snap = snapshot(3);
let ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(3),
};
let mut adapter = adapter(ExitPolicy::TimeSteps(0));
let mut out = Vec::new();
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
assert!(out.is_empty(), "no open legs: nothing to reprice or close");
}
#[test]
fn test_exits_stale_quote_reads_carried_mark_not_entry_premium() {
let Ok(spec) = InstrumentSpec::new(PriceCents::new(5), 100) else {
panic!("valid instrument spec");
};
let snap = ChainSnapshot {
ts: SimTime::new(TS_NS),
step: StepIndex::new(5),
underlying: und(),
underlying_price: PriceCents::new(500_000),
spec,
quotes: BTreeMap::new(),
};
let legs = vec![OpenPosition {
position_id: PositionId::new(1),
contract: key(510_000, OptionStyle::Call),
side: Side::Short,
quantity: qty(1),
entry_premium: PriceCents::new(2_000),
}];
let mut carried = BTreeMap::new();
carried.insert(key(510_000, OptionStyle::Call), PriceCents::new(500));
let mut rng = ChaCha8Rng::seed_from_u64(60);
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &carried,
rng: &mut rng,
step: StepIndex::new(5),
};
let mut marked_adapter = adapter(ExitPolicy::ProfitPercent(dec!(0.5)));
let mut out = Vec::new();
assert!(matches!(marked_adapter.exits(&ctx, &mut out), Ok(())));
assert_eq!(out.len(), 1, "the carried 75% profit fires the exit");
assert!(out.iter().all(is_close), "exits appends closes only");
let mut rng2 = ChaCha8Rng::seed_from_u64(61);
let ctx_no_mark = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng2,
step: StepIndex::new(5),
};
let mut unmarked_adapter = adapter(ExitPolicy::ProfitPercent(dec!(0.5)));
let mut out2 = Vec::new();
assert!(matches!(
unmarked_adapter.exits(&ctx_no_mark, &mut out2),
Ok(())
));
assert!(
out2.is_empty(),
"no carried mark → entry premium → no profit → no close"
);
}
#[test]
fn test_exits_then_on_snapshot_orders_closes_before_entries() {
let mut rng = ChaCha8Rng::seed_from_u64(7);
let snap = snapshot(9);
let legs = open_legs();
let mut adapter = adapter(ExitPolicy::TimeSteps(0));
let mut out = Vec::new();
{
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(9),
};
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
}
let mut ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(9),
};
assert!(matches!(adapter.on_snapshot(&mut ctx, &mut out), Ok(())));
let first_open = out.iter().position(is_open);
let last_close = out.iter().rposition(is_close);
assert!(matches!((last_close, first_open), (Some(lc), Some(fo)) if lc < fo));
assert_eq!(out.iter().filter(|&c| is_close(c)).count(), 4);
assert_eq!(out.iter().filter(|&c| is_open(c)).count(), 4);
}
#[test]
fn test_on_end_appends_only_closes() {
let mut rng = ChaCha8Rng::seed_from_u64(8);
let snap = snapshot(11);
let legs = open_legs();
let mut ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(11),
};
let mut adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(adapter.on_end(&mut ctx, &mut out), Ok(())));
assert_eq!(out.len(), 4);
assert!(out.iter().all(is_close), "on_end closes only");
assert!(!out.iter().any(is_open), "on_end never opens");
}
#[test]
fn test_on_end_after_exits_produces_one_close_per_leg() {
let mut rng = ChaCha8Rng::seed_from_u64(70);
let snap = snapshot(9);
let legs = open_legs();
let mut adapter = adapter(ExitPolicy::TimeSteps(0));
let mut out = Vec::new();
{
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(9),
};
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
}
assert_eq!(out.len(), 4, "the policy closes all four legs");
{
let mut ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(9),
};
assert!(matches!(adapter.on_end(&mut ctx, &mut out), Ok(())));
}
assert_eq!(out.len(), 4, "on_end appended no duplicate closes");
for pid in [1u64, 2, 3, 4] {
let count = out
.iter()
.filter(|c| {
matches!(
c,
OrderCommand::Submit(OrderIntent {
action: PositionAction::Close(p),
..
}) if p.value() == pid
)
})
.count();
assert_eq!(count, 1, "exactly one close for position {pid}");
}
}
#[test]
fn test_on_end_closes_a_leg_the_exit_phase_left_open() {
let mut rng = ChaCha8Rng::seed_from_u64(71);
let snap = snapshot(9);
let legs = open_legs();
let mut adapter = adapter(ExitPolicy::TimeSteps(1_000));
let mut out = Vec::new();
{
let ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(9),
};
assert!(matches!(adapter.exits(&ctx, &mut out), Ok(())));
}
assert!(out.is_empty(), "the policy did not fire");
{
let mut ctx = ChainContext {
snapshot: &snap,
open: &legs,
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(9),
};
assert!(matches!(adapter.on_end(&mut ctx, &mut out), Ok(())));
}
assert_eq!(out.len(), 4, "on_end flattens every still-open leg");
assert!(out.iter().all(is_close));
}
#[test]
fn test_request_cancel_foreign_order_is_execution_error() {
let mut rng = ChaCha8Rng::seed_from_u64(9);
let snap = snapshot(0);
let ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[], marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
let result = adapter.request_cancel(&ctx, OrderId::new(999), &mut out);
assert!(matches!(result, Err(BacktestError::Execution(_))));
assert!(out.is_empty(), "a foreign cancel emits nothing");
}
#[test]
fn test_request_cancel_owned_order_emits_cancel() {
let mut rng = ChaCha8Rng::seed_from_u64(10);
let snap = snapshot(0);
let resting = vec![PendingOrder {
order_id: OrderId::new(42),
intent: OrderIntent {
contract: key(510_000, OptionStyle::Call),
action: PositionAction::Open,
side: Side::Short,
quantity: qty(1),
limit: Some(PriceCents::new(2_000)),
tif: crate::domain::TimeInForce::Gtc,
decision_mid: PriceCents::new(2_000),
},
}];
let ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &resting,
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
assert!(matches!(
adapter.request_cancel(&ctx, OrderId::new(42), &mut out),
Ok(())
));
assert!(matches!(out.as_slice(), [OrderCommand::Cancel(id)] if id.value() == 42));
}
#[test]
fn test_request_replace_foreign_order_is_execution_error() {
let mut rng = ChaCha8Rng::seed_from_u64(11);
let snap = snapshot(0);
let ctx = ChainContext {
snapshot: &snap,
open: &[],
pending: &[],
marks: &NO_MARKS,
rng: &mut rng,
step: StepIndex::new(0),
};
let adapter = adapter(ExitPolicy::Expiration);
let mut out = Vec::new();
let replacement = OrderIntent {
contract: key(510_000, OptionStyle::Call),
action: PositionAction::Open,
side: Side::Short,
quantity: qty(1),
limit: Some(PriceCents::new(1_950)),
tif: crate::domain::TimeInForce::Gtc,
decision_mid: PriceCents::new(1_950),
};
let result = adapter.request_replace(&ctx, OrderId::new(7), replacement, &mut out);
assert!(matches!(result, Err(BacktestError::Execution(_))));
assert!(out.is_empty());
}
#[test]
fn test_policy_triggered_expiration_only_at_zero_days() {
assert!(policy_triggered(
&ExitPolicy::Expiration,
dec!(20),
dec!(20),
0,
Positive::ZERO,
pos(dec!(5000)),
false,
));
assert!(!policy_triggered(
&ExitPolicy::Expiration,
dec!(20),
dec!(20),
0,
pos(dec!(5)),
pos(dec!(5000)),
false,
));
}
#[test]
fn test_policy_reads_inner_is_false_for_all_v01_policies() {
for policy in [
ExitPolicy::Expiration,
ExitPolicy::DeltaThreshold(dec!(0.1)),
ExitPolicy::TimeSteps(0),
ExitPolicy::ProfitPercent(dec!(0.5)),
ExitPolicy::LossPercent(dec!(1.0)),
ExitPolicy::And(vec![
ExitPolicy::ProfitPercent(dec!(0.5)),
ExitPolicy::Expiration,
]),
ExitPolicy::Or(vec![
ExitPolicy::TimeSteps(10),
ExitPolicy::DeltaThreshold(dec!(0.2)),
]),
] {
assert!(
!policy_reads_inner(&policy),
"no v0.1 policy reads inner: {policy:?}"
);
}
}
#[test]
fn test_policy_triggered_delta_threshold_unsupported_never_triggers() {
assert!(!policy_triggered(
&ExitPolicy::DeltaThreshold(dec!(0.1)),
dec!(20),
dec!(20),
0,
Positive::ZERO,
pos(dec!(5000)),
false,
));
}
#[test]
fn test_policy_triggered_or_composes_profit_leg() {
let policy = ExitPolicy::Or(vec![
ExitPolicy::ProfitPercent(dec!(0.5)),
ExitPolicy::Expiration,
]);
assert!(policy_triggered(
&policy,
dec!(20),
dec!(8),
0,
pos(dec!(30)),
pos(dec!(5000)),
false,
));
assert!(!policy_triggered(
&policy,
dec!(20),
dec!(18),
0,
pos(dec!(30)),
pos(dec!(5000)),
false,
));
}
#[test]
fn test_exit_reason_maps_the_applied_policy_to_an_upstream_reason() {
use optionstratlib::backtesting::ExitReason;
assert_eq!(
adapter(ExitPolicy::ProfitPercent(dec!(50))).exit_reason(),
ExitReason::TargetReached
);
assert_eq!(
adapter(ExitPolicy::LossPercent(dec!(100))).exit_reason(),
ExitReason::StopLoss
);
assert_eq!(
adapter(ExitPolicy::TimeSteps(5)).exit_reason(),
ExitReason::Other("time_steps".to_string())
);
assert_eq!(
adapter(ExitPolicy::Expiration).exit_reason(),
ExitReason::Expiration
);
let composite = ExitPolicy::And(vec![ExitPolicy::Expiration]);
assert!(matches!(
adapter(composite).exit_reason(),
ExitReason::Other(_)
));
}
#[test]
fn test_close_all_skips_quantity_covered_by_pending_closes() {
let legs = open_legs();
let Some(first) = legs.first() else {
panic!("fixture has legs");
};
let pending = [crate::domain::PendingOrder {
order_id: OrderId::new(77),
intent: OrderIntent {
contract: first.contract.clone(),
action: PositionAction::Close(first.position_id),
side: Side::Long, quantity: first.quantity,
limit: Some(PriceCents::new(1)),
tif: crate::domain::TimeInForce::Gtc,
decision_mid: PriceCents::new(2_000),
},
}];
let mut out: Vec<OrderCommand> = Vec::new();
let result =
OptStratAdapter::<IronCondor>::close_all(&legs, &pending, &snapshot(9), &mut out);
assert!(matches!(result, Ok(())));
assert_eq!(out.len(), 3, "the pending-covered leg is not re-closed");
for cmd in &out {
let OrderCommand::Submit(intent) = cmd else {
panic!("close_all emits submits only");
};
let PositionAction::Close(pid) = intent.action else {
panic!("close_all emits closes only");
};
assert_ne!(pid, first.position_id, "leg 1 must not be re-closed");
}
}
}