Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 3 additions & 21 deletions src/strategies/long_call.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use super::base::{BreakEvenable, Positionable, StrategyType};
use crate::backtesting::results::{SimulationResult, SimulationStatsResult};
use crate::chains::OptionChain;
use crate::error::strategies::ProfitLossErrorKind;
use crate::error::{
GreeksError, PricingError, ProbabilityError, SimulationError, StrategyError,
position::{PositionError, PositionValidationErrorKind},
Expand Down Expand Up @@ -277,28 +276,11 @@ impl BreakEvenable for LongCall {

impl Strategies for LongCall {
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
Comment thread
ms32035 marked this conversation as resolved.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Correct: Long Call has theoretically unlimited upside. Consistent with LongStraddle and LongStrangle patterns.

⚠️ Note: since 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.

}
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).
Comment on lines 278 to +282
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread
ms32035 marked this conversation as resolved.
Ok(self.get_total_cost()?)
}
fn get_profit_area(&self) -> Result<Decimal, StrategyError> {
let high = self.get_max_profit().unwrap_or(Positive::ZERO);
Expand Down
17 changes: 5 additions & 12 deletions src/strategies/long_put.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,28 +274,21 @@ impl BreakEvenable for LongPut {

impl Strategies for LongPut {
fn get_max_profit(&self) -> Result<Positive, StrategyError> {
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 {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Correct: evaluating at Positive::ZERO gives the maximum profit for a long put (strike - premium - fees). This is the right evaluation point.

Ok(Positive::new_decimal(profit)?)
} else {
Err(StrategyError::ProfitLossError(
ProfitLossErrorKind::MaxProfitError {
reason: "Net premium received is negative".to_string(),
reason: "Max profit is negative".to_string(),
},
))
}
}
fn get_max_loss(&self) -> Result<Positive, StrategyError> {
let loss = self.calculate_profit_at(&self.long_put.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 put is the premium paid (at any price ≥ strike).
Comment thread
ms32035 marked this conversation as resolved.
Ok(self.get_total_cost()?)
}
fn get_profit_area(&self) -> Result<Decimal, StrategyError> {
let high = self.get_max_profit().unwrap_or(Positive::ZERO);
Expand Down
20 changes: 3 additions & 17 deletions src/strategies/short_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use super::base::{BreakEvenable, Positionable, StrategyType};
use crate::backtesting::results::{SimulationResult, SimulationStatsResult};

use crate::chains::OptionChain;
use crate::error::strategies::ProfitLossErrorKind;
use crate::error::{
GreeksError, PricingError, ProbabilityError, SimulationError, StrategyError,
position::{PositionError, PositionValidationErrorKind},
Expand Down Expand Up @@ -289,24 +288,11 @@ impl BreakEvenable for ShortCall {

impl Strategies for ShortCall {
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: "Net premium received is negative".to_string(),
},
))
}
// Max profit for a short call is the net premium received (at any price ≤ strike).
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

self.get_net_premium_received()
}
fn get_max_loss(&self) -> Result<Positive, StrategyError> {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.
Err(StrategyError::ProfitLossError(
ProfitLossErrorKind::MaxLossError {
reason: "Maximum loss is unlimited for a short call.".to_string(),
},
))
Ok(Positive::INFINITY) // Theoretically unlimited
}
fn get_profit_area(&self) -> Result<Decimal, StrategyError> {
let high = self.get_max_profit().unwrap_or(Positive::ZERO);
Expand Down
15 changes: 4 additions & 11 deletions src/strategies/short_put.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,19 +279,12 @@ impl BreakEvenable for ShortPut {

impl Strategies for ShortPut {
fn get_max_profit(&self) -> Result<Positive, StrategyError> {
let profit = self.calculate_profit_at(&self.short_put.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(),
},
))
}
// Max profit for a short put is the net premium received (at any price ≥ strike).
Comment thread
ms32035 marked this conversation as resolved.
self.get_net_premium_received()
}
fn get_max_loss(&self) -> Result<Positive, StrategyError> {
let loss = self.calculate_profit_at(&self.short_put.option.strike_price)?;
// Max loss for a short put occurs at price = 0: strike - premium.
Comment thread
ms32035 marked this conversation as resolved.
let loss = self.calculate_profit_at(&Positive::ZERO)?;
if loss <= Decimal::ZERO {
Comment thread
ms32035 marked this conversation as resolved.
Ok(Positive::new_decimal(loss.abs()).unwrap_or(Positive::ZERO))
} else {
Expand Down
40 changes: 8 additions & 32 deletions tests/unit/strategies/long_call_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,36 +188,9 @@ fn test_long_call_break_even_points() {
#[test]
fn test_long_call_get_max_profit() {
let long_call = create_test_long_call();
let result = long_call.get_max_profit();
// For a Long Call, the maximum profit is theoretically infinite
// but in practice it depends on the implementation
match result {
Ok(profit) => {
// Verify that the maximum profit is positive
assert!(profit > Positive::ZERO);
// For a long call with strike 100, premium 5, and underlying at 100,
// the profit at a very high price like 200 would be approximately:
// (200 - 100) - 5 - 0.5 - 0.5 = 94
// This is a rough approximation of what the max profit could be
let expected_min_profit = Positive::new(90.0).unwrap();
assert!(
profit >= expected_min_profit,
"Max profit should be at least {expected_min_profit}"
);
}
Err(e) => {
// If there is an error, it could be due to various reasons related to profit calculation
// The actual error message might vary depending on implementation
assert!(
e.to_string().contains("profit")
|| e.to_string().contains("Profit")
|| e.to_string().contains("premium")
|| e.to_string().contains("infinite")
|| e.to_string().contains("unlimited"),
"Error message should be related to profit calculation: {e}"
);
}
}
let max_profit = long_call.get_max_profit().unwrap();
// Long Call has theoretically unlimited upside
assert_eq!(max_profit, Positive::INFINITY);
}

#[test]
Expand Down Expand Up @@ -331,7 +304,10 @@ fn test_long_call_get_positions() {
#[test]
fn test_long_call_get_profit_ratio() {
let long_call = create_test_long_call();

let ratio = long_call.get_profit_ratio().unwrap();
assert_eq!(ratio, Decimal::ZERO);
// max_profit = INFINITY, so INFINITY / max_loss * 100 overflows Decimal → unwrap_or(ZERO)
assert!(
ratio < Decimal::new(1, 1),
"Expected near-zero ratio for unlimited profit strategy, got {ratio}"
);
}
92 changes: 14 additions & 78 deletions tests/unit/strategies/long_put_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,66 +189,19 @@ fn test_long_put_break_even_points() {
#[test]
fn test_long_put_get_max_profit() {
let long_put = create_test_long_put();
let result = long_put.get_max_profit();

// For a Long Put, the maximum profit is the strike price minus the premium and fees
match result {
Ok(profit) => {
// Verify that the maximum profit is positive
assert!(profit > Positive::ZERO);

// For a long put with strike 100, premium 5, and fees 1,
// the max profit would be approximately: 100 - 5 - 0.5 - 0.5 = 94
let expected_profit = Positive::new(94.0).unwrap();
assert!(
(profit.to_f64() - expected_profit.to_f64()).abs() < 1.0,
"Max profit should be close to {expected_profit}, but was {profit}"
);
}
Err(e) => {
// If there is an error, it could be due to various reasons related to profit calculation
assert!(
e.to_string().contains("profit")
|| e.to_string().contains("Profit")
|| e.to_string().contains("premium")
|| e.to_string().contains("infinite")
|| e.to_string().contains("unlimited"),
"Error message should be related to profit calculation: {e}"
);
}
}
let max_profit = long_put.get_max_profit().unwrap();
// Max profit for a long put: strike - premium - fees = 100 - 5 - 0.5 - 0.5 = 94
let expected_profit = Positive::new(94.0).unwrap();
assert_eq!(max_profit, expected_profit);
}

#[test]
fn test_long_put_get_max_loss() {
let long_put = create_test_long_put();
let result = long_put.get_max_loss();

// For a Long Put, the maximum loss is limited to the premium paid plus fees
match result {
Ok(loss) => {
// Verify that the maximum loss is positive
assert!(loss > Positive::ZERO);

// For a long put with premium 5 and fees 0.5 + 0.5 = 1, the max loss should be 6
let expected_max_loss = Positive::new(6.0).unwrap();
assert!(
(loss.to_f64() - expected_max_loss.to_f64()).abs() < 1.0,
"Max loss should be close to {expected_max_loss}, but was {loss}"
);
}
Err(e) => {
// If there is an error, it could be due to various reasons related to loss calculation
assert!(
e.to_string().contains("loss")
|| e.to_string().contains("Loss")
|| e.to_string().contains("negative")
|| e.to_string().contains("infinite")
|| e.to_string().contains("unlimited"),
"Error message should be related to loss calculation: {e}"
);
}
}
let max_loss = long_put.get_max_loss().unwrap();
// Max loss for a long put is the total cost: premium + fees = 5 + 0.5 + 0.5 = 6
let expected_max_loss = Positive::new(6.0).unwrap();
assert_eq!(max_loss, expected_max_loss);
}

#[test]
Expand Down Expand Up @@ -357,29 +310,12 @@ fn test_long_put_get_positions() {
#[test]
fn test_long_put_get_profit_ratio() {
let long_put = create_test_long_put();

// The profit/loss ratio can be positive, zero, or even undefined
// depending on how the calculation is implemented
match long_put.get_profit_ratio() {
Ok(ratio) => {
// If a ratio is returned, we simply verify that it exists
// The actual ratio may vary depending on implementation details
assert!(
ratio >= Decimal::ZERO,
"Profit ratio should be non-negative, but was {ratio}"
);
}
Err(e) => {
// If there is an error, we verify that it's because the ratio is undefined
// (for example, if the maximum loss is zero or the profit is infinite)
assert!(
e.to_string().contains("division")
|| e.to_string().contains("infinite")
|| e.to_string().contains("undefined"),
"Error message should indicate an issue with ratio calculation: {e}"
);
}
}
let ratio = long_put.get_profit_ratio().unwrap();
// Max profit (94) / Max loss (6) * 100 ≈ 1566.67%
assert!(
ratio > Decimal::ZERO,
"Profit ratio should be positive, got {ratio}"
);
}

#[test]
Expand Down
28 changes: 9 additions & 19 deletions tests/unit/strategies/short_call_test.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
use chrono::Utc;
use optionstratlib::{
ExpirationDate, Options,
error::StrategyError,
error::strategies::ProfitLossErrorKind,
model::{
position::Position,
types::{OptionStyle, OptionType, Side},
Expand Down Expand Up @@ -164,16 +162,9 @@ fn test_short_call_get_max_profit() {
fn test_short_call_get_max_loss() {
let short_call = create_test_short_call();
let result = short_call.get_max_loss();
assert!(result.is_err());
match result.err().unwrap() {
StrategyError::ProfitLossError(kind) => match kind {
ProfitLossErrorKind::MaxLossError { reason } => {
assert!(reason.to_lowercase().contains("unlimited"));
}
_ => panic!("Expected MaxLossError, got {kind:?}"),
},
e => panic!("Expected ProfitLossError, got {e:?}"),
}
// Max loss for a short call is theoretically unlimited (Positive::INFINITY).
assert!(result.is_ok());
assert_eq!(result.unwrap(), Positive::INFINITY);
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.


#[test]
Expand Down Expand Up @@ -300,12 +291,11 @@ fn test_short_call_get_positions() {
fn test_short_call_get_profit_ratio() {
let short_call = create_test_short_call();
let ratio_result = short_call.get_profit_ratio();
// For a short call, max loss is unlimited, so profit ratio should be Decimal::MAX (or an error if not handled gracefully)
// The current implementation returns Decimal::MAX if max_loss is zero, which is not the case here.
// If max_profit > 0 and max_loss is effectively infinite (represented by an error), then the ratio is effectively zero or very small.
// However, the current `short_call.get_max_loss()` returns an error because loss is unlimited.
// The `get_profit_ratio` in `short_call.rs` handles this by returning Decimal::MAX if `get_max_loss` is an error (interpreted as max_loss -> infinity, so profit/infinity -> 0, but the code has it as Decimal::MAX)
// Let's adjust the expectation based on the `ShortCall` implementation of `get_profit_ratio`.
// Max loss is Positive::INFINITY, so profit_ratio = max_profit / INFINITY * 100 ≈ 0.
assert!(ratio_result.is_ok());
assert_eq!(ratio_result.unwrap(), Decimal::MAX); // Based on current ShortCall impl
let ratio = ratio_result.unwrap();
assert!(
Comment thread
ms32035 marked this conversation as resolved.
ratio < Decimal::new(1, 1),
"Expected near-zero ratio, got {ratio}"
);
}
38 changes: 4 additions & 34 deletions tests/unit/strategies/short_put_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,40 +199,10 @@ fn test_short_put_get_max_profit() {
#[test]
fn test_short_put_get_max_loss() {
let short_put = create_test_short_put();
let result = short_put.get_max_loss();

// For a Short Put, the maximum loss can be the strike price minus the premium received
// or it can be theoretically infinite if the underlying reaches zero
match result {
Ok(loss) => {
// If a value is returned, verify that it is positive
assert!(loss > Positive::ZERO);

// If we want to be more specific, we can verify that it is close to the expected value
// In this case: 100 (strike) - 5 (premium) + 0.5 + 0.5 (fees) = 96
let expected_max_loss = Positive::new(96.0).unwrap();
assert!(
(loss.to_f64() - expected_max_loss.to_f64()).abs() < 1.0,
"Max loss should be close to {expected_max_loss}, but was {loss}"
);

// Also verify that the loss is less than the strike price
let strike = Positive::new(100.0).unwrap();
assert!(loss < strike, "Max loss should be less than strike price");
}
Err(e) => {
// If there is an error, it could be due to various reasons related to loss calculation
// The actual error message might vary depending on implementation
assert!(
e.to_string().contains("loss")
|| e.to_string().contains("Loss")
|| e.to_string().contains("negative")
|| e.to_string().contains("infinite")
|| e.to_string().contains("unlimited"),
"Error message should be related to loss calculation: {e}"
);
}
}
let max_loss = short_put.get_max_loss().unwrap();
// Max loss for a short put: strike - premium + fees = 100 - 5 + 0.5 + 0.5 = 96
let expected_max_loss = Positive::new(96.0).unwrap();
assert_eq!(max_loss, expected_max_loss);
}

#[test]
Expand Down