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