1#[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 pub min_usable_strikes: usize,
69 pub good_strike_count: usize,
71 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 pub fn builder() -> VolSurfaceConfigBuilder {
89 VolSurfaceConfigBuilder {
90 inner: VolSurfaceConfig::default(),
91 }
92 }
93}
94
95#[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 pub const fn min_usable_strikes(mut self, value: usize) -> Self {
109 self.inner.min_usable_strikes = value;
110 self
111 }
112
113 pub const fn good_strike_count(mut self, value: usize) -> Self {
115 self.inner.good_strike_count = value;
116 self
117 }
118
119 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 #[must_use]
127 pub const fn build(self) -> VolSurfaceConfig {
128 self.inner
129 }
130}
131
132#[derive(Debug, Clone, PartialEq)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139#[non_exhaustive]
140pub struct SmilePoint {
141 pub strike: f64,
143 pub iv: f64,
145 pub bid_iv: f64,
147 pub ask_iv: f64,
149 pub iv_spread: f64,
151}
152
153impl SmilePoint {
154 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
175#[non_exhaustive]
176pub enum SmileQuality {
177 Good,
179 Minimum,
182 Degraded,
184 Empty,
186}
187
188#[derive(Debug, Clone, PartialEq)]
201#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
202#[non_exhaustive]
203pub struct VolSmile {
204 pub expiry: Option<i64>,
206 pub points: Vec<SmilePoint>,
208 pub excluded: Vec<(f64, String)>,
210 pub quality: SmileQuality,
212 pub atm_iv: Option<f64>,
214}
215
216impl VolSmile {
217 #[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 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 #[must_use]
307 pub fn len(&self) -> usize {
308 self.points.len()
309 }
310
311 #[must_use]
313 pub fn is_empty(&self) -> bool {
314 self.points.is_empty()
315 }
316
317 #[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 #[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 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 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 #[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 #[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), make_point(100.0, 0.25, 0.01),
556 make_point(105.0, f64::NAN, 0.01), make_point(110.0, f64::INFINITY, 0.01), make_point(95.0, 0.27, 0.01),
559 ];
560 let smile = VolSmile::new(None, points, &config, 100.0);
561
562 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 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(); 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 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}