use crate::session::{ExpirationSchedule, SimulationParametersV2};
use crate::utils::ChainError;
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use utoipa::ToSchema;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum StrikeLadder {
#[default]
Rolling,
Pinned,
}
impl StrikeLadder {
#[must_use]
pub fn is_pinned(self) -> bool {
matches!(self, StrikeLadder::Pinned)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PinnedLadder {
strikes: BTreeSet<Positive>,
interval: Positive,
}
impl PinnedLadder {
pub(crate) fn resolve(parameters: &SimulationParametersV2) -> Result<Self, ChainError> {
let Some(interval) = parameters.strike_interval else {
return Err(ChainError::Validation {
field: "strike_interval".to_string(),
reason: "a pinned strike ladder needs an explicit strike_interval: without one \
upstream derives the interval from the expiration AND the chain size, \
and the pinned path varies the chain size per step, so the grid would \
move under the ladder it is meant to hold still"
.to_string(),
});
};
let chain_size = parameters
.chain_size
.unwrap_or(crate::domain::simulator::DEFAULT_CHAIN_SIZE);
let centre = at_the_money(parameters.initial_price, interval)?;
if centre == Positive::ZERO {
return Err(ChainError::Validation {
field: "strike_interval".to_string(),
reason: format!(
"an interval of {interval} rounds an initial price of {} to a strike of \
zero, so there is no ladder to pin",
parameters.initial_price
),
});
}
let mut strikes = BTreeSet::new();
strikes.insert(centre);
for step in 1..=chain_size {
let offset = interval
.checked_mul_dec(Decimal::from(step))
.map_err(|error| ladder_overflow(interval, step, &error.to_string()))?;
let upper = centre
.checked_add(&offset)
.map_err(|error| ladder_overflow(interval, step, &error.to_string()))?;
strikes.insert(upper);
if let Ok(lower) = centre.checked_sub(&offset)
&& lower > Positive::ZERO
{
strikes.insert(lower);
}
}
Ok(Self { strikes, interval })
}
#[cfg(test)]
#[must_use]
pub(crate) fn strikes(&self) -> &BTreeSet<Positive> {
&self.strikes
}
pub(crate) fn width_from(&self, spot: Positive, maximum: usize) -> Result<usize, ChainError> {
let anchor = at_the_money(spot, self.interval)?;
if anchor == Positive::ZERO {
return Err(ChainError::Validation {
field: "strike_ladder".to_string(),
reason: format!(
"the underlying has fallen to {spot}, below half the {} interval this \
simulation pinned, so its chain would have to be built around a strike of \
zero; a pinned ladder cannot follow a move that far",
self.interval
),
});
}
let centre = anchor.to_dec();
let interval = self.interval.to_dec();
let mut widest = 0_usize;
for strike in &self.strikes {
let distance = (strike.to_dec() - centre).abs();
let steps = distance
.checked_div(interval)
.map(|steps| steps.ceil())
.and_then(|steps| usize::try_from(steps).ok())
.ok_or_else(|| ChainError::Validation {
field: "strike_ladder".to_string(),
reason: format!(
"the pinned strike {strike} is unreachable from a spot of {spot}"
),
})?;
widest = widest.max(steps);
}
if widest > maximum {
return Err(ChainError::Validation {
field: "strike_ladder".to_string(),
reason: format!(
"the underlying has moved {widest} strikes from the ladder this simulation \
pinned at creation, past the {maximum} a chain may carry; a pinned ladder \
does not follow a move that far, and widening chain_size cannot help \
because a wider ladder puts its far strikes further still from this spot; \
create the simulation with a smaller chain_size, around an initial_price \
nearer where the underlying trades, or with the rolling ladder, which \
re-centres on every step"
),
});
}
Ok(widest)
}
pub(crate) fn keep_pinned(
&self,
chain: &mut optionstratlib::chains::chain::OptionChain,
) -> Result<(), ChainError> {
let strikes = &self.strikes;
let contracts = std::mem::take(&mut chain.options);
chain.options = contracts
.into_iter()
.filter(|contract| strikes.contains(&contract.strike_price))
.collect();
if chain.options.len() == strikes.len() {
return Ok(());
}
let present: BTreeSet<Positive> = chain
.options
.iter()
.map(|contract| contract.strike_price)
.collect();
let missing: Vec<String> = strikes
.iter()
.filter(|strike| !present.contains(strike))
.map(ToString::to_string)
.collect();
Err(ChainError::Internal(format!(
"the pinned ladder is incomplete: the chain built at this step does not carry {}",
missing.join(", ")
)))
}
}
pub(crate) fn at_the_money(
underlying: Positive,
interval: Positive,
) -> Result<Positive, ChainError> {
if interval == Positive::ZERO {
return Ok(underlying);
}
let price = underlying.to_dec();
let interval = interval.to_dec();
let unrepresentable = || ChainError::Validation {
field: "strike_interval".to_string(),
reason: format!("an interval of {interval} cannot anchor a ladder at {underlying}"),
};
let remainder = price.checked_rem(interval).ok_or_else(unrepresentable)?;
let base = price.checked_sub(remainder).ok_or_else(unrepresentable)?;
let half = interval
.checked_div(Decimal::TWO)
.ok_or_else(unrepresentable)?;
let rounds_up = remainder >= half;
let rounded = if rounds_up {
base.checked_add(interval).ok_or_else(unrepresentable)?
} else {
base
};
Positive::new_decimal(rounded).map_err(|_| unrepresentable())
}
pub(crate) const MAX_PINNED_WIDTH: usize = 500;
pub(crate) fn resolve_pinned_ceiling(schedule: &ExpirationSchedule) -> Result<usize, ChainError> {
let expirations = schedule
.rules()
.iter()
.try_fold(0usize, |total, rule| {
total.checked_add(rule.target_count().get())
})
.ok_or_else(|| ChainError::Validation {
field: "schedules".to_string(),
reason: "the requested expiration counts overflow".to_string(),
})?;
Ok(pinned_width_for(
crate::infrastructure::max_snapshot_contracts(),
expirations,
))
}
fn pinned_width_for(cap: usize, expirations: usize) -> usize {
let divisor = if expirations == 0 { 1 } else { expirations };
let strikes = cap.checked_div(divisor).unwrap_or(cap);
let budget = match strikes.checked_sub(1) {
Some(grid) => grid.checked_div(2).unwrap_or(0),
None => 0,
};
budget.min(MAX_PINNED_WIDTH)
}
pub(crate) fn ensure_ladder_fits(
initial_price: Positive,
interval: Positive,
chain_size: usize,
) -> Result<(), ChainError> {
let anchor = at_the_money(initial_price, interval)?;
let reach = interval
.checked_mul_dec(Decimal::from(chain_size))
.map_err(|error| ladder_overflow(interval, chain_size, &error.to_string()))?;
if reach > anchor {
return Err(ChainError::Validation {
field: "chain_size".to_string(),
reason: format!(
"a pinned ladder of {chain_size} strikes at an interval of {interval} reaches \
{reach} below an anchor of {anchor}, and upstream stops building a chain once \
the offset passes the anchor, so the lowest strikes would never exist; lower \
chain_size or the interval"
),
});
}
Ok(())
}
fn ladder_overflow(interval: Positive, step: usize, detail: &str) -> ChainError {
ChainError::Validation {
field: "strike_interval".to_string(),
reason: format!(
"an interval of {interval} cannot reach strike {step} of the pinned ladder: {detail}"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use positive::pos_or_panic;
#[test]
fn test_the_anchor_rounds_to_the_grid() {
let interval = pos_or_panic!(25.0);
for (underlying, expected) in [
(5000.0, 5000.0),
(5004.95, 5000.0),
(5013.54, 5025.0),
(5012.5, 5025.0),
(5012.49, 5000.0),
] {
match at_the_money(pos_or_panic!(underlying), interval) {
Ok(anchor) => assert_eq!(anchor, pos_or_panic!(expected), "for {underlying}"),
Err(error) => panic!("{underlying} must anchor: {error}"),
}
}
}
#[test]
fn test_a_zero_interval_is_not_a_grid() {
match at_the_money(pos_or_panic!(5000.0), Positive::ZERO) {
Ok(anchor) => assert_eq!(anchor, pos_or_panic!(5000.0)),
Err(error) => panic!("a zero interval must not fail: {error}"),
}
}
#[test]
fn test_the_ladder_is_the_grid_around_the_initial_price() {
let ladder = ladder_of(5000.0, 25.0, 2);
assert_eq!(
ladder.strikes().iter().copied().collect::<Vec<_>>(),
vec![
pos_or_panic!(4950.0),
pos_or_panic!(4975.0),
pos_or_panic!(5000.0),
pos_or_panic!(5025.0),
pos_or_panic!(5050.0),
]
);
}
#[test]
fn test_the_ladder_never_reaches_a_zero_strike() {
let ladder = ladder_of(50.0, 25.0, 2);
assert!(
ladder
.strikes()
.iter()
.all(|strike| *strike > Positive::ZERO),
"a strike of zero is not a contract: {:?}",
ladder.strikes()
);
assert!(ladder.strikes().contains(&pos_or_panic!(25.0)));
}
#[test]
fn test_a_ladder_reaching_past_the_anchor_is_refused() {
match ensure_ladder_fits(pos_or_panic!(100.0), pos_or_panic!(5.0), 25) {
Ok(()) => panic!("a ladder wider than its anchor must be refused"),
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "chain_size");
assert!(reason.contains("never exist"), "{reason}");
}
Err(error) => panic!("expected a validation failure, got {error:?}"),
}
match ensure_ladder_fits(pos_or_panic!(100.0), pos_or_panic!(5.0), 20) {
Ok(()) => {}
Err(error) => panic!("a ladder that ends at the anchor must fit: {error}"),
}
}
#[test]
fn test_an_unrepresentable_grid_is_refused_rather_than_panicking() {
let huge = match Positive::new_decimal(Decimal::MAX / Decimal::TWO) {
Ok(value) => value,
Err(error) => panic!("the fixture must be positive: {error}"),
};
match ensure_ladder_fits(huge, huge, 500) {
Ok(()) => panic!("a grid this wide cannot be laid out"),
Err(ChainError::Validation { .. }) => {}
Err(error) => panic!("expected a validation failure, got {error:?}"),
}
}
#[test]
fn test_the_width_grows_with_the_distance_from_the_ladder() {
let ladder = ladder_of(5000.0, 25.0, 2);
match ladder.width_from(pos_or_panic!(5000.0), 500) {
Ok(width) => assert_eq!(width, 2),
Err(error) => panic!("the centre must resolve: {error}"),
}
match ladder.width_from(pos_or_panic!(5100.0), 500) {
Ok(width) => assert_eq!(width, 6),
Err(error) => panic!("a drifted spot must resolve: {error}"),
}
}
#[test]
fn test_a_spot_beyond_the_maximum_is_refused() {
let ladder = ladder_of(5000.0, 25.0, 2);
match ladder.width_from(pos_or_panic!(9000.0), 10) {
Ok(width) => panic!("a spot 160 intervals away must not resolve, got {width}"),
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "strike_ladder");
assert!(
reason.contains("pinned at creation"),
"the failure must explain: {reason}"
);
assert!(
reason.contains("widening chain_size cannot help"),
"the failure must rule out the wrong remedy: {reason}"
);
assert!(
reason.contains("smaller chain_size")
&& reason.contains("initial_price")
&& reason.contains("rolling"),
"the failure must name a remedy that works: {reason}"
);
}
Err(error) => panic!("expected a validation failure, got {error:?}"),
}
}
#[test]
fn test_a_spot_below_half_the_interval_is_refused() {
let ladder = ladder_of(5000.0, 25.0, 2);
match ladder.width_from(pos_or_panic!(0.01), usize::MAX) {
Ok(width) => panic!("a spot under the grid must not resolve, got {width}"),
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "strike_ladder");
assert!(
reason.contains("strike of zero"),
"the failure must say why: {reason}"
);
}
Err(error) => panic!("expected a validation failure, got {error:?}"),
}
match ladder.width_from(pos_or_panic!(12.5), usize::MAX) {
Ok(width) => assert_eq!(width, 201),
Err(error) => panic!("the first quotable spot must resolve: {error}"),
}
}
#[test]
fn test_the_widening_ceiling_is_the_smaller_of_both_bounds() {
assert_eq!(pinned_width_for(200_000, 1), MAX_PINNED_WIDTH);
assert_eq!(pinned_width_for(200_000, 400), 249);
assert_eq!(pinned_width_for(101, 1), 50);
assert_eq!(pinned_width_for(2, 1), 0);
assert_eq!(pinned_width_for(1, 1), 0);
assert_eq!(pinned_width_for(0, 1), 0);
assert_eq!(pinned_width_for(200_000, 0), MAX_PINNED_WIDTH);
}
#[test]
fn test_the_ceiling_is_resolved_once_and_carried() {
let parameters = parameters(5000.0, 25.0, 2);
let cap = crate::infrastructure::max_snapshot_contracts();
let resolved = match resolve_pinned_ceiling(¶meters.schedule) {
Ok(width) => width,
Err(error) => panic!("the budget must resolve: {error}"),
};
assert_eq!(resolved, pinned_width_for(cap, 1));
assert!(
resolved * 2 < cap,
"{resolved} strikes per side exceeds {cap}"
);
assert!(
resolved <= MAX_PINNED_WIDTH,
"{resolved} strikes per side exceeds the {MAX_PINNED_WIDTH} ceiling"
);
assert_eq!(parameters.pinned_width_ceiling, resolved);
}
#[test]
fn test_a_step_obeys_the_stored_ceiling_not_the_environment() {
let mut parameters = parameters(5000.0, 25.0, 2);
parameters.strike_ladder = StrikeLadder::Pinned;
parameters.pinned_width_ceiling = 3;
let ladder = match PinnedLadder::resolve(¶meters) {
Ok(ladder) => ladder,
Err(error) => panic!("the ladder must resolve: {error}"),
};
match ladder.width_from(pos_or_panic!(5100.0), parameters.pinned_width_ceiling) {
Ok(width) => panic!("the stored ceiling must bind, got {width}"),
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "strike_ladder");
assert!(reason.contains("past the 3"), "{reason}");
}
Err(error) => panic!("expected a validation failure, got {error:?}"),
}
}
#[test]
fn test_a_ladder_without_an_interval_is_refused() {
let mut parameters = parameters(5000.0, 25.0, 2);
parameters.strike_interval = None;
match PinnedLadder::resolve(¶meters) {
Ok(ladder) => panic!("a ladder with no grid must not resolve, got {ladder:?}"),
Err(ChainError::Validation { field, reason }) => {
assert_eq!(field, "strike_interval");
assert!(reason.contains("varies the chain size"), "{reason}");
}
Err(error) => panic!("expected a validation failure, got {error:?}"),
}
}
#[test]
fn test_rolling_is_the_default() {
assert_eq!(StrikeLadder::default(), StrikeLadder::Rolling);
assert!(!StrikeLadder::default().is_pinned());
assert!(StrikeLadder::Pinned.is_pinned());
}
#[test]
fn test_the_wire_form_is_snake_case() {
for (ladder, wire) in [
(StrikeLadder::Rolling, "\"rolling\""),
(StrikeLadder::Pinned, "\"pinned\""),
] {
match serde_json::to_string(&ladder) {
Ok(json) => assert_eq!(json, wire),
Err(error) => panic!("must serialize: {error}"),
}
match serde_json::from_str::<StrikeLadder>(wire) {
Ok(parsed) => assert_eq!(parsed, ladder),
Err(error) => panic!("must deserialize: {error}"),
}
}
}
fn parameters(initial_price: f64, interval: f64, chain_size: usize) -> SimulationParametersV2 {
use crate::api::rest::models::{ApiTimeFrame, ApiWalkType};
use crate::api::rest::requests_v2::CreateSimulationRequest;
use crate::session::{ExpiryRule, ExpiryRuleKind};
use chrono::{TimeZone, Utc};
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: 4,
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,
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(chain_size),
strike_interval: Some(interval),
skew_slope: None,
smile_curve: None,
spread: Some(0.02),
strike_ladder: Some(StrikeLadder::Pinned),
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}"),
}
}
fn ladder_of(initial_price: f64, interval: f64, chain_size: usize) -> PinnedLadder {
match PinnedLadder::resolve(¶meters(initial_price, interval, chain_size)) {
Ok(ladder) => ladder,
Err(error) => panic!("the ladder must resolve: {error}"),
}
}
#[test]
fn test_the_mirrored_anchor_matches_upstream() {
use optionstratlib::ExpirationDate;
let interval = pos_or_panic!(25.0);
for spot in [5000.0, 5004.95, 5013.54, 4987.5] {
let mut parameters = parameters(5000.0, 25.0, 1);
parameters.strike_ladder = StrikeLadder::Rolling;
let chain = match crate::domain::factors::build_chain(
¶meters,
pos_or_panic!(spot),
pos_or_panic!(0.2),
ExpirationDate::Days(pos_or_panic!(30.0)),
false,
) {
Ok(chain) => chain,
Err(error) => panic!("the chain must build at {spot}: {error}"),
};
let strikes: Vec<Positive> =
chain.iter().map(|contract| contract.strike_price).collect();
let middle = strikes[strikes.len() / 2];
match at_the_money(pos_or_panic!(spot), interval) {
Ok(anchor) => assert_eq!(
anchor, middle,
"the mirror disagrees with upstream at a spot of {spot}: {strikes:?}"
),
Err(error) => panic!("the anchor must resolve at {spot}: {error}"),
}
}
}
}