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};
#[derive(Debug, Clone)]
pub enum CrossMatchResult {
Success {
events: Vec<BookEventEnvelope>,
hedge_legs: Vec<HedgeLeg>,
worst_case_pnl: Money,
},
NotPossible { reason: &'static str },
Failed {
reason: &'static str,
partial_events: Vec<BookEventEnvelope>,
},
}
pub struct CrossMatchEngine {
config: CrossMatchConfig,
next_correlation_id: u64,
}
impl CrossMatchEngine {
pub fn new(config: CrossMatchConfig) -> Self {
Self {
config,
next_correlation_id: 1_000_000_000,
}
}
pub fn house_account(&self) -> AccountId {
self.config.house_account
}
pub fn attempt_cross_match(
&mut self,
book: &mut Book,
user_order_id: OrderId,
) -> CrossMatchResult {
let Some(user_order) = book.get_order(user_order_id) else {
return CrossMatchResult::NotPossible {
reason: "Order not found",
};
};
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();
let runners: Vec<RunnerId> = book.runners().collect();
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",
};
}
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 => {
book.best_lay_price(runner_id)
}
Side::No => {
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));
}
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 };
}
};
let market_id = book.market_id();
let mut commands = Vec::new();
let house_match_side = match user_side {
Side::Yes => Side::No, Side::No => Side::Yes, };
commands.push(self.make_place_order_cmd(
market_id,
target_runner,
house_match_side,
user_odds,
user_stake,
));
for leg in &hedge_legs {
commands.push(self.make_place_order_cmd(
market_id,
leg.runner_id,
leg.side,
leg.odds,
leg.stake,
));
}
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(_) => {
let trade_ids = extract_trade_ids(&all_events);
if !trade_ids.is_empty() {
let void_cmd = Command {
correlation_id: crate::types::CorrelationId(self.next_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(self.next_correlation_id);
self.next_correlation_id += 1;
Command {
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 },
},
}
}
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::book::protocol::command::{Command, CommandKind, Persistence, TimeInForce};
use crate::book::{Book, BookEvent};
use crate::types::{ClientOrderId, CorrelationId};
fn setup_3runner_book() -> Book {
Book::new(MarketId(1), vec![RunnerId(1), RunnerId(2), RunnerId(3)])
}
#[allow(clippy::too_many_arguments)]
fn place_order(
book: &mut Book,
cmd_id: u64,
client_order_id: u64,
account_id: u64,
runner_id: u32,
side: Side,
odds: f64,
stake_cents: i64,
) -> OrderId {
let cmd = Command {
correlation_id: CorrelationId(cmd_id),
market_id: book.market_id(),
kind: CommandKind::PlaceOrder {
runner_id: RunnerId(runner_id),
account_id: AccountId(account_id),
client_order_id: Some(ClientOrderId(client_order_id)),
side,
odds: OddsX10000::from_decimal(odds),
stake: Money::from_cents(stake_cents),
persistence: Persistence::Persist,
time_in_force: TimeInForce::Gtc,
},
};
let (events, _) = book.handle(&cmd).expect("place order should succeed");
book.apply_all_events(&events);
events
.iter()
.find_map(|env| match env.event {
BookEvent::OrderAccepted { order_id, .. } => Some(order_id),
_ => None,
})
.expect("expected OrderAccepted")
}
#[test]
fn test_back_cross_match_basic_success() {
let mut book = setup_3runner_book();
place_order(&mut book, 1, 100, 10, 2, Side::Yes, 4.00, 50_000); place_order(&mut book, 2, 101, 11, 3, Side::Yes, 4.00, 50_000);
let user_order_id = place_order(&mut book, 3, 200, 20, 1, Side::Yes, 2.00, 10_000);
assert!(book.is_resting(user_order_id));
let config = CrossMatchConfig {
risk: RiskTolerance::risk_free(),
..Default::default()
};
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::Success {
events,
hedge_legs,
worst_case_pnl,
} => {
assert!(!events.is_empty(), "Should have events");
assert_eq!(hedge_legs.len(), 2, "Should have 2 hedge legs");
assert_eq!(worst_case_pnl, Money(0), "Should be risk-free");
for leg in &hedge_legs {
assert_eq!(leg.side, Side::No);
}
let total_hedge: i64 = hedge_legs.iter().map(|l| l.stake.0).sum();
assert_eq!(
total_hedge,
Money::from_cents(10_000).0,
"Hedge should cover $100 liability"
);
}
CrossMatchResult::NotPossible { reason } => {
panic!("Cross-match should succeed, got: {}", reason);
}
CrossMatchResult::Failed { reason, .. } => {
panic!("Cross-match failed: {}", reason);
}
}
}
#[test]
fn test_back_cross_match_no_liquidity() {
let mut book = setup_3runner_book();
let user_order_id = place_order(&mut book, 1, 200, 20, 1, Side::Yes, 2.00, 10_000);
let config = CrossMatchConfig::default();
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::NotPossible { reason } => {
assert!(
reason.contains("liquidity"),
"Should fail due to no liquidity"
);
}
_ => panic!("Should fail due to no liquidity"),
}
}
#[test]
fn test_cross_match_not_3_runners() {
let mut book = Book::new(MarketId(1), vec![RunnerId(1), RunnerId(2)]);
let user_order_id = place_order(&mut book, 1, 200, 20, 1, Side::Yes, 2.00, 10_000);
let config = CrossMatchConfig::default();
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::NotPossible { reason } => {
assert!(
reason.contains("3-runner"),
"Should fail due to runner count"
);
}
_ => panic!("Should fail due to runner count"),
}
}
#[test]
fn test_cross_match_order_not_found() {
let mut book = setup_3runner_book();
let mut engine = CrossMatchEngine::new(CrossMatchConfig::default());
let result = engine.attempt_cross_match(&mut book, OrderId(999));
match result {
CrossMatchResult::NotPossible { reason } => {
assert_eq!(reason, "Order not found");
}
_ => panic!("Expected Order not found"),
}
}
#[test]
fn test_cross_match_max_runners_limit() {
let mut book = setup_3runner_book();
let user_order_id = place_order(&mut book, 1, 200, 20, 1, Side::Yes, 2.00, 10_000);
let config = CrossMatchConfig {
max_runners: 2,
..CrossMatchConfig::default()
};
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::NotPossible { reason } => {
assert_eq!(reason, "Too many runners for cross-matching");
}
_ => panic!("Expected max runner limit rejection"),
}
}
#[test]
fn test_lay_cross_match_basic_success() {
let mut book = setup_3runner_book();
place_order(&mut book, 1, 100, 10, 2, Side::No, 4.00, 50_000); place_order(&mut book, 2, 101, 11, 3, Side::No, 4.00, 50_000);
let user_order_id = place_order(&mut book, 3, 200, 20, 1, Side::No, 2.00, 10_000);
assert!(book.is_resting(user_order_id));
let config = CrossMatchConfig {
risk: RiskTolerance::risk_free(),
..Default::default()
};
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::Success {
events,
hedge_legs,
worst_case_pnl,
} => {
assert!(!events.is_empty(), "Should have events");
assert_eq!(hedge_legs.len(), 2, "Should have 2 hedge legs");
assert!(worst_case_pnl.0 >= 0, "Should be risk-free or better");
for leg in &hedge_legs {
assert_eq!(leg.side, Side::Yes);
}
let total_hedge: i64 = hedge_legs.iter().map(|l| l.stake.0).sum();
assert!(
total_hedge <= Money::from_cents(10_000).0,
"Hedge should be within profit budget"
);
}
CrossMatchResult::NotPossible { reason } => {
panic!("Cross-match should succeed, got: {}", reason);
}
CrossMatchResult::Failed { reason, .. } => {
panic!("Cross-match failed: {}", reason);
}
}
}
#[test]
fn test_lay_cross_match_no_liquidity() {
let mut book = setup_3runner_book();
let user_order_id = place_order(&mut book, 1, 200, 20, 1, Side::No, 2.00, 10_000);
let config = CrossMatchConfig::default();
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::NotPossible { reason } => {
assert!(
reason.contains("liquidity"),
"Should fail due to no liquidity"
);
}
_ => panic!("Should fail due to no liquidity"),
}
}
#[test]
fn test_lay_cross_match_low_odds_no_solution() {
let mut book = setup_3runner_book();
place_order(&mut book, 1, 100, 10, 2, Side::No, 2.00, 50_000);
place_order(&mut book, 2, 101, 11, 3, Side::No, 2.00, 50_000);
let user_order_id = place_order(&mut book, 3, 200, 20, 1, Side::No, 1.10, 10_000);
let config = CrossMatchConfig {
risk: RiskTolerance::risk_free(),
..Default::default()
};
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::NotPossible { .. } => {
}
CrossMatchResult::Success { worst_case_pnl, .. } => {
assert!(worst_case_pnl.0 >= 0);
}
_ => {}
}
}
#[test]
fn test_lay_cross_match_with_tolerance() {
let mut book = setup_3runner_book();
place_order(&mut book, 1, 100, 10, 2, Side::No, 3.00, 50_000);
place_order(&mut book, 2, 101, 11, 3, Side::No, 3.00, 50_000);
let user_order_id = place_order(&mut book, 3, 200, 20, 1, Side::No, 1.50, 10_000);
let config = CrossMatchConfig {
risk: RiskTolerance::moderate(), ..Default::default()
};
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::Success { worst_case_pnl, .. } => {
assert!(worst_case_pnl.0 >= -10_000, "Should be within tolerance");
}
CrossMatchResult::NotPossible { .. } => {
}
_ => {}
}
}
#[test]
fn test_hedge_failure_voids_successful_trades() {
let mut book = setup_3runner_book();
place_order(&mut book, 1, 100, 10, 2, Side::Yes, 4.00, 50_000); place_order(&mut book, 2, 101, 11, 3, Side::Yes, 4.00, 100);
let user_order_id = place_order(&mut book, 3, 200, 20, 1, Side::Yes, 2.00, 10_000);
assert!(book.is_resting(user_order_id));
let config = CrossMatchConfig {
risk: RiskTolerance::risk_free(),
..Default::default()
};
let mut engine = CrossMatchEngine::new(config);
let result = engine.attempt_cross_match(&mut book, user_order_id);
match result {
CrossMatchResult::Failed {
reason,
partial_events,
} => {
assert_eq!(reason, "Hedge leg rejected");
let trade_voids: Vec<_> = partial_events
.iter()
.filter(|e| matches!(e.event, BookEvent::TradeVoided { .. }))
.collect();
let trades_matched: Vec<_> = partial_events
.iter()
.filter(|e| matches!(e.event, BookEvent::TradeMatched { .. }))
.collect();
if !trades_matched.is_empty() {
assert_eq!(
trade_voids.len(),
trades_matched.len(),
"Each matched trade should have a corresponding void"
);
}
}
CrossMatchResult::NotPossible { reason } => {
assert!(
reason.contains("liquidity") || reason.contains("solution"),
"Expected liquidity or solution related reason, got: {}",
reason
);
}
CrossMatchResult::Success { .. } => {
panic!("Expected failure due to insufficient liquidity on runner 3");
}
}
}
}