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_N;
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    fn vol(&self, x: f64) -> f64 {
127        let pts = &self.points;
128        let n = pts.len();
129        if x <= pts[0].0 {
130            return pts[0].1;
131        }
132        if x >= pts[n - 1].0 {
133            return pts[n - 1].1;
134        }
135        let idx = pts.partition_point(|&(xi, _)| xi < x);
136        let (x0, v0) = pts[idx - 1];
137        let (x1, v1) = pts[idx];
138        let w = (x - x0) / (x1 - x0);
139        v0 * (1.0 - w) + v1 * w
140    }
141}
142
143#[derive(Debug, Clone, Serialize)]
144enum SurfaceData {
145    Flat(f64),
146    Term { times: Vec<f64>, smiles: Vec<Smile>, coord: SmileCoord },
147}
148
149// manual impl so SmileCoord needn't be public-serializable
150impl Serialize for SmileCoord {
151    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
152        s.serialize_str(match self {
153            SmileCoord::Strike => "strike",
154            SmileCoord::Moneyness => "moneyness",
155            SmileCoord::LogMoneyness => "log_moneyness",
156        })
157    }
158}
159
160/// A canonical Black volatility surface anchored at `reference_date`.
161#[derive(Debug, Clone, Serialize)]
162pub struct VolSurface {
163    reference_date: NaiveDate,
164    day_count: DayCountConvention,
165    data: SurfaceData,
166}
167
168impl VolSurface {
169    // ── Constructors ────────────────────────────────────────────────────
170
171    /// Constant volatility for all strikes and expiries.
172    pub fn flat(
173        vol: f64,
174        reference_date: NaiveDate,
175        day_count: DayCountConvention,
176    ) -> Result<Self, VolError> {
177        if vol <= 0.0 {
178            return Err(VolError::NonPositiveVol(vol));
179        }
180        Ok(VolSurface { reference_date, day_count, data: SurfaceData::Flat(vol) })
181    }
182
183    /// Absolute strike x expiry grid.
184    pub fn from_strike_grid(
185        expiries: &[Tenor],
186        strikes: &[f64],
187        vols: &[Vec<f64>],
188        reference_date: NaiveDate,
189        day_count: DayCountConvention,
190    ) -> Result<Self, VolError> {
191        Self::from_grid(expiries, strikes, vols, reference_date, day_count, SmileCoord::Strike)
192    }
193
194    /// Forward moneyness (K/F) x expiry grid.
195    pub fn from_moneyness_grid(
196        expiries: &[Tenor],
197        moneyness: &[f64],
198        vols: &[Vec<f64>],
199        reference_date: NaiveDate,
200        day_count: DayCountConvention,
201    ) -> Result<Self, VolError> {
202        Self::from_grid(expiries, moneyness, vols, reference_date, day_count, SmileCoord::Moneyness)
203    }
204
205    /// Forward call delta x expiry grid (FX convention). Each pillar is
206    /// converted to log-moneyness with its own quoted vol:
207    /// `ln(K/F) = 0.5*sigma^2*t - sigma*sqrt(t)*inv_N(delta)`.
208    pub fn from_delta_grid(
209        expiries: &[Tenor],
210        deltas: &[f64],
211        vols: &[Vec<f64>],
212        reference_date: NaiveDate,
213        day_count: DayCountConvention,
214    ) -> Result<Self, VolError> {
215        for &d in deltas {
216            if !(d > 0.0 && d < 1.0) {
217                return Err(VolError::DeltaOutOfRange(d));
218            }
219        }
220        let times = Self::resolve_expiries(expiries, reference_date, day_count)?;
221        Self::validate_grid(&times, deltas, vols)?;
222        let smiles = times
223            .iter()
224            .zip(vols)
225            .map(|(&t, row)| {
226                let mut points: Vec<(f64, f64)> = deltas
227                    .iter()
228                    .zip(row)
229                    .map(|(&delta, &sigma)| {
230                        let k = 0.5 * sigma * sigma * t - sigma * t.sqrt() * inv_N(delta);
231                        (k, sigma)
232                    })
233                    .collect();
234                points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
235                Smile { points }
236            })
237            .collect();
238        Ok(VolSurface {
239            reference_date,
240            day_count,
241            data: SurfaceData::Term { times, smiles, coord: SmileCoord::LogMoneyness },
242        })
243    }
244
245    /// Per-expiry smiles on absolute strikes, where each expiry may have its
246    /// own strike list (as quoted option chains do): `smiles[i]` is a list of
247    /// `(strike, vol)` points for `expiries[i]`, sorted by strike.
248    pub fn from_strike_smiles(
249        expiries: &[Tenor],
250        smiles: &[Vec<(f64, f64)>],
251        reference_date: NaiveDate,
252        day_count: DayCountConvention,
253    ) -> Result<Self, VolError> {
254        let times = Self::resolve_expiries(expiries, reference_date, day_count)?;
255        if smiles.len() != times.len() {
256            return Err(VolError::LengthMismatch { expected: times.len(), got: smiles.len() });
257        }
258        for smile in smiles {
259            if smile.is_empty() {
260                return Err(VolError::Empty);
261            }
262            for &(_, v) in smile {
263                if v <= 0.0 {
264                    return Err(VolError::NonPositiveVol(v));
265                }
266            }
267            if smile.windows(2).any(|w| w[1].0 <= w[0].0) {
268                return Err(VolError::NonIncreasingAxis);
269            }
270        }
271        let smiles = smiles.iter().map(|points| Smile { points: points.clone() }).collect();
272        Ok(VolSurface {
273            reference_date,
274            day_count,
275            data: SurfaceData::Term { times, smiles, coord: SmileCoord::Strike },
276        })
277    }
278
279    /// Build from a deserialized [`VolInput`], anchored at `reference_date`.
280    pub fn from_input(input: &VolInput, reference_date: NaiveDate) -> Result<Self, VolError> {
281        match input {
282            VolInput::Flat { vol, day_count } => Self::flat(*vol, reference_date, *day_count),
283            VolInput::StrikeExpiry { expiries, strikes, vols, day_count } => {
284                Self::from_strike_grid(expiries, strikes, vols, reference_date, *day_count)
285            }
286            VolInput::MoneynessExpiry { expiries, moneyness, vols, day_count } => {
287                Self::from_moneyness_grid(expiries, moneyness, vols, reference_date, *day_count)
288            }
289            VolInput::DeltaExpiry { expiries, deltas, vols, day_count } => {
290                Self::from_delta_grid(expiries, deltas, vols, reference_date, *day_count)
291            }
292        }
293    }
294
295    // ── Queries ─────────────────────────────────────────────────────────
296
297    /// Black volatility for an option with the given absolute `strike`,
298    /// `forward` price of the underlying at expiry, and year fraction `t`.
299    ///
300    /// Strike dimension: linear in vol, flat wings. Time dimension: linear
301    /// in total variance at the fixed smile coordinate, flat vol before the
302    /// first and after the last expiry pillar.
303    pub fn vol(&self, strike: f64, forward: f64, t: f64) -> f64 {
304        match &self.data {
305            SurfaceData::Flat(v) => *v,
306            SurfaceData::Term { times, smiles, coord } => {
307                let x = match coord {
308                    SmileCoord::Strike => strike,
309                    SmileCoord::Moneyness => strike / forward,
310                    SmileCoord::LogMoneyness => (strike / forward).ln(),
311                };
312                let n = times.len();
313                if t <= times[0] {
314                    return smiles[0].vol(x);
315                }
316                if t >= times[n - 1] {
317                    return smiles[n - 1].vol(x);
318                }
319                let idx = times.partition_point(|&ti| ti < t);
320                let (t0, t1) = (times[idx - 1], times[idx]);
321                let (v0, v1) = (smiles[idx - 1].vol(x), smiles[idx].vol(x));
322                // linear total variance in time at fixed coordinate
323                let (w0, w1) = (v0 * v0 * t0, v1 * v1 * t1);
324                let w = w0 + (w1 - w0) * (t - t0) / (t1 - t0);
325                (w / t).sqrt()
326            }
327        }
328    }
329
330    pub fn reference_date(&self) -> NaiveDate {
331        self.reference_date
332    }
333    pub fn day_count(&self) -> DayCountConvention {
334        self.day_count
335    }
336    /// Expiry pillar times (empty for a flat surface).
337    pub fn expiry_times(&self) -> &[f64] {
338        match &self.data {
339            SurfaceData::Flat(_) => &[],
340            SurfaceData::Term { times, .. } => times,
341        }
342    }
343
344    // ── Internals ───────────────────────────────────────────────────────
345
346    fn from_grid(
347        expiries: &[Tenor],
348        axis: &[f64],
349        vols: &[Vec<f64>],
350        reference_date: NaiveDate,
351        day_count: DayCountConvention,
352        coord: SmileCoord,
353    ) -> Result<Self, VolError> {
354        let times = Self::resolve_expiries(expiries, reference_date, day_count)?;
355        Self::validate_grid(&times, axis, vols)?;
356        if axis.windows(2).any(|w| w[1] <= w[0]) {
357            return Err(VolError::NonIncreasingAxis);
358        }
359        let smiles = vols
360            .iter()
361            .map(|row| Smile { points: axis.iter().copied().zip(row.iter().copied()).collect() })
362            .collect();
363        Ok(VolSurface { reference_date, day_count, data: SurfaceData::Term { times, smiles, coord } })
364    }
365
366    fn resolve_expiries(
367        expiries: &[Tenor],
368        reference_date: NaiveDate,
369        day_count: DayCountConvention,
370    ) -> Result<Vec<f64>, VolError> {
371        if expiries.is_empty() {
372            return Err(VolError::Empty);
373        }
374        let times: Vec<f64> = expiries
375            .iter()
376            .map(|tenor| match tenor {
377                Tenor::Date(d) => day_count.year_fraction(reference_date, *d),
378                Tenor::YearFraction(t) => *t,
379            })
380            .collect();
381        for &t in &times {
382            if t <= 0.0 {
383                return Err(VolError::NonPositiveTime(t));
384            }
385        }
386        if times.windows(2).any(|w| w[1] <= w[0]) {
387            return Err(VolError::NonIncreasingTimes);
388        }
389        Ok(times)
390    }
391
392    fn validate_grid(times: &[f64], axis: &[f64], vols: &[Vec<f64>]) -> Result<(), VolError> {
393        if axis.is_empty() {
394            return Err(VolError::Empty);
395        }
396        if vols.len() != times.len() {
397            return Err(VolError::LengthMismatch { expected: times.len(), got: vols.len() });
398        }
399        for row in vols {
400            if row.len() != axis.len() {
401                return Err(VolError::LengthMismatch { expected: axis.len(), got: row.len() });
402            }
403            for &v in row {
404                if v <= 0.0 {
405                    return Err(VolError::NonPositiveVol(v));
406                }
407            }
408        }
409        Ok(())
410    }
411}
412
413impl fmt::Display for VolSurface {
414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        writeln!(f, "VolSurface (ref {}, {:?})", self.reference_date, self.day_count)?;
416        match &self.data {
417            SurfaceData::Flat(v) => writeln!(f, "  flat vol: {v}"),
418            SurfaceData::Term { times, smiles, coord } => {
419                writeln!(f, "  smile coordinate: {coord:?}")?;
420                for (t, smile) in times.iter().zip(smiles) {
421                    write!(f, "  t={t:<8.4}")?;
422                    for (x, v) in &smile.points {
423                        write!(f, " ({x:.4}, {v:.4})")?;
424                    }
425                    writeln!(f)?;
426                }
427                Ok(())
428            }
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::core::utils::N;
437
438    fn asof() -> NaiveDate {
439        NaiveDate::from_ymd_opt(2026, 7, 16).unwrap()
440    }
441
442    #[test]
443    fn flat_surface_is_constant() {
444        let surface = VolSurface::flat(0.3, asof(), DayCountConvention::Act365).unwrap();
445        assert_eq!(surface.vol(50.0, 100.0, 0.1), 0.3);
446        assert_eq!(surface.vol(200.0, 100.0, 5.0), 0.3);
447    }
448
449    fn strike_grid() -> VolSurface {
450        // expiries 1y and 2y; strikes 90 / 100 / 110
451        VolSurface::from_strike_grid(
452            &[Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
453            &[90.0, 100.0, 110.0],
454            &[vec![0.22, 0.20, 0.19], vec![0.27, 0.25, 0.24]],
455            asof(),
456            DayCountConvention::Act365,
457        )
458        .unwrap()
459    }
460
461    #[test]
462    fn strike_grid_exact_at_pillars() {
463        let s = strike_grid();
464        assert!((s.vol(100.0, 100.0, 1.0) - 0.20).abs() < 1e-14);
465        assert!((s.vol(90.0, 100.0, 2.0) - 0.27).abs() < 1e-14);
466    }
467
468    #[test]
469    fn strike_interpolation_linear_with_flat_wings() {
470        let s = strike_grid();
471        // midway between 90 and 100 at t=1: (0.22+0.20)/2
472        assert!((s.vol(95.0, 100.0, 1.0) - 0.21).abs() < 1e-14);
473        // wings are flat
474        assert!((s.vol(50.0, 100.0, 1.0) - 0.22).abs() < 1e-14);
475        assert!((s.vol(500.0, 100.0, 1.0) - 0.19).abs() < 1e-14);
476    }
477
478    #[test]
479    fn time_interpolation_is_linear_total_variance() {
480        let s = strike_grid();
481        // at K=100: w1 = 0.2^2*1 = 0.04, w2 = 0.25^2*2 = 0.125
482        // at t=1.5: w = 0.0825 -> vol = sqrt(0.0825/1.5)
483        let expected = (0.0825_f64 / 1.5).sqrt();
484        assert!((s.vol(100.0, 100.0, 1.5) - expected).abs() < 1e-12);
485    }
486
487    #[test]
488    fn time_extrapolation_is_flat_vol() {
489        let s = strike_grid();
490        assert!((s.vol(100.0, 100.0, 0.25) - 0.20).abs() < 1e-14); // before first
491        assert!((s.vol(100.0, 100.0, 5.0) - 0.25).abs() < 1e-14); // after last
492    }
493
494    #[test]
495    fn moneyness_grid_uses_forward() {
496        let s = VolSurface::from_moneyness_grid(
497            &[Tenor::YearFraction(1.0)],
498            &[0.9, 1.0, 1.1],
499            &[vec![0.22, 0.20, 0.19]],
500            asof(),
501            DayCountConvention::Act365,
502        )
503        .unwrap();
504        // strike 105 vs forward 105 -> K/F = 1.0 -> ATM vol
505        assert!((s.vol(105.0, 105.0, 1.0) - 0.20).abs() < 1e-14);
506        // strike 94.5 vs forward 105 -> K/F = 0.9
507        assert!((s.vol(94.5, 105.0, 1.0) - 0.22).abs() < 1e-12);
508    }
509
510    #[test]
511    fn delta_grid_round_trips_pillar_quotes() {
512        // FX-style smile at t=1: 25d call 19%, ATM 20%, 25d put (0.75) 23%
513        let t = 1.0_f64;
514        let deltas = [0.25, 0.5, 0.75];
515        let vols = [0.19, 0.20, 0.23];
516        let s = VolSurface::from_delta_grid(
517            &[Tenor::YearFraction(t)],
518            &deltas,
519            &[vols.to_vec()],
520            asof(),
521            DayCountConvention::Act365,
522        )
523        .unwrap();
524        let forward = 100.0;
525        for (&delta, &sigma) in deltas.iter().zip(&vols) {
526            // strike implied by the pillar's own quote
527            let k = 0.5 * sigma * sigma * t - sigma * t.sqrt() * inv_N(delta);
528            let strike = forward * k.exp();
529            assert!(
530                (s.vol(strike, forward, t) - sigma).abs() < 1e-10,
531                "delta {delta}: {} vs {sigma}",
532                s.vol(strike, forward, t)
533            );
534            // and the strike really has that forward delta under its vol
535            let d1 = ((forward / strike).ln() + 0.5 * sigma * sigma * t) / (sigma * t.sqrt());
536            assert!((N(d1) - delta).abs() < 1e-10);
537        }
538        // put wing (low strike = high call delta) has the higher vol
539        assert!(s.vol(80.0, forward, t) > s.vol(120.0, forward, t));
540    }
541
542    #[test]
543    fn inv_norm_cdf_round_trip() {
544        for i in -60..=60 {
545            let x = i as f64 / 10.0;
546            let p = N(x);
547            if p > 0.0 && p < 1.0 {
548                // in the far tails a 1-ulp error in p maps to ~1e-8 in x
549                // (dp/dx = phi(x) is tiny there) — that is the attainable
550                // double-precision accuracy, not an approximation error
551                let tol = if x.abs() <= 4.5 { 1e-9 } else { 5e-8 };
552                assert!(
553                    (inv_N(p) - x).abs() < tol,
554                    "x={x}: inv_N(N(x))={}",
555                    inv_N(p)
556                );
557            }
558        }
559        assert!(inv_N(0.0).is_nan());
560        assert!(inv_N(1.0).is_nan());
561    }
562
563    #[test]
564    fn vol_input_deserializes_from_json() {
565        let flat: VolInput = serde_json::from_str(r#"{"type": "flat", "vol": 0.3}"#).unwrap();
566        let s = VolSurface::from_input(&flat, asof()).unwrap();
567        assert_eq!(s.vol(100.0, 100.0, 1.0), 0.3);
568
569        let grid: VolInput = serde_json::from_str(
570            r#"{
571                "type": "strike_expiry",
572                "expiries": [0.5, "2028-07-16"],
573                "strikes": [90.0, 100.0, 110.0],
574                "vols": [[0.22, 0.20, 0.19], [0.26, 0.24, 0.23]],
575                "day_count": "Act365"
576            }"#,
577        )
578        .unwrap();
579        let s = VolSurface::from_input(&grid, asof()).unwrap();
580        assert!((s.vol(100.0, 100.0, 0.5) - 0.20).abs() < 1e-14);
581
582        let delta: VolInput = serde_json::from_str(
583            r#"{
584                "type": "delta_expiry",
585                "expiries": [1.0],
586                "deltas": [0.25, 0.5, 0.75],
587                "vols": [[0.19, 0.20, 0.23]]
588            }"#,
589        )
590        .unwrap();
591        assert!(VolSurface::from_input(&delta, asof()).is_ok());
592    }
593
594    #[test]
595    fn validation_errors() {
596        let dc = DayCountConvention::Act365;
597        assert_eq!(VolSurface::flat(0.0, asof(), dc).unwrap_err(), VolError::NonPositiveVol(0.0));
598        assert_eq!(
599            VolSurface::from_strike_grid(&[], &[100.0], &[], asof(), dc).unwrap_err(),
600            VolError::Empty
601        );
602        assert!(matches!(
603            VolSurface::from_strike_grid(
604                &[Tenor::YearFraction(1.0)],
605                &[90.0, 100.0],
606                &[vec![0.2]],
607                asof(),
608                dc
609            )
610            .unwrap_err(),
611            VolError::LengthMismatch { .. }
612        ));
613        assert_eq!(
614            VolSurface::from_strike_grid(
615                &[Tenor::YearFraction(1.0)],
616                &[100.0, 90.0],
617                &[vec![0.2, 0.2]],
618                asof(),
619                dc
620            )
621            .unwrap_err(),
622            VolError::NonIncreasingAxis
623        );
624        assert_eq!(
625            VolSurface::from_delta_grid(
626                &[Tenor::YearFraction(1.0)],
627                &[1.5],
628                &[vec![0.2]],
629                asof(),
630                dc
631            )
632            .unwrap_err(),
633            VolError::DeltaOutOfRange(1.5)
634        );
635    }
636}