Skip to main content

px_core/models/
orderbook.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use smallvec::SmallVec;
4
5// ---------------------------------------------------------------------------
6// FixedPrice — integer-backed price for orderbook hot paths
7// ---------------------------------------------------------------------------
8
9/// Fixed-point price representation. 1 tick = 0.0001 (scale factor 10,000).
10/// Eliminates f64 comparison issues (no PRICE_EPSILON), enables `Ord` (no NaN),
11/// and uses integer arithmetic (1-5ns vs 20-100ns for f64 ops).
12///
13/// Serializes as f64 on the wire for JSON backward compatibility.
14#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct FixedPrice(u64);
16
17impl FixedPrice {
18    pub const SCALE: u64 = 10_000;
19    pub const ZERO: Self = Self(0);
20    pub const ONE: Self = Self(Self::SCALE);
21
22    #[inline]
23    pub fn from_f64(price: f64) -> Self {
24        Self((price * Self::SCALE as f64).round() as u64)
25    }
26
27    #[inline]
28    pub fn to_f64(self) -> f64 {
29        self.0 as f64 / Self::SCALE as f64
30    }
31
32    #[inline]
33    pub fn raw(self) -> u64 {
34        self.0
35    }
36
37    #[inline]
38    pub fn from_raw(raw: u64) -> Self {
39        Self(raw)
40    }
41
42    /// 1.0 - self, exact in fixed-point. Used for NO-side price inversion.
43    #[inline]
44    pub fn complement(self) -> Self {
45        Self(Self::SCALE.saturating_sub(self.0))
46    }
47
48    #[inline]
49    pub fn midpoint(self, other: Self) -> Self {
50        Self((self.0 + other.0) / 2)
51    }
52}
53
54impl std::fmt::Debug for FixedPrice {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "FixedPrice({})", self.to_f64())
57    }
58}
59
60impl std::fmt::Display for FixedPrice {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "{}", self.to_f64())
63    }
64}
65
66impl Default for FixedPrice {
67    fn default() -> Self {
68        Self::ZERO
69    }
70}
71
72impl From<f64> for FixedPrice {
73    #[inline]
74    fn from(v: f64) -> Self {
75        Self::from_f64(v)
76    }
77}
78
79impl From<FixedPrice> for f64 {
80    #[inline]
81    fn from(v: FixedPrice) -> Self {
82        v.to_f64()
83    }
84}
85
86impl Serialize for FixedPrice {
87    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
88        serializer.serialize_f64(self.to_f64())
89    }
90}
91
92impl<'de> Deserialize<'de> for FixedPrice {
93    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
94        let v = f64::deserialize(deserializer)?;
95        Ok(Self::from_f64(v))
96    }
97}
98
99#[cfg(feature = "schema")]
100impl schemars::JsonSchema for FixedPrice {
101    fn schema_name() -> String {
102        "number".to_string()
103    }
104
105    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
106        f64::json_schema(gen)
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Orderbook types
112// ---------------------------------------------------------------------------
113
114/// Side of an orderbook level. Options: `bid`, `ask`.
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117#[serde(rename_all = "snake_case")]
118pub enum PriceLevelSide {
119    Bid,
120    Ask,
121}
122
123/// One price-level update; `size > 0` sets the level, `size == 0` removes it.
124#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126pub struct PriceLevelChange {
127    /// Which side. Options: `bid`, `ask`.
128    pub side: PriceLevelSide,
129    /// Price as YES probability in `[0, 1]` (e.g. `0.62`).
130    pub price: FixedPrice,
131    /// New size at this price in contracts; `0` removes the level.
132    pub size: f64,
133}
134
135/// Stack-allocated change list. Kalshi = 1 change, Polymarket typically 1-3.
136/// Falls back to heap only if > 4 changes in a single update (rare).
137pub type ChangeVec = SmallVec<[PriceLevelChange; 4]>;
138
139/// A single resting orderbook level.
140#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct PriceLevel {
143    /// Price as YES probability in `[0, 1]` (e.g. `0.62`).
144    pub price: FixedPrice,
145    /// Resting size at this price in contracts (e.g. `100.0`).
146    pub size: f64,
147}
148
149impl PriceLevel {
150    #[inline]
151    pub fn new(price: f64, size: f64) -> Self {
152        Self {
153            price: FixedPrice::from_f64(price),
154            size,
155        }
156    }
157
158    #[inline]
159    pub fn with_fixed(price: FixedPrice, size: f64) -> Self {
160        Self { price, size }
161    }
162}
163
164/// Full-depth L2 orderbook for one asset.
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct Orderbook {
168    /// The orderable asset — Kalshi market ticker or Polymarket CTF token id (e.g. `"KXBTCD-25APR1517"`).
169    pub asset_id: String,
170    /// Bid levels, sorted descending by price.
171    pub bids: Vec<PriceLevel>,
172    /// Ask levels, sorted ascending by price.
173    pub asks: Vec<PriceLevel>,
174    /// Monotonic sequence id from upstream; `null` when not provided.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub last_update_id: Option<u64>,
177    /// Upstream snapshot time in UTC (e.g. `"2026-04-25T12:00:00Z"`).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub timestamp: Option<DateTime<Utc>>,
180    /// Polymarket book-state hash for replay integrity; `null` on Kalshi.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub hash: Option<String>,
183}
184
185impl Orderbook {
186    #[inline]
187    pub fn best_bid(&self) -> Option<f64> {
188        self.bids.first().map(|l| l.price.to_f64())
189    }
190
191    #[inline]
192    pub fn best_ask(&self) -> Option<f64> {
193        self.asks.first().map(|l| l.price.to_f64())
194    }
195
196    #[inline]
197    pub fn mid_price(&self) -> Option<f64> {
198        match (self.bids.first(), self.asks.first()) {
199            (Some(bid), Some(ask)) => Some(bid.price.midpoint(ask.price).to_f64()),
200            _ => None,
201        }
202    }
203
204    #[inline]
205    pub fn spread(&self) -> Option<f64> {
206        match (self.bids.first(), self.asks.first()) {
207            (Some(bid), Some(ask)) => Some(ask.price.to_f64() - bid.price.to_f64()),
208            _ => None,
209        }
210    }
211
212    #[inline]
213    pub fn has_data(&self) -> bool {
214        !self.bids.is_empty() && !self.asks.is_empty()
215    }
216
217    /// Sort bids descending and asks ascending by price
218    pub fn sort(&mut self) {
219        sort_bids(&mut self.bids);
220        sort_asks(&mut self.asks);
221    }
222
223    pub fn from_rest_response(
224        bids: &[RestPriceLevel],
225        asks: &[RestPriceLevel],
226        asset_id: impl Into<String>,
227    ) -> Self {
228        let mut parsed_bids: Vec<PriceLevel> = bids
229            .iter()
230            .filter_map(|b| {
231                let price = b.price.parse::<f64>().ok()?;
232                let size = b.size.parse::<f64>().ok()?;
233                if price > 0.0 && size > 0.0 {
234                    Some(PriceLevel::new(price, size))
235                } else {
236                    None
237                }
238            })
239            .collect();
240
241        let mut parsed_asks: Vec<PriceLevel> = asks
242            .iter()
243            .filter_map(|a| {
244                let price = a.price.parse::<f64>().ok()?;
245                let size = a.size.parse::<f64>().ok()?;
246                if price > 0.0 && size > 0.0 {
247                    Some(PriceLevel::new(price, size))
248                } else {
249                    None
250                }
251            })
252            .collect();
253
254        sort_bids(&mut parsed_bids);
255        sort_asks(&mut parsed_asks);
256
257        Self {
258            asset_id: asset_id.into(),
259            bids: parsed_bids,
260            asks: parsed_asks,
261            last_update_id: None,
262            timestamp: Some(Utc::now()),
263            hash: None,
264        }
265    }
266}
267
268/// Sort price levels in descending order (highest price first) -- bid side ordering.
269/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
270pub fn sort_bids(levels: &mut [PriceLevel]) {
271    levels.sort_unstable_by_key(|l| std::cmp::Reverse(l.price));
272}
273
274/// Sort price levels in ascending order (lowest price first) -- ask side ordering.
275/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
276pub fn sort_asks(levels: &mut [PriceLevel]) {
277    levels.sort_unstable_by_key(|l| l.price);
278}
279
280/// Insert a price level into a bid-sorted (descending) list.
281/// Binary-search for the insert position (O(log n)), then Vec::insert
282/// (O(n) memcpy shift on average). Net O(log n + n) per op vs the old
283/// push+sort's O(n log n). Equal-price entries go AFTER existing entries.
284#[inline]
285pub fn insert_bid(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
286    let idx = levels.partition_point(|l| l.price > level.price);
287    levels.insert(idx, level);
288}
289
290/// Insert a price level into an ask-sorted (ascending) list.
291/// Binary-search + Vec::insert; same complexity profile as `insert_bid`.
292#[inline]
293pub fn insert_ask(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
294    let idx = levels.partition_point(|l| l.price < level.price);
295    levels.insert(idx, level);
296}
297
298/// Apply a price-level delta to a bid-sorted list with replace-or-insert
299/// semantics (matches the behaviour of a sorted associative map):
300///   - `size > 0.0` and price exists: replace in place (O(log n)).
301///   - `size > 0.0` and price is new: insert at sorted position (O(log n + n)).
302///   - `size == 0.0`: remove the level if present (no-op otherwise).
303#[inline]
304pub fn apply_bid_level(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
305    match levels.binary_search_by(|l| level.price.cmp(&l.price)) {
306        Ok(idx) => {
307            if level.size > 0.0 {
308                levels[idx] = level;
309            } else {
310                levels.remove(idx);
311            }
312        }
313        Err(idx) => {
314            if level.size > 0.0 {
315                levels.insert(idx, level);
316            }
317        }
318    }
319}
320
321/// See `apply_bid_level`. Same semantics, ascending ordering.
322#[inline]
323pub fn apply_ask_level(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
324    match levels.binary_search_by(|l| l.price.cmp(&level.price)) {
325        Ok(idx) => {
326            if level.size > 0.0 {
327                levels[idx] = level;
328            } else {
329                levels.remove(idx);
330            }
331        }
332        Err(idx) => {
333            if level.size > 0.0 {
334                levels.insert(idx, level);
335            }
336        }
337    }
338}
339
340/// Apply a *signed delta* at price `fp` to a bid-sorted list. Returns the
341/// resulting absolute size at `fp` (`0.0` when removed or never present).
342/// Used by exchanges whose WS payloads carry deltas rather than absolute
343/// sizes (Kalshi `orderbook_delta`); Polymarket-style absolute updates
344/// keep using `apply_bid_level` / `apply_ask_level`. Binary-search-based,
345/// O(log n) lookup + O(n) shift on insert/remove.
346#[inline]
347pub fn apply_bid_delta(levels: &mut Vec<PriceLevel>, fp: FixedPrice, delta: f64) -> f64 {
348    match levels.binary_search_by(|l| fp.cmp(&l.price)) {
349        Ok(idx) => {
350            let new_size = levels[idx].size + delta;
351            if new_size <= 0.0 {
352                levels.remove(idx);
353                0.0
354            } else {
355                levels[idx].size = new_size;
356                new_size
357            }
358        }
359        Err(idx) => {
360            if delta > 0.0 {
361                levels.insert(idx, PriceLevel::with_fixed(fp, delta));
362                delta
363            } else {
364                0.0
365            }
366        }
367    }
368}
369
370/// See `apply_bid_delta`. Same semantics, ascending ordering.
371#[inline]
372pub fn apply_ask_delta(levels: &mut Vec<PriceLevel>, fp: FixedPrice, delta: f64) -> f64 {
373    match levels.binary_search_by(|l| l.price.cmp(&fp)) {
374        Ok(idx) => {
375            let new_size = levels[idx].size + delta;
376            if new_size <= 0.0 {
377                levels.remove(idx);
378                0.0
379            } else {
380                levels[idx].size = new_size;
381                new_size
382            }
383        }
384        Err(idx) => {
385            if delta > 0.0 {
386                levels.insert(idx, PriceLevel::with_fixed(fp, delta));
387                delta
388            } else {
389                0.0
390            }
391        }
392    }
393}
394
395#[derive(Debug, Clone, Deserialize)]
396pub struct RestPriceLevel {
397    pub price: String,
398    pub size: String,
399}