betex 0.35.0

Betfair / Prediction Market Exchange
Documentation
//! Market snapshot types for bot synchronization.
//!
//! Bots use snapshots to get the current market state, then apply
//! streaming events to maintain a local replica.

use crate::book::{BinaryPriceSize, BookMarketState, PriceSize};
use crate::types::*;
use serde::{Deserialize, Serialize};

/// Strategy for selecting a representative price from snapshot depth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SnapshotPriceSelection {
    /// Use the top-of-book level.
    Best,
    /// Use a size-weighted average across visible levels.
    LiquidityWeighted,
    /// Use a distance-weighted average biased toward the best level.
    DistanceWeighted,
}

/// Representative prices for one side of the ladder using each selection mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SnapshotPriceSelections {
    pub best: Option<OddsX10000>,
    pub liquidity_weighted: Option<OddsX10000>,
    pub distance_weighted: Option<OddsX10000>,
}

impl SnapshotPriceSelections {
    pub fn from_levels(levels: &[PriceSize]) -> Self {
        Self {
            best: select_price(levels, SnapshotPriceSelection::Best),
            liquidity_weighted: select_price(levels, SnapshotPriceSelection::LiquidityWeighted),
            distance_weighted: select_price(levels, SnapshotPriceSelection::DistanceWeighted),
        }
    }

    pub fn get(self, mode: SnapshotPriceSelection) -> Option<OddsX10000> {
        match mode {
            SnapshotPriceSelection::Best => self.best,
            SnapshotPriceSelection::LiquidityWeighted => self.liquidity_weighted,
            SnapshotPriceSelection::DistanceWeighted => self.distance_weighted,
        }
    }
}

/// Complete snapshot of a market's depth at a specific per-market sequence number.
///
/// This provides the synchronization point for bots: after receiving
/// a snapshot at `market_seq`, apply all events for that market with sequence > `market_seq`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketSnapshot {
    /// Market identifier.
    pub market_id: MarketId,
    /// Human-readable market name from the engine market registry.
    pub name: String,
    /// Per-market sequence number when snapshot was taken.
    pub market_seq: u64,
    /// Current market state.
    pub state: BookMarketState,
    /// Current trading phase.
    pub phase: MarketPhase,
    /// Optional opaque downstream metadata for downstream consumers.
    pub metadata: Option<serde_json::Value>,
    /// Market-model-specific depth payload.
    pub book: MarketBookSnapshot,
}

/// Snapshot book payload, tagged by market model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MarketBookSnapshot {
    ExchangeOdds(ExchangeMarketSnapshot),
    BinaryYes(BinaryMarketSnapshot),
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExchangeMarketSnapshot {
    /// Depth for each runner.
    pub runners: Vec<RunnerSnapshot>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunnerRefSnapshot {
    /// Runner identifier.
    pub runner_id: RunnerId,
    /// Stable configured label for this runner.
    pub runner_label: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BinaryMarketSnapshot {
    pub yes_runner: RunnerRefSnapshot,
    pub no_runner: RunnerRefSnapshot,
    pub max_price_ticks: u16,
    /// Bid levels (highest `price_ticks` first).
    pub bids: Vec<BinaryPriceSize>,
    /// Ask levels (lowest `price_ticks` first).
    pub asks: Vec<BinaryPriceSize>,
}

/// Snapshot of a single runner's order book depth.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunnerSnapshot {
    /// Runner identifier.
    pub runner_id: RunnerId,
    /// Stable configured label for this runner.
    pub runner_label: String,
    /// Prices available to back (sorted best-first, i.e., highest odds first).
    /// These are the lay orders on the book that you can back against.
    pub available_to_back: Vec<PriceSize>,
    /// Prices available to lay (sorted best-first, i.e., lowest odds first).
    /// These are the back orders on the book that you can lay against.
    pub available_to_lay: Vec<PriceSize>,
    /// Total matched volume on this runner.
    pub matched_volume: Money,
    /// Representative prices for available-to-back by selection mode.
    pub selected_to_back: SnapshotPriceSelections,
    /// Representative prices for available-to-lay by selection mode.
    pub selected_to_lay: SnapshotPriceSelections,
}

impl MarketSnapshot {
    /// Market model (odds vs prediction).
    pub fn market_model(&self) -> MarketModel {
        self.book.market_model()
    }

    /// Exchange-odds depth, if this is an exchange market.
    pub fn exchange(&self) -> Option<&ExchangeMarketSnapshot> {
        self.book.exchange()
    }

    /// Exchange-odds depth, if this is an exchange market.
    pub fn exchange_mut(&mut self) -> Option<&mut ExchangeMarketSnapshot> {
        self.book.exchange_mut()
    }

    /// Binary depth, if this is a prediction market.
    pub fn binary(&self) -> Option<&BinaryMarketSnapshot> {
        self.book.binary()
    }

    /// Binary depth, if this is a prediction market.
    pub fn binary_mut(&mut self) -> Option<&mut BinaryMarketSnapshot> {
        self.book.binary_mut()
    }

    /// Runner depth snapshots for exchange markets, or an empty slice for binary markets.
    pub fn runners(&self) -> &[RunnerSnapshot] {
        self.exchange().map(|b| b.runners.as_slice()).unwrap_or(&[])
    }

    /// Mutable runner depth snapshots for exchange markets.
    pub fn runners_mut(&mut self) -> Option<&mut Vec<RunnerSnapshot>> {
        self.exchange_mut().map(|b| &mut b.runners)
    }

    /// Get runner snapshot by ID.
    pub fn runner(&self, runner_id: RunnerId) -> Option<&RunnerSnapshot> {
        self.runners().iter().find(|r| r.runner_id == runner_id)
    }

    /// Number of runners in the market.
    pub fn runner_count(&self) -> usize {
        self.runners().len()
    }

    /// Runner ID/label pairs for this market.
    pub fn runner_refs(&self) -> Vec<RunnerRefSnapshot> {
        match &self.book {
            MarketBookSnapshot::ExchangeOdds(book) => book
                .runners
                .iter()
                .map(|runner| RunnerRefSnapshot {
                    runner_id: runner.runner_id,
                    runner_label: runner.runner_label.clone(),
                })
                .collect(),
            MarketBookSnapshot::BinaryYes(book) => {
                vec![book.yes_runner.clone(), book.no_runner.clone()]
            }
        }
    }

    /// Runner IDs for this market, regardless of the market model.
    pub fn runner_ids(&self) -> Vec<RunnerId> {
        self.runner_refs()
            .into_iter()
            .map(|runner| runner.runner_id)
            .collect()
    }

    /// Truncate visible depth in-place while preserving the snapshot shape.
    pub fn truncate_depth(&mut self, depth: usize) {
        match &mut self.book {
            MarketBookSnapshot::ExchangeOdds(book) => {
                for runner in &mut book.runners {
                    runner.available_to_back.truncate(depth);
                    runner.available_to_lay.truncate(depth);
                }
            }
            MarketBookSnapshot::BinaryYes(book) => {
                book.bids.truncate(depth);
                book.asks.truncate(depth);
            }
        }
    }
}

impl MarketBookSnapshot {
    /// Market model (odds vs prediction).
    pub fn market_model(&self) -> MarketModel {
        match self {
            Self::ExchangeOdds(_) => MarketModel::ExchangeOdds,
            Self::BinaryYes(book) => MarketModel::BinaryYes {
                max_price_ticks: book.max_price_ticks,
            },
        }
    }

    pub fn exchange(&self) -> Option<&ExchangeMarketSnapshot> {
        match self {
            Self::ExchangeOdds(book) => Some(book),
            Self::BinaryYes(_) => None,
        }
    }

    pub fn exchange_mut(&mut self) -> Option<&mut ExchangeMarketSnapshot> {
        match self {
            Self::ExchangeOdds(book) => Some(book),
            Self::BinaryYes(_) => None,
        }
    }

    pub fn binary(&self) -> Option<&BinaryMarketSnapshot> {
        match self {
            Self::ExchangeOdds(_) => None,
            Self::BinaryYes(book) => Some(book),
        }
    }

    pub fn binary_mut(&mut self) -> Option<&mut BinaryMarketSnapshot> {
        match self {
            Self::ExchangeOdds(_) => None,
            Self::BinaryYes(book) => Some(book),
        }
    }
}

impl RunnerSnapshot {
    /// Best price to back at (highest odds available).
    pub fn best_back(&self) -> Option<&PriceSize> {
        self.available_to_back.first()
    }

    /// Best price to lay at (lowest odds available).
    pub fn best_lay(&self) -> Option<&PriceSize> {
        self.available_to_lay.first()
    }

    /// Select a representative back price from visible depth using `mode`.
    pub fn select_back_price(&self, mode: SnapshotPriceSelection) -> Option<OddsX10000> {
        self.selected_to_back
            .get(mode)
            .or_else(|| select_price(&self.available_to_back, mode))
    }

    /// Select a representative lay price from visible depth using `mode`.
    pub fn select_lay_price(&self, mode: SnapshotPriceSelection) -> Option<OddsX10000> {
        self.selected_to_lay
            .get(mode)
            .or_else(|| select_price(&self.available_to_lay, mode))
    }

    /// Spread in ticks between best back and lay.
    pub fn spread_ticks(&self) -> Option<u32> {
        let back = self.best_back()?;
        let lay = self.best_lay()?;
        back.price
            .ticks_between(lay.price)
            .map(|t| t.unsigned_abs())
    }

    /// Total available volume to back.
    pub fn total_back_volume(&self) -> Money {
        Money(self.available_to_back.iter().map(|p| p.size.0).sum())
    }

    /// Total available volume to lay.
    pub fn total_lay_volume(&self) -> Money {
        Money(self.available_to_lay.iter().map(|p| p.size.0).sum())
    }
}

fn select_price(levels: &[PriceSize], mode: SnapshotPriceSelection) -> Option<OddsX10000> {
    match mode {
        SnapshotPriceSelection::Best => levels.first().map(|p| p.price),
        SnapshotPriceSelection::LiquidityWeighted => {
            weighted_price(levels, |_, level| level.size.0.max(0) as f64)
        }
        SnapshotPriceSelection::DistanceWeighted => {
            let best = levels.first()?.price;
            weighted_price(levels, |idx, level| {
                let base = level
                    .price
                    .ticks_between(best)
                    .map(|d| d.unsigned_abs() as f64)
                    .unwrap_or(idx as f64);
                1.0 / (base + 1.0)
            })
        }
    }
}

fn weighted_price<F>(levels: &[PriceSize], mut weight_for: F) -> Option<OddsX10000>
where
    F: FnMut(usize, &PriceSize) -> f64,
{
    let mut weighted_sum = 0.0;
    let mut weight_total = 0.0;

    for (idx, level) in levels.iter().enumerate() {
        let weight = weight_for(idx, level);
        if !weight.is_finite() || weight <= 0.0 {
            continue;
        }
        weighted_sum += level.price.to_decimal() * weight;
        weight_total += weight;
    }

    if weight_total <= f64::EPSILON {
        return levels.first().map(|p| p.price);
    }

    nearest_tick(weighted_sum / weight_total)
}

fn nearest_tick(decimal: f64) -> Option<OddsX10000> {
    let raw = OddsX10000::from_decimal(decimal)?;
    let floor = raw.floor_tick();
    let ceil = raw.ceil_tick();

    match (floor, ceil) {
        (Some(f), Some(c)) => {
            let f_diff = (decimal - f.to_decimal()).abs();
            let c_diff = (c.to_decimal() - decimal).abs();
            if f_diff <= c_diff { Some(f) } else { Some(c) }
        }
        (Some(f), None) => Some(f),
        (None, Some(c)) => Some(c),
        (None, None) => None,
    }
}