Skip to main content

rustyqlib/equity/
greeks.rs

1//! The central sensitivity engine: one implementation of bump-and-reprice
2//! Greeks, one batch entry point, engine-native fast paths.
3//!
4//! Every Greek request routes through here. Engines fall into four routes:
5//!
6//! - **Grid** (finite difference): delta/gamma/theta are read off the
7//!   solved grid; the higher orders difference whole grid *solutions*
8//!   (vanna = d(grid delta)/dσ), which is smoother than price stencils.
9//! - **Tree** (binomial): same idea on the lattice — value and
10//!   delta/gamma/theta from one backward pass, higher orders from bumped
11//!   tree solutions ([`binomial::pricing_result`] shares seven passes).
12//! - **Analytic** (Black-Scholes engine): the payoff-aware
13//!   [`BlackScholesPricer`] with closed forms where they exist (vanilla
14//!   vanna/charm/zomma/volga, the Black-76 futures family).
15//! - **Bump**: everything else (Monte Carlo, Barone-Adesi-Whaley,
16//!   Bjerksund-Stensland, analytic Heston) shares the *one* set of
17//!   central-difference stencils below over
18//!   [`EquityOption::price_with`] — the engine's repricing kernel, which
19//!   guarantees common random numbers under Monte Carlo. What used to be
20//!   four hand-written copies of every stencil now differs only in a
21//!   [`BumpPolicy`]: a per-engine table of bump sizes (preserved exactly,
22//!   so values are bit-identical to the former per-engine code).
23//!
24//! The batch entry point [`pricing_result`] shares evaluations across
25//! Greeks through a reprice cache: one Monte Carlo `price()` costs 17
26//! simulations instead of 28, one finite-difference `price()` costs 9
27//! solves instead of ~16.
28//!
29//! Within the bump route, engines expose **native fast paths** where
30//! their structure allows a better estimator than a stencil:
31//! Monte Carlo pathwise delta/vega on the terminal-GBM route
32//! ([`montecarlo::pathwise_delta_vega`] — one simulation, no
33//! finite-difference bias), the **adjoint (AAD) sweep** on the
34//! path-simulation routes with continuous payoffs
35//! ([`montecarlo::aad_greeks`] — delta, vega and rho from one backward
36//! pass per path over the [`core::aad`](crate::core::aad) tape), the
37//! Heston vanilla delta read off the price integration
38//! ([`heston::native_vanilla_delta`]), and the Barone-Adesi-Whaley
39//! boundary solve shared across the spot ladder ([`baw::SpotKernel`],
40//! bit-identical values).
41
42use std::collections::HashMap;
43
44use crate::core::results::{Greeks, PricingResult};
45use crate::equity::blackscholes::BlackScholesPricer;
46use crate::equity::utils::PricingEngine;
47use crate::equity::vanilla_option::EquityOption;
48use crate::equity::{baw, binomial, finite_difference, heston, montecarlo};
49
50// ── Bump policies ───────────────────────────────────────────────────────
51
52/// Central-difference bump sizes for one engine. Sizes are inherited from
53/// the engines' historical per-Greek choices (larger steps where the
54/// kernel is noisier), so consolidating did not move any number.
55#[derive(Debug, Clone, Copy)]
56struct BumpPolicy {
57    /// Spot bump for delta, vanna and charm.
58    hs1: f64,
59    /// Spot bump for gamma and the zomma inner stencil (second
60    /// differences want a larger step).
61    hs2: f64,
62    /// Vol bump for vega, vanna and the zomma outer stencil.
63    hv: f64,
64    /// Vol bump for volga.
65    hv_volga: f64,
66    /// Rate bump for rho.
67    hr: f64,
68    /// Maturity bump for theta and charm.
69    ht: f64,
70}
71
72fn maturity_bump(option: &EquityOption) -> f64 {
73    (1.0 / 365.0_f64).min(0.5 * option.time_to_maturity())
74}
75
76/// How Greeks are produced for this option's engine.
77enum Route {
78    Grid,
79    Tree,
80    Analytic,
81    Bump(BumpPolicy),
82}
83
84fn route(option: &EquityOption) -> Route {
85    match option.engine {
86        PricingEngine::MonteCarlo(_) => {
87            let s = option.market.spot.value();
88            Route::Bump(BumpPolicy {
89                hs1: s * 0.01,
90                hs2: s * 0.01,
91                hv: 0.01,
92                hv_volga: 0.01,
93                hr: 1e-4,
94                ht: maturity_bump(option),
95            })
96        }
97        PricingEngine::FiniteDifference(_) => Route::Grid,
98        PricingEngine::BaroneAdesiWhaley | PricingEngine::BjerksundStensland => {
99            // the American approximations are smooth in the escrowed spot
100            let s = option.effective_spot();
101            Route::Bump(BumpPolicy {
102                hs1: s * 1e-4,
103                hs2: s * 1e-4,
104                hv: 1e-4,
105                hv_volga: 1e-3,
106                hr: 1e-4,
107                ht: maturity_bump(option),
108            })
109        }
110        _ if option.analytic_heston() => {
111            let s = option.market.spot.value();
112            Route::Bump(BumpPolicy {
113                hs1: s * 1e-4,
114                hs2: s * 1e-3,
115                hv: 1e-4,
116                hv_volga: 1e-2,
117                hr: 1e-5,
118                ht: maturity_bump(option),
119            })
120        }
121        PricingEngine::Binomial(_) => Route::Tree,
122        _ => Route::Analytic,
123    }
124}
125
126// ── The cached repricer ─────────────────────────────────────────────────
127
128/// Memoized shifted reprices of one option, in the **maturity-shift**
129/// convention: `v(ds, dv, dr, dt)` values the option with maturity
130/// extended by `dt` (the stencils below read like the textbook formulas).
131/// [`EquityOption::price_with`] takes elapsed calendar time, hence the
132/// sign flip.
133///
134/// On the Barone-Adesi-Whaley engine the repricer additionally caches the
135/// spot-independent boundary work per `(dv, dr, dt)` shift
136/// ([`baw::SpotKernel`]): the delta/gamma spot ladder solves the critical
137/// price once instead of once per evaluation, with bit-identical values.
138struct Repricer<'a> {
139    option: &'a EquityOption,
140    cache: HashMap<[u64; 4], f64>,
141    /// `Some` on the BAW engine: boundary kernels keyed by (dv, dr, dt).
142    baw_kernels: Option<HashMap<[u64; 3], baw::SpotKernel>>,
143}
144
145impl<'a> Repricer<'a> {
146    fn new(option: &'a EquityOption) -> Self {
147        let baw_kernels = matches!(option.engine, PricingEngine::BaroneAdesiWhaley)
148            .then(HashMap::new);
149        Repricer { option, cache: HashMap::new(), baw_kernels }
150    }
151
152    fn v(&mut self, ds: f64, dv: f64, dr: f64, dt: f64) -> f64 {
153        let key = [ds.to_bits(), dv.to_bits(), dr.to_bits(), dt.to_bits()];
154        if let Some(&cached) = self.cache.get(&key) {
155            return cached;
156        }
157        let value = match &mut self.baw_kernels {
158            Some(kernels) => {
159                let kernel = kernels
160                    .entry([dv.to_bits(), dr.to_bits(), dt.to_bits()])
161                    .or_insert_with(|| baw::SpotKernel::new(self.option, dv, dr, dt));
162                kernel.value(self.option.effective_spot() + ds)
163            }
164            None => self.option.price_with(ds, dv, dr, -dt),
165        };
166        self.cache.insert(key, value);
167        value
168    }
169}
170
171// ── The stencils (written once) ─────────────────────────────────────────
172
173fn bump_delta(r: &mut Repricer, p: &BumpPolicy) -> f64 {
174    let h = p.hs1;
175    (r.v(h, 0.0, 0.0, 0.0) - r.v(-h, 0.0, 0.0, 0.0)) / (2.0 * h)
176}
177
178fn bump_gamma(r: &mut Repricer, p: &BumpPolicy) -> f64 {
179    let h = p.hs2;
180    (r.v(h, 0.0, 0.0, 0.0) - 2.0 * r.v(0.0, 0.0, 0.0, 0.0) + r.v(-h, 0.0, 0.0, 0.0)) / (h * h)
181}
182
183fn bump_vega(r: &mut Repricer, p: &BumpPolicy) -> f64 {
184    let h = p.hv;
185    (r.v(0.0, h, 0.0, 0.0) - r.v(0.0, -h, 0.0, 0.0)) / (2.0 * h)
186}
187
188fn bump_theta(r: &mut Repricer, p: &BumpPolicy) -> f64 {
189    // calendar theta = dV/dt = -dV/dT
190    let h = p.ht;
191    -(r.v(0.0, 0.0, 0.0, h) - r.v(0.0, 0.0, 0.0, -h)) / (2.0 * h)
192}
193
194fn bump_rho(r: &mut Repricer, p: &BumpPolicy) -> f64 {
195    let h = p.hr;
196    (r.v(0.0, 0.0, h, 0.0) - r.v(0.0, 0.0, -h, 0.0)) / (2.0 * h)
197}
198
199fn bump_vanna(r: &mut Repricer, p: &BumpPolicy) -> f64 {
200    let (hs, hv) = (p.hs1, p.hv);
201    (r.v(hs, hv, 0.0, 0.0) - r.v(-hs, hv, 0.0, 0.0) - r.v(hs, -hv, 0.0, 0.0)
202        + r.v(-hs, -hv, 0.0, 0.0))
203        / (4.0 * hs * hv)
204}
205
206fn bump_charm(r: &mut Repricer, p: &BumpPolicy) -> f64 {
207    // charm = d(delta)/dt = -d(delta)/dT
208    let (hs, ht) = (p.hs1, p.ht);
209    -(r.v(hs, 0.0, 0.0, ht) - r.v(-hs, 0.0, 0.0, ht) - r.v(hs, 0.0, 0.0, -ht)
210        + r.v(-hs, 0.0, 0.0, -ht))
211        / (4.0 * hs * ht)
212}
213
214fn bump_zomma(r: &mut Repricer, p: &BumpPolicy) -> f64 {
215    let (hs, hv) = (p.hs2, p.hv);
216    let gamma_at = |r: &mut Repricer, dv: f64| {
217        (r.v(hs, dv, 0.0, 0.0) - 2.0 * r.v(0.0, dv, 0.0, 0.0) + r.v(-hs, dv, 0.0, 0.0))
218            / (hs * hs)
219    };
220    (gamma_at(r, hv) - gamma_at(r, -hv)) / (2.0 * hv)
221}
222
223fn bump_volga(r: &mut Repricer, p: &BumpPolicy) -> f64 {
224    let h = p.hv_volga;
225    (r.v(0.0, h, 0.0, 0.0) - 2.0 * r.v(0.0, 0.0, 0.0, 0.0) + r.v(0.0, -h, 0.0, 0.0)) / (h * h)
226}
227
228// ── Single-Greek entry points (the accessors' backend) ──────────────────
229
230macro_rules! greek {
231    ($name:ident, $stencil:ident, $grid:path, $tree:path, $analytic:ident) => {
232        pub fn $name(option: &EquityOption) -> f64 {
233            match route(option) {
234                Route::Grid => $grid(option),
235                Route::Tree => $tree(option),
236                Route::Analytic => BlackScholesPricer::new().$analytic(option),
237                Route::Bump(p) => $stencil(&mut Repricer::new(option), &p),
238            }
239        }
240    };
241}
242
243greek!(gamma, bump_gamma, finite_difference::gamma, binomial::gamma, gamma);
244greek!(theta, bump_theta, finite_difference::theta, binomial::theta, theta);
245greek!(vanna, bump_vanna, finite_difference::vanna, binomial::vanna, vanna);
246
247// ── Engine-native fast paths inside the bump route ──────────────────────
248//
249// Better estimators than the stencils where the engine's structure allows:
250// Monte Carlo pathwise delta/vega on the one-step terminal route, the
251// adjoint (AAD) sweep for delta/vega/rho on the path-simulation routes
252// with continuous payoffs, and the Heston vanilla delta that falls out of
253// the price integration. Fall through to the stencils everywhere else.
254
255fn native_delta(option: &EquityOption) -> Option<f64> {
256    match option.engine {
257        PricingEngine::MonteCarlo(_) => montecarlo::pathwise_delta_vega(option)
258            .map(|(delta, _)| delta)
259            .or_else(|| montecarlo::aad_greeks(option).map(|g| g.delta)),
260        _ if option.analytic_heston() => heston::native_vanilla_delta(option),
261        _ => None,
262    }
263}
264
265fn native_vega(option: &EquityOption) -> Option<f64> {
266    match option.engine {
267        PricingEngine::MonteCarlo(_) => montecarlo::pathwise_delta_vega(option)
268            .map(|(_, vega)| vega)
269            .or_else(|| montecarlo::aad_greeks(option).map(|g| g.vega)),
270        _ => None,
271    }
272}
273
274fn native_rho(option: &EquityOption) -> Option<f64> {
275    match option.engine {
276        // the one-step terminal route keeps the (cheaper) bump stencil;
277        // the adjoint sweep covers the path routes
278        PricingEngine::MonteCarlo(_)
279            if montecarlo::pathwise_delta_vega(option).is_none() =>
280        {
281            montecarlo::aad_greeks(option).map(|g| g.rho)
282        }
283        _ => None,
284    }
285}
286
287pub fn delta(option: &EquityOption) -> f64 {
288    match route(option) {
289        Route::Grid => finite_difference::delta(option),
290        Route::Tree => binomial::delta(option),
291        Route::Analytic => BlackScholesPricer::new().delta(option),
292        Route::Bump(p) => native_delta(option)
293            .unwrap_or_else(|| bump_delta(&mut Repricer::new(option), &p)),
294    }
295}
296
297pub fn vega(option: &EquityOption) -> f64 {
298    match route(option) {
299        Route::Grid => finite_difference::vega(option),
300        Route::Tree => binomial::vega(option),
301        Route::Analytic => BlackScholesPricer::new().vega(option),
302        Route::Bump(p) => native_vega(option)
303            .unwrap_or_else(|| bump_vega(&mut Repricer::new(option), &p)),
304    }
305}
306
307pub fn rho(option: &EquityOption) -> f64 {
308    match route(option) {
309        Route::Grid => finite_difference::rho(option),
310        Route::Tree => binomial::rho(option),
311        Route::Analytic => BlackScholesPricer::new().rho(option),
312        Route::Bump(p) => native_rho(option)
313            .unwrap_or_else(|| bump_rho(&mut Repricer::new(option), &p)),
314    }
315}
316greek!(charm, bump_charm, finite_difference::charm, binomial::charm, charm);
317greek!(zomma, bump_zomma, finite_difference::zomma, binomial::zomma, zomma);
318greek!(volga, bump_volga, finite_difference::volga, binomial::volga, volga);
319
320/// Delta elasticity `S * gamma / delta`; `NaN` when delta is zero.
321pub fn gamma_p(option: &EquityOption) -> f64 {
322    let delta = delta(option);
323    if delta == 0.0 {
324        f64::NAN
325    } else {
326        option.market.spot.value() * gamma(option) / delta
327    }
328}
329
330// ── The batch entry point ───────────────────────────────────────────────
331
332/// Value plus all reported Greeks, sharing work across them:
333/// grid/tree engines harvest delta/gamma/theta from the base solve, and
334/// bump engines reuse every shifted reprice that appears in more than one
335/// stencil through the [`Repricer`] cache.
336pub fn pricing_result(option: &EquityOption) -> PricingResult {
337    match route(option) {
338        Route::Tree => binomial::pricing_result(option),
339        Route::Grid => finite_difference::pricing_result(option),
340        Route::Analytic => {
341            let pricer = BlackScholesPricer::new();
342            PricingResult {
343                pv: pricer.npv(option),
344                greeks: Greeks {
345                    delta: pricer.delta(option),
346                    gamma: pricer.gamma(option),
347                    vega: pricer.vega(option),
348                    theta: pricer.theta(option),
349                    rho: pricer.rho(option),
350                    vanna: pricer.vanna(option),
351                    charm: pricer.charm(option),
352                    gamma_p: pricer.gamma_p(option),
353                    zomma: pricer.zomma(option),
354                },
355                std_err: None,
356            }
357        }
358        Route::Bump(p) => {
359            let (pv, std_err) = match option.engine {
360                PricingEngine::MonteCarlo(_) => {
361                    let stats = montecarlo::npv_with_stats(option);
362                    (stats.pv, Some(stats.std_err))
363                }
364                _ => (option.price_with(0.0, 0.0, 0.0, 0.0), None),
365            };
366            // one pathwise pass serves delta and vega under MC; one
367            // adjoint sweep serves delta, vega AND rho on the path routes
368            let pathwise = match option.engine {
369                PricingEngine::MonteCarlo(_) => montecarlo::pathwise_delta_vega(option),
370                _ => None,
371            };
372            let adjoint = match option.engine {
373                PricingEngine::MonteCarlo(_) if pathwise.is_none() => {
374                    montecarlo::aad_greeks(option)
375                }
376                _ => None,
377            };
378            let r = &mut Repricer::new(option);
379            let delta = pathwise
380                .map(|(delta, _)| delta)
381                .or(adjoint.map(|g| g.delta))
382                .or_else(|| {
383                    option.analytic_heston().then(|| heston::native_vanilla_delta(option)).flatten()
384                })
385                .unwrap_or_else(|| bump_delta(r, &p));
386            let vega = pathwise
387                .map(|(_, vega)| vega)
388                .or(adjoint.map(|g| g.vega))
389                .unwrap_or_else(|| bump_vega(r, &p));
390            let rho = adjoint.map(|g| g.rho).unwrap_or_else(|| bump_rho(r, &p));
391            let gamma = bump_gamma(r, &p);
392            let gamma_p = if delta == 0.0 {
393                f64::NAN
394            } else {
395                option.market.spot.value() * gamma / delta
396            };
397            PricingResult {
398                pv,
399                greeks: Greeks {
400                    delta,
401                    gamma,
402                    vega,
403                    theta: bump_theta(r, &p),
404                    rho,
405                    vanna: bump_vanna(r, &p),
406                    charm: bump_charm(r, &p),
407                    gamma_p,
408                    zomma: bump_zomma(r, &p),
409                },
410                std_err,
411            }
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::core::trade::PutOrCall;
420    use crate::core::traits::Instrument;
421    use crate::equity::builder::EquityOptionBuilder;
422    use crate::equity::utils::{Engine, Model};
423    use chrono::NaiveDate;
424
425    fn option(engine: Engine, put_or_call: PutOrCall) -> EquityOption {
426        EquityOptionBuilder::new()
427            .symbol("ACME")
428            .spot(100.0)
429            .strike(100.0)
430            .flat_vol(0.25)
431            .flat_rate(0.03)
432            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
433            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
434            .vanilla(put_or_call)
435            .engine(engine)
436            .build()
437            .expect("option must build")
438    }
439
440    #[test]
441    fn mc_pathwise_delta_and_vega_match_the_analytic_values() {
442        for pc in [PutOrCall::Call, PutOrCall::Put] {
443            let mc = option(Engine::MonteCarlo, pc);
444            let bs = option(Engine::BlackScholes, pc);
445            // the pathwise estimator is live for this option
446            let (d, v) = montecarlo::pathwise_delta_vega(&mc).expect("pathwise must apply");
447            assert_eq!(d, mc.delta(), "accessor must use the pathwise estimator");
448            assert_eq!(v, mc.vega(), "accessor must use the pathwise estimator");
449            // and agrees with the closed form (QMC terminal simulation)
450            assert!((d - bs.delta()).abs() < 5e-3, "{pc:?} delta {d} vs {}", bs.delta());
451            assert!((v - bs.vega()).abs() < 0.2, "{pc:?} vega {v} vs {}", bs.vega());
452            // batch equals accessors exactly
453            let result = mc.price().unwrap();
454            assert_eq!(result.greeks.delta, d);
455            assert_eq!(result.greeks.vega, v);
456        }
457    }
458
459    #[test]
460    fn mc_pathwise_declines_out_of_scope_and_the_adjoint_takes_over() {
461        // multi-step path simulation: the terminal pathwise estimator
462        // does not apply; the AAD sweep covers the route instead
463        let mut mc = option(Engine::MonteCarlo, PutOrCall::Call);
464        if let PricingEngine::MonteCarlo(cfg) = &mut mc.engine {
465            cfg.time_steps = 12;
466        }
467        assert!(montecarlo::pathwise_delta_vega(&mc).is_none());
468        let adjoint = montecarlo::aad_greeks(&mc).expect("AAD must cover multi-step vanilla");
469        assert_eq!(mc.delta(), adjoint.delta, "accessor must use the adjoint estimator");
470        assert_eq!(mc.vega(), adjoint.vega);
471        assert_eq!(mc.rho(), adjoint.rho);
472        // one sweep agrees with the closed forms
473        let bs = option(Engine::BlackScholes, PutOrCall::Call);
474        assert!((adjoint.delta - bs.delta()).abs() < 1e-2, "{} vs {}", adjoint.delta, bs.delta());
475        assert!((adjoint.vega - bs.vega()).abs() < 0.5, "{} vs {}", adjoint.vega, bs.vega());
476        assert!((adjoint.rho - bs.rho()).abs() < 0.5, "{} vs {}", adjoint.rho, bs.rho());
477        // batch equals accessors exactly
478        let result = mc.price().unwrap();
479        assert_eq!(result.greeks.delta, adjoint.delta);
480        assert_eq!(result.greeks.vega, adjoint.vega);
481        assert_eq!(result.greeks.rho, adjoint.rho);
482    }
483
484    #[test]
485    fn aad_covers_continuous_path_dependents_and_declines_discontinuous() {
486        use crate::core::utils::ContractStyle;
487        use crate::equity::asian::{AsianStrikeType, AveragingType};
488        use crate::equity::vanilla_option::AsianPayoff;
489        // arithmetic fixed-strike Asian: continuous payoff, AAD applies
490        let mut asian = option(Engine::MonteCarlo, PutOrCall::Call);
491        asian.payoff = Box::new(AsianPayoff {
492            put_or_call: PutOrCall::Call,
493            exercise_style: ContractStyle::European,
494            averaging: AveragingType::Arithmetic,
495            strike_type: AsianStrikeType::FixedStrike,
496        });
497        let adjoint = montecarlo::aad_greeks(&asian).expect("AAD must cover Asians");
498        assert_eq!(asian.delta(), adjoint.delta);
499        // the adjoint agrees with the CRN bump stencil on the same option
500        let h = asian.market.spot.value() * 0.01;
501        let bump = (asian.price_with(h, 0.0, 0.0, 0.0) - asian.price_with(-h, 0.0, 0.0, 0.0))
502            / (2.0 * h);
503        assert!((adjoint.delta - bump).abs() < 0.03, "adjoint {} vs bump {bump}", adjoint.delta);
504        assert!(adjoint.vega > 0.0 && adjoint.rho > 0.0);
505
506        // a barrier's indicator has zero almost-everywhere derivative:
507        // it must never opt into AAD, the bump stencils keep it
508        use crate::equity::barrier::{BarrierDirection, KnockType};
509        use crate::equity::vanilla_option::BarrierPayoff;
510        let mut barrier = option(Engine::MonteCarlo, PutOrCall::Call);
511        barrier.payoff = Box::new(BarrierPayoff {
512            put_or_call: PutOrCall::Call,
513            exercise_style: ContractStyle::European,
514            direction: BarrierDirection::Up,
515            knock: KnockType::Out,
516            barrier: 130.0,
517            barrier2: None,
518            rebate: 0.0,
519            rebate_at_hit: false,
520        });
521        assert!(montecarlo::aad_greeks(&barrier).is_none());
522    }
523
524    #[test]
525    fn heston_native_delta_matches_the_bump_stencil() {
526        use crate::equity::heston::HestonParams;
527        let params =
528            HestonParams { v0: 0.0625, kappa: 1.5, theta: 0.0625, vol_of_vol: 0.4, rho: -0.6 };
529        let mut call = option(Engine::BlackScholes, PutOrCall::Call);
530        call.model = Model::Heston(params);
531        let mut put = option(Engine::BlackScholes, PutOrCall::Put);
532        put.model = Model::Heston(params);
533        // native delta agrees with the old central-difference stencil
534        let h = call.market.spot.value() * 1e-4;
535        let stencil =
536            (call.price_with(h, 0.0, 0.0, 0.0) - call.price_with(-h, 0.0, 0.0, 0.0)) / (2.0 * h);
537        assert!(
538            (call.delta() - stencil).abs() < 1e-6,
539            "native {} vs stencil {stencil}",
540            call.delta()
541        );
542        // put-call delta parity: delta_C - delta_P = e^{-qT} (here q = 0)
543        assert!((call.delta() - put.delta() - 1.0).abs() < 1e-9);
544        // batch equals accessor exactly
545        assert_eq!(call.price().unwrap().greeks.delta, call.delta());
546    }
547
548    #[test]
549    fn baw_boundary_kernel_is_bit_identical_to_the_direct_reprice() {
550        // American put: the early-exercise boundary is live
551        let baw_put = EquityOptionBuilder::new()
552            .symbol("ACME")
553            .spot(100.0)
554            .strike(100.0)
555            .flat_vol(0.25)
556            .flat_rate(0.05)
557            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
558            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
559            .vanilla(PutOrCall::Put)
560            .american()
561            .engine(Engine::BaroneAdesiWhaley)
562            .build()
563            .expect("option must build");
564        // the kernel path (used by delta/gamma) must reproduce the direct
565        // price_with stencil bit for bit — sharing the boundary solve is a
566        // pure speed optimization
567        let h = baw_put.effective_spot() * 1e-4;
568        let direct_delta = (baw_put.price_with(h, 0.0, 0.0, 0.0)
569            - baw_put.price_with(-h, 0.0, 0.0, 0.0))
570            / (2.0 * h);
571        assert_eq!(baw_put.delta(), direct_delta);
572        let direct_gamma = (baw_put.price_with(h, 0.0, 0.0, 0.0)
573            - 2.0 * baw_put.price_with(0.0, 0.0, 0.0, 0.0)
574            + baw_put.price_with(-h, 0.0, 0.0, 0.0))
575            / (h * h);
576        assert_eq!(baw_put.gamma(), direct_gamma);
577    }
578}