Skip to main content

hypercall/vol_oracle/
factory.rs

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
30/// Builds the runtime volatility router from the market catalog.
31pub struct VolOracleFactory;
32
33/// Result of building the vol oracle, including handles for dynamic updates.
34pub struct VolOracleFactoryOutput {
35    pub oracle: SharedVolOracle,
36    /// Shared map for injecting platform spot prices into the Polygon oracle.
37    /// Update this map to enable dynamic strike scaling.
38    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        // Flatten every route into a set of unique provider names so we
52        // build each provider instance exactly once regardless of how
53        // many routes reference it.
54        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                    // v1 supports a single underlying per Databento provider.
177                    // The catalog validator (tested in cycle 12) should reject
178                    // multi-underlying configs, but we defend here too.
179                    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                    // v1 only supports GC futures + OG options (CME Globex
199                    // gold). The orchestrator run loop in
200                    // `databento_oracle.rs` gates on `root == "GC"` and the
201                    // option parser scopes to the OG chain, so a catalog
202                    // that asks for a different pair would silently build a
203                    // surface from the wrong contract or stay Disconnected
204                    // forever. Refuse to build the provider in that case
205                    // rather than deferring the failure to runtime.
206                    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                    // Spawn the live Databento feed task. The helper
235                    // connects to databento::LiveClient, subscribes to
236                    // GC.FUT + OG.OPT with definition + mbp-1 schemas,
237                    // and drives the oracle's run loop forever. If the
238                    // connection fails (e.g. missing api_key) the task
239                    // logs and exits; the oracle stays unready and the
240                    // RoutedVolOracle's get_iv call will return
241                    // UnhealthyProvider for GOLD.
242                    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    // Each route provider, plus source providers used by composite models,
330    // needs to know which underlyings it serves so per-underlying config
331    // lookups succeed.
332    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    // Fetch Deribit's actual term structure so we subscribe to expiries that
382    // Block Scholes recognizes. Hypercall's own expiry schedule (daily/weekly/
383    // monthly) doesn't match Deribit's listed expiries, causing BS to reject
384    // subscriptions. The vol surface interpolation layer bridges the gap.
385    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(), // Not needed — we use delta.iv, not strike.iv
407        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/// Deribit instruments API response.
416#[derive(Debug, serde::Deserialize)]
417struct DeribitInstrumentsResponse {
418    result: Vec<DeribitInstrument>,
419}
420
421#[derive(Debug, serde::Deserialize)]
422struct DeribitInstrument {
423    /// Expiry timestamp in milliseconds
424    expiration_timestamp: i64,
425}
426
427/// Fetch the current Deribit option term structure for the given underlyings.
428///
429/// Returns a sorted, deduplicated list of expiry timestamps in ISO 8601 format
430/// (e.g., "2026-04-11T08:00:00Z") that Block Scholes will accept.
431async 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                        // Deribit timestamps are in milliseconds
451                        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    /// Cycle 13 e2e: catalog with a Databento provider + route → factory
872    /// builds a `SharedVolOracle` without spawning a real feed → `get_iv`
873    /// returns `UnhealthyProvider` because no GC BBO has arrived.
874    ///
875    /// This test exists because cycle 11 covers the oracle directly but
876    /// doesn't exercise the catalog-parse-and-wire path, and cycle 12
877    /// covers the config parse but doesn't exercise the factory. A full
878    /// green-field catalog → queryable oracle walk catches wiring bugs
879    /// (missing match arms, bad defaults, mismatched provider names)
880    /// that unit tests at either end can miss.
881    #[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        // Swap the default BTC/ETH Fixed routes for a single GOLD→databento route.
888        // `base_catalog()` seeds collateral entries that reference BTC/ETH as their
889        // underlyings; since we're about to drop those, also drop the matching
890        // collateral rows (the validator rejects collateral that points at an
891        // underlying not in the catalog — "Collateral perp ETH_PERP references
892        // unknown underlying ETH"). Keep only USDC (stablecoin, no underlying
893        // reference) so the "at least one stablecoin collateral" rule is still met.
894        catalog.collateral.retain(|name, _| name == "USDC");
895        catalog.underlyings.clear();
896        catalog.underlyings.insert(
897            "GOLD".to_string(),
898            UnderlyingConfig {
899                // The per-underlying `vol_source` field is a legacy hint
900                // used by older catalog code paths — route-based catalogs
901                // use the vol_oracles.routes map instead. `"none"` is the
902                // pre-existing convention for GOLD since its real IV
903                // comes from the routed provider below.
904                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        // The factory spawns a detached tokio task for the Databento
949        // live feed via tokio::spawn (not the TaskGroup). task_group
950        // stays at 0 for this provider because the feed task is
951        // intentionally outside the structured task group — the
952        // connection can fail at any time and a supervisor-level
953        // restart is a v2 item.
954        assert_eq!(
955            task_group.len(),
956            0,
957            "databento provider spawns its live feed task outside the TaskGroup"
958        );
959
960        // get_iv on the routed provider should bubble UnhealthyProvider
961        // from DatabentoVolOracle's "no forward received yet" path.
962        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        // statuses() should report the single databento underlying.
979        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] // Requires network access to Deribit public API
995    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        // All should parse as valid ISO 8601 timestamps
1003        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        // Should be sorted
1009        let mut sorted = expiries.clone();
1010        sorted.sort();
1011        assert_eq!(expiries, sorted, "Expiries must be sorted ascending");
1012
1013        // Deribit typically has 10+ unique expiries for BTC
1014        assert!(
1015            expiries.len() >= 5,
1016            "Expected at least 5 BTC expiries, got {}",
1017            expiries.len()
1018        );
1019    }
1020}