Fix get_max_profit/get_max_loss for single-leg strategies#284
Fix get_max_profit/get_max_loss for single-leg strategies#284ms32035 wants to merge 2 commits intojoaquinbejar:mainfrom
Conversation
All four single-leg strategies (LongCall, LongPut, ShortCall, ShortPut) evaluated profit/loss at the strike price, which is incorrect: - LongCall at strike always yields -premium (worthless call) - LongPut at strike always yields -premium (worthless put) - ShortCall returned Err for unlimited loss instead of Positive::INFINITY - ShortPut at strike yields +premium, failing the loss check Fixed by using correct evaluation points and Positive::INFINITY for unlimited profit/loss, consistent with how LongStraddle handles it.
There was a problem hiding this comment.
Pull request overview
Fixes incorrect max profit/loss calculations for single-leg option strategies by evaluating at the correct underlying prices and representing unbounded outcomes as Positive::INFINITY.
Changes:
- Correct max profit/loss evaluation points for
LongPutandShortPut(e.g., price = 0 where appropriate). - Represent unbounded max profit/loss for
LongCallandShortCallasPositive::INFINITYinstead of erroring. - Update
ShortCallunit tests to reflect the newINFINITYbehavior and near-zero profit ratio.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/strategies/short_call_test.rs | Updates expectations for ShortCall max loss (now INFINITY) and profit ratio behavior. |
| src/strategies/short_put.rs | Fixes ShortPut max profit/loss logic using correct evaluation points (loss at underlying = 0). |
| src/strategies/short_call.rs | Fixes ShortCall max profit (net premium) and returns INFINITY for unlimited max loss. |
| src/strategies/long_put.rs | Fixes LongPut max profit to evaluate at underlying = 0; max loss becomes total cost. |
| src/strategies/long_call.rs | Fixes LongCall max profit to INFINITY; max loss becomes total cost. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| fn get_max_profit(&self) -> Result<Positive, StrategyError> { | ||
| let profit = self.calculate_profit_at(&self.long_call.option.strike_price)?; | ||
| if profit >= Decimal::ZERO { | ||
| Ok(Positive::new_decimal(profit)?) | ||
| } else { | ||
| Err(StrategyError::ProfitLossError( | ||
| ProfitLossErrorKind::MaxProfitError { | ||
| reason: "Net premium received is negative".to_string(), | ||
| }, | ||
| )) | ||
| } | ||
| Ok(Positive::INFINITY) // Theoretically unlimited | ||
| } | ||
| fn get_max_loss(&self) -> Result<Positive, StrategyError> { | ||
| let loss = self.calculate_profit_at(&self.long_call.option.strike_price)?; | ||
| if loss <= Decimal::ZERO { | ||
| Ok(Positive::new_decimal(loss.abs()).unwrap_or(Positive::ZERO)) | ||
| } else { | ||
| Err(StrategyError::ProfitLossError( | ||
| ProfitLossErrorKind::MaxLossError { | ||
| reason: "Max loss is negative".to_string(), | ||
| }, | ||
| )) | ||
| } | ||
| // Max loss for a long call is the premium paid (at any price ≤ strike). |
There was a problem hiding this comment.
Returning Positive::INFINITY for max profit interacts poorly with the existing get_profit_area/get_profit_ratio implementations: they convert via to_f64() and then Decimal::from_f64(...), which returns None for Inf/overflow and gets coerced to Decimal::ZERO via unwrap_or. That makes metrics like profit_ratio/area silently become 0 for an unlimited-profit strategy. Add explicit handling for Positive::INFINITY (e.g., return Decimal::MAX or a documented sentinel) instead of relying on float/Decimal conversions.
marty-mcclaw
left a comment
There was a problem hiding this comment.
Technical fix for max profit/loss calculations in single-leg strategies. Moving to Positive::INFINITY for unlimited exposure is much more consistent with the rest of the library's payoff modeling. A few observations on the ShortPut evaluation and test assertions.
Codecov Report❌ Patch coverage is
... and 2 files with indirect coverage changes 🚀 New features to boost your workflow:
|
joaquinbejar
left a comment
There was a problem hiding this comment.
Thanks for the PR @ms32035! The bug analysis is correct — all four single-leg strategies were evaluating profit/loss at the strike price, which for ATM options always yields -premium (worthless option).
Overall Assessment
The mathematical reasoning is sound and consistent with how LongStraddle/ShortStraddle/ShortStrangle already handle unlimited profit/loss via Positive::INFINITY.
CI Failure
The GitHub Actions pipeline reported a lint failure. I recommend running the following before pushing:
make lint-fix pre-pushThis will fix formatting issues and ensure clippy, tests, and build all pass cleanly.
Suggestions
I have left a few inline comments. The main points:
-
get_profit_ratio/get_profit_areasilently returnDecimal::ZEROwhenmax_profitormax_lossisPositive::INFINITY— this is a pre-existing issue not introduced by this PR, but now more visible. Consider handling theINFINITYcase explicitly inget_profit_ratio(returningDecimal::MAXorDecimal::ZEROdepending on which side is infinite). I can open a separate issue for this. -
Test coverage: the existing tests for
long_call,long_put, andshort_putuse flexiblematch Ok/Errpatterns that still pass, but it would be good to tighten them to explicitly assert the new correct behavior (similar to what you did forshort_call_test.rs). -
Consider adding missing tests for
long_call_test,long_put_test, andshort_put_testthat explicitly verifyPositive::INFINITYandget_total_cost()results.
| }, | ||
| )) | ||
| } | ||
| Ok(Positive::INFINITY) // Theoretically unlimited |
There was a problem hiding this comment.
✅ Correct: Long Call has theoretically unlimited upside. Consistent with LongStraddle and LongStrangle patterns.
get_profit_ratio uses Decimal::from_f64(max_profit.to_f64() / max_loss.to_f64() * 100.0).unwrap_or(Decimal::ZERO), when max_profit = INFINITY, this computes INFINITY / cost * 100 = INFINITY, and Decimal::from_f64(INFINITY) returns None, so get_profit_ratio silently returns Decimal::ZERO. This is a pre-existing issue (before the fix, the error path caused the same result), but worth noting for a follow-up.
| let profit = self.calculate_profit_at(&self.long_put.option.strike_price)?; | ||
| // Max profit for a long put occurs at price = 0: strike - premium. | ||
| let profit = self.calculate_profit_at(&Positive::ZERO)?; | ||
| if profit >= Decimal::ZERO { |
There was a problem hiding this comment.
✅ Correct: evaluating at Positive::ZERO gives the maximum profit for a long put (strike - premium - fees). This is the right evaluation point.
| }, | ||
| )) | ||
| } | ||
| // Max profit for a short call is the net premium received (at any price ≤ strike). |
There was a problem hiding this comment.
✅ get_net_premium_received() is the right abstraction for the max profit of a short option. Produces the same numerical result as the previous calculate_profit_at(strike) but is semantically clearer.
| // Max profit for a short call is the net premium received (at any price ≤ strike). | ||
| self.get_net_premium_received() | ||
| } | ||
| fn get_max_loss(&self) -> Result<Positive, StrategyError> { |
There was a problem hiding this comment.
✅ Returning Positive::INFINITY instead of Err is the correct pattern. This is consistent with ShortStraddle, ShortStrangle, and CallButterfly. The previous Err approach caused get_profit_ratio to use unwrap_or(Positive::ZERO) and then hit the (_, ZERO) branch returning Decimal::MAX, which was misleading.
| // Max loss for a short call is theoretically unlimited (Positive::INFINITY). | ||
| assert!(result.is_ok()); | ||
| assert_eq!(result.unwrap(), Positive::INFINITY); | ||
| } |
There was a problem hiding this comment.
✅ Good update. The old test was asserting that unlimited loss was an Err, which was the root cause of the semantic issue. Now correctly checks for Positive::INFINITY.
💡 Suggestion: consider adding similar explicit assertions in long_call_test.rs (assert get_max_profit() == Positive::INFINITY), long_put_test.rs (assert get_max_loss() == get_total_cost()), and short_put_test.rs (assert get_max_loss() matches calculate_profit_at(ZERO).abs()). The existing tests use flexible match Ok/Err patterns that still pass, but explicit assertions would be stronger.
| assert_eq!(ratio_result.unwrap(), Decimal::MAX); // Based on current ShortCall impl | ||
| let ratio = ratio_result.unwrap(); | ||
| assert!( | ||
| ratio < Decimal::new(1, 10), |
There was a problem hiding this comment.
✅ Good fix. With max_loss = Positive::INFINITY, the ratio max_profit / INFINITY * 100 ≈ 0, which is mathematically correct for a strategy with unlimited downside.
The assertion ratio < Decimal::new(1, 10) (i.e. < 1e-10) is appropriate since the actual value will be exactly 0.0.
|
All four single-leg strategies (LongCall, LongPut, ShortCall, ShortPut) evaluated profit/loss at the strike price, which is incorrect:
Fixed by using correct evaluation points and Positive::INFINITY for unlimited profit/loss, consistent with how LongStraddle handles it.