Skip to content

Commit 021de52

Browse files
authored
Merge pull request #371 from joaquinbejar/M5/issue-335-checked-decimal-monetary
M5 #335: Audit and convert monetary Decimal arithmetic to checked helpers
2 parents 26f76da + d7d4d38 commit 021de52

36 files changed

Lines changed: 1274 additions & 212 deletions

src/chains/chain.rs

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::metrics::{
2121
TimeDecayCurve, TimeDecaySurface, VannaVolgaSurface, VolatilitySensitivityCurve,
2222
VolatilitySensitivitySurface, VolatilitySkewCurve, VolumeProfileCurve, VolumeProfileSurface,
2323
};
24+
use crate::model::decimal::d_add;
2425
use crate::model::{
2526
BasicAxisTypes, ExpirationDate, OptionStyle, OptionType, Options, Position, Side,
2627
};
@@ -1941,7 +1942,11 @@ impl OptionChain {
19411942
pub fn gamma_exposure(&self) -> Result<Decimal, ChainError> {
19421943
let mut gamma_exposure = Decimal::ZERO;
19431944
for option in &self.options {
1944-
gamma_exposure += option.gamma.unwrap_or(Decimal::ZERO);
1945+
gamma_exposure = d_add(
1946+
gamma_exposure,
1947+
option.gamma.unwrap_or(Decimal::ZERO),
1948+
"chains::gamma_exposure",
1949+
)?;
19451950
}
19461951
Ok(gamma_exposure)
19471952
}
@@ -1968,8 +1973,16 @@ impl OptionChain {
19681973
pub fn delta_exposure(&self) -> Result<Decimal, ChainError> {
19691974
let mut delta_exposure = Decimal::ZERO;
19701975
for option in &self.options {
1971-
delta_exposure += option.delta_call.unwrap_or(Decimal::ZERO);
1972-
delta_exposure += option.delta_put.unwrap_or(Decimal::ZERO);
1976+
delta_exposure = d_add(
1977+
delta_exposure,
1978+
option.delta_call.unwrap_or(Decimal::ZERO),
1979+
"chains::delta_exposure::call",
1980+
)?;
1981+
delta_exposure = d_add(
1982+
delta_exposure,
1983+
option.delta_put.unwrap_or(Decimal::ZERO),
1984+
"chains::delta_exposure::put",
1985+
)?;
19731986
}
19741987
Ok(delta_exposure)
19751988
}
@@ -1999,11 +2012,11 @@ impl OptionChain {
19992012
let vega = option_data
20002013
.get_option(Side::Long, OptionStyle::Call)?
20012014
.vega()?;
2002-
vega_exposure += vega;
2015+
vega_exposure = d_add(vega_exposure, vega, "chains::vega_exposure::call")?;
20032016
let vega = option_data
20042017
.get_option(Side::Long, OptionStyle::Put)?
20052018
.vega()?;
2006-
vega_exposure += vega;
2019+
vega_exposure = d_add(vega_exposure, vega, "chains::vega_exposure::put")?;
20072020
}
20082021
Ok(vega_exposure)
20092022
}
@@ -2033,11 +2046,11 @@ impl OptionChain {
20332046
let theta = option_data
20342047
.get_option(Side::Long, OptionStyle::Call)?
20352048
.theta()?;
2036-
theta_exposure += theta;
2049+
theta_exposure = d_add(theta_exposure, theta, "chains::theta_exposure::call")?;
20372050
let theta = option_data
20382051
.get_option(Side::Long, OptionStyle::Put)?
20392052
.theta()?;
2040-
theta_exposure += theta;
2053+
theta_exposure = d_add(theta_exposure, theta, "chains::theta_exposure::put")?;
20412054
}
20422055
Ok(theta_exposure)
20432056
}
@@ -2067,11 +2080,11 @@ impl OptionChain {
20672080
let vanna = option_data
20682081
.get_option(Side::Long, OptionStyle::Call)?
20692082
.vanna()?;
2070-
vanna_exposure += vanna;
2083+
vanna_exposure = d_add(vanna_exposure, vanna, "chains::vanna_exposure::call")?;
20712084
let vanna = option_data
20722085
.get_option(Side::Long, OptionStyle::Put)?
20732086
.vanna()?;
2074-
vanna_exposure += vanna;
2087+
vanna_exposure = d_add(vanna_exposure, vanna, "chains::vanna_exposure::put")?;
20752088
}
20762089
Ok(vanna_exposure)
20772090
}
@@ -2100,11 +2113,11 @@ impl OptionChain {
21002113
let vomma = option_data
21012114
.get_option(Side::Long, OptionStyle::Call)?
21022115
.vomma()?;
2103-
vomma_exposure += vomma;
2116+
vomma_exposure = d_add(vomma_exposure, vomma, "chains::vomma_exposure::call")?;
21042117
let vomma = option_data
21052118
.get_option(Side::Long, OptionStyle::Put)?
21062119
.vomma()?;
2107-
vomma_exposure += vomma;
2120+
vomma_exposure = d_add(vomma_exposure, vomma, "chains::vomma_exposure::put")?;
21082121
}
21092122
Ok(vomma_exposure)
21102123
}
@@ -2133,11 +2146,11 @@ impl OptionChain {
21332146
let veta = option_data
21342147
.get_option(Side::Long, OptionStyle::Call)?
21352148
.veta()?;
2136-
veta_exposure += veta;
2149+
veta_exposure = d_add(veta_exposure, veta, "chains::veta_exposure::call")?;
21372150
let veta = option_data
21382151
.get_option(Side::Long, OptionStyle::Put)?
21392152
.veta()?;
2140-
veta_exposure += veta;
2153+
veta_exposure = d_add(veta_exposure, veta, "chains::veta_exposure::put")?;
21412154
}
21422155
Ok(veta_exposure)
21432156
}
@@ -2167,11 +2180,11 @@ impl OptionChain {
21672180
let charm = option_data
21682181
.get_option(Side::Long, OptionStyle::Call)?
21692182
.charm()?;
2170-
charm_exposure += charm;
2183+
charm_exposure = d_add(charm_exposure, charm, "chains::charm_exposure::call")?;
21712184
let charm = option_data
21722185
.get_option(Side::Long, OptionStyle::Put)?
21732186
.charm()?;
2174-
charm_exposure += charm;
2187+
charm_exposure = d_add(charm_exposure, charm, "chains::charm_exposure::put")?;
21752188
}
21762189
Ok(charm_exposure)
21772190
}
@@ -2201,11 +2214,11 @@ impl OptionChain {
22012214
let color = option_data
22022215
.get_option(Side::Long, OptionStyle::Call)?
22032216
.color()?;
2204-
color_exposure += color;
2217+
color_exposure = d_add(color_exposure, color, "chains::color_exposure::call")?;
22052218
let color = option_data
22062219
.get_option(Side::Long, OptionStyle::Put)?
22072220
.color()?;
2208-
color_exposure += color;
2221+
color_exposure = d_add(color_exposure, color, "chains::color_exposure::put")?;
22092222
}
22102223
Ok(color_exposure)
22112224
}

src/error/decimal.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Email: jb@taunais.com
44
Date: 25/12/24
55
******************************************************************************/
6+
use rust_decimal::Decimal;
67
use thiserror::Error;
78

89
/// # Decimal Error Management
@@ -128,6 +129,33 @@ pub enum DecimalError {
128129
/// Expiration-date conversion error surfaced during decimal operations.
129130
#[error(transparent)]
130131
ExpirationDate(expiration_date::error::ExpirationDateError),
132+
133+
/// Error when a `Decimal` arithmetic operation overflows the representable range.
134+
///
135+
/// Emitted by the crate-private `d_add` / `d_sub` / `d_mul` / `d_div` helpers
136+
/// in [`crate::model::decimal`] when the underlying
137+
/// `Decimal::checked_*` call returns `None`. The `operation` tag is a
138+
/// short, static identifier describing the call-site (for example
139+
/// `"pricing::black_scholes::discount_strike"`) so failures can be
140+
/// traced back to their kernel without walking the call stack.
141+
///
142+
/// The operands are captured verbatim for post-mortem debugging and
143+
/// are included in the formatted error output (see the `#[error]`
144+
/// template below). This `DecimalError` variant is propagated across
145+
/// domain modules through the existing `#[from]` cascade, so the
146+
/// operand values will appear in any log or wrapper-error string
147+
/// produced downstream. Callers that need to redact them for
148+
/// production logging should match on the `Overflow` variant
149+
/// explicitly and reformat rather than rely on the default `Display`.
150+
#[error("Decimal {operation} overflow: {lhs} op {rhs}")]
151+
Overflow {
152+
/// Short static tag identifying the call-site.
153+
operation: &'static str,
154+
/// Left-hand operand of the failed arithmetic.
155+
lhs: Decimal,
156+
/// Right-hand operand of the failed arithmetic.
157+
rhs: Decimal,
158+
},
131159
}
132160

133161
/// A specialized `Result` type for decimal calculation operations.
@@ -285,6 +313,33 @@ impl DecimalError {
285313
reason: reason.to_string(),
286314
}
287315
}
316+
317+
/// Creates a new `Overflow` error for a failed checked arithmetic operation.
318+
///
319+
/// Used by the crate-private `d_add` / `d_sub` / `d_mul` / `d_div` helpers
320+
/// to surface an overflow of the underlying `Decimal::checked_*` call
321+
/// with full operand context.
322+
///
323+
/// # Parameters
324+
///
325+
/// * `operation` - Short static tag identifying the call-site
326+
/// (for example `"pricing::black_scholes::discount_strike"`).
327+
/// * `lhs` - Left-hand operand of the failed arithmetic.
328+
/// * `rhs` - Right-hand operand of the failed arithmetic.
329+
///
330+
/// # Returns
331+
///
332+
/// A new `DecimalError::Overflow` instance.
333+
#[cold]
334+
#[inline(never)]
335+
#[must_use]
336+
pub fn overflow(operation: &'static str, lhs: Decimal, rhs: Decimal) -> Self {
337+
DecimalError::Overflow {
338+
operation,
339+
lhs,
340+
rhs,
341+
}
342+
}
288343
}
289344

290345
impl From<expiration_date::error::ExpirationDateError> for DecimalError {
@@ -332,4 +387,31 @@ mod tests {
332387
assert!(matches!(error, DecimalError::InvalidPrecision { .. }));
333388
assert!(error.to_string().contains("non-negative"));
334389
}
390+
391+
#[test]
392+
fn test_overflow_error() {
393+
let error = DecimalError::overflow("test::site", Decimal::MAX, Decimal::MAX);
394+
assert!(matches!(error, DecimalError::Overflow { .. }));
395+
let rendered = error.to_string();
396+
assert!(rendered.contains("test::site"));
397+
assert!(rendered.contains("overflow"));
398+
}
399+
400+
#[test]
401+
fn test_overflow_fields() {
402+
let lhs = Decimal::MAX;
403+
let rhs = Decimal::from(2);
404+
if let DecimalError::Overflow {
405+
operation,
406+
lhs: captured_lhs,
407+
rhs: captured_rhs,
408+
} = DecimalError::overflow("test::mul", lhs, rhs)
409+
{
410+
assert_eq!(operation, "test::mul");
411+
assert_eq!(captured_lhs, lhs);
412+
assert_eq!(captured_rhs, rhs);
413+
} else {
414+
panic!("expected Overflow variant");
415+
}
416+
}
335417
}

src/error/position.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ pub enum PositionError {
119119
/// Errors related to position update
120120
#[error("Update error: {0}")]
121121
UpdateError(PositionUpdateErrorKind),
122+
123+
/// Decimal arithmetic failures propagated from monetary-flow
124+
/// computations (overflow, division by zero, conversion).
125+
///
126+
/// Produced when a checked Decimal helper in `model::decimal`
127+
/// surfaces a [`crate::error::DecimalError`] from inside a
128+
/// `Position` method (for example `pnl_at_expiration` or
129+
/// `unrealized_pnl`). Propagated transparently via the
130+
/// `#[from]` cascade so callers keep matching `PositionError`.
131+
#[error(transparent)]
132+
DecimalError(#[from] crate::error::DecimalError),
122133
}
123134

124135
/// Specific errors that can occur in strategy operations

src/error/volatility.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ pub enum VolatilityError {
126126
/// Positive value errors
127127
#[error(transparent)]
128128
PositiveError(#[from] positive::PositiveError),
129+
130+
/// Decimal arithmetic failures propagated from monetary-flow
131+
/// computations in volatility kernels (overflow, division by
132+
/// zero, conversion).
133+
///
134+
/// Produced when a checked Decimal helper in `model::decimal`
135+
/// surfaces a [`crate::error::DecimalError`] from inside a
136+
/// volatility routine (for example `constant_volatility`,
137+
/// `ewma_volatility`, or `garch_volatility`). Propagated
138+
/// transparently via the `#[from]` cascade so callers keep
139+
/// matching `VolatilityError`.
140+
#[error(transparent)]
141+
DecimalError(#[from] crate::error::DecimalError),
129142
}
130143

131144
impl From<crate::error::ChainError> for VolatilityError {

src/greeks/equations.rs

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use crate::constants::{TRADING_DAYS, ZERO};
77
use crate::error::greeks::GreeksError;
88
use crate::greeks::utils::{big_n, d1, d2, n};
9+
use crate::model::decimal::{d_div, d_mul};
910
use crate::model::types::{OptionStyle, OptionType};
1011
use crate::{Options, Side};
1112
use positive::Positive;
@@ -838,8 +839,17 @@ pub fn theta(option: &Options) -> Result<Decimal, GreeksError> {
838839
}
839840
};
840841

841-
// Adjust for quantity and convert to daily value
842-
Ok((theta * option.quantity.to_dec()) / Decimal::from(365))
842+
// Adjust for quantity and convert to daily value (banker's-rounded annualisation).
843+
let weighted = d_mul(
844+
theta,
845+
option.quantity.to_dec(),
846+
"greeks::theta::position_weighted",
847+
)?;
848+
Ok(d_div(
849+
weighted,
850+
Decimal::from(365),
851+
"greeks::theta::per_day",
852+
)?)
843853
}
844854

845855
/// Computes the vega of an option.
@@ -1102,8 +1112,17 @@ pub fn rho(option: &Options) -> Result<Decimal, GreeksError> {
11021112
}
11031113
};
11041114

1105-
// Adjust for quantity and convert to basis points
1106-
Ok((rho * option.quantity.to_dec()) / Decimal::from(100))
1115+
// Adjust for quantity and convert to basis points (banker's rounding).
1116+
let weighted = d_mul(
1117+
rho,
1118+
option.quantity.to_dec(),
1119+
"greeks::rho::position_weighted",
1120+
)?;
1121+
Ok(d_div(
1122+
weighted,
1123+
Decimal::from(100),
1124+
"greeks::rho::per_basis_point",
1125+
)?)
11071126
}
11081127

11091128
/// Computes the sensitivity of the option price to changes in the dividend yield (Rho_d).
@@ -1235,7 +1254,12 @@ pub fn rho_d(option: &Options) -> Result<Decimal, GreeksError> {
12351254
};
12361255

12371256
let quantity: Decimal = option.quantity.into();
1238-
Ok(rhod * quantity / Decimal::from(100))
1257+
let weighted = d_mul(rhod, quantity, "greeks::rho_d::position_weighted")?;
1258+
Ok(d_div(
1259+
weighted,
1260+
Decimal::from(100),
1261+
"greeks::rho_d::per_basis_point",
1262+
)?)
12391263
}
12401264

12411265
pub fn alpha(option: &Options) -> Result<Decimal, GreeksError> {
@@ -1767,8 +1791,17 @@ pub fn charm(option: &Options) -> Result<Decimal, GreeksError> {
17671791
(-q * exp_minus_qt * big_n(-d1)?) - (exp_minus_qt * n(d1)? * common_term)
17681792
}
17691793
};
1770-
// Adjust for quantity and convert to daily value
1771-
Ok((charm * option.quantity) / Decimal::from(365))
1794+
// Adjust for quantity and convert to daily value.
1795+
let weighted = d_mul(
1796+
charm,
1797+
option.quantity.to_dec(),
1798+
"greeks::charm::position_weighted",
1799+
)?;
1800+
Ok(d_div(
1801+
weighted,
1802+
Decimal::from(365),
1803+
"greeks::charm::per_day",
1804+
)?)
17721805
}
17731806

17741807
/// Computes the Color of an option.
@@ -1902,7 +1935,22 @@ pub fn color(option: &Options) -> Result<Decimal, GreeksError> {
19021935
let numerator = (Decimal::TWO * (r - q) * tau) - (d2 * sigma * tau.sqrt());
19031936
let denominator = sigma * tau.sqrt();
19041937
let factor2 = (Decimal::TWO * q * tau) + Decimal::ONE + ((numerator / denominator) * d1);
1905-
let color = (-exp_minus_qt * factor1 * factor2 * option.quantity) / Decimal::from(365);
1938+
// Build the color numerator with checked multiplications so an
1939+
// overflow on `-exp(-qt) * factor1 * factor2 * quantity` surfaces
1940+
// a tagged `DecimalError::Overflow` instead of silently saturating
1941+
// before the final checked `d_div(/, 365)`.
1942+
let numerator = d_mul(
1943+
-exp_minus_qt,
1944+
factor1,
1945+
"greeks::color::numerator_exp_factor1",
1946+
)?;
1947+
let numerator = d_mul(numerator, factor2, "greeks::color::numerator_factor2")?;
1948+
let numerator = d_mul(
1949+
numerator,
1950+
option.quantity.to_dec(),
1951+
"greeks::color::numerator_quantity",
1952+
)?;
1953+
let color = d_div(numerator, Decimal::from(365), "greeks::color::per_day")?;
19061954
Ok(color)
19071955
}
19081956

0 commit comments

Comments
 (0)