use std::fmt::Display;
use rust_decimal::Decimal;
use crate::enums::{BetSide, OrderSide, OrderSideSpecified};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
)]
pub struct Bet {
price: Decimal,
stake: Decimal,
side: BetSide,
}
impl Bet {
#[must_use]
pub fn new(price: Decimal, stake: Decimal, side: BetSide) -> Self {
Self { price, stake, side }
}
#[must_use]
pub fn price(&self) -> Decimal {
self.price
}
#[must_use]
pub fn stake(&self) -> Decimal {
self.stake
}
#[must_use]
pub fn side(&self) -> BetSide {
self.side
}
#[must_use]
pub fn from_stake_or_liability(price: Decimal, volume: Decimal, side: BetSide) -> Self {
Self::from_stake_or_liability_checked(price, volume, side).unwrap_or_else(|e| panic!("{e}"))
}
pub fn from_stake_or_liability_checked(
price: Decimal,
volume: Decimal,
side: BetSide,
) -> anyhow::Result<Self> {
match side {
BetSide::Back => Ok(Self::from_stake(price, volume, side)),
BetSide::Lay => Self::from_liability_checked(price, volume, side),
}
}
#[must_use]
pub fn from_stake(price: Decimal, stake: Decimal, side: BetSide) -> Self {
Self::new(price, stake, side)
}
#[must_use]
pub fn from_liability(price: Decimal, liability: Decimal, side: BetSide) -> Self {
Self::from_liability_checked(price, liability, side).unwrap_or_else(|e| panic!("{e}"))
}
pub fn from_liability_checked(
price: Decimal,
liability: Decimal,
side: BetSide,
) -> anyhow::Result<Self> {
if side != BetSide::Lay {
anyhow::bail!("Liability-based betting is only applicable for Lay side.");
}
check_odds_gt_one(price)?;
let stake = checked_div(liability, checked_sub(price, Decimal::ONE)?)?;
Ok(Self::new(price, stake, side))
}
#[must_use]
pub fn exposure(&self) -> Decimal {
self.exposure_checked().unwrap_or_else(|e| panic!("{e}"))
}
pub fn exposure_checked(&self) -> anyhow::Result<Decimal> {
let notional = checked_mul(self.price, self.stake)?;
Ok(match self.side {
BetSide::Back => notional,
BetSide::Lay => -notional,
})
}
#[must_use]
pub fn liability(&self) -> Decimal {
self.liability_checked().unwrap_or_else(|e| panic!("{e}"))
}
pub fn liability_checked(&self) -> anyhow::Result<Decimal> {
match self.side {
BetSide::Back => Ok(self.stake),
BetSide::Lay => checked_mul(self.stake, checked_sub(self.price, Decimal::ONE)?),
}
}
#[must_use]
pub fn profit(&self) -> Decimal {
self.profit_checked().unwrap_or_else(|e| panic!("{e}"))
}
pub fn profit_checked(&self) -> anyhow::Result<Decimal> {
match self.side {
BetSide::Back => checked_mul(self.stake, checked_sub(self.price, Decimal::ONE)?),
BetSide::Lay => Ok(self.stake),
}
}
#[must_use]
pub fn outcome_win_payoff(&self) -> Decimal {
self.outcome_win_payoff_checked()
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn outcome_win_payoff_checked(&self) -> anyhow::Result<Decimal> {
match self.side {
BetSide::Back => self.profit_checked(),
BetSide::Lay => Ok(-self.liability_checked()?),
}
}
#[must_use]
pub fn outcome_lose_payoff(&self) -> Decimal {
self.outcome_lose_payoff_checked()
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn outcome_lose_payoff_checked(&self) -> anyhow::Result<Decimal> {
match self.side {
BetSide::Back => Ok(-self.liability_checked()?),
BetSide::Lay => self.profit_checked(),
}
}
#[must_use]
pub fn hedging_stake(&self, price: Decimal) -> Decimal {
self.hedging_stake_checked(price)
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn hedging_stake_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
match self.side {
BetSide::Back => checked_mul(checked_div(self.price, price)?, self.stake),
BetSide::Lay => checked_div(self.stake, checked_div(price, self.price)?),
}
}
#[must_use]
pub fn hedging_bet(&self, price: Decimal) -> Self {
self.hedging_bet_checked(price)
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn hedging_bet_checked(&self, price: Decimal) -> anyhow::Result<Self> {
Ok(Self::new(
price,
self.hedging_stake_checked(price)?,
self.side.opposite(),
))
}
}
impl Display for Bet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Bet({:?} @ {:.2} x{:.2})",
self.side, self.price, self.stake
)
}
}
#[derive(Debug, Clone)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
)]
pub struct BetPosition {
price: Decimal,
exposure: Decimal,
realized_pnl: Decimal,
bets: Vec<Bet>,
}
impl Default for BetPosition {
fn default() -> Self {
Self {
price: Decimal::ZERO,
exposure: Decimal::ZERO,
realized_pnl: Decimal::ZERO,
bets: vec![],
}
}
}
impl BetPosition {
#[must_use]
pub fn price(&self) -> Decimal {
self.price
}
#[must_use]
pub fn exposure(&self) -> Decimal {
self.exposure
}
#[must_use]
pub fn realized_pnl(&self) -> Decimal {
self.realized_pnl
}
#[must_use]
pub fn bets(&self) -> &[Bet] {
&self.bets
}
#[must_use]
pub fn side(&self) -> Option<BetSide> {
match self.exposure.cmp(&Decimal::ZERO) {
std::cmp::Ordering::Less => Some(BetSide::Lay),
std::cmp::Ordering::Greater => Some(BetSide::Back),
std::cmp::Ordering::Equal => None,
}
}
#[must_use]
pub fn as_bet(&self) -> Option<Bet> {
self.as_bet_checked().unwrap_or_else(|e| panic!("{e}"))
}
pub fn as_bet_checked(&self) -> anyhow::Result<Option<Bet>> {
let Some(side) = self.side() else {
return Ok(None);
};
check_nonzero_denominator(self.price, "price")?;
let stake = match side {
BetSide::Back => checked_div(self.exposure, self.price)?,
BetSide::Lay => checked_div(-self.exposure, self.price)?,
};
Ok(Some(Bet::new(self.price, stake, side)))
}
pub fn add_bet(&mut self, bet: Bet) {
match self.side() {
None => self.position_increase(&bet),
Some(current_side) => {
if current_side == bet.side {
self.position_increase(&bet);
} else {
self.position_decrease(&bet);
}
}
}
self.bets.push(bet);
}
pub fn add_bet_checked(&mut self, bet: Bet) -> anyhow::Result<()> {
let (price, exposure, realized_pnl) = match self.side() {
None => self.increased_state(&bet)?,
Some(current_side) if current_side == bet.side => self.increased_state(&bet)?,
Some(_) => self.decreased_state(&bet)?,
};
self.price = price;
self.exposure = exposure;
self.realized_pnl = realized_pnl;
self.bets.push(bet);
Ok(())
}
fn increased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
let bet_exposure = bet.exposure_checked()?;
let price = if self.side().is_none() {
bet.price
} else if self.side() == Some(bet.side)
&& self.price > Decimal::ZERO
&& bet.price > Decimal::ZERO
&& bet.stake > Decimal::ZERO
{
let abs_self_exposure = self.exposure.abs();
let abs_bet_exposure = bet_exposure.abs();
let total_stake = checked_add(checked_div(abs_self_exposure, self.price)?, bet.stake)?;
checked_div(
checked_add(abs_self_exposure, abs_bet_exposure)?,
total_stake,
)?
} else {
self.price
};
Ok((
price,
checked_add(self.exposure, bet_exposure)?,
self.realized_pnl,
))
}
fn decreased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
let current_side = self
.side()
.ok_or_else(|| anyhow::anyhow!("cannot decrease an empty bet position"))?;
let bet_exposure = bet.exposure_checked()?;
let abs_bet_exposure = bet_exposure.abs();
let abs_self_exposure = self.exposure.abs();
match abs_bet_exposure.cmp(&abs_self_exposure) {
std::cmp::Ordering::Less => {
check_nonzero_denominator(self.price, "price")?;
let decreasing_volume = checked_div(abs_bet_exposure, self.price)?;
let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
let pnl = calc_bets_pnl_checked(&[bet.clone(), decreasing_bet])?;
Ok((
self.price,
checked_add(self.exposure, bet_exposure)?,
checked_add(self.realized_pnl, pnl)?,
))
}
std::cmp::Ordering::Greater => Ok((
bet.price,
checked_add(self.exposure, bet_exposure)?,
self.realized_after_close(bet)?,
)),
std::cmp::Ordering::Equal => Ok((
Decimal::ZERO,
Decimal::ZERO,
self.realized_after_close(bet)?,
)),
}
}
fn realized_after_close(&self, bet: &Bet) -> anyhow::Result<Decimal> {
match self.as_bet_checked()? {
Some(self_bet) => checked_add(
self.realized_pnl,
calc_bets_pnl_checked(&[bet.clone(), self_bet])?,
),
None => Ok(self.realized_pnl),
}
}
pub fn position_increase(&mut self, bet: &Bet) {
if self.side().is_none() {
self.price = bet.price;
} else {
let abs_self_exposure = self.exposure.abs();
let abs_bet_exposure = bet.exposure().abs();
if self.side() == Some(bet.side)
&& self.price > Decimal::ZERO
&& bet.price > Decimal::ZERO
&& bet.stake > Decimal::ZERO
{
let total_stake = abs_self_exposure / self.price + bet.stake;
self.price = (abs_self_exposure + abs_bet_exposure) / total_stake;
}
}
self.exposure += bet.exposure();
}
pub fn position_decrease(&mut self, bet: &Bet) {
let abs_bet_exposure = bet.exposure().abs();
let abs_self_exposure = self.exposure.abs();
match abs_bet_exposure.cmp(&abs_self_exposure) {
std::cmp::Ordering::Less => {
let decreasing_volume = abs_bet_exposure / self.price;
let current_side = self.side().unwrap();
let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
let pnl = calc_bets_pnl(&[bet.clone(), decreasing_bet]);
self.realized_pnl += pnl;
self.exposure += bet.exposure();
}
std::cmp::Ordering::Greater => {
if let Some(self_bet) = self.as_bet() {
let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
self.realized_pnl += pnl;
}
self.price = bet.price;
self.exposure += bet.exposure();
}
std::cmp::Ordering::Equal => {
if let Some(self_bet) = self.as_bet() {
let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
self.realized_pnl += pnl;
}
self.price = Decimal::ZERO;
self.exposure = Decimal::ZERO;
}
}
}
#[must_use]
pub fn unrealized_pnl(&self, price: Decimal) -> Decimal {
self.unrealized_pnl_checked(price)
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn unrealized_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
if self.side().is_none() {
return Ok(Decimal::ZERO);
}
let Some(flattening_bet) = self.flattening_bet_checked(price)? else {
return Ok(Decimal::ZERO);
};
let Some(self_bet) = self.as_bet_checked()? else {
return Ok(Decimal::ZERO);
};
calc_bets_pnl_checked(&[flattening_bet, self_bet])
}
#[must_use]
pub fn total_pnl(&self, price: Decimal) -> Decimal {
self.total_pnl_checked(price)
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn total_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
checked_add(self.realized_pnl, self.unrealized_pnl_checked(price)?)
}
#[must_use]
pub fn flattening_bet(&self, price: Decimal) -> Option<Bet> {
self.flattening_bet_checked(price)
.unwrap_or_else(|e| panic!("{e}"))
}
pub fn flattening_bet_checked(&self, price: Decimal) -> anyhow::Result<Option<Bet>> {
let Some(side) = self.side() else {
return Ok(None);
};
check_nonzero_denominator(price, "price")?;
let stake = match side {
BetSide::Back => checked_div(self.exposure, price)?,
BetSide::Lay => checked_div(-self.exposure, price)?,
};
Ok(Some(Bet::new(price, stake, side.opposite())))
}
pub fn reset(&mut self) {
self.price = Decimal::ZERO;
self.exposure = Decimal::ZERO;
self.realized_pnl = Decimal::ZERO;
self.bets.clear();
}
}
impl Display for BetPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"BetPosition(price: {:.2}, exposure: {:.2}, realized_pnl: {:.2})",
self.price, self.exposure, self.realized_pnl
)
}
}
#[must_use]
pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
calc_bets_pnl_checked(bets).unwrap_or_else(|e| panic!("{e}"))
}
pub fn calc_bets_pnl_checked(bets: &[Bet]) -> anyhow::Result<Decimal> {
bets.iter().try_fold(Decimal::ZERO, |acc, bet| {
checked_add(acc, bet.outcome_win_payoff_checked()?)
})
}
pub fn check_probability_non_zero(probability: Decimal) -> anyhow::Result<()> {
if probability.is_zero() {
anyhow::bail!("invalid probability: must be non-zero")
}
Ok(())
}
pub fn check_probability_invertible(probability: Decimal) -> anyhow::Result<()> {
if probability == Decimal::ONE {
anyhow::bail!("invalid probability: must not be 1.0 (inverse would be zero)")
}
Ok(())
}
pub fn probability_to_bet(
probability: Decimal,
volume: Decimal,
side: OrderSideSpecified,
) -> anyhow::Result<Bet> {
check_probability_non_zero(probability)?;
let price = checked_div(Decimal::ONE, probability)?;
let stake = checked_div(volume, price)?;
let bet = match side {
OrderSideSpecified::Buy => Bet::new(price, stake, BetSide::Back),
OrderSideSpecified::Sell => Bet::new(price, stake, BetSide::Lay),
};
Ok(bet)
}
pub fn inverse_probability_to_bet(
probability: Decimal,
volume: Decimal,
side: OrderSideSpecified,
) -> anyhow::Result<Bet> {
check_probability_invertible(probability)?;
let inverse_probability = checked_sub(Decimal::ONE, probability)?;
let inverse_side = match side {
OrderSideSpecified::Buy => OrderSideSpecified::Sell,
OrderSideSpecified::Sell => OrderSideSpecified::Buy,
};
probability_to_bet(inverse_probability, volume, inverse_side)
}
fn check_odds_gt_one(price: Decimal) -> anyhow::Result<()> {
if price <= Decimal::ONE {
anyhow::bail!("Price must be greater than 1.0 for lay liability calculation, was {price}");
}
Ok(())
}
fn check_nonzero_denominator(value: Decimal, name: &str) -> anyhow::Result<()> {
if value.is_zero() {
anyhow::bail!("invalid {name}: must be non-zero")
}
Ok(())
}
pub fn specified_order_side(side: OrderSide) -> anyhow::Result<OrderSideSpecified> {
match side {
OrderSide::Buy => Ok(OrderSideSpecified::Buy),
OrderSide::Sell => Ok(OrderSideSpecified::Sell),
OrderSide::NoOrderSide => {
anyhow::bail!("invalid OrderSide: must be Buy or Sell, was {side}")
}
}
}
fn checked_add(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
lhs.checked_add(rhs)
.ok_or_else(|| anyhow::anyhow!("Decimal overflow adding {lhs} and {rhs}"))
}
fn checked_sub(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
lhs.checked_sub(rhs)
.ok_or_else(|| anyhow::anyhow!("Decimal overflow subtracting {rhs} from {lhs}"))
}
fn checked_mul(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
lhs.checked_mul(rhs)
.ok_or_else(|| anyhow::anyhow!("Decimal overflow multiplying {lhs} by {rhs}"))
}
fn checked_div(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
check_nonzero_denominator(rhs, "divisor")?;
lhs.checked_div(rhs)
.ok_or_else(|| anyhow::anyhow!("Decimal overflow dividing {lhs} by {rhs}"))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use super::*;
fn dec_str(s: &str) -> Decimal {
s.parse::<Decimal>().expect("Failed to parse Decimal")
}
#[rstest]
#[should_panic(expected = "Liability-based betting is only applicable for Lay side.")]
fn test_from_liability_panics_on_back_side() {
let _ = Bet::from_liability(dec!(2.0), dec!(100.0), BetSide::Back);
}
#[rstest]
fn test_bet_creation() {
let price = dec!(2.0);
let stake = dec!(100.0);
let side = BetSide::Back;
let bet = Bet::new(price, stake, side);
assert_eq!(bet.price, price);
assert_eq!(bet.stake, stake);
assert_eq!(bet.side, side);
}
#[rstest]
fn test_display_bet() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let formatted = format!("{bet}");
assert!(formatted.contains("Back"));
assert!(formatted.contains("2.00"));
assert!(formatted.contains("100.00"));
}
#[rstest]
fn test_bet_exposure_back() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let exposure = bet.exposure();
assert_eq!(exposure, dec!(200.0));
}
#[rstest]
fn test_bet_exposure_lay() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let exposure = bet.exposure();
assert_eq!(exposure, dec!(-200.0));
}
#[rstest]
fn test_bet_liability_back() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let liability = bet.liability();
assert_eq!(liability, dec!(100.0));
}
#[rstest]
fn test_bet_liability_lay() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let liability = bet.liability();
assert_eq!(liability, dec!(100.0));
}
#[rstest]
fn test_bet_profit_back() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let profit = bet.profit();
assert_eq!(profit, dec!(100.0));
}
#[rstest]
fn test_bet_profit_lay() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let profit = bet.profit();
assert_eq!(profit, dec!(100.0));
}
#[rstest]
fn test_outcome_win_payoff_back() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let win_payoff = bet.outcome_win_payoff();
assert_eq!(win_payoff, dec!(100.0));
}
#[rstest]
fn test_outcome_win_payoff_lay() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let win_payoff = bet.outcome_win_payoff();
assert_eq!(win_payoff, dec!(-100.0));
}
#[rstest]
fn test_outcome_lose_payoff_back() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let lose_payoff = bet.outcome_lose_payoff();
assert_eq!(lose_payoff, dec!(-100.0));
}
#[rstest]
fn test_outcome_lose_payoff_lay() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let lose_payoff = bet.outcome_lose_payoff();
assert_eq!(lose_payoff, dec!(100.0));
}
#[rstest]
fn test_hedging_stake_back() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let hedging_stake = bet.hedging_stake(dec!(1.5));
assert_eq!(hedging_stake.round_dp(8), dec_str("133.33333333"));
}
#[rstest]
fn test_hedging_bet_lay() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let hedge_bet = bet.hedging_bet(dec!(1.5));
assert_eq!(hedge_bet.side, BetSide::Back);
assert_eq!(hedge_bet.price, dec!(1.5));
assert_eq!(hedge_bet.stake.round_dp(8), dec_str("133.33333333"));
}
#[rstest]
fn test_bet_position_initialization() {
let position = BetPosition::default();
assert_eq!(position.price, dec!(0.0));
assert_eq!(position.exposure, dec!(0.0));
assert_eq!(position.realized_pnl, dec!(0.0));
}
#[rstest]
fn test_display_bet_position() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
let formatted = format!("{position}");
assert!(formatted.contains("price"));
assert!(formatted.contains("exposure"));
assert!(formatted.contains("realized_pnl"));
}
#[rstest]
fn test_as_bet() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
let as_bet = position.as_bet().expect("Expected a bet representation");
assert_eq!(as_bet.price, position.price);
assert_eq!(as_bet.stake, position.exposure / position.price);
assert_eq!(as_bet.side, BetSide::Back);
}
#[rstest]
fn test_reset_position() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
assert_ne!(position.exposure, dec!(0.0));
assert!(!position.bets().is_empty());
position.reset();
assert_eq!(position.price, dec!(0.0));
assert_eq!(position.exposure, dec!(0.0));
assert_eq!(position.realized_pnl, dec!(0.0));
assert!(position.bets().is_empty());
}
#[rstest]
fn test_bet_position_side_none() {
let position = BetPosition::default();
assert!(position.side().is_none());
}
#[rstest]
fn test_bet_position_side_back() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
assert_eq!(position.side(), Some(BetSide::Back));
}
#[rstest]
fn test_bet_position_side_lay() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
position.add_bet(bet);
assert_eq!(position.side(), Some(BetSide::Lay));
}
#[rstest]
fn test_position_increase_back() {
let mut position = BetPosition::default();
let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
position.add_bet(bet1);
position.add_bet(bet2);
assert_eq!(position.exposure, dec!(300.0));
}
#[rstest]
fn test_position_increase_cancelling_stakes_preserves_price() {
let mut position = BetPosition::default();
position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
position.add_bet(Bet::new(dec!(2.0), dec!(-100.0), BetSide::Back));
assert_eq!(position.price, dec!(2.0));
assert_eq!(position.exposure, dec!(0.0));
}
#[rstest]
fn test_position_increase_negative_stake_preserves_price() {
let mut position = BetPosition::default();
position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
position.add_bet(Bet::new(dec!(3.0), dec!(-50.0), BetSide::Back));
assert_eq!(position.price, dec!(2.0));
assert_eq!(position.exposure, dec!(50.0));
}
#[rstest]
fn test_position_increase_opposite_side_preserves_price() {
let mut position = BetPosition::default();
position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
position.position_increase(&Bet::new(dec!(3.0), dec!(50.0), BetSide::Lay));
assert_eq!(position.price, dec!(2.0));
}
#[rstest]
#[case(dec!(0.0))]
#[case(dec!(-3.0))]
fn test_position_increase_non_positive_incoming_price_preserves_price(#[case] price: Decimal) {
let mut position = BetPosition::default();
position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
position.add_bet(Bet::new(price, dec!(50.0), BetSide::Back));
assert_eq!(position.price, dec!(2.0));
}
#[rstest]
fn test_position_increase_non_positive_current_price_preserves_price() {
let mut position = BetPosition::default();
position.add_bet(Bet::new(dec!(-3.0), dec!(100.0), BetSide::Lay));
assert_eq!(position.side(), Some(BetSide::Back));
position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
assert_eq!(position.price, dec!(-3.0));
assert_eq!(position.exposure, dec!(500.0));
}
#[rstest]
fn test_position_increase_back_averages_price_and_conserves_pnl() {
let mut position = BetPosition::default();
let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Back);
let settlement_price = dec!(3.0);
let constituent_pnl = calc_bets_pnl(&[
bet1.clone(),
bet1.hedging_bet(settlement_price),
bet2.clone(),
bet2.hedging_bet(settlement_price),
]);
position.add_bet(bet1);
position.add_bet(bet2);
assert_eq!(position.price, dec!(400.0) / dec!(150.0));
assert_eq!(
position.total_pnl(settlement_price).round_dp(8),
constituent_pnl.round_dp(8)
);
}
#[rstest]
fn test_position_increase_lay() {
let mut position = BetPosition::default();
let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Lay);
position.add_bet(bet1);
position.add_bet(bet2);
assert_eq!(position.exposure, dec!(-300.0));
}
#[rstest]
fn test_position_increase_lay_averages_price_and_conserves_pnl() {
let mut position = BetPosition::default();
let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Lay);
let settlement_price = dec!(3.0);
let constituent_pnl = calc_bets_pnl(&[
bet1.clone(),
bet1.hedging_bet(settlement_price),
bet2.clone(),
bet2.hedging_bet(settlement_price),
]);
position.add_bet(bet1);
position.add_bet(bet2);
assert_eq!(position.price, dec!(400.0) / dec!(150.0));
assert_eq!(
position.total_pnl(settlement_price).round_dp(8),
constituent_pnl.round_dp(8)
);
}
#[rstest]
fn test_position_back_then_lay() {
let mut position = BetPosition::default();
let bet1 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
let bet2 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
position.add_bet(bet1);
position.add_bet(bet2);
assert_eq!(position.exposure, dec!(280_000.0));
assert_eq!(position.realized_pnl(), dec!(3333.333333333333333333333333));
assert_eq!(
position.unrealized_pnl(dec!(4.0)),
dec!(-23333.33333333333333333333334)
);
}
#[rstest]
fn test_position_lay_then_back() {
let mut position = BetPosition::default();
let bet1 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
let bet2 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
position.add_bet(bet1);
position.add_bet(bet2);
assert_eq!(position.exposure, dec!(280_000.0));
assert_eq!(position.realized_pnl(), dec!(190_000));
assert_eq!(
position.unrealized_pnl(dec!(4.0)),
dec!(-23333.33333333333333333333334)
);
}
#[rstest]
fn test_position_flip() {
let mut position = BetPosition::default();
let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); let lay_bet = Bet::new(dec!(2.0), dec!(150.0), BetSide::Lay); position.add_bet(back_bet);
position.add_bet(lay_bet);
assert_eq!(position.side(), Some(BetSide::Lay));
assert_eq!(position.exposure, dec!(-100.0));
}
#[rstest]
fn test_position_flat() {
let mut position = BetPosition::default();
let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); let lay_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay); position.add_bet(back_bet);
position.add_bet(lay_bet);
assert!(position.side().is_none());
assert_eq!(position.exposure, dec!(0.0));
}
#[rstest]
fn test_unrealized_pnl_negative() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); position.add_bet(bet);
let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
assert_eq!(unrealized_pnl, dec!(-20.0));
}
#[rstest]
fn test_total_pnl() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
position.realized_pnl = dec!(10.0);
let total_pnl = position.total_pnl(dec!(2.5));
assert_eq!(total_pnl, dec!(-10.0));
}
#[rstest]
fn test_flattening_bet_back_profit() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
let flattening_bet = position
.flattening_bet(dec!(1.6))
.expect("expected a flattening bet");
assert_eq!(flattening_bet.side, BetSide::Lay);
assert_eq!(flattening_bet.stake, dec_str("125"));
}
#[rstest]
fn test_flattening_bet_back_hack() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
position.add_bet(bet);
let flattening_bet = position
.flattening_bet(dec!(2.5))
.expect("expected a flattening bet");
assert_eq!(flattening_bet.side, BetSide::Lay);
assert_eq!(flattening_bet.stake, dec!(80.0));
}
#[rstest]
fn test_flattening_bet_lay() {
let mut position = BetPosition::default();
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
position.add_bet(bet);
let flattening_bet = position
.flattening_bet(dec!(1.5))
.expect("expected a flattening bet");
assert_eq!(flattening_bet.side, BetSide::Back);
assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
}
#[rstest]
fn test_realized_pnl_flattening() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let lay = Bet::new(dec!(4.0), dec!(125.0), BetSide::Lay); let mut position = BetPosition::default();
position.add_bet(back);
position.add_bet(lay);
assert_eq!(position.realized_pnl, dec!(25.0));
}
#[rstest]
fn test_realized_pnl_single_side() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
let mut position = BetPosition::default();
position.add_bet(back);
assert_eq!(position.realized_pnl, dec!(0.0));
}
#[rstest]
fn test_realized_pnl_open_position() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); let mut position = BetPosition::default();
position.add_bet(back);
position.add_bet(lay);
assert_eq!(position.realized_pnl, dec!(20.0));
}
#[rstest]
fn test_realized_pnl_partial_close() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let lay = Bet::new(dec!(4.0), dec!(110.0), BetSide::Lay); let mut position = BetPosition::default();
position.add_bet(back);
position.add_bet(lay);
assert_eq!(position.realized_pnl, dec!(22.0));
}
#[rstest]
fn test_realized_pnl_flipping() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let lay = Bet::new(dec!(4.0), dec!(130.0), BetSide::Lay); let mut position = BetPosition::default();
position.add_bet(back);
position.add_bet(lay);
assert_eq!(position.realized_pnl, dec!(10.0));
}
#[rstest]
fn test_unrealized_pnl_positive() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let mut position = BetPosition::default();
position.add_bet(back);
let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
assert_eq!(unrealized_pnl, dec!(25.0));
}
#[rstest]
fn test_total_pnl_with_pnl() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let lay = Bet::new(dec!(4.0), dec!(120.0), BetSide::Lay); let mut position = BetPosition::default();
position.add_bet(back);
position.add_bet(lay);
let realized_pnl = position.realized_pnl;
let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
let total_pnl = position.total_pnl(dec!(4.0));
assert_eq!(realized_pnl, dec!(24.0));
assert_eq!(unrealized_pnl, dec!(1.0));
assert_eq!(total_pnl, dec!(25.0));
}
#[rstest]
fn test_open_position_realized_unrealized() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); let mut position = BetPosition::default();
position.add_bet(back);
position.add_bet(lay);
let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
assert_eq!(unrealized_pnl, dec!(5.0));
}
#[rstest]
fn test_unrealized_no_position() {
let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
let mut position = BetPosition::default();
position.add_bet(back);
let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
assert_eq!(unrealized_pnl, dec!(0.0));
}
#[rstest]
fn test_calc_bets_pnl_single_back_bet() {
let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
let pnl = calc_bets_pnl(&[bet]);
assert_eq!(pnl, dec!(400.0));
}
#[rstest]
fn test_calc_bets_pnl_single_lay_bet() {
let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
let pnl = calc_bets_pnl(&[bet]);
assert_eq!(pnl, dec!(-300.0));
}
#[rstest]
fn test_calc_bets_pnl_multiple_bets() {
let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
let expected = dec!(400.0) + dec!(-300.0);
assert_eq!(pnl, expected);
}
#[rstest]
fn test_calc_bets_pnl_mixed_bets() {
let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
assert_eq!(pnl, expected);
}
#[rstest]
fn test_calc_bets_pnl_no_bets() {
let bets: Vec<Bet> = vec![];
let pnl = calc_bets_pnl(&bets);
assert_eq!(pnl, dec!(0.0));
}
#[rstest]
fn test_calc_bets_pnl_zero_outcome() {
let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
assert_eq!(pnl, dec!(0.0));
}
#[rstest]
fn test_probability_to_bet_back_simple() {
let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSideSpecified::Buy).unwrap();
let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
assert_eq!(bet, expected);
assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
}
#[rstest]
fn test_probability_to_bet_back_high_prob() {
let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Buy).unwrap();
let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
assert_eq!(bet, expected);
assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
}
#[rstest]
fn test_probability_to_bet_back_low_prob() {
let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSideSpecified::Buy).unwrap();
let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
assert_eq!(bet, expected);
assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
}
#[rstest]
fn test_probability_to_bet_sell() {
let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSideSpecified::Sell).unwrap();
let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
assert_eq!(bet, expected);
assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
}
#[rstest]
fn test_inverse_probability_to_bet() {
let original_bet =
probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell).unwrap();
let reverse_bet =
probability_to_bet(dec!(0.20), dec!(100.0), OrderSideSpecified::Buy).unwrap();
let inverse_bet =
inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell).unwrap();
assert_eq!(
original_bet.outcome_win_payoff(),
reverse_bet.outcome_lose_payoff(),
);
assert_eq!(
original_bet.outcome_win_payoff(),
inverse_bet.outcome_lose_payoff(),
);
assert_eq!(
original_bet.outcome_lose_payoff(),
reverse_bet.outcome_win_payoff(),
);
assert_eq!(
original_bet.outcome_lose_payoff(),
inverse_bet.outcome_win_payoff(),
);
}
#[rstest]
fn test_inverse_probability_to_bet_example2() {
let original_bet =
probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell).unwrap();
let inverse_bet =
inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell).unwrap();
assert_eq!(original_bet.stake, dec!(32.0));
assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
assert_eq!(inverse_bet.stake, dec!(18.0));
assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
}
#[rstest]
fn test_from_liability_checked_rejects_back_side() {
let err = Bet::from_liability_checked(dec!(2.0), dec!(100.0), BetSide::Back).unwrap_err();
assert_eq!(
err.to_string(),
"Liability-based betting is only applicable for Lay side."
);
}
#[rstest]
#[case(dec!(1.0))]
#[case(dec!(0.0))]
#[case(dec!(-1.0))]
fn test_from_liability_checked_rejects_odds_at_or_below_one(#[case] price: Decimal) {
let err = Bet::from_liability_checked(price, dec!(100.0), BetSide::Lay).unwrap_err();
assert_eq!(
err.to_string(),
format!("Price must be greater than 1.0 for lay liability calculation, was {price}")
);
}
#[rstest]
fn test_from_stake_or_liability_checked_rejects_lay_odds_at_one() {
let err =
Bet::from_stake_or_liability_checked(dec!(1.0), dec!(100.0), BetSide::Lay).unwrap_err();
assert_eq!(
err.to_string(),
"Price must be greater than 1.0 for lay liability calculation, was 1.0"
);
}
#[rstest]
fn test_from_stake_or_liability_checked_allows_back_odds_at_one() {
let bet =
Bet::from_stake_or_liability_checked(dec!(1.0), dec!(10.0), BetSide::Back).unwrap();
assert_eq!(bet.price(), dec!(1.0));
assert_eq!(bet.stake(), dec!(10.0));
assert_eq!(bet.side(), BetSide::Back);
assert_eq!(bet.exposure_checked().unwrap(), dec!(10.0));
assert_eq!(bet.profit_checked().unwrap(), dec!(0.0));
}
#[rstest]
fn test_from_liability_checked_preserves_stake_identity() {
let bet = Bet::from_liability_checked(dec!(2.5), dec!(15.0), BetSide::Lay).unwrap();
assert_eq!(bet.stake(), dec!(10.0));
assert_eq!(bet.liability_checked().unwrap(), dec!(15.0));
}
#[rstest]
fn test_hedging_stake_checked_rejects_zero_price() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
let err = bet.hedging_stake_checked(Decimal::ZERO).unwrap_err();
assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
}
#[rstest]
fn test_hedging_bet_checked_rejects_zero_price() {
let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
let err = bet.hedging_bet_checked(Decimal::ZERO).unwrap_err();
assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
}
#[rstest]
fn test_exposure_checked_rejects_overflow() {
let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back);
let err = bet.exposure_checked().unwrap_err();
assert!(err.to_string().starts_with("Decimal overflow multiplying"));
}
#[rstest]
fn test_liability_checked_rejects_overflow() {
let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Lay);
let err = bet.liability_checked().unwrap_err();
assert!(err.to_string().starts_with("Decimal overflow multiplying"));
}
#[rstest]
fn test_flattening_bet_checked_rejects_zero_price() {
let mut position = BetPosition::default();
position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
let err = position.flattening_bet_checked(Decimal::ZERO).unwrap_err();
assert_eq!(err.to_string(), "invalid price: must be non-zero");
}
#[rstest]
fn test_add_bet_checked_matches_infallible_decrease() {
let back = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
let lay = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
let mut expected = BetPosition::default();
expected.add_bet(back.clone());
expected.add_bet(lay.clone());
let mut position = BetPosition::default();
position.add_bet_checked(back).unwrap();
position.add_bet_checked(lay).unwrap();
assert_eq!(position.price(), expected.price());
assert_eq!(position.exposure(), expected.exposure());
assert_eq!(position.realized_pnl(), expected.realized_pnl());
assert_eq!(position.bets(), expected.bets());
}
#[rstest]
fn test_add_bet_checked_rejects_overflow_and_leaves_position_unchanged() {
let mut position = BetPosition::default();
position
.add_bet_checked(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back))
.unwrap();
let before_price = position.price();
let before_exposure = position.exposure();
let before_len = position.bets().len();
let err = position
.add_bet_checked(Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back))
.unwrap_err();
assert!(err.to_string().starts_with("Decimal overflow multiplying"));
assert_eq!(position.price(), before_price);
assert_eq!(position.exposure(), before_exposure);
assert_eq!(position.bets().len(), before_len);
}
#[rstest]
fn test_specified_order_side_rejects_unspecified() {
let err = specified_order_side(OrderSide::NoOrderSide).unwrap_err();
assert_eq!(
err.to_string(),
"invalid OrderSide: must be Buy or Sell, was NO_ORDER_SIDE"
);
}
#[rstest]
fn test_checked_methods_preserve_valid_identities() {
let back = Bet::new(dec!(2.5), dec!(10.0), BetSide::Back);
let hedge = back.hedging_bet_checked(dec!(1.5)).unwrap();
assert_eq!(back.exposure_checked().unwrap(), back.exposure());
assert_eq!(back.liability_checked().unwrap(), back.liability());
assert_eq!(back.profit_checked().unwrap(), back.profit());
assert_eq!(
back.outcome_win_payoff_checked().unwrap(),
back.outcome_win_payoff()
);
assert_eq!(
back.outcome_lose_payoff_checked().unwrap(),
back.outcome_lose_payoff()
);
assert_eq!(hedge, back.hedging_bet(dec!(1.5)));
assert_eq!(
calc_bets_pnl_checked(&[back.clone(), hedge.clone()]).unwrap(),
calc_bets_pnl(&[back, hedge])
);
}
}