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/// Bid or ask side. Serializes as "bid"/"ask" on the wire.
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/// A single price level change. Absolute replacement semantics:
124/// size > 0 = set level to this size, size == 0 = remove level.
125#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct PriceLevelChange {
128    pub side: PriceLevelSide,
129    pub price: FixedPrice,
130    pub size: f64,
131}
132
133/// Stack-allocated change list. Kalshi = 1 change, Polymarket typically 1-3.
134/// Falls back to heap only if > 4 changes in a single update (rare).
135pub type ChangeVec = SmallVec<[PriceLevelChange; 4]>;
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139pub struct PriceLevel {
140    pub price: FixedPrice,
141    pub size: f64,
142}
143
144impl PriceLevel {
145    #[inline]
146    pub fn new(price: f64, size: f64) -> Self {
147        Self {
148            price: FixedPrice::from_f64(price),
149            size,
150        }
151    }
152
153    #[inline]
154    pub fn with_fixed(price: FixedPrice, size: f64) -> Self {
155        Self { price, size }
156    }
157}
158
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161pub struct Orderbook {
162    pub market_id: String,
163    pub asset_id: String,
164    pub bids: Vec<PriceLevel>,
165    pub asks: Vec<PriceLevel>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub last_update_id: Option<u64>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub timestamp: Option<DateTime<Utc>>,
170    /// Exchange-provided hash for verifying book state integrity during replay.
171    /// Polymarket: present on `book` snapshot events.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub hash: Option<String>,
174}
175
176impl Orderbook {
177    #[inline]
178    pub fn best_bid(&self) -> Option<f64> {
179        self.bids.first().map(|l| l.price.to_f64())
180    }
181
182    #[inline]
183    pub fn best_ask(&self) -> Option<f64> {
184        self.asks.first().map(|l| l.price.to_f64())
185    }
186
187    #[inline]
188    pub fn mid_price(&self) -> Option<f64> {
189        match (self.bids.first(), self.asks.first()) {
190            (Some(bid), Some(ask)) => Some(bid.price.midpoint(ask.price).to_f64()),
191            _ => None,
192        }
193    }
194
195    #[inline]
196    pub fn spread(&self) -> Option<f64> {
197        match (self.bids.first(), self.asks.first()) {
198            (Some(bid), Some(ask)) => Some(ask.price.to_f64() - bid.price.to_f64()),
199            _ => None,
200        }
201    }
202
203    #[inline]
204    pub fn has_data(&self) -> bool {
205        !self.bids.is_empty() && !self.asks.is_empty()
206    }
207
208    /// Sort bids descending and asks ascending by price
209    pub fn sort(&mut self) {
210        sort_bids(&mut self.bids);
211        sort_asks(&mut self.asks);
212    }
213
214    pub fn from_rest_response(
215        bids: &[RestPriceLevel],
216        asks: &[RestPriceLevel],
217        asset_id: impl Into<String>,
218    ) -> Self {
219        let mut parsed_bids: Vec<PriceLevel> = bids
220            .iter()
221            .filter_map(|b| {
222                let price = b.price.parse::<f64>().ok()?;
223                let size = b.size.parse::<f64>().ok()?;
224                if price > 0.0 && size > 0.0 {
225                    Some(PriceLevel::new(price, size))
226                } else {
227                    None
228                }
229            })
230            .collect();
231
232        let mut parsed_asks: Vec<PriceLevel> = asks
233            .iter()
234            .filter_map(|a| {
235                let price = a.price.parse::<f64>().ok()?;
236                let size = a.size.parse::<f64>().ok()?;
237                if price > 0.0 && size > 0.0 {
238                    Some(PriceLevel::new(price, size))
239                } else {
240                    None
241                }
242            })
243            .collect();
244
245        sort_bids(&mut parsed_bids);
246        sort_asks(&mut parsed_asks);
247
248        Self {
249            market_id: String::new(),
250            asset_id: asset_id.into(),
251            bids: parsed_bids,
252            asks: parsed_asks,
253            last_update_id: None,
254            timestamp: Some(Utc::now()),
255            hash: None,
256        }
257    }
258}
259
260/// A point-in-time L2 orderbook snapshot, used for historical orderbook data.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
263pub struct OrderbookSnapshot {
264    pub timestamp: DateTime<Utc>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub recorded_at: Option<DateTime<Utc>>,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub hash: Option<String>,
269    pub bids: Vec<PriceLevel>,
270    pub asks: Vec<PriceLevel>,
271}
272
273/// Sort price levels in descending order (highest price first) -- bid side ordering.
274/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
275pub fn sort_bids(levels: &mut [PriceLevel]) {
276    levels.sort_unstable_by_key(|l| std::cmp::Reverse(l.price));
277}
278
279/// Sort price levels in ascending order (lowest price first) -- ask side ordering.
280/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
281pub fn sort_asks(levels: &mut [PriceLevel]) {
282    levels.sort_unstable_by_key(|l| l.price);
283}
284
285/// Insert a price level into a bid-sorted (descending) list.
286/// Binary-search for the insert position (O(log n)), then Vec::insert
287/// (O(n) memcpy shift on average). Net O(log n + n) per op vs the old
288/// push+sort's O(n log n). Equal-price entries go AFTER existing entries.
289#[inline]
290pub fn insert_bid(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
291    let idx = levels.partition_point(|l| l.price > level.price);
292    levels.insert(idx, level);
293}
294
295/// Insert a price level into an ask-sorted (ascending) list.
296/// Binary-search + Vec::insert; same complexity profile as `insert_bid`.
297#[inline]
298pub fn insert_ask(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
299    let idx = levels.partition_point(|l| l.price < level.price);
300    levels.insert(idx, level);
301}
302
303/// Apply a price-level delta to a bid-sorted list with replace-or-insert
304/// semantics (matches the behaviour of a sorted associative map):
305///   - `size > 0.0` and price exists: replace in place (O(log n)).
306///   - `size > 0.0` and price is new: insert at sorted position (O(log n + n)).
307///   - `size == 0.0`: remove the level if present (no-op otherwise).
308pub fn apply_bid_level(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
309    match levels.binary_search_by(|l| level.price.cmp(&l.price)) {
310        Ok(idx) => {
311            if level.size > 0.0 {
312                levels[idx] = level;
313            } else {
314                levels.remove(idx);
315            }
316        }
317        Err(idx) => {
318            if level.size > 0.0 {
319                levels.insert(idx, level);
320            }
321        }
322    }
323}
324
325/// See `apply_bid_level`. Same semantics, ascending ordering.
326pub fn apply_ask_level(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
327    match levels.binary_search_by(|l| l.price.cmp(&level.price)) {
328        Ok(idx) => {
329            if level.size > 0.0 {
330                levels[idx] = level;
331            } else {
332                levels.remove(idx);
333            }
334        }
335        Err(idx) => {
336            if level.size > 0.0 {
337                levels.insert(idx, level);
338            }
339        }
340    }
341}
342
343#[derive(Debug, Clone, Deserialize)]
344pub struct RestPriceLevel {
345    pub price: String,
346    pub size: String,
347}