nautilus_model/data/
bet.rs

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