Skip to main content

catalog_manager/
config.rs

1use anyhow::{Context, Result};
2use hypercall_types::{ExpiryTime, ExpiryTimes, TradingModes};
3use serde::{de::Error as _, Deserialize, Deserializer};
4use std::cell::Cell;
5use std::collections::{HashMap, HashSet};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SecretPlaceholderMode {
9    ResolveRequired,
10    AllowUnresolved,
11}
12
13thread_local! {
14    static SECRET_PLACEHOLDER_MODE: Cell<SecretPlaceholderMode> =
15        const { Cell::new(SecretPlaceholderMode::ResolveRequired) };
16}
17
18pub fn with_secret_placeholder_mode<T>(
19    mode: SecretPlaceholderMode,
20    f: impl FnOnce() -> Result<T>,
21) -> Result<T> {
22    SECRET_PLACEHOLDER_MODE.with(|current_mode| {
23        let previous_mode = current_mode.replace(mode);
24        struct ResetModeGuard<'a> {
25            current_mode: &'a Cell<SecretPlaceholderMode>,
26            previous_mode: SecretPlaceholderMode,
27        }
28
29        impl Drop for ResetModeGuard<'_> {
30            fn drop(&mut self) {
31                self.current_mode.set(self.previous_mode);
32            }
33        }
34
35        let _guard = ResetModeGuard {
36            current_mode,
37            previous_mode,
38        };
39        f()
40    })
41}
42
43#[derive(Debug, Clone, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct CatalogConfig {
46    pub version: u32,
47    pub expiry: ExpiryConfig,
48    pub underlyings: HashMap<String, UnderlyingConfig>,
49    pub collateral: HashMap<String, HyperliquidAssetConfig>,
50    pub strike_selection: StrikeSelectionConfig,
51    pub extension_policy: ExtensionPolicyConfig,
52    #[serde(default)]
53    pub observability: ObservabilityConfig,
54    pub vol_oracles: VolOracleCatalogConfig,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ExpiryConfig {
60    pub expiry_time_utc: String,
61    pub schedule: ExpiryScheduleConfig,
62}
63
64#[derive(Debug, Clone, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ExpiryScheduleConfig {
67    pub daily_count: usize,
68    pub weekly_count: usize,
69    pub monthly_count: usize,
70    #[serde(default)]
71    pub weekdays_only: bool,
72}
73
74#[derive(Debug, Clone, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct UnderlyingConfig {
77    #[serde(default = "default_vol_source")]
78    pub vol_source: String,
79    #[serde(default)]
80    pub hl_symbol: Option<String>,
81    #[serde(default = "default_trading_mode")]
82    pub trading_mode: TradingModes,
83    #[serde(
84        default,
85        rename = "max_expiry_date",
86        deserialize_with = "deserialize_max_expiry_code"
87    )]
88    pub max_expiry_code: Option<u32>,
89    /// Per-underlying UTC expiry time-of-day override ("HH:MM"). Falls back
90    /// to the global `expiry.expiry_time_utc` when unset. Consensus-critical:
91    /// the resolved timestamp is embedded in on-chain option token addresses,
92    /// so never change this while the underlying has listed instruments.
93    #[serde(default)]
94    pub expiry_time_utc: Option<String>,
95    #[serde(default)]
96    pub schedule: Option<ExpiryScheduleConfig>,
97}
98
99fn deserialize_max_expiry_code<'de, D>(
100    deserializer: D,
101) -> std::result::Result<Option<u32>, D::Error>
102where
103    D: Deserializer<'de>,
104{
105    use chrono::Datelike;
106    let raw: Option<String> = Option::deserialize(deserializer)?;
107    match raw {
108        None => Ok(None),
109        Some(s) => {
110            let date = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
111                .map_err(serde::de::Error::custom)?;
112            let code = date.year() as u32 * 10000 + date.month() * 100 + date.day();
113            Ok(Some(code))
114        }
115    }
116}
117
118fn default_trading_mode() -> TradingModes {
119    TradingModes::ORDERBOOK
120}
121
122#[derive(Debug, Clone, Deserialize)]
123#[serde(tag = "kind")]
124pub enum HyperliquidAssetConfig {
125    #[serde(rename = "perp")]
126    Perp(PerpCollateralConfig),
127    #[serde(rename = "stablecoin")]
128    Stablecoin(StablecoinCollateralConfig),
129}
130
131#[derive(Debug, Clone, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct PerpCollateralConfig {
134    pub asset_id: u32,
135    pub underlying: String,
136}
137
138#[derive(Debug, Clone, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct StablecoinCollateralConfig {
141    pub token_id: u32,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct StrikeSelectionConfig {
147    pub deribit_table_assets: Vec<String>,
148    pub deribit_region_steps: DeribitRegionStepsConfig,
149    pub occ_fallback_side_count: usize,
150}
151
152#[derive(Debug, Clone, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct DeribitRegionStepsConfig {
155    pub atm: usize,
156    pub outer: usize,
157    pub wings: usize,
158}
159
160#[derive(Debug, Clone, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct ExtensionPolicyConfig {
163    pub enabled: bool,
164    pub ensure_min_strikes_per_side: usize,
165    pub ensure_atm_within_pct: f64,
166    pub cooldown_secs: u64,
167    pub max_total_strikes_per_expiry: usize,
168    pub min_spot_move_pct: f64,
169}
170
171#[derive(Debug, Clone, Deserialize, Default)]
172#[serde(deny_unknown_fields)]
173pub struct ObservabilityConfig {
174    #[serde(default = "default_log_level")]
175    pub log_level: String,
176    #[serde(default = "default_true")]
177    pub metrics_enabled: bool,
178}
179
180#[derive(Debug, Clone, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct VolOracleCatalogConfig {
183    pub providers: HashMap<String, VolOracleProviderConfig>,
184    #[serde(deserialize_with = "deserialize_route_chain_map")]
185    pub routes: HashMap<String, Vec<String>>,
186}
187
188fn deserialize_route_chain_map<'de, D>(
189    deserializer: D,
190) -> std::result::Result<HashMap<String, Vec<String>>, D::Error>
191where
192    D: Deserializer<'de>,
193{
194    #[derive(Deserialize)]
195    #[serde(untagged)]
196    enum RouteTarget {
197        Single(String),
198        Chain(Vec<String>),
199    }
200
201    let raw: HashMap<String, RouteTarget> = HashMap::deserialize(deserializer)?;
202    Ok(raw
203        .into_iter()
204        .map(|(underlying, target)| {
205            let chain = match target {
206                RouteTarget::Single(name) => vec![name],
207                RouteTarget::Chain(names) => names,
208            };
209            (underlying, chain)
210        })
211        .collect())
212}
213
214#[derive(Debug, Clone, Deserialize)]
215#[serde(tag = "kind")]
216pub enum VolOracleProviderConfig {
217    #[serde(rename = "blockscholes")]
218    BlockScholes(BlockScholesProviderConfig),
219    #[serde(rename = "databento")]
220    Databento(DatabentoProviderConfig),
221    #[serde(rename = "deribit")]
222    Deribit(DeribitProviderConfig),
223    #[serde(rename = "derive")]
224    Derive(DeriveProviderConfig),
225    #[serde(rename = "polygon")]
226    Polygon(PolygonProviderConfig),
227    #[serde(rename = "sticky_moneyness")]
228    StickyMoneyness(StickyMoneynessProviderConfig),
229    #[serde(rename = "fixed")]
230    Fixed(FixedVolOracleProviderConfig),
231    #[serde(rename = "polymarket")]
232    Polymarket(PolymarketProviderConfig),
233    #[serde(rename = "realized_vol")]
234    RealizedVol(RealizedVolProviderConfig),
235}
236
237#[derive(Debug, Clone, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct BlockScholesProviderConfig {
240    pub ws_url: String,
241    #[serde(default, deserialize_with = "deserialize_secret_option")]
242    pub api_key: Option<String>,
243    #[serde(default, deserialize_with = "deserialize_secret_option")]
244    pub api_secret: Option<String>,
245    #[serde(default = "default_block_scholes_reconnect_delay_ms")]
246    pub reconnect_delay_ms: u64,
247    #[serde(default)]
248    pub max_reconnect_attempts: u32,
249    #[serde(default = "default_block_scholes_heartbeat_interval_ms")]
250    pub heartbeat_interval_ms: u64,
251    #[serde(default = "default_block_scholes_staleness_ms")]
252    pub staleness_ms: u64,
253}
254
255#[derive(Debug, Clone, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct DeribitProviderConfig {
258    #[serde(default = "default_deribit_base_url")]
259    pub base_url: String,
260    #[serde(default = "default_deribit_refresh_interval_ms")]
261    pub refresh_interval_ms: u64,
262    #[serde(default = "default_deribit_staleness_ms")]
263    pub staleness_ms: u64,
264}
265
266#[derive(Debug, Clone, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct DeriveProviderConfig {
269    #[serde(default = "default_derive_base_url")]
270    pub base_url: String,
271    #[serde(default = "default_derive_refresh_interval_ms")]
272    pub refresh_interval_ms: u64,
273    #[serde(default = "default_derive_staleness_ms")]
274    pub staleness_ms: u64,
275}
276
277#[derive(Debug, Clone, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct PolygonProviderConfig {
280    #[serde(deserialize_with = "deserialize_secret_string")]
281    pub api_key: String,
282    #[serde(default = "default_polygon_base_url")]
283    pub base_url: String,
284    #[serde(default = "default_polygon_refresh_interval_ms")]
285    pub refresh_interval_ms: u64,
286    #[serde(default = "default_polygon_staleness_ms")]
287    pub staleness_ms: u64,
288    pub underlyings: HashMap<String, PolygonProviderUnderlyingConfig>,
289}
290
291#[derive(Debug, Clone, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct PolygonProviderUnderlyingConfig {
294    pub ticker: String,
295    pub strike_scale: f64,
296    #[serde(default)]
297    pub require_live_strike_scale: bool,
298}
299
300#[derive(Debug, Clone, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct StickyMoneynessProviderConfig {
303    pub source_provider: String,
304    #[serde(default = "default_sticky_moneyness_max_snapshot_age_ms")]
305    pub max_snapshot_age_ms: u64,
306    #[serde(default = "default_sticky_moneyness_event_jump")]
307    pub event_jump: f64,
308    #[serde(default = "default_sticky_moneyness_min_tte_years")]
309    pub min_tte_years: f64,
310}
311
312#[derive(Debug, Clone, Deserialize)]
313#[serde(deny_unknown_fields)]
314pub struct FixedVolOracleProviderConfig {
315    pub iv: f64,
316}
317
318#[derive(Debug, Clone, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct PolymarketProviderConfig {
321    pub event_slug: String,
322    #[serde(default = "default_polymarket_shares_outstanding")]
323    pub shares_outstanding: f64,
324    #[serde(default = "default_polymarket_reference_tte_days")]
325    pub reference_tte_days: f64,
326    #[serde(default = "default_polymarket_refresh_interval_ms")]
327    pub refresh_interval_ms: u64,
328    #[serde(default = "default_polymarket_staleness_ms")]
329    pub staleness_ms: u64,
330}
331
332#[derive(Debug, Clone, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct RealizedVolProviderConfig {
335    pub info_url: String,
336    pub candle_coin: String,
337    pub lookback_days: u32,
338    pub min_samples: usize,
339    pub annualization_days: f64,
340    pub floor_iv: f64,
341    pub cap_iv: f64,
342    pub multiplier: f64,
343    pub refresh_interval_ms: u64,
344    pub staleness_ms: u64,
345    pub strike_min: f64,
346    pub strike_max: f64,
347    pub strike_step: f64,
348}
349
350#[derive(Debug, Clone, Deserialize)]
351#[serde(deny_unknown_fields)]
352pub struct DatabentoProviderConfig {
353    #[serde(deserialize_with = "deserialize_secret_string")]
354    pub api_key: String,
355    #[serde(default = "default_databento_dataset")]
356    pub dataset: String,
357    #[serde(default = "default_databento_staleness_ms")]
358    pub staleness_ms: u64,
359    pub underlyings: HashMap<String, DatabentoProviderUnderlyingConfig>,
360}
361
362#[derive(Debug, Clone, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct DatabentoProviderUnderlyingConfig {
365    pub futures_root: String,
366    pub options_root: String,
367    #[serde(default = "default_databento_strike_scale")]
368    pub strike_scale: f64,
369    #[serde(default = "default_databento_risk_free_rate")]
370    pub risk_free_rate: f64,
371}
372
373pub fn parse_catalog_config(contents: &str) -> Result<CatalogConfig> {
374    let config: CatalogConfig =
375        serde_yaml_ng::from_str(contents).context("Failed to parse catalog config")?;
376    config.validate()?;
377    Ok(config)
378}
379
380impl CatalogConfig {
381    pub fn validate(&self) -> Result<()> {
382        self.expiry_times()?;
383
384        if self.underlyings.is_empty() {
385            anyhow::bail!("At least one underlying must be configured");
386        }
387
388        for (symbol, config) in &self.underlyings {
389            match config.vol_source.as_str() {
390                "deribit" | "none" => {}
391                other => {
392                    anyhow::bail!(
393                        "Unknown vol_source '{}' for {}. Supported: \"deribit\", \"none\"",
394                        other,
395                        symbol
396                    );
397                }
398            }
399        }
400
401        let mut perp_asset_ids = HashSet::new();
402        let mut stablecoin_token_ids = HashSet::new();
403        for (symbol, config) in &self.collateral {
404            match config {
405                HyperliquidAssetConfig::Perp(perp) => {
406                    if !perp_asset_ids.insert(perp.asset_id) {
407                        anyhow::bail!(
408                            "Duplicate collateral perp asset_id {} configured",
409                            perp.asset_id
410                        );
411                    }
412                    if !self.underlyings.contains_key(&perp.underlying) {
413                        anyhow::bail!(
414                            "Collateral perp {} references unknown underlying {}",
415                            symbol,
416                            perp.underlying
417                        );
418                    }
419                }
420                HyperliquidAssetConfig::Stablecoin(stablecoin) => {
421                    if !stablecoin_token_ids.insert(stablecoin.token_id) {
422                        anyhow::bail!(
423                            "Duplicate collateral stablecoin token_id {} configured",
424                            stablecoin.token_id
425                        );
426                    }
427                    if self.underlyings.contains_key(symbol) {
428                        anyhow::bail!(
429                            "Collateral stablecoin {} must not also exist in underlyings",
430                            symbol
431                        );
432                    }
433                }
434            }
435        }
436        if stablecoin_token_ids.is_empty() {
437            anyhow::bail!("Collateral registry must configure at least one stablecoin");
438        }
439
440        if self.strike_selection.deribit_region_steps.atm == 0 {
441            anyhow::bail!("strike_selection.deribit_region_steps.atm must be > 0");
442        }
443        if self.strike_selection.occ_fallback_side_count == 0 {
444            anyhow::bail!("strike_selection.occ_fallback_side_count must be > 0");
445        }
446
447        if self.extension_policy.enabled {
448            if self.extension_policy.ensure_min_strikes_per_side == 0 {
449                anyhow::bail!("ensure_min_strikes_per_side must be > 0 when extension is enabled");
450            }
451            if self.extension_policy.ensure_atm_within_pct <= 0.0
452                || self.extension_policy.ensure_atm_within_pct > 1.0
453            {
454                anyhow::bail!("ensure_atm_within_pct must be in range (0, 1.0]");
455            }
456            if self.extension_policy.max_total_strikes_per_expiry == 0 {
457                anyhow::bail!("max_total_strikes_per_expiry must be > 0");
458            }
459            if self.extension_policy.min_spot_move_pct <= 0.0
460                || self.extension_policy.min_spot_move_pct > 1.0
461            {
462                anyhow::bail!("min_spot_move_pct must be in range (0, 1.0]");
463            }
464        }
465
466        if self.vol_oracles.providers.is_empty() {
467            anyhow::bail!("At least one vol oracle provider must be configured");
468        }
469        if self.vol_oracles.routes.is_empty() {
470            anyhow::bail!("At least one vol oracle route must be configured");
471        }
472
473        for (provider_name, provider) in &self.vol_oracles.providers {
474            if provider_name.trim().is_empty() {
475                anyhow::bail!("Vol oracle provider names cannot be empty");
476            }
477            match provider {
478                VolOracleProviderConfig::BlockScholes(config) => {
479                    if config.ws_url.trim().is_empty() {
480                        anyhow::bail!(
481                            "Block Scholes provider {} must define a non-empty ws_url",
482                            provider_name
483                        );
484                    }
485                    if config
486                        .api_key
487                        .as_deref()
488                        .map(str::trim)
489                        .filter(|value| !value.is_empty())
490                        .is_none()
491                    {
492                        anyhow::bail!(
493                            "Block Scholes provider {} must define a non-empty api_key",
494                            provider_name
495                        );
496                    }
497                }
498                VolOracleProviderConfig::Databento(config) => {
499                    if config.api_key.trim().is_empty() {
500                        anyhow::bail!(
501                            "Databento provider {} must define a non-empty api_key",
502                            provider_name
503                        );
504                    }
505                    if config.underlyings.is_empty() {
506                        anyhow::bail!(
507                            "Databento provider {} must configure at least one underlying",
508                            provider_name
509                        );
510                    }
511                }
512                VolOracleProviderConfig::Polygon(config) => {
513                    if config.api_key.trim().is_empty() {
514                        anyhow::bail!(
515                            "Polygon provider {} must define a non-empty api_key",
516                            provider_name
517                        );
518                    }
519                    if config.underlyings.is_empty() {
520                        anyhow::bail!(
521                            "Polygon provider {} must configure at least one underlying",
522                            provider_name
523                        );
524                    }
525                }
526                VolOracleProviderConfig::StickyMoneyness(config) => {
527                    if config.source_provider.trim().is_empty() {
528                        anyhow::bail!(
529                            "Sticky moneyness provider {} must define source_provider",
530                            provider_name
531                        );
532                    }
533                    if config.source_provider == *provider_name {
534                        anyhow::bail!(
535                            "Sticky moneyness provider {} cannot reference itself as source_provider",
536                            provider_name
537                        );
538                    }
539                    if !self
540                        .vol_oracles
541                        .providers
542                        .contains_key(&config.source_provider)
543                    {
544                        anyhow::bail!(
545                            "Sticky moneyness provider {} references unknown source_provider {}",
546                            provider_name,
547                            config.source_provider
548                        );
549                    }
550                    if config.event_jump < 0.0 || !config.event_jump.is_finite() {
551                        anyhow::bail!(
552                            "Sticky moneyness provider {} event_jump must be finite and >= 0",
553                            provider_name
554                        );
555                    }
556                    if config.min_tte_years <= 0.0 || !config.min_tte_years.is_finite() {
557                        anyhow::bail!(
558                            "Sticky moneyness provider {} min_tte_years must be finite and > 0",
559                            provider_name
560                        );
561                    }
562                    if config.max_snapshot_age_ms == 0 {
563                        anyhow::bail!(
564                            "Sticky moneyness provider {} max_snapshot_age_ms must be > 0",
565                            provider_name
566                        );
567                    }
568                }
569                VolOracleProviderConfig::Polymarket(config) => {
570                    if config.event_slug.trim().is_empty() {
571                        anyhow::bail!(
572                            "Polymarket provider {} must define a non-empty event_slug",
573                            provider_name
574                        );
575                    }
576                    if !config.shares_outstanding.is_finite() || config.shares_outstanding <= 0.0 {
577                        anyhow::bail!(
578                            "Polymarket provider {} shares_outstanding must be finite and > 0",
579                            provider_name
580                        );
581                    }
582                    if !config.reference_tte_days.is_finite() || config.reference_tte_days <= 0.0 {
583                        anyhow::bail!(
584                            "Polymarket provider {} reference_tte_days must be finite and > 0",
585                            provider_name
586                        );
587                    }
588                    if config.refresh_interval_ms == 0 || config.staleness_ms == 0 {
589                        anyhow::bail!(
590                            "Polymarket provider {} refresh_interval_ms and staleness_ms must be > 0",
591                            provider_name
592                        );
593                    }
594                }
595                VolOracleProviderConfig::RealizedVol(config) => {
596                    if config.info_url.trim().is_empty() {
597                        anyhow::bail!(
598                            "Realized vol provider {} must define a non-empty info_url",
599                            provider_name
600                        );
601                    }
602                    if config.candle_coin.trim().is_empty() {
603                        anyhow::bail!(
604                            "Realized vol provider {} must define a non-empty candle_coin",
605                            provider_name
606                        );
607                    }
608                    if config.lookback_days == 0 {
609                        anyhow::bail!(
610                            "Realized vol provider {} lookback_days must be > 0",
611                            provider_name
612                        );
613                    }
614                    if config.min_samples < 2 {
615                        anyhow::bail!(
616                            "Realized vol provider {} min_samples must be >= 2",
617                            provider_name
618                        );
619                    }
620                    if !config.annualization_days.is_finite() || config.annualization_days <= 0.0 {
621                        anyhow::bail!(
622                            "Realized vol provider {} annualization_days must be finite and > 0",
623                            provider_name
624                        );
625                    }
626                    if !config.floor_iv.is_finite() || config.floor_iv <= 0.0 {
627                        anyhow::bail!(
628                            "Realized vol provider {} floor_iv must be finite and > 0",
629                            provider_name
630                        );
631                    }
632                    if !config.cap_iv.is_finite() || config.cap_iv < config.floor_iv {
633                        anyhow::bail!(
634                            "Realized vol provider {} cap_iv must be finite and >= floor_iv",
635                            provider_name
636                        );
637                    }
638                    if !config.multiplier.is_finite() || config.multiplier <= 0.0 {
639                        anyhow::bail!(
640                            "Realized vol provider {} multiplier must be finite and > 0",
641                            provider_name
642                        );
643                    }
644                    if config.refresh_interval_ms == 0 || config.staleness_ms == 0 {
645                        anyhow::bail!(
646                            "Realized vol provider {} refresh_interval_ms and staleness_ms must be > 0",
647                            provider_name
648                        );
649                    }
650                    if !config.strike_min.is_finite()
651                        || !config.strike_max.is_finite()
652                        || !config.strike_step.is_finite()
653                        || config.strike_min <= 0.0
654                        || config.strike_max < config.strike_min
655                        || config.strike_step <= 0.0
656                    {
657                        anyhow::bail!(
658                            "Realized vol provider {} strike_min/strike_max/strike_step must define a positive finite grid",
659                            provider_name
660                        );
661                    }
662                }
663                VolOracleProviderConfig::Deribit(_)
664                | VolOracleProviderConfig::Derive(_)
665                | VolOracleProviderConfig::Fixed(_) => {}
666            }
667        }
668
669        for (underlying, route) in &self.vol_oracles.routes {
670            if !self.underlyings.contains_key(underlying) {
671                anyhow::bail!(
672                    "Vol oracle route references unknown underlying {}",
673                    underlying
674                );
675            }
676            if route.is_empty() {
677                anyhow::bail!("Vol oracle route for {} cannot be empty", underlying);
678            }
679            for provider_name in route {
680                if !self.vol_oracles.providers.contains_key(provider_name) {
681                    anyhow::bail!(
682                        "Vol oracle route for {} references unknown provider {}",
683                        underlying,
684                        provider_name
685                    );
686                }
687            }
688        }
689
690        Ok(())
691    }
692
693    /// Build the per-underlying expiry time policy from the global
694    /// `expiry.expiry_time_utc` default and per-underlying overrides.
695    pub fn expiry_times(&self) -> Result<ExpiryTimes> {
696        let default = ExpiryTime::parse(&self.expiry.expiry_time_utc)
697            .map_err(|e| anyhow::anyhow!("Invalid expiry.expiry_time_utc: {}", e))?;
698        let mut overrides = std::collections::HashMap::new();
699        for (symbol, config) in &self.underlyings {
700            if let Some(raw) = &config.expiry_time_utc {
701                let time = ExpiryTime::parse(raw).map_err(|e| {
702                    anyhow::anyhow!("Invalid expiry_time_utc for underlying {}: {}", symbol, e)
703                })?;
704                overrides.insert(symbol.clone(), time);
705            }
706        }
707        Ok(ExpiryTimes::new(default, overrides))
708    }
709}
710
711fn deserialize_secret_string<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
712where
713    D: Deserializer<'de>,
714{
715    let raw = String::deserialize(deserializer)?;
716    resolve_secret_placeholder(&raw).map_err(D::Error::custom)
717}
718
719fn deserialize_secret_option<'de, D>(
720    deserializer: D,
721) -> std::result::Result<Option<String>, D::Error>
722where
723    D: Deserializer<'de>,
724{
725    Option::<String>::deserialize(deserializer)?
726        .map(|raw| resolve_secret_placeholder(&raw))
727        .transpose()
728        .map_err(D::Error::custom)
729}
730
731fn resolve_secret_placeholder(raw: &str) -> Result<String> {
732    if let Some(variable) = raw
733        .strip_prefix("${")
734        .and_then(|value| value.strip_suffix('}'))
735    {
736        return SECRET_PLACEHOLDER_MODE.with(|mode| match mode.get() {
737            SecretPlaceholderMode::ResolveRequired => std::env::var(variable).with_context(|| {
738                format!(
739                    "Catalog config references environment variable {} but it is not set",
740                    variable
741                )
742            }),
743            SecretPlaceholderMode::AllowUnresolved => Ok(raw.to_string()),
744        });
745    }
746    Ok(raw.to_string())
747}
748
749fn default_vol_source() -> String {
750    "deribit".to_string()
751}
752
753fn default_log_level() -> String {
754    "info".to_string()
755}
756
757fn default_true() -> bool {
758    true
759}
760
761fn default_block_scholes_reconnect_delay_ms() -> u64 {
762    5_000
763}
764
765fn default_block_scholes_heartbeat_interval_ms() -> u64 {
766    30_000
767}
768
769fn default_block_scholes_staleness_ms() -> u64 {
770    60_000
771}
772
773fn default_deribit_base_url() -> String {
774    "https://www.deribit.com/api/v2".to_string()
775}
776
777fn default_deribit_refresh_interval_ms() -> u64 {
778    15_000
779}
780
781fn default_deribit_staleness_ms() -> u64 {
782    120_000
783}
784
785fn default_derive_base_url() -> String {
786    "https://api.lyra.finance".to_string()
787}
788
789fn default_derive_refresh_interval_ms() -> u64 {
790    30_000
791}
792
793fn default_derive_staleness_ms() -> u64 {
794    120_000
795}
796
797fn default_polygon_base_url() -> String {
798    "https://api.polygon.io".to_string()
799}
800
801fn default_polygon_refresh_interval_ms() -> u64 {
802    30_000
803}
804
805fn default_polygon_staleness_ms() -> u64 {
806    180_000
807}
808
809fn default_sticky_moneyness_max_snapshot_age_ms() -> u64 {
810    259_200_000
811}
812
813fn default_sticky_moneyness_event_jump() -> f64 {
814    0.03
815}
816
817fn default_sticky_moneyness_min_tte_years() -> f64 {
818    1.0 / 365.25
819}
820
821fn default_polymarket_shares_outstanding() -> f64 {
822    11.87e9
823}
824
825fn default_polymarket_reference_tte_days() -> f64 {
826    30.0
827}
828
829fn default_polymarket_refresh_interval_ms() -> u64 {
830    60_000
831}
832
833fn default_polymarket_staleness_ms() -> u64 {
834    300_000
835}
836
837fn default_databento_dataset() -> String {
838    "GLBX.MDP3".to_string()
839}
840
841fn default_databento_staleness_ms() -> u64 {
842    432_000_000
843}
844
845fn default_databento_strike_scale() -> f64 {
846    1.0
847}
848
849fn default_databento_risk_free_rate() -> f64 {
850    0.05
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856
857    fn valid_yaml() -> &'static str {
858        r#"
859version: 1
860expiry:
861  expiry_time_utc: "08:00"
862  schedule:
863    daily_count: 2
864    weekly_count: 4
865    monthly_count: 3
866underlyings:
867  BTC:
868    trading_mode: "orderbook|rfq"
869  HYPE:
870    vol_source: "none"
871    trading_mode: "orderbook"
872collateral:
873  BTC_PERP:
874    kind: perp
875    asset_id: 0
876    underlying: BTC
877  USDC:
878    kind: stablecoin
879    token_id: 0
880strike_selection:
881  deribit_table_assets: [BTC, ETH, SOL, AVAX, XRP, TRX]
882  deribit_region_steps:
883    atm: 3
884    outer: 4
885    wings: 3
886  occ_fallback_side_count: 8
887extension_policy:
888  enabled: true
889  ensure_min_strikes_per_side: 5
890  ensure_atm_within_pct: 0.05
891  cooldown_secs: 3600
892  max_total_strikes_per_expiry: 40
893  min_spot_move_pct: 0.05
894vol_oracles:
895  providers:
896    fixed_local:
897      kind: fixed
898      iv: 0.50
899  routes:
900    BTC: fixed_local
901    HYPE: fixed_local
902"#
903    }
904
905    #[test]
906    fn parses_valid_config() {
907        let config = parse_catalog_config(valid_yaml()).unwrap();
908        assert_eq!(
909            config.expiry_times().unwrap().for_underlying("BTC"),
910            ExpiryTime { hour: 8, minute: 0 }
911        );
912        assert_eq!(config.underlyings.len(), 2);
913        assert_eq!(config.strike_selection.deribit_region_steps.atm, 3);
914    }
915
916    #[test]
917    fn parses_max_expiry_date() {
918        let yaml = valid_yaml().replace(
919            "  BTC:\n    trading_mode: \"orderbook|rfq\"",
920            "  BTC:\n    trading_mode: \"orderbook|rfq\"\n    max_expiry_date: \"2026-06-12\"",
921        );
922        let config = parse_catalog_config(&yaml).unwrap();
923        let btc = config.underlyings.get("BTC").unwrap();
924        assert_eq!(btc.max_expiry_code, Some(20260612));
925        let hype = config.underlyings.get("HYPE").unwrap();
926        assert!(hype.max_expiry_code.is_none());
927    }
928
929    #[test]
930    fn parses_per_underlying_weekday_schedule() {
931        let yaml = valid_yaml().replace(
932            "  BTC:\n    trading_mode: \"orderbook|rfq\"",
933            "  BTC:\n    trading_mode: \"orderbook|rfq\"\n    schedule:\n      daily_count: 3\n      weekly_count: 0\n      monthly_count: 0\n      weekdays_only: true",
934        );
935        let config = parse_catalog_config(&yaml).unwrap();
936        let btc = config.underlyings.get("BTC").unwrap();
937        let schedule = btc.schedule.as_ref().unwrap();
938        assert_eq!(schedule.daily_count, 3);
939        assert_eq!(schedule.weekly_count, 0);
940        assert_eq!(schedule.monthly_count, 0);
941        assert!(schedule.weekdays_only);
942    }
943
944    #[test]
945    fn parses_per_underlying_calendar_schedule() {
946        let yaml = valid_yaml().replace(
947            "  BTC:\n    trading_mode: \"orderbook|rfq\"",
948            "  BTC:\n    trading_mode: \"orderbook|rfq\"\n    schedule:\n      daily_count: 3\n      weekly_count: 4\n      monthly_count: 0\n      weekdays_only: false",
949        );
950        let config = parse_catalog_config(&yaml).unwrap();
951        let btc = config.underlyings.get("BTC").unwrap();
952        let schedule = btc.schedule.as_ref().unwrap();
953        assert_eq!(schedule.daily_count, 3);
954        assert_eq!(schedule.weekly_count, 4);
955        assert_eq!(schedule.monthly_count, 0);
956        assert!(!schedule.weekdays_only);
957    }
958
959    #[test]
960    fn allows_empty_deribit_table_for_non_deribit_catalog() {
961        let yaml = valid_yaml().replace(
962            "  deribit_table_assets: [BTC, ETH, SOL, AVAX, XRP, TRX]",
963            "  deribit_table_assets: []",
964        );
965        let config = parse_catalog_config(&yaml).unwrap();
966        assert!(config.strike_selection.deribit_table_assets.is_empty());
967    }
968
969    #[test]
970    fn parses_sticky_moneyness_provider_config() {
971        let yaml = valid_yaml().replace(
972            "  providers:\n    fixed_local:\n      kind: fixed\n      iv: 0.50\n  routes:\n    BTC: fixed_local\n    HYPE: fixed_local",
973            "  providers:\n    polygon_main:\n      kind: polygon\n      api_key: test-key\n      base_url: http://127.0.0.1:9\n      refresh_interval_ms: 30000\n      staleness_ms: 180000\n      underlyings:\n        BTC:\n          ticker: SPY\n          strike_scale: 1.0\n          require_live_strike_scale: true\n        HYPE:\n          ticker: HYPE\n          strike_scale: 1.0\n          require_live_strike_scale: true\n    polygon_session_model:\n      kind: sticky_moneyness\n      source_provider: polygon_main\n      max_snapshot_age_ms: 259200000\n      event_jump: 0.03\n      min_tte_years: 0.0027378507871321013\n  routes:\n    BTC: polygon_session_model\n    HYPE: polygon_session_model",
974        );
975
976        let config = parse_catalog_config(&yaml).unwrap();
977        let provider = config
978            .vol_oracles
979            .providers
980            .get("polygon_session_model")
981            .expect("sticky provider should parse");
982        match provider {
983            VolOracleProviderConfig::StickyMoneyness(config) => {
984                assert_eq!(config.source_provider, "polygon_main");
985                assert_eq!(config.max_snapshot_age_ms, 259_200_000);
986                assert_eq!(config.event_jump, 0.03);
987            }
988            other => panic!("expected sticky moneyness provider, got {other:?}"),
989        }
990        let polygon = config
991            .vol_oracles
992            .providers
993            .get("polygon_main")
994            .expect("polygon provider should parse");
995        match polygon {
996            VolOracleProviderConfig::Polygon(config) => {
997                assert!(
998                    config.underlyings["BTC"].require_live_strike_scale,
999                    "production-style polygon route should require dynamic strike scale"
1000                );
1001            }
1002            other => panic!("expected polygon provider, got {other:?}"),
1003        }
1004    }
1005
1006    #[test]
1007    fn parses_realized_vol_provider_config() {
1008        let yaml = valid_yaml().replace(
1009            "  providers:\n    fixed_local:\n      kind: fixed\n      iv: 0.50\n  routes:\n    BTC: fixed_local\n    HYPE: fixed_local",
1010            "  providers:\n    realized_spcx:\n      kind: realized_vol\n      info_url: https://api.hyperliquid.xyz/info\n      candle_coin: xyz:SPCX\n      lookback_days: 7\n      min_samples: 48\n      annualization_days: 365\n      floor_iv: 0.50\n      cap_iv: 3.00\n      multiplier: 1.25\n      refresh_interval_ms: 60000\n      staleness_ms: 300000\n      strike_min: 50\n      strike_max: 500\n      strike_step: 5\n  routes:\n    BTC: realized_spcx\n    HYPE: realized_spcx",
1011        );
1012
1013        let config = parse_catalog_config(&yaml).unwrap();
1014        let provider = config
1015            .vol_oracles
1016            .providers
1017            .get("realized_spcx")
1018            .expect("realized-vol provider should parse");
1019        match provider {
1020            VolOracleProviderConfig::RealizedVol(config) => {
1021                assert_eq!(config.candle_coin, "xyz:SPCX");
1022                assert_eq!(config.min_samples, 48);
1023                assert_eq!(config.floor_iv, 0.50);
1024                assert_eq!(config.cap_iv, 3.00);
1025            }
1026            other => panic!("expected realized-vol provider, got {other:?}"),
1027        }
1028    }
1029
1030    #[test]
1031    fn rejects_self_referential_sticky_moneyness_provider() {
1032        let yaml = valid_yaml().replace(
1033            "  providers:\n    fixed_local:\n      kind: fixed\n      iv: 0.50\n  routes:\n    BTC: fixed_local\n    HYPE: fixed_local",
1034            "  providers:\n    polygon_session_model:\n      kind: sticky_moneyness\n      source_provider: polygon_session_model\n  routes:\n    BTC: polygon_session_model\n    HYPE: polygon_session_model",
1035        );
1036
1037        let err = parse_catalog_config(&yaml).unwrap_err().to_string();
1038        assert!(
1039            err.contains("cannot reference itself as source_provider"),
1040            "unexpected error: {err}"
1041        );
1042    }
1043
1044    #[test]
1045    fn committed_catalogs_parse_with_secret_placeholders() {
1046        with_secret_placeholder_mode(SecretPlaceholderMode::AllowUnresolved, || {
1047            parse_catalog_config(include_str!(concat!(
1048                env!("CARGO_MANIFEST_DIR"),
1049                "/../../market_catalog_prod.yaml"
1050            )))?;
1051            parse_catalog_config(include_str!(concat!(
1052                env!("CARGO_MANIFEST_DIR"),
1053                "/../../market_catalog_testnet.yaml"
1054            )))?;
1055            Ok(())
1056        })
1057        .expect("committed prod and testnet catalogs should parse");
1058    }
1059
1060    #[test]
1061    fn rejects_old_percent_ladder_fields() {
1062        let yaml = valid_yaml().replace(
1063            "  BTC:\n    trading_mode",
1064            "  BTC:\n    strike_tick: 1000\n    percent_ladder: [0.9, 1.0, 1.1]\n    trading_mode",
1065        );
1066        assert!(parse_catalog_config(&yaml).is_err());
1067    }
1068
1069    #[test]
1070    fn rejects_old_deribit_snap_root() {
1071        let yaml = valid_yaml().replace(
1072            "extension_policy:",
1073            "deribit_snap:\n  enabled: true\n  max_snap_ticks: 2\n  max_snap_pct: 0.02\n  require_paired: true\nextension_policy:",
1074        );
1075        assert!(parse_catalog_config(&yaml).is_err());
1076    }
1077
1078    #[test]
1079    fn rejects_invalid_expiry_time() {
1080        let yaml = valid_yaml().replace("08:00", "25:00");
1081        assert!(parse_catalog_config(&yaml).is_err());
1082    }
1083
1084    #[test]
1085    fn rejects_unknown_deribit_provider_fields() {
1086        let yaml = valid_yaml().replace(
1087            "    fixed_local:\n      kind: fixed\n      iv: 0.50",
1088            "    fixed_local:\n      kind: fixed\n      iv: 0.50\n    deribit_local:\n      kind: deribit\n      extra: true",
1089        );
1090        assert!(parse_catalog_config(&yaml).is_err());
1091    }
1092}