Skip to main content

hypercall/observability/metrics_collector/
market_quality.rs

1use super::*;
2
3type BookQuote = hypercall_runtime_api::SnapshotBookQuote;
4type NoArbGroupKey = (String, String, char);
5type NoArbPoint = (f64, Option<f64>, Option<f64>);
6
7#[derive(Default)]
8struct MarketQualitySnapshot {
9    total_spread_bps: f64,
10    markets_with_spread: i64,
11    total_bid_depth: f64,
12    total_ask_depth: f64,
13    crossed_orderbooks: i64,
14}
15
16impl MarketQualitySnapshot {
17    fn avg_spread_bps(&self) -> f64 {
18        if self.markets_with_spread > 0 {
19            self.total_spread_bps / self.markets_with_spread as f64
20        } else {
21            0.0
22        }
23    }
24
25    fn record_quote(&mut self, symbol: &str, quote: &BookQuote, snapshot_staleness: Duration) {
26        if let (Some(bid), Some(ask)) = (quote.best_bid, quote.best_ask) {
27            self.record_spread(symbol, quote, bid, ask, snapshot_staleness);
28        }
29
30        if let Some(size) = quote.best_bid_size {
31            self.total_bid_depth += size;
32        }
33        if let Some(size) = quote.best_ask_size {
34            self.total_ask_depth += size;
35        }
36    }
37
38    fn record_spread(
39        &mut self,
40        symbol: &str,
41        quote: &BookQuote,
42        bid: f64,
43        ask: f64,
44        snapshot_staleness: Duration,
45    ) {
46        if bid <= 0.0 || ask <= 0.0 {
47            return;
48        }
49
50        if bid >= ask {
51            self.crossed_orderbooks += 1;
52            log_crossed_orderbook(symbol, quote, bid, ask, snapshot_staleness);
53        }
54
55        let mid = (bid + ask) / 2.0;
56        let spread_bps = ((ask - bid) / mid) * 10_000.0;
57        self.total_spread_bps += spread_bps;
58        self.markets_with_spread += 1;
59
60        let underlying = symbol.split('-').next().unwrap_or("UNKNOWN");
61        gauge!("ht_spread_bps", "underlying" => underlying.to_string()).set(spread_bps);
62    }
63}
64
65impl MetricsCollector {
66    // ===== Market Quality Metrics =====
67
68    pub(super) async fn collect_market_quality_metrics(&self) {
69        let quotes = self.quote_provider.all_quotes();
70        let snapshot_staleness = self.quote_provider.staleness();
71        let stale_markets: i64 = if snapshot_staleness > Duration::from_secs(60) {
72            quotes.len() as i64
73        } else {
74            0
75        };
76        let mut snapshot = MarketQualitySnapshot::default();
77
78        for (symbol, quote) in &quotes {
79            snapshot.record_quote(symbol, quote, snapshot_staleness);
80        }
81
82        let avg_spread = snapshot.avg_spread_bps();
83        gauge!("ht_avg_spread_bps").set(avg_spread);
84        gauge!("ht_orderbook_bid_depth").set(snapshot.total_bid_depth);
85        gauge!("ht_orderbook_ask_depth").set(snapshot.total_ask_depth);
86        gauge!("ht_stale_markets").set(stale_markets as f64);
87        gauge!("ht_active_markets").set(quotes.len() as f64);
88
89        // CRITICAL INVARIANT: crossed orderbooks should ALWAYS be 0
90        gauge!("ht_crossed_orderbooks").set(snapshot.crossed_orderbooks as f64);
91
92        if snapshot.crossed_orderbooks > 0 {
93            warn!(
94                crossed_count = snapshot.crossed_orderbooks,
95                total_markets = quotes.len(),
96                "Market quality check found crossed orderbooks - see error logs for details"
97            );
98        }
99
100        // ===== No-Arbitrage Monotonicity Check =====
101        // Call asks must be non-increasing as strike increases.
102        // Call bids must be non-increasing as strike increases.
103        // Put asks must be non-decreasing as strike increases.
104        // Put bids must be non-decreasing as strike increases.
105        // Violations indicate free-money arbitrage on the book.
106        let arbitrage_violations = Self::check_no_arbitrage_violations(&quotes);
107
108        gauge!("ht_no_arbitrage_violations").set(arbitrage_violations as f64);
109
110        if arbitrage_violations > 0 {
111            error!(
112                violation_count = arbitrage_violations,
113                total_markets = quotes.len(),
114                "🚨 NO-ARBITRAGE VIOLATION: option prices violate monotonicity across strikes"
115            );
116        }
117
118        debug!(
119            "Market quality: {:.1} avg spread bps, {} stale markets, {} crossed, {} arb violations (snapshot age={}s)",
120            avg_spread,
121            stale_markets,
122            snapshot.crossed_orderbooks,
123            arbitrage_violations,
124            snapshot_staleness.as_secs()
125        );
126    }
127
128    /// Check for no-arbitrage monotonicity violations across the book's best prices.
129    ///
130    /// Groups instruments by (underlying, expiry, option_type) and verifies:
131    /// - Call best asks are non-increasing as strike increases
132    /// - Put best asks are non-decreasing as strike increases
133    ///
134    /// Returns the number of (underlying, expiry, type, side) groups that have violations.
135    fn check_no_arbitrage_violations(quotes: &HashMap<String, BookQuote>) -> i64 {
136        let mut groups: HashMap<NoArbGroupKey, Vec<NoArbPoint>> = HashMap::new();
137        for (symbol, quote) in quotes {
138            if let Some((group, point)) = parse_no_arb_point(symbol, quote) {
139                groups.entry(group).or_default().push(point);
140            }
141        }
142
143        groups
144            .into_iter()
145            .map(|(group, strikes)| count_group_violations(group, strikes))
146            .sum()
147    }
148}
149
150fn log_crossed_orderbook(
151    symbol: &str,
152    quote: &BookQuote,
153    bid: f64,
154    ask: f64,
155    snapshot_staleness: Duration,
156) {
157    let bids_preview: Vec<_> = quote.bids.iter().take(5).collect();
158    let asks_preview: Vec<_> = quote.asks.iter().take(5).collect();
159    error!(
160        symbol = %symbol,
161        best_bid = %bid,
162        best_ask = %ask,
163        spread = %(ask - bid),
164        snapshot_staleness_secs = snapshot_staleness.as_secs(),
165        bid_levels = quote.bids.len(),
166        ask_levels = quote.asks.len(),
167        bids_top5 = ?bids_preview,
168        asks_top5 = ?asks_preview,
169        "🚨 CROSSED ORDERBOOK: bid >= ask detected in snapshot"
170    );
171}
172
173fn parse_no_arb_point(symbol: &str, quote: &BookQuote) -> Option<(NoArbGroupKey, NoArbPoint)> {
174    // Parse symbol: "HYPE-20260327-683-C" -> (HYPE, 20260327, 683, C)
175    let mut parts = symbol.split('-');
176    let underlying = parts.next()?.to_string();
177    let expiry = parts.next()?.to_string();
178    let strike = parts.next()?.parse().ok()?;
179    let opt_type = match parts.next()?.chars().next()? {
180        c @ ('C' | 'P') => c,
181        _ => return None,
182    };
183    if parts.next().is_some() {
184        return None;
185    }
186
187    let bid = quote.best_bid.filter(|&b| b > 0.0);
188    let ask = quote.best_ask.filter(|&a| a > 0.0);
189    if bid.is_none() && ask.is_none() {
190        return None;
191    }
192
193    Some(((underlying, expiry, opt_type), (strike, bid, ask)))
194}
195
196fn count_group_violations(
197    (underlying, expiry, opt_type): NoArbGroupKey,
198    mut strikes: Vec<NoArbPoint>,
199) -> i64 {
200    if strikes.len() < 2 {
201        return 0;
202    }
203
204    strikes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
205
206    let mut has_ask_violation = false;
207    let mut has_bid_violation = false;
208    for pair in strikes.windows(2) {
209        let (prev_strike, prev_bid, prev_ask) = pair[0];
210        let (curr_strike, curr_bid, curr_ask) = pair[1];
211
212        has_ask_violation |= check_side_violation(
213            SideCheck {
214                previous: prev_ask,
215                current: curr_ask,
216                previous_strike: prev_strike,
217                current_strike: curr_strike,
218                opt_type,
219                side: "ask",
220            },
221            &underlying,
222            &expiry,
223            has_ask_violation,
224        );
225        has_bid_violation |= check_side_violation(
226            SideCheck {
227                previous: prev_bid,
228                current: curr_bid,
229                previous_strike: prev_strike,
230                current_strike: curr_strike,
231                opt_type,
232                side: "bid",
233            },
234            &underlying,
235            &expiry,
236            has_bid_violation,
237        );
238    }
239
240    i64::from(has_ask_violation) + i64::from(has_bid_violation)
241}
242
243struct SideCheck {
244    previous: Option<f64>,
245    current: Option<f64>,
246    previous_strike: f64,
247    current_strike: f64,
248    opt_type: char,
249    side: &'static str,
250}
251
252fn check_side_violation(
253    check: SideCheck,
254    underlying: &str,
255    expiry: &str,
256    already_logged: bool,
257) -> bool {
258    let Some(previous) = check.previous else {
259        return false;
260    };
261    let Some(current) = check.current else {
262        return false;
263    };
264
265    let violates = match check.opt_type {
266        'C' => current > previous,
267        'P' => current < previous,
268        _ => false,
269    };
270    if !violates {
271        return false;
272    }
273
274    if !already_logged {
275        let direction = if check.opt_type == 'C' {
276            "increases"
277        } else {
278            "decreases"
279        };
280        error!(
281            underlying = %underlying,
282            expiry = %expiry,
283            prev_strike = check.previous_strike,
284            curr_strike = check.current_strike,
285            previous_price = previous,
286            current_price = current,
287            side = check.side,
288            "No-arbitrage violation: option price {} with strike",
289            direction
290        );
291    }
292
293    true
294}