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 #[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 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}