Skip to content

Commit 0c12afd

Browse files
authored
fix(strategies): guard Positive boundaries in butterfly/spread P&L math (#387) (#389)
Multiple example binaries panicked mid-optimizer-scan with `Positive invariant broken` because strategy P&L and break-even expressions crossed the `Positive` boundary with an unchecked `Add<Decimal>` / `Sub<Positive>`: - `CallButterfly::update_break_even_points`: `strike ± profit/qty` replaced by `Positive::new_decimal(..)` on the underlying `Decimal` sum; out-of-range candidates are dropped cleanly instead of panicking. - `CallButterfly::get_profit_area`: `be[1] - be[0]` and `short_high_strike - short_low_strike` lowered to `Decimal` first then wrapped; non-positive widths fall back to `Positive::ZERO`. - `LongButterflySpread::update_break_even_points`: same `Positive ± Decimal` guard. - `BullPutSpread::get_max_loss`: width computed as `Decimal`, wrapped with `Positive::new_decimal(..).map_err` instead of `Positive - Positive`; inverted-strike candidates return a typed `MaxLossError` instead of panicking. Unblocks the following example binaries mid-optimizer-scan: `strategy_call_butterfly_best_{area,ratio}`, `strategy_long_butterfly_spread_best_{area,ratio}`, `strategy_call_butterfly_delta`, `strategy_bull_put_spread_extended_delta`. Closes #387.
1 parent 594164b commit 0c12afd

3 files changed

Lines changed: 45 additions & 29 deletions

File tree

src/strategies/bull_put_spread.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ use super::shared::SpreadStrategy;
2727
use crate::{
2828
ExpirationDate, Options,
2929
chains::{StrategyLegs, chain::OptionChain, utils::OptionDataGroup},
30-
constants::ZERO,
3130
error::{
3231
GreeksError, OperationErrorKind, PricingError,
3332
position::{PositionError, PositionValidationErrorKind},
@@ -612,18 +611,24 @@ impl Strategies for BullPutSpread {
612611
}
613612
}
614613
fn get_max_loss(&self) -> Result<Positive, StrategyError> {
615-
let width = self.short_put.option.strike_price - self.long_put.option.strike_price;
616-
let max_loss =
617-
(width * self.short_put.option.quantity) - self.get_net_premium_received()?;
618-
if max_loss < ZERO {
619-
Err(StrategyError::ProfitLossError(
614+
let short_strike = self.short_put.option.strike_price.to_dec();
615+
let long_strike = self.long_put.option.strike_price.to_dec();
616+
let width = short_strike - long_strike;
617+
if width < Decimal::ZERO {
618+
return Err(StrategyError::ProfitLossError(
620619
ProfitLossErrorKind::MaxLossError {
621-
reason: "Max loss is negative".to_string(),
620+
reason: "Short put strike must be above long put strike".to_string(),
622621
},
623-
))
624-
} else {
625-
Ok(max_loss)
622+
));
626623
}
624+
let qty = self.short_put.option.quantity.to_dec();
625+
let net_prem = self.get_net_premium_received()?.to_dec();
626+
let max_loss_dec = (width * qty) - net_prem;
627+
Positive::new_decimal(max_loss_dec).map_err(|_| {
628+
StrategyError::ProfitLossError(ProfitLossErrorKind::MaxLossError {
629+
reason: "Max loss is negative".to_string(),
630+
})
631+
})
627632
}
628633
fn get_profit_area(&self) -> Result<Decimal, StrategyError> {
629634
let high = self.get_max_profit().unwrap_or(Positive::ZERO);

src/strategies/call_butterfly.rs

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -381,19 +381,21 @@ impl BreakEvenable for CallButterfly {
381381
fn update_break_even_points(&mut self) -> Result<(), StrategyError> {
382382
self.break_even_points = Vec::new();
383383

384-
self.break_even_points.push(
385-
(self.long_call.option.strike_price
386-
- self.calculate_profit_at(&self.long_call.option.strike_price)?
387-
/ self.long_call.option.quantity)
388-
.round_to(2),
389-
);
384+
let long_strike = self.long_call.option.strike_price.to_dec();
385+
let long_qty = self.long_call.option.quantity.to_dec();
386+
let long_profit = self.calculate_profit_at(&self.long_call.option.strike_price)?;
387+
let candidate_low = long_strike - long_profit / long_qty;
388+
if let Ok(be) = Positive::new_decimal(candidate_low) {
389+
self.break_even_points.push(be.round_to(2));
390+
}
390391

391-
self.break_even_points.push(
392-
(self.short_call_high.option.strike_price
393-
+ self.calculate_profit_at(&self.short_call_high.option.strike_price)?
394-
/ self.short_call_high.option.quantity)
395-
.round_to(2),
396-
);
392+
let short_strike = self.short_call_high.option.strike_price.to_dec();
393+
let short_qty = self.short_call_high.option.quantity.to_dec();
394+
let short_profit = self.calculate_profit_at(&self.short_call_high.option.strike_price)?;
395+
let candidate_high = short_strike + short_profit / short_qty;
396+
if let Ok(be) = Positive::new_decimal(candidate_high) {
397+
self.break_even_points.push(be.round_to(2));
398+
}
397399

398400
self.break_even_points.sort();
399401
Ok(())
@@ -722,10 +724,15 @@ impl Strategies for CallButterfly {
722724
BreakEvenErrorKind::NoBreakEvenPoints,
723725
));
724726
}
725-
let base_low = break_even[1] - break_even[0];
727+
let be0 = break_even[0].to_dec();
728+
let be1 = break_even[1].to_dec();
729+
let base_low_dec = be1 - be0;
730+
let base_low = Positive::new_decimal(base_low_dec).unwrap_or(Positive::ZERO);
726731
let max_profit = self.get_max_profit().unwrap_or(Positive::ZERO);
727-
let base_high =
728-
self.short_call_high.option.strike_price - self.short_call_low.option.strike_price;
732+
let short_high = self.short_call_high.option.strike_price.to_dec();
733+
let short_low = self.short_call_low.option.strike_price.to_dec();
734+
let base_high_dec = short_high - short_low;
735+
let base_high = Positive::new_decimal(base_high_dec).unwrap_or(Positive::ZERO);
729736
Ok(
730737
Decimal::from_f64((base_low.to_f64() + base_high.to_f64()) * max_profit.to_f64() / 2.0)
731738
.unwrap_or(Decimal::ZERO),

src/strategies/long_butterfly_spread.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -379,13 +379,17 @@ impl BreakEvenable for LongButterflySpread {
379379
/ self.long_call_high.option.quantity;
380380

381381
if left_net_value <= Decimal::ZERO {
382-
self.break_even_points
383-
.push((self.long_call_low.option.strike_price - left_net_value).round_to(2));
382+
let candidate = self.long_call_low.option.strike_price.to_dec() - left_net_value;
383+
if let Ok(be) = Positive::new_decimal(candidate) {
384+
self.break_even_points.push(be.round_to(2));
385+
}
384386
}
385387

386388
if right_net_value <= Decimal::ZERO {
387-
self.break_even_points
388-
.push((self.long_call_high.option.strike_price + right_net_value).round_to(2));
389+
let candidate = self.long_call_high.option.strike_price.to_dec() + right_net_value;
390+
if let Ok(be) = Positive::new_decimal(candidate) {
391+
self.break_even_points.push(be.round_to(2));
392+
}
389393
}
390394

391395
self.break_even_points.sort();

0 commit comments

Comments
 (0)