Skip to main content

hypercall_vol_oracle/
sticky_moneyness_oracle.rs

1use std::collections::HashMap;
2use std::sync::{Arc, RwLock};
3use std::time::Duration;
4
5use chrono::Utc;
6use metrics::{counter, gauge};
7use tracing::debug;
8
9use super::polygon_oracle::PlatformSpotPrices;
10use super::risk_oracle::{
11    RiskVolOracle, SharedVolOracle, VolLookupError, VolOracleStatus, VolProviderKind,
12    VolSurfaceSnapshot,
13};
14use super::vol_surface_cache::VolatilitySurface;
15
16#[derive(Debug, Clone)]
17pub struct StickyMoneynessVolOracleConfig {
18    pub underlyings: Vec<String>,
19    pub max_snapshot_age: Duration,
20    pub event_jump: f64,
21    pub min_tte_years: f64,
22}
23
24pub struct StickyMoneynessVolOracle {
25    source: SharedVolOracle,
26    platform_spots: PlatformSpotPrices,
27    config: StickyMoneynessVolOracleConfig,
28    messages_received: Arc<RwLock<HashMap<String, u64>>>,
29}
30
31impl StickyMoneynessVolOracle {
32    pub fn new(
33        source: SharedVolOracle,
34        platform_spots: PlatformSpotPrices,
35        config: StickyMoneynessVolOracleConfig,
36    ) -> Self {
37        Self {
38            source,
39            platform_spots,
40            config,
41            messages_received: Arc::new(RwLock::new(HashMap::new())),
42        }
43    }
44
45    fn snapshot_for(&self, underlying: &str) -> Result<VolSurfaceSnapshot, VolLookupError> {
46        self.ensure_configured_underlying(underlying)?;
47
48        let snapshot = self
49            .source
50            .get_surface_snapshot(underlying)
51            .ok_or_else(|| VolLookupError::UnhealthyProvider {
52                underlying: underlying.to_string(),
53                provider: VolProviderKind::StickyMoneyness,
54                reason: "source provider has no last-good surface snapshot".to_string(),
55            })?;
56
57        let age = snapshot_age_seconds(&snapshot);
58        if age > self.config.max_snapshot_age.as_secs_f64() {
59            return Err(VolLookupError::StaleSurface {
60                underlying: underlying.to_string(),
61                provider: VolProviderKind::StickyMoneyness,
62                staleness_seconds: age,
63                threshold_seconds: self.config.max_snapshot_age.as_secs_f64(),
64            });
65        }
66
67        Ok(snapshot)
68    }
69
70    fn ensure_configured_underlying(&self, underlying: &str) -> Result<(), VolLookupError> {
71        if !self
72            .config
73            .underlyings
74            .iter()
75            .any(|item| item == underlying)
76        {
77            return Err(VolLookupError::UnsupportedUnderlying {
78                underlying: underlying.to_string(),
79            });
80        }
81
82        Ok(())
83    }
84
85    fn current_spot(&self, underlying: &str) -> Result<f64, VolLookupError> {
86        let spot = self
87            .platform_spots
88            .read()
89            .expect("platform_spots poisoned")
90            .get(underlying)
91            .copied()
92            .ok_or_else(|| VolLookupError::UnhealthyProvider {
93                underlying: underlying.to_string(),
94                provider: VolProviderKind::StickyMoneyness,
95                reason: "missing current platform spot".to_string(),
96            })?;
97
98        if !spot.is_finite() || spot <= 0.0 {
99            return Err(VolLookupError::UnhealthyProvider {
100                underlying: underlying.to_string(),
101                provider: VolProviderKind::StickyMoneyness,
102                reason: format!("invalid current platform spot {spot}"),
103            });
104        }
105
106        Ok(spot)
107    }
108
109    fn surface_from_snapshot(
110        &self,
111        snapshot: &VolSurfaceSnapshot,
112    ) -> Result<VolatilitySurface, VolLookupError> {
113        let mut surface = VolatilitySurface::new();
114        for point in &snapshot.strike_points {
115            if point.strike.is_finite()
116                && point.strike > 0.0
117                && point.iv.is_finite()
118                && point.iv > 0.0
119                && point.expiry > Utc::now().timestamp()
120            {
121                surface.insert(point.strike, point.expiry, point.iv);
122            }
123        }
124        for (expiry, iv) in &snapshot.atm_vols {
125            if iv.is_finite() && *iv > 0.0 && *expiry > Utc::now().timestamp() {
126                surface.set_atm_vol(*expiry, *iv);
127            }
128        }
129        for curve in &snapshot.delta_curves {
130            if curve.expiry <= Utc::now().timestamp() {
131                continue;
132            }
133            for point in &curve.points {
134                if point.delta.is_finite()
135                    && (0.0..=1.0).contains(&point.delta)
136                    && point.iv.is_finite()
137                    && point.iv > 0.0
138                {
139                    surface.set_delta_iv(curve.expiry, point.delta, point.iv);
140                }
141            }
142        }
143
144        if surface.is_empty() {
145            return Err(VolLookupError::UnhealthyProvider {
146                underlying: snapshot.underlying.clone(),
147                provider: VolProviderKind::StickyMoneyness,
148                reason: "source snapshot has no usable unexpired volatility points".to_string(),
149            });
150        }
151
152        Ok(surface)
153    }
154
155    fn transform_iv(
156        &self,
157        underlying: &str,
158        base_iv: f64,
159        expiry_ts: i64,
160    ) -> Result<f64, VolLookupError> {
161        let now = Utc::now().timestamp();
162        let tte =
163            time_to_expiry_years(now, expiry_ts, self.config.min_tte_years).ok_or_else(|| {
164                VolLookupError::UnhealthyProvider {
165                    underlying: underlying.to_string(),
166                    provider: VolProviderKind::StickyMoneyness,
167                    reason: "invalid time-to-expiry inputs".to_string(),
168                }
169            })?;
170        apply_event_variance(base_iv, tte, self.config.event_jump).ok_or_else(|| {
171            VolLookupError::UnhealthyProvider {
172                underlying: underlying.to_string(),
173                provider: VolProviderKind::StickyMoneyness,
174                reason: "event variance transform produced invalid implied volatility".to_string(),
175            }
176        })
177    }
178
179    fn increment_lookup_counter(&self, underlying: &str) {
180        let mut counts = self
181            .messages_received
182            .write()
183            .expect("sticky moneyness counter state poisoned");
184        *counts.entry(underlying.to_string()).or_insert(0) += 1;
185    }
186
187    fn status_for(&self, underlying: &str) -> VolOracleStatus {
188        let snapshot = self.source.get_surface_snapshot(underlying);
189        let age = snapshot.as_ref().map(snapshot_age_seconds);
190        let ready = snapshot.as_ref().is_some_and(|snapshot| {
191            snapshot.spot_price.is_some_and(valid_positive_finite)
192                && age.is_some_and(|age| age <= self.config.max_snapshot_age.as_secs_f64())
193                && self
194                    .platform_spots
195                    .read()
196                    .expect("platform_spots poisoned")
197                    .get(underlying)
198                    .copied()
199                    .is_some_and(valid_positive_finite)
200        });
201        let surface_points = snapshot
202            .as_ref()
203            .map(|snapshot| {
204                snapshot.strike_points.len()
205                    + snapshot.atm_vols.len()
206                    + snapshot
207                        .delta_curves
208                        .iter()
209                        .map(|curve| curve.points.len())
210                        .sum::<usize>()
211            })
212            .unwrap_or(0);
213        let messages_received = self
214            .messages_received
215            .read()
216            .expect("sticky moneyness counter state poisoned")
217            .get(underlying)
218            .copied()
219            .unwrap_or(0);
220
221        VolOracleStatus {
222            underlying: underlying.to_string(),
223            provider: VolProviderKind::StickyMoneyness,
224            route_facing: true,
225            connected: snapshot.is_some(),
226            ready,
227            last_update_ts_ms: snapshot.and_then(|snapshot| snapshot.last_update_ts_ms),
228            staleness_seconds: age,
229            staleness_threshold_seconds: Some(self.config.max_snapshot_age.as_secs_f64()),
230            surface_points,
231            messages_received,
232            last_error: if ready {
233                None
234            } else {
235                Some("waiting for valid source snapshot and current spot".to_string())
236            },
237        }
238    }
239}
240
241impl RiskVolOracle for StickyMoneynessVolOracle {
242    fn get_iv(&self, underlying: &str, strike: f64, expiry_ts: i64) -> Result<f64, VolLookupError> {
243        self.ensure_configured_underlying(underlying)?;
244
245        if !strike.is_finite() || strike <= 0.0 {
246            return Err(VolLookupError::MissingSurface {
247                underlying: underlying.to_string(),
248                provider: VolProviderKind::StickyMoneyness,
249                strike,
250                expiry_ts,
251            });
252        }
253
254        match self.source.get_iv(underlying, strike, expiry_ts) {
255            Ok(iv) => return Ok(iv),
256            Err(VolLookupError::StaleSurface { .. } | VolLookupError::UnhealthyProvider { .. }) => {
257            }
258            Err(err) => return Err(err),
259        }
260
261        let snapshot = self.snapshot_for(underlying)?;
262        let base_spot = snapshot
263            .spot_price
264            .ok_or_else(|| VolLookupError::UnhealthyProvider {
265                underlying: underlying.to_string(),
266                provider: VolProviderKind::StickyMoneyness,
267                reason: "source snapshot missing base spot".to_string(),
268            })?;
269        if !valid_positive_finite(base_spot) {
270            return Err(VolLookupError::UnhealthyProvider {
271                underlying: underlying.to_string(),
272                provider: VolProviderKind::StickyMoneyness,
273                reason: format!("source snapshot has invalid base spot {base_spot}"),
274            });
275        }
276
277        let current_spot = self.current_spot(underlying)?;
278        let base_strike =
279            map_sticky_moneyness_strike(strike, base_spot, current_spot).ok_or_else(|| {
280                VolLookupError::UnhealthyProvider {
281                    underlying: underlying.to_string(),
282                    provider: VolProviderKind::StickyMoneyness,
283                    reason: "sticky moneyness strike mapping produced invalid strike".to_string(),
284                }
285            })?;
286        let surface = self.surface_from_snapshot(&snapshot)?;
287        let base_iv = surface
288            .get_interpolated_with_spot(base_strike, expiry_ts, Some(base_spot))
289            .ok_or_else(|| VolLookupError::MissingSurface {
290                underlying: underlying.to_string(),
291                provider: VolProviderKind::StickyMoneyness,
292                strike,
293                expiry_ts,
294            })?;
295        let iv = self.transform_iv(underlying, base_iv, expiry_ts)?;
296
297        self.increment_lookup_counter(underlying);
298        counter!(
299            "ht_weekend_vol_transform_lookups_total",
300            "underlying" => underlying.to_string()
301        )
302        .increment(1);
303        gauge!(
304            "ht_weekend_vol_event_jump_pct",
305            "underlying" => underlying.to_string()
306        )
307        .set(self.config.event_jump);
308
309        debug!(
310            underlying,
311            strike,
312            base_strike,
313            base_spot,
314            current_spot,
315            expiry_ts,
316            base_iv,
317            iv,
318            provider = VolProviderKind::StickyMoneyness.as_str(),
319            "Sticky-moneyness transformed volatility used"
320        );
321
322        Ok(iv)
323    }
324
325    fn statuses(&self) -> Vec<VolOracleStatus> {
326        self.config
327            .underlyings
328            .iter()
329            .map(|underlying| self.status_for(underlying))
330            .collect()
331    }
332
333    fn get_surface_snapshot(&self, underlying: &str) -> Option<VolSurfaceSnapshot> {
334        self.ensure_configured_underlying(underlying).ok()?;
335        self.source.get_surface_snapshot(underlying)
336    }
337
338    fn supports_surface_snapshots(&self) -> bool {
339        self.source.supports_surface_snapshots()
340    }
341}
342
343fn snapshot_age_seconds(snapshot: &VolSurfaceSnapshot) -> f64 {
344    snapshot
345        .last_update_ts_ms
346        .map(|ts| ((Utc::now().timestamp_millis() - ts) as f64 / 1000.0).max(0.0))
347        .unwrap_or(f64::INFINITY)
348}
349
350fn valid_positive_finite(value: f64) -> bool {
351    value.is_finite() && value > 0.0
352}
353
354fn map_sticky_moneyness_strike(strike: f64, base_spot: f64, current_spot: f64) -> Option<f64> {
355    if !valid_positive_finite(strike)
356        || !valid_positive_finite(base_spot)
357        || !valid_positive_finite(current_spot)
358    {
359        return None;
360    }
361
362    let mapped = strike * base_spot / current_spot;
363    valid_positive_finite(mapped).then_some(mapped)
364}
365
366fn time_to_expiry_years(now_ts: i64, expiry_ts: i64, min_tte_years: f64) -> Option<f64> {
367    if !valid_positive_finite(min_tte_years) {
368        return None;
369    }
370
371    let seconds = expiry_ts.checked_sub(now_ts)? as f64;
372    let tte = (seconds / (365.25 * 86_400.0)).max(min_tte_years);
373    valid_positive_finite(tte).then_some(tte)
374}
375
376fn apply_event_variance(base_iv: f64, tte_years: f64, event_jump: f64) -> Option<f64> {
377    if !valid_positive_finite(base_iv)
378        || !valid_positive_finite(tte_years)
379        || !event_jump.is_finite()
380        || event_jump < 0.0
381    {
382        return None;
383    }
384
385    let base_variance = base_iv * base_iv * tte_years;
386    let event_variance = event_jump * event_jump;
387    let transformed = ((base_variance + event_variance) / tte_years).sqrt();
388    valid_positive_finite(transformed).then_some(transformed)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use std::sync::Arc;
395
396    struct SnapshotOracle {
397        snapshot: VolSurfaceSnapshot,
398        response: Result<f64, VolLookupError>,
399    }
400
401    impl RiskVolOracle for SnapshotOracle {
402        fn get_iv(
403            &self,
404            _underlying: &str,
405            _strike: f64,
406            _expiry_ts: i64,
407        ) -> Result<f64, VolLookupError> {
408            self.response.clone()
409        }
410
411        fn statuses(&self) -> Vec<VolOracleStatus> {
412            Vec::new()
413        }
414
415        fn get_surface_snapshot(&self, _underlying: &str) -> Option<VolSurfaceSnapshot> {
416            Some(self.snapshot.clone())
417        }
418
419        fn supports_surface_snapshots(&self) -> bool {
420            true
421        }
422    }
423
424    fn stale_source(underlying: &str) -> Result<f64, VolLookupError> {
425        Err(VolLookupError::StaleSurface {
426            underlying: underlying.to_string(),
427            provider: VolProviderKind::Polygon,
428            staleness_seconds: 999.0,
429            threshold_seconds: 60.0,
430        })
431    }
432
433    fn snapshot(underlying: &str, base_spot: Option<f64>, expiry: i64) -> VolSurfaceSnapshot {
434        VolSurfaceSnapshot {
435            underlying: underlying.to_string(),
436            last_update_ts_ms: Some(Utc::now().timestamp_millis()),
437            expiries: vec![expiry],
438            strike_points: vec![super::super::vol_surface_cache::VolPoint {
439                strike: 100.0,
440                expiry,
441                iv: 0.50,
442                timestamp: Utc::now().timestamp_millis(),
443            }],
444            delta_curves: Vec::new(),
445            atm_vols: Vec::new(),
446            spot_price: base_spot,
447        }
448    }
449
450    fn oracle(
451        snapshot: VolSurfaceSnapshot,
452        response: Result<f64, VolLookupError>,
453        current_spot: f64,
454        event_jump: f64,
455    ) -> StickyMoneynessVolOracle {
456        let underlying = snapshot.underlying.clone();
457        let spots = Arc::new(RwLock::new(HashMap::from([(
458            underlying.clone(),
459            current_spot,
460        )])));
461        StickyMoneynessVolOracle::new(
462            Arc::new(SnapshotOracle { snapshot, response }),
463            spots,
464            StickyMoneynessVolOracleConfig {
465                underlyings: vec![underlying],
466                max_snapshot_age: Duration::from_secs(3600),
467                event_jump,
468                min_tte_years: 1.0 / 365.25,
469            },
470        )
471    }
472
473    #[test]
474    fn preserves_log_moneyness_when_spot_moves() {
475        let expiry = Utc::now().timestamp() + 30 * 86_400;
476        let mut snapshot = snapshot("OIL", Some(100.0), expiry);
477        snapshot.strike_points = vec![
478            super::super::vol_surface_cache::VolPoint {
479                strike: 90.0,
480                expiry,
481                iv: 0.40,
482                timestamp: Utc::now().timestamp_millis(),
483            },
484            super::super::vol_surface_cache::VolPoint {
485                strike: 100.0,
486                expiry,
487                iv: 0.50,
488                timestamp: Utc::now().timestamp_millis(),
489            },
490            super::super::vol_surface_cache::VolPoint {
491                strike: 110.0,
492                expiry,
493                iv: 0.60,
494                timestamp: Utc::now().timestamp_millis(),
495            },
496        ];
497        let oracle = oracle(snapshot, stale_source("OIL"), 120.0, 0.0);
498
499        let iv = oracle.get_iv("OIL", 120.0, expiry).unwrap();
500        assert!((iv - 0.50).abs() < 1e-9);
501    }
502
503    #[test]
504    fn event_jump_adds_total_variance() {
505        let expiry = Utc::now().timestamp() + 365 * 86_400;
506        let oracle = oracle(
507            snapshot("OIL", Some(100.0), expiry),
508            stale_source("OIL"),
509            100.0,
510            0.10,
511        );
512
513        let iv = oracle.get_iv("OIL", 100.0, expiry).unwrap();
514        assert!(iv > 0.50);
515    }
516
517    #[test]
518    fn live_source_success_replaces_transformed_snapshot() {
519        let expiry = Utc::now().timestamp() + 30 * 86_400;
520        let oracle = oracle(snapshot("OIL", Some(100.0), expiry), Ok(0.24), 120.0, 0.10);
521
522        let iv = oracle.get_iv("OIL", 120.0, expiry).unwrap();
523        assert!((iv - 0.24).abs() < 1e-12);
524    }
525
526    #[test]
527    fn missing_surface_from_source_is_not_transformed() {
528        let expiry = Utc::now().timestamp() + 30 * 86_400;
529        let source_err = VolLookupError::MissingSurface {
530            underlying: "OIL".to_string(),
531            provider: VolProviderKind::Polygon,
532            strike: 120.0,
533            expiry_ts: expiry,
534        };
535        let oracle = oracle(
536            snapshot("OIL", Some(100.0), expiry),
537            Err(source_err),
538            120.0,
539            0.0,
540        );
541
542        let err = oracle.get_iv("OIL", 120.0, expiry).unwrap_err();
543        assert!(matches!(
544            err,
545            VolLookupError::MissingSurface {
546                provider: VolProviderKind::Polygon,
547                ..
548            }
549        ));
550    }
551
552    #[test]
553    fn unsupported_underlying_fails_before_source_snapshot_transform() {
554        let expiry = Utc::now().timestamp() + 30 * 86_400;
555        let oracle = oracle(
556            snapshot("OIL", Some(100.0), expiry),
557            stale_source("OIL"),
558            100.0,
559            0.0,
560        );
561
562        let err = oracle.get_iv("GAS", 100.0, expiry).unwrap_err();
563        assert!(matches!(
564            err,
565            VolLookupError::UnsupportedUnderlying { underlying } if underlying == "GAS"
566        ));
567    }
568
569    #[test]
570    fn unsupported_underlying_fails_before_live_source_passthrough() {
571        let expiry = Utc::now().timestamp() + 30 * 86_400;
572        let oracle = oracle(snapshot("OIL", Some(100.0), expiry), Ok(0.24), 100.0, 0.0);
573
574        let err = oracle.get_iv("GAS", 100.0, expiry).unwrap_err();
575        assert!(matches!(
576            err,
577            VolLookupError::UnsupportedUnderlying { underlying } if underlying == "GAS"
578        ));
579    }
580
581    #[test]
582    fn forwards_source_snapshots_for_configured_underlyings() {
583        let expiry = Utc::now().timestamp() + 30 * 86_400;
584        let oracle = oracle(
585            snapshot("OIL", Some(100.0), expiry),
586            stale_source("OIL"),
587            100.0,
588            0.0,
589        );
590
591        assert!(oracle.supports_surface_snapshots());
592        assert!(oracle.get_surface_snapshot("OIL").is_some());
593        assert!(oracle.get_surface_snapshot("GAS").is_none());
594    }
595
596    #[test]
597    fn stale_last_good_snapshot_fails_closed() {
598        let expiry = Utc::now().timestamp() + 30 * 86_400;
599        let mut snapshot = snapshot("OIL", Some(100.0), expiry);
600        snapshot.last_update_ts_ms = Some(Utc::now().timestamp_millis() - 7_200_000);
601        let oracle = oracle(snapshot, stale_source("OIL"), 100.0, 0.0);
602
603        let err = oracle.get_iv("OIL", 100.0, expiry).unwrap_err();
604        assert!(matches!(
605            err,
606            VolLookupError::StaleSurface {
607                provider: VolProviderKind::StickyMoneyness,
608                ..
609            }
610        ));
611    }
612
613    #[test]
614    fn invalid_current_platform_spot_fails_closed() {
615        let expiry = Utc::now().timestamp() + 30 * 86_400;
616        let oracle = oracle(
617            snapshot("OIL", Some(100.0), expiry),
618            stale_source("OIL"),
619            0.0,
620            0.0,
621        );
622
623        let err = oracle.get_iv("OIL", 100.0, expiry).unwrap_err();
624        assert!(matches!(
625            err,
626            VolLookupError::UnhealthyProvider {
627                provider: VolProviderKind::StickyMoneyness,
628                reason,
629                ..
630            } if reason.contains("invalid current platform spot")
631        ));
632    }
633
634    #[test]
635    fn invalid_source_snapshot_spot_fails_closed() {
636        let expiry = Utc::now().timestamp() + 30 * 86_400;
637        let oracle = oracle(
638            snapshot("OIL", Some(0.0), expiry),
639            stale_source("OIL"),
640            100.0,
641            0.0,
642        );
643
644        let err = oracle.get_iv("OIL", 100.0, expiry).unwrap_err();
645        assert!(matches!(
646            err,
647            VolLookupError::UnhealthyProvider {
648                provider: VolProviderKind::StickyMoneyness,
649                reason,
650                ..
651            } if reason.contains("invalid base spot")
652        ));
653    }
654
655    #[test]
656    fn pure_strike_mapping_rejects_invalid_inputs() {
657        assert_eq!(
658            map_sticky_moneyness_strike(120.0, 100.0, 120.0),
659            Some(100.0)
660        );
661        assert_eq!(map_sticky_moneyness_strike(0.0, 100.0, 120.0), None);
662        assert_eq!(map_sticky_moneyness_strike(120.0, f64::NAN, 120.0), None);
663        assert_eq!(map_sticky_moneyness_strike(120.0, 100.0, 0.0), None);
664    }
665
666    #[test]
667    fn pure_event_variance_rejects_invalid_inputs() {
668        assert!(apply_event_variance(0.50, 0.1, 0.0).is_some());
669        assert_eq!(apply_event_variance(0.0, 0.1, 0.0), None);
670        assert_eq!(apply_event_variance(0.50, 0.0, 0.0), None);
671        assert_eq!(apply_event_variance(0.50, 0.1, -0.01), None);
672    }
673}
674
675#[cfg(kani)]
676mod kani_proofs {
677    use super::*;
678
679    fn bounded_positive() -> f64 {
680        let value: f64 = kani::any();
681        kani::assume(value.is_finite());
682        kani::assume((0.000001..=1_000_000.0).contains(&value));
683        value
684    }
685
686    #[kani::proof]
687    fn fast_sticky_moneyness_strike_mapping_preserves_positive_finite() {
688        let strike = bounded_positive();
689        let base_spot = bounded_positive();
690        let current_spot = bounded_positive();
691
692        let mapped = map_sticky_moneyness_strike(strike, base_spot, current_spot)
693            .expect("bounded positive inputs must map to a valid strike");
694        assert!(mapped.is_finite());
695        assert!(mapped > 0.0);
696    }
697
698    #[kani::proof]
699    fn fast_sticky_moneyness_strike_mapping_rejects_non_positive_inputs() {
700        assert!(map_sticky_moneyness_strike(0.0, 100.0, 120.0).is_none());
701        assert!(map_sticky_moneyness_strike(120.0, 0.0, 120.0).is_none());
702        assert!(map_sticky_moneyness_strike(120.0, 100.0, 0.0).is_none());
703    }
704}