betex 0.35.0

Betfair / Prediction Market Exchange
Documentation
use serde::{Deserialize, Serialize};

/// Decimal odds represented as a fixed-point integer with 4 decimal places.
///
/// Example:
/// - `OddsX10000(25_000)` represents odds `2.5000`
/// - `OddsX10000(10_100)` represents odds `1.0100`
///
/// Why this exists:
/// - Determinism: integer math avoids floating point drift between platforms/runs.
/// - Serialization: compact and stable over the wire / in WAL/event logs.
///
/// # Betfair Tick Ladder
///
/// Betfair uses a non-uniform tick ladder with 350 valid prices from 1.01 to 1000.
/// Use `is_valid_tick()` to validate prices against this ladder.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Serialize,
    Deserialize,
    Hash,
    rkyv::Archive,
    rkyv::Serialize,
    rkyv::Deserialize,
)]
pub struct OddsX10000(pub u32);

/// Betfair tick ladder: 350 valid prices from 1.01 to 1000.
///
/// Generated at compile time for O(1) validation via binary search.
pub const TICK_LADDER: [u32; 350] = generate_tick_ladder();

/// Generate the complete Betfair tick ladder at compile time.
const fn generate_tick_ladder() -> [u32; 350] {
    let mut ticks = [0u32; 350];
    let mut idx = 0;

    // 1.01 - 2.00: increment 0.01 (100 in x10000)
    // 100 prices: 10100, 10200, ..., 20000
    let mut price = 10100u32;
    while price <= 20000 {
        ticks[idx] = price;
        idx += 1;
        price += 100;
    }

    // 2.02 - 3.00: increment 0.02 (200 in x10000)
    // 50 prices: 20200, 20400, ..., 30000
    price = 20200;
    while price <= 30000 {
        ticks[idx] = price;
        idx += 1;
        price += 200;
    }

    // 3.05 - 4.00: increment 0.05 (500 in x10000)
    // 20 prices: 30500, 31000, ..., 40000
    price = 30500;
    while price <= 40000 {
        ticks[idx] = price;
        idx += 1;
        price += 500;
    }

    // 4.1 - 6.0: increment 0.1 (1000 in x10000)
    // 20 prices: 41000, 42000, ..., 60000
    price = 41000;
    while price <= 60000 {
        ticks[idx] = price;
        idx += 1;
        price += 1000;
    }

    // 6.2 - 10.0: increment 0.2 (2000 in x10000)
    // 20 prices: 62000, 64000, ..., 100000
    price = 62000;
    while price <= 100000 {
        ticks[idx] = price;
        idx += 1;
        price += 2000;
    }

    // 10.5 - 20.0: increment 0.5 (5000 in x10000)
    // 20 prices: 105000, 110000, ..., 200000
    price = 105000;
    while price <= 200000 {
        ticks[idx] = price;
        idx += 1;
        price += 5000;
    }

    // 21 - 30: increment 1 (10000 in x10000)
    // 10 prices: 210000, 220000, ..., 300000
    price = 210000;
    while price <= 300000 {
        ticks[idx] = price;
        idx += 1;
        price += 10000;
    }

    // 32 - 50: increment 2 (20000 in x10000)
    // 10 prices: 320000, 340000, ..., 500000
    price = 320000;
    while price <= 500000 {
        ticks[idx] = price;
        idx += 1;
        price += 20000;
    }

    // 55 - 100: increment 5 (50000 in x10000)
    // 10 prices: 550000, 600000, ..., 1000000
    price = 550000;
    while price <= 1000000 {
        ticks[idx] = price;
        idx += 1;
        price += 50000;
    }

    // 110 - 1000: increment 10 (100000 in x10000)
    // 90 prices: 1100000, 1200000, ..., 10000000
    price = 1100000;
    while price <= 10000000 {
        ticks[idx] = price;
        idx += 1;
        price += 100000;
    }

    ticks
}

impl OddsX10000 {
    /// Minimum valid odds: 1.01 (represented as 10100)
    pub const MIN: u32 = 10_100;

    /// Maximum valid odds: 1000 (represented as 10_000_000)
    pub const MAX: u32 = 10_000_000;

    /// Create from raw x10000 value.
    pub const fn new(value: u32) -> Self {
        Self(value)
    }

    /// Create from decimal odds (e.g., 2.5 -> OddsX10000(25000)).
    /// Returns `None` for non-finite or negative input.
    ///
    /// Note: This may still produce invalid tick values. Use `from_decimal_rounded`
    /// when you need a value guaranteed to be on the tick ladder.
    pub fn from_decimal(odds: f64) -> Option<Self> {
        if !odds.is_finite() || odds < 0.0 {
            return None;
        }
        let scaled = (odds * 10000.0).round();
        if scaled < 0.0 || scaled > u32::MAX as f64 {
            return None;
        }
        Some(Self(scaled as u32))
    }

    /// Convert to decimal odds.
    pub fn to_decimal(self) -> f64 {
        self.0 as f64 / 10000.0
    }

    /// Basic validity check (>= 1.01).
    pub fn is_valid(self) -> bool {
        self.0 >= Self::MIN
    }

    /// Check if this price is on the Betfair tick ladder.
    pub fn is_valid_tick(self) -> bool {
        TICK_LADDER.binary_search(&self.0).is_ok()
    }

    /// Round down to the nearest valid tick.
    pub fn floor_tick(self) -> Option<OddsX10000> {
        if self.0 < Self::MIN {
            return None;
        }
        if self.0 > Self::MAX {
            return Some(OddsX10000(Self::MAX));
        }

        // Binary search for the largest tick <= self.0
        match TICK_LADDER.binary_search(&self.0) {
            Ok(_) => Some(self), // Already on a tick
            Err(idx) => {
                if idx == 0 {
                    None
                } else {
                    Some(OddsX10000(TICK_LADDER[idx - 1]))
                }
            }
        }
    }

    /// Round up to the nearest valid tick.
    pub fn ceil_tick(self) -> Option<OddsX10000> {
        if self.0 > Self::MAX {
            return None;
        }
        if self.0 < Self::MIN {
            return Some(OddsX10000(Self::MIN));
        }

        // Binary search for the smallest tick >= self.0
        match TICK_LADDER.binary_search(&self.0) {
            Ok(_) => Some(self), // Already on a tick
            Err(idx) => {
                if idx >= TICK_LADDER.len() {
                    None
                } else {
                    Some(OddsX10000(TICK_LADDER[idx]))
                }
            }
        }
    }

    /// Get the next higher valid tick (None if at max).
    pub fn tick_up(self) -> Option<OddsX10000> {
        match TICK_LADDER.binary_search(&self.0) {
            Ok(idx) => {
                if idx + 1 < TICK_LADDER.len() {
                    Some(OddsX10000(TICK_LADDER[idx + 1]))
                } else {
                    None
                }
            }
            Err(idx) => {
                if idx < TICK_LADDER.len() {
                    Some(OddsX10000(TICK_LADDER[idx]))
                } else {
                    None
                }
            }
        }
    }

    /// Get the next lower valid tick (None if at min).
    pub fn tick_down(self) -> Option<OddsX10000> {
        match TICK_LADDER.binary_search(&self.0) {
            Ok(idx) => {
                if idx > 0 {
                    Some(OddsX10000(TICK_LADDER[idx - 1]))
                } else {
                    None
                }
            }
            Err(idx) => {
                if idx > 0 {
                    Some(OddsX10000(TICK_LADDER[idx - 1]))
                } else {
                    None
                }
            }
        }
    }

    /// Get the index of this tick in the ladder (0-349), or None if not a valid tick.
    pub fn tick_index(self) -> Option<usize> {
        TICK_LADDER.binary_search(&self.0).ok()
    }

    /// Number of ticks between two prices (signed).
    /// Returns None if either price is not a valid tick.
    pub fn ticks_between(self, other: OddsX10000) -> Option<i32> {
        let self_idx = self.tick_index()?;
        let other_idx = other.tick_index()?;
        Some(other_idx as i32 - self_idx as i32)
    }

    /// Create from tick index (0-349).
    pub fn from_tick_index(idx: usize) -> Option<OddsX10000> {
        TICK_LADDER.get(idx).copied().map(OddsX10000)
    }
}

impl std::fmt::Display for OddsX10000 {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let whole = self.0 / 10_000;
        let frac = self.0 % 10_000;
        write!(f, "{}.{:04}", whole, frac)
    }
}