use crate::session::SimulationParametersV2;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use positive::Positive;
use rust_decimal::{Decimal, MathematicalOps};
use rust_decimal_macros::dec;
use std::sync::LazyLock;
pub(crate) const DEFAULT_SPREAD_FLOOR: Decimal = dec!(0.01);
pub(crate) const DEFAULT_TICK: Decimal = dec!(0.01);
static DEFAULT_TICK_POSITIVE: LazyLock<Positive> =
LazyLock::new(|| Positive::new_decimal(DEFAULT_TICK).unwrap_or(Positive::ZERO));
pub(crate) const MAX_PROPORTIONAL: f64 = 1.0;
pub(crate) const MAX_WIDENING: f64 = 10.0;
pub(crate) const MAX_TICK: f64 = 10.0;
const DAYS_PER_YEAR: Decimal = dec!(365);
const QUOTE_DECIMALS: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct SpreadModel {
floor: Decimal,
proportional: Decimal,
moneyness_widening: Decimal,
tenor_widening: Decimal,
tick: Positive,
}
impl SpreadModel {
#[must_use]
pub(crate) fn from_parameters(parameters: &SimulationParametersV2) -> Self {
Self {
floor: parameters
.spread
.map_or(DEFAULT_SPREAD_FLOOR, |spread| spread.to_dec()),
proportional: parameters.spread_proportional.unwrap_or(Decimal::ZERO),
moneyness_widening: parameters
.spread_moneyness_widening
.unwrap_or(Decimal::ZERO),
tenor_widening: parameters.spread_tenor_widening.unwrap_or(Decimal::ZERO),
tick: parameters.spread_tick.unwrap_or(*DEFAULT_TICK_POSITIVE),
}
}
#[must_use]
pub(crate) fn spread_for(
&self,
mid: Positive,
strike: Positive,
underlying: Positive,
days_to_expiration: Decimal,
) -> Positive {
let Some(mut spread) = self
.proportional
.checked_mul(mid.to_dec())
.and_then(|scaled| self.floor.checked_add(scaled))
else {
return self.tick;
};
if !self.moneyness_widening.is_zero() {
let Some(widened) = self
.moneyness_widening
.checked_mul(log_moneyness(strike, underlying))
.and_then(|term| spread.checked_add(term))
else {
return self.tick;
};
spread = widened;
}
if !self.tenor_widening.is_zero() && days_to_expiration > Decimal::ZERO {
let Some(years) = days_to_expiration.checked_div(DAYS_PER_YEAR) else {
return self.tick;
};
let Some(widened) = years
.sqrt()
.and_then(|root| self.tenor_widening.checked_mul(root))
.and_then(|term| spread.checked_add(term))
else {
return self.tick;
};
spread = widened;
}
Positive::new_decimal(spread)
.unwrap_or(self.tick)
.max(self.tick)
}
pub(crate) fn apply(&self, chain: &mut OptionChain, days_to_expiration: Decimal) {
let underlying = chain.underlying_price;
let contracts = std::mem::take(&mut chain.options);
chain.options = contracts
.into_iter()
.map(|mut contract| {
self.widen(&mut contract, underlying, days_to_expiration);
contract
})
.collect();
}
fn widen(&self, contract: &mut OptionData, underlying: Positive, days: Decimal) {
let strike = contract.strike_price;
if let Some(mid) = contract.call_middle {
let spread = self.spread_for(mid, strike, underlying, days);
let (bid, ask) = self.quote_around(mid, spread);
contract.call_bid = Some(bid);
contract.call_ask = Some(ask);
}
if let Some(mid) = contract.put_middle {
let spread = self.spread_for(mid, strike, underlying, days);
let (bid, ask) = self.quote_around(mid, spread);
contract.put_bid = Some(bid);
contract.put_ask = Some(ask);
}
}
#[must_use]
fn quote_around(&self, mid: Positive, spread: Positive) -> (Positive, Positive) {
let mid = mid.to_dec();
let half = spread
.to_dec()
.checked_div(Decimal::TWO)
.unwrap_or(Decimal::ZERO);
let ask = match mid.checked_add(half) {
Some(value) => round_quote(value).max(self.tick),
None => round_quote(mid).max(self.tick),
};
let floor = self.tick.min(round_quote(mid));
let bid = match mid.checked_sub(half) {
Some(value) => round_quote(value.max(Decimal::ZERO)).max(floor).min(ask),
None => floor.min(ask),
};
(bid, ask)
}
}
#[must_use]
fn log_moneyness(strike: Positive, underlying: Positive) -> Decimal {
strike
.to_dec()
.checked_div(underlying.to_dec())
.and_then(|ratio| ratio.checked_ln())
.map_or(Decimal::ZERO, |value| value.abs())
}
#[must_use]
fn round_quote(value: Decimal) -> Positive {
Positive::new_decimal(value.round_dp(QUOTE_DECIMALS)).unwrap_or(Positive::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::session::{ExpiryRule, ExpiryRuleKind};
use chrono::{TimeZone, Utc};
use positive::pos_or_panic;
fn parameters() -> SimulationParametersV2 {
let start_at = match Utc.with_ymd_and_hms(2026, 1, 5, 14, 30, 0).single() {
Some(instant) => instant,
None => panic!("the test instant must be valid"),
};
let rule = match ExpiryRule::new("zero_dte", ExpiryRuleKind::Daily, 1) {
Ok(rule) => rule,
Err(error) => panic!("the test rule must be valid: {error}"),
};
let request = CreateSimulationRequest {
symbol: "SPX".to_string(),
steps: 3,
start_at: Some(start_at),
step_interval_seconds: Some(86_400),
timezone: "America/New_York".to_string(),
calendar: None,
expiration_time: "17:00".to_string(),
schedules: vec![rule],
initial_price: 100.0,
volatility: 0.2,
risk_free_rate: 0.04,
dividend_yield: 0.0,
method: ApiWalkType::Brownian {
dt: 1.0 / 252.0,
drift: 0.0,
volatility: 0.2,
},
time_frame: ApiTimeFrame::Day,
chain_size: Some(3),
strike_interval: Some(5.0),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
strike_ladder: Default::default(),
spread_proportional: None,
spread_moneyness_widening: None,
spread_tenor_widening: None,
spread_tick: None,
seed: Some(42),
};
match SimulationParametersV2::try_from(request) {
Ok(parameters) => parameters,
Err(error) => panic!("the request must convert: {error}"),
}
}
#[test]
fn test_the_default_model_is_the_old_scalar() {
let mut params = parameters();
params.spread = None;
let model = SpreadModel::from_parameters(¶ms);
for (mid, strike) in [
(pos_or_panic!(12.0), pos_or_panic!(90.0)),
(pos_or_panic!(0.05), pos_or_panic!(150.0)),
] {
assert_eq!(
model.spread_for(mid, strike, pos_or_panic!(100.0), dec!(30)),
pos_or_panic!(0.01),
"no widening term is set, so every contract carries the floor"
);
}
}
#[test]
fn test_the_legacy_scalar_is_the_floor() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.25));
let model = SpreadModel::from_parameters(¶ms);
assert_eq!(
model.spread_for(
pos_or_panic!(3.0),
pos_or_panic!(100.0),
pos_or_panic!(100.0),
dec!(30)
),
pos_or_panic!(0.25)
);
}
#[test]
fn test_a_cheap_contract_has_the_wider_relative_spread() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.02));
params.spread_proportional = Some(dec!(0.01));
let model = SpreadModel::from_parameters(¶ms);
let expensive = model.spread_for(
pos_or_panic!(12.0),
pos_or_panic!(90.0),
pos_or_panic!(100.0),
dec!(30),
);
let cheap = model.spread_for(
pos_or_panic!(0.05),
pos_or_panic!(130.0),
pos_or_panic!(100.0),
dec!(30),
);
assert!(
expensive > cheap,
"the dearer contract carries the wider absolute spread: {expensive} vs {cheap}"
);
assert!(
cheap.to_dec() / dec!(0.05) > expensive.to_dec() / dec!(12.0),
"and the cheaper one the wider relative spread"
);
}
#[test]
fn test_the_moneyness_term_widens_away_from_the_money() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.02));
params.spread_moneyness_widening = Some(dec!(0.5));
let model = SpreadModel::from_parameters(¶ms);
let atm = model.spread_for(
pos_or_panic!(5.0),
pos_or_panic!(100.0),
pos_or_panic!(100.0),
dec!(30),
);
let wing = model.spread_for(
pos_or_panic!(5.0),
pos_or_panic!(150.0),
pos_or_panic!(100.0),
dec!(30),
);
let mirrored_strike = match Positive::new_decimal(dec!(100) / dec!(1.5)) {
Ok(strike) => strike,
Err(error) => panic!("the mirrored strike must be positive: {error}"),
};
let mirrored = model.spread_for(
pos_or_panic!(5.0),
mirrored_strike,
pos_or_panic!(100.0),
dec!(30),
);
assert!(
wing > atm,
"a wing is wider than the money: {wing} vs {atm}"
);
assert!(
(wing.to_dec() - mirrored.to_dec()).abs() < dec!(0.0000001),
"the term is symmetric in log space: {wing} vs {mirrored}"
);
}
#[test]
fn test_the_tenor_term_widens_with_time_to_expiry() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.02));
params.spread_tenor_widening = Some(dec!(0.10));
let model = SpreadModel::from_parameters(¶ms);
let near = model.spread_for(
pos_or_panic!(5.0),
pos_or_panic!(100.0),
pos_or_panic!(100.0),
dec!(1),
);
let far = model.spread_for(
pos_or_panic!(5.0),
pos_or_panic!(100.0),
pos_or_panic!(100.0),
dec!(365),
);
assert!(far > near, "a year is wider than a day: {far} vs {near}");
assert!(near < pos_or_panic!(0.03), "the near term is small: {near}");
assert_eq!(far, pos_or_panic!(0.12), "a full year adds the coefficient");
}
#[test]
fn test_the_spread_never_falls_below_the_tick() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.0001));
let model = SpreadModel::from_parameters(¶ms);
assert_eq!(
model.spread_for(
pos_or_panic!(0.02),
pos_or_panic!(100.0),
pos_or_panic!(100.0),
dec!(7)
),
pos_or_panic!(0.01),
"a spread below one tick is not a quotable width"
);
}
#[test]
fn test_a_quote_is_centred_on_the_mid() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.40));
let model = SpreadModel::from_parameters(¶ms);
let (bid, ask) = model.quote_around(pos_or_panic!(3.00), pos_or_panic!(0.40));
assert_eq!(bid, pos_or_panic!(2.80));
assert_eq!(ask, pos_or_panic!(3.20));
}
#[test]
fn test_a_wing_cheaper_than_the_spread_is_still_quoted() {
let mut params = parameters();
params.spread = Some(pos_or_panic!(0.10));
let model = SpreadModel::from_parameters(¶ms);
let (bid, ask) = model.quote_around(pos_or_panic!(0.03), pos_or_panic!(0.10));
assert_eq!(bid, pos_or_panic!(0.01), "the bid is floored at the tick");
assert_eq!(ask, pos_or_panic!(0.08));
assert!(bid <= ask, "and the book is never crossed");
}
#[test]
fn test_a_zero_mid_is_quoted_without_being_marked_up() {
let model = SpreadModel::from_parameters(¶meters());
let (bid, ask) = model.quote_around(Positive::ZERO, pos_or_panic!(0.01));
assert_eq!(bid, Positive::ZERO, "nothing is worth nothing");
assert_eq!(ask, pos_or_panic!(0.01));
assert!(bid <= ask);
}
#[test]
fn test_a_sub_tick_mid_is_not_bid_above_its_value() {
let model = SpreadModel::from_parameters(¶meters());
let (bid, ask) = model.quote_around(pos_or_panic!(0.003), pos_or_panic!(0.01));
assert_eq!(bid, Positive::ZERO, "a third of a cent is not worth a cent");
assert!(ask >= pos_or_panic!(0.01));
}
#[test]
fn test_an_extreme_quote_does_not_panic() {
let model = SpreadModel::from_parameters(¶meters());
for (mid, spread) in [
(Positive::ZERO, pos_or_panic!(0.01)),
(pos_or_panic!(0.003), pos_or_panic!(0.01)),
(
match Positive::new_decimal(Decimal::MAX) {
Ok(mid) => mid,
Err(error) => panic!("the fixture must be positive: {error}"),
},
pos_or_panic!(0.5),
),
] {
let (bid, ask) = model.quote_around(mid, spread);
assert!(bid <= ask, "the book must never cross: {bid} / {ask}");
}
}
#[test]
fn test_an_unrepresentable_spread_degrades_to_the_tick() {
let mut params = parameters();
params.spread_proportional = Some(Decimal::MAX);
let model = SpreadModel::from_parameters(¶ms);
assert_eq!(
model.spread_for(
pos_or_panic!(1000.0),
pos_or_panic!(100.0),
pos_or_panic!(100.0),
dec!(30)
),
pos_or_panic!(0.01),
"an unrepresentable width is not a market"
);
}
}