Skip to main content

black_76/
vol_surface.rs

1//! Per-expiry implied volatility smile with linear-in-strike interpolation.
2//!
3//! Construct a [`VolSmile`] from raw (strike, IV) observations with quality
4//! filtering, linear interpolation between observed points, and flat
5//! extrapolation beyond boundary strikes. Quality tiers
6//! ([`SmileQuality::Good`] / [`Minimum`](SmileQuality::Minimum) /
7//! [`Degraded`](SmileQuality::Degraded) / [`Empty`](SmileQuality::Empty))
8//! reflect surface reliability for downstream confidence.
9//!
10//! # Arbitrage
11//!
12//! Linear-in-strike interpolation and flat-wing extrapolation are **not**
13//! guaranteed arbitrage-free: the implied risk-neutral density (`d^2 C / dK^2`,
14//! per Breeden-Litzenberger) can go negative between nodes, so digitals derived
15//! from this smile may be locally inconsistent. This is a lightweight smoothing
16//! utility; for arbitrage-free surfaces use a total-variance or SVI/SABR
17//! parameterization (Gatheral, *The Volatility Surface*) or arbitrage-free
18//! smoothing (Fengler 2009).
19//!
20//! # Quick start
21//!
22//! ```
23//! use black_76::vol_surface::{SmilePoint, VolSmile, VolSurfaceConfig};
24//!
25//! let config = VolSurfaceConfig::default();
26//! let points = vec![
27//!     SmilePoint::new(90.0, 0.32, 0.31, 0.33),
28//!     SmilePoint::new(95.0, 0.28, 0.27, 0.29),
29//!     SmilePoint::new(100.0, 0.25, 0.245, 0.255),
30//!     SmilePoint::new(105.0, 0.27, 0.265, 0.275),
31//!     SmilePoint::new(110.0, 0.31, 0.30, 0.32),
32//! ];
33//! let smile = VolSmile::new(None, points, &config, 100.0);
34//! let iv = smile.interpolate(102.5).unwrap();
35//! assert!((iv - 0.26).abs() < 1e-9);
36//! ```
37
38// ---------------------------------------------------------------------------
39// VolSurfaceConfig
40// ---------------------------------------------------------------------------
41
42/// Configuration for vol-surface construction.
43///
44/// Controls quality filtering and tier thresholds when building a [`VolSmile`]
45/// from raw observations. Construct via [`VolSurfaceConfig::default`] for
46/// sensible defaults, or via [`VolSurfaceConfig::builder`] for selective
47/// overrides:
48///
49/// ```
50/// use black_76::vol_surface::VolSurfaceConfig;
51///
52/// let config = VolSurfaceConfig::builder()
53///     .min_usable_strikes(2)
54///     .good_strike_count(7)
55///     .max_iv_spread_filter(0.30)
56///     .build();
57/// assert_eq!(config.min_usable_strikes, 2);
58/// ```
59///
60/// New fields may be added in future minor versions.
61#[derive(Debug, Clone, PartialEq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63#[cfg_attr(feature = "serde", serde(default))]
64#[non_exhaustive]
65pub struct VolSurfaceConfig {
66    /// Minimum usable strikes per expiry to interpolate (below this, falls
67    /// back to flat ATM vol).
68    pub min_usable_strikes: usize,
69    /// Strike count at or above which the smile is rated [`SmileQuality::Good`].
70    pub good_strike_count: usize,
71    /// Maximum IV bid-ask spread to retain a strike. Strikes with wider spread
72    /// are excluded from the smile.
73    pub max_iv_spread_filter: f64,
74}
75
76impl Default for VolSurfaceConfig {
77    fn default() -> Self {
78        Self {
79            min_usable_strikes: 3,
80            good_strike_count: 5,
81            max_iv_spread_filter: 0.50,
82        }
83    }
84}
85
86impl VolSurfaceConfig {
87    /// Returns a fresh builder seeded with the default config.
88    pub fn builder() -> VolSurfaceConfigBuilder {
89        VolSurfaceConfigBuilder {
90            inner: VolSurfaceConfig::default(),
91        }
92    }
93}
94
95/// Builder for [`VolSurfaceConfig`].
96///
97/// Each setter consumes `self` and returns a new builder; call
98/// [`build`](Self::build) to produce the final config. All setters are
99/// `const fn`, so a config can be assembled in a `const` context.
100#[derive(Debug, Clone)]
101#[must_use = "a builder does nothing unless you call `.build()`"]
102pub struct VolSurfaceConfigBuilder {
103    inner: VolSurfaceConfig,
104}
105
106impl VolSurfaceConfigBuilder {
107    /// Sets `min_usable_strikes`.
108    pub const fn min_usable_strikes(mut self, value: usize) -> Self {
109        self.inner.min_usable_strikes = value;
110        self
111    }
112
113    /// Sets `good_strike_count`.
114    pub const fn good_strike_count(mut self, value: usize) -> Self {
115        self.inner.good_strike_count = value;
116        self
117    }
118
119    /// Sets `max_iv_spread_filter`.
120    pub const fn max_iv_spread_filter(mut self, value: f64) -> Self {
121        self.inner.max_iv_spread_filter = value;
122        self
123    }
124
125    /// Consumes the builder and returns the configured [`VolSurfaceConfig`].
126    #[must_use]
127    pub const fn build(self) -> VolSurfaceConfig {
128        self.inner
129    }
130}
131
132// ---------------------------------------------------------------------------
133// SmilePoint
134// ---------------------------------------------------------------------------
135
136/// A single observed point on the vol smile.
137#[derive(Debug, Clone, PartialEq)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139#[non_exhaustive]
140pub struct SmilePoint {
141    /// Strike price.
142    pub strike: f64,
143    /// Mid implied volatility (annualized).
144    pub iv: f64,
145    /// Bid-side implied volatility.
146    pub bid_iv: f64,
147    /// Ask-side implied volatility.
148    pub ask_iv: f64,
149    /// IV bid-ask spread (`ask_iv - bid_iv`).
150    pub iv_spread: f64,
151}
152
153impl SmilePoint {
154    /// Construct a smile point. The bid-ask spread is computed as
155    /// `ask_iv - bid_iv`.
156    #[must_use]
157    pub fn new(strike: f64, iv: f64, bid_iv: f64, ask_iv: f64) -> Self {
158        Self {
159            strike,
160            iv,
161            bid_iv,
162            ask_iv,
163            iv_spread: ask_iv - bid_iv,
164        }
165    }
166}
167
168// ---------------------------------------------------------------------------
169// SmileQuality
170// ---------------------------------------------------------------------------
171
172/// Quality tier reflecting vol-smile reliability.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
175#[non_exhaustive]
176pub enum SmileQuality {
177    /// `good_strike_count` or more usable strikes; reliable interpolation.
178    Good,
179    /// Between `min_usable_strikes` and `good_strike_count - 1` usable strikes;
180    /// the minimum for interpolation.
181    Minimum,
182    /// Fewer than `min_usable_strikes`; falls back to flat ATM vol.
183    Degraded,
184    /// Zero usable strikes; no data.
185    Empty,
186}
187
188// ---------------------------------------------------------------------------
189// VolSmile
190// ---------------------------------------------------------------------------
191
192/// Per-expiry implied-volatility smile with quality filtering.
193///
194/// Points are always sorted by strike ascending. Quality filtering excludes
195/// strikes with excessive IV bid-ask spread or non-positive IV.
196///
197/// The `expiry` field is a free-form label; pass `None` if you don't track
198/// expiry timestamps, or `Some(unix_seconds)` if you do. The smile math
199/// itself does not consume the expiry value.
200#[derive(Debug, Clone, PartialEq)]
201#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
202#[non_exhaustive]
203pub struct VolSmile {
204    /// Expiry label (UNIX seconds), or `None` if untracked.
205    pub expiry: Option<i64>,
206    /// Usable smile points, sorted by strike ascending.
207    pub points: Vec<SmilePoint>,
208    /// Excluded strikes with reasons (strike, reason).
209    pub excluded: Vec<(f64, String)>,
210    /// Quality tier based on remaining point count.
211    pub quality: SmileQuality,
212    /// ATM implied volatility (nearest to forward price).
213    pub atm_iv: Option<f64>,
214}
215
216impl VolSmile {
217    /// Construct a vol smile from raw observations with quality filtering.
218    ///
219    /// Filters out points with excessive IV spread or non-positive IV,
220    /// sorts the remaining points by strike, determines the quality tier,
221    /// and identifies ATM IV as the point nearest to the forward price.
222    #[must_use]
223    pub fn new(
224        expiry: Option<i64>,
225        raw_points: Vec<SmilePoint>,
226        config: &VolSurfaceConfig,
227        forward_price: f64,
228    ) -> Self {
229        let mut points = Vec::with_capacity(raw_points.len());
230        let mut excluded = Vec::new();
231
232        for p in raw_points {
233            // Reject non-finite observations first: a NaN strike slips the
234            // `iv <= 0.0` test below (NaN comparisons are false), survives the
235            // `partial_cmp(..).unwrap_or(Equal)` sort as "equal" (leaving the
236            // points vec unsorted), and then poisons `interpolate` /
237            // `nearest_bracket` (both assume a sorted slice) with NaN. A
238            // non-finite IV is equally unusable. Drop both so the
239            // sorted-ascending invariant always holds.
240            if !p.strike.is_finite() || !p.iv.is_finite() {
241                excluded.push((p.strike, "non-finite strike/IV".to_string()));
242                continue;
243            }
244
245            if p.iv <= 0.0 {
246                excluded.push((p.strike, "non-positive IV".to_string()));
247                continue;
248            }
249
250            if p.iv_spread > config.max_iv_spread_filter {
251                excluded.push((
252                    p.strike,
253                    format!(
254                        "iv_spread={:.2} exceeds max {:.2}",
255                        p.iv_spread, config.max_iv_spread_filter
256                    ),
257                ));
258                continue;
259            }
260
261            points.push(p);
262        }
263
264        points.sort_by(|a, b| {
265            a.strike
266                .partial_cmp(&b.strike)
267                .unwrap_or(std::cmp::Ordering::Equal)
268        });
269
270        let atm_iv = if points.is_empty() {
271            None
272        } else {
273            let mut closest = &points[0];
274            let mut min_dist = (closest.strike - forward_price).abs();
275            for p in &points[1..] {
276                let dist = (p.strike - forward_price).abs();
277                if dist < min_dist {
278                    min_dist = dist;
279                    closest = p;
280                }
281            }
282            Some(closest.iv)
283        };
284
285        let count = points.len();
286        let quality = if count == 0 {
287            SmileQuality::Empty
288        } else if count < config.min_usable_strikes {
289            SmileQuality::Degraded
290        } else if count >= config.good_strike_count {
291            SmileQuality::Good
292        } else {
293            SmileQuality::Minimum
294        };
295
296        Self {
297            expiry,
298            points,
299            excluded,
300            quality,
301            atm_iv,
302        }
303    }
304
305    /// Number of usable points in the smile.
306    #[must_use]
307    pub fn len(&self) -> usize {
308        self.points.len()
309    }
310
311    /// True if no usable points remain.
312    #[must_use]
313    pub fn is_empty(&self) -> bool {
314        self.points.is_empty()
315    }
316
317    /// Interpolate implied volatility at an arbitrary strike.
318    ///
319    /// - [`Empty`](SmileQuality::Empty) quality: returns `None`.
320    /// - [`Degraded`](SmileQuality::Degraded) quality with ATM IV: returns
321    ///   flat ATM vol for any strike.
322    /// - Single point: returns that point's IV for any strike.
323    /// - Below the minimum strike: flat extrapolation (first point's IV).
324    /// - Above the maximum strike: flat extrapolation (last point's IV).
325    /// - Between two points: linear interpolation.
326    #[must_use]
327    pub fn interpolate(&self, strike: f64) -> Option<f64> {
328        if self.quality == SmileQuality::Empty {
329            return None;
330        }
331
332        if self.quality == SmileQuality::Degraded {
333            if let Some(atm) = self.atm_iv {
334                return Some(atm);
335            }
336            return self.points.first().map(|p| p.iv);
337        }
338
339        if self.points.len() == 1 {
340            return Some(self.points[0].iv);
341        }
342
343        let first = &self.points[0];
344        let last = &self.points[self.points.len() - 1];
345
346        if strike <= first.strike {
347            return Some(first.iv);
348        }
349
350        if strike >= last.strike {
351            return Some(last.iv);
352        }
353
354        let idx = self.points.partition_point(|p| p.strike < strike);
355
356        let upper = &self.points[idx];
357        let lower = &self.points[idx - 1];
358
359        if (upper.strike - strike).abs() < f64::EPSILON {
360            return Some(upper.iv);
361        }
362        if (lower.strike - strike).abs() < f64::EPSILON {
363            return Some(lower.iv);
364        }
365
366        let t = (strike - lower.strike) / (upper.strike - lower.strike);
367        let iv = lower.iv + (upper.iv - lower.iv) * t;
368        Some(iv)
369    }
370
371    /// Find the nearest observed strikes bracketing the target strike.
372    ///
373    /// Returns `(k_lower, k_upper)` where `k_lower < target < k_upper`.
374    /// If the target lands exactly on an observed strike, uses the adjacent
375    /// strikes on both sides.
376    ///
377    /// Returns `None` if the target is below all or above all observed
378    /// strikes (cannot bracket), or if fewer than two points exist.
379    #[must_use]
380    pub fn nearest_bracket(&self, target_strike: f64) -> Option<(f64, f64)> {
381        if self.points.len() < 2 {
382            return None;
383        }
384
385        let first = self.points[0].strike;
386        let last = self.points[self.points.len() - 1].strike;
387
388        if target_strike <= first || target_strike >= last {
389            return None;
390        }
391
392        let idx = self.points.partition_point(|p| p.strike < target_strike);
393
394        // Scale-relative equality so an on-grid strike is recognized at crypto
395        // magnitudes (e.g. 100_000), where an absolute `f64::EPSILON` never
396        // matches a strike that was computed rather than typed.
397        let strike_eq_tol = 1e-9 * target_strike.abs().max(1.0);
398
399        if idx < self.points.len()
400            && (self.points[idx].strike - target_strike).abs() <= strike_eq_tol
401        {
402            // Exact hit on an observed strike: use the neighbors on BOTH sides
403            // so the spread straddles the target. On a non-uniform grid this
404            // widens the spread; the caller (`call_spread_probability`) caps
405            // and recenters it on the target.
406            if idx == 0 || idx >= self.points.len() - 1 {
407                return None;
408            }
409            return Some((self.points[idx - 1].strike, self.points[idx + 1].strike));
410        }
411
412        if idx == 0 || idx >= self.points.len() {
413            return None;
414        }
415
416        Some((self.points[idx - 1].strike, self.points[idx].strike))
417    }
418
419    /// Compute skew at a given strike: `interpolate(strike) - atm_iv`.
420    ///
421    /// Returns `None` if ATM IV is unavailable or interpolation fails.
422    #[must_use]
423    pub fn skew_at(&self, strike: f64) -> Option<f64> {
424        let atm = self.atm_iv?;
425        let strike_iv = self.interpolate(strike)?;
426        Some(strike_iv - atm)
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn default_config() -> VolSurfaceConfig {
435        VolSurfaceConfig::default()
436    }
437
438    fn make_point(strike: f64, iv: f64, spread: f64) -> SmilePoint {
439        SmilePoint::new(strike, iv, iv - spread / 2.0, iv + spread / 2.0)
440    }
441
442    #[test]
443    fn construction_good_quality() {
444        let config = default_config();
445        let points = vec![
446            make_point(90000.0, 0.60, 0.05),
447            make_point(95000.0, 0.55, 0.04),
448            make_point(100000.0, 0.50, 0.03),
449            make_point(105000.0, 0.52, 0.04),
450            make_point(110000.0, 0.58, 0.06),
451        ];
452        let smile = VolSmile::new(Some(1_750_000_000), points, &config, 100000.0);
453
454        assert_eq!(smile.quality, SmileQuality::Good);
455        assert_eq!(smile.points.len(), 5);
456        assert!(smile.excluded.is_empty());
457        assert!((smile.atm_iv.unwrap() - 0.50).abs() < f64::EPSILON);
458    }
459
460    #[test]
461    fn construction_excludes_wide_spread() {
462        let config = default_config();
463        let points = vec![
464            make_point(90000.0, 0.60, 0.05),
465            make_point(95000.0, 0.55, 0.80),
466            make_point(100000.0, 0.50, 0.03),
467            make_point(105000.0, 0.52, 0.70),
468            make_point(110000.0, 0.58, 0.06),
469        ];
470        let smile = VolSmile::new(None, points, &config, 100000.0);
471
472        assert_eq!(smile.points.len(), 3);
473        assert_eq!(smile.excluded.len(), 2);
474        assert_eq!(smile.quality, SmileQuality::Minimum);
475
476        assert!(smile.excluded[0].1.contains("iv_spread="));
477        assert!(smile.excluded[0].1.contains("exceeds max"));
478    }
479
480    #[test]
481    fn construction_degraded_quality() {
482        let config = default_config();
483        let points = vec![
484            make_point(100000.0, 0.50, 0.03),
485            make_point(105000.0, 0.52, 0.04),
486        ];
487        let smile = VolSmile::new(None, points, &config, 100000.0);
488
489        assert_eq!(smile.quality, SmileQuality::Degraded);
490        assert_eq!(smile.points.len(), 2);
491        assert!(smile.atm_iv.is_some());
492    }
493
494    #[test]
495    fn construction_sorted_by_strike() {
496        let config = default_config();
497        let points = vec![
498            make_point(110000.0, 0.58, 0.06),
499            make_point(90000.0, 0.60, 0.05),
500            make_point(105000.0, 0.52, 0.04),
501            make_point(95000.0, 0.55, 0.04),
502            make_point(100000.0, 0.50, 0.03),
503        ];
504        let smile = VolSmile::new(None, points, &config, 100000.0);
505
506        let strikes: Vec<f64> = smile.points.iter().map(|p| p.strike).collect();
507        assert_eq!(
508            strikes,
509            vec![90000.0, 95000.0, 100000.0, 105000.0, 110000.0]
510        );
511    }
512
513    #[test]
514    fn construction_empty() {
515        let config = default_config();
516        let smile = VolSmile::new(None, Vec::new(), &config, 100000.0);
517
518        assert_eq!(smile.quality, SmileQuality::Empty);
519        assert!(smile.atm_iv.is_none());
520        assert!(smile.is_empty());
521    }
522
523    #[test]
524    fn construction_excludes_non_positive_iv() {
525        let config = default_config();
526        let points = vec![
527            make_point(90000.0, 0.60, 0.05),
528            make_point(95000.0, 0.0, 0.04),
529            make_point(100000.0, -0.10, 0.03),
530            make_point(105000.0, 0.52, 0.04),
531            make_point(110000.0, 0.58, 0.06),
532        ];
533        let smile = VolSmile::new(None, points, &config, 100000.0);
534
535        assert_eq!(smile.points.len(), 3);
536        assert_eq!(smile.excluded.len(), 2);
537        assert!(
538            smile
539                .excluded
540                .iter()
541                .all(|(_, reason)| reason == "non-positive IV")
542        );
543        assert_eq!(smile.quality, SmileQuality::Minimum);
544    }
545
546    /// Non-finite strike or IV is excluded by the constructor so the
547    /// sorted-ascending invariant holds and downstream interpolation never
548    /// returns NaN on a clean in-range query.
549    #[test]
550    fn construction_excludes_non_finite_strike_or_iv() {
551        let config = default_config();
552        let points = vec![
553            make_point(90.0, 0.30, 0.01),
554            make_point(f64::NAN, 0.28, 0.01), // NaN strike
555            make_point(100.0, 0.25, 0.01),
556            make_point(105.0, f64::NAN, 0.01),      // NaN IV
557            make_point(110.0, f64::INFINITY, 0.01), // non-finite IV
558            make_point(95.0, 0.27, 0.01),
559        ];
560        let smile = VolSmile::new(None, points, &config, 100.0);
561
562        // Three good points remain (90, 95, 100); the three non-finite ones
563        // are excluded and never enter `points`.
564        assert_eq!(smile.points.len(), 3);
565        assert_eq!(smile.excluded.len(), 3);
566        assert!(
567            smile
568                .excluded
569                .iter()
570                .all(|(_, reason)| reason == "non-finite strike/IV")
571        );
572        assert!(
573            smile
574                .points
575                .iter()
576                .all(|p| p.strike.is_finite() && p.iv.is_finite())
577        );
578
579        // Points are genuinely sorted, so an in-range query is finite.
580        let strikes: Vec<f64> = smile.points.iter().map(|p| p.strike).collect();
581        assert_eq!(strikes, vec![90.0, 95.0, 100.0]);
582        let iv = smile.interpolate(97.0).unwrap();
583        assert!(iv.is_finite());
584    }
585
586    fn make_good_smile() -> VolSmile {
587        let config = default_config();
588        let points = vec![
589            make_point(90000.0, 0.60, 0.05),
590            make_point(95000.0, 0.55, 0.04),
591            make_point(100000.0, 0.50, 0.03),
592            make_point(105000.0, 0.52, 0.04),
593            make_point(110000.0, 0.58, 0.06),
594        ];
595        VolSmile::new(None, points, &config, 100000.0)
596    }
597
598    #[test]
599    fn interpolate_exact_strike() {
600        let smile = make_good_smile();
601        let iv = smile.interpolate(100000.0).unwrap();
602        assert!((iv - 0.50).abs() < 1e-10);
603
604        let iv_low = smile.interpolate(90000.0).unwrap();
605        assert!((iv_low - 0.60).abs() < 1e-10);
606    }
607
608    #[test]
609    fn interpolate_between_strikes() {
610        let smile = make_good_smile();
611        let iv = smile.interpolate(92500.0).unwrap();
612        let expected = 0.60 + (0.55 - 0.60) * (92500.0 - 90000.0) / (95000.0 - 90000.0);
613        assert!((iv - expected).abs() < 1e-10);
614        assert!(iv > 0.55 && iv < 0.60);
615    }
616
617    #[test]
618    fn extrapolate_below() {
619        let smile = make_good_smile();
620        let iv = smile.interpolate(80000.0).unwrap();
621        assert!((iv - 0.60).abs() < 1e-10);
622    }
623
624    #[test]
625    fn extrapolate_above() {
626        let smile = make_good_smile();
627        let iv = smile.interpolate(120000.0).unwrap();
628        assert!((iv - 0.58).abs() < 1e-10);
629    }
630
631    #[test]
632    fn nearest_bracket_between() {
633        let smile = make_good_smile();
634        let (lower, upper) = smile.nearest_bracket(97000.0).unwrap();
635        assert!((lower - 95000.0).abs() < f64::EPSILON);
636        assert!((upper - 100000.0).abs() < f64::EPSILON);
637    }
638
639    #[test]
640    fn nearest_bracket_out_of_range() {
641        let smile = make_good_smile();
642        assert!(smile.nearest_bracket(80000.0).is_none());
643        assert!(smile.nearest_bracket(120000.0).is_none());
644        assert!(smile.nearest_bracket(90000.0).is_none());
645        assert!(smile.nearest_bracket(110000.0).is_none());
646    }
647
648    #[test]
649    fn nearest_bracket_exact_strike() {
650        let smile = make_good_smile();
651        let (lower, upper) = smile.nearest_bracket(100000.0).unwrap();
652        assert!((lower - 95000.0).abs() < f64::EPSILON);
653        assert!((upper - 105000.0).abs() < f64::EPSILON);
654    }
655
656    #[test]
657    fn nearest_bracket_near_grid_target_recognized_at_scale() {
658        let smile = make_good_smile(); // strikes 90_000 ..= 110_000
659        // A target a hair below the 100_000 node (as if computed with rounding)
660        // is recognized as the on-grid strike thanks to the scale-relative
661        // tolerance; an absolute f64::EPSILON would miss it and return
662        // (95_000, 100_000) instead.
663        let (lower, upper) = smile.nearest_bracket(100_000.0 - 1e-5).unwrap();
664        assert!((lower - 95_000.0).abs() < f64::EPSILON);
665        assert!((upper - 105_000.0).abs() < f64::EPSILON);
666    }
667
668    #[test]
669    fn nearest_bracket_uneven_grid_exact_hit() {
670        let config = default_config();
671        let points = vec![
672            make_point(100.0, 0.30, 0.02),
673            make_point(101.0, 0.29, 0.02),
674            make_point(150.0, 0.40, 0.02),
675        ];
676        let smile = VolSmile::new(None, points, &config, 101.0);
677        // Exact hit at 101 -> neighbors on both sides (100, 150); the spread is
678        // wide because the grid is uneven.
679        let (lower, upper) = smile.nearest_bracket(101.0).unwrap();
680        assert!((lower - 100.0).abs() < f64::EPSILON);
681        assert!((upper - 150.0).abs() < f64::EPSILON);
682    }
683
684    #[test]
685    fn skew_at_various_strikes() {
686        let smile = make_good_smile();
687
688        let skew_atm = smile.skew_at(100000.0).unwrap();
689        assert!(skew_atm.abs() < 1e-10);
690
691        let skew_low = smile.skew_at(90000.0).unwrap();
692        assert!((skew_low - 0.10).abs() < 1e-10);
693
694        let skew_high = smile.skew_at(110000.0).unwrap();
695        assert!((skew_high - 0.08).abs() < 1e-10);
696    }
697
698    #[test]
699    fn degraded_returns_flat_atm() {
700        let config = default_config();
701        let points = vec![
702            make_point(100000.0, 0.50, 0.03),
703            make_point(105000.0, 0.52, 0.04),
704        ];
705        let smile = VolSmile::new(None, points, &config, 100000.0);
706
707        assert_eq!(smile.quality, SmileQuality::Degraded);
708
709        let iv_low = smile.interpolate(80000.0).unwrap();
710        let iv_mid = smile.interpolate(100000.0).unwrap();
711        let iv_high = smile.interpolate(120000.0).unwrap();
712        assert!((iv_low - 0.50).abs() < 1e-10);
713        assert!((iv_mid - 0.50).abs() < 1e-10);
714        assert!((iv_high - 0.50).abs() < 1e-10);
715    }
716
717    #[test]
718    fn empty_returns_none() {
719        let config = default_config();
720        let smile = VolSmile::new(None, Vec::new(), &config, 100000.0);
721
722        assert!(smile.interpolate(100000.0).is_none());
723        assert!(smile.nearest_bracket(100000.0).is_none());
724        assert!(smile.skew_at(100000.0).is_none());
725    }
726
727    #[test]
728    fn single_point_returns_its_iv() {
729        let config = VolSurfaceConfig::builder().min_usable_strikes(1).build();
730        let points = vec![make_point(100000.0, 0.50, 0.03)];
731        let smile = VolSmile::new(None, points, &config, 100000.0);
732
733        let iv = smile.interpolate(80000.0).unwrap();
734        assert!((iv - 0.50).abs() < 1e-10);
735        let iv = smile.interpolate(120000.0).unwrap();
736        assert!((iv - 0.50).abs() < 1e-10);
737    }
738
739    #[test]
740    fn interpolation_monotonicity() {
741        let smile = make_good_smile();
742        let strikes = &smile.points;
743        for w in strikes.windows(2) {
744            let (k_lo, iv_lo) = (w[0].strike, w[0].iv);
745            let (k_hi, iv_hi) = (w[1].strike, w[1].iv);
746            let lo_iv = iv_lo.min(iv_hi);
747            let hi_iv = iv_lo.max(iv_hi);
748
749            for i in 1..10 {
750                let frac = i as f64 / 10.0;
751                let k = k_lo + (k_hi - k_lo) * frac;
752                let iv = smile.interpolate(k).unwrap();
753                assert!(iv >= lo_iv - 1e-10 && iv <= hi_iv + 1e-10);
754            }
755        }
756    }
757
758    #[test]
759    fn builder_overrides() {
760        let config = VolSurfaceConfig::builder()
761            .min_usable_strikes(2)
762            .good_strike_count(7)
763            .max_iv_spread_filter(0.30)
764            .build();
765        assert_eq!(config.min_usable_strikes, 2);
766        assert_eq!(config.good_strike_count, 7);
767        assert!((config.max_iv_spread_filter - 0.30).abs() < f64::EPSILON);
768    }
769}