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