use crate::error::GreeksError;
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};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, 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,
)
}
#[must_use]
pub fn initial_value(&self) -> Positive {
self.quantity * self.cost_basis
}
#[must_use]
pub fn market_value(&self, current_price: Positive) -> Positive {
self.quantity * current_price
}
#[must_use]
pub fn percentage_return(&self, current_price: Positive) -> Decimal {
if self.cost_basis == Positive::ZERO {
return Decimal::ZERO;
}
let price_change = current_price.to_dec() - self.cost_basis.to_dec();
let return_pct = price_change / self.cost_basis.to_dec();
match self.side {
Side::Long => return_pct,
Side::Short => -return_pct,
}
}
#[must_use]
pub fn break_even_price(&self) -> Positive {
if self.quantity == Positive::ZERO {
return self.cost_basis;
}
let fee_per_unit = (self.open_fee + self.close_fee) / self.quantity;
match self.side {
Side::Long => self.cost_basis + fee_per_unit,
Side::Short => {
if self.cost_basis > fee_per_unit {
self.cost_basis - 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) -> Decimal {
let price_change = price.to_dec() - self.cost_basis.to_dec();
let gross_pnl = price_change * self.quantity.to_dec();
let total_fees = self.open_fee.to_dec() + self.close_fee.to_dec();
match self.side {
Side::Long => gross_pnl - total_fees,
Side::Short => -gross_pnl - total_fees,
}
}
fn total_cost(&self) -> Positive {
match self.side {
Side::Long => self.quantity * self.cost_basis + self.open_fee + self.close_fee,
Side::Short => self.open_fee + self.close_fee,
}
}
fn fees(&self) -> Positive {
self.open_fee + self.close_fee
}
fn delta(&self) -> Result<Decimal, GreeksError> {
let delta_per_unit = match self.side {
Side::Long => Decimal::ONE,
Side::Short => -Decimal::ONE,
};
Ok(delta_per_unit * self.quantity.to_dec())
}
}
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,
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(), 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)),
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, 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, 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, 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, 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, Decimal::from(980)); }
#[test]
fn test_delta_long() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(spot.delta().unwrap(), Decimal::from(100));
}
#[test]
fn test_delta_short() {
let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(spot.delta().unwrap(), Decimal::from(-100));
}
#[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(), 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(), 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, 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, 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(), 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(), pos_or_panic!(149.0)); }
#[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());
}
#[test]
fn test_gamma_is_zero() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(spot.gamma().unwrap(), Decimal::ZERO);
}
#[test]
fn test_theta_is_zero() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(spot.theta().unwrap(), Decimal::ZERO);
}
#[test]
fn test_vega_is_zero() {
let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
assert_eq!(spot.vega().unwrap(), Decimal::ZERO);
}
}