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}
149
150#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152pub struct PriceLevel {
153    pub price: FixedPrice,
154    pub size: f64,
155}
156
157impl PriceLevel {
158    #[inline]
159    pub fn new(price: f64, size: f64) -> Self {
160        Self {
161            price: FixedPrice::from_f64(price),
162            size,
163        }
164    }
165
166    #[inline]
167    pub fn with_fixed(price: FixedPrice, size: f64) -> Self {
168        Self { price, size }
169    }
170}
171
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct Orderbook {
175    pub market_id: String,
176    pub asset_id: String,
177    pub bids: Vec<PriceLevel>,
178    pub asks: Vec<PriceLevel>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub last_update_id: Option<u64>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub timestamp: Option<DateTime<Utc>>,
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            market_id: String::new(),
259            asset_id: asset_id.into(),
260            bids: parsed_bids,
261            asks: parsed_asks,
262            last_update_id: None,
263            timestamp: Some(Utc::now()),
264        }
265    }
266}
267
268/// A point-in-time L2 orderbook snapshot, used for historical orderbook data.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271pub struct OrderbookSnapshot {
272    pub timestamp: DateTime<Utc>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub recorded_at: Option<DateTime<Utc>>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub hash: Option<String>,
277    pub bids: Vec<PriceLevel>,
278    pub asks: Vec<PriceLevel>,
279}
280
281/// Sort price levels in descending order (highest price first) -- bid side ordering.
282/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
283pub fn sort_bids(levels: &mut [PriceLevel]) {
284    levels.sort_unstable_by(|a, b| b.price.cmp(&a.price));
285}
286
287/// Sort price levels in ascending order (lowest price first) -- ask side ordering.
288/// Uses integer comparison via FixedPrice::Ord (no partial_cmp/NaN handling).
289pub fn sort_asks(levels: &mut [PriceLevel]) {
290    levels.sort_unstable_by(|a, b| a.price.cmp(&b.price));
291}
292
293/// Insert a price level into a bid-sorted (descending) list.
294/// Uses push + sort_unstable for prediction market books (typically < 100 levels).
295/// sort_unstable avoids the allocation of a merge-sort buffer and is faster
296/// on small, nearly-sorted arrays than Vec::insert's O(n) memcpy shift.
297#[inline]
298pub fn insert_bid(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
299    levels.push(level);
300    sort_bids(levels);
301}
302
303/// Insert a price level into an ask-sorted (ascending) list.
304/// Uses push + sort_unstable for prediction market books (typically < 100 levels).
305#[inline]
306pub fn insert_ask(levels: &mut Vec<PriceLevel>, level: PriceLevel) {
307    levels.push(level);
308    sort_asks(levels);
309}
310
311#[derive(Debug, Clone, Deserialize)]
312pub struct RestPriceLevel {
313    pub price: String,
314    pub size: String,
315}