use crate::error::PositionError;
use crate::model::decimal::{d_div, d_mul};
use crate::model::types::Side;
use positive::Positive;
use rust_decimal::Decimal;
const HOURS_PER_YEAR: u32 = 24 * 365;
const DAYS_PER_YEAR: u32 = 365;
pub trait LegAble {
fn get_symbol(&self) -> &str;
fn get_quantity(&self) -> Positive;
fn get_side(&self) -> Side;
fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError>;
fn total_cost(&self) -> Result<Positive, PositionError>;
fn fees(&self) -> Result<Positive, PositionError>;
#[must_use]
fn is_long(&self) -> bool {
matches!(self.get_side(), Side::Long)
}
#[must_use]
fn is_short(&self) -> bool {
matches!(self.get_side(), Side::Short)
}
fn notional_value(&self, price: Positive) -> Result<Positive, PositionError> {
Ok(self.get_quantity().checked_mul(&price)?)
}
}
pub trait Marginable: LegAble {
fn initial_margin(&self) -> Result<Positive, PositionError>;
fn maintenance_margin(&self) -> Result<Positive, PositionError>;
fn leverage(&self) -> Result<Positive, PositionError>;
fn liquidation_price(&self, current_price: Positive)
-> Result<Option<Positive>, PositionError>;
fn is_liquidation_risk(
&self,
current_price: Positive,
margin_ratio: Decimal,
) -> Result<bool, PositionError>;
}
#[must_use]
pub(crate) fn liquidation_threshold(threshold: Decimal, side: Side) -> Option<Positive> {
match Positive::new_decimal(threshold) {
Ok(price) => Some(price),
Err(_) => match side {
Side::Long => None,
Side::Short => Some(Positive::ZERO),
},
}
}
pub trait Fundable: LegAble {
fn funding_rate(&self) -> Decimal;
fn funding_interval_hours(&self) -> u32;
fn funding_payment(&self, mark_price: Positive) -> Result<Decimal, PositionError>;
fn annualized_funding(&self, mark_price: Positive) -> Result<Decimal, PositionError> {
let payment = self.funding_payment(mark_price)?;
let periods_per_year = HOURS_PER_YEAR
.checked_div(self.funding_interval_hours())
.ok_or_else(|| PositionError::invalid_position("funding interval is zero hours"))?;
Ok(d_mul(
payment,
Decimal::from(periods_per_year),
"Fundable::annualized_funding",
)?)
}
}
pub trait Expirable: LegAble {
fn expiration_timestamp(&self) -> Result<i64, PositionError>;
fn days_to_expiration(&self) -> Result<Positive, PositionError>;
fn is_expired(&self) -> Result<bool, PositionError>;
fn time_to_expiration_years(&self) -> Result<Decimal, PositionError> {
Ok(d_div(
self.days_to_expiration()?.to_dec(),
Decimal::from(DAYS_PER_YEAR),
"Expirable::time_to_expiration_years",
)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockLeg {
symbol: String,
quantity: Positive,
side: Side,
cost_basis: Positive,
fees: Positive,
}
impl LegAble for MockLeg {
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> {
let value_change = (price.to_dec() - self.cost_basis.to_dec()) * self.quantity.to_dec();
Ok(match self.side {
Side::Long => value_change,
Side::Short => -value_change,
})
}
fn total_cost(&self) -> Result<Positive, PositionError> {
Ok(self
.cost_basis
.checked_mul(&self.quantity)?
.checked_add(&self.fees)?)
}
fn fees(&self) -> Result<Positive, PositionError> {
Ok(self.fees)
}
}
#[test]
fn test_mock_leg_long_pnl() {
let leg = MockLeg {
symbol: "BTC".to_string(),
quantity: positive::Positive::ONE,
side: Side::Long,
cost_basis: positive::pos_or_panic!(50000.0),
fees: positive::pos_or_panic!(10.0),
};
let pnl = leg.pnl_at_price(positive::pos_or_panic!(55000.0));
assert_eq!(pnl.ok(), Some(Decimal::from(5000)));
let pnl = leg.pnl_at_price(positive::pos_or_panic!(45000.0));
assert_eq!(pnl.ok(), Some(Decimal::from(-5000)));
}
#[test]
fn test_mock_leg_short_pnl() {
let leg = MockLeg {
symbol: "BTC".to_string(),
quantity: positive::Positive::ONE,
side: Side::Short,
cost_basis: positive::pos_or_panic!(50000.0),
fees: positive::pos_or_panic!(10.0),
};
let pnl = leg.pnl_at_price(positive::pos_or_panic!(55000.0));
assert_eq!(pnl.ok(), Some(Decimal::from(-5000)));
let pnl = leg.pnl_at_price(positive::pos_or_panic!(45000.0));
assert_eq!(pnl.ok(), Some(Decimal::from(5000)));
}
#[test]
fn test_is_long_short() {
let long_leg = MockLeg {
symbol: "BTC".to_string(),
quantity: positive::Positive::ONE,
side: Side::Long,
cost_basis: positive::pos_or_panic!(50000.0),
fees: positive::pos_or_panic!(10.0),
};
let short_leg = MockLeg {
symbol: "BTC".to_string(),
quantity: Positive::ONE,
side: Side::Short,
cost_basis: positive::pos_or_panic!(50000.0),
fees: positive::pos_or_panic!(10.0),
};
assert!(long_leg.is_long());
assert!(!long_leg.is_short());
assert!(!short_leg.is_long());
assert!(short_leg.is_short());
}
#[test]
fn test_notional_value() {
let leg = MockLeg {
symbol: "BTC".to_string(),
quantity: positive::Positive::TWO,
side: Side::Long,
cost_basis: positive::pos_or_panic!(50000.0),
fees: positive::pos_or_panic!(10.0),
};
let notional = leg.notional_value(positive::pos_or_panic!(55000.0));
assert_eq!(notional.ok(), Some(positive::pos_or_panic!(110000.0)));
}
#[test]
fn test_notional_value_overflow_is_error() {
let leg = MockLeg {
symbol: "BTC".to_string(),
quantity: positive::Positive::TWO,
side: Side::Long,
cost_basis: positive::Positive::ONE,
fees: positive::Positive::ZERO,
};
assert!(leg.notional_value(Positive::MAX).is_err());
}
struct ZeroIntervalLeg(MockLeg);
impl LegAble for ZeroIntervalLeg {
fn get_symbol(&self) -> &str {
self.0.get_symbol()
}
fn get_quantity(&self) -> Positive {
self.0.get_quantity()
}
fn get_side(&self) -> Side {
self.0.get_side()
}
fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
self.0.pnl_at_price(price)
}
fn total_cost(&self) -> Result<Positive, PositionError> {
self.0.total_cost()
}
fn fees(&self) -> Result<Positive, PositionError> {
self.0.fees()
}
}
impl Fundable for ZeroIntervalLeg {
fn funding_rate(&self) -> Decimal {
Decimal::ONE
}
fn funding_interval_hours(&self) -> u32 {
0
}
fn funding_payment(&self, _mark_price: Positive) -> Result<Decimal, PositionError> {
Ok(Decimal::ONE)
}
}
#[test]
fn test_annualized_funding_zero_interval_is_error() {
let leg = ZeroIntervalLeg(MockLeg {
symbol: "BTC".to_string(),
quantity: positive::Positive::ONE,
side: Side::Long,
cost_basis: positive::Positive::ONE,
fees: positive::Positive::ZERO,
});
assert!(matches!(
leg.annualized_funding(Positive::ONE),
Err(PositionError::ValidationError(_))
));
}
}