use crate::compat::Instant;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::*;
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StrategyType {
LongCall,
LongPut,
CoveredCall,
ProtectivePut,
BullCallSpread,
BearPutSpread,
LongStraddle,
LongStrangle,
IronCondor,
ButterflySpread,
Collar,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LegType {
Call,
Put,
Stock,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LegPosition {
Long,
Short,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyLeg {
pub leg_type: LegType,
pub position: LegPosition,
pub strike: Option<Money>,
pub premium: Money,
pub quantity: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyInput {
pub strategy_type: StrategyType,
pub underlying_price: Money,
pub legs: Vec<StrategyLeg>,
pub price_range: Option<(Money, Money)>,
pub price_steps: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayoffPoint {
pub underlying_price: Money,
pub payoff: Money,
pub per_leg: Vec<Money>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyCharacteristics {
pub direction: String,
pub profit_type: String,
pub loss_type: String,
pub requires_margin: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyOutput {
pub strategy_name: String,
pub net_premium: Money,
pub max_profit: Option<Money>,
pub max_loss: Option<Money>,
pub breakeven_points: Vec<Money>,
pub payoff_table: Vec<PayoffPoint>,
pub risk_reward_ratio: Option<Decimal>,
pub profit_probability_estimate: Option<Rate>,
pub strategy_characteristics: StrategyCharacteristics,
}
fn leg_payoff(leg: &StrategyLeg, s: Decimal) -> Decimal {
let raw = match leg.leg_type {
LegType::Call => {
let k = leg.strike.unwrap_or(Decimal::ZERO);
let intrinsic = if s > k { s - k } else { Decimal::ZERO };
match leg.position {
LegPosition::Long => intrinsic - leg.premium,
LegPosition::Short => leg.premium - intrinsic,
}
}
LegType::Put => {
let k = leg.strike.unwrap_or(Decimal::ZERO);
let intrinsic = if k > s { k - s } else { Decimal::ZERO };
match leg.position {
LegPosition::Long => intrinsic - leg.premium,
LegPosition::Short => leg.premium - intrinsic,
}
}
LegType::Stock => match leg.position {
LegPosition::Long => s - leg.premium,
LegPosition::Short => leg.premium - s,
},
};
raw * leg.quantity
}
fn strategy_payoff(legs: &[StrategyLeg], s: Decimal) -> (Decimal, Vec<Decimal>) {
let per_leg: Vec<Decimal> = legs.iter().map(|leg| leg_payoff(leg, s)).collect();
let total: Decimal = per_leg.iter().copied().sum();
(total, per_leg)
}
fn build_payoff_table(
legs: &[StrategyLeg],
low: Decimal,
high: Decimal,
steps: u32,
) -> Vec<PayoffPoint> {
let mut table = Vec::with_capacity(steps as usize + 1);
let range = high - low;
let step_size = if steps > 0 {
range / Decimal::from(steps)
} else {
range
};
for i in 0..=steps {
let price = low + step_size * Decimal::from(i);
let (payoff, per_leg) = strategy_payoff(legs, price);
table.push(PayoffPoint {
underlying_price: price,
payoff,
per_leg,
});
}
table
}
fn find_breakevens(table: &[PayoffPoint]) -> Vec<Money> {
let mut breakevens = Vec::new();
for i in 0..table.len() {
if table[i].payoff == Decimal::ZERO {
let price = table[i].underlying_price;
if !breakevens.contains(&price) {
breakevens.push(price);
}
continue;
}
if i == 0 {
continue;
}
let prev = &table[i - 1];
let curr = &table[i];
let prev_sign = prev.payoff > Decimal::ZERO;
let curr_sign = curr.payoff > Decimal::ZERO;
if prev_sign != curr_sign && prev.payoff != Decimal::ZERO && curr.payoff != Decimal::ZERO {
let denom = curr.payoff - prev.payoff;
if denom != Decimal::ZERO {
let t = -prev.payoff / denom;
let be =
prev.underlying_price + t * (curr.underlying_price - prev.underlying_price);
if !breakevens.contains(&be) {
breakevens.push(be);
}
}
}
}
breakevens.sort();
breakevens
}
fn find_max_profit_loss(table: &[PayoffPoint]) -> (Option<Money>, Option<Money>) {
if table.is_empty() {
return (None, None);
}
let max_payoff = table.iter().map(|p| p.payoff).max().unwrap();
let min_payoff = table.iter().map(|p| p.payoff).min().unwrap();
let n = table.len();
let profit_unlimited = if n >= 2 {
let at_right_end =
table[n - 1].payoff == max_payoff && table[n - 1].payoff > table[n - 2].payoff;
let at_left_end = table[0].payoff == max_payoff && table[0].payoff > table[1].payoff;
at_right_end || at_left_end
} else {
false
};
let loss_unlimited = if n >= 2 {
let at_right_end =
table[n - 1].payoff == min_payoff && table[n - 1].payoff < table[n - 2].payoff;
let at_left_end = table[0].payoff == min_payoff && table[0].payoff < table[1].payoff;
at_right_end || at_left_end
} else {
false
};
let max_profit = if profit_unlimited {
None
} else {
Some(max_payoff)
};
let max_loss = if loss_unlimited {
None
} else {
Some(min_payoff.abs())
};
(max_profit, max_loss)
}
fn compute_net_premium(legs: &[StrategyLeg]) -> Money {
let mut net = Decimal::ZERO;
for leg in legs {
let cost = leg.premium * leg.quantity;
match leg.position {
LegPosition::Long => {
match leg.leg_type {
LegType::Call | LegType::Put => net += cost,
LegType::Stock => {} }
}
LegPosition::Short => {
match leg.leg_type {
LegType::Call | LegType::Put => net -= cost,
LegType::Stock => {} }
}
}
}
net
}
fn determine_characteristics(strategy_type: &StrategyType) -> StrategyCharacteristics {
match strategy_type {
StrategyType::LongCall => StrategyCharacteristics {
direction: "bullish".to_string(),
profit_type: "unlimited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::LongPut => StrategyCharacteristics {
direction: "bearish".to_string(),
profit_type: "unlimited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::CoveredCall => StrategyCharacteristics {
direction: "bullish".to_string(),
profit_type: "limited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::ProtectivePut => StrategyCharacteristics {
direction: "bullish".to_string(),
profit_type: "unlimited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::BullCallSpread => StrategyCharacteristics {
direction: "bullish".to_string(),
profit_type: "limited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::BearPutSpread => StrategyCharacteristics {
direction: "bearish".to_string(),
profit_type: "limited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::LongStraddle => StrategyCharacteristics {
direction: "volatile".to_string(),
profit_type: "unlimited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::LongStrangle => StrategyCharacteristics {
direction: "volatile".to_string(),
profit_type: "unlimited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::IronCondor => StrategyCharacteristics {
direction: "neutral".to_string(),
profit_type: "limited".to_string(),
loss_type: "limited".to_string(),
requires_margin: true,
},
StrategyType::ButterflySpread => StrategyCharacteristics {
direction: "neutral".to_string(),
profit_type: "limited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::Collar => StrategyCharacteristics {
direction: "bullish".to_string(),
profit_type: "limited".to_string(),
loss_type: "limited".to_string(),
requires_margin: false,
},
StrategyType::Custom => StrategyCharacteristics {
direction: "neutral".to_string(),
profit_type: "unknown".to_string(),
loss_type: "unknown".to_string(),
requires_margin: false,
},
}
}
fn strategy_name(strategy_type: &StrategyType) -> String {
match strategy_type {
StrategyType::LongCall => "Long Call".to_string(),
StrategyType::LongPut => "Long Put".to_string(),
StrategyType::CoveredCall => "Covered Call".to_string(),
StrategyType::ProtectivePut => "Protective Put".to_string(),
StrategyType::BullCallSpread => "Bull Call Spread".to_string(),
StrategyType::BearPutSpread => "Bear Put Spread".to_string(),
StrategyType::LongStraddle => "Long Straddle".to_string(),
StrategyType::LongStrangle => "Long Strangle".to_string(),
StrategyType::IronCondor => "Iron Condor".to_string(),
StrategyType::ButterflySpread => "Butterfly Spread".to_string(),
StrategyType::Collar => "Collar".to_string(),
StrategyType::Custom => "Custom Strategy".to_string(),
}
}
fn validate_input(input: &StrategyInput) -> CorpFinanceResult<()> {
if input.legs.is_empty() {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Strategy must have at least one leg".into(),
});
}
if input.underlying_price <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: "underlying_price".into(),
reason: "Underlying price must be positive".into(),
});
}
for (i, leg) in input.legs.iter().enumerate() {
if leg.quantity <= Decimal::ZERO {
return Err(CorpFinanceError::InvalidInput {
field: format!("legs[{}].quantity", i),
reason: "Quantity must be positive".into(),
});
}
match leg.leg_type {
LegType::Call | LegType::Put => match leg.strike {
Some(k) if k <= Decimal::ZERO => {
return Err(CorpFinanceError::InvalidInput {
field: format!("legs[{}].strike", i),
reason: "Strike price must be positive for options".into(),
});
}
None => {
return Err(CorpFinanceError::InvalidInput {
field: format!("legs[{}].strike", i),
reason: "Strike price is required for Call/Put legs".into(),
});
}
_ => {}
},
LegType::Stock => {}
}
}
validate_strategy_legs(input)?;
Ok(())
}
fn validate_strategy_legs(input: &StrategyInput) -> CorpFinanceResult<()> {
let call_count = input
.legs
.iter()
.filter(|l| matches!(l.leg_type, LegType::Call))
.count();
let put_count = input
.legs
.iter()
.filter(|l| matches!(l.leg_type, LegType::Put))
.count();
let stock_count = input
.legs
.iter()
.filter(|l| matches!(l.leg_type, LegType::Stock))
.count();
match input.strategy_type {
StrategyType::LongCall => {
if call_count != 1 || put_count != 0 || stock_count != 0 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Long Call requires exactly 1 call leg".into(),
});
}
}
StrategyType::LongPut => {
if put_count != 1 || call_count != 0 || stock_count != 0 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Long Put requires exactly 1 put leg".into(),
});
}
}
StrategyType::CoveredCall => {
if stock_count != 1 || call_count != 1 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Covered Call requires 1 stock leg and 1 call leg".into(),
});
}
}
StrategyType::ProtectivePut => {
if stock_count != 1 || put_count != 1 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Protective Put requires 1 stock leg and 1 put leg".into(),
});
}
}
StrategyType::BullCallSpread => {
if call_count != 2 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Bull Call Spread requires exactly 2 call legs".into(),
});
}
}
StrategyType::BearPutSpread => {
if put_count != 2 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Bear Put Spread requires exactly 2 put legs".into(),
});
}
}
StrategyType::LongStraddle => {
if call_count != 1 || put_count != 1 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Long Straddle requires 1 call and 1 put leg".into(),
});
}
}
StrategyType::LongStrangle => {
if call_count != 1 || put_count != 1 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Long Strangle requires 1 call and 1 put leg".into(),
});
}
}
StrategyType::IronCondor => {
if call_count != 2 || put_count != 2 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Iron Condor requires 2 call legs and 2 put legs".into(),
});
}
}
StrategyType::ButterflySpread => {
if call_count != 3 && put_count != 3 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Butterfly Spread requires 3 call or 3 put legs".into(),
});
}
}
StrategyType::Collar => {
if stock_count != 1 || put_count != 1 || call_count != 1 {
return Err(CorpFinanceError::InvalidInput {
field: "legs".into(),
reason: "Collar requires 1 stock, 1 put, and 1 call leg".into(),
});
}
}
StrategyType::Custom => {}
}
Ok(())
}
pub fn analyze_strategy(
input: &StrategyInput,
) -> CorpFinanceResult<ComputationOutput<StrategyOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
validate_input(input)?;
let thirty_pct = input.underlying_price * Decimal::new(30, 2); let (low, high) = input.price_range.unwrap_or((
input.underlying_price - thirty_pct,
input.underlying_price + thirty_pct,
));
let low = if low < Decimal::ZERO {
Decimal::ZERO
} else {
low
};
if low >= high {
return Err(CorpFinanceError::InvalidInput {
field: "price_range".into(),
reason: "Low price must be less than high price".into(),
});
}
let steps = input.price_steps.unwrap_or(21);
if steps == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "price_steps".into(),
reason: "Must have at least 1 price step".into(),
});
}
let payoff_table = build_payoff_table(&input.legs, low, high, steps);
let breakeven_points = find_breakevens(&payoff_table);
let (max_profit, max_loss) = find_max_profit_loss(&payoff_table);
let risk_reward_ratio = match (max_profit, max_loss) {
(Some(mp), Some(ml)) if ml > Decimal::ZERO => Some(mp / ml),
_ => None,
};
let net_premium = compute_net_premium(&input.legs);
let profitable_count = payoff_table
.iter()
.filter(|p| p.payoff > Decimal::ZERO)
.count();
let total_points = payoff_table.len();
let profit_probability_estimate = if total_points > 0 {
Some(Decimal::from(profitable_count as u32) / Decimal::from(total_points as u32))
} else {
None
};
let strategy_characteristics = determine_characteristics(&input.strategy_type);
if max_profit.is_none() {
warnings.push("Profit potential is theoretically unlimited".to_string());
}
if max_loss.is_none() {
warnings.push("Loss potential is theoretically unlimited".to_string());
}
let output = StrategyOutput {
strategy_name: strategy_name(&input.strategy_type),
net_premium,
max_profit,
max_loss,
breakeven_points,
payoff_table,
risk_reward_ratio,
profit_probability_estimate,
strategy_characteristics,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Option Strategy Analysis — Expiry Payoff Profile",
&serde_json::json!({
"strategy_type": strategy_name(&input.strategy_type),
"underlying_price": input.underlying_price.to_string(),
"num_legs": input.legs.len(),
"price_range": format!("{} - {}", low, high),
"price_steps": steps,
}),
warnings,
elapsed,
output,
))
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_long_call_payoff() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[0].payoff, dec!(-5));
assert_eq!(table[40].payoff, dec!(15));
let at_strike = table
.iter()
.find(|p| p.underlying_price == dec!(100))
.unwrap();
assert_eq!(at_strike.payoff, dec!(-5));
assert_eq!(result.result.strategy_characteristics.direction, "bullish");
}
#[test]
fn test_long_put_payoff() {
let input = StrategyInput {
strategy_type: StrategyType::LongPut,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[0].payoff, dec!(15));
assert_eq!(table[40].payoff, dec!(-5));
assert_eq!(result.result.strategy_characteristics.direction, "bearish");
}
#[test]
fn test_covered_call() {
let input = StrategyInput {
strategy_type: StrategyType::CoveredCall,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Stock,
position: LegPosition::Long,
strike: None,
premium: dec!(100), quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(105)),
premium: dec!(3),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[40].payoff, dec!(8));
assert_eq!(table[0].payoff, dec!(-17));
assert_eq!(
result.result.strategy_characteristics.profit_type,
"limited"
);
}
#[test]
fn test_protective_put() {
let input = StrategyInput {
strategy_type: StrategyType::ProtectivePut,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Stock,
position: LegPosition::Long,
strike: None,
premium: dec!(100),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[0].payoff, dec!(-7));
assert_eq!(table[40].payoff, dec!(18));
assert_eq!(result.result.strategy_characteristics.direction, "bullish");
}
#[test]
fn test_bull_call_spread() {
let input = StrategyInput {
strategy_type: StrategyType::BullCallSpread,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(7),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(105)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[0].payoff, dec!(-5));
assert_eq!(table[40].payoff, dec!(5));
assert_eq!(
result.result.strategy_characteristics.profit_type,
"limited"
);
assert_eq!(result.result.strategy_characteristics.loss_type, "limited");
}
#[test]
fn test_bear_put_spread() {
let input = StrategyInput {
strategy_type: StrategyType::BearPutSpread,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(105)),
premium: dec!(7),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Short,
strike: Some(dec!(95)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[0].payoff, dec!(5));
assert_eq!(table[40].payoff, dec!(-5));
assert_eq!(result.result.strategy_characteristics.direction, "bearish");
}
#[test]
fn test_long_straddle() {
let input = StrategyInput {
strategy_type: StrategyType::LongStraddle,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
let at_strike = table
.iter()
.find(|p| p.underlying_price == dec!(100))
.unwrap();
assert_eq!(at_strike.payoff, dec!(-10));
assert_eq!(table[0].payoff, dec!(10));
assert_eq!(table[40].payoff, dec!(10));
assert_eq!(result.result.strategy_characteristics.direction, "volatile");
}
#[test]
fn test_long_strangle() {
let input = StrategyInput {
strategy_type: StrategyType::LongStrangle,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(110)),
premium: dec!(2),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(90)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(70), dec!(130))),
price_steps: Some(60),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
let at_100 = table
.iter()
.find(|p| p.underlying_price == dec!(100))
.unwrap();
assert_eq!(at_100.payoff, dec!(-4));
assert_eq!(table[0].payoff, dec!(16));
assert_eq!(table[60].payoff, dec!(16));
}
#[test]
fn test_iron_condor() {
let input = StrategyInput {
strategy_type: StrategyType::IronCondor,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Short,
strike: Some(dec!(90)),
premium: dec!(3),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(110)),
premium: dec!(3),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(85)),
premium: dec!(1),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(115)),
premium: dec!(1),
quantity: dec!(1),
},
],
price_range: Some((dec!(75), dec!(125))),
price_steps: Some(50),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
let at_100 = table
.iter()
.find(|p| p.underlying_price == dec!(100))
.unwrap();
assert_eq!(at_100.payoff, dec!(4));
assert_eq!(table[0].payoff, dec!(-1));
assert_eq!(result.result.strategy_characteristics.direction, "neutral");
}
#[test]
fn test_butterfly_spread() {
let input = StrategyInput {
strategy_type: StrategyType::ButterflySpread,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(8),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(2),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(105)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(85), dec!(115))),
price_steps: Some(30),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
let at_100 = table
.iter()
.find(|p| p.underlying_price == dec!(100))
.unwrap();
assert_eq!(at_100.payoff, dec!(5));
assert_eq!(table[0].payoff, dec!(0));
assert_eq!(table[30].payoff, dec!(0));
assert_eq!(result.result.strategy_characteristics.direction, "neutral");
}
#[test]
fn test_collar() {
let input = StrategyInput {
strategy_type: StrategyType::Collar,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Stock,
position: LegPosition::Long,
strike: None,
premium: dec!(100),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(3),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(105)),
premium: dec!(3),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[0].payoff, dec!(-5));
assert_eq!(table[40].payoff, dec!(5));
assert_eq!(result.result.net_premium, dec!(0));
}
#[test]
fn test_breakeven_long_call() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: Some((dec!(90), dec!(120))),
price_steps: Some(300), };
let result = analyze_strategy(&input).unwrap();
let breakevens = &result.result.breakeven_points;
assert_eq!(breakevens.len(), 1);
assert_eq!(breakevens[0], dec!(105));
}
#[test]
fn test_breakeven_straddle() {
let input = StrategyInput {
strategy_type: StrategyType::LongStraddle,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(400), };
let result = analyze_strategy(&input).unwrap();
let breakevens = &result.result.breakeven_points;
assert_eq!(breakevens.len(), 2);
assert_eq!(breakevens[0], dec!(90));
assert_eq!(breakevens[1], dec!(110));
}
#[test]
fn test_max_profit_limited() {
let input = StrategyInput {
strategy_type: StrategyType::BullCallSpread,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(7),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(105)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
assert!(result.result.max_profit.is_some());
assert_eq!(result.result.max_profit.unwrap(), dec!(5));
}
#[test]
fn test_max_loss_limited() {
let input = StrategyInput {
strategy_type: StrategyType::BullCallSpread,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(7),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(105)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
assert!(result.result.max_loss.is_some());
assert_eq!(result.result.max_loss.unwrap(), dec!(5));
}
#[test]
fn test_net_premium_credit() {
let input = StrategyInput {
strategy_type: StrategyType::IronCondor,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Short,
strike: Some(dec!(90)),
premium: dec!(3),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(110)),
premium: dec!(3),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Put,
position: LegPosition::Long,
strike: Some(dec!(85)),
premium: dec!(1),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(115)),
premium: dec!(1),
quantity: dec!(1),
},
],
price_range: Some((dec!(75), dec!(125))),
price_steps: Some(50),
};
let result = analyze_strategy(&input).unwrap();
assert_eq!(result.result.net_premium, dec!(-4));
}
#[test]
fn test_empty_legs_error() {
let input = StrategyInput {
strategy_type: StrategyType::Custom,
underlying_price: dec!(100),
legs: vec![],
price_range: None,
price_steps: None,
};
let err = analyze_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "legs");
}
_ => panic!("Expected InvalidInput error for empty legs"),
}
}
#[test]
fn test_metadata_populated() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: None,
price_steps: None,
};
let result = analyze_strategy(&input).unwrap();
assert!(!result.methodology.is_empty());
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
assert!(!result.metadata.version.is_empty());
let assumptions = result.assumptions.as_object().unwrap();
assert!(assumptions.contains_key("strategy_type"));
assert!(assumptions.contains_key("underlying_price"));
assert!(assumptions.contains_key("num_legs"));
}
#[test]
fn test_quantity_multiplier() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(10),
}],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table[40].payoff, dec!(150));
assert_eq!(table[0].payoff, dec!(-50));
}
#[test]
fn test_invalid_strike_error() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(-10)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: None,
price_steps: None,
};
let err = analyze_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert!(field.contains("strike"));
}
_ => panic!("Expected InvalidInput error for negative strike"),
}
}
#[test]
fn test_risk_reward_ratio() {
let input = StrategyInput {
strategy_type: StrategyType::BullCallSpread,
underlying_price: dec!(100),
legs: vec![
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(7),
quantity: dec!(1),
},
StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Short,
strike: Some(dec!(105)),
premium: dec!(2),
quantity: dec!(1),
},
],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let rr = result.result.risk_reward_ratio.unwrap();
assert_eq!(rr, dec!(1));
}
#[test]
fn test_profit_probability_estimate() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: Some((dec!(80), dec!(120))),
price_steps: Some(40),
};
let result = analyze_strategy(&input).unwrap();
let prob = result.result.profit_probability_estimate.unwrap();
assert!(prob > Decimal::ZERO);
assert!(prob < Decimal::ONE);
}
#[test]
fn test_default_price_range() {
let input = StrategyInput {
strategy_type: StrategyType::LongCall,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(100)),
premium: dec!(5),
quantity: dec!(1),
}],
price_range: None,
price_steps: None,
};
let result = analyze_strategy(&input).unwrap();
let table = &result.result.payoff_table;
assert_eq!(table.len(), 22);
assert_eq!(table[0].underlying_price, dec!(70));
assert_eq!(table[21].underlying_price, dec!(130));
}
#[test]
fn test_wrong_leg_count_bull_call_spread() {
let input = StrategyInput {
strategy_type: StrategyType::BullCallSpread,
underlying_price: dec!(100),
legs: vec![StrategyLeg {
leg_type: LegType::Call,
position: LegPosition::Long,
strike: Some(dec!(95)),
premium: dec!(7),
quantity: dec!(1),
}],
price_range: None,
price_steps: None,
};
let err = analyze_strategy(&input).unwrap_err();
match err {
CorpFinanceError::InvalidInput { field, .. } => {
assert_eq!(field, "legs");
}
_ => panic!("Expected InvalidInput for wrong leg count"),
}
}
}