1use statrs::distribution::{ContinuousCDF, Normal};
40
41use crate::pricing::{call_price, d1_d2};
42use crate::vol_surface::VolSmile;
43
44#[derive(Debug, Clone, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51#[non_exhaustive]
52pub struct CallSpreadResult {
53 pub probability: f64,
55 pub epsilon_used: f64,
57 pub k_lower: f64,
59 pub k_upper: f64,
61}
62
63#[derive(Debug, Clone, PartialEq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[non_exhaustive]
67pub struct Nd2Result {
68 pub probability: f64,
70 pub skew_adjustment: f64,
72}
73
74#[derive(Debug, Clone, PartialEq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77#[non_exhaustive]
78pub struct ProbabilityExtraction {
79 pub primary_probability: f64,
81 pub primary_method: ProbabilityMethod,
83 pub call_spread: Option<CallSpreadResult>,
86 pub nd2: Nd2Result,
89 pub method_disagreement: f64,
92 pub skew_adjustment: f64,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99#[non_exhaustive]
100pub enum ProbabilityMethod {
101 CallSpreadReplication,
103 Nd2SkewAdjusted,
105}
106
107fn call_spread_probability(
136 target_strike: f64,
137 smile: &VolSmile,
138 forward: f64,
139 time_to_expiry: f64,
140 rate: f64,
141 max_epsilon: f64,
142) -> Option<CallSpreadResult> {
143 let (k_lower, k_upper) = smile.nearest_bracket(target_strike)?;
146
147 let half_spacing = (k_upper - k_lower) / 2.0;
148 if half_spacing > max_epsilon {
149 return None;
152 }
153
154 let first = smile.points.first()?.strike;
159 let last = smile.points.last()?.strike;
160 let epsilon = half_spacing
161 .min(target_strike - first)
162 .min(last - target_strike);
163 if epsilon <= 0.0 {
164 return None;
165 }
166
167 let k_low = target_strike - epsilon;
168 let k_high = target_strike + epsilon;
169
170 let iv_low = smile.interpolate(k_low)?;
171 let iv_high = smile.interpolate(k_high)?;
172
173 let c_low = call_price(forward, k_low, time_to_expiry, iv_low, rate);
174 let c_high = call_price(forward, k_high, time_to_expiry, iv_high, rate);
175
176 let df = (-rate * time_to_expiry).exp();
179 let raw_prob = (c_low - c_high) / (2.0 * epsilon) / df;
180
181 if !raw_prob.is_finite() || !(-1e-9..=1.0 + 1e-9).contains(&raw_prob) {
186 return None;
187 }
188 let prob = raw_prob.clamp(0.0, 1.0);
189
190 Some(CallSpreadResult {
191 probability: prob,
192 epsilon_used: epsilon,
193 k_lower: k_low,
194 k_upper: k_high,
195 })
196}
197
198fn nd2_probability(
207 forward: f64,
208 strike: f64,
209 time_to_expiry: f64,
210 strike_iv: f64,
211 atm_iv: Option<f64>,
212) -> Nd2Result {
213 let (_, d2) = d1_d2(forward, strike, time_to_expiry, strike_iv);
214 let norm = Normal::standard();
215 let probability = norm.cdf(d2);
216 let skew_adjustment = atm_iv.map(|atm| strike_iv - atm).unwrap_or(0.0);
217
218 Nd2Result {
219 probability,
220 skew_adjustment,
221 }
222}
223
224#[must_use]
241pub fn extract_probabilities(
242 target_strike: f64,
243 smile: &VolSmile,
244 forward: f64,
245 time_to_expiry: f64,
246 rate: f64,
247 max_epsilon: f64,
248) -> Option<ProbabilityExtraction> {
249 if time_to_expiry <= 0.0 {
255 return None;
256 }
257
258 let call_spread = call_spread_probability(
259 target_strike,
260 smile,
261 forward,
262 time_to_expiry,
263 rate,
264 max_epsilon,
265 );
266
267 let strike_iv = smile.interpolate(target_strike)?;
268
269 let nd2 = nd2_probability(
270 forward,
271 target_strike,
272 time_to_expiry,
273 strike_iv,
274 smile.atm_iv,
275 );
276
277 let (primary_probability, primary_method) = if let Some(ref cs) = call_spread {
278 (cs.probability, ProbabilityMethod::CallSpreadReplication)
279 } else {
280 (nd2.probability, ProbabilityMethod::Nd2SkewAdjusted)
281 };
282
283 let method_disagreement = if let Some(ref cs) = call_spread {
284 (cs.probability - nd2.probability).abs()
285 } else {
286 0.0
287 };
288
289 let skew_adjustment = nd2.skew_adjustment;
290
291 Some(ProbabilityExtraction {
292 primary_probability,
293 primary_method,
294 call_spread,
295 nd2,
296 method_disagreement,
297 skew_adjustment,
298 })
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::vol_surface::{SmilePoint, VolSmile, VolSurfaceConfig};
305
306 fn make_point(strike: f64, iv: f64, spread: f64) -> SmilePoint {
307 SmilePoint::new(strike, iv, iv - spread / 2.0, iv + spread / 2.0)
308 }
309
310 fn flat_smile(iv: f64) -> VolSmile {
311 let config = VolSurfaceConfig::default();
312 let points = vec![
313 make_point(90.0, iv, 0.01),
314 make_point(95.0, iv, 0.01),
315 make_point(100.0, iv, 0.01),
316 make_point(105.0, iv, 0.01),
317 make_point(110.0, iv, 0.01),
318 ];
319 VolSmile::new(None, points, &config, 100.0)
320 }
321
322 fn skewed_smile() -> VolSmile {
323 let config = VolSurfaceConfig::default();
324 let points = vec![
325 make_point(90.0, 0.35, 0.02),
326 make_point(95.0, 0.28, 0.02),
327 make_point(100.0, 0.25, 0.02),
328 make_point(105.0, 0.27, 0.02),
329 make_point(110.0, 0.30, 0.02),
330 ];
331 VolSmile::new(None, points, &config, 100.0)
332 }
333
334 #[test]
335 fn call_spread_known_smile() {
336 let smile = flat_smile(0.20);
337 let result = call_spread_probability(100.0, &smile, 100.0, 1.0, 0.0, 10.0);
338
339 let cs = result.expect("call spread should succeed for ATM strike with good smile");
340 assert!(cs.probability > 0.3 && cs.probability < 0.7);
341 assert!(cs.epsilon_used > 0.0);
342 assert!(cs.k_lower < 100.0 && cs.k_upper > 100.0);
343 }
344
345 #[test]
346 fn call_spread_none_when_epsilon_exceeds_max() {
347 let smile = flat_smile(0.20);
348 let result = call_spread_probability(100.0, &smile, 100.0, 1.0, 0.0, 1.0);
350 assert!(result.is_none());
351 }
352
353 #[test]
354 fn nd2_matches_call_spread_on_flat_surface() {
355 let smile = flat_smile(0.20);
356 let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
357
358 let cs = extraction
359 .call_spread
360 .as_ref()
361 .expect("call spread expected");
362 let diff = (cs.probability - extraction.nd2.probability).abs();
363 assert!(diff < 0.02);
364 }
365
366 #[test]
367 fn nd2_skew_adjustment_correct() {
368 let smile = skewed_smile();
369 let nd2 = nd2_probability(100.0, 95.0, 1.0, 0.28, smile.atm_iv);
371 assert!((nd2.skew_adjustment - 0.03).abs() < 1e-10);
372
373 let nd2_atm = nd2_probability(100.0, 100.0, 1.0, 0.25, smile.atm_iv);
374 assert!(nd2_atm.skew_adjustment.abs() < 1e-10);
375 }
376
377 #[test]
378 fn extract_uses_call_spread_as_primary() {
379 let smile = flat_smile(0.20);
380 let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
381
382 assert_eq!(
383 extraction.primary_method,
384 ProbabilityMethod::CallSpreadReplication
385 );
386 assert!(extraction.call_spread.is_some());
387 }
388
389 #[test]
390 fn extract_falls_back_to_nd2() {
391 let smile = flat_smile(0.20);
392 let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 0.01).unwrap();
394
395 assert_eq!(
396 extraction.primary_method,
397 ProbabilityMethod::Nd2SkewAdjusted
398 );
399 assert!(extraction.call_spread.is_none());
400 assert!(extraction.method_disagreement.abs() < f64::EPSILON);
401 }
402
403 #[test]
404 fn method_disagreement_on_skewed_surface() {
405 let smile = skewed_smile();
406 let extraction = extract_probabilities(95.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
407
408 let cs = extraction
409 .call_spread
410 .as_ref()
411 .expect("call spread expected");
412 let expected = (cs.probability - extraction.nd2.probability).abs();
413 assert!((extraction.method_disagreement - expected).abs() < 1e-10);
414 }
415
416 #[test]
417 fn probability_clamped() {
418 let smile = flat_smile(0.20);
419 let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
420 assert!(extraction.primary_probability >= 0.0 && extraction.primary_probability <= 1.0);
421 assert!(extraction.nd2.probability >= 0.0 && extraction.nd2.probability <= 1.0);
422 }
423
424 #[test]
425 fn nd2_no_atm_iv_zero_skew() {
426 let nd2 = nd2_probability(100.0, 100.0, 1.0, 0.20, None);
427 assert!(nd2.skew_adjustment.abs() < f64::EPSILON);
428 }
429
430 #[test]
434 fn call_spread_with_nonzero_rate() {
435 let smile = flat_smile(0.20);
436 let rate = 0.05;
437
438 let cs = call_spread_probability(100.0, &smile, 100.0, 1.0, rate, 10.0)
439 .expect("call spread should succeed at non-zero rate");
440
441 assert!(
442 (cs.probability - 0.5).abs() < 0.05,
443 "ATM call-spread probability at r=5% must be ~0.5, got {} \
444 (an unrescaled implementation would give ~0.476)",
445 cs.probability,
446 );
447
448 let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, rate, 10.0).unwrap();
450 let diff = (extraction.call_spread.unwrap().probability - extraction.nd2.probability).abs();
451 assert!(
452 diff < 0.02,
453 "flat-surface methods must agree, got diff={diff}"
454 );
455 }
456
457 #[test]
461 fn call_spread_recenters_on_off_grid_target() {
462 let smile = flat_smile(0.20);
463 let f = 100.0;
464 let target = 96.0;
465 let cs = call_spread_probability(target, &smile, f, 1.0, 0.0, 10.0)
466 .expect("flat smile brackets the target");
467
468 let p_target = nd2_probability(f, target, 1.0, 0.20, None).probability;
470 let p_midpoint = nd2_probability(f, 97.5, 1.0, 0.20, None).probability;
471
472 assert!(
473 (cs.probability - p_target).abs() < 1.5e-2,
474 "call spread {} should track P(F>{target})={p_target}",
475 cs.probability,
476 );
477 assert!(
478 (cs.probability - p_target).abs() < (cs.probability - p_midpoint).abs(),
479 "call spread {} should be closer to the target digital {p_target} \
480 than to the midpoint digital {p_midpoint}",
481 cs.probability,
482 );
483 assert!(((cs.k_lower + cs.k_upper) / 2.0 - target).abs() < 1e-9);
485 }
486
487 #[test]
491 fn call_spread_rejects_arbitraging_smile() {
492 let config = VolSurfaceConfig::default();
493 let points = vec![
494 make_point(90.0, 0.20, 0.01),
495 make_point(95.0, 0.20, 0.01),
496 make_point(100.0, 0.20, 0.01),
497 make_point(105.0, 0.20, 0.01),
498 make_point(110.0, 3.00, 0.01), ];
500 let smile = VolSmile::new(None, points, &config, 100.0);
501 let result = call_spread_probability(107.0, &smile, 100.0, 1.0, 0.0, 10.0);
502 assert!(
503 result.is_none(),
504 "arbitraging smile must yield None, got {result:?}",
505 );
506 }
507
508 #[test]
512 fn extract_returns_none_at_or_after_expiry() {
513 let smile = flat_smile(0.20);
514 assert!(extract_probabilities(100.0, &smile, 100.0, 0.0, 0.0, 10.0).is_none());
515 assert!(extract_probabilities(100.0, &smile, 100.0, -1.0, 0.0, 10.0).is_none());
516 }
517
518 #[test]
520 fn extract_has_no_nan_fields_for_valid_inputs() {
521 let smile = skewed_smile();
522 for &k in &[95.0_f64, 100.0, 105.0] {
523 let e = extract_probabilities(k, &smile, 100.0, 0.5, 0.03, 10.0).unwrap();
524 assert!(e.primary_probability.is_finite());
525 assert!(e.nd2.probability.is_finite());
526 assert!(e.method_disagreement.is_finite());
527 assert!(e.skew_adjustment.is_finite());
528 if let Some(cs) = &e.call_spread {
529 assert!(cs.probability.is_finite());
530 }
531 }
532 }
533}