1use serde::{Deserialize, Serialize};
9use std::collections::{BTreeSet, HashMap};
10use tracing::debug;
11
12use hypercall_margin::black_scholes::black_scholes;
13use hypercall_margin::OptionType;
14
15#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
17pub struct VolPoint {
18 pub strike: f64,
20 pub expiry: i64,
22 pub iv: f64,
24 pub timestamp: i64,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
30pub struct DeltaIvExport {
31 pub delta: f64,
32 pub iv: f64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
37pub struct DeltaCurveExport {
38 pub expiry: i64,
39 pub points: Vec<DeltaIvExport>,
40}
41
42#[derive(Debug, Clone, Copy)]
44struct DeltaIvPoint {
45 delta: f64,
47 iv: f64,
49}
50
51#[derive(Debug, Clone)]
61pub struct VolatilitySurface {
62 points: HashMap<i64, HashMap<i64, VolPoint>>,
64 atm_vols: HashMap<i64, f64>,
66 delta_curves: HashMap<i64, Vec<DeltaIvPoint>>,
68 strike_precision: f64,
70 last_update: Option<i64>,
72 expiries: BTreeSet<i64>,
74}
75
76impl Default for VolatilitySurface {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl VolatilitySurface {
83 pub fn new() -> Self {
85 Self::with_precision(1.0)
86 }
87
88 pub fn with_precision(strike_precision: f64) -> Self {
93 Self {
94 points: HashMap::new(),
95 atm_vols: HashMap::new(),
96 delta_curves: HashMap::new(),
97 strike_precision,
98 last_update: None,
99 expiries: BTreeSet::new(),
100 }
101 }
102
103 fn quantize_strike(&self, strike: f64) -> i64 {
105 (strike / self.strike_precision).round() as i64
106 }
107
108 pub fn insert(&mut self, strike: f64, expiry: i64, iv: f64) {
110 let quantized_strike = self.quantize_strike(strike);
111 let now = chrono::Utc::now().timestamp_millis();
112
113 let point = VolPoint {
114 strike,
115 expiry,
116 iv,
117 timestamp: now,
118 };
119
120 self.points
121 .entry(quantized_strike)
122 .or_default()
123 .insert(expiry, point);
124
125 self.expiries.insert(expiry);
126 self.last_update = Some(now);
127 }
128
129 pub fn set_atm_vol(&mut self, expiry: i64, iv: f64) {
131 self.atm_vols.insert(expiry, iv);
132 self.expiries.insert(expiry);
133 self.last_update = Some(chrono::Utc::now().timestamp_millis());
134 }
135
136 pub fn set_delta_iv(&mut self, expiry: i64, delta: f64, iv: f64) {
141 let curve = self.delta_curves.entry(expiry).or_default();
142 if let Some(point) = curve.iter_mut().find(|p| (p.delta - delta).abs() < 1e-6) {
144 point.iv = iv;
145 } else {
146 curve.push(DeltaIvPoint { delta, iv });
147 curve.sort_by(|a, b| a.delta.partial_cmp(&b.delta).unwrap());
148 }
149 self.expiries.insert(expiry);
150 self.last_update = Some(chrono::Utc::now().timestamp_millis());
151 }
152
153 pub fn get(&self, strike: f64, expiry: i64) -> Option<f64> {
155 let quantized = self.quantize_strike(strike);
156 self.points
157 .get(&quantized)
158 .and_then(|expiries| expiries.get(&expiry))
159 .map(|p| p.iv)
160 }
161
162 pub fn get_point(&self, strike: f64, expiry: i64) -> Option<&VolPoint> {
164 let quantized = self.quantize_strike(strike);
165 self.points
166 .get(&quantized)
167 .and_then(|expiries| expiries.get(&expiry))
168 }
169
170 pub fn get_interpolated_with_spot(
177 &self,
178 strike: f64,
179 expiry: i64,
180 spot: Option<f64>,
181 ) -> Option<f64> {
182 if let Some(iv) = self.get(strike, expiry) {
184 debug!(
185 strike,
186 expiry,
187 iv,
188 spot = ?spot,
189 lookup_path = "exact_point",
190 "Backend vol surface lookup"
191 );
192 return Some(iv);
193 }
194
195 let quantized = self.quantize_strike(strike);
197 let strike_keys: Vec<i64> = self.points.keys().copied().collect();
198
199 if strike_keys.is_empty() {
200 if let Some(s) = spot {
202 if let Some(iv) = self.interpolate_delta_surface(strike, expiry, s) {
203 debug!(
204 strike,
205 expiry,
206 iv,
207 spot = s,
208 lookup_path = "delta_surface",
209 expiries = self.delta_curves.len(),
210 "Backend vol surface lookup"
211 );
212 return Some(iv);
213 }
214 }
215 let atm_iv = self.get_atm(expiry);
217 if let Some(iv) = atm_iv {
218 debug!(
219 strike,
220 expiry,
221 iv,
222 spot = ?spot,
223 lookup_path = "atm_fallback",
224 "Backend vol surface lookup"
225 );
226 }
227 return atm_iv;
228 }
229
230 let (lower_strike_q, upper_strike_q) =
232 self.find_bracketing_values(quantized, &strike_keys)?;
233
234 let lower_expiries = self.points.get(&lower_strike_q)?;
236 let upper_expiries = self.points.get(&upper_strike_q)?;
237
238 let all_expiries: BTreeSet<i64> = lower_expiries
240 .keys()
241 .chain(upper_expiries.keys())
242 .copied()
243 .collect();
244
245 let expiry_vec: Vec<i64> = all_expiries.iter().copied().collect();
246 let (lower_expiry, upper_expiry) = self.find_bracketing_values(expiry, &expiry_vec)?;
247
248 let ll = lower_expiries.get(&lower_expiry).map(|p| p.iv);
250 let lu = lower_expiries.get(&upper_expiry).map(|p| p.iv);
251 let ul = upper_expiries.get(&lower_expiry).map(|p| p.iv);
252 let uu = upper_expiries.get(&upper_expiry).map(|p| p.iv);
253
254 let lower_strike = lower_strike_q as f64 * self.strike_precision;
256 let upper_strike = upper_strike_q as f64 * self.strike_precision;
257
258 let interpolated = self.bilinear_interpolate(BilinearPoint {
260 lower_strike,
261 upper_strike,
262 lower_expiry,
263 upper_expiry,
264 strike,
265 expiry,
266 lower_lower: ll,
267 lower_upper: lu,
268 upper_lower: ul,
269 upper_upper: uu,
270 });
271
272 if let Some(iv) = interpolated {
273 debug!(
274 strike,
275 expiry,
276 iv,
277 spot = ?spot,
278 lookup_path = "bilinear_strike_expiry",
279 lower_strike,
280 upper_strike,
281 lower_expiry,
282 upper_expiry,
283 ll = ?ll,
284 lu = ?lu,
285 ul = ?ul,
286 uu = ?uu,
287 "Backend vol surface lookup"
288 );
289 }
290
291 interpolated
292 }
293
294 pub fn get_interpolated(&self, strike: f64, expiry: i64) -> Option<f64> {
296 self.get_interpolated_with_spot(strike, expiry, None)
297 }
298
299 fn interpolate_delta_surface(&self, strike: f64, expiry: i64, spot: f64) -> Option<f64> {
305 if self.delta_curves.is_empty() {
306 return None;
307 }
308
309 let expiry_vec: Vec<i64> = self.delta_curves.keys().copied().collect();
310 let (lower_exp, upper_exp) = find_bracketing_sorted(expiry, &expiry_vec)?;
311
312 let iv_lower = self.interpolate_delta_smile_at_expiry(strike, lower_exp, spot)?;
313
314 if lower_exp == upper_exp {
315 return Some(iv_lower);
316 }
317
318 let iv_upper = self.interpolate_delta_smile_at_expiry(strike, upper_exp, spot)?;
319
320 let t = (expiry - lower_exp) as f64 / (upper_exp - lower_exp) as f64;
322 Some(iv_lower + t * (iv_upper - iv_lower))
323 }
324
325 fn interpolate_delta_smile_at_expiry(
327 &self,
328 strike: f64,
329 expiry: i64,
330 spot: f64,
331 ) -> Option<f64> {
332 let curve = self.delta_curves.get(&expiry)?;
333 if curve.is_empty() {
334 return None;
335 }
336
337 let atm_iv = self.atm_vols.get(&expiry).copied().unwrap_or(0.50);
339 let now = chrono::Utc::now().timestamp();
340 let tte = (expiry - now) as f64 / (365.25 * 86400.0);
341 if tte <= 0.0 {
342 return self.atm_vols.get(&expiry).copied();
344 }
345
346 let sqrt_t = tte.sqrt();
348 let d1 = ((spot / strike).ln() + 0.5 * atm_iv * atm_iv * tte) / (atm_iv * sqrt_t);
349 let delta = normal_cdf(d1);
350
351 interpolate_curve(curve, delta)
353 }
354
355 pub fn get_atm(&self, expiry: i64) -> Option<f64> {
357 if let Some(&iv) = self.atm_vols.get(&expiry) {
359 return Some(iv);
360 }
361
362 if self.atm_vols.is_empty() {
364 return None;
365 }
366
367 let expiry_vec: Vec<i64> = self.atm_vols.keys().copied().collect();
368 let (lower, upper) = self.find_bracketing_values(expiry, &expiry_vec)?;
369
370 let lower_iv = self.atm_vols.get(&lower)?;
371 let upper_iv = self.atm_vols.get(&upper)?;
372
373 if lower == upper {
374 return Some(*lower_iv);
375 }
376
377 let t = (expiry - lower) as f64 / (upper - lower) as f64;
379 Some(lower_iv + t * (upper_iv - lower_iv))
380 }
381
382 pub fn last_update(&self) -> Option<i64> {
384 self.last_update
385 }
386
387 pub fn expiries(&self) -> &BTreeSet<i64> {
389 &self.expiries
390 }
391
392 pub fn len(&self) -> usize {
394 self.points.values().map(|m| m.len()).sum::<usize>()
395 + self.atm_vols.len()
396 + self.delta_curves.values().map(|c| c.len()).sum::<usize>()
397 }
398
399 pub fn is_empty(&self) -> bool {
401 self.points.is_empty() && self.atm_vols.is_empty() && self.delta_curves.is_empty()
402 }
403
404 pub fn export_all_points(&self) -> Vec<VolPoint> {
406 self.points
407 .values()
408 .flat_map(|expiry_map| expiry_map.values())
409 .cloned()
410 .collect()
411 }
412
413 pub fn export_delta_curves(&self) -> Vec<DeltaCurveExport> {
415 self.delta_curves
416 .iter()
417 .map(|(&expiry, curve)| DeltaCurveExport {
418 expiry,
419 points: curve
420 .iter()
421 .map(|p| DeltaIvExport {
422 delta: p.delta,
423 iv: p.iv,
424 })
425 .collect(),
426 })
427 .collect()
428 }
429
430 pub fn export_atm_vols(&self) -> Vec<(i64, f64)> {
432 self.atm_vols
433 .iter()
434 .map(|(&expiry, &iv)| (expiry, iv))
435 .collect()
436 }
437
438 pub fn sanitize_arb_free(&mut self, spot: f64, risk_free_rate: f64) -> u32 {
456 if spot <= 0.0 {
457 return 0;
458 }
459 let mut total = 0u32;
460 let expiries: Vec<i64> = self.expiries.iter().copied().collect();
461 let now_ts = chrono::Utc::now().timestamp();
462
463 for expiry in expiries {
464 let tte = (expiry - now_ts) as f64 / (365.25 * 86400.0);
465 if tte <= 0.0 {
466 continue;
467 }
468
469 let mut slice: Vec<(f64, f64, i64)> = self
471 .points
472 .iter()
473 .filter_map(|(q, exp_map)| exp_map.get(&expiry).map(|p| (p.strike, p.iv, *q)))
474 .collect();
475 if slice.len() < 2 {
476 continue;
477 }
478 slice.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
479
480 for i in 1..slice.len() {
482 let (k_prev, iv_prev, _) = slice[i - 1];
483 let (k_curr, iv_curr, q_curr) = slice[i];
484 let c_prev = bs_call(spot, k_prev, tte, risk_free_rate, iv_prev);
485 let c_curr = bs_call(spot, k_curr, tte, risk_free_rate, iv_curr);
486 if c_curr > c_prev + PRICE_EPS {
487 let new_iv = solve_call_iv(spot, k_curr, tte, risk_free_rate, c_prev, iv_curr);
488 if (new_iv - iv_curr).abs() > IV_EPS {
489 self.update_iv(q_curr, expiry, new_iv);
490 slice[i].1 = new_iv;
491 total += 1;
492 }
493 }
494 }
495
496 for i in 1..slice.len().saturating_sub(1) {
500 let (k_prev, iv_prev, _) = slice[i - 1];
501 let (k_curr, iv_curr, q_curr) = slice[i];
502 let (k_next, iv_next, _) = slice[i + 1];
503 let span = k_next - k_prev;
504 if span <= 0.0 {
505 continue;
506 }
507 let w_prev = (k_next - k_curr) / span;
508 let w_next = (k_curr - k_prev) / span;
509 let c_prev = bs_call(spot, k_prev, tte, risk_free_rate, iv_prev);
510 let c_curr = bs_call(spot, k_curr, tte, risk_free_rate, iv_curr);
511 let c_next = bs_call(spot, k_next, tte, risk_free_rate, iv_next);
512 let c_max = w_prev * c_prev + w_next * c_next;
513 if c_curr > c_max + PRICE_EPS {
514 let new_iv = solve_call_iv(spot, k_curr, tte, risk_free_rate, c_max, iv_curr);
515 if (new_iv - iv_curr).abs() > IV_EPS {
516 self.update_iv(q_curr, expiry, new_iv);
517 slice[i].1 = new_iv;
518 total += 1;
519 }
520 }
521 }
522 }
523 total
524 }
525
526 fn update_iv(&mut self, quantized_strike: i64, expiry: i64, new_iv: f64) {
528 if let Some(exp_map) = self.points.get_mut(&quantized_strike) {
529 if let Some(point) = exp_map.get_mut(&expiry) {
530 point.iv = new_iv;
531 point.timestamp = chrono::Utc::now().timestamp_millis();
532 }
533 }
534 }
535
536 pub fn clear(&mut self) {
538 self.points.clear();
539 self.atm_vols.clear();
540 self.delta_curves.clear();
541 self.expiries.clear();
542 self.last_update = None;
543 }
544
545 fn find_bracketing_values(&self, target: i64, keys: &[i64]) -> Option<(i64, i64)> {
547 if keys.is_empty() {
548 return None;
549 }
550
551 let mut sorted: Vec<i64> = keys.to_vec();
552 sorted.sort();
553
554 match sorted.binary_search(&target) {
555 Ok(i) => Some((sorted[i], sorted[i])),
556 Err(i) => {
557 if i == 0 {
558 Some((sorted[0], sorted[0]))
560 } else if i >= sorted.len() {
561 let last = *sorted.last()?;
563 Some((last, last))
564 } else {
565 Some((sorted[i - 1], sorted[i]))
566 }
567 }
568 }
569 }
570
571 fn bilinear_interpolate(&self, point: BilinearPoint) -> Option<f64> {
573 let BilinearPoint {
574 lower_strike,
575 upper_strike,
576 lower_expiry,
577 upper_expiry,
578 strike,
579 expiry,
580 lower_lower,
581 lower_upper,
582 upper_lower,
583 upper_upper,
584 } = point;
585
586 let values: Vec<f64> = [lower_lower, lower_upper, upper_lower, upper_upper]
588 .iter()
589 .filter_map(|v| *v)
590 .collect();
591
592 if values.is_empty() {
593 return None;
594 }
595
596 if values.len() < 4 {
598 return Some(values.iter().sum::<f64>() / values.len() as f64);
599 }
600
601 let lower_lower = lower_lower?;
602 let lower_upper = lower_upper?;
603 let upper_lower = upper_lower?;
604 let upper_upper = upper_upper?;
605
606 if (upper_strike - lower_strike).abs() < 1e-9 && upper_expiry == lower_expiry {
608 return Some(lower_lower);
609 }
610
611 let t = if (upper_strike - lower_strike).abs() < 1e-9 {
613 0.0
614 } else {
615 (strike - lower_strike) / (upper_strike - lower_strike)
616 };
617
618 let u = if upper_expiry == lower_expiry {
619 0.0
620 } else {
621 (expiry - lower_expiry) as f64 / (upper_expiry - lower_expiry) as f64
622 };
623
624 let result = (1.0 - t) * (1.0 - u) * lower_lower
626 + t * (1.0 - u) * upper_lower
627 + (1.0 - t) * u * lower_upper
628 + t * u * upper_upper;
629
630 Some(result)
631 }
632}
633
634struct BilinearPoint {
635 lower_strike: f64,
636 upper_strike: f64,
637 lower_expiry: i64,
638 upper_expiry: i64,
639 strike: f64,
640 expiry: i64,
641 lower_lower: Option<f64>,
642 lower_upper: Option<f64>,
643 upper_lower: Option<f64>,
644 upper_upper: Option<f64>,
645}
646
647const PRICE_EPS: f64 = 1e-6;
649const IV_EPS: f64 = 1e-5;
651const IV_FLOOR: f64 = 1e-4;
653
654fn bs_call(spot: f64, strike: f64, tte: f64, r: f64, iv: f64) -> f64 {
655 black_scholes(&OptionType::Call, spot, strike, tte, r, iv)
656}
657
658fn solve_call_iv(spot: f64, strike: f64, tte: f64, r: f64, target: f64, iv_hint: f64) -> f64 {
662 let mut lo = IV_FLOOR;
663 let mut hi = iv_hint.max(IV_FLOOR);
664 let c_lo = bs_call(spot, strike, tte, r, lo);
665 if target <= c_lo {
666 return lo;
667 }
668 let c_hi = bs_call(spot, strike, tte, r, hi);
669 if c_hi <= target {
670 return hi; }
672 for _ in 0..60 {
673 let mid = 0.5 * (lo + hi);
674 let c_mid = bs_call(spot, strike, tte, r, mid);
675 if c_mid > target {
676 hi = mid;
677 } else {
678 lo = mid;
679 }
680 if hi - lo < 1e-8 {
681 break;
682 }
683 }
684 0.5 * (lo + hi)
685}
686
687fn normal_cdf(x: f64) -> f64 {
689 0.5 * (1.0 + libm::erf(x / std::f64::consts::SQRT_2))
690}
691
692fn interpolate_curve(curve: &[DeltaIvPoint], delta: f64) -> Option<f64> {
694 if curve.is_empty() {
695 return None;
696 }
697 if curve.len() == 1 {
698 return Some(curve[0].iv);
699 }
700 if delta <= curve[0].delta {
702 return Some(curve[0].iv);
703 }
704 if delta >= curve.last().unwrap().delta {
705 return Some(curve.last().unwrap().iv);
706 }
707 for window in curve.windows(2) {
709 let (lo, hi) = (&window[0], &window[1]);
710 if delta >= lo.delta && delta <= hi.delta {
711 let t = (delta - lo.delta) / (hi.delta - lo.delta);
712 return Some(lo.iv + t * (hi.iv - lo.iv));
713 }
714 }
715 None
716}
717
718fn find_bracketing_sorted(target: i64, keys: &[i64]) -> Option<(i64, i64)> {
720 if keys.is_empty() {
721 return None;
722 }
723 let mut sorted: Vec<i64> = keys.to_vec();
724 sorted.sort();
725 match sorted.binary_search(&target) {
726 Ok(i) => Some((sorted[i], sorted[i])),
727 Err(i) => {
728 if i == 0 {
729 Some((sorted[0], sorted[0]))
730 } else if i >= sorted.len() {
731 let last = *sorted.last()?;
732 Some((last, last))
733 } else {
734 Some((sorted[i - 1], sorted[i]))
735 }
736 }
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn test_insert_and_get() {
746 let mut surface = VolatilitySurface::new();
747 surface.insert(50000.0, 1735689600, 0.80);
748
749 assert_eq!(surface.get(50000.0, 1735689600), Some(0.80));
750 assert_eq!(surface.get(50000.0, 1735689601), None); assert_eq!(surface.get(50100.0, 1735689600), None); }
753
754 #[test]
755 fn test_strike_quantization() {
756 let mut surface = VolatilitySurface::new();
757 surface.insert(50000.0, 1735689600, 0.80);
758
759 assert_eq!(surface.get(50000.49, 1735689600), Some(0.80));
763 assert_eq!(surface.get(49999.51, 1735689600), Some(0.80));
764
765 assert_eq!(surface.get(50000.51, 1735689600), None);
768 assert_eq!(surface.get(50001.0, 1735689600), None);
769 }
770
771 #[test]
772 fn test_atm_vol() {
773 let mut surface = VolatilitySurface::new();
774 surface.set_atm_vol(1735689600, 0.70);
775 surface.set_atm_vol(1735776000, 0.75);
776
777 assert_eq!(surface.get_atm(1735689600), Some(0.70));
778 assert_eq!(surface.get_atm(1735776000), Some(0.75));
779
780 let mid_expiry = (1735689600 + 1735776000) / 2;
782 let interpolated = surface.get_atm(mid_expiry);
783 assert!(interpolated.is_some());
784 let iv = interpolated.unwrap();
785 assert!(iv > 0.70 && iv < 0.75, "Expected ~0.725, got {}", iv);
786 }
787
788 #[test]
789 fn test_interpolation_2x2_grid() {
790 let mut surface = VolatilitySurface::new();
791
792 surface.insert(40000.0, 1735689600, 0.70); surface.insert(40000.0, 1735776000, 0.75); surface.insert(60000.0, 1735689600, 0.80); surface.insert(60000.0, 1735776000, 0.85); let mid_strike = 50000.0;
800 let mid_expiry = (1735689600 + 1735776000) / 2;
801
802 let iv = surface.get_interpolated(mid_strike, mid_expiry);
803 assert!(iv.is_some());
804
805 let expected = 0.775;
807 let actual = iv.unwrap();
808 assert!(
809 (actual - expected).abs() < 0.01,
810 "Expected ~{}, got {}",
811 expected,
812 actual
813 );
814 }
815
816 #[test]
817 fn test_interpolation_exact_match() {
818 let mut surface = VolatilitySurface::new();
819 surface.insert(50000.0, 1735689600, 0.80);
820
821 let iv = surface.get_interpolated(50000.0, 1735689600);
823 assert_eq!(iv, Some(0.80));
824 }
825
826 #[test]
827 fn test_empty_surface() {
828 let surface = VolatilitySurface::new();
829
830 assert!(surface.is_empty());
831 assert_eq!(surface.len(), 0);
832 assert_eq!(surface.get(50000.0, 1735689600), None);
833 assert_eq!(surface.get_interpolated(50000.0, 1735689600), None);
834 assert_eq!(surface.get_atm(1735689600), None);
835 }
836
837 #[test]
838 fn test_clear() {
839 let mut surface = VolatilitySurface::new();
840 surface.insert(50000.0, 1735689600, 0.80);
841 surface.set_atm_vol(1735689600, 0.70);
842
843 assert!(!surface.is_empty());
844
845 surface.clear();
846
847 assert!(surface.is_empty());
848 assert_eq!(surface.get(50000.0, 1735689600), None);
849 assert_eq!(surface.get_atm(1735689600), None);
850 }
851
852 #[test]
853 fn test_custom_precision() {
854 let mut surface = VolatilitySurface::with_precision(1000.0);
856 surface.insert(50000.0, 1735689600, 0.80);
857
858 assert_eq!(surface.get(50499.0, 1735689600), Some(0.80));
862 assert_eq!(surface.get(49501.0, 1735689600), Some(0.80));
863
864 assert_eq!(surface.get(50501.0, 1735689600), None);
867 assert_eq!(surface.get(51500.0, 1735689600), None);
868 }
869
870 #[test]
871 fn test_expiries_tracking() {
872 let mut surface = VolatilitySurface::new();
873 surface.insert(50000.0, 1735689600, 0.80);
874 surface.insert(50000.0, 1735776000, 0.75);
875 surface.set_atm_vol(1735862400, 0.70);
876
877 let expiries = surface.expiries();
878 assert_eq!(expiries.len(), 3);
879 assert!(expiries.contains(&1735689600));
880 assert!(expiries.contains(&1735776000));
881 assert!(expiries.contains(&1735862400));
882 }
883
884 #[test]
885 fn test_partial_grid_interpolation() {
886 let mut surface = VolatilitySurface::new();
887
888 surface.insert(40000.0, 1735689600, 0.70);
890 surface.insert(40000.0, 1735776000, 0.75);
891 surface.insert(60000.0, 1735689600, 0.80);
892 let mid_strike = 50000.0;
895 let mid_expiry = (1735689600 + 1735776000) / 2;
896
897 let iv = surface.get_interpolated(mid_strike, mid_expiry);
898 assert!(iv.is_some());
899
900 let expected = (0.70 + 0.75 + 0.80) / 3.0;
902 let actual = iv.unwrap();
903 assert!(
904 (actual - expected).abs() < 0.01,
905 "Expected ~{}, got {}",
906 expected,
907 actual
908 );
909 }
910
911 #[test]
912 fn test_delta_surface_interpolation() {
913 let mut surface = VolatilitySurface::new();
914 let expiry = chrono::Utc::now().timestamp() + 30 * 86400; surface.set_delta_iv(expiry, 0.10, 0.70); surface.set_delta_iv(expiry, 0.25, 0.55); surface.set_delta_iv(expiry, 0.50, 0.45); surface.set_delta_iv(expiry, 0.75, 0.50); surface.set_delta_iv(expiry, 0.90, 0.60); surface.set_atm_vol(expiry, 0.45);
924
925 let spot = 80000.0;
926
927 let atm_iv = surface.get_interpolated_with_spot(spot, expiry, Some(spot));
929 assert!(atm_iv.is_some());
930 let v = atm_iv.unwrap();
931 assert!(
932 (v - 0.45).abs() < 0.05,
933 "ATM query should give ~0.45, got {}",
934 v
935 );
936
937 let otm_put_iv = surface.get_interpolated_with_spot(50000.0, expiry, Some(spot));
939 assert!(otm_put_iv.is_some());
940 assert!(
941 otm_put_iv.unwrap() > 0.50,
942 "OTM put strike should have higher IV than ATM, got {}",
943 otm_put_iv.unwrap()
944 );
945
946 assert!(!surface.is_empty());
948 }
949
950 #[test]
951 fn test_delta_curve_interpolation_helper() {
952 let curve = vec![
953 DeltaIvPoint {
954 delta: 0.10,
955 iv: 0.70,
956 },
957 DeltaIvPoint {
958 delta: 0.50,
959 iv: 0.45,
960 },
961 DeltaIvPoint {
962 delta: 0.90,
963 iv: 0.60,
964 },
965 ];
966
967 assert_eq!(interpolate_curve(&curve, 0.50), Some(0.45));
969
970 let mid = interpolate_curve(&curve, 0.30).unwrap();
972 assert!(mid > 0.45 && mid < 0.70, "Expected ~0.575, got {}", mid);
973
974 assert_eq!(interpolate_curve(&curve, 0.01), Some(0.70));
976
977 assert_eq!(interpolate_curve(&curve, 0.99), Some(0.60));
979 }
980
981 #[test]
982 fn test_normal_cdf() {
983 assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6);
984 assert!((normal_cdf(1.0) - 0.8413).abs() < 0.01);
985 assert!((normal_cdf(-1.0) - 0.1587).abs() < 0.01);
986 }
987
988 #[test]
991 fn test_export_all_points_empty() {
992 let surface = VolatilitySurface::new();
993 assert!(surface.export_all_points().is_empty());
994 }
995
996 #[test]
997 fn test_export_all_points() {
998 let mut surface = VolatilitySurface::new();
999 surface.insert(50000.0, 1735689600, 0.80);
1000 surface.insert(60000.0, 1735689600, 0.85);
1001 surface.insert(50000.0, 1735776000, 0.75);
1002 assert_eq!(surface.export_all_points().len(), 3);
1003 }
1004
1005 #[test]
1006 fn test_export_delta_curves_empty() {
1007 let surface = VolatilitySurface::new();
1008 assert!(surface.export_delta_curves().is_empty());
1009 }
1010
1011 #[test]
1012 fn test_export_delta_curves() {
1013 let mut surface = VolatilitySurface::new();
1014 surface.set_delta_iv(1735689600, 0.25, 0.55);
1015 surface.set_delta_iv(1735689600, 0.50, 0.45);
1016 surface.set_delta_iv(1735776000, 0.25, 0.60);
1017 let curves = surface.export_delta_curves();
1018 assert_eq!(curves.len(), 2);
1019 for curve in &curves {
1020 assert!(!curve.points.is_empty());
1021 }
1022 }
1023
1024 #[test]
1025 fn test_export_atm_vols_empty() {
1026 let surface = VolatilitySurface::new();
1027 assert!(surface.export_atm_vols().is_empty());
1028 }
1029
1030 #[test]
1031 fn test_export_atm_vols() {
1032 let mut surface = VolatilitySurface::new();
1033 surface.set_atm_vol(1735689600, 0.70);
1034 surface.set_atm_vol(1735776000, 0.75);
1035 surface.set_atm_vol(1735862400, 0.80);
1036 let atm = surface.export_atm_vols();
1037 assert_eq!(atm.len(), 3);
1038 }
1039
1040 }