Skip to content

Commit dcbcce6

Browse files
userFRMclaude
andauthored
fix(tdbe): reject non-positive spot/strike and size u8 layout fields correctly (#812)
Black-Scholes is undefined for spot <= 0 or strike <= 0: the (spot / strike).ln() term goes non-finite and strike == 0 is an outright divide-by-zero. The fallible public entry points all_greeks and implied_volatility now reject a non-positive (or non-finite) spot or strike with Error::Config naming the offending value, so no SDK, CLI, or MCP surface can serialise NaN or null Greeks. The infallible compute_full_bundle_with_iv treats the same inputs as degenerate and returns an all-zero bundle rather than emitting NaN. The FPSS C-layout generator sized u8 fields as 4/4 by sharing the i32 match arm. The arm is split so u8 is 1/1 and i32 is 4/4, which keeps the generated offset static_assert correct for any future u8-before-smaller-aligned field; the build.rs run that emits the asserts still compiles. Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab8e6c4 commit dcbcce6

2 files changed

Lines changed: 161 additions & 3 deletions

File tree

crates/thetadatadx/build_support_bin/fpss_events/layout.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ pub(super) struct CFieldLayout {
1717
/// the pair before computing offsets.
1818
pub(super) fn c_field_layout(ty: &str) -> CFieldLayout {
1919
match ty {
20-
"i32" | "u8" => CFieldLayout { size: 4, align: 4 },
20+
"u8" => CFieldLayout { size: 1, align: 1 },
21+
"i32" => CFieldLayout { size: 4, align: 4 },
2122
"i64" | "u64" | "f64" => CFieldLayout { size: 8, align: 8 },
2223
// String → `const char*` borrowed pointer.
2324
"String" => CFieldLayout { size: 8, align: 8 },

crates/thetadatadx/src/tdbe/greeks.rs

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,45 @@ fn is_degenerate(v: f64, t: f64) -> bool {
5252
t <= 0.0 || v <= 0.0
5353
}
5454

55+
/// Return true if spot or strike make Black-Scholes degenerate. A
56+
/// non-positive spot or strike makes `(s / x).ln()` non-finite (and
57+
/// `x == 0.0` an outright divide-by-zero), so the bundle path treats
58+
/// these as degenerate to keep every Greek finite.
59+
#[inline]
60+
fn is_price_degenerate(s: f64, x: f64) -> bool {
61+
!is_positive(s) || !is_positive(x)
62+
}
63+
64+
/// Return true only for a strictly positive, finite-comparable value.
65+
/// `NaN` is not positive, so it reports `false` and routes to the
66+
/// degenerate path instead of poisoning downstream arithmetic.
67+
#[inline]
68+
fn is_positive(value: f64) -> bool {
69+
value > 0.0
70+
}
71+
72+
/// Reject a non-positive spot or strike at a fallible public entry point.
73+
///
74+
/// Black-Scholes is undefined for `spot <= 0` or `strike <= 0`: the
75+
/// `(spot / strike).ln()` term goes non-finite and `strike == 0` is an
76+
/// outright divide-by-zero. Rejecting here keeps every SDK / CLI / MCP
77+
/// surface from serialising NaN or `null` Greeks.
78+
// Reason: s, x are the standard Black-Scholes spot/strike parameter names.
79+
#[allow(clippy::many_single_char_names)]
80+
fn reject_nonpositive_price(s: f64, x: f64) -> Result<(), crate::tdbe::Error> {
81+
if !is_positive(s) {
82+
return Err(crate::tdbe::Error::Config(format!(
83+
"spot must be strictly positive, got {s}"
84+
)));
85+
}
86+
if !is_positive(x) {
87+
return Err(crate::tdbe::Error::Config(format!(
88+
"strike must be strictly positive, got {x}"
89+
)));
90+
}
91+
Ok(())
92+
}
93+
5594
/// Standard normal CDF approximation (Zelen & Severo, 1964).
5695
///
5796
/// Uses Horner's method for polynomial evaluation: 4 fused multiply-adds instead
@@ -451,7 +490,9 @@ pub fn dual_gamma(s: f64, x: f64, v: f64, r: f64, q: f64, t: f64) -> f64 {
451490
/// # Errors
452491
///
453492
/// Returns [`crate::greeks::Error::Config`] if `right` is not one of the accepted
454-
/// forms or resolves to `both`/`*`. Strict-parse failures from
493+
/// forms or resolves to `both`/`*`, or if `spot`/`strike` is non-positive
494+
/// (Black-Scholes requires `spot > 0` and `strike > 0`; `strike == 0` is an
495+
/// outright divide-by-zero). Strict-parse failures from
455496
/// [`crate::tdbe::right::parse_right_strict`] surface here directly.
456497
// Reason: s, x, r, q, t are standard Black-Scholes parameter names.
457498
#[allow(clippy::many_single_char_names)]
@@ -471,6 +512,7 @@ pub fn implied_volatility(
471512
"option right '{right}' resolves to 'both' but a single side is required"
472513
))
473514
})?;
515+
reject_nonpositive_price(s, x)?;
474516
if t <= 0.0 || option_price <= 0.0 {
475517
return Ok((0.0, 0.0));
476518
}
@@ -618,7 +660,9 @@ pub struct GreeksResult {
618660
/// # Errors
619661
///
620662
/// Returns [`crate::greeks::Error::Config`] if `right` is not one of the accepted
621-
/// forms or resolves to `both`/`*`. Strict-parse failures from
663+
/// forms or resolves to `both`/`*`, or if `spot`/`strike` is non-positive
664+
/// (Black-Scholes requires `spot > 0` and `strike > 0`; `strike == 0` is an
665+
/// outright divide-by-zero). Strict-parse failures from
622666
/// [`crate::tdbe::right::parse_right_strict`] surface here directly.
623667
// Reason: s, x, r, q, t are standard Black-Scholes parameter names.
624668
// Reason: 23-Greek computation cannot be meaningfully split without duplicating intermediates.
@@ -643,6 +687,7 @@ pub fn all_greeks(
643687
"option right '{right}' resolves to 'both' but a single side is required"
644688
))
645689
})?;
690+
reject_nonpositive_price(s, x)?;
646691

647692
// Inline the IV solver to keep the `right` parse at this layer (avoids
648693
// a second parse that `implied_volatility(&str)` would otherwise do).
@@ -694,6 +739,12 @@ pub fn all_greeks(
694739
/// negative tenor) every Greek field is `0.0` except `value`,
695740
/// which is the intrinsic value. Mirrors [`all_greeks`]'s
696741
/// degenerate-branch semantics.
742+
///
743+
/// A non-positive (or non-finite) spot or strike is also degenerate:
744+
/// `(spot / strike).ln()` is non-finite and `strike == 0` divides by
745+
/// zero, so the whole bundle is returned all-zero rather than emitting
746+
/// NaN. The fallible [`all_greeks`] / [`implied_volatility`] entry points
747+
/// reject these inputs up front; this infallible helper clamps them.
697748
// Reason: s, x, v, r, q, t are standard Black-Scholes parameter names.
698749
#[allow(
699750
clippy::many_single_char_names,
@@ -711,6 +762,37 @@ pub fn compute_full_bundle_with_iv(
711762
t: f64,
712763
is_call: bool,
713764
) -> GreeksResult {
765+
// Guard: a non-positive/non-finite spot or strike makes the whole
766+
// surface non-finite (`(s / x).ln()`, and `x == 0` divides by zero),
767+
// so return an all-zero bundle rather than emitting NaN.
768+
if is_price_degenerate(s, x) {
769+
return GreeksResult {
770+
value: 0.0,
771+
delta: 0.0,
772+
gamma: 0.0,
773+
theta: 0.0,
774+
vega: 0.0,
775+
rho: 0.0,
776+
iv: v,
777+
iv_error: 0.0,
778+
vanna: 0.0,
779+
charm: 0.0,
780+
vomma: 0.0,
781+
veta: 0.0,
782+
vera: 0.0,
783+
speed: 0.0,
784+
zomma: 0.0,
785+
color: 0.0,
786+
ultima: 0.0,
787+
d1: 0.0,
788+
d2: 0.0,
789+
dual_delta: 0.0,
790+
dual_gamma: 0.0,
791+
epsilon: 0.0,
792+
lambda: 0.0,
793+
};
794+
}
795+
714796
// Guard: if vol or time is degenerate, return all zeros (except value = intrinsic).
715797
if is_degenerate(v, t) {
716798
return GreeksResult {
@@ -1248,6 +1330,81 @@ mod tests {
12481330
assert!(bundle.value.is_finite());
12491331
}
12501332

1333+
// -- Non-positive spot / strike rejection --
1334+
1335+
#[test]
1336+
fn all_greeks_errors_on_nonpositive_spot() {
1337+
for spot in [0.0, -1.0, -100.0] {
1338+
let err = all_greeks(spot, 100.0, 0.05, 0.01, 0.25, 5.0, "C").unwrap_err();
1339+
assert!(matches!(err, crate::tdbe::Error::Config(_)), "spot={spot}");
1340+
assert!(
1341+
err.to_string().contains("spot must be strictly positive"),
1342+
"spot={spot}: {err}"
1343+
);
1344+
}
1345+
}
1346+
1347+
#[test]
1348+
fn all_greeks_errors_on_nonpositive_strike() {
1349+
for strike in [0.0, -1.0, -100.0] {
1350+
let err = all_greeks(100.0, strike, 0.05, 0.01, 0.25, 5.0, "C").unwrap_err();
1351+
assert!(
1352+
matches!(err, crate::tdbe::Error::Config(_)),
1353+
"strike={strike}"
1354+
);
1355+
assert!(
1356+
err.to_string().contains("strike must be strictly positive"),
1357+
"strike={strike}: {err}"
1358+
);
1359+
}
1360+
}
1361+
1362+
#[test]
1363+
fn implied_volatility_errors_on_nonpositive_spot() {
1364+
for spot in [0.0, -1.0] {
1365+
let err = implied_volatility(spot, 100.0, 0.05, 0.01, 0.25, 5.0, "C").unwrap_err();
1366+
assert!(matches!(err, crate::tdbe::Error::Config(_)), "spot={spot}");
1367+
assert!(
1368+
err.to_string().contains("spot must be strictly positive"),
1369+
"spot={spot}: {err}"
1370+
);
1371+
}
1372+
}
1373+
1374+
#[test]
1375+
fn implied_volatility_errors_on_nonpositive_strike() {
1376+
for strike in [0.0, -1.0] {
1377+
let err = implied_volatility(100.0, strike, 0.05, 0.01, 0.25, 5.0, "C").unwrap_err();
1378+
assert!(
1379+
matches!(err, crate::tdbe::Error::Config(_)),
1380+
"strike={strike}"
1381+
);
1382+
assert!(
1383+
err.to_string().contains("strike must be strictly positive"),
1384+
"strike={strike}: {err}"
1385+
);
1386+
}
1387+
}
1388+
1389+
#[test]
1390+
fn compute_full_bundle_zeros_on_nonpositive_spot_or_strike() {
1391+
// The infallible bundle path must clamp non-positive spot/strike to
1392+
// an all-zero bundle rather than emitting NaN/Inf in any field.
1393+
for (s, x) in [(0.0, 100.0), (-1.0, 100.0), (100.0, 0.0), (100.0, -1.0)] {
1394+
let bundle = compute_full_bundle_with_iv(s, x, 0.20, 0.05, 0.01, 0.25, true);
1395+
assert_eq!(bundle.value, 0.0, "value s={s} x={x}");
1396+
assert_eq!(bundle.delta, 0.0, "delta s={s} x={x}");
1397+
assert_eq!(bundle.gamma, 0.0, "gamma s={s} x={x}");
1398+
assert_eq!(bundle.theta, 0.0, "theta s={s} x={x}");
1399+
assert_eq!(bundle.vega, 0.0, "vega s={s} x={x}");
1400+
assert_eq!(bundle.d1, 0.0, "d1 s={s} x={x}");
1401+
assert_eq!(bundle.d2, 0.0, "d2 s={s} x={x}");
1402+
// Every field stays finite (no NaN, no Inf).
1403+
assert_finite(bundle.dual_gamma, "dual_gamma");
1404+
assert_finite(bundle.lambda, "lambda");
1405+
}
1406+
}
1407+
12511408
// ---------------------------------------------------------------------------
12521409
// Property-based tests
12531410
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)