1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::sync::Arc;
3use std::time::Duration;
4
5use anyhow::{Context, Result};
6use tokio::task::JoinHandle;
7use tracing::{error, info, warn};
8
9use crate::shared::shutdown::Shutdown;
10use crate::shared::task_group::TaskGroup;
11use catalog_manager::{
12 BlockScholesProviderConfig, CatalogConfig, DeribitProviderConfig, DeriveProviderConfig,
13 PolygonProviderConfig, PolymarketProviderConfig, RealizedVolProviderConfig,
14 StickyMoneynessProviderConfig, VolOracleProviderConfig,
15};
16use hypercall_vol_oracle::FixedTestRiskVolOracle;
17use hypercall_vol_oracle::PollingVolOracle as _;
18use hypercall_vol_oracle::RoutedVolOracle;
19use hypercall_vol_oracle::SharedVolOracle;
20use hypercall_vol_oracle::{BlockScholesVolOracle, BlockScholesVolOracleConfig};
21use hypercall_vol_oracle::{DeribitVolOracle, DeribitVolOracleConfig};
22use hypercall_vol_oracle::{DeriveVolOracle, DeriveVolOracleConfig};
23use hypercall_vol_oracle::{
24 PlatformSpotPrices, PolygonUnderlyingConfig, PolygonVolOracle, PolygonVolOracleConfig,
25};
26use hypercall_vol_oracle::{PolymarketVolOracle, PolymarketVolOracleConfig};
27use hypercall_vol_oracle::{RealizedVolOracle, RealizedVolOracleConfig};
28use hypercall_vol_oracle::{StickyMoneynessVolOracle, StickyMoneynessVolOracleConfig};
29
30pub struct VolOracleFactory;
32
33pub struct VolOracleFactoryOutput {
35 pub oracle: SharedVolOracle,
36 pub polygon_platform_spots: PlatformSpotPrices,
39}
40
41impl VolOracleFactory {
42 pub async fn build(
43 catalog_config: &CatalogConfig,
44 task_group: &mut TaskGroup,
45 shutdown: &Shutdown,
46 ) -> Result<VolOracleFactoryOutput> {
47 let routes = catalog_config.vol_oracles.routes.clone();
48 let polygon_platform_spots: PlatformSpotPrices =
49 Arc::new(std::sync::RwLock::new(HashMap::new()));
50 let routed_underlyings = routed_underlyings_by_provider(catalog_config);
51 let mut used_provider_names = routes
55 .values()
56 .flat_map(|chain| chain.iter().cloned())
57 .collect::<HashSet<_>>();
58 expand_source_providers(catalog_config, &mut used_provider_names);
59 let mut used_provider_names = used_provider_names.into_iter().collect::<Vec<_>>();
60 used_provider_names.sort();
61 let mut providers = HashMap::new();
62
63 for provider_name in used_provider_names
64 .iter()
65 .filter(|name| {
66 !matches!(
67 catalog_config
68 .vol_oracles
69 .providers
70 .get(*name)
71 .expect("catalog validation must guarantee provider existence"),
72 VolOracleProviderConfig::StickyMoneyness(_)
73 )
74 })
75 .cloned()
76 .collect::<Vec<_>>()
77 {
78 let provider_config = catalog_config
79 .vol_oracles
80 .providers
81 .get(&provider_name)
82 .expect("catalog validation must guarantee provider existence");
83 let assigned_underlyings = routed_underlyings
84 .get(&provider_name)
85 .cloned()
86 .unwrap_or_default();
87
88 let provider = match provider_config {
89 VolOracleProviderConfig::BlockScholes(config) => {
90 let block_scholes_config =
91 build_block_scholes_config(config, &assigned_underlyings).await?;
92 let oracle = Arc::new(BlockScholesVolOracle::new(block_scholes_config));
93 let handle = oracle.clone().start_polling();
94 spawn_provider_task(task_group, shutdown, "BlockScholesVolOracle", handle);
95 info!(
96 "Started Block Scholes vol oracle provider {} for {:?}",
97 provider_name, assigned_underlyings
98 );
99 let provider: SharedVolOracle = oracle;
100 provider
101 }
102 VolOracleProviderConfig::Deribit(config) => {
103 let deribit_config = build_deribit_config(config, &assigned_underlyings);
104 let oracle = Arc::new(DeribitVolOracle::new(deribit_config));
105 let handle = oracle.clone().start_polling();
106 spawn_provider_task(task_group, shutdown, "DeribitVolOracle", handle);
107 info!(
108 "Started Deribit vol oracle provider {} for {:?}",
109 provider_name, assigned_underlyings
110 );
111 let provider: SharedVolOracle = oracle;
112 provider
113 }
114 VolOracleProviderConfig::Derive(config) => {
115 let derive_config = build_derive_config(config, &assigned_underlyings);
116 let oracle = Arc::new(DeriveVolOracle::new(derive_config));
117 let handle = oracle.clone().start_polling();
118 spawn_provider_task(task_group, shutdown, "DeriveVolOracle", handle);
119 info!(
120 "Started Derive vol oracle provider {} for {:?}",
121 provider_name, assigned_underlyings
122 );
123 let provider: SharedVolOracle = oracle;
124 provider
125 }
126 VolOracleProviderConfig::Polygon(config) => {
127 let polygon_config = build_polygon_config(config, &assigned_underlyings);
128 let oracle = Arc::new(PolygonVolOracle::new(
129 polygon_config,
130 polygon_platform_spots.clone(),
131 ));
132 let handle = oracle.clone().start_polling();
133 spawn_provider_task(task_group, shutdown, "PolygonVolOracle", handle);
134 info!(
135 "Started Polygon vol oracle provider {} for {:?}",
136 provider_name, assigned_underlyings
137 );
138 let provider: SharedVolOracle = oracle;
139 provider
140 }
141 VolOracleProviderConfig::StickyMoneyness(_) => {
142 unreachable!("sticky moneyness providers are built after their sources")
143 }
144 VolOracleProviderConfig::Fixed(config) => {
145 let provider: SharedVolOracle = Arc::new(
146 FixedTestRiskVolOracle::with_underlyings(config.iv, assigned_underlyings),
147 );
148 info!("Configured fixed vol oracle provider {}", provider_name);
149 provider
150 }
151 VolOracleProviderConfig::Polymarket(config) => {
152 let polymarket_config = build_polymarket_config(config, &assigned_underlyings);
153 let oracle = Arc::new(PolymarketVolOracle::new(polymarket_config));
154 let handle = oracle.clone().start_polling();
155 spawn_provider_task(task_group, shutdown, "PolymarketVolOracle", handle);
156 info!(
157 "Started Polymarket vol oracle provider {} for {:?}",
158 provider_name, assigned_underlyings
159 );
160 let provider: SharedVolOracle = oracle;
161 provider
162 }
163 VolOracleProviderConfig::RealizedVol(config) => {
164 let realized_config = build_realized_vol_config(config, &assigned_underlyings);
165 let oracle = Arc::new(RealizedVolOracle::new(realized_config));
166 let handle = oracle.clone().start_polling();
167 spawn_provider_task(task_group, shutdown, "RealizedVolOracle", handle);
168 info!(
169 "Configured realized-vol oracle provider {} for {:?}",
170 provider_name, assigned_underlyings
171 );
172 let provider: SharedVolOracle = oracle;
173 provider
174 }
175 VolOracleProviderConfig::Databento(config) => {
176 if assigned_underlyings.len() != 1 {
180 anyhow::bail!(
181 "Databento provider {} must serve exactly one \
182 underlying (got {:?}) — v2 item to support more",
183 provider_name,
184 assigned_underlyings
185 );
186 }
187 let underlying_name = assigned_underlyings[0].clone();
188 let underlying_config =
189 config.underlyings.get(&underlying_name).ok_or_else(|| {
190 anyhow::anyhow!(
191 "Databento provider {} missing per-underlying \
192 config for {}",
193 provider_name,
194 underlying_name
195 )
196 })?;
197
198 if underlying_config.futures_root != "GC"
207 || underlying_config.options_root != "OG"
208 {
209 anyhow::bail!(
210 "Databento provider {} for {} currently only \
211 supports futures_root=\"GC\" + options_root=\"OG\" \
212 (CME Globex gold). Got futures_root=\"{}\", \
213 options_root=\"{}\". Add explicit plumbing to \
214 `DatabentoVolOracleConfig` and the orchestrator \
215 before configuring a non-gold route.",
216 provider_name,
217 underlying_name,
218 underlying_config.futures_root,
219 underlying_config.options_root,
220 );
221 }
222
223 let oracle = Arc::new(
224 hypercall_vol_oracle::databento_oracle::DatabentoVolOracle::new(
225 hypercall_vol_oracle::databento_oracle::DatabentoVolOracleConfig {
226 underlying: underlying_name.clone(),
227 risk_free_rate: underlying_config.risk_free_rate,
228 strike_scale: underlying_config.strike_scale,
229 staleness_threshold: Duration::from_millis(config.staleness_ms),
230 },
231 ),
232 );
233
234 hypercall_vol_oracle::databento_oracle::spawn_databento_live_feed_task(
243 oracle.clone(),
244 config.api_key.clone(),
245 config.dataset.clone(),
246 );
247 info!(
248 provider = %provider_name,
249 underlying = %underlying_name,
250 dataset = %config.dataset,
251 "Databento vol oracle live feed task spawned"
252 );
253
254 let provider: SharedVolOracle = oracle;
255 provider
256 }
257 };
258
259 providers.insert(provider_name, provider);
260 }
261
262 for provider_name in used_provider_names
263 .iter()
264 .filter(|name| {
265 matches!(
266 catalog_config
267 .vol_oracles
268 .providers
269 .get(*name)
270 .expect("catalog validation must guarantee provider existence"),
271 VolOracleProviderConfig::StickyMoneyness(_)
272 )
273 })
274 .cloned()
275 .collect::<Vec<_>>()
276 {
277 let VolOracleProviderConfig::StickyMoneyness(config) = catalog_config
278 .vol_oracles
279 .providers
280 .get(&provider_name)
281 .expect("catalog validation must guarantee provider existence")
282 else {
283 unreachable!("filtered to sticky moneyness providers");
284 };
285 let assigned_underlyings = routed_underlyings
286 .get(&provider_name)
287 .cloned()
288 .unwrap_or_default();
289 let source = providers
290 .get(&config.source_provider)
291 .cloned()
292 .ok_or_else(|| {
293 anyhow::anyhow!(
294 "Sticky moneyness provider {} source_provider {} was not built",
295 provider_name,
296 config.source_provider
297 )
298 })?;
299 if !source.supports_surface_snapshots() {
300 anyhow::bail!(
301 "Sticky moneyness provider {} source_provider {} does not support surface snapshots",
302 provider_name,
303 config.source_provider
304 );
305 }
306
307 let provider: SharedVolOracle = Arc::new(StickyMoneynessVolOracle::new(
308 source,
309 polygon_platform_spots.clone(),
310 build_sticky_moneyness_config(config, &assigned_underlyings),
311 ));
312 info!(
313 "Configured sticky moneyness vol oracle provider {} for {:?} from {}",
314 provider_name, assigned_underlyings, config.source_provider
315 );
316 providers.insert(provider_name, provider);
317 }
318
319 Ok(VolOracleFactoryOutput {
320 oracle: Arc::new(RoutedVolOracle::new(providers, routes)),
321 polygon_platform_spots,
322 })
323 }
324}
325
326fn routed_underlyings_by_provider(catalog_config: &CatalogConfig) -> HashMap<String, Vec<String>> {
327 let mut routed = HashMap::<String, Vec<String>>::new();
328
329 for (underlying, chain) in &catalog_config.vol_oracles.routes {
333 for provider_name in chain {
334 routed
335 .entry(provider_name.clone())
336 .or_default()
337 .push(underlying.clone());
338
339 if let Some(VolOracleProviderConfig::StickyMoneyness(config)) =
340 catalog_config.vol_oracles.providers.get(provider_name)
341 {
342 routed
343 .entry(config.source_provider.clone())
344 .or_default()
345 .push(underlying.clone());
346 }
347 }
348 }
349
350 for underlyings in routed.values_mut() {
351 underlyings.sort();
352 underlyings.dedup();
353 }
354
355 routed
356}
357
358fn expand_source_providers(
359 catalog_config: &CatalogConfig,
360 used_provider_names: &mut HashSet<String>,
361) {
362 loop {
363 let before = used_provider_names.len();
364 for provider_name in used_provider_names.clone() {
365 if let Some(VolOracleProviderConfig::StickyMoneyness(config)) =
366 catalog_config.vol_oracles.providers.get(&provider_name)
367 {
368 used_provider_names.insert(config.source_provider.clone());
369 }
370 }
371 if used_provider_names.len() == before {
372 break;
373 }
374 }
375}
376
377async fn build_block_scholes_config(
378 provider_config: &BlockScholesProviderConfig,
379 assigned_underlyings: &[String],
380) -> Result<BlockScholesVolOracleConfig> {
381 let expiries = fetch_deribit_expiries(assigned_underlyings).await?;
386
387 if expiries.is_empty() {
388 warn!(
389 "No Deribit expiries found for Block Scholes underlyings {:?} — \
390 oracle will start with no subscriptions and remain unready.",
391 assigned_underlyings
392 );
393 } else {
394 info!(
395 "Fetched {} Deribit expiries for Block Scholes subscription: {:?}",
396 expiries.len(),
397 expiries
398 );
399 }
400
401 Ok(BlockScholesVolOracleConfig {
402 ws_url: provider_config.ws_url.clone(),
403 api_key: provider_config.api_key.clone(),
404 api_secret: provider_config.api_secret.clone(),
405 symbols: assigned_underlyings.to_vec(),
406 strikes: HashMap::new(), expiries,
408 reconnect_delay_ms: provider_config.reconnect_delay_ms,
409 max_reconnect_attempts: provider_config.max_reconnect_attempts,
410 heartbeat_interval_ms: provider_config.heartbeat_interval_ms,
411 cache_ttl_ms: provider_config.staleness_ms,
412 })
413}
414
415#[derive(Debug, serde::Deserialize)]
417struct DeribitInstrumentsResponse {
418 result: Vec<DeribitInstrument>,
419}
420
421#[derive(Debug, serde::Deserialize)]
422struct DeribitInstrument {
423 expiration_timestamp: i64,
425}
426
427async fn fetch_deribit_expiries(underlyings: &[String]) -> Result<Vec<String>> {
432 let client = reqwest::Client::builder()
433 .timeout(Duration::from_secs(10))
434 .build()
435 .context("failed to build HTTP client for Deribit")?;
436
437 let mut all_expiry_ts: BTreeSet<i64> = BTreeSet::new();
438
439 for underlying in underlyings {
440 let url = format!(
441 "https://www.deribit.com/api/v2/public/get_instruments?currency={}&kind=option&expired=false",
442 underlying
443 );
444
445 match client.get(&url).send().await {
446 Ok(resp) => match resp.json::<DeribitInstrumentsResponse>().await {
447 Ok(data) => {
448 let count = data.result.len();
449 for instrument in data.result {
450 all_expiry_ts.insert(instrument.expiration_timestamp / 1000);
452 }
453 info!(
454 "Fetched {} {} instruments from Deribit ({} unique expiries so far)",
455 count,
456 underlying,
457 all_expiry_ts.len()
458 );
459 }
460 Err(e) => {
461 warn!(
462 "Failed to parse Deribit instruments response for {}: {}",
463 underlying, e
464 );
465 }
466 },
467 Err(e) => {
468 warn!(
469 "Failed to fetch Deribit instruments for {}: {}",
470 underlying, e
471 );
472 }
473 }
474 }
475
476 let expiries: Vec<String> = all_expiry_ts
477 .into_iter()
478 .filter_map(|ts| {
479 let dt = chrono::DateTime::from_timestamp(ts, 0);
480 if dt.is_none() {
481 warn!("Skipping invalid Deribit expiry timestamp: {}", ts);
482 }
483 dt.map(|d| d.format("%Y-%m-%dT%H:%M:%SZ").to_string())
484 })
485 .collect();
486
487 Ok(expiries)
488}
489
490fn build_deribit_config(
491 provider_config: &DeribitProviderConfig,
492 assigned_underlyings: &[String],
493) -> DeribitVolOracleConfig {
494 DeribitVolOracleConfig {
495 base_url: provider_config.base_url.clone(),
496 poll_interval: Duration::from_millis(provider_config.refresh_interval_ms),
497 staleness_threshold: Duration::from_millis(provider_config.staleness_ms),
498 symbols: assigned_underlyings.to_vec(),
499 }
500}
501
502fn build_derive_config(
503 provider_config: &DeriveProviderConfig,
504 assigned_underlyings: &[String],
505) -> DeriveVolOracleConfig {
506 DeriveVolOracleConfig {
507 base_url: provider_config.base_url.clone(),
508 poll_interval: Duration::from_millis(provider_config.refresh_interval_ms),
509 staleness_threshold: Duration::from_millis(provider_config.staleness_ms),
510 symbols: assigned_underlyings.to_vec(),
511 }
512}
513
514fn build_polygon_config(
515 provider_config: &PolygonProviderConfig,
516 assigned_underlyings: &[String],
517) -> PolygonVolOracleConfig {
518 let configured_underlyings = assigned_underlyings
519 .iter()
520 .map(|underlying| {
521 let config = provider_config
522 .underlyings
523 .get(underlying)
524 .unwrap_or_else(|| {
525 panic!(
526 "catalog validation must guarantee Polygon settings for routed underlying {}",
527 underlying
528 )
529 });
530
531 (
532 underlying.clone(),
533 PolygonUnderlyingConfig {
534 ticker: config.ticker.clone(),
535 strike_scale: config.strike_scale,
536 require_live_strike_scale: config.require_live_strike_scale,
537 },
538 )
539 })
540 .collect();
541
542 PolygonVolOracleConfig {
543 api_key: provider_config.api_key.clone(),
544 base_url: provider_config.base_url.clone(),
545 poll_interval: Duration::from_millis(provider_config.refresh_interval_ms),
546 staleness_threshold: Duration::from_millis(provider_config.staleness_ms),
547 underlyings: configured_underlyings,
548 }
549}
550
551fn build_polymarket_config(
552 provider_config: &PolymarketProviderConfig,
553 assigned_underlyings: &[String],
554) -> PolymarketVolOracleConfig {
555 PolymarketVolOracleConfig {
556 event_slug: provider_config.event_slug.clone(),
557 shares_outstanding: provider_config.shares_outstanding,
558 reference_tte_days: provider_config.reference_tte_days,
559 poll_interval: Duration::from_millis(provider_config.refresh_interval_ms),
560 staleness_threshold: Duration::from_millis(provider_config.staleness_ms),
561 symbols: assigned_underlyings.to_vec(),
562 }
563}
564
565fn build_realized_vol_config(
566 provider_config: &RealizedVolProviderConfig,
567 assigned_underlyings: &[String],
568) -> RealizedVolOracleConfig {
569 RealizedVolOracleConfig {
570 info_url: provider_config.info_url.clone(),
571 candle_coin: provider_config.candle_coin.clone(),
572 lookback_days: provider_config.lookback_days,
573 min_samples: provider_config.min_samples,
574 annualization_days: provider_config.annualization_days,
575 floor_iv: provider_config.floor_iv,
576 cap_iv: provider_config.cap_iv,
577 multiplier: provider_config.multiplier,
578 poll_interval: Duration::from_millis(provider_config.refresh_interval_ms),
579 staleness_threshold: Duration::from_millis(provider_config.staleness_ms),
580 strike_min: provider_config.strike_min,
581 strike_max: provider_config.strike_max,
582 strike_step: provider_config.strike_step,
583 symbols: assigned_underlyings.to_vec(),
584 }
585}
586
587fn build_sticky_moneyness_config(
588 provider_config: &StickyMoneynessProviderConfig,
589 assigned_underlyings: &[String],
590) -> StickyMoneynessVolOracleConfig {
591 StickyMoneynessVolOracleConfig {
592 underlyings: assigned_underlyings.to_vec(),
593 max_snapshot_age: Duration::from_millis(provider_config.max_snapshot_age_ms),
594 event_jump: provider_config.event_jump,
595 min_tte_years: provider_config.min_tte_years,
596 }
597}
598
599fn spawn_provider_task(
600 task_group: &mut TaskGroup,
601 shutdown: &Shutdown,
602 task_name: &'static str,
603 handle: JoinHandle<()>,
604) {
605 let mut shutdown_rx = shutdown.subscribe();
606 task_group.spawn(task_name, async move {
607 let mut handle = handle;
608 tokio::select! {
609 result = &mut handle => {
610 if let Err(err) = result {
611 error!("{} task panicked: {:?}", task_name, err);
612 }
613 }
614 _ = shutdown_rx.recv() => {
615 handle.abort();
616 let _ = handle.await;
617 }
618 }
619 Ok(())
620 });
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use catalog_manager::{
627 DeribitRegionStepsConfig, FixedVolOracleProviderConfig, HyperliquidAssetConfig,
628 PerpCollateralConfig, StablecoinCollateralConfig, StrikeSelectionConfig,
629 };
630 use catalog_manager::{
631 ExpiryConfig, ExpiryScheduleConfig, ExtensionPolicyConfig, ObservabilityConfig,
632 UnderlyingConfig, VolOracleCatalogConfig,
633 };
634
635 fn base_catalog() -> CatalogConfig {
636 CatalogConfig {
637 version: 1,
638 expiry: ExpiryConfig {
639 expiry_time_utc: "08:00".to_string(),
640 schedule: ExpiryScheduleConfig {
641 daily_count: 2,
642 weekly_count: 4,
643 monthly_count: 3,
644 weekdays_only: false,
645 },
646 },
647 underlyings: HashMap::from([
648 (
649 "BTC".to_string(),
650 UnderlyingConfig {
651 vol_source: "deribit".to_string(),
652 hl_symbol: None,
653 trading_mode: hypercall_types::TradingModes::ORDERBOOK,
654 max_expiry_code: None,
655 expiry_time_utc: None,
656 schedule: None,
657 },
658 ),
659 (
660 "ETH".to_string(),
661 UnderlyingConfig {
662 vol_source: "deribit".to_string(),
663 hl_symbol: None,
664 trading_mode: hypercall_types::TradingModes::ORDERBOOK,
665 max_expiry_code: None,
666 expiry_time_utc: None,
667 schedule: None,
668 },
669 ),
670 ]),
671 collateral: HashMap::from([
672 (
673 "BTC_PERP".to_string(),
674 HyperliquidAssetConfig::Perp(PerpCollateralConfig {
675 asset_id: 0,
676 underlying: "BTC".to_string(),
677 }),
678 ),
679 (
680 "ETH_PERP".to_string(),
681 HyperliquidAssetConfig::Perp(PerpCollateralConfig {
682 asset_id: 1,
683 underlying: "ETH".to_string(),
684 }),
685 ),
686 (
687 "USDC".to_string(),
688 HyperliquidAssetConfig::Stablecoin(StablecoinCollateralConfig { token_id: 0 }),
689 ),
690 ]),
691 strike_selection: StrikeSelectionConfig {
692 deribit_table_assets: vec![
693 "BTC".to_string(),
694 "ETH".to_string(),
695 "SOL".to_string(),
696 "AVAX".to_string(),
697 "XRP".to_string(),
698 "TRX".to_string(),
699 ],
700 deribit_region_steps: DeribitRegionStepsConfig {
701 atm: 3,
702 outer: 4,
703 wings: 3,
704 },
705 occ_fallback_side_count: 8,
706 },
707 extension_policy: ExtensionPolicyConfig {
708 enabled: true,
709 ensure_min_strikes_per_side: 5,
710 ensure_atm_within_pct: 0.05,
711 cooldown_secs: 3600,
712 max_total_strikes_per_expiry: 40,
713 min_spot_move_pct: 0.05,
714 },
715 observability: ObservabilityConfig::default(),
716 vol_oracles: VolOracleCatalogConfig {
717 providers: HashMap::from([
718 (
719 "btc_fixed".to_string(),
720 VolOracleProviderConfig::Fixed(FixedVolOracleProviderConfig { iv: 0.55 }),
721 ),
722 (
723 "eth_fixed".to_string(),
724 VolOracleProviderConfig::Fixed(FixedVolOracleProviderConfig { iv: 0.91 }),
725 ),
726 ]),
727 routes: HashMap::from([
728 ("BTC".to_string(), vec!["btc_fixed".to_string()]),
729 ("ETH".to_string(), vec!["eth_fixed".to_string()]),
730 ]),
731 },
732 }
733 }
734
735 #[tokio::test]
736 async fn factory_routes_underlyings_to_named_providers() {
737 let catalog = base_catalog();
738 let mut task_group = TaskGroup::new();
739 let shutdown = Shutdown::new();
740
741 let output = VolOracleFactory::build(&catalog, &mut task_group, &shutdown)
742 .await
743 .expect("factory should build fixed providers");
744
745 assert_eq!(
746 output
747 .oracle
748 .get_iv("BTC", 100_000.0, 1_800_000_000)
749 .unwrap(),
750 0.55
751 );
752 assert_eq!(
753 output.oracle.get_iv("ETH", 4_000.0, 1_800_000_000).unwrap(),
754 0.91
755 );
756 assert!(matches!(
757 output.oracle.get_iv("US500", 600.0, 1_800_000_000),
758 Err(hypercall_vol_oracle::VolLookupError::UnsupportedUnderlying { .. })
759 ));
760 assert_eq!(task_group.len(), 0);
761 }
762
763 #[tokio::test]
764 async fn factory_builds_sticky_moneyness_provider_from_snapshot_source() {
765 let mut catalog = base_catalog();
766 catalog.underlyings.clear();
767 catalog.underlyings.insert(
768 "US500".to_string(),
769 UnderlyingConfig {
770 vol_source: "none".to_string(),
771 hl_symbol: Some("km:US500".to_string()),
772 trading_mode: hypercall_types::TradingModes::RFQ,
773 max_expiry_code: None,
774 expiry_time_utc: None,
775 schedule: None,
776 },
777 );
778 catalog.underlyings.insert(
779 "USOIL".to_string(),
780 UnderlyingConfig {
781 vol_source: "none".to_string(),
782 hl_symbol: Some("km:USOIL".to_string()),
783 trading_mode: hypercall_types::TradingModes::RFQ,
784 max_expiry_code: None,
785 expiry_time_utc: None,
786 schedule: None,
787 },
788 );
789 catalog.collateral = HashMap::from([(
790 "USDC".to_string(),
791 HyperliquidAssetConfig::Stablecoin(StablecoinCollateralConfig { token_id: 0 }),
792 )]);
793 catalog.vol_oracles = VolOracleCatalogConfig {
794 providers: HashMap::from([
795 (
796 "polygon_main".to_string(),
797 VolOracleProviderConfig::Polygon(PolygonProviderConfig {
798 api_key: "test-polygon-key".to_string(),
799 base_url: "http://127.0.0.1:9".to_string(),
800 refresh_interval_ms: 60_000,
801 staleness_ms: 180_000,
802 underlyings: HashMap::from([
803 (
804 "US500".to_string(),
805 catalog_manager::PolygonProviderUnderlyingConfig {
806 ticker: "SPY".to_string(),
807 strike_scale: 1.0,
808 require_live_strike_scale: true,
809 },
810 ),
811 (
812 "USOIL".to_string(),
813 catalog_manager::PolygonProviderUnderlyingConfig {
814 ticker: "USO".to_string(),
815 strike_scale: 1.0,
816 require_live_strike_scale: true,
817 },
818 ),
819 ]),
820 }),
821 ),
822 (
823 "polygon_session_model".to_string(),
824 VolOracleProviderConfig::StickyMoneyness(StickyMoneynessProviderConfig {
825 source_provider: "polygon_main".to_string(),
826 max_snapshot_age_ms: 259_200_000,
827 event_jump: 0.03,
828 min_tte_years: 1.0 / 365.25,
829 }),
830 ),
831 ]),
832 routes: HashMap::from([
833 (
834 "US500".to_string(),
835 vec!["polygon_session_model".to_string()],
836 ),
837 (
838 "USOIL".to_string(),
839 vec!["polygon_session_model".to_string()],
840 ),
841 ]),
842 };
843 catalog
844 .validate()
845 .expect("synthetic sticky moneyness catalog should validate");
846
847 let mut task_group = TaskGroup::new();
848 let shutdown = Shutdown::new();
849 let output = VolOracleFactory::build(&catalog, &mut task_group, &shutdown)
850 .await
851 .expect("factory should build polygon plus sticky moneyness provider");
852
853 let statuses = output.oracle.statuses();
854 for underlying in ["US500", "USOIL"] {
855 assert!(statuses.iter().any(|status| {
856 status.underlying == underlying
857 && status.provider == hypercall_vol_oracle::VolProviderKind::Polygon
858 }));
859 assert!(statuses.iter().any(|status| {
860 status.underlying == underlying
861 && status.provider == hypercall_vol_oracle::VolProviderKind::StickyMoneyness
862 }));
863 }
864
865 task_group
866 .shutdown_and_join(&shutdown, Duration::from_secs(1))
867 .await
868 .expect("polygon polling task should shut down");
869 }
870
871 #[tokio::test]
882 async fn factory_builds_databento_oracle_and_reports_unhealthy_without_feed() {
883 use catalog_manager::{DatabentoProviderConfig, DatabentoProviderUnderlyingConfig};
884
885 let mut catalog = base_catalog();
886
887 catalog.collateral.retain(|name, _| name == "USDC");
895 catalog.underlyings.clear();
896 catalog.underlyings.insert(
897 "GOLD".to_string(),
898 UnderlyingConfig {
899 vol_source: "none".to_string(),
905 hl_symbol: Some("km:GOLD".to_string()),
906 trading_mode: hypercall_types::TradingModes::RFQ,
907 max_expiry_code: None,
908 expiry_time_utc: None,
909 schedule: None,
910 },
911 );
912 catalog.collateral = HashMap::from([(
913 "USDC".to_string(),
914 HyperliquidAssetConfig::Stablecoin(StablecoinCollateralConfig { token_id: 0 }),
915 )]);
916
917 catalog.vol_oracles = VolOracleCatalogConfig {
918 providers: HashMap::from([(
919 "databento_main".to_string(),
920 VolOracleProviderConfig::Databento(DatabentoProviderConfig {
921 api_key: "test-key".to_string(),
922 dataset: "GLBX.MDP3".to_string(),
923 staleness_ms: 120_000,
924 underlyings: HashMap::from([(
925 "GOLD".to_string(),
926 DatabentoProviderUnderlyingConfig {
927 futures_root: "GC".to_string(),
928 options_root: "OG".to_string(),
929 strike_scale: 1.0,
930 risk_free_rate: 0.05,
931 },
932 )]),
933 }),
934 )]),
935 routes: HashMap::from([("GOLD".to_string(), vec!["databento_main".to_string()])]),
936 };
937
938 catalog
939 .validate()
940 .expect("synthetic GOLD/databento catalog should validate");
941
942 let mut task_group = TaskGroup::new();
943 let shutdown = Shutdown::new();
944 let oracle = VolOracleFactory::build(&catalog, &mut task_group, &shutdown)
945 .await
946 .expect("factory should build a databento oracle");
947
948 assert_eq!(
955 task_group.len(),
956 0,
957 "databento provider spawns its live feed task outside the TaskGroup"
958 );
959
960 let err = oracle
963 .oracle
964 .get_iv("GOLD", 4700.0, 1_800_000_000)
965 .expect_err("no data means unhealthy");
966 assert!(
967 matches!(
968 err,
969 hypercall_vol_oracle::VolLookupError::UnhealthyProvider {
970 ref provider,
971 ..
972 }
973 if matches!(provider, hypercall_vol_oracle::VolProviderKind::Databento)
974 ),
975 "expected UnhealthyProvider(Databento), got {err:?}"
976 );
977
978 let statuses = oracle.oracle.statuses();
980 let gold = statuses
981 .iter()
982 .find(|s| s.underlying == "GOLD")
983 .expect("GOLD should be reported");
984 assert_eq!(
985 gold.provider,
986 hypercall_vol_oracle::VolProviderKind::Databento
987 );
988 assert!(!gold.connected, "no feed → not connected");
989 assert!(!gold.ready, "no feed → not ready");
990 assert_eq!(gold.surface_points, 0);
991 }
992
993 #[tokio::test]
994 #[ignore] async fn fetch_deribit_expiries_returns_sorted_iso_dates() {
996 let expiries = fetch_deribit_expiries(&["BTC".to_string()])
997 .await
998 .expect("Deribit public API should be reachable");
999
1000 assert!(!expiries.is_empty(), "Deribit must have active BTC options");
1001
1002 for exp in &expiries {
1004 chrono::DateTime::parse_from_rfc3339(exp)
1005 .unwrap_or_else(|e| panic!("Invalid ISO 8601 expiry '{}': {}", exp, e));
1006 }
1007
1008 let mut sorted = expiries.clone();
1010 sorted.sort();
1011 assert_eq!(expiries, sorted, "Expiries must be sorted ascending");
1012
1013 assert!(
1015 expiries.len() >= 5,
1016 "Expected at least 5 BTC expiries, got {}",
1017 expiries.len()
1018 );
1019 }
1020}