use statrs::distribution::{ContinuousCDF, Normal};
use crate::pricing::{call_price, d1_d2};
use crate::vol_surface::VolSmile;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct CallSpreadResult {
pub probability: f64,
pub epsilon_used: f64,
pub k_lower: f64,
pub k_upper: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Nd2Result {
pub probability: f64,
pub skew_adjustment: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ProbabilityExtraction {
pub primary_probability: f64,
pub primary_method: ProbabilityMethod,
pub call_spread: Option<CallSpreadResult>,
pub nd2: Nd2Result,
pub method_disagreement: f64,
pub skew_adjustment: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ProbabilityMethod {
CallSpreadReplication,
Nd2SkewAdjusted,
}
fn call_spread_probability(
target_strike: f64,
smile: &VolSmile,
forward: f64,
time_to_expiry: f64,
rate: f64,
max_epsilon: f64,
) -> Option<CallSpreadResult> {
let (k_lower, k_upper) = smile.nearest_bracket(target_strike)?;
let half_spacing = (k_upper - k_lower) / 2.0;
if half_spacing > max_epsilon {
return None;
}
let first = smile.points.first()?.strike;
let last = smile.points.last()?.strike;
let epsilon = half_spacing
.min(target_strike - first)
.min(last - target_strike);
if epsilon <= 0.0 {
return None;
}
let k_low = target_strike - epsilon;
let k_high = target_strike + epsilon;
let iv_low = smile.interpolate(k_low)?;
let iv_high = smile.interpolate(k_high)?;
let c_low = call_price(forward, k_low, time_to_expiry, iv_low, rate);
let c_high = call_price(forward, k_high, time_to_expiry, iv_high, rate);
let df = (-rate * time_to_expiry).exp();
let raw_prob = (c_low - c_high) / (2.0 * epsilon) / df;
if !raw_prob.is_finite() || !(-1e-9..=1.0 + 1e-9).contains(&raw_prob) {
return None;
}
let prob = raw_prob.clamp(0.0, 1.0);
Some(CallSpreadResult {
probability: prob,
epsilon_used: epsilon,
k_lower: k_low,
k_upper: k_high,
})
}
fn nd2_probability(
forward: f64,
strike: f64,
time_to_expiry: f64,
strike_iv: f64,
atm_iv: Option<f64>,
) -> Nd2Result {
let (_, d2) = d1_d2(forward, strike, time_to_expiry, strike_iv);
let norm = Normal::standard();
let probability = norm.cdf(d2);
let skew_adjustment = atm_iv.map(|atm| strike_iv - atm).unwrap_or(0.0);
Nd2Result {
probability,
skew_adjustment,
}
}
#[must_use]
pub fn extract_probabilities(
target_strike: f64,
smile: &VolSmile,
forward: f64,
time_to_expiry: f64,
rate: f64,
max_epsilon: f64,
) -> Option<ProbabilityExtraction> {
if time_to_expiry <= 0.0 {
return None;
}
let call_spread = call_spread_probability(
target_strike,
smile,
forward,
time_to_expiry,
rate,
max_epsilon,
);
let strike_iv = smile.interpolate(target_strike)?;
let nd2 = nd2_probability(
forward,
target_strike,
time_to_expiry,
strike_iv,
smile.atm_iv,
);
let (primary_probability, primary_method) = if let Some(ref cs) = call_spread {
(cs.probability, ProbabilityMethod::CallSpreadReplication)
} else {
(nd2.probability, ProbabilityMethod::Nd2SkewAdjusted)
};
let method_disagreement = if let Some(ref cs) = call_spread {
(cs.probability - nd2.probability).abs()
} else {
0.0
};
let skew_adjustment = nd2.skew_adjustment;
Some(ProbabilityExtraction {
primary_probability,
primary_method,
call_spread,
nd2,
method_disagreement,
skew_adjustment,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vol_surface::{SmilePoint, VolSmile, VolSurfaceConfig};
fn make_point(strike: f64, iv: f64, spread: f64) -> SmilePoint {
SmilePoint::new(strike, iv, iv - spread / 2.0, iv + spread / 2.0)
}
fn flat_smile(iv: f64) -> VolSmile {
let config = VolSurfaceConfig::default();
let points = vec![
make_point(90.0, iv, 0.01),
make_point(95.0, iv, 0.01),
make_point(100.0, iv, 0.01),
make_point(105.0, iv, 0.01),
make_point(110.0, iv, 0.01),
];
VolSmile::new(None, points, &config, 100.0)
}
fn skewed_smile() -> VolSmile {
let config = VolSurfaceConfig::default();
let points = vec![
make_point(90.0, 0.35, 0.02),
make_point(95.0, 0.28, 0.02),
make_point(100.0, 0.25, 0.02),
make_point(105.0, 0.27, 0.02),
make_point(110.0, 0.30, 0.02),
];
VolSmile::new(None, points, &config, 100.0)
}
#[test]
fn call_spread_known_smile() {
let smile = flat_smile(0.20);
let result = call_spread_probability(100.0, &smile, 100.0, 1.0, 0.0, 10.0);
let cs = result.expect("call spread should succeed for ATM strike with good smile");
assert!(cs.probability > 0.3 && cs.probability < 0.7);
assert!(cs.epsilon_used > 0.0);
assert!(cs.k_lower < 100.0 && cs.k_upper > 100.0);
}
#[test]
fn call_spread_none_when_epsilon_exceeds_max() {
let smile = flat_smile(0.20);
let result = call_spread_probability(100.0, &smile, 100.0, 1.0, 0.0, 1.0);
assert!(result.is_none());
}
#[test]
fn nd2_matches_call_spread_on_flat_surface() {
let smile = flat_smile(0.20);
let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
let cs = extraction
.call_spread
.as_ref()
.expect("call spread expected");
let diff = (cs.probability - extraction.nd2.probability).abs();
assert!(diff < 0.02);
}
#[test]
fn nd2_skew_adjustment_correct() {
let smile = skewed_smile();
let nd2 = nd2_probability(100.0, 95.0, 1.0, 0.28, smile.atm_iv);
assert!((nd2.skew_adjustment - 0.03).abs() < 1e-10);
let nd2_atm = nd2_probability(100.0, 100.0, 1.0, 0.25, smile.atm_iv);
assert!(nd2_atm.skew_adjustment.abs() < 1e-10);
}
#[test]
fn extract_uses_call_spread_as_primary() {
let smile = flat_smile(0.20);
let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
assert_eq!(
extraction.primary_method,
ProbabilityMethod::CallSpreadReplication
);
assert!(extraction.call_spread.is_some());
}
#[test]
fn extract_falls_back_to_nd2() {
let smile = flat_smile(0.20);
let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 0.01).unwrap();
assert_eq!(
extraction.primary_method,
ProbabilityMethod::Nd2SkewAdjusted
);
assert!(extraction.call_spread.is_none());
assert!(extraction.method_disagreement.abs() < f64::EPSILON);
}
#[test]
fn method_disagreement_on_skewed_surface() {
let smile = skewed_smile();
let extraction = extract_probabilities(95.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
let cs = extraction
.call_spread
.as_ref()
.expect("call spread expected");
let expected = (cs.probability - extraction.nd2.probability).abs();
assert!((extraction.method_disagreement - expected).abs() < 1e-10);
}
#[test]
fn probability_clamped() {
let smile = flat_smile(0.20);
let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
assert!(extraction.primary_probability >= 0.0 && extraction.primary_probability <= 1.0);
assert!(extraction.nd2.probability >= 0.0 && extraction.nd2.probability <= 1.0);
}
#[test]
fn nd2_no_atm_iv_zero_skew() {
let nd2 = nd2_probability(100.0, 100.0, 1.0, 0.20, None);
assert!(nd2.skew_adjustment.abs() < f64::EPSILON);
}
#[test]
fn call_spread_with_nonzero_rate() {
let smile = flat_smile(0.20);
let rate = 0.05;
let cs = call_spread_probability(100.0, &smile, 100.0, 1.0, rate, 10.0)
.expect("call spread should succeed at non-zero rate");
assert!(
(cs.probability - 0.5).abs() < 0.05,
"ATM call-spread probability at r=5% must be ~0.5, got {} \
(an unrescaled implementation would give ~0.476)",
cs.probability,
);
let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, rate, 10.0).unwrap();
let diff = (extraction.call_spread.unwrap().probability - extraction.nd2.probability).abs();
assert!(
diff < 0.02,
"flat-surface methods must agree, got diff={diff}"
);
}
#[test]
fn call_spread_recenters_on_off_grid_target() {
let smile = flat_smile(0.20);
let f = 100.0;
let target = 96.0;
let cs = call_spread_probability(target, &smile, f, 1.0, 0.0, 10.0)
.expect("flat smile brackets the target");
let p_target = nd2_probability(f, target, 1.0, 0.20, None).probability;
let p_midpoint = nd2_probability(f, 97.5, 1.0, 0.20, None).probability;
assert!(
(cs.probability - p_target).abs() < 1.5e-2,
"call spread {} should track P(F>{target})={p_target}",
cs.probability,
);
assert!(
(cs.probability - p_target).abs() < (cs.probability - p_midpoint).abs(),
"call spread {} should be closer to the target digital {p_target} \
than to the midpoint digital {p_midpoint}",
cs.probability,
);
assert!(((cs.k_lower + cs.k_upper) / 2.0 - target).abs() < 1e-9);
}
#[test]
fn call_spread_rejects_arbitraging_smile() {
let config = VolSurfaceConfig::default();
let points = vec![
make_point(90.0, 0.20, 0.01),
make_point(95.0, 0.20, 0.01),
make_point(100.0, 0.20, 0.01),
make_point(105.0, 0.20, 0.01),
make_point(110.0, 3.00, 0.01), ];
let smile = VolSmile::new(None, points, &config, 100.0);
let result = call_spread_probability(107.0, &smile, 100.0, 1.0, 0.0, 10.0);
assert!(
result.is_none(),
"arbitraging smile must yield None, got {result:?}",
);
}
#[test]
fn extract_returns_none_at_or_after_expiry() {
let smile = flat_smile(0.20);
assert!(extract_probabilities(100.0, &smile, 100.0, 0.0, 0.0, 10.0).is_none());
assert!(extract_probabilities(100.0, &smile, 100.0, -1.0, 0.0, 10.0).is_none());
}
#[test]
fn extract_has_no_nan_fields_for_valid_inputs() {
let smile = skewed_smile();
for &k in &[95.0_f64, 100.0, 105.0] {
let e = extract_probabilities(k, &smile, 100.0, 0.5, 0.03, 10.0).unwrap();
assert!(e.primary_probability.is_finite());
assert!(e.nd2.probability.is_finite());
assert!(e.method_disagreement.is_finite());
assert!(e.skew_adjustment.is_finite());
if let Some(cs) = &e.call_spread {
assert!(cs.probability.is_finite());
}
}
}
}