1use 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#[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,
60 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
95pub struct PerpetualPosition {
96 pub symbol: String,
98
99 pub quantity: Positive,
101
102 pub entry_price: Positive,
104
105 pub side: Side,
107
108 pub leverage: Positive,
110
111 pub margin: Positive,
113
114 pub margin_type: MarginType,
116
117 pub funding_rate: Decimal,
119
120 pub date: DateTime<Utc>,
122
123 pub fees: Positive,
125}
126
127impl PerpetualPosition {
128 pub const DEFAULT_FUNDING_INTERVAL_HOURS: u32 = 8;
130
131 pub const DEFAULT_MAINTENANCE_MARGIN_RATIO: Decimal = Decimal::ONE_HUNDRED;
133
134 #[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 #[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 #[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 pub fn notional_value_at_entry(&self) -> Result<Positive, PositionError> {
247 Ok(self.quantity.checked_mul(&self.entry_price)?)
248 }
249
250 pub fn notional_value_at_price(
261 &self,
262 current_price: Positive,
263 ) -> Result<Positive, PositionError> {
264 Ok(self.quantity.checked_mul(¤t_price)?)
265 }
266
267 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 Side::Short => -pnl,
300 })
301 }
302
303 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 pub fn margin_ratio(&self, current_price: Positive) -> Result<Decimal, PositionError> {
356 let notional = self.quantity.checked_mul(¤t_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 pub fn update_funding_rate(&mut self, new_rate: Decimal) {
382 self.funding_rate = new_rate;
383 }
384
385 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(¤t_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 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 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 #[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 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 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 #[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 #[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}