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_N;
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 {
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
149impl 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#[derive(Debug, Clone, Serialize)]
162pub struct VolSurface {
163 reference_date: NaiveDate,
164 day_count: DayCountConvention,
165 data: SurfaceData,
166}
167
168impl VolSurface {
169 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 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 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 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(×, 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 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 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 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 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 pub fn expiry_times(&self) -> &[f64] {
338 match &self.data {
339 SurfaceData::Flat(_) => &[],
340 SurfaceData::Term { times, .. } => times,
341 }
342 }
343
344 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(×, 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 × {
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 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 assert!((s.vol(95.0, 100.0, 1.0) - 0.21).abs() < 1e-14);
473 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 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); assert!((s.vol(100.0, 100.0, 5.0) - 0.25).abs() < 1e-14); }
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 assert!((s.vol(105.0, 105.0, 1.0) - 0.20).abs() < 1e-14);
506 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 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 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 let d1 = ((forward / strike).ln() + 0.5 * sigma * sigma * t) / (sigma * t.sqrt());
536 assert!((N(d1) - delta).abs() < 1e-10);
537 }
538 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 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}