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