betex 0.20.0

Betfair / Prediction Market Exchange
Documentation
use super::super::RunnerResult;
use crate::types::*;
use serde::{Deserialize, Serialize};
use std::fmt;

#[derive(
    Debug,
    Clone,
    Copy,
    Serialize,
    Deserialize,
    PartialEq,
    Eq,
    Hash,
    strum::Display,
    strum::EnumString,
    rkyv::Archive,
    rkyv::Serialize,
    rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum Side {
    /// Yes/long side.
    ///
    /// Mapping:
    /// - Exchange odds: Back
    /// - Binary: Buy
    Yes,
    /// No/short side.
    ///
    /// Mapping:
    /// - Exchange odds: Lay
    /// - Binary: Sell
    No,
}

#[allow(non_upper_case_globals)]
impl Side {
    /// Alias for `Yes` (exchange BACK).
    pub const Back: Self = Self::Yes;
    /// Alias for `No` (exchange LAY).
    pub const Lay: Self = Self::No;
    /// Alias for `Yes` (binary BUY).
    pub const Buy: Self = Self::Yes;
    /// Alias for `No` (binary SELL).
    pub const Sell: Self = Self::No;
}

pub type BinarySide = Side;
pub type BinaryTimeInForce = TimeInForce;

#[derive(
    Debug,
    Clone,
    Copy,
    Serialize,
    Deserialize,
    PartialEq,
    Eq,
    Hash,
    strum::Display,
    strum::EnumString,
    rkyv::Archive,
    rkyv::Serialize,
    rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum Persistence {
    Lapse,
    Persist,
    MarketOnClose,
}

/// Time-in-force policy for order execution.
#[derive(
    Debug,
    Clone,
    Copy,
    Serialize,
    Deserialize,
    PartialEq,
    Eq,
    Hash,
    rkyv::Archive,
    rkyv::Serialize,
    rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TimeInForce {
    /// Good-til-Cancelled: match what's available now, then rest the remainder.
    Gtc,
    /// Fill-or-Kill with an optional minimum fill size.
    ///
    /// Units:
    /// - Exchange orders: stake (Money quanta)
    /// - Binary orders: shares
    FillOrKill { min_fill: Option<Quantity> },
}

#[derive(
    Debug,
    Clone,
    Copy,
    Serialize,
    Deserialize,
    PartialEq,
    Eq,
    Hash,
    strum::Display,
    strum::EnumString,
    rkyv::Archive,
    rkyv::Serialize,
    rkyv::Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum MarketState {
    /// Market is open and accepts orders.
    Open,
    /// Market is temporarily suspended (no matching).
    Suspended,
    /// Market is closed (no new orders; awaiting settlement).
    Closed,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Command {
    /// Optional client-provided correlation id (UUID-like string, max 36 bytes).
    ///
    /// Propagated into market/order creation events for downstream correlation when present.
    #[serde(default)]
    pub correlation_id: Option<CorrelationId>,
    /// Optional client-provided metadata propagated to all emitted book event envelopes.
    #[serde(default)]
    pub metadata: Option<serde_json::Value>,
    /// Target market id for the command.
    pub market_id: MarketId,
    /// The command payload.
    pub kind: CommandKind,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum CommandKind {
    /// Engine-level: create and initialize a market/book.
    CreateMarket {
        name: String,
        market_model: MarketModel,
        market_kind: MarketKind,
        #[serde(default)]
        market_phase: MarketPhase,
        /// Empty means "dynamic multi-runner" (runners may be added later).
        runner_ids: Vec<RunnerId>,
        runner_labels: Vec<String>,
    },
    /// Place a new order on a market/runner.
    PlaceOrder {
        runner_id: RunnerId,
        account_id: AccountId,
        client_order_id: Option<ClientOrderId>,
        side: Side,
        odds: OddsX10000,
        stake: Money,
        persistence: Persistence,
        time_in_force: TimeInForce,
    },
    /// Place a new canonical YES-only prediction-market order.
    PlaceBinaryOrder {
        account_id: AccountId,
        client_order_id: Option<ClientOrderId>,
        side: Side,
        /// Uniform probability ticks in `[0..max_price_ticks]` for this market.
        price_ticks: u16,
        /// Quantity in shares (each share pays out `max_price_ticks` quanta on YES resolution).
        qty_shares: u64,
        time_in_force: TimeInForce,
    },
    /// Cancel an existing order.
    CancelOrder {
        account_id: AccountId,
        order_id: OrderId,
    },
    /// Replace an existing order by updating price and/or stake.
    ReplaceOrder {
        account_id: AccountId,
        order_id: OrderId,
        new_odds: Option<OddsX10000>,
        new_stake: Option<Money>,
    },
    /// Replace an existing prediction-market order by updating price and/or quantity.
    ReplaceBinaryOrder {
        account_id: AccountId,
        order_id: OrderId,
        new_price_ticks: Option<u16>,
        new_qty_shares: Option<u64>,
    },
    /// Update market trading state.
    SetMarketState { state: MarketState },
    /// Update market trading phase.
    SetMarketPhase { phase: MarketPhase },
    /// Close a market in bounded event batches (starts a close process).
    CloseMarket,
    /// Internal: continue an in-progress close process (emits another cancel batch).
    ContinueCloseMarket,
    /// Cancel matching live orders in bounded event batches without closing the market.
    BatchCancelOrders {
        from_created_at_inclusive: Option<DateTime>,
        to_created_at_inclusive: Option<DateTime>,
        account_id: Option<AccountId>,
        runner_id: Option<RunnerId>,
        batch_max_events: u16,
        reason: String,
    },
    /// Internal: continue an in-progress batch-cancel process.
    ContinueBatchCancelOrders,
    /// Internal: continue an in-progress lapse process.
    ContinueLapseOrders,
    /// Internal: continue an in-progress void process.
    ContinueVoidOrders,
    /// Remove a runner from the market, optionally applying a reduction factor (in bps).
    RemoveRunner {
        runner_id: RunnerId,
        reduction_factor_bps: Option<u32>,
    },
    /// Void all trades matched at/after a timestamp (administrative).
    VoidTradesFromTime {
        from_matched_at_inclusive: DateTime,
        reason: String,
    },
    /// Void a market (terminal).
    VoidMarket { reason: String },
    /// Settle a market (terminal).
    SettleMarket {
        runner_results: Vec<(RunnerId, RunnerResult)>,
        dead_heat_divisor: Option<u32>,
    },
    /// Halt a market/book (administrative).
    HaltMarket { reason: u32 },
    /// Resume a market/book after a halt (administrative).
    ResumeMarket,
    /// Engine-level: remove a terminal market from memory.
    RemoveMarket,
}

impl Command {
    /// Extract the market_id from the command.
    pub fn market_id(&self) -> MarketId {
        self.market_id
    }
}

impl CommandKind {
    #[inline]
    pub fn is_internal_batch_continue(&self) -> bool {
        matches!(
            self,
            Self::ContinueCloseMarket
                | Self::ContinueBatchCancelOrders
                | Self::ContinueLapseOrders
                | Self::ContinueVoidOrders
        )
    }

    #[inline]
    pub fn may_affect_batch_scheduler(&self) -> bool {
        matches!(
            self,
            Self::SetMarketState {
                state: MarketState::Closed | MarketState::Suspended
            } | Self::SetMarketPhase { .. }
                | Self::CloseMarket
                | Self::ContinueCloseMarket
                | Self::BatchCancelOrders { .. }
                | Self::ContinueBatchCancelOrders
                | Self::ContinueLapseOrders
                | Self::ContinueVoidOrders
                | Self::VoidMarket { .. }
        )
    }
}

impl fmt::Display for Command {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.kind)
    }
}

impl fmt::Display for CommandKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            CommandKind::CreateMarket { .. } => "CREATE_MARKET",
            CommandKind::PlaceOrder { .. } => "PLACE_ORDER",
            CommandKind::PlaceBinaryOrder { .. } => "PLACE_BINARY_ORDER",
            CommandKind::CancelOrder { .. } => "CANCEL_ORDER",
            CommandKind::ReplaceOrder { .. } => "REPLACE_ORDER",
            CommandKind::ReplaceBinaryOrder { .. } => "REPLACE_BINARY_ORDER",
            CommandKind::SetMarketState { .. } => "SET_MARKET_STATE",
            CommandKind::SetMarketPhase { .. } => "SET_MARKET_PHASE",
            CommandKind::CloseMarket => "CLOSE_MARKET",
            CommandKind::ContinueCloseMarket => "CONTINUE_CLOSE_MARKET",
            CommandKind::BatchCancelOrders { .. } => "BATCH_CANCEL_ORDERS",
            CommandKind::ContinueBatchCancelOrders => "CONTINUE_BATCH_CANCEL_ORDERS",
            CommandKind::ContinueLapseOrders => "CONTINUE_LAPSE_ORDERS",
            CommandKind::ContinueVoidOrders => "CONTINUE_VOID_ORDERS",
            CommandKind::RemoveRunner { .. } => "REMOVE_RUNNER",
            CommandKind::VoidTradesFromTime { .. } => "VOID_TRADES_FROM_TIME",
            CommandKind::VoidMarket { .. } => "VOID_MARKET",
            CommandKind::SettleMarket { .. } => "SETTLE_MARKET",
            CommandKind::HaltMarket { .. } => "HALT_MARKET",
            CommandKind::ResumeMarket => "RESUME_MARKET",
            CommandKind::RemoveMarket => "REMOVE_MARKET",
        };
        write!(f, "{s}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn may_affect_batch_scheduler_flags_only_batch_relevant_commands() {
        assert!(CommandKind::CloseMarket.may_affect_batch_scheduler());
        assert!(CommandKind::ContinueCloseMarket.may_affect_batch_scheduler());
        assert!(
            CommandKind::BatchCancelOrders {
                from_created_at_inclusive: None,
                to_created_at_inclusive: None,
                account_id: None,
                runner_id: None,
                batch_max_events: 16,
                reason: "x".to_string(),
            }
            .may_affect_batch_scheduler()
        );
        assert!(CommandKind::ContinueBatchCancelOrders.may_affect_batch_scheduler());
        assert!(CommandKind::ContinueLapseOrders.may_affect_batch_scheduler());
        assert!(CommandKind::ContinueVoidOrders.may_affect_batch_scheduler());
        assert!(
            CommandKind::VoidMarket {
                reason: "x".to_string(),
            }
            .may_affect_batch_scheduler()
        );
        assert!(
            CommandKind::SetMarketPhase {
                phase: MarketPhase::Live,
            }
            .may_affect_batch_scheduler()
        );
        assert!(
            CommandKind::SetMarketState {
                state: MarketState::Closed,
            }
            .may_affect_batch_scheduler()
        );
        assert!(
            CommandKind::SetMarketState {
                state: MarketState::Suspended,
            }
            .may_affect_batch_scheduler()
        );

        assert!(
            !CommandKind::SetMarketState {
                state: MarketState::Open,
            }
            .may_affect_batch_scheduler()
        );
        assert!(
            !CommandKind::PlaceOrder {
                runner_id: RunnerId(1),
                account_id: AccountId::from(1_u64),
                client_order_id: None,
                side: Side::Yes,
                odds: OddsX10000(20_000),
                stake: Money(100),
                persistence: Persistence::Persist,
                time_in_force: TimeInForce::Gtc,
            }
            .may_affect_batch_scheduler()
        );
    }
}