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/// Emitted by exchange WS implementations through OrderbookStream.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub enum OrderbookUpdate {
140    /// Full orderbook snapshot (initial connect, reconnect).
141    Snapshot(Orderbook),
142    /// Incremental change. Changes only — NO full book clone.
143    /// WsManager maintains its own cached book and applies changes in-place.
144    Delta {
145        changes: ChangeVec,
146        timestamp: Option<DateTime<Utc>>,
147    },
148    /// Connection was lost and re-established. All orderbook state is potentially stale.
149    /// The next Snapshot for each market is a full reset, not a continuation.
150    Reconnected,
151}
152
153#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct PriceLevel {
156    pub price: FixedPrice,
157    pub size: f64,
158}
159
160impl PriceLevel {
161    #[inline]
162    pub fn new(price: f64, size: f64) -> Self {
163        Self {
164            price: FixedPrice::from_f64(price),
165            size,
166        }
167    }
168
169    #[inline]
170    pub fn with_fixed(price: FixedPrice, size: f64) -> Self {
171        Self { price, size }
172    }
173}
174
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177pub struct Orderbook {
178    pub market_id: String,
179    pub asset_id: String,
180    pub bids: Vec<PriceLevel>,
181    pub asks: Vec<PriceLevel>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub last_update_id: Option<u64>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub timestamp: Option<DateTime<Utc>>,
186    /// Exchange-provided hash for verifying book state integrity during replay.
187    /// Polymarket: present on `book` snapshot events.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub hash: Option<String>,
190}
191
192impl Orderbook {
193    #[inline]
194    pub fn best_bid(&self) -> Option<f64> {
195        self.bids.first().map(|l| l.price.to_f64())
196    }
197
198    #[inline]
199    pub fn best_ask(&self) -> Option<f64> {
200        self.asks.first().map(|l| l.price.to_f64())
201    }
202
203    #[inline]
204    pub fn mid_price(&self) -> Option<f64> {
205        match (self.bids.first(), self.asks.first()) {
206            (Some(bid), Some(ask)) => Some(bid.price.midpoint(ask.price).to_f64()),
207            _ => None,
208        }
209    }
210
211    #[inline]
212    pub fn spread(&self) -> Option<f64> {
213        match (self.bids.first(), self.asks.first()) {
214            (Some(bid), Some(ask)) => Some(ask.price.to_f64() - bid.price.to_f64()),
215            _ => None,
216        }
217    }
218
219    #[inline]
220    pub fn has_data(&self) -> bool {
221        !self.bids.is_empty() && !self.asks.is_empty()
222    }
223
224    /// Sort bids descending and asks ascending by price
225    pub fn sort(&mut self) {
226        sort_bids(&mut self.bids);
227        sort_asks(&mut self.asks);
228    }
229
230    pub fn from_rest_response(
231        bids: &[RestPriceLevel],
232        asks: &[RestPriceLevel],
233        asset_id: impl Into<String>,
234    ) -> Self {
235        let mut parsed_bids: Vec<PriceLevel> = bids
236            .iter()
237            .filter_map(|b| {
238                let price = b.price.parse::<f64>().ok()?;
239                let size = b.size.parse::<f64>().ok()?;
240                if price > 0.0 && size > 0.0 {
241                    Some(PriceLevel::new(price, size))
242                } else {
243                    None
244                }
245            })
246            .collect();
247
248        let mut parsed_asks: Vec<PriceLevel> = asks
249            .iter()
250            .filter_map(|a| {
251                let price = a.price.parse::<f64>().ok()?;
252                let size = a.size.parse::<f64>().ok()?;
253                if price > 0.0 && size > 0.0 {
254                    Some(PriceLevel::new(price, size))
255                } else {
256                    None
257                }
258            })
259            .collect();
260
261        sort_bids(&mut parsed_bids);
262        sort_asks(&mut parsed_asks);
263
264        Self {
265            market_id: String::new(),
266            asset_id: asset_id.into(),
267            bids: parsed_bids,
268            asks: parsed_asks,
269            last_update_id: None,
270            timestamp: Some(Utc::now()),
271            hash: None,
272        }
273    }
274}
275
276/// A point-in-time L2 orderbook snapshot, used for historical orderbook data.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
279pub struct OrderbookSnapshot {
280    pub timestamp: DateTime<Utc>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub recorded_at: Option<DateTime<Utc>>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub hash: Option<String>,
285    pub bids: Vec<PriceLevel>,
286    pub asks: Vec<PriceLevel>,
287}
288
289/// Sort price levels in descending order (highest price first) -- bid side ordering.
290/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
291pub fn sort_bids(levels: &mut [PriceLevel]) {
292    levels.sort_unstable_by(|a, b| b.price.cmp(&a.price));
293}
294
295/// Sort price levels in ascending order (lowest price first) -- ask side ordering.
296/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
297pub fn sort_asks(levels: &mut [PriceLevel]) {
298    levels.sort_unstable_by(|a, b| a.price.cmp(&b.price));
299}
300
301/// Insert a price level into a bid-sorted (descending) list.
302/// Uses push + sort_unstable for prediction market books (typically < 100 levels).
303/// sort_unstable avoids the allocation of a merge-sort buffer and is faster
304/// on small, nearly-sorted arrays than Vec::insert's O(n) memcpy shift.
305#[inline]
306pub fn insert_bid(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
307    levels.push(level);
308    sort_bids(levels);
309}
310
311/// Insert a price level into an ask-sorted (ascending) list.
312/// Uses push + sort_unstable for prediction market books (typically < 100 levels).
313#[inline]
314pub fn insert_ask(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
315    levels.push(level);
316    sort_asks(levels);
317}
318
319#[derive(Debug, Clone, Deserialize)]
320pub struct RestPriceLevel {
321    pub price: String,
322    pub size: String,
323}