use crate::error::PositionError;
use crate::model::decimal::{d_add, d_div, d_mul, d_sub};
use crate::model::leg::traits::{Fundable, LegAble, Marginable, liquidation_threshold};
use crate::model::types::Side;
use chrono::{DateTime, Utc};
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
#[repr(u8)]
pub enum MarginType {
Cross,
#[default]
Isolated,
}
impl std::fmt::Display for MarginType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cross => write!(f, "Cross"),
Self::Isolated => write!(f, "Isolated"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct PerpetualPosition {
pub symbol: String,
pub quantity: Positive,
pub entry_price: Positive,
pub side: Side,
pub leverage: Positive,
pub margin: Positive,
pub margin_type: MarginType,
pub funding_rate: Decimal,
pub date: DateTime<Utc>,
pub fees: Positive,
}
impl PerpetualPosition {
pub const DEFAULT_FUNDING_INTERVAL_HOURS: u32 = 8;
pub const DEFAULT_MAINTENANCE_MARGIN_RATIO: Decimal = Decimal::ONE_HUNDRED;
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
symbol: String,
quantity: Positive,
entry_price: Positive,
side: Side,
leverage: Positive,
margin: Positive,
margin_type: MarginType,
funding_rate: Decimal,
date: DateTime<Utc>,
fees: Positive,
) -> Self {
Self {
symbol,
quantity,
entry_price,
side,
leverage,
margin,
margin_type,
funding_rate,
date,
fees,
}
}
#[must_use]
pub fn long(
symbol: String,
quantity: Positive,
entry_price: Positive,
leverage: Positive,
margin: Positive,
) -> Self {
Self::new(
symbol,
quantity,
entry_price,
Side::Long,
leverage,
margin,
MarginType::Isolated,
Decimal::ZERO,
Utc::now(),
Positive::ZERO,
)
}
#[must_use]
pub fn short(
symbol: String,
quantity: Positive,
entry_price: Positive,
leverage: Positive,
margin: Positive,
) -> Self {
Self::new(
symbol,
quantity,
entry_price,
Side::Short,
leverage,
margin,
MarginType::Isolated,
Decimal::ZERO,
Utc::now(),
Positive::ZERO,
)
}
pub fn notional_value_at_entry(&self) -> Result<Positive, PositionError> {
Ok(self.quantity.checked_mul(&self.entry_price)?)
}
pub fn notional_value_at_price(
&self,
current_price: Positive,
) -> Result<Positive, PositionError> {
Ok(self.quantity.checked_mul(¤t_price)?)
}
pub fn unrealized_pnl(&self, current_price: Positive) -> Result<Decimal, PositionError> {
let price_change = d_sub(
current_price.to_dec(),
self.entry_price.to_dec(),
"PerpetualPosition::unrealized_pnl/price_change",
)?;
let pnl = d_mul(
price_change,
self.quantity.to_dec(),
"PerpetualPosition::unrealized_pnl/quantity",
)?;
Ok(match self.side {
Side::Long => pnl,
Side::Short => -pnl,
})
}
pub fn roe_percentage(&self, current_price: Positive) -> Result<Decimal, PositionError> {
if self.margin == Positive::ZERO {
return Ok(Decimal::ZERO);
}
let pnl = self.unrealized_pnl(current_price)?;
let ratio = d_div(
pnl,
self.margin.to_dec(),
"PerpetualPosition::roe_percentage/ratio",
)?;
Ok(d_mul(
ratio,
Decimal::ONE_HUNDRED,
"PerpetualPosition::roe_percentage/percent",
)?)
}
pub fn margin_ratio(&self, current_price: Positive) -> Result<Decimal, PositionError> {
let notional = self.quantity.checked_mul(¤t_price)?;
if notional == Positive::ZERO {
return Ok(Decimal::ZERO);
}
let equity = d_add(
self.margin.to_dec(),
self.unrealized_pnl(current_price)?,
"PerpetualPosition::margin_ratio/equity",
)?;
Ok(d_div(
equity,
notional.to_dec(),
"PerpetualPosition::margin_ratio/ratio",
)?)
}
pub fn update_funding_rate(&mut self, new_rate: Decimal) {
self.funding_rate = new_rate;
}
pub fn effective_leverage(&self, current_price: Positive) -> Result<Decimal, PositionError> {
let equity = d_add(
self.margin.to_dec(),
self.unrealized_pnl(current_price)?,
"PerpetualPosition::effective_leverage/equity",
)?;
if equity <= Decimal::ZERO {
return Ok(Decimal::MAX);
}
let notional = self.quantity.checked_mul(¤t_price)?;
Ok(d_div(
notional.to_dec(),
equity,
"PerpetualPosition::effective_leverage/leverage",
)?)
}
}
impl LegAble for PerpetualPosition {
fn get_symbol(&self) -> &str {
&self.symbol
}
fn get_quantity(&self) -> Positive {
self.quantity
}
fn get_side(&self) -> Side {
self.side
}
fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
Ok(d_sub(
self.unrealized_pnl(price)?,
self.fees.to_dec(),
"PerpetualPosition::pnl_at_price/net",
)?)
}
fn total_cost(&self) -> Result<Positive, PositionError> {
Ok(self.margin.checked_add(&self.fees)?)
}
fn fees(&self) -> Result<Positive, PositionError> {
Ok(self.fees)
}
}
impl Marginable for PerpetualPosition {
fn initial_margin(&self) -> Result<Positive, PositionError> {
Ok(self.margin)
}
fn maintenance_margin(&self) -> Result<Positive, PositionError> {
let notional = self.notional_value_at_entry()?;
let maintenance_ratio = Decimal::new(5, 3);
let maintenance = d_mul(
notional.to_dec(),
maintenance_ratio,
"PerpetualPosition::maintenance_margin",
)?;
Ok(Positive::new_decimal(maintenance).unwrap_or(Positive::ZERO))
}
fn leverage(&self) -> Result<Positive, PositionError> {
Ok(self.leverage)
}
fn liquidation_price(
&self,
_current_price: Positive,
) -> Result<Option<Positive>, PositionError> {
let maintenance = self.maintenance_margin()?;
let margin_buffer = d_sub(
self.margin.to_dec(),
maintenance.to_dec(),
"PerpetualPosition::liquidation_price/margin_buffer",
)?;
let price_buffer = d_div(
margin_buffer,
self.quantity.to_dec(),
"PerpetualPosition::liquidation_price/price_buffer",
)?;
let threshold = match self.side {
Side::Long => d_sub(
self.entry_price.to_dec(),
price_buffer,
"PerpetualPosition::liquidation_price/long",
)?,
Side::Short => d_add(
self.entry_price.to_dec(),
price_buffer,
"PerpetualPosition::liquidation_price/short",
)?,
};
Ok(liquidation_threshold(threshold, self.side))
}
fn is_liquidation_risk(
&self,
current_price: Positive,
_margin_ratio: Decimal,
) -> Result<bool, PositionError> {
let Some(liq_price) = self.liquidation_price(current_price)? else {
return Ok(false);
};
Ok(match self.side {
Side::Long => current_price <= liq_price,
Side::Short => current_price >= liq_price,
})
}
}
impl Fundable for PerpetualPosition {
fn funding_rate(&self) -> Decimal {
self.funding_rate
}
fn funding_interval_hours(&self) -> u32 {
Self::DEFAULT_FUNDING_INTERVAL_HOURS
}
fn funding_payment(&self, mark_price: Positive) -> Result<Decimal, PositionError> {
let notional = d_mul(
self.quantity.to_dec(),
mark_price.to_dec(),
"PerpetualPosition::funding_payment/notional",
)?;
let payment = d_mul(
notional,
self.funding_rate,
"PerpetualPosition::funding_payment/payment",
)?;
Ok(match self.side {
Side::Long => payment,
Side::Short => -payment,
})
}
}
impl std::fmt::Display for PerpetualPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {} {} @ {} ({}x {}, margin: {})",
self.side,
self.quantity,
self.symbol,
self.entry_price,
self.leverage,
self.margin_type,
self.margin
)
}
}
impl Default for PerpetualPosition {
fn default() -> Self {
Self {
symbol: String::new(),
quantity: Positive::ZERO,
entry_price: Positive::ZERO,
side: Side::Long,
leverage: positive::Positive::ONE,
margin: Positive::ZERO,
margin_type: MarginType::default(),
funding_rate: Decimal::ZERO,
date: Utc::now(),
fees: Positive::ZERO,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
#[test]
fn test_perpetual_ratios_report_a_notional_that_overflows() {
let perp = PerpetualPosition::new(
"BTC-USDT-PERP".to_string(),
Positive::MAX,
pos_or_panic!(50000.0),
Side::Long,
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
MarginType::Isolated,
dec!(0.0001),
Utc::now(),
pos_or_panic!(5.0),
);
assert!(perp.margin_ratio(pos_or_panic!(50000.0)).is_err());
assert!(perp.effective_leverage(pos_or_panic!(50000.0)).is_err());
}
#[test]
fn test_perpetual_position_new() {
let perp = PerpetualPosition::new(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
Side::Long,
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
MarginType::Isolated,
dec!(0.0001),
Utc::now(),
pos_or_panic!(5.0),
);
assert_eq!(perp.symbol, "BTC-USDT-PERP");
assert_eq!(perp.quantity, Positive::ONE);
assert_eq!(perp.entry_price, pos_or_panic!(50000.0));
assert_eq!(perp.side, Side::Long);
assert_eq!(perp.leverage, pos_or_panic!(10.0));
assert_eq!(perp.margin, pos_or_panic!(5000.0));
assert_eq!(perp.margin_type, MarginType::Isolated);
assert_eq!(perp.funding_rate, dec!(0.0001));
}
#[test]
fn test_perpetual_long_convenience() {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
assert_eq!(perp.side, Side::Long);
assert_eq!(perp.margin_type, MarginType::Isolated);
}
#[test]
fn test_perpetual_short_convenience() {
let perp = PerpetualPosition::short(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
assert_eq!(perp.side, Side::Short);
}
#[test]
fn test_notional_value() -> Result<(), Box<dyn std::error::Error>> {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::TWO,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(10000.0),
);
assert_eq!(perp.notional_value_at_entry()?, pos_or_panic!(100000.0));
assert_eq!(
perp.notional_value_at_price(pos_or_panic!(55000.0))?,
pos_or_panic!(110000.0)
);
Ok(())
}
#[test]
fn test_unrealized_pnl_long() {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
assert_eq!(
perp.unrealized_pnl(pos_or_panic!(55000.0)).ok(),
Some(dec!(5000))
);
assert_eq!(
perp.unrealized_pnl(pos_or_panic!(45000.0)).ok(),
Some(dec!(-5000))
);
}
#[test]
fn test_unrealized_pnl_short() {
let perp = PerpetualPosition::short(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
assert_eq!(
perp.unrealized_pnl(pos_or_panic!(45000.0)).ok(),
Some(dec!(5000))
);
assert_eq!(
perp.unrealized_pnl(pos_or_panic!(55000.0)).ok(),
Some(dec!(-5000))
);
}
#[test]
fn test_roe_percentage() {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
let roe = perp.roe_percentage(pos_or_panic!(55000.0));
assert_eq!(roe.ok(), Some(dec!(100)));
}
#[test]
fn test_funding_payment_long() -> Result<(), Box<dyn std::error::Error>> {
let perp = PerpetualPosition::new(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
Side::Long,
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
MarginType::Isolated,
dec!(0.0001),
Utc::now(),
Positive::ZERO,
);
let payment = perp.funding_payment(pos_or_panic!(50000.0))?;
assert_eq!(payment, dec!(5));
Ok(())
}
#[test]
fn test_funding_payment_short() -> Result<(), Box<dyn std::error::Error>> {
let perp = PerpetualPosition::new(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
Side::Short,
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
MarginType::Isolated,
dec!(0.0001),
Utc::now(),
Positive::ZERO,
);
let payment = perp.funding_payment(pos_or_panic!(50000.0))?;
assert_eq!(payment, dec!(-5));
Ok(())
}
#[test]
fn test_liquidation_price_long() -> Result<(), Box<dyn std::error::Error>> {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
let liq_price = perp.liquidation_price(pos_or_panic!(50000.0))?;
assert_eq!(liq_price, Some(pos_or_panic!(45250.0)));
assert!(perp.is_liquidation_risk(pos_or_panic!(45250.0), Decimal::ZERO)?);
assert!(!perp.is_liquidation_risk(pos_or_panic!(45251.0), Decimal::ZERO)?);
Ok(())
}
#[test]
fn test_liquidation_price_long_below_zero_is_unreachable()
-> Result<(), Box<dyn std::error::Error>> {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(100.0),
pos_or_panic!(0.5),
pos_or_panic!(200.0),
);
assert_eq!(perp.liquidation_price(pos_or_panic!(100.0))?, None);
assert!(!perp.is_liquidation_risk(Positive::ZERO, Decimal::ZERO)?);
Ok(())
}
#[test]
fn test_liquidation_price_short() -> Result<(), Box<dyn std::error::Error>> {
let perp = PerpetualPosition::short(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
let liq_price = perp.liquidation_price(pos_or_panic!(50000.0))?;
assert_eq!(liq_price, Some(pos_or_panic!(54750.0)));
assert!(perp.is_liquidation_risk(pos_or_panic!(54750.0), Decimal::ZERO)?);
assert!(!perp.is_liquidation_risk(pos_or_panic!(54749.0), Decimal::ZERO)?);
Ok(())
}
#[test]
fn test_effective_leverage() {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
let eff_lev = perp.effective_leverage(pos_or_panic!(50000.0));
assert_eq!(eff_lev.ok(), Some(dec!(10)));
}
#[test]
fn test_margin_type_display() {
assert_eq!(format!("{}", MarginType::Cross), "Cross");
assert_eq!(format!("{}", MarginType::Isolated), "Isolated");
}
#[test]
fn test_display() {
let perp = PerpetualPosition::long(
"BTC-USDT-PERP".to_string(),
Positive::ONE,
pos_or_panic!(50000.0),
pos_or_panic!(10.0),
pos_or_panic!(5000.0),
);
let display = format!("{}", perp);
assert!(display.contains("Long"));
assert!(display.contains("BTC-USDT-PERP"));
assert!(display.contains("10x"));
}
#[test]
fn test_liquidation_price_default_is_error() {
let perp = PerpetualPosition::default();
assert!(matches!(
perp.liquidation_price(Positive::ONE),
Err(PositionError::DecimalError(_))
));
assert!(
perp.is_liquidation_risk(Positive::ONE, Decimal::ZERO)
.is_err()
);
}
#[test]
fn test_notional_and_funding_overflow_is_error() {
let perp = PerpetualPosition {
quantity: Positive::MAX,
entry_price: Positive::TWO,
funding_rate: dec!(2),
..PerpetualPosition::default()
};
assert!(perp.notional_value_at_entry().is_err());
assert!(perp.notional_value_at_price(Positive::TWO).is_err());
assert!(perp.maintenance_margin().is_err());
assert!(perp.funding_payment(Positive::TWO).is_err());
assert!(perp.annualized_funding(Positive::TWO).is_err());
}
}