Skip to main content

paft_prediction/
market_data.rs

1//! Prediction-market quotes, order books, and trade-history payloads.
2
3use crate::error::PredictionError;
4use crate::instrument::{BinaryMarketKey, OutcomeInstrument};
5use crate::price::{NonZeroContractQuantity, OutcomePrice, PriceGrid};
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::{cmp::Reverse, num::NonZeroU32};
9
10/// YES/NO outcome side for binary prediction markets.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum BinaryOutcome {
14    /// YES outcome.
15    Yes,
16    /// NO outcome.
17    No,
18}
19
20impl BinaryOutcome {
21    /// Returns the opposite binary outcome.
22    #[must_use]
23    pub const fn complement(self) -> Self {
24        match self {
25            Self::Yes => Self::No,
26            Self::No => Self::Yes,
27        }
28    }
29}
30
31/// Bid/ask side of an order book.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum BookSide {
35    /// Bid side.
36    Bid,
37    /// Ask side.
38    Ask,
39}
40
41/// Buy/sell trade or order action.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum TradeAction {
45    /// Buy action.
46    Buy,
47    /// Sell action.
48    Sell,
49}
50
51/// Explicit binary order direction.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub struct BinaryOrderDirection {
54    /// Buy/sell action.
55    pub action: TradeAction,
56    /// YES/NO outcome being bought or sold.
57    pub outcome: BinaryOutcome,
58}
59
60impl BinaryOrderDirection {
61    /// Buy YES.
62    #[must_use]
63    pub const fn buy_yes() -> Self {
64        Self {
65            action: TradeAction::Buy,
66            outcome: BinaryOutcome::Yes,
67        }
68    }
69
70    /// Sell YES.
71    #[must_use]
72    pub const fn sell_yes() -> Self {
73        Self {
74            action: TradeAction::Sell,
75            outcome: BinaryOutcome::Yes,
76        }
77    }
78
79    /// Buy NO.
80    #[must_use]
81    pub const fn buy_no() -> Self {
82        Self {
83            action: TradeAction::Buy,
84            outcome: BinaryOutcome::No,
85        }
86    }
87
88    /// Sell NO.
89    #[must_use]
90    pub const fn sell_no() -> Self {
91        Self {
92            action: TradeAction::Sell,
93            outcome: BinaryOutcome::No,
94        }
95    }
96}
97
98/// A single prediction-market book level.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub struct PredictionBookLevel {
101    /// Non-zero displayed quantity at this level.
102    pub quantity: NonZeroContractQuantity,
103    /// Outcome price at this level.
104    pub price: OutcomePrice,
105    /// Optional number of orders aggregated at this price level.
106    pub order_count: Option<NonZeroU32>,
107}
108
109impl PredictionBookLevel {
110    /// Build a book level with no order count.
111    #[must_use]
112    pub const fn new(price: OutcomePrice, quantity: NonZeroContractQuantity) -> Self {
113        Self {
114            quantity,
115            price,
116            order_count: None,
117        }
118    }
119
120    /// Return the same level with its price complemented around 1.0.
121    #[must_use]
122    pub const fn complement_price(self) -> Self {
123        Self {
124            quantity: self.quantity,
125            price: self.price.complement(),
126            order_count: self.order_count,
127        }
128    }
129}
130
131/// A top-of-book quote level.
132///
133/// Unlike full depth book levels, top-of-book feeds may report only a best
134/// price without a displayed quantity.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
136pub struct PredictionQuoteLevel {
137    /// Outcome price at this quote level.
138    pub price: OutcomePrice,
139    /// Non-zero displayed quantity at this quote level, when reported by the source.
140    #[serde(default)]
141    pub quantity: Option<NonZeroContractQuantity>,
142}
143
144impl PredictionQuoteLevel {
145    /// Build a quote level.
146    #[must_use]
147    pub const fn new(price: OutcomePrice, quantity: Option<NonZeroContractQuantity>) -> Self {
148        Self { price, quantity }
149    }
150}
151
152/// Top-of-book quote for a binary market in canonical YES view.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct GenericBinaryQuote<M = ()> {
155    /// Venue-namespaced binary market key.
156    pub market: BinaryMarketKey,
157    /// Timestamp (UTC) when this quote snapshot was observed.
158    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
159    pub as_of: Option<DateTime<Utc>>,
160    /// Best YES bid, if known.
161    pub yes_bid: Option<PredictionQuoteLevel>,
162    /// Best YES ask, if known.
163    pub yes_ask: Option<PredictionQuoteLevel>,
164    /// Last traded YES-view price, if known.
165    pub last_price: Option<OutcomePrice>,
166    /// Provider-specific payload, flattened into the serialized form.
167    #[serde(flatten, default = "Default::default")]
168    pub provider: M,
169}
170
171/// Standard binary quote with no provider metadata.
172pub type BinaryQuote = GenericBinaryQuote<()>;
173
174/// Canonical YES-view order book for an atomic binary market.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct GenericBinaryOrderBook<M = ()> {
177    /// Venue-namespaced binary market key.
178    pub market: BinaryMarketKey,
179    /// Timestamp (UTC) when this book snapshot was observed.
180    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
181    pub as_of: Option<DateTime<Utc>>,
182    /// YES bids, normally sorted by price descending.
183    pub yes_bids: Vec<PredictionBookLevel>,
184    /// YES asks, normally sorted by price ascending.
185    pub yes_asks: Vec<PredictionBookLevel>,
186    /// Market-specific price grid, if known.
187    pub price_grid: Option<PriceGrid>,
188    /// Provider-specific payload, flattened into the serialized form.
189    #[serde(flatten, default = "Default::default")]
190    pub provider: M,
191}
192
193impl<M: Default> GenericBinaryOrderBook<M> {
194    /// Build an empty canonical YES-view binary order book.
195    #[must_use]
196    pub fn new(market: BinaryMarketKey) -> Self {
197        Self {
198            market,
199            as_of: None,
200            yes_bids: Vec::new(),
201            yes_asks: Vec::new(),
202            price_grid: None,
203            provider: M::default(),
204        }
205    }
206}
207
208impl<M> GenericBinaryOrderBook<M> {
209    /// Canonicalize level ordering in place.
210    ///
211    /// Bids are sorted by price descending and asks by price ascending. Equal
212    /// price levels keep their relative order.
213    pub fn sort_levels(&mut self) {
214        self.yes_bids.sort_by_key(|level| Reverse(level.price));
215        self.yes_asks.sort_by_key(|level| level.price);
216    }
217
218    /// Returns the best YES bid.
219    #[must_use]
220    pub fn best_yes_bid(&self) -> Option<&PredictionBookLevel> {
221        self.yes_bids.first()
222    }
223
224    /// Returns the best YES ask.
225    #[must_use]
226    pub fn best_yes_ask(&self) -> Option<&PredictionBookLevel> {
227        self.yes_asks.first()
228    }
229
230    /// Returns the derived best NO bid.
231    #[must_use]
232    pub fn best_no_bid(&self) -> Option<PredictionBookLevel> {
233        self.best_yes_ask()
234            .copied()
235            .map(PredictionBookLevel::complement_price)
236    }
237
238    /// Returns the derived best NO ask.
239    #[must_use]
240    pub fn best_no_ask(&self) -> Option<PredictionBookLevel> {
241        self.best_yes_bid()
242            .copied()
243            .map(PredictionBookLevel::complement_price)
244    }
245
246    /// Returns the YES-view midpoint when both top-of-book sides exist.
247    #[must_use]
248    pub fn yes_midpoint(&self) -> Option<OutcomePrice> {
249        Some(
250            self.best_yes_bid()?
251                .price
252                .midpoint(self.best_yes_ask()?.price),
253        )
254    }
255
256    /// Returns the YES-view spread when both sides exist and the book is not crossed.
257    #[must_use]
258    pub fn yes_spread(&self) -> Option<OutcomePrice> {
259        let bid = self.best_yes_bid()?.price.micros();
260        let ask = self.best_yes_ask()?.price.micros();
261        let spread = ask.checked_sub(bid)?;
262        Some(OutcomePrice::from_valid_micros(spread))
263    }
264
265    /// Returns `true` when bids are descending, asks are ascending, and the
266    /// best bid does not exceed the best ask.
267    #[must_use]
268    pub fn is_sorted(&self) -> bool {
269        descending_prices(&self.yes_bids) && ascending_prices(&self.yes_asks) && !self.is_crossed()
270    }
271
272    /// Validate canonical ordering for the book.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`PredictionError::UnsortedOrderBook`] or
277    /// [`PredictionError::CrossedOrderBook`] when the book violates canonical
278    /// ordering.
279    pub fn validate_sorted(&self) -> Result<(), PredictionError> {
280        if !descending_prices(&self.yes_bids) || !ascending_prices(&self.yes_asks) {
281            return Err(PredictionError::UnsortedOrderBook { book: "binary" });
282        }
283
284        if let (Some(bid), Some(ask)) = (self.best_yes_bid(), self.best_yes_ask())
285            && bid.price > ask.price
286        {
287            return Err(PredictionError::CrossedOrderBook {
288                book: "binary",
289                bid_micros: bid.price.micros(),
290                ask_micros: ask.price.micros(),
291            });
292        }
293
294        Ok(())
295    }
296
297    /// Validate every book level against `price_grid`, when present.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`PredictionError::InvalidPriceGrid`] or
302    /// [`PredictionError::InvalidPriceBand`] for an invalid grid, or
303    /// [`PredictionError::PriceOffGrid`] for the first off-grid level.
304    pub fn validate_on_grid(&self) -> Result<(), PredictionError> {
305        let Some(grid) = &self.price_grid else {
306            return Ok(());
307        };
308        grid.validate()?;
309        validate_levels_on_grid(grid, &self.yes_bids)?;
310        validate_levels_on_grid(grid, &self.yes_asks)
311    }
312
313    fn is_crossed(&self) -> bool {
314        matches!(
315            (self.best_yes_bid(), self.best_yes_ask()),
316            (Some(bid), Some(ask)) if bid.price > ask.price
317        )
318    }
319}
320
321/// Standard binary order book with no provider metadata.
322pub type BinaryOrderBook = GenericBinaryOrderBook<()>;
323
324/// Bid/ask book for one outcome instrument.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct GenericOutcomeOrderBook<M = ()> {
327    /// Venue-namespaced outcome instrument.
328    pub instrument: OutcomeInstrument,
329    /// Timestamp (UTC) when this book snapshot was observed.
330    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
331    pub as_of: Option<DateTime<Utc>>,
332    /// Bids for this outcome instrument, normally sorted by price descending.
333    pub bids: Vec<PredictionBookLevel>,
334    /// Asks for this outcome instrument, normally sorted by price ascending.
335    pub asks: Vec<PredictionBookLevel>,
336    /// Market-specific price grid, if known.
337    pub price_grid: Option<PriceGrid>,
338    /// Provider-specific payload, flattened into the serialized form.
339    #[serde(flatten, default = "Default::default")]
340    pub provider: M,
341}
342
343impl<M: Default> GenericOutcomeOrderBook<M> {
344    /// Build an empty outcome-instrument order book.
345    #[must_use]
346    pub fn new(instrument: OutcomeInstrument) -> Self {
347        Self {
348            instrument,
349            as_of: None,
350            bids: Vec::new(),
351            asks: Vec::new(),
352            price_grid: None,
353            provider: M::default(),
354        }
355    }
356}
357
358impl<M> GenericOutcomeOrderBook<M> {
359    /// Canonicalize level ordering in place.
360    ///
361    /// Bids are sorted by price descending and asks by price ascending. Equal
362    /// price levels keep their relative order.
363    pub fn sort_levels(&mut self) {
364        self.bids.sort_by_key(|level| Reverse(level.price));
365        self.asks.sort_by_key(|level| level.price);
366    }
367
368    /// Returns the best bid.
369    #[must_use]
370    pub fn best_bid(&self) -> Option<&PredictionBookLevel> {
371        self.bids.first()
372    }
373
374    /// Returns the best ask.
375    #[must_use]
376    pub fn best_ask(&self) -> Option<&PredictionBookLevel> {
377        self.asks.first()
378    }
379
380    /// Returns `true` when bids are descending, asks are ascending, and the
381    /// best bid does not exceed the best ask.
382    #[must_use]
383    pub fn is_sorted(&self) -> bool {
384        descending_prices(&self.bids) && ascending_prices(&self.asks) && !self.is_crossed()
385    }
386
387    /// Validate canonical ordering for the book.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`PredictionError::UnsortedOrderBook`] or
392    /// [`PredictionError::CrossedOrderBook`] when the book violates canonical
393    /// ordering.
394    pub fn validate_sorted(&self) -> Result<(), PredictionError> {
395        if !descending_prices(&self.bids) || !ascending_prices(&self.asks) {
396            return Err(PredictionError::UnsortedOrderBook { book: "outcome" });
397        }
398
399        if let (Some(bid), Some(ask)) = (self.best_bid(), self.best_ask())
400            && bid.price > ask.price
401        {
402            return Err(PredictionError::CrossedOrderBook {
403                book: "outcome",
404                bid_micros: bid.price.micros(),
405                ask_micros: ask.price.micros(),
406            });
407        }
408
409        Ok(())
410    }
411
412    /// Validate every book level against `price_grid`, when present.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`PredictionError::InvalidPriceGrid`] or
417    /// [`PredictionError::InvalidPriceBand`] for an invalid grid, or
418    /// [`PredictionError::PriceOffGrid`] for the first off-grid level.
419    pub fn validate_on_grid(&self) -> Result<(), PredictionError> {
420        let Some(grid) = &self.price_grid else {
421            return Ok(());
422        };
423        grid.validate()?;
424        validate_levels_on_grid(grid, &self.bids)?;
425        validate_levels_on_grid(grid, &self.asks)
426    }
427
428    fn is_crossed(&self) -> bool {
429        matches!(
430            (self.best_bid(), self.best_ask()),
431            (Some(bid), Some(ask)) if bid.price > ask.price
432        )
433    }
434}
435
436/// Standard outcome order book with no provider metadata.
437pub type OutcomeOrderBook = GenericOutcomeOrderBook<()>;
438
439/// Trade print for one outcome instrument.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct GenericPredictionTrade<M = ()> {
442    /// Venue-namespaced outcome instrument.
443    pub instrument: OutcomeInstrument,
444    /// Trade price in outcome-price micros.
445    pub price: OutcomePrice,
446    /// Non-zero trade quantity in microcontracts.
447    pub quantity: NonZeroContractQuantity,
448    /// Optional aggressor/action side when known.
449    pub action: Option<TradeAction>,
450    /// Provider-native trade identifier, if available.
451    pub trade_id: Option<String>,
452    /// Event timestamp (UTC).
453    #[serde(with = "chrono::serde::ts_milliseconds")]
454    pub ts: DateTime<Utc>,
455    /// Provider-specific payload, flattened into the serialized form.
456    #[serde(flatten, default = "Default::default")]
457    pub provider: M,
458}
459
460/// Standard prediction trade with no provider metadata.
461pub type PredictionTrade = GenericPredictionTrade<()>;
462
463fn descending_prices(levels: &[PredictionBookLevel]) -> bool {
464    levels.windows(2).all(|pair| pair[0].price >= pair[1].price)
465}
466
467fn ascending_prices(levels: &[PredictionBookLevel]) -> bool {
468    levels.windows(2).all(|pair| pair[0].price <= pair[1].price)
469}
470
471fn validate_levels_on_grid(
472    grid: &PriceGrid,
473    levels: &[PredictionBookLevel],
474) -> Result<(), PredictionError> {
475    for level in levels {
476        grid.validate_price(level.price)?;
477    }
478
479    Ok(())
480}