Skip to main content

rustyqlib/equity/
blackscholes.rs

1use libm::exp;
2use crate::core::utils::{norm_pdf, norm_cdf};
3use crate::core::trade::PutOrCall;
4use super::asian::{self, AsianStrikeType, AveragingType};
5use super::barrier;
6use super::vanilla_option::{AsianPayoff, BarrierPayoff, BinaryPayoff, BinaryType, EquityOption};
7use super::utils::PayoffType;
8use crate::core::errors::RustyQLibError;
9
10pub struct BlackScholesPricer;
11impl BlackScholesPricer {
12    pub fn new() -> Self {
13        BlackScholesPricer
14    }
15    pub fn npv(&self, bsd_option: &EquityOption) -> f64 {
16        //assert!(bsd_option.volatility >= 0.0);
17        assert!(bsd_option.time_to_maturity() >= 0.0, "Option is expired or negative time");
18        assert!(bsd_option.market.spot.mid() >= 0.0, "Negative underlying price not allowed");
19        if bsd_option.base.is_futures_option() {
20            return self.npv_black76(bsd_option);
21        }
22        match &bsd_option.payoff.payoff_kind() {
23            PayoffType::Vanilla => self.npv_vanilla(bsd_option),
24            PayoffType::Binary => self.npv_binary(bsd_option),
25            PayoffType::Barrier => self.npv_barrier(bsd_option),
26            PayoffType::Asian => self.npv_asian(bsd_option),
27            PayoffType::Lookback => Self::lookback_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0),
28            PayoffType::ForwardStart => self.npv_forward_start(bsd_option),
29            _ => {0.0}
30        }
31    }
32    pub fn delta(&self, bsd_option: &EquityOption) -> f64 {
33        //assert!(bsd_option.volatility >= 0.0);
34        assert!(bsd_option.time_to_maturity() >= 0.0, "Option is expired or negative time");
35        assert!(bsd_option.market.spot.mid() >= 0.0, "Negative underlying price not allowed");
36        if bsd_option.base.is_futures_option() {
37            return self.delta_black76(bsd_option);
38        }
39        match &bsd_option.payoff.payoff_kind() {
40            PayoffType::Vanilla => self.delta_vanilla(bsd_option),
41            PayoffType::Binary => self.delta_binary(bsd_option),
42            PayoffType::Barrier => self.delta_barrier(bsd_option),
43            PayoffType::Asian => self.delta_asian(bsd_option),
44            PayoffType::ForwardStart => self.delta_forward_start(bsd_option),
45            PayoffType::Lookback => self.delta_lookback(bsd_option),
46            _ => {0.0}
47        }
48    }
49    pub fn gamma(&self, bsd_option: &EquityOption) -> f64 {
50        if bsd_option.base.is_futures_option() {
51            return self.gamma_black76(bsd_option);
52        }
53        match &bsd_option.payoff.payoff_kind() {
54            PayoffType::Vanilla => self.gamma_vanilla(bsd_option),
55            PayoffType::Binary => self.gamma_binary(bsd_option),
56            PayoffType::Barrier => self.gamma_barrier(bsd_option),
57            PayoffType::Asian => self.gamma_asian(bsd_option),
58            PayoffType::ForwardStart => self.gamma_forward_start(bsd_option),
59            PayoffType::Lookback => self.gamma_lookback(bsd_option),
60            _ => {0.0}
61        }
62    }
63    pub fn vega(&self, bsd_option: &EquityOption) -> f64 {
64        if bsd_option.base.is_futures_option() {
65            return self.vega_black76(bsd_option);
66        }
67        match &bsd_option.payoff.payoff_kind() {
68            PayoffType::Vanilla => self.vega_vanilla(bsd_option),
69            PayoffType::Binary => self.vega_binary(bsd_option),
70            PayoffType::Barrier => self.vega_barrier(bsd_option),
71            PayoffType::Asian => self.vega_asian(bsd_option),
72            PayoffType::ForwardStart => self.vega_forward_start(bsd_option),
73            PayoffType::Lookback => self.vega_lookback(bsd_option),
74            _ => {0.0}
75        }
76    }
77    pub fn theta(&self, bsd_option: &EquityOption) -> f64 {
78        if bsd_option.base.is_futures_option() {
79            return self.theta_black76(bsd_option);
80        }
81        match &bsd_option.payoff.payoff_kind() {
82            PayoffType::Vanilla => self.theta_vanilla(bsd_option),
83            PayoffType::Binary => self.theta_binary(bsd_option),
84            PayoffType::Barrier => self.theta_barrier(bsd_option),
85            PayoffType::Asian => self.theta_asian(bsd_option),
86            PayoffType::ForwardStart => self.theta_forward_start(bsd_option),
87            PayoffType::Lookback => self.theta_lookback(bsd_option),
88            _ => {0.0}
89        }
90    }
91    pub fn rho(&self, bsd_option: &EquityOption) -> f64 {
92        if bsd_option.base.is_futures_option() {
93            return self.rho_black76(bsd_option);
94        }
95        match &bsd_option.payoff.payoff_kind() {
96            PayoffType::Vanilla => self.rho_vanilla(bsd_option),
97            PayoffType::Binary => self.rho_binary(bsd_option),
98            PayoffType::Barrier => self.rho_barrier(bsd_option),
99            PayoffType::Asian => self.rho_asian(bsd_option),
100            PayoffType::ForwardStart => self.rho_forward_start(bsd_option),
101            PayoffType::Lookback => self.rho_lookback(bsd_option),
102            _ => {0.0}
103        }
104    }
105    /// Vanna (`d delta / d volatility`).  Vanilla and Black-76 options use
106    /// their closed forms; other analytic payoffs use a stable mixed bump.
107    pub fn vanna(&self, bsd_option: &EquityOption) -> f64 {
108        if bsd_option.base.is_futures_option() {
109            return self.vanna_black76(bsd_option);
110        }
111        if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
112            return bs_vanna(
113                bsd_option.effective_spot(), bsd_option.base.strike_price,
114                bsd_option.risk_free_rate(), bsd_option.carry_yield(),
115                bsd_option.volatility(), bsd_option.time_to_maturity(),
116            );
117        }
118        self.vanna_bumped(bsd_option)
119    }
120    /// Charm (`d delta / d calendar time`).  A positive value means delta
121    /// rises as one year of calendar time elapses.
122    pub fn charm(&self, bsd_option: &EquityOption) -> f64 {
123        if bsd_option.base.is_futures_option() {
124            return self.charm_black76(bsd_option);
125        }
126        if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
127            return bs_charm(
128                bsd_option.effective_spot(), bsd_option.base.strike_price,
129                bsd_option.risk_free_rate(), bsd_option.carry_yield(),
130                bsd_option.volatility(), bsd_option.time_to_maturity(),
131                *bsd_option.payoff.put_or_call(),
132            );
133        }
134        self.charm_bumped(bsd_option)
135    }
136    /// Delta elasticity (`S * gamma / delta`), sometimes called percentage
137    /// gamma. It is undefined when delta is zero.
138    pub fn gamma_p(&self, bsd_option: &EquityOption) -> f64 {
139        if bsd_option.base.is_futures_option() {
140            return self.gamma_p_black76(bsd_option);
141        }
142        let delta = self.delta(bsd_option);
143        if delta == 0.0 {
144            f64::NAN
145        } else {
146            bsd_option.market.spot.value() * self.gamma(bsd_option) / delta
147        }
148    }
149    /// Zomma (`d gamma / d volatility`). Vanilla and Black-76 use closed
150    /// forms; other analytic payoffs use a central volatility bump.
151    pub fn zomma(&self, bsd_option: &EquityOption) -> f64 {
152        if bsd_option.base.is_futures_option() {
153            return self.zomma_black76(bsd_option);
154        }
155        if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
156            return bs_zomma(
157                bsd_option.effective_spot(), bsd_option.base.strike_price,
158                bsd_option.risk_free_rate(), bsd_option.carry_yield(),
159                bsd_option.volatility(), bsd_option.time_to_maturity(),
160            );
161        }
162        self.zomma_bumped(bsd_option)
163    }
164    /// Volga / vomma (`d vega / d volatility`). Vanilla and Black-76 use
165    /// closed forms; other analytic payoffs use a central volatility bump.
166    pub fn volga(&self, bsd_option: &EquityOption) -> f64 {
167        if bsd_option.base.is_futures_option() {
168            return self.volga_black76(bsd_option);
169        }
170        if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
171            return bs_volga(
172                bsd_option.effective_spot(), bsd_option.base.strike_price,
173                bsd_option.risk_free_rate(), bsd_option.carry_yield(),
174                bsd_option.volatility(), bsd_option.time_to_maturity(),
175            );
176        }
177        self.volga_bumped(bsd_option)
178    }
179    // ── Black-76: European options on a future ─────────────────────────
180    // The underlying_price is the futures price F; there is no spot,
181    // dividend or carry. Vol is read from the surface at (K, F, T).
182
183    fn black76_inputs(bsd_option: &EquityOption)
184        -> (f64, f64, f64, f64, f64, PutOrCall, crate::equity::black76::FuturesSettlement)
185    {
186        let f = bsd_option.market.spot.value();
187        let k = bsd_option.base.strike_price;
188        let r = bsd_option.risk_free_rate();
189        let t = bsd_option.time_to_maturity();
190        let sigma = bsd_option.market.vol_surface.vol(k, f, t);
191        let settlement = bsd_option
192            .base
193            .futures_settlement
194            .expect("black76 pricer called on a non-futures option");
195        (f, k, r, sigma, t, *bsd_option.payoff.put_or_call(), settlement)
196    }
197    fn npv_black76(&self, o: &EquityOption) -> f64 {
198        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
199        crate::equity::black76::price(f, k, r, sig, t, pc, s)
200    }
201    fn delta_black76(&self, o: &EquityOption) -> f64 {
202        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
203        crate::equity::black76::delta(f, k, r, sig, t, pc, s)
204    }
205    fn gamma_black76(&self, o: &EquityOption) -> f64 {
206        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
207        crate::equity::black76::gamma(f, k, r, sig, t, s)
208    }
209    fn vega_black76(&self, o: &EquityOption) -> f64 {
210        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
211        crate::equity::black76::vega(f, k, r, sig, t, s)
212    }
213    fn theta_black76(&self, o: &EquityOption) -> f64 {
214        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
215        crate::equity::black76::theta(f, k, r, sig, t, pc, s)
216    }
217    fn rho_black76(&self, o: &EquityOption) -> f64 {
218        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
219        crate::equity::black76::rho(f, k, r, sig, t, pc, s)
220    }
221    fn vanna_black76(&self, o: &EquityOption) -> f64 {
222        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
223        crate::equity::black76::vanna(f, k, r, sig, t, s)
224    }
225    fn charm_black76(&self, o: &EquityOption) -> f64 {
226        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
227        crate::equity::black76::charm(f, k, r, sig, t, pc, s)
228    }
229    fn gamma_p_black76(&self, o: &EquityOption) -> f64 {
230        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
231        crate::equity::black76::gamma_p(f, k, r, sig, t, pc, s)
232    }
233    fn zomma_black76(&self, o: &EquityOption) -> f64 {
234        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
235        crate::equity::black76::zomma(f, k, r, sig, t, s)
236    }
237    fn volga_black76(&self, o: &EquityOption) -> f64 {
238        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
239        crate::equity::black76::volga(f, k, r, sig, t, s)
240    }
241    fn npv_vanilla(&self, bsd_option: &EquityOption) -> f64 {
242
243        let n_d1 = norm_cdf(bsd_option.d1());
244        let n_d2 = norm_cdf(bsd_option.d2());
245        let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
246        let df_r = bsd_option.maturity_discount_factor();
247        match bsd_option.payoff.put_or_call() {
248            PutOrCall::Call => {bsd_option.effective_spot()*n_d1 *df_d
249                -bsd_option.base.strike_price*n_d2*df_r
250            }
251            PutOrCall::Put => {bsd_option.base.strike_price*norm_cdf(-bsd_option.d2())*df_r-
252                bsd_option.effective_spot()*norm_cdf(-bsd_option.d1()) *df_d
253                }
254
255        }
256    }
257    fn delta_vanilla(&self, bsd_option: &EquityOption) -> f64 {
258        // spot delta: e^{-qT} N(d1) for a call, e^{-qT}(N(d1)-1) for a put
259        let n_d1 = norm_cdf(bsd_option.d1());
260        let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
261
262        match bsd_option.payoff.put_or_call() {
263            PutOrCall::Call => {n_d1 * df_d }
264            PutOrCall::Put => {(n_d1-1.0) * df_d }
265        }
266    }
267    fn gamma_vanilla(&self, bsd_option: &EquityOption) -> f64 {
268        // e^{-qT} dN(d1) / (S sigma sqrt(T))
269        let dn_d1 = norm_pdf(bsd_option.d1());
270        let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
271        let var_sqrt = bsd_option.volatility() * (bsd_option.time_to_maturity().sqrt());
272        dn_d1 * df_d / (bsd_option.effective_spot() * var_sqrt)
273    }
274    fn vega_vanilla(&self, bsd_option: &EquityOption) -> f64 {
275        // S e^{-qT} dN(d1) sqrt(T)
276        let dn_d1 = norm_pdf(bsd_option.d1());
277        let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
278        let df_s = bsd_option.effective_spot() * df_d;
279        let vega = df_s * dn_d1 * bsd_option.time_to_maturity().sqrt();
280        vega
281    }
282    fn theta_vanilla(&self, bsd_option: &EquityOption) -> f64 {
283        // call: -S e^{-qT} dN(d1) sigma/(2 sqrt(T)) + q S e^{-qT} N(d1) - r K e^{-rT} N(d2)
284        // put:  -S e^{-qT} dN(d1) sigma/(2 sqrt(T)) - q S e^{-qT} N(-d1) + r K e^{-rT} N(-d2)
285        let q = bsd_option.carry_yield();
286        let r = bsd_option.risk_free_rate();
287        let k = bsd_option.base.strike_price;
288        let dn_d1 = norm_pdf(bsd_option.d1());
289        let n_d1 = norm_cdf(bsd_option.d1());
290        let n_d2 = norm_cdf(bsd_option.d2());
291        let df_d = exp(-q * bsd_option.time_to_maturity());
292        let df_r = bsd_option.maturity_discount_factor();
293        let df_s = bsd_option.effective_spot() * df_d;
294        let t1 = -df_s * dn_d1 * bsd_option.volatility()
295            / (2.0 * bsd_option.time_to_maturity().sqrt());
296
297        match bsd_option.payoff.put_or_call() {
298            PutOrCall::Call => {
299                t1 + q * df_s * n_d1 - r * k * df_r * n_d2
300            }
301            PutOrCall::Put => {
302                t1 - q * df_s * norm_cdf(-bsd_option.d1()) + r * k * df_r * norm_cdf(-bsd_option.d2())
303            }
304        }
305    }
306    fn rho_vanilla(&self, bsd_option: &EquityOption) -> f64 {
307        // call: K T e^{-rT} N(d2); put: -K T e^{-rT} N(-d2)
308        let n_d2 = norm_cdf(bsd_option.d2());
309        let df_r = bsd_option.maturity_discount_factor();
310        let r1 = bsd_option.time_to_maturity()*bsd_option.base.strike_price;
311        match bsd_option.payoff.put_or_call() {
312            PutOrCall::Call => {
313                r1*n_d2*df_r
314            }
315            PutOrCall::Put => {-r1*norm_cdf(-bsd_option.d2())*df_r
316            }
317
318        }
319    }
320
321    /// Analytic price with bumpable spot, volatility, rate and expiry.  This
322    /// supports the cross-Greeks of payoffs whose first-order formulas are
323    /// deliberately implemented by bump-and-reprice, and the portfolio
324    /// PnL-attribution reprice.
325    pub(crate) fn price_with(
326        bsd_option: &EquityOption,
327        ds: f64,
328        dsigma: f64,
329        dr: f64,
330        dt_shift: f64,
331    ) -> f64 {
332        match bsd_option.payoff.payoff_kind() {
333            PayoffType::Vanilla => bs_price(
334                bsd_option.effective_spot() + ds,
335                bsd_option.base.strike_price,
336                bsd_option.risk_free_rate() + dr,
337                bsd_option.carry_yield(),
338                bsd_option.volatility() + dsigma,
339                bsd_option.time_to_maturity() + dt_shift,
340                *bsd_option.payoff.put_or_call(),
341            ),
342            PayoffType::Binary => Self::binary_price_with(bsd_option, ds, dsigma, dr, dt_shift),
343            PayoffType::Barrier => Self::barrier_price_with(bsd_option, ds, dsigma, dr, dt_shift),
344            PayoffType::Asian => Self::asian_price_with(bsd_option, ds, dsigma, dr, dt_shift),
345            PayoffType::ForwardStart => Self::forward_start_price_with(bsd_option, ds, dsigma, dr, dt_shift),
346            PayoffType::Lookback => Self::lookback_price_with(bsd_option, ds, dsigma, dr, dt_shift),
347            _ => panic!("cross-Greeks are not available for this analytic payoff"),
348        }
349    }
350    fn vanna_bumped(&self, bsd_option: &EquityOption) -> f64 {
351        let hs = bsd_option.market.spot.value() * 1e-4;
352        let hv = 1e-4;
353        (Self::price_with(bsd_option, hs, hv, 0.0, 0.0)
354            - Self::price_with(bsd_option, -hs, hv, 0.0, 0.0)
355            - Self::price_with(bsd_option, hs, -hv, 0.0, 0.0)
356            + Self::price_with(bsd_option, -hs, -hv, 0.0, 0.0))
357            / (4.0 * hs * hv)
358    }
359    fn charm_bumped(&self, bsd_option: &EquityOption) -> f64 {
360        let hs = bsd_option.market.spot.value() * 1e-4;
361        let ht = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
362        -(Self::price_with(bsd_option, hs, 0.0, 0.0, ht)
363            - Self::price_with(bsd_option, -hs, 0.0, 0.0, ht)
364            - Self::price_with(bsd_option, hs, 0.0, 0.0, -ht)
365            + Self::price_with(bsd_option, -hs, 0.0, 0.0, -ht))
366            / (4.0 * hs * ht)
367    }
368    fn zomma_bumped(&self, bsd_option: &EquityOption) -> f64 {
369        let hv = 1e-4;
370        let hs = bsd_option.market.spot.value() * 1e-3;
371        let gamma_at_vol = |dsigma: f64| {
372            (Self::price_with(bsd_option, hs, dsigma, 0.0, 0.0)
373                - 2.0 * Self::price_with(bsd_option, 0.0, dsigma, 0.0, 0.0)
374                + Self::price_with(bsd_option, -hs, dsigma, 0.0, 0.0))
375                / (hs * hs)
376        };
377        (gamma_at_vol(hv) - gamma_at_vol(-hv)) / (2.0 * hv)
378    }
379    /// Volga as the second price derivative in volatility (`d2 V / d sigma2`).
380    fn volga_bumped(&self, bsd_option: &EquityOption) -> f64 {
381        let hv = 1e-3;
382        (Self::price_with(bsd_option, 0.0, hv, 0.0, 0.0)
383            - 2.0 * Self::price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
384            + Self::price_with(bsd_option, 0.0, -hv, 0.0, 0.0))
385            / (hv * hv)
386    }
387
388    // ── Binary (digital) options ───────────────────────────────────────
389    // cash-or-nothing:  cash * e^{-rT} N(+-d2)
390    // asset-or-nothing: S e^{-qT} N(+-d1)
391    // All asset-or-nothing Greeks are implemented directly (not via the
392    // vanilla replication identity), so the replication tests are a real
393    // cross-check.
394
395    fn binary_details(bsd_option: &EquityOption) -> (BinaryType, f64) {
396        let payoff = bsd_option
397            .payoff
398            .as_any()
399            .downcast_ref::<BinaryPayoff>()
400            .expect("payoff of kind Binary must be a BinaryPayoff");
401        (payoff.binary_type, payoff.cash)
402    }
403
404    fn binary_price_with(
405        bsd_option: &EquityOption,
406        ds: f64,
407        dsigma: f64,
408        dr: f64,
409        dt_shift: f64,
410    ) -> f64 {
411        let (binary_type, cash) = Self::binary_details(bsd_option);
412        let s = bsd_option.effective_spot() + ds;
413        let k = bsd_option.base.strike_price;
414        let r = bsd_option.risk_free_rate() + dr;
415        let q = bsd_option.carry_yield();
416        let sigma = bsd_option.volatility() + dsigma;
417        let t = bsd_option.time_to_maturity() + dt_shift;
418        let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
419        let d2 = d1 - sigma * t.sqrt();
420        match (binary_type, bsd_option.payoff.put_or_call()) {
421            (BinaryType::CashOrNothing, PutOrCall::Call) => cash * (-r * t).exp() * norm_cdf(d2),
422            (BinaryType::CashOrNothing, PutOrCall::Put) => cash * (-r * t).exp() * norm_cdf(-d2),
423            (BinaryType::AssetOrNothing, PutOrCall::Call) => s * (-q * t).exp() * norm_cdf(d1),
424            (BinaryType::AssetOrNothing, PutOrCall::Put) => s * (-q * t).exp() * norm_cdf(-d1),
425        }
426    }
427
428    fn npv_binary(&self, bsd_option: &EquityOption) -> f64 {
429        let (binary_type, cash) = Self::binary_details(bsd_option);
430        let df_r = bsd_option.maturity_discount_factor();
431        let df_q = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
432        let s = bsd_option.effective_spot();
433        match (binary_type, bsd_option.payoff.put_or_call()) {
434            (BinaryType::CashOrNothing, PutOrCall::Call) => cash * df_r * norm_cdf(bsd_option.d2()),
435            (BinaryType::CashOrNothing, PutOrCall::Put) => cash * df_r * norm_cdf(-bsd_option.d2()),
436            (BinaryType::AssetOrNothing, PutOrCall::Call) => s * df_q * norm_cdf(bsd_option.d1()),
437            (BinaryType::AssetOrNothing, PutOrCall::Put) => s * df_q * norm_cdf(-bsd_option.d1()),
438        }
439    }
440    fn delta_binary(&self, bsd_option: &EquityOption) -> f64 {
441        let (binary_type, cash) = Self::binary_details(bsd_option);
442        let t = bsd_option.time_to_maturity();
443        let sigma = bsd_option.volatility();
444        let s = bsd_option.effective_spot();
445        let vol_sqrt_t = sigma * t.sqrt();
446        match binary_type {
447            BinaryType::CashOrNothing => {
448                // +- cash e^{-rT} dN(d2) / (S sigma sqrt(T))
449                let df_r = bsd_option.maturity_discount_factor();
450                let delta_call = cash * df_r * norm_pdf(bsd_option.d2()) / (s * vol_sqrt_t);
451                match bsd_option.payoff.put_or_call() {
452                    PutOrCall::Call => delta_call,
453                    PutOrCall::Put => -delta_call,
454                }
455            }
456            BinaryType::AssetOrNothing => {
457                // e^{-qT} (N(+-d1) +- dN(d1)/(sigma sqrt(T)))
458                let df_q = exp(-bsd_option.market.dividend_yield * t);
459                let d1 = bsd_option.d1();
460                match bsd_option.payoff.put_or_call() {
461                    PutOrCall::Call => df_q * (norm_cdf(d1) + norm_pdf(d1) / vol_sqrt_t),
462                    PutOrCall::Put => df_q * (norm_cdf(-d1) - norm_pdf(d1) / vol_sqrt_t),
463                }
464            }
465        }
466    }
467    fn gamma_binary(&self, bsd_option: &EquityOption) -> f64 {
468        let (binary_type, cash) = Self::binary_details(bsd_option);
469        let t = bsd_option.time_to_maturity();
470        let sigma = bsd_option.volatility();
471        let s = bsd_option.effective_spot();
472        let vol_sqrt_t = sigma * t.sqrt();
473        let gamma_call = match binary_type {
474            BinaryType::CashOrNothing => {
475                // - cash e^{-rT} dN(d2) d1 / (S^2 sigma^2 T)
476                let df_r = bsd_option.maturity_discount_factor();
477                -cash * df_r * norm_pdf(bsd_option.d2()) * bsd_option.d1()
478                    / (s * s * sigma * sigma * t)
479            }
480            BinaryType::AssetOrNothing => {
481                // e^{-qT} dN(d1) (1 - d1/(sigma sqrt(T))) / (S sigma sqrt(T))
482                let df_q = exp(-bsd_option.market.dividend_yield * t);
483                let d1 = bsd_option.d1();
484                df_q * norm_pdf(d1) * (1.0 - d1 / vol_sqrt_t) / (s * vol_sqrt_t)
485            }
486        };
487        match bsd_option.payoff.put_or_call() {
488            PutOrCall::Call => gamma_call,
489            PutOrCall::Put => -gamma_call,
490        }
491    }
492    fn vega_binary(&self, bsd_option: &EquityOption) -> f64 {
493        let (binary_type, cash) = Self::binary_details(bsd_option);
494        let t = bsd_option.time_to_maturity();
495        let sigma = bsd_option.volatility();
496        let s = bsd_option.effective_spot();
497        let vega_call = match binary_type {
498            BinaryType::CashOrNothing => {
499                // - cash e^{-rT} dN(d2) d1 / sigma
500                let df_r = bsd_option.maturity_discount_factor();
501                -cash * df_r * norm_pdf(bsd_option.d2()) * bsd_option.d1() / sigma
502            }
503            BinaryType::AssetOrNothing => {
504                // - S e^{-qT} dN(d1) d2 / sigma
505                let df_q = exp(-bsd_option.market.dividend_yield * t);
506                -s * df_q * norm_pdf(bsd_option.d1()) * bsd_option.d2() / sigma
507            }
508        };
509        match bsd_option.payoff.put_or_call() {
510            PutOrCall::Call => vega_call,
511            PutOrCall::Put => -vega_call,
512        }
513    }
514    fn theta_binary(&self, bsd_option: &EquityOption) -> f64 {
515        let (binary_type, cash) = Self::binary_details(bsd_option);
516        let r = bsd_option.risk_free_rate();
517        let q = bsd_option.carry_yield();
518        let t = bsd_option.time_to_maturity();
519        let sigma = bsd_option.volatility();
520        let s = bsd_option.effective_spot();
521        match binary_type {
522            BinaryType::CashOrNothing => {
523                // dd2/dT = (r - q - sigma^2/2)/(sigma sqrt(T)) - d2/(2T)
524                let df_r = bsd_option.maturity_discount_factor();
525                let d2 = bsd_option.d2();
526                let dd2_dt = (r - q - 0.5 * sigma * sigma) / (sigma * t.sqrt()) - d2 / (2.0 * t);
527                match bsd_option.payoff.put_or_call() {
528                    PutOrCall::Call => cash * (r * df_r * norm_cdf(d2) - df_r * norm_pdf(d2) * dd2_dt),
529                    PutOrCall::Put => cash * (r * df_r * norm_cdf(-d2) + df_r * norm_pdf(d2) * dd2_dt),
530                }
531            }
532            BinaryType::AssetOrNothing => {
533                // dd1/dT = (r - q + sigma^2/2)/(sigma sqrt(T)) - d1/(2T)
534                let df_q = exp(-q * t);
535                let d1 = bsd_option.d1();
536                let dd1_dt = (r - q + 0.5 * sigma * sigma) / (sigma * t.sqrt()) - d1 / (2.0 * t);
537                match bsd_option.payoff.put_or_call() {
538                    PutOrCall::Call => q * s * df_q * norm_cdf(d1) - s * df_q * norm_pdf(d1) * dd1_dt,
539                    PutOrCall::Put => q * s * df_q * norm_cdf(-d1) + s * df_q * norm_pdf(d1) * dd1_dt,
540                }
541            }
542        }
543    }
544    fn rho_binary(&self, bsd_option: &EquityOption) -> f64 {
545        let (binary_type, cash) = Self::binary_details(bsd_option);
546        let t = bsd_option.time_to_maturity();
547        let sigma = bsd_option.volatility();
548        let s = bsd_option.effective_spot();
549        match binary_type {
550            BinaryType::CashOrNothing => {
551                let df_r = bsd_option.maturity_discount_factor();
552                let d2 = bsd_option.d2();
553                match bsd_option.payoff.put_or_call() {
554                    PutOrCall::Call => cash * (-t * df_r * norm_cdf(d2) + df_r * norm_pdf(d2) * t.sqrt() / sigma),
555                    PutOrCall::Put => cash * (-t * df_r * norm_cdf(-d2) - df_r * norm_pdf(d2) * t.sqrt() / sigma),
556                }
557            }
558            BinaryType::AssetOrNothing => {
559                // +- S e^{-qT} dN(d1) sqrt(T)/sigma
560                let df_q = exp(-bsd_option.market.dividend_yield * t);
561                let rho_call = s * df_q * norm_pdf(bsd_option.d1()) * t.sqrt() / sigma;
562                match bsd_option.payoff.put_or_call() {
563                    PutOrCall::Call => rho_call,
564                    PutOrCall::Put => -rho_call,
565                }
566            }
567        }
568    }
569
570    // ── Barrier options (Reiner-Rubinstein) ────────────────────────────
571    // NPV is the closed form; Greeks are central-difference bumps of it
572    // (the standard approach — the analytic derivatives are long and easy
573    // to get wrong, and near the barrier bumped Greeks are what desks use).
574
575    /// Reprice the barrier with additive bumps to (spot, vol, rate, expiry).
576    fn barrier_price_with(
577        bsd_option: &EquityOption,
578        ds: f64,
579        dsigma: f64,
580        dr: f64,
581        dt_shift: f64,
582    ) -> f64 {
583        let payoff = bsd_option
584            .payoff
585            .as_any()
586            .downcast_ref::<BarrierPayoff>()
587            .expect("payoff of kind Barrier must be a BarrierPayoff");
588        let s = bsd_option.effective_spot() + ds;
589        let k = bsd_option.base.strike_price;
590        let r = bsd_option.risk_free_rate() + dr;
591        let q = bsd_option.carry_yield();
592        let sigma = bsd_option.volatility() + dsigma;
593        let t = bsd_option.time_to_maturity() + dt_shift;
594        let pc = *bsd_option.payoff.put_or_call();
595        match payoff.barrier2 {
596            Some(b2) => {
597                assert!(
598                    payoff.rebate == 0.0,
599                    "double-barrier rebates are not supported analytically; use MonteCarlo                      (rebate at expiry)"
600                );
601                let (lo, hi) = (payoff.barrier.min(b2), payoff.barrier.max(b2));
602                barrier::double_barrier_price(s, k, lo, hi, r, q, sigma, t, payoff.knock, pc)
603            }
604            None => {
605                let timing = if payoff.rebate_at_hit {
606                    barrier::RebateTiming::AtHit
607                } else {
608                    barrier::RebateTiming::AtExpiry
609                };
610                barrier::barrier_price_with_rebate(
611                    s, k, payoff.barrier, payoff.rebate, r, q, sigma, t,
612                    payoff.direction, payoff.knock, timing, pc,
613                )
614            }
615        }
616    }
617    fn npv_barrier(&self, bsd_option: &EquityOption) -> f64 {
618        Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
619    }
620    fn delta_barrier(&self, bsd_option: &EquityOption) -> f64 {
621        let h = bsd_option.market.spot.value() * 1e-4;
622        (Self::barrier_price_with(bsd_option, h, 0.0, 0.0, 0.0)
623            - Self::barrier_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
624            / (2.0 * h)
625    }
626    fn gamma_barrier(&self, bsd_option: &EquityOption) -> f64 {
627        let h = bsd_option.market.spot.value() * 1e-3;
628        (Self::barrier_price_with(bsd_option, h, 0.0, 0.0, 0.0)
629            - 2.0 * Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
630            + Self::barrier_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
631            / (h * h)
632    }
633    fn vega_barrier(&self, bsd_option: &EquityOption) -> f64 {
634        let h = 1e-4;
635        (Self::barrier_price_with(bsd_option, 0.0, h, 0.0, 0.0)
636            - Self::barrier_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
637            / (2.0 * h)
638    }
639    fn theta_barrier(&self, bsd_option: &EquityOption) -> f64 {
640        let h = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
641        -(Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, h)
642            - Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
643            / (2.0 * h)
644    }
645    fn rho_barrier(&self, bsd_option: &EquityOption) -> f64 {
646        let h = 1e-5;
647        (Self::barrier_price_with(bsd_option, 0.0, 0.0, h, 0.0)
648            - Self::barrier_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
649            / (2.0 * h)
650    }
651
652    // ── Asian options ──────────────────────────────────────────────────
653    // geometric average price: exact closed form (continuous averaging)
654    // arithmetic average price: Turnbull-Wakeman approximation
655    // floating strike: Monte Carlo only
656    // Greeks by central-difference bumps, like barriers.
657
658    fn asian_price_with(
659        bsd_option: &EquityOption,
660        ds: f64,
661        dsigma: f64,
662        dr: f64,
663        dt_shift: f64,
664    ) -> f64 {
665        let payoff = bsd_option
666            .payoff
667            .as_any()
668            .downcast_ref::<AsianPayoff>()
669            .expect("payoff of kind Asian must be an AsianPayoff");
670        let s = bsd_option.effective_spot() + ds;
671        let k = bsd_option.base.strike_price;
672        let r = bsd_option.risk_free_rate() + dr;
673        let q = bsd_option.carry_yield();
674        let sigma = bsd_option.volatility() + dsigma;
675        let t = bsd_option.time_to_maturity() + dt_shift;
676        let pc = *bsd_option.payoff.put_or_call();
677        match (payoff.strike_type, payoff.averaging) {
678            (AsianStrikeType::FixedStrike, AveragingType::Geometric) => {
679                asian::geometric_asian_price(s, k, r, q, sigma, t, None, pc)
680            }
681            (AsianStrikeType::FixedStrike, AveragingType::Arithmetic) => {
682                asian::turnbull_wakeman_price(s, k, r, q, sigma, t, pc)
683            }
684            (AsianStrikeType::FloatingStrike, AveragingType::Geometric) => {
685                // exact exchange-option closed form (continuous averaging)
686                asian::geometric_average_strike_price(s, r, q, sigma, t, None, pc)
687            }
688            (AsianStrikeType::FloatingStrike, AveragingType::Arithmetic) => {
689                // Henderson-Wojakowski symmetry + Turnbull-Wakeman
690                asian::turnbull_wakeman_average_strike_price(s, r, q, sigma, t, pc)
691            }
692        }
693    }
694    /// Continuous-monitoring lookback closed forms (fresh options: the
695    /// running extremum is the current spot).
696    fn lookback_price_with(
697        bsd_option: &EquityOption,
698        ds: f64,
699        dsigma: f64,
700        dr: f64,
701        dt_shift: f64,
702    ) -> f64 {
703        let payoff = bsd_option
704            .payoff
705            .as_any()
706            .downcast_ref::<crate::equity::vanilla_option::LookbackPayoff>()
707            .expect("payoff of kind Lookback must be a LookbackPayoff");
708        let anchor = bsd_option.effective_spot();
709        let s = anchor + ds;
710        let k = bsd_option.base.strike_price;
711        let r = bsd_option.risk_free_rate() + dr;
712        let q = bsd_option.carry_yield();
713        let sigma = bsd_option.volatility() + dsigma;
714        let t = bsd_option.time_to_maturity() + dt_shift;
715        let pc = *bsd_option.payoff.put_or_call();
716        // the running extremum is a historical observable: spot bumps hold
717        // it at the unbumped level (clamped so min <= s / max >= s stays
718        // true), giving the market-standard hedge delta rather than the
719        // fresh-reissue homogeneity delta
720        use crate::equity::vanilla_option::LookbackType;
721        match (payoff.lookback_type, pc) {
722            (LookbackType::FloatingStrike, PutOrCall::Call) => {
723                crate::equity::lookback::floating_strike_lookback_price(
724                    s, anchor.min(s), r, q, sigma, t, pc,
725                )
726            }
727            (LookbackType::FloatingStrike, PutOrCall::Put) => {
728                crate::equity::lookback::floating_strike_lookback_price(
729                    s, anchor.max(s), r, q, sigma, t, pc,
730                )
731            }
732            (LookbackType::FixedStrike, PutOrCall::Call) => {
733                crate::equity::lookback::fixed_strike_lookback_price(
734                    s, k, anchor.max(s), r, q, sigma, t, pc,
735                )
736            }
737            (LookbackType::FixedStrike, PutOrCall::Put) => {
738                crate::equity::lookback::fixed_strike_lookback_price(
739                    s, k, anchor.min(s), r, q, sigma, t, pc,
740                )
741            }
742        }
743    }
744
745    fn delta_lookback(&self, o: &EquityOption) -> f64 {
746        let h = o.market.spot.value() * 1e-4;
747        (Self::lookback_price_with(o, h, 0.0, 0.0, 0.0)
748            - Self::lookback_price_with(o, -h, 0.0, 0.0, 0.0))
749            / (2.0 * h)
750    }
751    fn gamma_lookback(&self, o: &EquityOption) -> f64 {
752        let h = o.market.spot.value() * 1e-3;
753        (Self::lookback_price_with(o, h, 0.0, 0.0, 0.0)
754            - 2.0 * Self::lookback_price_with(o, 0.0, 0.0, 0.0, 0.0)
755            + Self::lookback_price_with(o, -h, 0.0, 0.0, 0.0))
756            / (h * h)
757    }
758    fn vega_lookback(&self, o: &EquityOption) -> f64 {
759        let h = 1e-4;
760        (Self::lookback_price_with(o, 0.0, h, 0.0, 0.0)
761            - Self::lookback_price_with(o, 0.0, -h, 0.0, 0.0))
762            / (2.0 * h)
763    }
764    fn theta_lookback(&self, o: &EquityOption) -> f64 {
765        let h = (1.0 / 365.0_f64).min(0.5 * o.time_to_maturity());
766        -(Self::lookback_price_with(o, 0.0, 0.0, 0.0, h)
767            - Self::lookback_price_with(o, 0.0, 0.0, 0.0, -h))
768            / (2.0 * h)
769    }
770    fn rho_lookback(&self, o: &EquityOption) -> f64 {
771        let h = 1e-5;
772        (Self::lookback_price_with(o, 0.0, 0.0, h, 0.0)
773            - Self::lookback_price_with(o, 0.0, 0.0, -h, 0.0))
774            / (2.0 * h)
775    }
776
777    fn npv_asian(&self, bsd_option: &EquityOption) -> f64 {
778        Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
779    }
780    fn delta_asian(&self, bsd_option: &EquityOption) -> f64 {
781        let h = bsd_option.market.spot.value() * 1e-4;
782        (Self::asian_price_with(bsd_option, h, 0.0, 0.0, 0.0)
783            - Self::asian_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
784            / (2.0 * h)
785    }
786    fn gamma_asian(&self, bsd_option: &EquityOption) -> f64 {
787        let h = bsd_option.market.spot.value() * 1e-3;
788        (Self::asian_price_with(bsd_option, h, 0.0, 0.0, 0.0)
789            - 2.0 * Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
790            + Self::asian_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
791            / (h * h)
792    }
793    fn vega_asian(&self, bsd_option: &EquityOption) -> f64 {
794        let h = 1e-4;
795        (Self::asian_price_with(bsd_option, 0.0, h, 0.0, 0.0)
796            - Self::asian_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
797            / (2.0 * h)
798    }
799    fn theta_asian(&self, bsd_option: &EquityOption) -> f64 {
800        let h = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
801        -(Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, h)
802            - Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
803            / (2.0 * h)
804    }
805    fn rho_asian(&self, bsd_option: &EquityOption) -> f64 {
806        let h = 1e-5;
807        (Self::asian_price_with(bsd_option, 0.0, 0.0, h, 0.0)
808            - Self::asian_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
809            / (2.0 * h)
810    }
811
812    // -- Forward-start options (Rubinstein closed form, GBM) ------------
813
814    fn forward_start_price_with(
815        bsd_option: &EquityOption,
816        ds: f64,
817        dsigma: f64,
818        dr: f64,
819        dt_shift: f64,
820    ) -> f64 {
821        let payoff = bsd_option
822            .payoff
823            .as_any()
824            .downcast_ref::<crate::equity::forward_start_option::ForwardStartPayoff>()
825            .expect("payoff of kind ForwardStart must be a ForwardStartPayoff");
826        let t = bsd_option.time_to_maturity() + dt_shift;
827        crate::equity::forward_start_option::forward_start_price(
828            bsd_option.effective_spot() + ds,
829            payoff.strike_fraction,
830            bsd_option.risk_free_rate() + dr,
831            bsd_option.carry_yield(),
832            bsd_option.volatility() + dsigma,
833            payoff.start_fraction * t,
834            t,
835            *bsd_option.payoff.put_or_call(),
836        )
837    }
838    fn npv_forward_start(&self, bsd_option: &EquityOption) -> f64 {
839        Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
840    }
841    fn delta_forward_start(&self, bsd_option: &EquityOption) -> f64 {
842        let h = bsd_option.market.spot.value() * 1e-4;
843        (Self::forward_start_price_with(bsd_option, h, 0.0, 0.0, 0.0)
844            - Self::forward_start_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
845            / (2.0 * h)
846    }
847    fn gamma_forward_start(&self, bsd_option: &EquityOption) -> f64 {
848        let h = bsd_option.market.spot.value() * 1e-3;
849        (Self::forward_start_price_with(bsd_option, h, 0.0, 0.0, 0.0)
850            - 2.0 * Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
851            + Self::forward_start_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
852            / (h * h)
853    }
854    fn vega_forward_start(&self, bsd_option: &EquityOption) -> f64 {
855        let h = 1e-4;
856        (Self::forward_start_price_with(bsd_option, 0.0, h, 0.0, 0.0)
857            - Self::forward_start_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
858            / (2.0 * h)
859    }
860    fn theta_forward_start(&self, bsd_option: &EquityOption) -> f64 {
861        let h = (1.0 / 365.0_f64).min(0.25 * bsd_option.time_to_maturity());
862        -(Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, h)
863            - Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
864            / (2.0 * h)
865    }
866    fn rho_forward_start(&self, bsd_option: &EquityOption) -> f64 {
867        let h = 1e-5;
868        (Self::forward_start_price_with(bsd_option, 0.0, 0.0, h, 0.0)
869            - Self::forward_start_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
870            / (2.0 * h)
871    }
872
873}
874
875
876/// Black-Scholes price of a European vanilla as a pure function of its
877/// inputs (no option object needed).
878pub fn bs_price(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64, put_or_call: PutOrCall) -> f64 {
879    if t <= 0.0 || sigma <= 0.0 {
880        return match put_or_call {
881            PutOrCall::Call => (s * exp(-q * t) - k * exp(-r * t)).max(0.0),
882            PutOrCall::Put => (k * exp(-r * t) - s * exp(-q * t)).max(0.0),
883        };
884    }
885    let sqrt_t = t.sqrt();
886    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
887    let d2 = d1 - sigma * sqrt_t;
888    match put_or_call {
889        PutOrCall::Call => s * exp(-q * t) * norm_cdf(d1) - k * exp(-r * t) * norm_cdf(d2),
890        PutOrCall::Put => k * exp(-r * t) * norm_cdf(-d2) - s * exp(-q * t) * norm_cdf(-d1),
891    }
892}
893
894/// Black-Scholes vega as a pure function (per unit of vol).
895pub fn bs_vega(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
896    let sqrt_t = t.sqrt();
897    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
898    s * exp(-q * t) * norm_pdf(d1) * sqrt_t
899}
900
901/// Black-Scholes vanna, the change in spot delta per unit of volatility.
902pub fn bs_vanna(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
903    let sqrt_t = t.sqrt();
904    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
905    exp(-q * t) * norm_pdf(d1) * (sqrt_t - d1 / sigma)
906}
907
908/// Black-Scholes charm, the change in spot delta per year of calendar time.
909pub fn bs_charm(
910    s: f64,
911    k: f64,
912    r: f64,
913    q: f64,
914    sigma: f64,
915    t: f64,
916    put_or_call: PutOrCall,
917) -> f64 {
918    let sqrt_t = t.sqrt();
919    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
920    let df_q = exp(-q * t);
921    let d1_dt = (r - q + 0.5 * sigma * sigma) / (sigma * sqrt_t) - d1 / (2.0 * t);
922    let delta_component = match put_or_call {
923        PutOrCall::Call => norm_cdf(d1),
924        PutOrCall::Put => norm_cdf(d1) - 1.0,
925    };
926    q * df_q * delta_component - df_q * norm_pdf(d1) * d1_dt
927}
928
929/// Black-Scholes zomma, the change in spot gamma per unit of volatility.
930pub fn bs_zomma(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
931    let sqrt_t = t.sqrt();
932    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
933    let d2 = d1 - sigma * sqrt_t;
934    let gamma = exp(-q * t) * norm_pdf(d1) / (s * sigma * sqrt_t);
935    gamma * (d1 * d2 - 1.0) / sigma
936}
937
938/// Black-Scholes volga (vomma), the change in vega per unit of volatility,
939/// `vega * d1 * d2 / sigma`. Same for calls and puts (parity is
940/// volatility-independent); negative at the money, positive in the wings.
941pub fn bs_volga(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
942    let sqrt_t = t.sqrt();
943    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
944    let d2 = d1 - sigma * sqrt_t;
945    let vega = s * exp(-q * t) * norm_pdf(d1) * sqrt_t;
946    vega * d1 * d2 / sigma
947}
948
949const IMPLIED_VOL_MIN: f64 = 1e-4;
950const IMPLIED_VOL_MAX: f64 = 5.0;
951
952/// Implied Black-Scholes volatility for a European vanilla price.
953///
954/// Safeguarded Newton: full Newton steps while they stay inside the current
955/// bisection bracket `[1e-4, 5.0]`, bisection otherwise, so it converges for
956/// deep in/out-of-the-money quotes where raw Newton diverges. Prices outside
957/// the arbitrage bounds return an error.
958pub fn implied_vol_from_price(
959    s: f64,
960    k: f64,
961    r: f64,
962    q: f64,
963    t: f64,
964    target: f64,
965    put_or_call: PutOrCall,
966) -> Result<f64, RustyQLibError> {
967    if t <= 0.0 {
968        return Err(RustyQLibError::NumericalError("option is expired".to_string()));
969    }
970    let lower_bound = bs_price(s, k, r, q, 0.0, t, put_or_call);
971    let upper_bound = match put_or_call {
972        PutOrCall::Call => s * exp(-q * t),
973        PutOrCall::Put => k * exp(-r * t),
974    };
975    if target < lower_bound - 1e-12 || target > upper_bound + 1e-12 {
976        return Err(RustyQLibError::NumericalError(format!(
977            "price {target} violates arbitrage bounds [{lower_bound}, {upper_bound}]"
978        )));
979    }
980
981    let (lo, hi) = (IMPLIED_VOL_MIN, IMPLIED_VOL_MAX);
982    if bs_price(s, k, r, q, lo, t, put_or_call) > target {
983        return Ok(lo); // at or below the vol floor
984    }
985    if bs_price(s, k, r, q, hi, t, put_or_call) < target {
986        return Err(RustyQLibError::NumericalError(format!("implied vol above {IMPLIED_VOL_MAX}")));
987    }
988
989    // price is increasing in vol (vega > 0), the shape newton_safeguarded
990    // wants; the answer is best-effort even if the tolerance isn't met
991    let tol = 1e-12 * target.max(1.0);
992    let root = crate::core::solvers::Solver1d::new(tol, 100).newton_safeguarded(
993        |sigma| bs_price(s, k, r, q, sigma, t, put_or_call) - target,
994        |sigma| bs_vega(s, k, r, q, sigma, t),
995        lo,
996        hi,
997        0.5,
998    );
999    Ok(root.x)
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use assert_approx_eq::assert_approx_eq;
1005    use super::*;
1006    use chrono::NaiveDate;
1007    use crate::core::curves::{Compounding, InterpolationMethod, Tenor, YieldCurve};
1008    use crate::core::daycount::DayCountConvention;
1009    use crate::core::quotes::Quote;
1010    use crate::core::traits::Instrument;
1011    use crate::core::utils::ContractStyle;
1012    use crate::core::vols::VolSurface;
1013    use crate::equity::utils::{Engine, LongShort, Payoff};
1014    use crate::equity::vanilla_option::{EquityOptionBase, VanillaPayoff};
1015
1016    /// S=100, K=100, sigma=30%, q=0, T=1y (2026-01-01 -> 2027-01-01, Act/365).
1017    fn test_option_with(payoff: Box<dyn Payoff>, curve: YieldCurve) -> EquityOption {
1018        let valuation_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1019        let base = EquityOptionBase {
1020            symbol: "TEST".to_string(),
1021            currency: None,
1022            exchange: None,
1023            name: None,
1024            cusip: None,
1025            isin: None,
1026            settlement_type: None,
1027            strike_price: 100.0,
1028            maturity_date: NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
1029            futures_settlement: None,
1030            multiplier: 1.0,
1031            current_price: Quote::new(0.0),
1032            entry_price: 0.0,
1033            long_short: LongShort::LONG,
1034        };
1035        let market = crate::equity::vanilla_option::EquityMarketData {
1036            valuation_date,
1037            spot: Quote::new(100.0),
1038            dividend_yield: 0.0,
1039            borrow_cost: 0.0,
1040            cash_dividends: vec![],
1041            vol_surface: std::sync::Arc::new(
1042                VolSurface::flat(0.3, valuation_date, DayCountConvention::Act365).unwrap(),
1043            ),
1044            discount_curve: std::sync::Arc::new(curve),
1045        };
1046        EquityOption {
1047            base,
1048            market,
1049            payoff,
1050            engine: crate::equity::utils::PricingEngine::BlackScholes,
1051            model: crate::equity::utils::Model::Gbm,
1052        }
1053    }
1054
1055    fn test_option(put_or_call: PutOrCall, curve: YieldCurve) -> EquityOption {
1056        test_option_with(
1057            Box::new(VanillaPayoff { put_or_call, exercise_style: ContractStyle::European }),
1058            curve,
1059        )
1060    }
1061
1062    fn binary_option_of(
1063        put_or_call: PutOrCall,
1064        binary_type: BinaryType,
1065        cash: f64,
1066    ) -> EquityOption {
1067        test_option_with(
1068            Box::new(BinaryPayoff {
1069                put_or_call,
1070                exercise_style: ContractStyle::European,
1071                binary_type,
1072                cash,
1073            }),
1074            flat_5pct(),
1075        )
1076    }
1077
1078    fn binary_option(put_or_call: PutOrCall) -> EquityOption {
1079        binary_option_of(put_or_call, BinaryType::CashOrNothing, 1.0)
1080    }
1081
1082    fn flat_5pct() -> YieldCurve {
1083        YieldCurve::flat(
1084            0.05,
1085            NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1086            DayCountConvention::Act365,
1087            Compounding::Continuous,
1088        )
1089        .unwrap()
1090    }
1091
1092    // Golden values computed independently (erf-based reference implementation)
1093    #[test]
1094    fn golden_call_npv_and_greeks() {
1095        let option = test_option(PutOrCall::Call, flat_5pct());
1096        assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
1097        assert_approx_eq!(option.delta(), 0.6242517279, 1e-8);
1098        assert_approx_eq!(option.gamma(), 0.0126477644, 1e-8);
1099        assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
1100        assert_approx_eq!(option.theta(), -8.1011898970, 1e-8);
1101        assert_approx_eq!(option.rho(), 48.1939180046, 1e-8);
1102    }
1103
1104    #[test]
1105    fn golden_put_npv_and_greeks() {
1106        let option = test_option(PutOrCall::Put, flat_5pct());
1107        assert_approx_eq!(option.npv(), 9.3541972361, 1e-8);
1108        assert_approx_eq!(option.delta(), -0.3757482721, 1e-8);
1109        assert_approx_eq!(option.gamma(), 0.0126477644, 1e-8);
1110        assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
1111        assert_approx_eq!(option.theta(), -3.3450427745, 1e-8);
1112        assert_approx_eq!(option.rho(), -46.9290244455, 1e-8);
1113    }
1114
1115    #[test]
1116    fn vanilla_vanna_and_charm_match_mixed_price_bumps() {
1117        let option = test_option(PutOrCall::Call, flat_5pct());
1118        let s = option.effective_spot();
1119        let k = option.base.strike_price;
1120        let r = option.risk_free_rate();
1121        let q = option.carry_yield();
1122        let sigma = option.volatility();
1123        let t = option.time_to_maturity();
1124        let hs = 1e-3;
1125        let hv = 1e-5;
1126        let ht = 1e-5;
1127        let pc = PutOrCall::Call;
1128        let vanna_bump = (bs_price(s + hs, k, r, q, sigma + hv, t, pc)
1129            - bs_price(s - hs, k, r, q, sigma + hv, t, pc)
1130            - bs_price(s + hs, k, r, q, sigma - hv, t, pc)
1131            + bs_price(s - hs, k, r, q, sigma - hv, t, pc))
1132            / (4.0 * hs * hv);
1133        let charm_bump = -(bs_price(s + hs, k, r, q, sigma, t + ht, pc)
1134            - bs_price(s - hs, k, r, q, sigma, t + ht, pc)
1135            - bs_price(s + hs, k, r, q, sigma, t - ht, pc)
1136            + bs_price(s - hs, k, r, q, sigma, t - ht, pc))
1137            / (4.0 * hs * ht);
1138        assert_approx_eq!(option.vanna(), vanna_bump, 1e-6);
1139        assert_approx_eq!(option.charm(), charm_bump, 1e-6);
1140    }
1141
1142    #[test]
1143    fn vanilla_gamma_p_and_zomma_match_definitions() {
1144        let option = test_option(PutOrCall::Call, flat_5pct());
1145        let s = option.market.spot.value();
1146        assert_approx_eq!(option.gamma_p(), s * option.gamma() / option.delta(), 1e-12);
1147
1148        let h = 1e-5;
1149        let k = option.base.strike_price;
1150        let r = option.risk_free_rate();
1151        let q = option.carry_yield();
1152        let sigma = option.volatility();
1153        let t = option.time_to_maturity();
1154        // zomma = d(gamma)/d(sigma); bump the analytic gamma in vol rather
1155        // than a price-based gamma (double price-differencing is far too
1156        // noisy to check against a 1e-6 tolerance)
1157        let gamma_at_vol = |vol: f64| {
1158            let d1 = ((s / k).ln() + (r - q + 0.5 * vol * vol) * t) / (vol * t.sqrt());
1159            (-q * t).exp() * norm_pdf(d1) / (s * vol * t.sqrt())
1160        };
1161        let zomma_bump = (gamma_at_vol(sigma + h) - gamma_at_vol(sigma - h)) / (2.0 * h);
1162        assert_approx_eq!(option.zomma(), zomma_bump, 1e-6);
1163    }
1164
1165    #[test]
1166    fn vanilla_volga_matches_vega_bump_and_closed_form() {
1167        let option = test_option(PutOrCall::Call, flat_5pct());
1168        let s = option.effective_spot();
1169        let k = option.base.strike_price;
1170        let r = option.risk_free_rate();
1171        let q = option.carry_yield();
1172        let sigma = option.volatility();
1173        let t = option.time_to_maturity();
1174
1175        // the analytic engine returns the closed form
1176        assert_approx_eq!(option.volga(), bs_volga(s, k, r, q, sigma, t), 1e-12);
1177
1178        // volga = d(vega)/d(sigma); bump the analytic vega in vol
1179        let h = 1e-5;
1180        let vega_at_vol = |vol: f64| bs_vega(s, k, r, q, vol, t);
1181        let volga_bump = (vega_at_vol(sigma + h) - vega_at_vol(sigma - h)) / (2.0 * h);
1182        assert_approx_eq!(option.volga(), volga_bump, 1e-6);
1183
1184        // put and call share volga (parity is volatility-independent)
1185        let put = test_option(PutOrCall::Put, flat_5pct());
1186        assert_approx_eq!(option.volga(), put.volga(), 1e-12);
1187    }
1188
1189    #[test]
1190    fn volga_agrees_across_engines() {
1191        let analytic = test_option(PutOrCall::Call, flat_5pct()).volga();
1192
1193        let mut fd = test_option(PutOrCall::Call, flat_5pct());
1194        fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
1195        assert!((fd.volga() - analytic).abs() < 0.5, "fd {} vs analytic {analytic}", fd.volga());
1196
1197        let mut mc = test_option(PutOrCall::Call, flat_5pct());
1198        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1199        mc.mc_cfg_mut().paths = 200_000;
1200        assert!((mc.volga() - analytic).abs() < 1.5, "mc {} vs analytic {analytic}", mc.volga());
1201    }
1202
1203    #[test]
1204    fn put_call_parity() {
1205        let call = test_option(PutOrCall::Call, flat_5pct());
1206        let put = test_option(PutOrCall::Put, flat_5pct());
1207        let s = call.market.spot.value();
1208        let k_df = call.base.strike_price * call.maturity_discount_factor();
1209        assert_approx_eq!(call.npv() - put.npv(), s - k_df, 1e-10);
1210    }
1211
1212    // Binary golden values computed independently and bump-verified
1213    #[test]
1214    fn golden_binary_call_npv_and_greeks() {
1215        let option = binary_option(PutOrCall::Call);
1216        assert_approx_eq!(option.npv(), 0.4819391800, 1e-8);
1217        assert_approx_eq!(option.delta(), 0.0126477644, 1e-8);
1218        assert_approx_eq!(option.gamma(), -0.0001335042, 1e-8);
1219        assert_approx_eq!(option.vega(), -0.4005125405, 1e-8);
1220        assert_approx_eq!(option.theta(), 0.0209350179, 1e-8);
1221        assert_approx_eq!(option.rho(), 0.7828372637, 1e-8);
1222    }
1223
1224    #[test]
1225    fn golden_binary_put_npv_and_greeks() {
1226        let option = binary_option(PutOrCall::Put);
1227        assert_approx_eq!(option.npv(), 0.4692902445, 1e-8);
1228        assert_approx_eq!(option.delta(), -0.0126477644, 1e-8);
1229        assert_approx_eq!(option.gamma(), 0.0001335042, 1e-8);
1230        assert_approx_eq!(option.vega(), 0.4005125405, 1e-8);
1231        assert_approx_eq!(option.theta(), 0.0266264533, 1e-8);
1232        assert_approx_eq!(option.rho(), -1.7340666882, 1e-8);
1233    }
1234
1235    #[test]
1236    fn binary_call_plus_put_equals_discount_factor() {
1237        let call = binary_option(PutOrCall::Call);
1238        let put = binary_option(PutOrCall::Put);
1239        assert_approx_eq!(call.npv() + put.npv(), call.maturity_discount_factor(), 1e-12);
1240    }
1241
1242    #[test]
1243    fn cash_amount_scales_cash_or_nothing_linearly() {
1244        let unit = binary_option(PutOrCall::Call);
1245        let sized = binary_option_of(PutOrCall::Call, BinaryType::CashOrNothing, 1000.0);
1246        assert_approx_eq!(sized.npv(), 1000.0 * unit.npv(), 1e-9);
1247        assert_approx_eq!(sized.delta(), 1000.0 * unit.delta(), 1e-9);
1248        assert_approx_eq!(sized.vega(), 1000.0 * unit.vega(), 1e-9);
1249    }
1250
1251    // Asset-or-nothing goldens computed independently and bump-verified,
1252    // with q = 2% so the dividend terms are exercised
1253    #[test]
1254    fn golden_asset_or_nothing_call_npv_and_greeks() {
1255        let mut option = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1256        option.market.dividend_yield = 0.02;
1257        assert_approx_eq!(option.npv(), 58.6851146135, 1e-8);
1258        assert_approx_eq!(option.delta(), 1.8502230631, 1e-8);
1259        assert_approx_eq!(option.gamma(), 0.0021056199, 1e-8);
1260        assert_approx_eq!(option.vega(), 6.3168595850, 1e-8);
1261        assert_approx_eq!(option.theta(), -3.5639423965, 1e-8);
1262        assert_approx_eq!(option.rho(), 126.3371917001, 1e-8);
1263    }
1264
1265    #[test]
1266    fn golden_asset_or_nothing_put_npv_and_greeks() {
1267        let mut option = binary_option_of(PutOrCall::Put, BinaryType::AssetOrNothing, 0.0);
1268        option.market.dividend_yield = 0.02;
1269        assert_approx_eq!(option.npv(), 39.3347527172, 1e-8);
1270        assert_approx_eq!(option.delta(), -0.8700243898, 1e-8);
1271        assert_approx_eq!(option.gamma(), -0.0021056199, 1e-8);
1272        assert_approx_eq!(option.vega(), -6.3168595850, 1e-8);
1273        assert_approx_eq!(option.theta(), 5.5243397431, 1e-8);
1274        assert_approx_eq!(option.rho(), -126.3371917001, 1e-8);
1275    }
1276
1277    #[test]
1278    fn asset_call_plus_put_equals_forward_leg() {
1279        let call = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1280        let put = binary_option_of(PutOrCall::Put, BinaryType::AssetOrNothing, 0.0);
1281        // A_c + A_p = S e^{-qT}; q = 0 in the test setup
1282        assert_approx_eq!(call.npv() + put.npv(), 100.0, 1e-10);
1283    }
1284
1285    /// Replication: an asset-or-nothing call is a long vanilla call plus
1286    /// K cash-or-nothing calls — payoff-wise S·1{S>K} = (S-K)^+ + K·1{S>K}.
1287    /// Both sides are implemented independently, so this checks the closed
1288    /// forms (price and every Greek) against each other.
1289    #[test]
1290    fn asset_digital_replicated_by_call_plus_cash_digitals() {
1291        let k = 100.0;
1292        let asset = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1293        let vanilla = test_option(PutOrCall::Call, flat_5pct());
1294        let cash = binary_option_of(PutOrCall::Call, BinaryType::CashOrNothing, k);
1295
1296        assert_approx_eq!(asset.npv(), vanilla.npv() + cash.npv(), 1e-10);
1297        assert_approx_eq!(asset.delta(), vanilla.delta() + cash.delta(), 1e-10);
1298        assert_approx_eq!(asset.gamma(), vanilla.gamma() + cash.gamma(), 1e-10);
1299        assert_approx_eq!(asset.vega(), vanilla.vega() + cash.vega(), 1e-10);
1300        assert_approx_eq!(asset.theta(), vanilla.theta() + cash.theta(), 1e-10);
1301        assert_approx_eq!(asset.rho(), vanilla.rho() + cash.rho(), 1e-10);
1302    }
1303
1304    /// The same replication must hold on the numerical engines, which see
1305    /// only the payoff function.
1306    #[test]
1307    fn asset_digital_replication_holds_across_engines() {
1308        let k = 100.0;
1309        let priced = |engine: Engine, payoff: Box<dyn Payoff>| {
1310            let mut option = test_option_with(payoff, flat_5pct());
1311            option.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
1312            option.npv()
1313        };
1314        let asset_payoff = || -> Box<dyn Payoff> {
1315            Box::new(BinaryPayoff {
1316                put_or_call: PutOrCall::Call,
1317                exercise_style: ContractStyle::European,
1318                binary_type: BinaryType::AssetOrNothing,
1319                cash: 0.0,
1320            })
1321        };
1322        let cash_payoff = || -> Box<dyn Payoff> {
1323            Box::new(BinaryPayoff {
1324                put_or_call: PutOrCall::Call,
1325                exercise_style: ContractStyle::European,
1326                binary_type: BinaryType::CashOrNothing,
1327                cash: k,
1328            })
1329        };
1330        let vanilla_payoff = || -> Box<dyn Payoff> {
1331            Box::new(VanillaPayoff {
1332                put_or_call: PutOrCall::Call,
1333                exercise_style: ContractStyle::European,
1334            })
1335        };
1336        for (engine, tol) in [
1337            (Engine::FiniteDifference, 0.01),
1338            (Engine::Binomial, 0.05),
1339            (Engine::MonteCarlo, 0.05),
1340        ] {
1341            let asset = priced(engine.clone(), asset_payoff());
1342            let replicated =
1343                priced(engine.clone(), vanilla_payoff()) + priced(engine.clone(), cash_payoff());
1344            assert!(
1345                (asset - replicated).abs() < tol,
1346                "{engine:?}: asset={asset} replicated={replicated}"
1347            );
1348        }
1349    }
1350
1351    #[test]
1352    fn asset_digital_matches_analytic_across_engines() {
1353        let analytic = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0).npv();
1354        for (engine, tol) in [
1355            (Engine::FiniteDifference, 0.05),
1356            (Engine::Binomial, 2.0), // digitals on a CRR tree oscillate; jump size is ~K
1357            (Engine::MonteCarlo, 0.05),
1358        ] {
1359            let mut option = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1360            option.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
1361            let value = option.npv();
1362            assert!(
1363                (value - analytic).abs() < tol,
1364                "{engine:?}: {value} vs analytic {analytic}"
1365            );
1366        }
1367    }
1368
1369    // ── Cross-engine agreement ──────────────────────────────────────────
1370
1371    #[test]
1372    fn finite_difference_matches_analytic_vanilla() {
1373        for pc in [PutOrCall::Call, PutOrCall::Put] {
1374            let mut option = test_option(pc, flat_5pct());
1375            let analytic = option.npv();
1376            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
1377            let fd = option.npv();
1378            assert!(
1379                (fd - analytic).abs() < 0.01,
1380                "{pc:?}: fd={fd} analytic={analytic}"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn finite_difference_matches_analytic_binary() {
1387        for pc in [PutOrCall::Call, PutOrCall::Put] {
1388            let mut option = binary_option(pc);
1389            let analytic = option.npv();
1390            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
1391            let fd = option.npv();
1392            assert!(
1393                (fd - analytic).abs() < 0.002,
1394                "{pc:?}: fd={fd} analytic={analytic}"
1395            );
1396        }
1397    }
1398
1399    #[test]
1400    fn binomial_matches_analytic_vanilla_and_binary() {
1401        for pc in [PutOrCall::Call, PutOrCall::Put] {
1402            let mut vanilla = test_option(pc, flat_5pct());
1403            let analytic = vanilla.npv();
1404            vanilla.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
1405            let tree = vanilla.npv();
1406            assert!((tree - analytic).abs() < 0.02, "vanilla {pc:?}: tree={tree} bs={analytic}");
1407
1408            let mut binary = binary_option(pc);
1409            let analytic = binary.npv();
1410            binary.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
1411            let tree = binary.npv();
1412            assert!((tree - analytic).abs() < 0.02, "binary {pc:?}: tree={tree} bs={analytic}");
1413        }
1414    }
1415
1416    #[test]
1417    fn monte_carlo_matches_analytic_vanilla_and_binary() {
1418        // default config: Sobol low-discrepancy terminal simulation
1419        for pc in [PutOrCall::Call, PutOrCall::Put] {
1420            let mut vanilla = test_option(pc, flat_5pct());
1421            let analytic = vanilla.npv();
1422            vanilla.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1423            let mc = vanilla.npv();
1424            assert!((mc - analytic).abs() < 0.02, "vanilla {pc:?}: mc={mc} bs={analytic}");
1425
1426            let mut binary = binary_option(pc);
1427            let analytic = binary.npv();
1428            binary.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1429            let mc = binary.npv();
1430            assert!((mc - analytic).abs() < 0.005, "binary {pc:?}: mc={mc} bs={analytic}");
1431        }
1432    }
1433
1434    #[test]
1435    fn monte_carlo_sobol_beats_default_tolerance_and_is_reproducible() {
1436        let mut option = test_option(PutOrCall::Call, flat_5pct());
1437        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1438        let first = option.npv();
1439        let second = option.npv();
1440        assert_eq!(first, second, "deterministic sampler must reproduce exactly");
1441        assert!((first - 14.2312547860).abs() < 0.02, "sobol mc = {first}");
1442    }
1443
1444    #[test]
1445    fn monte_carlo_path_wise_starts_at_spot_and_schemes_converge() {
1446        // regression for the path-wise bug (paths used to start at the
1447        // option premium instead of the underlying spot)
1448        let analytic = test_option(PutOrCall::Call, flat_5pct()).npv();
1449        for scheme in ["exact", "euler", "milstein"] {
1450            let mut option = test_option(PutOrCall::Call, flat_5pct());
1451            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1452            option.mc_cfg_mut().scheme = scheme.parse().unwrap();
1453            option.mc_cfg_mut().time_steps = 252;
1454            option.mc_cfg_mut().paths = 50_000;
1455            let mc = option.npv();
1456            assert!(
1457                (mc - analytic).abs() < 0.35,
1458                "{scheme}: mc={mc} analytic={analytic}"
1459            );
1460        }
1461    }
1462
1463    #[test]
1464    fn monte_carlo_greeks_match_analytic() {
1465        let mut option = test_option(PutOrCall::Call, flat_5pct());
1466        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1467        // common-random-number bumps against the analytic golden values
1468        assert!((option.delta() - 0.6242517279).abs() < 0.01, "delta {}", option.delta());
1469        assert!((option.gamma() - 0.0126477644).abs() < 0.003, "gamma {}", option.gamma());
1470        assert!((option.vega() - 37.9432933117).abs() < 1.0, "vega {}", option.vega());
1471        assert!((option.theta() - -8.1011898970).abs() < 0.5, "theta {}", option.theta());
1472        assert!((option.rho() - 48.1939180046).abs() < 0.5, "rho {}", option.rho());
1473    }
1474
1475    #[test]
1476    fn lsmc_american_put_close_to_tree_and_dominates_european() {
1477        let european = test_option(PutOrCall::Put, flat_5pct()).npv();
1478        let mut tree_option = test_option_with(
1479            Box::new(VanillaPayoff {
1480                put_or_call: PutOrCall::Put,
1481                exercise_style: ContractStyle::American,
1482            }),
1483            flat_5pct(),
1484        );
1485        tree_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
1486        let tree = tree_option.npv();
1487
1488        let mut lsmc_option = test_option_with(
1489            Box::new(VanillaPayoff {
1490                put_or_call: PutOrCall::Put,
1491                exercise_style: ContractStyle::American,
1492            }),
1493            flat_5pct(),
1494        );
1495        lsmc_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1496        lsmc_option.mc_cfg_mut().paths = 20_000;
1497        let lsmc = lsmc_option.npv();
1498
1499        // LSMC is biased slightly low (suboptimal exercise policy) but must
1500        // sit between the European price and just above the tree price
1501        assert!(lsmc > european, "lsmc {lsmc} must exceed european {european}");
1502        assert!((lsmc - tree).abs() < 0.25, "lsmc={lsmc} tree={tree}");
1503    }
1504
1505    // ── Implied vol solver ──────────────────────────────────────────────
1506
1507    #[test]
1508    fn implied_vol_round_trips_across_strikes_and_vols() {
1509        let (s, r, q) = (100.0, 0.05, 0.02);
1510        for pc in [PutOrCall::Call, PutOrCall::Put] {
1511            for k in [50.0, 80.0, 100.0, 120.0, 200.0] {
1512                for vol in [0.05, 0.2, 0.6, 1.5] {
1513                    for t in [0.05, 0.5, 2.0] {
1514                        let price = bs_price(s, k, r, q, vol, t, pc);
1515                        // skip quotes indistinguishable from intrinsic
1516                        if price - bs_price(s, k, r, q, 0.0, t, pc) < 1e-10 {
1517                            continue;
1518                        }
1519                        let iv = implied_vol_from_price(s, k, r, q, t, price, pc).unwrap();
1520                        // deep in-the-money short-dated quotes have vega ~1e-7,
1521                        // so a double-precision price only pins the vol to
1522                        // ~1e-6 — 1e-5 is the attainable accuracy everywhere
1523                        assert!(
1524                            (iv - vol).abs() < 1e-5,
1525                            "{pc:?} K={k} vol={vol} t={t}: recovered {iv}"
1526                        );
1527                    }
1528                }
1529            }
1530        }
1531    }
1532
1533    #[test]
1534    fn implied_vol_rejects_arbitrage_violating_prices() {
1535        // below intrinsic
1536        assert!(implied_vol_from_price(100.0, 80.0, 0.05, 0.0, 1.0, 10.0, PutOrCall::Call)
1537            .is_err());
1538        // above the underlying
1539        assert!(implied_vol_from_price(100.0, 100.0, 0.05, 0.0, 1.0, 101.0, PutOrCall::Call)
1540            .is_err());
1541    }
1542
1543    // ── Implied surface construction + Dupire local vol round trip ──────
1544
1545    /// Quotes generated from a known smile: sigma(K, T) = base(T) - 0.001*(K-100)
1546    fn smile_vol(k: f64, base: f64) -> f64 {
1547        base - 0.001 * (k - 100.0)
1548    }
1549
1550    fn quoted_option(
1551        k: f64,
1552        maturity: NaiveDate,
1553        market_price: f64,
1554    ) -> Box<EquityOption> {
1555        let mut option = test_option(PutOrCall::Call, flat_5pct());
1556        option.base.strike_price = k;
1557        option.base.maturity_date = maturity;
1558        option.base.current_price = Quote::new(market_price);
1559        Box::new(option)
1560    }
1561
1562    fn build_surface_from_quotes() -> crate::core::vols::VolSurface {
1563        let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1564        let maturities = [
1565            (NaiveDate::from_ymd_opt(2026, 7, 2).unwrap(), 0.23),
1566            (NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(), 0.25),
1567        ];
1568        let mut quotes = Vec::new();
1569        for (maturity, base) in maturities {
1570            let t = (maturity - valuation).num_days() as f64 / 365.0;
1571            for i in 0..13 {
1572                let k = 70.0 + 5.0 * i as f64;
1573                let vol = smile_vol(k, base);
1574                let price = bs_price(100.0, k, 0.05, 0.0, vol, t, PutOrCall::Call);
1575                quotes.push(quoted_option(k, maturity, price));
1576            }
1577        }
1578        crate::equity::vol_surface::build_implied_vol_surface(&quotes).unwrap()
1579    }
1580
1581    #[test]
1582    fn implied_surface_recovers_input_vols() {
1583        let surface = build_surface_from_quotes();
1584        // exact at the quoted pillars (forward is irrelevant on a strike axis)
1585        for (t, base) in [(182.0 / 365.0, 0.23), (1.0, 0.25)] {
1586            for k in [70.0, 85.0, 100.0, 115.0, 130.0] {
1587                let vol = surface.vol(k, 100.0, t);
1588                assert!(
1589                    (vol - smile_vol(k, base)).abs() < 1e-7,
1590                    "K={k} t={t}: {vol} vs {}",
1591                    smile_vol(k, base)
1592                );
1593            }
1594        }
1595    }
1596
1597    fn local_vol_option(surface: crate::core::vols::VolSurface, k: f64) -> EquityOption {
1598        let mut option = test_option(PutOrCall::Call, flat_5pct());
1599        option.base.strike_price = k;
1600        option.market.vol_surface = std::sync::Arc::new(surface);
1601        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1602        option.model = crate::equity::utils::Model::LocalVol;
1603        option.mc_cfg_mut().paths = 20_000;
1604        option
1605    }
1606
1607    #[test]
1608    fn local_vol_prices_back_vanilla_from_calibrated_surface() {
1609        // implied quotes -> implied surface -> Dupire local vol -> MC price
1610        // must reproduce the original Black-Scholes prices
1611        let surface = build_surface_from_quotes();
1612        for k in [90.0, 100.0, 110.0] {
1613            let expected = bs_price(100.0, k, 0.05, 0.0, smile_vol(k, 0.25), 1.0, PutOrCall::Call);
1614            let lv_price = local_vol_option(surface.clone(), k).npv();
1615            assert!(
1616                (lv_price - expected).abs() < 0.3,
1617                "K={k}: local vol {lv_price} vs BS {expected}"
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn local_vol_flat_surface_reproduces_black_scholes() {
1624        let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1625        let surface =
1626            crate::core::vols::VolSurface::flat(0.3, valuation, DayCountConvention::Act365)
1627                .unwrap();
1628        let expected = 14.2312547860; // flat-30% golden
1629        let lv_price = local_vol_option(surface, 100.0).npv();
1630        assert!((lv_price - expected).abs() < 0.3, "{lv_price} vs {expected}");
1631    }
1632
1633    #[test]
1634    fn local_vol_term_structure_reproduces_terminal_implied() {
1635        // 20% to 6M, 25% to 1Y: pricing a 1Y option through the local vol
1636        // (which steps at ~20% then at the ~29.2% forward vol) must recover
1637        // the 25% terminal implied price
1638        let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1639        let surface = crate::core::vols::VolSurface::from_strike_smiles(
1640            &[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0)],
1641            &[vec![(100.0, 0.20)], vec![(100.0, 0.25)]],
1642            valuation,
1643            DayCountConvention::Act365,
1644        )
1645        .unwrap();
1646        let expected = bs_price(100.0, 100.0, 0.05, 0.0, 0.25, 1.0, PutOrCall::Call);
1647        let lv_price = local_vol_option(surface, 100.0).npv();
1648        assert!((lv_price - expected).abs() < 0.3, "{lv_price} vs {expected}");
1649    }
1650
1651    // ── Barrier options ─────────────────────────────────────────────────
1652
1653    fn barrier_option(
1654        put_or_call: PutOrCall,
1655        direction: crate::equity::barrier::BarrierDirection,
1656        knock: crate::equity::barrier::KnockType,
1657        barrier: f64,
1658    ) -> EquityOption {
1659        let mut option = test_option_with(
1660            Box::new(BarrierPayoff {
1661                put_or_call,
1662                exercise_style: ContractStyle::European,
1663                direction,
1664                knock,
1665                barrier,
1666                barrier2: None,
1667                rebate: 0.0,
1668                rebate_at_hit: false,
1669            }),
1670            flat_5pct(),
1671        );
1672        option.market.dividend_yield = 0.02; // match the oracle setup
1673        option
1674    }
1675
1676    #[test]
1677    fn golden_barrier_prices_all_eight_types() {
1678        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1679        // independently generated Reiner-Rubinstein oracle values
1680        // (S=100, K=100, r=5%, q=2%, sigma=30%, T=1)
1681        let cases = [
1682            (Down, In, PutOrCall::Call, 90.0, 4.5095197744),
1683            (Down, Out, PutOrCall::Call, 90.0, 8.5107614943),
1684            (Down, In, PutOrCall::Put, 90.0, 10.0710164338),
1685            (Down, Out, PutOrCall::Put, 90.0, 0.0523399543),
1686            (Up, In, PutOrCall::Call, 120.0, 12.5974705742),
1687            (Up, Out, PutOrCall::Call, 120.0, 0.4228106946),
1688            (Up, In, PutOrCall::Put, 120.0, 1.4297711810),
1689            (Up, Out, PutOrCall::Put, 120.0, 8.6935852071),
1690        ];
1691        for (direction, knock, pc, h, expected) in cases {
1692            let option = barrier_option(pc, direction, knock, h);
1693            assert_approx_eq!(option.npv(), expected, 1e-8);
1694        }
1695    }
1696
1697    #[test]
1698    fn barrier_greeks_satisfy_in_out_parity() {
1699        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1700        // KI + KO = vanilla holds for the Greeks too (no rebate)
1701        let ki = barrier_option(PutOrCall::Call, Down, In, 90.0);
1702        let ko = barrier_option(PutOrCall::Call, Down, Out, 90.0);
1703        let mut vanilla = test_option(PutOrCall::Call, flat_5pct());
1704        vanilla.market.dividend_yield = 0.02;
1705        assert_approx_eq!(ki.npv() + ko.npv(), vanilla.npv(), 1e-10);
1706        assert_approx_eq!(ki.delta() + ko.delta(), vanilla.delta(), 1e-5);
1707        assert_approx_eq!(ki.gamma() + ko.gamma(), vanilla.gamma(), 1e-4);
1708        assert_approx_eq!(ki.vega() + ko.vega(), vanilla.vega(), 1e-4);
1709        assert_approx_eq!(ki.theta() + ko.theta(), vanilla.theta(), 1e-4);
1710        assert_approx_eq!(ki.rho() + ko.rho(), vanilla.rho(), 1e-4);
1711    }
1712
1713    #[test]
1714    fn monte_carlo_barrier_matches_analytic() {
1715        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1716        let cases = [
1717            (Down, Out, PutOrCall::Call, 90.0),
1718            (Down, In, PutOrCall::Put, 90.0),
1719            (Up, Out, PutOrCall::Put, 120.0),
1720            (Up, In, PutOrCall::Call, 110.0),
1721        ];
1722        for (direction, knock, pc, h) in cases {
1723            let analytic = barrier_option(pc, direction, knock, h).npv();
1724            let mut option = barrier_option(pc, direction, knock, h);
1725            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1726            option.mc_cfg_mut().paths = 50_000;
1727            let mc = option.npv();
1728            assert!(
1729                (mc - analytic).abs() < 0.3,
1730                "{direction:?} {knock:?} {pc:?} H={h}: mc={mc} analytic={analytic}"
1731            );
1732        }
1733    }
1734
1735    // ── Asian options ───────────────────────────────────────────────────
1736
1737    fn asian_option(
1738        put_or_call: PutOrCall,
1739        averaging: crate::equity::asian::AveragingType,
1740        strike_type: crate::equity::asian::AsianStrikeType,
1741    ) -> EquityOption {
1742        let mut option = test_option_with(
1743            Box::new(AsianPayoff {
1744                put_or_call,
1745                exercise_style: ContractStyle::European,
1746                averaging,
1747                strike_type,
1748            }),
1749            flat_5pct(),
1750        );
1751        option.market.dividend_yield = 0.02; // match the oracle setup
1752        option
1753    }
1754
1755    #[test]
1756    fn golden_asian_analytic_prices() {
1757        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1758        // independently generated oracle values (S=100 K=100 r=5% q=2% sigma=30% T=1)
1759        let geo = asian_option(PutOrCall::Call, Geometric, FixedStrike);
1760        assert_approx_eq!(geo.npv(), 6.953600, 1e-5);
1761        let arith = asian_option(PutOrCall::Call, Arithmetic, FixedStrike);
1762        assert_approx_eq!(arith.npv(), 7.409272, 1e-5);
1763    }
1764
1765    #[test]
1766    fn geometric_asian_mc_matches_discrete_closed_form() {
1767        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1768        let mut option = asian_option(PutOrCall::Call, Geometric, FixedStrike);
1769        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1770        option.mc_cfg_mut().paths = 50_000;
1771        let mc = option.npv(); // generic path route, 100 monitoring steps
1772        let closed = crate::equity::asian::geometric_asian_price(
1773            100.0, 100.0, 0.05, 0.02, 0.3, 1.0, Some(100), PutOrCall::Call,
1774        );
1775        assert!((mc - closed).abs() < 0.15, "mc={mc} closed={closed}");
1776    }
1777
1778    #[test]
1779    fn geometric_average_strike_analytic_matches_monte_carlo() {
1780        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1781        // the analytic engine now prices floating-strike geometric Asians
1782        // via the exact exchange-option closed form (continuous averaging)
1783        let analytic_option = asian_option(PutOrCall::Call, Geometric, FloatingStrike);
1784        let analytic = analytic_option.npv();
1785        let closed = crate::equity::asian::geometric_average_strike_price(
1786            100.0, 0.05, 0.02, 0.3, 1.0, None, PutOrCall::Call,
1787        );
1788        assert_approx_eq!(analytic, closed, 1e-12);
1789        // engine-level Greeks flow through the same closed form
1790        assert!(analytic_option.delta() > 0.0 && analytic_option.vega() > 0.0);
1791
1792        // Monte Carlo (100 discrete fixings) against the discrete form
1793        let mut mc_option = asian_option(PutOrCall::Call, Geometric, FloatingStrike);
1794        mc_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1795        mc_option.mc_cfg_mut().paths = 50_000;
1796        let mc = mc_option.npv();
1797        let discrete = crate::equity::asian::geometric_average_strike_price(
1798            100.0, 0.05, 0.02, 0.3, 1.0, Some(100), PutOrCall::Call,
1799        );
1800        assert!((mc - discrete).abs() < 0.10, "mc={mc} closed={discrete}");
1801    }
1802
1803    #[test]
1804    fn arithmetic_average_strike_analytic_matches_monte_carlo() {
1805        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1806        // the analytic engine prices arithmetic floating strikes via the
1807        // Henderson-Wojakowski symmetry + Turnbull-Wakeman
1808        for pc in [PutOrCall::Call, PutOrCall::Put] {
1809            let analytic_option = asian_option(pc, Arithmetic, FloatingStrike);
1810            let analytic = analytic_option.npv();
1811            let direct = crate::equity::asian::turnbull_wakeman_average_strike_price(
1812                100.0, 0.05, 0.02, 0.3, 1.0, pc,
1813            );
1814            // 1e-9: the engine's curve-implied rate differs from the flat
1815            // input at ln/exp roundoff level, amplified by the TW moments
1816            assert_approx_eq!(analytic, direct, 1e-9);
1817            assert!(analytic_option.vega() > 0.0, "{pc:?}");
1818
1819            let mut mc_option = asian_option(pc, Arithmetic, FloatingStrike);
1820            mc_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1821            mc_option.mc_cfg_mut().paths = 50_000;
1822            let mc = mc_option.npv();
1823            // TW approximation + discrete-vs-continuous monitoring gap
1824            assert!((mc - analytic).abs() < 0.2, "{pc:?}: mc={mc} analytic={analytic}");
1825        }
1826    }
1827
1828    #[test]
1829    fn double_barriers_and_rebates_price_across_engines() {
1830        use crate::equity::barrier::KnockType;
1831        use crate::equity::builder::EquityOptionBuilder;
1832        use chrono::NaiveDate;
1833        let build = || {
1834            EquityOptionBuilder::new()
1835                .symbol("DKO")
1836                .spot(100.0)
1837                .strike(100.0)
1838                .flat_vol(0.3)
1839                .flat_rate(0.05)
1840                .dividend_yield(0.02)
1841                .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
1842                .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
1843        };
1844        // double knock-out call: analytic (Ikeda-Kunitomo) vs the direct fn
1845        let dko = build().double_barrier(PutOrCall::Call, KnockType::Out, 85.0, 120.0)
1846            .engine(Engine::BlackScholes).build().expect("option must build");
1847        let direct = crate::equity::barrier::double_barrier_price(
1848            100.0, 100.0, 85.0, 120.0, 0.05, 0.02, 0.3, 1.0, KnockType::Out, PutOrCall::Call,
1849        );
1850        assert_approx_eq!(dko.npv(), direct, 1e-9);
1851        // MC (discrete monitoring, generic path route) sits above and near
1852        let mut mc = build().double_barrier(PutOrCall::Call, KnockType::Out, 85.0, 120.0)
1853            .engine(Engine::MonteCarlo).build().expect("option must build");
1854        mc.mc_cfg_mut().paths = 50_000;
1855        mc.mc_cfg_mut().time_steps = 500;
1856        let mc_px = mc.npv();
1857        assert!(mc_px > dko.npv() - 0.02 && (mc_px - dko.npv()).abs() < 0.30,
1858            "mc {mc_px} vs analytic {}", dko.npv());
1859
1860        // knock-out with an at-expiry rebate: analytic vs MC agree in
1861        // convention; the rebate lifts the price by less than its PV
1862        let rebate = 5.0;
1863        let plain = build()
1864            .barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
1865                KnockType::Out, 120.0)
1866            .engine(Engine::BlackScholes).build().expect("option must build").npv();
1867        let rebated = build()
1868            .barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
1869                KnockType::Out, 120.0)
1870            .barrier_rebate(rebate, false)
1871            .engine(Engine::BlackScholes).build().expect("option must build");
1872        assert!(rebated.npv() > plain && rebated.npv() < plain + rebate);
1873        let mut mc_rebate = build()
1874            .barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
1875                KnockType::Out, 120.0)
1876            .barrier_rebate(rebate, false)
1877            .engine(Engine::MonteCarlo).build().expect("option must build");
1878        mc_rebate.mc_cfg_mut().paths = 50_000;
1879        mc_rebate.mc_cfg_mut().time_steps = 500;
1880        assert!((mc_rebate.npv() - rebated.npv()).abs() < 0.30,
1881            "mc {} vs analytic {}", mc_rebate.npv(), rebated.npv());
1882        // at-hit rebates are analytic-engine territory
1883        let at_hit = build()
1884            .barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
1885                KnockType::Out, 120.0)
1886            .barrier_rebate(rebate, true)
1887            .engine(Engine::BlackScholes).build().expect("option must build");
1888        assert!(at_hit.npv() > rebated.npv(), "earlier payment is worth more");
1889    }
1890
1891    #[test]
1892    fn lookback_analytic_engine_matches_the_closed_forms_and_mc_converges() {
1893        use crate::equity::vanilla_option::{LookbackPayoff, LookbackType};
1894        let lookback = |lookback_type, pc| {
1895            let mut option = test_option_with(
1896                Box::new(LookbackPayoff {
1897                    put_or_call: pc,
1898                    exercise_style: ContractStyle::European,
1899                    lookback_type,
1900                }),
1901                flat_5pct(),
1902            );
1903            option.market.dividend_yield = 0.02;
1904            option
1905        };
1906        // analytic engine = the continuous closed forms
1907        let float_call = lookback(LookbackType::FloatingStrike, PutOrCall::Call);
1908        let direct = crate::equity::lookback::floating_strike_lookback_price(
1909            100.0, 100.0, 0.05, 0.02, 0.3, 1.0, PutOrCall::Call,
1910        );
1911        assert_approx_eq!(float_call.npv(), direct, 1e-9);
1912        // Greeks are populated (not silently zero). The fresh floating
1913        // lookback is a volatility machine, not a directional bet: its
1914        // delta is small (~0.225 -- V is homogeneous in S) while its vega
1915        // is enormous
1916        assert!(float_call.delta() > 0.1 && float_call.delta() < 0.4, "{}", float_call.delta());
1917        assert!(float_call.vega() > 30.0, "{}", float_call.vega());
1918
1919        // MC monitors discretely: below the continuous form for the
1920        // max/min-based payoffs, and converging as steps grow
1921        let mut coarse = lookback(LookbackType::FloatingStrike, PutOrCall::Call);
1922        coarse.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1923        coarse.mc_cfg_mut().paths = 40_000;
1924        coarse.mc_cfg_mut().time_steps = 50;
1925        let coarse_px = coarse.npv();
1926        let mut fine = lookback(LookbackType::FloatingStrike, PutOrCall::Call);
1927        fine.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1928        fine.mc_cfg_mut().paths = 40_000;
1929        fine.mc_cfg_mut().time_steps = 400;
1930        let fine_px = fine.npv();
1931        assert!(coarse_px < direct && fine_px < direct, "{coarse_px} {fine_px} vs {direct}");
1932        assert!(direct - fine_px < direct - coarse_px, "finer grid must close the gap");
1933        assert!(direct - fine_px < 1.5, "fine-grid gap {}", direct - fine_px);
1934
1935        // fixed strike side too
1936        let fixed_put = lookback(LookbackType::FixedStrike, PutOrCall::Put);
1937        let direct_put = crate::equity::lookback::fixed_strike_lookback_price(
1938            100.0, 100.0, 100.0, 0.05, 0.02, 0.3, 1.0, PutOrCall::Put,
1939        );
1940        assert_approx_eq!(fixed_put.npv(), direct_put, 1e-9);
1941        let mut mc_put = lookback(LookbackType::FixedStrike, PutOrCall::Put);
1942        mc_put.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1943        mc_put.mc_cfg_mut().paths = 40_000;
1944        mc_put.mc_cfg_mut().time_steps = 400;
1945        let mc_px = mc_put.npv();
1946        assert!(mc_px < direct_put && direct_put - mc_px < 1.5, "{mc_px} vs {direct_put}");
1947    }
1948
1949    #[test]
1950    fn phoenix_certificate_prices_and_orders_sensibly() {
1951        use crate::equity::builder::EquityOptionBuilder;
1952        use chrono::NaiveDate;
1953        let build = |coupon_barrier: f64, memory: bool| {
1954            EquityOptionBuilder::new()
1955                .symbol("PHX")
1956                .spot(100.0)
1957                .strike(100.0)
1958                .flat_vol(0.25)
1959                .flat_rate(0.03)
1960                .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
1961                .maturity_date(NaiveDate::from_ymd_opt(2028, 1, 1).unwrap())
1962                .phoenix(110.0, coupon_barrier, 60.0, 2.0, 8, 100.0, memory)
1963                .engine(Engine::MonteCarlo)
1964                .paths(30_000)
1965                .build().expect("option must build")
1966        };
1967        let plain = build(80.0, false).npv();
1968        let with_memory = build(80.0, true).npv();
1969        let lower_barrier = build(60.0, false).npv();
1970        // memory can only add coupons; a lower coupon barrier pays more often
1971        assert!(with_memory > plain, "memory {with_memory} vs {plain}");
1972        assert!(lower_barrier > plain, "cb60 {lower_barrier} vs cb80 {plain}");
1973        // sane range: below max possible (notional + all coupons), above
1974        // a deeply protected floor
1975        assert!(plain > 80.0 && with_memory < 100.0 + 16.0 + 1.0, "{plain} {with_memory}");
1976    }
1977
1978    #[test]
1979    fn arithmetic_asian_cv_mc_close_to_turnbull_wakeman() {
1980        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1981        let analytic = asian_option(PutOrCall::Call, Arithmetic, FixedStrike).npv();
1982        let mut option = asian_option(PutOrCall::Call, Arithmetic, FixedStrike);
1983        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1984        option.mc_cfg_mut().paths = 50_000;
1985        let mc = option.npv(); // control-variate route
1986        // TW is a moment-matching approximation and the MC monitors
1987        // discretely, so agreement is at the approximation level, not
1988        // sampler noise level
1989        assert!((mc - analytic).abs() < 0.08, "cv-mc={mc} tw={analytic}");
1990    }
1991
1992    #[test]
1993    fn arithmetic_average_dominates_geometric_on_same_paths() {
1994        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1995        let price_mc = |averaging| {
1996            let mut option = asian_option(PutOrCall::Call, averaging, FixedStrike);
1997            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
1998            option.mc_cfg_mut().paths = 20_000;
1999            // force the generic path route for both by disabling the CV's
2000            // exact-scheme precondition
2001            option.mc_cfg_mut().scheme = crate::equity::montecarlo::DiscretizationScheme::Euler;
2002            option.mc_cfg_mut().time_steps = 100;
2003            option.npv()
2004        };
2005        assert!(price_mc(Arithmetic) > price_mc(Geometric), "AM-GM inequality");
2006    }
2007
2008    #[test]
2009    fn arithmetic_floating_strike_asian_prices_on_mc() {
2010        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
2011        let mut option = asian_option(PutOrCall::Call, Arithmetic, FloatingStrike);
2012        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2013        option.mc_cfg_mut().paths = 20_000;
2014        let price = option.npv();
2015        // floating-strike call: pays (S_T - average)^+; positive, below vanilla
2016        let vanilla = test_option(PutOrCall::Call, flat_5pct()).npv();
2017        assert!(price > 0.0 && price < vanilla, "{price}");
2018    }
2019
2020
2021    // ── FD upgrades: grid Greeks, barriers, local vol, config ───────────
2022
2023    #[test]
2024    fn fd_grid_greeks_match_analytic_for_european() {
2025        let mut option = test_option(PutOrCall::Call, flat_5pct());
2026        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2027        assert!((option.delta() - 0.6242517279).abs() < 1e-3, "delta {}", option.delta());
2028        assert!((option.gamma() - 0.0126477644).abs() < 1e-4, "gamma {}", option.gamma());
2029        assert!((option.theta() - -8.1011898970).abs() < 0.03, "theta {}", option.theta());
2030        assert!((option.vega() - 37.9432933117).abs() < 0.05, "vega {}", option.vega());
2031        assert!((option.rho() - 48.1939180046).abs() < 0.05, "rho {}", option.rho());
2032    }
2033
2034    #[test]
2035    fn fd_american_put_greeks_differ_from_european_correctly() {
2036        let mut american = test_option_with(
2037            Box::new(VanillaPayoff {
2038                put_or_call: PutOrCall::Put,
2039                exercise_style: ContractStyle::American,
2040            }),
2041            flat_5pct(),
2042        );
2043        american.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2044        let european_delta = -0.3757482721; // analytic European put delta
2045        // early exercise makes the American put delta more negative and
2046        // theta less negative than the European
2047        assert!(
2048            american.delta() < european_delta,
2049            "american delta {} vs european {european_delta}",
2050            american.delta()
2051        );
2052        assert!(american.npv() > test_option(PutOrCall::Put, flat_5pct()).npv());
2053    }
2054
2055    #[test]
2056    fn fd_brennan_schwartz_american_matches_tree() {
2057        let mut fd = test_option_with(
2058            Box::new(VanillaPayoff {
2059                put_or_call: PutOrCall::Put,
2060                exercise_style: ContractStyle::American,
2061            }),
2062            flat_5pct(),
2063        );
2064        fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2065        let mut tree = test_option_with(
2066            Box::new(VanillaPayoff {
2067                put_or_call: PutOrCall::Put,
2068                exercise_style: ContractStyle::American,
2069            }),
2070            flat_5pct(),
2071        );
2072        tree.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
2073        assert!((fd.npv() - tree.npv()).abs() < 0.02, "fd={} tree={}", fd.npv(), tree.npv());
2074    }
2075
2076    #[test]
2077    fn fd_barrier_matches_reiner_rubinstein() {
2078        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
2079        for (direction, knock, pc, h) in [
2080            (Down, Out, PutOrCall::Call, 90.0),
2081            (Down, In, PutOrCall::Call, 90.0),
2082            (Up, Out, PutOrCall::Put, 120.0),
2083            (Up, In, PutOrCall::Put, 120.0),
2084        ] {
2085            let analytic = barrier_option(pc, direction, knock, h).npv();
2086            let mut option = barrier_option(pc, direction, knock, h);
2087            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2088            let fd = option.npv();
2089            assert!(
2090                (fd - analytic).abs() < 0.02,
2091                "{direction:?} {knock:?} {pc:?} H={h}: fd={fd} analytic={analytic}"
2092            );
2093        }
2094    }
2095
2096    #[test]
2097    fn fd_barrier_in_out_parity_on_grid() {
2098        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
2099        let mut ki = barrier_option(PutOrCall::Call, Down, In, 90.0);
2100        let mut ko = barrier_option(PutOrCall::Call, Down, Out, 90.0);
2101        ki.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2102        ko.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2103        let mut vanilla = test_option(PutOrCall::Call, flat_5pct());
2104        vanilla.market.dividend_yield = 0.02;
2105        vanilla.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2106        assert!((ki.npv() + ko.npv() - vanilla.npv()).abs() < 1e-9);
2107        assert!((ki.delta() + ko.delta() - vanilla.delta()).abs() < 1e-9);
2108    }
2109
2110    #[test]
2111    fn fd_local_vol_flat_surface_matches_black_scholes() {
2112        let mut option = test_option(PutOrCall::Call, flat_5pct());
2113        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2114        option.model = crate::equity::utils::Model::LocalVol;
2115        // flat surface: local vol == implied vol, FD-LV must equal FD-GBM
2116        assert_approx_eq!(option.npv(), 14.2312547860, 5e-3);
2117    }
2118
2119    #[test]
2120    fn fd_grid_is_configurable() {
2121        let mut coarse = test_option(PutOrCall::Call, flat_5pct());
2122        coarse.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2123        coarse.fd_cfg_mut().spot_steps = 100;
2124        coarse.fd_cfg_mut().time_steps = 50;
2125        // still accurate at a quarter of the resolution
2126        assert!((coarse.npv() - 14.2312547860).abs() < 0.02, "{}", coarse.npv());
2127    }
2128
2129    // ── MC upgrades: QMC paths, stats, determinism ──────────────────────
2130
2131    #[test]
2132    fn qmc_path_wise_prices_accurately() {
2133        // multi-step path simulation through the Brownian bridge + QMC
2134        let mut option = test_option(PutOrCall::Call, flat_5pct());
2135        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2136        option.mc_cfg_mut().time_steps = 64;
2137        option.mc_cfg_mut().paths = 20_000;
2138        let qmc = option.npv();
2139        assert!((qmc - 14.2312547860).abs() < 0.05, "qmc path-wise {qmc}");
2140    }
2141
2142    #[test]
2143    fn mc_stats_reports_consistent_standard_error() {
2144        let mut option = test_option(PutOrCall::Call, flat_5pct());
2145        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2146        option.mc_cfg_mut().sampler = crate::equity::montecarlo::Sampler::PseudoRandom;
2147        let stats = crate::equity::montecarlo::npv_with_stats(&option);
2148        assert!(stats.std_err > 0.0 && stats.std_err < 1.0);
2149        assert!(stats.paths == 100_000 && stats.steps == 1);
2150        // iid pseudo draws: the analytic value must sit within a few
2151        // standard errors of the estimate
2152        assert!(
2153            (stats.pv - 14.2312547860).abs() < 5.0 * stats.std_err,
2154            "pv={} stderr={}",
2155            stats.pv,
2156            stats.std_err
2157        );
2158    }
2159
2160    #[test]
2161    fn parallel_paths_are_bit_reproducible() {
2162        for sampler in
2163            [crate::equity::montecarlo::Sampler::Sobol, crate::equity::montecarlo::Sampler::PseudoRandom]
2164        {
2165            let mut option = test_option(PutOrCall::Call, flat_5pct());
2166            option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2167            option.mc_cfg_mut().sampler = sampler;
2168            option.mc_cfg_mut().time_steps = 32;
2169            option.mc_cfg_mut().paths = 30_000;
2170            assert_eq!(option.npv(), option.npv());
2171        }
2172    }
2173
2174    // ── Heston stochastic vol ───────────────────────────────────────────
2175
2176    fn heston_option(payoff: Box<dyn Payoff>) -> EquityOption {
2177        let mut option = test_option_with(payoff, flat_5pct());
2178        option.market.dividend_yield = 0.02;
2179        option.model = crate::equity::utils::Model::Heston(crate::equity::heston::HestonParams {
2180            v0: 0.09,
2181            kappa: 2.0,
2182            theta: 0.09,
2183            vol_of_vol: 0.4,
2184            rho: -0.7,
2185        });
2186        option
2187    }
2188
2189    fn heston_vanilla(pc: PutOrCall) -> EquityOption {
2190        heston_option(Box::new(VanillaPayoff {
2191            put_or_call: pc,
2192            exercise_style: ContractStyle::European,
2193        }))
2194    }
2195
2196    #[test]
2197    fn heston_mc_matches_semi_analytic() {
2198        for pc in [PutOrCall::Call, PutOrCall::Put] {
2199            let analytic = heston_vanilla(pc).npv();
2200            let mut mc = heston_vanilla(pc);
2201            mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2202            mc.mc_cfg_mut().paths = 50_000;
2203            let mc_price = mc.npv();
2204            // full-truncation Euler bias + sampler noise at 50k x 250
2205            assert!(
2206                (mc_price - analytic).abs() < 0.15,
2207                "{pc:?}: mc={mc_price} analytic={analytic}"
2208            );
2209        }
2210    }
2211
2212    #[test]
2213    fn heston_binary_mc_matches_semi_analytic() {
2214        let payoff = || -> Box<dyn Payoff> {
2215            Box::new(BinaryPayoff {
2216                put_or_call: PutOrCall::Call,
2217                exercise_style: ContractStyle::European,
2218                binary_type: BinaryType::CashOrNothing,
2219                cash: 1.0,
2220            })
2221        };
2222        let analytic = heston_option(payoff()).npv();
2223        let mut mc = heston_option(payoff());
2224        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2225        mc.mc_cfg_mut().paths = 50_000;
2226        assert!((mc.npv() - analytic).abs() < 0.01, "mc={} analytic={analytic}", mc.npv());
2227    }
2228
2229    #[test]
2230    fn heston_greeks_are_consistent() {
2231        let call = heston_vanilla(PutOrCall::Call);
2232        let put = heston_vanilla(PutOrCall::Put);
2233        // parity: delta_call - delta_put = e^{-qT}
2234        let dfq = (-0.02_f64).exp();
2235        assert!((call.delta() - put.delta() - dfq).abs() < 1e-4);
2236        // same gamma and vega for call and put by parity
2237        assert!((call.gamma() - put.gamma()).abs() < 1e-6);
2238        assert!((call.vega() - put.vega()).abs() < 1e-4);
2239        assert!(call.vega() > 0.0);
2240    }
2241
2242    #[test]
2243    fn heston_barrier_and_asian_price_on_mc() {
2244        // knock-out <= vanilla under the same dynamics; asian < vanilla
2245        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
2246        let vanilla = {
2247            let mut o = heston_vanilla(PutOrCall::Call);
2248            o.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2249            o.mc_cfg_mut().paths = 20_000;
2250            o.npv()
2251        };
2252        let mut ko = heston_option(Box::new(BarrierPayoff {
2253            put_or_call: PutOrCall::Call,
2254            exercise_style: ContractStyle::European,
2255            direction: Down,
2256            knock: Out,
2257            barrier: 90.0,
2258                barrier2: None,
2259                rebate: 0.0,
2260                rebate_at_hit: false,
2261        }));
2262        ko.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2263        ko.mc_cfg_mut().paths = 20_000;
2264        let ko_price = ko.npv();
2265        assert!(ko_price > 0.0 && ko_price < vanilla, "ko={ko_price} vanilla={vanilla}");
2266
2267        let mut asian = heston_option(Box::new(AsianPayoff {
2268            put_or_call: PutOrCall::Call,
2269            exercise_style: ContractStyle::European,
2270            averaging: crate::equity::asian::AveragingType::Arithmetic,
2271            strike_type: crate::equity::asian::AsianStrikeType::FixedStrike,
2272        }));
2273        asian.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2274        asian.mc_cfg_mut().paths = 20_000;
2275        let asian_price = asian.npv();
2276        assert!(asian_price > 0.0 && asian_price < vanilla);
2277    }
2278
2279    fn heston_american_put() -> Box<dyn Payoff> {
2280        Box::new(VanillaPayoff {
2281            put_or_call: PutOrCall::Put,
2282            exercise_style: ContractStyle::American,
2283        })
2284    }
2285
2286    #[test]
2287    fn heston_american_put_degenerates_to_the_lattice_price() {
2288        // vol_of_vol -> 0 with v0 = theta = sigma^2: the dynamics collapse
2289        // to GBM, so the (S, v)-basis LSMC on QE paths must reproduce the
2290        // binomial American price at sigma = 30%
2291        let mut lattice = test_option_with(heston_american_put(), flat_5pct());
2292        lattice.market.dividend_yield = 0.02;
2293        lattice.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
2294        let reference = lattice.npv();
2295
2296        let mut mc = heston_option(heston_american_put());
2297        mc.model = crate::equity::utils::Model::Heston(crate::equity::heston::HestonParams {
2298            v0: 0.09,
2299            kappa: 1.0,
2300            theta: 0.09,
2301            vol_of_vol: 1e-3,
2302            rho: 0.0,
2303        });
2304        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2305        mc.mc_cfg_mut().paths = 50_000;
2306        let lsmc = mc.npv();
2307        // two-pass LSMC is slightly low-biased (suboptimal fitted rule)
2308        // on top of sampler noise
2309        assert!((lsmc - reference).abs() < 0.2, "lsmc={lsmc} lattice={reference}");
2310    }
2311
2312    #[test]
2313    fn heston_american_put_carries_an_early_exercise_premium() {
2314        // r > q: the American put must be worth strictly more than the
2315        // semi-analytic European under the same (full-strength) dynamics
2316        let european = heston_vanilla(PutOrCall::Put).npv();
2317        let mut american = heston_option(heston_american_put());
2318        american.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2319        american.mc_cfg_mut().paths = 50_000;
2320        let am = american.npv();
2321        assert!(am > european + 0.05, "american={am} european={european}");
2322        // sanity ceiling: the premium is a fraction of the option value
2323        assert!(am - european < 3.0, "american={am} european={european}");
2324    }
2325
2326    // ── Borrow cost and dividends ───────────────────────────────────────
2327
2328    #[test]
2329    fn borrow_cost_is_equivalent_to_extra_dividend_yield() {
2330        for engine in [Engine::BlackScholes, Engine::FiniteDifference, Engine::MonteCarlo] {
2331            let mut with_borrow = test_option(PutOrCall::Call, flat_5pct());
2332            with_borrow.market.dividend_yield = 0.01;
2333            with_borrow.market.borrow_cost = 0.03;
2334            with_borrow.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
2335            let mut with_yield = test_option(PutOrCall::Call, flat_5pct());
2336            with_yield.market.dividend_yield = 0.04;
2337            with_yield.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
2338            assert!(
2339                (with_borrow.npv() - with_yield.npv()).abs() < 1e-12,
2340                "{engine:?}: borrow {} vs yield {}",
2341                with_borrow.npv(),
2342                with_yield.npv()
2343            );
2344        }
2345    }
2346
2347    fn dividend_paying_option(pc: PutOrCall) -> EquityOption {
2348        let mut option = test_option(pc, flat_5pct());
2349        option.market.cash_dividends =
2350            vec![(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), 3.0)];
2351        option
2352    }
2353
2354    #[test]
2355    fn cash_dividend_prices_as_escrowed_spot_analytically() {
2356        let option = dividend_paying_option(PutOrCall::Call);
2357        let t_div = (NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
2358            - NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2359        .num_days() as f64
2360            / 365.0;
2361        let s_eff = 100.0 - 3.0 * (-0.05 * t_div).exp();
2362        assert!((option.effective_spot() - s_eff).abs() < 1e-10);
2363        let expected = bs_price(s_eff, 100.0, 0.05, 0.0, 0.3, 1.0, PutOrCall::Call);
2364        assert_approx_eq!(option.npv(), expected, 1e-10);
2365    }
2366
2367    #[test]
2368    fn put_call_parity_with_dividends_and_borrow() {
2369        let mut call = dividend_paying_option(PutOrCall::Call);
2370        let mut put = dividend_paying_option(PutOrCall::Put);
2371        call.market.borrow_cost = 0.02;
2372        put.market.borrow_cost = 0.02;
2373        let parity = call.effective_spot() * (-call.carry_yield()).exp()
2374            - 100.0 * (-0.05_f64).exp();
2375        assert_approx_eq!(call.npv() - put.npv(), parity, 1e-10);
2376    }
2377
2378    #[test]
2379    fn mc_dividend_jumps_close_to_escrowed_analytic() {
2380        // the jump model (dividends subtracted on the path) and the
2381        // escrowed model differ slightly by construction; they must agree
2382        // at the tens-of-basis-points level for moderate dividends
2383        let analytic = dividend_paying_option(PutOrCall::Call).npv();
2384        let mut mc = dividend_paying_option(PutOrCall::Call);
2385        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2386        mc.mc_cfg_mut().time_steps = 100;
2387        mc.mc_cfg_mut().paths = 50_000;
2388        assert!((mc.npv() - analytic).abs() < 0.3, "mc={} analytic={analytic}", mc.npv());
2389    }
2390
2391    #[test]
2392    fn fd_dividend_jump_condition_consistent_with_mc_jump_model() {
2393        // FD and path-MC both implement the jump (piecewise lognormal)
2394        // dividend model and must agree tightly; both sit a known
2395        // ~0.1-0.2 above the escrowed analytic for a call (the classic
2396        // escrowed-vs-jump model difference)
2397        let mut fd = dividend_paying_option(PutOrCall::Call);
2398        fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2399        let mut mc = dividend_paying_option(PutOrCall::Call);
2400        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2401        mc.mc_cfg_mut().time_steps = 100;
2402        mc.mc_cfg_mut().paths = 50_000;
2403        assert!((fd.npv() - mc.npv()).abs() < 0.1, "fd={} mc={}", fd.npv(), mc.npv());
2404        let escrowed = dividend_paying_option(PutOrCall::Call).npv();
2405        assert!((fd.npv() - escrowed).abs() < 0.3, "fd={} escrowed={escrowed}", fd.npv());
2406    }
2407
2408    #[test]
2409    fn forward_price_reflects_borrow_and_cash_dividends() {
2410        let mut option = dividend_paying_option(PutOrCall::Call);
2411        option.market.borrow_cost = 0.02;
2412        let expected = option.effective_spot() * ((0.05 - 0.02) * 1.0_f64).exp();
2413        assert_approx_eq!(option.forward_price(), expected, 1e-10);
2414    }
2415
2416    #[test]
2417    fn cash_dividend_with_carry_discounts_at_net_carry() {
2418        // with a continuous carry present, the cash dividend must be
2419        // discounted at (r - carry), not r, so the escrowed spot and the
2420        // analytic forward match the jump-model ground truth
2421        // F = (S - D e^{-(r-carry)t}) e^{(r-carry)T}
2422        let carry = 0.03;
2423        let mut option = dividend_paying_option(PutOrCall::Call);
2424        option.market.borrow_cost = carry;
2425        let (r, s, d, t) = (0.05, 100.0, 3.0, 1.0);
2426        let t_div = (NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
2427            - NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2428        .num_days() as f64
2429            / 365.0;
2430
2431        let s_eff = s - d * (-(r - carry) * t_div).exp();
2432        assert_approx_eq!(option.effective_spot(), s_eff, 1e-10);
2433
2434        let jump_forward = s_eff * ((r - carry) * t).exp();
2435        assert_approx_eq!(option.forward_price(), jump_forward, 1e-10);
2436    }
2437
2438    #[test]
2439    fn net_carry_discounting_flows_through_to_price_and_stays_near_jump_engines() {
2440        // The fix guarantees forward consistency (checked above); this
2441        // confirms it flows through to the analytic price, which is exactly
2442        // the escrowed lognormal on the net-carry spot, and that the price
2443        // stays within the escrowed-vs-jump tolerance of the FD engine.
2444        //
2445        // Note: matching the forward does NOT make the escrowed *price*
2446        // equal the jump price — the escrowed model applies vol to S - PV
2447        // rather than to S with a jump, an intrinsic approximation. So we
2448        // check the band, not exact agreement.
2449        let carry = 0.03;
2450        let mut analytic = dividend_paying_option(PutOrCall::Call);
2451        analytic.market.borrow_cost = carry;
2452        let a = analytic.npv();
2453
2454        let expected =
2455            bs_price(analytic.effective_spot(), 100.0, 0.05, carry, 0.3, 1.0, PutOrCall::Call);
2456        assert_approx_eq!(a, expected, 1e-10);
2457
2458        let mut fd = dividend_paying_option(PutOrCall::Call);
2459        fd.market.borrow_cost = carry;
2460        fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2461        assert!((a - fd.npv()).abs() < 0.2, "analytic {a} vs fd {}", fd.npv());
2462    }
2463
2464    // ── Options on futures (Black-76) ───────────────────────────────────
2465
2466    fn futures_option(
2467        pc: PutOrCall,
2468        settlement: crate::equity::black76::FuturesSettlement,
2469    ) -> EquityOption {
2470        crate::equity::builder::EquityOptionBuilder::new()
2471            .symbol("FUT")
2472            .spot(100.0) // interpreted as the futures price F
2473            .strike(100.0)
2474            .flat_vol(0.30)
2475            .flat_rate(0.05)
2476            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2477            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
2478            .vanilla(pc)
2479            .on_future(settlement)
2480            .engine(Engine::BlackScholes)
2481            .build().expect("option must build")
2482    }
2483
2484    #[test]
2485    fn black76_option_api_matches_closed_form() {
2486        use crate::equity::black76::FuturesSettlement::*;
2487        for (settlement, gold) in [(Discounted, 11.34202064), (Margined, 11.92353847)] {
2488            let option = futures_option(PutOrCall::Call, settlement);
2489            assert_approx_eq!(option.npv(), gold, 1e-7);
2490        }
2491        // spot-check a Greek reaches the option API too
2492        let call = futures_option(PutOrCall::Call, Discounted);
2493        assert_approx_eq!(call.delta(), 0.53232482, 1e-7);
2494        assert_approx_eq!(call.rho(), -11.34202064, 1e-6);
2495    }
2496
2497    #[test]
2498    fn margined_futures_option_has_zero_rho_and_exceeds_discounted() {
2499        use crate::equity::black76::FuturesSettlement::*;
2500        let disc = futures_option(PutOrCall::Call, Discounted).npv();
2501        let marg = futures_option(PutOrCall::Call, Margined);
2502        assert_eq!(marg.rho(), 0.0);
2503        assert!(marg.npv() > disc);
2504        assert_approx_eq!(marg.npv(), disc * (0.05_f64).exp(), 1e-9);
2505    }
2506
2507    #[test]
2508    fn black76_on_the_forward_equals_spot_black_scholes() {
2509        // a discounted Black-76 option on F = S e^{(r-q)T} must equal the
2510        // equivalent spot option priced by the equity Black-Scholes engine
2511        let (s, q, r, t): (f64, f64, f64, f64) = (100.0, 0.02, 0.05, 1.0);
2512        let fwd = s * ((r - q) * t).exp();
2513        let futures_opt = crate::equity::builder::EquityOptionBuilder::new()
2514            .spot(fwd)
2515            .strike(100.0)
2516            .flat_vol(0.30)
2517            .flat_rate(r)
2518            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2519            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
2520            .vanilla(PutOrCall::Call)
2521            .on_future(crate::equity::black76::FuturesSettlement::Discounted)
2522            .build().expect("option must build");
2523        let spot_opt = crate::equity::builder::EquityOptionBuilder::new()
2524            .spot(s)
2525            .strike(100.0)
2526            .flat_vol(0.30)
2527            .flat_rate(r)
2528            .dividend_yield(q)
2529            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2530            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
2531            .vanilla(PutOrCall::Call)
2532            .build().expect("option must build");
2533        assert_approx_eq!(futures_opt.npv(), spot_opt.npv(), 1e-10);
2534    }
2535
2536    #[test]
2537    fn put_call_parity_on_futures_both_styles() {
2538        use crate::equity::black76::FuturesSettlement::*;
2539        for (settlement, df) in [(Discounted, (-0.05_f64).exp()), (Margined, 1.0)] {
2540            let c = futures_option(PutOrCall::Call, settlement).npv();
2541            let p = futures_option(PutOrCall::Put, settlement).npv();
2542            // F = K = 100 -> parity value is 0
2543            assert_approx_eq!(c - p, df * (100.0 - 100.0), 1e-10);
2544        }
2545    }
2546
2547    #[test]
2548    #[should_panic(expected = "Options on futures (Black-76) price on the Analytical engine only")]
2549    fn futures_option_rejects_non_analytic_engine() {
2550        let mut option =
2551            futures_option(PutOrCall::Call, crate::equity::black76::FuturesSettlement::Discounted);
2552        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2553        option.npv();
2554    }
2555
2556    // ── Forward-start options ───────────────────────────────────────────
2557
2558    fn forward_start_option(pc: PutOrCall) -> EquityOption {
2559        test_option_with(
2560            Box::new(crate::equity::forward_start_option::ForwardStartPayoff {
2561                put_or_call: pc,
2562                exercise_style: ContractStyle::European,
2563                strike_fraction: 1.0,
2564                start_fraction: 0.5,
2565            }),
2566            flat_5pct(),
2567        )
2568    }
2569
2570    #[test]
2571    fn forward_start_analytic_matches_monte_carlo() {
2572        let analytic = forward_start_option(PutOrCall::Call).npv();
2573        let mut mc = forward_start_option(PutOrCall::Call);
2574        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2575        mc.mc_cfg_mut().paths = 50_000;
2576        assert!((mc.npv() - analytic).abs() < 0.15, "mc={} analytic={analytic}", mc.npv());
2577    }
2578
2579    #[test]
2580    fn forward_start_heston_degenerates_to_black_scholes() {
2581        let bs = forward_start_option(PutOrCall::Call).npv();
2582        let mut heston = forward_start_option(PutOrCall::Call);
2583        heston.engine = crate::equity::utils::PricingEngine::MonteCarlo(
2584            crate::equity::montecarlo::MonteCarloConfig { paths: 50_000, ..Default::default() },
2585        );
2586        heston.model = crate::equity::utils::Model::Heston(crate::equity::heston::HestonParams {
2587            v0: 0.09,
2588            kappa: 1.0,
2589            theta: 0.09,
2590            vol_of_vol: 1e-3,
2591            rho: 0.0,
2592        });
2593        assert!((heston.npv() - bs).abs() < 0.2, "heston={} bs={bs}", heston.npv());
2594    }
2595
2596    // ── Autocallables ───────────────────────────────────────────────────
2597
2598    fn autocall_note(autocall_barrier: f64, protection_barrier: f64, coupon: f64) -> EquityOption {
2599        let mut option = test_option_with(
2600            Box::new(crate::equity::autocallable::AutocallablePayoff {
2601                exercise_style: ContractStyle::European,
2602                autocall_barrier,
2603                protection_barrier,
2604                coupon,
2605                observations: 4,
2606                observation_times: None,
2607                notional: 100.0,
2608                initial_fixing: 100.0,
2609                coupon_barrier: None,
2610                memory: false,
2611            }),
2612            flat_5pct(),
2613        );
2614        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2615        option.mc_cfg_mut().paths = 20_000;
2616        option
2617    }
2618
2619    #[test]
2620    fn autocall_that_always_calls_pays_coupon_at_first_observation() {
2621        // barrier below any reachable spot: every path calls at t1 = T/4
2622        let note = autocall_note(1e-9, 50.0, 5.0);
2623        let stats = crate::equity::montecarlo::npv_with_stats(&note);
2624        let expected = 105.0 * (-0.05 * 0.25_f64).exp();
2625        assert_approx_eq!(stats.pv, expected, 1e-9);
2626        // identical path values: stderr is pure floating-point cancellation
2627        assert!(stats.std_err < 1e-6, "deterministic payoff: stderr {}", stats.std_err);
2628    }
2629
2630    #[test]
2631    fn autocall_never_called_with_full_protection_is_a_zero_coupon_bond() {
2632        let note = autocall_note(1e12, 1e-9, 5.0);
2633        let expected = 100.0 * (-0.05_f64).exp();
2634        assert_approx_eq!(note.npv(), expected, 1e-9);
2635    }
2636
2637    #[test]
2638    fn autocall_full_downside_is_the_discounted_forward() {
2639        // protection always breached, never called: pays N * S_T / S_0,
2640        // whose discounted expectation is N (q = 0)
2641        let note = autocall_note(1e12, 1e12, 0.0);
2642        assert!((note.npv() - 100.0).abs() < 0.3, "{}", note.npv());
2643    }
2644
2645    #[test]
2646    fn autocall_value_increases_with_coupon_and_lower_protection() {
2647        let base = autocall_note(105.0, 70.0, 5.0).npv();
2648        assert!(autocall_note(105.0, 70.0, 8.0).npv() > base, "higher coupon");
2649        assert!(autocall_note(105.0, 50.0, 5.0).npv() > base, "lower knock-in barrier");
2650    }
2651
2652    #[test]
2653    fn autocall_prices_under_local_vol() {
2654        // flat surface: local vol must reproduce the GBM value
2655        let gbm = autocall_note(105.0, 70.0, 5.0).npv();
2656        let mut lv = autocall_note(105.0, 70.0, 5.0);
2657        lv.model = crate::equity::utils::Model::LocalVol;
2658        assert!((lv.npv() - gbm).abs() < 0.5, "lv={} gbm={gbm}", lv.npv());
2659    }
2660
2661    #[test]
2662    #[should_panic(expected = "Autocallables and accumulators price on the MonteCarlo engine only")]
2663    fn analytic_engine_rejects_autocallables() {
2664        let mut note = autocall_note(105.0, 70.0, 5.0);
2665        note.engine = crate::equity::utils::PricingEngine::from_kind(Engine::BlackScholes);
2666        note.npv();
2667    }
2668
2669    #[test]
2670    #[should_panic(expected = "only barriers price on the FD")]
2671    fn fd_engine_rejects_forward_start() {
2672        let mut option = forward_start_option(PutOrCall::Call);
2673        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2674        option.npv();
2675    }
2676
2677    #[test]
2678    fn adjoint_greeks_cover_the_approximate_schemes() {
2679        // the AAD tape records Euler and Milstein stepping too: one
2680        // simulation must reproduce the analytic delta and vega
2681        let analytic = test_option(PutOrCall::Call, flat_5pct());
2682        for scheme in [
2683            crate::equity::montecarlo::DiscretizationScheme::Euler,
2684            crate::equity::montecarlo::DiscretizationScheme::Milstein,
2685        ] {
2686            let mut mc = test_option(PutOrCall::Call, flat_5pct());
2687            mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2688            mc.mc_cfg_mut().scheme = scheme;
2689            mc.mc_cfg_mut().time_steps = 252;
2690            mc.mc_cfg_mut().paths = 100_000;
2691            let g = crate::equity::montecarlo::aad_greeks(&mc)
2692                .expect("AAD must cover approximate schemes");
2693            assert!(
2694                (g.delta - analytic.delta()).abs() < 0.01,
2695                "{scheme:?}: delta {} vs {}",
2696                g.delta,
2697                analytic.delta()
2698            );
2699            assert!(
2700                (g.vega - analytic.vega()).abs() < 0.6,
2701                "{scheme:?}: vega {} vs {}",
2702                g.vega,
2703                analytic.vega()
2704            );
2705        }
2706    }
2707
2708    #[test]
2709    fn heston_adjoint_greeks_match_the_characteristic_function() {
2710        use crate::equity::heston::heston_price;
2711        let mut mc = heston_vanilla(PutOrCall::Call);
2712        mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
2713        mc.mc_cfg_mut().paths = 100_000;
2714        let g = crate::equity::montecarlo::aad_greeks(&mc)
2715            .expect("Heston AAD must cover European vanillas");
2716
2717        // delta oracle: the exact probability-based CF delta
2718        let delta_cf = crate::equity::heston::native_vanilla_delta(&heston_vanilla(
2719            PutOrCall::Call,
2720        ))
2721        .unwrap();
2722        assert!((g.delta - delta_cf).abs() < 0.01, "delta {} vs {delta_cf}", g.delta);
2723
2724        // vega and rho oracles: central CF bumps in the same conventions
2725        let hp = crate::equity::heston::HestonParams {
2726            v0: 0.09,
2727            kappa: 2.0,
2728            theta: 0.09,
2729            vol_of_vol: 0.4,
2730            rho: -0.7,
2731        };
2732        let (s, k, r, q, t) = (100.0, 100.0, 0.05, 0.02, 1.0);
2733        let h = 0.005;
2734        let vega_cf = (heston_price(s, k, r, q, t, &hp.with_vol_shift(h), PutOrCall::Call)
2735            - heston_price(s, k, r, q, t, &hp.with_vol_shift(-h), PutOrCall::Call))
2736            / (2.0 * h);
2737        assert!(
2738            (g.vega - vega_cf).abs() < 0.03 * vega_cf.abs() + 0.05,
2739            "vega {} vs {vega_cf}",
2740            g.vega
2741        );
2742        let hr = 1e-4;
2743        let rho_cf = (heston_price(s, k, r + hr, q, t, &hp, PutOrCall::Call)
2744            - heston_price(s, k, r - hr, q, t, &hp, PutOrCall::Call))
2745            / (2.0 * hr);
2746        assert!(
2747            (g.rho - rho_cf).abs() < 0.03 * rho_cf.abs() + 0.05,
2748            "rho {} vs {rho_cf}",
2749            g.rho
2750        );
2751    }
2752
2753    #[test]
2754    fn fd_engine_prices_heston_on_the_adi_grid() {
2755        // the 2-D ADI grid must agree with the semi-analytic price
2756        let analytic = heston_vanilla(PutOrCall::Call).npv();
2757        let mut option = heston_vanilla(PutOrCall::Call);
2758        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2759        let fd = option.npv();
2760        assert!((fd - analytic).abs() < 0.05, "adi {fd:.4} vs cf {analytic:.4}");
2761    }
2762
2763    #[test]
2764    #[should_panic(expected = "vanilla and binary payoffs")]
2765    fn fd_engine_rejects_heston_barriers() {
2766        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
2767        let mut option = heston_option(Box::new(BarrierPayoff {
2768            put_or_call: PutOrCall::Call,
2769            exercise_style: ContractStyle::European,
2770            direction: Down,
2771            knock: Out,
2772            barrier: 90.0,
2773            barrier2: None,
2774            rebate: 0.0,
2775            rebate_at_hit: false,
2776        }));
2777        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
2778        option.npv();
2779    }
2780
2781    #[test]
2782    #[should_panic(expected = "not supported on the Binomial engine")]
2783    fn tree_engine_rejects_barrier_options() {
2784        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
2785        let mut option = barrier_option(PutOrCall::Call, Down, Out, 90.0);
2786        option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
2787        option.npv();
2788    }
2789
2790    #[test]
2791    #[should_panic(expected = "Analytical engine cannot price early exercise")]
2792    fn analytic_engine_rejects_american_exercise() {
2793        let option = test_option_with(
2794            Box::new(VanillaPayoff {
2795                put_or_call: PutOrCall::Put,
2796                exercise_style: ContractStyle::American,
2797            }),
2798            flat_5pct(),
2799        );
2800        option.npv();
2801    }
2802
2803    #[test]
2804    fn american_put_fd_and_tree_agree_and_dominate_european() {
2805        let european_put = test_option(PutOrCall::Put, flat_5pct()).npv();
2806        let american = |engine: Engine| {
2807            let mut option = test_option_with(
2808                Box::new(VanillaPayoff {
2809                    put_or_call: PutOrCall::Put,
2810                    exercise_style: ContractStyle::American,
2811                }),
2812                flat_5pct(),
2813            );
2814            option.engine = crate::equity::utils::PricingEngine::from_kind(engine);
2815            option.npv()
2816        };
2817        let fd = american(Engine::FiniteDifference);
2818        let tree = american(Engine::Binomial);
2819        assert!(fd > european_put, "american {fd} must exceed european {european_put}");
2820        assert!(tree > european_put);
2821        assert!((fd - tree).abs() < 0.02, "fd={fd} tree={tree}");
2822    }
2823
2824    #[test]
2825    fn smile_surface_prices_with_interpolated_vol() {
2826        // K=100 sits midway between the 90 and 110 pillars at the 1y expiry,
2827        // so the option must price at the interpolated 30% vol — i.e. match
2828        // the flat-30% golden values exactly.
2829        let valuation_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
2830        let surface = crate::core::vols::VolSurface::from_strike_grid(
2831            &[Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
2832            &[90.0, 100.0, 110.0],
2833            &[vec![0.32, 0.30, 0.28], vec![0.36, 0.34, 0.32]],
2834            valuation_date,
2835            DayCountConvention::Act365,
2836        )
2837        .unwrap();
2838        let mut option = test_option(PutOrCall::Call, flat_5pct());
2839        option.market.vol_surface = std::sync::Arc::new(surface);
2840        assert_approx_eq!(option.volatility(), 0.30, 1e-14);
2841        assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
2842        assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
2843        // a lower strike picks up the skew: vol(95) = 0.31
2844        option.base.strike_price = 95.0;
2845        assert_approx_eq!(option.volatility(), 0.31, 1e-14);
2846    }
2847
2848    #[test]
2849    fn implied_vol_recovers_input_vol() {
2850        let mut option = test_option(PutOrCall::Call, flat_5pct());
2851        let target_price = option.npv(); // priced at 30% flat
2852        // start the solve from a different vol level
2853        option.market.vol_surface = std::sync::Arc::new(
2854            crate::core::vols::VolSurface::flat(
2855                0.6,
2856                option.market.valuation_date,
2857                DayCountConvention::Act365,
2858            )
2859            .unwrap(),
2860        );
2861        let iv = option.imp_vol(target_price);
2862        assert_approx_eq!(iv, 0.30, 1e-10);
2863    }
2864
2865    #[test]
2866    fn zero_curve_prices_off_maturity_pillar() {
2867        // A non-flat zero curve whose 1y pillar is 5% must reproduce the
2868        // flat-5% price: discounting reads df at maturity, not any other node.
2869        let curve = YieldCurve::from_zero_rates(
2870            &[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
2871            &[0.02, 0.05, 0.07],
2872            NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
2873            DayCountConvention::Act365,
2874            Compounding::Continuous,
2875            InterpolationMethod::LogLinearDf,
2876        )
2877        .unwrap();
2878        let option = test_option(PutOrCall::Call, curve);
2879        assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
2880        assert_approx_eq!(option.risk_free_rate(), 0.05, 1e-12);
2881    }
2882}