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`], or if `price` is not greater than 1.
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        self.bets.clear();
363    }
364}
365
366impl Display for BetPosition {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        write!(
369            f,
370            "BetPosition(price: {:.2}, exposure: {:.2}, realized_pnl: {:.2})",
371            self.price, self.exposure, self.realized_pnl
372        )
373    }
374}
375
376/// Calculates the combined profit and loss for a slice of bets.
377#[must_use]
378pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
379    bets.iter()
380        .fold(Decimal::ZERO, |acc, bet| acc + bet.outcome_win_payoff())
381}
382
383/// Checks that `probability` is non-zero.
384///
385/// # Errors
386///
387/// Returns an error if `probability` is zero.
388pub fn check_probability_non_zero(probability: Decimal) -> anyhow::Result<()> {
389    if probability.is_zero() {
390        anyhow::bail!("invalid probability: must be non-zero")
391    }
392    Ok(())
393}
394
395/// Checks that `probability` is invertible (not equal to 1.0).
396///
397/// # Errors
398///
399/// Returns an error if `probability` is 1.0.
400pub fn check_probability_invertible(probability: Decimal) -> anyhow::Result<()> {
401    if probability == Decimal::ONE {
402        anyhow::bail!("invalid probability: must not be 1.0 (inverse would be zero)")
403    }
404    Ok(())
405}
406
407/// Converts a probability and volume into a Bet.
408///
409/// For a BUY side, this creates a BACK bet; for SELL, a LAY bet.
410///
411/// # Errors
412///
413/// Returns an error if `probability` is zero.
414pub fn probability_to_bet(
415    probability: Decimal,
416    volume: Decimal,
417    side: OrderSideSpecified,
418) -> anyhow::Result<Bet> {
419    check_probability_non_zero(probability)?;
420    let price = Decimal::ONE / probability;
421    let bet = match side {
422        OrderSideSpecified::Buy => Bet::new(price, volume / price, BetSide::Back),
423        OrderSideSpecified::Sell => Bet::new(price, volume / price, BetSide::Lay),
424    };
425    Ok(bet)
426}
427
428/// Converts a probability and volume into a Bet using the inverse probability.
429///
430/// The side is also inverted (BUY becomes SELL and vice versa).
431///
432/// # Errors
433///
434/// Returns an error if `probability` is 1.0 or its inverse is zero.
435pub fn inverse_probability_to_bet(
436    probability: Decimal,
437    volume: Decimal,
438    side: OrderSideSpecified,
439) -> anyhow::Result<Bet> {
440    check_probability_invertible(probability)?;
441    let inverse_probability = Decimal::ONE - probability;
442    let inverse_side = match side {
443        OrderSideSpecified::Buy => OrderSideSpecified::Sell,
444        OrderSideSpecified::Sell => OrderSideSpecified::Buy,
445    };
446    probability_to_bet(inverse_probability, volume, inverse_side)
447}
448
449#[cfg(test)]
450mod tests {
451    use rstest::rstest;
452    use rust_decimal::Decimal;
453    use rust_decimal_macros::dec;
454
455    use super::*;
456
457    fn dec_str(s: &str) -> Decimal {
458        s.parse::<Decimal>().expect("Failed to parse Decimal")
459    }
460
461    #[rstest]
462    #[should_panic(expected = "Liability-based betting is only applicable for Lay side.")]
463    fn test_from_liability_panics_on_back_side() {
464        let _ = Bet::from_liability(dec!(2.0), dec!(100.0), BetSide::Back);
465    }
466
467    #[rstest]
468    fn test_bet_creation() {
469        let price = dec!(2.0);
470        let stake = dec!(100.0);
471        let side = BetSide::Back;
472        let bet = Bet::new(price, stake, side);
473        assert_eq!(bet.price, price);
474        assert_eq!(bet.stake, stake);
475        assert_eq!(bet.side, side);
476    }
477
478    #[rstest]
479    fn test_display_bet() {
480        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
481        let formatted = format!("{bet}");
482        assert!(formatted.contains("Back"));
483        assert!(formatted.contains("2.00"));
484        assert!(formatted.contains("100.00"));
485    }
486
487    #[rstest]
488    fn test_bet_exposure_back() {
489        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
490        let exposure = bet.exposure();
491        assert_eq!(exposure, dec!(200.0));
492    }
493
494    #[rstest]
495    fn test_bet_exposure_lay() {
496        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
497        let exposure = bet.exposure();
498        assert_eq!(exposure, dec!(-200.0));
499    }
500
501    #[rstest]
502    fn test_bet_liability_back() {
503        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
504        let liability = bet.liability();
505        assert_eq!(liability, dec!(100.0));
506    }
507
508    #[rstest]
509    fn test_bet_liability_lay() {
510        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
511        let liability = bet.liability();
512        assert_eq!(liability, dec!(100.0));
513    }
514
515    #[rstest]
516    fn test_bet_profit_back() {
517        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
518        let profit = bet.profit();
519        assert_eq!(profit, dec!(100.0));
520    }
521
522    #[rstest]
523    fn test_bet_profit_lay() {
524        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
525        let profit = bet.profit();
526        assert_eq!(profit, dec!(100.0));
527    }
528
529    #[rstest]
530    fn test_outcome_win_payoff_back() {
531        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
532        let win_payoff = bet.outcome_win_payoff();
533        assert_eq!(win_payoff, dec!(100.0));
534    }
535
536    #[rstest]
537    fn test_outcome_win_payoff_lay() {
538        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
539        let win_payoff = bet.outcome_win_payoff();
540        assert_eq!(win_payoff, dec!(-100.0));
541    }
542
543    #[rstest]
544    fn test_outcome_lose_payoff_back() {
545        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
546        let lose_payoff = bet.outcome_lose_payoff();
547        assert_eq!(lose_payoff, dec!(-100.0));
548    }
549
550    #[rstest]
551    fn test_outcome_lose_payoff_lay() {
552        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
553        let lose_payoff = bet.outcome_lose_payoff();
554        assert_eq!(lose_payoff, dec!(100.0));
555    }
556
557    #[rstest]
558    fn test_hedging_stake_back() {
559        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
560        let hedging_stake = bet.hedging_stake(dec!(1.5));
561        // Expected: (2.0/1.5)*100 = 133.3333333333...
562        assert_eq!(hedging_stake.round_dp(8), dec_str("133.33333333"));
563    }
564
565    #[rstest]
566    fn test_hedging_bet_lay() {
567        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
568        let hedge_bet = bet.hedging_bet(dec!(1.5));
569        assert_eq!(hedge_bet.side, BetSide::Back);
570        assert_eq!(hedge_bet.price, dec!(1.5));
571        assert_eq!(hedge_bet.stake.round_dp(8), dec_str("133.33333333"));
572    }
573
574    #[rstest]
575    fn test_bet_position_initialization() {
576        let position = BetPosition::default();
577        assert_eq!(position.price, dec!(0.0));
578        assert_eq!(position.exposure, dec!(0.0));
579        assert_eq!(position.realized_pnl, dec!(0.0));
580    }
581
582    #[rstest]
583    fn test_display_bet_position() {
584        let mut position = BetPosition::default();
585        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
586        position.add_bet(bet);
587        let formatted = format!("{position}");
588
589        assert!(formatted.contains("price"));
590        assert!(formatted.contains("exposure"));
591        assert!(formatted.contains("realized_pnl"));
592    }
593
594    #[rstest]
595    fn test_as_bet() {
596        let mut position = BetPosition::default();
597        // Add a BACK bet so the position has exposure
598        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
599        position.add_bet(bet);
600        let as_bet = position.as_bet().expect("Expected a bet representation");
601
602        assert_eq!(as_bet.price, position.price);
603        assert_eq!(as_bet.stake, position.exposure / position.price);
604        assert_eq!(as_bet.side, BetSide::Back);
605    }
606
607    #[rstest]
608    fn test_reset_position() {
609        let mut position = BetPosition::default();
610        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
611        position.add_bet(bet);
612        assert_ne!(position.exposure, dec!(0.0));
613        assert!(!position.bets().is_empty());
614        position.reset();
615
616        // After reset, the position should be cleared
617        assert_eq!(position.price, dec!(0.0));
618        assert_eq!(position.exposure, dec!(0.0));
619        assert_eq!(position.realized_pnl, dec!(0.0));
620        assert!(position.bets().is_empty());
621    }
622
623    #[rstest]
624    fn test_bet_position_side_none() {
625        let position = BetPosition::default();
626        assert!(position.side().is_none());
627    }
628
629    #[rstest]
630    fn test_bet_position_side_back() {
631        let mut position = BetPosition::default();
632        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
633        position.add_bet(bet);
634        assert_eq!(position.side(), Some(BetSide::Back));
635    }
636
637    #[rstest]
638    fn test_bet_position_side_lay() {
639        let mut position = BetPosition::default();
640        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
641        position.add_bet(bet);
642        assert_eq!(position.side(), Some(BetSide::Lay));
643    }
644
645    #[rstest]
646    fn test_position_increase_back() {
647        let mut position = BetPosition::default();
648        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
649        let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
650        position.add_bet(bet1);
651        position.add_bet(bet2);
652        // Expected exposure = 200 + 100 = 300
653        assert_eq!(position.exposure, dec!(300.0));
654    }
655
656    #[rstest]
657    fn test_position_increase_lay() {
658        let mut position = BetPosition::default();
659        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
660        let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Lay);
661        position.add_bet(bet1);
662        position.add_bet(bet2);
663        // exposure = -200 + (-100) = -300
664        assert_eq!(position.exposure, dec!(-300.0));
665    }
666
667    #[rstest]
668    fn test_position_back_then_lay() {
669        let mut position = BetPosition::default();
670        let bet1 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
671        let bet2 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
672        position.add_bet(bet1);
673        position.add_bet(bet2);
674
675        assert_eq!(position.exposure, dec!(280_000.0));
676        assert_eq!(position.realized_pnl(), dec!(3333.333333333333333333333333));
677        assert_eq!(
678            position.unrealized_pnl(dec!(4.0)),
679            dec!(-23333.33333333333333333333334)
680        );
681    }
682
683    #[rstest]
684    fn test_position_lay_then_back() {
685        let mut position = BetPosition::default();
686        let bet1 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
687        let bet2 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
688        position.add_bet(bet1);
689        position.add_bet(bet2);
690
691        assert_eq!(position.exposure, dec!(280_000.0));
692        assert_eq!(position.realized_pnl(), dec!(190_000));
693        assert_eq!(
694            position.unrealized_pnl(dec!(4.0)),
695            dec!(-23333.33333333333333333333334)
696        );
697    }
698
699    #[rstest]
700    fn test_position_flip() {
701        let mut position = BetPosition::default();
702        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
703        let lay_bet = Bet::new(dec!(2.0), dec!(150.0), BetSide::Lay); // exposure -300
704        position.add_bet(back_bet);
705        position.add_bet(lay_bet);
706        // Net exposure: 200 + (-300) = -100 → side becomes Lay.
707        assert_eq!(position.side(), Some(BetSide::Lay));
708        assert_eq!(position.exposure, dec!(-100.0));
709    }
710
711    #[rstest]
712    fn test_position_flat() {
713        let mut position = BetPosition::default();
714        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
715        let lay_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay); // exposure -200
716        position.add_bet(back_bet);
717        position.add_bet(lay_bet);
718        assert!(position.side().is_none());
719        assert_eq!(position.exposure, dec!(0.0));
720    }
721
722    #[rstest]
723    fn test_unrealized_pnl_negative() {
724        let mut position = BetPosition::default();
725        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure 200
726        position.add_bet(bet);
727        // As computed: flattening bet (Lay at 2.5) gives stake = 80 and win payoff = -120, plus original bet win payoff = 100 → -20
728        let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
729        assert_eq!(unrealized_pnl, dec!(-20.0));
730    }
731
732    #[rstest]
733    fn test_total_pnl() {
734        let mut position = BetPosition::default();
735        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
736        position.add_bet(bet);
737        position.realized_pnl = dec!(10.0);
738        let total_pnl = position.total_pnl(dec!(2.5));
739        // Expected realized (10) + unrealized (-20) = -10
740        assert_eq!(total_pnl, dec!(-10.0));
741    }
742
743    #[rstest]
744    fn test_flattening_bet_back_profit() {
745        let mut position = BetPosition::default();
746        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
747        position.add_bet(bet);
748        let flattening_bet = position
749            .flattening_bet(dec!(1.6))
750            .expect("expected a flattening bet");
751        assert_eq!(flattening_bet.side, BetSide::Lay);
752        assert_eq!(flattening_bet.stake, dec_str("125"));
753    }
754
755    #[rstest]
756    fn test_flattening_bet_back_hack() {
757        let mut position = BetPosition::default();
758        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
759        position.add_bet(bet);
760        let flattening_bet = position
761            .flattening_bet(dec!(2.5))
762            .expect("expected a flattening bet");
763        assert_eq!(flattening_bet.side, BetSide::Lay);
764        // Expected stake ~80
765        assert_eq!(flattening_bet.stake, dec!(80.0));
766    }
767
768    #[rstest]
769    fn test_flattening_bet_lay() {
770        let mut position = BetPosition::default();
771        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
772        position.add_bet(bet);
773        let flattening_bet = position
774            .flattening_bet(dec!(1.5))
775            .expect("expected a flattening bet");
776        assert_eq!(flattening_bet.side, BetSide::Back);
777        assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
778    }
779
780    #[rstest]
781    fn test_realized_pnl_flattening() {
782        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // profit = 400
783        let lay = Bet::new(dec!(4.0), dec!(125.0), BetSide::Lay); // outcome win payoff = -375
784        let mut position = BetPosition::default();
785        position.add_bet(back);
786        position.add_bet(lay);
787        // Expected realized pnl = 25
788        assert_eq!(position.realized_pnl, dec!(25.0));
789    }
790
791    #[rstest]
792    fn test_realized_pnl_single_side() {
793        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
794        let mut position = BetPosition::default();
795        position.add_bet(back);
796        // No opposing bet → pnl remains 0
797        assert_eq!(position.realized_pnl, dec!(0.0));
798    }
799
800    #[rstest]
801    fn test_realized_pnl_open_position() {
802        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
803        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
804        let mut position = BetPosition::default();
805        position.add_bet(back);
806        position.add_bet(lay);
807        // Expected realized pnl = 20
808        assert_eq!(position.realized_pnl, dec!(20.0));
809    }
810
811    #[rstest]
812    fn test_realized_pnl_partial_close() {
813        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
814        let lay = Bet::new(dec!(4.0), dec!(110.0), BetSide::Lay); // exposure -440
815        let mut position = BetPosition::default();
816        position.add_bet(back);
817        position.add_bet(lay);
818        // Expected realized pnl = 22
819        assert_eq!(position.realized_pnl, dec!(22.0));
820    }
821
822    #[rstest]
823    fn test_realized_pnl_flipping() {
824        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
825        let lay = Bet::new(dec!(4.0), dec!(130.0), BetSide::Lay); // exposure -520
826        let mut position = BetPosition::default();
827        position.add_bet(back);
828        position.add_bet(lay);
829        // Expected realized pnl = 10
830        assert_eq!(position.realized_pnl, dec!(10.0));
831    }
832
833    #[rstest]
834    fn test_unrealized_pnl_positive() {
835        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
836        let mut position = BetPosition::default();
837        position.add_bet(back);
838        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
839        // Expected unrealized pnl = 25
840        assert_eq!(unrealized_pnl, dec!(25.0));
841    }
842
843    #[rstest]
844    fn test_total_pnl_with_pnl() {
845        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
846        let lay = Bet::new(dec!(4.0), dec!(120.0), BetSide::Lay); // exposure -480
847        let mut position = BetPosition::default();
848        position.add_bet(back);
849        position.add_bet(lay);
850        // After processing, realized pnl should be 24 and unrealized pnl 1.0
851        let realized_pnl = position.realized_pnl;
852        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
853        let total_pnl = position.total_pnl(dec!(4.0));
854        assert_eq!(realized_pnl, dec!(24.0));
855        assert_eq!(unrealized_pnl, dec!(1.0));
856        assert_eq!(total_pnl, dec!(25.0));
857    }
858
859    #[rstest]
860    fn test_open_position_realized_unrealized() {
861        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
862        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
863        let mut position = BetPosition::default();
864        position.add_bet(back);
865        position.add_bet(lay);
866        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
867        // Expected unrealized pnl = 5
868        assert_eq!(unrealized_pnl, dec!(5.0));
869    }
870
871    #[rstest]
872    fn test_unrealized_no_position() {
873        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
874        let mut position = BetPosition::default();
875        position.add_bet(back);
876        let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
877        assert_eq!(unrealized_pnl, dec!(0.0));
878    }
879
880    #[rstest]
881    fn test_calc_bets_pnl_single_back_bet() {
882        let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
883        let pnl = calc_bets_pnl(&[bet]);
884        assert_eq!(pnl, dec!(400.0));
885    }
886
887    #[rstest]
888    fn test_calc_bets_pnl_single_lay_bet() {
889        let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
890        let pnl = calc_bets_pnl(&[bet]);
891        assert_eq!(pnl, dec!(-300.0));
892    }
893
894    #[rstest]
895    fn test_calc_bets_pnl_multiple_bets() {
896        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
897        let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
898        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
899        let expected = dec!(400.0) + dec!(-300.0);
900        assert_eq!(pnl, expected);
901    }
902
903    #[rstest]
904    fn test_calc_bets_pnl_mixed_bets() {
905        let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
906        let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
907        let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
908        let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
909        let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
910        assert_eq!(pnl, expected);
911    }
912
913    #[rstest]
914    fn test_calc_bets_pnl_no_bets() {
915        let bets: Vec<Bet> = vec![];
916        let pnl = calc_bets_pnl(&bets);
917        assert_eq!(pnl, dec!(0.0));
918    }
919
920    #[rstest]
921    fn test_calc_bets_pnl_zero_outcome() {
922        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
923        let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
924        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
925        assert_eq!(pnl, dec!(0.0));
926    }
927
928    #[rstest]
929    fn test_probability_to_bet_back_simple() {
930        // Using OrderSideSpecified in place of ProbSide.
931        let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSideSpecified::Buy).unwrap();
932        let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
933        assert_eq!(bet, expected);
934        assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
935        assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
936    }
937
938    #[rstest]
939    fn test_probability_to_bet_back_high_prob() {
940        let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Buy).unwrap();
941        let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
942        assert_eq!(bet, expected);
943        assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
944        assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
945    }
946
947    #[rstest]
948    fn test_probability_to_bet_back_low_prob() {
949        let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSideSpecified::Buy).unwrap();
950        let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
951        assert_eq!(bet, expected);
952        assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
953        assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
954    }
955
956    #[rstest]
957    fn test_probability_to_bet_sell() {
958        let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSideSpecified::Sell).unwrap();
959        let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
960        assert_eq!(bet, expected);
961        assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
962        assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
963    }
964
965    #[rstest]
966    fn test_inverse_probability_to_bet() {
967        // Original bet with SELL side
968        let original_bet =
969            probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell).unwrap();
970        // Equivalent reverse bet by buying the inverse probability
971        let reverse_bet =
972            probability_to_bet(dec!(0.20), dec!(100.0), OrderSideSpecified::Buy).unwrap();
973        let inverse_bet =
974            inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSideSpecified::Sell).unwrap();
975
976        assert_eq!(
977            original_bet.outcome_win_payoff(),
978            reverse_bet.outcome_lose_payoff(),
979        );
980        assert_eq!(
981            original_bet.outcome_win_payoff(),
982            inverse_bet.outcome_lose_payoff(),
983        );
984        assert_eq!(
985            original_bet.outcome_lose_payoff(),
986            reverse_bet.outcome_win_payoff(),
987        );
988        assert_eq!(
989            original_bet.outcome_lose_payoff(),
990            inverse_bet.outcome_win_payoff(),
991        );
992    }
993
994    #[rstest]
995    fn test_inverse_probability_to_bet_example2() {
996        let original_bet =
997            probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell).unwrap();
998        let inverse_bet =
999            inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSideSpecified::Sell).unwrap();
1000
1001        assert_eq!(original_bet.stake, dec!(32.0));
1002        assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
1003        assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
1004
1005        assert_eq!(inverse_bet.stake, dec!(18.0));
1006        assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
1007        assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
1008    }
1009}