Skip to main content

black_76/
digital.rs

1//! Risk-neutral probability extraction via call-spread replication and N(d2).
2//!
3//! Two independent estimators for `P_Q(F_T > K)` under the risk-neutral
4//! measure:
5//!
6//! 1. **Call-spread replication** (primary): a tight call spread centered on
7//!    the target strike, priced with the smile's interpolated (skew-aware) IV
8//!    at each leg. The discounted price difference is rescaled by `exp(rT)` to
9//!    recover the undiscounted probability, and centered on the target to
10//!    avoid a strike-location bias.
11//! 2. **N(d2)** (baseline): closed-form Black-76 risk-neutral probability
12//!    with skew adjustment (`strike_iv - atm_iv`). Always available when
13//!    the smile can interpolate at the target strike.
14//!
15//! Both are computed and their disagreement is reported for downstream
16//! confidence scoring.
17//!
18//! # Quick start
19//!
20//! ```
21//! use black_76::vol_surface::{SmilePoint, VolSmile, VolSurfaceConfig};
22//! use black_76::digital::extract_probabilities;
23//!
24//! let config = VolSurfaceConfig::default();
25//! let points = vec![
26//!     SmilePoint::new(90.0, 0.30, 0.295, 0.305),
27//!     SmilePoint::new(95.0, 0.28, 0.275, 0.285),
28//!     SmilePoint::new(100.0, 0.25, 0.245, 0.255),
29//!     SmilePoint::new(105.0, 0.27, 0.265, 0.275),
30//!     SmilePoint::new(110.0, 0.30, 0.295, 0.305),
31//! ];
32//! let smile = VolSmile::new(None, points, &config, 100.0);
33//!
34//! // ATM probability with a 10.0 epsilon budget
35//! let extraction = extract_probabilities(100.0, &smile, 100.0, 1.0, 0.0, 10.0).unwrap();
36//! assert!(extraction.primary_probability > 0.4 && extraction.primary_probability < 0.6);
37//! ```
38
39use statrs::distribution::{ContinuousCDF, Normal};
40
41use crate::pricing::{call_price, d1_d2};
42use crate::vol_surface::VolSmile;
43
44// ---------------------------------------------------------------------------
45// Result types
46// ---------------------------------------------------------------------------
47
48/// Output of call-spread replication probability extraction.
49#[derive(Debug, Clone, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51#[non_exhaustive]
52pub struct CallSpreadResult {
53    /// Extracted probability `P_Q(F_T > K)`.
54    pub probability: f64,
55    /// Epsilon used (half the distance between bracket strikes).
56    pub epsilon_used: f64,
57    /// Lower bracket strike.
58    pub k_lower: f64,
59    /// Upper bracket strike.
60    pub k_upper: f64,
61}
62
63/// Output of N(d2) probability extraction.
64#[derive(Debug, Clone, PartialEq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[non_exhaustive]
67pub struct Nd2Result {
68    /// Extracted probability `P_Q(F_T > K)` via `N(d2)`.
69    pub probability: f64,
70    /// Skew adjustment: `strike_iv - atm_iv` (0 if ATM IV unavailable).
71    pub skew_adjustment: f64,
72}
73
74/// Combined probability extraction output.
75#[derive(Debug, Clone, PartialEq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77#[non_exhaustive]
78pub struct ProbabilityExtraction {
79    /// Primary probability estimate (from whichever method was selected).
80    pub primary_probability: f64,
81    /// Which method produced `primary_probability`.
82    pub primary_method: ProbabilityMethod,
83    /// Call-spread result (`None` if epsilon exceeded `max_epsilon` or no
84    /// bracket).
85    pub call_spread: Option<CallSpreadResult>,
86    /// `N(d2)` result (present whenever IV interpolation succeeds at the
87    /// target strike).
88    pub nd2: Nd2Result,
89    /// Absolute disagreement `|call_spread.probability - nd2.probability|`
90    /// when both methods are present; otherwise zero.
91    pub method_disagreement: f64,
92    /// Skew adjustment from N(d2) (`strike_iv - atm_iv`).
93    pub skew_adjustment: f64,
94}
95
96/// Which probability method was used as primary.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99#[non_exhaustive]
100pub enum ProbabilityMethod {
101    /// Call-spread replication using observed bracket strikes.
102    CallSpreadReplication,
103    /// Black-76 `N(d2)` with skew adjustment.
104    Nd2SkewAdjusted,
105}
106
107// ---------------------------------------------------------------------------
108// Call-spread replication
109// ---------------------------------------------------------------------------
110
111/// Compute `P_Q(F_T > target_strike)` via call-spread replication, centered on
112/// the target strike.
113///
114/// Steps out symmetrically from `target_strike` by `eps` (the local observed
115/// half-spacing, clamped to keep both legs inside the smile), prices a call at
116/// `target +/- eps` using the IV interpolated at each leg, and recovers the
117/// risk-neutral probability from the discount-corrected central difference:
118///
119/// ```text
120/// P_Q(F_T > K) ≈ (c(K − ε) − c(K + ε)) / (2ε · exp(−rT))
121/// ```
122///
123/// Centering on the target (rather than on the midpoint of two observed
124/// strikes) removes a strike-location bias.
125///
126/// Returns `None` when:
127/// - the smile cannot bracket `target_strike` (target outside observed range);
128/// - the local observed half-spacing exceeds `max_epsilon` (smile too sparse,
129///   the caller falls back to `N(d2)`);
130/// - IV interpolation fails at either leg;
131/// - the usable step `eps` is non-positive;
132/// - the raw probability falls outside `[0, 1]`, a smile arbitrage (e.g.
133///   `c(K - eps) < c(K + eps)`) reported as unavailable rather than silently
134///   clamped.
135fn 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    // Use the local observed bracket only to size the step and to gate on
144    // smile density; the actual finite difference is centered on the target.
145    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        // Local smile spacing too wide for a reliable call spread; the caller
150        // falls back to N(d2).
151        return None;
152    }
153
154    // Center the difference on the target: the central difference estimates the
155    // CDF at the MIDPOINT of its two legs, so evaluating at observed grid
156    // strikes returns P(F > bracket-midpoint), not P(F > target). Keep both
157    // legs inside the interpolation range.
158    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    // Undo discounting (the `/ df`) so the result is a probability, not a
177    // discounted-probability proxy.
178    let df = (-rate * time_to_expiry).exp();
179    let raw_prob = (c_low - c_high) / (2.0 * epsilon) / df;
180
181    // Do NOT silently clamp. A raw probability outside [0, 1] (or
182    // `c_low < c_high`) signals a smile arbitrage or numerical breakdown;
183    // report it as unavailable rather than returning a plausible-looking
184    // 0.0 / 1.0. A tiny float-noise overshoot is tolerated, then clamped.
185    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
198// ---------------------------------------------------------------------------
199// N(d2)
200// ---------------------------------------------------------------------------
201
202/// Compute `P_Q(F_T > strike)` via Black-76 `N(d2)`.
203///
204/// The skew adjustment is `strike_iv - atm_iv`; pass `None` for `atm_iv`
205/// to suppress it.
206fn 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// ---------------------------------------------------------------------------
225// Public API
226// ---------------------------------------------------------------------------
227
228/// Extract `P_Q(F_T > target_strike)` using both methods and report which
229/// served as primary.
230///
231/// Call-spread replication is preferred whenever it succeeds. When the
232/// bracket half-width exceeds `max_epsilon` (or no bracket exists), the
233/// function falls back to `N(d2)` with skew adjustment. Both estimators run
234/// whenever possible, and the absolute disagreement is reported.
235///
236/// Requires `time_to_expiry > 0`. Returns `None` when the option has expired
237/// (`time_to_expiry <= 0`, where the risk-neutral probability degenerates to
238/// the intrinsic indicator and `N(d2)` is undefined at the money) or when IV
239/// cannot be interpolated at `target_strike` (so `N(d2)` is also unavailable).
240#[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    // At/after expiry `d1_d2` forms `v = sigma*sqrt(T) = 0`, so `N(d2)` is NaN
250    // at the money (0/0) and +/-inf-driven 0/1 off the money, never a usable
251    // probability. Mirror the `t <= 0` intrinsic guards in `pricing`/`greeks`
252    // by reporting the result as unavailable rather than leaking NaN through
253    // `nd2.probability` / `method_disagreement`.
254    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        // Bracket half-width is 5.0; budget 1.0 forces failure.
349        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        // ATM IV = 0.25 (at strike 100), strike 95 has IV = 0.28.
370        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        // Tiny epsilon budget forces call-spread to fail.
393        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    /// Without the `/ df` rescaling the ATM call-spread probability at `r = 5%`
431    /// would settle near `0.5 * exp(-0.05) ~= 0.476`, visibly biased; it must
432    /// remain near `0.5`.
433    #[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        // Cross-check against N(d2): on a flat smile they must agree closely.
449        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    /// For an off-grid target the call spread must estimate `P(F > target)`,
458    /// not `P(F > bracket-midpoint)`. On strikes {90,95,100,105,110}, target 96
459    /// brackets to (95,100) whose midpoint is 97.5, not the target.
460    #[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        // True digital at the target vs at the (wrong) bracket midpoint.
469        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        // The evaluated legs are centered on the target.
484        assert!(((cs.k_lower + cs.k_upper) / 2.0 - target).abs() < 1e-9);
485    }
486
487    /// An arbitraging smile (extreme wing IV makes the higher-strike call worth
488    /// more than the lower-strike one) must yield `None`, not a silently
489    /// clamped `0.0`.
490    #[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), // pathological wing
499        ];
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    /// At/after expiry the extractor returns `None` rather than leaking a NaN
509    /// `nd2.probability` / `method_disagreement` (the `t <= 0` guard mirrors
510    /// the intrinsic guards in `pricing`/`greeks`).
511    #[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    /// For finite `t > 0` inputs no field of the extraction is ever NaN.
519    #[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}