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) if current_side == bet.side => self.position_increase(&bet),
415 Some(_) => self.position_decrease(&bet),
416 }
417 self.bets.push(bet);
418 }
419
420 pub fn add_bet_checked(&mut self, bet: Bet) -> anyhow::Result<()> {
427 let (price, exposure, realized_pnl) = match self.side() {
428 None => self.increased_state(&bet)?,
429 Some(current_side) if current_side == bet.side => self.increased_state(&bet)?,
430 Some(_) => self.decreased_state(&bet)?,
431 };
432 self.price = price;
433 self.exposure = exposure;
434 self.realized_pnl = realized_pnl;
435 self.bets.push(bet);
436 Ok(())
437 }
438
439 fn increased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
440 let bet_exposure = bet.exposure_checked()?;
441 let price = if self.side().is_none() {
442 bet.price
443 } else if self.side() == Some(bet.side)
444 && self.price > Decimal::ZERO
445 && bet.price > Decimal::ZERO
446 && bet.stake > Decimal::ZERO
447 {
448 let abs_self_exposure = self.exposure.abs();
449 let abs_bet_exposure = bet_exposure.abs();
450 let total_stake = checked_add(checked_div(abs_self_exposure, self.price)?, bet.stake)?;
451 checked_div(
452 checked_add(abs_self_exposure, abs_bet_exposure)?,
453 total_stake,
454 )?
455 } else {
456 self.price
457 };
458 Ok((
459 price,
460 checked_add(self.exposure, bet_exposure)?,
461 self.realized_pnl,
462 ))
463 }
464
465 fn decreased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
466 let current_side = self
467 .side()
468 .ok_or_else(|| anyhow::anyhow!("cannot decrease an empty bet position"))?;
469 let bet_exposure = bet.exposure_checked()?;
470 let abs_bet_exposure = bet_exposure.abs();
471 let abs_self_exposure = self.exposure.abs();
472
473 match abs_bet_exposure.cmp(&abs_self_exposure) {
474 std::cmp::Ordering::Less => {
475 check_nonzero_denominator(self.price, "price")?;
476 let decreasing_volume = checked_div(abs_bet_exposure, self.price)?;
477 let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
478 let pnl = calc_bets_pnl_checked(&[bet.clone(), decreasing_bet])?;
479 Ok((
480 self.price,
481 checked_add(self.exposure, bet_exposure)?,
482 checked_add(self.realized_pnl, pnl)?,
483 ))
484 }
485 std::cmp::Ordering::Greater => Ok((
486 bet.price,
487 checked_add(self.exposure, bet_exposure)?,
488 self.realized_after_close(bet)?,
489 )),
490 std::cmp::Ordering::Equal => Ok((
491 Decimal::ZERO,
492 Decimal::ZERO,
493 self.realized_after_close(bet)?,
494 )),
495 }
496 }
497
498 fn realized_after_close(&self, bet: &Bet) -> anyhow::Result<Decimal> {
499 match self.as_bet_checked()? {
500 Some(self_bet) => checked_add(
501 self.realized_pnl,
502 calc_bets_pnl_checked(&[bet.clone(), self_bet])?,
503 ),
504 None => Ok(self.realized_pnl),
505 }
506 }
507
508 pub fn position_increase(&mut self, bet: &Bet) {
512 if self.side().is_none() {
513 self.price = bet.price;
514 } else {
515 let abs_self_exposure = self.exposure.abs();
516 let abs_bet_exposure = bet.exposure().abs();
517 if self.side() == Some(bet.side)
523 && self.price > Decimal::ZERO
524 && bet.price > Decimal::ZERO
525 && bet.stake > Decimal::ZERO
526 {
527 let total_stake = abs_self_exposure / self.price + bet.stake;
528 self.price = (abs_self_exposure + abs_bet_exposure) / total_stake;
529 }
530 }
531 self.exposure += bet.exposure();
532 }
533
534 pub fn position_decrease(&mut self, bet: &Bet) {
540 let abs_bet_exposure = bet.exposure().abs();
541 let abs_self_exposure = self.exposure.abs();
542
543 match abs_bet_exposure.cmp(&abs_self_exposure) {
544 std::cmp::Ordering::Less => {
545 let decreasing_volume = abs_bet_exposure / self.price;
546 let current_side = self.side().unwrap();
547 let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
548 let pnl = calc_bets_pnl(&[bet.clone(), decreasing_bet]);
549 self.realized_pnl += pnl;
550 self.exposure += bet.exposure();
551 }
552 std::cmp::Ordering::Greater => {
553 if let Some(self_bet) = self.as_bet() {
554 let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
555 self.realized_pnl += pnl;
556 }
557 self.price = bet.price;
558 self.exposure += bet.exposure();
559 }
560 std::cmp::Ordering::Equal => {
561 if let Some(self_bet) = self.as_bet() {
562 let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
563 self.realized_pnl += pnl;
564 }
565 self.price = Decimal::ZERO;
566 self.exposure = Decimal::ZERO;
567 }
568 }
569 }
570
571 #[must_use]
577 pub fn unrealized_pnl(&self, price: Decimal) -> Decimal {
578 self.unrealized_pnl_checked(price)
579 .unwrap_or_else(|e| panic!("{e}"))
580 }
581
582 pub fn unrealized_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
588 let Some(flattening_bet) = self.flattening_bet_checked(price)? else {
589 return Ok(Decimal::ZERO);
590 };
591 let Some(self_bet) = self.as_bet_checked()? else {
592 return Ok(Decimal::ZERO);
593 };
594 calc_bets_pnl_checked(&[flattening_bet, self_bet])
595 }
596
597 #[must_use]
603 pub fn total_pnl(&self, price: Decimal) -> Decimal {
604 self.total_pnl_checked(price)
605 .unwrap_or_else(|e| panic!("{e}"))
606 }
607
608 pub fn total_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
614 checked_add(self.realized_pnl, self.unrealized_pnl_checked(price)?)
615 }
616
617 #[must_use]
624 pub fn flattening_bet(&self, price: Decimal) -> Option<Bet> {
625 self.flattening_bet_checked(price)
626 .unwrap_or_else(|e| panic!("{e}"))
627 }
628
629 pub fn flattening_bet_checked(&self, price: Decimal) -> anyhow::Result<Option<Bet>> {
636 let Some(side) = self.side() else {
637 return Ok(None);
638 };
639 check_nonzero_denominator(price, "price")?;
640 let stake = match side {
641 BetSide::Back => checked_div(self.exposure, price)?,
642 BetSide::Lay => checked_div(-self.exposure, price)?,
643 };
644 Ok(Some(Bet::new(price, stake, side.opposite())))
645 }
646
647 pub fn reset(&mut self) {
649 self.price = Decimal::ZERO;
650 self.exposure = Decimal::ZERO;
651 self.realized_pnl = Decimal::ZERO;
652 self.bets.clear();
653 }
654}
655
656impl Display for BetPosition {
657 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658 write!(
659 f,
660 "BetPosition(price={:.2}, exposure={:.2}, realized_pnl={:.2})",
661 self.price, self.exposure, self.realized_pnl
662 )
663 }
664}
665
666#[must_use]
672pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
673 calc_bets_pnl_checked(bets).unwrap_or_else(|e| panic!("{e}"))
674}
675
676pub fn calc_bets_pnl_checked(bets: &[Bet]) -> anyhow::Result<Decimal> {
682 bets.iter().try_fold(Decimal::ZERO, |acc, bet| {
683 checked_add(acc, bet.outcome_win_payoff_checked()?)
684 })
685}
686
687pub fn check_probability_non_zero(probability: Decimal) -> anyhow::Result<()> {
693 if probability.is_zero() {
694 anyhow::bail!("invalid probability: must be non-zero")
695 }
696 Ok(())
697}
698
699pub fn check_probability_invertible(probability: Decimal) -> anyhow::Result<()> {
705 if probability == Decimal::ONE {
706 anyhow::bail!("invalid probability: must not be 1.0 (inverse would be zero)")
707 }
708 Ok(())
709}
710
711pub fn probability_to_bet(
719 probability: Decimal,
720 volume: Decimal,
721 side: OrderSide,
722) -> anyhow::Result<Bet> {
723 check_probability_non_zero(probability)?;
724 let price = checked_div(Decimal::ONE, probability)?;
725 let stake = checked_div(volume, price)?;
726 let bet = match side {
727 OrderSide::Buy => Bet::new(price, stake, BetSide::Back),
728 OrderSide::Sell => Bet::new(price, stake, BetSide::Lay),
729 };
730 Ok(bet)
731}
732
733pub fn inverse_probability_to_bet(
741 probability: Decimal,
742 volume: Decimal,
743 side: OrderSide,
744) -> anyhow::Result<Bet> {
745 check_probability_invertible(probability)?;
746 let inverse_probability = checked_sub(Decimal::ONE, probability)?;
747 probability_to_bet(inverse_probability, volume, side.opposite())
748}
749
750fn check_odds_gt_one(price: Decimal) -> anyhow::Result<()> {
751 if price <= Decimal::ONE {
752 anyhow::bail!("Price must be greater than 1.0 for lay liability calculation, was {price}");
753 }
754 Ok(())
755}
756
757fn check_nonzero_denominator(value: Decimal, name: &str) -> anyhow::Result<()> {
758 if value.is_zero() {
759 anyhow::bail!("invalid {name}: must be non-zero")
760 }
761 Ok(())
762}
763
764fn checked_add(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
765 lhs.checked_add(rhs)
766 .ok_or_else(|| anyhow::anyhow!("Decimal overflow adding {lhs} and {rhs}"))
767}
768
769fn checked_sub(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
770 lhs.checked_sub(rhs)
771 .ok_or_else(|| anyhow::anyhow!("Decimal overflow subtracting {rhs} from {lhs}"))
772}
773
774fn checked_mul(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
775 lhs.checked_mul(rhs)
776 .ok_or_else(|| anyhow::anyhow!("Decimal overflow multiplying {lhs} by {rhs}"))
777}
778
779fn checked_div(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
780 check_nonzero_denominator(rhs, "divisor")?;
781 lhs.checked_div(rhs)
782 .ok_or_else(|| anyhow::anyhow!("Decimal overflow dividing {lhs} by {rhs}"))
783}
784
785#[cfg(test)]
786mod tests {
787 use rstest::rstest;
788 use rust_decimal::Decimal;
789 use rust_decimal_macros::dec;
790
791 use super::*;
792
793 fn dec_str(s: &str) -> Decimal {
794 s.parse::<Decimal>().expect("Failed to parse Decimal")
795 }
796
797 #[rstest]
798 #[should_panic(expected = "Liability-based betting is only applicable for Lay side.")]
799 fn test_from_liability_panics_on_back_side() {
800 let _ = Bet::from_liability(dec!(2.0), dec!(100.0), BetSide::Back);
801 }
802
803 #[rstest]
804 fn test_bet_creation() {
805 let price = dec!(2.0);
806 let stake = dec!(100.0);
807 let side = BetSide::Back;
808 let bet = Bet::new(price, stake, side);
809 assert_eq!(bet.price, price);
810 assert_eq!(bet.stake, stake);
811 assert_eq!(bet.side, side);
812 }
813
814 #[rstest]
815 fn test_display_bet() {
816 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
817 let formatted = format!("{bet}");
818 assert!(formatted.contains("Back"));
819 assert!(formatted.contains("2.00"));
820 assert!(formatted.contains("100.00"));
821 }
822
823 #[rstest]
824 fn test_bet_exposure_back() {
825 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
826 let exposure = bet.exposure();
827 assert_eq!(exposure, dec!(200.0));
828 }
829
830 #[rstest]
831 fn test_bet_exposure_lay() {
832 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
833 let exposure = bet.exposure();
834 assert_eq!(exposure, dec!(-200.0));
835 }
836
837 #[rstest]
838 fn test_bet_liability_back() {
839 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
840 let liability = bet.liability();
841 assert_eq!(liability, dec!(100.0));
842 }
843
844 #[rstest]
845 fn test_bet_liability_lay() {
846 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
847 let liability = bet.liability();
848 assert_eq!(liability, dec!(100.0));
849 }
850
851 #[rstest]
852 fn test_bet_profit_back() {
853 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
854 let profit = bet.profit();
855 assert_eq!(profit, dec!(100.0));
856 }
857
858 #[rstest]
859 fn test_bet_profit_lay() {
860 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
861 let profit = bet.profit();
862 assert_eq!(profit, dec!(100.0));
863 }
864
865 #[rstest]
866 fn test_outcome_win_payoff_back() {
867 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
868 let win_payoff = bet.outcome_win_payoff();
869 assert_eq!(win_payoff, dec!(100.0));
870 }
871
872 #[rstest]
873 fn test_outcome_win_payoff_lay() {
874 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
875 let win_payoff = bet.outcome_win_payoff();
876 assert_eq!(win_payoff, dec!(-100.0));
877 }
878
879 #[rstest]
880 fn test_outcome_lose_payoff_back() {
881 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
882 let lose_payoff = bet.outcome_lose_payoff();
883 assert_eq!(lose_payoff, dec!(-100.0));
884 }
885
886 #[rstest]
887 fn test_outcome_lose_payoff_lay() {
888 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
889 let lose_payoff = bet.outcome_lose_payoff();
890 assert_eq!(lose_payoff, dec!(100.0));
891 }
892
893 #[rstest]
894 fn test_hedging_stake_back() {
895 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
896 let hedging_stake = bet.hedging_stake(dec!(1.5));
897 assert_eq!(hedging_stake.round_dp(8), dec_str("133.33333333"));
899 }
900
901 #[rstest]
902 fn test_hedging_bet_lay() {
903 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
904 let hedge_bet = bet.hedging_bet(dec!(1.5));
905 assert_eq!(hedge_bet.side, BetSide::Back);
906 assert_eq!(hedge_bet.price, dec!(1.5));
907 assert_eq!(hedge_bet.stake.round_dp(8), dec_str("133.33333333"));
908 }
909
910 #[rstest]
911 fn test_bet_position_initialization() {
912 let position = BetPosition::default();
913 assert_eq!(position.price, dec!(0.0));
914 assert_eq!(position.exposure, dec!(0.0));
915 assert_eq!(position.realized_pnl, dec!(0.0));
916 }
917
918 #[rstest]
919 fn test_display_bet_position() {
920 let mut position = BetPosition::default();
921 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
922 position.add_bet(bet);
923
924 assert_eq!(
925 format!("{position}"),
926 "BetPosition(price=2.00, exposure=200.00, realized_pnl=0.00)"
927 );
928 }
929
930 #[rstest]
931 fn test_as_bet() {
932 let mut position = BetPosition::default();
933 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
935 position.add_bet(bet);
936 let as_bet = position.as_bet().expect("Expected a bet representation");
937
938 assert_eq!(as_bet.price, position.price);
939 assert_eq!(as_bet.stake, position.exposure / position.price);
940 assert_eq!(as_bet.side, BetSide::Back);
941 }
942
943 #[rstest]
944 fn test_reset_position() {
945 let mut position = BetPosition::default();
946 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
947 position.add_bet(bet);
948 assert_ne!(position.exposure, dec!(0.0));
949 assert!(!position.bets().is_empty());
950 position.reset();
951
952 assert_eq!(position.price, dec!(0.0));
954 assert_eq!(position.exposure, dec!(0.0));
955 assert_eq!(position.realized_pnl, dec!(0.0));
956 assert!(position.bets().is_empty());
957 }
958
959 #[rstest]
960 fn test_bet_position_side_none() {
961 let position = BetPosition::default();
962 assert!(position.side().is_none());
963 }
964
965 #[rstest]
966 fn test_bet_position_side_back() {
967 let mut position = BetPosition::default();
968 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
969 position.add_bet(bet);
970 assert_eq!(position.side(), Some(BetSide::Back));
971 }
972
973 #[rstest]
974 fn test_bet_position_side_lay() {
975 let mut position = BetPosition::default();
976 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
977 position.add_bet(bet);
978 assert_eq!(position.side(), Some(BetSide::Lay));
979 }
980
981 #[rstest]
982 fn test_position_increase_back() {
983 let mut position = BetPosition::default();
984 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
985 let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
986 position.add_bet(bet1);
987 position.add_bet(bet2);
988 assert_eq!(position.exposure, dec!(300.0));
990 }
991
992 #[rstest]
993 fn test_position_increase_cancelling_stakes_preserves_price() {
994 let mut position = BetPosition::default();
995 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
996 position.add_bet(Bet::new(dec!(2.0), dec!(-100.0), BetSide::Back));
999
1000 assert_eq!(position.price, dec!(2.0));
1001 assert_eq!(position.exposure, dec!(0.0));
1002 }
1003
1004 #[rstest]
1005 fn test_position_increase_negative_stake_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!(3.0), dec!(-50.0), BetSide::Back));
1010
1011 assert_eq!(position.price, dec!(2.0));
1012 assert_eq!(position.exposure, dec!(50.0));
1013 }
1014
1015 #[rstest]
1016 fn test_position_increase_opposite_side_preserves_price() {
1017 let mut position = BetPosition::default();
1018 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1019 position.position_increase(&Bet::new(dec!(3.0), dec!(50.0), BetSide::Lay));
1022
1023 assert_eq!(position.price, dec!(2.0));
1024 }
1025
1026 #[rstest]
1027 #[case(dec!(0.0))]
1028 #[case(dec!(-3.0))]
1029 fn test_position_increase_non_positive_incoming_price_preserves_price(#[case] price: Decimal) {
1030 let mut position = BetPosition::default();
1031 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1032 position.add_bet(Bet::new(price, dec!(50.0), BetSide::Back));
1034
1035 assert_eq!(position.price, dec!(2.0));
1036 }
1037
1038 #[rstest]
1039 fn test_position_increase_non_positive_current_price_preserves_price() {
1040 let mut position = BetPosition::default();
1041 position.add_bet(Bet::new(dec!(-3.0), dec!(100.0), BetSide::Lay));
1044 assert_eq!(position.side(), Some(BetSide::Back));
1045
1046 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1050
1051 assert_eq!(position.price, dec!(-3.0));
1052 assert_eq!(position.exposure, dec!(500.0));
1053 }
1054
1055 #[rstest]
1056 fn test_position_increase_back_averages_price_and_conserves_pnl() {
1057 let mut position = BetPosition::default();
1058 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1059 let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Back);
1060 let settlement_price = dec!(3.0);
1061 let constituent_pnl = calc_bets_pnl(&[
1062 bet1.clone(),
1063 bet1.hedging_bet(settlement_price),
1064 bet2.clone(),
1065 bet2.hedging_bet(settlement_price),
1066 ]);
1067
1068 position.add_bet(bet1);
1069 position.add_bet(bet2);
1070
1071 assert_eq!(position.price, dec!(400.0) / dec!(150.0));
1072 assert_eq!(
1073 position.total_pnl(settlement_price).round_dp(8),
1074 constituent_pnl.round_dp(8)
1075 );
1076 }
1077
1078 #[rstest]
1079 fn test_position_increase_lay() {
1080 let mut position = BetPosition::default();
1081 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1082 let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Lay);
1083 position.add_bet(bet1);
1084 position.add_bet(bet2);
1085 assert_eq!(position.exposure, dec!(-300.0));
1087 }
1088
1089 #[rstest]
1090 fn test_position_increase_lay_averages_price_and_conserves_pnl() {
1091 let mut position = BetPosition::default();
1092 let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1093 let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Lay);
1094 let settlement_price = dec!(3.0);
1095 let constituent_pnl = calc_bets_pnl(&[
1096 bet1.clone(),
1097 bet1.hedging_bet(settlement_price),
1098 bet2.clone(),
1099 bet2.hedging_bet(settlement_price),
1100 ]);
1101
1102 position.add_bet(bet1);
1103 position.add_bet(bet2);
1104
1105 assert_eq!(position.price, dec!(400.0) / dec!(150.0));
1106 assert_eq!(
1107 position.total_pnl(settlement_price).round_dp(8),
1108 constituent_pnl.round_dp(8)
1109 );
1110 }
1111
1112 #[rstest]
1113 fn test_position_back_then_lay() {
1114 let mut position = BetPosition::default();
1115 let bet1 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1116 let bet2 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1117 position.add_bet(bet1);
1118 position.add_bet(bet2);
1119
1120 assert_eq!(position.exposure, dec!(280_000.0));
1121 assert_eq!(position.realized_pnl(), dec!(3333.333333333333333333333333));
1122 assert_eq!(
1123 position.unrealized_pnl(dec!(4.0)),
1124 dec!(-23333.33333333333333333333334)
1125 );
1126 }
1127
1128 #[rstest]
1129 fn test_position_lay_then_back() {
1130 let mut position = BetPosition::default();
1131 let bet1 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1132 let bet2 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1133 position.add_bet(bet1);
1134 position.add_bet(bet2);
1135
1136 assert_eq!(position.exposure, dec!(280_000.0));
1137 assert_eq!(position.realized_pnl(), dec!(190_000));
1138 assert_eq!(
1139 position.unrealized_pnl(dec!(4.0)),
1140 dec!(-23333.33333333333333333333334)
1141 );
1142 }
1143
1144 #[rstest]
1145 fn test_position_flip() {
1146 let mut position = BetPosition::default();
1147 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);
1150 position.add_bet(lay_bet);
1151 assert_eq!(position.side(), Some(BetSide::Lay));
1153 assert_eq!(position.exposure, dec!(-100.0));
1154 }
1155
1156 #[rstest]
1157 fn test_position_flat() {
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!(100.0), BetSide::Lay); position.add_bet(back_bet);
1162 position.add_bet(lay_bet);
1163 assert!(position.side().is_none());
1164 assert_eq!(position.exposure, dec!(0.0));
1165 }
1166
1167 #[rstest]
1168 fn test_unrealized_pnl_negative() {
1169 let mut position = BetPosition::default();
1170 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); position.add_bet(bet);
1172 let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
1174 assert_eq!(unrealized_pnl, dec!(-20.0));
1175 }
1176
1177 #[rstest]
1178 fn test_total_pnl() {
1179 let mut position = BetPosition::default();
1180 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1181 position.add_bet(bet);
1182 position.realized_pnl = dec!(10.0);
1183 let total_pnl = position.total_pnl(dec!(2.5));
1184 assert_eq!(total_pnl, dec!(-10.0));
1186 }
1187
1188 #[rstest]
1189 fn test_flattening_bet_back_profit() {
1190 let mut position = BetPosition::default();
1191 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1192 position.add_bet(bet);
1193 let flattening_bet = position
1194 .flattening_bet(dec!(1.6))
1195 .expect("expected a flattening bet");
1196 assert_eq!(flattening_bet.side, BetSide::Lay);
1197 assert_eq!(flattening_bet.stake, dec_str("125"));
1198 }
1199
1200 #[rstest]
1201 fn test_flattening_bet_back_hack() {
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!(2.5))
1207 .expect("expected a flattening bet");
1208 assert_eq!(flattening_bet.side, BetSide::Lay);
1209 assert_eq!(flattening_bet.stake, dec!(80.0));
1211 }
1212
1213 #[rstest]
1214 fn test_flattening_bet_lay() {
1215 let mut position = BetPosition::default();
1216 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1217 position.add_bet(bet);
1218 let flattening_bet = position
1219 .flattening_bet(dec!(1.5))
1220 .expect("expected a flattening bet");
1221 assert_eq!(flattening_bet.side, BetSide::Back);
1222 assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
1223 }
1224
1225 #[rstest]
1226 fn test_realized_pnl_flattening() {
1227 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();
1230 position.add_bet(back);
1231 position.add_bet(lay);
1232 assert_eq!(position.realized_pnl, dec!(25.0));
1234 }
1235
1236 #[rstest]
1237 fn test_realized_pnl_single_side() {
1238 let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1239 let mut position = BetPosition::default();
1240 position.add_bet(back);
1241 assert_eq!(position.realized_pnl, dec!(0.0));
1243 }
1244
1245 #[rstest]
1246 fn test_realized_pnl_open_position() {
1247 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();
1250 position.add_bet(back);
1251 position.add_bet(lay);
1252 assert_eq!(position.realized_pnl, dec!(20.0));
1254 }
1255
1256 #[rstest]
1257 fn test_realized_pnl_partial_close() {
1258 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();
1261 position.add_bet(back);
1262 position.add_bet(lay);
1263 assert_eq!(position.realized_pnl, dec!(22.0));
1265 }
1266
1267 #[rstest]
1268 fn test_realized_pnl_flipping() {
1269 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();
1272 position.add_bet(back);
1273 position.add_bet(lay);
1274 assert_eq!(position.realized_pnl, dec!(10.0));
1276 }
1277
1278 #[rstest]
1279 fn test_unrealized_pnl_positive() {
1280 let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); let mut position = BetPosition::default();
1282 position.add_bet(back);
1283 let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1284 assert_eq!(unrealized_pnl, dec!(25.0));
1286 }
1287
1288 #[rstest]
1289 fn test_total_pnl_with_pnl() {
1290 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();
1293 position.add_bet(back);
1294 position.add_bet(lay);
1295 let realized_pnl = position.realized_pnl;
1297 let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1298 let total_pnl = position.total_pnl(dec!(4.0));
1299 assert_eq!(realized_pnl, dec!(24.0));
1300 assert_eq!(unrealized_pnl, dec!(1.0));
1301 assert_eq!(total_pnl, dec!(25.0));
1302 }
1303
1304 #[rstest]
1305 fn test_open_position_realized_unrealized() {
1306 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();
1309 position.add_bet(back);
1310 position.add_bet(lay);
1311 let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1312 assert_eq!(unrealized_pnl, dec!(5.0));
1314 }
1315
1316 #[rstest]
1317 fn test_unrealized_no_position() {
1318 let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
1319 let mut position = BetPosition::default();
1320 position.add_bet(back);
1321 let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
1322 assert_eq!(unrealized_pnl, dec!(0.0));
1323 }
1324
1325 #[rstest]
1326 fn test_calc_bets_pnl_single_back_bet() {
1327 let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1328 let pnl = calc_bets_pnl(&[bet]);
1329 assert_eq!(pnl, dec!(400.0));
1330 }
1331
1332 #[rstest]
1333 fn test_calc_bets_pnl_single_lay_bet() {
1334 let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
1335 let pnl = calc_bets_pnl(&[bet]);
1336 assert_eq!(pnl, dec!(-300.0));
1337 }
1338
1339 #[rstest]
1340 fn test_calc_bets_pnl_multiple_bets() {
1341 let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1342 let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
1343 let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
1344 let expected = dec!(400.0) + dec!(-300.0);
1345 assert_eq!(pnl, expected);
1346 }
1347
1348 #[rstest]
1349 fn test_calc_bets_pnl_mixed_bets() {
1350 let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1351 let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
1352 let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
1353 let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
1354 let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
1355 assert_eq!(pnl, expected);
1356 }
1357
1358 #[rstest]
1359 fn test_calc_bets_pnl_no_bets() {
1360 let bets: Vec<Bet> = vec![];
1361 let pnl = calc_bets_pnl(&bets);
1362 assert_eq!(pnl, dec!(0.0));
1363 }
1364
1365 #[rstest]
1366 fn test_calc_bets_pnl_zero_outcome() {
1367 let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1368 let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
1369 let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
1370 assert_eq!(pnl, dec!(0.0));
1371 }
1372
1373 #[rstest]
1374 fn test_probability_to_bet_back_simple() {
1375 let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSide::Buy).unwrap();
1377 let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
1378 assert_eq!(bet, expected);
1379 assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
1380 assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
1381 }
1382
1383 #[rstest]
1384 fn test_probability_to_bet_back_high_prob() {
1385 let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Buy).unwrap();
1386 let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
1387 assert_eq!(bet, expected);
1388 assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
1389 assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
1390 }
1391
1392 #[rstest]
1393 fn test_probability_to_bet_back_low_prob() {
1394 let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSide::Buy).unwrap();
1395 let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
1396 assert_eq!(bet, expected);
1397 assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
1398 assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
1399 }
1400
1401 #[rstest]
1402 fn test_probability_to_bet_sell() {
1403 let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSide::Sell).unwrap();
1404 let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
1405 assert_eq!(bet, expected);
1406 assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
1407 assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
1408 }
1409
1410 #[rstest]
1411 fn test_inverse_probability_to_bet() {
1412 let original_bet = probability_to_bet(dec!(0.80), dec!(100.0), OrderSide::Sell).unwrap();
1414 let reverse_bet = probability_to_bet(dec!(0.20), dec!(100.0), OrderSide::Buy).unwrap();
1416 let inverse_bet =
1417 inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSide::Sell).unwrap();
1418
1419 assert_eq!(
1420 original_bet.outcome_win_payoff(),
1421 reverse_bet.outcome_lose_payoff(),
1422 );
1423 assert_eq!(
1424 original_bet.outcome_win_payoff(),
1425 inverse_bet.outcome_lose_payoff(),
1426 );
1427 assert_eq!(
1428 original_bet.outcome_lose_payoff(),
1429 reverse_bet.outcome_win_payoff(),
1430 );
1431 assert_eq!(
1432 original_bet.outcome_lose_payoff(),
1433 inverse_bet.outcome_win_payoff(),
1434 );
1435 }
1436
1437 #[rstest]
1438 fn test_inverse_probability_to_bet_example2() {
1439 let original_bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Sell).unwrap();
1440 let inverse_bet =
1441 inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Sell).unwrap();
1442
1443 assert_eq!(original_bet.stake, dec!(32.0));
1444 assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
1445 assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
1446
1447 assert_eq!(inverse_bet.stake, dec!(18.0));
1448 assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
1449 assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
1450 }
1451
1452 #[rstest]
1453 fn test_from_liability_checked_rejects_back_side() {
1454 let err = Bet::from_liability_checked(dec!(2.0), dec!(100.0), BetSide::Back).unwrap_err();
1455 assert_eq!(
1456 err.to_string(),
1457 "Liability-based betting is only applicable for Lay side."
1458 );
1459 }
1460
1461 #[rstest]
1462 #[case(dec!(1.0))]
1463 #[case(dec!(0.0))]
1464 #[case(dec!(-1.0))]
1465 fn test_from_liability_checked_rejects_odds_at_or_below_one(#[case] price: Decimal) {
1466 let err = Bet::from_liability_checked(price, dec!(100.0), BetSide::Lay).unwrap_err();
1467 assert_eq!(
1468 err.to_string(),
1469 format!("Price must be greater than 1.0 for lay liability calculation, was {price}")
1470 );
1471 }
1472
1473 #[rstest]
1474 fn test_from_stake_or_liability_checked_rejects_lay_odds_at_one() {
1475 let err =
1476 Bet::from_stake_or_liability_checked(dec!(1.0), dec!(100.0), BetSide::Lay).unwrap_err();
1477 assert_eq!(
1478 err.to_string(),
1479 "Price must be greater than 1.0 for lay liability calculation, was 1.0"
1480 );
1481 }
1482
1483 #[rstest]
1484 fn test_from_stake_or_liability_checked_allows_back_odds_at_one() {
1485 let bet =
1486 Bet::from_stake_or_liability_checked(dec!(1.0), dec!(10.0), BetSide::Back).unwrap();
1487 assert_eq!(bet.price(), dec!(1.0));
1488 assert_eq!(bet.stake(), dec!(10.0));
1489 assert_eq!(bet.side(), BetSide::Back);
1490 assert_eq!(bet.exposure_checked().unwrap(), dec!(10.0));
1491 assert_eq!(bet.profit_checked().unwrap(), dec!(0.0));
1492 }
1493
1494 #[rstest]
1495 fn test_from_liability_checked_preserves_stake_identity() {
1496 let bet = Bet::from_liability_checked(dec!(2.5), dec!(15.0), BetSide::Lay).unwrap();
1497 assert_eq!(bet.stake(), dec!(10.0));
1498 assert_eq!(bet.liability_checked().unwrap(), dec!(15.0));
1499 }
1500
1501 #[rstest]
1502 fn test_hedging_stake_checked_rejects_zero_price() {
1503 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1504 let err = bet.hedging_stake_checked(Decimal::ZERO).unwrap_err();
1505 assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
1506 }
1507
1508 #[rstest]
1509 fn test_hedging_bet_checked_rejects_zero_price() {
1510 let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1511 let err = bet.hedging_bet_checked(Decimal::ZERO).unwrap_err();
1512 assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
1513 }
1514
1515 #[rstest]
1516 fn test_exposure_checked_rejects_overflow() {
1517 let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back);
1518 let err = bet.exposure_checked().unwrap_err();
1519 assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1520 }
1521
1522 #[rstest]
1523 fn test_liability_checked_rejects_overflow() {
1524 let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Lay);
1525 let err = bet.liability_checked().unwrap_err();
1526 assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1527 }
1528
1529 #[rstest]
1530 fn test_flattening_bet_checked_rejects_zero_price() {
1531 let mut position = BetPosition::default();
1532 position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1533 let err = position.flattening_bet_checked(Decimal::ZERO).unwrap_err();
1534 assert_eq!(err.to_string(), "invalid price: must be non-zero");
1535 }
1536
1537 #[rstest]
1538 fn test_add_bet_checked_matches_infallible_decrease() {
1539 let back = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1540 let lay = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1541 let mut expected = BetPosition::default();
1542 expected.add_bet(back.clone());
1543 expected.add_bet(lay.clone());
1544
1545 let mut position = BetPosition::default();
1546 position.add_bet_checked(back).unwrap();
1547 position.add_bet_checked(lay).unwrap();
1548
1549 assert_eq!(position.price(), expected.price());
1550 assert_eq!(position.exposure(), expected.exposure());
1551 assert_eq!(position.realized_pnl(), expected.realized_pnl());
1552 assert_eq!(position.bets(), expected.bets());
1553 }
1554
1555 #[rstest]
1556 fn test_add_bet_checked_rejects_overflow_and_leaves_position_unchanged() {
1557 let mut position = BetPosition::default();
1558 position
1559 .add_bet_checked(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back))
1560 .unwrap();
1561 let before_price = position.price();
1562 let before_exposure = position.exposure();
1563 let before_len = position.bets().len();
1564
1565 let err = position
1566 .add_bet_checked(Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back))
1567 .unwrap_err();
1568
1569 assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1570 assert_eq!(position.price(), before_price);
1571 assert_eq!(position.exposure(), before_exposure);
1572 assert_eq!(position.bets().len(), before_len);
1573 }
1574
1575 #[rstest]
1576 fn test_checked_methods_preserve_valid_identities() {
1577 let back = Bet::new(dec!(2.5), dec!(10.0), BetSide::Back);
1578 let hedge = back.hedging_bet_checked(dec!(1.5)).unwrap();
1579
1580 assert_eq!(back.exposure_checked().unwrap(), back.exposure());
1581 assert_eq!(back.liability_checked().unwrap(), back.liability());
1582 assert_eq!(back.profit_checked().unwrap(), back.profit());
1583 assert_eq!(
1584 back.outcome_win_payoff_checked().unwrap(),
1585 back.outcome_win_payoff()
1586 );
1587 assert_eq!(
1588 back.outcome_lose_payoff_checked().unwrap(),
1589 back.outcome_lose_payoff()
1590 );
1591 assert_eq!(hedge, back.hedging_bet(dec!(1.5)));
1592 assert_eq!(
1593 calc_bets_pnl_checked(&[back.clone(), hedge.clone()]).unwrap(),
1594 calc_bets_pnl(&[back, hedge])
1595 );
1596 }
1597}