Skip to main content

rustyqlib/core/
vols.rs

1//! Volatility surface infrastructure, mirroring [`crate::core::curves`].
2//!
3//! Design invariants:
4//! - Every input form is canonicalized at construction into per-expiry
5//!   smiles on a strike-like coordinate, so pricing has one query path:
6//!   [`VolSurface::vol`]`(strike, forward, t)`.
7//! - **Time interpolation is linear in total variance** (`w = sigma^2 * t`)
8//!   at a fixed smile coordinate — the industry-standard baseline.
9//! - Strike interpolation is linear in vol on the smile coordinate, with
10//!   flat wing extrapolation; flat vol extrapolation before the first and
11//!   after the last expiry.
12//!
13//! Quoting conventions per axis:
14//! - `strikes` — absolute strikes (equity listed convention). Time
15//!   interpolation at fixed strike (sticky strike).
16//! - `moneyness` — forward moneyness `K/F` (relative strikes). Sticky
17//!   moneyness behavior.
18//! - `deltas` — **forward call deltas** in (0, 1) (FX convention). Quote a
19//!   25-delta put as `0.75` (`= 1 + forward put delta`); ATM-delta-neutral is
20//!   approximately `0.5`. Pillars are converted to log-moneyness at
21//!   construction using each pillar's own quoted vol
22//!   (`ln(K/F) = 0.5*sigma^2*t - sigma*sqrt(t)*inv_N(delta)`), so queries are
23//!   sticky delta.
24
25use chrono::NaiveDate;
26use serde::{Deserialize, Serialize};
27use std::fmt;
28
29use crate::core::curves::Tenor;
30use crate::core::daycount::DayCountConvention;
31use crate::core::utils::inv_norm_cdf;
32
33/// The accepted input forms for a volatility surface. Deserializes from
34/// JSON; canonicalized at construction ([`VolSurface::from_input`]).
35///
36/// For the grid forms, `vols[i][j]` is the vol at `expiries[i]` and the
37/// j-th strike/moneyness/delta.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum VolInput {
41    /// A single constant volatility for all strikes and expiries.
42    Flat {
43        vol: f64,
44        #[serde(default)]
45        day_count: DayCountConvention,
46    },
47    /// Absolute strike x expiry grid (equity convention).
48    StrikeExpiry {
49        expiries: Vec<Tenor>,
50        strikes: Vec<f64>,
51        vols: Vec<Vec<f64>>,
52        #[serde(default)]
53        day_count: DayCountConvention,
54    },
55    /// Forward moneyness (K/F) x expiry grid.
56    MoneynessExpiry {
57        expiries: Vec<Tenor>,
58        moneyness: Vec<f64>,
59        vols: Vec<Vec<f64>>,
60        #[serde(default)]
61        day_count: DayCountConvention,
62    },
63    /// Forward call delta x expiry grid (FX convention).
64    DeltaExpiry {
65        expiries: Vec<Tenor>,
66        deltas: Vec<f64>,
67        vols: Vec<Vec<f64>>,
68        #[serde(default)]
69        day_count: DayCountConvention,
70    },
71}
72
73/// Errors from surface construction.
74#[derive(Debug, Clone, PartialEq)]
75pub enum VolError {
76    Empty,
77    LengthMismatch { expected: usize, got: usize },
78    NonPositiveVol(f64),
79    NonPositiveTime(f64),
80    NonIncreasingTimes,
81    NonIncreasingAxis,
82    DeltaOutOfRange(f64),
83}
84
85impl fmt::Display for VolError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            VolError::Empty => write!(f, "vol surface needs at least one pillar"),
89            VolError::LengthMismatch { expected, got } => {
90                write!(f, "dimension mismatch: expected {expected}, got {got}")
91            }
92            VolError::NonPositiveVol(v) => write!(f, "volatility must be > 0, got {v}"),
93            VolError::NonPositiveTime(t) => write!(f, "expiry time must be > 0, got {t}"),
94            VolError::NonIncreasingTimes => write!(f, "expiry times must be strictly increasing"),
95            VolError::NonIncreasingAxis => {
96                write!(f, "strike/moneyness/delta axis must be strictly increasing")
97            }
98            VolError::DeltaOutOfRange(d) => {
99                write!(f, "forward call delta must be in (0,1), got {d}")
100            }
101        }
102    }
103}
104
105impl std::error::Error for VolError {}
106
107/// How query `(strike, forward)` maps onto the stored smile coordinate.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109enum SmileCoord {
110    /// coordinate = absolute strike (forward unused)
111    Strike,
112    /// coordinate = K/F
113    Moneyness,
114    /// coordinate = ln(K/F)
115    LogMoneyness,
116}
117
118/// One expiry's smile: `(coordinate, vol)` points sorted by coordinate.
119#[derive(Debug, Clone, Serialize)]
120struct Smile {
121    points: Vec<(f64, f64)>,
122}
123
124impl Smile {
125    /// Linear in vol on the coordinate; flat beyond the wings
126    /// (via the shared [`interp_pairs`](crate::core::interpolation::interp_pairs)).
127    fn vol(&self, x: f64) -> f64 {
128        if self.points.len() == 1 {
129            return self.points[0].1;
130        }
131        crate::core::interpolation::interp_pairs(&self.points, x)
132    }
133}
134
135#[derive(Debug, Clone, Serialize)]
136enum SurfaceData {
137    Flat(f64),
138    Term { times: Vec<f64>, smiles: Vec<Smile>, coord: SmileCoord },
139}
140
141// manual impl so SmileCoord needn't be public-serializable
142impl Serialize for SmileCoord {
143    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
144        s.serialize_str(match self {
145            SmileCoord::Strike => "strike",
146            SmileCoord::Moneyness => "moneyness",
147            SmileCoord::LogMoneyness => "log_moneyness",
148        })
149    }
150}
151
152/// A shift applied to a whole surface by [`VolSurface::bumped`]. The
153/// surface owns the semantics: shifts move every quoted vol, preserving
154/// the smile shape and the surface's coordinate system.
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub enum VolShift {
157    /// Add `d` vol points to every quote (e.g. `0.01` = +1 vol point).
158    ParallelAbsolute(f64),
159    /// Scale every quote by `1 + r` (e.g. `0.10` = vols up 10%).
160    ParallelRelative(f64),
161}
162
163/// A canonical Black volatility surface anchored at `reference_date`.
164#[derive(Debug, Clone, Serialize)]
165pub struct VolSurface {
166    reference_date: NaiveDate,
167    day_count: DayCountConvention,
168    data: SurfaceData,
169}
170
171impl VolSurface {
172    // ── Constructors ────────────────────────────────────────────────────
173
174    /// Constant volatility for all strikes and expiries.
175    pub fn flat(
176        vol: f64,
177        reference_date: NaiveDate,
178        day_count: DayCountConvention,
179    ) -> Result<Self, VolError> {
180        if vol <= 0.0 {
181            return Err(VolError::NonPositiveVol(vol));
182        }
183        Ok(VolSurface { reference_date, day_count, data: SurfaceData::Flat(vol) })
184    }
185
186    /// Absolute strike x expiry grid.
187    pub fn from_strike_grid(
188        expiries: &[Tenor],
189        strikes: &[f64],
190        vols: &[Vec<f64>],
191        reference_date: NaiveDate,
192        day_count: DayCountConvention,
193    ) -> Result<Self, VolError> {
194        Self::from_grid(expiries, strikes, vols, reference_date, day_count, SmileCoord::Strike)
195    }
196
197    /// Forward moneyness (K/F) x expiry grid.
198    pub fn from_moneyness_grid(
199        expiries: &[Tenor],
200        moneyness: &[f64],
201        vols: &[Vec<f64>],
202        reference_date: NaiveDate,
203        day_count: DayCountConvention,
204    ) -> Result<Self, VolError> {
205        Self::from_grid(expiries, moneyness, vols, reference_date, day_count, SmileCoord::Moneyness)
206    }
207
208    /// Forward call delta x expiry grid (FX convention). Each pillar is
209    /// converted to log-moneyness with its own quoted vol:
210    /// `ln(K/F) = 0.5*sigma^2*t - sigma*sqrt(t)*inv_N(delta)`.
211    pub fn from_delta_grid(
212        expiries: &[Tenor],
213        deltas: &[f64],
214        vols: &[Vec<f64>],
215        reference_date: NaiveDate,
216        day_count: DayCountConvention,
217    ) -> Result<Self, VolError> {
218        for &d in deltas {
219            if !(d > 0.0 && d < 1.0) {
220                return Err(VolError::DeltaOutOfRange(d));
221            }
222        }
223        let times = Self::resolve_expiries(expiries, reference_date, day_count)?;
224        Self::validate_grid(&times, deltas, vols)?;
225        let smiles = times
226            .iter()
227            .zip(vols)
228            .map(|(&t, row)| {
229                let mut points: Vec<(f64, f64)> = deltas
230                    .iter()
231                    .zip(row)
232                    .map(|(&delta, &sigma)| {
233                        let k = 0.5 * sigma * sigma * t - sigma * t.sqrt() * inv_norm_cdf(delta);
234                        (k, sigma)
235                    })
236                    .collect();
237                points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
238                Smile { points }
239            })
240            .collect();
241        Ok(VolSurface {
242            reference_date,
243            day_count,
244            data: SurfaceData::Term { times, smiles, coord: SmileCoord::LogMoneyness },
245        })
246    }
247
248    /// Per-expiry smiles on absolute strikes, where each expiry may have its
249    /// own strike list (as quoted option chains do): `smiles[i]` is a list of
250    /// `(strike, vol)` points for `expiries[i]`, sorted by strike.
251    pub fn from_strike_smiles(
252        expiries: &[Tenor],
253        smiles: &[Vec<(f64, f64)>],
254        reference_date: NaiveDate,
255        day_count: DayCountConvention,
256    ) -> Result<Self, VolError> {
257        let times = Self::resolve_expiries(expiries, reference_date, day_count)?;
258        if smiles.len() != times.len() {
259            return Err(VolError::LengthMismatch { expected: times.len(), got: smiles.len() });
260        }
261        for smile in smiles {
262            if smile.is_empty() {
263                return Err(VolError::Empty);
264            }
265            for &(_, v) in smile {
266                if v <= 0.0 {
267                    return Err(VolError::NonPositiveVol(v));
268                }
269            }
270            if smile.windows(2).any(|w| w[1].0 <= w[0].0) {
271                return Err(VolError::NonIncreasingAxis);
272            }
273        }
274        let smiles = smiles.iter().map(|points| Smile { points: points.clone() }).collect();
275        Ok(VolSurface {
276            reference_date,
277            day_count,
278            data: SurfaceData::Term { times, smiles, coord: SmileCoord::Strike },
279        })
280    }
281
282    /// Build from a deserialized [`VolInput`], anchored at `reference_date`.
283    pub fn from_input(input: &VolInput, reference_date: NaiveDate) -> Result<Self, VolError> {
284        match input {
285            VolInput::Flat { vol, day_count } => Self::flat(*vol, reference_date, *day_count),
286            VolInput::StrikeExpiry { expiries, strikes, vols, day_count } => {
287                Self::from_strike_grid(expiries, strikes, vols, reference_date, *day_count)
288            }
289            VolInput::MoneynessExpiry { expiries, moneyness, vols, day_count } => {
290                Self::from_moneyness_grid(expiries, moneyness, vols, reference_date, *day_count)
291            }
292            VolInput::DeltaExpiry { expiries, deltas, vols, day_count } => {
293                Self::from_delta_grid(expiries, deltas, vols, reference_date, *day_count)
294            }
295        }
296    }
297
298    // ── Queries ─────────────────────────────────────────────────────────
299
300    /// Black volatility for an option with the given absolute `strike`,
301    /// `forward` price of the underlying at expiry, and year fraction `t`.
302    ///
303    /// Strike dimension: linear in vol, flat wings. Time dimension: linear
304    /// in total variance at the fixed smile coordinate, flat vol before the
305    /// first and after the last expiry pillar.
306    pub fn vol(&self, strike: f64, forward: f64, t: f64) -> f64 {
307        match &self.data {
308            SurfaceData::Flat(v) => *v,
309            SurfaceData::Term { times, smiles, coord } => {
310                let x = match coord {
311                    SmileCoord::Strike => strike,
312                    SmileCoord::Moneyness => strike / forward,
313                    SmileCoord::LogMoneyness => (strike / forward).ln(),
314                };
315                let n = times.len();
316                if t <= times[0] {
317                    return smiles[0].vol(x);
318                }
319                if t >= times[n - 1] {
320                    return smiles[n - 1].vol(x);
321                }
322                let idx = times.partition_point(|&ti| ti < t);
323                let (t0, t1) = (times[idx - 1], times[idx]);
324                let (v0, v1) = (smiles[idx - 1].vol(x), smiles[idx].vol(x));
325                // linear total variance in time at fixed coordinate
326                let (w0, w1) = (v0 * v0 * t0, v1 * v1 * t1);
327                let w = w0 + (w1 - w0) * (t - t0) / (t1 - t0);
328                (w / t).sqrt()
329            }
330        }
331    }
332
333    /// This surface with `shift` applied to every quoted vol — the smile
334    /// shape, coordinate system and expiry pillars are preserved. Errors
335    /// when any bumped vol would be non-positive (a shock that large is a
336    /// data problem, not a market).
337    pub fn bumped(&self, shift: VolShift) -> Result<VolSurface, VolError> {
338        let apply = |v: f64| match shift {
339            VolShift::ParallelAbsolute(d) => v + d,
340            VolShift::ParallelRelative(r) => v * (1.0 + r),
341        };
342        let mut bumped = self.clone();
343        match &mut bumped.data {
344            SurfaceData::Flat(v) => {
345                *v = apply(*v);
346                if *v <= 0.0 {
347                    return Err(VolError::NonPositiveVol(*v));
348                }
349            }
350            SurfaceData::Term { smiles, .. } => {
351                for smile in smiles {
352                    for point in &mut smile.points {
353                        point.1 = apply(point.1);
354                        if point.1 <= 0.0 {
355                            return Err(VolError::NonPositiveVol(point.1));
356                        }
357                    }
358                }
359            }
360        }
361        Ok(bumped)
362    }
363
364    pub fn reference_date(&self) -> NaiveDate {
365        self.reference_date
366    }
367    pub fn day_count(&self) -> DayCountConvention {
368        self.day_count
369    }
370    /// Expiry pillar times (empty for a flat surface).
371    pub fn expiry_times(&self) -> &[f64] {
372        match &self.data {
373            SurfaceData::Flat(_) => &[],
374            SurfaceData::Term { times, .. } => times,
375        }
376    }
377
378    // ── Internals ───────────────────────────────────────────────────────
379
380    fn from_grid(
381        expiries: &[Tenor],
382        axis: &[f64],
383        vols: &[Vec<f64>],
384        reference_date: NaiveDate,
385        day_count: DayCountConvention,
386        coord: SmileCoord,
387    ) -> Result<Self, VolError> {
388        let times = Self::resolve_expiries(expiries, reference_date, day_count)?;
389        Self::validate_grid(&times, axis, vols)?;
390        if axis.windows(2).any(|w| w[1] <= w[0]) {
391            return Err(VolError::NonIncreasingAxis);
392        }
393        let smiles = vols
394            .iter()
395            .map(|row| Smile { points: axis.iter().copied().zip(row.iter().copied()).collect() })
396            .collect();
397        Ok(VolSurface { reference_date, day_count, data: SurfaceData::Term { times, smiles, coord } })
398    }
399
400    fn resolve_expiries(
401        expiries: &[Tenor],
402        reference_date: NaiveDate,
403        day_count: DayCountConvention,
404    ) -> Result<Vec<f64>, VolError> {
405        if expiries.is_empty() {
406            return Err(VolError::Empty);
407        }
408        let times: Vec<f64> = expiries
409            .iter()
410            .map(|tenor| match tenor {
411                Tenor::Date(d) => day_count.year_fraction(reference_date, *d),
412                Tenor::YearFraction(t) => *t,
413            })
414            .collect();
415        for &t in &times {
416            if t <= 0.0 {
417                return Err(VolError::NonPositiveTime(t));
418            }
419        }
420        if times.windows(2).any(|w| w[1] <= w[0]) {
421            return Err(VolError::NonIncreasingTimes);
422        }
423        Ok(times)
424    }
425
426    fn validate_grid(times: &[f64], axis: &[f64], vols: &[Vec<f64>]) -> Result<(), VolError> {
427        if axis.is_empty() {
428            return Err(VolError::Empty);
429        }
430        if vols.len() != times.len() {
431            return Err(VolError::LengthMismatch { expected: times.len(), got: vols.len() });
432        }
433        for row in vols {
434            if row.len() != axis.len() {
435                return Err(VolError::LengthMismatch { expected: axis.len(), got: row.len() });
436            }
437            for &v in row {
438                if v <= 0.0 {
439                    return Err(VolError::NonPositiveVol(v));
440                }
441            }
442        }
443        Ok(())
444    }
445}
446
447impl fmt::Display for VolSurface {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        writeln!(f, "VolSurface (ref {}, {:?})", self.reference_date, self.day_count)?;
450        match &self.data {
451            SurfaceData::Flat(v) => writeln!(f, "  flat vol: {v}"),
452            SurfaceData::Term { times, smiles, coord } => {
453                writeln!(f, "  smile coordinate: {coord:?}")?;
454                for (t, smile) in times.iter().zip(smiles) {
455                    write!(f, "  t={t:<8.4}")?;
456                    for (x, v) in &smile.points {
457                        write!(f, " ({x:.4}, {v:.4})")?;
458                    }
459                    writeln!(f)?;
460                }
461                Ok(())
462            }
463        }
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::core::utils::norm_cdf;
471
472    fn asof() -> NaiveDate {
473        NaiveDate::from_ymd_opt(2026, 7, 16).unwrap()
474    }
475
476    #[test]
477    fn flat_surface_is_constant() {
478        let surface = VolSurface::flat(0.3, asof(), DayCountConvention::Act365).unwrap();
479        assert_eq!(surface.vol(50.0, 100.0, 0.1), 0.3);
480        assert_eq!(surface.vol(200.0, 100.0, 5.0), 0.3);
481    }
482
483    fn strike_grid() -> VolSurface {
484        // expiries 1y and 2y; strikes 90 / 100 / 110
485        VolSurface::from_strike_grid(
486            &[Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
487            &[90.0, 100.0, 110.0],
488            &[vec![0.22, 0.20, 0.19], vec![0.27, 0.25, 0.24]],
489            asof(),
490            DayCountConvention::Act365,
491        )
492        .unwrap()
493    }
494
495    #[test]
496    fn strike_grid_exact_at_pillars() {
497        let s = strike_grid();
498        assert!((s.vol(100.0, 100.0, 1.0) - 0.20).abs() < 1e-14);
499        assert!((s.vol(90.0, 100.0, 2.0) - 0.27).abs() < 1e-14);
500    }
501
502    #[test]
503    fn strike_interpolation_linear_with_flat_wings() {
504        let s = strike_grid();
505        // midway between 90 and 100 at t=1: (0.22+0.20)/2
506        assert!((s.vol(95.0, 100.0, 1.0) - 0.21).abs() < 1e-14);
507        // wings are flat
508        assert!((s.vol(50.0, 100.0, 1.0) - 0.22).abs() < 1e-14);
509        assert!((s.vol(500.0, 100.0, 1.0) - 0.19).abs() < 1e-14);
510    }
511
512    #[test]
513    fn bumped_shifts_every_quote_and_preserves_the_smile() {
514        let flat = VolSurface::flat(0.30, asof(), DayCountConvention::Act365).unwrap();
515        let up = flat.bumped(VolShift::ParallelAbsolute(0.05)).unwrap();
516        assert!((up.vol(100.0, 100.0, 1.0) - 0.35).abs() < 1e-14);
517        let scaled = flat.bumped(VolShift::ParallelRelative(0.10)).unwrap();
518        assert!((scaled.vol(100.0, 100.0, 1.0) - 0.33).abs() < 1e-14);
519
520        let s = strike_grid();
521        let up = s.bumped(VolShift::ParallelAbsolute(0.01)).unwrap();
522        // every pillar moves by the shift; the skew (differences) is intact
523        assert!((up.vol(100.0, 100.0, 1.0) - 0.21).abs() < 1e-14);
524        assert!((up.vol(90.0, 100.0, 2.0) - 0.28).abs() < 1e-14);
525        let skew_base = s.vol(90.0, 100.0, 1.0) - s.vol(110.0, 100.0, 1.0);
526        let skew_up = up.vol(90.0, 100.0, 1.0) - up.vol(110.0, 100.0, 1.0);
527        assert!((skew_base - skew_up).abs() < 1e-14, "parallel shift must keep the skew");
528        // the original is untouched
529        assert!((s.vol(100.0, 100.0, 1.0) - 0.20).abs() < 1e-14);
530    }
531
532    #[test]
533    fn bumped_rejects_non_positive_vols() {
534        let flat = VolSurface::flat(0.20, asof(), DayCountConvention::Act365).unwrap();
535        assert!(matches!(
536            flat.bumped(VolShift::ParallelAbsolute(-0.20)),
537            Err(VolError::NonPositiveVol(_))
538        ));
539        assert!(matches!(
540            strike_grid().bumped(VolShift::ParallelRelative(-1.0)),
541            Err(VolError::NonPositiveVol(_))
542        ));
543    }
544
545    #[test]
546    fn time_interpolation_is_linear_total_variance() {
547        let s = strike_grid();
548        // at K=100: w1 = 0.2^2*1 = 0.04, w2 = 0.25^2*2 = 0.125
549        // at t=1.5: w = 0.0825 -> vol = sqrt(0.0825/1.5)
550        let expected = (0.0825_f64 / 1.5).sqrt();
551        assert!((s.vol(100.0, 100.0, 1.5) - expected).abs() < 1e-12);
552    }
553
554    #[test]
555    fn time_extrapolation_is_flat_vol() {
556        let s = strike_grid();
557        assert!((s.vol(100.0, 100.0, 0.25) - 0.20).abs() < 1e-14); // before first
558        assert!((s.vol(100.0, 100.0, 5.0) - 0.25).abs() < 1e-14); // after last
559    }
560
561    #[test]
562    fn moneyness_grid_uses_forward() {
563        let s = VolSurface::from_moneyness_grid(
564            &[Tenor::YearFraction(1.0)],
565            &[0.9, 1.0, 1.1],
566            &[vec![0.22, 0.20, 0.19]],
567            asof(),
568            DayCountConvention::Act365,
569        )
570        .unwrap();
571        // strike 105 vs forward 105 -> K/F = 1.0 -> ATM vol
572        assert!((s.vol(105.0, 105.0, 1.0) - 0.20).abs() < 1e-14);
573        // strike 94.5 vs forward 105 -> K/F = 0.9
574        assert!((s.vol(94.5, 105.0, 1.0) - 0.22).abs() < 1e-12);
575    }
576
577    #[test]
578    fn delta_grid_round_trips_pillar_quotes() {
579        // FX-style smile at t=1: 25d call 19%, ATM 20%, 25d put (0.75) 23%
580        let t = 1.0_f64;
581        let deltas = [0.25, 0.5, 0.75];
582        let vols = [0.19, 0.20, 0.23];
583        let s = VolSurface::from_delta_grid(
584            &[Tenor::YearFraction(t)],
585            &deltas,
586            &[vols.to_vec()],
587            asof(),
588            DayCountConvention::Act365,
589        )
590        .unwrap();
591        let forward = 100.0;
592        for (&delta, &sigma) in deltas.iter().zip(&vols) {
593            // strike implied by the pillar's own quote
594            let k = 0.5 * sigma * sigma * t - sigma * t.sqrt() * inv_norm_cdf(delta);
595            let strike = forward * k.exp();
596            assert!(
597                (s.vol(strike, forward, t) - sigma).abs() < 1e-10,
598                "delta {delta}: {} vs {sigma}",
599                s.vol(strike, forward, t)
600            );
601            // and the strike really has that forward delta under its vol
602            let d1 = ((forward / strike).ln() + 0.5 * sigma * sigma * t) / (sigma * t.sqrt());
603            assert!((norm_cdf(d1) - delta).abs() < 1e-10);
604        }
605        // put wing (low strike = high call delta) has the higher vol
606        assert!(s.vol(80.0, forward, t) > s.vol(120.0, forward, t));
607    }
608
609    #[test]
610    fn inv_norm_cdf_round_trip() {
611        for i in -60..=60 {
612            let x = i as f64 / 10.0;
613            let p = norm_cdf(x);
614            if p > 0.0 && p < 1.0 {
615                // in the far tails a 1-ulp error in p maps to ~1e-8 in x
616                // (dp/dx = phi(x) is tiny there) — that is the attainable
617                // double-precision accuracy, not an approximation error
618                let tol = if x.abs() <= 4.5 { 1e-9 } else { 5e-8 };
619                assert!(
620                    (inv_norm_cdf(p) - x).abs() < tol,
621                    "x={x}: inv_norm_cdf(norm_cdf(x))={}",
622                    inv_norm_cdf(p)
623                );
624            }
625        }
626        assert!(inv_norm_cdf(0.0).is_nan());
627        assert!(inv_norm_cdf(1.0).is_nan());
628    }
629
630    #[test]
631    fn vol_input_deserializes_from_json() {
632        let flat: VolInput = serde_json::from_str(r#"{"type": "flat", "vol": 0.3}"#).unwrap();
633        let s = VolSurface::from_input(&flat, asof()).unwrap();
634        assert_eq!(s.vol(100.0, 100.0, 1.0), 0.3);
635
636        let grid: VolInput = serde_json::from_str(
637            r#"{
638                "type": "strike_expiry",
639                "expiries": [0.5, "2028-07-16"],
640                "strikes": [90.0, 100.0, 110.0],
641                "vols": [[0.22, 0.20, 0.19], [0.26, 0.24, 0.23]],
642                "day_count": "Act365"
643            }"#,
644        )
645        .unwrap();
646        let s = VolSurface::from_input(&grid, asof()).unwrap();
647        assert!((s.vol(100.0, 100.0, 0.5) - 0.20).abs() < 1e-14);
648
649        let delta: VolInput = serde_json::from_str(
650            r#"{
651                "type": "delta_expiry",
652                "expiries": [1.0],
653                "deltas": [0.25, 0.5, 0.75],
654                "vols": [[0.19, 0.20, 0.23]]
655            }"#,
656        )
657        .unwrap();
658        assert!(VolSurface::from_input(&delta, asof()).is_ok());
659    }
660
661    #[test]
662    fn validation_errors() {
663        let dc = DayCountConvention::Act365;
664        assert_eq!(VolSurface::flat(0.0, asof(), dc).unwrap_err(), VolError::NonPositiveVol(0.0));
665        assert_eq!(
666            VolSurface::from_strike_grid(&[], &[100.0], &[], asof(), dc).unwrap_err(),
667            VolError::Empty
668        );
669        assert!(matches!(
670            VolSurface::from_strike_grid(
671                &[Tenor::YearFraction(1.0)],
672                &[90.0, 100.0],
673                &[vec![0.2]],
674                asof(),
675                dc
676            )
677            .unwrap_err(),
678            VolError::LengthMismatch { .. }
679        ));
680        assert_eq!(
681            VolSurface::from_strike_grid(
682                &[Tenor::YearFraction(1.0)],
683                &[100.0, 90.0],
684                &[vec![0.2, 0.2]],
685                asof(),
686                dc
687            )
688            .unwrap_err(),
689            VolError::NonIncreasingAxis
690        );
691        assert_eq!(
692            VolSurface::from_delta_grid(
693                &[Tenor::YearFraction(1.0)],
694                &[1.5],
695                &[vec![0.2]],
696                asof(),
697                dc
698            )
699            .unwrap_err(),
700            VolError::DeltaOutOfRange(1.5)
701        );
702    }
703}