1use std::fmt::Display;
19
20use rust_decimal::Decimal;
21
22use crate::enums::{BetSide, OrderSide};
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26#[cfg_attr(
27 feature = "python",
28 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
29)]
30#[cfg_attr(
31 feature = "python",
32 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
33)]
34pub struct Bet {
35 price: Decimal,
36 stake: Decimal,
37 side: BetSide,
38}
39
40impl Bet {
41 #[must_use]
43 pub fn new(price: Decimal, stake: Decimal, side: BetSide) -> Self {
44 Self { price, stake, side }
45 }
46
47 #[must_use]
49 pub fn price(&self) -> Decimal {
50 self.price
51 }
52
53 #[must_use]
55 pub fn stake(&self) -> Decimal {
56 self.stake
57 }
58
59 #[must_use]
61 pub fn side(&self) -> BetSide {
62 self.side
63 }
64
65 #[must_use]
74 pub fn from_stake_or_liability(price: Decimal, volume: Decimal, side: BetSide) -> Self {
75 Self::from_stake_or_liability_checked(price, volume, side).unwrap_or_else(|e| panic!("{e}"))
76 }
77
78 pub fn from_stake_or_liability_checked(
84 price: Decimal,
85 volume: Decimal,
86 side: BetSide,
87 ) -> anyhow::Result<Self> {
88 match side {
89 BetSide::Back => Ok(Self::from_stake(price, volume, side)),
90 BetSide::Lay => Self::from_liability_checked(price, volume, side),
91 }
92 }
93
94 #[must_use]
96 pub fn from_stake(price: Decimal, stake: Decimal, side: BetSide) -> Self {
97 Self::new(price, stake, side)
98 }
99
100 #[must_use]
107 pub fn from_liability(price: Decimal, liability: Decimal, side: BetSide) -> Self {
108 Self::from_liability_checked(price, liability, side).unwrap_or_else(|e| panic!("{e}"))
109 }
110
111 pub fn from_liability_checked(
118 price: Decimal,
119 liability: Decimal,
120 side: BetSide,
121 ) -> anyhow::Result<Self> {
122 if side != BetSide::Lay {
123 anyhow::bail!("Liability-based betting is only applicable for Lay side.");
124 }
125
126 check_odds_gt_one(price)?;
127 let stake = checked_div(liability, checked_sub(price, Decimal::ONE)?)?;
128 Ok(Self::new(price, stake, side))
129 }
130
131 #[must_use]
139 pub fn exposure(&self) -> Decimal {
140 self.exposure_checked().unwrap_or_else(|e| panic!("{e}"))
141 }
142
143 pub fn exposure_checked(&self) -> anyhow::Result<Decimal> {
149 let notional = checked_mul(self.price, self.stake)?;
150 Ok(match self.side {
151 BetSide::Back => notional,
152 BetSide::Lay => -notional,
153 })
154 }
155
156 #[must_use]
165 pub fn liability(&self) -> Decimal {
166 self.liability_checked().unwrap_or_else(|e| panic!("{e}"))
167 }
168
169 pub fn liability_checked(&self) -> anyhow::Result<Decimal> {
175 match self.side {
176 BetSide::Back => Ok(self.stake),
177 BetSide::Lay => checked_mul(self.stake, checked_sub(self.price, Decimal::ONE)?),
178 }
179 }
180
181 #[must_use]
189 pub fn profit(&self) -> Decimal {
190 self.profit_checked().unwrap_or_else(|e| panic!("{e}"))
191 }
192
193 pub fn profit_checked(&self) -> anyhow::Result<Decimal> {
199 match self.side {
200 BetSide::Back => checked_mul(self.stake, checked_sub(self.price, Decimal::ONE)?),
201 BetSide::Lay => Ok(self.stake),
202 }
203 }
204
205 #[must_use]
213 pub fn outcome_win_payoff(&self) -> Decimal {
214 self.outcome_win_payoff_checked()
215 .unwrap_or_else(|e| panic!("{e}"))
216 }
217
218 pub fn outcome_win_payoff_checked(&self) -> anyhow::Result<Decimal> {
224 match self.side {
225 BetSide::Back => self.profit_checked(),
226 BetSide::Lay => Ok(-self.liability_checked()?),
227 }
228 }
229
230 #[must_use]
238 pub fn outcome_lose_payoff(&self) -> Decimal {
239 self.outcome_lose_payoff_checked()
240 .unwrap_or_else(|e| panic!("{e}"))
241 }
242
243 pub fn outcome_lose_payoff_checked(&self) -> anyhow::Result<Decimal> {
249 match self.side {
250 BetSide::Back => Ok(-self.liability_checked()?),
251 BetSide::Lay => self.profit_checked(),
252 }
253 }
254
255 #[must_use]
262 pub fn hedging_stake(&self, price: Decimal) -> Decimal {
263 self.hedging_stake_checked(price)
264 .unwrap_or_else(|e| panic!("{e}"))
265 }
266
267 pub fn hedging_stake_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
274 match self.side {
275 BetSide::Back => checked_mul(checked_div(self.price, price)?, self.stake),
276 BetSide::Lay => checked_div(self.stake, checked_div(price, self.price)?),
277 }
278 }
279
280 #[must_use]
286 pub fn hedging_bet(&self, price: Decimal) -> Self {
287 self.hedging_bet_checked(price)
288 .unwrap_or_else(|e| panic!("{e}"))
289 }
290
291 pub fn hedging_bet_checked(&self, price: Decimal) -> anyhow::Result<Self> {
297 Ok(Self::new(
298 price,
299 self.hedging_stake_checked(price)?,
300 self.side.opposite(),
301 ))
302 }
303}
304
305impl Display for Bet {
306 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307 write!(
309 f,
310 "Bet({:?} @ {:.2} x{:.2})",
311 self.side, self.price, self.stake
312 )
313 }
314}
315
316#[derive(Debug, Clone)]
318#[cfg_attr(
319 feature = "python",
320 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
321)]
322#[cfg_attr(
323 feature = "python",
324 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
325)]
326pub struct BetPosition {
327 price: Decimal,
328 exposure: Decimal,
329 realized_pnl: Decimal,
330 bets: Vec<Bet>,
331}
332
333impl Default for BetPosition {
334 fn default() -> Self {
335 Self {
336 price: Decimal::ZERO,
337 exposure: Decimal::ZERO,
338 realized_pnl: Decimal::ZERO,
339 bets: vec![],
340 }
341 }
342}
343
344impl BetPosition {
345 #[must_use]
347 pub fn price(&self) -> Decimal {
348 self.price
349 }
350
351 #[must_use]
353 pub fn exposure(&self) -> Decimal {
354 self.exposure
355 }
356
357 #[must_use]
359 pub fn realized_pnl(&self) -> Decimal {
360 self.realized_pnl
361 }
362
363 #[must_use]
365 pub fn bets(&self) -> &[Bet] {
366 &self.bets
367 }
368
369 #[must_use]
373 pub fn side(&self) -> Option<BetSide> {
374 match self.exposure.cmp(&Decimal::ZERO) {
375 std::cmp::Ordering::Less => Some(BetSide::Lay),
376 std::cmp::Ordering::Greater => Some(BetSide::Back),
377 std::cmp::Ordering::Equal => None,
378 }
379 }
380
381 #[must_use]
388 pub fn as_bet(&self) -> Option<Bet> {
389 self.as_bet_checked().unwrap_or_else(|e| panic!("{e}"))
390 }
391
392 pub fn as_bet_checked(&self) -> anyhow::Result<Option<Bet>> {
399 let Some(side) = self.side() else {
400 return Ok(None);
401 };
402 check_nonzero_denominator(self.price, "price")?;
403 let stake = match side {
404 BetSide::Back => checked_div(self.exposure, self.price)?,
405 BetSide::Lay => checked_div(-self.exposure, self.price)?,
406 };
407 Ok(Some(Bet::new(self.price, stake, side)))
408 }
409
410 pub fn add_bet(&mut self, bet: Bet) {
412 match self.side() {
413 None => self.position_increase(&bet),
414 Some(current_side) => {
415 if current_side == bet.side {
416 self.position_increase(&bet);
417 } else {
418 self.position_decrease(&bet);
419 }
420 }
421 }
422 self.bets.push(bet);
423 }
424
425 pub fn add_bet_checked(&mut self, bet: Bet) -> anyhow::Result<()> {
432 let (price, exposure, realized_pnl) = match self.side() {
433 None => self.increased_state(&bet)?,
434 Some(current_side) if current_side == bet.side => self.increased_state(&bet)?,
435 Some(_) => self.decreased_state(&bet)?,
436 };
437 self.price = price;
438 self.exposure = exposure;
439 self.realized_pnl = realized_pnl;
440 self.bets.push(bet);
441 Ok(())
442 }
443
444 fn increased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
445 let bet_exposure = bet.exposure_checked()?;
446 let price = if self.side().is_none() {
447 bet.price
448 } else if self.side() == Some(bet.side)
449 && self.price > Decimal::ZERO
450 && bet.price > Decimal::ZERO
451 && bet.stake > Decimal::ZERO
452 {
453 let abs_self_exposure = self.exposure.abs();
454 let abs_bet_exposure = bet_exposure.abs();
455 let total_stake = checked_add(checked_div(abs_self_exposure, self.price)?, bet.stake)?;
456 checked_div(
457 checked_add(abs_self_exposure, abs_bet_exposure)?,
458 total_stake,
459 )?
460 } else {
461 self.price
462 };
463 Ok((
464 price,
465 checked_add(self.exposure, bet_exposure)?,
466 self.realized_pnl,
467 ))
468 }
469
470 fn decreased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
471 let current_side = self
472 .side()
473 .ok_or_else(|| anyhow::anyhow!("cannot decrease an empty bet position"))?;
474 let bet_exposure = bet.exposure_checked()?;
475 let abs_bet_exposure = bet_exposure.abs();
476 let abs_self_exposure = self.exposure.abs();
477
478 match abs_bet_exposure.cmp(&abs_self_exposure) {
479 std::cmp::Ordering::Less => {
480 check_nonzero_denominator(self.price, "price")?;
481 let decreasing_volume = checked_div(abs_bet_exposure, self.price)?;
482 let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
483 let pnl = calc_bets_pnl_checked(&[bet.clone(), decreasing_bet])?;
484 Ok((
485 self.price,
486 checked_add(self.exposure, bet_exposure)?,
487 checked_add(self.realized_pnl, pnl)?,
488 ))
489 }
490 std::cmp::Ordering::Greater => Ok((
491 bet.price,
492 checked_add(self.exposure, bet_exposure)?,
493 self.realized_after_close(bet)?,
494 )),
495 std::cmp::Ordering::Equal => Ok((
496 Decimal::ZERO,
497 Decimal::ZERO,
498 self.realized_after_close(bet)?,
499 )),
500 }
501 }
502
503 fn realized_after_close(&self, bet: &Bet) -> anyhow::Result<Decimal> {
504 match self.as_bet_checked()? {
505 Some(self_bet) => checked_add(
506 self.realized_pnl,
507 calc_bets_pnl_checked(&[bet.clone(), self_bet])?,
508 ),
509 None => Ok(self.realized_pnl),
510 }
511 }
512
513 pub fn position_increase(&mut self, bet: &Bet) {
517 if self.side().is_none() {
518 self.price = bet.price;
519 } else {
520 let abs_self_exposure = self.exposure.abs();
521 let abs_bet_exposure = bet.exposure().abs();
522 if self.side() == Some(bet.side)
528 && self.price > Decimal::ZERO
529 && bet.price > Decimal::ZERO
530 && bet.stake > Decimal::ZERO
531 {
532 let total_stake = abs_self_exposure / self.price + bet.stake;
533 self.price = (abs_self_exposure + abs_bet_exposure) / total_stake;
534 }
535 }
536 self.exposure += bet.exposure();
537 }
538
539 pub fn position_decrease(&mut self, bet: &Bet) {
545 let abs_bet_exposure = bet.exposure().abs();
546 let abs_self_exposure = self.exposure.abs();
547
548 match abs_bet_exposure.cmp(&abs_self_exposure) {
549 std::cmp::Ordering::Less => {
550 let decreasing_volume = abs_bet_exposure / self.price;
551 let current_side = self.side().unwrap();
552 let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
553 let pnl = calc_bets_pnl(&[bet.clone(), decreasing_bet]);
554 self.realized_pnl += pnl;
555 self.exposure += bet.exposure();
556 }
557 std::cmp::Ordering::Greater => {
558 if let Some(self_bet) = self.as_bet() {
559 let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
560 self.realized_pnl += pnl;
561 }
562 self.price = bet.price;
563 self.exposure += bet.exposure();
564 }
565 std::cmp::Ordering::Equal => {
566 if let Some(self_bet) = self.as_bet() {
567 let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
568 self.realized_pnl += pnl;
569 }
570 self.price = Decimal::ZERO;
571 self.exposure = Decimal::ZERO;
572 }
573 }
574 }
575
576 #[must_use]
582 pub fn unrealized_pnl(&self, price: Decimal) -> Decimal {
583 self.unrealized_pnl_checked(price)
584 .unwrap_or_else(|e| panic!("{e}"))
585 }
586
587 pub fn unrealized_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
593 if self.side().is_none() {
594 return Ok(Decimal::ZERO);
595 }
596 let Some(flattening_bet) = self.flattening_bet_checked(price)? else {
597 return Ok(Decimal::ZERO);
598 };
599 let Some(self_bet) = self.as_bet_checked()? else {
600 return Ok(Decimal::ZERO);
601 };
602 calc_bets_pnl_checked(&[flattening_bet, self_bet])
603 }
604
605 #[must_use]
611 pub fn total_pnl(&self, price: Decimal) -> Decimal {
612 self.total_pnl_checked(price)
613 .unwrap_or_else(|e| panic!("{e}"))
614 }
615
616 pub fn total_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
622 checked_add(self.realized_pnl, self.unrealized_pnl_checked(price)?)
623 }
624
625 #[must_use]
632 pub fn flattening_bet(&self, price: Decimal) -> Option<Bet> {
633 self.flattening_bet_checked(price)
634 .unwrap_or_else(|e| panic!("{e}"))
635 }
636
637 pub fn flattening_bet_checked(&self, price: Decimal) -> anyhow::Result<Option<Bet>> {
644 let Some(side) = self.side() else {
645 return Ok(None);
646 };
647 check_nonzero_denominator(price, "price")?;
648 let stake = match side {
649 BetSide::Back => checked_div(self.exposure, price)?,
650 BetSide::Lay => checked_div(-self.exposure, price)?,
651 };
652 Ok(Some(Bet::new(price, stake, side.opposite())))
653 }
654
655 pub fn reset(&mut self) {
657 self.price = Decimal::ZERO;
658 self.exposure = Decimal::ZERO;
659 self.realized_pnl = Decimal::ZERO;
660 self.bets.clear();
661 }
662}
663
664impl Display for BetPosition {
665 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
666 write!(
667 f,
668 "BetPosition(price: {:.2}, exposure: {:.2}, realized_pnl: {:.2})",
669 self.price, self.exposure, self.realized_pnl
670 )
671 }
672}
673
674#[must_use]
680pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
681 calc_bets_pnl_checked(bets).unwrap_or_else(|e| panic!("{e}"))
682}
683
684pub fn calc_bets_pnl_checked(bets: &[Bet]) -> anyhow::Result<Decimal> {
690 bets.iter().try_fold(Decimal::ZERO, |acc, bet| {
691 checked_add(acc, bet.outcome_win_payoff_checked()?)
692 })
693}
694
695pub fn check_probability_non_zero(probability: Decimal) -> anyhow::Result<()> {
701 if probability.is_zero() {
702 anyhow::bail!("invalid probability: must be non-zero")
703 }
704 Ok(())
705}
706
707pub fn check_probability_invertible(probability: Decimal) -> anyhow::Result<()> {
713 if probability == Decimal::ONE {
714 anyhow::bail!("invalid probability: must not be 1.0 (inverse would be zero)")
715 }
716 Ok(())
717}
718
719pub fn probability_to_bet(
727 probability: Decimal,
728 volume: Decimal,
729 side: OrderSide,
730) -> anyhow::Result<Bet> {
731 check_probability_non_zero(probability)?;
732 let price = checked_div(Decimal::ONE, probability)?;
733 let stake = checked_div(volume, price)?;
734 let bet = match side {
735 OrderSide::Buy => Bet::new(price, stake, BetSide::Back),
736 OrderSide::Sell => Bet::new(price, stake, BetSide::Lay),
737 };
738 Ok(bet)
739}
740
741pub fn inverse_probability_to_bet(
749 probability: Decimal,
750 volume: Decimal,
751 side: OrderSide,
752) -> anyhow::Result<Bet> {
753 check_probability_invertible(probability)?;
754 let inverse_probability = checked_sub(Decimal::ONE, probability)?;
755 let inverse_side = match side {
756 OrderSide::Buy => OrderSide::Sell,
757 OrderSide::Sell => OrderSide::Buy,
758 };
759 probability_to_bet(inverse_probability, volume, inverse_side)
760}
761
762fn check_odds_gt_one(price: Decimal) -> anyhow::Result<()> {
763 if price <= Decimal::ONE {
764 anyhow::bail!("Price must be greater than 1.0 for lay liability calculation, was {price}");
765 }
766 Ok(())
767}
768
769fn check_nonzero_denominator(value: Decimal, name: &str) -> anyhow::Result<()> {
770 if value.is_zero() {
771 anyhow::bail!("invalid {name}: must be non-zero")
772 }
773 Ok(())
774}
775
776fn checked_add(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
777 lhs.checked_add(rhs)
778 .ok_or_else(|| anyhow::anyhow!("Decimal overflow adding {lhs} and {rhs}"))
779}
780
781fn checked_sub(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
782 lhs.checked_sub(rhs)
783 .ok_or_else(|| anyhow::anyhow!("Decimal overflow subtracting {rhs} from {lhs}"))
784}
785
786fn checked_mul(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
787 lhs.checked_mul(rhs)
788 .ok_or_else(|| anyhow::anyhow!("Decimal overflow multiplying {lhs} by {rhs}"))
789}
790
791fn checked_div(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
792 check_nonzero_denominator(rhs, "divisor")?;
793 lhs.checked_div(rhs)
794 .ok_or_else(|| anyhow::anyhow!("Decimal overflow dividing {lhs} by {rhs}"))
795}
796
797#[cfg(test)]
798mod tests {
799 use rstest::rstest;
800 use rust_decimal::Decimal;
801 use rust_decimal_macros::dec;
802
803 use super::*;
804
805 fn dec_str(s: &str) -> Decimal {
806 s.parse::<Decimal>().expect("Failed to parse Decimal")
807 }
808
809 #[rstest]
810 #[should_panic(expected = "Liability-based betting is only applicable for Lay side.")]
811 fn test_from_liability_panics_on_back_side() {
812 let _ = Bet::from_liability(dec!(2.0), dec!(100.0), BetSide::Back);
813 }
814
815 #[rstest]
816 fn test_bet_creation() {
817 let price = dec!(2.0);
818 let stake = dec!(100.0);
819 let side = BetSide::Back;
820 let bet = Bet::new(price, stake, side);
821 assert_eq!(bet.price, price);
822 assert_eq!(bet.stake, stake);
823 assert_eq!(bet.side, side);
824 }
825
826 #[rstest]
827 fn test_display_bet() {
828 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
829 let formatted = format!("{bet}");
830 assert!(formatted.contains("Back"));
831 assert!(formatted.contains("2.00"));
832 assert!(formatted.contains("100.00"));
833 }
834
835 #[rstest]
836 fn test_bet_exposure_back() {
837 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
838 let exposure = bet.exposure();
839 assert_eq!(exposure, dec!(200.0));
840 }
841
842 #[rstest]
843 fn test_bet_exposure_lay() {
844 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
845 let exposure = bet.exposure();
846 assert_eq!(exposure, dec!(-200.0));
847 }
848
849 #[rstest]
850 fn test_bet_liability_back() {
851 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
852 let liability = bet.liability();
853 assert_eq!(liability, dec!(100.0));
854 }
855
856 #[rstest]
857 fn test_bet_liability_lay() {
858 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
859 let liability = bet.liability();
860 assert_eq!(liability, dec!(100.0));
861 }
862
863 #[rstest]
864 fn test_bet_profit_back() {
865 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
866 let profit = bet.profit();
867 assert_eq!(profit, dec!(100.0));
868 }
869
870 #[rstest]
871 fn test_bet_profit_lay() {
872 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
873 let profit = bet.profit();
874 assert_eq!(profit, dec!(100.0));
875 }
876
877 #[rstest]
878 fn test_outcome_win_payoff_back() {
879 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
880 let win_payoff = bet.outcome_win_payoff();
881 assert_eq!(win_payoff, dec!(100.0));
882 }
883
884 #[rstest]
885 fn test_outcome_win_payoff_lay() {
886 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
887 let win_payoff = bet.outcome_win_payoff();
888 assert_eq!(win_payoff, dec!(-100.0));
889 }
890
891 #[rstest]
892 fn test_outcome_lose_payoff_back() {
893 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
894 let lose_payoff = bet.outcome_lose_payoff();
895 assert_eq!(lose_payoff, dec!(-100.0));
896 }
897
898 #[rstest]
899 fn test_outcome_lose_payoff_lay() {
900 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
901 let lose_payoff = bet.outcome_lose_payoff();
902 assert_eq!(lose_payoff, dec!(100.0));
903 }
904
905 #[rstest]
906 fn test_hedging_stake_back() {
907 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
908 let hedging_stake = bet.hedging_stake(dec!(1.5));
909 assert_eq!(hedging_stake.round_dp(8), dec_str("133.33333333"));
911 }
912
913 #[rstest]
914 fn test_hedging_bet_lay() {
915 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
916 let hedge_bet = bet.hedging_bet(dec!(1.5));
917 assert_eq!(hedge_bet.side, BetSide::Back);
918 assert_eq!(hedge_bet.price, dec!(1.5));
919 assert_eq!(hedge_bet.stake.round_dp(8), dec_str("133.33333333"));
920 }
921
922 #[rstest]
923 fn test_bet_position_initialization() {
924 let position = BetPosition::default();
925 assert_eq!(position.price, dec!(0.0));
926 assert_eq!(position.exposure, dec!(0.0));
927 assert_eq!(position.realized_pnl, dec!(0.0));
928 }
929
930 #[rstest]
931 fn test_display_bet_position() {
932 let mut position = BetPosition::default();
933 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
934 position.add_bet(bet);
935 let formatted = format!("{position}");
936
937 assert!(formatted.contains("price"));
938 assert!(formatted.contains("exposure"));
939 assert!(formatted.contains("realized_pnl"));
940 }
941
942 #[rstest]
943 fn test_as_bet() {
944 let mut position = BetPosition::default();
945 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
947 position.add_bet(bet);
948 let as_bet = position.as_bet().expect("Expected a bet representation");
949
950 assert_eq!(as_bet.price, position.price);
951 assert_eq!(as_bet.stake, position.exposure / position.price);
952 assert_eq!(as_bet.side, BetSide::Back);
953 }
954
955 #[rstest]
956 fn test_reset_position() {
957 let mut position = BetPosition::default();
958 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
959 position.add_bet(bet);
960 assert_ne!(position.exposure, dec!(0.0));
961 assert!(!position.bets().is_empty());
962 position.reset();
963
964 assert_eq!(position.price, dec!(0.0));
966 assert_eq!(position.exposure, dec!(0.0));
967 assert_eq!(position.realized_pnl, dec!(0.0));
968 assert!(position.bets().is_empty());
969 }
970
971 #[rstest]
972 fn test_bet_position_side_none() {
973 let position = BetPosition::default();
974 assert!(position.side().is_none());
975 }
976
977 #[rstest]
978 fn test_bet_position_side_back() {
979 let mut position = BetPosition::default();
980 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
981 position.add_bet(bet);
982 assert_eq!(position.side(), Some(BetSide::Back));
983 }
984
985 #[rstest]
986 fn test_bet_position_side_lay() {
987 let mut position = BetPosition::default();
988 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
989 position.add_bet(bet);
990 assert_eq!(position.side(), Some(BetSide::Lay));
991 }
992
993 #[rstest]
994 fn test_position_increase_back() {
995 let mut position = BetPosition::default();
996 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
997 let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
998 position.add_bet(bet1);
999 position.add_bet(bet2);
1000 assert_eq!(position.exposure, dec!(300.0));
1002 }
1003
1004 #[rstest]
1005 fn test_position_increase_cancelling_stakes_preserves_price() {
1006 let mut position = BetPosition::default();
1007 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1008 position.add_bet(Bet::new(dec!(2.0), dec!(-100.0), BetSide::Back));
1011
1012 assert_eq!(position.price, dec!(2.0));
1013 assert_eq!(position.exposure, dec!(0.0));
1014 }
1015
1016 #[rstest]
1017 fn test_position_increase_negative_stake_preserves_price() {
1018 let mut position = BetPosition::default();
1019 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1020 position.add_bet(Bet::new(dec!(3.0), dec!(-50.0), BetSide::Back));
1022
1023 assert_eq!(position.price, dec!(2.0));
1024 assert_eq!(position.exposure, dec!(50.0));
1025 }
1026
1027 #[rstest]
1028 fn test_position_increase_opposite_side_preserves_price() {
1029 let mut position = BetPosition::default();
1030 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1031 position.position_increase(&Bet::new(dec!(3.0), dec!(50.0), BetSide::Lay));
1034
1035 assert_eq!(position.price, dec!(2.0));
1036 }
1037
1038 #[rstest]
1039 #[case(dec!(0.0))]
1040 #[case(dec!(-3.0))]
1041 fn test_position_increase_non_positive_incoming_price_preserves_price(#[case] price: Decimal) {
1042 let mut position = BetPosition::default();
1043 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1044 position.add_bet(Bet::new(price, dec!(50.0), BetSide::Back));
1046
1047 assert_eq!(position.price, dec!(2.0));
1048 }
1049
1050 #[rstest]
1051 fn test_position_increase_non_positive_current_price_preserves_price() {
1052 let mut position = BetPosition::default();
1053 position.add_bet(Bet::new(dec!(-3.0), dec!(100.0), BetSide::Lay));
1056 assert_eq!(position.side(), Some(BetSide::Back));
1057
1058 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1062
1063 assert_eq!(position.price, dec!(-3.0));
1064 assert_eq!(position.exposure, dec!(500.0));
1065 }
1066
1067 #[rstest]
1068 fn test_position_increase_back_averages_price_and_conserves_pnl() {
1069 let mut position = BetPosition::default();
1070 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1071 let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Back);
1072 let settlement_price = dec!(3.0);
1073 let constituent_pnl = calc_bets_pnl(&[
1074 bet1.clone(),
1075 bet1.hedging_bet(settlement_price),
1076 bet2.clone(),
1077 bet2.hedging_bet(settlement_price),
1078 ]);
1079
1080 position.add_bet(bet1);
1081 position.add_bet(bet2);
1082
1083 assert_eq!(position.price, dec!(400.0) / dec!(150.0));
1084 assert_eq!(
1085 position.total_pnl(settlement_price).round_dp(8),
1086 constituent_pnl.round_dp(8)
1087 );
1088 }
1089
1090 #[rstest]
1091 fn test_position_increase_lay() {
1092 let mut position = BetPosition::default();
1093 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1094 let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Lay);
1095 position.add_bet(bet1);
1096 position.add_bet(bet2);
1097 assert_eq!(position.exposure, dec!(-300.0));
1099 }
1100
1101 #[rstest]
1102 fn test_position_increase_lay_averages_price_and_conserves_pnl() {
1103 let mut position = BetPosition::default();
1104 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1105 let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Lay);
1106 let settlement_price = dec!(3.0);
1107 let constituent_pnl = calc_bets_pnl(&[
1108 bet1.clone(),
1109 bet1.hedging_bet(settlement_price),
1110 bet2.clone(),
1111 bet2.hedging_bet(settlement_price),
1112 ]);
1113
1114 position.add_bet(bet1);
1115 position.add_bet(bet2);
1116
1117 assert_eq!(position.price, dec!(400.0) / dec!(150.0));
1118 assert_eq!(
1119 position.total_pnl(settlement_price).round_dp(8),
1120 constituent_pnl.round_dp(8)
1121 );
1122 }
1123
1124 #[rstest]
1125 fn test_position_back_then_lay() {
1126 let mut position = BetPosition::default();
1127 let bet1 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1128 let bet2 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1129 position.add_bet(bet1);
1130 position.add_bet(bet2);
1131
1132 assert_eq!(position.exposure, dec!(280_000.0));
1133 assert_eq!(position.realized_pnl(), dec!(3333.333333333333333333333333));
1134 assert_eq!(
1135 position.unrealized_pnl(dec!(4.0)),
1136 dec!(-23333.33333333333333333333334)
1137 );
1138 }
1139
1140 #[rstest]
1141 fn test_position_lay_then_back() {
1142 let mut position = BetPosition::default();
1143 let bet1 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1144 let bet2 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1145 position.add_bet(bet1);
1146 position.add_bet(bet2);
1147
1148 assert_eq!(position.exposure, dec!(280_000.0));
1149 assert_eq!(position.realized_pnl(), dec!(190_000));
1150 assert_eq!(
1151 position.unrealized_pnl(dec!(4.0)),
1152 dec!(-23333.33333333333333333333334)
1153 );
1154 }
1155
1156 #[rstest]
1157 fn test_position_flip() {
1158 let mut position = BetPosition::default();
1159 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);
1162 position.add_bet(lay_bet);
1163 assert_eq!(position.side(), Some(BetSide::Lay));
1165 assert_eq!(position.exposure, dec!(-100.0));
1166 }
1167
1168 #[rstest]
1169 fn test_position_flat() {
1170 let mut position = BetPosition::default();
1171 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);
1174 position.add_bet(lay_bet);
1175 assert!(position.side().is_none());
1176 assert_eq!(position.exposure, dec!(0.0));
1177 }
1178
1179 #[rstest]
1180 fn test_unrealized_pnl_negative() {
1181 let mut position = BetPosition::default();
1182 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); position.add_bet(bet);
1184 let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
1186 assert_eq!(unrealized_pnl, dec!(-20.0));
1187 }
1188
1189 #[rstest]
1190 fn test_total_pnl() {
1191 let mut position = BetPosition::default();
1192 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1193 position.add_bet(bet);
1194 position.realized_pnl = dec!(10.0);
1195 let total_pnl = position.total_pnl(dec!(2.5));
1196 assert_eq!(total_pnl, dec!(-10.0));
1198 }
1199
1200 #[rstest]
1201 fn test_flattening_bet_back_profit() {
1202 let mut position = BetPosition::default();
1203 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1204 position.add_bet(bet);
1205 let flattening_bet = position
1206 .flattening_bet(dec!(1.6))
1207 .expect("expected a flattening bet");
1208 assert_eq!(flattening_bet.side, BetSide::Lay);
1209 assert_eq!(flattening_bet.stake, dec_str("125"));
1210 }
1211
1212 #[rstest]
1213 fn test_flattening_bet_back_hack() {
1214 let mut position = BetPosition::default();
1215 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1216 position.add_bet(bet);
1217 let flattening_bet = position
1218 .flattening_bet(dec!(2.5))
1219 .expect("expected a flattening bet");
1220 assert_eq!(flattening_bet.side, BetSide::Lay);
1221 assert_eq!(flattening_bet.stake, dec!(80.0));
1223 }
1224
1225 #[rstest]
1226 fn test_flattening_bet_lay() {
1227 let mut position = BetPosition::default();
1228 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1229 position.add_bet(bet);
1230 let flattening_bet = position
1231 .flattening_bet(dec!(1.5))
1232 .expect("expected a flattening bet");
1233 assert_eq!(flattening_bet.side, BetSide::Back);
1234 assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
1235 }
1236
1237 #[rstest]
1238 fn test_realized_pnl_flattening() {
1239 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();
1242 position.add_bet(back);
1243 position.add_bet(lay);
1244 assert_eq!(position.realized_pnl, dec!(25.0));
1246 }
1247
1248 #[rstest]
1249 fn test_realized_pnl_single_side() {
1250 let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1251 let mut position = BetPosition::default();
1252 position.add_bet(back);
1253 assert_eq!(position.realized_pnl, dec!(0.0));
1255 }
1256
1257 #[rstest]
1258 fn test_realized_pnl_open_position() {
1259 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();
1262 position.add_bet(back);
1263 position.add_bet(lay);
1264 assert_eq!(position.realized_pnl, dec!(20.0));
1266 }
1267
1268 #[rstest]
1269 fn test_realized_pnl_partial_close() {
1270 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();
1273 position.add_bet(back);
1274 position.add_bet(lay);
1275 assert_eq!(position.realized_pnl, dec!(22.0));
1277 }
1278
1279 #[rstest]
1280 fn test_realized_pnl_flipping() {
1281 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();
1284 position.add_bet(back);
1285 position.add_bet(lay);
1286 assert_eq!(position.realized_pnl, dec!(10.0));
1288 }
1289
1290 #[rstest]
1291 fn test_unrealized_pnl_positive() {
1292 let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let mut position = BetPosition::default();
1294 position.add_bet(back);
1295 let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1296 assert_eq!(unrealized_pnl, dec!(25.0));
1298 }
1299
1300 #[rstest]
1301 fn test_total_pnl_with_pnl() {
1302 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();
1305 position.add_bet(back);
1306 position.add_bet(lay);
1307 let realized_pnl = position.realized_pnl;
1309 let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1310 let total_pnl = position.total_pnl(dec!(4.0));
1311 assert_eq!(realized_pnl, dec!(24.0));
1312 assert_eq!(unrealized_pnl, dec!(1.0));
1313 assert_eq!(total_pnl, dec!(25.0));
1314 }
1315
1316 #[rstest]
1317 fn test_open_position_realized_unrealized() {
1318 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();
1321 position.add_bet(back);
1322 position.add_bet(lay);
1323 let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1324 assert_eq!(unrealized_pnl, dec!(5.0));
1326 }
1327
1328 #[rstest]
1329 fn test_unrealized_no_position() {
1330 let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
1331 let mut position = BetPosition::default();
1332 position.add_bet(back);
1333 let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
1334 assert_eq!(unrealized_pnl, dec!(0.0));
1335 }
1336
1337 #[rstest]
1338 fn test_calc_bets_pnl_single_back_bet() {
1339 let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1340 let pnl = calc_bets_pnl(&[bet]);
1341 assert_eq!(pnl, dec!(400.0));
1342 }
1343
1344 #[rstest]
1345 fn test_calc_bets_pnl_single_lay_bet() {
1346 let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
1347 let pnl = calc_bets_pnl(&[bet]);
1348 assert_eq!(pnl, dec!(-300.0));
1349 }
1350
1351 #[rstest]
1352 fn test_calc_bets_pnl_multiple_bets() {
1353 let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1354 let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
1355 let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
1356 let expected = dec!(400.0) + dec!(-300.0);
1357 assert_eq!(pnl, expected);
1358 }
1359
1360 #[rstest]
1361 fn test_calc_bets_pnl_mixed_bets() {
1362 let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1363 let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
1364 let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
1365 let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
1366 let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
1367 assert_eq!(pnl, expected);
1368 }
1369
1370 #[rstest]
1371 fn test_calc_bets_pnl_no_bets() {
1372 let bets: Vec<Bet> = vec![];
1373 let pnl = calc_bets_pnl(&bets);
1374 assert_eq!(pnl, dec!(0.0));
1375 }
1376
1377 #[rstest]
1378 fn test_calc_bets_pnl_zero_outcome() {
1379 let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1380 let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
1381 let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
1382 assert_eq!(pnl, dec!(0.0));
1383 }
1384
1385 #[rstest]
1386 fn test_probability_to_bet_back_simple() {
1387 let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSide::Buy).unwrap();
1389 let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
1390 assert_eq!(bet, expected);
1391 assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
1392 assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
1393 }
1394
1395 #[rstest]
1396 fn test_probability_to_bet_back_high_prob() {
1397 let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Buy).unwrap();
1398 let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
1399 assert_eq!(bet, expected);
1400 assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
1401 assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
1402 }
1403
1404 #[rstest]
1405 fn test_probability_to_bet_back_low_prob() {
1406 let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSide::Buy).unwrap();
1407 let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
1408 assert_eq!(bet, expected);
1409 assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
1410 assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
1411 }
1412
1413 #[rstest]
1414 fn test_probability_to_bet_sell() {
1415 let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSide::Sell).unwrap();
1416 let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
1417 assert_eq!(bet, expected);
1418 assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
1419 assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
1420 }
1421
1422 #[rstest]
1423 fn test_inverse_probability_to_bet() {
1424 let original_bet = probability_to_bet(dec!(0.80), dec!(100.0), OrderSide::Sell).unwrap();
1426 let reverse_bet = probability_to_bet(dec!(0.20), dec!(100.0), OrderSide::Buy).unwrap();
1428 let inverse_bet =
1429 inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSide::Sell).unwrap();
1430
1431 assert_eq!(
1432 original_bet.outcome_win_payoff(),
1433 reverse_bet.outcome_lose_payoff(),
1434 );
1435 assert_eq!(
1436 original_bet.outcome_win_payoff(),
1437 inverse_bet.outcome_lose_payoff(),
1438 );
1439 assert_eq!(
1440 original_bet.outcome_lose_payoff(),
1441 reverse_bet.outcome_win_payoff(),
1442 );
1443 assert_eq!(
1444 original_bet.outcome_lose_payoff(),
1445 inverse_bet.outcome_win_payoff(),
1446 );
1447 }
1448
1449 #[rstest]
1450 fn test_inverse_probability_to_bet_example2() {
1451 let original_bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Sell).unwrap();
1452 let inverse_bet =
1453 inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Sell).unwrap();
1454
1455 assert_eq!(original_bet.stake, dec!(32.0));
1456 assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
1457 assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
1458
1459 assert_eq!(inverse_bet.stake, dec!(18.0));
1460 assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
1461 assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
1462 }
1463
1464 #[rstest]
1465 fn test_from_liability_checked_rejects_back_side() {
1466 let err = Bet::from_liability_checked(dec!(2.0), dec!(100.0), BetSide::Back).unwrap_err();
1467 assert_eq!(
1468 err.to_string(),
1469 "Liability-based betting is only applicable for Lay side."
1470 );
1471 }
1472
1473 #[rstest]
1474 #[case(dec!(1.0))]
1475 #[case(dec!(0.0))]
1476 #[case(dec!(-1.0))]
1477 fn test_from_liability_checked_rejects_odds_at_or_below_one(#[case] price: Decimal) {
1478 let err = Bet::from_liability_checked(price, dec!(100.0), BetSide::Lay).unwrap_err();
1479 assert_eq!(
1480 err.to_string(),
1481 format!("Price must be greater than 1.0 for lay liability calculation, was {price}")
1482 );
1483 }
1484
1485 #[rstest]
1486 fn test_from_stake_or_liability_checked_rejects_lay_odds_at_one() {
1487 let err =
1488 Bet::from_stake_or_liability_checked(dec!(1.0), dec!(100.0), BetSide::Lay).unwrap_err();
1489 assert_eq!(
1490 err.to_string(),
1491 "Price must be greater than 1.0 for lay liability calculation, was 1.0"
1492 );
1493 }
1494
1495 #[rstest]
1496 fn test_from_stake_or_liability_checked_allows_back_odds_at_one() {
1497 let bet =
1498 Bet::from_stake_or_liability_checked(dec!(1.0), dec!(10.0), BetSide::Back).unwrap();
1499 assert_eq!(bet.price(), dec!(1.0));
1500 assert_eq!(bet.stake(), dec!(10.0));
1501 assert_eq!(bet.side(), BetSide::Back);
1502 assert_eq!(bet.exposure_checked().unwrap(), dec!(10.0));
1503 assert_eq!(bet.profit_checked().unwrap(), dec!(0.0));
1504 }
1505
1506 #[rstest]
1507 fn test_from_liability_checked_preserves_stake_identity() {
1508 let bet = Bet::from_liability_checked(dec!(2.5), dec!(15.0), BetSide::Lay).unwrap();
1509 assert_eq!(bet.stake(), dec!(10.0));
1510 assert_eq!(bet.liability_checked().unwrap(), dec!(15.0));
1511 }
1512
1513 #[rstest]
1514 fn test_hedging_stake_checked_rejects_zero_price() {
1515 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1516 let err = bet.hedging_stake_checked(Decimal::ZERO).unwrap_err();
1517 assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
1518 }
1519
1520 #[rstest]
1521 fn test_hedging_bet_checked_rejects_zero_price() {
1522 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1523 let err = bet.hedging_bet_checked(Decimal::ZERO).unwrap_err();
1524 assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
1525 }
1526
1527 #[rstest]
1528 fn test_exposure_checked_rejects_overflow() {
1529 let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back);
1530 let err = bet.exposure_checked().unwrap_err();
1531 assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1532 }
1533
1534 #[rstest]
1535 fn test_liability_checked_rejects_overflow() {
1536 let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Lay);
1537 let err = bet.liability_checked().unwrap_err();
1538 assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1539 }
1540
1541 #[rstest]
1542 fn test_flattening_bet_checked_rejects_zero_price() {
1543 let mut position = BetPosition::default();
1544 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1545 let err = position.flattening_bet_checked(Decimal::ZERO).unwrap_err();
1546 assert_eq!(err.to_string(), "invalid price: must be non-zero");
1547 }
1548
1549 #[rstest]
1550 fn test_add_bet_checked_matches_infallible_decrease() {
1551 let back = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1552 let lay = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1553 let mut expected = BetPosition::default();
1554 expected.add_bet(back.clone());
1555 expected.add_bet(lay.clone());
1556
1557 let mut position = BetPosition::default();
1558 position.add_bet_checked(back).unwrap();
1559 position.add_bet_checked(lay).unwrap();
1560
1561 assert_eq!(position.price(), expected.price());
1562 assert_eq!(position.exposure(), expected.exposure());
1563 assert_eq!(position.realized_pnl(), expected.realized_pnl());
1564 assert_eq!(position.bets(), expected.bets());
1565 }
1566
1567 #[rstest]
1568 fn test_add_bet_checked_rejects_overflow_and_leaves_position_unchanged() {
1569 let mut position = BetPosition::default();
1570 position
1571 .add_bet_checked(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back))
1572 .unwrap();
1573 let before_price = position.price();
1574 let before_exposure = position.exposure();
1575 let before_len = position.bets().len();
1576
1577 let err = position
1578 .add_bet_checked(Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back))
1579 .unwrap_err();
1580
1581 assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1582 assert_eq!(position.price(), before_price);
1583 assert_eq!(position.exposure(), before_exposure);
1584 assert_eq!(position.bets().len(), before_len);
1585 }
1586
1587 #[rstest]
1588 fn test_checked_methods_preserve_valid_identities() {
1589 let back = Bet::new(dec!(2.5), dec!(10.0), BetSide::Back);
1590 let hedge = back.hedging_bet_checked(dec!(1.5)).unwrap();
1591
1592 assert_eq!(back.exposure_checked().unwrap(), back.exposure());
1593 assert_eq!(back.liability_checked().unwrap(), back.liability());
1594 assert_eq!(back.profit_checked().unwrap(), back.profit());
1595 assert_eq!(
1596 back.outcome_win_payoff_checked().unwrap(),
1597 back.outcome_win_payoff()
1598 );
1599 assert_eq!(
1600 back.outcome_lose_payoff_checked().unwrap(),
1601 back.outcome_lose_payoff()
1602 );
1603 assert_eq!(hedge, back.hedging_bet(dec!(1.5)));
1604 assert_eq!(
1605 calc_bets_pnl_checked(&[back.clone(), hedge.clone()]).unwrap(),
1606 calc_bets_pnl(&[back, hedge])
1607 );
1608 }
1609}