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