use crate::error::PositionError;
use crate::model::decimal::{d_div, d_mul, d_sub};
use crate::model::leg::traits::LegAble;
use crate::model::types::Side;
use chrono::{DateTime, Utc};
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct SpotPosition {
pub symbol: String,
pub quantity: Positive,
pub cost_basis: Positive,
pub side: Side,
pub date: DateTime<Utc>,
pub open_fee: Positive,
pub close_fee: Positive,
}
impl SpotPosition {
#[must_use]
pub fn new(
symbol: String,
quantity: Positive,
cost_basis: Positive,
side: Side,
date: DateTime<Utc>,
open_fee: Positive,
close_fee: Positive,
) -> Self {
Self {
symbol,
quantity,
cost_basis,
side,
date,
open_fee,
close_fee,
}
}
#[must_use]
pub fn long(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
Self::new(
symbol,
quantity,
cost_basis,
Side::Long,
Utc::now(),
Positive::ZERO,
Positive::ZERO,
)
}
#[must_use]
pub fn short(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
Self::new(
symbol,
quantity,
cost_basis,
Side::Short,
Utc::now(),
Positive::ZERO,
Positive::ZERO,
)
}
pub fn initial_value(&self) -> Result<Positive, PositionError> {
Ok(self.quantity.checked_mul(&self.cost_basis)?)
}
pub fn market_value(&self, current_price: Positive) -> Result<Positive, PositionError> {
Ok(self.quantity.checked_mul(¤t_price)?)
}
pub fn percentage_return(&self, current_price: Positive) -> Result<Decimal, PositionError> {
if self.cost_basis == Positive::ZERO {
return Ok(Decimal::ZERO);
}
let price_change = d_sub(
current_price.to_dec(),
self.cost_basis.to_dec(),
"SpotPosition::percentage_return/price_change",
)?;
let return_pct = d_div(
price_change,
self.cost_basis.to_dec(),
"SpotPosition::percentage_return",
)?;
Ok(match self.side {
Side::Long => return_pct,
Side::Short => -return_pct,
})
}
pub fn break_even_price(&self) -> Result<Positive, PositionError> {
if self.quantity == Positive::ZERO {
return Ok(self.cost_basis);
}
let fee_per_unit = self
.open_fee
.checked_add(&self.close_fee)?
.checked_div(&self.quantity)?;
Ok(match self.side {
Side::Long => self.cost_basis.checked_add(&fee_per_unit)?,
Side::Short => {
if self.cost_basis > fee_per_unit {
self.cost_basis.checked_sub(&fee_per_unit)?
} else {
Positive::ZERO
}
}
})
}
}
impl LegAble for SpotPosition {
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 price_change = d_sub(
price.to_dec(),
self.cost_basis.to_dec(),
"SpotPosition::pnl_at_price/price_change",
)?;
let gross_pnl = d_mul(
price_change,
self.quantity.to_dec(),
"SpotPosition::pnl_at_price/gross",
)?;
let total_fees = self.open_fee.checked_add(&self.close_fee)?;
let signed_gross = match self.side {
Side::Long => gross_pnl,
Side::Short => -gross_pnl,
};
Ok(d_sub(
signed_gross,
total_fees.to_dec(),
"SpotPosition::pnl_at_price/net",
)?)
}
fn total_cost(&self) -> Result<Positive, PositionError> {
Ok(match self.side {
Side::Long => self
.quantity
.checked_mul(&self.cost_basis)?
.checked_add(&self.open_fee)?
.checked_add(&self.close_fee)?,
Side::Short => self.open_fee.checked_add(&self.close_fee)?,
})
}
fn fees(&self) -> Result<Positive, PositionError> {
Ok(self.open_fee.checked_add(&self.close_fee)?)
}
}
impl std::fmt::Display for SpotPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} {} {} @ {} (fees: ",
self.side, self.quantity, self.symbol, self.cost_basis,
)?;
match self.open_fee.checked_add(&self.close_fee) {
Ok(total) => write!(f, "{total})"),
Err(_) => write!(f, "{} + {})", self.open_fee, self.close_fee),
}
}
}
impl Default for SpotPosition {
fn default() -> Self {
Self {
symbol: String::new(),
quantity: Positive::ZERO,
cost_basis: Positive::ZERO,
side: Side::Long,
date: Utc::now(),
open_fee: Positive::ZERO,
close_fee: Positive::ZERO,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use positive::pos_or_panic;
#[test]
fn test_spot_position_new() {
let spot = SpotPosition::new(
"AAPL".to_string(),
Positive::HUNDRED,
pos_or_panic!(150.0),
Side::Long,
Utc::now(),
Positive::ONE,
Positive::ONE,
);
assert_eq!(spot.symbol, "AAPL");
assert_eq!(spot.quantity, Positive::HUNDRED);
assert_eq!(spot.cost_basis, pos_or_panic!(150.0));
assert_eq!(spot.side, Side::Long);
assert_eq!(spot.open_fee, Positive::ONE);
assert_eq!(spot.close_fee, Positive::ONE);
}
#[test]
fn test_spot_position_long_convenience() {
let spot = SpotPosition::long("BTC".to_string(), Positive::ONE, pos_or_panic!(50000.0));
assert_eq!(spot.symbol, "BTC");
assert_eq!(spot.quantity, Positive::ONE);
assert_eq!(spot.cost_basis, pos_or_panic!(50000.0));
assert_eq!(spot.side, Side::Long);
assert_eq!(spot.open_fee, Positive::ZERO);
}
#[test]
fn test_spot_position_short_convenience() {
let spot = SpotPosition::short(
"ETH".to_string(),
pos_or_panic!(10.0),
pos_or_panic!(3000.0),
);
assert_eq!(spot.symbol, "ETH");
assert_eq!(spot.quantity, pos_or_panic!(10.0));
assert_eq!(spot.cost_basis, pos_or_panic!(3000.0));
assert_eq!(spot.side, Side::Short);
}
#[test]
fn test_initial_value() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(spot.initial_value().ok(), Some(pos_or_panic!(15000.0)));
}
#[test]
fn test_market_value() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(
spot.market_value(pos_or_panic!(160.0)).ok(),
Some(pos_or_panic!(16000.0))
);
}
#[test]
fn test_long_pnl_profit() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
assert_eq!(pnl.ok(), Some(Decimal::from(1000))); }
#[test]
fn test_long_pnl_loss() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); }
#[test]
fn test_short_pnl_profit() {
let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
assert_eq!(pnl.ok(), Some(Decimal::from(1000))); }
#[test]
fn test_short_pnl_loss() {
let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); }
#[test]
fn test_pnl_with_fees() {
let spot = SpotPosition::new(
"AAPL".to_string(),
Positive::HUNDRED,
pos_or_panic!(150.0),
Side::Long,
Utc::now(),
pos_or_panic!(10.0),
pos_or_panic!(10.0),
);
let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
assert_eq!(pnl.ok(), Some(Decimal::from(980))); }
#[test]
fn test_spot_position_pnl_at_price_overflowing_notional_is_reported() {
let mut spot =
SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
spot.quantity = Positive::MAX;
assert!(spot.pnl_at_price(Positive::MAX).is_err());
}
#[test]
fn test_spot_position_total_cost_overflowing_notional_is_reported() {
let mut spot = SpotPosition::long("AAPL".to_string(), Positive::MAX, Positive::MAX);
spot.open_fee = Positive::ONE;
assert!(spot.total_cost().is_err());
}
#[test]
fn test_spot_position_total_cost_overflowing_fees_is_reported() {
let mut spot =
SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
spot.open_fee = Positive::MAX;
spot.close_fee = Positive::MAX;
assert!(spot.total_cost().is_err());
assert!(spot.fees().is_err());
}
#[test]
fn test_total_cost_long() {
let spot = SpotPosition::new(
"AAPL".to_string(),
Positive::HUNDRED,
pos_or_panic!(150.0),
Side::Long,
Utc::now(),
pos_or_panic!(10.0),
pos_or_panic!(10.0),
);
assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(15020.0))); }
#[test]
fn test_total_cost_short() {
let spot = SpotPosition::new(
"AAPL".to_string(),
Positive::HUNDRED,
pos_or_panic!(150.0),
Side::Short,
Utc::now(),
pos_or_panic!(10.0),
pos_or_panic!(10.0),
);
assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(20.0))); }
#[test]
fn test_percentage_return_long() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
let return_pct = spot.percentage_return(pos_or_panic!(110.0));
assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); }
#[test]
fn test_percentage_return_short() {
let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
let return_pct = spot.percentage_return(pos_or_panic!(90.0));
assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); }
#[test]
fn test_break_even_long() {
let spot = SpotPosition::new(
"AAPL".to_string(),
Positive::HUNDRED,
pos_or_panic!(150.0),
Side::Long,
Utc::now(),
pos_or_panic!(50.0),
pos_or_panic!(50.0),
);
assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(151.0))); }
#[test]
fn test_break_even_short() {
let spot = SpotPosition::new(
"AAPL".to_string(),
Positive::HUNDRED,
pos_or_panic!(150.0),
Side::Short,
Utc::now(),
pos_or_panic!(50.0),
pos_or_panic!(50.0),
);
assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(149.0))); }
#[test]
fn test_extreme_values_are_errors_not_aborts() {
let wide = SpotPosition::long("X".to_string(), Positive::MAX, Positive::TWO);
assert!(wide.initial_value().is_err());
assert!(wide.market_value(Positive::TWO).is_err());
let tiny_basis = SpotPosition::long("X".to_string(), Positive::ONE, pos_or_panic!(1e-27));
assert!(matches!(
tiny_basis.percentage_return(Positive::MAX),
Err(PositionError::DecimalError(_))
));
let mut tiny_quantity =
SpotPosition::long("X".to_string(), pos_or_panic!(1e-27), Positive::ONE);
tiny_quantity.open_fee = Positive::MAX;
assert!(tiny_quantity.break_even_price().is_err());
tiny_quantity.close_fee = Positive::MAX;
let shown = tiny_quantity.to_string();
assert!(shown.ends_with(&format!("(fees: {} + {})", Positive::MAX, Positive::MAX)));
}
#[test]
fn test_display() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
let display = format!("{}", spot);
assert!(display.contains("Long"));
assert!(display.contains("AAPL"));
assert!(display.contains("100"));
}
#[test]
fn test_is_long_short() {
let long = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
let short =
SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert!(long.is_long());
assert!(!long.is_short());
assert!(!short.is_long());
assert!(short.is_short());
}
}