Skip to main content

px_core/models/
orderbook_insights.rs

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/// Top-of-book snapshot stats for one asset.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13pub struct OrderbookStats {
14    /// Upstream snapshot time in UTC; `null` when not provided.
15    pub exchange_ts: Option<DateTime<Utc>>,
16    /// Wall-clock time OpenPX served the response (UTC).
17    pub openpx_ts: DateTime<Utc>,
18    /// Orderable asset id (e.g. `"KXBTCD-25APR1517"`).
19    pub asset_id: String,
20    /// Best bid as YES probability (e.g. `0.61`).
21    pub best_bid: Option<f64>,
22    /// Best ask as YES probability (e.g. `0.63`).
23    pub best_ask: Option<f64>,
24    /// Mid price as YES probability (e.g. `0.62`).
25    pub mid: Option<f64>,
26    /// Spread in basis points relative to mid (e.g. `400.0`).
27    pub spread_bps: Option<f64>,
28    /// Size-weighted mid using the top-10 levels (e.g. `0.62`).
29    pub weighted_mid: Option<f64>,
30    /// Top-10 imbalance in `[-1, 1]` (positive = bid-heavy) (e.g. `0.12`).
31    pub imbalance: Option<f64>,
32    /// Total resting bid size in contracts (e.g. `1000.0`).
33    pub bid_depth: f64,
34    /// Total resting ask size in contracts (e.g. `1000.0`).
35    pub ask_depth: f64,
36}
37
38/// Slippage curve at one requested size.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct OrderbookImpact {
42    /// Upstream snapshot time in UTC; `null` when not provided.
43    pub exchange_ts: Option<DateTime<Utc>>,
44    /// Wall-clock time OpenPX served the response (UTC).
45    pub openpx_ts: DateTime<Utc>,
46    /// Orderable asset id (e.g. `"KXBTCD-25APR1517"`).
47    pub asset_id: String,
48    /// Requested order size in contracts (e.g. `100.0`).
49    pub size: f64,
50    /// Mid price as YES probability (e.g. `0.62`).
51    pub mid: Option<f64>,
52    /// Average fill price hitting asks (e.g. `0.625`).
53    pub buy_avg_price: Option<f64>,
54    /// Buy-side slippage vs mid in basis points (e.g. `80.0`).
55    pub buy_slippage_bps: Option<f64>,
56    /// Buy-side fill percentage in `[0, 100]` (e.g. `100.0`).
57    pub buy_fill_pct: f64,
58    /// Average fill price hitting bids (e.g. `0.615`).
59    pub sell_avg_price: Option<f64>,
60    /// Sell-side slippage vs mid in basis points (e.g. `80.0`).
61    pub sell_slippage_bps: Option<f64>,
62    /// Sell-side fill percentage in `[0, 100]` (e.g. `100.0`).
63    pub sell_fill_pct: f64,
64}
65
66/// Microstructure signals for one orderbook.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69pub struct OrderbookMicrostructure {
70    /// Upstream snapshot time in UTC; `null` when not provided.
71    pub exchange_ts: Option<DateTime<Utc>>,
72    /// Wall-clock time OpenPX served the response (UTC).
73    pub openpx_ts: DateTime<Utc>,
74    /// Orderable asset id (e.g. `"KXBTCD-25APR1517"`).
75    pub asset_id: String,
76    /// Cumulative depth at 10/50/100 bps from mid.
77    pub depth_buckets: DepthBuckets,
78    /// OLS slope of cumulative bid size vs distance-from-mid (e.g. `12.5`).
79    pub bid_slope: Option<f64>,
80    /// OLS slope of cumulative ask size vs distance-from-mid (e.g. `12.5`).
81    pub ask_slope: Option<f64>,
82    /// Largest consecutive-level price gap on each side, in basis points.
83    pub max_gap: MaxGap,
84    /// Number of levels per side.
85    pub level_count: LevelCount,
86}
87
88/// Cumulative depth at 10/50/100 bps tiers from mid.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91pub struct DepthBuckets {
92    /// Cumulative bid size within 10 bps of mid (contracts).
93    pub bid_within_10bps: f64,
94    /// Cumulative ask size within 10 bps of mid (contracts).
95    pub ask_within_10bps: f64,
96    /// Cumulative bid size within 50 bps of mid (contracts).
97    pub bid_within_50bps: f64,
98    /// Cumulative ask size within 50 bps of mid (contracts).
99    pub ask_within_50bps: f64,
100    /// Cumulative bid size within 100 bps of mid (contracts).
101    pub bid_within_100bps: f64,
102    /// Cumulative ask size within 100 bps of mid (contracts).
103    pub ask_within_100bps: f64,
104}
105
106/// Largest consecutive-level price gap per side.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
109pub struct MaxGap {
110    /// Max bid-side gap in basis points (e.g. `25.0`).
111    pub bid_gap_bps: Option<f64>,
112    /// Max ask-side gap in basis points (e.g. `25.0`).
113    pub ask_gap_bps: Option<f64>,
114}
115
116/// Per-side resting-level counts.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct LevelCount {
120    /// Number of bid levels (e.g. `12`).
121    pub bids: u32,
122    /// Number of ask levels (e.g. `12`).
123    pub asks: u32,
124}
125
126/// Snapshot stats: top-of-book, weighted mid, imbalance, total depth.
127/// Pure function over the unified orderbook — no upstream calls.
128pub 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
181/// Slippage curve at a single requested size. Walks both sides of the book
182/// (asks ascending for buy, bids descending for sell) consuming levels until
183/// `size` is filled or the side exhausts.
184///
185/// Note: `bps` are mid-relative; interpretability degrades for prices near 0
186/// or 1, where small absolute moves represent very large bps.
187pub 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
216/// Microstructure signals: cumulative depth at standard bps tiers, slope of
217/// cumulative size vs distance-from-mid, largest consecutive-level gap, and
218/// raw level counts per side.
219pub 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
300/// OLS slope of cumulative size (y) vs distance-from-mid in bps (x), over the
301/// closer of: top SLOPE_MAX_LEVELS levels, or all levels within
302/// SLOPE_BPS_WINDOW bps. Returns `None` if fewer than 2 points qualify.
303fn 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        // mid = 0.50; ask gap from 0.51 -> 0.55 = 0.04; bps = 0.04/0.50 * 10_000 = 800.
446        assert!((m.max_gap.ask_gap_bps.unwrap() - 800.0).abs() < 1e-6);
447
448        // oversize buy: total ask depth = 300, request 500 → partial.
449        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}