#![allow(clippy::indexing_slicing)]
use super::base::{
BreakEvenable, Optimizable, Positionable, Strategable, StrategyBasics, StrategyType, Validable,
};
use super::shared::ButterflyStrategy;
use crate::{
ExpirationDate, Options,
chains::{StrategyLegs, chain::OptionChain, utils::OptionDataGroup},
error::{
GreeksError, OperationErrorKind, PricingError,
position::{PositionError, PositionValidationErrorKind},
probability::ProbabilityError,
strategies::{ProfitLossErrorKind, StrategyError},
},
greeks::Greeks,
model::{
ProfitLossRange,
decimal::d_sum,
position::Position,
types::{OptionBasicType, OptionStyle, OptionType, Side},
utils::mean_and_std,
},
pnl::{PnLCalculator, utils::PnL},
pricing::payoff::Profit,
strategies::{
BasicAble, Strategies, StrategyConstructor,
delta_neutral::DeltaNeutrality,
probabilities::{core::ProbabilityAnalysis, utils::VolatilityAdjustment},
utils::{FindOptimalSide, OptimizationCriteria},
},
test_strategy_traits,
};
use chrono::Utc;
use num_traits::FromPrimitive;
use positive::Positive;
#[cfg(test)]
use positive::pos_or_panic;
use pretty_simple_display::{DebugPretty, DisplaySimple};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tracing::{debug, info};
use utoipa::ToSchema;
pub const LONG_BUTTERFLY_DESCRIPTION: &str = "A long butterfly spread is created by buying one call at a lower strike price, \
selling two calls at a middle strike price, and buying one call at a higher strike price, \
all with the same expiration date. This strategy profits when the underlying price stays \
near the middle strike price at expiration.";
#[derive(Clone, DebugPretty, DisplaySimple, Serialize, Deserialize, ToSchema)]
pub struct LongButterflySpread {
pub name: String,
pub kind: StrategyType,
pub description: String,
pub break_even_points: Vec<Positive>,
pub short_call: Position,
pub long_call_low: Position,
pub long_call_high: Position,
}
impl LongButterflySpread {
#[allow(clippy::too_many_arguments)]
#[inline(never)]
pub fn new(
underlying_symbol: String,
underlying_price: Positive,
low_strike: Positive,
middle_strike: Positive,
high_strike: Positive,
expiration: ExpirationDate,
implied_volatility: Positive,
risk_free_rate: Decimal,
dividend_yield: Positive,
quantity: Positive,
premium_low: Positive,
premium_middle: Positive,
premium_high: Positive,
open_fee_short_call: Positive,
close_fee_short_call: Positive,
open_fee_long_call_low: Positive,
close_fee_long_call_low: Positive,
open_fee_long_call_high: Positive,
close_fee_long_call_high: Positive,
) -> Result<Self, StrategyError> {
let mut strategy = LongButterflySpread {
name: "Long Butterfly".to_string(),
kind: StrategyType::LongButterflySpread,
description: LONG_BUTTERFLY_DESCRIPTION.to_string(),
break_even_points: Vec::new(),
long_call_low: Position::default(),
short_call: Position::default(),
long_call_high: Position::default(),
};
let long_calls = Options::new(
OptionType::European,
Side::Short,
underlying_symbol.clone(),
middle_strike,
expiration,
implied_volatility,
quantity * 2.0, underlying_price,
risk_free_rate,
OptionStyle::Call,
dividend_yield,
None,
);
strategy.short_call = Position::new(
long_calls,
premium_middle,
Utc::now(),
open_fee_short_call,
close_fee_short_call,
None,
None,
);
let long_call_low = Options::new(
OptionType::European,
Side::Long,
underlying_symbol.clone(),
low_strike,
expiration,
implied_volatility,
quantity,
underlying_price,
risk_free_rate,
OptionStyle::Call,
dividend_yield,
None,
);
strategy.long_call_low = Position::new(
long_call_low,
premium_low,
Utc::now(),
open_fee_long_call_low,
close_fee_long_call_low,
None,
None,
);
let long_call_high = Options::new(
OptionType::European,
Side::Long,
underlying_symbol,
high_strike,
expiration,
implied_volatility,
quantity,
underlying_price,
risk_free_rate,
OptionStyle::Call,
dividend_yield,
None,
);
strategy.long_call_high = Position::new(
long_call_high,
premium_high,
Utc::now(),
open_fee_long_call_high,
close_fee_long_call_high,
None,
None,
);
strategy.validate();
strategy.update_break_even_points()?;
Ok(strategy)
}
}
impl StrategyConstructor for LongButterflySpread {
fn get_strategy(vec_positions: &[Position]) -> Result<Self, StrategyError> {
if vec_positions.len() != 3 {
return Err(StrategyError::OperationError(
OperationErrorKind::InvalidParameters {
operation: "Long Butterfly Spread get_strategy".to_string(),
reason: "Must have exactly 3 options".to_string(),
},
));
}
let mut sorted_positions = vec_positions.to_vec();
sorted_positions.sort_by(|a, b| {
a.option
.strike_price
.partial_cmp(&b.option.strike_price)
.unwrap_or(std::cmp::Ordering::Equal)
});
let lower_strike_position = &sorted_positions[0];
let middle_strike_position = &sorted_positions[1];
let higher_strike_position = &sorted_positions[2];
if lower_strike_position.option.option_style != OptionStyle::Call
|| middle_strike_position.option.option_style != OptionStyle::Call
|| higher_strike_position.option.option_style != OptionStyle::Call
{
return Err(StrategyError::OperationError(
OperationErrorKind::InvalidParameters {
operation: "Long Butterfly Spread get_strategy".to_string(),
reason: "Options must be calls".to_string(),
},
));
}
if lower_strike_position.option.side != Side::Long
|| middle_strike_position.option.side != Side::Short
|| higher_strike_position.option.side != Side::Long
{
return Err(StrategyError::OperationError(
OperationErrorKind::InvalidParameters {
operation: "Long Butterfly Spread get_strategy".to_string(),
reason: "Long Butterfly requires long lower and higher strikes with a short middle strike".to_string(),
},
));
}
let lower_strike = lower_strike_position.option.strike_price;
let middle_strike = middle_strike_position.option.strike_price;
let higher_strike = higher_strike_position.option.strike_price;
if middle_strike - lower_strike != higher_strike - middle_strike {
return Err(StrategyError::OperationError(
OperationErrorKind::InvalidParameters {
operation: "Long Butterfly Spread get_strategy".to_string(),
reason: "Strikes must be symmetrical".to_string(),
},
));
}
if vec_positions
.iter()
.any(|opt| opt.option.expiration_date != lower_strike_position.option.expiration_date)
{
return Err(StrategyError::OperationError(
OperationErrorKind::InvalidParameters {
operation: "Long Butterfly Spread get_strategy".to_string(),
reason: "Options must have the same expiration date".to_string(),
},
));
}
let strategy = LongButterflySpread {
name: "Long Butterfly Spread".to_string(),
kind: StrategyType::LongButterflySpread,
description: LONG_BUTTERFLY_DESCRIPTION.to_string(),
break_even_points: Vec::new(),
short_call: Position::new(
middle_strike_position.option.clone(),
middle_strike_position.premium,
Utc::now(),
middle_strike_position.open_fee,
middle_strike_position.close_fee,
middle_strike_position.epic.clone(),
middle_strike_position.extra_fields.clone(),
),
long_call_low: Position::new(
lower_strike_position.option.clone(),
lower_strike_position.premium,
Utc::now(),
lower_strike_position.open_fee,
lower_strike_position.close_fee,
lower_strike_position.epic.clone(),
lower_strike_position.extra_fields.clone(),
),
long_call_high: Position::new(
higher_strike_position.option.clone(),
higher_strike_position.premium,
Utc::now(),
higher_strike_position.open_fee,
higher_strike_position.close_fee,
higher_strike_position.epic.clone(),
higher_strike_position.extra_fields.clone(),
),
};
Ok(strategy)
}
}
impl BreakEvenable for LongButterflySpread {
fn get_break_even_points(&self) -> Result<&Vec<Positive>, StrategyError> {
Ok(&self.break_even_points)
}
fn update_break_even_points(&mut self) -> Result<(), StrategyError> {
self.break_even_points = Vec::new();
let left_net_value = self.calculate_profit_at(&self.long_call_low.option.strike_price)?
/ self.long_call_low.option.quantity;
let right_net_value = self.calculate_profit_at(&self.long_call_high.option.strike_price)?
/ self.long_call_high.option.quantity;
if left_net_value <= Decimal::ZERO {
self.break_even_points
.push((self.long_call_low.option.strike_price - left_net_value).round_to(2));
}
if right_net_value <= Decimal::ZERO {
self.break_even_points
.push((self.long_call_high.option.strike_price + right_net_value).round_to(2));
}
self.break_even_points.sort();
Ok(())
}
}
impl Validable for LongButterflySpread {
fn validate(&self) -> bool {
if !self.long_call_low.validate() {
debug!("Long call (low strike) is invalid");
return false;
}
if !self.short_call.validate() {
debug!("Short calls (middle strike) are invalid");
return false;
}
if !self.long_call_high.validate() {
debug!("Long call (high strike) is invalid");
return false;
}
if self.long_call_low.option.strike_price >= self.short_call.option.strike_price {
debug!("Low strike must be lower than middle strike");
return false;
}
if self.short_call.option.strike_price >= self.long_call_high.option.strike_price {
debug!("Middle strike must be lower than high strike");
return false;
}
if self.short_call.option.quantity != self.long_call_low.option.quantity * 2.0 {
debug!("Middle strike quantity must be double the wing quantities");
return false;
}
if self.long_call_low.option.quantity != self.long_call_high.option.quantity {
debug!("Wing quantities must be equal");
return false;
}
true
}
}
impl Positionable for LongButterflySpread {
fn add_position(&mut self, position: &Position) -> Result<(), PositionError> {
match &position.option.side {
Side::Long => {
if position.option.strike_price < self.short_call.option.strike_price {
self.long_call_low = position.clone();
Ok(())
} else {
self.long_call_high = position.clone();
Ok(())
}
}
Side::Short => {
self.short_call = position.clone();
Ok(())
}
}
}
fn get_positions(&self) -> Result<Vec<&Position>, PositionError> {
Ok(vec![
&self.long_call_low,
&self.short_call,
&self.long_call_high,
])
}
fn get_position(
&mut self,
option_style: &OptionStyle,
side: &Side,
strike: &Positive,
) -> Result<Vec<&mut Position>, PositionError> {
match (side, option_style, strike) {
(Side::Short, OptionStyle::Call, strike)
if *strike == self.short_call.option.strike_price =>
{
Ok(vec![&mut self.short_call])
}
(_, OptionStyle::Put, _) => Err(PositionError::invalid_position_type(
*side,
"Put not found in positions".to_string(),
)),
(Side::Long, OptionStyle::Call, strike)
if *strike == self.long_call_low.option.strike_price =>
{
Ok(vec![&mut self.long_call_low])
}
(Side::Long, OptionStyle::Call, strike)
if *strike == self.long_call_high.option.strike_price =>
{
Ok(vec![&mut self.long_call_high])
}
_ => Err(PositionError::invalid_position_type(
*side,
"Strike not found in positions".to_string(),
)),
}
}
fn modify_position(&mut self, position: &Position) -> Result<(), PositionError> {
if !position.validate() {
return Err(PositionError::ValidationError(
PositionValidationErrorKind::InvalidPosition {
reason: "Invalid position data".to_string(),
},
));
}
match (
&position.option.side,
&position.option.option_style,
&position.option.strike_price,
) {
(Side::Short, OptionStyle::Call, strike)
if *strike == self.short_call.option.strike_price =>
{
self.short_call = position.clone();
}
(_, OptionStyle::Put, _) => {
return Err(PositionError::invalid_position_type(
position.option.side,
"Put not found in positions".to_string(),
));
}
(Side::Long, OptionStyle::Call, strike)
if *strike == self.long_call_low.option.strike_price =>
{
self.long_call_low = position.clone();
}
(Side::Long, OptionStyle::Call, strike)
if *strike == self.long_call_high.option.strike_price =>
{
self.long_call_high = position.clone();
}
_ => {
return Err(PositionError::invalid_position_type(
position.option.side,
"Strike not found in positions".to_string(),
));
}
}
Ok(())
}
}
impl Strategable for LongButterflySpread {
fn info(&self) -> Result<StrategyBasics, StrategyError> {
Ok(StrategyBasics {
name: self.name.clone(),
kind: self.kind.clone(),
description: self.description.clone(),
})
}
}
impl BasicAble for LongButterflySpread {
fn get_title(&self) -> String {
let strategy_title = format!("{:?} Strategy: ", self.kind);
let leg_titles: Vec<String> = [
self.long_call_low.get_title(),
self.short_call.get_title(),
self.long_call_high.get_title(),
]
.iter()
.map(|leg| leg.to_string())
.collect();
if leg_titles.is_empty() {
strategy_title
} else {
format!("{}\n\t{}", strategy_title, leg_titles.join("\n\t"))
}
}
fn get_option_basic_type(&self) -> HashSet<OptionBasicType<'_>> {
let mut hash_set = HashSet::new();
let long_call_low = &self.long_call_low.option;
let short_call = &self.short_call.option;
let long_call_high = &self.long_call_high.option;
hash_set.insert(OptionBasicType {
option_style: &long_call_low.option_style,
side: &long_call_low.side,
strike_price: &long_call_low.strike_price,
expiration_date: &long_call_low.expiration_date,
});
hash_set.insert(OptionBasicType {
option_style: &short_call.option_style,
side: &short_call.side,
strike_price: &short_call.strike_price,
expiration_date: &short_call.expiration_date,
});
hash_set.insert(OptionBasicType {
option_style: &long_call_high.option_style,
side: &long_call_high.side,
strike_price: &long_call_high.strike_price,
expiration_date: &long_call_high.expiration_date,
});
hash_set
}
fn get_implied_volatility(&self) -> HashMap<OptionBasicType<'_>, &Positive> {
let options = [
(
&self.long_call_low.option,
&self.long_call_low.option.implied_volatility,
),
(
&self.short_call.option,
&self.short_call.option.implied_volatility,
),
(
&self.long_call_high.option,
&self.long_call_high.option.implied_volatility,
),
];
options
.into_iter()
.map(|(option, iv)| {
(
OptionBasicType {
option_style: &option.option_style,
side: &option.side,
strike_price: &option.strike_price,
expiration_date: &option.expiration_date,
},
iv,
)
})
.collect()
}
fn get_quantity(&self) -> HashMap<OptionBasicType<'_>, &Positive> {
let options = [
(
&self.long_call_low.option,
&self.long_call_low.option.quantity,
),
(&self.short_call.option, &self.short_call.option.quantity),
(
&self.long_call_high.option,
&self.long_call_high.option.quantity,
),
];
options
.into_iter()
.map(|(option, quantity)| {
(
OptionBasicType {
option_style: &option.option_style,
side: &option.side,
strike_price: &option.strike_price,
expiration_date: &option.expiration_date,
},
quantity,
)
})
.collect()
}
fn one_option(&self) -> &Options {
self.short_call.one_option()
}
fn one_option_mut(&mut self) -> &mut Options {
self.short_call.one_option_mut()
}
fn set_expiration_date(
&mut self,
expiration_date: ExpirationDate,
) -> Result<(), StrategyError> {
self.long_call_low.option.expiration_date = expiration_date;
self.short_call.option.expiration_date = expiration_date;
self.long_call_high.option.expiration_date = expiration_date;
Ok(())
}
fn set_underlying_price(&mut self, price: &Positive) -> Result<(), StrategyError> {
self.long_call_low.option.underlying_price = *price;
self.long_call_low.premium = Positive::new_decimal(
self.long_call_low
.option
.calculate_price_black_scholes()?
.abs(),
)
.unwrap_or(Positive::ZERO);
self.short_call.option.underlying_price = *price;
self.short_call.premium = Positive::new_decimal(
self.short_call
.option
.calculate_price_black_scholes()?
.abs(),
)
.unwrap_or(Positive::ZERO);
self.long_call_high.option.underlying_price = *price;
self.long_call_high.premium = Positive::new_decimal(
self.long_call_high
.option
.calculate_price_black_scholes()?
.abs(),
)
.unwrap_or(Positive::ZERO);
Ok(())
}
fn set_implied_volatility(&mut self, volatility: &Positive) -> Result<(), StrategyError> {
self.long_call_low.option.implied_volatility = *volatility;
self.short_call.option.implied_volatility = *volatility;
self.long_call_high.option.implied_volatility = *volatility;
self.long_call_low.premium = Positive::new_decimal(
self.long_call_low
.option
.calculate_price_black_scholes()?
.abs(),
)
.unwrap_or(Positive::ZERO);
self.short_call.premium = Positive::new_decimal(
self.short_call
.option
.calculate_price_black_scholes()?
.abs(),
)
.unwrap_or(Positive::ZERO);
self.long_call_high.premium = Positive::new_decimal(
self.long_call_high
.option
.calculate_price_black_scholes()?
.abs(),
)
.unwrap_or(Positive::ZERO);
Ok(())
}
}
impl Strategies for LongButterflySpread {
fn get_max_profit(&self) -> Result<Positive, StrategyError> {
let profit = self.calculate_profit_at(&self.short_call.option.strike_price)?;
if profit > Decimal::ZERO {
Ok(Positive::new_decimal(profit)?)
} else {
Err(StrategyError::ProfitLossError(
ProfitLossErrorKind::MaxProfitError {
reason: "max_profit is negative".to_string(),
},
))
}
}
fn get_max_loss(&self) -> Result<Positive, StrategyError> {
let left_loss = self.calculate_profit_at(&self.long_call_low.option.strike_price)?;
let right_loss = self.calculate_profit_at(&self.long_call_high.option.strike_price)?;
let max_loss = left_loss.min(right_loss);
if max_loss > Decimal::ZERO {
Err(StrategyError::ProfitLossError(
ProfitLossErrorKind::MaxLossError {
reason: "Max loss is negative".to_string(),
},
))
} else {
Ok(Positive::new_decimal(max_loss.abs()).unwrap_or(Positive::ZERO))
}
}
fn get_profit_area(&self) -> Result<Decimal, StrategyError> {
let high = self.get_max_profit().unwrap_or(Positive::ZERO);
let break_even_points = self.get_break_even_points()?;
let base = if break_even_points.len() == 2 {
break_even_points[1] - break_even_points[0]
} else {
let break_even_point = break_even_points[0];
if break_even_point < self.short_call.option.strike_price {
Positive::new_decimal(
self.calculate_profit_at(&self.long_call_high.option.strike_price)?
.abs(),
)
.unwrap_or(Positive::ZERO)
} else {
Positive::new_decimal(
self.calculate_profit_at(&self.long_call_low.option.strike_price)?
.abs(),
)
.unwrap_or(Positive::ZERO)
}
};
Ok(Decimal::from_f64(high.to_f64() * base.to_f64() / 200.0).unwrap_or(Decimal::ZERO))
}
fn get_profit_ratio(&self) -> Result<Decimal, StrategyError> {
let max_profit = self.get_max_profit().unwrap_or(Positive::ZERO);
let max_loss = self.get_max_loss().unwrap_or(Positive::ZERO);
match (max_profit, max_loss) {
(value, _) if value == Positive::ZERO => Ok(Decimal::ZERO),
(_, value) if value == Positive::ZERO => Ok(Decimal::MAX),
_ => Ok(
Decimal::from_f64(max_profit.to_f64() / max_loss.to_f64() * 100.0)
.unwrap_or(Decimal::ZERO),
),
}
}
}
impl Optimizable for LongButterflySpread {
type Strategy = LongButterflySpread;
fn filter_combinations<'a>(
&'a self,
option_chain: &'a OptionChain,
side: FindOptimalSide,
) -> impl Iterator<Item = OptionDataGroup<'a>> {
let underlying_price = self.get_underlying_price();
let strategy = self.clone();
option_chain
.get_triple_iter()
.filter(move |(long_low, short, long_high)| {
if side == FindOptimalSide::Center {
let atm_strike = match option_chain.atm_strike() {
Ok(atm_strike) => atm_strike,
_ => return false,
};
long_low.is_valid_optimal_side(underlying_price, &FindOptimalSide::Lower)
&& short.is_valid_optimal_side(
underlying_price,
&FindOptimalSide::Range(*atm_strike, *atm_strike),
)
&& long_high
.is_valid_optimal_side(underlying_price, &FindOptimalSide::Upper)
} else {
long_low.is_valid_optimal_side(underlying_price, &side)
&& short.is_valid_optimal_side(underlying_price, &side)
&& long_high.is_valid_optimal_side(underlying_price, &side)
}
})
.filter(move |(long_low, short, long_high)| {
long_low.strike_price < short.strike_price
&& short.strike_price < long_high.strike_price
})
.filter(|(long_low, short, long_high)| {
long_low.call_ask.unwrap_or(Positive::ZERO) > Positive::ZERO
&& short.call_bid.unwrap_or(Positive::ZERO) > Positive::ZERO
&& long_high.call_ask.unwrap_or(Positive::ZERO) > Positive::ZERO
})
.filter(move |(long_low, short, long_high)| {
let legs = StrategyLegs::ThreeLegs {
first: long_low,
second: short,
third: long_high,
};
match strategy.create_strategy(option_chain, &legs) {
Ok(s) => s.validate() && s.get_max_profit().is_ok() && s.get_max_loss().is_ok(),
Err(_) => false,
}
})
.map(move |(long_low, short, long_high)| {
OptionDataGroup::Three(long_low, short, long_high)
})
}
fn find_optimal(
&mut self,
option_chain: &OptionChain,
side: FindOptimalSide,
criteria: OptimizationCriteria,
) {
let mut best_value = Decimal::MIN;
let strategy_clone = self.clone();
let options_iter = strategy_clone.filter_combinations(option_chain, side);
for option_data_group in options_iter {
let (long_low, short, long_high) = match option_data_group {
OptionDataGroup::Three(first, second, third) => (first, second, third),
other => {
tracing::warn!(
group = ?other,
"find_optimal: skipping unexpected OptionDataGroup variant"
);
continue;
}
};
let legs = StrategyLegs::ThreeLegs {
first: long_low,
second: short,
third: long_high,
};
let strategy = match self.create_strategy(option_chain, &legs) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "skipping invalid strategy combination");
continue;
}
};
let metric = match criteria {
OptimizationCriteria::Ratio => strategy.get_profit_ratio(),
OptimizationCriteria::Area => strategy.get_profit_area(),
};
let current_value = match metric {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "skipping candidate with unscorable metric");
continue;
}
};
if current_value > best_value {
info!("Found better value: {}", current_value);
best_value = current_value;
*self = strategy.clone();
}
}
}
fn create_strategy(
&self,
chain: &OptionChain,
legs: &StrategyLegs,
) -> Result<Self::Strategy, StrategyError> {
match legs {
StrategyLegs::ThreeLegs {
first: low_strike,
second: middle_strike,
third: high_strike,
} => {
let implied_volatility = middle_strike.implied_volatility;
if implied_volatility > Positive::ONE {
return Err(StrategyError::invalid_parameters(
"create_strategy",
&format!(
"implied volatility {implied_volatility} exceeds the supported maximum of 1.0"
),
));
}
let low_call_ask = low_strike.call_ask.ok_or_else(|| {
StrategyError::operation_not_supported(
"create_strategy",
"missing call_ask for low strike leg",
)
})?;
let middle_call_bid = middle_strike.call_bid.ok_or_else(|| {
StrategyError::operation_not_supported(
"create_strategy",
"missing call_bid for middle strike leg",
)
})?;
let high_call_ask = high_strike.call_ask.ok_or_else(|| {
StrategyError::operation_not_supported(
"create_strategy",
"missing call_ask for high strike leg",
)
})?;
LongButterflySpread::new(
chain.symbol.clone(),
chain.underlying_price,
low_strike.strike_price,
middle_strike.strike_price,
high_strike.strike_price,
self.long_call_low.option.expiration_date,
implied_volatility,
self.long_call_low.option.risk_free_rate,
self.long_call_low.option.dividend_yield,
self.long_call_low.option.quantity,
low_call_ask,
middle_call_bid,
high_call_ask,
self.short_call.open_fee,
self.short_call.close_fee,
self.long_call_low.open_fee,
self.long_call_low.close_fee,
self.long_call_high.open_fee,
self.long_call_high.close_fee,
)
}
_ => Err(StrategyError::operation_not_supported(
"create_strategy",
"LongButterflySpread requires exactly three legs (ThreeLegs)",
)),
}
}
}
impl Profit for LongButterflySpread {
fn calculate_profit_at(&self, price: &Positive) -> Result<Decimal, PricingError> {
let price = Some(price);
Ok(d_sum(
&[
self.long_call_low.pnl_at_expiration(&price)?,
self.short_call.pnl_at_expiration(&price)?,
self.long_call_high.pnl_at_expiration(&price)?,
],
"strategies::long_butterfly_spread::profit_at",
)?)
}
}
impl ProbabilityAnalysis for LongButterflySpread {
fn get_profit_ranges(&self) -> Result<Vec<ProfitLossRange>, ProbabilityError> {
let break_even_points = self.get_break_even_points()?;
let option = &self.short_call.option;
let expiration_date = &option.expiration_date;
let risk_free_rate = option.risk_free_rate;
let (mean_volatility, std_dev) = mean_and_std(vec![
self.long_call_low.option.implied_volatility,
self.short_call.option.implied_volatility,
self.long_call_high.option.implied_volatility,
]);
let mut profit_range = ProfitLossRange::new(
Some(break_even_points[0]),
Some(break_even_points[1]),
Positive::ZERO,
)?;
profit_range.calculate_probability(
self.get_underlying_price(),
Some(VolatilityAdjustment {
base_volatility: mean_volatility,
std_dev_adjustment: std_dev,
}),
None,
expiration_date,
Some(risk_free_rate),
)?;
Ok(vec![profit_range])
}
fn get_loss_ranges(&self) -> Result<Vec<ProfitLossRange>, ProbabilityError> {
let mut ranges = Vec::new();
let break_even_points = self.get_break_even_points()?;
let option = &self.short_call.option;
let expiration_date = &option.expiration_date;
let risk_free_rate = option.risk_free_rate;
let (mean_volatility, std_dev) = mean_and_std(vec![
self.long_call_low.option.implied_volatility,
self.short_call.option.implied_volatility,
self.long_call_high.option.implied_volatility,
]);
let volatility_adjustment = Some(VolatilityAdjustment {
base_volatility: mean_volatility,
std_dev_adjustment: std_dev,
});
let mut lower_loss_range = ProfitLossRange::new(
None, Some(break_even_points[0]),
Positive::ZERO,
)?;
lower_loss_range.calculate_probability(
self.get_underlying_price(),
volatility_adjustment.clone(),
None,
expiration_date,
Some(risk_free_rate),
)?;
ranges.push(lower_loss_range);
let mut upper_loss_range = ProfitLossRange::new(
Some(break_even_points[1]),
None, Positive::ZERO,
)?;
upper_loss_range.calculate_probability(
self.get_underlying_price(),
volatility_adjustment,
None,
expiration_date,
Some(risk_free_rate),
)?;
ranges.push(upper_loss_range);
Ok(ranges)
}
}
impl Greeks for LongButterflySpread {
fn get_options(&self) -> Result<Vec<&Options>, GreeksError> {
Ok(vec![
&self.long_call_low.option,
&self.short_call.option,
&self.long_call_high.option,
])
}
}
impl DeltaNeutrality for LongButterflySpread {}
impl ButterflyStrategy for LongButterflySpread {
fn wing_strikes(&self) -> (Positive, Positive) {
(
self.long_call_low.option.strike_price,
self.long_call_high.option.strike_price,
)
}
fn body_strike(&self) -> Positive {
self.short_call.option.strike_price
}
fn get_butterfly_positions(&self) -> Vec<&Position> {
vec![&self.long_call_low, &self.short_call, &self.long_call_high]
}
}
impl PnLCalculator for LongButterflySpread {
fn calculate_pnl(
&self,
market_price: &Positive,
expiration_date: ExpirationDate,
implied_volatility: &Positive,
) -> Result<PnL, PricingError> {
Ok(self
.short_call
.calculate_pnl(market_price, expiration_date, implied_volatility)?
+ self.long_call_low.calculate_pnl(
market_price,
expiration_date,
implied_volatility,
)?
+ self.long_call_high.calculate_pnl(
market_price,
expiration_date,
implied_volatility,
)?)
}
fn calculate_pnl_at_expiration(
&self,
underlying_price: &Positive,
) -> Result<PnL, PricingError> {
Ok(self
.short_call
.calculate_pnl_at_expiration(underlying_price)?
+ self
.long_call_low
.calculate_pnl_at_expiration(underlying_price)?
+ self
.long_call_high
.calculate_pnl_at_expiration(underlying_price)?)
}
}
test_strategy_traits!(LongButterflySpread, test_short_call_implementations);
#[cfg(test)]
mod tests_long_butterfly_spread {
use super::*;
use crate::model::ExpirationDate;
use rust_decimal_macros::dec;
fn create_test_butterfly() -> LongButterflySpread {
LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED, pos_or_panic!(90.0), Positive::HUNDRED, pos_or_panic!(110.0), ExpirationDate::Days(pos_or_panic!(30.0)), pos_or_panic!(0.2), dec!(0.05), Positive::ZERO, Positive::ONE, pos_or_panic!(3.0), Positive::TWO, Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn test_new_butterfly_basic_properties() {
let butterfly = create_test_butterfly();
assert_eq!(butterfly.name, "Long Butterfly");
assert_eq!(butterfly.kind, StrategyType::LongButterflySpread);
assert!(!butterfly.description.is_empty());
assert!(butterfly.description.contains("long butterfly spread"));
}
#[test]
fn test_butterfly_strikes() {
let butterfly = create_test_butterfly();
assert_eq!(
butterfly.long_call_low.option.strike_price,
pos_or_panic!(90.0)
);
assert_eq!(butterfly.short_call.option.strike_price, Positive::HUNDRED);
assert_eq!(
butterfly.long_call_high.option.strike_price,
pos_or_panic!(110.0)
);
}
#[test]
fn test_butterfly_quantities() {
let butterfly = create_test_butterfly();
assert_eq!(butterfly.long_call_low.option.quantity, Positive::ONE);
assert_eq!(butterfly.short_call.option.quantity, Positive::TWO); assert_eq!(butterfly.long_call_high.option.quantity, Positive::ONE);
}
#[test]
fn test_butterfly_sides() {
let butterfly = create_test_butterfly();
assert_eq!(butterfly.long_call_low.option.side, Side::Long);
assert_eq!(butterfly.short_call.option.side, Side::Short);
assert_eq!(butterfly.long_call_high.option.side, Side::Long);
}
#[test]
fn test_butterfly_option_styles() {
let butterfly = create_test_butterfly();
assert_eq!(
butterfly.long_call_low.option.option_style,
OptionStyle::Call
);
assert_eq!(butterfly.short_call.option.option_style, OptionStyle::Call);
assert_eq!(
butterfly.long_call_high.option.option_style,
OptionStyle::Call
);
}
#[test]
fn test_butterfly_expiration_consistency() {
let butterfly = create_test_butterfly();
let expiration = ExpirationDate::Days(pos_or_panic!(30.0));
assert_eq!(
format!("{:?}", butterfly.long_call_low.option.expiration_date),
format!("{:?}", expiration)
);
assert_eq!(
format!("{:?}", butterfly.short_call.option.expiration_date),
format!("{:?}", expiration)
);
assert_eq!(
format!("{:?}", butterfly.long_call_high.option.expiration_date),
format!("{:?}", expiration)
);
}
#[test]
fn test_butterfly_fees_distribution() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
pos_or_panic!(3.0),
Positive::TWO,
Positive::ONE,
Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), Positive::ONE, pos_or_panic!(0.05), )
.unwrap();
assert_eq!(butterfly.long_call_low.open_fee, 0.05); assert_eq!(butterfly.short_call.open_fee, 1.0); assert_eq!(butterfly.long_call_high.open_fee, 1.0); }
#[test]
fn test_butterfly_break_even_points() {
let butterfly = create_test_butterfly();
let break_even_points = butterfly.break_even_points;
assert_eq!(break_even_points.len(), 2);
assert!(break_even_points[0] > butterfly.long_call_low.option.strike_price);
assert!(break_even_points[0] < butterfly.short_call.option.strike_price);
assert!(break_even_points[1] > butterfly.short_call.option.strike_price);
assert!(break_even_points[1] < butterfly.long_call_high.option.strike_price);
}
#[test]
fn test_butterfly_with_different_quantities() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::TWO, pos_or_panic!(3.0),
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
assert_eq!(butterfly.long_call_low.option.quantity, Positive::TWO);
assert_eq!(butterfly.short_call.option.quantity, pos_or_panic!(4.0)); assert_eq!(butterfly.long_call_high.option.quantity, Positive::TWO);
}
#[test]
fn test_butterfly_with_symmetric_strikes() {
let butterfly = create_test_butterfly();
let lower_width =
butterfly.short_call.option.strike_price - butterfly.long_call_low.option.strike_price;
let upper_width =
butterfly.long_call_high.option.strike_price - butterfly.short_call.option.strike_price;
assert_eq!(lower_width, upper_width);
}
#[test]
fn test_butterfly_with_equal_implied_volatility() {
let butterfly = create_test_butterfly();
assert_eq!(
butterfly.long_call_low.option.implied_volatility,
butterfly.short_call.option.implied_volatility
);
assert_eq!(
butterfly.short_call.option.implied_volatility,
butterfly.long_call_high.option.implied_volatility
);
}
#[test]
fn test_butterfly_with_invalid_premiums() {
let check_profit = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
Positive::ONE,
Positive::ONE,
Positive::ONE,
pos_or_panic!(1.05), pos_or_panic!(10.05), pos_or_panic!(1.05), pos_or_panic!(0.05), pos_or_panic!(1.05), pos_or_panic!(0.05), )
.unwrap();
assert!(check_profit.get_max_profit().is_err());
}
}
#[cfg(test)]
mod tests_long_butterfly_validation {
use super::*;
use rust_decimal_macros::dec;
fn create_valid_position(side: Side, strike_price: Positive, quantity: Positive) -> Position {
Position::new(
Options::new(
OptionType::European,
side,
"TEST".to_string(),
strike_price,
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
quantity,
Positive::HUNDRED,
dec!(0.05),
OptionStyle::Call,
Positive::ZERO,
None,
),
Positive::ONE,
Utc::now(),
Positive::ZERO,
Positive::ZERO,
None,
None,
)
}
#[test]
fn test_valid_long_butterfly() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
Positive::ONE,
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
assert!(butterfly.validate());
}
#[test]
fn test_invalid_long_call_low() {
let mut butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
Positive::ONE,
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
butterfly.long_call_low =
create_valid_position(Side::Long, pos_or_panic!(90.0), Positive::ZERO);
assert!(!butterfly.validate());
}
#[test]
fn test_invalid_strike_order_low() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
Positive::HUNDRED,
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
Positive::ONE,
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
assert!(!butterfly.validate());
}
#[test]
fn test_invalid_quantities() {
let mut butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
Positive::ONE,
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
butterfly.short_call = create_valid_position(Side::Short, Positive::HUNDRED, Positive::ONE);
assert!(!butterfly.validate());
}
#[test]
fn test_unequal_wing_quantities() {
let mut butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
Positive::ONE,
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
butterfly.long_call_high =
create_valid_position(Side::Long, pos_or_panic!(110.0), Positive::TWO);
assert!(!butterfly.validate());
}
}
#[cfg(test)]
mod tests_long_butterfly_profit {
use super::*;
use crate::constants::ZERO;
use crate::model::ExpirationDate;
use approx::assert_relative_eq;
use num_traits::ToPrimitive;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::str::FromStr;
fn create_test() -> LongButterflySpread {
LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED, pos_or_panic!(90.0), Positive::HUNDRED, pos_or_panic!(110.0), ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2), dec!(0.05), Positive::ZERO, Positive::ONE, pos_or_panic!(3.0), Positive::TWO, Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn test_profit_at_middle_strike() {
let butterfly = create_test();
let profit = butterfly.calculate_profit_at(&Positive::HUNDRED).unwrap();
assert!(profit > Decimal::ZERO);
let expected = Positive::new_decimal(Decimal::from_str("9.6").unwrap()).unwrap();
assert_eq!(profit, expected);
}
#[test]
fn test_profit_below_lowest_strike() {
let butterfly = create_test();
let profit = butterfly
.calculate_profit_at(&pos_or_panic!(85.0))
.unwrap()
.to_f64()
.unwrap();
assert!(profit < ZERO);
let expected = 0.4;
assert_relative_eq!(-profit, expected, epsilon = 0.0001);
}
#[test]
fn test_profit_above_highest_strike() {
let butterfly = create_test();
let profit = butterfly
.calculate_profit_at(&pos_or_panic!(115.0))
.unwrap();
assert!(profit < Decimal::ZERO);
assert_relative_eq!(
profit.to_f64().unwrap(),
-butterfly.get_max_loss().unwrap().to_f64(),
epsilon = 0.0001
);
}
#[test]
fn test_profit_at_break_even_points() {
let butterfly = create_test();
let break_even_points = butterfly.get_break_even_points().unwrap();
for &point in break_even_points {
let profit = butterfly
.calculate_profit_at(&point)
.unwrap()
.to_f64()
.unwrap();
assert_relative_eq!(profit, 0.0, epsilon = 0.01);
}
}
#[test]
fn test_profit_with_different_quantities() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED, pos_or_panic!(90.0), Positive::HUNDRED, pos_or_panic!(110.0), ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2), dec!(0.05), Positive::ZERO, Positive::TWO, pos_or_panic!(3.0), Positive::TWO, Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
let scaled_profit = butterfly
.calculate_profit_at(&Positive::HUNDRED)
.unwrap()
.to_f64()
.unwrap();
assert_relative_eq!(scaled_profit, 19.2, epsilon = 0.0001);
}
}
#[cfg(test)]
mod tests_long_butterfly_delta {
use super::*;
use positive::assert_pos_relative_eq;
use crate::assert_decimal_eq;
use crate::model::types::OptionStyle;
use crate::strategies::delta_neutral::DELTA_THRESHOLD;
use crate::strategies::delta_neutral::{DeltaAdjustment, DeltaNeutrality};
use crate::strategies::long_butterfly_spread::LongButterflySpread;
use rust_decimal_macros::dec;
fn get_strategy(underlying_price: Positive) -> LongButterflySpread {
LongButterflySpread::new(
"SP500".to_string(),
underlying_price, pos_or_panic!(5710.0), pos_or_panic!(5820.0), pos_or_panic!(6100.0), ExpirationDate::Days(Positive::TWO),
pos_or_panic!(0.18), dec!(0.05), Positive::ZERO, Positive::ONE, pos_or_panic!(49.65), pos_or_panic!(42.93), Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn create_test_reducing_adjustments() {
let strategy = get_strategy(pos_or_panic!(5881.88));
let size = dec!(-0.5970615569);
let delta1 = pos_or_panic!(0.60439151471911);
let delta2 = pos_or_panic!(175.125_739_348_840_2);
let k1 = pos_or_panic!(5710.0);
let k2 = pos_or_panic!(6100.0);
assert_decimal_eq!(
strategy.delta_neutrality().unwrap().net_delta,
size,
DELTA_THRESHOLD
);
assert!(!strategy.is_delta_neutral());
let binding = strategy.delta_adjustments().unwrap();
let suggestion_zero = binding.first().unwrap();
let suggestion_one = binding.last().unwrap();
match suggestion_zero {
DeltaAdjustment::BuyOptions {
quantity,
strike,
option_style,
side,
} => {
assert_pos_relative_eq!(
*quantity,
delta1,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_pos_relative_eq!(
*strike,
k1,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_eq!(*option_style, OptionStyle::Call);
assert_eq!(*side, Side::Long);
}
_ => panic!("Invalid suggestion"),
}
match suggestion_one {
DeltaAdjustment::BuyOptions {
quantity,
strike,
option_style,
side,
} => {
assert_pos_relative_eq!(
*quantity,
delta2,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_pos_relative_eq!(
*strike,
k2,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_eq!(*option_style, OptionStyle::Call);
assert_eq!(*side, Side::Long);
}
_ => panic!("Invalid suggestion"),
}
let mut option = strategy.long_call_low.option.clone();
option.quantity = delta1;
let delta = option.delta().unwrap();
assert_decimal_eq!(delta, -size, DELTA_THRESHOLD);
assert_decimal_eq!(
delta + strategy.delta_neutrality().unwrap().net_delta,
Decimal::ZERO,
DELTA_THRESHOLD
);
}
#[test]
fn create_test_increasing_adjustments() {
let strategy = get_strategy(pos_or_panic!(5710.81));
let size = dec!(0.3518);
let delta = pos_or_panic!(4.310_394_079_825_43);
let k = pos_or_panic!(5820.0);
assert_decimal_eq!(
strategy.delta_neutrality().unwrap().net_delta,
size,
DELTA_THRESHOLD
);
assert!(!strategy.is_delta_neutral());
let binding = strategy.delta_adjustments().unwrap();
match &binding[1] {
DeltaAdjustment::BuyOptions {
quantity,
strike,
option_style,
side,
} => {
assert_pos_relative_eq!(
*quantity,
delta,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_pos_relative_eq!(
*strike,
k,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_eq!(*option_style, OptionStyle::Call);
assert_eq!(*side, Side::Short);
}
_ => panic!("Invalid suggestion"),
}
let mut option = strategy.short_call.option.clone();
option.quantity = delta;
let delta = option.delta().unwrap();
assert_decimal_eq!(delta, -size, DELTA_THRESHOLD);
assert_decimal_eq!(
delta + strategy.delta_neutrality().unwrap().net_delta,
Decimal::ZERO,
DELTA_THRESHOLD
);
}
#[test]
fn create_test_no_adjustments() {
let strategy = get_strategy(pos_or_panic!(5420.0));
assert_decimal_eq!(
strategy.delta_neutrality().unwrap().net_delta,
Decimal::ZERO,
DELTA_THRESHOLD
);
assert!(strategy.is_delta_neutral());
let suggestion = strategy.delta_adjustments().unwrap();
assert_eq!(suggestion[0], DeltaAdjustment::NoAdjustmentNeeded);
}
}
#[cfg(test)]
mod tests_long_butterfly_delta_size {
use super::*;
use crate::assert_decimal_eq;
use crate::model::types::OptionStyle;
use crate::strategies::delta_neutral::DELTA_THRESHOLD;
use crate::strategies::delta_neutral::{DeltaAdjustment, DeltaNeutrality};
use crate::strategies::long_butterfly_spread::LongButterflySpread;
use positive::assert_pos_relative_eq;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::str::FromStr;
fn get_strategy(underlying_price: Positive) -> LongButterflySpread {
LongButterflySpread::new(
"SP500".to_string(),
underlying_price, pos_or_panic!(5710.0), pos_or_panic!(5820.0), pos_or_panic!(6100.0), ExpirationDate::Days(Positive::TWO),
pos_or_panic!(0.18), dec!(0.05), Positive::ZERO, pos_or_panic!(3.0), pos_or_panic!(49.65), pos_or_panic!(42.93), Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn create_test_reducing_adjustments() {
let strategy = get_strategy(pos_or_panic!(5881.85));
let size = dec!(-1.7905);
let delta1 = pos_or_panic!(1.812583011030011);
let delta2 = pos_or_panic!(525.8051045358664);
let k1 = pos_or_panic!(5710.0);
let k2 = pos_or_panic!(6100.0);
assert_decimal_eq!(
strategy.delta_neutrality().unwrap().net_delta,
size,
DELTA_THRESHOLD
);
assert!(!strategy.is_delta_neutral());
let binding = strategy.delta_adjustments().unwrap();
let suggestion_zero = binding.first().unwrap();
let suggestion_one = binding.last().unwrap();
match suggestion_zero {
DeltaAdjustment::BuyOptions {
quantity,
strike,
option_style,
side,
} => {
assert_pos_relative_eq!(
*quantity,
delta1,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_pos_relative_eq!(
*strike,
k1,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_eq!(*option_style, OptionStyle::Call);
assert_eq!(*side, Side::Long);
}
_ => panic!("Invalid suggestion"),
}
match suggestion_one {
DeltaAdjustment::BuyOptions {
quantity,
strike,
option_style,
side,
} => {
assert_pos_relative_eq!(
*quantity,
delta2,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_pos_relative_eq!(
*strike,
k2,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_eq!(*option_style, OptionStyle::Call);
assert_eq!(*side, Side::Long);
}
_ => panic!("Invalid suggestion"),
}
let mut option = strategy.long_call_low.option.clone();
option.quantity = delta1;
let delta = option.delta().unwrap();
assert_decimal_eq!(delta, -size, DELTA_THRESHOLD);
assert_decimal_eq!(
delta + strategy.delta_neutrality().unwrap().net_delta,
Decimal::ZERO,
DELTA_THRESHOLD
);
}
#[test]
fn create_test_increasing_adjustments() {
let strategy = get_strategy(pos_or_panic!(5710.88));
let size = dec!(1.0558);
let delta =
Positive::new_decimal(Decimal::from_str("12.912467384337744").unwrap()).unwrap();
let k = pos_or_panic!(5820.0);
assert_decimal_eq!(
strategy.delta_neutrality().unwrap().net_delta,
size,
DELTA_THRESHOLD
);
assert!(!strategy.is_delta_neutral());
let binding = strategy.delta_adjustments().unwrap();
match &binding[1] {
DeltaAdjustment::BuyOptions {
quantity,
strike,
option_style,
side,
} => {
assert_pos_relative_eq!(
*quantity,
delta,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_pos_relative_eq!(
*strike,
k,
Positive::new_decimal(DELTA_THRESHOLD).unwrap()
);
assert_eq!(*option_style, OptionStyle::Call);
assert_eq!(*side, Side::Short);
}
_ => panic!("Invalid suggestion"),
}
let mut option = strategy.short_call.option.clone();
option.quantity = delta;
let delta = option.delta().unwrap();
assert_decimal_eq!(delta, -size, DELTA_THRESHOLD);
assert_decimal_eq!(
delta + strategy.delta_neutrality().unwrap().net_delta,
Decimal::ZERO,
DELTA_THRESHOLD
);
}
#[test]
fn create_test_no_adjustments() {
let strategy = get_strategy(pos_or_panic!(5410.0));
assert_decimal_eq!(
strategy.delta_neutrality().unwrap().net_delta,
Decimal::ZERO,
DELTA_THRESHOLD
);
assert!(strategy.is_delta_neutral());
let suggestion = strategy.delta_adjustments().unwrap();
assert_eq!(suggestion[0], DeltaAdjustment::NoAdjustmentNeeded);
}
}
#[cfg(test)]
mod tests_long_butterfly_position_management {
use super::*;
use crate::error::position::PositionValidationErrorKind;
use crate::model::types::{OptionStyle, Side};
use rust_decimal_macros::dec;
use tracing::error;
fn create_test_butterfly() -> LongButterflySpread {
LongButterflySpread::new(
"SP500".to_string(),
pos_or_panic!(5795.88), pos_or_panic!(5710.0), pos_or_panic!(5780.0), pos_or_panic!(5850.0), ExpirationDate::Days(Positive::TWO),
pos_or_panic!(0.18), dec!(0.05), Positive::ZERO, Positive::TWO, pos_or_panic!(113.3), pos_or_panic!(64.20), pos_or_panic!(31.65), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn test_short_butterfly_get_position() {
let mut butterfly = create_test_butterfly();
let call_position =
butterfly.get_position(&OptionStyle::Call, &Side::Short, &pos_or_panic!(5780.0));
assert!(call_position.is_ok());
let positions = call_position.unwrap();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].option.strike_price, pos_or_panic!(5780.0));
assert_eq!(positions[0].option.option_style, OptionStyle::Call);
assert_eq!(positions[0].option.side, Side::Short);
let invalid_position =
butterfly.get_position(&OptionStyle::Call, &Side::Short, &pos_or_panic!(2715.0));
assert!(invalid_position.is_err());
match invalid_position {
Err(PositionError::ValidationError(
PositionValidationErrorKind::IncompatibleSide {
position_side: _,
reason,
},
)) => {
assert_eq!(reason, "Strike not found in positions");
}
_ => {
error!("Unexpected error: {:?}", invalid_position);
panic!()
}
}
}
#[test]
fn test_long_butterfly_get_position() {
let mut butterfly = create_test_butterfly();
let call_position =
butterfly.get_position(&OptionStyle::Call, &Side::Long, &pos_or_panic!(5710.0));
assert!(call_position.is_ok());
let positions = call_position.unwrap();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].option.strike_price, pos_or_panic!(5710.0));
assert_eq!(positions[0].option.option_style, OptionStyle::Call);
assert_eq!(positions[0].option.side, Side::Long);
let put_position =
butterfly.get_position(&OptionStyle::Call, &Side::Long, &pos_or_panic!(5850.0));
assert!(put_position.is_ok());
let positions = put_position.unwrap();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].option.strike_price, pos_or_panic!(5850.0));
assert_eq!(positions[0].option.option_style, OptionStyle::Call);
assert_eq!(positions[0].option.side, Side::Long);
let invalid_position =
butterfly.get_position(&OptionStyle::Call, &Side::Long, &pos_or_panic!(2715.0));
assert!(invalid_position.is_err());
match invalid_position {
Err(PositionError::ValidationError(
PositionValidationErrorKind::IncompatibleSide {
position_side: _,
reason,
},
)) => {
assert_eq!(reason, "Strike not found in positions");
}
_ => {
error!("Unexpected error: {:?}", invalid_position);
panic!()
}
}
}
#[test]
fn test_short_butterfly_modify_position() {
let mut butterfly = create_test_butterfly();
let mut modified_call = butterfly.short_call.clone();
modified_call.option.quantity = Positive::TWO;
let result = butterfly.modify_position(&modified_call);
assert!(result.is_ok());
assert_eq!(butterfly.short_call.option.quantity, Positive::TWO);
let mut invalid_position = butterfly.short_call.clone();
invalid_position.option.strike_price = pos_or_panic!(95.0);
let result = butterfly.modify_position(&invalid_position);
assert!(result.is_err());
match result {
Err(PositionError::ValidationError(kind)) => match kind {
PositionValidationErrorKind::IncompatibleSide {
position_side: _,
reason,
} => {
assert_eq!(reason, "Strike not found in positions");
}
_ => panic!("Expected ValidationError::InvalidPosition"),
},
_ => panic!("Expected ValidationError"),
}
}
#[test]
fn test_long_butterfly_modify_position() {
let mut butterfly = create_test_butterfly();
let mut modified_call = butterfly.long_call_low.clone();
modified_call.option.quantity = Positive::TWO;
let result = butterfly.modify_position(&modified_call);
assert!(result.is_ok());
assert_eq!(butterfly.long_call_low.option.quantity, Positive::TWO);
let mut modified_put = butterfly.long_call_high.clone();
modified_put.option.quantity = Positive::TWO;
let result = butterfly.modify_position(&modified_put);
assert!(result.is_ok());
assert_eq!(butterfly.long_call_high.option.quantity, Positive::TWO);
let mut invalid_position = butterfly.long_call_high.clone();
invalid_position.option.strike_price = pos_or_panic!(95.0);
let result = butterfly.modify_position(&invalid_position);
assert!(result.is_err());
match result {
Err(PositionError::ValidationError(kind)) => match kind {
PositionValidationErrorKind::IncompatibleSide {
position_side: _,
reason,
} => {
assert_eq!(reason, "Strike not found in positions");
}
_ => panic!("Expected ValidationError::InvalidPosition"),
},
_ => panic!("Expected ValidationError"),
}
}
}
#[cfg(test)]
mod tests_adjust_option_position_long {
use super::*;
use crate::model::types::{OptionStyle, Side};
use rust_decimal_macros::dec;
fn create_test_strategy() -> LongButterflySpread {
LongButterflySpread::new(
"SP500".to_string(),
pos_or_panic!(5795.88), pos_or_panic!(5710.0), pos_or_panic!(5780.0), pos_or_panic!(5850.0), ExpirationDate::Days(Positive::TWO),
pos_or_panic!(0.18), dec!(0.05), Positive::ZERO, Positive::TWO, pos_or_panic!(113.3), pos_or_panic!(64.20), pos_or_panic!(31.65), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn test_adjust_existing_call_position() {
let mut strategy = create_test_strategy();
let initial_quantity = strategy.long_call_low.option.quantity;
let adjustment = Positive::ONE;
let result = strategy.adjust_option_position(
adjustment.to_dec(),
&pos_or_panic!(5710.0),
&OptionStyle::Call,
&Side::Long,
);
assert!(result.is_ok());
assert_eq!(
strategy.long_call_low.option.quantity,
initial_quantity + adjustment
);
}
#[test]
fn test_adjust_existing_put_position() {
let mut strategy = create_test_strategy();
let initial_quantity = strategy.short_call.option.quantity;
let adjustment = Positive::ONE;
let result = strategy.adjust_option_position(
adjustment.to_dec(),
&pos_or_panic!(5780.0),
&OptionStyle::Call,
&Side::Short,
);
assert!(result.is_ok());
assert_eq!(
strategy.short_call.option.quantity,
initial_quantity + adjustment
);
}
#[test]
fn test_adjust_nonexistent_position() {
let mut strategy = create_test_strategy();
let result = strategy.adjust_option_position(
Decimal::ONE,
&pos_or_panic!(110.0),
&OptionStyle::Put,
&Side::Short,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Put not found in positions"));
}
#[test]
fn test_adjust_with_invalid_strike() {
let mut strategy = create_test_strategy();
let result = strategy.adjust_option_position(
Decimal::ONE,
&Positive::HUNDRED, &OptionStyle::Call,
&Side::Short,
);
assert!(result.is_err());
}
#[test]
fn test_zero_quantity_adjustment() {
let mut strategy = create_test_strategy();
let initial_quantity = strategy.long_call_high.option.quantity;
let result = strategy.adjust_option_position(
Decimal::ZERO,
&pos_or_panic!(5850.0),
&OptionStyle::Call,
&Side::Long,
);
assert!(result.is_ok());
assert_eq!(strategy.long_call_high.option.quantity, initial_quantity);
}
}
#[cfg(test)]
mod tests_long_butterfly_spread_constructor {
use super::*;
use crate::model::utils::create_sample_position;
#[test]
fn test_get_strategy_valid() {
let options = vec![
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(105.0),
pos_or_panic!(0.2),
),
];
let result = LongButterflySpread::get_strategy(&options);
assert!(result.is_ok());
let strategy = result.unwrap();
assert_eq!(
strategy.long_call_low.option.strike_price,
pos_or_panic!(95.0)
);
assert_eq!(strategy.short_call.option.strike_price, Positive::HUNDRED);
assert_eq!(
strategy.long_call_high.option.strike_price,
pos_or_panic!(105.0)
);
}
#[test]
fn test_get_strategy_wrong_number_of_options() {
let options = vec![
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
),
];
let result = LongButterflySpread::get_strategy(&options);
assert!(matches!(
result,
Err(StrategyError::OperationError(OperationErrorKind::InvalidParameters { operation, reason }))
if operation == "Long Butterfly Spread get_strategy" && reason == "Must have exactly 3 options"
));
}
#[test]
fn test_get_strategy_wrong_option_style() {
let mut option1 = create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
option1.option.option_style = OptionStyle::Put;
let option2 = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
);
let option3 = create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(105.0),
pos_or_panic!(0.2),
);
let options = vec![option1, option2, option3];
let result = LongButterflySpread::get_strategy(&options);
assert!(matches!(
result,
Err(StrategyError::OperationError(OperationErrorKind::InvalidParameters { operation, reason }))
if operation == "Long Butterfly Spread get_strategy" && reason == "Options must be calls"
));
}
#[test]
fn test_get_strategy_wrong_sides() {
let options = vec![
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(105.0),
pos_or_panic!(0.2),
),
];
let result = LongButterflySpread::get_strategy(&options);
assert!(matches!(
result,
Err(StrategyError::OperationError(OperationErrorKind::InvalidParameters { operation, reason }))
if operation == "Long Butterfly Spread get_strategy"
&& reason == "Long Butterfly requires long lower and higher strikes with a short middle strike"
));
}
#[test]
fn test_get_strategy_asymmetric_strikes() {
let options = vec![
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(101.0),
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(105.0),
pos_or_panic!(0.2),
),
];
let result = LongButterflySpread::get_strategy(&options);
assert!(matches!(
result,
Err(StrategyError::OperationError(OperationErrorKind::InvalidParameters { operation, reason }))
if operation == "Long Butterfly Spread get_strategy" && reason == "Strikes must be symmetrical"
));
}
#[test]
fn test_get_strategy_different_expiration_dates() {
let mut option1 = create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
);
let mut option2 = create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
);
let mut option3 = create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(105.0),
pos_or_panic!(0.2),
);
option1.option.expiration_date = ExpirationDate::Days(pos_or_panic!(30.0));
option2.option.expiration_date = ExpirationDate::Days(pos_or_panic!(60.0));
option3.option.expiration_date = ExpirationDate::Days(pos_or_panic!(30.0));
let options = vec![option1, option2, option3];
let result = LongButterflySpread::get_strategy(&options);
assert!(matches!(
result,
Err(StrategyError::OperationError(OperationErrorKind::InvalidParameters { operation, reason }))
if operation == "Long Butterfly Spread get_strategy" && reason == "Options must have the same expiration date"
));
}
#[test]
fn test_get_strategy_with_extra_conditions() {
let options = vec![
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(90.0), pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
pos_or_panic!(90.0),
Positive::ONE,
Positive::HUNDRED, pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Long,
pos_or_panic!(90.0),
Positive::ONE,
pos_or_panic!(110.0), pos_or_panic!(0.2),
),
];
let result = LongButterflySpread::get_strategy(&options);
assert!(result.is_ok());
}
#[test]
fn test_get_strategy_multiple_identical_strikes() {
let options = vec![
create_sample_position(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED,
Positive::ONE,
pos_or_panic!(95.0),
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Short,
Positive::HUNDRED,
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
),
create_sample_position(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED,
Positive::ONE,
pos_or_panic!(105.0),
pos_or_panic!(0.2),
),
];
let result = LongButterflySpread::get_strategy(&options);
assert!(result.is_ok());
}
}
#[cfg(test)]
mod tests_long_butterfly_spread_pnl {
use super::*;
use crate::assert_decimal_eq;
use crate::model::utils::create_sample_position;
use rust_decimal_macros::dec;
fn create_test_long_butterfly_spread() -> Result<LongButterflySpread, StrategyError> {
let lower_short_call = create_sample_position(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED, Positive::ONE, pos_or_panic!(95.0), pos_or_panic!(0.2), );
let middle_short_call = create_sample_position(
OptionStyle::Call,
Side::Short,
Positive::HUNDRED, Positive::ONE, Positive::HUNDRED, pos_or_panic!(0.2), );
let higher_short_call = create_sample_position(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED, Positive::ONE, pos_or_panic!(105.0), pos_or_panic!(0.2), );
LongButterflySpread::get_strategy(&[lower_short_call, middle_short_call, higher_short_call])
}
#[test]
fn test_calculate_pnl_below_strikes() {
let spread = create_test_long_butterfly_spread().unwrap();
let market_price = pos_or_panic!(90.0); let expiration_date = ExpirationDate::Days(pos_or_panic!(20.0));
let implied_volatility = pos_or_panic!(0.2);
let result = spread.calculate_pnl(&market_price, expiration_date, &implied_volatility);
assert!(result.is_ok());
let pnl = result.unwrap();
assert!(pnl.unrealized.is_some());
assert!(pnl.unrealized.unwrap() < dec!(0.0));
assert!(pnl.unrealized.unwrap() > dec!(-5.0)); }
#[test]
fn test_calculate_pnl_between_strikes() {
let spread = create_test_long_butterfly_spread().unwrap();
let market_price = Positive::HUNDRED; let expiration_date = ExpirationDate::Days(pos_or_panic!(20.0));
let implied_volatility = pos_or_panic!(0.1);
let result = spread.calculate_pnl(&market_price, expiration_date, &implied_volatility);
assert!(result.is_ok());
let pnl = result.unwrap();
assert!(pnl.unrealized.is_some());
assert!(pnl.unrealized.is_some(), "Unrealized PnL should be present");
assert!(
pnl.unrealized.unwrap() >= dec!(-10.0) && pnl.unrealized.unwrap() <= dec!(10.0),
"Unrealized PnL should be within a reasonable range. Got: {}",
pnl.unrealized.unwrap()
);
}
#[test]
fn test_calculate_pnl_above_strikes() {
let spread = create_test_long_butterfly_spread().unwrap();
let market_price = pos_or_panic!(90.0);
let expiration_date = ExpirationDate::Days(pos_or_panic!(20.0));
let implied_volatility = pos_or_panic!(0.2);
let result = spread.calculate_pnl(&market_price, expiration_date, &implied_volatility);
assert!(result.is_ok());
let pnl = result.unwrap();
assert!(pnl.unrealized.is_some());
assert!(pnl.unrealized.unwrap() < dec!(0.0));
assert!(pnl.unrealized.unwrap() > dec!(-5.0)); }
#[test]
fn test_calculate_pnl_at_expiration_max_profit() {
let spread = create_test_long_butterfly_spread().unwrap();
let underlying_price = pos_or_panic!(95.0);
let result = spread.calculate_pnl_at_expiration(&underlying_price);
assert!(result.is_ok());
let pnl = result.unwrap();
assert!(pnl.realized.is_some());
assert_decimal_eq!(pnl.realized.unwrap(), dec!(-8.0), dec!(1e-6));
}
#[test]
fn test_calculate_pnl_at_expiration_max_loss() {
let spread = create_test_long_butterfly_spread().unwrap();
let underlying_price = pos_or_panic!(110.0);
let result = spread.calculate_pnl_at_expiration(&underlying_price);
assert!(result.is_ok());
let pnl = result.unwrap();
assert!(pnl.realized.is_some());
assert_decimal_eq!(pnl.realized.unwrap(), dec!(2.0), dec!(1e-6));
}
#[test]
fn test_calculate_pnl_at_expiration_at_middle_strike() {
let spread = create_test_long_butterfly_spread().unwrap();
let underlying_price = Positive::HUNDRED;
let result = spread.calculate_pnl_at_expiration(&underlying_price);
assert!(result.is_ok());
let pnl = result.unwrap();
assert!(pnl.realized.is_some());
assert_decimal_eq!(pnl.realized.unwrap(), dec!(-3.0), dec!(1e-6));
}
}
#[cfg(test)]
mod tests_butterfly_strategies {
use super::*;
use crate::constants::ZERO;
use crate::model::ExpirationDate;
use num_traits::ToPrimitive;
use rust_decimal_macros::dec;
fn create_test_long() -> LongButterflySpread {
LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED, pos_or_panic!(90.0), Positive::HUNDRED, pos_or_panic!(110.0), ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2), dec!(0.05), Positive::ZERO, Positive::ONE, pos_or_panic!(3.0), Positive::TWO, Positive::ONE, pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn test_add_leg_long_butterfly() {
let mut butterfly = create_test_long();
let new_long = Position::new(
Options::new(
OptionType::European,
Side::Long,
"TEST".to_string(),
pos_or_panic!(85.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
OptionStyle::Call,
Positive::ZERO,
None,
),
Positive::ONE,
Utc::now(),
Positive::ZERO,
Positive::ZERO,
None,
None,
);
butterfly
.add_position(&new_long)
.expect("Failed to add position");
assert_eq!(
butterfly.long_call_low.option.strike_price,
pos_or_panic!(85.0)
);
}
#[test]
fn test_get_legs() {
let long_butterfly = create_test_long();
assert_eq!(long_butterfly.get_positions().unwrap().len(), 3);
}
#[test]
fn test_max_profit_long_butterfly() {
let butterfly = create_test_long();
let max_profit = butterfly.get_max_profit().unwrap().to_dec();
let expected_profit = butterfly.calculate_profit_at(&Positive::HUNDRED).unwrap();
assert_eq!(max_profit, expected_profit);
}
#[test]
fn test_max_loss_long_butterfly() {
let butterfly = create_test_long();
let max_loss = butterfly.get_max_loss().unwrap().to_dec();
let left_loss = butterfly.calculate_profit_at(&pos_or_panic!(90.0)).unwrap();
let right_loss = butterfly
.calculate_profit_at(&pos_or_panic!(110.0))
.unwrap();
assert_eq!(max_loss, left_loss.min(right_loss).abs());
}
#[test]
fn test_total_cost() {
let long_butterfly = create_test_long();
assert!(long_butterfly.get_total_cost().unwrap() > Positive::ZERO);
}
#[test]
fn test_fees() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
pos_or_panic!(3.0),
Positive::TWO,
Positive::ONE,
Positive::ONE, Positive::ONE, Positive::ONE, Positive::ONE, Positive::ONE, Positive::ONE, )
.unwrap();
assert_eq!(butterfly.get_fees().unwrap().to_f64(), 8.0);
}
#[test]
fn test_fees_bis() {
let butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::TWO,
pos_or_panic!(3.0),
Positive::TWO,
Positive::ONE,
Positive::ONE, Positive::ONE, Positive::ONE, Positive::ONE, Positive::ONE, Positive::ONE, )
.unwrap();
assert_eq!(butterfly.get_fees().unwrap(), pos_or_panic!(16.0));
}
#[test]
fn test_profit_area_long_butterfly() {
let butterfly = create_test_long();
let area = butterfly.get_profit_area().unwrap().to_f64().unwrap();
assert!(area > ZERO);
}
#[test]
fn test_profit_ratio() {
let long_butterfly = create_test_long();
assert!(long_butterfly.get_profit_ratio().unwrap().to_f64().unwrap() > ZERO);
}
#[test]
fn test_break_even_points() {
let long_butterfly = create_test_long();
assert_eq!(long_butterfly.get_break_even_points().unwrap().len(), 2);
}
#[test]
fn test_profits_with_quantities() {
let long_butterfly = LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::TWO, pos_or_panic!(3.0),
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
let base_butterfly = create_test_long();
assert_eq!(
long_butterfly.get_max_profit().unwrap().to_f64(),
base_butterfly.get_max_profit().unwrap().to_f64() * 2.0
);
}
}
#[cfg(test)]
mod tests_butterfly_optimizable {
use super::*;
use positive::spos;
use crate::model::ExpirationDate;
use rust_decimal_macros::dec;
fn create_test_option_chain() -> OptionChain {
let mut chain = OptionChain::new(
"TEST",
Positive::HUNDRED,
"2024-12-31".to_string(),
None,
None,
);
for strike in [85.0, 90.0, 95.0, 100.0, 105.0, 110.0, 115.0] {
chain.add_option(
pos_or_panic!(strike),
spos!(5.0), spos!(5.2), spos!(5.0), spos!(5.2), pos_or_panic!(0.2), Some(dec!(0.5)), Some(dec!(0.2)),
Some(dec!(0.2)),
spos!(100.0), Some(50), None,
);
}
chain
}
fn create_test_long() -> LongButterflySpread {
LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
pos_or_panic!(3.0),
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
#[test]
fn test_find_optimal_area() {
let mut butterfly = create_test_long();
let chain = create_test_option_chain();
let initial_area = butterfly.get_profit_area().unwrap();
butterfly.find_optimal(&chain, FindOptimalSide::All, OptimizationCriteria::Area);
assert!(butterfly.validate());
assert!(butterfly.get_profit_area().unwrap() >= initial_area);
}
#[test]
fn test_valid_strike_order() {
let mut butterfly = create_test_long();
let chain = create_test_option_chain();
butterfly.find_optimal(&chain, FindOptimalSide::All, OptimizationCriteria::Ratio);
assert!(
butterfly.long_call_low.option.strike_price < butterfly.short_call.option.strike_price
);
assert!(
butterfly.short_call.option.strike_price < butterfly.long_call_high.option.strike_price
);
}
#[test]
fn test_find_optimal_area_long() {
let mut butterfly = create_test_long();
let chain = create_test_option_chain();
let initial_area = butterfly.get_profit_area().unwrap();
butterfly.find_optimal(&chain, FindOptimalSide::All, OptimizationCriteria::Area);
assert!(butterfly.validate());
assert!(butterfly.get_profit_area().unwrap() >= initial_area);
}
#[test]
fn test_find_optimal_with_range() {
let mut long_butterfly = create_test_long();
let chain = create_test_option_chain();
long_butterfly.find_optimal(
&chain,
FindOptimalSide::Range(pos_or_panic!(95.0), pos_or_panic!(105.0)),
OptimizationCriteria::Ratio,
);
assert!(long_butterfly.short_call.option.strike_price >= pos_or_panic!(95.0));
assert!(long_butterfly.short_call.option.strike_price <= pos_or_panic!(105.0));
}
}
#[cfg(test)]
mod tests_butterfly_probability {
use super::*;
use crate::model::ExpirationDate;
use crate::strategies::probabilities::calculate_price_probability;
use rust_decimal_macros::dec;
fn create_test_long() -> LongButterflySpread {
LongButterflySpread::new(
"TEST".to_string(),
Positive::HUNDRED,
pos_or_panic!(90.0),
Positive::HUNDRED,
pos_or_panic!(110.0),
ExpirationDate::Days(pos_or_panic!(30.0)),
pos_or_panic!(0.2),
dec!(0.05),
Positive::ZERO,
Positive::ONE,
pos_or_panic!(10.0),
Positive::TWO,
Positive::ONE,
pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap()
}
mod long_butterfly_tests {
use super::*;
#[test]
fn test_get_expiration() {
let butterfly = create_test_long();
let expiration_date = *butterfly.get_expiration().values().next().unwrap();
assert_eq!(expiration_date, &ExpirationDate::Days(pos_or_panic!(30.0)));
}
#[test]
fn test_get_risk_free_rate() {
let butterfly = create_test_long();
assert_eq!(
**butterfly.get_risk_free_rate().values().next().unwrap(),
dec!(0.05)
);
}
#[test]
fn test_get_profit_ranges() {
let butterfly = create_test_long();
let ranges = butterfly.get_profit_ranges().unwrap();
assert_eq!(ranges.len(), 1);
let range = &ranges[0];
let break_even_points = butterfly.get_break_even_points().unwrap();
assert_eq!(range.lower_bound.unwrap(), break_even_points[0]);
assert_eq!(range.upper_bound.unwrap(), break_even_points[1]);
assert!(range.probability > Positive::ZERO);
}
#[test]
fn test_get_loss_ranges() {
let butterfly = create_test_long();
let ranges = butterfly.get_loss_ranges().unwrap();
assert_eq!(ranges.len(), 2);
let lower_range = &ranges[0];
assert!(lower_range.lower_bound.is_none());
assert_eq!(
lower_range.upper_bound.unwrap(),
butterfly.get_break_even_points().unwrap()[0]
);
assert!(lower_range.probability > Positive::ZERO);
let upper_range = &ranges[1];
assert_eq!(
upper_range.lower_bound.unwrap(),
butterfly.get_break_even_points().unwrap()[1]
);
assert!(upper_range.upper_bound.is_none());
assert!(upper_range.probability > Positive::ZERO);
}
}
#[test]
fn test_volatility_calculations() {
let long_butterfly = create_test_long();
let long_ranges = long_butterfly.get_profit_ranges().unwrap();
assert!(!long_ranges.is_empty());
assert!(long_ranges[0].probability > Positive::ZERO);
}
#[test]
fn test_probability_sum() {
let long_butterfly = create_test_long();
let long_profit_ranges = long_butterfly.get_profit_ranges().unwrap();
let long_loss_ranges = long_butterfly.get_loss_ranges().unwrap();
let long_total_prob = long_profit_ranges
.iter()
.map(|r| r.probability.to_f64())
.sum::<f64>()
+ long_loss_ranges
.iter()
.map(|r| r.probability.to_f64())
.sum::<f64>();
assert!((long_total_prob - 1.0).abs() < 0.1);
}
#[test]
fn test_debug_user_case() {
let underlying_price = pos_or_panic!(23750.0);
let strategy = LongButterflySpread::new(
"DAX".to_string(),
underlying_price, pos_or_panic!(23600.0), pos_or_panic!(23750.0), pos_or_panic!(23900.0), ExpirationDate::Days(pos_or_panic!(63.0)),
pos_or_panic!(0.14), dec!(0.0), Positive::ZERO, Positive::ONE, pos_or_panic!(645.3), pos_or_panic!(545.6), pos_or_panic!(477.1), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), pos_or_panic!(0.05), )
.unwrap();
info!("=== DEBUGGING USER CASE ===");
let break_even_points = strategy.get_break_even_points().unwrap();
info!("Break-even points: {:?}", break_even_points);
let profit_ranges = strategy.get_profit_ranges().unwrap();
info!("\nProfit ranges:");
for (i, range) in profit_ranges.iter().enumerate() {
info!(
" Range {}: lower={:?}, upper={:?}, probability={:.6}",
i, range.lower_bound, range.upper_bound, range.probability
);
}
let loss_ranges = strategy.get_loss_ranges().unwrap();
info!("\nLoss ranges:");
for (i, range) in loss_ranges.iter().enumerate() {
info!(
" Range {}: lower={:?}, upper={:?}, probability={:.6}",
i, range.lower_bound, range.upper_bound, range.probability
);
}
let total_profit_prob: f64 = profit_ranges.iter().map(|r| r.probability.to_f64()).sum();
let total_loss_prob: f64 = loss_ranges.iter().map(|r| r.probability.to_f64()).sum();
info!("\n=== SUMMARY ===");
info!(
"Total Profit Probability: {:.6} ({:.2}%)",
total_profit_prob,
total_profit_prob * 100.0
);
info!(
"Total Loss Probability: {:.6} ({:.2}%)",
total_loss_prob,
total_loss_prob * 100.0
);
info!(
"Sum: {:.6} ({:.2}%)",
total_profit_prob + total_loss_prob,
(total_profit_prob + total_loss_prob) * 100.0
);
info!("\n=== INDIVIDUAL PROBABILITY TESTS ===");
let current_price = &pos_or_panic!(23750.0);
let lower_bound = break_even_points[0];
let upper_bound = break_even_points[1];
info!("Testing range: {} to {}", lower_bound, upper_bound);
let (prob_below_lower, prob_in_range, prob_above_upper) = calculate_price_probability(
current_price,
&lower_bound,
&upper_bound,
None,
None,
&ExpirationDate::Days(pos_or_panic!(63.0)),
Some(dec!(0.0)),
)
.unwrap();
info!(
"Probability below lower bound ({}): {:.6}",
lower_bound, prob_below_lower
);
info!(
"Probability in range ({} to {}): {:.6}",
lower_bound, upper_bound, prob_in_range
);
info!(
"Probability above upper bound ({}): {:.6}",
upper_bound, prob_above_upper
);
let calculated_total = prob_below_lower + prob_in_range + prob_above_upper;
info!(
"Calculated total from individual probs: {:.6}",
calculated_total
);
assert!(
(total_profit_prob + total_loss_prob - 1.0).abs() < 0.1,
"Probabilities don't sum to ~1.0: profit={:.6}, loss={:.6}, sum={:.6}",
total_profit_prob,
total_loss_prob,
total_profit_prob + total_loss_prob
);
}
}