Skip to main content

optionstratlib_core/model/leg/
perpetual.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 24/12/25
5******************************************************************************/
6
7//! # Perpetual Position Module
8//!
9//! This module provides the `PerpetualPosition` struct for representing perpetual
10//! swap contracts, commonly used in cryptocurrency markets.
11//!
12//! ## Characteristics
13//!
14//! - No expiration date (perpetual)
15//! - Margin-based (leveraged)
16//! - Funding rate mechanism to anchor price to spot
17//! - Funding payments typically every 8 hours
18//! - Liquidation risk based on margin ratio
19//!
20//! ## Example
21//!
22//! ```rust
23//! use optionstratlib_core::model::leg::{PerpetualPosition, MarginType};
24//! use optionstratlib_core::model::types::Side;
25//! use positive::{pos_or_panic,Positive};
26//! use chrono::Utc;
27//! use rust_decimal_macros::dec;
28//!
29//! let perp = PerpetualPosition::new(
30//!     "BTC-USDT-PERP".to_string(),
31//!     Positive::ONE,           // 1 BTC position size
32//!     pos_or_panic!(50000.0),       // entry price
33//!     Side::Long,
34//!     pos_or_panic!(10.0),          // 10x leverage
35//!     pos_or_panic!(5000.0),        // margin posted
36//!     MarginType::Isolated,
37//!     dec!(0.0001),        // 0.01% funding rate
38//!     Utc::now(),
39//!     pos_or_panic!(5.0),           // $5 fees
40//! );
41//! ```
42
43use crate::error::PositionError;
44use crate::model::decimal::{d_add, d_div, d_mul, d_sub};
45use crate::model::leg::traits::{Fundable, LegAble, Marginable, liquidation_threshold};
46use crate::model::types::Side;
47use chrono::{DateTime, Utc};
48use positive::Positive;
49use rust_decimal::Decimal;
50use serde::{Deserialize, Serialize};
51
52/// Margin type for perpetual positions.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
55#[serde(rename_all = "snake_case")]
56#[repr(u8)]
57pub enum MarginType {
58    /// Cross margin - shares margin across all positions
59    Cross,
60    /// Isolated margin - margin is specific to this position
61    #[default]
62    Isolated,
63}
64
65impl std::fmt::Display for MarginType {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::Cross => write!(f, "Cross"),
69            Self::Isolated => write!(f, "Isolated"),
70        }
71    }
72}
73
74/// Represents a perpetual swap contract position.
75///
76/// Perpetual swaps are derivative contracts common in cryptocurrency markets
77/// that allow traders to speculate on price movements with leverage, without
78/// an expiration date. They use a funding rate mechanism to keep the contract
79/// price anchored to the spot price.
80///
81/// # Fields
82///
83/// * `symbol` - Trading pair (e.g., "BTC-USDT-PERP")
84/// * `quantity` - Position size in base currency
85/// * `entry_price` - Average entry price
86/// * `side` - Long or Short position
87/// * `leverage` - Applied leverage (1x to 125x typical)
88/// * `margin` - Collateral posted for this position
89/// * `margin_type` - Cross or Isolated margin mode
90/// * `funding_rate` - Current funding rate (updated periodically)
91/// * `date` - Position open timestamp
92/// * `fees` - Trading fees (maker/taker)
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
95pub struct PerpetualPosition {
96    /// Trading pair symbol (e.g., "BTC-USDT-PERP").
97    pub symbol: String,
98
99    /// Position size in base currency units.
100    pub quantity: Positive,
101
102    /// Average entry price for the position.
103    pub entry_price: Positive,
104
105    /// Position direction (Long or Short).
106    pub side: Side,
107
108    /// Leverage multiplier applied to the position.
109    pub leverage: Positive,
110
111    /// Margin/collateral posted for this position.
112    pub margin: Positive,
113
114    /// Margin mode (Cross or Isolated).
115    pub margin_type: MarginType,
116
117    /// Current funding rate as a decimal (e.g., 0.0001 = 0.01%).
118    pub funding_rate: Decimal,
119
120    /// Timestamp when the position was opened.
121    pub date: DateTime<Utc>,
122
123    /// Total trading fees paid.
124    pub fees: Positive,
125}
126
127impl PerpetualPosition {
128    /// Default funding interval in hours (8 hours for most exchanges).
129    pub const DEFAULT_FUNDING_INTERVAL_HOURS: u32 = 8;
130
131    /// Default maintenance margin ratio (typically 0.5% to 1%).
132    pub const DEFAULT_MAINTENANCE_MARGIN_RATIO: Decimal = Decimal::ONE_HUNDRED;
133
134    /// Creates a new perpetual position.
135    ///
136    /// # Arguments
137    ///
138    /// * `symbol` - Trading pair symbol
139    /// * `quantity` - Position size in base currency
140    /// * `entry_price` - Average entry price
141    /// * `side` - Long or Short
142    /// * `leverage` - Leverage multiplier
143    /// * `margin` - Collateral posted
144    /// * `margin_type` - Cross or Isolated
145    /// * `funding_rate` - Current funding rate
146    /// * `date` - Position open timestamp
147    /// * `fees` - Trading fees
148    #[must_use]
149    #[allow(clippy::too_many_arguments)]
150    pub fn new(
151        symbol: String,
152        quantity: Positive,
153        entry_price: Positive,
154        side: Side,
155        leverage: Positive,
156        margin: Positive,
157        margin_type: MarginType,
158        funding_rate: Decimal,
159        date: DateTime<Utc>,
160        fees: Positive,
161    ) -> Self {
162        Self {
163            symbol,
164            quantity,
165            entry_price,
166            side,
167            leverage,
168            margin,
169            margin_type,
170            funding_rate,
171            date,
172            fees,
173        }
174    }
175
176    /// Creates a long perpetual position with default settings.
177    ///
178    /// # Arguments
179    ///
180    /// * `symbol` - Trading pair symbol
181    /// * `quantity` - Position size
182    /// * `entry_price` - Entry price
183    /// * `leverage` - Leverage multiplier
184    /// * `margin` - Collateral posted
185    #[must_use]
186    pub fn long(
187        symbol: String,
188        quantity: Positive,
189        entry_price: Positive,
190        leverage: Positive,
191        margin: Positive,
192    ) -> Self {
193        Self::new(
194            symbol,
195            quantity,
196            entry_price,
197            Side::Long,
198            leverage,
199            margin,
200            MarginType::Isolated,
201            Decimal::ZERO,
202            Utc::now(),
203            Positive::ZERO,
204        )
205    }
206
207    /// Creates a short perpetual position with default settings.
208    ///
209    /// # Arguments
210    ///
211    /// * `symbol` - Trading pair symbol
212    /// * `quantity` - Position size
213    /// * `entry_price` - Entry price
214    /// * `leverage` - Leverage multiplier
215    /// * `margin` - Collateral posted
216    #[must_use]
217    pub fn short(
218        symbol: String,
219        quantity: Positive,
220        entry_price: Positive,
221        leverage: Positive,
222        margin: Positive,
223    ) -> Self {
224        Self::new(
225            symbol,
226            quantity,
227            entry_price,
228            Side::Short,
229            leverage,
230            margin,
231            MarginType::Isolated,
232            Decimal::ZERO,
233            Utc::now(),
234            Positive::ZERO,
235        )
236    }
237
238    /// Returns the notional value of the position.
239    ///
240    /// Notional = quantity × entry_price
241    ///
242    /// # Errors
243    ///
244    /// Returns [`PositionError::PositiveError`] when the product leaves the
245    /// `Positive` range; `Positive * Positive` aborted on it (#788).
246    pub fn notional_value_at_entry(&self) -> Result<Positive, PositionError> {
247        Ok(self.quantity.checked_mul(&self.entry_price)?)
248    }
249
250    /// Returns the current notional value at a given price.
251    ///
252    /// # Arguments
253    ///
254    /// * `current_price` - Current market price
255    ///
256    /// # Errors
257    ///
258    /// Returns [`PositionError::PositiveError`] when the product leaves the
259    /// `Positive` range (#788).
260    pub fn notional_value_at_price(
261        &self,
262        current_price: Positive,
263    ) -> Result<Positive, PositionError> {
264        Ok(self.quantity.checked_mul(&current_price)?)
265    }
266
267    /// Calculates the unrealized P&L at a given price.
268    ///
269    /// # Arguments
270    ///
271    /// * `current_price` - Current market price
272    ///
273    /// # Decision (issue #471): fallible because `pnl_at_price` is
274    ///
275    /// This is the whole body of [`LegAble::pnl_at_price`] for a perpetual,
276    /// so leaving it infallible would have moved that method's abort one
277    /// frame down rather than removing it. `entry_price` and `quantity` are
278    /// `pub`, so no constructor guard bounds the product.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`PositionError::DecimalError`] when the price difference or the
283    /// quantity scaling leaves the representable `Decimal` range.
284    pub fn unrealized_pnl(&self, current_price: Positive) -> Result<Decimal, PositionError> {
285        let price_change = d_sub(
286            current_price.to_dec(),
287            self.entry_price.to_dec(),
288            "PerpetualPosition::unrealized_pnl/price_change",
289        )?;
290        let pnl = d_mul(
291            price_change,
292            self.quantity.to_dec(),
293            "PerpetualPosition::unrealized_pnl/quantity",
294        )?;
295
296        Ok(match self.side {
297            Side::Long => pnl,
298            // Negating a representable `Decimal` is always representable.
299            Side::Short => -pnl,
300        })
301    }
302
303    /// Calculates the ROE (Return on Equity) percentage.
304    ///
305    /// ROE = (Unrealized P&L / Margin) × 100
306    ///
307    /// A zero margin has no return on equity to report, and the function
308    /// keeps its existing answer of `Decimal::ZERO` for that case rather than
309    /// turning it into an error.
310    ///
311    /// # Arguments
312    ///
313    /// * `current_price` - Current market price
314    ///
315    /// # Errors
316    ///
317    /// Propagates [`PerpetualPosition::unrealized_pnl`], and returns
318    /// [`PositionError::DecimalError`] when the ratio or its percentage scaling
319    /// leaves the representable `Decimal` range.
320    pub fn roe_percentage(&self, current_price: Positive) -> Result<Decimal, PositionError> {
321        if self.margin == Positive::ZERO {
322            return Ok(Decimal::ZERO);
323        }
324
325        let pnl = self.unrealized_pnl(current_price)?;
326        let ratio = d_div(
327            pnl,
328            self.margin.to_dec(),
329            "PerpetualPosition::roe_percentage/ratio",
330        )?;
331        Ok(d_mul(
332            ratio,
333            Decimal::ONE_HUNDRED,
334            "PerpetualPosition::roe_percentage/percent",
335        )?)
336    }
337
338    /// Calculates the margin ratio at a given price.
339    ///
340    /// Margin Ratio = (Margin + Unrealized P&L) / Notional Value
341    ///
342    /// A zero notional has no margin ratio to report, and the function keeps
343    /// its existing answer of `Decimal::ZERO` for that case rather than
344    /// turning it into an error.
345    ///
346    /// # Arguments
347    ///
348    /// * `current_price` - Current market price
349    ///
350    /// # Errors
351    ///
352    /// Propagates [`PerpetualPosition::unrealized_pnl`], and returns
353    /// [`PositionError::DecimalError`] when the equity sum or the ratio leaves the
354    /// representable `Decimal` range.
355    pub fn margin_ratio(&self, current_price: Positive) -> Result<Decimal, PositionError> {
356        // Formed here rather than read from `notional_value_at_price`, whose
357        // raw product aborts for a quantity and price whose product leaves
358        // the `Positive` range — before this `Result` could report it.
359        let notional = self.quantity.checked_mul(&current_price)?;
360        if notional == Positive::ZERO {
361            return Ok(Decimal::ZERO);
362        }
363
364        let equity = d_add(
365            self.margin.to_dec(),
366            self.unrealized_pnl(current_price)?,
367            "PerpetualPosition::margin_ratio/equity",
368        )?;
369        Ok(d_div(
370            equity,
371            notional.to_dec(),
372            "PerpetualPosition::margin_ratio/ratio",
373        )?)
374    }
375
376    /// Updates the funding rate.
377    ///
378    /// # Arguments
379    ///
380    /// * `new_rate` - New funding rate
381    pub fn update_funding_rate(&mut self, new_rate: Decimal) {
382        self.funding_rate = new_rate;
383    }
384
385    /// Calculates the effective leverage at a given price.
386    ///
387    /// Effective Leverage = Notional Value / (Margin + Unrealized P&L)
388    ///
389    /// A position whose equity has been wiped out is infinitely levered, and
390    /// the function keeps its existing answer of `Decimal::MAX` for that case
391    /// rather than turning it into an error.
392    ///
393    /// # Arguments
394    ///
395    /// * `current_price` - Current market price
396    ///
397    /// # Errors
398    ///
399    /// Propagates [`PerpetualPosition::unrealized_pnl`], and returns
400    /// [`PositionError::DecimalError`] when the equity sum or the quotient leaves
401    /// the representable `Decimal` range.
402    pub fn effective_leverage(&self, current_price: Positive) -> Result<Decimal, PositionError> {
403        let equity = d_add(
404            self.margin.to_dec(),
405            self.unrealized_pnl(current_price)?,
406            "PerpetualPosition::effective_leverage/equity",
407        )?;
408        if equity <= Decimal::ZERO {
409            return Ok(Decimal::MAX);
410        }
411
412        let notional = self.quantity.checked_mul(&current_price)?;
413        Ok(d_div(
414            notional.to_dec(),
415            equity,
416            "PerpetualPosition::effective_leverage/leverage",
417        )?)
418    }
419}
420
421impl LegAble for PerpetualPosition {
422    fn get_symbol(&self) -> &str {
423        &self.symbol
424    }
425
426    fn get_quantity(&self) -> Positive {
427        self.quantity
428    }
429
430    fn get_side(&self) -> Side {
431        self.side
432    }
433
434    fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
435        Ok(d_sub(
436            self.unrealized_pnl(price)?,
437            self.fees.to_dec(),
438            "PerpetualPosition::pnl_at_price/net",
439        )?)
440    }
441
442    fn total_cost(&self) -> Result<Positive, PositionError> {
443        Ok(self.margin.checked_add(&self.fees)?)
444    }
445
446    fn fees(&self) -> Result<Positive, PositionError> {
447        Ok(self.fees)
448    }
449}
450
451impl Marginable for PerpetualPosition {
452    fn initial_margin(&self) -> Result<Positive, PositionError> {
453        Ok(self.margin)
454    }
455
456    fn maintenance_margin(&self) -> Result<Positive, PositionError> {
457        let notional = self.notional_value_at_entry()?;
458        let maintenance_ratio = Decimal::new(5, 3);
459        let maintenance = d_mul(
460            notional.to_dec(),
461            maintenance_ratio,
462            "PerpetualPosition::maintenance_margin",
463        )?;
464        Ok(Positive::new_decimal(maintenance).unwrap_or(Positive::ZERO))
465    }
466
467    fn leverage(&self) -> Result<Positive, PositionError> {
468        Ok(self.leverage)
469    }
470
471    /// `None` for a long whose margin buffer per unit exceeds the entry
472    /// price: no non-negative price liquidates it. A short threshold below
473    /// zero is floored at zero, the documented domain floor of
474    /// [`Marginable::liquidation_price`].
475    ///
476    /// # Errors
477    ///
478    /// Returns [`PositionError::DecimalError`] for a zero quantity, where the
479    /// buffer per unit is undefined (`PerpetualPosition::default()` aborted
480    /// here with `Division by zero`, #788), and when the maintenance margin,
481    /// the buffer or the shifted price leaves the representable range.
482    fn liquidation_price(
483        &self,
484        _current_price: Positive,
485    ) -> Result<Option<Positive>, PositionError> {
486        let maintenance = self.maintenance_margin()?;
487        let margin_buffer = d_sub(
488            self.margin.to_dec(),
489            maintenance.to_dec(),
490            "PerpetualPosition::liquidation_price/margin_buffer",
491        )?;
492        let price_buffer = d_div(
493            margin_buffer,
494            self.quantity.to_dec(),
495            "PerpetualPosition::liquidation_price/price_buffer",
496        )?;
497
498        let threshold = match self.side {
499            Side::Long => d_sub(
500                self.entry_price.to_dec(),
501                price_buffer,
502                "PerpetualPosition::liquidation_price/long",
503            )?,
504            Side::Short => d_add(
505                self.entry_price.to_dec(),
506                price_buffer,
507                "PerpetualPosition::liquidation_price/short",
508            )?,
509        };
510        Ok(liquidation_threshold(threshold, self.side))
511    }
512
513    fn is_liquidation_risk(
514        &self,
515        current_price: Positive,
516        _margin_ratio: Decimal,
517    ) -> Result<bool, PositionError> {
518        // No reachable liquidation price means no price is at risk.
519        let Some(liq_price) = self.liquidation_price(current_price)? else {
520            return Ok(false);
521        };
522
523        Ok(match self.side {
524            Side::Long => current_price <= liq_price,
525            Side::Short => current_price >= liq_price,
526        })
527    }
528}
529
530impl Fundable for PerpetualPosition {
531    fn funding_rate(&self) -> Decimal {
532        self.funding_rate
533    }
534
535    fn funding_interval_hours(&self) -> u32 {
536        Self::DEFAULT_FUNDING_INTERVAL_HOURS
537    }
538
539    fn funding_payment(&self, mark_price: Positive) -> Result<Decimal, PositionError> {
540        let notional = d_mul(
541            self.quantity.to_dec(),
542            mark_price.to_dec(),
543            "PerpetualPosition::funding_payment/notional",
544        )?;
545        let payment = d_mul(
546            notional,
547            self.funding_rate,
548            "PerpetualPosition::funding_payment/payment",
549        )?;
550
551        Ok(match self.side {
552            Side::Long => payment,
553            Side::Short => -payment,
554        })
555    }
556}
557
558impl std::fmt::Display for PerpetualPosition {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        write!(
561            f,
562            "{} {} {} @ {} ({}x {}, margin: {})",
563            self.side,
564            self.quantity,
565            self.symbol,
566            self.entry_price,
567            self.leverage,
568            self.margin_type,
569            self.margin
570        )
571    }
572}
573
574impl Default for PerpetualPosition {
575    fn default() -> Self {
576        Self {
577            symbol: String::new(),
578            quantity: Positive::ZERO,
579            entry_price: Positive::ZERO,
580            side: Side::Long,
581            leverage: positive::Positive::ONE,
582            margin: Positive::ZERO,
583            margin_type: MarginType::default(),
584            funding_rate: Decimal::ZERO,
585            date: Utc::now(),
586            fees: Positive::ZERO,
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use positive::pos_or_panic;
595
596    use rust_decimal_macros::dec;
597
598    /// Both ratios divide by a notional whose product leaves the `Positive`
599    /// range for a large quantity at a large price. Both signatures promise a
600    /// `Result`, so both have to report it rather than abort inside the raw
601    /// multiplication `notional_value_at_price` performs.
602    #[test]
603    fn test_perpetual_ratios_report_a_notional_that_overflows() {
604        let perp = PerpetualPosition::new(
605            "BTC-USDT-PERP".to_string(),
606            Positive::MAX,
607            pos_or_panic!(50000.0),
608            Side::Long,
609            pos_or_panic!(10.0),
610            pos_or_panic!(5000.0),
611            MarginType::Isolated,
612            dec!(0.0001),
613            Utc::now(),
614            pos_or_panic!(5.0),
615        );
616
617        assert!(perp.margin_ratio(pos_or_panic!(50000.0)).is_err());
618        assert!(perp.effective_leverage(pos_or_panic!(50000.0)).is_err());
619    }
620
621    #[test]
622    fn test_perpetual_position_new() {
623        let perp = PerpetualPosition::new(
624            "BTC-USDT-PERP".to_string(),
625            Positive::ONE,
626            pos_or_panic!(50000.0),
627            Side::Long,
628            pos_or_panic!(10.0),
629            pos_or_panic!(5000.0),
630            MarginType::Isolated,
631            dec!(0.0001),
632            Utc::now(),
633            pos_or_panic!(5.0),
634        );
635
636        assert_eq!(perp.symbol, "BTC-USDT-PERP");
637        assert_eq!(perp.quantity, Positive::ONE);
638        assert_eq!(perp.entry_price, pos_or_panic!(50000.0));
639        assert_eq!(perp.side, Side::Long);
640        assert_eq!(perp.leverage, pos_or_panic!(10.0));
641        assert_eq!(perp.margin, pos_or_panic!(5000.0));
642        assert_eq!(perp.margin_type, MarginType::Isolated);
643        assert_eq!(perp.funding_rate, dec!(0.0001));
644    }
645
646    #[test]
647    fn test_perpetual_long_convenience() {
648        let perp = PerpetualPosition::long(
649            "BTC-USDT-PERP".to_string(),
650            Positive::ONE,
651            pos_or_panic!(50000.0),
652            pos_or_panic!(10.0),
653            pos_or_panic!(5000.0),
654        );
655
656        assert_eq!(perp.side, Side::Long);
657        assert_eq!(perp.margin_type, MarginType::Isolated);
658    }
659
660    #[test]
661    fn test_perpetual_short_convenience() {
662        let perp = PerpetualPosition::short(
663            "BTC-USDT-PERP".to_string(),
664            Positive::ONE,
665            pos_or_panic!(50000.0),
666            pos_or_panic!(10.0),
667            pos_or_panic!(5000.0),
668        );
669
670        assert_eq!(perp.side, Side::Short);
671    }
672
673    #[test]
674    fn test_notional_value() -> Result<(), Box<dyn std::error::Error>> {
675        let perp = PerpetualPosition::long(
676            "BTC-USDT-PERP".to_string(),
677            Positive::TWO,
678            pos_or_panic!(50000.0),
679            pos_or_panic!(10.0),
680            pos_or_panic!(10000.0),
681        );
682
683        assert_eq!(perp.notional_value_at_entry()?, pos_or_panic!(100000.0));
684        assert_eq!(
685            perp.notional_value_at_price(pos_or_panic!(55000.0))?,
686            pos_or_panic!(110000.0)
687        );
688        Ok(())
689    }
690
691    #[test]
692    fn test_unrealized_pnl_long() {
693        let perp = PerpetualPosition::long(
694            "BTC-USDT-PERP".to_string(),
695            Positive::ONE,
696            pos_or_panic!(50000.0),
697            pos_or_panic!(10.0),
698            pos_or_panic!(5000.0),
699        );
700
701        assert_eq!(
702            perp.unrealized_pnl(pos_or_panic!(55000.0)).ok(),
703            Some(dec!(5000))
704        );
705        assert_eq!(
706            perp.unrealized_pnl(pos_or_panic!(45000.0)).ok(),
707            Some(dec!(-5000))
708        );
709    }
710
711    #[test]
712    fn test_unrealized_pnl_short() {
713        let perp = PerpetualPosition::short(
714            "BTC-USDT-PERP".to_string(),
715            Positive::ONE,
716            pos_or_panic!(50000.0),
717            pos_or_panic!(10.0),
718            pos_or_panic!(5000.0),
719        );
720
721        assert_eq!(
722            perp.unrealized_pnl(pos_or_panic!(45000.0)).ok(),
723            Some(dec!(5000))
724        );
725        assert_eq!(
726            perp.unrealized_pnl(pos_or_panic!(55000.0)).ok(),
727            Some(dec!(-5000))
728        );
729    }
730
731    #[test]
732    fn test_roe_percentage() {
733        let perp = PerpetualPosition::long(
734            "BTC-USDT-PERP".to_string(),
735            Positive::ONE,
736            pos_or_panic!(50000.0),
737            pos_or_panic!(10.0),
738            pos_or_panic!(5000.0),
739        );
740
741        let roe = perp.roe_percentage(pos_or_panic!(55000.0));
742        assert_eq!(roe.ok(), Some(dec!(100)));
743    }
744
745    #[test]
746    fn test_funding_payment_long() -> Result<(), Box<dyn std::error::Error>> {
747        let perp = PerpetualPosition::new(
748            "BTC-USDT-PERP".to_string(),
749            Positive::ONE,
750            pos_or_panic!(50000.0),
751            Side::Long,
752            pos_or_panic!(10.0),
753            pos_or_panic!(5000.0),
754            MarginType::Isolated,
755            dec!(0.0001),
756            Utc::now(),
757            Positive::ZERO,
758        );
759
760        let payment = perp.funding_payment(pos_or_panic!(50000.0))?;
761        assert_eq!(payment, dec!(5));
762        Ok(())
763    }
764
765    #[test]
766    fn test_funding_payment_short() -> Result<(), Box<dyn std::error::Error>> {
767        let perp = PerpetualPosition::new(
768            "BTC-USDT-PERP".to_string(),
769            Positive::ONE,
770            pos_or_panic!(50000.0),
771            Side::Short,
772            pos_or_panic!(10.0),
773            pos_or_panic!(5000.0),
774            MarginType::Isolated,
775            dec!(0.0001),
776            Utc::now(),
777            Positive::ZERO,
778        );
779
780        let payment = perp.funding_payment(pos_or_panic!(50000.0))?;
781        assert_eq!(payment, dec!(-5));
782        Ok(())
783    }
784
785    #[test]
786    fn test_liquidation_price_long() -> Result<(), Box<dyn std::error::Error>> {
787        let perp = PerpetualPosition::long(
788            "BTC-USDT-PERP".to_string(),
789            Positive::ONE,
790            pos_or_panic!(50000.0),
791            pos_or_panic!(10.0),
792            pos_or_panic!(5000.0),
793        );
794
795        // Maintenance 0.5% of 50000 = 250; (5000 - 250) / 1 = 4750 below entry.
796        let liq_price = perp.liquidation_price(pos_or_panic!(50000.0))?;
797        assert_eq!(liq_price, Some(pos_or_panic!(45250.0)));
798        assert!(perp.is_liquidation_risk(pos_or_panic!(45250.0), Decimal::ZERO)?);
799        assert!(!perp.is_liquidation_risk(pos_or_panic!(45251.0), Decimal::ZERO)?);
800        Ok(())
801    }
802
803    #[test]
804    fn test_liquidation_price_long_below_zero_is_unreachable()
805    -> Result<(), Box<dyn std::error::Error>> {
806        // A margin of 200 on a notional of 100 leaves 199.5 of buffer per
807        // unit under an entry of 100: no non-negative price liquidates it.
808        let perp = PerpetualPosition::long(
809            "BTC-USDT-PERP".to_string(),
810            Positive::ONE,
811            pos_or_panic!(100.0),
812            pos_or_panic!(0.5),
813            pos_or_panic!(200.0),
814        );
815
816        assert_eq!(perp.liquidation_price(pos_or_panic!(100.0))?, None);
817        assert!(!perp.is_liquidation_risk(Positive::ZERO, Decimal::ZERO)?);
818        Ok(())
819    }
820
821    #[test]
822    fn test_liquidation_price_short() -> Result<(), Box<dyn std::error::Error>> {
823        let perp = PerpetualPosition::short(
824            "BTC-USDT-PERP".to_string(),
825            Positive::ONE,
826            pos_or_panic!(50000.0),
827            pos_or_panic!(10.0),
828            pos_or_panic!(5000.0),
829        );
830
831        let liq_price = perp.liquidation_price(pos_or_panic!(50000.0))?;
832        assert_eq!(liq_price, Some(pos_or_panic!(54750.0)));
833        assert!(perp.is_liquidation_risk(pos_or_panic!(54750.0), Decimal::ZERO)?);
834        assert!(!perp.is_liquidation_risk(pos_or_panic!(54749.0), Decimal::ZERO)?);
835        Ok(())
836    }
837
838    #[test]
839    fn test_effective_leverage() {
840        let perp = PerpetualPosition::long(
841            "BTC-USDT-PERP".to_string(),
842            Positive::ONE,
843            pos_or_panic!(50000.0),
844            pos_or_panic!(10.0),
845            pos_or_panic!(5000.0),
846        );
847
848        let eff_lev = perp.effective_leverage(pos_or_panic!(50000.0));
849        assert_eq!(eff_lev.ok(), Some(dec!(10)));
850    }
851
852    #[test]
853    fn test_margin_type_display() {
854        assert_eq!(format!("{}", MarginType::Cross), "Cross");
855        assert_eq!(format!("{}", MarginType::Isolated), "Isolated");
856    }
857
858    #[test]
859    fn test_display() {
860        let perp = PerpetualPosition::long(
861            "BTC-USDT-PERP".to_string(),
862            Positive::ONE,
863            pos_or_panic!(50000.0),
864            pos_or_panic!(10.0),
865            pos_or_panic!(5000.0),
866        );
867
868        let display = format!("{}", perp);
869        assert!(display.contains("Long"));
870        assert!(display.contains("BTC-USDT-PERP"));
871        assert!(display.contains("10x"));
872    }
873
874    // The default position has a zero quantity; the per-unit buffer
875    // division aborted with `Division by zero` (#788).
876    #[test]
877    fn test_liquidation_price_default_is_error() {
878        let perp = PerpetualPosition::default();
879        assert!(matches!(
880            perp.liquidation_price(Positive::ONE),
881            Err(PositionError::DecimalError(_))
882        ));
883        assert!(
884            perp.is_liquidation_risk(Positive::ONE, Decimal::ZERO)
885                .is_err()
886        );
887    }
888
889    // `Positive * Positive` aborted with `Positive arithmetic overflow in
890    // mul`, and `Decimal * Decimal` with `Multiplication overflowed` (#788).
891    #[test]
892    fn test_notional_and_funding_overflow_is_error() {
893        let perp = PerpetualPosition {
894            quantity: Positive::MAX,
895            entry_price: Positive::TWO,
896            funding_rate: dec!(2),
897            ..PerpetualPosition::default()
898        };
899        assert!(perp.notional_value_at_entry().is_err());
900        assert!(perp.notional_value_at_price(Positive::TWO).is_err());
901        assert!(perp.maintenance_margin().is_err());
902        assert!(perp.funding_payment(Positive::TWO).is_err());
903        assert!(perp.annualized_funding(Positive::TWO).is_err());
904    }
905}