Skip to main content

hypercall_vol_oracle/
vol_surface_cache.rs

1//! Volatility surface cache for storing and querying implied volatility data.
2//!
3//! This module provides an efficient cache for volatility surface data with:
4//! - O(1) lookups for exact (strike, expiry) matches
5//! - Bilinear interpolation for points between grid nodes
6//! - Strike quantization to avoid floating-point comparison issues
7
8use 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/// A single point on the volatility surface.
16#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
17pub struct VolPoint {
18    /// Strike price in USD
19    pub strike: f64,
20    /// Expiry timestamp in Unix seconds
21    pub expiry: i64,
22    /// Implied volatility as decimal (0.80 = 80%)
23    pub iv: f64,
24    /// Timestamp when this point was last updated (milliseconds)
25    pub timestamp: i64,
26}
27
28/// Exported delta-IV pair for API responses.
29#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
30pub struct DeltaIvExport {
31    pub delta: f64,
32    pub iv: f64,
33}
34
35/// Exported delta curve for a single expiry.
36#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
37pub struct DeltaCurveExport {
38    pub expiry: i64,
39    pub points: Vec<DeltaIvExport>,
40}
41
42/// A delta-IV pair on the vol smile for a single expiry.
43#[derive(Debug, Clone, Copy)]
44struct DeltaIvPoint {
45    /// Call delta (0.0–1.0)
46    delta: f64,
47    /// Implied volatility as decimal
48    iv: f64,
49}
50
51/// Volatility surface for a single underlying.
52///
53/// Supports two data representations:
54/// 1. Strike-space: direct (strike, expiry) → IV grid with bilinear interpolation
55/// 2. Delta-space: (delta, expiry) → IV curves, queried via strike→delta conversion
56///
57/// When both are available, strike-space is preferred (exact). When only delta-space
58/// data exists (e.g., from Block Scholes delta.iv feed), the caller provides spot
59/// price to convert query strikes to deltas for interpolation.
60#[derive(Debug, Clone)]
61pub struct VolatilitySurface {
62    /// Map from quantized_strike -> expiry -> VolPoint
63    points: HashMap<i64, HashMap<i64, VolPoint>>,
64    /// ATM volatilities by expiry for quick ATM lookups
65    atm_vols: HashMap<i64, f64>,
66    /// Delta-IV curves by expiry, sorted by delta ascending
67    delta_curves: HashMap<i64, Vec<DeltaIvPoint>>,
68    /// Strike quantization factor (e.g., 100 = $100 precision)
69    strike_precision: f64,
70    /// Last update time for the entire surface (milliseconds)
71    last_update: Option<i64>,
72    /// All known expiries for iteration
73    expiries: BTreeSet<i64>,
74}
75
76impl Default for VolatilitySurface {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl VolatilitySurface {
83    /// Create a new empty volatility surface with default $1 strike precision.
84    pub fn new() -> Self {
85        Self::with_precision(1.0)
86    }
87
88    /// Create a new volatility surface with custom strike precision.
89    ///
90    /// # Arguments
91    /// * `strike_precision` - Strike price bucket size (e.g., 1.0 for $1 buckets)
92    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    /// Quantize strike to avoid floating point comparison issues.
104    fn quantize_strike(&self, strike: f64) -> i64 {
105        (strike / self.strike_precision).round() as i64
106    }
107
108    /// Insert or update a volatility point.
109    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    /// Set ATM volatility for an expiry.
130    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    /// Store a delta-IV point for an expiry.
137    ///
138    /// Delta-IV curves are used when strike-level data is unavailable.
139    /// The caller provides spot price at query time to convert strikes to deltas.
140    pub fn set_delta_iv(&mut self, expiry: i64, delta: f64, iv: f64) {
141        let curve = self.delta_curves.entry(expiry).or_default();
142        // Update existing delta or append
143        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    /// Get volatility at exact (strike, expiry) or None.
154    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    /// Get the full VolPoint at exact (strike, expiry) or None.
163    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    /// Get volatility with interpolation if exact match not found.
171    ///
172    /// Uses bilinear interpolation across strike and expiry dimensions.
173    /// If `spot` is provided and no strike-level data exists, falls back to
174    /// delta-space interpolation: converts the query strike to a call delta
175    /// and interpolates the delta-IV smile, then interpolates across expiries.
176    pub fn get_interpolated_with_spot(
177        &self,
178        strike: f64,
179        expiry: i64,
180        spot: Option<f64>,
181    ) -> Option<f64> {
182        // First try exact match
183        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        // Find nearest strikes
196        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            // No strike-level data — try delta-space interpolation
201            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            // Last resort: flat ATM
216            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        // Find bracketing strikes
231        let (lower_strike_q, upper_strike_q) =
232            self.find_bracketing_values(quantized, &strike_keys)?;
233
234        // Get expiry maps for each strike
235        let lower_expiries = self.points.get(&lower_strike_q)?;
236        let upper_expiries = self.points.get(&upper_strike_q)?;
237
238        // Collect all expiries from both strikes
239        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        // Get the four corner points
249        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        // Convert quantized strikes back to actual values for interpolation
255        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        // Bilinear interpolation
259        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    /// Get volatility with interpolation (no spot price for delta conversion).
295    pub fn get_interpolated(&self, strike: f64, expiry: i64) -> Option<f64> {
296        self.get_interpolated_with_spot(strike, expiry, None)
297    }
298
299    /// Interpolate the delta-IV surface for a given strike and expiry.
300    ///
301    /// Converts strike to call delta using approximate ATM vol, then
302    /// linearly interpolates the delta-IV smile at that delta.
303    /// Finally interpolates across expiries.
304    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        // Linear interpolation across expiries
321        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    /// Interpolate the delta-IV smile at a single expiry for a given strike.
326    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        // Use ATM vol as initial guess for delta computation
338        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            // Expired — return ATM vol for this expiry if available
343            return self.atm_vols.get(&expiry).copied();
344        }
345
346        // Convert strike to call delta: δ = N(d1)
347        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 the delta-IV curve
352        interpolate_curve(curve, delta)
353    }
354
355    /// Get ATM volatility for a given expiry.
356    pub fn get_atm(&self, expiry: i64) -> Option<f64> {
357        // First check explicit ATM vols
358        if let Some(&iv) = self.atm_vols.get(&expiry) {
359            return Some(iv);
360        }
361
362        // Fallback: try to interpolate ATM vol from nearby expiries
363        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        // Linear interpolation between expiries
378        let t = (expiry - lower) as f64 / (upper - lower) as f64;
379        Some(lower_iv + t * (upper_iv - lower_iv))
380    }
381
382    /// Get the last update timestamp.
383    pub fn last_update(&self) -> Option<i64> {
384        self.last_update
385    }
386
387    /// Get all known expiries.
388    pub fn expiries(&self) -> &BTreeSet<i64> {
389        &self.expiries
390    }
391
392    /// Get the number of points in the surface.
393    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    /// Check if the surface is empty.
400    pub fn is_empty(&self) -> bool {
401        self.points.is_empty() && self.atm_vols.is_empty() && self.delta_curves.is_empty()
402    }
403
404    /// Export all strike-space points for serialisation.
405    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    /// Export delta-IV curves for every expiry.
414    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    /// Export ATM vols as (expiry, iv) pairs.
431    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    /// Enforce static no-arbitrage constraints on the strike-indexed surface.
439    ///
440    /// For each expiry, walks strikes ascending and clamps IVs down so that:
441    /// 1. Call prices are non-increasing in K (no vertical-spread arb)
442    /// 2. Call prices are convex in K (no butterfly arb)
443    ///
444    /// Both constraints follow from risk-neutral pricing; they must hold for any
445    /// arb-free surface. We enforce in price space (via Black-Scholes) then back
446    /// out the clamped IV by bisection — this handles non-uniform strike grids
447    /// correctly and uses the same pricer the rest of the system trusts.
448    ///
449    /// Only clamps *down*: if an IV is lower than it should be for arb-freeness,
450    /// we do nothing. Lowering an IV cannot create new arbs against neighbors we
451    /// have already cleaned.
452    ///
453    /// Returns the number of IV points modified. Pass `risk_free_rate = 0.0` on
454    /// testnet.
455    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            // Gather (strike, iv, quantized_strike) for this expiry, sorted asc.
470            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            // Pass 1: monotonicity — C(K_i) <= C(K_{i-1}).
481            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            // Pass 2: butterfly / convexity. For convex C(K): at interior K_i,
497            // C_i <= w_prev * C_{i-1} + w_next * C_{i+1}, where weights come from
498            // linear interpolation between K_{i-1} and K_{i+1}.
499            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    /// Mutate the stored IV for an already-present (quantized_strike, expiry).
527    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    /// Clear all data from the surface.
537    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    /// Find bracketing values for interpolation.
546    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                    // Target is below all values
559                    Some((sorted[0], sorted[0]))
560                } else if i >= sorted.len() {
561                    // Target is above all values
562                    let last = *sorted.last()?;
563                    Some((last, last))
564                } else {
565                    Some((sorted[i - 1], sorted[i]))
566                }
567            }
568        }
569    }
570
571    /// Perform bilinear interpolation on the four corner values.
572    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        // Collect available corner values
587        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 we don't have all four corners, fall back to average
597        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        // Handle edge cases where points coincide
607        if (upper_strike - lower_strike).abs() < 1e-9 && upper_expiry == lower_expiry {
608            return Some(lower_lower);
609        }
610
611        // Calculate interpolation weights
612        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        // Bilinear interpolation formula
625        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
647/// Tolerance for declaring a call-price arb violation (quote-unit dollars).
648const PRICE_EPS: f64 = 1e-6;
649/// Minimum IV change worth writing back after solving (avoids flapping).
650const IV_EPS: f64 = 1e-5;
651/// IV floor used during bisection; below this the option is effectively intrinsic.
652const 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
658/// Find σ in [IV_FLOOR, iv_hint] such that BS call price ≈ target. BS call is
659/// monotone increasing in σ, so bisection converges. If the lower bound's price
660/// already exceeds target (target below intrinsic), returns IV_FLOOR.
661fn 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; // nothing to lower
671    }
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
687/// Normal CDF approximation (Abramowitz & Stegun 26.2.17).
688fn normal_cdf(x: f64) -> f64 {
689    0.5 * (1.0 + libm::erf(x / std::f64::consts::SQRT_2))
690}
691
692/// Linear interpolation on a sorted delta-IV curve.
693fn 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    // Clamp to curve bounds
701    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    // Find bracketing points
708    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
718/// Find bracketing values in a sorted slice.
719fn 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); // Different expiry
751        assert_eq!(surface.get(50100.0, 1735689600), None); // Different strike
752    }
753
754    #[test]
755    fn test_strike_quantization() {
756        let mut surface = VolatilitySurface::new();
757        surface.insert(50000.0, 1735689600, 0.80);
758
759        // Should find the same point with nearby strike (within ±$0.50 of bucket center)
760        // 50000.49 / 1 = 50000.49 rounds to 50000 (same bucket as 50000)
761        // 49999.51 / 1 = 49999.51 rounds to 50000 (same bucket as 50000)
762        assert_eq!(surface.get(50000.49, 1735689600), Some(0.80));
763        assert_eq!(surface.get(49999.51, 1735689600), Some(0.80));
764
765        // Should NOT find with strike in different bucket
766        // 50000.51 / 1 = 50000.51 rounds to 50001 (different bucket)
767        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        // Test interpolation between expiries
781        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        // Create a 2x2 grid
793        surface.insert(40000.0, 1735689600, 0.70); // Lower strike, lower expiry
794        surface.insert(40000.0, 1735776000, 0.75); // Lower strike, upper expiry
795        surface.insert(60000.0, 1735689600, 0.80); // Upper strike, lower expiry
796        surface.insert(60000.0, 1735776000, 0.85); // Upper strike, upper expiry
797
798        // Query the center point
799        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        // Expected: average of all four corners = (0.70 + 0.75 + 0.80 + 0.85) / 4 = 0.775
806        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        // Exact match should return exact value
822        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        // Use $1000 strike precision
855        let mut surface = VolatilitySurface::with_precision(1000.0);
856        surface.insert(50000.0, 1735689600, 0.80);
857
858        // Should find with strikes within ±$500 of bucket center
859        // 50499.0 / 1000 = 50.499 rounds to 50 (same bucket as 50000)
860        // 49501.0 / 1000 = 49.501 rounds to 50 (same bucket as 50000)
861        assert_eq!(surface.get(50499.0, 1735689600), Some(0.80));
862        assert_eq!(surface.get(49501.0, 1735689600), Some(0.80));
863
864        // Should NOT find with strike in different bucket
865        // 50501.0 / 1000 = 50.501 rounds to 51 (different bucket)
866        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        // Only have 3 of 4 corners
889        surface.insert(40000.0, 1735689600, 0.70);
890        surface.insert(40000.0, 1735776000, 0.75);
891        surface.insert(60000.0, 1735689600, 0.80);
892        // Missing: (60000, 1735776000)
893
894        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        // Should fall back to average of available points
901        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        // Use a far-future expiry so TTE > 0
915        let expiry = chrono::Utc::now().timestamp() + 30 * 86400; // 30 days out
916
917        // Set up a vol smile: OTM puts (low delta) have higher IV than ATM
918        surface.set_delta_iv(expiry, 0.10, 0.70); // 10-delta: 70%
919        surface.set_delta_iv(expiry, 0.25, 0.55); // 25-delta: 55%
920        surface.set_delta_iv(expiry, 0.50, 0.45); // ATM: 45%
921        surface.set_delta_iv(expiry, 0.75, 0.50); // 75-delta: 50%
922        surface.set_delta_iv(expiry, 0.90, 0.60); // 90-delta: 60%
923        surface.set_atm_vol(expiry, 0.45);
924
925        let spot = 80000.0;
926
927        // ATM strike (~spot) should give ~ATM IV
928        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        // Deep OTM put (very low strike) → low delta → higher IV
938        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        // Surface len should include delta points
947        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        // Exact match
968        assert_eq!(interpolate_curve(&curve, 0.50), Some(0.45));
969
970        // Between 0.10 and 0.50 (midpoint delta=0.30)
971        let mid = interpolate_curve(&curve, 0.30).unwrap();
972        assert!(mid > 0.45 && mid < 0.70, "Expected ~0.575, got {}", mid);
973
974        // Below range → clamp to first
975        assert_eq!(interpolate_curve(&curve, 0.01), Some(0.70));
976
977        // Above range → clamp to last
978        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    // --- Export method tests ---
989
990    #[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    // No-arb sanitizer tests live in tests/vol_surface_arb_free_test.rs so they
1041    // link against the public lib only, bypassing unrelated lib-test build
1042    // errors in other modules of the crate.
1043}