nautilus_model/data/
bet.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Domain model representing a *Bet* used by betting-market integrations (e.g. prediction markets).
17
18use std::fmt::Display;
19
20use rust_decimal::Decimal;
21
22use crate::enums::{BetSide, OrderSideSpecified};
23
24/// A bet in a betting market.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26#[cfg_attr(
27    feature = "python",
28    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
29)]
30pub struct Bet {
31    price: Decimal,
32    stake: Decimal,
33    side: BetSide,
34}
35
36impl Bet {
37    /// Creates a new [`Bet`] instance.
38    pub fn new(price: Decimal, stake: Decimal, side: BetSide) -> Self {
39        Self { price, stake, side }
40    }
41
42    /// Returns the bet's price.
43    #[must_use]
44    pub fn price(&self) -> Decimal {
45        self.price
46    }
47
48    /// Returns the bet's stake.
49    #[must_use]
50    pub fn stake(&self) -> Decimal {
51        self.stake
52    }
53
54    /// Returns the bet's side.
55    #[must_use]
56    pub fn side(&self) -> BetSide {
57        self.side
58    }
59
60    /// Creates a bet from a stake or liability depending on the bet side.
61    ///
62    /// For `BetSide::Back` this calls [Self::from_stake] and for
63    /// `BetSide::Lay` it calls [Self::from_liability].
64    pub fn from_stake_or_liability(price: Decimal, volume: Decimal, side: BetSide) -> Self {
65        match side {
66            BetSide::Back => Self::from_stake(price, volume, side),
67            BetSide::Lay => Self::from_liability(price, volume, side),
68        }
69    }
70
71    /// Creates a bet from a given stake.
72    pub fn from_stake(price: Decimal, stake: Decimal, side: BetSide) -> Self {
73        Self::new(price, stake, side)
74    }
75
76    /// Creates a bet from a given liability.
77    ///
78    /// # Panics
79    ///
80    /// Panics if the side is not [BetSide::Lay].
81    pub fn from_liability(price: Decimal, liability: Decimal, side: BetSide) -> Self {
82        assert!(
83            side == BetSide::Lay,
84            "Liability-based betting is only applicable for Lay side."
85        );
86        let adjusted_volume = liability / (price - Decimal::ONE);
87        Self::new(price, adjusted_volume, side)
88    }
89
90    /// Returns the bet's exposure.
91    ///
92    /// For BACK bets, exposure is positive; for LAY bets, it is negative.
93    pub fn exposure(&self) -> Decimal {
94        match self.side {
95            BetSide::Back => self.price * self.stake,
96            BetSide::Lay => -self.price * self.stake,
97        }
98    }
99
100    /// Returns the bet's liability.
101    ///
102    /// For BACK bets, liability equals the stake; for LAY bets, it is
103    /// stake multiplied by (price - 1).
104    pub fn liability(&self) -> Decimal {
105        match self.side {
106            BetSide::Back => self.stake,
107            BetSide::Lay => self.stake * (self.price - Decimal::ONE),
108        }
109    }
110
111    /// Returns the bet's profit.
112    ///
113    /// For BACK bets, profit is stake * (price - 1); for LAY bets it equals the stake.
114    pub fn profit(&self) -> Decimal {
115        match self.side {
116            BetSide::Back => self.stake * (self.price - Decimal::ONE),
117            BetSide::Lay => self.stake,
118        }
119    }
120
121    /// Returns the outcome win payoff.
122    ///
123    /// For BACK bets this is the profit; for LAY bets it is the negative liability.
124    pub fn outcome_win_payoff(&self) -> Decimal {
125        match self.side {
126            BetSide::Back => self.profit(),
127            BetSide::Lay => -self.liability(),
128        }
129    }
130
131    /// Returns the outcome lose payoff.
132    ///
133    /// For BACK bets this is the negative liability; for LAY bets it is the profit.
134    pub fn outcome_lose_payoff(&self) -> Decimal {
135        match self.side {
136            BetSide::Back => -self.liability(),
137            BetSide::Lay => self.profit(),
138        }
139    }
140
141    /// Returns the hedging stake given a new price.
142    pub fn hedging_stake(&self, price: Decimal) -> Decimal {
143        match self.side {
144            BetSide::Back => (self.price / price) * self.stake,
145            BetSide::Lay => self.stake / (price / self.price),
146        }
147    }
148
149    /// Creates a hedging bet for a given price.
150    #[must_use]
151    pub fn hedging_bet(&self, price: Decimal) -> Self {
152        Self::new(price, self.hedging_stake(price), self.side.opposite())
153    }
154}
155
156impl Display for Bet {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        // Example output: "Bet(Back @ 2.50 x10.00)"
159        write!(
160            f,
161            "Bet({:?} @ {:.2} x{:.2})",
162            self.side, self.price, self.stake
163        )
164    }
165}
166
167/// A position comprising one or more bets.
168#[derive(Debug, Clone)]
169#[cfg_attr(
170    feature = "python",
171    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
172)]
173pub struct BetPosition {
174    price: Decimal,
175    exposure: Decimal,
176    realized_pnl: Decimal,
177    bets: Vec<Bet>,
178}
179
180impl Default for BetPosition {
181    fn default() -> Self {
182        Self {
183            price: Decimal::ZERO,
184            exposure: Decimal::ZERO,
185            realized_pnl: Decimal::ZERO,
186            bets: vec![],
187        }
188    }
189}
190
191impl BetPosition {
192    /// Returns the position's price.
193    #[must_use]
194    pub fn price(&self) -> Decimal {
195        self.price
196    }
197
198    /// Returns the position's exposure.
199    #[must_use]
200    pub fn exposure(&self) -> Decimal {
201        self.exposure
202    }
203
204    /// Returns the position's realized profit and loss.
205    #[must_use]
206    pub fn realized_pnl(&self) -> Decimal {
207        self.realized_pnl
208    }
209
210    /// Returns a reference to the position's bets.
211    #[must_use]
212    pub fn bets(&self) -> &[Bet] {
213        &self.bets
214    }
215
216    /// Returns the overall side of the position.
217    ///
218    /// If exposure is positive the side is BACK; if negative, LAY; if zero, None.
219    pub fn side(&self) -> Option<BetSide> {
220        match self.exposure.cmp(&Decimal::ZERO) {
221            std::cmp::Ordering::Less => Some(BetSide::Lay),
222            std::cmp::Ordering::Greater => Some(BetSide::Back),
223            std::cmp::Ordering::Equal => None,
224        }
225    }
226
227    /// Converts the current position into a single bet, if possible.
228    pub fn as_bet(&self) -> Option<Bet> {
229        self.side().map(|side| {
230            let stake = match side {
231                BetSide::Back => self.exposure / self.price,
232                BetSide::Lay => -self.exposure / self.price,
233            };
234            Bet::new(self.price, stake, side)
235        })
236    }
237
238    /// Adds a bet to the position, adjusting exposure and realized PnL.
239    pub fn add_bet(&mut self, bet: Bet) {
240        match self.side() {
241            None => self.position_increase(&bet),
242            Some(current_side) => {
243                if current_side == bet.side {
244                    self.position_increase(&bet);
245                } else {
246                    self.position_decrease(&bet);
247                }
248            }
249        }
250        self.bets.push(bet);
251    }
252
253    /// Increases the position with the provided bet.
254    pub fn position_increase(&mut self, bet: &Bet) {
255        if self.side().is_none() {
256            self.price = bet.price;
257        }
258        self.exposure += bet.exposure();
259    }
260
261    /// Decreases the position with the provided bet, updating exposure and realized P&L.
262    ///
263    /// # Panics
264    ///
265    /// Panics if there is no current side (empty position) when unwrapping the side.
266    pub fn position_decrease(&mut self, bet: &Bet) {
267        let abs_bet_exposure = bet.exposure().abs();
268        let abs_self_exposure = self.exposure.abs();
269
270        match abs_bet_exposure.cmp(&abs_self_exposure) {
271            std::cmp::Ordering::Less => {
272                let decreasing_volume = abs_bet_exposure / self.price;
273                let current_side = self.side().unwrap();
274                let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
275                let pnl = calc_bets_pnl(&[bet.clone(), decreasing_bet]);
276                self.realized_pnl += pnl;
277                self.exposure += bet.exposure();
278            }
279            std::cmp::Ordering::Greater => {
280                if let Some(self_bet) = self.as_bet() {
281                    let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
282                    self.realized_pnl += pnl;
283                }
284                self.price = bet.price;
285                self.exposure += bet.exposure();
286            }
287            std::cmp::Ordering::Equal => {
288                if let Some(self_bet) = self.as_bet() {
289                    let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
290                    self.realized_pnl += pnl;
291                }
292                self.price = Decimal::ZERO;
293                self.exposure = Decimal::ZERO;
294            }
295        }
296    }
297
298    /// Calculates the unrealized profit and loss given a current price.
299    pub fn unrealized_pnl(&self, price: Decimal) -> Decimal {
300        if self.side().is_none() {
301            Decimal::ZERO
302        } else if let Some(flattening_bet) = self.flattening_bet(price) {
303            if let Some(self_bet) = self.as_bet() {
304                calc_bets_pnl(&[flattening_bet, self_bet])
305            } else {
306                Decimal::ZERO
307            }
308        } else {
309            Decimal::ZERO
310        }
311    }
312
313    /// Returns the total profit and loss (realized plus unrealized) given a current price.
314    pub fn total_pnl(&self, price: Decimal) -> Decimal {
315        self.realized_pnl + self.unrealized_pnl(price)
316    }
317
318    /// Creates a bet that would flatten (neutralize) the current position.
319    pub fn flattening_bet(&self, price: Decimal) -> Option<Bet> {
320        self.side().map(|side| {
321            let stake = match side {
322                BetSide::Back => self.exposure / price,
323                BetSide::Lay => -self.exposure / price,
324            };
325            // Use the opposite side to flatten the position.
326            Bet::new(price, stake, side.opposite())
327        })
328    }
329
330    /// Resets the bet position to its initial state.
331    pub fn reset(&mut self) {
332        self.price = Decimal::ZERO;
333        self.exposure = Decimal::ZERO;
334        self.realized_pnl = Decimal::ZERO;
335    }
336}
337
338impl Display for BetPosition {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        write!(
341            f,
342            "BetPosition(price: {:.2}, exposure: {:.2}, realized_pnl: {:.2})",
343            self.price, self.exposure, self.realized_pnl
344        )
345    }
346}
347
348/// Calculates the combined profit and loss for a slice of bets.
349pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
350    bets.iter()
351        .fold(Decimal::ZERO, |acc, bet| acc + bet.outcome_win_payoff())
352}
353
354/// Converts a probability and volume into a Bet.
355///
356/// For a BUY side, this creates a BACK bet; for SELL, a LAY bet.
357pub fn probability_to_bet(probability: Decimal, volume: Decimal, side: OrderSideSpecified) -> Bet {
358    let price = Decimal::ONE / probability;
359    match side {
360        OrderSideSpecified::Buy => Bet::new(price, volume / price, BetSide::Back),
361        OrderSideSpecified::Sell => Bet::new(price, volume / price, BetSide::Lay),
362    }
363}
364
365/// Converts a probability and volume into a Bet using the inverse probability.
366///
367/// The side is also inverted (BUY becomes SELL and vice versa).
368pub fn inverse_probability_to_bet(
369    probability: Decimal,
370    volume: Decimal,
371    side: OrderSideSpecified,
372) -> Bet {
373    let inverse_probability = Decimal::ONE - probability;
374    let inverse_side = match side {
375        OrderSideSpecified::Buy => OrderSideSpecified::Sell,
376        OrderSideSpecified::Sell => OrderSideSpecified::Buy,
377    };
378    probability_to_bet(inverse_probability, volume, inverse_side)
379}
380
381#[cfg(test)]
382mod tests {
383    use rstest::rstest;
384    use rust_decimal::Decimal;
385    use rust_decimal_macros::dec;
386
387    use super::*;
388
389    fn dec_str(s: &str) -> Decimal {
390        s.parse::<Decimal>().expect("Failed to parse Decimal")
391    }
392
393    #[rstest]
394    #[should_panic(expected = "Liability-based betting is only applicable for Lay side.")]
395    fn test_from_liability_panics_on_back_side() {
396        let _ = Bet::from_liability(dec!(2.0), dec!(100.0), BetSide::Back);
397    }
398
399    #[rstest]
400    fn test_bet_creation() {
401        let price = dec!(2.0);
402        let stake = dec!(100.0);
403        let side = BetSide::Back;
404        let bet = Bet::new(price, stake, side);
405        assert_eq!(bet.price, price);
406        assert_eq!(bet.stake, stake);
407        assert_eq!(bet.side, side);
408    }
409
410    #[rstest]
411    fn test_display_bet() {
412        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
413        let formatted = format!("{bet}");
414        assert!(formatted.contains("Back"));
415        assert!(formatted.contains("2.00"));
416        assert!(formatted.contains("100.00"));
417    }
418
419    #[rstest]
420    fn test_bet_exposure_back() {
421        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
422        let exposure = bet.exposure();
423        assert_eq!(exposure, dec!(200.0));
424    }
425
426    #[rstest]
427    fn test_bet_exposure_lay() {
428        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
429        let exposure = bet.exposure();
430        assert_eq!(exposure, dec!(-200.0));
431    }
432
433    #[rstest]
434    fn test_bet_liability_back() {
435        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
436        let liability = bet.liability();
437        assert_eq!(liability, dec!(100.0));
438    }
439
440    #[rstest]
441    fn test_bet_liability_lay() {
442        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
443        let liability = bet.liability();
444        assert_eq!(liability, dec!(100.0));
445    }
446
447    #[rstest]
448    fn test_bet_profit_back() {
449        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
450        let profit = bet.profit();
451        assert_eq!(profit, dec!(100.0));
452    }
453
454    #[rstest]
455    fn test_bet_profit_lay() {
456        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
457        let profit = bet.profit();
458        assert_eq!(profit, dec!(100.0));
459    }
460
461    #[rstest]
462    fn test_outcome_win_payoff_back() {
463        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
464        let win_payoff = bet.outcome_win_payoff();
465        assert_eq!(win_payoff, dec!(100.0));
466    }
467
468    #[rstest]
469    fn test_outcome_win_payoff_lay() {
470        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
471        let win_payoff = bet.outcome_win_payoff();
472        assert_eq!(win_payoff, dec!(-100.0));
473    }
474
475    #[rstest]
476    fn test_outcome_lose_payoff_back() {
477        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
478        let lose_payoff = bet.outcome_lose_payoff();
479        assert_eq!(lose_payoff, dec!(-100.0));
480    }
481
482    #[rstest]
483    fn test_outcome_lose_payoff_lay() {
484        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
485        let lose_payoff = bet.outcome_lose_payoff();
486        assert_eq!(lose_payoff, dec!(100.0));
487    }
488
489    #[rstest]
490    fn test_hedging_stake_back() {
491        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
492        let hedging_stake = bet.hedging_stake(dec!(1.5));
493        // Expected: (2.0/1.5)*100 = 133.3333333333...
494        assert_eq!(hedging_stake.round_dp(8), dec_str("133.33333333"));
495    }
496
497    #[rstest]
498    fn test_hedging_bet_lay() {
499        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
500        let hedge_bet = bet.hedging_bet(dec!(1.5));
501        assert_eq!(hedge_bet.side, BetSide::Back);
502        assert_eq!(hedge_bet.price, dec!(1.5));
503        assert_eq!(hedge_bet.stake.round_dp(8), dec_str("133.33333333"));
504    }
505
506    #[rstest]
507    fn test_bet_position_initialization() {
508        let position = BetPosition::default();
509        assert_eq!(position.price, dec!(0.0));
510        assert_eq!(position.exposure, dec!(0.0));
511        assert_eq!(position.realized_pnl, dec!(0.0));
512    }
513
514    #[rstest]
515    fn test_display_bet_position() {
516        let mut position = BetPosition::default();
517        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
518        position.add_bet(bet);
519        let formatted = format!("{position}");
520
521        assert!(formatted.contains("price"));
522        assert!(formatted.contains("exposure"));
523        assert!(formatted.contains("realized_pnl"));
524    }
525
526    #[rstest]
527    fn test_as_bet() {
528        let mut position = BetPosition::default();
529        // Add a BACK bet so the position has exposure
530        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
531        position.add_bet(bet);
532        let as_bet = position.as_bet().expect("Expected a bet representation");
533
534        assert_eq!(as_bet.price, position.price);
535        assert_eq!(as_bet.stake, position.exposure / position.price);
536        assert_eq!(as_bet.side, BetSide::Back);
537    }
538
539    #[rstest]
540    fn test_reset_position() {
541        let mut position = BetPosition::default();
542        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
543        position.add_bet(bet);
544        assert!(position.exposure != dec!(0.0));
545        position.reset();
546
547        // After reset, the position should be cleared
548        assert_eq!(position.price, dec!(0.0));
549        assert_eq!(position.exposure, dec!(0.0));
550        assert_eq!(position.realized_pnl, dec!(0.0));
551    }
552
553    #[rstest]
554    fn test_bet_position_side_none() {
555        let position = BetPosition::default();
556        assert!(position.side().is_none());
557    }
558
559    #[rstest]
560    fn test_bet_position_side_back() {
561        let mut position = BetPosition::default();
562        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
563        position.add_bet(bet);
564        assert_eq!(position.side(), Some(BetSide::Back));
565    }
566
567    #[rstest]
568    fn test_bet_position_side_lay() {
569        let mut position = BetPosition::default();
570        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
571        position.add_bet(bet);
572        assert_eq!(position.side(), Some(BetSide::Lay));
573    }
574
575    #[rstest]
576    fn test_position_increase_back() {
577        let mut position = BetPosition::default();
578        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
579        let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
580        position.add_bet(bet1);
581        position.add_bet(bet2);
582        // Expected exposure = 200 + 100 = 300
583        assert_eq!(position.exposure, dec!(300.0));
584    }
585
586    #[rstest]
587    fn test_position_increase_lay() {
588        let mut position = BetPosition::default();
589        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
590        let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Lay);
591        position.add_bet(bet1);
592        position.add_bet(bet2);
593        // exposure = -200 + (-100) = -300
594        assert_eq!(position.exposure, dec!(-300.0));
595    }
596
597    #[rstest]
598    fn test_position_back_then_lay() {
599        let mut position = BetPosition::default();
600        let bet1 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
601        let bet2 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
602        position.add_bet(bet1);
603        position.add_bet(bet2);
604
605        assert_eq!(position.exposure, dec!(280_000.0));
606        assert_eq!(position.realized_pnl(), dec!(3333.333333333333333333333333));
607        assert_eq!(
608            position.unrealized_pnl(dec!(4.0)),
609            dec!(-23333.33333333333333333333334)
610        );
611    }
612
613    #[rstest]
614    fn test_position_lay_then_back() {
615        let mut position = BetPosition::default();
616        let bet1 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
617        let bet2 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
618        position.add_bet(bet1);
619        position.add_bet(bet2);
620
621        assert_eq!(position.exposure, dec!(280_000.0));
622        assert_eq!(position.realized_pnl(), dec!(190_000));
623        assert_eq!(
624            position.unrealized_pnl(dec!(4.0)),
625            dec!(-23333.33333333333333333333334)
626        );
627    }
628
629    #[rstest]
630    fn test_position_flip() {
631        let mut position = BetPosition::default();
632        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
633        let lay_bet = Bet::new(dec!(2.0), dec!(150.0), BetSide::Lay); // exposure -300
634        position.add_bet(back_bet);
635        position.add_bet(lay_bet);
636        // Net exposure: 200 + (-300) = -100 → side becomes Lay.
637        assert_eq!(position.side(), Some(BetSide::Lay));
638        assert_eq!(position.exposure, dec!(-100.0));
639    }
640
641    #[rstest]
642    fn test_position_flat() {
643        let mut position = BetPosition::default();
644        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
645        let lay_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay); // exposure -200
646        position.add_bet(back_bet);
647        position.add_bet(lay_bet);
648        assert!(position.side().is_none());
649        assert_eq!(position.exposure, dec!(0.0));
650    }
651
652    #[rstest]
653    fn test_unrealized_pnl_negative() {
654        let mut position = BetPosition::default();
655        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure 200
656        position.add_bet(bet);
657        // As computed: flattening bet (Lay at 2.5) gives stake = 80 and win payoff = -120, plus original bet win payoff = 100 → -20
658        let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
659        assert_eq!(unrealized_pnl, dec!(-20.0));
660    }
661
662    #[rstest]
663    fn test_total_pnl() {
664        let mut position = BetPosition::default();
665        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
666        position.add_bet(bet);
667        position.realized_pnl = dec!(10.0);
668        let total_pnl = position.total_pnl(dec!(2.5));
669        // Expected realized (10) + unrealized (-20) = -10
670        assert_eq!(total_pnl, dec!(-10.0));
671    }
672
673    #[rstest]
674    fn test_flattening_bet_back_profit() {
675        let mut position = BetPosition::default();
676        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
677        position.add_bet(bet);
678        let flattening_bet = position
679            .flattening_bet(dec!(1.6))
680            .expect("expected a flattening bet");
681        assert_eq!(flattening_bet.side, BetSide::Lay);
682        assert_eq!(flattening_bet.stake, dec_str("125"));
683    }
684
685    #[rstest]
686    fn test_flattening_bet_back_hack() {
687        let mut position = BetPosition::default();
688        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
689        position.add_bet(bet);
690        let flattening_bet = position
691            .flattening_bet(dec!(2.5))
692            .expect("expected a flattening bet");
693        assert_eq!(flattening_bet.side, BetSide::Lay);
694        // Expected stake ~80
695        assert_eq!(flattening_bet.stake, dec!(80.0));
696    }
697
698    #[rstest]
699    fn test_flattening_bet_lay() {
700        let mut position = BetPosition::default();
701        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
702        position.add_bet(bet);
703        let flattening_bet = position
704            .flattening_bet(dec!(1.5))
705            .expect("expected a flattening bet");
706        assert_eq!(flattening_bet.side, BetSide::Back);
707        assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
708    }
709
710    #[rstest]
711    fn test_realized_pnl_flattening() {
712        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // profit = 400
713        let lay = Bet::new(dec!(4.0), dec!(125.0), BetSide::Lay); // outcome win payoff = -375
714        let mut position = BetPosition::default();
715        position.add_bet(back);
716        position.add_bet(lay);
717        // Expected realized pnl = 25
718        assert_eq!(position.realized_pnl, dec!(25.0));
719    }
720
721    #[rstest]
722    fn test_realized_pnl_single_side() {
723        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
724        let mut position = BetPosition::default();
725        position.add_bet(back);
726        // No opposing bet → pnl remains 0
727        assert_eq!(position.realized_pnl, dec!(0.0));
728    }
729
730    #[rstest]
731    fn test_realized_pnl_open_position() {
732        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
733        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
734        let mut position = BetPosition::default();
735        position.add_bet(back);
736        position.add_bet(lay);
737        // Expected realized pnl = 20
738        assert_eq!(position.realized_pnl, dec!(20.0));
739    }
740
741    #[rstest]
742    fn test_realized_pnl_partial_close() {
743        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
744        let lay = Bet::new(dec!(4.0), dec!(110.0), BetSide::Lay); // exposure -440
745        let mut position = BetPosition::default();
746        position.add_bet(back);
747        position.add_bet(lay);
748        // Expected realized pnl = 22
749        assert_eq!(position.realized_pnl, dec!(22.0));
750    }
751
752    #[rstest]
753    fn test_realized_pnl_flipping() {
754        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
755        let lay = Bet::new(dec!(4.0), dec!(130.0), BetSide::Lay); // exposure -520
756        let mut position = BetPosition::default();
757        position.add_bet(back);
758        position.add_bet(lay);
759        // Expected realized pnl = 10
760        assert_eq!(position.realized_pnl, dec!(10.0));
761    }
762
763    #[rstest]
764    fn test_unrealized_pnl_positive() {
765        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
766        let mut position = BetPosition::default();
767        position.add_bet(back);
768        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
769        // Expected unrealized pnl = 25
770        assert_eq!(unrealized_pnl, dec!(25.0));
771    }
772
773    #[rstest]
774    fn test_total_pnl_with_pnl() {
775        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
776        let lay = Bet::new(dec!(4.0), dec!(120.0), BetSide::Lay); // exposure -480
777        let mut position = BetPosition::default();
778        position.add_bet(back);
779        position.add_bet(lay);
780        // After processing, realized pnl should be 24 and unrealized pnl 1.0
781        let realized_pnl = position.realized_pnl;
782        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
783        let total_pnl = position.total_pnl(dec!(4.0));
784        assert_eq!(realized_pnl, dec!(24.0));
785        assert_eq!(unrealized_pnl, dec!(1.0));
786        assert_eq!(total_pnl, dec!(25.0));
787    }
788
789    #[rstest]
790    fn test_open_position_realized_unrealized() {
791        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
792        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
793        let mut position = BetPosition::default();
794        position.add_bet(back);
795        position.add_bet(lay);
796        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
797        // Expected unrealized pnl = 5
798        assert_eq!(unrealized_pnl, dec!(5.0));
799    }
800
801    #[rstest]
802    fn test_unrealized_no_position() {
803        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
804        let mut position = BetPosition::default();
805        position.add_bet(back);
806        let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
807        assert_eq!(unrealized_pnl, dec!(0.0));
808    }
809
810    #[rstest]
811    fn test_calc_bets_pnl_single_back_bet() {
812        let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
813        let pnl = calc_bets_pnl(&[bet]);
814        assert_eq!(pnl, dec!(400.0));
815    }
816
817    #[rstest]
818    fn test_calc_bets_pnl_single_lay_bet() {
819        let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
820        let pnl = calc_bets_pnl(&[bet]);
821        assert_eq!(pnl, dec!(-300.0));
822    }
823
824    #[rstest]
825    fn test_calc_bets_pnl_multiple_bets() {
826        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
827        let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
828        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
829        let expected = dec!(400.0) + dec!(-300.0);
830        assert_eq!(pnl, expected);
831    }
832
833    #[rstest]
834    fn test_calc_bets_pnl_mixed_bets() {
835        let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
836        let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
837        let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
838        let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
839        let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
840        assert_eq!(pnl, expected);
841    }
842
843    #[rstest]
844    fn test_calc_bets_pnl_no_bets() {
845        let bets: Vec<Bet> = vec![];
846        let pnl = calc_bets_pnl(&bets);
847        assert_eq!(pnl, dec!(0.0));
848    }
849
850    #[rstest]
851    fn test_calc_bets_pnl_zero_outcome() {
852        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
853        let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
854        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
855        assert_eq!(pnl, dec!(0.0));
856    }
857
858    #[rstest]
859    fn test_probability_to_bet_back_simple() {
860        // Using OrderSideSpecified in place of ProbSide.
861        let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSideSpecified::Buy);
862        let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
863        assert_eq!(bet, expected);
864        assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
865        assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
866    }
867
868    #[rstest]
869    fn test_probability_to_bet_back_high_prob() {
870        let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Buy);
871        let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
872        assert_eq!(bet, expected);
873        assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
874        assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
875    }
876
877    #[rstest]
878    fn test_probability_to_bet_back_low_prob() {
879        let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSideSpecified::Buy);
880        let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
881        assert_eq!(bet, expected);
882        assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
883        assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
884    }
885
886    #[rstest]
887    fn test_probability_to_bet_sell() {
888        let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSideSpecified::Sell);
889        let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
890        assert_eq!(bet, expected);
891        assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
892        assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
893    }
894
895    #[rstest]
896    fn test_inverse_probability_to_bet() {
897        // Original bet with SELL side
898        let original_bet = probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell);
899        // Equivalent reverse bet by buying the inverse probability
900        let reverse_bet = probability_to_bet(dec!(0.20), dec!(100.0), OrderSideSpecified::Buy);
901        let inverse_bet =
902            inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell);
903
904        assert_eq!(
905            original_bet.outcome_win_payoff(),
906            reverse_bet.outcome_lose_payoff(),
907        );
908        assert_eq!(
909            original_bet.outcome_win_payoff(),
910            inverse_bet.outcome_lose_payoff(),
911        );
912        assert_eq!(
913            original_bet.outcome_lose_payoff(),
914            reverse_bet.outcome_win_payoff(),
915        );
916        assert_eq!(
917            original_bet.outcome_lose_payoff(),
918            inverse_bet.outcome_win_payoff(),
919        );
920    }
921
922    #[rstest]
923    fn test_inverse_probability_to_bet_example2() {
924        let original_bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell);
925        let inverse_bet =
926            inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell);
927
928        assert_eq!(original_bet.stake, dec!(32.0));
929        assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
930        assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
931
932        assert_eq!(inverse_bet.stake, dec!(18.0));
933        assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
934        assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
935    }
936}