Skip to main content

nautilus_model/data/
bet.rs

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