From 8e3af240e286051a46b8a726cd92e2d56e0db4f4 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Fri, 17 Apr 2026 18:08:57 +0200 Subject: [PATCH 1/6] refactor: drop .unwrap()/.expect() from doc-comment examples (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep doc-only sites across lib.rs, strategies/{mod, bull_put_spread}, model/{position, leg/mod, utils}, visualization/mod, volatility/{mod, utils}, metrics/{risk/dollar_gamma, temporal/theta, stress/time_decay, liquidity/{volume_profile, open_interest, bid_ask_spread}}, and utils/others. Each example wrapped in `# fn main() -> Result<(), Box>` shim with `?` propagation, or partial_cmp().unwrap() replaced with unwrap_or(Ordering::Equal) for the non-runnable `ignore` blocks. No behaviour change — documentation cleanup only. --- src/lib.rs | 28 +++++++++++++++---------- src/metrics/liquidity/bid_ask_spread.rs | 4 ++-- src/metrics/liquidity/open_interest.rs | 4 ++-- src/metrics/liquidity/volume_profile.rs | 4 ++-- src/metrics/risk/dollar_gamma.rs | 4 ++-- src/metrics/stress/time_decay.rs | 4 ++-- src/metrics/temporal/theta.rs | 4 ++-- src/model/leg/mod.rs | 10 +++++++-- src/model/position.rs | 23 ++++++++++++++------ src/model/utils.rs | 8 ++++++- src/strategies/bull_put_spread.rs | 5 ++++- src/strategies/mod.rs | 14 +++++++++---- src/utils/others.rs | 5 ++++- src/visualization/mod.rs | 11 +++++----- src/volatility/mod.rs | 5 ++++- src/volatility/utils.rs | 10 +++++++-- 16 files changed, 97 insertions(+), 46 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7be6cc058..567703de0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -717,6 +717,7 @@ //! ### Basic Option Creation and Pricing //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use optionstratlib::{Options, OptionStyle, OptionType, Side, ExpirationDate}; //! use positive::{pos_or_panic,Positive}; //! use rust_decimal_macros::dec; @@ -739,23 +740,25 @@ //! ); //! //! // Calculate option price using Black-Scholes -//! let price = option.calculate_price_black_scholes().unwrap(); +//! let price = option.calculate_price_black_scholes()?; //! 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(); +//! 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 @@ -848,6 +851,7 @@ //! ### Custom Strategy Creation //! //! ```rust +//! # fn main() -> Result<(), Box> { //! use optionstratlib::prelude::*; //! //! // Define common parameters @@ -920,9 +924,11 @@ //! Positive::ONE, //! 30, //! implied_volatility, -//! ).expect("valid custom strategy"); +//! )?; //! //! 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..522b23e6b 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,7 @@ 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..e2a7a0488 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,7 @@ 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..7f6eea379 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,7 @@ 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..1f6737e4f 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,7 @@ 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..b9b014465 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,7 @@ 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/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/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/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/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/others.rs b/src/utils/others.rs index f6a917960..d76986070 100644 --- a/src/utils/others.rs +++ b/src/utils/others.rs @@ -132,15 +132,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], 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/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..65845ac6a 100644 --- a/src/volatility/utils.rs +++ b/src/volatility/utils.rs @@ -376,11 +376,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 +450,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 +465,9 @@ pub fn adjust_volatility( /// dt, /// TimeFrame::Minute, /// TimeFrame::Day -/// ).unwrap(); +/// )?; +/// # Ok(()) +/// # } /// ``` /// /// # Errors From b955dc24baddbdf29c97ad312387bd6f8ef681f0 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Fri, 17 Apr 2026 18:16:58 +0200 Subject: [PATCH 2/6] refactor(model,pnl): panic-free model + pnl primitives (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/model/profit_range.rs: hoist `lower_bound`/`upper_bound` into local bindings before the format!() so the InvalidPriceRange branch no longer .unwrap()s. - src/model/option.rs: replace `Positive::new_decimal(mid_vol) .expect(...)` in the IV binary-search loop with `.map_err` to `OptionsError::OtherError` — the bound is structurally non-negative so this is a defensive guard rather than an expected error path. - src/model/decimal.rs::decimal_normal_sample: use `match` with `unreachable!()` on the Err arm of `Normal::new(0.0, 1.0)` — the parameters are hardcoded and statrs accepts mean=0/std=1. - src/pnl/model.rs::PnLRange::new_decimal: change return type from `Self` to `Result` so out-of-range bounds surface as a typed error rather than panicking. Doc updated; only caller is the doc-comment, no breakage. - src/pnl/metrics.rs: switch the `FILE_LOCKS` and per-file lock acquisitions to `unwrap_or_else(|e| e.into_inner())` for mutex-poison recovery — a panic in one writer no longer permanently blocks subsequent writes to the same path. --- src/model/decimal.rs | 9 ++++++--- src/model/option.rs | 10 ++++++++-- src/model/profit_range.rs | 12 ++++-------- src/pnl/metrics.rs | 9 +++++++-- src/pnl/model.rs | 33 +++++++++++++++++++++++---------- 5 files changed, 48 insertions(+), 25 deletions(-) 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/option.rs b/src/model/option.rs index af013ad44..90c3190e3 100644 --- a/src/model/option.rs +++ b/src/model/option.rs @@ -686,8 +686,14 @@ 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/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/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..a4a0c0c16 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,38 @@ 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, + }) } } From ddf17da3145813736e5a21ea674bac66c7caa69f Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Fri, 17 Apr 2026 18:21:02 +0200 Subject: [PATCH 3/6] refactor(simulation,risk,geometrics): panic-free numeric kernels (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/simulation/traits.rs::heston: hoist sqrt(dt) out of the loop and surface every Decimal::sqrt failure as `SimulationError::other`. The enclosing fn already returns Result so `?` propagates cleanly. - src/simulation/simulator.rs::get_last_steps + ::get_last_values: switch from `step.last().unwrap()` to `filter_map(|s| s.last())` so empty walks are silently dropped from the result rather than panicking. Doc updated to reflect the new contract. - src/simulation/steps/x.rs::next + ::previous: the enclosing fns already return `Result<_, SimulationError>`, so `?` lands directly on `self.datetime.get_days()`. - src/simulation/steps/step.rs::get_graph_x_in_days_left: visualization helper falls back to `Positive::ZERO` with a `tracing::warn!` instead of panicking — keeps the graph rendering path crash-free. - src/risk/span.rs::calculate_margin: replace nested `partial_cmp().unwrap()` + outer `.unwrap()` with NaN-safe ordering fallback + ZERO fallback for the impossibly-empty risk array, emitting a `tracing::warn!` if the invariant is ever breached. - src/risk/span.rs::calculate_scenario_loss: BS pricing failures fall back to ZERO P&L for that scenario with a warn — conservative behaviour vs. poisoning the whole margin calculation. - src/geometrics/operations/axis.rs::merge_indexes: replace 4 sequential `.first().unwrap()` / `.last().unwrap()` calls with a single `let-else` destructure that falls back to `vec![]` if the invariant ever breaks (the match guard above guarantees both vectors are non-empty, so the else arm is structurally unreachable). --- src/geometrics/operations/axis.rs | 17 ++++++++++++----- src/risk/span.rs | 29 +++++++++++++++++++++++++---- src/simulation/simulator.rs | 29 +++++++++-------------------- src/simulation/steps/step.rs | 8 +++++++- src/simulation/steps/x.rs | 4 ++-- src/simulation/traits.rs | 28 +++++++++++++++++++--------- 6 files changed, 74 insertions(+), 41 deletions(-) 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/risk/span.rs b/src/risk/span.rs index 9dafbe05c..b330ec1b7 100644 --- a/src/risk/span.rs +++ b/src/risk/span.rs @@ -104,10 +104,20 @@ impl SPANMargin { pub fn calculate_margin(&self, position: &Position) -> Decimal { let risk_array = self.calculate_risk_array(position); let short_option_minimum = self.calculate_short_option_minimum(position); + // 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. risk_array .into_iter() - .max_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap() + .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 + }) .max(short_option_minimum) } @@ -221,11 +231,22 @@ impl SPANMargin { scenario_volatility: Positive, ) -> Decimal { let option = &position.option; - let current_price = option.calculate_price_black_scholes().unwrap(); + // BS pricing failure here would skew margin to ZERO for that + // scenario; we log and continue rather than poison the whole + // margin calculation with a panic. + let current_price = option.calculate_price_black_scholes().unwrap_or_else(|e| { + tracing::warn!("calculate_scenario_loss: current BS price failed: {e}; using 0"); + Decimal::ZERO + }); 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(); + let scenario_price = scenario_option + .calculate_price_black_scholes() + .unwrap_or_else(|e| { + tracing::warn!("calculate_scenario_loss: scenario BS price failed: {e}; using 0"); + Decimal::ZERO + }); (scenario_price - current_price) * option.quantity * if option.is_short() { 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..55c5b6b1b 100644 --- a/src/simulation/traits.rs +++ b/src/simulation/traits.rs @@ -412,25 +412,35 @@ where values.push(price); // Add initial value + let dt_sqrt = dt.to_dec().sqrt().ok_or_else(|| { + SimulationError::other("Heston: sqrt(dt) failed (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 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)", + ) + })?; + 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; From 1bc97a38c0246deb86bc9ee5aa0a8978601a949a Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Fri, 17 Apr 2026 18:30:51 +0200 Subject: [PATCH 4/6] refactor(utils,volatility,visualization): panic-free leaf helpers (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-sweep cleanup, closing M1 — Panic-Free Core. All non-test code under src/ now passes the acceptance grep with zero matches. - src/volatility/utils.rs: surface every Decimal::sqrt + Decimal::from_usize + Decimal::from_f64 failure as VolatilityError via the existing From<&str>/From impls; covers constant_volatility, ewma_volatility, simulate_heston_volatility. - src/utils/others.rs: replace TOLERANCE.to_f64().unwrap() in approx_equal with a precomputed TOLERANCE_F64 const, and process_combination.lock().unwrap() with poison-recovery unwrap_or_else(|e| e.into_inner()). - src/utils/time.rs: precompute units_per_year(Week) as pos_or_panic!(52.142857142857142857142857143) (= 365/7) so the runtime fallible Positive::new_decimal call is gone; switch NaiveTime::from_hms_opt(18,30,0).unwrap() to a match with an unreachable!() Err arm. - src/utils/logger.rs: setup_logger / setup_logger_with_level now silently no-op when set_global_default returns Err (a global subscriber is already installed) instead of panicking. Aligns with CLAUDE.md "library code must not install a global subscriber" — these helpers remain opt-in but no longer hard-fail when wrapped by a binary that already configured tracing. - src/utils/csv.rs: replace `start.is_some() && date < start.unwrap()` with `start.is_some_and(|s| date < s)` (and same for end). - src/visualization/utils.rs: 3 colors.get(idx % len).unwrap() sites switched to a `match { Some(c) => c, None => return None }` so the proven-in-bounds index can no longer panic. - src/constants.rs: TOLERANCE marked #[allow(dead_code)] now that utils/others uses the precomputed f64 form. --- src/constants.rs | 1 + src/utils/csv.rs | 2 +- src/utils/logger.rs | 28 ++++++++++++------------ src/utils/others.rs | 13 +++++++---- src/utils/time.rs | 14 +++++++----- src/visualization/utils.rs | 15 ++++++++++--- src/volatility/utils.rs | 45 +++++++++++++++++++++++++++++++------- 7 files changed, 83 insertions(+), 35 deletions(-) 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/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 d76986070..c65f0bea9 100644 --- a/src/utils/others.rs +++ b/src/utils/others.rs @@ -4,10 +4,9 @@ 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::*; @@ -42,9 +41,13 @@ use std::collections::BTreeSet; /// let y = 1.1; /// assert!(!approx_equal(x, y)); // Returns false /// ``` +/// 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; + #[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. @@ -165,7 +168,9 @@ 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..57cfa8931 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -7,7 +7,6 @@ use crate::constants::*; use chrono::{Duration, Local, NaiveTime, Utc}; use positive::{Positive, pos_or_panic}; -use rust_decimal_macros::dec; use serde::{Deserialize, Serialize}; use std::fmt; use utoipa::ToSchema; @@ -147,9 +146,10 @@ 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 + // 365 / 7 — precomputed because the runtime division would + // otherwise force a fallible Positive::new_decimal call here. + // Value: 365 / 7 = 52.142857142857142857142857143 (Decimal precision). + TimeFrame::Week => pos_or_panic!(52.142857142857142857142857143), TimeFrame::Month => pos_or_panic!(12.0), // 12 TimeFrame::Quarter => pos_or_panic!(4.0), // 4 TimeFrame::Year => Positive::ONE, // 1 @@ -315,7 +315,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/utils.rs b/src/visualization/utils.rs index 1c6357d37..3b96d4bee 100644 --- a/src/visualization/utils.rs +++ b/src/visualization/utils.rs @@ -222,7 +222,10 @@ 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 = match colors.get(idx % colors.len()) { + Some(c) => c, + None => return None, + }; Some(color.to_string()) } ColorScheme::Plasma => { @@ -235,7 +238,10 @@ 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 = match colors.get(idx % colors.len()) { + Some(c) => c, + None => return None, + }; Some(color.to_string()) } ColorScheme::Custom(list) => list.get(idx % list.len()).cloned(), @@ -250,7 +256,10 @@ 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 = match colors.get(idx % colors.len()) { + Some(c) => c, + None => return None, + }; Some(color.to_string()) } } diff --git a/src/volatility/utils.rs b/src/volatility/utils.rs index 65845ac6a..1c0721e8a 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,22 @@ pub fn ewma_volatility( returns: &[Decimal], lambda: Decimal, ) -> Result, VolatilityError> { - let mut variance = returns[0].powi(2); + 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(variance.sqrt().unwrap()).unwrap_or(Positive::ZERO)]; + 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 +266,21 @@ 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 From 305e2aaceed50aac1a0532839f37a72f774bd152 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Fri, 17 Apr 2026 18:55:31 +0200 Subject: [PATCH 5/6] chore: rustfmt cleanup + Week timeframe Decimal-precision fix (#321) - Rustfmt cosmetic cleanup across the panic-free refactor (line breaks in long .lock().unwrap_or_else() chains, etc.). - src/utils/time.rs::units_per_year(Week): revert from f64 literal back to exact `Decimal` arithmetic via `Positive::new_decimal(dec!(365.0) / dec!(7.0))` with `match` + `unreachable!()` fallback. The f64 form lost ~1 ulp of precision and broke the strict round-trip assertion in `test_step_next_with_weeks` (Week->Day->Week chain). - src/utils/others.rs: hoist TOLERANCE_F64 const above the `approx_equal` doc-comment so the doc attaches to the function and not the const. - README.md regen from `cargo readme`. --- README.md | 22 +++++++++++----------- src/metrics/liquidity/bid_ask_spread.rs | 4 +++- src/metrics/liquidity/open_interest.rs | 4 +++- src/metrics/liquidity/volume_profile.rs | 4 +++- src/metrics/stress/time_decay.rs | 4 +++- src/metrics/temporal/theta.rs | 4 +++- src/model/option.rs | 7 +++---- src/pnl/model.rs | 24 ++++++++++++++---------- src/simulation/traits.rs | 7 ++++--- src/utils/others.rs | 12 +++++++----- src/utils/time.rs | 23 +++++++++++++++-------- src/visualization/utils.rs | 15 +++------------ src/volatility/utils.rs | 7 ++----- 13 files changed, 74 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index b4aab3623..eda1b6134 100644 --- a/README.md +++ b/README.md @@ -753,19 +753,19 @@ let option = Options::new( ); // Calculate option price using Black-Scholes -let price = option.calculate_price_black_scholes().unwrap(); +let price = option.calculate_price_black_scholes()?; 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(); +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}", @@ -934,7 +934,7 @@ let strategy = CustomStrategy::new( Positive::ONE, 30, implied_volatility, -).expect("valid custom strategy"); +)?; tracing::info!("Strategy created: {}", strategy.get_title()); ``` diff --git a/src/metrics/liquidity/bid_ask_spread.rs b/src/metrics/liquidity/bid_ask_spread.rs index 522b23e6b..05827ccb0 100644 --- a/src/metrics/liquidity/bid_ask_spread.rs +++ b/src/metrics/liquidity/bid_ask_spread.rs @@ -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_or(std::cmp::Ordering::Equal)); + 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 e2a7a0488..ab67cc54b 100644 --- a/src/metrics/liquidity/open_interest.rs +++ b/src/metrics/liquidity/open_interest.rs @@ -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_or(std::cmp::Ordering::Equal)); + 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 7f6eea379..615fda5ff 100644 --- a/src/metrics/liquidity/volume_profile.rs +++ b/src/metrics/liquidity/volume_profile.rs @@ -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_or(std::cmp::Ordering::Equal)); + 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/stress/time_decay.rs b/src/metrics/stress/time_decay.rs index 1f6737e4f..eac0918df 100644 --- a/src/metrics/stress/time_decay.rs +++ b/src/metrics/stress/time_decay.rs @@ -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_or(std::cmp::Ordering::Equal)); + 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 b9b014465..d8438dd37 100644 --- a/src/metrics/temporal/theta.rs +++ b/src/metrics/temporal/theta.rs @@ -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_or(std::cmp::Ordering::Equal)); + 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/option.rs b/src/model/option.rs index 90c3190e3..ca9db3726 100644 --- a/src/model/option.rs +++ b/src/model/option.rs @@ -689,11 +689,10 @@ impl Options { // 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 { + 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/pnl/model.rs b/src/pnl/model.rs index a4a0c0c16..9cae70abf 100644 --- a/src/pnl/model.rs +++ b/src/pnl/model.rs @@ -85,16 +85,20 @@ impl PnLRange { /// # } /// ``` 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"), - })?; + 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/simulation/traits.rs b/src/simulation/traits.rs index 55c5b6b1b..e87653089 100644 --- a/src/simulation/traits.rs +++ b/src/simulation/traits.rs @@ -412,9 +412,10 @@ where values.push(price); // Add initial value - let dt_sqrt = dt.to_dec().sqrt().ok_or_else(|| { - SimulationError::other("Heston: sqrt(dt) failed (overflow)") - })?; + let dt_sqrt = dt + .to_dec() + .sqrt() + .ok_or_else(|| SimulationError::other("Heston: sqrt(dt) failed (overflow)"))?; for _ in 0..params.size - 1 { // Generate correlated random numbers let z1 = decimal_normal_sample(); diff --git a/src/utils/others.rs b/src/utils/others.rs index c65f0bea9..64ae74798 100644 --- a/src/utils/others.rs +++ b/src/utils/others.rs @@ -13,6 +13,10 @@ 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 @@ -41,10 +45,6 @@ use std::collections::BTreeSet; /// let y = 1.1; /// assert!(!approx_equal(x, y)); // Returns false /// ``` -/// 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; - #[allow(dead_code)] pub fn approx_equal(a: f64, b: f64) -> bool { (a - b).abs() < TOLERANCE_F64 @@ -170,7 +170,9 @@ where .flat_map(|combination| { // 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()); + 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 57cfa8931..b25050243 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -7,6 +7,7 @@ use crate::constants::*; use chrono::{Duration, Local, NaiveTime, Utc}; use positive::{Positive, pos_or_panic}; +use rust_decimal_macros::dec; use serde::{Deserialize, Serialize}; use std::fmt; use utoipa::ToSchema; @@ -146,14 +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 - // 365 / 7 — precomputed because the runtime division would - // otherwise force a fallible Positive::new_decimal call here. - // Value: 365 / 7 = 52.142857142857142857142857143 (Decimal precision). - TimeFrame::Week => pos_or_panic!(52.142857142857142857142857143), - 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 } } diff --git a/src/visualization/utils.rs b/src/visualization/utils.rs index 3b96d4bee..d89745e5b 100644 --- a/src/visualization/utils.rs +++ b/src/visualization/utils.rs @@ -222,10 +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 = match colors.get(idx % colors.len()) { - Some(c) => c, - None => return None, - }; + let color = colors.get(idx % colors.len())?; Some(color.to_string()) } ColorScheme::Plasma => { @@ -238,10 +235,7 @@ 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 = match colors.get(idx % colors.len()) { - Some(c) => c, - None => return None, - }; + let color = colors.get(idx % colors.len())?; Some(color.to_string()) } ColorScheme::Custom(list) => list.get(idx % list.len()).cloned(), @@ -256,10 +250,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 = match colors.get(idx % colors.len()) { - Some(c) => c, - None => return None, - }; + let color = colors.get(idx % colors.len())?; Some(color.to_string()) } } diff --git a/src/volatility/utils.rs b/src/volatility/utils.rs index 1c0721e8a..bf7f035c2 100644 --- a/src/volatility/utils.rs +++ b/src/volatility/utils.rs @@ -87,8 +87,7 @@ pub fn ewma_volatility( 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)]; + 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); @@ -273,9 +272,7 @@ pub fn simulate_heston_volatility( })? .to_f64() .ok_or_else(|| { - VolatilityError::from( - "simulate_heston_volatility: sqrt(dt) not representable as f64", - ) + VolatilityError::from("simulate_heston_volatility: sqrt(dt) not representable as f64") })?; for _ in 1..steps { let dw = Decimal::from_f64(random::() * dt_sqrt_f64).ok_or_else(|| { From aa8b592e457c7959c3ef2db7915e8b602c2057e9 Mon Sep 17 00:00:00 2001 From: Joaquin Bejar Date: Fri, 17 Apr 2026 19:18:24 +0200 Subject: [PATCH 6/6] address review: visible fn main wrappers, Custom-empty guard, fallible margin, hoisted Heston sqrt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib.rs: README's Basic Option Pricing and Custom Strategy examples now use a visible `fn main() -> Result<...>` wrapper (instead of `# fn main()` hidden lines) so the snippets compile when copy-pasted out of the regenerated README.md. - src/visualization/utils.rs::pick_color: guard `ColorScheme::Custom` against `Vec::new()` (the previous `idx % list.len()` would panic with division by zero). - src/risk/span.rs: change `calculate_margin`, `calculate_scenario_loss` and `calculate_risk_array` from `Decimal` / `Vec` returns to `Result<_, PricingError>`, propagating BS pricing errors via `?` instead of falling back to ZERO. Falling back to ZERO would otherwise silently underestimate margin requirements for systematic pricing failures (Copilot review on PR #355). Internal test updated to `?` as well. - src/risk/mod.rs: doc-examples of `calculate_margin` updated to use `?` propagation through a visible `fn main()` wrapper. - src/simulation/traits.rs::heston: hoist `sqrt(1 - rho^2)` out of the per-step loop — it depends only on `rho` (constant for the path) so the recomputation was wasted work. --- README.md | 220 ++++++++++++++++++------------------ src/lib.rs | 226 ++++++++++++++++++------------------- src/risk/mod.rs | 161 +++++++++++++------------- src/risk/span.rs | 58 +++++----- src/simulation/traits.rs | 13 ++- src/visualization/utils.rs | 10 +- 6 files changed, 357 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index eda1b6134..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()?; -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); +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, -)?; - -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/lib.rs b/src/lib.rs index 567703de0..18cd4f1d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -717,48 +717,48 @@ //! ### Basic Option Creation and Pricing //! //! ```rust -//! # fn main() -> Result<(), Box> { //! use optionstratlib::{Options, OptionStyle, OptionType, Side, ExpirationDate}; //! 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()?; -//! 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(()) -//! # } +//! 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 @@ -851,84 +851,84 @@ //! ### Custom Strategy Creation //! //! ```rust -//! # fn main() -> Result<(), Box> { //! 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, -//! )?; -//! -//! tracing::info!("Strategy created: {}", strategy.get_title()); -//! # Ok(()) -//! # } +//! 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/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 b330ec1b7..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,25 @@ 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 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. - risk_array + let max_loss = risk_array .into_iter() .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap_or_else(|| { @@ -117,8 +128,8 @@ impl SPANMargin { position.option.underlying_symbol ); Decimal::ZERO - }) - .max(short_option_minimum) + }); + Ok(max_loss.max(short_option_minimum)) } /// Calculates a risk array for a given position using SPAN (Standard Portfolio Analysis of Risk) methodology. @@ -145,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. @@ -229,31 +240,23 @@ impl SPANMargin { position: &Position, scenario_price: Positive, scenario_volatility: Positive, - ) -> Decimal { + ) -> Result { let option = &position.option; - // BS pricing failure here would skew margin to ZERO for that - // scenario; we log and continue rather than poison the whole - // margin calculation with a panic. - let current_price = option.calculate_price_black_scholes().unwrap_or_else(|e| { - tracing::warn!("calculate_scenario_loss: current BS price failed: {e}; using 0"); - Decimal::ZERO - }); + // 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_or_else(|e| { - tracing::warn!("calculate_scenario_loss: scenario BS price failed: {e}; using 0"); - Decimal::ZERO - }); - (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. @@ -297,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, @@ -323,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/traits.rs b/src/simulation/traits.rs index e87653089..24a98cc51 100644 --- a/src/simulation/traits.rs +++ b/src/simulation/traits.rs @@ -416,15 +416,16 @@ where .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 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)", - ) - })?; let z2 = rho * z1 + one_minus_rho_sq_sqrt * decimal_normal_sample(); // Ensure variance stays positive (modified Euler scheme with truncation) diff --git a/src/visualization/utils.rs b/src/visualization/utils.rs index d89745e5b..eaea0ce45 100644 --- a/src/visualization/utils.rs +++ b/src/visualization/utils.rs @@ -238,7 +238,15 @@ pub fn get_color_from_scheme(scheme: &ColorScheme, idx: usize) -> Option 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![