use crate::error::position::PositionValidationErrorKind;
use crate::error::{PositionError, TradeError};
use crate::model::decimal::{d_add, d_mul, d_sub};
use crate::model::expiration::resolve_expiration_date;
use crate::model::trade::TradeStatusAble;
use crate::model::types::{Action, OptionStyle, Side};
use crate::model::{ExpirationDate, Options};
use crate::model::{Trade, TradeAble, TradeStatus};
use chrono::{DateTime, Utc};
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Position {
pub option: Options,
pub premium: Positive,
pub date: DateTime<Utc>,
pub open_fee: Positive,
pub close_fee: Positive,
pub epic: Option<String>,
pub extra_fields: Option<serde_json::Value>,
}
impl Position {
#[inline]
fn premium_per_contract(&self) -> Result<Positive, PositionError> {
Ok(self.premium.checked_mul(&self.option.contract_size)?)
}
#[must_use]
pub fn new(
option: Options,
premium: Positive,
date: DateTime<Utc>,
open_fee: Positive,
close_fee: Positive,
epic: Option<String>,
extra_fields: Option<serde_json::Value>,
) -> Self {
Position {
option,
premium,
date,
open_fee,
close_fee,
epic,
extra_fields,
}
}
pub fn total_cost(&self) -> Result<Positive, PositionError> {
let total_cost = match self.option.side {
Side::Long => self
.premium_per_contract()?
.checked_add(&self.open_fee)?
.checked_add(&self.close_fee)?
.checked_mul(&self.option.quantity)?,
Side::Short => self.fees()?,
};
Ok(total_cost)
}
pub fn premium_received(&self) -> Result<Positive, PositionError> {
match self.option.side {
Side::Long => Ok(Positive::ZERO),
Side::Short => Ok(self
.premium_per_contract()?
.checked_mul(&self.option.quantity)?),
}
}
pub fn net_premium_received(&self) -> Result<Positive, PositionError> {
match self.option.side {
Side::Long => Ok(Positive::ZERO),
Side::Short => {
let premium = self
.premium_per_contract()?
.checked_mul(&self.option.quantity)?;
let total_cost = self.total_cost()?;
if premium >= total_cost {
Ok(premium.checked_sub(&total_cost)?)
} else {
Ok(Positive::ZERO)
}
}
}
}
pub fn pnl_at_expiration(&self, price: &Option<&Positive>) -> Result<Decimal, PositionError> {
let intrinsic = match price {
None => self.option.intrinsic_value(self.option.underlying_price)?,
Some(price) => self.option.intrinsic_value(**price)?,
};
let cost = self.total_cost()?.to_dec();
let premium_recv = self.premium_received()?.to_dec();
let net_after_cost = d_sub(intrinsic, cost, "position::pnl_at_expiration::net")?;
d_add(
net_after_cost,
premium_recv,
"position::pnl_at_expiration::total",
)
.map_err(PositionError::from)
}
pub fn unrealized_pnl(&self, price: Positive) -> Result<Decimal, PositionError> {
let per_contract = match self.option.side {
Side::Long => {
let after_premium = d_mul(
d_sub(
price.to_dec(),
self.premium.to_dec(),
"position::unrealized_pnl::long::after_premium",
)?,
self.option.contract_size.to_dec(),
"position::unrealized_pnl::long::contract_size",
)?;
let after_open = d_sub(
after_premium,
self.open_fee.to_dec(),
"position::unrealized_pnl::long::after_open_fee",
)?;
d_sub(
after_open,
self.close_fee.to_dec(),
"position::unrealized_pnl::long::net",
)?
}
Side::Short => {
let after_price = d_mul(
d_sub(
self.premium.to_dec(),
price.to_dec(),
"position::unrealized_pnl::short::after_price",
)?,
self.option.contract_size.to_dec(),
"position::unrealized_pnl::short::contract_size",
)?;
let after_open = d_sub(
after_price,
self.open_fee.to_dec(),
"position::unrealized_pnl::short::after_open_fee",
)?;
d_sub(
after_open,
self.close_fee.to_dec(),
"position::unrealized_pnl::short::net",
)?
}
};
Ok(d_mul(
per_contract,
self.option.quantity.to_dec(),
"position::unrealized_pnl::scaled",
)?)
}
pub fn days_held(&self) -> Result<Positive, PositionError> {
let days = Utc::now().signed_duration_since(self.date).num_days() as f64;
Positive::new(days).map_err(|e| {
PositionError::ValidationError(PositionValidationErrorKind::InvalidPosition {
reason: format!("failed to calculate days held: {}", e),
})
})
}
pub fn days_to_expiration(&self) -> Result<Positive, PositionError> {
match self.option.expiration_date {
ExpirationDate::Days(days) => Ok(days),
ExpirationDate::DateTime(datetime) => {
let days = datetime.signed_duration_since(Utc::now()).num_days() as f64;
Positive::new(days.max(0.0)).map_err(|e| {
PositionError::ValidationError(PositionValidationErrorKind::InvalidPosition {
reason: format!("failed to calculate days to expiration: {}", e),
})
})
}
}
}
#[must_use]
pub fn is_long(&self) -> bool {
match self.option.side {
Side::Long => true,
Side::Short => false,
}
}
#[must_use]
pub fn is_short(&self) -> bool {
match self.option.side {
Side::Long => false,
Side::Short => true,
}
}
pub fn net_cost(&self) -> Result<Decimal, PositionError> {
match self.option.side {
Side::Long => Ok(self.total_cost()?.to_dec()),
Side::Short => {
let fees = self.fees()?.to_dec();
let premium = self.premium_received()?.to_dec();
Ok(d_sub(fees, premium, "Position::net_cost/short")?)
}
}
}
#[must_use]
pub fn break_even(&self) -> Option<Positive> {
if self.option.quantity == Positive::ZERO {
return None;
}
let position_total_cost = self.total_cost().ok()?;
let position_size = self.option.position_size().ok()?;
let total_cost_per_unit = position_total_cost.checked_div(&position_size).ok()?;
match (&self.option.side, &self.option.option_style) {
(Side::Long, OptionStyle::Call) => self
.option
.strike_price
.checked_add(&total_cost_per_unit)
.ok(),
(Side::Short, OptionStyle::Call) => self
.option
.strike_price
.checked_add(&self.premium)
.and_then(|total| total.checked_sub(&total_cost_per_unit))
.ok(),
(Side::Long, OptionStyle::Put) => self
.option
.strike_price
.checked_sub(&total_cost_per_unit)
.ok(),
(Side::Short, OptionStyle::Put) => self
.option
.strike_price
.checked_sub(&self.premium)
.and_then(|net| net.checked_add(&total_cost_per_unit))
.ok(),
}
}
#[allow(dead_code)]
pub(crate) fn max_profit(&self) -> Result<Positive, PositionError> {
match self.option.side {
Side::Long => Ok(Positive::MAX),
Side::Short => self.net_premium_received(),
}
}
#[allow(dead_code)]
pub(crate) fn max_loss(&self) -> Result<Positive, PositionError> {
match self.option.side {
Side::Long => self.total_cost(),
Side::Short => Ok(Positive::MAX),
}
}
pub fn fees(&self) -> Result<Positive, PositionError> {
Ok(self
.open_fee
.checked_add(&self.close_fee)?
.checked_mul(&self.option.quantity)?)
}
pub fn validate(&self) -> bool {
if self.option.side == Side::Short && self.premium == Positive::ZERO {
debug!("Premium must be greater than zero for short positions.");
return false;
}
if !self.option.validate() {
debug!("Option is not valid.");
return false;
}
true
}
}
impl Default for Position {
fn default() -> Self {
Position {
option: Options::default(),
premium: Positive::ZERO,
date: Utc::now(),
open_fee: Positive::ZERO,
close_fee: Positive::ZERO,
epic: None,
extra_fields: None,
}
}
}
impl TradeAble for Position {
fn trade(&self) -> Result<Trade, TradeError> {
let fee = self
.open_fee
.checked_add(&self.close_fee)
.map_err(|error| {
TradeError::invalid_trade(&format!(
"open and close fees overflow the Positive range: {error}"
))
})?;
self.premium_per_contract().map_err(|error| {
TradeError::invalid_trade(&format!(
"premium times contract size overflows the Positive range: {error}"
))
})?;
if let (Ok(expiry), Some(timestamp)) = (
resolve_expiration_date(&self.option.expiration_date),
Utc::now().timestamp_nanos_opt(),
) {
Ok(Trade {
id: uuid::Uuid::new_v4(),
action: Action::Buy,
side: self.option.side,
option_style: self.option.option_style,
fee,
symbol: None,
strike: self.option.strike_price,
expiry,
timestamp,
quantity: self.option.quantity,
premium: self.premium,
contract_size: self.option.contract_size,
underlying_price: self.option.underlying_price,
notes: None,
status: TradeStatus::Other("Not yet initialized".to_string()),
})
} else {
Err(TradeError::invalid_trade(
"Could not create trade from position",
))
}
}
fn trade_ref(&self) -> Result<&Trade, TradeError> {
Err(TradeError::invalid_trade(
"trade_ref() is not implemented for Position",
))
}
fn trade_mut(&mut self) -> Result<&mut Trade, TradeError> {
Err(TradeError::invalid_trade(
"trade_mut() is not implemented for Position",
))
}
}
impl TradeStatusAble for Position {
fn open(&self) -> Result<Trade, TradeError> {
let mut trade = self.trade()?;
trade.status = TradeStatus::Open;
Ok(trade)
}
fn close(&self) -> Result<Trade, TradeError> {
let mut trade = self.trade()?;
if trade.premium.to_dec() <= rust_decimal_macros::dec!(0.01) {
trade.premium = Positive::ZERO;
}
trade.status = TradeStatus::Closed;
trade.action = Action::Sell;
Ok(trade)
}
fn expired(&self) -> Result<Trade, TradeError> {
let mut trade = self.trade()?;
trade.status = TradeStatus::Expired;
trade.action = Action::Sell;
Ok(trade)
}
fn exercised(&self) -> Result<Trade, TradeError> {
let mut trade = self.trade()?;
trade.status = TradeStatus::Exercised;
trade.action = Action::Sell;
Ok(trade)
}
fn assigned(&self) -> Result<Trade, TradeError> {
let mut trade = self.trade()?;
trade.status = TradeStatus::Assigned;
trade.action = Action::Other;
Ok(trade)
}
fn status_other(&self) -> Result<Trade, TradeError> {
let mut trade = self.trade()?;
trade.status = TradeStatus::Other("Not yet initialized".to_string());
trade.action = Action::Other;
Ok(trade)
}
}
#[cfg(test)]
mod tests_position {
use super::*;
use crate::constants::ZERO;
use crate::model::types::{OptionStyle, OptionType, Side};
use chrono::Duration;
use num_traits::ToPrimitive;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
fn setup_option(
side: Side,
option_style: OptionStyle,
strike_price: Positive,
underlying_price: Positive,
quantity: Positive,
expiration_days: Positive,
) -> Options {
Options {
option_type: OptionType::European,
side,
underlying_symbol: "".to_string(),
strike_price,
expiration_date: ExpirationDate::Days(expiration_days),
implied_volatility: pos_or_panic!(0.2),
quantity,
contract_size: Positive::ONE,
underlying_price,
risk_free_rate: dec!(0.01),
option_style,
dividend_yield: Positive::ZERO,
exotic_params: None,
}
}
#[test]
fn test_position_total_cost() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.total_cost().unwrap(),
7.0,
"Total cost calculation is incorrect."
);
}
#[test]
fn test_position_total_cost_size() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.total_cost().unwrap(),
70.0,
"Total cost calculation is incorrect."
);
}
#[test]
fn test_position_total_cost_short() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.total_cost().unwrap(),
2.0,
"Total cost calculation is incorrect."
);
}
#[test]
fn test_position_total_cost_short_size() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.total_cost().unwrap(),
20.0,
"Total cost calculation is incorrect."
);
}
#[test]
fn test_position_check_negative_premium() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(110.0),
Positive::ONE,
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap(),
dec!(3.0),
"PNL at expiration for long call ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_long_call_itm() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(110.0),
Positive::ONE,
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap(),
dec!(3.0),
"PNL at expiration for long call ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_long_call_itm_quantity() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(110.0),
pos_or_panic!(10.0),
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap(),
dec!(30.0),
"PNL at expiration for long call ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_short_call_itm() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(110.0),
Positive::ONE,
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
-7.0,
"PNL at expiration for short call ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_short_call_itm_quantity() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(110.0),
pos_or_panic!(10.0),
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
-70.0,
"PNL at expiration for short call ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_long_put_itm() {
let option = setup_option(
Side::Long,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::ONE,
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
3.0,
"PNL at expiration for long put ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_long_put_itm_quantity() {
let option = setup_option(
Side::Long,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(90.0),
pos_or_panic!(10.0),
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
30.0,
"PNL at expiration for long put ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_short_put_itm() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::ONE,
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
-7.0,
"PNL at expiration for short put ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_short_put_itm_quantity() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(90.0),
pos_or_panic!(10.0),
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
-70.0,
"PNL at expiration for short put ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_short_put_itm_winning() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(110.0),
Positive::ONE,
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
3.0,
"PNL at expiration for short put ITM is incorrect."
);
}
#[test]
fn test_position_pnl_at_expiration_short_put_itm_quantity_winning() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(110.0),
pos_or_panic!(10.0),
Positive::ZERO,
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
30.0,
"PNL at expiration for short put ITM is incorrect."
);
}
#[test]
fn test_unrealized_pnl_long_call() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position
.pnl_at_expiration(&Some(&pos_or_panic!(107.0)))
.unwrap(),
Positive::ZERO,
"Unrealized PNL for long call is incorrect."
);
}
#[test]
fn test_unrealized_pnl_long_call_quantity() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position
.pnl_at_expiration(&Some(&pos_or_panic!(107.0)))
.unwrap()
.to_f64()
.unwrap(),
ZERO,
"Unrealized PNL for long call is incorrect."
);
}
#[test]
fn test_unrealized_pnl_short_call() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position
.unrealized_pnl(pos_or_panic!(3.0))
.unwrap()
.to_f64()
.unwrap(),
ZERO,
"Unrealized PNL for short call is incorrect."
);
}
#[test]
fn test_unrealized_pnl_short_call_bis() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position
.unrealized_pnl(pos_or_panic!(10.0))
.unwrap()
.to_f64()
.unwrap(),
-7.0,
"Unrealized PNL for short call is incorrect."
);
}
#[test]
fn test_days_held() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let date = Utc::now() - Duration::days(10);
let position = Position::new(
option,
pos_or_panic!(5.0),
date,
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.days_held().unwrap().to_f64(),
10.0,
"Days held calculation is incorrect."
);
}
#[test]
fn test_days_to_expiration() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(
position.days_to_expiration().unwrap().to_f64(),
30.0,
"Days to expiration calculation is incorrect."
);
}
#[test]
fn test_is_long_position() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert!(
position.is_long(),
"is_long should return true for long positions."
);
assert!(
!position.is_short(),
"is_short should return false for long positions."
);
}
#[test]
fn test_is_short_position() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert!(
position.is_short(),
"is_short should return true for short positions."
);
assert!(
!position.is_long(),
"is_long should return false for short positions."
);
}
}
#[cfg(test)]
mod tests_valid_position {
use super::*;
use crate::model::utils::create_sample_position;
use positive::pos_or_panic;
#[test]
fn test_valid_position() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
assert!(position.validate());
}
#[test]
fn test_zero_premium() {
let mut position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
position.premium = Positive::ZERO;
assert!(!position.validate());
}
#[test]
fn test_invalid_option() {
let mut position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
position.option.strike_price = Positive::ZERO; assert!(!position.validate());
}
#[test]
fn test_position_validate_negative_risk_free_rate_is_valid() {
let mut position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
position.option.risk_free_rate = rust_decimal_macros::dec!(-0.01);
assert!(position.validate());
}
#[test]
fn test_zero_fees() {
let mut position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
position.open_fee = Positive::ZERO;
position.close_fee = Positive::ZERO;
assert!(position.validate());
}
}
#[cfg(test)]
mod tests_position_break_even {
use super::*;
use crate::model::types::{OptionStyle, OptionType, Side};
use positive::pos_or_panic;
use rust_decimal_macros::dec;
fn setup_option(
side: Side,
option_style: OptionStyle,
strike_price: Positive,
underlying_price: Positive,
quantity: Positive,
expiration_days: Positive,
) -> Options {
Options {
option_type: OptionType::European,
side,
underlying_symbol: "".to_string(),
strike_price,
expiration_date: ExpirationDate::Days(expiration_days),
implied_volatility: pos_or_panic!(0.2),
quantity,
contract_size: Positive::ONE,
underlying_price,
risk_free_rate: dec!(0.01),
option_style,
dividend_yield: Positive::ZERO,
exotic_params: None,
}
}
#[test]
fn test_unrealized_pnl_long_call() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 107.0);
}
#[test]
fn test_unrealized_pnl_long_call_size() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 107.0);
}
#[test]
fn test_unrealized_pnl_short_call() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 103.0);
}
#[test]
fn test_unrealized_pnl_short_call_size() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 103.0);
}
#[test]
fn test_unrealized_pnl_long_put() {
let option = setup_option(
Side::Long,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 93.0);
}
#[test]
fn test_unrealized_pnl_long_put_size() {
let option = setup_option(
Side::Long,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 93.0);
}
#[test]
fn test_unrealized_pnl_short_put() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 97.0);
}
#[test]
fn test_unrealized_pnl_short_put_size() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.break_even().unwrap(), 97.0);
}
#[test]
fn test_zero_quantity() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ZERO, pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert!(position.break_even().is_none());
}
}
#[cfg(test)]
mod tests_position_max_loss_profit {
use super::*;
use crate::model::types::{OptionStyle, OptionType, Side};
use approx::assert_relative_eq;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
fn setup_option(
side: Side,
option_style: OptionStyle,
strike_price: Positive,
underlying_price: Positive,
quantity: Positive,
expiration_days: Positive,
) -> Options {
Options {
option_type: OptionType::European,
side,
underlying_symbol: "".to_string(),
strike_price,
expiration_date: ExpirationDate::Days(expiration_days),
implied_volatility: pos_or_panic!(0.2),
quantity,
contract_size: Positive::ONE,
underlying_price,
risk_free_rate: dec!(0.01),
option_style,
dividend_yield: Positive::ZERO,
exotic_params: None,
}
}
#[test]
fn test_unrealized_pnl_long_call() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap().to_f64(), 7.0, epsilon = 0.001);
assert_eq!(position.max_profit().unwrap(), Positive::MAX);
}
#[test]
fn test_unrealized_pnl_long_call_size() {
let option = setup_option(
Side::Long,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap().to_f64(), 70.0, epsilon = 0.001);
assert_eq!(position.max_profit().unwrap(), Positive::MAX);
}
#[test]
fn test_unrealized_pnl_short_call() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
assert_relative_eq!(
position.max_profit().unwrap().to_f64(),
3.0,
epsilon = 0.001
);
}
#[test]
fn test_unrealized_pnl_short_call_size() {
let option = setup_option(
Side::Short,
OptionStyle::Call,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
assert_relative_eq!(
position.max_profit().unwrap().to_f64(),
30.0,
epsilon = 0.001
);
}
#[test]
fn test_unrealized_pnl_long_put() {
let option = setup_option(
Side::Long,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap().to_f64(), 7.0, epsilon = 0.001);
assert_relative_eq!(position.max_profit().unwrap(), Positive::MAX);
}
#[test]
fn test_unrealized_pnl_long_put_size() {
let option = setup_option(
Side::Long,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap().to_f64(), 70.0, epsilon = 0.001);
assert_relative_eq!(position.max_profit().unwrap(), Positive::MAX);
}
#[test]
fn test_unrealized_pnl_short_put() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
Positive::ONE,
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
assert_relative_eq!(
position.max_profit().unwrap().to_f64(),
3.0,
epsilon = 0.001
);
}
#[test]
fn test_unrealized_pnl_short_put_size() {
let option = setup_option(
Side::Short,
OptionStyle::Put,
Positive::HUNDRED,
pos_or_panic!(105.0),
pos_or_panic!(10.0),
pos_or_panic!(30.0),
);
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
assert_relative_eq!(
position.max_profit().unwrap().to_f64(),
30.0,
epsilon = 0.001
);
}
}
#[cfg(test)]
mod tests_premium {
use super::*;
use positive::pos_or_panic;
fn setup_basic_position(side: Side) -> Position {
let option = Options {
side,
quantity: Positive::ONE,
..Default::default()
};
Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
)
}
#[test]
fn test_premium_received_long() {
let position = setup_basic_position(Side::Long);
assert_eq!(position.premium_received().unwrap(), Positive::ZERO);
}
#[test]
fn test_premium_received_short() {
let position = setup_basic_position(Side::Short);
assert_eq!(position.premium_received().unwrap(), 5.0);
}
#[test]
fn test_net_premium_received_long() {
let position = setup_basic_position(Side::Long);
assert_eq!(position.net_premium_received().unwrap(), 0.0);
}
#[test]
fn test_net_premium_received_short() {
let position = setup_basic_position(Side::Short);
assert_eq!(position.net_premium_received().unwrap(), 3.0); }
#[test]
fn test_premium_received_with_quantity() {
let side = Side::Short;
let option = Options {
side,
quantity: pos_or_panic!(10.0),
..Default::default()
};
let position = Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
);
assert_eq!(position.premium_received().unwrap(), 50.0);
}
}
#[cfg(test)]
mod tests_position_serde {
use super::*;
use crate::model::utils::create_sample_position;
use positive::pos_or_panic;
use serde_json;
use tracing::info;
#[test]
fn test_position_serialization() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let serialized = serde_json::to_string(&position).unwrap();
assert!(serialized.contains("\"option\""));
assert!(serialized.contains("\"premium\""));
assert!(serialized.contains("\"date\""));
assert!(serialized.contains("\"open_fee\""));
assert!(serialized.contains("\"close_fee\""));
assert!(serialized.contains("AAPL"));
assert!(serialized.contains("95"));
}
#[test]
fn test_position_deserialization() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let serialized = serde_json::to_string(&position).unwrap();
let deserialized: Position = serde_json::from_str(&serialized).unwrap();
assert_eq!(position, deserialized);
assert_eq!(deserialized.option.underlying_symbol, "AAPL");
assert_eq!(deserialized.option.strike_price, pos_or_panic!(95.0));
assert_eq!(deserialized.premium, pos_or_panic!(5.0));
assert_eq!(deserialized.open_fee, pos_or_panic!(0.5));
assert_eq!(deserialized.close_fee, pos_or_panic!(0.5));
}
#[test]
fn test_position_json_structure() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let serialized = serde_json::to_string_pretty(&position).unwrap();
info!("Serialized Position:\n{}", serialized);
let value: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert!(value.is_object());
assert!(value.get("option").is_some());
assert!(value.get("premium").is_some());
assert!(value.get("date").is_some());
assert!(value.get("open_fee").is_some());
assert!(value.get("close_fee").is_some());
}
#[test]
fn test_position_deserialize_invalid_json() {
let invalid_json = r#"{
"option": null,
"premium": 5.0,
"date": "2024-01-01T00:00:00Z",
"open_fee": 1.0,
"close_fee": 1.0
}"#;
let result: Result<Position, serde_json::Error> = serde_json::from_str(invalid_json);
assert!(result.is_err());
}
#[test]
fn test_position_roundtrip() {
let original = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: Position = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
let reserialized = serde_json::to_string(&deserialized).unwrap();
let redeserialized: Position = serde_json::from_str(&reserialized).unwrap();
assert_eq!(deserialized, redeserialized);
}
#[test]
fn test_position_with_different_option_types() {
let put_position = create_sample_position(
OptionStyle::Put,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let serialized = serde_json::to_string(&put_position).unwrap();
let deserialized: Position = serde_json::from_str(&serialized).unwrap();
assert_eq!(put_position, deserialized);
assert_eq!(deserialized.option.option_style, OptionStyle::Put);
let short_position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let serialized = serde_json::to_string(&short_position).unwrap();
let deserialized: Position = serde_json::from_str(&serialized).unwrap();
assert_eq!(short_position, deserialized);
assert_eq!(deserialized.option.side, Side::Short);
}
}
#[cfg(test)]
mod tests_position_tradeable_trait {
use super::*;
use crate::model::utils::create_sample_position;
use positive::pos_or_panic;
#[test]
fn test_tradeable_trade_ref() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let result = position.trade_ref();
assert!(result.is_err());
}
#[test]
fn test_tradeable_trade_mut() {
let mut position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let result = position.trade_mut();
assert!(result.is_err());
}
}
#[cfg(test)]
mod tests_position_tradestatusable_trait {
use super::*;
use crate::model::utils::create_sample_position;
use positive::pos_or_panic;
#[test]
fn test_tradestatusable_expired() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0), Positive::ONE, pos_or_panic!(95.0), pos_or_panic!(0.2), );
let result = position.expired();
assert!(result.is_ok());
}
#[test]
fn test_tradestatusable_exercised() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0), Positive::ONE, pos_or_panic!(95.0), pos_or_panic!(0.2), );
let result = position.exercised();
assert!(result.is_ok());
}
#[test]
fn test_tradestatusable_assigned() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0), Positive::ONE, pos_or_panic!(95.0), pos_or_panic!(0.2), );
let result = position.assigned();
assert!(result.is_ok());
}
#[test]
fn test_tradestatusable_status_other() {
let position = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0), Positive::ONE, pos_or_panic!(95.0), pos_or_panic!(0.2), );
let result = position.status_other();
assert!(result.is_ok());
}
#[test]
fn test_tradestatusable_close_zeros_tiny_premium() {
use crate::model::utils::create_sample_option_simplest;
let build = |premium: Positive| {
Position::new(
create_sample_option_simplest(OptionStyle::Call, Side::Long),
premium,
Utc::now(),
pos_or_panic!(0.01),
pos_or_panic!(0.01),
None,
None,
)
};
let position = build(pos_or_panic!(0.02));
let trade = position.close().expect("close() should succeed");
assert_eq!(trade.premium, pos_or_panic!(0.02));
let position = build(pos_or_panic!(0.01));
let trade = position.close().expect("close() should succeed");
assert_eq!(trade.premium, Positive::ZERO);
let position = build(pos_or_panic!(0.005));
let trade = position.close().expect("close() should succeed");
assert_eq!(trade.premium, Positive::ZERO);
}
}
#[cfg(test)]
mod tests_position_contract_size {
use super::*;
use crate::model::leg::{Leg, LegAble};
use crate::model::utils::create_sample_option;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
fn sized_call(side: Side, contract_size: Positive) -> Position {
let option = create_sample_option(
OptionStyle::Call,
side,
Positive::HUNDRED,
Positive::TWO,
Positive::HUNDRED,
pos_or_panic!(0.2),
)
.with_contract_size(contract_size);
Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
pos_or_panic!(0.5),
pos_or_panic!(0.5),
None,
None,
)
}
#[test]
fn test_position_contract_size_fees_stay_per_contract() {
let long = sized_call(Side::Long, Positive::HUNDRED);
assert_eq!(long.fees().ok(), Some(Positive::TWO));
let short = sized_call(Side::Short, Positive::HUNDRED);
assert_eq!(short.fees().ok(), Some(Positive::TWO));
}
#[test]
fn test_position_contract_size_long_cost_scales_premium_not_fees() {
let long = sized_call(Side::Long, Positive::HUNDRED);
assert_eq!(long.total_cost().ok(), Some(pos_or_panic!(1002.0)));
assert_eq!(long.premium_received().ok(), Some(Positive::ZERO));
assert_eq!(long.net_cost().ok(), Some(dec!(1002)));
}
#[test]
fn test_position_contract_size_short_premium_scales_fees_do_not() {
let short = sized_call(Side::Short, Positive::HUNDRED);
assert_eq!(short.total_cost().ok(), Some(Positive::TWO));
assert_eq!(short.premium_received().ok(), Some(pos_or_panic!(1000.0)));
assert_eq!(
short.net_premium_received().ok(),
Some(pos_or_panic!(998.0))
);
assert_eq!(short.net_cost().ok(), Some(dec!(-998)));
}
#[test]
fn test_position_contract_size_pnl_at_expiration_scales() {
let long = sized_call(Side::Long, Positive::HUNDRED);
assert_eq!(
long.pnl_at_expiration(&Some(&pos_or_panic!(120.0))).ok(),
Some(dec!(2998))
);
assert_eq!(
long.pnl_at_expiration(&Some(&pos_or_panic!(90.0))).ok(),
Some(dec!(-1002))
);
let short = sized_call(Side::Short, Positive::HUNDRED);
assert_eq!(
short.pnl_at_expiration(&Some(&pos_or_panic!(110.0))).ok(),
Some(dec!(-1002))
);
assert_eq!(
short.pnl_at_expiration(&Some(&pos_or_panic!(90.0))).ok(),
Some(dec!(998))
);
}
#[test]
fn test_position_contract_size_unrealized_pnl_scales_price_not_fees() {
let long = sized_call(Side::Long, Positive::HUNDRED);
assert_eq!(
long.unrealized_pnl(pos_or_panic!(7.0)).ok(),
Some(dec!(398))
);
let short = sized_call(Side::Short, Positive::HUNDRED);
assert_eq!(
short.unrealized_pnl(pos_or_panic!(3.0)).ok(),
Some(dec!(398))
);
}
#[test]
fn test_position_contract_size_break_even_spreads_fees_over_units() {
let long = sized_call(Side::Long, Positive::HUNDRED);
assert_eq!(long.break_even(), Some(pos_or_panic!(105.01)));
let short = sized_call(Side::Short, Positive::HUNDRED);
assert_eq!(short.break_even(), Some(pos_or_panic!(104.99)));
for position in [long, short] {
let Some(break_even) = position.break_even() else {
panic!("break-even exists");
};
assert_eq!(
position.pnl_at_expiration(&Some(&break_even)).ok(),
Some(Decimal::ZERO)
);
}
}
#[test]
fn test_position_contract_size_one_matches_default() {
let default = sized_call(Side::Long, Positive::ONE);
let mut legacy = default.clone();
legacy.option = create_sample_option(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED,
Positive::TWO,
Positive::HUNDRED,
pos_or_panic!(0.2),
);
assert_eq!(default, legacy);
assert_eq!(default.total_cost().ok(), Some(pos_or_panic!(12.0)));
}
#[test]
fn test_position_contract_size_trade_carries_contract_size() {
let long = sized_call(Side::Long, Positive::HUNDRED);
let Ok(trade) = long.trade() else {
panic!("trade builds");
};
assert_eq!(trade.premium, pos_or_panic!(5.0));
assert_eq!(trade.contract_size, Positive::HUNDRED);
assert_eq!(trade.fee, Positive::ONE);
assert_eq!(trade.quantity, Positive::TWO);
assert_eq!(trade.cost().unwrap(), pos_or_panic!(1002.0));
assert_eq!(trade.income().unwrap(), Positive::ZERO);
assert_eq!(trade.net().unwrap(), dec!(-1002));
let Ok(sold) = sized_call(Side::Short, Positive::HUNDRED).trade() else {
panic!("trade builds");
};
let mut sold = sold;
sold.action = Action::Sell;
assert_eq!(sold.cost().unwrap(), pos_or_panic!(1002.0));
}
#[test]
fn test_position_contract_size_trade_matches_unit_lots() {
let Ok(sized) = sized_call(Side::Long, Positive::HUNDRED).trade() else {
panic!("trade builds");
};
let mut lots = sized.clone();
lots.contract_size = Positive::ONE;
lots.quantity = pos_or_panic!(200.0);
lots.fee = pos_or_panic!(0.01);
assert_eq!(sized.cost().unwrap(), lots.cost().unwrap());
assert_eq!(sized.net().unwrap(), lots.net().unwrap());
}
#[test]
fn test_position_contract_size_trade_serde_round_trip() {
let Ok(trade) = sized_call(Side::Long, Positive::HUNDRED).trade() else {
panic!("trade builds");
};
let Ok(json) = serde_json::to_string(&trade) else {
panic!("trade serializes");
};
let Ok(back) = serde_json::from_str::<Trade>(&json) else {
panic!("trade deserializes");
};
assert_eq!(back.contract_size, Positive::HUNDRED);
assert_eq!(back, trade);
}
#[test]
fn test_position_contract_size_leg_notional_covers_all_units() {
let leg = Leg::from(sized_call(Side::Long, Positive::HUNDRED));
assert_eq!(leg.get_quantity(), Positive::TWO);
assert_eq!(
leg.notional_value(Positive::HUNDRED).ok(),
Some(pos_or_panic!(20000.0))
);
assert_eq!(
leg.pnl_at_price(pos_or_panic!(120.0)).ok(),
Some(dec!(2998))
);
}
}