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, OrderSide};
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.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    ///
70    /// # Panics
71    ///
72    /// Panics if `side` is [`BetSide::Lay`] and [`Self::from_liability`] panics.
73    #[must_use]
74    pub fn from_stake_or_liability(price: Decimal, volume: Decimal, side: BetSide) -> Self {
75        Self::from_stake_or_liability_checked(price, volume, side).unwrap_or_else(|e| panic!("{e}"))
76    }
77
78    /// Creates a bet from a stake or liability depending on the bet side.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if `side` is [`BetSide::Lay`] and [`Self::from_liability_checked`] fails.
83    pub fn from_stake_or_liability_checked(
84        price: Decimal,
85        volume: Decimal,
86        side: BetSide,
87    ) -> anyhow::Result<Self> {
88        match side {
89            BetSide::Back => Ok(Self::from_stake(price, volume, side)),
90            BetSide::Lay => Self::from_liability_checked(price, volume, side),
91        }
92    }
93
94    /// Creates a bet from a given stake.
95    #[must_use]
96    pub fn from_stake(price: Decimal, stake: Decimal, side: BetSide) -> Self {
97        Self::new(price, stake, side)
98    }
99
100    /// Creates a bet from a given liability.
101    ///
102    /// # Panics
103    ///
104    /// Panics if the side is not [`BetSide::Lay`], if `price` is not greater than 1,
105    /// or if the stake calculation overflows.
106    #[must_use]
107    pub fn from_liability(price: Decimal, liability: Decimal, side: BetSide) -> Self {
108        Self::from_liability_checked(price, liability, side).unwrap_or_else(|e| panic!("{e}"))
109    }
110
111    /// Creates a bet from a given liability.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the side is not [`BetSide::Lay`], if `price` is not greater
116    /// than 1, or if the stake calculation overflows.
117    pub fn from_liability_checked(
118        price: Decimal,
119        liability: Decimal,
120        side: BetSide,
121    ) -> anyhow::Result<Self> {
122        if side != BetSide::Lay {
123            anyhow::bail!("Liability-based betting is only applicable for Lay side.");
124        }
125
126        check_odds_gt_one(price)?;
127        let stake = checked_div(liability, checked_sub(price, Decimal::ONE)?)?;
128        Ok(Self::new(price, stake, side))
129    }
130
131    /// Returns the bet's exposure.
132    ///
133    /// For BACK bets, exposure is positive; for LAY bets, it is negative.
134    ///
135    /// # Panics
136    ///
137    /// Panics if the calculation overflows.
138    #[must_use]
139    pub fn exposure(&self) -> Decimal {
140        self.exposure_checked().unwrap_or_else(|e| panic!("{e}"))
141    }
142
143    /// Returns the bet's exposure.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the calculation overflows.
148    pub fn exposure_checked(&self) -> anyhow::Result<Decimal> {
149        let notional = checked_mul(self.price, self.stake)?;
150        Ok(match self.side {
151            BetSide::Back => notional,
152            BetSide::Lay => -notional,
153        })
154    }
155
156    /// Returns the bet's liability.
157    ///
158    /// For BACK bets, liability equals the stake; for LAY bets, it is
159    /// stake multiplied by (price - 1).
160    ///
161    /// # Panics
162    ///
163    /// Panics if the calculation overflows.
164    #[must_use]
165    pub fn liability(&self) -> Decimal {
166        self.liability_checked().unwrap_or_else(|e| panic!("{e}"))
167    }
168
169    /// Returns the bet's liability.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the calculation overflows.
174    pub fn liability_checked(&self) -> anyhow::Result<Decimal> {
175        match self.side {
176            BetSide::Back => Ok(self.stake),
177            BetSide::Lay => checked_mul(self.stake, checked_sub(self.price, Decimal::ONE)?),
178        }
179    }
180
181    /// Returns the bet's profit.
182    ///
183    /// For BACK bets, profit is stake * (price - 1); for LAY bets it equals the stake.
184    ///
185    /// # Panics
186    ///
187    /// Panics if the calculation overflows.
188    #[must_use]
189    pub fn profit(&self) -> Decimal {
190        self.profit_checked().unwrap_or_else(|e| panic!("{e}"))
191    }
192
193    /// Returns the bet's profit.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the calculation overflows.
198    pub fn profit_checked(&self) -> anyhow::Result<Decimal> {
199        match self.side {
200            BetSide::Back => checked_mul(self.stake, checked_sub(self.price, Decimal::ONE)?),
201            BetSide::Lay => Ok(self.stake),
202        }
203    }
204
205    /// Returns the outcome win payoff.
206    ///
207    /// For BACK bets this is the profit; for LAY bets it is the negative liability.
208    ///
209    /// # Panics
210    ///
211    /// Panics if the calculation overflows.
212    #[must_use]
213    pub fn outcome_win_payoff(&self) -> Decimal {
214        self.outcome_win_payoff_checked()
215            .unwrap_or_else(|e| panic!("{e}"))
216    }
217
218    /// Returns the outcome win payoff.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the calculation overflows.
223    pub fn outcome_win_payoff_checked(&self) -> anyhow::Result<Decimal> {
224        match self.side {
225            BetSide::Back => self.profit_checked(),
226            BetSide::Lay => Ok(-self.liability_checked()?),
227        }
228    }
229
230    /// Returns the outcome lose payoff.
231    ///
232    /// For BACK bets this is the negative liability; for LAY bets it is the profit.
233    ///
234    /// # Panics
235    ///
236    /// Panics if the calculation overflows.
237    #[must_use]
238    pub fn outcome_lose_payoff(&self) -> Decimal {
239        self.outcome_lose_payoff_checked()
240            .unwrap_or_else(|e| panic!("{e}"))
241    }
242
243    /// Returns the outcome lose payoff.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the calculation overflows.
248    pub fn outcome_lose_payoff_checked(&self) -> anyhow::Result<Decimal> {
249        match self.side {
250            BetSide::Back => Ok(-self.liability_checked()?),
251            BetSide::Lay => self.profit_checked(),
252        }
253    }
254
255    /// Returns the hedging stake given a new price.
256    ///
257    /// # Panics
258    ///
259    /// Panics if `price` is zero, if this bet's price is zero on the lay path,
260    /// or if the calculation overflows.
261    #[must_use]
262    pub fn hedging_stake(&self, price: Decimal) -> Decimal {
263        self.hedging_stake_checked(price)
264            .unwrap_or_else(|e| panic!("{e}"))
265    }
266
267    /// Returns the hedging stake given a new price.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if `price` is zero, if this bet's price is zero on the
272    /// lay path, or if the calculation overflows.
273    pub fn hedging_stake_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
274        match self.side {
275            BetSide::Back => checked_mul(checked_div(self.price, price)?, self.stake),
276            BetSide::Lay => checked_div(self.stake, checked_div(price, self.price)?),
277        }
278    }
279
280    /// Creates a hedging bet for a given price.
281    ///
282    /// # Panics
283    ///
284    /// Panics if [`Self::hedging_stake`] panics.
285    #[must_use]
286    pub fn hedging_bet(&self, price: Decimal) -> Self {
287        self.hedging_bet_checked(price)
288            .unwrap_or_else(|e| panic!("{e}"))
289    }
290
291    /// Creates a hedging bet for a given price.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if [`Self::hedging_stake_checked`] fails.
296    pub fn hedging_bet_checked(&self, price: Decimal) -> anyhow::Result<Self> {
297        Ok(Self::new(
298            price,
299            self.hedging_stake_checked(price)?,
300            self.side.opposite(),
301        ))
302    }
303}
304
305impl Display for Bet {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        // Example output: "Bet(Back @ 2.50 x10.00)"
308        write!(
309            f,
310            "Bet({:?} @ {:.2} x{:.2})",
311            self.side, self.price, self.stake
312        )
313    }
314}
315
316/// A position comprising one or more bets.
317#[derive(Debug, Clone)]
318#[cfg_attr(
319    feature = "python",
320    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
321)]
322#[cfg_attr(
323    feature = "python",
324    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
325)]
326pub struct BetPosition {
327    price: Decimal,
328    exposure: Decimal,
329    realized_pnl: Decimal,
330    bets: Vec<Bet>,
331}
332
333impl Default for BetPosition {
334    fn default() -> Self {
335        Self {
336            price: Decimal::ZERO,
337            exposure: Decimal::ZERO,
338            realized_pnl: Decimal::ZERO,
339            bets: vec![],
340        }
341    }
342}
343
344impl BetPosition {
345    /// Returns the position's price.
346    #[must_use]
347    pub fn price(&self) -> Decimal {
348        self.price
349    }
350
351    /// Returns the position's exposure.
352    #[must_use]
353    pub fn exposure(&self) -> Decimal {
354        self.exposure
355    }
356
357    /// Returns the position's realized profit and loss.
358    #[must_use]
359    pub fn realized_pnl(&self) -> Decimal {
360        self.realized_pnl
361    }
362
363    /// Returns a reference to the position's bets.
364    #[must_use]
365    pub fn bets(&self) -> &[Bet] {
366        &self.bets
367    }
368
369    /// Returns the overall side of the position.
370    ///
371    /// If exposure is positive the side is BACK; if negative, LAY; if zero, None.
372    #[must_use]
373    pub fn side(&self) -> Option<BetSide> {
374        match self.exposure.cmp(&Decimal::ZERO) {
375            std::cmp::Ordering::Less => Some(BetSide::Lay),
376            std::cmp::Ordering::Greater => Some(BetSide::Back),
377            std::cmp::Ordering::Equal => None,
378        }
379    }
380
381    /// Converts the current position into a single bet, if possible.
382    ///
383    /// # Panics
384    ///
385    /// Panics if the position has a side and `price` is zero, or if the
386    /// calculation overflows.
387    #[must_use]
388    pub fn as_bet(&self) -> Option<Bet> {
389        self.as_bet_checked().unwrap_or_else(|e| panic!("{e}"))
390    }
391
392    /// Converts the current position into a single bet, if possible.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error if the position has a side and `price` is zero, or if
397    /// the calculation overflows.
398    pub fn as_bet_checked(&self) -> anyhow::Result<Option<Bet>> {
399        let Some(side) = self.side() else {
400            return Ok(None);
401        };
402        check_nonzero_denominator(self.price, "price")?;
403        let stake = match side {
404            BetSide::Back => checked_div(self.exposure, self.price)?,
405            BetSide::Lay => checked_div(-self.exposure, self.price)?,
406        };
407        Ok(Some(Bet::new(self.price, stake, side)))
408    }
409
410    /// Adds a bet to the position, adjusting exposure and realized PnL.
411    pub fn add_bet(&mut self, bet: Bet) {
412        match self.side() {
413            None => self.position_increase(&bet),
414            Some(current_side) if current_side == bet.side => self.position_increase(&bet),
415            Some(_) => self.position_decrease(&bet),
416        }
417        self.bets.push(bet);
418    }
419
420    /// Adds a bet to the position, adjusting exposure and realized PnL.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if a denominator is zero or a Decimal calculation overflows.
425    /// On error the position is left unchanged.
426    pub fn add_bet_checked(&mut self, bet: Bet) -> anyhow::Result<()> {
427        let (price, exposure, realized_pnl) = match self.side() {
428            None => self.increased_state(&bet)?,
429            Some(current_side) if current_side == bet.side => self.increased_state(&bet)?,
430            Some(_) => self.decreased_state(&bet)?,
431        };
432        self.price = price;
433        self.exposure = exposure;
434        self.realized_pnl = realized_pnl;
435        self.bets.push(bet);
436        Ok(())
437    }
438
439    fn increased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
440        let bet_exposure = bet.exposure_checked()?;
441        let price = if self.side().is_none() {
442            bet.price
443        } else if self.side() == Some(bet.side)
444            && self.price > Decimal::ZERO
445            && bet.price > Decimal::ZERO
446            && bet.stake > Decimal::ZERO
447        {
448            let abs_self_exposure = self.exposure.abs();
449            let abs_bet_exposure = bet_exposure.abs();
450            let total_stake = checked_add(checked_div(abs_self_exposure, self.price)?, bet.stake)?;
451            checked_div(
452                checked_add(abs_self_exposure, abs_bet_exposure)?,
453                total_stake,
454            )?
455        } else {
456            self.price
457        };
458        Ok((
459            price,
460            checked_add(self.exposure, bet_exposure)?,
461            self.realized_pnl,
462        ))
463    }
464
465    fn decreased_state(&self, bet: &Bet) -> anyhow::Result<(Decimal, Decimal, Decimal)> {
466        let current_side = self
467            .side()
468            .ok_or_else(|| anyhow::anyhow!("cannot decrease an empty bet position"))?;
469        let bet_exposure = bet.exposure_checked()?;
470        let abs_bet_exposure = bet_exposure.abs();
471        let abs_self_exposure = self.exposure.abs();
472
473        match abs_bet_exposure.cmp(&abs_self_exposure) {
474            std::cmp::Ordering::Less => {
475                check_nonzero_denominator(self.price, "price")?;
476                let decreasing_volume = checked_div(abs_bet_exposure, self.price)?;
477                let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
478                let pnl = calc_bets_pnl_checked(&[bet.clone(), decreasing_bet])?;
479                Ok((
480                    self.price,
481                    checked_add(self.exposure, bet_exposure)?,
482                    checked_add(self.realized_pnl, pnl)?,
483                ))
484            }
485            std::cmp::Ordering::Greater => Ok((
486                bet.price,
487                checked_add(self.exposure, bet_exposure)?,
488                self.realized_after_close(bet)?,
489            )),
490            std::cmp::Ordering::Equal => Ok((
491                Decimal::ZERO,
492                Decimal::ZERO,
493                self.realized_after_close(bet)?,
494            )),
495        }
496    }
497
498    fn realized_after_close(&self, bet: &Bet) -> anyhow::Result<Decimal> {
499        match self.as_bet_checked()? {
500            Some(self_bet) => checked_add(
501                self.realized_pnl,
502                calc_bets_pnl_checked(&[bet.clone(), self_bet])?,
503            ),
504            None => Ok(self.realized_pnl),
505        }
506    }
507
508    /// Increases the position with the provided bet.
509    ///
510    /// A same-side increase sets the price to the stake-weighted mean of the decimal odds.
511    pub fn position_increase(&mut self, bet: &Bet) {
512        if self.side().is_none() {
513            self.price = bet.price;
514        } else {
515            let abs_self_exposure = self.exposure.abs();
516            let abs_bet_exposure = bet.exposure().abs();
517            // The mean is only meaningful for a same-side bet with well-formed odds:
518            // `add_bet` routes the opposite side to `position_decrease`, but this
519            // method is public, and `Bet` enforces neither a positive price nor a
520            // positive stake. These conditions also guarantee a positive denominator
521            // below. Anything else keeps the price it had.
522            if self.side() == Some(bet.side)
523                && self.price > Decimal::ZERO
524                && bet.price > Decimal::ZERO
525                && bet.stake > Decimal::ZERO
526            {
527                let total_stake = abs_self_exposure / self.price + bet.stake;
528                self.price = (abs_self_exposure + abs_bet_exposure) / total_stake;
529            }
530        }
531        self.exposure += bet.exposure();
532    }
533
534    /// Decreases the position with the provided bet, updating exposure and realized P&L.
535    ///
536    /// # Panics
537    ///
538    /// Panics if there is no current side (empty position) when unwrapping the side.
539    pub fn position_decrease(&mut self, bet: &Bet) {
540        let abs_bet_exposure = bet.exposure().abs();
541        let abs_self_exposure = self.exposure.abs();
542
543        match abs_bet_exposure.cmp(&abs_self_exposure) {
544            std::cmp::Ordering::Less => {
545                let decreasing_volume = abs_bet_exposure / self.price;
546                let current_side = self.side().unwrap();
547                let decreasing_bet = Bet::new(self.price, decreasing_volume, current_side);
548                let pnl = calc_bets_pnl(&[bet.clone(), decreasing_bet]);
549                self.realized_pnl += pnl;
550                self.exposure += bet.exposure();
551            }
552            std::cmp::Ordering::Greater => {
553                if let Some(self_bet) = self.as_bet() {
554                    let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
555                    self.realized_pnl += pnl;
556                }
557                self.price = bet.price;
558                self.exposure += bet.exposure();
559            }
560            std::cmp::Ordering::Equal => {
561                if let Some(self_bet) = self.as_bet() {
562                    let pnl = calc_bets_pnl(&[bet.clone(), self_bet]);
563                    self.realized_pnl += pnl;
564                }
565                self.price = Decimal::ZERO;
566                self.exposure = Decimal::ZERO;
567            }
568        }
569    }
570
571    /// Calculates the unrealized profit and loss given a current price.
572    ///
573    /// # Panics
574    ///
575    /// Panics if flattening or marking the position overflows or divides by zero.
576    #[must_use]
577    pub fn unrealized_pnl(&self, price: Decimal) -> Decimal {
578        self.unrealized_pnl_checked(price)
579            .unwrap_or_else(|e| panic!("{e}"))
580    }
581
582    /// Calculates the unrealized profit and loss given a current price.
583    ///
584    /// # Errors
585    ///
586    /// Returns an error if flattening or marking the position overflows or divides by zero.
587    pub fn unrealized_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
588        let Some(flattening_bet) = self.flattening_bet_checked(price)? else {
589            return Ok(Decimal::ZERO);
590        };
591        let Some(self_bet) = self.as_bet_checked()? else {
592            return Ok(Decimal::ZERO);
593        };
594        calc_bets_pnl_checked(&[flattening_bet, self_bet])
595    }
596
597    /// Returns the total profit and loss (realized plus unrealized) given a current price.
598    ///
599    /// # Panics
600    ///
601    /// Panics if [`Self::unrealized_pnl`] panics or the sum overflows.
602    #[must_use]
603    pub fn total_pnl(&self, price: Decimal) -> Decimal {
604        self.total_pnl_checked(price)
605            .unwrap_or_else(|e| panic!("{e}"))
606    }
607
608    /// Returns the total profit and loss (realized plus unrealized) given a current price.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error if unrealized PnL cannot be computed or the sum overflows.
613    pub fn total_pnl_checked(&self, price: Decimal) -> anyhow::Result<Decimal> {
614        checked_add(self.realized_pnl, self.unrealized_pnl_checked(price)?)
615    }
616
617    /// Creates a bet that would flatten (neutralize) the current position.
618    ///
619    /// # Panics
620    ///
621    /// Panics if the position has a side and `price` is zero, or if the
622    /// calculation overflows.
623    #[must_use]
624    pub fn flattening_bet(&self, price: Decimal) -> Option<Bet> {
625        self.flattening_bet_checked(price)
626            .unwrap_or_else(|e| panic!("{e}"))
627    }
628
629    /// Creates a bet that would flatten (neutralize) the current position.
630    ///
631    /// # Errors
632    ///
633    /// Returns an error if the position has a side and `price` is zero, or if
634    /// the calculation overflows.
635    pub fn flattening_bet_checked(&self, price: Decimal) -> anyhow::Result<Option<Bet>> {
636        let Some(side) = self.side() else {
637            return Ok(None);
638        };
639        check_nonzero_denominator(price, "price")?;
640        let stake = match side {
641            BetSide::Back => checked_div(self.exposure, price)?,
642            BetSide::Lay => checked_div(-self.exposure, price)?,
643        };
644        Ok(Some(Bet::new(price, stake, side.opposite())))
645    }
646
647    /// Resets the bet position to its initial state.
648    pub fn reset(&mut self) {
649        self.price = Decimal::ZERO;
650        self.exposure = Decimal::ZERO;
651        self.realized_pnl = Decimal::ZERO;
652        self.bets.clear();
653    }
654}
655
656impl Display for BetPosition {
657    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658        write!(
659            f,
660            "BetPosition(price={:.2}, exposure={:.2}, realized_pnl={:.2})",
661            self.price, self.exposure, self.realized_pnl
662        )
663    }
664}
665
666/// Calculates the combined profit and loss for a slice of bets.
667///
668/// # Panics
669///
670/// Panics if a payoff or the running total overflows.
671#[must_use]
672pub fn calc_bets_pnl(bets: &[Bet]) -> Decimal {
673    calc_bets_pnl_checked(bets).unwrap_or_else(|e| panic!("{e}"))
674}
675
676/// Calculates the combined profit and loss for a slice of bets.
677///
678/// # Errors
679///
680/// Returns an error if a payoff or the running total overflows.
681pub fn calc_bets_pnl_checked(bets: &[Bet]) -> anyhow::Result<Decimal> {
682    bets.iter().try_fold(Decimal::ZERO, |acc, bet| {
683        checked_add(acc, bet.outcome_win_payoff_checked()?)
684    })
685}
686
687/// Checks that `probability` is non-zero.
688///
689/// # Errors
690///
691/// Returns an error if `probability` is zero.
692pub fn check_probability_non_zero(probability: Decimal) -> anyhow::Result<()> {
693    if probability.is_zero() {
694        anyhow::bail!("invalid probability: must be non-zero")
695    }
696    Ok(())
697}
698
699/// Checks that `probability` is invertible (not equal to 1.0).
700///
701/// # Errors
702///
703/// Returns an error if `probability` is 1.0.
704pub fn check_probability_invertible(probability: Decimal) -> anyhow::Result<()> {
705    if probability == Decimal::ONE {
706        anyhow::bail!("invalid probability: must not be 1.0 (inverse would be zero)")
707    }
708    Ok(())
709}
710
711/// Converts a probability and volume into a Bet.
712///
713/// For a BUY side, this creates a BACK bet; for SELL, a LAY bet.
714///
715/// # Errors
716///
717/// Returns an error if `probability` is zero or the conversion overflows.
718pub fn probability_to_bet(
719    probability: Decimal,
720    volume: Decimal,
721    side: OrderSide,
722) -> anyhow::Result<Bet> {
723    check_probability_non_zero(probability)?;
724    let price = checked_div(Decimal::ONE, probability)?;
725    let stake = checked_div(volume, price)?;
726    let bet = match side {
727        OrderSide::Buy => Bet::new(price, stake, BetSide::Back),
728        OrderSide::Sell => Bet::new(price, stake, BetSide::Lay),
729    };
730    Ok(bet)
731}
732
733/// Converts a probability and volume into a Bet using the inverse probability.
734///
735/// The side is also inverted (BUY becomes SELL and vice versa).
736///
737/// # Errors
738///
739/// Returns an error if `probability` is 1.0 or its inverse is zero.
740pub fn inverse_probability_to_bet(
741    probability: Decimal,
742    volume: Decimal,
743    side: OrderSide,
744) -> anyhow::Result<Bet> {
745    check_probability_invertible(probability)?;
746    let inverse_probability = checked_sub(Decimal::ONE, probability)?;
747    probability_to_bet(inverse_probability, volume, side.opposite())
748}
749
750fn check_odds_gt_one(price: Decimal) -> anyhow::Result<()> {
751    if price <= Decimal::ONE {
752        anyhow::bail!("Price must be greater than 1.0 for lay liability calculation, was {price}");
753    }
754    Ok(())
755}
756
757fn check_nonzero_denominator(value: Decimal, name: &str) -> anyhow::Result<()> {
758    if value.is_zero() {
759        anyhow::bail!("invalid {name}: must be non-zero")
760    }
761    Ok(())
762}
763
764fn checked_add(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
765    lhs.checked_add(rhs)
766        .ok_or_else(|| anyhow::anyhow!("Decimal overflow adding {lhs} and {rhs}"))
767}
768
769fn checked_sub(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
770    lhs.checked_sub(rhs)
771        .ok_or_else(|| anyhow::anyhow!("Decimal overflow subtracting {rhs} from {lhs}"))
772}
773
774fn checked_mul(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
775    lhs.checked_mul(rhs)
776        .ok_or_else(|| anyhow::anyhow!("Decimal overflow multiplying {lhs} by {rhs}"))
777}
778
779fn checked_div(lhs: Decimal, rhs: Decimal) -> anyhow::Result<Decimal> {
780    check_nonzero_denominator(rhs, "divisor")?;
781    lhs.checked_div(rhs)
782        .ok_or_else(|| anyhow::anyhow!("Decimal overflow dividing {lhs} by {rhs}"))
783}
784
785#[cfg(test)]
786mod tests {
787    use rstest::rstest;
788    use rust_decimal::Decimal;
789    use rust_decimal_macros::dec;
790
791    use super::*;
792
793    fn dec_str(s: &str) -> Decimal {
794        s.parse::<Decimal>().expect("Failed to parse Decimal")
795    }
796
797    #[rstest]
798    #[should_panic(expected = "Liability-based betting is only applicable for Lay side.")]
799    fn test_from_liability_panics_on_back_side() {
800        let _ = Bet::from_liability(dec!(2.0), dec!(100.0), BetSide::Back);
801    }
802
803    #[rstest]
804    fn test_bet_creation() {
805        let price = dec!(2.0);
806        let stake = dec!(100.0);
807        let side = BetSide::Back;
808        let bet = Bet::new(price, stake, side);
809        assert_eq!(bet.price, price);
810        assert_eq!(bet.stake, stake);
811        assert_eq!(bet.side, side);
812    }
813
814    #[rstest]
815    fn test_display_bet() {
816        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
817        let formatted = format!("{bet}");
818        assert!(formatted.contains("Back"));
819        assert!(formatted.contains("2.00"));
820        assert!(formatted.contains("100.00"));
821    }
822
823    #[rstest]
824    fn test_bet_exposure_back() {
825        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
826        let exposure = bet.exposure();
827        assert_eq!(exposure, dec!(200.0));
828    }
829
830    #[rstest]
831    fn test_bet_exposure_lay() {
832        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
833        let exposure = bet.exposure();
834        assert_eq!(exposure, dec!(-200.0));
835    }
836
837    #[rstest]
838    fn test_bet_liability_back() {
839        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
840        let liability = bet.liability();
841        assert_eq!(liability, dec!(100.0));
842    }
843
844    #[rstest]
845    fn test_bet_liability_lay() {
846        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
847        let liability = bet.liability();
848        assert_eq!(liability, dec!(100.0));
849    }
850
851    #[rstest]
852    fn test_bet_profit_back() {
853        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
854        let profit = bet.profit();
855        assert_eq!(profit, dec!(100.0));
856    }
857
858    #[rstest]
859    fn test_bet_profit_lay() {
860        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
861        let profit = bet.profit();
862        assert_eq!(profit, dec!(100.0));
863    }
864
865    #[rstest]
866    fn test_outcome_win_payoff_back() {
867        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
868        let win_payoff = bet.outcome_win_payoff();
869        assert_eq!(win_payoff, dec!(100.0));
870    }
871
872    #[rstest]
873    fn test_outcome_win_payoff_lay() {
874        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
875        let win_payoff = bet.outcome_win_payoff();
876        assert_eq!(win_payoff, dec!(-100.0));
877    }
878
879    #[rstest]
880    fn test_outcome_lose_payoff_back() {
881        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
882        let lose_payoff = bet.outcome_lose_payoff();
883        assert_eq!(lose_payoff, dec!(-100.0));
884    }
885
886    #[rstest]
887    fn test_outcome_lose_payoff_lay() {
888        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
889        let lose_payoff = bet.outcome_lose_payoff();
890        assert_eq!(lose_payoff, dec!(100.0));
891    }
892
893    #[rstest]
894    fn test_hedging_stake_back() {
895        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
896        let hedging_stake = bet.hedging_stake(dec!(1.5));
897        // Expected: (2.0/1.5)*100 = 133.3333333333...
898        assert_eq!(hedging_stake.round_dp(8), dec_str("133.33333333"));
899    }
900
901    #[rstest]
902    fn test_hedging_bet_lay() {
903        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
904        let hedge_bet = bet.hedging_bet(dec!(1.5));
905        assert_eq!(hedge_bet.side, BetSide::Back);
906        assert_eq!(hedge_bet.price, dec!(1.5));
907        assert_eq!(hedge_bet.stake.round_dp(8), dec_str("133.33333333"));
908    }
909
910    #[rstest]
911    fn test_bet_position_initialization() {
912        let position = BetPosition::default();
913        assert_eq!(position.price, dec!(0.0));
914        assert_eq!(position.exposure, dec!(0.0));
915        assert_eq!(position.realized_pnl, dec!(0.0));
916    }
917
918    #[rstest]
919    fn test_display_bet_position() {
920        let mut position = BetPosition::default();
921        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
922        position.add_bet(bet);
923
924        assert_eq!(
925            format!("{position}"),
926            "BetPosition(price=2.00, exposure=200.00, realized_pnl=0.00)"
927        );
928    }
929
930    #[rstest]
931    fn test_as_bet() {
932        let mut position = BetPosition::default();
933        // Add a BACK bet so the position has exposure
934        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
935        position.add_bet(bet);
936        let as_bet = position.as_bet().expect("Expected a bet representation");
937
938        assert_eq!(as_bet.price, position.price);
939        assert_eq!(as_bet.stake, position.exposure / position.price);
940        assert_eq!(as_bet.side, BetSide::Back);
941    }
942
943    #[rstest]
944    fn test_reset_position() {
945        let mut position = BetPosition::default();
946        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
947        position.add_bet(bet);
948        assert_ne!(position.exposure, dec!(0.0));
949        assert!(!position.bets().is_empty());
950        position.reset();
951
952        // After reset, the position should be cleared
953        assert_eq!(position.price, dec!(0.0));
954        assert_eq!(position.exposure, dec!(0.0));
955        assert_eq!(position.realized_pnl, dec!(0.0));
956        assert!(position.bets().is_empty());
957    }
958
959    #[rstest]
960    fn test_bet_position_side_none() {
961        let position = BetPosition::default();
962        assert!(position.side().is_none());
963    }
964
965    #[rstest]
966    fn test_bet_position_side_back() {
967        let mut position = BetPosition::default();
968        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
969        position.add_bet(bet);
970        assert_eq!(position.side(), Some(BetSide::Back));
971    }
972
973    #[rstest]
974    fn test_bet_position_side_lay() {
975        let mut position = BetPosition::default();
976        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
977        position.add_bet(bet);
978        assert_eq!(position.side(), Some(BetSide::Lay));
979    }
980
981    #[rstest]
982    fn test_position_increase_back() {
983        let mut position = BetPosition::default();
984        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
985        let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
986        position.add_bet(bet1);
987        position.add_bet(bet2);
988        // Expected exposure = 200 + 100 = 300
989        assert_eq!(position.exposure, dec!(300.0));
990    }
991
992    #[rstest]
993    fn test_position_increase_cancelling_stakes_preserves_price() {
994        let mut position = BetPosition::default();
995        position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
996        // `Bet` does not enforce a positive stake, and a cancelling one drives the
997        // aggregate stake to zero.
998        position.add_bet(Bet::new(dec!(2.0), dec!(-100.0), BetSide::Back));
999
1000        assert_eq!(position.price, dec!(2.0));
1001        assert_eq!(position.exposure, dec!(0.0));
1002    }
1003
1004    #[rstest]
1005    fn test_position_increase_negative_stake_preserves_price() {
1006        let mut position = BetPosition::default();
1007        position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1008        // Leaves a positive aggregate stake, so only the stake sign rejects it.
1009        position.add_bet(Bet::new(dec!(3.0), dec!(-50.0), BetSide::Back));
1010
1011        assert_eq!(position.price, dec!(2.0));
1012        assert_eq!(position.exposure, dec!(50.0));
1013    }
1014
1015    #[rstest]
1016    fn test_position_increase_opposite_side_preserves_price() {
1017        let mut position = BetPosition::default();
1018        position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1019        // `add_bet` would route this to `position_decrease`; the public method is
1020        // callable directly and must not average across sides.
1021        position.position_increase(&Bet::new(dec!(3.0), dec!(50.0), BetSide::Lay));
1022
1023        assert_eq!(position.price, dec!(2.0));
1024    }
1025
1026    #[rstest]
1027    #[case(dec!(0.0))]
1028    #[case(dec!(-3.0))]
1029    fn test_position_increase_non_positive_incoming_price_preserves_price(#[case] price: Decimal) {
1030        let mut position = BetPosition::default();
1031        position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1032        // Both stakes are positive, so only the incoming price rejects it.
1033        position.add_bet(Bet::new(price, dec!(50.0), BetSide::Back));
1034
1035        assert_eq!(position.price, dec!(2.0));
1036    }
1037
1038    #[rstest]
1039    fn test_position_increase_non_positive_current_price_preserves_price() {
1040        let mut position = BetPosition::default();
1041        // `Bet` enforces no positive price, so an opening bet can leave a nonempty
1042        // position whose current price is negative.
1043        position.add_bet(Bet::new(dec!(-3.0), dec!(100.0), BetSide::Lay));
1044        assert_eq!(position.side(), Some(BetSide::Back));
1045
1046        // Same side, positive incoming price and stake, so only the current price
1047        // rejects it: the aggregate stake would be 300 / -3 + 100, and averaging
1048        // would divide by zero.
1049        position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1050
1051        assert_eq!(position.price, dec!(-3.0));
1052        assert_eq!(position.exposure, dec!(500.0));
1053    }
1054
1055    #[rstest]
1056    fn test_position_increase_back_averages_price_and_conserves_pnl() {
1057        let mut position = BetPosition::default();
1058        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1059        let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Back);
1060        let settlement_price = dec!(3.0);
1061        let constituent_pnl = calc_bets_pnl(&[
1062            bet1.clone(),
1063            bet1.hedging_bet(settlement_price),
1064            bet2.clone(),
1065            bet2.hedging_bet(settlement_price),
1066        ]);
1067
1068        position.add_bet(bet1);
1069        position.add_bet(bet2);
1070
1071        assert_eq!(position.price, dec!(400.0) / dec!(150.0));
1072        assert_eq!(
1073            position.total_pnl(settlement_price).round_dp(8),
1074            constituent_pnl.round_dp(8)
1075        );
1076    }
1077
1078    #[rstest]
1079    fn test_position_increase_lay() {
1080        let mut position = BetPosition::default();
1081        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1082        let bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Lay);
1083        position.add_bet(bet1);
1084        position.add_bet(bet2);
1085        // exposure = -200 + (-100) = -300
1086        assert_eq!(position.exposure, dec!(-300.0));
1087    }
1088
1089    #[rstest]
1090    fn test_position_increase_lay_averages_price_and_conserves_pnl() {
1091        let mut position = BetPosition::default();
1092        let bet1 = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1093        let bet2 = Bet::new(dec!(4.0), dec!(50.0), BetSide::Lay);
1094        let settlement_price = dec!(3.0);
1095        let constituent_pnl = calc_bets_pnl(&[
1096            bet1.clone(),
1097            bet1.hedging_bet(settlement_price),
1098            bet2.clone(),
1099            bet2.hedging_bet(settlement_price),
1100        ]);
1101
1102        position.add_bet(bet1);
1103        position.add_bet(bet2);
1104
1105        assert_eq!(position.price, dec!(400.0) / dec!(150.0));
1106        assert_eq!(
1107            position.total_pnl(settlement_price).round_dp(8),
1108            constituent_pnl.round_dp(8)
1109        );
1110    }
1111
1112    #[rstest]
1113    fn test_position_back_then_lay() {
1114        let mut position = BetPosition::default();
1115        let bet1 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1116        let bet2 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1117        position.add_bet(bet1);
1118        position.add_bet(bet2);
1119
1120        assert_eq!(position.exposure, dec!(280_000.0));
1121        assert_eq!(position.realized_pnl(), dec!(3333.333333333333333333333333));
1122        assert_eq!(
1123            position.unrealized_pnl(dec!(4.0)),
1124            dec!(-23333.33333333333333333333334)
1125        );
1126    }
1127
1128    #[rstest]
1129    fn test_position_lay_then_back() {
1130        let mut position = BetPosition::default();
1131        let bet1 = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1132        let bet2 = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1133        position.add_bet(bet1);
1134        position.add_bet(bet2);
1135
1136        assert_eq!(position.exposure, dec!(280_000.0));
1137        assert_eq!(position.realized_pnl(), dec!(190_000));
1138        assert_eq!(
1139            position.unrealized_pnl(dec!(4.0)),
1140            dec!(-23333.33333333333333333333334)
1141        );
1142    }
1143
1144    #[rstest]
1145    fn test_position_flip() {
1146        let mut position = BetPosition::default();
1147        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
1148        let lay_bet = Bet::new(dec!(2.0), dec!(150.0), BetSide::Lay); // exposure -300
1149        position.add_bet(back_bet);
1150        position.add_bet(lay_bet);
1151        // Net exposure: 200 + (-300) = -100 → side becomes Lay.
1152        assert_eq!(position.side(), Some(BetSide::Lay));
1153        assert_eq!(position.exposure, dec!(-100.0));
1154    }
1155
1156    #[rstest]
1157    fn test_position_flat() {
1158        let mut position = BetPosition::default();
1159        let back_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure +200
1160        let lay_bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay); // exposure -200
1161        position.add_bet(back_bet);
1162        position.add_bet(lay_bet);
1163        assert!(position.side().is_none());
1164        assert_eq!(position.exposure, dec!(0.0));
1165    }
1166
1167    #[rstest]
1168    fn test_unrealized_pnl_negative() {
1169        let mut position = BetPosition::default();
1170        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back); // exposure 200
1171        position.add_bet(bet);
1172        // As computed: flattening bet (Lay at 2.5) gives stake = 80 and win payoff = -120, plus original bet win payoff = 100 → -20
1173        let unrealized_pnl = position.unrealized_pnl(dec!(2.5));
1174        assert_eq!(unrealized_pnl, dec!(-20.0));
1175    }
1176
1177    #[rstest]
1178    fn test_total_pnl() {
1179        let mut position = BetPosition::default();
1180        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1181        position.add_bet(bet);
1182        position.realized_pnl = dec!(10.0);
1183        let total_pnl = position.total_pnl(dec!(2.5));
1184        // Expected realized (10) + unrealized (-20) = -10
1185        assert_eq!(total_pnl, dec!(-10.0));
1186    }
1187
1188    #[rstest]
1189    fn test_flattening_bet_back_profit() {
1190        let mut position = BetPosition::default();
1191        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1192        position.add_bet(bet);
1193        let flattening_bet = position
1194            .flattening_bet(dec!(1.6))
1195            .expect("expected a flattening bet");
1196        assert_eq!(flattening_bet.side, BetSide::Lay);
1197        assert_eq!(flattening_bet.stake, dec_str("125"));
1198    }
1199
1200    #[rstest]
1201    fn test_flattening_bet_back_hack() {
1202        let mut position = BetPosition::default();
1203        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1204        position.add_bet(bet);
1205        let flattening_bet = position
1206            .flattening_bet(dec!(2.5))
1207            .expect("expected a flattening bet");
1208        assert_eq!(flattening_bet.side, BetSide::Lay);
1209        // Expected stake ~80
1210        assert_eq!(flattening_bet.stake, dec!(80.0));
1211    }
1212
1213    #[rstest]
1214    fn test_flattening_bet_lay() {
1215        let mut position = BetPosition::default();
1216        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1217        position.add_bet(bet);
1218        let flattening_bet = position
1219            .flattening_bet(dec!(1.5))
1220            .expect("expected a flattening bet");
1221        assert_eq!(flattening_bet.side, BetSide::Back);
1222        assert_eq!(flattening_bet.stake.round_dp(8), dec_str("133.33333333"));
1223    }
1224
1225    #[rstest]
1226    fn test_realized_pnl_flattening() {
1227        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // profit = 400
1228        let lay = Bet::new(dec!(4.0), dec!(125.0), BetSide::Lay); // outcome win payoff = -375
1229        let mut position = BetPosition::default();
1230        position.add_bet(back);
1231        position.add_bet(lay);
1232        // Expected realized pnl = 25
1233        assert_eq!(position.realized_pnl, dec!(25.0));
1234    }
1235
1236    #[rstest]
1237    fn test_realized_pnl_single_side() {
1238        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1239        let mut position = BetPosition::default();
1240        position.add_bet(back);
1241        // No opposing bet → pnl remains 0
1242        assert_eq!(position.realized_pnl, dec!(0.0));
1243    }
1244
1245    #[rstest]
1246    fn test_realized_pnl_open_position() {
1247        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
1248        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
1249        let mut position = BetPosition::default();
1250        position.add_bet(back);
1251        position.add_bet(lay);
1252        // Expected realized pnl = 20
1253        assert_eq!(position.realized_pnl, dec!(20.0));
1254    }
1255
1256    #[rstest]
1257    fn test_realized_pnl_partial_close() {
1258        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
1259        let lay = Bet::new(dec!(4.0), dec!(110.0), BetSide::Lay); // exposure -440
1260        let mut position = BetPosition::default();
1261        position.add_bet(back);
1262        position.add_bet(lay);
1263        // Expected realized pnl = 22
1264        assert_eq!(position.realized_pnl, dec!(22.0));
1265    }
1266
1267    #[rstest]
1268    fn test_realized_pnl_flipping() {
1269        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
1270        let lay = Bet::new(dec!(4.0), dec!(130.0), BetSide::Lay); // exposure -520
1271        let mut position = BetPosition::default();
1272        position.add_bet(back);
1273        position.add_bet(lay);
1274        // Expected realized pnl = 10
1275        assert_eq!(position.realized_pnl, dec!(10.0));
1276    }
1277
1278    #[rstest]
1279    fn test_unrealized_pnl_positive() {
1280        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
1281        let mut position = BetPosition::default();
1282        position.add_bet(back);
1283        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1284        // Expected unrealized pnl = 25
1285        assert_eq!(unrealized_pnl, dec!(25.0));
1286    }
1287
1288    #[rstest]
1289    fn test_total_pnl_with_pnl() {
1290        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
1291        let lay = Bet::new(dec!(4.0), dec!(120.0), BetSide::Lay); // exposure -480
1292        let mut position = BetPosition::default();
1293        position.add_bet(back);
1294        position.add_bet(lay);
1295        // After processing, realized pnl should be 24 and unrealized pnl 1.0
1296        let realized_pnl = position.realized_pnl;
1297        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1298        let total_pnl = position.total_pnl(dec!(4.0));
1299        assert_eq!(realized_pnl, dec!(24.0));
1300        assert_eq!(unrealized_pnl, dec!(1.0));
1301        assert_eq!(total_pnl, dec!(25.0));
1302    }
1303
1304    #[rstest]
1305    fn test_open_position_realized_unrealized() {
1306        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back); // exposure +500
1307        let lay = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay); // exposure -400
1308        let mut position = BetPosition::default();
1309        position.add_bet(back);
1310        position.add_bet(lay);
1311        let unrealized_pnl = position.unrealized_pnl(dec!(4.0));
1312        // Expected unrealized pnl = 5
1313        assert_eq!(unrealized_pnl, dec!(5.0));
1314    }
1315
1316    #[rstest]
1317    fn test_unrealized_no_position() {
1318        let back = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
1319        let mut position = BetPosition::default();
1320        position.add_bet(back);
1321        let unrealized_pnl = position.unrealized_pnl(dec!(5.0));
1322        assert_eq!(unrealized_pnl, dec!(0.0));
1323    }
1324
1325    #[rstest]
1326    fn test_calc_bets_pnl_single_back_bet() {
1327        let bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1328        let pnl = calc_bets_pnl(&[bet]);
1329        assert_eq!(pnl, dec!(400.0));
1330    }
1331
1332    #[rstest]
1333    fn test_calc_bets_pnl_single_lay_bet() {
1334        let bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
1335        let pnl = calc_bets_pnl(&[bet]);
1336        assert_eq!(pnl, dec!(-300.0));
1337    }
1338
1339    #[rstest]
1340    fn test_calc_bets_pnl_multiple_bets() {
1341        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1342        let lay_bet = Bet::new(dec!(4.0), dec!(100.0), BetSide::Lay);
1343        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
1344        let expected = dec!(400.0) + dec!(-300.0);
1345        assert_eq!(pnl, expected);
1346    }
1347
1348    #[rstest]
1349    fn test_calc_bets_pnl_mixed_bets() {
1350        let back_bet1 = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1351        let back_bet2 = Bet::new(dec!(2.0), dec!(50.0), BetSide::Back);
1352        let lay_bet1 = Bet::new(dec!(3.0), dec!(75.0), BetSide::Lay);
1353        let pnl = calc_bets_pnl(&[back_bet1, back_bet2, lay_bet1]);
1354        let expected = dec!(400.0) + dec!(50.0) + dec!(-150.0);
1355        assert_eq!(pnl, expected);
1356    }
1357
1358    #[rstest]
1359    fn test_calc_bets_pnl_no_bets() {
1360        let bets: Vec<Bet> = vec![];
1361        let pnl = calc_bets_pnl(&bets);
1362        assert_eq!(pnl, dec!(0.0));
1363    }
1364
1365    #[rstest]
1366    fn test_calc_bets_pnl_zero_outcome() {
1367        let back_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Back);
1368        let lay_bet = Bet::new(dec!(5.0), dec!(100.0), BetSide::Lay);
1369        let pnl = calc_bets_pnl(&[back_bet, lay_bet]);
1370        assert_eq!(pnl, dec!(0.0));
1371    }
1372
1373    #[rstest]
1374    fn test_probability_to_bet_back_simple() {
1375        // Using OrderSide in place of ProbSide.
1376        let bet = probability_to_bet(dec!(0.50), dec!(50.0), OrderSide::Buy).unwrap();
1377        let expected = Bet::new(dec!(2.0), dec!(25.0), BetSide::Back);
1378        assert_eq!(bet, expected);
1379        assert_eq!(bet.outcome_win_payoff(), dec!(25.0));
1380        assert_eq!(bet.outcome_lose_payoff(), dec!(-25.0));
1381    }
1382
1383    #[rstest]
1384    fn test_probability_to_bet_back_high_prob() {
1385        let bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Buy).unwrap();
1386        let expected = Bet::new(dec!(1.5625), dec!(32.0), BetSide::Back);
1387        assert_eq!(bet, expected);
1388        assert_eq!(bet.outcome_win_payoff(), dec!(18.0));
1389        assert_eq!(bet.outcome_lose_payoff(), dec!(-32.0));
1390    }
1391
1392    #[rstest]
1393    fn test_probability_to_bet_back_low_prob() {
1394        let bet = probability_to_bet(dec!(0.40), dec!(50.0), OrderSide::Buy).unwrap();
1395        let expected = Bet::new(dec!(2.5), dec!(20.0), BetSide::Back);
1396        assert_eq!(bet, expected);
1397        assert_eq!(bet.outcome_win_payoff(), dec!(30.0));
1398        assert_eq!(bet.outcome_lose_payoff(), dec!(-20.0));
1399    }
1400
1401    #[rstest]
1402    fn test_probability_to_bet_sell() {
1403        let bet = probability_to_bet(dec!(0.80), dec!(50.0), OrderSide::Sell).unwrap();
1404        let expected = Bet::new(dec_str("1.25"), dec_str("40"), BetSide::Lay);
1405        assert_eq!(bet, expected);
1406        assert_eq!(bet.outcome_win_payoff(), dec_str("-10"));
1407        assert_eq!(bet.outcome_lose_payoff(), dec_str("40"));
1408    }
1409
1410    #[rstest]
1411    fn test_inverse_probability_to_bet() {
1412        // Original bet with SELL side
1413        let original_bet = probability_to_bet(dec!(0.80), dec!(100.0), OrderSide::Sell).unwrap();
1414        // Equivalent reverse bet by buying the inverse probability
1415        let reverse_bet = probability_to_bet(dec!(0.20), dec!(100.0), OrderSide::Buy).unwrap();
1416        let inverse_bet =
1417            inverse_probability_to_bet(dec!(0.80), dec!(100.0), OrderSide::Sell).unwrap();
1418
1419        assert_eq!(
1420            original_bet.outcome_win_payoff(),
1421            reverse_bet.outcome_lose_payoff(),
1422        );
1423        assert_eq!(
1424            original_bet.outcome_win_payoff(),
1425            inverse_bet.outcome_lose_payoff(),
1426        );
1427        assert_eq!(
1428            original_bet.outcome_lose_payoff(),
1429            reverse_bet.outcome_win_payoff(),
1430        );
1431        assert_eq!(
1432            original_bet.outcome_lose_payoff(),
1433            inverse_bet.outcome_win_payoff(),
1434        );
1435    }
1436
1437    #[rstest]
1438    fn test_inverse_probability_to_bet_example2() {
1439        let original_bet = probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Sell).unwrap();
1440        let inverse_bet =
1441            inverse_probability_to_bet(dec!(0.64), dec!(50.0), OrderSide::Sell).unwrap();
1442
1443        assert_eq!(original_bet.stake, dec!(32.0));
1444        assert_eq!(original_bet.outcome_win_payoff(), dec!(-18.0));
1445        assert_eq!(original_bet.outcome_lose_payoff(), dec!(32.0));
1446
1447        assert_eq!(inverse_bet.stake, dec!(18.0));
1448        assert_eq!(inverse_bet.outcome_win_payoff(), dec!(32.0));
1449        assert_eq!(inverse_bet.outcome_lose_payoff(), dec!(-18.0));
1450    }
1451
1452    #[rstest]
1453    fn test_from_liability_checked_rejects_back_side() {
1454        let err = Bet::from_liability_checked(dec!(2.0), dec!(100.0), BetSide::Back).unwrap_err();
1455        assert_eq!(
1456            err.to_string(),
1457            "Liability-based betting is only applicable for Lay side."
1458        );
1459    }
1460
1461    #[rstest]
1462    #[case(dec!(1.0))]
1463    #[case(dec!(0.0))]
1464    #[case(dec!(-1.0))]
1465    fn test_from_liability_checked_rejects_odds_at_or_below_one(#[case] price: Decimal) {
1466        let err = Bet::from_liability_checked(price, dec!(100.0), BetSide::Lay).unwrap_err();
1467        assert_eq!(
1468            err.to_string(),
1469            format!("Price must be greater than 1.0 for lay liability calculation, was {price}")
1470        );
1471    }
1472
1473    #[rstest]
1474    fn test_from_stake_or_liability_checked_rejects_lay_odds_at_one() {
1475        let err =
1476            Bet::from_stake_or_liability_checked(dec!(1.0), dec!(100.0), BetSide::Lay).unwrap_err();
1477        assert_eq!(
1478            err.to_string(),
1479            "Price must be greater than 1.0 for lay liability calculation, was 1.0"
1480        );
1481    }
1482
1483    #[rstest]
1484    fn test_from_stake_or_liability_checked_allows_back_odds_at_one() {
1485        let bet =
1486            Bet::from_stake_or_liability_checked(dec!(1.0), dec!(10.0), BetSide::Back).unwrap();
1487        assert_eq!(bet.price(), dec!(1.0));
1488        assert_eq!(bet.stake(), dec!(10.0));
1489        assert_eq!(bet.side(), BetSide::Back);
1490        assert_eq!(bet.exposure_checked().unwrap(), dec!(10.0));
1491        assert_eq!(bet.profit_checked().unwrap(), dec!(0.0));
1492    }
1493
1494    #[rstest]
1495    fn test_from_liability_checked_preserves_stake_identity() {
1496        let bet = Bet::from_liability_checked(dec!(2.5), dec!(15.0), BetSide::Lay).unwrap();
1497        assert_eq!(bet.stake(), dec!(10.0));
1498        assert_eq!(bet.liability_checked().unwrap(), dec!(15.0));
1499    }
1500
1501    #[rstest]
1502    fn test_hedging_stake_checked_rejects_zero_price() {
1503        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Back);
1504        let err = bet.hedging_stake_checked(Decimal::ZERO).unwrap_err();
1505        assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
1506    }
1507
1508    #[rstest]
1509    fn test_hedging_bet_checked_rejects_zero_price() {
1510        let bet = Bet::new(dec!(2.0), dec!(100.0), BetSide::Lay);
1511        let err = bet.hedging_bet_checked(Decimal::ZERO).unwrap_err();
1512        assert_eq!(err.to_string(), "invalid divisor: must be non-zero");
1513    }
1514
1515    #[rstest]
1516    fn test_exposure_checked_rejects_overflow() {
1517        let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back);
1518        let err = bet.exposure_checked().unwrap_err();
1519        assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1520    }
1521
1522    #[rstest]
1523    fn test_liability_checked_rejects_overflow() {
1524        let bet = Bet::new(Decimal::MAX, dec!(2.0), BetSide::Lay);
1525        let err = bet.liability_checked().unwrap_err();
1526        assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1527    }
1528
1529    #[rstest]
1530    fn test_flattening_bet_checked_rejects_zero_price() {
1531        let mut position = BetPosition::default();
1532        position.add_bet(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back));
1533        let err = position.flattening_bet_checked(Decimal::ZERO).unwrap_err();
1534        assert_eq!(err.to_string(), "invalid price: must be non-zero");
1535    }
1536
1537    #[rstest]
1538    fn test_add_bet_checked_matches_infallible_decrease() {
1539        let back = Bet::new(dec!(3.0), dec!(100_000), BetSide::Back);
1540        let lay = Bet::new(dec!(2.0), dec!(10_000), BetSide::Lay);
1541        let mut expected = BetPosition::default();
1542        expected.add_bet(back.clone());
1543        expected.add_bet(lay.clone());
1544
1545        let mut position = BetPosition::default();
1546        position.add_bet_checked(back).unwrap();
1547        position.add_bet_checked(lay).unwrap();
1548
1549        assert_eq!(position.price(), expected.price());
1550        assert_eq!(position.exposure(), expected.exposure());
1551        assert_eq!(position.realized_pnl(), expected.realized_pnl());
1552        assert_eq!(position.bets(), expected.bets());
1553    }
1554
1555    #[rstest]
1556    fn test_add_bet_checked_rejects_overflow_and_leaves_position_unchanged() {
1557        let mut position = BetPosition::default();
1558        position
1559            .add_bet_checked(Bet::new(dec!(2.0), dec!(100.0), BetSide::Back))
1560            .unwrap();
1561        let before_price = position.price();
1562        let before_exposure = position.exposure();
1563        let before_len = position.bets().len();
1564
1565        let err = position
1566            .add_bet_checked(Bet::new(Decimal::MAX, dec!(2.0), BetSide::Back))
1567            .unwrap_err();
1568
1569        assert!(err.to_string().starts_with("Decimal overflow multiplying"));
1570        assert_eq!(position.price(), before_price);
1571        assert_eq!(position.exposure(), before_exposure);
1572        assert_eq!(position.bets().len(), before_len);
1573    }
1574
1575    #[rstest]
1576    fn test_checked_methods_preserve_valid_identities() {
1577        let back = Bet::new(dec!(2.5), dec!(10.0), BetSide::Back);
1578        let hedge = back.hedging_bet_checked(dec!(1.5)).unwrap();
1579
1580        assert_eq!(back.exposure_checked().unwrap(), back.exposure());
1581        assert_eq!(back.liability_checked().unwrap(), back.liability());
1582        assert_eq!(back.profit_checked().unwrap(), back.profit());
1583        assert_eq!(
1584            back.outcome_win_payoff_checked().unwrap(),
1585            back.outcome_win_payoff()
1586        );
1587        assert_eq!(
1588            back.outcome_lose_payoff_checked().unwrap(),
1589            back.outcome_lose_payoff()
1590        );
1591        assert_eq!(hedge, back.hedging_bet(dec!(1.5)));
1592        assert_eq!(
1593            calc_bets_pnl_checked(&[back.clone(), hedge.clone()]).unwrap(),
1594            calc_bets_pnl(&[back, hedge])
1595        );
1596    }
1597}