hypercall/observability/metrics_collector/
market_quality.rs1use 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 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 "es {
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 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 let arbitrage_violations = Self::check_no_arbitrage_violations("es);
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 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 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}