Skip to main content

regit_blackscholes/
types.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Core types for option parameterisation and pricing output.
5//!
6//! All types are generic over `F: Float` to support both `f32` and `f64`
7//! paths. Monomorphisation at compile time — no runtime dispatch overhead.
8
9use core::fmt;
10use core::ops::{Add, Div, Mul, Neg, Sub};
11
12use crate::errors::{IvError, PricingError};
13use crate::iv::IvSolver;
14use crate::models::bachelier::BachelierParams;
15use crate::models::black76::Black76Params;
16use crate::models::displaced::DisplacedParams;
17
18/// Minimal float trait — satisfied by `f32` and `f64`.
19///
20/// Provides the arithmetic and mathematical operations needed by the
21/// pricing engine without pulling in any external dependency. Every
22/// bound maps directly to a `std` primitive method.
23pub trait Float:
24    Copy
25    + PartialOrd
26    + fmt::Debug
27    + fmt::Display
28    + Add<Output = Self>
29    + Sub<Output = Self>
30    + Mul<Output = Self>
31    + Div<Output = Self>
32    + Neg<Output = Self>
33{
34    /// The zero value (`0.0`).
35    fn zero() -> Self;
36
37    /// The one value (`1.0`).
38    fn one() -> Self;
39
40    /// Natural logarithm.
41    #[must_use]
42    fn ln(self) -> Self;
43
44    /// Exponential function.
45    #[must_use]
46    fn exp(self) -> Self;
47
48    /// Square root.
49    #[must_use]
50    fn sqrt(self) -> Self;
51
52    /// Absolute value.
53    #[must_use]
54    fn abs(self) -> Self;
55
56    /// Fused multiply-add: `self * a + b`.
57    ///
58    /// Maps to the hardware FMA instruction when available.
59    #[must_use]
60    fn mul_add(self, a: Self, b: Self) -> Self;
61
62    /// Converts from an `f64` constant. Used for embedding typed literals.
63    fn from_f64(val: f64) -> Self;
64
65    /// Converts to `f64`. Used for error reporting.
66    fn to_f64(self) -> f64;
67
68    /// The mathematical constant pi.
69    fn pi() -> Self;
70
71    /// Returns `true` if the value is NaN.
72    fn is_nan(self) -> bool;
73
74    /// Returns `true` if the value is infinite.
75    fn is_infinite(self) -> bool;
76}
77
78impl Float for f64 {
79    #[inline]
80    fn zero() -> Self {
81        0.0_f64
82    }
83
84    #[inline]
85    fn one() -> Self {
86        1.0_f64
87    }
88
89    #[inline]
90    fn ln(self) -> Self {
91        f64::ln(self)
92    }
93
94    #[inline]
95    fn exp(self) -> Self {
96        f64::exp(self)
97    }
98
99    #[inline]
100    fn sqrt(self) -> Self {
101        f64::sqrt(self)
102    }
103
104    #[inline]
105    fn abs(self) -> Self {
106        f64::abs(self)
107    }
108
109    #[inline]
110    fn mul_add(self, a: Self, b: Self) -> Self {
111        f64::mul_add(self, a, b)
112    }
113
114    #[inline]
115    fn from_f64(val: f64) -> Self {
116        val
117    }
118
119    #[inline]
120    fn to_f64(self) -> f64 {
121        self
122    }
123
124    #[inline]
125    fn pi() -> Self {
126        core::f64::consts::PI
127    }
128
129    #[inline]
130    fn is_nan(self) -> bool {
131        f64::is_nan(self)
132    }
133
134    #[inline]
135    fn is_infinite(self) -> bool {
136        f64::is_infinite(self)
137    }
138}
139
140impl Float for f32 {
141    #[inline]
142    fn zero() -> Self {
143        0.0_f32
144    }
145
146    #[inline]
147    fn one() -> Self {
148        1.0_f32
149    }
150
151    #[inline]
152    fn ln(self) -> Self {
153        f32::ln(self)
154    }
155
156    #[inline]
157    fn exp(self) -> Self {
158        f32::exp(self)
159    }
160
161    #[inline]
162    fn sqrt(self) -> Self {
163        f32::sqrt(self)
164    }
165
166    #[inline]
167    fn abs(self) -> Self {
168        f32::abs(self)
169    }
170
171    #[inline]
172    fn mul_add(self, a: Self, b: Self) -> Self {
173        f32::mul_add(self, a, b)
174    }
175
176    #[inline]
177    #[allow(clippy::cast_possible_truncation)] // Intentional narrowing to f32 by design.
178    fn from_f64(val: f64) -> Self {
179        val as f32
180    }
181
182    #[inline]
183    fn to_f64(self) -> f64 {
184        f64::from(self)
185    }
186
187    #[inline]
188    fn pi() -> Self {
189        core::f32::consts::PI
190    }
191
192    #[inline]
193    fn is_nan(self) -> bool {
194        f32::is_nan(self)
195    }
196
197    #[inline]
198    fn is_infinite(self) -> bool {
199        f32::is_infinite(self)
200    }
201}
202
203/// European option type — call or put.
204///
205/// Determines the payoff direction in all pricing models.
206///
207/// # Examples
208///
209/// ```
210/// use regit_blackscholes::types::OptionType;
211///
212/// let ot = OptionType::Call;
213/// assert!(matches!(ot, OptionType::Call));
214/// ```
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
216pub enum OptionType {
217    /// Right to **buy** the underlying at the strike price.
218    Call,
219    /// Right to **sell** the underlying at the strike price.
220    Put,
221}
222
223/// Parameters describing a European option contract.
224///
225/// Generic over `F: Float` to support both `f32` and `f64` paths.
226/// All fields use continuous, annualised conventions.
227///
228/// # Fields
229///
230/// | Field | Symbol | Description |
231/// |-------|--------|-------------|
232/// | `spot` | S | Underlying price |
233/// | `strike` | K | Strike price |
234/// | `rate` | r | Risk-free rate (continuous, annualised) |
235/// | `div_yield` | q | Dividend yield (continuous, annualised) |
236/// | `vol` | sigma | Implied volatility (annualised) |
237/// | `time` | T | Time to expiry in years |
238/// | `option_type` | — | Call or Put |
239///
240/// # Examples
241///
242/// ```
243/// use regit_blackscholes::types::{OptionParams, OptionType};
244///
245/// let params = OptionParams {
246///     option_type: OptionType::Call,
247///     spot:        100.0_f64,
248///     strike:      100.0_f64,
249///     rate:        0.05_f64,
250///     div_yield:   0.02_f64,
251///     vol:         0.20_f64,
252///     time:        1.0_f64,
253/// };
254/// ```
255#[derive(Debug, Clone, Copy)]
256pub struct OptionParams<F: Float> {
257    /// Option type — call or put.
258    pub option_type: OptionType,
259    /// S — underlying spot price.
260    pub spot: F,
261    /// K — strike price.
262    pub strike: F,
263    /// r — risk-free rate (continuous, annualised).
264    pub rate: F,
265    /// q — continuous dividend yield (annualised).
266    pub div_yield: F,
267    /// sigma — implied volatility (annualised).
268    pub vol: F,
269    /// T — time to expiry in years.
270    pub time: F,
271}
272
273/// All 17 analytic Greeks through 3rd order.
274///
275/// Computed once per pricing call from shared intermediates `d1`, `d2`,
276/// `N(d1)`, `N(d2)`, `phi(d1)` — no recomputation.
277///
278/// # Greek summary
279///
280/// | Order | Greeks |
281/// |-------|--------|
282/// | 1st   | Delta, Theta, Vega, Rho, Epsilon, Lambda, Dual Delta |
283/// | 2nd   | Gamma, Vanna, Charm, Veta, Vomma, Dual Gamma |
284/// | 3rd   | Speed, Zomma, Color, Ultima |
285///
286/// # Examples
287///
288/// ```
289/// use regit_blackscholes::types::Greeks;
290///
291/// let g = Greeks {
292///     delta: 0.5987_f64, gamma: 0.0185_f64, theta: -0.0152_f64,
293///     vega: 0.3702_f64, rho: 0.4174_f64, epsilon: -0.5702_f64,
294///     lambda: 6.47_f64, vanna: -0.1314_f64, charm: -0.0265_f64,
295///     veta: 0.0_f64, vomma: 0.1499_f64, speed: -0.0006_f64,
296///     zomma: -0.0009_f64, color: 0.0_f64, ultima: 0.0_f64,
297///     dual_delta: -0.4741_f64, dual_gamma: 0.0_f64,
298/// };
299/// assert!(g.delta > 0.0_f64);
300/// ```
301#[derive(Debug, Clone, Copy)]
302pub struct Greeks<F: Float> {
303    /// Delta (1st order) — rate of change of price with respect to spot.
304    pub delta: F,
305    /// Gamma (2nd order) — rate of change of delta with respect to spot.
306    pub gamma: F,
307    /// Theta (1st order) — rate of change of price with respect to time.
308    /// Expressed per calendar day (divide annual theta by 365).
309    pub theta: F,
310    /// Vega (1st order) — rate of change of price with respect to volatility.
311    pub vega: F,
312    /// Rho (1st order) — rate of change of price with respect to interest rate.
313    pub rho: F,
314    /// Epsilon (1st order) — rate of change of price with respect to dividend yield.
315    pub epsilon: F,
316    /// Lambda (1st order) — percentage change in price per percentage change in spot.
317    /// Also known as the elasticity or leverage.
318    pub lambda: F,
319    /// Vanna (2nd order) — cross-derivative of price with respect to spot and volatility.
320    /// Equivalently: `d(Delta)/d(sigma)` or `d(Vega)/d(S)`.
321    pub vanna: F,
322    /// Charm (2nd order) — rate of change of delta with respect to time.
323    /// Also known as delta decay.
324    pub charm: F,
325    /// Veta (2nd order) — rate of change of vega with respect to time.
326    /// Also known as vega decay.
327    pub veta: F,
328    /// Vomma (2nd order) — rate of change of vega with respect to volatility.
329    /// Also known as volga or vega convexity.
330    pub vomma: F,
331    /// Speed (3rd order) — rate of change of gamma with respect to spot.
332    pub speed: F,
333    /// Zomma (3rd order) — rate of change of gamma with respect to volatility.
334    pub zomma: F,
335    /// Color (3rd order) — rate of change of gamma with respect to time.
336    /// Also known as gamma decay.
337    pub color: F,
338    /// Ultima (3rd order) — third derivative of price with respect to volatility.
339    pub ultima: F,
340    /// Dual delta (1st order) — rate of change of price with respect to strike.
341    pub dual_delta: F,
342    /// Dual gamma (2nd order) — second derivative of price with respect to strike.
343    pub dual_gamma: F,
344}
345
346// ─── Ergonomic traits ───────────────────────────────────────────────────────
347
348/// Trait for computing option prices.
349///
350/// Implemented on [`OptionParams<f64>`], [`Black76Params`], [`BachelierParams`],
351/// [`DisplacedParams`], and [`Model`] for a uniform pricing API across all
352/// four models.
353///
354/// # Examples
355///
356/// ```
357/// use regit_blackscholes::types::{OptionParams, OptionType, Pricing};
358///
359/// let params = OptionParams {
360///     option_type: OptionType::Call,
361///     spot: 100.0_f64, strike: 100.0_f64,
362///     rate: 0.05_f64, div_yield: 0.02_f64,
363///     vol: 0.20_f64, time: 1.0_f64,
364/// };
365/// let price = params.price().unwrap();
366/// assert!(price > 0.0_f64);
367/// ```
368pub trait Pricing {
369    /// Computes the fair value of the option under the model.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`PricingError`] when input validation fails.
374    fn price(&self) -> Result<f64, PricingError>;
375}
376
377/// Trait for computing all 17 analytic Greeks.
378///
379/// Currently implemented only for [`OptionParams<f64>`] (Black-Scholes-Merton).
380///
381/// # Examples
382///
383/// ```
384/// use regit_blackscholes::types::{OptionParams, OptionType, GreeksCalc};
385///
386/// let params = OptionParams {
387///     option_type: OptionType::Call,
388///     spot: 100.0_f64, strike: 100.0_f64,
389///     rate: 0.05_f64, div_yield: 0.02_f64,
390///     vol: 0.20_f64, time: 1.0_f64,
391/// };
392/// let greeks = params.greeks().unwrap();
393/// assert!(greeks.delta > 0.0_f64);
394/// ```
395pub trait GreeksCalc {
396    /// Computes all 17 analytic Greeks from shared intermediates.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`PricingError`] when input validation fails.
401    fn greeks(&self) -> Result<Greeks<f64>, PricingError>;
402}
403
404/// Trait for recovering implied volatility from a market price.
405///
406/// Currently implemented only for [`OptionParams<f64>`] (Black-Scholes-Merton).
407///
408/// # Examples
409///
410/// ```
411/// use regit_blackscholes::types::{OptionParams, OptionType, ImpliedVol};
412/// use regit_blackscholes::iv::IvSolver;
413///
414/// let params = OptionParams {
415///     option_type: OptionType::Call,
416///     spot: 100.0_f64, strike: 100.0_f64,
417///     rate: 0.05_f64, div_yield: 0.02_f64,
418///     vol: 0.0_f64, time: 1.0_f64,
419/// };
420/// let iv = params.implied_vol(9.2270_f64, IvSolver::Auto).unwrap();
421/// assert!((iv - 0.20_f64).abs() < 1e-4_f64);
422/// ```
423pub trait ImpliedVol {
424    /// Recovers the implied volatility that reprices the market price.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`IvError`] when convergence fails or the market price
429    /// is inconsistent with the model.
430    fn implied_vol(&self, market_price: f64, solver: IvSolver) -> Result<f64, IvError>;
431}
432
433// ─── Trait implementations ──────────────────────────────────────────────────
434
435impl Pricing for OptionParams<f64> {
436    #[inline]
437    fn price(&self) -> Result<f64, PricingError> {
438        crate::models::black_scholes::price(self)
439    }
440}
441
442impl GreeksCalc for OptionParams<f64> {
443    #[inline]
444    fn greeks(&self) -> Result<Greeks<f64>, PricingError> {
445        crate::greeks::compute_greeks(self)
446    }
447}
448
449impl ImpliedVol for OptionParams<f64> {
450    #[inline]
451    fn implied_vol(&self, market_price: f64, solver: IvSolver) -> Result<f64, IvError> {
452        crate::iv::implied_vol(self, market_price, solver)
453    }
454}
455
456impl Pricing for Black76Params {
457    #[inline]
458    fn price(&self) -> Result<f64, PricingError> {
459        crate::models::black76::price(self)
460    }
461}
462
463impl Pricing for BachelierParams {
464    #[inline]
465    fn price(&self) -> Result<f64, PricingError> {
466        crate::models::bachelier::price(self)
467    }
468}
469
470impl Pricing for DisplacedParams {
471    #[inline]
472    fn price(&self) -> Result<f64, PricingError> {
473        crate::models::displaced::price(self)
474    }
475}
476
477// ─── Model enum ─────────────────────────────────────────────────────────────
478
479/// Dynamic dispatch across the four pricing models.
480///
481/// Wraps [`OptionParams<f64>`], [`Black76Params`], [`BachelierParams`], and
482/// [`DisplacedParams`] in a single enum for runtime model selection.
483///
484/// # Examples
485///
486/// ```
487/// use regit_blackscholes::types::{Model, OptionParams, OptionType, Pricing};
488/// use regit_blackscholes::models::black76::Black76Params;
489///
490/// let bs = Model::BlackScholes(OptionParams {
491///     option_type: OptionType::Call,
492///     spot: 100.0_f64, strike: 100.0_f64,
493///     rate: 0.05_f64, div_yield: 0.02_f64,
494///     vol: 0.20_f64, time: 1.0_f64,
495/// });
496/// let price = bs.price().unwrap();
497/// assert!(price > 0.0_f64);
498/// ```
499#[derive(Debug, Clone, Copy)]
500pub enum Model {
501    /// Black-Scholes-Merton (Merton 1973) — vanilla European, continuous dividend.
502    BlackScholes(OptionParams<f64>),
503    /// Black-76 (Black 1976) — options on futures/forwards.
504    Black76(Black76Params),
505    /// Bachelier / Normal model (Bachelier 1900) — for rates near/below zero.
506    Bachelier(BachelierParams),
507    /// Displaced log-normal (Rubinstein 1983) — shifted Black-76.
508    Displaced(DisplacedParams),
509}
510
511impl Pricing for Model {
512    #[inline]
513    fn price(&self) -> Result<f64, PricingError> {
514        match self {
515            Self::BlackScholes(p) => p.price(),
516            Self::Black76(p) => p.price(),
517            Self::Bachelier(p) => p.price(),
518            Self::Displaced(p) => p.price(),
519        }
520    }
521}
522
523#[cfg(test)]
524// These tests assert exact equality between literals and values that are
525// bit-identical by construction (no arithmetic/rounding occurs between the
526// two sides), so `float_cmp`'s general warning does not apply here.
527#[allow(clippy::float_cmp)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_option_type_clone_copy() {
533        let call = OptionType::Call;
534        let call2 = call;
535        assert_eq!(call, call2);
536
537        let put = OptionType::Put;
538        assert_ne!(call, put);
539    }
540
541    #[test]
542    fn test_option_type_debug() {
543        let call = OptionType::Call;
544        let debug_str = format!("{call:?}");
545        assert_eq!(debug_str, "Call");
546    }
547
548    #[test]
549    fn test_option_params_f64_construction() {
550        let params = OptionParams {
551            option_type: OptionType::Call,
552            spot: 100.0_f64,
553            strike: 100.0_f64,
554            rate: 0.05_f64,
555            div_yield: 0.02_f64,
556            vol: 0.20_f64,
557            time: 1.0_f64,
558        };
559        assert_eq!(params.spot.to_f64(), 100.0_f64);
560        assert_eq!(params.vol.to_f64(), 0.20_f64);
561    }
562
563    #[test]
564    fn test_option_params_f32_construction() {
565        let params = OptionParams {
566            option_type: OptionType::Put,
567            spot: 100.0_f32,
568            strike: 110.0_f32,
569            rate: 0.05_f32,
570            div_yield: 0.02_f32,
571            vol: 0.20_f32,
572            time: 1.0_f32,
573        };
574        assert!((params.spot.to_f64() - 100.0_f64).abs() < 1e-5_f64);
575    }
576
577    #[test]
578    fn test_option_params_copy() {
579        let p1 = OptionParams {
580            option_type: OptionType::Call,
581            spot: 100.0_f64,
582            strike: 100.0_f64,
583            rate: 0.05_f64,
584            div_yield: 0.02_f64,
585            vol: 0.20_f64,
586            time: 1.0_f64,
587        };
588        let p2 = p1;
589        assert_eq!(p1.spot.to_f64(), p2.spot.to_f64());
590    }
591
592    #[test]
593    fn test_greeks_f64_construction() {
594        let g = Greeks {
595            delta: 0.5987_f64,
596            gamma: 0.0185_f64,
597            theta: -0.0152_f64,
598            vega: 0.3702_f64,
599            rho: 0.4174_f64,
600            epsilon: -0.5702_f64,
601            lambda: 6.47_f64,
602            vanna: -0.1314_f64,
603            charm: -0.0265_f64,
604            veta: 0.0_f64,
605            vomma: 0.1499_f64,
606            speed: -0.0006_f64,
607            zomma: -0.0009_f64,
608            color: 0.0_f64,
609            ultima: 0.0_f64,
610            dual_delta: -0.4741_f64,
611            dual_gamma: 0.0_f64,
612        };
613        assert!(g.delta > 0.0_f64);
614        assert!(g.gamma > 0.0_f64);
615        assert!(g.theta < 0.0_f64);
616    }
617
618    #[test]
619    fn test_greeks_copy() {
620        let g1 = Greeks {
621            delta: 0.5_f64,
622            gamma: 0.01_f64,
623            theta: -0.01_f64,
624            vega: 0.3_f64,
625            rho: 0.4_f64,
626            epsilon: -0.5_f64,
627            lambda: 6.0_f64,
628            vanna: -0.1_f64,
629            charm: -0.02_f64,
630            veta: 0.0_f64,
631            vomma: 0.1_f64,
632            speed: -0.0006_f64,
633            zomma: -0.0009_f64,
634            color: 0.0_f64,
635            ultima: 0.0_f64,
636            dual_delta: -0.4_f64,
637            dual_gamma: 0.0_f64,
638        };
639        let g2 = g1;
640        assert_eq!(g1.delta.to_f64(), g2.delta.to_f64());
641    }
642
643    #[test]
644    fn test_float_f64_zero_one() {
645        assert_eq!(f64::zero(), 0.0_f64);
646        assert_eq!(f64::one(), 1.0_f64);
647    }
648
649    #[test]
650    fn test_float_f64_ln_exp() {
651        let val = 2.0_f64;
652        let result = val.ln().exp();
653        assert!((result - 2.0_f64).abs() < 1e-15_f64);
654    }
655
656    #[test]
657    fn test_float_f64_sqrt() {
658        let val = 4.0_f64;
659        assert!((val.sqrt() - 2.0_f64).abs() < 1e-15_f64);
660    }
661
662    #[test]
663    fn test_float_f64_abs() {
664        assert_eq!((-3.0_f64).abs(), 3.0_f64);
665        assert_eq!(3.0_f64.abs(), 3.0_f64);
666    }
667
668    #[test]
669    fn test_float_f64_mul_add() {
670        // 2.0 * 3.0 + 4.0 = 10.0
671        let result = 2.0_f64.mul_add(3.0_f64, 4.0_f64);
672        assert!((result - 10.0_f64).abs() < 1e-15_f64);
673    }
674
675    #[test]
676    fn test_float_f64_from_f64() {
677        let val = f64::from_f64(3.25_f64);
678        assert!((val - 3.25_f64).abs() < 1e-15_f64);
679    }
680
681    #[test]
682    fn test_float_f32_roundtrip() {
683        let val = f32::from_f64(3.25_f64);
684        let back = val.to_f64();
685        assert!((back - 3.25_f64).abs() < 1e-5_f64);
686    }
687
688    #[test]
689    fn test_float_f64_pi() {
690        assert!((f64::pi() - core::f64::consts::PI).abs() < 1e-15_f64);
691    }
692
693    #[test]
694    fn test_float_f64_is_nan() {
695        assert!(f64::NAN.is_nan());
696        assert!(!1.0_f64.is_nan());
697    }
698
699    #[test]
700    fn test_float_f64_is_infinite() {
701        assert!(f64::INFINITY.is_infinite());
702        assert!(!1.0_f64.is_infinite());
703    }
704
705    #[test]
706    fn test_float_f32_basic_ops() {
707        let a = 2.0_f32;
708        let b = 3.0_f32;
709        assert!((a + b - 5.0_f32).abs() < 1e-6_f32);
710        assert!((a * b - 6.0_f32).abs() < 1e-6_f32);
711        assert!((b - a - 1.0_f32).abs() < 1e-6_f32);
712        assert!((b / a - 1.5_f32).abs() < 1e-6_f32);
713        assert!((-a + 2.0_f32).abs() < 1e-6_f32);
714    }
715
716    // ── Pricing trait tests ────────────────────────────────────────────
717
718    #[test]
719    fn test_pricing_trait_bs_call() {
720        let params = OptionParams {
721            option_type: OptionType::Call,
722            spot: 100.0_f64,
723            strike: 100.0_f64,
724            rate: 0.05_f64,
725            div_yield: 0.02_f64,
726            vol: 0.20_f64,
727            time: 1.0_f64,
728        };
729        let p = params.price().unwrap();
730        assert!(
731            (p - 9.2270_f64).abs() < 1e-4_f64,
732            "BS call via trait: got {p}"
733        );
734    }
735
736    #[test]
737    fn test_pricing_trait_black76() {
738        let params = Black76Params {
739            option_type: OptionType::Call,
740            forward: 100.0_f64,
741            strike: 100.0_f64,
742            rate: 0.05_f64,
743            vol: 0.20_f64,
744            time: 1.0_f64,
745        };
746        let p = params.price().unwrap();
747        assert!(p > 0.0_f64, "Black76 via trait: got {p}");
748    }
749
750    #[test]
751    fn test_pricing_trait_bachelier() {
752        let params = BachelierParams {
753            option_type: OptionType::Call,
754            forward: 100.0_f64,
755            strike: 100.0_f64,
756            rate: 0.05_f64,
757            normal_vol: 5.0_f64,
758            time: 1.0_f64,
759        };
760        let p = params.price().unwrap();
761        assert!(p > 0.0_f64, "Bachelier via trait: got {p}");
762    }
763
764    #[test]
765    fn test_pricing_trait_displaced() {
766        let params = DisplacedParams {
767            option_type: OptionType::Call,
768            forward: 100.0_f64,
769            strike: 100.0_f64,
770            rate: 0.05_f64,
771            vol: 0.20_f64,
772            time: 1.0_f64,
773            displacement: 50.0_f64,
774        };
775        let p = params.price().unwrap();
776        assert!(p > 0.0_f64, "Displaced via trait: got {p}");
777    }
778
779    // ── GreeksCalc trait tests ─────────────────────────────────────────
780
781    #[test]
782    fn test_greeks_calc_trait_call() {
783        let params = OptionParams {
784            option_type: OptionType::Call,
785            spot: 100.0_f64,
786            strike: 100.0_f64,
787            rate: 0.05_f64,
788            div_yield: 0.02_f64,
789            vol: 0.20_f64,
790            time: 1.0_f64,
791        };
792        let g = params.greeks().unwrap();
793        assert!(g.delta > 0.0_f64 && g.delta < 1.0_f64);
794        assert!(g.gamma > 0.0_f64);
795        assert!(g.vega > 0.0_f64);
796    }
797
798    // ── ImpliedVol trait tests ─────────────────────────────────────────
799
800    #[test]
801    fn test_implied_vol_trait_roundtrip() {
802        let params = OptionParams {
803            option_type: OptionType::Call,
804            spot: 100.0_f64,
805            strike: 100.0_f64,
806            rate: 0.05_f64,
807            div_yield: 0.02_f64,
808            vol: 0.20_f64,
809            time: 1.0_f64,
810        };
811        let market_price = params.price().unwrap();
812        let iv_params = OptionParams {
813            vol: 0.0_f64,
814            ..params
815        };
816        let iv = iv_params.implied_vol(market_price, IvSolver::Auto).unwrap();
817        assert!(
818            (iv - 0.20_f64).abs() < 1e-6_f64,
819            "IV roundtrip: expected ~0.20, got {iv}"
820        );
821    }
822
823    // ── Model enum tests ───────────────────────────────────────────────
824
825    #[test]
826    fn test_model_enum_bs() {
827        let m = Model::BlackScholes(OptionParams {
828            option_type: OptionType::Call,
829            spot: 100.0_f64,
830            strike: 100.0_f64,
831            rate: 0.05_f64,
832            div_yield: 0.02_f64,
833            vol: 0.20_f64,
834            time: 1.0_f64,
835        });
836        let p = m.price().unwrap();
837        assert!((p - 9.2270_f64).abs() < 1e-4_f64, "Model::BS: got {p}");
838    }
839
840    #[test]
841    fn test_model_enum_black76() {
842        let m = Model::Black76(Black76Params {
843            option_type: OptionType::Call,
844            forward: 100.0_f64,
845            strike: 100.0_f64,
846            rate: 0.05_f64,
847            vol: 0.20_f64,
848            time: 1.0_f64,
849        });
850        let p = m.price().unwrap();
851        assert!(p > 0.0_f64, "Model::Black76: got {p}");
852    }
853
854    #[test]
855    fn test_model_enum_bachelier() {
856        let m = Model::Bachelier(BachelierParams {
857            option_type: OptionType::Call,
858            forward: 100.0_f64,
859            strike: 100.0_f64,
860            rate: 0.05_f64,
861            normal_vol: 5.0_f64,
862            time: 1.0_f64,
863        });
864        let p = m.price().unwrap();
865        assert!(p > 0.0_f64, "Model::Bachelier: got {p}");
866    }
867
868    #[test]
869    fn test_model_enum_displaced() {
870        let m = Model::Displaced(DisplacedParams {
871            option_type: OptionType::Call,
872            forward: 100.0_f64,
873            strike: 100.0_f64,
874            rate: 0.05_f64,
875            vol: 0.20_f64,
876            time: 1.0_f64,
877            displacement: 50.0_f64,
878        });
879        let p = m.price().unwrap();
880        assert!(p > 0.0_f64, "Model::Displaced: got {p}");
881    }
882
883    #[test]
884    fn test_model_enum_matches_direct_call() {
885        let params = OptionParams {
886            option_type: OptionType::Call,
887            spot: 100.0_f64,
888            strike: 100.0_f64,
889            rate: 0.05_f64,
890            div_yield: 0.02_f64,
891            vol: 0.20_f64,
892            time: 1.0_f64,
893        };
894        let direct = params.price().unwrap();
895        let via_model = Model::BlackScholes(params).price().unwrap();
896        assert!(
897            (direct - via_model).abs() < 1e-15_f64,
898            "Model dispatch must match direct call"
899        );
900    }
901}