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, DateTime, MarketId, Money, OddsX10000, OrderId, RunnerId};
use chrono::Utc;
#[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.clone()
}
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() > self.config.max_runners {
return CrossMatchResult::NotPossible {
reason: "Too many runners for cross-matching",
};
}
if runners.len() != 3 {
return CrossMatchResult::NotPossible {
reason: "Cross-matching only supported for 3-runner markets",
};
}
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_raw, worst_case_pnl) = match hedge_result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => (legs, worst_case_pnl),
HedgeResult::NoSolution { reason } => {
return CrossMatchResult::NotPossible { reason };
}
};
let hedge_legs: Vec<HedgeLeg> = hedge_legs_raw
.into_iter()
.filter(|leg| leg.stake.is_positive())
.collect();
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 compensation_failed = if let Some((start_time, end_time)) =
extract_trade_match_window(&all_events)
{
self.append_compensation_void(
book,
market_id,
Utc::now(),
start_time,
end_time,
&mut all_events,
)
.is_err()
} else {
false
};
return CrossMatchResult::Failed {
reason: if compensation_failed {
"Hedge leg rejected; compensation void failed"
} else {
"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.to_string());
self.next_correlation_id += 1;
Command {
correlation_id: Some(correlation_id),
metadata: None,
market_id,
kind: CommandKind::PlaceOrder {
runner_id,
account_id: self.config.house_account.clone(),
client_order_id: None,
side,
odds: price,
stake,
persistence: Persistence::Lapse,
time_in_force: TimeInForce::FillOrKill { min_fill: None },
},
}
}
fn append_compensation_void(
&mut self,
book: &mut Book,
market_id: MarketId,
timestamp: DateTime,
start_time: DateTime,
end_time: DateTime,
all_events: &mut Vec<BookEventEnvelope>,
) -> Result<(), ()> {
let correlation_id = crate::types::CorrelationId(self.next_correlation_id.to_string());
let void_cmd = Command {
correlation_id: Some(correlation_id),
metadata: None,
market_id,
kind: CommandKind::VoidTrades {
timestamp,
start_time,
end_time,
void_reason: "Cross-match hedge leg failed".to_string(),
},
};
self.next_correlation_id += 1;
let (void_events, _) = book.handle(&void_cmd).map_err(|_| ())?;
book.apply_all_events(&void_events);
all_events.extend(void_events);
Ok(())
}
}
fn extract_trade_match_window(events: &[BookEventEnvelope]) -> Option<(DateTime, DateTime)> {
let mut window: Option<(DateTime, DateTime)> = None;
for env in events {
if !matches!(&env.event, BookEvent::TradeMatched { .. }) {
continue;
}
window = Some(match window {
Some((start, end)) => (start.min(env.timestamp), end.max(env.timestamp)),
None => (env.timestamp, env.timestamp),
});
}
window
}
#[cfg(test)]
mod tests {
use super::*;
use crate::book::BookEvent;
use crate::book::protocol::command::{Command, CommandKind};
use crate::types::{CorrelationId, RunnerId, unix_epoch};
fn exec(book: &mut Book, cmd: Command) -> Vec<BookEventEnvelope> {
let (events, _) = book.handle(&cmd).expect("command should succeed");
book.apply_all_events(&events);
events
}
#[test]
fn append_compensation_void_appends_void_event() {
let market_id = MarketId(7001);
let mut engine = CrossMatchEngine::new(CrossMatchConfig::default());
let mut book = Book::new_multi_runner(market_id, [RunnerId(1), RunnerId(2), RunnerId(3)]);
let mut events = Vec::new();
let res = engine.append_compensation_void(
&mut book,
market_id,
unix_epoch(),
unix_epoch(),
unix_epoch(),
&mut events,
);
assert!(res.is_ok());
assert!(events.iter().any(|e| {
matches!(
e.event,
BookEvent::VoidTrades {
void_reason: ref r,
..
} if r == "Cross-match hedge leg failed"
)
}));
}
#[test]
fn append_compensation_void_returns_error_when_void_rejected() {
let market_id = MarketId(7002);
let mut engine = CrossMatchEngine::new(CrossMatchConfig::default());
let mut book = Book::new_multi_runner(market_id, [RunnerId(1), RunnerId(2), RunnerId(3)]);
let _ = exec(
&mut book,
Command {
correlation_id: Some(CorrelationId(1.to_string())),
metadata: None,
market_id,
kind: CommandKind::CloseMarket {
reason: "TEST_CLOSE".to_string(),
},
},
);
let mut events = Vec::new();
let res = engine.append_compensation_void(
&mut book,
market_id,
unix_epoch(),
unix_epoch(),
unix_epoch(),
&mut events,
);
assert!(res.is_err(), "void should be rejected on terminal market");
assert!(events.is_empty());
}
}