diff --git a/README.md b/README.md index b4aab3623..f26afc7ee 100644 --- a/README.md +++ b/README.md @@ -736,40 +736,43 @@ use positive::{pos_or_panic,Positive}; use rust_decimal_macros::dec; use optionstratlib::greeks::Greeks; -// Create a European call option -let option = Options::new( - OptionType::European, - Side::Long, - "AAPL".to_string(), - pos_or_panic!(150.0), // strike_price - ExpirationDate::Days(pos_or_panic!(30.0)), - pos_or_panic!(0.25), // implied_volatility - Positive::ONE, // quantity - pos_or_panic!(155.0), // underlying_price - dec!(0.05), // risk_free_rate - OptionStyle::Call, - pos_or_panic!(0.02), // dividend_yield - None, // exotic_params -); - -// Calculate option price using Black-Scholes -let price = option.calculate_price_black_scholes().unwrap(); -tracing::info!("Option price: ${:.2}", price); - -// Calculate Greeks for risk management -let delta = option.delta().unwrap(); -let gamma = option.gamma().unwrap(); -let theta = option.theta().unwrap(); -let vega = option.vega().unwrap(); -let vanna = option.vanna().unwrap(); -let vomma = option.vomma().unwrap(); -let veta = option.veta().unwrap(); -let charm = option.charm().unwrap(); -let color = option.color().unwrap(); -tracing::info!("Greeks - Delta: {:.4}, Gamma: {:.4}, Theta: {:.4}, - Vega: {:.4}, Vanna: {:.4}, Vomma: {:.4}, Veta: {:.4} - Charm: {:.4}, Color: {:.4}", - delta, gamma, theta, vega, vanna, vomma, veta, charm, color); +fn main() -> Result<(), Box> { + // Create a European call option + let option = Options::new( + OptionType::European, + Side::Long, + "AAPL".to_string(), + pos_or_panic!(150.0), // strike_price + ExpirationDate::Days(pos_or_panic!(30.0)), + pos_or_panic!(0.25), // implied_volatility + Positive::ONE, // quantity + pos_or_panic!(155.0), // underlying_price + dec!(0.05), // risk_free_rate + OptionStyle::Call, + pos_or_panic!(0.02), // dividend_yield + None, // exotic_params + ); + + // Calculate option price using Black-Scholes + let price = option.calculate_price_black_scholes()?; + tracing::info!("Option price: ${:.2}", price); + + // Calculate Greeks for risk management + let delta = option.delta()?; + let gamma = option.gamma()?; + let theta = option.theta()?; + let vega = option.vega()?; + let vanna = option.vanna()?; + let vomma = option.vomma()?; + let veta = option.veta()?; + let charm = option.charm()?; + let color = option.color()?; + tracing::info!("Greeks - Delta: {:.4}, Gamma: {:.4}, Theta: {:.4}, + Vega: {:.4}, Vanna: {:.4}, Vomma: {:.4}, Veta: {:.4} + Charm: {:.4}, Color: {:.4}", + delta, gamma, theta, vega, vanna, vomma, veta, charm, color); + Ok(()) +} ``` #### Working with Trading Strategies @@ -864,79 +867,82 @@ fn main() -> Result<(), Box> { ```rust use optionstratlib::prelude::*; -// Define common parameters -let underlying_symbol = "DAX".to_string(); -let underlying_price = pos_or_panic!(24000.0); -let expiration = ExpirationDate::Days(pos_or_panic!(30.0)); -let implied_volatility = pos_or_panic!(0.25); -let risk_free_rate = dec!(0.05); -let dividend_yield = pos_or_panic!(0.02); -let fee = Positive::TWO; - -// Create a long put option -let long_put_option = Options::new( - OptionType::European, - Side::Long, - underlying_symbol.clone(), - pos_or_panic!(24070.0), // strike - expiration.clone(), - implied_volatility, - Positive::ONE, // quantity - underlying_price, - risk_free_rate, - OptionStyle::Put, - dividend_yield, - None, -); -let long_put = Position::new( - long_put_option, - pos_or_panic!(150.0), // premium - Utc::now(), - fee, - fee, - None, - None, -); - -// Create a long call option -let long_call_option = Options::new( - OptionType::European, - Side::Long, - underlying_symbol.clone(), - pos_or_panic!(24030.0), // strike - expiration.clone(), - implied_volatility, - Positive::ONE, // quantity - underlying_price, - risk_free_rate, - OptionStyle::Call, - dividend_yield, - None, -); -let long_call = Position::new( - long_call_option, - pos_or_panic!(120.0), // premium - Utc::now(), - fee, - fee, - None, - None, -); - -// Create CustomStrategy with the positions -let positions = vec![long_call, long_put]; -let strategy = CustomStrategy::new( - "DAX Straddle Strategy".to_string(), - underlying_symbol, - "A DAX long straddle strategy".to_string(), - underlying_price, - positions, - Positive::ONE, - 30, - implied_volatility, -).expect("valid custom strategy"); - -tracing::info!("Strategy created: {}", strategy.get_title()); +fn main() -> Result<(), Box> { + // Define common parameters + let underlying_symbol = "DAX".to_string(); + let underlying_price = pos_or_panic!(24000.0); + let expiration = ExpirationDate::Days(pos_or_panic!(30.0)); + let implied_volatility = pos_or_panic!(0.25); + let risk_free_rate = dec!(0.05); + let dividend_yield = pos_or_panic!(0.02); + let fee = Positive::TWO; + + // Create a long put option + let long_put_option = Options::new( + OptionType::European, + Side::Long, + underlying_symbol.clone(), + pos_or_panic!(24070.0), // strike + expiration.clone(), + implied_volatility, + Positive::ONE, // quantity + underlying_price, + risk_free_rate, + OptionStyle::Put, + dividend_yield, + None, + ); + let long_put = Position::new( + long_put_option, + pos_or_panic!(150.0), // premium + Utc::now(), + fee, + fee, + None, + None, + ); + + // Create a long call option + let long_call_option = Options::new( + OptionType::European, + Side::Long, + underlying_symbol.clone(), + pos_or_panic!(24030.0), // strike + expiration.clone(), + implied_volatility, + Positive::ONE, // quantity + underlying_price, + risk_free_rate, + OptionStyle::Call, + dividend_yield, + None, + ); + let long_call = Position::new( + long_call_option, + pos_or_panic!(120.0), // premium + Utc::now(), + fee, + fee, + None, + None, + ); + + // Create CustomStrategy with the positions + let positions = vec![long_call, long_put]; + let strategy = CustomStrategy::new( + "DAX Straddle Strategy".to_string(), + underlying_symbol, + "A DAX long straddle strategy".to_string(), + underlying_price, + positions, + Positive::ONE, + 30, + implied_volatility, + )?; + + tracing::info!("Strategy created: {}", strategy.get_title()); + Ok(()) +} ``` ### Testing diff --git a/src/constants.rs b/src/constants.rs index 5bfab69e2..5dee1b7b6 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -17,6 +17,7 @@ pub const ZERO: f64 = 0.0; /// Small decimal value used as a threshold for convergence tests and equality comparisons. /// Represents a general tolerance level for numerical algorithms. +#[allow(dead_code)] pub(crate) const TOLERANCE: Decimal = dec!(1e-8); /// Extremely small decimal value used for high-precision calculations. diff --git a/src/geometrics/operations/axis.rs b/src/geometrics/operations/axis.rs index 8fbb433e3..93364e262 100644 --- a/src/geometrics/operations/axis.rs +++ b/src/geometrics/operations/axis.rs @@ -90,11 +90,18 @@ where (0, _) => vec![], (_, 0) => vec![], _ => { - // Find the overlapping range - let min_self = self_indexes.first().unwrap(); - let max_self = self_indexes.last().unwrap(); - let min_other = other_indexes.first().unwrap(); - let max_other = other_indexes.last().unwrap(); + // Find the overlapping range. The match arm above + // guarantees both vectors are non-empty, so .first() / + // .last() are statically Some — fall back to a no-op + // empty result if that invariant is ever broken. + let (Some(min_self), Some(max_self), Some(min_other), Some(max_other)) = ( + self_indexes.first(), + self_indexes.last(), + other_indexes.first(), + other_indexes.last(), + ) else { + return vec![]; + }; // Determine the common range let start = std::cmp::max(min_self, min_other); diff --git a/src/lib.rs b/src/lib.rs index 7be6cc058..18cd4f1d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -722,40 +722,43 @@ //! use rust_decimal_macros::dec; //! use optionstratlib::greeks::Greeks; //! -//! // Create a European call option -//! let option = Options::new( -//! OptionType::European, -//! Side::Long, -//! "AAPL".to_string(), -//! pos_or_panic!(150.0), // strike_price -//! ExpirationDate::Days(pos_or_panic!(30.0)), -//! pos_or_panic!(0.25), // implied_volatility -//! Positive::ONE, // quantity -//! pos_or_panic!(155.0), // underlying_price -//! dec!(0.05), // risk_free_rate -//! OptionStyle::Call, -//! pos_or_panic!(0.02), // dividend_yield -//! None, // exotic_params -//! ); -//! -//! // Calculate option price using Black-Scholes -//! let price = option.calculate_price_black_scholes().unwrap(); -//! tracing::info!("Option price: ${:.2}", price); -//! -//! // Calculate Greeks for risk management -//! let delta = option.delta().unwrap(); -//! let gamma = option.gamma().unwrap(); -//! let theta = option.theta().unwrap(); -//! let vega = option.vega().unwrap(); -//! let vanna = option.vanna().unwrap(); -//! let vomma = option.vomma().unwrap(); -//! let veta = option.veta().unwrap(); -//! let charm = option.charm().unwrap(); -//! let color = option.color().unwrap(); -//! tracing::info!("Greeks - Delta: {:.4}, Gamma: {:.4}, Theta: {:.4}, -//! Vega: {:.4}, Vanna: {:.4}, Vomma: {:.4}, Veta: {:.4} -//! Charm: {:.4}, Color: {:.4}", -//! delta, gamma, theta, vega, vanna, vomma, veta, charm, color); +//! fn main() -> Result<(), Box> { +//! // Create a European call option +//! let option = Options::new( +//! OptionType::European, +//! Side::Long, +//! "AAPL".to_string(), +//! pos_or_panic!(150.0), // strike_price +//! ExpirationDate::Days(pos_or_panic!(30.0)), +//! pos_or_panic!(0.25), // implied_volatility +//! Positive::ONE, // quantity +//! pos_or_panic!(155.0), // underlying_price +//! dec!(0.05), // risk_free_rate +//! OptionStyle::Call, +//! pos_or_panic!(0.02), // dividend_yield +//! None, // exotic_params +//! ); +//! +//! // Calculate option price using Black-Scholes +//! let price = option.calculate_price_black_scholes()?; +//! tracing::info!("Option price: ${:.2}", price); +//! +//! // Calculate Greeks for risk management +//! let delta = option.delta()?; +//! let gamma = option.gamma()?; +//! let theta = option.theta()?; +//! let vega = option.vega()?; +//! let vanna = option.vanna()?; +//! let vomma = option.vomma()?; +//! let veta = option.veta()?; +//! let charm = option.charm()?; +//! let color = option.color()?; +//! tracing::info!("Greeks - Delta: {:.4}, Gamma: {:.4}, Theta: {:.4}, +//! Vega: {:.4}, Vanna: {:.4}, Vomma: {:.4}, Veta: {:.4} +//! Charm: {:.4}, Color: {:.4}", +//! delta, gamma, theta, vega, vanna, vomma, veta, charm, color); +//! Ok(()) +//! } //! ``` //! //! ### Working with Trading Strategies @@ -850,79 +853,82 @@ //! ```rust //! use optionstratlib::prelude::*; //! -//! // Define common parameters -//! let underlying_symbol = "DAX".to_string(); -//! let underlying_price = pos_or_panic!(24000.0); -//! let expiration = ExpirationDate::Days(pos_or_panic!(30.0)); -//! let implied_volatility = pos_or_panic!(0.25); -//! let risk_free_rate = dec!(0.05); -//! let dividend_yield = pos_or_panic!(0.02); -//! let fee = Positive::TWO; -//! -//! // Create a long put option -//! let long_put_option = Options::new( -//! OptionType::European, -//! Side::Long, -//! underlying_symbol.clone(), -//! pos_or_panic!(24070.0), // strike -//! expiration.clone(), -//! implied_volatility, -//! Positive::ONE, // quantity -//! underlying_price, -//! risk_free_rate, -//! OptionStyle::Put, -//! dividend_yield, -//! None, -//! ); -//! let long_put = Position::new( -//! long_put_option, -//! pos_or_panic!(150.0), // premium -//! Utc::now(), -//! fee, -//! fee, -//! None, -//! None, -//! ); -//! -//! // Create a long call option -//! let long_call_option = Options::new( -//! OptionType::European, -//! Side::Long, -//! underlying_symbol.clone(), -//! pos_or_panic!(24030.0), // strike -//! expiration.clone(), -//! implied_volatility, -//! Positive::ONE, // quantity -//! underlying_price, -//! risk_free_rate, -//! OptionStyle::Call, -//! dividend_yield, -//! None, -//! ); -//! let long_call = Position::new( -//! long_call_option, -//! pos_or_panic!(120.0), // premium -//! Utc::now(), -//! fee, -//! fee, -//! None, -//! None, -//! ); -//! -//! // Create CustomStrategy with the positions -//! let positions = vec![long_call, long_put]; -//! let strategy = CustomStrategy::new( -//! "DAX Straddle Strategy".to_string(), -//! underlying_symbol, -//! "A DAX long straddle strategy".to_string(), -//! underlying_price, -//! positions, -//! Positive::ONE, -//! 30, -//! implied_volatility, -//! ).expect("valid custom strategy"); -//! -//! tracing::info!("Strategy created: {}", strategy.get_title()); +//! fn main() -> Result<(), Box> { +//! // Define common parameters +//! let underlying_symbol = "DAX".to_string(); +//! let underlying_price = pos_or_panic!(24000.0); +//! let expiration = ExpirationDate::Days(pos_or_panic!(30.0)); +//! let implied_volatility = pos_or_panic!(0.25); +//! let risk_free_rate = dec!(0.05); +//! let dividend_yield = pos_or_panic!(0.02); +//! let fee = Positive::TWO; +//! +//! // Create a long put option +//! let long_put_option = Options::new( +//! OptionType::European, +//! Side::Long, +//! underlying_symbol.clone(), +//! pos_or_panic!(24070.0), // strike +//! expiration.clone(), +//! implied_volatility, +//! Positive::ONE, // quantity +//! underlying_price, +//! risk_free_rate, +//! OptionStyle::Put, +//! dividend_yield, +//! None, +//! ); +//! let long_put = Position::new( +//! long_put_option, +//! pos_or_panic!(150.0), // premium +//! Utc::now(), +//! fee, +//! fee, +//! None, +//! None, +//! ); +//! +//! // Create a long call option +//! let long_call_option = Options::new( +//! OptionType::European, +//! Side::Long, +//! underlying_symbol.clone(), +//! pos_or_panic!(24030.0), // strike +//! expiration.clone(), +//! implied_volatility, +//! Positive::ONE, // quantity +//! underlying_price, +//! risk_free_rate, +//! OptionStyle::Call, +//! dividend_yield, +//! None, +//! ); +//! let long_call = Position::new( +//! long_call_option, +//! pos_or_panic!(120.0), // premium +//! Utc::now(), +//! fee, +//! fee, +//! None, +//! None, +//! ); +//! +//! // Create CustomStrategy with the positions +//! let positions = vec![long_call, long_put]; +//! let strategy = CustomStrategy::new( +//! "DAX Straddle Strategy".to_string(), +//! underlying_symbol, +//! "A DAX long straddle strategy".to_string(), +//! underlying_price, +//! positions, +//! Positive::ONE, +//! 30, +//! implied_volatility, +//! )?; +//! +//! tracing::info!("Strategy created: {}", strategy.get_title()); +//! Ok(()) +//! } //! ``` //! //! ## Testing diff --git a/src/metrics/liquidity/bid_ask_spread.rs b/src/metrics/liquidity/bid_ask_spread.rs index b48d22f4c..05827ccb0 100644 --- a/src/metrics/liquidity/bid_ask_spread.rs +++ b/src/metrics/liquidity/bid_ask_spread.rs @@ -76,7 +76,7 @@ use crate::error::CurveError; /// /// // Find strike with tightest spread (most liquid) /// let min_spread = spread_curve.points.iter() -/// .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); +/// .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); /// ``` pub trait BidAskSpreadCurve { /// Computes the bid-ask spread curve by strike price. @@ -147,7 +147,9 @@ mod tests_bid_ask_spread { let points: Vec<&Point2D> = curve.points.iter().collect(); // Find minimum spread - let min_spread = points.iter().min_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); + let min_spread = points + .iter() + .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); if let Some(min) = min_spread { // ATM should have tightest spread (around 450) diff --git a/src/metrics/liquidity/open_interest.rs b/src/metrics/liquidity/open_interest.rs index 481c3dcf4..ab67cc54b 100644 --- a/src/metrics/liquidity/open_interest.rs +++ b/src/metrics/liquidity/open_interest.rs @@ -65,7 +65,7 @@ use crate::error::CurveError; /// /// // Find strike with maximum OI (potential support/resistance) /// let max_oi = oi_curve.points.iter() -/// .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); +/// .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); /// ``` pub trait OpenInterestCurve { /// Computes the open interest distribution curve by strike price. @@ -137,7 +137,9 @@ mod tests_open_interest { let points: Vec<&Point2D> = curve.points.iter().collect(); // Find maximum OI - let max_oi = points.iter().max_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); + let max_oi = points + .iter() + .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); if let Some(max) = max_oi { // ATM round strike should have highest OI diff --git a/src/metrics/liquidity/volume_profile.rs b/src/metrics/liquidity/volume_profile.rs index 516eb9c29..615fda5ff 100644 --- a/src/metrics/liquidity/volume_profile.rs +++ b/src/metrics/liquidity/volume_profile.rs @@ -59,7 +59,7 @@ use positive::Positive; /// /// // Find strike with highest volume /// let max_volume = volume_curve.points.iter() -/// .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); +/// .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); /// ``` pub trait VolumeProfileCurve { /// Computes the volume profile curve by strike price. @@ -216,7 +216,9 @@ mod tests_volume_profile { let points: Vec<&Point2D> = curve.points.iter().collect(); // Find maximum volume - let max_vol = points.iter().max_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); + let max_vol = points + .iter() + .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); if let Some(max) = max_vol { // ATM should have highest volume (around 450) diff --git a/src/metrics/risk/dollar_gamma.rs b/src/metrics/risk/dollar_gamma.rs index 768040d6c..f391645c6 100644 --- a/src/metrics/risk/dollar_gamma.rs +++ b/src/metrics/risk/dollar_gamma.rs @@ -77,8 +77,8 @@ use crate::model::OptionStyle; /// /// // Find the strike with maximum dollar gamma exposure /// let max_dg = dg_curve.points.iter() -/// .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap()) -/// .unwrap(); +/// .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)) +/// .ok_or("empty dollar gamma curve")?; /// println!("Max dollar gamma at strike {}: ${:.2}", max_dg.x, max_dg.y); /// ``` /// diff --git a/src/metrics/stress/time_decay.rs b/src/metrics/stress/time_decay.rs index a08cf17e0..eac0918df 100644 --- a/src/metrics/stress/time_decay.rs +++ b/src/metrics/stress/time_decay.rs @@ -73,7 +73,7 @@ use positive::Positive; /// /// // Find strike with maximum theta decay /// let max_theta = theta_curve.points.iter() -/// .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); // Most negative +/// .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); // Most negative /// ``` pub trait TimeDecayCurve { /// Computes the time decay profile curve by strike price. @@ -243,7 +243,9 @@ mod tests_time_decay { let points: Vec<&Point2D> = curve.points.iter().collect(); // Find most negative theta (ATM) - let min_theta = points.iter().min_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); + let min_theta = points + .iter() + .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); if let Some(min) = min_theta { // ATM should have most negative theta diff --git a/src/metrics/temporal/theta.rs b/src/metrics/temporal/theta.rs index 03d49e3ea..d8438dd37 100644 --- a/src/metrics/temporal/theta.rs +++ b/src/metrics/temporal/theta.rs @@ -78,7 +78,7 @@ use positive::Positive; /// /// // Find strike with maximum theta decay /// let max_decay = theta_curve.points.iter() -/// .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); +/// .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); /// ``` pub trait ThetaCurve { /// Computes the theta curve by strike price. @@ -244,7 +244,9 @@ mod tests_theta { let points: Vec<&Point2D> = curve.points.iter().collect(); // Find most negative theta - let min_theta = points.iter().min_by(|a, b| a.y.partial_cmp(&b.y).unwrap()); + let min_theta = points + .iter() + .min_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal)); if let Some(min) = min_theta { assert_eq!(min.x, dec!(450.0)); diff --git a/src/model/decimal.rs b/src/model/decimal.rs index 0e0e4e172..b98311c18 100644 --- a/src/model/decimal.rs +++ b/src/model/decimal.rs @@ -219,9 +219,12 @@ pub fn f64_to_decimal(value: f64) -> Result { /// ``` pub fn decimal_normal_sample() -> Decimal { let mut t_rng = rand::rng(); - // SAFETY: Normal::new(0.0, 1.0) is always valid (mean=0, std=1 are valid parameters) - let normal = - Normal::new(0.0, 1.0).expect("standard normal distribution parameters are always valid"); + // Normal::new(0.0, 1.0) is provably valid (mean=0, std=1 are accepted + // by `statrs::distribution::Normal`), so the Err arm is unreachable. + let normal = match Normal::new(0.0, 1.0) { + Ok(n) => n, + Err(_) => unreachable!("standard normal parameters are always valid"), + }; Decimal::from_f64(normal.sample(&mut t_rng)).unwrap_or(Decimal::ZERO) } diff --git a/src/model/leg/mod.rs b/src/model/leg/mod.rs index c6c2a3031..8b1b5adea 100644 --- a/src/model/leg/mod.rs +++ b/src/model/leg/mod.rs @@ -37,6 +37,7 @@ //! ## Example: Covered Call Strategy //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use optionstratlib::model::leg::{Leg, SpotPosition}; //! use optionstratlib::model::Position; //! use optionstratlib::model::types::Side; @@ -51,12 +52,15 @@ //! //! // Both legs can be handled uniformly via LegAble trait //! use optionstratlib::model::leg::LegAble; -//! println!("Spot delta: {}", spot_leg.delta().unwrap()); +//! println!("Spot delta: {}", spot_leg.delta()?); +//! # Ok(()) +//! # } //! ``` //! //! ## Example: Cash & Carry Arbitrage (Crypto) //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use optionstratlib::model::leg::{Leg, SpotPosition, PerpetualPosition, MarginType}; //! use optionstratlib::model::types::Side; //! use positive::{Positive, pos_or_panic}; @@ -80,8 +84,10 @@ //! //! // Net delta should be approximately zero //! use optionstratlib::model::leg::LegAble; -//! let net_delta = spot_leg.delta().unwrap() + perp_leg.delta().unwrap(); +//! let net_delta = spot_leg.delta()? + perp_leg.delta()?; //! assert_eq!(net_delta, rust_decimal::Decimal::ZERO); +//! # Ok(()) +//! # } //! ``` //! //! ## Strategies Enabled diff --git a/src/model/option.rs b/src/model/option.rs index af013ad44..ca9db3726 100644 --- a/src/model/option.rs +++ b/src/model/option.rs @@ -686,8 +686,13 @@ impl Options { for _ in 0..MAX_ITERATIONS_IV { // Calculate midpoint volatility let mid_vol = (high.to_dec() + low.to_dec()) / Decimal::TWO; - let volatility = Positive::new_decimal(mid_vol) - .expect("mid_vol derived from Positive bounds is non-negative"); + // mid_vol is the average of two non-negative bounds, so it is + // structurally non-negative; a None here would indicate a + // breached invariant on the bounds themselves. + let volatility = + Positive::new_decimal(mid_vol).map_err(|e| OptionsError::OtherError { + reason: format!("mid_vol invariant breached: {e}"), + })?; // Calculate option price at this volatility let mut option_copy = self.clone(); diff --git a/src/model/position.rs b/src/model/position.rs index 7fd5e5951..a592aa63a 100644 --- a/src/model/position.rs +++ b/src/model/position.rs @@ -38,6 +38,7 @@ use utoipa::ToSchema; /// # Examples /// /// ```rust +/// # fn main() -> Result<(), Box> { /// use optionstratlib::{Options, Side, OptionStyle}; /// use positive::pos_or_panic; /// use chrono::Utc; @@ -56,8 +57,10 @@ use utoipa::ToSchema; /// None, /// ); /// -/// let total_cost = position.total_cost().unwrap(); +/// let total_cost = position.total_cost()?; /// info!("Total position cost: {}", total_cost); +/// # Ok(()) +/// # } /// ``` #[derive(Clone, PartialEq, Serialize, Deserialize, ToSchema)] pub struct Position { @@ -264,6 +267,7 @@ impl Position { /// # Examples /// /// ```rust + /// # fn main() -> Result<(), Box> { /// use optionstratlib::{ Side, OptionStyle}; /// use positive::pos_or_panic; /// use optionstratlib::model::Position; @@ -284,8 +288,10 @@ impl Position { /// ); /// /// // Calculate premium received - /// let received = position.premium_received().unwrap(); + /// let received = position.premium_received()?; /// info!("Premium received: {}", received); + /// # Ok(()) + /// # } /// ``` pub fn premium_received(&self) -> Result { match self.option.side { @@ -348,7 +354,7 @@ impl Position { /// # Examples /// /// ```rust - /// + /// # fn main() -> Result<(), Box> { /// // Assuming position is a properly initialized Position /// use chrono::Utc; /// use optionstratlib::model::utils::create_sample_option_simplest; @@ -369,10 +375,12 @@ impl Position { /// let current_price = pos_or_panic!(105.0); /// /// // Calculate PnL at expiration with specified price - /// let pnl_specific = position.pnl_at_expiration(&Some(¤t_price)).unwrap(); + /// let pnl_specific = position.pnl_at_expiration(&Some(¤t_price))?; /// /// // Calculate PnL at expiration using the option's current underlying price - /// let pnl_current = position.pnl_at_expiration(&None).unwrap(); + /// let pnl_current = position.pnl_at_expiration(&None)?; + /// # Ok(()) + /// # } /// ``` pub fn pnl_at_expiration(&self, price: &Option<&Positive>) -> Result { match price { @@ -406,6 +414,7 @@ impl Position { /// # Example /// /// ```rust + /// # fn main() -> Result<(), Box> { /// use chrono::Utc; /// use tracing::info; /// use optionstratlib::model::Position; @@ -423,8 +432,10 @@ impl Position { /// None, // epic (optional) /// None, // extra fields (optional) /// ); - /// let unrealized_pnl = position.unrealized_pnl(current_price).unwrap(); + /// let unrealized_pnl = position.unrealized_pnl(current_price)?; /// info!("Current unrealized PnL: {}", unrealized_pnl); + /// # Ok(()) + /// # } /// ``` pub fn unrealized_pnl(&self, price: Positive) -> Result { match self.option.side { diff --git a/src/model/profit_range.rs b/src/model/profit_range.rs index cef60c1d7..7791b4b09 100644 --- a/src/model/profit_range.rs +++ b/src/model/profit_range.rs @@ -130,16 +130,12 @@ impl ProfitLossRange { expiration_date: &ExpirationDate, risk_free_rate: Option, ) -> Result<(), ProbabilityError> { - if self.lower_bound.unwrap_or(Positive::ZERO) - > self.upper_bound.unwrap_or(Positive::INFINITY) - { + let lower = self.lower_bound.unwrap_or(Positive::ZERO); + let upper = self.upper_bound.unwrap_or(Positive::INFINITY); + if lower > upper { return Err(ProbabilityError::PriceError( PriceErrorKind::InvalidPriceRange { - range: format!( - "lower_bound: {} upper_bound: {}", - self.lower_bound.unwrap().value(), - self.upper_bound.unwrap().value() - ), + range: format!("lower_bound: {lower} upper_bound: {upper}"), reason: "Lower bound must be less than upper bound".to_string(), }, )); diff --git a/src/model/utils.rs b/src/model/utils.rs index 233b0727c..981e096c4 100644 --- a/src/model/utils.rs +++ b/src/model/utils.rs @@ -376,14 +376,20 @@ pub fn create_sample_option_simplest_strike( /// # Example /// /// ```rust +/// # fn main() -> Result<(), Box> { /// use positive::Positive; /// use optionstratlib::model::utils::mean_and_std; /// -/// let data = vec![Positive::new(2.0).unwrap(), Positive::new(4.0).unwrap(), Positive::new(4.0).unwrap(), Positive::new(4.0).unwrap(), Positive::new(5.0).unwrap(), Positive::new(5.0).unwrap(), Positive::new(7.0).unwrap(), Positive::new(9.0).unwrap()]; +/// let data = vec![ +/// Positive::new(2.0)?, Positive::new(4.0)?, Positive::new(4.0)?, Positive::new(4.0)?, +/// Positive::new(5.0)?, Positive::new(5.0)?, Positive::new(7.0)?, Positive::new(9.0)?, +/// ]; /// let (mean, std) = mean_and_std(data); /// /// assert_eq!(mean.to_f64(), 5.0); /// assert_eq!(std.to_f64(), 4.0_f64.sqrt()); +/// # Ok(()) +/// # } /// ``` /// /// # Details diff --git a/src/pnl/metrics.rs b/src/pnl/metrics.rs index 8e5309ce0..2a610e086 100644 --- a/src/pnl/metrics.rs +++ b/src/pnl/metrics.rs @@ -312,7 +312,10 @@ lazy_static! { // Helper function to get or create a lock for a specific file fn get_file_lock(file_path: &str) -> Arc> { - let mut locks = FILE_LOCKS.lock().unwrap(); + // Mutex-poison recovery: if a previous panic poisoned the lock, we + // still want to vend a per-file lock rather than propagate the panic + // to every subsequent caller of `get_file_lock`. + let mut locks = FILE_LOCKS.lock().unwrap_or_else(|e| e.into_inner()); locks .entry(file_path.to_string()) .or_insert_with(|| Arc::new(Mutex::new(()))) @@ -376,7 +379,9 @@ pub fn save_pnl_metrics_with_document( ) -> io::Result<()> { // Get a lock for this specific file let file_lock = get_file_lock(file_path); - let _guard = file_lock.lock().unwrap(); + // Poison-recover: a panic in a previous holder shouldn't permanently + // block file writes for this path. + let _guard = file_lock.lock().unwrap_or_else(|e| e.into_inner()); // Check if file exists let file_exists = Path::new(file_path).exists(); diff --git a/src/pnl/model.rs b/src/pnl/model.rs index f0aba5014..9cae70abf 100644 --- a/src/pnl/model.rs +++ b/src/pnl/model.rs @@ -3,6 +3,7 @@ Email: jb@taunais.com Date: 26/2/25 ******************************************************************************/ +use crate::error::DecimalError; use num_traits::ToPrimitive; use rust_decimal::Decimal; use serde::{Deserialize, Serialize, Serializer}; @@ -66,26 +67,42 @@ impl PnLRange { /// /// A new `PnLRange` instance with the bounds converted to integers. /// - /// # Panics + /// # Errors /// - /// This function will panic if either the lower or upper Decimal value cannot be - /// converted to an i32 (e.g., if the value is outside the i32 range or is not - /// representable as an integer). + /// Returns a [`DecimalError::ConversionError`] if either bound is + /// outside the `i32` range or cannot be represented as an integer. /// /// # Example /// /// ```rust + /// # fn main() -> Result<(), Box> { /// use rust_decimal_macros::dec; /// use optionstratlib::pnl::model::PnLRange; /// - /// let range = PnLRange::new_decimal(dec!(-50.5), dec!(75.25)); + /// let range = PnLRange::new_decimal(dec!(-50.5), dec!(75.25))?; /// // Creates a PnL range from -50 (inclusive) to 75 (exclusive) + /// # Ok(()) + /// # } /// ``` - pub fn new_decimal(lower: Decimal, upper: Decimal) -> Self { - Self { - lower: lower.to_i32().unwrap(), - upper: upper.to_i32().unwrap(), - } + pub fn new_decimal(lower: Decimal, upper: Decimal) -> Result { + let lower_i32 = lower + .to_i32() + .ok_or_else(|| DecimalError::ConversionError { + from_type: "Decimal".to_string(), + to_type: "i32".to_string(), + reason: format!("lower bound {lower} out of i32 range"), + })?; + let upper_i32 = upper + .to_i32() + .ok_or_else(|| DecimalError::ConversionError { + from_type: "Decimal".to_string(), + to_type: "i32".to_string(), + reason: format!("upper bound {upper} out of i32 range"), + })?; + Ok(Self { + lower: lower_i32, + upper: upper_i32, + }) } } diff --git a/src/risk/mod.rs b/src/risk/mod.rs index 95aef64ab..24876104e 100644 --- a/src/risk/mod.rs +++ b/src/risk/mod.rs @@ -55,41 +55,44 @@ use positive::pos_or_panic; //! use rust_decimal_macros::dec; //! use optionstratlib::risk::SPANMargin; //! -//! // Create an option position -//! let option = Options::new( -//! OptionType::European, -//! Side::Short, -//! "STOCK".to_string(), -//! pos_or_panic!(150.0), // Strike price -//! ExpirationDate::Days(pos_or_panic!(30.0)), -//! pos_or_panic!(0.2), // Volatility -//! Positive::ONE, // Quantity -//! pos_or_panic!(155.0), // Current price -//! dec!(0.05), // Risk-free rate -//! OptionStyle::Call, -//! Positive::ZERO, // Dividend yield -//! None, // Exotic parameters -//! ); -//! -//! let position = Position { -//! option, -//! premium: pos_or_panic!(5.0), -//! date: Utc::now(), -//! open_fee: pos_or_panic!(0.5), -//! close_fee: pos_or_panic!(0.5), -//! epic: None, -//! extra_fields: None, -//! }; -//! -//! // Create SPAN calculator -//! let span = SPANMargin::new( -//! dec!(0.10), // 10% short option minimum -//! dec!(0.05), // 5% price scan range -//! dec!(0.10), // 10% volatility scan range -//! ); -//! -//! // Calculate margin requirement -//! let margin = span.calculate_margin(&position); +//! fn main() -> Result<(), Box> { +//! // Create an option position +//! let option = Options::new( +//! OptionType::European, +//! Side::Short, +//! "STOCK".to_string(), +//! pos_or_panic!(150.0), // Strike price +//! ExpirationDate::Days(pos_or_panic!(30.0)), +//! pos_or_panic!(0.2), // Volatility +//! Positive::ONE, // Quantity +//! pos_or_panic!(155.0), // Current price +//! dec!(0.05), // Risk-free rate +//! OptionStyle::Call, +//! Positive::ZERO, // Dividend yield +//! None, // Exotic parameters +//! ); +//! +//! let position = Position { +//! option, +//! premium: pos_or_panic!(5.0), +//! date: Utc::now(), +//! open_fee: pos_or_panic!(0.5), +//! close_fee: pos_or_panic!(0.5), +//! epic: None, +//! extra_fields: None, +//! }; +//! +//! // Create SPAN calculator +//! let span = SPANMargin::new( +//! dec!(0.10), // 10% short option minimum +//! dec!(0.05), // 5% price scan range +//! dec!(0.10), // 10% volatility scan range +//! ); +//! +//! // Calculate margin requirement +//! let margin = span.calculate_margin(&position)?; +//! Ok(()) +//! } //! ``` //! //! ### Portfolio Analysis @@ -105,48 +108,52 @@ use positive::pos_or_panic; //! use positive::pos_or_panic; //! use optionstratlib::risk::SPANMargin; //! -//! let option = Options { -//! option_type: OptionType::European, -//! side: Side::Long, -//! underlying_symbol: "AAPL".to_string(), -//! strike_price: Positive::HUNDRED, -//! expiration_date: ExpirationDate::Days(pos_or_panic!(30.0)), -//! implied_volatility: pos_or_panic!(0.2), -//! quantity: Positive::ONE, -//! underlying_price: pos_or_panic!(105.0), -//! risk_free_rate: dec!(0.05), -//! option_style: OptionStyle::Call, -//! dividend_yield: pos_or_panic!(0.01), -//! exotic_params: None, -//! }; -//! // Create multiple positions -//! let positions = vec![ -//! Position { -//! option: option.clone(), -//! premium: pos_or_panic!(5.0), -//! date: Utc::now(), -//! open_fee: pos_or_panic!(0.5), -//! close_fee: pos_or_panic!(0.5), -//! epic: None, -//! extra_fields: None, -//! }, -//! Position { -//! option, -//! premium: pos_or_panic!(3.0), -//! date: Utc::now(), -//! open_fee: pos_or_panic!(0.5), -//! close_fee: pos_or_panic!(0.5), -//! epic: None, -//! extra_fields: None, -//! }, -//! ]; -//! -//! let span = SPANMargin::new(dec!(0.10), dec!(0.05), dec!(0.10)); -//! -//! // Calculate margin for each position -//! let margins: Vec = positions.iter() -//! .map(|pos| span.calculate_margin(pos)) -//! .collect(); +//! fn main() -> Result<(), Box> { +//! let option = Options { +//! option_type: OptionType::European, +//! side: Side::Long, +//! underlying_symbol: "AAPL".to_string(), +//! strike_price: Positive::HUNDRED, +//! expiration_date: ExpirationDate::Days(pos_or_panic!(30.0)), +//! implied_volatility: pos_or_panic!(0.2), +//! quantity: Positive::ONE, +//! underlying_price: pos_or_panic!(105.0), +//! risk_free_rate: dec!(0.05), +//! option_style: OptionStyle::Call, +//! dividend_yield: pos_or_panic!(0.01), +//! exotic_params: None, +//! }; +//! // Create multiple positions +//! let positions = vec![ +//! Position { +//! option: option.clone(), +//! premium: pos_or_panic!(5.0), +//! date: Utc::now(), +//! open_fee: pos_or_panic!(0.5), +//! close_fee: pos_or_panic!(0.5), +//! epic: None, +//! extra_fields: None, +//! }, +//! Position { +//! option, +//! premium: pos_or_panic!(3.0), +//! date: Utc::now(), +//! open_fee: pos_or_panic!(0.5), +//! close_fee: pos_or_panic!(0.5), +//! epic: None, +//! extra_fields: None, +//! }, +//! ]; +//! +//! let span = SPANMargin::new(dec!(0.10), dec!(0.05), dec!(0.10)); +//! +//! // Calculate margin for each position; propagate any pricing error. +//! let margins: Vec = positions +//! .iter() +//! .map(|pos| span.calculate_margin(pos)) +//! .collect::, _>>()?; +//! Ok(()) +//! } //! ``` //! //! ## Implementation Details diff --git a/src/risk/span.rs b/src/risk/span.rs index 9dafbe05c..c8e53ec97 100644 --- a/src/risk/span.rs +++ b/src/risk/span.rs @@ -3,6 +3,7 @@ Email: jb@taunais.com Date: 2/10/24 ******************************************************************************/ +use crate::error::PricingError; use crate::model::position::Position; use positive::Positive; use rust_decimal::Decimal; @@ -100,15 +101,35 @@ impl SPANMargin { /// * `position` - The option position for which to calculate margin requirements /// /// # Returns - /// * `Decimal` - The calculated margin requirement for the position - pub fn calculate_margin(&self, position: &Position) -> Decimal { - let risk_array = self.calculate_risk_array(position); + /// * `Result` - The calculated margin + /// requirement for the position, or the underlying Black-Scholes + /// pricing error if any scenario fails to price. Returning a + /// typed error rather than silently falling back to `ZERO` + /// prevents margin underestimation. + /// + /// # Errors + /// + /// Returns the propagated `PricingError` from + /// `Options::calculate_price_black_scholes` if any scenario price + /// cannot be computed. + pub fn calculate_margin(&self, position: &Position) -> Result { + let risk_array = self.calculate_risk_array(position)?; let short_option_minimum = self.calculate_short_option_minimum(position); - risk_array + // risk_array is structurally non-empty (price_scenarios x + // volatility_scenarios is at least 1x1) so the max_by is + // expected to succeed; the fallback to ZERO + warn is a + // defensive guard rather than an expected error path. + let max_loss = risk_array .into_iter() - .max_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap() - .max(short_option_minimum) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or_else(|| { + tracing::warn!( + "calculate_margin: empty risk_array for position {}; using ZERO", + position.option.underlying_symbol + ); + Decimal::ZERO + }); + Ok(max_loss.max(short_option_minimum)) } /// Calculates a risk array for a given position using SPAN (Standard Portfolio Analysis of Risk) methodology. @@ -135,18 +156,18 @@ impl SPANMargin { /// # Example Use Case /// This is typically used in risk management systems to determine the appropriate /// margin requirements for option positions. - fn calculate_risk_array(&self, position: &Position) -> Vec { + fn calculate_risk_array(&self, position: &Position) -> Result, PricingError> { let mut risk_array = Vec::new(); let option = &position.option; let price_scenarios = self.generate_price_scenarios(option.underlying_price); let volatility_scenarios = self.generate_volatility_scenarios(option.implied_volatility); for &price in &price_scenarios { for &volatility in &volatility_scenarios { - let scenario_loss = self.calculate_scenario_loss(position, price, volatility); + let scenario_loss = self.calculate_scenario_loss(position, price, volatility)?; risk_array.push(scenario_loss); } } - risk_array + Ok(risk_array) } /// Generates a vector of price scenarios for risk analysis based on the underlying asset price. @@ -219,20 +240,23 @@ impl SPANMargin { position: &Position, scenario_price: Positive, scenario_volatility: Positive, - ) -> Decimal { + ) -> Result { let option = &position.option; - let current_price = option.calculate_price_black_scholes().unwrap(); + // Propagate BS pricing errors instead of falling back to ZERO, + // which would underestimate margin requirements (Copilot review + // on PR #355). + let current_price = option.calculate_price_black_scholes()?; let mut scenario_option = option.clone(); scenario_option.underlying_price = scenario_price; scenario_option.implied_volatility = scenario_volatility; - let scenario_price = scenario_option.calculate_price_black_scholes().unwrap(); - (scenario_price - current_price) + let scenario_price = scenario_option.calculate_price_black_scholes()?; + Ok((scenario_price - current_price) * option.quantity * if option.is_short() { Decimal::NEGATIVE_ONE } else { Decimal::ONE - } + }) } /// Calculates the minimum margin requirement for short option positions. @@ -276,7 +300,7 @@ mod tests_span { use tracing::info; #[test] - fn test_span_margin() { + fn test_span_margin() -> Result<(), Box> { let option = create_sample_option( OptionStyle::Call, Side::Short, @@ -302,8 +326,9 @@ mod tests_span { dec!(0.1), // volatility_scan_range (10%) ); - let margin = span.calculate_margin(&position); + let margin = span.calculate_margin(&position)?; assert!(margin > Decimal::ZERO, "Margin should be positive"); info!("Calculated margin: {}", margin); + Ok(()) } } diff --git a/src/simulation/simulator.rs b/src/simulation/simulator.rs index ad725999f..2cc6fe53f 100644 --- a/src/simulation/simulator.rs +++ b/src/simulation/simulator.rs @@ -218,33 +218,22 @@ where /// This method assumes that each item in the iterator is an iterable itself. /// It retrieves the last element of each iterable and collects them into a new `Vec`. /// - /// # Panics - /// This method will panic if: - /// - Any of the iterables in the iterator are empty. - /// - The `last()` call on any of the items returns `None`. - /// /// # Returns - /// A vector of references to the last `Step` elements of each item in the iterator. - /// + /// A vector of references to the last `Step` of each non-empty + /// random walk. Empty walks are silently skipped, so the returned + /// vector may be shorter than `self.len()`. pub fn get_last_steps(&self) -> Vec<&Step> { - self.into_iter().map(|step| step.last().unwrap()).collect() + self.into_iter().filter_map(|step| step.last()).collect() } - /// Retrieves the last value of each item in the iterator. - /// - /// This function processes the current instance of the object (likely some iterable structure) - /// and extracts the last element from each `Step` item in it. - /// The result is a `Vec` containing references to the last value of each `Step`. + /// Retrieves the last value of each non-empty random walk. /// /// # Returns - /// A `Vec` of references to the last element of each `Step` in the iterator. - /// - /// # Panics - /// This method panics if any `Step` within the iterator is empty, - /// as it uses `unwrap()` on the result of `step.last()`. - /// + /// A `Vec` of references to the last `Step` of each non-empty + /// random walk. Empty walks are silently skipped, so the returned + /// vector may be shorter than `self.len()`. pub fn get_last_values(&self) -> Vec<&Step> { - self.into_iter().map(|step| step.last().unwrap()).collect() + self.into_iter().filter_map(|step| step.last()).collect() } /// Retrieves the last set of positive values from the internal state. diff --git a/src/simulation/steps/step.rs b/src/simulation/steps/step.rs index c9e738bdb..08dd0e60f 100644 --- a/src/simulation/steps/step.rs +++ b/src/simulation/steps/step.rs @@ -179,7 +179,13 @@ where /// until expiration. This value is extracted from the step's x-component's expiration date. /// pub fn get_graph_x_in_days_left(&self) -> Positive { - self.x.days_left().unwrap() + // Used by visualization; on error we fall back to ZERO so the + // chart renders the step at the rightmost edge instead of + // panicking. Underlying error logged for diagnostics. + self.x.days_left().unwrap_or_else(|e| { + tracing::warn!("get_graph_x_in_days_left: days_left failed: {e}; using 0"); + Positive::ZERO + }) } /// Returns the y-value prepared for graphing operations diff --git a/src/simulation/steps/x.rs b/src/simulation/steps/x.rs index 4852756ca..98fc564c1 100644 --- a/src/simulation/steps/x.rs +++ b/src/simulation/steps/x.rs @@ -185,7 +185,7 @@ where /// /// A new `Xstep` instance with updated index and datetime values. pub fn next(&self) -> Result { - let days = self.datetime.get_days().unwrap(); + let days = self.datetime.get_days()?; if days == Positive::ZERO { return Err("Cannot generate next step. Expiration date is already reached.".into()); } @@ -222,7 +222,7 @@ where /// /// A new `Xstep` instance with updated index and datetime values. pub fn previous(&self) -> Result { - let days = self.datetime.get_days().unwrap(); + let days = self.datetime.get_days()?; let days_to_rest = convert_time_frame( self.step_size_in_time.try_into().map_err(|_| { SimulationError::step_error("Failed to convert step size to Positive") diff --git a/src/simulation/traits.rs b/src/simulation/traits.rs index 3739d36ab..24a98cc51 100644 --- a/src/simulation/traits.rs +++ b/src/simulation/traits.rs @@ -412,25 +412,37 @@ where values.push(price); // Add initial value + let dt_sqrt = dt + .to_dec() + .sqrt() + .ok_or_else(|| SimulationError::other("Heston: sqrt(dt) failed (overflow)"))?; + // sqrt(1 - rho^2) depends only on `rho`, hoist out of the + // hot loop so we don't recompute it per step. + let one_minus_rho_sq_sqrt = (Decimal::ONE - rho * rho).sqrt().ok_or_else(|| { + SimulationError::other( + "Heston: sqrt(1 - rho^2) failed (rho out of range or overflow)", + ) + })?; for _ in 0..params.size - 1 { // Generate correlated random numbers let z1 = decimal_normal_sample(); - let z2 = rho * z1 - + (Decimal::ONE - rho * rho).sqrt().unwrap() * decimal_normal_sample(); + let z2 = rho * z1 + one_minus_rho_sq_sqrt * decimal_normal_sample(); // Ensure variance stays positive (modified Euler scheme with truncation) + let variance_sqrt = variance.sqrt().ok_or_else(|| { + SimulationError::other("Heston: sqrt(variance) failed (overflow)") + })?; let variance_new = (variance + kappa.to_dec() * (theta.to_dec() - variance) * dt.to_dec() - + xi.to_dec() - * variance.sqrt().unwrap() - * z2 - * dt.to_dec().sqrt().unwrap()) - .max(Decimal::ZERO); + + xi.to_dec() * variance_sqrt * z2 * dt_sqrt) + .max(Decimal::ZERO); // Update price using the average variance over the step let avg_variance = (variance + variance_new) / Decimal::TWO; - let price_change = drift * dt.to_dec() - + avg_variance.sqrt().unwrap() * z1 * dt.to_dec().sqrt().unwrap(); + let avg_variance_sqrt = avg_variance.sqrt().ok_or_else(|| { + SimulationError::other("Heston: sqrt(avg_variance) failed (overflow)") + })?; + let price_change = drift * dt.to_dec() + avg_variance_sqrt * z1 * dt_sqrt; price *= (price_change).exp(); variance = variance_new; diff --git a/src/strategies/bull_put_spread.rs b/src/strategies/bull_put_spread.rs index 8d28342b0..04ace1a4b 100644 --- a/src/strategies/bull_put_spread.rs +++ b/src/strategies/bull_put_spread.rs @@ -701,6 +701,7 @@ impl Optimizable for BullPutSpread { /// # Examples /// /// ```rust + /// # fn main() -> Result<(), Box> { /// use rust_decimal_macros::dec; /// use tracing::info; /// use optionstratlib::chains::chain::OptionChain; @@ -730,7 +731,7 @@ impl Optimizable for BullPutSpread { /// pos_or_panic!(0.78), // open_fee_long /// pos_or_panic!(0.73), // close_fee_long /// pos_or_panic!(0.73), // close_fee_short - /// ).expect("valid bull put spread"); + /// )?; /// /// let side = FindOptimalSide::Lower; /// let filtered_combinations = bull_put_spread_strategy.filter_combinations(&option_chain, side); @@ -742,6 +743,8 @@ impl Optimizable for BullPutSpread { /// }; /// info!("Long Option: {:?}, Short Option: {:?}", long, short); /// } + /// # Ok(()) + /// # } /// ``` /// /// # Notes diff --git a/src/strategies/mod.rs b/src/strategies/mod.rs index 8ddc4974c..24358b864 100644 --- a/src/strategies/mod.rs +++ b/src/strategies/mod.rs @@ -41,6 +41,7 @@ //! Example usage of the Bull Call Spread strategy: //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use rust_decimal_macros::dec; //! use tracing::info; //! use optionstratlib::ExpirationDate; @@ -52,7 +53,7 @@ //! let spread = BullCallSpread::new( //! "SP500".to_string(), //! pos_or_panic!(5780.0), // underlying_price -//! pos_or_panic!(5750.0), // long_strike_itm +//! pos_or_panic!(5750.0), // long_strike_itm //! pos_or_panic!(5820.0), // short_strike //! ExpirationDate::Days(Positive::TWO), //! pos_or_panic!(0.18), // implied_volatility @@ -65,11 +66,13 @@ //! pos_or_panic!(0.78), // open_fee_long //! pos_or_panic!(0.73), // close_fee_long //! pos_or_panic!(0.73), // close_fee_short -//! ).expect("valid bull call spread"); +//! )?; //! //! let profit = spread.get_max_profit().unwrap_or(Positive::ZERO); //! let loss = spread.get_max_loss().unwrap_or(Positive::ZERO); //! info!("Max Profit: {}, Max Loss: {}", profit, loss); +//! # Ok(()) +//! # } //! ``` //! //! Refer to the documentation of each sub-module for more details on the specific @@ -154,6 +157,7 @@ //! //! Example usage of the Iron Condor strategy: //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use rust_decimal_macros::dec; //! use tracing::info; //! use optionstratlib::ExpirationDate; @@ -166,7 +170,7 @@ //! "AAPL".to_string(), //! pos_or_panic!(150.0), // underlying_price //! pos_or_panic!(155.0), // short_call_strike -//! pos_or_panic!(145.0), // short_put_strike +//! pos_or_panic!(145.0), // short_put_strike //! pos_or_panic!(160.0), // long_call_strike //! pos_or_panic!(140.0), // long_put_strike //! ExpirationDate::Days(pos_or_panic!(30.0)), @@ -180,11 +184,13 @@ //! pos_or_panic!(1.8), // premium_long_put //! pos_or_panic!(5.0), // open_fee //! pos_or_panic!(5.0), // close_fee -//! ).expect("valid iron condor"); +//! )?; //! //! let max_profit = condor.get_max_profit().unwrap_or(Positive::ZERO); //! let max_loss = condor.get_max_loss().unwrap_or(Positive::ZERO); //! info!("Max Profit: {}, Max Loss: {}", max_profit, max_loss); +//! # Ok(()) +//! # } //! ``` //! //! Refer to the documentation of each sub-module for more details on the specific diff --git a/src/utils/csv.rs b/src/utils/csv.rs index b5c157fa4..10cd3f398 100644 --- a/src/utils/csv.rs +++ b/src/utils/csv.rs @@ -123,7 +123,7 @@ pub fn read_ohlcv_from_zip( let date = NaiveDate::parse_from_str(parts[0], "%d/%m/%Y")?; // Skip records outside our date range if dates are specified - if (start.is_some() && date < start.unwrap()) || (end.is_some() && date > end.unwrap()) { + if start.is_some_and(|s| date < s) || end.is_some_and(|e| date > e) { continue; } diff --git a/src/utils/logger.rs b/src/utils/logger.rs index 4c1a6fbbd..e1d4daf5c 100644 --- a/src/utils/logger.rs +++ b/src/utils/logger.rs @@ -82,9 +82,9 @@ static INIT: Once = Once::new(); /// /// **Behavior:** /// - Concurrent calls to this function result in the logger being initialized only once. -/// -/// # Panics -/// This function panics if setting the default subscriber fails. +/// - If a global subscriber is already installed (for example, by a binary +/// wrapping the library) the second installation is silently ignored +/// rather than panicking. pub fn setup_logger() { INIT.call_once(|| { let log_level = env::var("LOGLEVEL") @@ -101,10 +101,12 @@ pub fn setup_logger() { let subscriber = FmtSubscriber::builder().with_max_level(level).finish(); - tracing::subscriber::set_global_default(subscriber) - .expect("Error setting default subscriber"); - - tracing::debug!("Log level set to: {}", level); + // `set_global_default` returns Err only if a global subscriber is + // already installed; in that case the caller already has a + // working subscriber and we silently no-op. + if tracing::subscriber::set_global_default(subscriber).is_ok() { + tracing::debug!("Log level set to: {}", level); + } }); } @@ -115,9 +117,8 @@ pub fn setup_logger() { /// /// **Behavior:** /// - Concurrent calls to this function result in the logger being initialized only once. -/// -/// # Panics -/// This function panics if setting the default subscriber fails. +/// - If a global subscriber is already installed the second installation +/// is silently ignored rather than panicking. #[allow(unused_variables)] pub fn setup_logger_with_level(log_level: &str) { INIT.call_once(|| { @@ -133,10 +134,9 @@ pub fn setup_logger_with_level(log_level: &str) { let subscriber = FmtSubscriber::builder().with_max_level(level).finish(); - tracing::subscriber::set_global_default(subscriber) - .expect("Error setting default subscriber"); - - tracing::debug!("Log level set to: {}", level); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + tracing::debug!("Log level set to: {}", level); + } }); } diff --git a/src/utils/others.rs b/src/utils/others.rs index f6a917960..64ae74798 100644 --- a/src/utils/others.rs +++ b/src/utils/others.rs @@ -4,16 +4,19 @@ Date: 27/9/24 ******************************************************************************/ -use crate::constants::TOLERANCE; use crate::error::{DecimalError, Error}; use itertools::Itertools; -use num_traits::{FromPrimitive, ToPrimitive}; +use num_traits::FromPrimitive; use positive::Positive; use rand::{Rng, RngExt, rng}; use rayon::prelude::*; use rust_decimal::Decimal; use std::collections::BTreeSet; +/// Precomputed f64 form of `crate::constants::TOLERANCE` (= 1e-8) so the +/// hot-path comparison can avoid the runtime fallible `Decimal::to_f64`. +const TOLERANCE_F64: f64 = 1e-8; + /// Checks for approximate equality between two f64 values within a defined tolerance. /// /// This function compares two floating-point numbers and returns `true` if the absolute @@ -44,7 +47,7 @@ use std::collections::BTreeSet; /// ``` #[allow(dead_code)] pub fn approx_equal(a: f64, b: f64) -> bool { - (a - b).abs() < TOLERANCE.to_f64().unwrap() + (a - b).abs() < TOLERANCE_F64 } /// Gets a random element from a BTreeSet. @@ -132,15 +135,18 @@ pub fn random_decimal(rng: &mut impl Rng) -> Result { /// # Examples /// /// ``` +/// # fn main() -> Result<(), Box> { /// use optionstratlib::utils::others::process_n_times_iter; /// /// let numbers = vec![1, 2, 3]; /// let n = 2; /// let result = process_n_times_iter(&numbers, n, |combination| { /// vec![combination[0] + combination[1]] -/// }).unwrap(); +/// })?; /// /// assert_eq!(result, vec![2, 3, 4, 4, 5, 6]); +/// # Ok(()) +/// # } /// ``` pub fn process_n_times_iter( positions: &[T], @@ -162,7 +168,11 @@ where Ok(combinations .par_iter() .flat_map(|combination| { - let mut closure = process_combination.lock().unwrap(); + // Mutex-poison recovery: a panic in one closure invocation + // shouldn't poison the entire combination scan. + let mut closure = process_combination + .lock() + .unwrap_or_else(|e| e.into_inner()); closure(combination) }) .collect()) diff --git a/src/utils/time.rs b/src/utils/time.rs index d7adf55be..b25050243 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -147,13 +147,20 @@ pub fn units_per_year(time_frame: &TimeFrame) -> Positive { TimeFrame::Minute => pos_or_panic!(525600.0), // 365 * 24 * 60 TimeFrame::Hour => pos_or_panic!(8760.0), // 365 * 24 TimeFrame::Day => pos_or_panic!(365.0), // 365 - TimeFrame::Week => { - Positive::new_decimal(dec!(365.0) / dec!(7.0)).expect("365/7 is positive") - } // 365 / 7 - TimeFrame::Month => pos_or_panic!(12.0), // 12 - TimeFrame::Quarter => pos_or_panic!(4.0), // 4 - TimeFrame::Year => Positive::ONE, // 1 - TimeFrame::Custom(periods) => *periods, // Custom periods per year + // 365 / 7 — kept as exact Decimal arithmetic to preserve the + // round-trip identity Week→Day→Week (an f64 literal would + // accumulate ~1 ulp of error and break the strict assertion in + // tests like `test_step_next_with_weeks`). The structural + // invariant (positive non-zero numerator and denominator) + // makes the Err arm unreachable. + TimeFrame::Week => match Positive::new_decimal(dec!(365.0) / dec!(7.0)) { + Ok(v) => v, + Err(_) => unreachable!("365/7 is structurally positive non-zero"), + }, + TimeFrame::Month => pos_or_panic!(12.0), // 12 + TimeFrame::Quarter => pos_or_panic!(4.0), // 4 + TimeFrame::Year => Positive::ONE, // 1 + TimeFrame::Custom(periods) => *periods, // Custom periods per year } } @@ -315,7 +322,11 @@ pub fn get_today_formatted() -> String { /// info!("{}", get_today_or_tomorrow_formatted()); /// ``` pub fn get_today_or_tomorrow_formatted() -> String { - let cutoff_time = NaiveTime::from_hms_opt(18, 30, 0).unwrap(); + // 18:30 is a valid wall-clock time, so the Err arm is unreachable. + let cutoff_time = match NaiveTime::from_hms_opt(18, 30, 0) { + Some(t) => t, + None => unreachable!("18:30:00 is always a valid NaiveTime"), + }; let now = Utc::now(); // Get the date we should use based on current UTC time let target_date = if now.time() > cutoff_time { diff --git a/src/visualization/mod.rs b/src/visualization/mod.rs index 4dba7ae79..6d21d2a7d 100644 --- a/src/visualization/mod.rs +++ b/src/visualization/mod.rs @@ -61,6 +61,7 @@ //! ## Example: Simple Line Chart //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use std::fs; //! use std::path::{Path, PathBuf}; //! use rust_decimal::Decimal; @@ -111,12 +112,13 @@ //! data.show(); //! // Save as PNG //! let filename: PathBuf = PathBuf::from("my_chart.png"); -//! data.render(OutputType::Png(&filename)).unwrap(); +//! data.render(OutputType::Png(&filename))?; //! if Path::new(&filename.clone()).exists() { -//! fs::remove_file(filename.clone()) -//! .unwrap_or_else(|_| panic!("Failed to remove {}", filename.to_str().unwrap())); +//! fs::remove_file(filename.clone())?; //! } //! } +//! # Ok(()) +//! # } //! ``` //! //! ## Example: 3D Surface @@ -272,8 +274,7 @@ //! chart.to_interactive_html(&filename)?; //! info!("Interactive HTML chart created successfully!"); //! if Path::new(&filename.clone()).exists() { -//! fs::remove_file(filename.clone()) -//! .unwrap_or_else(|_| panic!("Failed to remove {}", filename.to_str().unwrap())); +//! fs::remove_file(filename.clone())?; //! } //! } //! Ok(()) diff --git a/src/visualization/utils.rs b/src/visualization/utils.rs index 1c6357d37..eaea0ce45 100644 --- a/src/visualization/utils.rs +++ b/src/visualization/utils.rs @@ -222,7 +222,7 @@ pub fn get_color_from_scheme(scheme: &ColorScheme, idx: usize) -> Option "#307B8E", "#34B778", "#C6DE2F", "#432D7A", "#288A8D", "#42C675", "#E3E419", "#3F4889", "#21968A", "#5DC864", "#F0E51B", "#461C74", ]; - let color = colors.get(idx % colors.len()).unwrap(); + let color = colors.get(idx % colors.len())?; Some(color.to_string()) } ColorScheme::Plasma => { @@ -235,10 +235,18 @@ pub fn get_color_from_scheme(scheme: &ColorScheme, idx: usize) -> Option "#FDC229", "#7B04A7", "#E36159", "#FED330", "#8D0BA2", "#E7704F", "#FEE54F", "#9E189B", "#EC7F45", "#FEF06F", "#1C0377", "#AC2294", ]; - let color = colors.get(idx % colors.len()).unwrap(); + let color = colors.get(idx % colors.len())?; Some(color.to_string()) } - ColorScheme::Custom(list) => list.get(idx % list.len()).cloned(), + ColorScheme::Custom(list) => { + // Guard against `Custom(Vec::new())` — `idx % 0` would + // otherwise panic on integer division by zero. + if list.is_empty() { + None + } else { + list.get(idx % list.len()).cloned() + } + } ColorScheme::White => Some("#FFFFFF".to_string()), ColorScheme::HighContrast => { let colors = vec![ @@ -250,7 +258,7 @@ pub fn get_color_from_scheme(scheme: &ColorScheme, idx: usize) -> Option "#9ACD32", "#B22222", "#A52A2A", "#6A5ACD", "#778899", "#FF6347", "#7CFC00", "#87CEFA", "#FFA500", "#9932CC", "#008B8B", ]; - let color = colors.get(idx % colors.len()).unwrap(); + let color = colors.get(idx % colors.len())?; Some(color.to_string()) } } diff --git a/src/volatility/mod.rs b/src/volatility/mod.rs index 7478bf2cc..875177837 100644 --- a/src/volatility/mod.rs +++ b/src/volatility/mod.rs @@ -133,13 +133,16 @@ use positive::pos_or_panic; //! The module includes utilities for converting between different time frames: //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use positive::pos_or_panic; //! use optionstratlib::utils::time::TimeFrame; //! use optionstratlib::volatility::{annualized_volatility, de_annualized_volatility}; //! //! let daily_vol = pos_or_panic!(0.01); -//! let annual_vol = annualized_volatility(daily_vol, TimeFrame::Day).unwrap(); +//! let annual_vol = annualized_volatility(daily_vol, TimeFrame::Day)?; //! let daily_vol_again = de_annualized_volatility(annual_vol, TimeFrame::Day); +//! # Ok(()) +//! # } //! ``` //! //! ## Performance Considerations diff --git a/src/volatility/utils.rs b/src/volatility/utils.rs index 908932baf..bf7f035c2 100644 --- a/src/volatility/utils.rs +++ b/src/volatility/utils.rs @@ -24,8 +24,13 @@ use rust_decimal::{Decimal, MathematicalOps}; /// /// The calculated volatility as a Decimal. pub fn constant_volatility(returns: &[Decimal]) -> Result { - let n = Positive::new_decimal(Decimal::from_usize(returns.len()).unwrap()) - .unwrap_or(Positive::ZERO); + let n_dec = Decimal::from_usize(returns.len()).ok_or_else(|| { + VolatilityError::from(format!( + "constant_volatility: returns.len() {} not representable as Decimal", + returns.len() + )) + })?; + let n = Positive::new_decimal(n_dec).unwrap_or(Positive::ZERO); if n < Decimal::TWO { return Ok(Positive::ZERO); @@ -35,7 +40,10 @@ pub fn constant_volatility(returns: &[Decimal]) -> Result() / (n - Decimal::ONE); - Ok(Positive::new_decimal(variance.sqrt().unwrap()).unwrap_or(Positive::ZERO)) + let std_dev = variance.sqrt().ok_or_else(|| { + VolatilityError::from("constant_volatility: sqrt(variance) failed (overflow)") + })?; + Ok(Positive::new_decimal(std_dev).unwrap_or(Positive::ZERO)) } /// Calculates historical volatility using a moving window approach. @@ -72,14 +80,21 @@ pub fn ewma_volatility( returns: &[Decimal], lambda: Decimal, ) -> Result, VolatilityError> { - let mut variance = returns[0].powi(2); - let mut volatilities = - vec![Positive::new_decimal(variance.sqrt().unwrap()).unwrap_or(Positive::ZERO)]; + let first_return = returns + .first() + .ok_or_else(|| VolatilityError::from("ewma_volatility: returns slice is empty"))?; + let mut variance = first_return.powi(2); + let initial_std_dev = variance.sqrt().ok_or_else(|| { + VolatilityError::from("ewma_volatility: sqrt(initial variance) failed (overflow)") + })?; + let mut volatilities = vec![Positive::new_decimal(initial_std_dev).unwrap_or(Positive::ZERO)]; for &return_value in &returns[1..] { variance = lambda * variance + (Decimal::ONE - lambda) * return_value.powi(2); - volatilities - .push(Positive::new_decimal(variance.sqrt().unwrap()).unwrap_or(Positive::ZERO)); + let std_dev = variance.sqrt().ok_or_else(|| { + VolatilityError::from("ewma_volatility: sqrt(variance) failed (overflow)") + })?; + volatilities.push(Positive::new_decimal(std_dev).unwrap_or(Positive::ZERO)); } Ok(volatilities) @@ -250,8 +265,19 @@ pub fn simulate_heston_volatility( let mut v = v0.max(Decimal::ZERO); let mut v_pos = Positive::new_decimal(v).unwrap_or(Positive::ZERO); let mut volatilities = vec![v_pos.sqrt()]; + let dt_sqrt_f64 = dt + .sqrt() + .ok_or_else(|| { + VolatilityError::from("simulate_heston_volatility: sqrt(dt) failed (overflow)") + })? + .to_f64() + .ok_or_else(|| { + VolatilityError::from("simulate_heston_volatility: sqrt(dt) not representable as f64") + })?; for _ in 1..steps { - let dw = Decimal::from_f64(random::() * dt.sqrt().unwrap().to_f64().unwrap()).unwrap(); + let dw = Decimal::from_f64(random::() * dt_sqrt_f64).ok_or_else(|| { + VolatilityError::from("simulate_heston_volatility: dw not representable as Decimal") + })?; let sqrt_v = v_pos.sqrt().to_dec(); v += kappa * (theta - v) * dt + xi * sqrt_v * dw; v = v.max(Decimal::ZERO); // Ensure variance doesn't become negative @@ -376,11 +402,14 @@ pub fn de_annualized_volatility( /// /// # Example /// ``` +/// # fn main() -> Result<(), Box> { /// use positive::pos_or_panic; /// use optionstratlib::utils::TimeFrame; /// use optionstratlib::volatility::adjust_volatility; /// let daily_vol = pos_or_panic!(0.2); // 20% daily volatility -/// let minute_vol = adjust_volatility(daily_vol, TimeFrame::Day, TimeFrame::Minute).unwrap(); +/// let minute_vol = adjust_volatility(daily_vol, TimeFrame::Day, TimeFrame::Minute)?; +/// # Ok(()) +/// # } /// ``` pub fn adjust_volatility( volatility: Positive, @@ -447,6 +476,7 @@ pub fn adjust_volatility( /// # Examples /// /// ``` +/// # fn main() -> Result<(), Box> { /// use positive::{pos_or_panic, Positive}; /// use optionstratlib::utils::time::{TimeFrame, convert_time_frame}; /// use optionstratlib::volatility::volatility_for_dt; @@ -461,7 +491,9 @@ pub fn adjust_volatility( /// dt, /// TimeFrame::Minute, /// TimeFrame::Day -/// ).unwrap(); +/// )?; +/// # Ok(()) +/// # } /// ``` /// /// # Errors