use crate::error::{DecimalError, PositionError};
use crate::model::ExpirationDate;
use crate::model::decimal::{d_add, d_div, d_mul, d_sub};
use crate::model::expiration::resolve_expiration_date;
use crate::model::leg::traits::{Expirable, LegAble, Marginable, liquidation_threshold};
use crate::model::types::Side;
use chrono::{DateTime, Utc};
use positive::Positive;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct FuturePosition {
pub symbol: String,
pub quantity: Positive,
pub entry_price: Positive,
pub side: Side,
pub expiration_date: ExpirationDate,
pub contract_size: Positive,
pub initial_margin_req: Positive,
pub maintenance_margin_req: Positive,
pub date: DateTime<Utc>,
pub fees: Positive,
}
impl FuturePosition {
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
symbol: String,
quantity: Positive,
entry_price: Positive,
side: Side,
expiration_date: ExpirationDate,
contract_size: Positive,
initial_margin_req: Positive,
maintenance_margin_req: Positive,
date: DateTime<Utc>,
fees: Positive,
) -> Self {
Self {
symbol,
quantity,
entry_price,
side,
expiration_date,
contract_size,
initial_margin_req,
maintenance_margin_req,
date,
fees,
}
}
#[must_use]
pub fn long(
symbol: String,
quantity: Positive,
entry_price: Positive,
expiration_date: ExpirationDate,
contract_size: Positive,
margin: Positive,
) -> Self {
Self::new(
symbol,
quantity,
entry_price,
Side::Long,
expiration_date,
contract_size,
margin,
maintenance_from_initial(margin), Utc::now(),
Positive::ZERO,
)
}
#[must_use]
pub fn short(
symbol: String,
quantity: Positive,
entry_price: Positive,
expiration_date: ExpirationDate,
contract_size: Positive,
margin: Positive,
) -> Self {
Self::new(
symbol,
quantity,
entry_price,
Side::Short,
expiration_date,
contract_size,
margin,
maintenance_from_initial(margin),
Utc::now(),
Positive::ZERO,
)
}
pub fn notional_value_at_entry(&self) -> Result<Positive, PositionError> {
Ok(self
.quantity
.checked_mul(&self.entry_price)?
.checked_mul(&self.contract_size)?)
}
pub fn notional_value_at_price(
&self,
current_price: Positive,
) -> Result<Positive, PositionError> {
Ok(self
.quantity
.checked_mul(¤t_price)?
.checked_mul(&self.contract_size)?)
}
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(),
"FuturePosition::unrealized_pnl/price_change",
)?;
let pnl = d_mul(
d_mul(
price_change,
self.quantity.to_dec(),
"FuturePosition::unrealized_pnl/quantity",
)?,
self.contract_size.to_dec(),
"FuturePosition::unrealized_pnl/contract_size",
)?;
Ok(match self.side {
Side::Long => pnl,
Side::Short => -pnl,
})
}
pub fn tick_value(&self, tick_size: Positive) -> Result<Positive, PositionError> {
Ok(tick_size.checked_mul(&self.contract_size)?)
}
pub fn total_margin_required(&self) -> Result<Positive, PositionError> {
Ok(self.initial_margin_req.checked_mul(&self.quantity)?)
}
#[must_use]
#[expect(
clippy::arithmetic_side_effects,
reason = "the difference of two values in [0, Decimal::MAX] is representable"
)]
pub fn basis(&self, spot_price: Positive) -> Decimal {
self.entry_price.to_dec() - spot_price.to_dec()
}
pub fn implied_leverage(&self) -> Result<Decimal, PositionError> {
let margin = self.total_margin_required()?;
if margin == Positive::ZERO {
return Ok(Decimal::ZERO);
}
Ok(d_div(
self.notional_value_at_entry()?.to_dec(),
margin.to_dec(),
"FuturePosition::implied_leverage",
)?)
}
}
#[expect(
clippy::arithmetic_side_effects,
reason = "0.8 times a value in [0, Decimal::MAX] stays in that range"
)]
fn maintenance_from_initial(margin: Positive) -> Positive {
margin * Decimal::new(8, 1)
}
impl LegAble for FuturePosition {
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(),
"FuturePosition::pnl_at_price/net",
)?)
}
fn total_cost(&self) -> Result<Positive, PositionError> {
let margin = self
.initial_margin_req
.checked_mul(&self.quantity)
.map_err(PositionError::from)?;
Ok(margin.checked_add(&self.fees)?)
}
fn fees(&self) -> Result<Positive, PositionError> {
Ok(self.fees)
}
}
impl Marginable for FuturePosition {
fn initial_margin(&self) -> Result<Positive, PositionError> {
Ok(self.initial_margin_req.checked_mul(&self.quantity)?)
}
fn maintenance_margin(&self) -> Result<Positive, PositionError> {
Ok(self.maintenance_margin_req.checked_mul(&self.quantity)?)
}
fn leverage(&self) -> Result<Positive, PositionError> {
let lev = self.implied_leverage()?;
Ok(Positive::new_decimal(lev).unwrap_or(positive::Positive::ONE))
}
fn liquidation_price(
&self,
_current_price: Positive,
) -> Result<Option<Positive>, PositionError> {
let margin_buffer = d_sub(
self.initial_margin()?.to_dec(),
self.maintenance_margin()?.to_dec(),
"FuturePosition::liquidation_price/margin_buffer",
)?;
let units = d_mul(
self.quantity.to_dec(),
self.contract_size.to_dec(),
"FuturePosition::liquidation_price/units",
)?;
let price_buffer = d_div(
margin_buffer,
units,
"FuturePosition::liquidation_price/price_buffer",
)?;
let threshold = match self.side {
Side::Long => d_sub(
self.entry_price.to_dec(),
price_buffer,
"FuturePosition::liquidation_price/long",
)?,
Side::Short => d_add(
self.entry_price.to_dec(),
price_buffer,
"FuturePosition::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 Expirable for FuturePosition {
fn expiration_timestamp(&self) -> Result<i64, PositionError> {
let date = resolve_expiration_date(&self.expiration_date).map_err(DecimalError::from)?;
Ok(date.timestamp())
}
fn days_to_expiration(&self) -> Result<Positive, PositionError> {
let years = self
.expiration_date
.get_years()
.map_err(DecimalError::from)?;
let days = d_mul(
years.to_dec(),
Decimal::from(365),
"FuturePosition::days_to_expiration",
)?;
Ok(Positive::new_decimal(days)?)
}
fn is_expired(&self) -> Result<bool, PositionError> {
let date = resolve_expiration_date(&self.expiration_date).map_err(DecimalError::from)?;
Ok(date < Utc::now())
}
}
impl std::fmt::Display for FuturePosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {} {} @ {} (size: {}, exp: {})",
self.side,
self.quantity,
self.symbol,
self.entry_price,
self.contract_size,
self.expiration_date
)
}
}
impl Default for FuturePosition {
fn default() -> Self {
Self {
symbol: String::new(),
quantity: Positive::ZERO,
entry_price: Positive::ZERO,
side: Side::Long,
expiration_date: ExpirationDate::Days(
Positive::new_decimal(dec!(30.0)).unwrap_or(Positive::ZERO),
),
contract_size: positive::Positive::ONE,
initial_margin_req: Positive::ZERO,
maintenance_margin_req: Positive::ZERO,
date: Utc::now(),
fees: Positive::ZERO,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use positive::pos_or_panic;
#[test]
fn test_future_total_cost_reports_a_margin_product_that_overflows() {
let future = FuturePosition::new(
"ES".to_string(),
Positive::TWO,
pos_or_panic!(4500.0),
Side::Long,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
Positive::MAX,
pos_or_panic!(12000.0),
Utc::now(),
pos_or_panic!(5.0),
);
assert!(future.total_cost().is_err());
}
#[test]
fn test_future_position_new() {
let future = FuturePosition::new(
"ES".to_string(),
Positive::TWO,
pos_or_panic!(4500.0),
Side::Long,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
pos_or_panic!(12000.0),
Utc::now(),
pos_or_panic!(5.0),
);
assert_eq!(future.symbol, "ES");
assert_eq!(future.quantity, Positive::TWO);
assert_eq!(future.entry_price, pos_or_panic!(4500.0));
assert_eq!(future.side, Side::Long);
assert_eq!(future.contract_size, pos_or_panic!(50.0));
}
#[test]
fn test_future_long_convenience() {
let future = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
assert_eq!(future.side, Side::Long);
assert_eq!(future.initial_margin_req, pos_or_panic!(15000.0));
}
#[test]
fn test_future_short_convenience() {
let future = FuturePosition::short(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
assert_eq!(future.side, Side::Short);
}
#[test]
fn test_notional_value() -> Result<(), Box<dyn std::error::Error>> {
let future = FuturePosition::long(
"ES".to_string(),
Positive::TWO,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
assert_eq!(future.notional_value_at_entry()?, pos_or_panic!(450000.0));
Ok(())
}
#[test]
fn test_unrealized_pnl_long() {
let future = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let pnl = future.unrealized_pnl(pos_or_panic!(4510.0));
assert_eq!(pnl.ok(), Some(Decimal::from(500)));
let pnl_loss = future.unrealized_pnl(pos_or_panic!(4490.0));
assert_eq!(pnl_loss.ok(), Some(Decimal::from(-500)));
}
#[test]
fn test_unrealized_pnl_short() {
let future = FuturePosition::short(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let pnl = future.unrealized_pnl(pos_or_panic!(4490.0));
assert_eq!(pnl.ok(), Some(Decimal::from(500)));
let pnl_loss = future.unrealized_pnl(pos_or_panic!(4510.0));
assert_eq!(pnl_loss.ok(), Some(Decimal::from(-500)));
}
#[test]
fn test_implied_leverage() -> Result<(), Box<dyn std::error::Error>> {
let future = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let leverage = future.implied_leverage()?;
assert_eq!(leverage, Decimal::from(15));
Ok(())
}
#[test]
fn test_basis() {
let future = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4510.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let basis = future.basis(pos_or_panic!(4500.0));
assert_eq!(basis, Decimal::from(10));
}
#[test]
fn test_tick_value() -> Result<(), Box<dyn std::error::Error>> {
let future = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let tick_val = future.tick_value(pos_or_panic!(0.25))?;
assert_eq!(tick_val, pos_or_panic!(12.5));
Ok(())
}
#[test]
fn test_total_margin_required() -> Result<(), Box<dyn std::error::Error>> {
let future = FuturePosition::long(
"ES".to_string(),
Positive::TWO,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
assert_eq!(future.total_margin_required()?, pos_or_panic!(30000.0));
Ok(())
}
#[test]
fn test_display() {
let future = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let display = format!("{}", future);
assert!(display.contains("Long"));
assert!(display.contains("ES"));
assert!(display.contains("4500"));
}
fn future_with_margins(
side: Side,
entry_price: Positive,
initial_margin_req: Positive,
maintenance_margin_req: Positive,
) -> FuturePosition {
FuturePosition::new(
"ES".to_string(),
Positive::ONE,
entry_price,
side,
ExpirationDate::Days(pos_or_panic!(30.0)),
Positive::ONE,
initial_margin_req,
maintenance_margin_req,
Utc::now(),
Positive::ZERO,
)
}
#[test]
fn test_future_liquidation_price_reachable_is_unchanged()
-> Result<(), Box<dyn std::error::Error>> {
let long = FuturePosition::long(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
let short = FuturePosition::short(
"ES".to_string(),
Positive::ONE,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
);
assert_eq!(
long.liquidation_price(pos_or_panic!(4500.0))?,
Some(pos_or_panic!(4440.0))
);
assert_eq!(
short.liquidation_price(pos_or_panic!(4500.0))?,
Some(pos_or_panic!(4560.0))
);
assert!(long.is_liquidation_risk(pos_or_panic!(4440.0), Decimal::ZERO)?);
assert!(!long.is_liquidation_risk(pos_or_panic!(4441.0), Decimal::ZERO)?);
assert!(short.is_liquidation_risk(pos_or_panic!(4560.0), Decimal::ZERO)?);
assert!(!short.is_liquidation_risk(pos_or_panic!(4559.0), Decimal::ZERO)?);
Ok(())
}
#[test]
fn test_future_long_liquidation_price_below_zero_is_unreachable()
-> Result<(), Box<dyn std::error::Error>> {
let long = future_with_margins(
Side::Long,
pos_or_panic!(10.0),
pos_or_panic!(100.0),
pos_or_panic!(10.0),
);
assert_eq!(long.liquidation_price(pos_or_panic!(10.0))?, None);
assert!(!long.is_liquidation_risk(Positive::ZERO, Decimal::ZERO)?);
assert!(!long.is_liquidation_risk(pos_or_panic!(10.0), Decimal::ZERO)?);
Ok(())
}
#[test]
fn test_future_short_liquidation_price_below_zero_floors_at_zero()
-> Result<(), Box<dyn std::error::Error>> {
let short = future_with_margins(
Side::Short,
pos_or_panic!(10.0),
pos_or_panic!(10.0),
pos_or_panic!(100.0),
);
assert_eq!(
short.liquidation_price(pos_or_panic!(10.0))?,
Some(Positive::ZERO)
);
assert!(short.is_liquidation_risk(Positive::ZERO, Decimal::ZERO)?);
assert!(short.is_liquidation_risk(pos_or_panic!(10.0), Decimal::ZERO)?);
Ok(())
}
#[test]
fn test_future_days_to_expiration_is_unchanged() {
let future = future_with_margins(
Side::Long,
pos_or_panic!(4500.0),
pos_or_panic!(15000.0),
pos_or_panic!(12000.0),
);
let expected = ExpirationDate::Days(pos_or_panic!(30.0))
.get_years()
.expect("30 days")
* Decimal::from(365);
let days = future.days_to_expiration().expect("30 days");
assert_eq!(days, expected);
assert_eq!(
future.time_to_expiration_years().expect("30 days"),
expected.to_dec() / Decimal::from(365)
);
}
#[test]
fn test_future_days_to_expiration_overflow_is_an_error_not_zero() {
let mut future = future_with_margins(
Side::Long,
pos_or_panic!(4500.0),
pos_or_panic!(15000.0),
pos_or_panic!(12000.0),
);
future.expiration_date = ExpirationDate::Days(Positive::MAX);
let result = future.days_to_expiration();
assert!(
matches!(
result,
Err(PositionError::DecimalError(DecimalError::Overflow { .. }))
),
"{result:?}"
);
assert!(future.time_to_expiration_years().is_err());
}
#[test]
fn test_future_expiration_timestamp_and_is_expired_are_unchanged() {
let expiry = Utc::now() + chrono::Duration::days(30);
let mut future = future_with_margins(
Side::Long,
pos_or_panic!(4500.0),
pos_or_panic!(15000.0),
pos_or_panic!(12000.0),
);
future.expiration_date = ExpirationDate::DateTime(expiry);
assert_eq!(future.expiration_timestamp().ok(), Some(expiry.timestamp()));
assert_eq!(future.is_expired().ok(), Some(false));
let past = Utc::now() - chrono::Duration::days(1);
future.expiration_date = ExpirationDate::DateTime(past);
assert_eq!(future.expiration_timestamp().ok(), Some(past.timestamp()));
assert_eq!(future.is_expired().ok(), Some(true));
}
#[test]
fn test_future_unresolvable_expiration_is_an_error_not_epoch_or_live() {
let mut future = future_with_margins(
Side::Long,
pos_or_panic!(4500.0),
pos_or_panic!(15000.0),
pos_or_panic!(12000.0),
);
future.expiration_date = ExpirationDate::Days(Positive::MAX);
let timestamp = future.expiration_timestamp();
assert!(
matches!(
timestamp,
Err(PositionError::DecimalError(DecimalError::ExpirationDate(_)))
),
"{timestamp:?}"
);
let expired = future.is_expired();
assert!(
matches!(
expired,
Err(PositionError::DecimalError(DecimalError::ExpirationDate(_)))
),
"{expired:?}"
);
}
fn future_with_quantity(quantity: Positive) -> FuturePosition {
FuturePosition::long(
"ES".to_string(),
quantity,
pos_or_panic!(4500.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(50.0),
pos_or_panic!(15000.0),
)
}
#[test]
fn test_liquidation_price_zero_quantity_is_error() {
let future = future_with_quantity(Positive::ZERO);
assert!(matches!(
future.liquidation_price(pos_or_panic!(4500.0)),
Err(PositionError::DecimalError(_))
));
assert!(
future
.is_liquidation_risk(pos_or_panic!(4500.0), Decimal::ZERO)
.is_err()
);
}
#[test]
fn test_notional_and_margins_overflow_is_error() {
let future = future_with_quantity(Positive::MAX);
assert!(future.notional_value_at_entry().is_err());
assert!(future.notional_value_at_price(Positive::TWO).is_err());
assert!(future.total_margin_required().is_err());
assert!(future.implied_leverage().is_err());
assert!(future.initial_margin().is_err());
assert!(future.maintenance_margin().is_err());
assert!(future.leverage().is_err());
let mut wide = future_with_quantity(Positive::ONE);
wide.contract_size = Positive::MAX;
assert!(wide.tick_value(Positive::TWO).is_err());
}
}