Skip to main content

finance_solution/derivatives/
black_scholes.rs

1//! # Black–Scholes–Merton European options
2//!
3//! Continuous dividend yield `q`. Call/put **prices**, first-order **Greeks**, and
4//! **cross Greeks** (vanna, volga, charm).
5//!
6//! ---
7//!
8//! ## Trading perspective — reading the Greeks
9//!
10//! | Greek | Sign (long call, typical) | How desks use it |
11//! |-------|---------------------------|------------------|
12//! | **Delta** | 0…1 | Hedge ratio; “30Δ call” screening; book Δ sum |
13//! | **Gamma** | ≥ 0 | Convexity; how often to re-hedge; pin risk near expiry |
14//! | **Vega** | ≥ 0 | IV long/short; event premium; surface relative value |
15//! | **Theta** | often ≤ 0 for long options | Overnight bleed; why short premium earns carry |
16//! | **Rho** | call ≥ 0, put ≤ 0 | Rate sensitivity; larger for long-dated |
17//! | **Vanna** | mixed | Δ change when IV moves; sticky-strike / sticky-delta stories |
18//! | **Volga** | ≥ 0 near ATM often | Convexity in vol; smile risk |
19//! | **Charm** | mixed | Δ bleed as time passes; overnight hedge drift |
20//!
21//! **Hedge sketch (per long call):** short ≈ `delta` shares of underlier to flatten Δ.
22//! Gamma makes that hedge wrong as spot moves — traders who are long gamma rebalance
23//! productively; short gamma pay for stability.
24//!
25//! **IV vs realized:** model vol is an *input*. Market IV is solved from mid
26//! ([`crate::derivatives::bsm_implied_vol`]). Comparing IV to realized vol (from
27//! [`crate::stocks`]) is a classic relative-value story — join those in *your* engine.
28//!
29//! ---
30//!
31//! ## Engineering perspective
32//!
33//! - Hot path: [`bsm_price`] / [`bsm_greeks`] or [`ValidatedBsm`] after one validate.  
34//! - Live tape: [`crate::derivatives::BsmState`] per contract.  
35//! - Teaching / audit: [`bsm_solution`] + [`BsmSolution::print_table`].  
36//! - Vega is per **+1.0** in σ; for “per vol point (1%)” use [`BsmGreeks::vega_per_vol_point`].  
37//! - Theta is per **year**; for daily use [`BsmGreeks::theta_per_calendar_day`].
38//!
39//! Limits handled explicitly: `T = 0` → intrinsic; `σ = 0` → discounted forward intrinsic.
40//!
41//! ---
42//!
43//! ## Word problem
44//!
45//! > Spot 100, strike 100, T=1y, r=5%, q=0, σ=20%. What is the BSM call roughly?
46//!
47//! Expect: about **10.45** (classic textbook ATM).
48//!
49//! ```
50//! use finance_solution::derivatives::{bsm_price, BsmParams, OptionType};
51//! let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
52//! let c = bsm_price(p, OptionType::Call).unwrap();
53//! assert!((c - 10.4506).abs() < 1e-3);
54//! ```
55//!
56//! ## Sample `bsm_solution` table
57//!
58//! ```text
59//! type    price   delta   gamma     vega    theta      rho
60//! ----  -------  ------  ------  -------  -------  -------
61//! Call  10.4506  0.6368  0.0188  37.5240  -6.4140  53.2325
62//! ```
63
64use crate::derivatives::norm::{norm_cdf, norm_pdf};
65use crate::derivatives::types::{
66    forward_moneyness, intrinsic, time_value, validate_bsm_params, BsmParams, OptionType,
67    ValidatedBsm,
68};
69use crate::util::error::FinanceResult;
70use crate::{columns_with_strings, print_table_locale_opt};
71
72/// First-order BSM Greeks.
73///
74/// # Units (critical)
75///
76/// | Field | Unit |
77/// |-------|------|
78/// | `delta` | ∂V/∂S (share equivalent per option) |
79/// | `gamma` | ∂²V/∂S² |
80/// | `vega` | ∂V/∂σ per **+1.0** absolute vol (not per 1%) |
81/// | `theta` | ∂V/∂T per **year** |
82/// | `rho` | ∂V/∂r per +1.0 absolute rate |
83///
84/// **Trading:** risk systems often show vega “per 1%” and theta “per day” — use the helpers
85/// below so you do not silently mis-scale P&amp;L.
86#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct BsmGreeks {
88    pub delta: f64,
89    pub gamma: f64,
90    /// Per +1.0 absolute vol (divide by 100 for “per vol point”).
91    pub vega: f64,
92    /// Calendar year basis (same time unit as `time_years`).
93    pub theta: f64,
94    pub rho: f64,
95}
96
97impl BsmGreeks {
98    /// Vega per **one percentage point** of vol (desk convention): `vega / 100`.
99    ///
100    /// Example: if IV rises from 20% to 21%, P&amp;L ≈ `vega_per_vol_point()` per option.
101    #[inline]
102    pub fn vega_per_vol_point(self) -> f64 {
103        self.vega / 100.0
104    }
105
106    /// Theta per **calendar day** using 365.25 days/year: `theta / 365.25`.
107    ///
108    /// Business-day or trading-day conventions differ by desk — override externally if needed.
109    #[inline]
110    pub fn theta_per_calendar_day(self) -> f64 {
111        self.theta / 365.25
112    }
113}
114
115/// Cross / second-order BSM Greeks (vol surface &amp; hedge-drift risk).
116///
117/// # Units
118///
119/// | Field | Unit |
120/// |-------|------|
121/// | `vanna` | ∂²V/∂S∂σ = ∂Δ/∂σ (per +1.0 absolute vol) |
122/// | `volga` | ∂²V/∂σ² (per +1.0 absolute vol squared) |
123/// | `charm` | ∂Δ/∂T per **year** of calendar time to expiry |
124///
125/// Use [`BsmCrossGreeks::charm_per_calendar_day`] for overnight Δ drift estimates.
126#[derive(Clone, Copy, Debug, PartialEq)]
127pub struct BsmCrossGreeks {
128    pub vanna: f64,
129    pub volga: f64,
130    /// ∂Δ/∂T (time-to-expiry year basis). As one calendar day passes, Δ ≈ changes by
131    /// `−charm_per_calendar_day()` if other inputs are held fixed.
132    pub charm: f64,
133}
134
135impl BsmCrossGreeks {
136    /// Charm scaled to one calendar day: `charm / 365.25`.
137    #[inline]
138    pub fn charm_per_calendar_day(self) -> f64 {
139        self.charm / 365.25
140    }
141}
142
143/// Intermediate terms shared by price and Greeks (`d1`, `d2`, discounts).
144///
145/// **Teaching:** show `d1`/`d2` next to N(d1) stories.  
146/// **Engineering:** rarely needed on the hot path if you only consume price/greeks.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct BsmTerms {
149    pub d1: f64,
150    pub d2: f64,
151    pub discount: f64,
152    pub dividend_discount: f64,
153    pub sqrt_t: f64,
154}
155
156/// Teaching solution: price, greeks, cross Greeks, parity check, formulas.
157///
158/// Prefer this for notebooks and audit logs. Prefer [`bsm_price`] / [`BsmState`] in production.
159#[derive(Clone, Debug)]
160pub struct BsmSolution {
161    pub option_type: OptionType,
162    pub params: BsmParams,
163    pub price: f64,
164    pub greeks: BsmGreeks,
165    pub cross_greeks: BsmCrossGreeks,
166    pub terms: BsmTerms,
167    pub intrinsic: f64,
168    pub time_value: f64,
169    pub forward_moneyness: f64,
170    /// `C − P − (S e^{-qT} − K e^{-rT})`; model-consistent prices → ≈ 0.
171    pub parity_residual: f64,
172    formula: String,
173    symbolic_formula: String,
174}
175
176impl BsmSolution {
177    pub fn formula(&self) -> &str {
178        &self.formula
179    }
180    pub fn symbolic_formula(&self) -> &str {
181        &self.symbolic_formula
182    }
183
184    /// Print a one-row summary table of price + Greeks.
185    ///
186    /// ```text
187    /// type    price   delta   gamma     vega    theta      rho
188    /// ----  -------  ------  ------  -------  -------  -------
189    /// Call  10.4506  0.6368  0.0188  37.5240  -6.4140  53.2325
190    /// ```
191    pub fn print_table(&self) {
192        self.print_table_locale_opt(None, None);
193    }
194
195    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
196        self.print_table_locale_opt(Some(locale), Some(precision));
197    }
198
199    fn print_table_locale_opt(
200        &self,
201        locale: Option<&num_format::Locale>,
202        precision: Option<usize>,
203    ) {
204        let columns = columns_with_strings(&[
205            ("type", "s", true),
206            ("price", "f", true),
207            ("delta", "f", true),
208            ("gamma", "f", true),
209            ("vega", "f", true),
210            ("theta", "f", true),
211            ("rho", "f", true),
212        ]);
213        let data = vec![vec![
214            self.option_type.to_string(),
215            self.price.to_string(),
216            self.greeks.delta.to_string(),
217            self.greeks.gamma.to_string(),
218            self.greeks.vega.to_string(),
219            self.greeks.theta.to_string(),
220            self.greeks.rho.to_string(),
221        ]];
222        print_table_locale_opt(&columns, data, locale, precision);
223    }
224}
225
226/// European BSM price.
227///
228/// **Trading:** model mark for edge vs mid (after fees).  
229/// **Engineering:** pure function of [`BsmParams`]; validate domain first.
230///
231/// # Errors
232/// Non-finite or non-positive spot/strike; negative time or vol.
233pub fn bsm_price(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
234    validate_bsm_params(params)?;
235    Ok(price_unchecked(params, option_type))
236}
237
238/// European BSM Greeks (see [`BsmGreeks`] for units).
239///
240/// **Trading:** feed risk aggregators and hedge ratios.  
241/// **Engineering:** one call returns all first-order Greeks for a snapshot.
242pub fn bsm_greeks(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmGreeks> {
243    validate_bsm_params(params)?;
244    Ok(greeks_unchecked(params, option_type))
245}
246
247/// Cross Greeks: vanna, volga, charm (see [`BsmCrossGreeks`]).
248///
249/// **Trading:** surface risk (vanna/volga) and overnight Δ drift (charm).  
250/// **Engineering:** same validation as [`bsm_greeks`]; zeros at T=0 or σ=0.
251pub fn bsm_cross_greeks(
252    params: BsmParams,
253    option_type: OptionType,
254) -> FinanceResult<BsmCrossGreeks> {
255    validate_bsm_params(params)?;
256    Ok(cross_greeks_unchecked(params, option_type))
257}
258
259/// d1/d2 and discount factors (for teaching / advanced use).
260pub fn bsm_terms(params: BsmParams) -> FinanceResult<BsmTerms> {
261    validate_bsm_params(params)?;
262    Ok(terms_unchecked(params))
263}
264
265/// Put–call parity residual: `C − P − (S e^{−qT} − K e^{−rT})` (≈ 0 for BSM).
266///
267/// **Trading:** large residual on **market** mids may mean bad quotes, early exercise
268/// premium (American), or dividends — not free arb without costs.  
269/// **Engineering:** use on **model** prices as a self-consistency test (should be ~0).
270pub fn put_call_parity_residual(params: BsmParams) -> FinanceResult<f64> {
271    let c = bsm_price(params, OptionType::Call)?;
272    let p = bsm_price(params, OptionType::Put)?;
273    let disc = (-params.rate * params.time_years).exp();
274    let div = (-params.dividend_yield * params.time_years).exp();
275    Ok(c - p - (params.spot * div - params.strike * disc))
276}
277
278/// Full teaching solution (price, greeks, intrinsic, parity, formulas).
279///
280/// # Examples
281/// ```
282/// use finance_solution::derivatives::{bsm_solution, BsmParams, OptionType};
283/// let sol = bsm_solution(BsmParams::atm_one_year(100.0, 0.05, 0.20), OptionType::Call).unwrap();
284/// assert!(sol.price > 0.0);
285/// assert!(sol.parity_residual.abs() < 1e-8);
286/// assert!(sol.greeks.vega_per_vol_point() > 0.0);
287/// // sol.print_table();
288/// ```
289pub fn bsm_solution(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmSolution> {
290    let _ = ValidatedBsm::new(params)?;
291    let price = price_unchecked(params, option_type);
292    let greeks = greeks_unchecked(params, option_type);
293    let cross_greeks = cross_greeks_unchecked(params, option_type);
294    let terms = terms_unchecked(params);
295    let intrinsic_v = intrinsic(params.spot, params.strike, option_type)?;
296    let tv = time_value(price, params.spot, params.strike, option_type)?;
297    let fm = forward_moneyness(params)?;
298    let parity = put_call_parity_residual(params)?;
299    let formula = format!(
300        "{option_type} BSM S={} K={} T={} r={} q={} σ={} → price={:.6}",
301        params.spot,
302        params.strike,
303        params.time_years,
304        params.rate,
305        params.dividend_yield,
306        params.vol,
307        price
308    );
309    let symbolic = match option_type {
310        OptionType::Call => {
311            "C = S e^{-qT} N(d1) - K e^{-rT} N(d2); d1 = [ln(S/K)+(r-q+σ²/2)T]/(σ√T); d2 = d1-σ√T"
312                .to_string()
313        }
314        OptionType::Put => "P = K e^{-rT} N(-d2) - S e^{-qT} N(-d1); d1,d2 as in call".to_string(),
315    };
316    Ok(BsmSolution {
317        option_type,
318        params,
319        price,
320        greeks,
321        cross_greeks,
322        terms,
323        intrinsic: intrinsic_v,
324        time_value: tv,
325        forward_moneyness: fm,
326        parity_residual: parity,
327        formula,
328        symbolic_formula: symbolic,
329    })
330}
331
332fn terms_unchecked(p: BsmParams) -> BsmTerms {
333    let sqrt_t = p.time_years.sqrt();
334    let discount = (-p.rate * p.time_years).exp();
335    let dividend_discount = (-p.dividend_yield * p.time_years).exp();
336
337    if p.time_years == 0.0 || p.vol == 0.0 {
338        let forward = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
339        let d1 = if forward > p.strike {
340            f64::INFINITY
341        } else if forward < p.strike {
342            f64::NEG_INFINITY
343        } else {
344            0.0
345        };
346        return BsmTerms {
347            d1,
348            d2: d1,
349            discount,
350            dividend_discount,
351            sqrt_t,
352        };
353    }
354
355    let sig_s = p.vol * sqrt_t;
356    let d1 = ((p.spot / p.strike).ln()
357        + (p.rate - p.dividend_yield + 0.5 * p.vol * p.vol) * p.time_years)
358        / sig_s;
359    let d2 = d1 - sig_s;
360    BsmTerms {
361        d1,
362        d2,
363        discount,
364        dividend_discount,
365        sqrt_t,
366    }
367}
368
369fn price_unchecked(p: BsmParams, option_type: OptionType) -> f64 {
370    if p.time_years == 0.0 {
371        return match option_type {
372            OptionType::Call => (p.spot - p.strike).max(0.0),
373            OptionType::Put => (p.strike - p.spot).max(0.0),
374        };
375    }
376    if p.vol == 0.0 {
377        let f = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
378        let disc = (-p.rate * p.time_years).exp();
379        return match option_type {
380            OptionType::Call => disc * (f - p.strike).max(0.0),
381            OptionType::Put => disc * (p.strike - f).max(0.0),
382        };
383    }
384
385    let t = terms_unchecked(p);
386    let df_q = t.dividend_discount;
387    let df_r = t.discount;
388    match option_type {
389        OptionType::Call => p.spot * df_q * norm_cdf(t.d1) - p.strike * df_r * norm_cdf(t.d2),
390        OptionType::Put => p.strike * df_r * norm_cdf(-t.d2) - p.spot * df_q * norm_cdf(-t.d1),
391    }
392}
393
394fn greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmGreeks {
395    if p.time_years == 0.0 {
396        let delta = match option_type {
397            OptionType::Call => {
398                if p.spot > p.strike {
399                    1.0
400                } else if p.spot < p.strike {
401                    0.0
402                } else {
403                    0.5
404                }
405            }
406            OptionType::Put => {
407                if p.spot < p.strike {
408                    -1.0
409                } else if p.spot > p.strike {
410                    0.0
411                } else {
412                    -0.5
413                }
414            }
415        };
416        return BsmGreeks {
417            delta,
418            gamma: 0.0,
419            vega: 0.0,
420            theta: 0.0,
421            rho: 0.0,
422        };
423    }
424
425    if p.vol == 0.0 {
426        let price_up = {
427            let mut q = p;
428            q.spot *= 1.0 + 1e-6;
429            price_unchecked(q, option_type)
430        };
431        let price_0 = price_unchecked(p, option_type);
432        let delta = (price_up - price_0) / (p.spot * 1e-6);
433        return BsmGreeks {
434            delta,
435            gamma: 0.0,
436            vega: 0.0,
437            theta: 0.0,
438            rho: 0.0,
439        };
440    }
441
442    let t = terms_unchecked(p);
443    let df_q = t.dividend_discount;
444    let df_r = t.discount;
445    let n_d1 = norm_pdf(t.d1);
446    let sqrt_t = t.sqrt_t;
447    let gamma = df_q * n_d1 / (p.spot * p.vol * sqrt_t);
448    let vega = p.spot * df_q * n_d1 * sqrt_t;
449
450    let (delta, theta, rho) = match option_type {
451        OptionType::Call => {
452            let delta = df_q * norm_cdf(t.d1);
453            let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
454                - p.rate * p.strike * df_r * norm_cdf(t.d2)
455                + p.dividend_yield * p.spot * df_q * norm_cdf(t.d1);
456            let rho = p.strike * p.time_years * df_r * norm_cdf(t.d2);
457            (delta, theta, rho)
458        }
459        OptionType::Put => {
460            let delta = df_q * (norm_cdf(t.d1) - 1.0);
461            let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
462                + p.rate * p.strike * df_r * norm_cdf(-t.d2)
463                - p.dividend_yield * p.spot * df_q * norm_cdf(-t.d1);
464            let rho = -p.strike * p.time_years * df_r * norm_cdf(-t.d2);
465            (delta, theta, rho)
466        }
467    };
468
469    BsmGreeks {
470        delta,
471        gamma,
472        vega,
473        theta,
474        rho,
475    }
476}
477
478fn cross_greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmCrossGreeks {
479    if p.time_years == 0.0 || p.vol == 0.0 {
480        return BsmCrossGreeks {
481            vanna: 0.0,
482            volga: 0.0,
483            charm: 0.0,
484        };
485    }
486
487    let t = terms_unchecked(p);
488    let df_q = t.dividend_discount;
489    let n_d1 = norm_pdf(t.d1);
490    let sqrt_t = t.sqrt_t;
491    let sigma = p.vol;
492    let tt = p.time_years;
493
494    // vanna = ∂Δ/∂σ = −e^{-qT} n(d1) d2 / σ
495    let vanna = -df_q * n_d1 * t.d2 / sigma;
496    // volga = vega * d1 * d2 / σ
497    let vega = p.spot * df_q * n_d1 * sqrt_t;
498    let volga = vega * t.d1 * t.d2 / sigma;
499
500    let common = n_d1 * (2.0 * (p.rate - p.dividend_yield) * tt - t.d2 * sigma * sqrt_t)
501        / (2.0 * tt * sigma * sqrt_t);
502    let charm = match option_type {
503        OptionType::Call => -df_q * (common + p.dividend_yield * norm_cdf(t.d1)),
504        OptionType::Put => -df_q * (common - p.dividend_yield * norm_cdf(-t.d1)),
505    };
506
507    BsmCrossGreeks {
508        vanna,
509        volga,
510        charm,
511    }
512}
513
514pub(crate) fn price_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
515    bsm_price(params, option_type)
516}
517
518pub(crate) fn vega_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
519    Ok(bsm_greeks(params, option_type)?.vega)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn atm_call_textbook() {
528        let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
529        let c = bsm_price(p, OptionType::Call).unwrap();
530        assert!((c - 10.450_583_57).abs() < 1e-4);
531    }
532
533    #[test]
534    fn put_call_parity() {
535        let p = BsmParams {
536            spot: 100.0,
537            strike: 95.0,
538            time_years: 0.5,
539            rate: 0.03,
540            dividend_yield: 0.01,
541            vol: 0.25,
542        };
543        assert!(put_call_parity_residual(p).unwrap().abs() < 1e-10);
544    }
545
546    #[test]
547    fn expiry_intrinsic() {
548        let p = BsmParams {
549            spot: 110.0,
550            strike: 100.0,
551            time_years: 0.0,
552            rate: 0.05,
553            dividend_yield: 0.0,
554            vol: 0.2,
555        };
556        assert!((bsm_price(p, OptionType::Call).unwrap() - 10.0).abs() < 1e-12);
557        assert!((bsm_price(p, OptionType::Put).unwrap()).abs() < 1e-12);
558    }
559
560    #[test]
561    fn delta_bounds_call() {
562        let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
563        let d = bsm_greeks(p, OptionType::Call).unwrap().delta;
564        assert!(d > 0.0 && d < 1.0);
565    }
566
567    #[test]
568    fn vega_scale_helpers() {
569        let g = bsm_greeks(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
570        assert!((g.vega_per_vol_point() * 100.0 - g.vega).abs() < 1e-12);
571        assert!((g.theta_per_calendar_day() * 365.25 - g.theta).abs() < 1e-12);
572    }
573
574    #[test]
575    fn rejects_bad_spot() {
576        let mut p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
577        p.spot = 0.0;
578        assert!(bsm_price(p, OptionType::Call).is_err());
579    }
580
581    #[test]
582    fn cross_greeks_finite_and_volga_sign_atm() {
583        let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
584        let x = bsm_cross_greeks(p, OptionType::Call).unwrap();
585        assert!(x.vanna.is_finite() && x.volga.is_finite() && x.charm.is_finite());
586        // ATM-ish: d1,d2 same sign often → volga can be positive
587        assert!(x.volga > 0.0);
588        assert!((x.charm_per_calendar_day() * 365.25 - x.charm).abs() < 1e-12);
589    }
590
591    #[test]
592    fn vanna_matches_finite_difference() {
593        let p = BsmParams::atm_one_year(100.0, 0.05, 0.25);
594        let h = 1e-5;
595        let mut p_up = p;
596        p_up.vol += h;
597        let mut p_dn = p;
598        p_dn.vol -= h;
599        let d_up = bsm_greeks(p_up, OptionType::Call).unwrap().delta;
600        let d_dn = bsm_greeks(p_dn, OptionType::Call).unwrap().delta;
601        let fd = (d_up - d_dn) / (2.0 * h);
602        let analytic = bsm_cross_greeks(p, OptionType::Call).unwrap().vanna;
603        assert!((fd - analytic).abs() < 1e-4, "fd={fd} analytic={analytic}");
604    }
605
606    #[test]
607    fn volga_matches_finite_difference() {
608        let p = BsmParams::atm_one_year(100.0, 0.05, 0.25);
609        let h = 1e-4;
610        let mut p_up = p;
611        p_up.vol += h;
612        let mut p_dn = p;
613        p_dn.vol -= h;
614        let v_up = bsm_greeks(p_up, OptionType::Call).unwrap().vega;
615        let v_dn = bsm_greeks(p_dn, OptionType::Call).unwrap().vega;
616        let fd = (v_up - v_dn) / (2.0 * h);
617        let analytic = bsm_cross_greeks(p, OptionType::Call).unwrap().volga;
618        assert!((fd - analytic).abs() < 5e-2, "fd={fd} analytic={analytic}");
619    }
620
621    #[test]
622    fn put_delta_negative_and_call_put_sum_near_df_q() {
623        let p = BsmParams {
624            spot: 100.0,
625            strike: 100.0,
626            time_years: 0.75,
627            rate: 0.04,
628            dividend_yield: 0.01,
629            vol: 0.22,
630        };
631        let dc = bsm_greeks(p, OptionType::Call).unwrap().delta;
632        let dp = bsm_greeks(p, OptionType::Put).unwrap().delta;
633        let df_q = (-p.dividend_yield * p.time_years).exp();
634        assert!(dp < 0.0);
635        assert!((dc - dp - df_q).abs() < 1e-10);
636    }
637
638    #[test]
639    fn solution_includes_cross_greeks() {
640        let sol =
641            bsm_solution(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
642        assert!(sol.cross_greeks.volga > 0.0);
643    }
644}