betex 0.35.0

Betfair / Prediction Market Exchange
Documentation
use betex::{
    book::protocol::{
        command::{
            BinarySide, BinaryTimeInForce, MarketState, Persistence, RunnerChange, Side,
            TimeInForce,
        },
        response::{Response, TradeSummary},
    },
    snapshot::MarketSnapshot,
    types::{
        AccountId, BookType, CorrelationId, MarketId, MarketKind, MarketModel, MarketPhase,
        OddsX10000, OrderId, RunnerId,
    },
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum MarketStatus {
    Active,
    Closed,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct MarketInfo {
    pub market_id: MarketId,
    pub engine_id: Uuid,
    pub name: String,
    pub market_model: MarketModel,
    pub market_kind: MarketKind,
    pub market_phase: MarketPhase,
    pub runner_ids: Vec<RunnerId>,
    pub runner_labels: Vec<String>,
    pub status: MarketStatus,
    pub created_at: i64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum ClientMsg {
    // Admin commands
    SetMarketState {
        #[serde(default)]
        market_id: Option<MarketId>,
        state: MarketState,
    },
    AwaitLiveMarket {
        #[serde(default)]
        market_id: Option<MarketId>,
    },
    GoLiveMarket {
        #[serde(default)]
        market_id: Option<MarketId>,
    },
    CloseMarket {
        #[serde(default)]
        market_id: Option<MarketId>,
        #[serde(default)]
        reason: String,
    },
    BatchCancelOrders {
        #[serde(default)]
        correlation_id: Option<CorrelationId>,
        #[serde(default)]
        market_id: Option<MarketId>,
        #[serde(default)]
        from_created_at_inclusive_ms: Option<i64>,
        #[serde(default)]
        to_created_at_inclusive_ms: Option<i64>,
        #[serde(default)]
        account_id: Option<AccountId>,
        #[serde(default)]
        runner_id: Option<RunnerId>,
        reason: String,
        #[serde(default)]
        metadata: Option<serde_json::Value>,
    },
    ContinueBatchProcess {
        #[serde(default)]
        correlation_id: Option<CorrelationId>,
        #[serde(default)]
        market_id: Option<MarketId>,
    },
    HaltMarket {
        #[serde(default)]
        market_id: Option<MarketId>,
        reason: String,
    },
    ResumeMarket {
        #[serde(default)]
        market_id: Option<MarketId>,
    },
    Ping,

    // Market lifecycle (control plane)
    CreateMarket {
        name: String,
        #[serde(default)]
        market_model: MarketModel,
        book_type: Option<BookType>,
        market_kind: MarketKind,
        #[serde(default)]
        market_state: MarketState,
        market_phase: MarketPhase,
        runner_ids: Vec<RunnerId>,
        runner_labels: Vec<String>,
    },
    AddRunners {
        #[serde(default)]
        market_id: Option<MarketId>,
        runner_ids: Vec<RunnerId>,
        runner_labels: Vec<String>,
    },
    ChangeRunners {
        #[serde(default)]
        market_id: Option<MarketId>,
        add: Vec<RunnerChange>,
        remove: Vec<RunnerChange>,
    },
    ListMarkets,
    Subscribe {
        market_id: MarketId,
    },

    // Trading commands
    PlaceOrder {
        #[serde(default)]
        correlation_id: Option<CorrelationId>,
        order_id: OrderId,
        market_id: MarketId,
        runner_id: RunnerId,
        account_id: AccountId,
        side: Side,
        odds: OddsX10000,
        stake: i64,
        persistence: Option<Persistence>,
        time_in_force: Option<TimeInForce>,
    },
    PlaceBinaryOrder {
        #[serde(default)]
        correlation_id: Option<CorrelationId>,
        order_id: OrderId,
        market_id: MarketId,
        account_id: AccountId,
        side: BinarySide,
        price_ticks: u16,
        qty_shares: u64,
        time_in_force: Option<BinaryTimeInForce>,
    },
    CancelOrder {
        #[serde(default)]
        correlation_id: Option<CorrelationId>,
        market_id: MarketId,
        account_id: AccountId,
        order_id: OrderId,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum ServerMsg {
    Snapshot {
        snapshot: MarketSnapshot,
    },
    Status {
        global_seq: u64,
        market_count: usize,
    },
    Ack {
        ok: bool,
        response: Option<Response>,
        error: Option<String>,
    },
    OrderResponse {
        correlation_id: Option<CorrelationId>,
        ok: bool,
        order_id: Option<OrderId>,
        trades: Vec<TradeSummary>,
        error: Option<String>,
    },
    MarketCreated {
        market: MarketInfo,
    },
    MarketUpdated {
        market: MarketInfo,
    },
    MarketRemoved {
        market_id: MarketId,
    },
    MarketList {
        markets: Vec<MarketInfo>,
    },
    MarketStatusChanged {
        market_id: MarketId,
        status: MarketStatus,
    },
    MarketPhaseChanged {
        market_id: MarketId,
        phase: MarketPhase,
    },
}

pub(crate) fn encode_msg<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
    serde_json::to_string(value)
}

pub(crate) fn decode_msg<T>(data: &[u8]) -> Result<T, serde_json::Error>
where
    T: for<'de> Deserialize<'de>,
{
    serde_json::from_slice(data)
}

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

    #[test]
    fn create_market_json_missing_book_type_decodes_to_none() {
        let value = serde_json::json!({
            "CREATE_MARKET": {
                "name": "x",
                "market_model": "EXCHANGE_ODDS",
                "market_kind": "IN_PLAY_CAPABLE",
                "market_state": "OPEN",
                "market_phase": "PRE",
                "runner_ids": [1, 2],
                "runner_labels": ["A", "B"]
            }
        });

        let decoded = serde_json::from_value::<ClientMsg>(value).expect("decode create market");
        match decoded {
            ClientMsg::CreateMarket { book_type, .. } => assert_eq!(book_type, None),
            other => panic!("expected CreateMarket, got {other:?}"),
        }
    }

    #[test]
    fn change_runners_json_roundtrip() {
        let msg = ClientMsg::ChangeRunners {
            market_id: Some(MarketId(7)),
            add: vec![RunnerChange {
                runner_id: RunnerId(3),
                runner_label: "C".to_string(),
            }],
            remove: vec![RunnerChange {
                runner_id: RunnerId(1),
                runner_label: "A".to_string(),
            }],
        };

        let value = serde_json::to_value(&msg).expect("serialize change runners");
        let decoded = serde_json::from_value::<ClientMsg>(value).expect("decode change runners");
        match decoded {
            ClientMsg::ChangeRunners {
                market_id,
                add,
                remove,
            } => {
                assert_eq!(market_id, Some(MarketId(7)));
                assert_eq!(add[0].runner_id, RunnerId(3));
                assert_eq!(add[0].runner_label, "C");
                assert_eq!(remove[0].runner_id, RunnerId(1));
                assert_eq!(remove[0].runner_label, "A");
            }
            other => panic!("expected ChangeRunners, got {other:?}"),
        }
    }
}