use crate::book::protocol::command::Side;
use crate::types::{Money, OddsX10000, RunnerId};
fn clamp_i128_to_i64(v: i128) -> i64 {
if v > i64::MAX as i128 {
i64::MAX
} else if v < i64::MIN as i128 {
i64::MIN
} else {
v as i64
}
}
fn profit_quanta(stake: Money, odds: OddsX10000) -> i64 {
let stake_i = stake.0 as i128;
let odds_i = odds.0 as i128;
let profit = (stake_i * (odds_i - 10_000)) / 10_000;
clamp_i128_to_i64(profit)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HedgeLeg {
pub runner_id: RunnerId,
pub odds: OddsX10000,
pub stake: Money,
pub side: Side,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HedgeResult {
Success {
legs: Vec<HedgeLeg>,
worst_case_pnl: Money,
},
NoSolution { reason: &'static str },
}
#[derive(Debug, Clone)]
pub struct HedgeInput {
pub target_runner: RunnerId,
pub user_side: Side,
pub user_odds: OddsX10000,
pub user_stake: Money,
pub other_runners: Vec<(RunnerId, OddsX10000, Money)>, pub max_loss: Money,
}
pub fn calculate_3runner_hedge(input: &HedgeInput) -> HedgeResult {
if input.other_runners.len() != 2 {
return HedgeResult::NoSolution {
reason: "Expected exactly 2 other runners for 3-runner market",
};
}
match input.user_side {
Side::Yes => calculate_back_hedge(input),
Side::No => calculate_lay_hedge(input),
}
}
fn calculate_back_hedge(input: &HedgeInput) -> HedgeResult {
let (runner_b, odds_b, size_b) = input.other_runners[0];
let (runner_c, odds_c, size_c) = input.other_runners[1];
let ob = odds_b.to_decimal();
let oc = odds_c.to_decimal();
if ob <= 1.0 || oc <= 1.0 {
return HedgeResult::NoSolution {
reason: "Invalid odds (must be > 1.0)",
};
}
let s = input.user_stake.0 as f64;
let slack = input.max_loss.0 as f64;
let liability = profit_quanta(input.user_stake, input.user_odds) as f64;
let min_hedge_required = (liability - slack).max(0.0);
let target = min_hedge_required;
let max_tb = (s + target + slack) / ob;
let max_tc = (s + target + slack) / oc;
let min_tb = (((oc - 1.0) * target - s - slack) / oc).max(0.0);
let min_tc = (((ob - 1.0) * target - s - slack) / ob).max(0.0);
if min_tb > max_tb || min_tc > max_tc || min_tb + min_tc > target {
return HedgeResult::NoSolution {
reason: "Insufficient hedge capacity within risk tolerance",
};
}
let (tb, tc) = find_valid_split_with_bounds(target, min_tb, max_tb, min_tc, max_tc);
if tb < 0.0 || tc < 0.0 {
return HedgeResult::NoSolution {
reason: "Could not find non-negative hedge stakes",
};
}
let tb_money = Money((tb.round() as i64).max(0));
let tc_money = Money((tc.round() as i64).max(0));
if tb_money.0 > size_b.0 {
return HedgeResult::NoSolution {
reason: "Insufficient liquidity on runner B",
};
}
if tc_money.0 > size_c.0 {
return HedgeResult::NoSolution {
reason: "Insufficient liquidity on runner C",
};
}
let pnl_a = -profit_quanta(input.user_stake, input.user_odds) + tb_money.0 + tc_money.0;
let pnl_b = input.user_stake.0 + tc_money.0 - profit_quanta(tb_money, odds_b);
let pnl_c = input.user_stake.0 + tb_money.0 - profit_quanta(tc_money, odds_c);
let worst_pnl = pnl_a.min(pnl_b).min(pnl_c);
if worst_pnl < -(input.max_loss.0) {
return HedgeResult::NoSolution {
reason: "Calculated hedge exceeds risk tolerance",
};
}
HedgeResult::Success {
legs: vec![
HedgeLeg {
runner_id: runner_b,
odds: odds_b,
stake: tb_money,
side: Side::No, },
HedgeLeg {
runner_id: runner_c,
odds: odds_c,
stake: tc_money,
side: Side::No,
},
],
worst_case_pnl: Money(worst_pnl),
}
}
fn calculate_lay_hedge(input: &HedgeInput) -> HedgeResult {
let (runner_b, odds_b, size_b) = input.other_runners[0];
let (runner_c, odds_c, size_c) = input.other_runners[1];
let ob = odds_b.to_decimal();
let oc = odds_c.to_decimal();
if ob <= 1.0 || oc <= 1.0 {
return HedgeResult::NoSolution {
reason: "Invalid odds (must be > 1.0)",
};
}
let s = input.user_stake.0 as f64;
let slack = input.max_loss.0 as f64;
let profit_if_a_wins = profit_quanta(input.user_stake, input.user_odds) as f64;
let max_total = profit_if_a_wins;
let result = find_lay_hedge_stakes(s, slack, ob, oc, max_total);
let (tb, tc) = match result {
Some((tb, tc)) => (tb, tc),
None => {
return HedgeResult::NoSolution {
reason: "No valid hedge exists for LAY order",
};
}
};
let profit_budget = profit_quanta(input.user_stake, input.user_odds);
let tb_floor = tb.floor().max(0.0) as i64;
let tb_ceil = tb.ceil().max(0.0) as i64;
let tc_floor = tc.floor().max(0.0) as i64;
let tc_ceil = tc.ceil().max(0.0) as i64;
let tb_min = tb_floor.saturating_sub(2);
let tb_max = tb_ceil.saturating_add(2);
let tc_min = tc_floor.saturating_sub(2);
let tc_max = tc_ceil.saturating_add(2);
let mut best: Option<(Money, Money, i64)> = None; let mut blocked_b = false;
let mut blocked_c = false;
for tb_i in tb_min..=tb_max {
for tc_i in tc_min..=tc_max {
let tb_money = Money(tb_i);
let tc_money = Money(tc_i);
if tb_money.0.saturating_add(tc_money.0) > profit_budget {
continue;
}
let pnl_a = profit_budget - tb_money.0 - tc_money.0;
let pnl_b = -input.user_stake.0 + profit_quanta(tb_money, odds_b) - tc_money.0;
let pnl_c = -input.user_stake.0 - tb_money.0 + profit_quanta(tc_money, odds_c);
let worst_pnl = pnl_a.min(pnl_b).min(pnl_c);
if worst_pnl < -(input.max_loss.0) {
continue;
}
if tb_money.0 > size_b.0 {
blocked_b = true;
continue;
}
if tc_money.0 > size_c.0 {
blocked_c = true;
continue;
}
best = match best {
None => Some((tb_money, tc_money, worst_pnl)),
Some((best_tb, best_tc, best_worst)) => {
let best_total = best_tb.0 + best_tc.0;
let total = tb_money.0 + tc_money.0;
if worst_pnl > best_worst || (worst_pnl == best_worst && total < best_total) {
Some((tb_money, tc_money, worst_pnl))
} else {
Some((best_tb, best_tc, best_worst))
}
}
};
}
}
let Some((tb_money, tc_money, worst_pnl)) = best else {
if blocked_b || blocked_c {
return HedgeResult::NoSolution {
reason: "Insufficient liquidity",
};
}
return HedgeResult::NoSolution {
reason: "Calculated hedge exceeds risk tolerance",
};
};
HedgeResult::Success {
legs: vec![
HedgeLeg {
runner_id: runner_b,
odds: odds_b,
stake: tb_money,
side: Side::Yes, },
HedgeLeg {
runner_id: runner_c,
odds: odds_c,
stake: tc_money,
side: Side::Yes,
},
],
worst_case_pnl: Money(worst_pnl),
}
}
fn find_lay_hedge_stakes(
s: f64,
slack: f64,
ob: f64,
oc: f64,
max_total: f64,
) -> Option<(f64, f64)> {
let min_tb_base = (s - slack) / (ob - 1.0);
let min_tc_base = (s - slack) / (oc - 1.0);
if min_tb_base <= 0.0 && min_tc_base <= 0.0 {
let total = max_total.min(s); return Some((total / 2.0, total / 2.0));
}
let mut tb = min_tb_base.max(0.0);
let mut tc = min_tc_base.max(0.0);
for _ in 0..10 {
let new_tb = ((s + tc - slack) / (ob - 1.0)).max(0.0);
let new_tc = ((s + tb - slack) / (oc - 1.0)).max(0.0);
if (new_tb - tb).abs() < 0.01 && (new_tc - tc).abs() < 0.01 {
break;
}
tb = new_tb;
tc = new_tc;
}
if tb + tc > max_total {
return None;
}
if tb < 0.0 || tc < 0.0 {
return None;
}
Some((tb, tc))
}
fn find_valid_split_with_bounds(
target: f64,
min_tb: f64,
max_tb: f64,
min_tc: f64,
max_tc: f64,
) -> (f64, f64) {
let tb_lower = min_tb.max(target - max_tc);
let tb_upper = max_tb.min(target - min_tc);
if tb_lower > tb_upper {
return (-1.0, -1.0); }
let tb = (tb_lower + tb_upper) / 2.0;
let tc = target - tb;
(tb, tc)
}
#[cfg(test)]
mod tests {
use super::*;
fn odds(decimal: f64) -> OddsX10000 {
OddsX10000::from_decimal(decimal)
}
fn cents(cents: i64) -> Money {
Money::from_cents(cents)
}
#[test]
fn test_back_worked_example_risk_free() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(2.00),
user_stake: Money::from_cents(10_000), other_runners: vec![
(RunnerId(2), odds(4.00), Money::from_cents(50_000)), (RunnerId(3), odds(4.00), Money::from_cents(50_000)), ],
max_loss: Money(0), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => {
assert_eq!(legs.len(), 2);
assert_eq!(legs[0].stake, Money::from_cents(5_000)); assert_eq!(legs[1].stake, Money::from_cents(5_000)); assert_eq!(legs[0].side, Side::No);
assert_eq!(legs[1].side, Side::No);
assert_eq!(worst_case_pnl, Money(0));
}
HedgeResult::NoSolution { reason } => {
panic!("Expected success, got: {}", reason);
}
}
}
#[test]
fn test_back_no_solution_when_odds_too_low() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(2.14),
user_stake: Money::from_cents(10_000),
other_runners: vec![
(RunnerId(2), odds(3.40), Money::from_cents(50_000)),
(RunnerId(3), odds(4.20), Money::from_cents(50_000)),
],
max_loss: Money(0),
};
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success { worst_case_pnl, .. } => {
assert!(worst_case_pnl.0 >= 0, "Should be risk-free");
}
HedgeResult::NoSolution { .. } => {
}
}
}
#[test]
fn test_back_with_tolerance_enables_more_matches() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(2.14),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(3.40), cents(50_000)),
(RunnerId(3), odds(4.20), cents(50_000)),
],
max_loss: cents(500), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success { worst_case_pnl, .. } => {
assert!(
worst_case_pnl.0 >= -cents(500).0,
"Should be within tolerance"
);
}
HedgeResult::NoSolution { .. } => {
}
}
}
#[test]
fn test_back_insufficient_liquidity() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(2.00),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(4.00), cents(1_000)),
(RunnerId(3), odds(4.00), cents(1_000)),
],
max_loss: Money(0),
};
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::NoSolution { reason } => {
assert!(reason.contains("liquidity"), "Should fail due to liquidity");
}
HedgeResult::Success { .. } => {
panic!("Should have failed due to insufficient liquidity");
}
}
}
#[test]
fn test_back_asymmetric_odds() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(2.00),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(3.00), cents(100_000)),
(RunnerId(3), odds(6.00), cents(100_000)),
],
max_loss: cents(1_000), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => {
let total_stake: i64 = legs.iter().map(|l| l.stake.0).sum();
assert!(
total_stake >= cents(9_000).0,
"Should cover liability minus tolerance"
);
assert!(
worst_case_pnl.0 >= -cents(1_000).0,
"Should be within tolerance"
);
}
HedgeResult::NoSolution { reason } => {
panic!("Expected success, got: {}", reason);
}
}
}
#[test]
fn test_back_partial_coverage_with_tolerance() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(2.00),
user_stake: cents(10_000), other_runners: vec![
(RunnerId(2), odds(4.00), cents(4_000)), (RunnerId(3), odds(4.00), cents(4_000)), ],
max_loss: cents(5_000), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => {
let total_stake: i64 = legs.iter().map(|l| l.stake.0).sum();
assert_eq!(
total_stake,
cents(5_000).0,
"Should hedge liability minus tolerance"
);
assert!(
worst_case_pnl.0 >= -cents(5_000).0,
"Should be within tolerance"
);
}
HedgeResult::NoSolution { reason } => {
panic!("Should succeed with partial coverage, got: {}", reason);
}
}
}
#[test]
fn test_back_tolerance_exceeds_liability() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::Yes,
user_odds: odds(1.50),
user_stake: cents(10_000), other_runners: vec![
(RunnerId(2), odds(4.00), cents(1_000)), (RunnerId(3), odds(4.00), cents(1_000)),
],
max_loss: cents(10_000), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => {
let total_stake: i64 = legs.iter().map(|l| l.stake.0).sum();
assert_eq!(total_stake, 0, "Should require zero hedge");
assert!(
worst_case_pnl.0 >= -cents(10_000).0,
"Should be within tolerance"
);
}
HedgeResult::NoSolution { reason } => {
panic!("Should succeed with zero hedge, got: {}", reason);
}
}
}
#[test]
fn test_lay_worked_example_risk_free() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::No,
user_odds: odds(2.00),
user_stake: cents(10_000), other_runners: vec![
(RunnerId(2), odds(4.00), cents(50_000)), (RunnerId(3), odds(4.00), cents(50_000)), ],
max_loss: Money(0), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => {
assert_eq!(legs.len(), 2);
assert_eq!(legs[0].side, Side::Yes);
assert_eq!(legs[1].side, Side::Yes);
let total_stake: i64 = legs.iter().map(|l| l.stake.0).sum();
assert!(
total_stake <= cents(10_000).0,
"Should not exceed profit budget"
);
assert!(worst_case_pnl.0 >= 0, "Should be risk-free");
}
HedgeResult::NoSolution { reason } => {
panic!("Expected success, got: {}", reason);
}
}
}
#[test]
fn test_lay_symmetric_odds() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::No,
user_odds: odds(2.00),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(4.00), cents(50_000)),
(RunnerId(3), odds(4.00), cents(50_000)),
],
max_loss: Money(0),
};
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success {
legs,
worst_case_pnl,
} => {
let total_stake: i64 = legs.iter().map(|l| l.stake.0).sum();
assert!(total_stake <= cents(10_000).0, "Within budget");
assert!(worst_case_pnl.0 >= 0, "Risk-free");
}
HedgeResult::NoSolution { reason } => {
panic!("Expected success, got: {}", reason);
}
}
}
#[test]
fn test_lay_insufficient_liquidity() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::No,
user_odds: odds(2.00),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(4.00), cents(1_000)), (RunnerId(3), odds(4.00), cents(1_000)),
],
max_loss: Money(0),
};
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::NoSolution { reason } => {
assert!(reason.contains("liquidity"), "Should fail due to liquidity");
}
HedgeResult::Success { .. } => {
panic!("Should have failed due to insufficient liquidity");
}
}
}
#[test]
fn test_lay_low_odds_no_solution() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::No,
user_odds: odds(1.50),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(2.00), cents(50_000)),
(RunnerId(3), odds(2.00), cents(50_000)),
],
max_loss: Money(0),
};
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::NoSolution { .. } => {
}
HedgeResult::Success { worst_case_pnl, .. } => {
assert!(worst_case_pnl.0 >= 0);
}
}
}
#[test]
fn test_lay_with_tolerance() {
let input = HedgeInput {
target_runner: RunnerId(1),
user_side: Side::No,
user_odds: odds(1.50),
user_stake: cents(10_000),
other_runners: vec![
(RunnerId(2), odds(3.00), cents(50_000)),
(RunnerId(3), odds(3.00), cents(50_000)),
],
max_loss: cents(1_000), };
let result = calculate_3runner_hedge(&input);
match result {
HedgeResult::Success { worst_case_pnl, .. } => {
assert!(
worst_case_pnl.0 >= -cents(1_000).0,
"Should be within tolerance"
);
}
HedgeResult::NoSolution { .. } => {
}
}
}
}