use crate::chains::OptionData;
use crate::error::position::PositionValidationErrorKind;
use crate::error::{
GreeksError, PositionError, PricingError, StrategyError, TradeError, TransactionError,
};
use crate::greeks::Greeks;
use crate::model::trade::TradeStatusAble;
use crate::model::types::{Action, OptionBasicType, OptionStyle, Side};
use crate::model::{Trade, TradeAble, TradeStatus};
use crate::pnl::utils::PnL;
use crate::pnl::{PnLCalculator, Transaction, TransactionAble};
use crate::pricing::payoff::Profit;
use crate::strategies::base::BasicAble;
use crate::visualization::{Graph, GraphConfig, GraphData};
use crate::{ExpirationDate, OptionType, Options};
use chrono::{DateTime, Utc};
use num_traits::ToPrimitive;
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tracing::{debug, trace};
use utoipa::ToSchema;
#[derive(Clone, PartialEq, Serialize, Deserialize, 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 {
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,
}
}
#[allow(dead_code)]
pub(crate) fn update_from_option_data(
&mut self,
option_data: &OptionData,
) -> Result<(), PositionError> {
self.date = Utc::now();
self.option.update_from_option_data(option_data);
match (self.option.side, self.option.option_style) {
(Side::Long, OptionStyle::Call) => {
self.premium = option_data.call_ask.ok_or_else(|| {
PositionError::invalid_position_update(
"premium".to_string(),
"Missing call ask price for long call position".to_string(),
)
})?;
}
(Side::Long, OptionStyle::Put) => {
self.premium = option_data.put_ask.ok_or_else(|| {
PositionError::invalid_position_update(
"premium".to_string(),
"Missing put ask price for long put position".to_string(),
)
})?;
}
(Side::Short, OptionStyle::Call) => {
self.premium = option_data.call_bid.ok_or_else(|| {
PositionError::invalid_position_update(
"premium".to_string(),
"Missing call bid price for short call position".to_string(),
)
})?;
}
(Side::Short, OptionStyle::Put) => {
self.premium = option_data.put_bid.ok_or_else(|| {
PositionError::invalid_position_update(
"premium".to_string(),
"Missing put bid price for short put position".to_string(),
)
})?;
}
}
trace!("Updated position: {:#?}", self);
Ok(())
}
pub fn total_cost(&self) -> Result<Positive, PositionError> {
let total_cost = match self.option.side {
Side::Long => (self.premium + self.open_fee + self.close_fee) * 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 * 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 * self.option.quantity;
let total_cost = self.total_cost()?;
if premium >= total_cost {
Ok(premium - total_cost)
} else {
Ok(Positive::ZERO)
}
}
}
}
pub fn pnl_at_expiration(&self, price: &Option<&Positive>) -> Result<Decimal, PricingError> {
match price {
None => Ok(self.option.intrinsic_value(self.option.underlying_price)?
- self.total_cost()?
+ self.premium_received()?),
Some(price) => Ok(self.option.intrinsic_value(**price)? - self.total_cost()?
+ self.premium_received()?),
}
}
pub fn unrealized_pnl(&self, price: Positive) -> Result<Decimal, PositionError> {
match self.option.side {
Side::Long => Ok((price.to_dec()
- self.premium.to_dec()
- self.open_fee.to_dec()
- self.close_fee.to_dec())
* self.option.quantity),
Side::Short => Ok((self.premium.to_dec()
- price.to_dec()
- self.open_fee.to_dec()
- self.close_fee.to_dec())
* self.option.quantity),
}
}
pub fn days_held(&self) -> Result<Positive, PositionError> {
let days = (Utc::now() - 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),
})
})
}
}
}
pub fn is_long(&self) -> bool {
match self.option.side {
Side::Long => true,
Side::Short => false,
}
}
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(fees - premium)
}
}
}
pub fn break_even(&self) -> Option<Positive> {
if self.option.quantity == Positive::ZERO {
return None;
}
if let Ok(position_total_cost) = self.total_cost() {
let total_cost_per_contract = position_total_cost / self.option.quantity;
match (&self.option.side, &self.option.option_style) {
(Side::Long, OptionStyle::Call) => {
Some(self.option.strike_price + total_cost_per_contract)
}
(Side::Short, OptionStyle::Call) => {
Some(self.option.strike_price + self.premium - total_cost_per_contract)
}
(Side::Long, OptionStyle::Put) => {
Some(self.option.strike_price - total_cost_per_contract)
}
(Side::Short, OptionStyle::Put) => {
Some(self.option.strike_price - self.premium + total_cost_per_contract)
}
}
} else {
None
}
}
#[allow(dead_code)]
pub(crate) fn max_profit(&self) -> Result<Positive, PositionError> {
match self.option.side {
Side::Long => Ok(Positive::INFINITY),
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::INFINITY),
}
}
pub fn fees(&self) -> Result<Positive, PositionError> {
Ok((self.open_fee + self.close_fee) * 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 Greeks for Position {
fn get_options(&self) -> Result<Vec<&Options>, GreeksError> {
Ok(vec![&self.option])
}
}
impl TransactionAble for Position {
fn add_transaction(&mut self, _transaction: Transaction) -> Result<(), TransactionError> {
todo!()
}
fn get_transactions(&self) -> Result<Vec<Transaction>, TransactionError> {
todo!()
}
}
impl TradeAble for Position {
fn trade(&self) -> Result<Trade, TradeError> {
if let (Ok(expiry), Some(timestamp)) = (
self.option.expiration_date.get_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: self.open_fee + self.close_fee,
symbol: None,
strike: self.option.strike_price,
expiry,
timestamp,
quantity: self.option.quantity,
premium: self.premium,
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()?;
let threshold = unsafe { Positive::new_unchecked(rust_decimal_macros::dec!(0.01)) };
if trade.premium <= threshold {
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)
}
}
impl PnLCalculator for Position {
fn calculate_pnl(
&self,
underlying_price: &Positive,
expiration_date: ExpirationDate,
implied_volatility: &Positive,
) -> Result<PnL, PricingError> {
let price_at_buy = self.option.calculate_price_black_scholes()?;
let mut current_option = self.option.clone();
current_option.expiration_date = expiration_date;
current_option.underlying_price = *underlying_price;
current_option.implied_volatility = *implied_volatility;
let price_at_sell = current_option.calculate_price_black_scholes()?;
let unrealized = price_at_sell - price_at_buy;
let initial_cost = self.total_cost()?;
let initial_income = self.premium_received()?;
let realized = initial_income.to_dec() - initial_cost.to_dec();
Ok(PnL::new(
Some(realized),
Some(unrealized),
initial_cost,
initial_income,
self.date,
))
}
fn calculate_pnl_at_expiration(
&self,
underlying_price: &Positive,
) -> Result<PnL, PricingError> {
let initial_cost = self.total_cost()?;
let initial_income = self.premium_received()?;
let date_time = self.option.expiration_date.get_date()?;
let realized = self.option.intrinsic_value(*underlying_price)? - initial_cost.to_dec()
+ initial_income.to_dec();
Ok(PnL::new(
Some(realized),
Some(Decimal::ZERO),
initial_cost,
initial_income,
date_time,
))
}
fn diff_position_pnl(&self, position: &Position) -> Result<PnL, PricingError> {
if self.option.option_type != position.option.option_type {
return Err(PositionError::invalid_position(&format!(
"Option types do not match: {:?} vs {:?}",
self.option.option_type, position.option.option_type
))
.into());
}
if self.option.side != position.option.side {
return Err(PositionError::invalid_position(&format!(
"Sides do not match: {:?} vs {:?}",
self.option.side, position.option.side
))
.into());
}
if self.option.underlying_symbol != position.option.underlying_symbol {
return Err(PositionError::invalid_position(&format!(
"Underlying symbols do not match: {} vs {}",
self.option.underlying_symbol, position.option.underlying_symbol
))
.into());
}
if self.option.strike_price != position.option.strike_price {
return Err(PositionError::invalid_position(&format!(
"Strike prices do not match: {} vs {}",
self.option.strike_price, position.option.strike_price
))
.into());
}
if self.option.expiration_date != position.option.expiration_date {
return Err(PositionError::invalid_position(&format!(
"Expiration dates do not match: {:?} vs {:?}",
self.option.expiration_date, position.option.expiration_date
))
.into());
}
if self.option.quantity != position.option.quantity {
return Err(PositionError::invalid_position(&format!(
"Quantities do not match: {} vs {}",
self.option.quantity, position.option.quantity
))
.into());
}
if self.epic != position.epic {
return Err(PositionError::invalid_position(&format!(
"Epics do not match: {:?} vs {:?}",
self.epic, position.epic
))
.into());
}
let self_pnl = self.calculate_pnl(
&self.option.underlying_price,
self.option.expiration_date,
&self.option.implied_volatility,
)?;
let other_pnl = position.calculate_pnl(
&position.option.underlying_price,
position.option.expiration_date,
&position.option.implied_volatility,
)?;
let realized_diff = match (self_pnl.realized, other_pnl.realized) {
(Some(self_realized), Some(other_realized)) => Some(self_realized - other_realized),
_ => None,
};
let unrealized_diff = match (self_pnl.unrealized, other_pnl.unrealized) {
(Some(self_unrealized), Some(other_unrealized)) => {
Some(self_unrealized - other_unrealized)
}
_ => None,
};
let cost_diff = self_pnl.initial_costs.to_dec() - other_pnl.initial_costs.to_dec();
let income_diff = self_pnl.initial_income.to_dec() - other_pnl.initial_income.to_dec();
let initial_costs = Positive::new(cost_diff.abs().to_f64().ok_or_else(|| {
PricingError::other("initial_costs: cost_diff Decimal cannot be represented as f64")
})?)
.map_err(|_| PricingError::other("initial_costs value is not strictly positive"))?;
let initial_income = Positive::new(income_diff.abs().to_f64().ok_or_else(|| {
PricingError::other("initial_income: income_diff Decimal cannot be represented as f64")
})?)
.map_err(|_| PricingError::other("initial_income value is not strictly positive"))?;
Ok(PnL {
realized: realized_diff,
unrealized: unrealized_diff,
initial_costs,
initial_income,
date_time: self.date,
})
}
}
impl Profit for Position {
fn calculate_profit_at(&self, price: &Positive) -> Result<Decimal, PricingError> {
self.pnl_at_expiration(&Some(price))
}
}
impl BasicAble for Position {
fn get_title(&self) -> String {
self.option.get_title()
}
fn get_option_basic_type(&self) -> HashSet<OptionBasicType<'_>> {
self.option.get_option_basic_type()
}
fn get_symbol(&self) -> &str {
self.option.get_symbol()
}
fn get_strike(&self) -> HashMap<OptionBasicType<'_>, &Positive> {
self.option.get_strike()
}
fn get_strikes(&self) -> Vec<&Positive> {
self.option.get_strikes()
}
fn get_side(&self) -> HashMap<OptionBasicType<'_>, &Side> {
self.option.get_side()
}
fn get_type(&self) -> &OptionType {
self.option.get_type()
}
fn get_style(&self) -> HashMap<OptionBasicType<'_>, &OptionStyle> {
self.option.get_style()
}
fn get_expiration(&self) -> HashMap<OptionBasicType<'_>, &ExpirationDate> {
self.option.get_expiration()
}
fn get_implied_volatility(&self) -> HashMap<OptionBasicType<'_>, &Positive> {
self.option.get_implied_volatility()
}
fn get_quantity(&self) -> HashMap<OptionBasicType<'_>, &Positive> {
self.option.get_quantity()
}
fn get_underlying_price(&self) -> &Positive {
self.option.get_underlying_price()
}
fn get_risk_free_rate(&self) -> HashMap<OptionBasicType<'_>, &Decimal> {
self.option.get_risk_free_rate()
}
fn get_dividend_yield(&self) -> HashMap<OptionBasicType<'_>, &Positive> {
self.option.get_dividend_yield()
}
fn one_option(&self) -> &Options {
&self.option
}
fn one_option_mut(&mut self) -> &mut Options {
&mut self.option
}
fn set_expiration_date(
&mut self,
expiration_date: ExpirationDate,
) -> Result<(), StrategyError> {
self.option.set_expiration_date(expiration_date)
}
fn set_underlying_price(&mut self, _price: &Positive) -> Result<(), StrategyError> {
self.option.set_underlying_price(_price)
}
fn set_implied_volatility(&mut self, _volatility: &Positive) -> Result<(), StrategyError> {
self.option.set_implied_volatility(_volatility)
}
}
impl Graph for Position {
fn graph_data(&self) -> GraphData {
self.option.graph_data()
}
fn graph_config(&self) -> GraphConfig {
self.option.graph_config()
}
}
#[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,
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_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,
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,
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::INFINITY);
}
#[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::INFINITY);
}
#[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::INFINITY);
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::INFINITY);
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::INFINITY);
}
#[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::INFINITY);
}
#[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::INFINITY);
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::INFINITY);
assert_relative_eq!(
position.max_profit().unwrap().to_f64(),
30.0,
epsilon = 0.001
);
}
}
#[cfg(test)]
mod tests_update_from_option_data {
use super::*;
use positive::{pos_or_panic, spos};
use rust_decimal_macros::dec;
fn create_test_option_data() -> OptionData {
OptionData::new(
pos_or_panic!(110.0),
spos!(9.5),
spos!(10.0),
spos!(8.5),
spos!(9.0),
pos_or_panic!(0.25),
Some(dec!(-0.3)),
Some(dec!(0.3)),
Some(dec!(0.3)),
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
fn create_wrong_call_ask_test_option_data() -> OptionData {
OptionData::new(
pos_or_panic!(110.0),
spos!(9.5), None, spos!(8.5), spos!(9.0), pos_or_panic!(0.25), Some(dec!(-0.3)), Some(dec!(0.3)), Some(dec!(0.3)), None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
fn create_wrong_put_ask_test_option_data() -> OptionData {
OptionData::new(
pos_or_panic!(110.0),
spos!(9.5), spos!(10.0), spos!(8.5), None, pos_or_panic!(0.25), Some(dec!(-0.3)), Some(dec!(0.3)), Some(dec!(0.3)), None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
fn create_wrong_call_bid_test_option_data() -> OptionData {
OptionData::new(
pos_or_panic!(110.0),
None, spos!(10.0), spos!(8.5), spos!(9.0), pos_or_panic!(0.25), Some(dec!(-0.3)), Some(dec!(0.3)), Some(dec!(0.3)), None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
fn create_wrong_put_bid_test_option_data() -> OptionData {
OptionData::new(
pos_or_panic!(110.0),
spos!(9.5), spos!(10.0), None, spos!(9.0), pos_or_panic!(0.25), Some(dec!(-0.3)), Some(dec!(0.3)), Some(dec!(0.3)), None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
#[test]
fn test_update_long_call() {
let mut position = Position::default();
position.option.side = Side::Long;
position.option.option_style = OptionStyle::Call;
let option_data = create_test_option_data();
let _ = position.update_from_option_data(&option_data);
assert_eq!(position.option.strike_price, pos_or_panic!(110.0));
assert_eq!(position.option.implied_volatility, 0.25);
assert_eq!(position.premium, 10.0); }
#[test]
fn test_update_short_call() {
let mut position = Position::default();
position.option.side = Side::Short;
position.option.option_style = OptionStyle::Call;
let option_data = create_test_option_data();
let _ = position.update_from_option_data(&option_data);
assert_eq!(position.premium, 9.5); }
#[test]
fn test_update_long_put() {
let mut position = Position::default();
position.option.side = Side::Long;
position.option.option_style = OptionStyle::Put;
let option_data = create_test_option_data();
let _ = position.update_from_option_data(&option_data);
assert_eq!(position.premium, 9.0); }
#[test]
fn test_update_short_put() {
let mut position = Position::default();
position.option.side = Side::Short;
position.option.option_style = OptionStyle::Put;
let option_data = create_test_option_data();
let _ = position.update_from_option_data(&option_data);
assert_eq!(position.premium, 8.5); }
#[test]
fn test_update_wrong_long_call() {
let mut position = Position::default();
position.option.side = Side::Long;
position.option.option_style = OptionStyle::Call;
let option_data = create_wrong_call_ask_test_option_data();
let result = position.update_from_option_data(&option_data);
assert!(result.is_err());
}
#[test]
fn test_update_wrong_long_put() {
let mut position = Position::default();
position.option.side = Side::Long;
position.option.option_style = OptionStyle::Put;
let option_data = create_wrong_put_ask_test_option_data();
let result = position.update_from_option_data(&option_data);
assert!(result.is_err());
}
#[test]
fn test_update_wrong_short_call() {
let mut position = Position::default();
position.option.side = Side::Short;
position.option.option_style = OptionStyle::Call;
let option_data = create_wrong_call_bid_test_option_data();
let result = position.update_from_option_data(&option_data);
assert!(result.is_err());
}
#[test]
fn test_update_wrong_short_put() {
let mut position = Position::default();
position.option.side = Side::Short;
position.option.option_style = OptionStyle::Put;
let option_data = create_wrong_put_bid_test_option_data();
let result = position.update_from_option_data(&option_data);
assert!(result.is_err());
}
}
#[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_pnl_calculator {
use super::*;
use crate::{OptionType, assert_decimal_eq};
use positive::pos_or_panic;
use rust_decimal_macros::dec;
fn setup_test_position(side: Side, option_style: OptionStyle) -> Position {
let option = Options::new(
OptionType::European,
side,
"AAPL".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
option_style,
Positive::ZERO,
None,
);
Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
None,
None,
)
}
#[test]
fn test_calculate_pnl_long_call_no_changes() {
let position = setup_test_position(Side::Long, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_eq!(pnl.unrealized.unwrap(), Decimal::ZERO); assert_eq!(position.total_cost().unwrap(), 7.0);
assert_eq!(position.premium_received().unwrap(), 0.0);
}
#[test]
fn test_calculate_pnl_long_call_price_up() {
let position = setup_test_position(Side::Long, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&pos_or_panic!(107.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(5.2150), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_long_call_vol_down() {
let position = setup_test_position(Side::Long, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.1),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(-1.1352), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_long_call_date_closer() {
let position = setup_test_position(Side::Long, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(3.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(-1.7494), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_short_call_no_changes() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_eq!(pnl.unrealized.unwrap(), Decimal::ZERO); assert_eq!(position.total_cost().unwrap(), 2.0);
assert_eq!(position.premium_received().unwrap(), 5.0);
}
#[test]
fn test_calculate_pnl_short_call_price_up() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&pos_or_panic!(107.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(-5.2150), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_short_call_price_down() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&pos_or_panic!(97.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(1.3069), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_short_call_vol_down() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.1),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(1.1352), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_short_call_vol_up() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
&pos_or_panic!(0.3),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(-1.1386), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_short_call_date_closer() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(3.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(1.7494), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_short_call_date_further() {
let position = setup_test_position(Side::Short, OptionStyle::Call);
let pnl = position
.calculate_pnl(
&Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(40.0)),
&pos_or_panic!(0.2),
)
.unwrap();
assert_decimal_eq!(pnl.unrealized.unwrap(), dec!(-0.4224), dec!(0.0001));
}
#[test]
fn test_calculate_pnl_at_expiration_long_call() {
let position = setup_test_position(Side::Long, OptionStyle::Call);
let pnl = position
.calculate_pnl_at_expiration(&pos_or_panic!(110.0))
.unwrap();
assert_eq!(pnl.realized.unwrap(), dec!(3.0)); assert_eq!(position.total_cost().unwrap(), 7.0);
assert_eq!(position.premium_received().unwrap(), 0.0);
}
#[test]
fn test_calculate_pnl_at_expiration_short_put() {
let position = setup_test_position(Side::Short, OptionStyle::Put);
let pnl = position
.calculate_pnl_at_expiration(&pos_or_panic!(90.0))
.unwrap();
assert_eq!(pnl.realized.unwrap(), dec!(-7.0)); assert_eq!(position.total_cost().unwrap(), 2.0);
assert_eq!(position.premium_received().unwrap(), 5.0);
}
fn setup_test_position_with_epic(
side: Side,
option_style: OptionStyle,
epic: &str,
) -> Position {
let option = Options::new(
OptionType::European,
side,
"AAPL".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
option_style,
Positive::ZERO,
None,
);
Position::new(
option,
pos_or_panic!(5.0),
Utc::now(),
Positive::ONE,
Positive::ONE,
Some(epic.to_string()),
None,
)
}
#[test]
fn test_from_position_pnl_compatible_positions() {
let position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let result = position1.diff_position_pnl(&position2);
assert!(result.is_ok());
}
#[test]
fn test_from_position_pnl_different_option_type() {
let mut position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let mut position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
position1.option.option_type = OptionType::European;
position2.option.option_type = OptionType::American;
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Option types do not match")
);
}
#[test]
fn test_from_position_pnl_different_side() {
let position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let position2 = setup_test_position_with_epic(Side::Short, OptionStyle::Call, "EPIC123");
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Sides do not match")
);
}
#[test]
fn test_from_position_pnl_different_underlying_symbol() {
let mut position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let mut position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
position1.option.underlying_symbol = "AAPL".to_string();
position2.option.underlying_symbol = "MSFT".to_string();
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Underlying symbols do not match")
);
}
#[test]
fn test_from_position_pnl_different_strike_price() {
let mut position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let mut position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
position1.option.strike_price = Positive::HUNDRED;
position2.option.strike_price = pos_or_panic!(105.0);
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Strike prices do not match")
);
}
#[test]
fn test_from_position_pnl_different_expiration_date() {
let mut position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let mut position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
position1.option.expiration_date = ExpirationDate::Days(pos_or_panic!(30.0));
position2.option.expiration_date = ExpirationDate::Days(pos_or_panic!(60.0));
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Expiration dates do not match")
);
}
#[test]
fn test_from_position_pnl_different_quantity() {
let mut position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let mut position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
position1.option.quantity = Positive::ONE;
position2.option.quantity = Positive::TWO;
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Quantities do not match")
);
}
#[test]
fn test_from_position_pnl_different_epic() {
let position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC456");
let result = position1.diff_position_pnl(&position2);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Epics do not match")
);
}
#[test]
fn test_from_position_pnl_calculation() {
let position1 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let position2 = setup_test_position_with_epic(Side::Long, OptionStyle::Call, "EPIC123");
let result = position1.diff_position_pnl(&position2);
assert!(result.is_ok());
let pnl = result.unwrap();
assert_eq!(pnl.realized, Some(Decimal::ZERO));
assert_eq!(pnl.unrealized, Some(Decimal::ZERO));
assert_eq!(pnl.initial_costs, Positive::ZERO);
assert_eq!(pnl.initial_income, Positive::ZERO);
}
fn setup_test_position_with_premium(
side: Side,
option_style: OptionStyle,
epic: &str,
premium: Positive,
) -> Position {
let option = Options::new(
OptionType::European,
side,
"AAPL".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
option_style,
Positive::ZERO,
None,
);
Position::new(
option,
premium,
Utc::now(),
Positive::ONE,
Positive::ONE,
Some(epic.to_string()),
None,
)
}
#[test]
fn test_from_position_pnl_short_call() {
let position1 = setup_test_position_with_premium(
Side::Short,
OptionStyle::Call,
"EPIC123",
pos_or_panic!(5.0),
);
let position2 = setup_test_position_with_premium(
Side::Short,
OptionStyle::Call,
"EPIC123",
pos_or_panic!(3.0),
);
let result = position1.diff_position_pnl(&position2);
assert!(result.is_ok());
let pnl = result.unwrap();
assert_eq!(pnl.realized, Some(dec!(2.0)));
}
#[test]
fn test_from_position_pnl_long_call() {
let position1 = setup_test_position_with_premium(
Side::Long,
OptionStyle::Call,
"EPIC123",
pos_or_panic!(5.0),
);
let position2 = setup_test_position_with_premium(
Side::Long,
OptionStyle::Call,
"EPIC123",
pos_or_panic!(3.0),
);
let result = position1.diff_position_pnl(&position2);
assert!(result.is_ok());
let pnl = result.unwrap();
assert_eq!(pnl.realized, Some(dec!(-2.0)));
}
#[test]
fn test_from_position_pnl_short_put() {
let position1 = setup_test_position_with_premium(
Side::Short,
OptionStyle::Put,
"EPIC123",
pos_or_panic!(4.0),
);
let position2 = setup_test_position_with_premium(
Side::Short,
OptionStyle::Put,
"EPIC123",
pos_or_panic!(2.5),
);
let result = position1.diff_position_pnl(&position2);
assert!(result.is_ok());
let pnl = result.unwrap();
assert_eq!(pnl.realized, Some(dec!(1.5)));
}
#[test]
fn test_from_position_pnl_long_put() {
let position1 = setup_test_position_with_premium(
Side::Long,
OptionStyle::Put,
"EPIC123",
pos_or_panic!(4.0),
);
let position2 = setup_test_position_with_premium(
Side::Long,
OptionStyle::Put,
"EPIC123",
pos_or_panic!(2.5),
);
let result = position1.diff_position_pnl(&position2);
assert!(result.is_ok());
let pnl = result.unwrap();
assert_eq!(pnl.realized, Some(dec!(-1.5)));
}
}
#[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());
}
}