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
16use std::fmt::Display;
17
18use rust_decimal::Decimal;
19
20use crate::enums::{BetSide, OrderSideSpecified};
21
22/// A bet in a betting market.
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24#[cfg_attr(
25    feature = "python",
26    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
27)]
28pub struct Bet {
29    price: Decimal,
30    stake: Decimal,
31    side: BetSide,
32}
33
34impl Bet {
35    /// Creates a new [`Bet`] instance.
36    pub fn new(price: Decimal, stake: Decimal, side: BetSide) -> Self {
37        Self { price, stake, side }
38    }
39
40    /// Returns the bet's price.
41    #[must_use]
42    pub fn price(&self) -> Decimal {
43        self.price
44    }
45
46    /// Returns the bet's stake.
47    #[must_use]
48    pub fn stake(&self) -> Decimal {
49        self.stake
50    }
51
52    /// Returns the bet's side.
53    #[must_use]
54    pub fn side(&self) -> BetSide {
55        self.side
56    }
57
58    /// Creates a bet from a stake or liability depending on the bet side.
59    ///
60    /// For `BetSide::Back` this calls [Self::from_stake] and for
61    /// `BetSide::Lay` it calls [Self::from_liability].
62    pub fn from_stake_or_liability(price: Decimal, volume: Decimal, side: BetSide) -> Self {
63        match side {
64            BetSide::Back => Self::from_stake(price, volume, side),
65            BetSide::Lay => Self::from_liability(price, volume, side),
66        }
67    }
68
69    /// Creates a bet from a given stake.
70    pub fn from_stake(price: Decimal, stake: Decimal, side: BetSide) -> Self {
71        Self::new(price, stake, side)
72    }
73
74    /// Creates a bet from a given liability.
75    ///
76    /// # Panics
77    ///
78    /// Panics if the side is not [BetSide::Lay].
79    pub fn from_liability(price: Decimal, liability: Decimal, side: BetSide) -> Self {
80        if side != BetSide::Lay {
81            panic!("Liability-based betting is only applicable for Lay side.");
82        }
83        let adjusted_volume = liability / (price - Decimal::ONE);
84        Self::new(price, adjusted_volume, side)
85    }
86
87    /// Returns the bet's exposure.
88    ///
89    /// For BACK bets, exposure is positive; for LAY bets, it is negative.
90    pub fn exposure(&self) -> Decimal {
91        match self.side {
92            BetSide::Back => self.price * self.stake,
93            BetSide::Lay => -self.price * self.stake,
94        }
95    }
96
97    /// Returns the bet's liability.
98    ///
99    /// For BACK bets, liability equals the stake; for LAY bets, it is
100    /// stake multiplied by (price - 1).
101    pub fn liability(&self) -> Decimal {
102        match self.side {
103            BetSide::Back => self.stake,
104            BetSide::Lay => self.stake * (self.price - Decimal::ONE),
105        }
106    }
107
108    /// Returns the bet's profit.
109    ///
110    /// For BACK bets, profit is stake * (price - 1); for LAY bets it equals the stake.
111    pub fn profit(&self) -> Decimal {
112        match self.side {
113            BetSide::Back => self.stake * (self.price - Decimal::ONE),
114            BetSide::Lay => self.stake,
115        }
116    }
117
118    /// Returns the outcome win payoff.
119    ///
120    /// For BACK bets this is the profit; for LAY bets it is the negative liability.
121    pub fn outcome_win_payoff(&self) -> Decimal {
122        match self.side {
123            BetSide::Back => self.profit(),
124            BetSide::Lay => -self.liability(),
125        }
126    }
127
128    /// Returns the outcome lose payoff.
129    ///
130    /// For BACK bets this is the negative liability; for LAY bets it is the profit.
131    pub fn outcome_lose_payoff(&self) -> Decimal {
132        match self.side {
133            BetSide::Back => -self.liability(),
134            BetSide::Lay => self.profit(),
135        }
136    }
137
138    /// Returns the hedging stake given a new price.
139    pub fn hedging_stake(&self, price: Decimal) -> Decimal {
140        match self.side {
141            BetSide::Back => (self.price / price) * self.stake,
142            BetSide::Lay => self.stake / (price / self.price),
143        }
144    }
145
146    /// Creates a hedging bet for a given price.
147    pub fn hedging_bet(&self, price: Decimal) -> Self {
148        Self::new(price, self.hedging_stake(price), self.side.opposite())
149    }
150}
151
152impl Display for Bet {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        // Example output: "Bet(Back @ 2.50 x10.00)"
155        write!(
156            f,
157            "Bet({:?} @ {:.2} x{:.2})",
158            self.side, self.price, self.stake
159        )
160    }
161}
162
163/// A position comprising one or more bets.
164#[derive(Debug, Clone)]
165#[cfg_attr(
166    feature = "python",
167    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
168)]
169pub struct BetPosition {
170    price: Decimal,
171    exposure: Decimal,
172    realized_pnl: Decimal,
173    bets: Vec<Bet>,
174}
175
176impl Default for BetPosition {
177    fn default() -> Self {
178        Self {
179            price: Decimal::ZERO,
180            exposure: Decimal::ZERO,
181            realized_pnl: Decimal::ZERO,
182            bets: vec![],
183        }
184    }
185}
186
187impl BetPosition {
188    /// Returns the position's price.
189    #[must_use]
190    pub fn price(&self) -> Decimal {
191        self.price
192    }
193
194    /// Returns the position's exposure.
195    #[must_use]
196    pub fn exposure(&self) -> Decimal {
197        self.exposure
198    }
199
200    /// Returns the position's realized profit and loss.
201    #[must_use]
202    pub fn realized_pnl(&self) -> Decimal {
203        self.realized_pnl
204    }
205
206    /// Returns a reference to the position's bets.
207    #[must_use]
208    pub fn bets(&self) -> &[Bet] {
209        &self.bets
210    }
211
212    /// Returns the overall side of the position.
213    ///
214    /// If exposure is positive the side is BACK; if negative, LAY; if zero, None.
215    pub fn side(&self) -> Option<BetSide> {
216        match self.exposure.cmp(&Decimal::ZERO) {
217            std::cmp::Ordering::Less => Some(BetSide::Lay),
218            std::cmp::Ordering::Greater => Some(BetSide::Back),
219            std::cmp::Ordering::Equal => None,
220        }
221    }
222
223    /// Converts the current position into a single bet, if possible.
224    pub fn as_bet(&self) -> Option<Bet> {
225        self.side().map(|side| {
226            let stake = match side {
227                BetSide::Back => self.exposure / self.price,
228                BetSide::Lay => -self.exposure / self.price,
229            };
230            Bet::new(self.price, stake, side)
231        })
232    }
233
234    /// Adds a bet to the position, adjusting exposure and realized PnL.
235    pub fn add_bet(&mut self, bet: Bet) {
236        match self.side() {
237            None => self.position_increase(&bet),
238            Some(current_side) => {
239                if current_side == bet.side {
240                    self.position_increase(&bet);
241                } else {
242                    self.position_decrease(&bet);
243                }
244            }
245        }
246        self.bets.push(bet);
247    }
248
249    /// Increases the position with the provided bet.
250    pub fn position_increase(&mut self, bet: &Bet) {
251        if self.side().is_none() {
252            self.price = bet.price;
253            self.exposure = bet.exposure();
254        } else {
255            self.exposure += bet.exposure();
256        }
257    }
258
259    /// Decreases the position with the provided bet.
260    ///
261    /// This method calculates the realized PnL by comparing the incoming bet with
262    /// a corresponding bet derived from the current position.
263    pub fn position_decrease(&mut self, bet: &Bet) {
264        let abs_bet_exposure = bet.exposure().abs();
265        let abs_self_exposure = self.exposure.abs();
266
267        match abs_bet_exposure.cmp(&abs_self_exposure) {
268            std::cmp::Ordering::Less => {
269                let decreasing_volume = abs_bet_exposure / self.price;
270                let current_side = self.side().unwrap();
271                let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
272                let pnl = calc_bets_pnl(&[bet.clone(), decreasing_bet]);
273                self.realized_pnl += pnl;
274                self.exposure += bet.exposure();
275            }
276            std::cmp::Ordering::Greater => {
277                if let Some(self_bet) = self.as_bet() {
278                    let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
279                    self.realized_pnl += pnl;
280                }
281                self.price = bet.price;
282                self.exposure += bet.exposure();
283            }
284            std::cmp::Ordering::Equal => {
285                if let Some(self_bet) = self.as_bet() {
286                    let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
287                    self.realized_pnl += pnl;
288                }
289                self.price = Decimal::ZERO;
290                self.exposure = Decimal::ZERO;
291            }
292        }
293    }
294
295    /// Calculates the unrealized profit and loss given a current price.
296    pub fn unrealized_pnl(&self, price: Decimal) -> Decimal {
297        if self.side().is_none() {
298            Decimal::ZERO
299        } else if let Some(flattening_bet) = self.flattening_bet(price) {
300            if let Some(self_bet) = self.as_bet() {
301                calc_bets_pnl(&[flattening_bet, self_bet])
302            } else {
303                Decimal::ZERO
304            }
305        } else {
306            Decimal::ZERO
307        }
308    }
309
310    /// Returns the total profit and loss (realized plus unrealized) given a current price.
311    pub fn total_pnl(&self, price: Decimal) -> Decimal {
312        self.realized_pnl + self.unrealized_pnl(price)
313    }
314
315    /// Creates a bet that would flatten (neutralize) the current position.
316    pub fn flattening_bet(&self, price: Decimal) -> Option<Bet> {
317        self.side().map(|side| {
318            let stake = match side {
319                BetSide::Back => self.exposure / price,
320                BetSide::Lay => -self.exposure / price,
321            };
322            // Use the opposite side to flatten the position.
323            Bet::new(price, stake, side.opposite())
324        })
325    }
326
327    /// Resets the bet position to its initial state.
328    pub fn reset(&mut self) {
329        self.price = Decimal::ZERO;
330        self.exposure = Decimal::ZERO;
331        self.realized_pnl = Decimal::ZERO;
332    }
333}
334
335impl Display for BetPosition {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        write!(
338            f,
339            "BetPosition(price: {:.2}, exposure: {:.2}, realized_pnl: {:.2})",
340            self.price, self.exposure, self.realized_pnl
341        )
342    }
343}
344
345/// Calculates the combined profit and loss for a slice of bets.
346pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
347    bets.iter()
348        .fold(Decimal::ZERO, |acc, bet| acc + bet.outcome_win_payoff())
349}
350
351/// Converts a probability and volume into a Bet.
352///
353/// For a BUY side, this creates a BACK bet; for SELL, a LAY bet.
354pub fn probability_to_bet(probability: Decimal, volume: Decimal, side: OrderSideSpecified) -> Bet {
355    let price = Decimal::ONE / probability;
356    match side {
357        OrderSideSpecified::Buy => Bet::new(price, volume / price, BetSide::Back),
358        OrderSideSpecified::Sell => Bet::new(price, volume / price, BetSide::Lay),
359    }
360}
361
362/// Converts a probability and volume into a Bet using the inverse probability.
363///
364/// The side is also inverted (BUY becomes SELL and vice versa).
365pub fn inverse_probability_to_bet(
366    probability: Decimal,
367    volume: Decimal,
368    side: OrderSideSpecified,
369) -> Bet {
370    let inverse_probability = Decimal::ONE - probability;
371    let inverse_side = match side {
372        OrderSideSpecified::Buy => OrderSideSpecified::Sell,
373        OrderSideSpecified::Sell => OrderSideSpecified::Buy,
374    };
375    probability_to_bet(inverse_probability, volume, inverse_side)
376}
377
378////////////////////////////////////////////////////////////////////////////////
379// Tests
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_flip() {
599        let mut position = BetPosition::default();
600        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
601        let lay_bet = Bet::new(dec!(2.0), dec!(150.0), BetSide::Lay); // exposure -300
602        position.add_bet(back_bet);
603        position.add_bet(lay_bet);
604        // Net exposure: 200 + (-300) = -100 → side becomes Lay.
605        assert_eq!(position.side(), Some(BetSide::Lay));
606        assert_eq!(position.exposure, dec!(-100.0));
607    }
608
609    #[rstest]
610    fn test_position_flat() {
611        let mut position = BetPosition::default();
612        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
613        let lay_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay); // exposure -200
614        position.add_bet(back_bet);
615        position.add_bet(lay_bet);
616        assert!(position.side().is_none());
617        assert_eq!(position.exposure, dec!(0.0));
618    }
619
620    #[rstest]
621    fn test_unrealized_pnl_negative() {
622        let mut position = BetPosition::default();
623        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure 200
624        position.add_bet(bet);
625        // As computed: flattening bet (Lay at 2.5) gives stake = 80 and win payoff = -120, plus original bet win payoff = 100 → -20
626        let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
627        assert_eq!(unrealized_pnl, dec!(-20.0));
628    }
629
630    #[rstest]
631    fn test_total_pnl() {
632        let mut position = BetPosition::default();
633        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
634        position.add_bet(bet);
635        position.realized_pnl = dec!(10.0);
636        let total_pnl = position.total_pnl(dec!(2.5));
637        // Expected realized (10) + unrealized (-20) = -10
638        assert_eq!(total_pnl, dec!(-10.0));
639    }
640
641    #[rstest]
642    fn test_flattening_bet_back_profit() {
643        let mut position = BetPosition::default();
644        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
645        position.add_bet(bet);
646        let flattening_bet = position
647            .flattening_bet(dec!(1.6))
648            .expect("expected a flattening bet");
649        assert_eq!(flattening_bet.side, BetSide::Lay);
650        assert_eq!(flattening_bet.stake, dec_str("125"));
651    }
652
653    #[rstest]
654    fn test_flattening_bet_back_hack() {
655        let mut position = BetPosition::default();
656        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
657        position.add_bet(bet);
658        let flattening_bet = position
659            .flattening_bet(dec!(2.5))
660            .expect("expected a flattening bet");
661        assert_eq!(flattening_bet.side, BetSide::Lay);
662        // Expected stake ~80
663        assert_eq!(flattening_bet.stake, dec!(80.0));
664    }
665
666    #[rstest]
667    fn test_flattening_bet_lay() {
668        let mut position = BetPosition::default();
669        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
670        position.add_bet(bet);
671        let flattening_bet = position
672            .flattening_bet(dec!(1.5))
673            .expect("expected a flattening bet");
674        assert_eq!(flattening_bet.side, BetSide::Back);
675        assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
676    }
677
678    #[rstest]
679    fn test_realized_pnl_flattening() {
680        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // profit = 400
681        let lay = Bet::new(dec!(4.0), dec!(125.0), BetSide::Lay); // outcome win payoff = -375
682        let mut position = BetPosition::default();
683        position.add_bet(back);
684        position.add_bet(lay);
685        // Expected realized pnl = 25
686        assert_eq!(position.realized_pnl, dec!(25.0));
687    }
688
689    #[rstest]
690    fn test_realized_pnl_single_side() {
691        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
692        let mut position = BetPosition::default();
693        position.add_bet(back);
694        // No opposing bet → pnl remains 0
695        assert_eq!(position.realized_pnl, dec!(0.0));
696    }
697
698    #[rstest]
699    fn test_realized_pnl_open_position() {
700        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
701        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
702        let mut position = BetPosition::default();
703        position.add_bet(back);
704        position.add_bet(lay);
705        // Expected realized pnl = 20
706        assert_eq!(position.realized_pnl, dec!(20.0));
707    }
708
709    #[rstest]
710    fn test_realized_pnl_partial_close() {
711        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
712        let lay = Bet::new(dec!(4.0), dec!(110.0), BetSide::Lay); // exposure -440
713        let mut position = BetPosition::default();
714        position.add_bet(back);
715        position.add_bet(lay);
716        // Expected realized pnl = 22
717        assert_eq!(position.realized_pnl, dec!(22.0));
718    }
719
720    #[rstest]
721    fn test_realized_pnl_flipping() {
722        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
723        let lay = Bet::new(dec!(4.0), dec!(130.0), BetSide::Lay); // exposure -520
724        let mut position = BetPosition::default();
725        position.add_bet(back);
726        position.add_bet(lay);
727        // Expected realized pnl = 10
728        assert_eq!(position.realized_pnl, dec!(10.0));
729    }
730
731    #[rstest]
732    fn test_unrealized_pnl_positive() {
733        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
734        let mut position = BetPosition::default();
735        position.add_bet(back);
736        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
737        // Expected unrealized pnl = 25
738        assert_eq!(unrealized_pnl, dec!(25.0));
739    }
740
741    #[rstest]
742    fn test_total_pnl_with_pnl() {
743        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
744        let lay = Bet::new(dec!(4.0), dec!(120.0), BetSide::Lay); // exposure -480
745        let mut position = BetPosition::default();
746        position.add_bet(back);
747        position.add_bet(lay);
748        // After processing, realized pnl should be 24 and unrealized pnl 1.0
749        let realized_pnl = position.realized_pnl;
750        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
751        let total_pnl = position.total_pnl(dec!(4.0));
752        assert_eq!(realized_pnl, dec!(24.0));
753        assert_eq!(unrealized_pnl, dec!(1.0));
754        assert_eq!(total_pnl, dec!(25.0));
755    }
756
757    #[rstest]
758    fn test_open_position_realized_unrealized() {
759        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
760        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
761        let mut position = BetPosition::default();
762        position.add_bet(back);
763        position.add_bet(lay);
764        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
765        // Expected unrealized pnl = 5
766        assert_eq!(unrealized_pnl, dec!(5.0));
767    }
768
769    #[rstest]
770    fn test_unrealized_no_position() {
771        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
772        let mut position = BetPosition::default();
773        position.add_bet(back);
774        let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
775        assert_eq!(unrealized_pnl, dec!(0.0));
776    }
777
778    #[rstest]
779    fn test_calc_bets_pnl_single_back_bet() {
780        let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
781        let pnl = calc_bets_pnl(&[bet]);
782        assert_eq!(pnl, dec!(400.0));
783    }
784
785    #[rstest]
786    fn test_calc_bets_pnl_single_lay_bet() {
787        let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
788        let pnl = calc_bets_pnl(&[bet]);
789        assert_eq!(pnl, dec!(-300.0));
790    }
791
792    #[rstest]
793    fn test_calc_bets_pnl_multiple_bets() {
794        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
795        let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
796        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
797        let expected = dec!(400.0) + dec!(-300.0);
798        assert_eq!(pnl, expected);
799    }
800
801    #[rstest]
802    fn test_calc_bets_pnl_mixed_bets() {
803        let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
804        let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
805        let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
806        let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
807        let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
808        assert_eq!(pnl, expected);
809    }
810
811    #[rstest]
812    fn test_calc_bets_pnl_no_bets() {
813        let bets: Vec<Bet> = vec![];
814        let pnl = calc_bets_pnl(&bets);
815        assert_eq!(pnl, dec!(0.0));
816    }
817
818    #[rstest]
819    fn test_calc_bets_pnl_zero_outcome() {
820        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
821        let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
822        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
823        assert_eq!(pnl, dec!(0.0));
824    }
825
826    #[rstest]
827    fn test_probability_to_bet_back_simple() {
828        // Using OrderSideSpecified in place of ProbSide.
829        let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSideSpecified::Buy);
830        let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
831        assert_eq!(bet, expected);
832        assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
833        assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
834    }
835
836    #[rstest]
837    fn test_probability_to_bet_back_high_prob() {
838        let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Buy);
839        let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
840        assert_eq!(bet, expected);
841        assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
842        assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
843    }
844
845    #[rstest]
846    fn test_probability_to_bet_back_low_prob() {
847        let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSideSpecified::Buy);
848        let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
849        assert_eq!(bet, expected);
850        assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
851        assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
852    }
853
854    #[rstest]
855    fn test_probability_to_bet_sell() {
856        let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSideSpecified::Sell);
857        let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
858        assert_eq!(bet, expected);
859        assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
860        assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
861    }
862
863    #[rstest]
864    fn test_inverse_probability_to_bet() {
865        // Original bet with SELL side
866        let original_bet = probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell);
867        // Equivalent reverse bet by buying the inverse probability
868        let reverse_bet = probability_to_bet(dec!(0.20), dec!(100.0), OrderSideSpecified::Buy);
869        let inverse_bet =
870            inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell);
871
872        assert_eq!(
873            original_bet.outcome_win_payoff(),
874            reverse_bet.outcome_lose_payoff(),
875        );
876        assert_eq!(
877            original_bet.outcome_win_payoff(),
878            inverse_bet.outcome_lose_payoff(),
879        );
880        assert_eq!(
881            original_bet.outcome_lose_payoff(),
882            reverse_bet.outcome_win_payoff(),
883        );
884        assert_eq!(
885            original_bet.outcome_lose_payoff(),
886            inverse_bet.outcome_win_payoff(),
887        );
888    }
889
890    #[rstest]
891    fn test_inverse_probability_to_bet_example2() {
892        let original_bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell);
893        let inverse_bet =
894            inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell);
895
896        assert_eq!(original_bet.stake, dec!(32.0));
897        assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
898        assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
899
900        assert_eq!(inverse_bet.stake, dec!(18.0));
901        assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
902        assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
903    }
904}