1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::models::Orderbook;
5
6const TOP_N_FOR_WEIGHTED: usize = 10;
7const SLOPE_MAX_LEVELS: usize = 20;
8const SLOPE_BPS_WINDOW: f64 = 200.0;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13pub struct OrderbookStats {
14 pub exchange_ts: Option<DateTime<Utc>>,
16 pub openpx_ts: DateTime<Utc>,
18 pub asset_id: String,
20 pub best_bid: Option<f64>,
22 pub best_ask: Option<f64>,
24 pub mid: Option<f64>,
26 pub spread_bps: Option<f64>,
28 pub weighted_mid: Option<f64>,
30 pub imbalance: Option<f64>,
32 pub bid_depth: f64,
34 pub ask_depth: f64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct OrderbookImpact {
42 pub exchange_ts: Option<DateTime<Utc>>,
44 pub openpx_ts: DateTime<Utc>,
46 pub asset_id: String,
48 pub size: f64,
50 pub mid: Option<f64>,
52 pub buy_avg_price: Option<f64>,
54 pub buy_slippage_bps: Option<f64>,
56 pub buy_fill_pct: f64,
58 pub sell_avg_price: Option<f64>,
60 pub sell_slippage_bps: Option<f64>,
62 pub sell_fill_pct: f64,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69pub struct OrderbookMicrostructure {
70 pub exchange_ts: Option<DateTime<Utc>>,
72 pub openpx_ts: DateTime<Utc>,
74 pub asset_id: String,
76 pub depth_buckets: DepthBuckets,
78 pub bid_slope: Option<f64>,
80 pub ask_slope: Option<f64>,
82 pub max_gap: MaxGap,
84 pub level_count: LevelCount,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91pub struct DepthBuckets {
92 pub bid_within_10bps: f64,
94 pub ask_within_10bps: f64,
96 pub bid_within_50bps: f64,
98 pub ask_within_50bps: f64,
100 pub bid_within_100bps: f64,
102 pub ask_within_100bps: f64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
109pub struct MaxGap {
110 pub bid_gap_bps: Option<f64>,
112 pub ask_gap_bps: Option<f64>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct LevelCount {
120 pub bids: u32,
122 pub asks: u32,
124}
125
126pub fn orderbook_stats(book: &Orderbook) -> OrderbookStats {
129 let best_bid = book.best_bid();
130 let best_ask = book.best_ask();
131 let mid = book.mid_price();
132
133 let spread_bps = match (best_bid, best_ask, mid) {
134 (Some(b), Some(a), Some(m)) if m > 0.0 => Some((a - b) / m * 10_000.0),
135 _ => None,
136 };
137
138 let q_b: f64 = book
139 .bids
140 .iter()
141 .take(TOP_N_FOR_WEIGHTED)
142 .map(|l| l.size)
143 .sum();
144 let q_a: f64 = book
145 .asks
146 .iter()
147 .take(TOP_N_FOR_WEIGHTED)
148 .map(|l| l.size)
149 .sum();
150 let total_top_n = q_b + q_a;
151
152 let weighted_mid = match (best_bid, best_ask) {
153 (Some(b), Some(a)) if total_top_n > 0.0 => Some((b * q_a + a * q_b) / total_top_n),
154 _ => None,
155 };
156
157 let imbalance = if total_top_n > 0.0 {
158 Some((q_b - q_a) / total_top_n)
159 } else {
160 None
161 };
162
163 let bid_depth: f64 = book.bids.iter().map(|l| l.size).sum();
164 let ask_depth: f64 = book.asks.iter().map(|l| l.size).sum();
165
166 OrderbookStats {
167 exchange_ts: book.timestamp,
168 openpx_ts: Utc::now(),
169 asset_id: book.asset_id.clone(),
170 best_bid,
171 best_ask,
172 mid,
173 spread_bps,
174 weighted_mid,
175 imbalance,
176 bid_depth,
177 ask_depth,
178 }
179}
180
181pub fn orderbook_impact(book: &Orderbook, size: f64) -> OrderbookImpact {
188 let mid = book.mid_price();
189 let (buy_avg, buy_fill) = walk_side(&book.asks, size);
190 let (sell_avg, sell_fill) = walk_side(&book.bids, size);
191
192 let buy_slippage_bps = match (buy_avg, mid) {
193 (Some(p), Some(m)) if m > 0.0 => Some((p - m).abs() / m * 10_000.0),
194 _ => None,
195 };
196 let sell_slippage_bps = match (sell_avg, mid) {
197 (Some(p), Some(m)) if m > 0.0 => Some((p - m).abs() / m * 10_000.0),
198 _ => None,
199 };
200
201 OrderbookImpact {
202 exchange_ts: book.timestamp,
203 openpx_ts: Utc::now(),
204 asset_id: book.asset_id.clone(),
205 size,
206 mid,
207 buy_avg_price: buy_avg,
208 buy_slippage_bps,
209 buy_fill_pct: pct(buy_fill, size),
210 sell_avg_price: sell_avg,
211 sell_slippage_bps,
212 sell_fill_pct: pct(sell_fill, size),
213 }
214}
215
216pub fn orderbook_microstructure(book: &Orderbook) -> OrderbookMicrostructure {
220 let mid = book.mid_price();
221
222 let depth_buckets = match mid {
223 Some(m) if m > 0.0 => DepthBuckets {
224 bid_within_10bps: cumulative_within(&book.bids, m, 10.0),
225 ask_within_10bps: cumulative_within(&book.asks, m, 10.0),
226 bid_within_50bps: cumulative_within(&book.bids, m, 50.0),
227 ask_within_50bps: cumulative_within(&book.asks, m, 50.0),
228 bid_within_100bps: cumulative_within(&book.bids, m, 100.0),
229 ask_within_100bps: cumulative_within(&book.asks, m, 100.0),
230 },
231 _ => DepthBuckets {
232 bid_within_10bps: 0.0,
233 ask_within_10bps: 0.0,
234 bid_within_50bps: 0.0,
235 ask_within_50bps: 0.0,
236 bid_within_100bps: 0.0,
237 ask_within_100bps: 0.0,
238 },
239 };
240
241 let bid_slope = mid.and_then(|m| slope(&book.bids, m));
242 let ask_slope = mid.and_then(|m| slope(&book.asks, m));
243
244 let max_gap = MaxGap {
245 bid_gap_bps: mid.and_then(|m| max_gap_bps(&book.bids, m)),
246 ask_gap_bps: mid.and_then(|m| max_gap_bps(&book.asks, m)),
247 };
248
249 OrderbookMicrostructure {
250 exchange_ts: book.timestamp,
251 openpx_ts: Utc::now(),
252 asset_id: book.asset_id.clone(),
253 depth_buckets,
254 bid_slope,
255 ask_slope,
256 max_gap,
257 level_count: LevelCount {
258 bids: book.bids.len() as u32,
259 asks: book.asks.len() as u32,
260 },
261 }
262}
263
264fn walk_side(levels: &[crate::models::PriceLevel], size: f64) -> (Option<f64>, f64) {
265 if size <= 0.0 || levels.is_empty() {
266 return (None, 0.0);
267 }
268 let mut filled = 0.0;
269 let mut notional = 0.0;
270 for l in levels {
271 let take = (size - filled).min(l.size);
272 notional += take * l.price.to_f64();
273 filled += take;
274 if filled >= size {
275 break;
276 }
277 }
278 if filled <= 0.0 {
279 (None, 0.0)
280 } else {
281 (Some(notional / filled), filled)
282 }
283}
284
285fn pct(filled: f64, size: f64) -> f64 {
286 if size <= 0.0 {
287 return 0.0;
288 }
289 (filled / size).min(1.0) * 100.0
290}
291
292fn cumulative_within(levels: &[crate::models::PriceLevel], mid: f64, bps_window: f64) -> f64 {
293 levels
294 .iter()
295 .take_while(|l| (l.price.to_f64() - mid).abs() / mid * 10_000.0 <= bps_window)
296 .map(|l| l.size)
297 .sum()
298}
299
300fn slope(levels: &[crate::models::PriceLevel], mid: f64) -> Option<f64> {
304 if mid <= 0.0 {
305 return None;
306 }
307 let mut points: Vec<(f64, f64)> = Vec::with_capacity(SLOPE_MAX_LEVELS);
308 let mut cum = 0.0;
309 for l in levels.iter().take(SLOPE_MAX_LEVELS) {
310 let dist_bps = (l.price.to_f64() - mid).abs() / mid * 10_000.0;
311 if dist_bps > SLOPE_BPS_WINDOW {
312 break;
313 }
314 cum += l.size;
315 points.push((dist_bps, cum));
316 }
317 if points.len() < 2 {
318 return None;
319 }
320 let n = points.len() as f64;
321 let mean_x = points.iter().map(|(x, _)| x).sum::<f64>() / n;
322 let mean_y = points.iter().map(|(_, y)| y).sum::<f64>() / n;
323 let mut num = 0.0;
324 let mut den = 0.0;
325 for (x, y) in &points {
326 num += (x - mean_x) * (y - mean_y);
327 den += (x - mean_x).powi(2);
328 }
329 if den == 0.0 {
330 None
331 } else {
332 Some(num / den)
333 }
334}
335
336fn max_gap_bps(levels: &[crate::models::PriceLevel], mid: f64) -> Option<f64> {
337 if mid <= 0.0 || levels.len() < 2 {
338 return None;
339 }
340 let mut max = 0.0_f64;
341 for w in levels.windows(2) {
342 let gap = (w[0].price.to_f64() - w[1].price.to_f64()).abs();
343 let bps = gap / mid * 10_000.0;
344 if bps > max {
345 max = bps;
346 }
347 }
348 Some(max)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::models::PriceLevel;
355
356 fn book(bids: Vec<(f64, f64)>, asks: Vec<(f64, f64)>) -> Orderbook {
357 Orderbook {
358 asset_id: "test-asset".into(),
359 bids: bids
360 .into_iter()
361 .map(|(p, s)| PriceLevel::new(p, s))
362 .collect(),
363 asks: asks
364 .into_iter()
365 .map(|(p, s)| PriceLevel::new(p, s))
366 .collect(),
367 last_update_id: None,
368 timestamp: None,
369 hash: None,
370 }
371 }
372
373 #[test]
374 fn stats_tight_book_around_half() {
375 let bids: Vec<(f64, f64)> = (0..10).map(|i| (0.49 - 0.001 * i as f64, 100.0)).collect();
376 let asks: Vec<(f64, f64)> = (0..10).map(|i| (0.51 + 0.001 * i as f64, 100.0)).collect();
377 let s = orderbook_stats(&book(bids, asks));
378 assert_eq!(s.best_bid, Some(0.49));
379 assert_eq!(s.best_ask, Some(0.51));
380 assert_eq!(s.mid, Some(0.50));
381 assert!((s.spread_bps.unwrap() - 400.0).abs() < 1e-6);
382 assert!((s.imbalance.unwrap()).abs() < 1e-9);
383 assert!((s.weighted_mid.unwrap() - 0.50).abs() < 1e-9);
384 assert!((s.bid_depth - 1000.0).abs() < 1e-9);
385 assert!((s.ask_depth - 1000.0).abs() < 1e-9);
386 }
387
388 #[test]
389 fn impact_skewed_book() {
390 let b = book(
391 vec![(0.49, 1000.0), (0.48, 1000.0), (0.47, 1000.0)],
392 vec![(0.51, 10.0)],
393 );
394 let s = orderbook_stats(&b);
395 assert!(s.imbalance.unwrap() > 0.9);
396
397 let small_buy = orderbook_impact(&b, 5.0);
398 assert!((small_buy.buy_fill_pct - 100.0).abs() < 1e-9);
399 assert_eq!(small_buy.buy_avg_price, Some(0.51));
400
401 let big_sell = orderbook_impact(&b, 5_000.0);
402 assert!(big_sell.sell_fill_pct < 100.0);
403 assert!(big_sell.sell_avg_price.is_some());
404
405 let oversize_buy = orderbook_impact(&b, 1_000.0);
406 assert!(oversize_buy.buy_fill_pct < 100.0);
407 }
408
409 #[test]
410 fn microstructure_single_level_each() {
411 let b = book(vec![(0.49, 100.0)], vec![(0.51, 100.0)]);
412 let m = orderbook_microstructure(&b);
413 assert_eq!(m.bid_slope, None);
414 assert_eq!(m.ask_slope, None);
415 assert_eq!(m.max_gap.bid_gap_bps, None);
416 assert_eq!(m.max_gap.ask_gap_bps, None);
417 assert_eq!(m.level_count.bids, 1);
418 assert_eq!(m.level_count.asks, 1);
419 }
420
421 #[test]
422 fn empty_one_side() {
423 let b = book(vec![(0.49, 100.0), (0.48, 50.0)], vec![]);
424 let s = orderbook_stats(&b);
425 assert_eq!(s.mid, None);
426 assert_eq!(s.spread_bps, None);
427 assert_eq!(s.weighted_mid, None);
428 assert!((s.bid_depth - 150.0).abs() < 1e-9);
429 assert!((s.ask_depth).abs() < 1e-9);
430
431 let i = orderbook_impact(&b, 50.0);
432 assert_eq!(i.buy_avg_price, None);
433 assert!((i.buy_fill_pct).abs() < 1e-9);
434 assert_eq!(i.sell_avg_price, Some(0.49));
435 assert!((i.sell_fill_pct - 100.0).abs() < 1e-9);
436 }
437
438 #[test]
439 fn microstructure_gappy_asks() {
440 let b = book(
441 vec![(0.49, 100.0)],
442 vec![(0.51, 100.0), (0.55, 100.0), (0.56, 100.0)],
443 );
444 let m = orderbook_microstructure(&b);
445 assert!((m.max_gap.ask_gap_bps.unwrap() - 800.0).abs() < 1e-6);
447
448 let i = orderbook_impact(&b, 500.0);
450 assert!(i.buy_fill_pct < 100.0);
451 assert!(i.buy_avg_price.is_some());
452 }
453}