1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum VolInput {
41 Flat {
43 vol: f64,
44 #[serde(default)]
45 day_count: DayCountConvention,
46 },
47 StrikeExpiry {
49 expiries: Vec<Tenor>,
50 strikes: Vec<f64>,
51 vols: Vec<Vec<f64>>,
52 #[serde(default)]
53 day_count: DayCountConvention,
54 },
55 MoneynessExpiry {
57 expiries: Vec<Tenor>,
58 moneyness: Vec<f64>,
59 vols: Vec<Vec<f64>>,
60 #[serde(default)]
61 day_count: DayCountConvention,
62 },
63 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109enum SmileCoord {
110 Strike,
112 Moneyness,
114 LogMoneyness,
116}
117
118#[derive(Debug, Clone, Serialize)]
120struct Smile {
121 points: Vec<(f64, f64)>,
122}
123
124impl Smile {
125 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
141impl 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#[derive(Debug, Clone, Copy, PartialEq)]
156pub enum VolShift {
157 ParallelAbsolute(f64),
159 ParallelRelative(f64),
161}
162
163#[derive(Debug, Clone, Serialize)]
165pub struct VolSurface {
166 reference_date: NaiveDate,
167 day_count: DayCountConvention,
168 data: SurfaceData,
169}
170
171impl VolSurface {
172 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 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 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 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(×, 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 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 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 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 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 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 pub fn expiry_times(&self) -> &[f64] {
372 match &self.data {
373 SurfaceData::Flat(_) => &[],
374 SurfaceData::Term { times, .. } => times,
375 }
376 }
377
378 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(×, 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 × {
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 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 assert!((s.vol(95.0, 100.0, 1.0) - 0.21).abs() < 1e-14);
507 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 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 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 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); assert!((s.vol(100.0, 100.0, 5.0) - 0.25).abs() < 1e-14); }
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 assert!((s.vol(105.0, 105.0, 1.0) - 0.20).abs() < 1e-14);
573 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 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 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 let d1 = ((forward / strike).ln() + 0.5 * sigma * sigma * t) / (sigma * t.sqrt());
603 assert!((norm_cdf(d1) - delta).abs() < 1e-10);
604 }
605 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 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}