nautilus_model/data/
bet.rs

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