Skip to main content

hypercall_api/caches/
options_summary.rs

1use anyhow::{anyhow, Context, Result};
2use arc_swap::ArcSwap;
3use axum::body::Bytes;
4use chrono::Utc;
5use rust_decimal::prelude::ToPrimitive;
6use rust_decimal_macros::dec;
7use serde::Serialize;
8use std::collections::{BTreeMap, HashMap};
9use std::sync::Arc;
10use std::time::{Duration, Instant, SystemTime};
11use tracing::error;
12
13use crate::boundary::market_inputs::GreeksCacheReader;
14use crate::boundary::market_inputs::InstrumentsCacheReader;
15use crate::handlers::market_data::OptionSummary;
16use crate::models::Instrument;
17use crate::rfq::indicative_quote_cache::{AggregatedIndicativeQuote, IndicativeQuoteCache};
18use hypercall_db::{AnalyticsReader, BboReferenceData, HistoricalTheoPoint};
19use hypercall_runtime_api::valuation::get_symbol_theoretical_price;
20use hypercall_runtime_api::{QuoteProvider, SnapshotBookQuote};
21use hypercall_types::{CONTRACT_UNIT_MULTIPLIER, HISTORICAL_THEO_INTERVAL_1H_MS};
22
23const DEFAULT_REFRESH_INTERVAL_MS: u64 = 1_000;
24const OPTIONS_SUMMARY_SNAPSHOT_ENVELOPE_SCHEMA_VERSION: u32 = 2;
25
26struct OptionsSummarySnapshotData {
27    by_underlying: BTreeMap<String, Vec<OptionSummary>>,
28    published_response: Bytes,
29    built_at: SystemTime,
30}
31
32pub struct OptionsSummarySnapshotCache {
33    instruments_cache: Arc<dyn InstrumentsCacheReader>,
34    greeks_cache: Arc<dyn GreeksCacheReader>,
35    quote_provider: Arc<dyn QuoteProvider>,
36    bbo_snapshot_reader: Arc<dyn BboReferenceAskReader>,
37    indicative_cache: Option<Arc<IndicativeQuoteCache>>,
38    db: Arc<dyn AnalyticsReader>,
39    snapshot: ArcSwap<OptionsSummarySnapshotData>,
40    refresh_interval: Duration,
41}
42
43#[async_trait::async_trait]
44pub trait BboReferenceAskReader: Send + Sync {
45    async fn get_reference_asks(
46        &self,
47        symbols: &[String],
48        cutoff_ts: i64,
49    ) -> HashMap<String, BboReferenceData>;
50}
51
52impl OptionsSummarySnapshotCache {
53    pub fn new(
54        instruments_cache: Arc<dyn InstrumentsCacheReader>,
55        greeks_cache: Arc<dyn GreeksCacheReader>,
56        quote_provider: Arc<dyn QuoteProvider>,
57        bbo_snapshot_reader: Arc<dyn BboReferenceAskReader>,
58        indicative_cache: Option<Arc<IndicativeQuoteCache>>,
59        db: Arc<dyn AnalyticsReader>,
60    ) -> Self {
61        Self {
62            instruments_cache,
63            greeks_cache,
64            quote_provider,
65            bbo_snapshot_reader,
66            indicative_cache,
67            db,
68            snapshot: ArcSwap::from_pointee(OptionsSummarySnapshotData {
69                by_underlying: BTreeMap::new(),
70                published_response: Bytes::from_static(
71                    br#"{"schema_version":1,"built_at_ms":0,"payload":{}}"#,
72                ),
73                built_at: SystemTime::UNIX_EPOCH,
74            }),
75            refresh_interval: Duration::from_millis(DEFAULT_REFRESH_INTERVAL_MS),
76        }
77    }
78
79    pub fn with_refresh_interval(mut self, refresh_interval: Duration) -> Self {
80        self.refresh_interval = refresh_interval;
81        self
82    }
83
84    pub async fn refresh_once(&self) -> Result<()> {
85        let load_start = Instant::now();
86        let mut instruments: Vec<Instrument> = self
87            .instruments_cache
88            .get_all()
89            .await
90            .into_iter()
91            .filter(|instrument| instrument.status.is_active())
92            .collect();
93        instruments.sort_by(|a, b| {
94            (
95                a.underlying.as_str(),
96                a.expiry,
97                a.strike,
98                a.option_type.as_str(),
99                a.id.as_str(),
100            )
101                .cmp(&(
102                    b.underlying.as_str(),
103                    b.expiry,
104                    b.strike,
105                    b.option_type.as_str(),
106                    b.id.as_str(),
107                ))
108        });
109        let option_symbols: Vec<String> = instruments.iter().map(|opt| opt.id.clone()).collect();
110        let bulk_mark_ivs = self.greeks_cache.get_bulk_iv(&option_symbols).await;
111        let reference_asks = self
112            .bbo_snapshot_reader
113            .get_reference_asks(&option_symbols, Utc::now().timestamp() - 86_400)
114            .await;
115        let all_spot_prices = self.greeks_cache.get_all_spot_prices_snapshot().await;
116        let reference_theos = self
117            .db
118            .get_historical_theos_batch(&option_symbols, HISTORICAL_THEO_INTERVAL_1H_MS, 25)
119            .await
120            .unwrap_or_default();
121        metrics::histogram!("ht_options_summary_snapshot_load_seconds")
122            .record(load_start.elapsed().as_secs_f64());
123
124        let build_start = Instant::now();
125        let cutoff_ms = Utc::now().timestamp_millis() - 86_400_000;
126        let mut grouped = BTreeMap::<String, Vec<OptionSummary>>::new();
127        for instrument in instruments {
128            let summary = build_option_summary(
129                &instrument,
130                &all_spot_prices,
131                &bulk_mark_ivs,
132                &reference_asks,
133                &reference_theos,
134                cutoff_ms,
135                self.greeks_cache.as_ref(),
136                self.quote_provider.as_ref(),
137                self.indicative_cache.as_deref(),
138            )
139            .await
140            .with_context(|| {
141                format!(
142                    "refresh_once failed while rebuilding options summary for {}",
143                    instrument.id
144                )
145            })?;
146            grouped
147                .entry(instrument.underlying.to_uppercase())
148                .or_default()
149                .push(summary);
150        }
151        metrics::histogram!("ht_options_summary_snapshot_build_seconds")
152            .record(build_start.elapsed().as_secs_f64());
153
154        let serialize_start = Instant::now();
155        let built_at = SystemTime::now();
156        let published_response = Bytes::from(
157            serde_json::to_vec(&PublishedOptionsSummarySnapshotRef {
158                schema_version: OPTIONS_SUMMARY_SNAPSHOT_ENVELOPE_SCHEMA_VERSION,
159                built_at_ms: system_time_to_millis(built_at)?,
160                payload: &grouped,
161            })
162            .map_err(|e| anyhow!("Failed to serialize options-summary snapshot: {}", e))?,
163        );
164        metrics::histogram!("ht_options_summary_snapshot_serialize_seconds")
165            .record(serialize_start.elapsed().as_secs_f64());
166
167        self.snapshot.store(Arc::new(OptionsSummarySnapshotData {
168            by_underlying: grouped,
169            published_response,
170            built_at,
171        }));
172        metrics::counter!("ht_options_summary_snapshot_refresh_total", "status" => "success")
173            .increment(1);
174        Ok(())
175    }
176
177    pub async fn initialize(&self) {
178        if let Err(error) = self.refresh_once().await {
179            metrics::counter!("ht_options_summary_snapshot_refresh_total", "status" => "error")
180                .increment(1);
181            error!(
182                error = %error,
183                "Initial /options-summary snapshot build failed; serving cold-start payload"
184            );
185        }
186    }
187
188    pub fn start_with_shutdown(
189        self: Arc<Self>,
190        mut shutdown_rx: tokio::sync::broadcast::Receiver<()>,
191    ) -> tokio::task::JoinHandle<()> {
192        tokio::spawn(async move {
193            let mut interval = tokio::time::interval(self.refresh_interval);
194            loop {
195                tokio::select! {
196                    _ = shutdown_rx.recv() => {
197                        break;
198                    }
199                    _ = interval.tick() => {
200                        if let Err(error) = self.refresh_once().await {
201                            metrics::counter!("ht_options_summary_snapshot_refresh_total", "status" => "error")
202                                .increment(1);
203                            error!(
204                                error = %error,
205                                "Failed to refresh /options-summary snapshot; keeping last-good"
206                            );
207                        }
208                    }
209                }
210            }
211        })
212    }
213
214    pub fn available_underlyings(&self) -> Vec<String> {
215        self.snapshot.load().by_underlying.keys().cloned().collect()
216    }
217
218    pub fn get_for_underlyings(
219        &self,
220        requested_underlyings: &[String],
221    ) -> (Vec<OptionSummary>, SystemTime) {
222        let snapshot = self.snapshot.load();
223        let mut result = Vec::new();
224        for underlying in requested_underlyings {
225            if let Some(entries) = snapshot.by_underlying.get(&underlying.to_uppercase()) {
226                result.extend(entries.iter().cloned());
227            }
228        }
229        (result, snapshot.built_at)
230    }
231
232    pub fn published_response(&self) -> (Bytes, SystemTime) {
233        let snapshot = self.snapshot.load();
234        (snapshot.published_response.clone(), snapshot.built_at)
235    }
236}
237
238async fn build_option_summary(
239    instrument: &Instrument,
240    all_spot_prices: &HashMap<String, f64>,
241    bulk_mark_ivs: &HashMap<String, f64>,
242    reference_asks: &HashMap<String, BboReferenceData>,
243    reference_theos: &HashMap<String, Vec<HistoricalTheoPoint>>,
244    cutoff_ms: i64,
245    greeks_cache: &dyn GreeksCacheReader,
246    quote_provider: &dyn QuoteProvider,
247    indicative_cache: Option<&IndicativeQuoteCache>,
248) -> Result<OptionSummary> {
249    let symbol = instrument.id.clone();
250    let underlying = instrument.underlying.to_uppercase();
251    let underlying_price = resolve_underlying_price(all_spot_prices, &underlying)
252        .with_context(|| format!("resolve_underlying_price failed for {}", underlying))?;
253    let mut theoretical_price = get_symbol_theoretical_price(greeks_cache, &symbol)
254        .await
255        .ok();
256
257    let quote = quote_provider.get_quote(&symbol);
258    let indicative_quote = indicative_cache.and_then(|cache| cache.get_aggregate(&symbol));
259    let resolved_quotes = resolve_option_summary_quotes(quote.as_ref(), indicative_quote.as_ref());
260
261    let mut price_change = None;
262    if let Some(reference) = reference_asks.get(&symbol) {
263        metrics::counter!("ht_options_summary_reference_lookup_total", "status" => "hit")
264            .increment(1);
265        if reference.used_earliest_fallback {
266            metrics::counter!(
267                "ht_options_summary_reference_lookup_total",
268                "status" => "fallback_earliest"
269            )
270            .increment(1);
271        }
272
273        if resolved_quotes.orderbook_bid_price > 0.0 && reference.reference_ask > dec!(0) {
274            if let Some(reference_ask) = reference.reference_ask.to_f64() {
275                price_change = Some(
276                    ((resolved_quotes.orderbook_bid_price - reference_ask) / reference_ask) * 100.0,
277                );
278            }
279        }
280    } else {
281        metrics::counter!("ht_options_summary_reference_lookup_total", "status" => "miss")
282            .increment(1);
283    }
284
285    const THEO_FLOOR: f64 = 0.50;
286    if price_change.is_none() {
287        if let Some(current_theo) = theoretical_price {
288            if current_theo >= THEO_FLOOR {
289                let ref_theo = reference_theos
290                    .get(&symbol)
291                    .and_then(|series| {
292                        series
293                            .iter()
294                            .rfind(|p| p.timestamp_ms <= cutoff_ms)
295                            .and_then(|p| p.theoretical_price.to_f64())
296                    })
297                    .unwrap_or(0.0);
298                if ref_theo >= THEO_FLOOR {
299                    price_change = Some(((current_theo - ref_theo) / ref_theo) * 100.0);
300                    metrics::counter!(
301                        "ht_options_summary_reference_lookup_total",
302                        "status" => "theo_fallback"
303                    )
304                    .increment(1);
305                }
306            }
307        }
308    }
309
310    let last_price = if resolved_quotes.mid_price > 0.0 {
311        Some(resolved_quotes.mid_price)
312    } else {
313        None
314    };
315
316    let volume = instrument.volume_24h.to_f64().unwrap_or(0.0);
317    let volume_usd = volume
318        * if resolved_quotes.mark_price > 0.0 {
319            resolved_quotes.mark_price
320        } else {
321            underlying_price
322        };
323
324    let (dynamic_mark_iv, greeks) = match greeks_cache.get_greeks(&symbol).await {
325        Ok(greeks) => {
326            theoretical_price = theoretical_price.or(Some(greeks.theoretical_price));
327            (
328                bulk_mark_ivs
329                    .get(&symbol)
330                    .copied()
331                    .or(Some(greeks.implied_vol)),
332                Some(hypercall_types::OrderBookGreeks {
333                    delta: greeks.delta,
334                    gamma: greeks.gamma,
335                    vega: greeks.vega,
336                    theta: greeks.theta,
337                    rho: greeks.rho,
338                }),
339            )
340        }
341        Err(_) => (bulk_mark_ivs.get(&symbol).copied(), None),
342    };
343    let (bid_iv, ask_iv) = greeks_cache
344        .get_quote_side_ivs_from_prices(
345            &symbol,
346            resolved_quotes.bid_quote_price,
347            resolved_quotes.ask_quote_price,
348        )
349        .await
350        .unwrap_or((None, None));
351
352    Ok(OptionSummary {
353        instrument_id: instrument.instrument_id,
354        instrument_name: symbol,
355        option_token_address: instrument.option_token_address,
356        expiration_timestamp: (instrument.expiry * 1000) as i64,
357        bid_price: resolved_quotes.orderbook_bid_price,
358        ask_price: resolved_quotes.orderbook_ask_price,
359        best_bid_size: resolved_quotes.orderbook_bid_size,
360        best_ask_size: resolved_quotes.orderbook_ask_size,
361        indicative_bid_price: resolved_quotes.indicative_bid_price,
362        indicative_ask_price: resolved_quotes.indicative_ask_price,
363        indicative_bid_size: resolved_quotes.indicative_bid_size,
364        indicative_ask_size: resolved_quotes.indicative_ask_size,
365        mark_price: resolved_quotes.mark_price,
366        theoretical_price,
367        mark_iv: dynamic_mark_iv,
368        ask_iv,
369        bid_iv,
370        underlying_price,
371        underlying_index: format!("{}_USD", underlying),
372        open_interest: instrument.open_interest.to_f64().unwrap_or(0.0),
373        volume,
374        volume_usd,
375        high: None,
376        low: None,
377        last: last_price,
378        price_change,
379        interest_rate: 0.0,
380        estimated_delivery_price: if resolved_quotes.mark_price > 0.0 {
381            resolved_quotes.mark_price
382        } else {
383            underlying_price
384        },
385        creation_timestamp: instrument.updated_at.timestamp_millis(),
386        base_currency: underlying.clone(),
387        quote_currency: "USD".to_string(),
388        mid_price: resolved_quotes.mid_price,
389        greeks,
390    })
391}
392
393#[derive(Debug, PartialEq)]
394struct ResolvedOptionSummaryQuotes {
395    orderbook_bid_price: f64,
396    orderbook_ask_price: f64,
397    orderbook_bid_size: Option<f64>,
398    orderbook_ask_size: Option<f64>,
399    indicative_bid_price: Option<f64>,
400    indicative_ask_price: Option<f64>,
401    indicative_bid_size: Option<f64>,
402    indicative_ask_size: Option<f64>,
403    bid_quote_price: Option<f64>,
404    ask_quote_price: Option<f64>,
405    reference_bid_price: f64,
406    mark_price: f64,
407    mid_price: f64,
408}
409
410fn resolve_option_summary_quotes(
411    orderbook_quote: Option<&SnapshotBookQuote>,
412    indicative_quote: Option<&AggregatedIndicativeQuote>,
413) -> ResolvedOptionSummaryQuotes {
414    let orderbook_bid_price = orderbook_quote
415        .and_then(|quote| quote.best_bid)
416        .filter(|&value| value > 0.0)
417        .unwrap_or(0.0);
418    let orderbook_ask_price = orderbook_quote
419        .and_then(|quote| quote.best_ask)
420        .filter(|&value| value > 0.0)
421        .unwrap_or(0.0);
422    let orderbook_mid_price = orderbook_quote
423        .and_then(|quote| quote.mid)
424        .filter(|&value| value > 0.0);
425    let orderbook_bid_size = orderbook_quote
426        .and_then(|quote| quote.best_bid_size)
427        .and_then(raw_contract_units_to_human_contracts);
428    let orderbook_ask_size = orderbook_quote
429        .and_then(|quote| quote.best_ask_size)
430        .and_then(raw_contract_units_to_human_contracts);
431
432    let indicative_bid_price = indicative_quote
433        .and_then(|quote| quote.best_bid.to_f64())
434        .filter(|&value| value > 0.0);
435    let indicative_ask_price = indicative_quote
436        .and_then(|quote| quote.best_ask.to_f64())
437        .filter(|&value| value > 0.0);
438    let indicative_bid_size = indicative_quote
439        .and_then(|quote| quote.indicative_bid_size.to_f64())
440        .and_then(raw_contract_units_to_human_contracts);
441    let indicative_ask_size = indicative_quote
442        .and_then(|quote| quote.indicative_ask_size.to_f64())
443        .and_then(raw_contract_units_to_human_contracts);
444
445    let mut bid_quote_price = if orderbook_bid_price > 0.0 {
446        Some(orderbook_bid_price)
447    } else {
448        indicative_bid_price
449    };
450    let mut ask_quote_price = if orderbook_ask_price > 0.0 {
451        Some(orderbook_ask_price)
452    } else {
453        indicative_ask_price
454    };
455
456    // Mixing orderbook and indicative sources can produce a crossed market
457    // (e.g. stale orderbook bid above indicative ask). Fall back to
458    // indicative-only pricing when that happens so downstream IV
459    // calculations respect the bid_iv <= ask_iv invariant.
460    if let (Some(bid), Some(ask)) = (bid_quote_price, ask_quote_price) {
461        if bid > ask {
462            bid_quote_price = indicative_bid_price;
463            ask_quote_price = indicative_ask_price;
464        }
465    }
466    let mark_price = orderbook_mid_price
467        .or_else(|| midpoint(orderbook_bid_price, orderbook_ask_price))
468        .or_else(|| midpoint_from_options(indicative_bid_price, indicative_ask_price))
469        .or_else(|| positive_value(orderbook_bid_price))
470        .or_else(|| positive_value(orderbook_ask_price))
471        .or(indicative_bid_price)
472        .or(indicative_ask_price)
473        .unwrap_or(0.0);
474    let mid_price = orderbook_mid_price
475        .or_else(|| midpoint(orderbook_bid_price, orderbook_ask_price))
476        .or_else(|| midpoint_from_options(indicative_bid_price, indicative_ask_price))
477        .unwrap_or(mark_price);
478    let reference_bid_price = positive_value(orderbook_bid_price)
479        .or(indicative_bid_price)
480        .unwrap_or(0.0);
481
482    ResolvedOptionSummaryQuotes {
483        orderbook_bid_price,
484        orderbook_ask_price,
485        orderbook_bid_size,
486        orderbook_ask_size,
487        indicative_bid_price,
488        indicative_ask_price,
489        indicative_bid_size,
490        indicative_ask_size,
491        bid_quote_price,
492        ask_quote_price,
493        reference_bid_price,
494        mark_price,
495        mid_price,
496    }
497}
498
499fn positive_value(value: f64) -> Option<f64> {
500    (value > 0.0).then_some(value)
501}
502
503fn midpoint(bid: f64, ask: f64) -> Option<f64> {
504    if bid > 0.0 && ask > 0.0 {
505        Some((bid + ask) / 2.0)
506    } else {
507        None
508    }
509}
510
511fn midpoint_from_options(bid: Option<f64>, ask: Option<f64>) -> Option<f64> {
512    match (bid, ask) {
513        (Some(bid), Some(ask)) if bid > 0.0 && ask > 0.0 => Some((bid + ask) / 2.0),
514        _ => None,
515    }
516}
517
518fn raw_contract_units_to_human_contracts(size_contract_units_raw: f64) -> Option<f64> {
519    (size_contract_units_raw > 0.0).then_some(size_contract_units_raw / CONTRACT_UNIT_MULTIPLIER)
520}
521
522fn resolve_underlying_price(
523    all_spot_prices: &HashMap<String, f64>,
524    underlying: &str,
525) -> Result<f64> {
526    all_spot_prices
527        .get(underlying)
528        .copied()
529        .or_else(|| {
530            all_spot_prices
531                .get(&format!("{}-PERP", underlying))
532                .copied()
533        })
534        .ok_or_else(|| anyhow!("Missing spot price for underlying {}", underlying))
535}
536
537fn system_time_to_millis(value: SystemTime) -> Result<u64> {
538    Ok(value
539        .duration_since(SystemTime::UNIX_EPOCH)
540        .map_err(|e| anyhow!("Invalid snapshot build time: {}", e))?
541        .as_millis() as u64)
542}
543
544#[derive(Serialize)]
545struct PublishedOptionsSummarySnapshotRef<'a> {
546    schema_version: u32,
547    built_at_ms: u64,
548    payload: &'a BTreeMap<String, Vec<OptionSummary>>,
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use rust_decimal_macros::dec;
555
556    #[test]
557    fn resolve_option_summary_quotes_keeps_orderbook_and_indicative_separate() {
558        let orderbook = SnapshotBookQuote {
559            best_bid: Some(100.0),
560            best_bid_size: Some(2_000_000.0),
561            best_ask: Some(110.0),
562            best_ask_size: Some(3_000_000.0),
563            mid: Some(105.0),
564            bids: Vec::new(),
565            asks: Vec::new(),
566        };
567        let indicative = AggregatedIndicativeQuote {
568            instrument: "BTC-20260424-75000-C".to_string(),
569            best_bid: dec!(120),
570            best_ask: dec!(130),
571            indicative_bid_size: dec!(4000000),
572            indicative_ask_size: dec!(5000000),
573            num_providers: 2,
574            updated_at: 0,
575        };
576
577        let resolved = resolve_option_summary_quotes(Some(&orderbook), Some(&indicative));
578
579        assert_eq!(resolved.orderbook_bid_price, 100.0);
580        assert_eq!(resolved.orderbook_ask_price, 110.0);
581        assert_eq!(resolved.orderbook_bid_size, Some(2.0));
582        assert_eq!(resolved.orderbook_ask_size, Some(3.0));
583        assert_eq!(resolved.indicative_bid_price, Some(120.0));
584        assert_eq!(resolved.indicative_ask_price, Some(130.0));
585        assert_eq!(resolved.indicative_bid_size, Some(4.0));
586        assert_eq!(resolved.indicative_ask_size, Some(5.0));
587        assert_eq!(resolved.reference_bid_price, 100.0);
588        assert_eq!(resolved.bid_quote_price, Some(100.0));
589        assert_eq!(resolved.ask_quote_price, Some(110.0));
590        assert_eq!(resolved.mark_price, 105.0);
591        assert_eq!(resolved.mid_price, 105.0);
592    }
593
594    #[test]
595    fn resolve_option_summary_quotes_uncrosses_mixed_source_market() {
596        let orderbook = SnapshotBookQuote {
597            best_bid: Some(2343.8),
598            best_bid_size: Some(1_000_000.0),
599            best_ask: None,
600            best_ask_size: None,
601            mid: None,
602            bids: Vec::new(),
603            asks: Vec::new(),
604        };
605        let indicative = AggregatedIndicativeQuote {
606            instrument: "BTC-20260529-79000-C".to_string(),
607            best_bid: dec!(660),
608            best_ask: dec!(732),
609            indicative_bid_size: dec!(2000000),
610            indicative_ask_size: dec!(3000000),
611            num_providers: 1,
612            updated_at: 0,
613        };
614
615        let resolved = resolve_option_summary_quotes(Some(&orderbook), Some(&indicative));
616
617        assert!(
618            resolved.bid_quote_price.unwrap() <= resolved.ask_quote_price.unwrap(),
619            "bid_quote_price ({:?}) must not exceed ask_quote_price ({:?})",
620            resolved.bid_quote_price,
621            resolved.ask_quote_price,
622        );
623        assert_eq!(resolved.bid_quote_price, Some(660.0));
624        assert_eq!(resolved.ask_quote_price, Some(732.0));
625    }
626
627    #[test]
628    fn resolve_option_summary_quotes_falls_back_to_indicative_when_book_missing() {
629        let indicative = AggregatedIndicativeQuote {
630            instrument: "BTC-20260424-75000-C".to_string(),
631            best_bid: dec!(98),
632            best_ask: dec!(102),
633            indicative_bid_size: dec!(2000000),
634            indicative_ask_size: dec!(3000000),
635            num_providers: 1,
636            updated_at: 0,
637        };
638
639        let resolved = resolve_option_summary_quotes(None, Some(&indicative));
640
641        assert_eq!(resolved.orderbook_bid_price, 0.0);
642        assert_eq!(resolved.orderbook_ask_price, 0.0);
643        assert_eq!(resolved.indicative_bid_price, Some(98.0));
644        assert_eq!(resolved.indicative_ask_price, Some(102.0));
645        assert_eq!(resolved.bid_quote_price, Some(98.0));
646        assert_eq!(resolved.ask_quote_price, Some(102.0));
647        assert_eq!(resolved.reference_bid_price, 98.0);
648        assert_eq!(resolved.mark_price, 100.0);
649        assert_eq!(resolved.mid_price, 100.0);
650    }
651}
652
653#[cfg(test)]
654mod underlying_price_tests {
655    use super::resolve_underlying_price;
656    use std::collections::HashMap;
657
658    #[test]
659    fn resolve_underlying_price_prefers_spot_symbol() {
660        let prices = HashMap::from([
661            ("BTC".to_string(), 101_000.0),
662            ("BTC-PERP".to_string(), 99_000.0),
663        ]);
664
665        let price = resolve_underlying_price(&prices, "BTC").unwrap();
666
667        assert_eq!(price, 101_000.0);
668    }
669
670    #[test]
671    fn resolve_underlying_price_falls_back_to_perp_symbol() {
672        let prices = HashMap::from([("ETH-PERP".to_string(), 2_000.0)]);
673
674        let price = resolve_underlying_price(&prices, "ETH").unwrap();
675
676        assert_eq!(price, 2_000.0);
677    }
678
679    #[test]
680    fn resolve_underlying_price_errors_when_missing() {
681        let prices = HashMap::new();
682
683        let error = resolve_underlying_price(&prices, "SOL").unwrap_err();
684
685        assert!(error
686            .to_string()
687            .contains("Missing spot price for underlying SOL"));
688    }
689}