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}