betex 0.7.6

Betfair / Prediction Market Exchange
Documentation
//! Cross-matching engine for multi-runner markets.
//!
//! Cross-matching allows the exchange to fill user orders by creating
//! offsetting positions on other runners. The exchange uses an internal
//! house account to execute hedge legs, ensuring risk stays within
//! configured tolerance.
//!
//! ## Compensation Model
//!
//! Cross-matching follows an **all-or-nothing** model with void-on-failure:
//!
//! 1. The engine attempts to match the user's order and execute hedge legs
//! 2. If any hedge leg fails, all successful trades are voided
//! 3. Events from both the trades and voids are returned in `partial_events`
//!
//! This ensures the house never holds an unhedged position due to partial
//! cross-match failures.
//!
//! ## Risk Tolerance
//!
//! The [`RiskTolerance`] configuration controls how much worst-case loss the
//! house is willing to accept per cross-match:
//!
//! - **Risk-free**: Only accept perfectly hedged positions (worst-case P&L = 0)
//! - **Moderate**: Allow small losses to enable more matches
//! - **Custom**: Specify absolute and/or percentage-based tolerance
//!
//! Higher tolerance enables more cross-matches but increases house exposure.
//!
//! ## Example
//!
//! ```ignore
//! use btx::cross_match::{CrossMatchEngine, CrossMatchConfig, RiskTolerance};
//!
//! let config = CrossMatchConfig {
//!     risk: RiskTolerance::risk_free(),
//!     ..Default::default()
//! };
//! let mut engine = CrossMatchEngine::new(config);
//!
//! // After a user order rests without matching...
//! match engine.attempt_cross_match(&mut book, user_order_id) {
//!     CrossMatchResult::Success { events, hedge_legs, worst_case_pnl } => {
//!         // Cross-match succeeded - events include user match + hedge trades
//!         journal_events(&events);
//!     }
//!     CrossMatchResult::Failed { reason, partial_events } => {
//!         // Hedge leg failed - partial_events include matched trades AND their voids
//!         journal_events(&partial_events);
//!     }
//!     CrossMatchResult::NotPossible { reason } => {
//!         // No cross-match possible (insufficient liquidity, odds don't work, etc.)
//!     }
//! }
//! ```

mod config;
mod hedge;

pub use config::{CrossMatchConfig, RiskTolerance};
pub use hedge::{HedgeInput, HedgeLeg, HedgeResult, calculate_3runner_hedge};

use crate::book::protocol::command::{Command, CommandKind, Persistence, Side, TimeInForce};
use crate::book::{Book, BookEvent, BookEventEnvelope};
use crate::types::{AccountId, MarketId, Money, OddsX10000, OrderId, RunnerId, TradeId};

/// Result of a cross-match attempt.
#[derive(Debug, Clone)]
pub enum CrossMatchResult {
    /// Successfully executed cross-match.
    Success {
        /// All events from the cross-match (user match + hedge legs).
        events: Vec<BookEventEnvelope>,
        /// The hedge legs that were executed.
        hedge_legs: Vec<HedgeLeg>,
        /// Worst-case P&L for the house.
        worst_case_pnl: Money,
    },
    /// No cross-match possible (order remains resting).
    NotPossible { reason: &'static str },
    /// Cross-match attempted but a leg failed.
    Failed {
        reason: &'static str,
        /// Events from legs that did execute (may need compensation).
        partial_events: Vec<BookEventEnvelope>,
    },
}

/// Cross-matching engine.
///
/// Sits above the Book and orchestrates multi-leg cross-match execution.
/// The controller is responsible for transaction semantics (journaling, atomicity).
pub struct CrossMatchEngine {
    config: CrossMatchConfig,
    /// Next correlation id for hedge orders.
    next_correlation_id: u64,
    // TODO: Track aggregate exposure for circuit breaker
    // current_exposure: Money,
}

impl CrossMatchEngine {
    /// Create a new cross-match engine with the given configuration.
    pub fn new(config: CrossMatchConfig) -> Self {
        Self {
            config,
            // Start with high IDs to avoid collision with user orders
            next_correlation_id: 1_000_000_000,
        }
    }

    /// Get the house account ID.
    pub fn house_account(&self) -> AccountId {
        self.config.house_account
    }

    /// Attempt to cross-match a resting user order.
    ///
    /// Supports both BACK and LAY orders:
    /// - BACK: House LAYs target runner, then LAYs other runners as hedge
    /// - LAY: House BACKs target runner, then BACKs other runners as hedge
    ///
    /// # Arguments
    /// * `book` - The order book (will be mutated if cross-match succeeds)
    /// * `user_order_id` - The resting order to cross-match
    ///
    /// # Returns
    /// Result indicating success, not possible, or failure.
    pub fn attempt_cross_match(
        &mut self,
        book: &mut Book,
        user_order_id: OrderId,
    ) -> CrossMatchResult {
        // Get the user's order
        let Some(user_order) = book.get_order(user_order_id) else {
            return CrossMatchResult::NotPossible {
                reason: "Order not found",
            };
        };

        // Check order is still resting
        if !book.is_resting(user_order_id) {
            return CrossMatchResult::NotPossible {
                reason: "Order is not resting",
            };
        }

        let target_runner = user_order.runner_id;
        let user_side = user_order.info.side;
        let user_odds = user_order.price;
        let user_stake = user_order.remaining();

        // Get all runners in the market
        let runners: Vec<RunnerId> = book.runners().collect();

        // Check runner count
        if runners.len() != 3 {
            return CrossMatchResult::NotPossible {
                reason: "Cross-matching only supported for 3-runner markets",
            };
        }
        if runners.len() > self.config.max_runners {
            return CrossMatchResult::NotPossible {
                reason: "Too many runners for cross-matching",
            };
        }

        // Get other runners and their best prices for hedging
        // - For BACK orders: we need BACK liquidity on other runners (house will LAY against it)
        // - For LAY orders: we need LAY liquidity on other runners (house will BACK against it)
        let mut other_runners: Vec<(RunnerId, OddsX10000, Money)> = Vec::new();

        for &runner_id in &runners {
            if runner_id == target_runner {
                continue;
            }

            let best_price = match user_side {
                Side::Yes => {
                    // House needs to LAY other runners, so find BACK orders to match against
                    book.best_lay_price(runner_id)
                }
                Side::No => {
                    // House needs to BACK other runners, so find LAY orders to match against
                    book.best_back_price(runner_id)
                }
            };

            let Some(price_size) = best_price else {
                let reason = match user_side {
                    Side::Yes => "No BACK liquidity on other runners",
                    Side::No => "No LAY liquidity on other runners",
                };
                return CrossMatchResult::NotPossible { reason };
            };

            other_runners.push((runner_id, price_size.price, price_size.size));
        }

        // Calculate hedge
        let tolerance = self.config.risk.effective_tolerance(user_stake);
        let hedge_input = HedgeInput {
            target_runner,
            user_side,
            user_odds,
            user_stake,
            other_runners,
            max_loss: tolerance,
        };

        let hedge_result = calculate_3runner_hedge(&hedge_input);

        let (hedge_legs, worst_case_pnl) = match hedge_result {
            HedgeResult::Success {
                legs,
                worst_case_pnl,
            } => (legs, worst_case_pnl),
            HedgeResult::NoSolution { reason } => {
                return CrossMatchResult::NotPossible { reason };
            }
        };

        // TODO: Check aggregate exposure circuit breaker
        // if self.current_exposure + worst_case_pnl.abs() > self.config.risk.max_total_exposure {
        //     return CrossMatchResult::NotPossible {
        //         reason: "Aggregate exposure limit reached",
        //     };
        // }

        // Build commands for execution
        let market_id = book.market_id();
        let mut commands = Vec::new();

        // 1. House takes opposite side of user's order
        let house_match_side = match user_side {
            Side::Yes => Side::No, // User backs, house lays
            Side::No => Side::Yes, // User lays, house backs
        };

        commands.push(self.make_place_order_cmd(
            market_id,
            target_runner,
            house_match_side,
            user_odds,
            user_stake,
        ));

        // 2. House places hedge legs (side comes from hedge calculation)
        for leg in &hedge_legs {
            commands.push(self.make_place_order_cmd(
                market_id,
                leg.runner_id,
                leg.side,
                leg.odds,
                leg.stake,
            ));
        }

        // Execute all commands sequentially, applying emitted events after each leg.
        let mut all_events: Vec<BookEventEnvelope> = Vec::new();
        for cmd in commands {
            match book.handle(&cmd) {
                Ok((events, _)) => {
                    book.apply_all_events(&events);
                    all_events.extend(events);
                }
                Err(_) => {
                    // Compensate by voiding any trades from successful legs
                    let trade_ids = extract_trade_ids(&all_events);
                    if !trade_ids.is_empty() {
                        let correlation_id =
                            crate::types::CorrelationId::from(self.next_correlation_id);
                        let void_cmd = Command {
                            correlation_id: Some(correlation_id),
                            market_id,
                            kind: CommandKind::VoidTradeIds {
                                trade_ids,
                                reason: "Cross-match hedge leg failed".to_string(),
                            },
                        };
                        self.next_correlation_id += 1;
                        if let Ok((void_events, _)) = book.handle(&void_cmd) {
                            book.apply_all_events(&void_events);
                            all_events.extend(void_events);
                        }
                    }
                    return CrossMatchResult::Failed {
                        reason: "Hedge leg rejected",
                        partial_events: all_events,
                    };
                }
            }
        }

        CrossMatchResult::Success {
            events: all_events,
            hedge_legs,
            worst_case_pnl,
        }
    }

    fn make_place_order_cmd(
        &mut self,
        market_id: MarketId,
        runner_id: RunnerId,
        side: Side,
        price: OddsX10000,
        stake: Money,
    ) -> Command {
        let correlation_id = crate::types::CorrelationId::from(self.next_correlation_id);
        self.next_correlation_id += 1;

        Command {
            correlation_id: Some(correlation_id),
            market_id,
            kind: CommandKind::PlaceOrder {
                runner_id,
                account_id: self.config.house_account,
                client_order_id: None,
                side,
                odds: price,
                stake,
                persistence: Persistence::Lapse,
                time_in_force: TimeInForce::FillOrKill { min_fill: None },
            },
        }
    }
}

/// Extract trade IDs from book events.
fn extract_trade_ids(events: &[BookEventEnvelope]) -> Vec<TradeId> {
    events
        .iter()
        .filter_map(|env| match &env.event {
            BookEvent::TradeMatched { trade_id, .. } => Some(*trade_id),
            _ => None,
        })
        .collect()
}