Skip to main content

rustyqlib/equity/
blackscholes.rs

1use libm::{exp, log};
2use std::f64::consts::{PI, SQRT_2};
3use std::{io, thread};
4use crate::core::quotes::Quote;
5use chrono::{Datelike, Local, NaiveDate};
6//use utils::{N,dN};
7//use vanila_option::{EquityOption,OptionType};
8use crate::core::utils::{ContractStyle, dN, N};
9use crate::core::trade::{PutOrCall, Transection};
10use super::asian::{self, AsianStrikeType, AveragingType};
11use super::barrier;
12use super::vanila_option::{AsianPayoff, BarrierPayoff, BinaryPayoff, BinaryType, EquityOption, EquityOptionBase, VanillaPayoff};
13use super::utils::{Engine, PayoffType, Payoff, LongShort};
14use crate::core::curves::{Compounding, YieldCurve};
15use crate::core::daycount::DayCountConvention;
16use crate::core::vols::VolSurface;
17use super::super::core::traits::{Instrument,Greeks};
18
19pub struct BlackScholesPricer;
20impl BlackScholesPricer {
21    pub fn new() -> Self {
22        BlackScholesPricer
23    }
24    pub fn npv(&self, bsd_option: &EquityOption) -> f64 {
25        //assert!(bsd_option.volatility >= 0.0);
26        assert!(bsd_option.time_to_maturity() >= 0.0, "Option is expired or negative time");
27        assert!(bsd_option.base.underlying_price.value >= 0.0, "Negative underlying price not allowed");
28        if bsd_option.base.is_futures_option() {
29            return self.npv_black76(bsd_option);
30        }
31        match &bsd_option.payoff.payoff_kind() {
32            PayoffType::Vanilla => self.npv_vanilla(bsd_option),
33            PayoffType::Binary => self.npv_binary(bsd_option),
34            PayoffType::Barrier => self.npv_barrier(bsd_option),
35            PayoffType::Asian => self.npv_asian(bsd_option),
36            PayoffType::ForwardStart => self.npv_forward_start(bsd_option),
37            _ => {0.0}
38        }
39    }
40    pub fn delta(&self, bsd_option: &EquityOption) -> f64 {
41        //assert!(bsd_option.volatility >= 0.0);
42        assert!(bsd_option.time_to_maturity() >= 0.0, "Option is expired or negative time");
43        assert!(bsd_option.base.underlying_price.value >= 0.0, "Negative underlying price not allowed");
44        if bsd_option.base.is_futures_option() {
45            return self.delta_black76(bsd_option);
46        }
47        match &bsd_option.payoff.payoff_kind() {
48            PayoffType::Vanilla => self.delta_vanilla(bsd_option),
49            PayoffType::Binary => self.delta_binary(bsd_option),
50            PayoffType::Barrier => self.delta_barrier(bsd_option),
51            PayoffType::Asian => self.delta_asian(bsd_option),
52            PayoffType::ForwardStart => self.delta_forward_start(bsd_option),
53            _ => {0.0}
54        }
55    }
56    pub fn gamma(&self, bsd_option: &EquityOption) -> f64 {
57        if bsd_option.base.is_futures_option() {
58            return self.gamma_black76(bsd_option);
59        }
60        match &bsd_option.payoff.payoff_kind() {
61            PayoffType::Vanilla => self.gamma_vanilla(bsd_option),
62            PayoffType::Binary => self.gamma_binary(bsd_option),
63            PayoffType::Barrier => self.gamma_barrier(bsd_option),
64            PayoffType::Asian => self.gamma_asian(bsd_option),
65            PayoffType::ForwardStart => self.gamma_forward_start(bsd_option),
66            _ => {0.0}
67        }
68    }
69    pub fn vega(&self, bsd_option: &EquityOption) -> f64 {
70        if bsd_option.base.is_futures_option() {
71            return self.vega_black76(bsd_option);
72        }
73        match &bsd_option.payoff.payoff_kind() {
74            PayoffType::Vanilla => self.vega_vanilla(bsd_option),
75            PayoffType::Binary => self.vega_binary(bsd_option),
76            PayoffType::Barrier => self.vega_barrier(bsd_option),
77            PayoffType::Asian => self.vega_asian(bsd_option),
78            PayoffType::ForwardStart => self.vega_forward_start(bsd_option),
79            _ => {0.0}
80        }
81    }
82    pub fn theta(&self, bsd_option: &EquityOption) -> f64 {
83        if bsd_option.base.is_futures_option() {
84            return self.theta_black76(bsd_option);
85        }
86        match &bsd_option.payoff.payoff_kind() {
87            PayoffType::Vanilla => self.theta_vanilla(bsd_option),
88            PayoffType::Binary => self.theta_binary(bsd_option),
89            PayoffType::Barrier => self.theta_barrier(bsd_option),
90            PayoffType::Asian => self.theta_asian(bsd_option),
91            PayoffType::ForwardStart => self.theta_forward_start(bsd_option),
92            _ => {0.0}
93        }
94    }
95    pub fn rho(&self, bsd_option: &EquityOption) -> f64 {
96        if bsd_option.base.is_futures_option() {
97            return self.rho_black76(bsd_option);
98        }
99        match &bsd_option.payoff.payoff_kind() {
100            PayoffType::Vanilla => self.rho_vanilla(bsd_option),
101            PayoffType::Binary => self.rho_binary(bsd_option),
102            PayoffType::Barrier => self.rho_barrier(bsd_option),
103            PayoffType::Asian => self.rho_asian(bsd_option),
104            PayoffType::ForwardStart => self.rho_forward_start(bsd_option),
105            _ => {0.0}
106        }
107    }
108    // ── Black-76: European options on a future ─────────────────────────
109    // The underlying_price is the futures price F; there is no spot,
110    // dividend or carry. Vol is read from the surface at (K, F, T).
111
112    fn black76_inputs(bsd_option: &EquityOption)
113        -> (f64, f64, f64, f64, f64, PutOrCall, crate::equity::black76::FuturesSettlement)
114    {
115        let f = bsd_option.base.underlying_price.value();
116        let k = bsd_option.base.strike_price;
117        let r = bsd_option.base.risk_free_rate();
118        let t = bsd_option.time_to_maturity();
119        let sigma = bsd_option.base.vol_surface.vol(k, f, t);
120        let settlement = bsd_option
121            .base
122            .futures_settlement
123            .expect("black76 pricer called on a non-futures option");
124        (f, k, r, sigma, t, *bsd_option.payoff.put_or_call(), settlement)
125    }
126    fn npv_black76(&self, o: &EquityOption) -> f64 {
127        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
128        crate::equity::black76::price(f, k, r, sig, t, pc, s)
129    }
130    fn delta_black76(&self, o: &EquityOption) -> f64 {
131        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
132        crate::equity::black76::delta(f, k, r, sig, t, pc, s)
133    }
134    fn gamma_black76(&self, o: &EquityOption) -> f64 {
135        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
136        crate::equity::black76::gamma(f, k, r, sig, t, s)
137    }
138    fn vega_black76(&self, o: &EquityOption) -> f64 {
139        let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
140        crate::equity::black76::vega(f, k, r, sig, t, s)
141    }
142    fn theta_black76(&self, o: &EquityOption) -> f64 {
143        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
144        crate::equity::black76::theta(f, k, r, sig, t, pc, s)
145    }
146    fn rho_black76(&self, o: &EquityOption) -> f64 {
147        let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
148        crate::equity::black76::rho(f, k, r, sig, t, pc, s)
149    }
150    fn npv_vanilla(&self, bsd_option: &EquityOption) -> f64 {
151
152        let n_d1 = N(bsd_option.base.d1());
153        let n_d2 = N(bsd_option.base.d2());
154        let df_d = exp(-bsd_option.base.carry_yield() * bsd_option.time_to_maturity());
155        let df_r = bsd_option.base.maturity_discount_factor();
156        match bsd_option.payoff.put_or_call() {
157            PutOrCall::Call => {bsd_option.base.effective_spot()*n_d1 *df_d
158                -bsd_option.base.strike_price*n_d2*df_r
159            }
160            PutOrCall::Put => {bsd_option.base.strike_price*N(-bsd_option.base.d2())*df_r-
161                bsd_option.base.effective_spot()*N(-bsd_option.base.d1()) *df_d
162                }
163
164        }
165    }
166    fn delta_vanilla(&self, bsd_option: &EquityOption) -> f64 {
167        // spot delta: e^{-qT} N(d1) for a call, e^{-qT}(N(d1)-1) for a put
168        let n_d1 = N(bsd_option.base.d1());
169        let df_d = exp(-bsd_option.base.carry_yield() * bsd_option.time_to_maturity());
170
171        match bsd_option.payoff.put_or_call() {
172            PutOrCall::Call => {n_d1 * df_d }
173            PutOrCall::Put => {(n_d1-1.0) * df_d }
174        }
175    }
176    fn gamma_vanilla(&self, bsd_option: &EquityOption) -> f64 {
177        // e^{-qT} dN(d1) / (S sigma sqrt(T))
178        let dn_d1 = dN(bsd_option.base.d1());
179        let df_d = exp(-bsd_option.base.carry_yield() * bsd_option.time_to_maturity());
180        let var_sqrt = bsd_option.base.volatility() * (bsd_option.time_to_maturity().sqrt());
181        dn_d1 * df_d / (bsd_option.base.effective_spot() * var_sqrt)
182    }
183    fn vega_vanilla(&self, bsd_option: &EquityOption) -> f64 {
184        // S e^{-qT} dN(d1) sqrt(T)
185        let dn_d1 = dN(bsd_option.base.d1());
186        let df_d = exp(-bsd_option.base.carry_yield() * bsd_option.time_to_maturity());
187        let df_S = bsd_option.base.effective_spot() * df_d;
188        let vega = df_S * dn_d1 * bsd_option.time_to_maturity().sqrt();
189        vega
190    }
191    fn theta_vanilla(&self, bsd_option: &EquityOption) -> f64 {
192        // call: -S e^{-qT} dN(d1) sigma/(2 sqrt(T)) + q S e^{-qT} N(d1) - r K e^{-rT} N(d2)
193        // put:  -S e^{-qT} dN(d1) sigma/(2 sqrt(T)) - q S e^{-qT} N(-d1) + r K e^{-rT} N(-d2)
194        let q = bsd_option.base.carry_yield();
195        let r = bsd_option.base.risk_free_rate();
196        let k = bsd_option.base.strike_price;
197        let dn_d1 = dN(bsd_option.base.d1());
198        let n_d1 = N(bsd_option.base.d1());
199        let n_d2 = N(bsd_option.base.d2());
200        let df_d = exp(-q * bsd_option.time_to_maturity());
201        let df_r = bsd_option.base.maturity_discount_factor();
202        let df_S = bsd_option.base.effective_spot() * df_d;
203        let t1 = -df_S * dn_d1 * bsd_option.base.volatility()
204            / (2.0 * bsd_option.time_to_maturity().sqrt());
205
206        match bsd_option.payoff.put_or_call() {
207            PutOrCall::Call => {
208                t1 + q * df_S * n_d1 - r * k * df_r * n_d2
209            }
210            PutOrCall::Put => {
211                t1 - q * df_S * N(-bsd_option.base.d1()) + r * k * df_r * N(-bsd_option.base.d2())
212            }
213        }
214    }
215    fn rho_vanilla(&self, bsd_option: &EquityOption) -> f64 {
216        // call: K T e^{-rT} N(d2); put: -K T e^{-rT} N(-d2)
217        let n_d2 = N(bsd_option.base.d2());
218        let df_r = bsd_option.base.maturity_discount_factor();
219        let r1 = bsd_option.time_to_maturity()*bsd_option.base.strike_price;
220        match bsd_option.payoff.put_or_call() {
221            PutOrCall::Call => {
222                r1*n_d2*df_r
223            }
224            PutOrCall::Put => {-r1*N(-bsd_option.base.d2())*df_r
225            }
226
227        }
228    }
229
230    // ── Binary (digital) options ───────────────────────────────────────
231    // cash-or-nothing:  cash * e^{-rT} N(+-d2)
232    // asset-or-nothing: S e^{-qT} N(+-d1)
233    // All asset-or-nothing Greeks are implemented directly (not via the
234    // vanilla replication identity), so the replication tests are a real
235    // cross-check.
236
237    fn binary_details(bsd_option: &EquityOption) -> (BinaryType, f64) {
238        let payoff = bsd_option
239            .payoff
240            .as_any()
241            .downcast_ref::<BinaryPayoff>()
242            .expect("payoff of kind Binary must be a BinaryPayoff");
243        (payoff.binary_type, payoff.cash)
244    }
245
246    fn npv_binary(&self, bsd_option: &EquityOption) -> f64 {
247        let (binary_type, cash) = Self::binary_details(bsd_option);
248        let df_r = bsd_option.base.maturity_discount_factor();
249        let df_q = exp(-bsd_option.base.carry_yield() * bsd_option.time_to_maturity());
250        let s = bsd_option.base.effective_spot();
251        match (binary_type, bsd_option.payoff.put_or_call()) {
252            (BinaryType::CashOrNothing, PutOrCall::Call) => cash * df_r * N(bsd_option.base.d2()),
253            (BinaryType::CashOrNothing, PutOrCall::Put) => cash * df_r * N(-bsd_option.base.d2()),
254            (BinaryType::AssetOrNothing, PutOrCall::Call) => s * df_q * N(bsd_option.base.d1()),
255            (BinaryType::AssetOrNothing, PutOrCall::Put) => s * df_q * N(-bsd_option.base.d1()),
256        }
257    }
258    fn delta_binary(&self, bsd_option: &EquityOption) -> f64 {
259        let (binary_type, cash) = Self::binary_details(bsd_option);
260        let t = bsd_option.time_to_maturity();
261        let sigma = bsd_option.base.volatility();
262        let s = bsd_option.base.effective_spot();
263        let vol_sqrt_t = sigma * t.sqrt();
264        match binary_type {
265            BinaryType::CashOrNothing => {
266                // +- cash e^{-rT} dN(d2) / (S sigma sqrt(T))
267                let df_r = bsd_option.base.maturity_discount_factor();
268                let delta_call = cash * df_r * dN(bsd_option.base.d2()) / (s * vol_sqrt_t);
269                match bsd_option.payoff.put_or_call() {
270                    PutOrCall::Call => delta_call,
271                    PutOrCall::Put => -delta_call,
272                }
273            }
274            BinaryType::AssetOrNothing => {
275                // e^{-qT} (N(+-d1) +- dN(d1)/(sigma sqrt(T)))
276                let df_q = exp(-bsd_option.base.dividend_yield * t);
277                let d1 = bsd_option.base.d1();
278                match bsd_option.payoff.put_or_call() {
279                    PutOrCall::Call => df_q * (N(d1) + dN(d1) / vol_sqrt_t),
280                    PutOrCall::Put => df_q * (N(-d1) - dN(d1) / vol_sqrt_t),
281                }
282            }
283        }
284    }
285    fn gamma_binary(&self, bsd_option: &EquityOption) -> f64 {
286        let (binary_type, cash) = Self::binary_details(bsd_option);
287        let t = bsd_option.time_to_maturity();
288        let sigma = bsd_option.base.volatility();
289        let s = bsd_option.base.effective_spot();
290        let vol_sqrt_t = sigma * t.sqrt();
291        let gamma_call = match binary_type {
292            BinaryType::CashOrNothing => {
293                // - cash e^{-rT} dN(d2) d1 / (S^2 sigma^2 T)
294                let df_r = bsd_option.base.maturity_discount_factor();
295                -cash * df_r * dN(bsd_option.base.d2()) * bsd_option.base.d1()
296                    / (s * s * sigma * sigma * t)
297            }
298            BinaryType::AssetOrNothing => {
299                // e^{-qT} dN(d1) (1 - d1/(sigma sqrt(T))) / (S sigma sqrt(T))
300                let df_q = exp(-bsd_option.base.dividend_yield * t);
301                let d1 = bsd_option.base.d1();
302                df_q * dN(d1) * (1.0 - d1 / vol_sqrt_t) / (s * vol_sqrt_t)
303            }
304        };
305        match bsd_option.payoff.put_or_call() {
306            PutOrCall::Call => gamma_call,
307            PutOrCall::Put => -gamma_call,
308        }
309    }
310    fn vega_binary(&self, bsd_option: &EquityOption) -> f64 {
311        let (binary_type, cash) = Self::binary_details(bsd_option);
312        let t = bsd_option.time_to_maturity();
313        let sigma = bsd_option.base.volatility();
314        let s = bsd_option.base.effective_spot();
315        let vega_call = match binary_type {
316            BinaryType::CashOrNothing => {
317                // - cash e^{-rT} dN(d2) d1 / sigma
318                let df_r = bsd_option.base.maturity_discount_factor();
319                -cash * df_r * dN(bsd_option.base.d2()) * bsd_option.base.d1() / sigma
320            }
321            BinaryType::AssetOrNothing => {
322                // - S e^{-qT} dN(d1) d2 / sigma
323                let df_q = exp(-bsd_option.base.dividend_yield * t);
324                -s * df_q * dN(bsd_option.base.d1()) * bsd_option.base.d2() / sigma
325            }
326        };
327        match bsd_option.payoff.put_or_call() {
328            PutOrCall::Call => vega_call,
329            PutOrCall::Put => -vega_call,
330        }
331    }
332    fn theta_binary(&self, bsd_option: &EquityOption) -> f64 {
333        let (binary_type, cash) = Self::binary_details(bsd_option);
334        let r = bsd_option.base.risk_free_rate();
335        let q = bsd_option.base.carry_yield();
336        let t = bsd_option.time_to_maturity();
337        let sigma = bsd_option.base.volatility();
338        let s = bsd_option.base.effective_spot();
339        match binary_type {
340            BinaryType::CashOrNothing => {
341                // dd2/dT = (r - q - sigma^2/2)/(sigma sqrt(T)) - d2/(2T)
342                let df_r = bsd_option.base.maturity_discount_factor();
343                let d2 = bsd_option.base.d2();
344                let dd2_dt = (r - q - 0.5 * sigma * sigma) / (sigma * t.sqrt()) - d2 / (2.0 * t);
345                match bsd_option.payoff.put_or_call() {
346                    PutOrCall::Call => cash * (r * df_r * N(d2) - df_r * dN(d2) * dd2_dt),
347                    PutOrCall::Put => cash * (r * df_r * N(-d2) + df_r * dN(d2) * dd2_dt),
348                }
349            }
350            BinaryType::AssetOrNothing => {
351                // dd1/dT = (r - q + sigma^2/2)/(sigma sqrt(T)) - d1/(2T)
352                let df_q = exp(-q * t);
353                let d1 = bsd_option.base.d1();
354                let dd1_dt = (r - q + 0.5 * sigma * sigma) / (sigma * t.sqrt()) - d1 / (2.0 * t);
355                match bsd_option.payoff.put_or_call() {
356                    PutOrCall::Call => q * s * df_q * N(d1) - s * df_q * dN(d1) * dd1_dt,
357                    PutOrCall::Put => q * s * df_q * N(-d1) + s * df_q * dN(d1) * dd1_dt,
358                }
359            }
360        }
361    }
362    fn rho_binary(&self, bsd_option: &EquityOption) -> f64 {
363        let (binary_type, cash) = Self::binary_details(bsd_option);
364        let t = bsd_option.time_to_maturity();
365        let sigma = bsd_option.base.volatility();
366        let s = bsd_option.base.effective_spot();
367        match binary_type {
368            BinaryType::CashOrNothing => {
369                let df_r = bsd_option.base.maturity_discount_factor();
370                let d2 = bsd_option.base.d2();
371                match bsd_option.payoff.put_or_call() {
372                    PutOrCall::Call => cash * (-t * df_r * N(d2) + df_r * dN(d2) * t.sqrt() / sigma),
373                    PutOrCall::Put => cash * (-t * df_r * N(-d2) - df_r * dN(d2) * t.sqrt() / sigma),
374                }
375            }
376            BinaryType::AssetOrNothing => {
377                // +- S e^{-qT} dN(d1) sqrt(T)/sigma
378                let df_q = exp(-bsd_option.base.dividend_yield * t);
379                let rho_call = s * df_q * dN(bsd_option.base.d1()) * t.sqrt() / sigma;
380                match bsd_option.payoff.put_or_call() {
381                    PutOrCall::Call => rho_call,
382                    PutOrCall::Put => -rho_call,
383                }
384            }
385        }
386    }
387
388    // ── Barrier options (Reiner-Rubinstein) ────────────────────────────
389    // NPV is the closed form; Greeks are central-difference bumps of it
390    // (the standard approach — the analytic derivatives are long and easy
391    // to get wrong, and near the barrier bumped Greeks are what desks use).
392
393    /// Reprice the barrier with additive bumps to (spot, vol, rate, expiry).
394    fn barrier_price_with(
395        bsd_option: &EquityOption,
396        ds: f64,
397        dsigma: f64,
398        dr: f64,
399        dt_shift: f64,
400    ) -> f64 {
401        let payoff = bsd_option
402            .payoff
403            .as_any()
404            .downcast_ref::<BarrierPayoff>()
405            .expect("payoff of kind Barrier must be a BarrierPayoff");
406        barrier::barrier_price(
407            bsd_option.base.effective_spot() + ds,
408            bsd_option.base.strike_price,
409            payoff.barrier,
410            bsd_option.base.risk_free_rate() + dr,
411            bsd_option.base.carry_yield(),
412            bsd_option.base.volatility() + dsigma,
413            bsd_option.time_to_maturity() + dt_shift,
414            payoff.direction,
415            payoff.knock,
416            *bsd_option.payoff.put_or_call(),
417        )
418    }
419    fn npv_barrier(&self, bsd_option: &EquityOption) -> f64 {
420        Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
421    }
422    fn delta_barrier(&self, bsd_option: &EquityOption) -> f64 {
423        let h = bsd_option.base.underlying_price.value() * 1e-4;
424        (Self::barrier_price_with(bsd_option, h, 0.0, 0.0, 0.0)
425            - Self::barrier_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
426            / (2.0 * h)
427    }
428    fn gamma_barrier(&self, bsd_option: &EquityOption) -> f64 {
429        let h = bsd_option.base.underlying_price.value() * 1e-3;
430        (Self::barrier_price_with(bsd_option, h, 0.0, 0.0, 0.0)
431            - 2.0 * Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
432            + Self::barrier_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
433            / (h * h)
434    }
435    fn vega_barrier(&self, bsd_option: &EquityOption) -> f64 {
436        let h = 1e-4;
437        (Self::barrier_price_with(bsd_option, 0.0, h, 0.0, 0.0)
438            - Self::barrier_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
439            / (2.0 * h)
440    }
441    fn theta_barrier(&self, bsd_option: &EquityOption) -> f64 {
442        let h = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
443        -(Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, h)
444            - Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
445            / (2.0 * h)
446    }
447    fn rho_barrier(&self, bsd_option: &EquityOption) -> f64 {
448        let h = 1e-5;
449        (Self::barrier_price_with(bsd_option, 0.0, 0.0, h, 0.0)
450            - Self::barrier_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
451            / (2.0 * h)
452    }
453
454    // ── Asian options ──────────────────────────────────────────────────
455    // geometric average price: exact closed form (continuous averaging)
456    // arithmetic average price: Turnbull-Wakeman approximation
457    // floating strike: Monte Carlo only
458    // Greeks by central-difference bumps, like barriers.
459
460    fn asian_price_with(
461        bsd_option: &EquityOption,
462        ds: f64,
463        dsigma: f64,
464        dr: f64,
465        dt_shift: f64,
466    ) -> f64 {
467        let payoff = bsd_option
468            .payoff
469            .as_any()
470            .downcast_ref::<AsianPayoff>()
471            .expect("payoff of kind Asian must be an AsianPayoff");
472        if payoff.strike_type == AsianStrikeType::FloatingStrike {
473            panic!(
474                "Floating-strike Asian options have no analytic pricer; \
475                 use the MonteCarlo engine"
476            );
477        }
478        let s = bsd_option.base.effective_spot() + ds;
479        let k = bsd_option.base.strike_price;
480        let r = bsd_option.base.risk_free_rate() + dr;
481        let q = bsd_option.base.carry_yield();
482        let sigma = bsd_option.base.volatility() + dsigma;
483        let t = bsd_option.time_to_maturity() + dt_shift;
484        let pc = *bsd_option.payoff.put_or_call();
485        match payoff.averaging {
486            AveragingType::Geometric => {
487                asian::geometric_asian_price(s, k, r, q, sigma, t, None, pc)
488            }
489            AveragingType::Arithmetic => asian::turnbull_wakeman_price(s, k, r, q, sigma, t, pc),
490        }
491    }
492    fn npv_asian(&self, bsd_option: &EquityOption) -> f64 {
493        Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
494    }
495    fn delta_asian(&self, bsd_option: &EquityOption) -> f64 {
496        let h = bsd_option.base.underlying_price.value() * 1e-4;
497        (Self::asian_price_with(bsd_option, h, 0.0, 0.0, 0.0)
498            - Self::asian_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
499            / (2.0 * h)
500    }
501    fn gamma_asian(&self, bsd_option: &EquityOption) -> f64 {
502        let h = bsd_option.base.underlying_price.value() * 1e-3;
503        (Self::asian_price_with(bsd_option, h, 0.0, 0.0, 0.0)
504            - 2.0 * Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
505            + Self::asian_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
506            / (h * h)
507    }
508    fn vega_asian(&self, bsd_option: &EquityOption) -> f64 {
509        let h = 1e-4;
510        (Self::asian_price_with(bsd_option, 0.0, h, 0.0, 0.0)
511            - Self::asian_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
512            / (2.0 * h)
513    }
514    fn theta_asian(&self, bsd_option: &EquityOption) -> f64 {
515        let h = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
516        -(Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, h)
517            - Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
518            / (2.0 * h)
519    }
520    fn rho_asian(&self, bsd_option: &EquityOption) -> f64 {
521        let h = 1e-5;
522        (Self::asian_price_with(bsd_option, 0.0, 0.0, h, 0.0)
523            - Self::asian_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
524            / (2.0 * h)
525    }
526
527    // -- Forward-start options (Rubinstein closed form, GBM) ------------
528
529    fn forward_start_price_with(
530        bsd_option: &EquityOption,
531        ds: f64,
532        dsigma: f64,
533        dr: f64,
534        dt_shift: f64,
535    ) -> f64 {
536        let payoff = bsd_option
537            .payoff
538            .as_any()
539            .downcast_ref::<crate::equity::forward_start_option::ForwardStartPayoff>()
540            .expect("payoff of kind ForwardStart must be a ForwardStartPayoff");
541        let t = bsd_option.time_to_maturity() + dt_shift;
542        crate::equity::forward_start_option::forward_start_price(
543            bsd_option.base.effective_spot() + ds,
544            payoff.strike_fraction,
545            bsd_option.base.risk_free_rate() + dr,
546            bsd_option.base.carry_yield(),
547            bsd_option.base.volatility() + dsigma,
548            payoff.start_fraction * t,
549            t,
550            *bsd_option.payoff.put_or_call(),
551        )
552    }
553    fn npv_forward_start(&self, bsd_option: &EquityOption) -> f64 {
554        Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
555    }
556    fn delta_forward_start(&self, bsd_option: &EquityOption) -> f64 {
557        let h = bsd_option.base.underlying_price.value() * 1e-4;
558        (Self::forward_start_price_with(bsd_option, h, 0.0, 0.0, 0.0)
559            - Self::forward_start_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
560            / (2.0 * h)
561    }
562    fn gamma_forward_start(&self, bsd_option: &EquityOption) -> f64 {
563        let h = bsd_option.base.underlying_price.value() * 1e-3;
564        (Self::forward_start_price_with(bsd_option, h, 0.0, 0.0, 0.0)
565            - 2.0 * Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
566            + Self::forward_start_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
567            / (h * h)
568    }
569    fn vega_forward_start(&self, bsd_option: &EquityOption) -> f64 {
570        let h = 1e-4;
571        (Self::forward_start_price_with(bsd_option, 0.0, h, 0.0, 0.0)
572            - Self::forward_start_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
573            / (2.0 * h)
574    }
575    fn theta_forward_start(&self, bsd_option: &EquityOption) -> f64 {
576        let h = (1.0 / 365.0_f64).min(0.25 * bsd_option.time_to_maturity());
577        -(Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, h)
578            - Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
579            / (2.0 * h)
580    }
581    fn rho_forward_start(&self, bsd_option: &EquityOption) -> f64 {
582        let h = 1e-5;
583        (Self::forward_start_price_with(bsd_option, 0.0, 0.0, h, 0.0)
584            - Self::forward_start_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
585            / (2.0 * h)
586    }
587
588}
589
590
591/// Black-Scholes price of a European vanilla as a pure function of its
592/// inputs (no option object needed).
593pub fn bs_price(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64, put_or_call: PutOrCall) -> f64 {
594    if t <= 0.0 || sigma <= 0.0 {
595        return match put_or_call {
596            PutOrCall::Call => (s * exp(-q * t) - k * exp(-r * t)).max(0.0),
597            PutOrCall::Put => (k * exp(-r * t) - s * exp(-q * t)).max(0.0),
598        };
599    }
600    let sqrt_t = t.sqrt();
601    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
602    let d2 = d1 - sigma * sqrt_t;
603    match put_or_call {
604        PutOrCall::Call => s * exp(-q * t) * N(d1) - k * exp(-r * t) * N(d2),
605        PutOrCall::Put => k * exp(-r * t) * N(-d2) - s * exp(-q * t) * N(-d1),
606    }
607}
608
609/// Black-Scholes vega as a pure function (per unit of vol).
610pub fn bs_vega(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
611    let sqrt_t = t.sqrt();
612    let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
613    s * exp(-q * t) * dN(d1) * sqrt_t
614}
615
616const IMPLIED_VOL_MIN: f64 = 1e-4;
617const IMPLIED_VOL_MAX: f64 = 5.0;
618
619/// Implied Black-Scholes volatility for a European vanilla price.
620///
621/// Safeguarded Newton: full Newton steps while they stay inside the current
622/// bisection bracket `[1e-4, 5.0]`, bisection otherwise, so it converges for
623/// deep in/out-of-the-money quotes where raw Newton diverges. Prices outside
624/// the arbitrage bounds return an error.
625pub fn implied_vol_from_price(
626    s: f64,
627    k: f64,
628    r: f64,
629    q: f64,
630    t: f64,
631    target: f64,
632    put_or_call: PutOrCall,
633) -> Result<f64, String> {
634    if t <= 0.0 {
635        return Err("option is expired".to_string());
636    }
637    let lower_bound = bs_price(s, k, r, q, 0.0, t, put_or_call);
638    let upper_bound = match put_or_call {
639        PutOrCall::Call => s * exp(-q * t),
640        PutOrCall::Put => k * exp(-r * t),
641    };
642    if target < lower_bound - 1e-12 || target > upper_bound + 1e-12 {
643        return Err(format!(
644            "price {target} violates arbitrage bounds [{lower_bound}, {upper_bound}]"
645        ));
646    }
647
648    let (mut lo, mut hi) = (IMPLIED_VOL_MIN, IMPLIED_VOL_MAX);
649    if bs_price(s, k, r, q, lo, t, put_or_call) > target {
650        return Ok(lo); // at or below the vol floor
651    }
652    if bs_price(s, k, r, q, hi, t, put_or_call) < target {
653        return Err(format!("implied vol above {IMPLIED_VOL_MAX}"));
654    }
655
656    let mut sigma = 0.5_f64.min(hi).max(lo);
657    let tol = 1e-12 * target.max(1.0);
658    for _ in 0..100 {
659        let diff = bs_price(s, k, r, q, sigma, t, put_or_call) - target;
660        if diff.abs() < tol {
661            return Ok(sigma);
662        }
663        if diff > 0.0 {
664            hi = sigma;
665        } else {
666            lo = sigma;
667        }
668        let vega = bs_vega(s, k, r, q, sigma, t);
669        let newton = sigma - diff / vega;
670        sigma = if vega > 1e-12 && newton > lo && newton < hi {
671            newton
672        } else {
673            0.5 * (lo + hi)
674        };
675        if hi - lo < 1e-14 {
676            return Ok(sigma);
677        }
678    }
679    Ok(sigma)
680}
681
682pub fn option_pricing() {
683    println!("Welcome to the Black-Scholes Option pricer.");
684    print!(">>");
685    println!(" What is the current price of the underlying asset?");
686    print!(">>");
687    let mut curr_price = String::new();
688    io::stdin()
689        .read_line(&mut curr_price)
690        .expect("Failed to read line");
691    println!(" Do you want a call option ('C') or a put option ('P') ?");
692    print!(">>");
693    let mut side_input = String::new();
694    io::stdin()
695        .read_line(&mut side_input)
696        .expect("Failed to read line");
697    let side: PutOrCall;
698    match side_input.trim() {
699        "C" | "c" | "Call" | "call" => side = PutOrCall::Call,
700        "P" | "p" | "Put" | "put" => side = PutOrCall::Put,
701        _ => panic!("Invalide side argument! Side has to be either 'C' or 'P'."),
702    }
703    println!("Stike price:");
704    print!(">>");
705    let mut strike = String::new();
706    io::stdin()
707        .read_line(&mut strike)
708        .expect("Failed to read line");
709    println!("Expected annualized volatility in %:");
710    println!("E.g.: Enter 50% chance as 0.50 ");
711    print!(">>");
712    let mut vol = String::new();
713    io::stdin()
714        .read_line(&mut vol)
715        .expect("Failed to read line");
716
717    println!("Risk-free rate in %:");
718    print!(">>");
719    let mut rf = String::new();
720    io::stdin().read_line(&mut rf).expect("Failed to read line");
721    println!(" Maturity date in YYYY-MM-DD format:");
722
723    let mut expiry = String::new();
724    println!("E.g.: Enter 2020-12-31 for 31st December 2020");
725    print!(">>");
726    io::stdin()
727        .read_line(&mut expiry)
728        .expect("Failed to read line");
729    println!("{:?}", expiry.trim());
730    let _d = expiry.trim();
731    let future_date = NaiveDate::parse_from_str(&_d, "%Y-%m-%d").expect("Invalid date format");
732    //println!("{:?}", future_date);
733    println!("Dividend yield on this stock:");
734    print!(">>");
735    let mut div = String::new();
736    io::stdin()
737        .read_line(&mut div)
738        .expect("Failed to read line");
739
740    let valuation_date = Local::now().date_naive();
741    let discount_curve = YieldCurve::flat(
742        rf.trim().parse::<f64>().unwrap(),
743        valuation_date,
744        DayCountConvention::Act365,
745        Compounding::Continuous,
746    )
747    .expect("Invalid risk free rate");
748    let vol_surface = VolSurface::flat(
749        vol.trim().parse::<f64>().unwrap(),
750        valuation_date,
751        DayCountConvention::Act365,
752    )
753    .expect("Invalid volatility");
754    let curr_quote = Quote::new( curr_price.trim().parse::<f64>().unwrap());
755    let option = EquityOptionBase {
756
757        symbol:"ABC".to_string(),
758        currency: None,
759        exchange:None,
760        name: None,
761        cusip: None,
762        isin: None,
763        settlement_type: Some("ABC".to_string()),
764        entry_price: 0.0,
765        long_short: LongShort::LONG,
766        underlying_price: curr_quote,
767        current_price: Quote::new(0.0),
768        strike_price: strike.trim().parse::<f64>().unwrap(),
769        vol_surface,
770        maturity_date: future_date,
771        discount_curve,
772        dividend_yield: div.trim().parse::<f64>().unwrap(),
773        borrow_cost: 0.0,
774        cash_dividends: vec![],
775        futures_settlement: None,
776        valuation_date,
777        multiplier: 1.0,
778    };
779    //println!("{:?}", option.time_to_maturity());
780    let payoff = Box::new(VanillaPayoff{put_or_call:side,
781                                    exercise_style:ContractStyle::European});
782    let option = EquityOption {
783        base: option,
784        payoff:payoff,
785        engine:Engine::BlackScholes,
786        mc: crate::equity::montecarlo::MonteCarloConfig::default(),
787        fd: crate::equity::finite_difference::FdConfig::default(),
788        heston: None
789    };
790    println!("Theoretical Price ${}", option.npv());
791    println!("Premium at risk ${}", option.get_premium_at_risk());
792    println!("Delta {}", option.delta());
793    println!("Gamma {}", option.gamma());
794    println!("Vega {}", option.vega() * 0.01);
795    println!("Theta {}", option.theta() * (1.0 / 365.0));
796    println!("Rho {}", option.rho() * 0.01);
797    let mut wait = String::new();
798    io::stdin()
799        .read_line(&mut wait)
800        .expect("Failed to read line");
801}
802pub fn implied_volatility(){}
803// pub fn implied_volatility() {
804//     println!("Welcome to the Black-Scholes Option pricer.");
805//     println!("(Step 1/7) What is the current price of the underlying asset?");
806//     let mut curr_price = String::new();
807//     io::stdin()
808//         .read_line(&mut curr_price)
809//         .expect("Failed to read line");
810//
811//     println!("(Step 2/7) Do you want a call option ('C') or a put option ('P') ?");
812//     let mut side_input = String::new();
813//     io::stdin()
814//         .read_line(&mut side_input)
815//         .expect("Failed to read line");
816//
817//     let side: OptionType;
818//     match side_input.trim() {
819//         "C" | "c" | "Call" | "call" => side = OptionType::Call,
820//         "P" | "p" | "Put" | "put" => side = OptionType::Put,
821//         _ => panic!("Invalide side argument! Side has to be either 'C' or 'P'."),
822//     }
823//
824//     println!("Stike price:");
825//     let mut strike = String::new();
826//     io::stdin()
827//         .read_line(&mut strike)
828//         .expect("Failed to read line");
829//
830//     println!("What is option price:");
831//     let mut option_price = String::new();
832//     io::stdin()
833//         .read_line(&mut option_price)
834//         .expect("Failed to read line");
835//
836//     println!("Risk-free rate in %:");
837//     let mut rf = String::new();
838//     io::stdin().read_line(&mut rf).expect("Failed to read line");
839//
840//     println!(" Maturity date in YYYY-MM-DD format:");
841//     let mut expiry = String::new();
842//     io::stdin()
843//         .read_line(&mut expiry)
844//         .expect("Failed to read line");
845//     let future_date = NaiveDate::parse_from_str(&expiry.trim(), "%Y-%m-%d").expect("Invalid date format");
846//     println!("Dividend yield on this stock:");
847//     let mut div = String::new();
848//     io::stdin()
849//         .read_line(&mut div)
850//         .expect("Failed to read line");
851//
852//     //let ts = YieldTermStructure{
853//     //    date: vec![0.01,0.02,0.05,0.1,0.5,1.0,2.0,3.0],
854//     //    rates: vec![0.01,0.02,0.05,0.07,0.08,0.1,0.11,0.12]
855//     //};
856//     let date =  vec![0.01,0.02,0.05,0.1,0.5,1.0,2.0,3.0];
857//     let rates = vec![0.01,0.02,0.05,0.07,0.08,0.1,0.11,0.12];
858//     let ts = YieldTermStructure::new(date,rates);
859//     let curr_quote = Quote::new( curr_price.trim().parse::<f64>().unwrap());
860//     let sim = Some(10000);
861//     let mut option = EquityOption {
862//         option_type: side,
863//         transection: Transection::Buy,
864//         underlying_price: curr_quote,
865//         current_price: Quote::new(0.0),
866//         strike_price: strike.trim().parse::<f64>().unwrap(),
867//         volatility: 0.20,
868//         maturity_date: future_date,
869//         risk_free_rate: rf.trim().parse::<f64>().unwrap(),
870//         dividend_yield: div.trim().parse::<f64>().unwrap(),
871//         transection_price: 0.0,
872//         term_structure: ts,
873//         engine: Engine::BlackScholes,
874//         simulation:sim,
875//         //style:Option::from("European".to_string()),
876//         style: ContractStyle::European,
877//         valuation_date: Local::today().naive_utc(),
878//     };
879//     option.set_risk_free_rate();
880//     println!("Implied Volatility  {}%", 100.0*option.imp_vol(option_price.trim().parse::<f64>().unwrap()));
881//
882//     let mut div1 = String::new();
883//     io::stdin()
884//         .read_line(&mut div)
885//         .expect("Failed to read line");
886// }
887
888
889#[cfg(test)]
890mod tests {
891    use assert_approx_eq::assert_approx_eq;
892    use super::*;
893    use crate::core::curves::{Compounding, InterpolationMethod, Tenor, YieldCurve};
894    use crate::core::daycount::DayCountConvention;
895    use crate::core::utils::ContractStyle;
896
897    /// S=100, K=100, sigma=30%, q=0, T=1y (2026-01-01 -> 2027-01-01, Act/365).
898    fn test_option_with(payoff: Box<dyn Payoff>, curve: YieldCurve) -> EquityOption {
899        let valuation_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
900        let base = EquityOptionBase {
901            symbol: "TEST".to_string(),
902            currency: None,
903            exchange: None,
904            name: None,
905            cusip: None,
906            isin: None,
907            settlement_type: None,
908            underlying_price: Quote::new(100.0),
909            current_price: Quote::new(0.0),
910            strike_price: 100.0,
911            dividend_yield: 0.0,
912            borrow_cost: 0.0,
913            cash_dividends: vec![],
914        futures_settlement: None,
915            vol_surface: VolSurface::flat(0.3, valuation_date, DayCountConvention::Act365)
916                .unwrap(),
917            maturity_date: NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
918            valuation_date,
919            discount_curve: curve,
920            entry_price: 0.0,
921            long_short: LongShort::LONG,
922            multiplier: 1.0,
923        };
924        EquityOption {
925            base,
926            payoff,
927            engine: Engine::BlackScholes,
928            mc: crate::equity::montecarlo::MonteCarloConfig::default(),
929            fd: crate::equity::finite_difference::FdConfig::default(),
930            heston: None,
931        }
932    }
933
934    fn test_option(put_or_call: PutOrCall, curve: YieldCurve) -> EquityOption {
935        test_option_with(
936            Box::new(VanillaPayoff { put_or_call, exercise_style: ContractStyle::European }),
937            curve,
938        )
939    }
940
941    fn binary_option_of(
942        put_or_call: PutOrCall,
943        binary_type: BinaryType,
944        cash: f64,
945    ) -> EquityOption {
946        test_option_with(
947            Box::new(BinaryPayoff {
948                put_or_call,
949                exercise_style: ContractStyle::European,
950                binary_type,
951                cash,
952            }),
953            flat_5pct(),
954        )
955    }
956
957    fn binary_option(put_or_call: PutOrCall) -> EquityOption {
958        binary_option_of(put_or_call, BinaryType::CashOrNothing, 1.0)
959    }
960
961    fn flat_5pct() -> YieldCurve {
962        YieldCurve::flat(
963            0.05,
964            NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
965            DayCountConvention::Act365,
966            Compounding::Continuous,
967        )
968        .unwrap()
969    }
970
971    // Golden values computed independently (erf-based reference implementation)
972    #[test]
973    fn golden_call_npv_and_greeks() {
974        let option = test_option(PutOrCall::Call, flat_5pct());
975        assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
976        assert_approx_eq!(option.delta(), 0.6242517279, 1e-8);
977        assert_approx_eq!(option.gamma(), 0.0126477644, 1e-8);
978        assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
979        assert_approx_eq!(option.theta(), -8.1011898970, 1e-8);
980        assert_approx_eq!(option.rho(), 48.1939180046, 1e-8);
981    }
982
983    #[test]
984    fn golden_put_npv_and_greeks() {
985        let option = test_option(PutOrCall::Put, flat_5pct());
986        assert_approx_eq!(option.npv(), 9.3541972361, 1e-8);
987        assert_approx_eq!(option.delta(), -0.3757482721, 1e-8);
988        assert_approx_eq!(option.gamma(), 0.0126477644, 1e-8);
989        assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
990        assert_approx_eq!(option.theta(), -3.3450427745, 1e-8);
991        assert_approx_eq!(option.rho(), -46.9290244455, 1e-8);
992    }
993
994    #[test]
995    fn put_call_parity() {
996        let call = test_option(PutOrCall::Call, flat_5pct());
997        let put = test_option(PutOrCall::Put, flat_5pct());
998        let s = call.base.underlying_price.value();
999        let k_df = call.base.strike_price * call.base.maturity_discount_factor();
1000        assert_approx_eq!(call.npv() - put.npv(), s - k_df, 1e-10);
1001    }
1002
1003    // Binary golden values computed independently and bump-verified
1004    #[test]
1005    fn golden_binary_call_npv_and_greeks() {
1006        let option = binary_option(PutOrCall::Call);
1007        assert_approx_eq!(option.npv(), 0.4819391800, 1e-8);
1008        assert_approx_eq!(option.delta(), 0.0126477644, 1e-8);
1009        assert_approx_eq!(option.gamma(), -0.0001335042, 1e-8);
1010        assert_approx_eq!(option.vega(), -0.4005125405, 1e-8);
1011        assert_approx_eq!(option.theta(), 0.0209350179, 1e-8);
1012        assert_approx_eq!(option.rho(), 0.7828372637, 1e-8);
1013    }
1014
1015    #[test]
1016    fn golden_binary_put_npv_and_greeks() {
1017        let option = binary_option(PutOrCall::Put);
1018        assert_approx_eq!(option.npv(), 0.4692902445, 1e-8);
1019        assert_approx_eq!(option.delta(), -0.0126477644, 1e-8);
1020        assert_approx_eq!(option.gamma(), 0.0001335042, 1e-8);
1021        assert_approx_eq!(option.vega(), 0.4005125405, 1e-8);
1022        assert_approx_eq!(option.theta(), 0.0266264533, 1e-8);
1023        assert_approx_eq!(option.rho(), -1.7340666882, 1e-8);
1024    }
1025
1026    #[test]
1027    fn binary_call_plus_put_equals_discount_factor() {
1028        let call = binary_option(PutOrCall::Call);
1029        let put = binary_option(PutOrCall::Put);
1030        assert_approx_eq!(call.npv() + put.npv(), call.base.maturity_discount_factor(), 1e-12);
1031    }
1032
1033    #[test]
1034    fn cash_amount_scales_cash_or_nothing_linearly() {
1035        let unit = binary_option(PutOrCall::Call);
1036        let sized = binary_option_of(PutOrCall::Call, BinaryType::CashOrNothing, 1000.0);
1037        assert_approx_eq!(sized.npv(), 1000.0 * unit.npv(), 1e-9);
1038        assert_approx_eq!(sized.delta(), 1000.0 * unit.delta(), 1e-9);
1039        assert_approx_eq!(sized.vega(), 1000.0 * unit.vega(), 1e-9);
1040    }
1041
1042    // Asset-or-nothing goldens computed independently and bump-verified,
1043    // with q = 2% so the dividend terms are exercised
1044    #[test]
1045    fn golden_asset_or_nothing_call_npv_and_greeks() {
1046        let mut option = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1047        option.base.dividend_yield = 0.02;
1048        assert_approx_eq!(option.npv(), 58.6851146135, 1e-8);
1049        assert_approx_eq!(option.delta(), 1.8502230631, 1e-8);
1050        assert_approx_eq!(option.gamma(), 0.0021056199, 1e-8);
1051        assert_approx_eq!(option.vega(), 6.3168595850, 1e-8);
1052        assert_approx_eq!(option.theta(), -3.5639423965, 1e-8);
1053        assert_approx_eq!(option.rho(), 126.3371917001, 1e-8);
1054    }
1055
1056    #[test]
1057    fn golden_asset_or_nothing_put_npv_and_greeks() {
1058        let mut option = binary_option_of(PutOrCall::Put, BinaryType::AssetOrNothing, 0.0);
1059        option.base.dividend_yield = 0.02;
1060        assert_approx_eq!(option.npv(), 39.3347527172, 1e-8);
1061        assert_approx_eq!(option.delta(), -0.8700243898, 1e-8);
1062        assert_approx_eq!(option.gamma(), -0.0021056199, 1e-8);
1063        assert_approx_eq!(option.vega(), -6.3168595850, 1e-8);
1064        assert_approx_eq!(option.theta(), 5.5243397431, 1e-8);
1065        assert_approx_eq!(option.rho(), -126.3371917001, 1e-8);
1066    }
1067
1068    #[test]
1069    fn asset_call_plus_put_equals_forward_leg() {
1070        let call = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1071        let put = binary_option_of(PutOrCall::Put, BinaryType::AssetOrNothing, 0.0);
1072        // A_c + A_p = S e^{-qT}; q = 0 in the test setup
1073        assert_approx_eq!(call.npv() + put.npv(), 100.0, 1e-10);
1074    }
1075
1076    /// Replication: an asset-or-nothing call is a long vanilla call plus
1077    /// K cash-or-nothing calls — payoff-wise S·1{S>K} = (S-K)^+ + K·1{S>K}.
1078    /// Both sides are implemented independently, so this checks the closed
1079    /// forms (price and every Greek) against each other.
1080    #[test]
1081    fn asset_digital_replicated_by_call_plus_cash_digitals() {
1082        let k = 100.0;
1083        let asset = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1084        let vanilla = test_option(PutOrCall::Call, flat_5pct());
1085        let cash = binary_option_of(PutOrCall::Call, BinaryType::CashOrNothing, k);
1086
1087        assert_approx_eq!(asset.npv(), vanilla.npv() + cash.npv(), 1e-10);
1088        assert_approx_eq!(asset.delta(), vanilla.delta() + cash.delta(), 1e-10);
1089        assert_approx_eq!(asset.gamma(), vanilla.gamma() + cash.gamma(), 1e-10);
1090        assert_approx_eq!(asset.vega(), vanilla.vega() + cash.vega(), 1e-10);
1091        assert_approx_eq!(asset.theta(), vanilla.theta() + cash.theta(), 1e-10);
1092        assert_approx_eq!(asset.rho(), vanilla.rho() + cash.rho(), 1e-10);
1093    }
1094
1095    /// The same replication must hold on the numerical engines, which see
1096    /// only the payoff function.
1097    #[test]
1098    fn asset_digital_replication_holds_across_engines() {
1099        let k = 100.0;
1100        let priced = |engine: Engine, payoff: Box<dyn Payoff>| {
1101            let mut option = test_option_with(payoff, flat_5pct());
1102            option.engine = engine.clone();
1103            option.npv()
1104        };
1105        let asset_payoff = || -> Box<dyn Payoff> {
1106            Box::new(BinaryPayoff {
1107                put_or_call: PutOrCall::Call,
1108                exercise_style: ContractStyle::European,
1109                binary_type: BinaryType::AssetOrNothing,
1110                cash: 0.0,
1111            })
1112        };
1113        let cash_payoff = || -> Box<dyn Payoff> {
1114            Box::new(BinaryPayoff {
1115                put_or_call: PutOrCall::Call,
1116                exercise_style: ContractStyle::European,
1117                binary_type: BinaryType::CashOrNothing,
1118                cash: k,
1119            })
1120        };
1121        let vanilla_payoff = || -> Box<dyn Payoff> {
1122            Box::new(VanillaPayoff {
1123                put_or_call: PutOrCall::Call,
1124                exercise_style: ContractStyle::European,
1125            })
1126        };
1127        for (engine, tol) in [
1128            (Engine::FiniteDifference, 0.01),
1129            (Engine::Binomial, 0.05),
1130            (Engine::MonteCarlo, 0.05),
1131        ] {
1132            let asset = priced(engine.clone(), asset_payoff());
1133            let replicated =
1134                priced(engine.clone(), vanilla_payoff()) + priced(engine.clone(), cash_payoff());
1135            assert!(
1136                (asset - replicated).abs() < tol,
1137                "{engine:?}: asset={asset} replicated={replicated}"
1138            );
1139        }
1140    }
1141
1142    #[test]
1143    fn asset_digital_matches_analytic_across_engines() {
1144        let analytic = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0).npv();
1145        for (engine, tol) in [
1146            (Engine::FiniteDifference, 0.05),
1147            (Engine::Binomial, 2.0), // digitals on a CRR tree oscillate; jump size is ~K
1148            (Engine::MonteCarlo, 0.05),
1149        ] {
1150            let mut option = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
1151            option.engine = engine.clone();
1152            let value = option.npv();
1153            assert!(
1154                (value - analytic).abs() < tol,
1155                "{engine:?}: {value} vs analytic {analytic}"
1156            );
1157        }
1158    }
1159
1160    // ── Cross-engine agreement ──────────────────────────────────────────
1161
1162    #[test]
1163    fn finite_difference_matches_analytic_vanilla() {
1164        for pc in [PutOrCall::Call, PutOrCall::Put] {
1165            let mut option = test_option(pc, flat_5pct());
1166            let analytic = option.npv();
1167            option.engine = Engine::FiniteDifference;
1168            let fd = option.npv();
1169            assert!(
1170                (fd - analytic).abs() < 0.01,
1171                "{pc:?}: fd={fd} analytic={analytic}"
1172            );
1173        }
1174    }
1175
1176    #[test]
1177    fn finite_difference_matches_analytic_binary() {
1178        for pc in [PutOrCall::Call, PutOrCall::Put] {
1179            let mut option = binary_option(pc);
1180            let analytic = option.npv();
1181            option.engine = Engine::FiniteDifference;
1182            let fd = option.npv();
1183            assert!(
1184                (fd - analytic).abs() < 0.002,
1185                "{pc:?}: fd={fd} analytic={analytic}"
1186            );
1187        }
1188    }
1189
1190    #[test]
1191    fn binomial_matches_analytic_vanilla_and_binary() {
1192        for pc in [PutOrCall::Call, PutOrCall::Put] {
1193            let mut vanilla = test_option(pc, flat_5pct());
1194            let analytic = vanilla.npv();
1195            vanilla.engine = Engine::Binomial;
1196            let tree = vanilla.npv();
1197            assert!((tree - analytic).abs() < 0.02, "vanilla {pc:?}: tree={tree} bs={analytic}");
1198
1199            let mut binary = binary_option(pc);
1200            let analytic = binary.npv();
1201            binary.engine = Engine::Binomial;
1202            let tree = binary.npv();
1203            assert!((tree - analytic).abs() < 0.02, "binary {pc:?}: tree={tree} bs={analytic}");
1204        }
1205    }
1206
1207    #[test]
1208    fn monte_carlo_matches_analytic_vanilla_and_binary() {
1209        // default config: Sobol low-discrepancy terminal simulation
1210        for pc in [PutOrCall::Call, PutOrCall::Put] {
1211            let mut vanilla = test_option(pc, flat_5pct());
1212            let analytic = vanilla.npv();
1213            vanilla.engine = Engine::MonteCarlo;
1214            let mc = vanilla.npv();
1215            assert!((mc - analytic).abs() < 0.02, "vanilla {pc:?}: mc={mc} bs={analytic}");
1216
1217            let mut binary = binary_option(pc);
1218            let analytic = binary.npv();
1219            binary.engine = Engine::MonteCarlo;
1220            let mc = binary.npv();
1221            assert!((mc - analytic).abs() < 0.005, "binary {pc:?}: mc={mc} bs={analytic}");
1222        }
1223    }
1224
1225    #[test]
1226    fn monte_carlo_sobol_beats_default_tolerance_and_is_reproducible() {
1227        let mut option = test_option(PutOrCall::Call, flat_5pct());
1228        option.engine = Engine::MonteCarlo;
1229        let first = option.npv();
1230        let second = option.npv();
1231        assert_eq!(first, second, "deterministic sampler must reproduce exactly");
1232        assert!((first - 14.2312547860).abs() < 0.02, "sobol mc = {first}");
1233    }
1234
1235    #[test]
1236    fn monte_carlo_path_wise_starts_at_spot_and_schemes_converge() {
1237        // regression for the path-wise bug (paths used to start at the
1238        // option premium instead of the underlying spot)
1239        let analytic = test_option(PutOrCall::Call, flat_5pct()).npv();
1240        for scheme in ["exact", "euler", "milstein"] {
1241            let mut option = test_option(PutOrCall::Call, flat_5pct());
1242            option.engine = Engine::MonteCarlo;
1243            option.mc.scheme = scheme.parse().unwrap();
1244            option.mc.time_steps = 252;
1245            option.mc.paths = 50_000;
1246            let mc = option.npv();
1247            assert!(
1248                (mc - analytic).abs() < 0.35,
1249                "{scheme}: mc={mc} analytic={analytic}"
1250            );
1251        }
1252    }
1253
1254    #[test]
1255    fn monte_carlo_greeks_match_analytic() {
1256        let mut option = test_option(PutOrCall::Call, flat_5pct());
1257        option.engine = Engine::MonteCarlo;
1258        // common-random-number bumps against the analytic golden values
1259        assert!((option.delta() - 0.6242517279).abs() < 0.01, "delta {}", option.delta());
1260        assert!((option.gamma() - 0.0126477644).abs() < 0.003, "gamma {}", option.gamma());
1261        assert!((option.vega() - 37.9432933117).abs() < 1.0, "vega {}", option.vega());
1262        assert!((option.theta() - -8.1011898970).abs() < 0.5, "theta {}", option.theta());
1263        assert!((option.rho() - 48.1939180046).abs() < 0.5, "rho {}", option.rho());
1264    }
1265
1266    #[test]
1267    fn lsmc_american_put_close_to_tree_and_dominates_european() {
1268        let european = test_option(PutOrCall::Put, flat_5pct()).npv();
1269        let mut tree_option = test_option_with(
1270            Box::new(VanillaPayoff {
1271                put_or_call: PutOrCall::Put,
1272                exercise_style: ContractStyle::American,
1273            }),
1274            flat_5pct(),
1275        );
1276        tree_option.engine = Engine::Binomial;
1277        let tree = tree_option.npv();
1278
1279        let mut lsmc_option = test_option_with(
1280            Box::new(VanillaPayoff {
1281                put_or_call: PutOrCall::Put,
1282                exercise_style: ContractStyle::American,
1283            }),
1284            flat_5pct(),
1285        );
1286        lsmc_option.engine = Engine::MonteCarlo;
1287        lsmc_option.mc.paths = 20_000;
1288        let lsmc = lsmc_option.npv();
1289
1290        // LSMC is biased slightly low (suboptimal exercise policy) but must
1291        // sit between the European price and just above the tree price
1292        assert!(lsmc > european, "lsmc {lsmc} must exceed european {european}");
1293        assert!((lsmc - tree).abs() < 0.25, "lsmc={lsmc} tree={tree}");
1294    }
1295
1296    // ── Implied vol solver ──────────────────────────────────────────────
1297
1298    #[test]
1299    fn implied_vol_round_trips_across_strikes_and_vols() {
1300        let (s, r, q) = (100.0, 0.05, 0.02);
1301        for pc in [PutOrCall::Call, PutOrCall::Put] {
1302            for k in [50.0, 80.0, 100.0, 120.0, 200.0] {
1303                for vol in [0.05, 0.2, 0.6, 1.5] {
1304                    for t in [0.05, 0.5, 2.0] {
1305                        let price = bs_price(s, k, r, q, vol, t, pc);
1306                        // skip quotes indistinguishable from intrinsic
1307                        if price - bs_price(s, k, r, q, 0.0, t, pc) < 1e-10 {
1308                            continue;
1309                        }
1310                        let iv = implied_vol_from_price(s, k, r, q, t, price, pc).unwrap();
1311                        // deep in-the-money short-dated quotes have vega ~1e-7,
1312                        // so a double-precision price only pins the vol to
1313                        // ~1e-6 — 1e-5 is the attainable accuracy everywhere
1314                        assert!(
1315                            (iv - vol).abs() < 1e-5,
1316                            "{pc:?} K={k} vol={vol} t={t}: recovered {iv}"
1317                        );
1318                    }
1319                }
1320            }
1321        }
1322    }
1323
1324    #[test]
1325    fn implied_vol_rejects_arbitrage_violating_prices() {
1326        // below intrinsic
1327        assert!(implied_vol_from_price(100.0, 80.0, 0.05, 0.0, 1.0, 10.0, PutOrCall::Call)
1328            .is_err());
1329        // above the underlying
1330        assert!(implied_vol_from_price(100.0, 100.0, 0.05, 0.0, 1.0, 101.0, PutOrCall::Call)
1331            .is_err());
1332    }
1333
1334    // ── Implied surface construction + Dupire local vol round trip ──────
1335
1336    /// Quotes generated from a known smile: sigma(K, T) = base(T) - 0.001*(K-100)
1337    fn smile_vol(k: f64, base: f64) -> f64 {
1338        base - 0.001 * (k - 100.0)
1339    }
1340
1341    fn quoted_option(
1342        k: f64,
1343        maturity: NaiveDate,
1344        market_price: f64,
1345    ) -> Box<EquityOption> {
1346        let mut option = test_option(PutOrCall::Call, flat_5pct());
1347        option.base.strike_price = k;
1348        option.base.maturity_date = maturity;
1349        option.base.current_price = Quote::new(market_price);
1350        Box::new(option)
1351    }
1352
1353    fn build_surface_from_quotes() -> crate::core::vols::VolSurface {
1354        let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1355        let maturities = [
1356            (NaiveDate::from_ymd_opt(2026, 7, 2).unwrap(), 0.23),
1357            (NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(), 0.25),
1358        ];
1359        let mut quotes = Vec::new();
1360        for (maturity, base) in maturities {
1361            let t = (maturity - valuation).num_days() as f64 / 365.0;
1362            for i in 0..13 {
1363                let k = 70.0 + 5.0 * i as f64;
1364                let vol = smile_vol(k, base);
1365                let price = bs_price(100.0, k, 0.05, 0.0, vol, t, PutOrCall::Call);
1366                quotes.push(quoted_option(k, maturity, price));
1367            }
1368        }
1369        crate::equity::vol_surface::build_implied_vol_surface(&quotes).unwrap()
1370    }
1371
1372    #[test]
1373    fn implied_surface_recovers_input_vols() {
1374        let surface = build_surface_from_quotes();
1375        // exact at the quoted pillars (forward is irrelevant on a strike axis)
1376        for (t, base) in [(182.0 / 365.0, 0.23), (1.0, 0.25)] {
1377            for k in [70.0, 85.0, 100.0, 115.0, 130.0] {
1378                let vol = surface.vol(k, 100.0, t);
1379                assert!(
1380                    (vol - smile_vol(k, base)).abs() < 1e-7,
1381                    "K={k} t={t}: {vol} vs {}",
1382                    smile_vol(k, base)
1383                );
1384            }
1385        }
1386    }
1387
1388    fn local_vol_option(surface: crate::core::vols::VolSurface, k: f64) -> EquityOption {
1389        let mut option = test_option(PutOrCall::Call, flat_5pct());
1390        option.base.strike_price = k;
1391        option.base.vol_surface = surface;
1392        option.engine = Engine::MonteCarlo;
1393        option.mc.model = crate::equity::montecarlo::McModel::LocalVol;
1394        option.mc.paths = 20_000;
1395        option
1396    }
1397
1398    #[test]
1399    fn local_vol_prices_back_vanilla_from_calibrated_surface() {
1400        // implied quotes -> implied surface -> Dupire local vol -> MC price
1401        // must reproduce the original Black-Scholes prices
1402        let surface = build_surface_from_quotes();
1403        for k in [90.0, 100.0, 110.0] {
1404            let expected = bs_price(100.0, k, 0.05, 0.0, smile_vol(k, 0.25), 1.0, PutOrCall::Call);
1405            let lv_price = local_vol_option(surface.clone(), k).npv();
1406            assert!(
1407                (lv_price - expected).abs() < 0.3,
1408                "K={k}: local vol {lv_price} vs BS {expected}"
1409            );
1410        }
1411    }
1412
1413    #[test]
1414    fn local_vol_flat_surface_reproduces_black_scholes() {
1415        let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1416        let surface =
1417            crate::core::vols::VolSurface::flat(0.3, valuation, DayCountConvention::Act365)
1418                .unwrap();
1419        let expected = 14.2312547860; // flat-30% golden
1420        let lv_price = local_vol_option(surface, 100.0).npv();
1421        assert!((lv_price - expected).abs() < 0.3, "{lv_price} vs {expected}");
1422    }
1423
1424    #[test]
1425    fn local_vol_term_structure_reproduces_terminal_implied() {
1426        // 20% to 6M, 25% to 1Y: pricing a 1Y option through the local vol
1427        // (which steps at ~20% then at the ~29.2% forward vol) must recover
1428        // the 25% terminal implied price
1429        let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
1430        let surface = crate::core::vols::VolSurface::from_strike_smiles(
1431            &[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0)],
1432            &[vec![(100.0, 0.20)], vec![(100.0, 0.25)]],
1433            valuation,
1434            DayCountConvention::Act365,
1435        )
1436        .unwrap();
1437        let expected = bs_price(100.0, 100.0, 0.05, 0.0, 0.25, 1.0, PutOrCall::Call);
1438        let lv_price = local_vol_option(surface, 100.0).npv();
1439        assert!((lv_price - expected).abs() < 0.3, "{lv_price} vs {expected}");
1440    }
1441
1442    // ── Barrier options ─────────────────────────────────────────────────
1443
1444    fn barrier_option(
1445        put_or_call: PutOrCall,
1446        direction: crate::equity::barrier::BarrierDirection,
1447        knock: crate::equity::barrier::KnockType,
1448        barrier: f64,
1449    ) -> EquityOption {
1450        let mut option = test_option_with(
1451            Box::new(BarrierPayoff {
1452                put_or_call,
1453                exercise_style: ContractStyle::European,
1454                direction,
1455                knock,
1456                barrier,
1457            }),
1458            flat_5pct(),
1459        );
1460        option.base.dividend_yield = 0.02; // match the oracle setup
1461        option
1462    }
1463
1464    #[test]
1465    fn golden_barrier_prices_all_eight_types() {
1466        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1467        // independently generated Reiner-Rubinstein oracle values
1468        // (S=100, K=100, r=5%, q=2%, sigma=30%, T=1)
1469        let cases = [
1470            (Down, In, PutOrCall::Call, 90.0, 4.5095197744),
1471            (Down, Out, PutOrCall::Call, 90.0, 8.5107614943),
1472            (Down, In, PutOrCall::Put, 90.0, 10.0710164338),
1473            (Down, Out, PutOrCall::Put, 90.0, 0.0523399543),
1474            (Up, In, PutOrCall::Call, 120.0, 12.5974705742),
1475            (Up, Out, PutOrCall::Call, 120.0, 0.4228106946),
1476            (Up, In, PutOrCall::Put, 120.0, 1.4297711810),
1477            (Up, Out, PutOrCall::Put, 120.0, 8.6935852071),
1478        ];
1479        for (direction, knock, pc, h, expected) in cases {
1480            let option = barrier_option(pc, direction, knock, h);
1481            assert_approx_eq!(option.npv(), expected, 1e-8);
1482        }
1483    }
1484
1485    #[test]
1486    fn barrier_greeks_satisfy_in_out_parity() {
1487        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1488        // KI + KO = vanilla holds for the Greeks too (no rebate)
1489        let ki = barrier_option(PutOrCall::Call, Down, In, 90.0);
1490        let ko = barrier_option(PutOrCall::Call, Down, Out, 90.0);
1491        let mut vanilla = test_option(PutOrCall::Call, flat_5pct());
1492        vanilla.base.dividend_yield = 0.02;
1493        assert_approx_eq!(ki.npv() + ko.npv(), vanilla.npv(), 1e-10);
1494        assert_approx_eq!(ki.delta() + ko.delta(), vanilla.delta(), 1e-5);
1495        assert_approx_eq!(ki.gamma() + ko.gamma(), vanilla.gamma(), 1e-4);
1496        assert_approx_eq!(ki.vega() + ko.vega(), vanilla.vega(), 1e-4);
1497        assert_approx_eq!(ki.theta() + ko.theta(), vanilla.theta(), 1e-4);
1498        assert_approx_eq!(ki.rho() + ko.rho(), vanilla.rho(), 1e-4);
1499    }
1500
1501    #[test]
1502    fn monte_carlo_barrier_matches_analytic() {
1503        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1504        let cases = [
1505            (Down, Out, PutOrCall::Call, 90.0),
1506            (Down, In, PutOrCall::Put, 90.0),
1507            (Up, Out, PutOrCall::Put, 120.0),
1508            (Up, In, PutOrCall::Call, 110.0),
1509        ];
1510        for (direction, knock, pc, h) in cases {
1511            let analytic = barrier_option(pc, direction, knock, h).npv();
1512            let mut option = barrier_option(pc, direction, knock, h);
1513            option.engine = Engine::MonteCarlo;
1514            option.mc.paths = 50_000;
1515            let mc = option.npv();
1516            assert!(
1517                (mc - analytic).abs() < 0.3,
1518                "{direction:?} {knock:?} {pc:?} H={h}: mc={mc} analytic={analytic}"
1519            );
1520        }
1521    }
1522
1523    // ── Asian options ───────────────────────────────────────────────────
1524
1525    fn asian_option(
1526        put_or_call: PutOrCall,
1527        averaging: crate::equity::asian::AveragingType,
1528        strike_type: crate::equity::asian::AsianStrikeType,
1529    ) -> EquityOption {
1530        let mut option = test_option_with(
1531            Box::new(AsianPayoff {
1532                put_or_call,
1533                exercise_style: ContractStyle::European,
1534                averaging,
1535                strike_type,
1536            }),
1537            flat_5pct(),
1538        );
1539        option.base.dividend_yield = 0.02; // match the oracle setup
1540        option
1541    }
1542
1543    #[test]
1544    fn golden_asian_analytic_prices() {
1545        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1546        // independently generated oracle values (S=100 K=100 r=5% q=2% sigma=30% T=1)
1547        let geo = asian_option(PutOrCall::Call, Geometric, FixedStrike);
1548        assert_approx_eq!(geo.npv(), 6.953600, 1e-5);
1549        let arith = asian_option(PutOrCall::Call, Arithmetic, FixedStrike);
1550        assert_approx_eq!(arith.npv(), 7.409272, 1e-5);
1551    }
1552
1553    #[test]
1554    fn geometric_asian_mc_matches_discrete_closed_form() {
1555        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1556        let mut option = asian_option(PutOrCall::Call, Geometric, FixedStrike);
1557        option.engine = Engine::MonteCarlo;
1558        option.mc.paths = 50_000;
1559        let mc = option.npv(); // generic path route, 100 monitoring steps
1560        let closed = crate::equity::asian::geometric_asian_price(
1561            100.0, 100.0, 0.05, 0.02, 0.3, 1.0, Some(100), PutOrCall::Call,
1562        );
1563        assert!((mc - closed).abs() < 0.15, "mc={mc} closed={closed}");
1564    }
1565
1566    #[test]
1567    fn arithmetic_asian_cv_mc_close_to_turnbull_wakeman() {
1568        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1569        let analytic = asian_option(PutOrCall::Call, Arithmetic, FixedStrike).npv();
1570        let mut option = asian_option(PutOrCall::Call, Arithmetic, FixedStrike);
1571        option.engine = Engine::MonteCarlo;
1572        option.mc.paths = 50_000;
1573        let mc = option.npv(); // control-variate route
1574        // TW is a moment-matching approximation and the MC monitors
1575        // discretely, so agreement is at the approximation level, not
1576        // sampler noise level
1577        assert!((mc - analytic).abs() < 0.08, "cv-mc={mc} tw={analytic}");
1578    }
1579
1580    #[test]
1581    fn arithmetic_average_dominates_geometric_on_same_paths() {
1582        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1583        let price_mc = |averaging| {
1584            let mut option = asian_option(PutOrCall::Call, averaging, FixedStrike);
1585            option.engine = Engine::MonteCarlo;
1586            option.mc.paths = 20_000;
1587            // force the generic path route for both by disabling the CV's
1588            // exact-scheme precondition
1589            option.mc.scheme = crate::equity::montecarlo::DiscretizationScheme::Euler;
1590            option.mc.time_steps = 100;
1591            option.npv()
1592        };
1593        assert!(price_mc(Arithmetic) > price_mc(Geometric), "AM-GM inequality");
1594    }
1595
1596    #[test]
1597    fn floating_strike_asian_prices_on_mc_only() {
1598        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1599        let mut option = asian_option(PutOrCall::Call, Arithmetic, FloatingStrike);
1600        option.engine = Engine::MonteCarlo;
1601        option.mc.paths = 20_000;
1602        let price = option.npv();
1603        // floating-strike call: pays (S_T - average)^+; positive, below vanilla
1604        let vanilla = test_option(PutOrCall::Call, flat_5pct()).npv();
1605        assert!(price > 0.0 && price < vanilla, "{price}");
1606    }
1607
1608    #[test]
1609    #[should_panic(expected = "no analytic pricer")]
1610    fn analytic_engine_rejects_floating_strike_asian() {
1611        use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
1612        asian_option(PutOrCall::Call, Arithmetic, FloatingStrike).npv();
1613    }
1614
1615    // ── FD upgrades: grid Greeks, barriers, local vol, config ───────────
1616
1617    #[test]
1618    fn fd_grid_greeks_match_analytic_for_european() {
1619        let mut option = test_option(PutOrCall::Call, flat_5pct());
1620        option.engine = Engine::FiniteDifference;
1621        assert!((option.delta() - 0.6242517279).abs() < 1e-3, "delta {}", option.delta());
1622        assert!((option.gamma() - 0.0126477644).abs() < 1e-4, "gamma {}", option.gamma());
1623        assert!((option.theta() - -8.1011898970).abs() < 0.03, "theta {}", option.theta());
1624        assert!((option.vega() - 37.9432933117).abs() < 0.05, "vega {}", option.vega());
1625        assert!((option.rho() - 48.1939180046).abs() < 0.05, "rho {}", option.rho());
1626    }
1627
1628    #[test]
1629    fn fd_american_put_greeks_differ_from_european_correctly() {
1630        let mut american = test_option_with(
1631            Box::new(VanillaPayoff {
1632                put_or_call: PutOrCall::Put,
1633                exercise_style: ContractStyle::American,
1634            }),
1635            flat_5pct(),
1636        );
1637        american.engine = Engine::FiniteDifference;
1638        let european_delta = -0.3757482721; // analytic European put delta
1639        // early exercise makes the American put delta more negative and
1640        // theta less negative than the European
1641        assert!(
1642            american.delta() < european_delta,
1643            "american delta {} vs european {european_delta}",
1644            american.delta()
1645        );
1646        assert!(american.npv() > test_option(PutOrCall::Put, flat_5pct()).npv());
1647    }
1648
1649    #[test]
1650    fn fd_brennan_schwartz_american_matches_tree() {
1651        let mut fd = test_option_with(
1652            Box::new(VanillaPayoff {
1653                put_or_call: PutOrCall::Put,
1654                exercise_style: ContractStyle::American,
1655            }),
1656            flat_5pct(),
1657        );
1658        fd.engine = Engine::FiniteDifference;
1659        let mut tree = test_option_with(
1660            Box::new(VanillaPayoff {
1661                put_or_call: PutOrCall::Put,
1662                exercise_style: ContractStyle::American,
1663            }),
1664            flat_5pct(),
1665        );
1666        tree.engine = Engine::Binomial;
1667        assert!((fd.npv() - tree.npv()).abs() < 0.02, "fd={} tree={}", fd.npv(), tree.npv());
1668    }
1669
1670    #[test]
1671    fn fd_barrier_matches_reiner_rubinstein() {
1672        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1673        for (direction, knock, pc, h) in [
1674            (Down, Out, PutOrCall::Call, 90.0),
1675            (Down, In, PutOrCall::Call, 90.0),
1676            (Up, Out, PutOrCall::Put, 120.0),
1677            (Up, In, PutOrCall::Put, 120.0),
1678        ] {
1679            let analytic = barrier_option(pc, direction, knock, h).npv();
1680            let mut option = barrier_option(pc, direction, knock, h);
1681            option.engine = Engine::FiniteDifference;
1682            let fd = option.npv();
1683            assert!(
1684                (fd - analytic).abs() < 0.02,
1685                "{direction:?} {knock:?} {pc:?} H={h}: fd={fd} analytic={analytic}"
1686            );
1687        }
1688    }
1689
1690    #[test]
1691    fn fd_barrier_in_out_parity_on_grid() {
1692        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1693        let mut ki = barrier_option(PutOrCall::Call, Down, In, 90.0);
1694        let mut ko = barrier_option(PutOrCall::Call, Down, Out, 90.0);
1695        ki.engine = Engine::FiniteDifference;
1696        ko.engine = Engine::FiniteDifference;
1697        let mut vanilla = test_option(PutOrCall::Call, flat_5pct());
1698        vanilla.base.dividend_yield = 0.02;
1699        vanilla.engine = Engine::FiniteDifference;
1700        assert!((ki.npv() + ko.npv() - vanilla.npv()).abs() < 1e-9);
1701        assert!((ki.delta() + ko.delta() - vanilla.delta()).abs() < 1e-9);
1702    }
1703
1704    #[test]
1705    fn fd_local_vol_flat_surface_matches_black_scholes() {
1706        let mut option = test_option(PutOrCall::Call, flat_5pct());
1707        option.engine = Engine::FiniteDifference;
1708        option.mc.model = crate::equity::montecarlo::McModel::LocalVol;
1709        // flat surface: local vol == implied vol, FD-LV must equal FD-GBM
1710        assert_approx_eq!(option.npv(), 14.2312547860, 5e-3);
1711    }
1712
1713    #[test]
1714    fn fd_grid_is_configurable() {
1715        let mut coarse = test_option(PutOrCall::Call, flat_5pct());
1716        coarse.engine = Engine::FiniteDifference;
1717        coarse.fd.spot_steps = 100;
1718        coarse.fd.time_steps = 50;
1719        // still accurate at a quarter of the resolution
1720        assert!((coarse.npv() - 14.2312547860).abs() < 0.02, "{}", coarse.npv());
1721    }
1722
1723    // ── MC upgrades: QMC paths, stats, determinism ──────────────────────
1724
1725    #[test]
1726    fn qmc_path_wise_prices_accurately() {
1727        // multi-step path simulation through the Brownian bridge + QMC
1728        let mut option = test_option(PutOrCall::Call, flat_5pct());
1729        option.engine = Engine::MonteCarlo;
1730        option.mc.time_steps = 64;
1731        option.mc.paths = 20_000;
1732        let qmc = option.npv();
1733        assert!((qmc - 14.2312547860).abs() < 0.05, "qmc path-wise {qmc}");
1734    }
1735
1736    #[test]
1737    fn mc_stats_reports_consistent_standard_error() {
1738        let mut option = test_option(PutOrCall::Call, flat_5pct());
1739        option.engine = Engine::MonteCarlo;
1740        option.mc.sampler = crate::equity::montecarlo::Sampler::PseudoRandom;
1741        let stats = crate::equity::montecarlo::npv_with_stats(&option);
1742        assert!(stats.std_err > 0.0 && stats.std_err < 1.0);
1743        assert!(stats.paths == 100_000 && stats.steps == 1);
1744        // iid pseudo draws: the analytic value must sit within a few
1745        // standard errors of the estimate
1746        assert!(
1747            (stats.pv - 14.2312547860).abs() < 5.0 * stats.std_err,
1748            "pv={} stderr={}",
1749            stats.pv,
1750            stats.std_err
1751        );
1752    }
1753
1754    #[test]
1755    fn parallel_paths_are_bit_reproducible() {
1756        for sampler in
1757            [crate::equity::montecarlo::Sampler::Sobol, crate::equity::montecarlo::Sampler::PseudoRandom]
1758        {
1759            let mut option = test_option(PutOrCall::Call, flat_5pct());
1760            option.engine = Engine::MonteCarlo;
1761            option.mc.sampler = sampler;
1762            option.mc.time_steps = 32;
1763            option.mc.paths = 30_000;
1764            assert_eq!(option.npv(), option.npv());
1765        }
1766    }
1767
1768    // ── Heston stochastic vol ───────────────────────────────────────────
1769
1770    fn heston_option(payoff: Box<dyn Payoff>) -> EquityOption {
1771        let mut option = test_option_with(payoff, flat_5pct());
1772        option.base.dividend_yield = 0.02;
1773        option.mc.model = crate::equity::montecarlo::McModel::Heston;
1774        option.heston = Some(crate::equity::heston::HestonParams {
1775            v0: 0.09,
1776            kappa: 2.0,
1777            theta: 0.09,
1778            vol_of_vol: 0.4,
1779            rho: -0.7,
1780        });
1781        option
1782    }
1783
1784    fn heston_vanilla(pc: PutOrCall) -> EquityOption {
1785        heston_option(Box::new(VanillaPayoff {
1786            put_or_call: pc,
1787            exercise_style: ContractStyle::European,
1788        }))
1789    }
1790
1791    #[test]
1792    fn heston_mc_matches_semi_analytic() {
1793        for pc in [PutOrCall::Call, PutOrCall::Put] {
1794            let analytic = heston_vanilla(pc).npv();
1795            let mut mc = heston_vanilla(pc);
1796            mc.engine = Engine::MonteCarlo;
1797            mc.mc.paths = 50_000;
1798            let mc_price = mc.npv();
1799            // full-truncation Euler bias + sampler noise at 50k x 250
1800            assert!(
1801                (mc_price - analytic).abs() < 0.15,
1802                "{pc:?}: mc={mc_price} analytic={analytic}"
1803            );
1804        }
1805    }
1806
1807    #[test]
1808    fn heston_binary_mc_matches_semi_analytic() {
1809        let payoff = || -> Box<dyn Payoff> {
1810            Box::new(BinaryPayoff {
1811                put_or_call: PutOrCall::Call,
1812                exercise_style: ContractStyle::European,
1813                binary_type: BinaryType::CashOrNothing,
1814                cash: 1.0,
1815            })
1816        };
1817        let analytic = heston_option(payoff()).npv();
1818        let mut mc = heston_option(payoff());
1819        mc.engine = Engine::MonteCarlo;
1820        mc.mc.paths = 50_000;
1821        assert!((mc.npv() - analytic).abs() < 0.01, "mc={} analytic={analytic}", mc.npv());
1822    }
1823
1824    #[test]
1825    fn heston_greeks_are_consistent() {
1826        let call = heston_vanilla(PutOrCall::Call);
1827        let put = heston_vanilla(PutOrCall::Put);
1828        // parity: delta_call - delta_put = e^{-qT}
1829        let dfq = (-0.02_f64).exp();
1830        assert!((call.delta() - put.delta() - dfq).abs() < 1e-4);
1831        // same gamma and vega for call and put by parity
1832        assert!((call.gamma() - put.gamma()).abs() < 1e-6);
1833        assert!((call.vega() - put.vega()).abs() < 1e-4);
1834        assert!(call.vega() > 0.0);
1835    }
1836
1837    #[test]
1838    fn heston_barrier_and_asian_price_on_mc() {
1839        // knock-out <= vanilla under the same dynamics; asian < vanilla
1840        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
1841        let vanilla = {
1842            let mut o = heston_vanilla(PutOrCall::Call);
1843            o.engine = Engine::MonteCarlo;
1844            o.mc.paths = 20_000;
1845            o.npv()
1846        };
1847        let mut ko = heston_option(Box::new(BarrierPayoff {
1848            put_or_call: PutOrCall::Call,
1849            exercise_style: ContractStyle::European,
1850            direction: Down,
1851            knock: Out,
1852            barrier: 90.0,
1853        }));
1854        ko.engine = Engine::MonteCarlo;
1855        ko.mc.paths = 20_000;
1856        let ko_price = ko.npv();
1857        assert!(ko_price > 0.0 && ko_price < vanilla, "ko={ko_price} vanilla={vanilla}");
1858
1859        let mut asian = heston_option(Box::new(AsianPayoff {
1860            put_or_call: PutOrCall::Call,
1861            exercise_style: ContractStyle::European,
1862            averaging: crate::equity::asian::AveragingType::Arithmetic,
1863            strike_type: crate::equity::asian::AsianStrikeType::FixedStrike,
1864        }));
1865        asian.engine = Engine::MonteCarlo;
1866        asian.mc.paths = 20_000;
1867        let asian_price = asian.npv();
1868        assert!(asian_price > 0.0 && asian_price < vanilla);
1869    }
1870
1871    // ── Borrow cost and dividends ───────────────────────────────────────
1872
1873    #[test]
1874    fn borrow_cost_is_equivalent_to_extra_dividend_yield() {
1875        for engine in [Engine::BlackScholes, Engine::FiniteDifference, Engine::MonteCarlo] {
1876            let mut with_borrow = test_option(PutOrCall::Call, flat_5pct());
1877            with_borrow.base.dividend_yield = 0.01;
1878            with_borrow.base.borrow_cost = 0.03;
1879            with_borrow.engine = engine.clone();
1880            let mut with_yield = test_option(PutOrCall::Call, flat_5pct());
1881            with_yield.base.dividend_yield = 0.04;
1882            with_yield.engine = engine.clone();
1883            assert!(
1884                (with_borrow.npv() - with_yield.npv()).abs() < 1e-12,
1885                "{engine:?}: borrow {} vs yield {}",
1886                with_borrow.npv(),
1887                with_yield.npv()
1888            );
1889        }
1890    }
1891
1892    fn dividend_paying_option(pc: PutOrCall) -> EquityOption {
1893        let mut option = test_option(pc, flat_5pct());
1894        option.base.cash_dividends =
1895            vec![(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), 3.0)];
1896        option
1897    }
1898
1899    #[test]
1900    fn cash_dividend_prices_as_escrowed_spot_analytically() {
1901        let option = dividend_paying_option(PutOrCall::Call);
1902        let t_div = (NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
1903            - NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
1904        .num_days() as f64
1905            / 365.0;
1906        let s_eff = 100.0 - 3.0 * (-0.05 * t_div).exp();
1907        assert!((option.base.effective_spot() - s_eff).abs() < 1e-10);
1908        let expected = bs_price(s_eff, 100.0, 0.05, 0.0, 0.3, 1.0, PutOrCall::Call);
1909        assert_approx_eq!(option.npv(), expected, 1e-10);
1910    }
1911
1912    #[test]
1913    fn put_call_parity_with_dividends_and_borrow() {
1914        let mut call = dividend_paying_option(PutOrCall::Call);
1915        let mut put = dividend_paying_option(PutOrCall::Put);
1916        call.base.borrow_cost = 0.02;
1917        put.base.borrow_cost = 0.02;
1918        let parity = call.base.effective_spot() * (-call.base.carry_yield()).exp()
1919            - 100.0 * (-0.05_f64).exp();
1920        assert_approx_eq!(call.npv() - put.npv(), parity, 1e-10);
1921    }
1922
1923    #[test]
1924    fn mc_dividend_jumps_close_to_escrowed_analytic() {
1925        // the jump model (dividends subtracted on the path) and the
1926        // escrowed model differ slightly by construction; they must agree
1927        // at the tens-of-basis-points level for moderate dividends
1928        let analytic = dividend_paying_option(PutOrCall::Call).npv();
1929        let mut mc = dividend_paying_option(PutOrCall::Call);
1930        mc.engine = Engine::MonteCarlo;
1931        mc.mc.time_steps = 100;
1932        mc.mc.paths = 50_000;
1933        assert!((mc.npv() - analytic).abs() < 0.3, "mc={} analytic={analytic}", mc.npv());
1934    }
1935
1936    #[test]
1937    fn fd_dividend_jump_condition_consistent_with_mc_jump_model() {
1938        // FD and path-MC both implement the jump (piecewise lognormal)
1939        // dividend model and must agree tightly; both sit a known
1940        // ~0.1-0.2 above the escrowed analytic for a call (the classic
1941        // escrowed-vs-jump model difference)
1942        let mut fd = dividend_paying_option(PutOrCall::Call);
1943        fd.engine = Engine::FiniteDifference;
1944        let mut mc = dividend_paying_option(PutOrCall::Call);
1945        mc.engine = Engine::MonteCarlo;
1946        mc.mc.time_steps = 100;
1947        mc.mc.paths = 50_000;
1948        assert!((fd.npv() - mc.npv()).abs() < 0.1, "fd={} mc={}", fd.npv(), mc.npv());
1949        let escrowed = dividend_paying_option(PutOrCall::Call).npv();
1950        assert!((fd.npv() - escrowed).abs() < 0.3, "fd={} escrowed={escrowed}", fd.npv());
1951    }
1952
1953    #[test]
1954    fn forward_price_reflects_borrow_and_cash_dividends() {
1955        let mut option = dividend_paying_option(PutOrCall::Call);
1956        option.base.borrow_cost = 0.02;
1957        let expected = option.base.effective_spot() * ((0.05 - 0.02) * 1.0_f64).exp();
1958        assert_approx_eq!(option.base.forward_price(), expected, 1e-10);
1959    }
1960
1961    #[test]
1962    fn cash_dividend_with_carry_discounts_at_net_carry() {
1963        // with a continuous carry present, the cash dividend must be
1964        // discounted at (r - carry), not r, so the escrowed spot and the
1965        // analytic forward match the jump-model ground truth
1966        // F = (S - D e^{-(r-carry)t}) e^{(r-carry)T}
1967        let carry = 0.03;
1968        let mut option = dividend_paying_option(PutOrCall::Call);
1969        option.base.borrow_cost = carry;
1970        let (r, s, d, t) = (0.05, 100.0, 3.0, 1.0);
1971        let t_div = (NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
1972            - NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
1973        .num_days() as f64
1974            / 365.0;
1975
1976        let s_eff = s - d * (-(r - carry) * t_div).exp();
1977        assert_approx_eq!(option.base.effective_spot(), s_eff, 1e-10);
1978
1979        let jump_forward = s_eff * ((r - carry) * t).exp();
1980        assert_approx_eq!(option.base.forward_price(), jump_forward, 1e-10);
1981    }
1982
1983    #[test]
1984    fn net_carry_discounting_flows_through_to_price_and_stays_near_jump_engines() {
1985        // The fix guarantees forward consistency (checked above); this
1986        // confirms it flows through to the analytic price, which is exactly
1987        // the escrowed lognormal on the net-carry spot, and that the price
1988        // stays within the escrowed-vs-jump tolerance of the FD engine.
1989        //
1990        // Note: matching the forward does NOT make the escrowed *price*
1991        // equal the jump price — the escrowed model applies vol to S - PV
1992        // rather than to S with a jump, an intrinsic approximation. So we
1993        // check the band, not exact agreement.
1994        let carry = 0.03;
1995        let mut analytic = dividend_paying_option(PutOrCall::Call);
1996        analytic.base.borrow_cost = carry;
1997        let a = analytic.npv();
1998
1999        let expected =
2000            bs_price(analytic.base.effective_spot(), 100.0, 0.05, carry, 0.3, 1.0, PutOrCall::Call);
2001        assert_approx_eq!(a, expected, 1e-10);
2002
2003        let mut fd = dividend_paying_option(PutOrCall::Call);
2004        fd.base.borrow_cost = carry;
2005        fd.engine = Engine::FiniteDifference;
2006        assert!((a - fd.npv()).abs() < 0.2, "analytic {a} vs fd {}", fd.npv());
2007    }
2008
2009    // ── Options on futures (Black-76) ───────────────────────────────────
2010
2011    fn futures_option(
2012        pc: PutOrCall,
2013        settlement: crate::equity::black76::FuturesSettlement,
2014    ) -> EquityOption {
2015        crate::equity::builder::EquityOptionBuilder::new()
2016            .symbol("FUT")
2017            .spot(100.0) // interpreted as the futures price F
2018            .strike(100.0)
2019            .flat_vol(0.30)
2020            .flat_rate(0.05)
2021            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2022            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
2023            .vanilla(pc)
2024            .on_future(settlement)
2025            .engine(Engine::BlackScholes)
2026            .build()
2027    }
2028
2029    #[test]
2030    fn black76_option_api_matches_closed_form() {
2031        use crate::equity::black76::FuturesSettlement::*;
2032        for (settlement, gold) in [(Discounted, 11.34202064), (Margined, 11.92353847)] {
2033            let option = futures_option(PutOrCall::Call, settlement);
2034            assert_approx_eq!(option.npv(), gold, 1e-7);
2035        }
2036        // spot-check a Greek reaches the option API too
2037        let call = futures_option(PutOrCall::Call, Discounted);
2038        assert_approx_eq!(call.delta(), 0.53232482, 1e-7);
2039        assert_approx_eq!(call.rho(), -11.34202064, 1e-6);
2040    }
2041
2042    #[test]
2043    fn margined_futures_option_has_zero_rho_and_exceeds_discounted() {
2044        use crate::equity::black76::FuturesSettlement::*;
2045        let disc = futures_option(PutOrCall::Call, Discounted).npv();
2046        let marg = futures_option(PutOrCall::Call, Margined);
2047        assert_eq!(marg.rho(), 0.0);
2048        assert!(marg.npv() > disc);
2049        assert_approx_eq!(marg.npv(), disc * (0.05_f64).exp(), 1e-9);
2050    }
2051
2052    #[test]
2053    fn black76_on_the_forward_equals_spot_black_scholes() {
2054        // a discounted Black-76 option on F = S e^{(r-q)T} must equal the
2055        // equivalent spot option priced by the equity Black-Scholes engine
2056        let (s, q, r, t): (f64, f64, f64, f64) = (100.0, 0.02, 0.05, 1.0);
2057        let fwd = s * ((r - q) * t).exp();
2058        let futures_opt = crate::equity::builder::EquityOptionBuilder::new()
2059            .spot(fwd)
2060            .strike(100.0)
2061            .flat_vol(0.30)
2062            .flat_rate(r)
2063            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2064            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
2065            .vanilla(PutOrCall::Call)
2066            .on_future(crate::equity::black76::FuturesSettlement::Discounted)
2067            .build();
2068        let spot_opt = crate::equity::builder::EquityOptionBuilder::new()
2069            .spot(s)
2070            .strike(100.0)
2071            .flat_vol(0.30)
2072            .flat_rate(r)
2073            .dividend_yield(q)
2074            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
2075            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
2076            .vanilla(PutOrCall::Call)
2077            .build();
2078        assert_approx_eq!(futures_opt.npv(), spot_opt.npv(), 1e-10);
2079    }
2080
2081    #[test]
2082    fn put_call_parity_on_futures_both_styles() {
2083        use crate::equity::black76::FuturesSettlement::*;
2084        for (settlement, df) in [(Discounted, (-0.05_f64).exp()), (Margined, 1.0)] {
2085            let c = futures_option(PutOrCall::Call, settlement).npv();
2086            let p = futures_option(PutOrCall::Put, settlement).npv();
2087            // F = K = 100 -> parity value is 0
2088            assert_approx_eq!(c - p, df * (100.0 - 100.0), 1e-10);
2089        }
2090    }
2091
2092    #[test]
2093    #[should_panic(expected = "Options on futures (Black-76) price on the Analytical engine only")]
2094    fn futures_option_rejects_non_analytic_engine() {
2095        let mut option =
2096            futures_option(PutOrCall::Call, crate::equity::black76::FuturesSettlement::Discounted);
2097        option.engine = Engine::MonteCarlo;
2098        option.npv();
2099    }
2100
2101    // ── Forward-start options ───────────────────────────────────────────
2102
2103    fn forward_start_option(pc: PutOrCall) -> EquityOption {
2104        test_option_with(
2105            Box::new(crate::equity::forward_start_option::ForwardStartPayoff {
2106                put_or_call: pc,
2107                exercise_style: ContractStyle::European,
2108                strike_fraction: 1.0,
2109                start_fraction: 0.5,
2110            }),
2111            flat_5pct(),
2112        )
2113    }
2114
2115    #[test]
2116    fn forward_start_analytic_matches_monte_carlo() {
2117        let analytic = forward_start_option(PutOrCall::Call).npv();
2118        let mut mc = forward_start_option(PutOrCall::Call);
2119        mc.engine = Engine::MonteCarlo;
2120        mc.mc.paths = 50_000;
2121        assert!((mc.npv() - analytic).abs() < 0.15, "mc={} analytic={analytic}", mc.npv());
2122    }
2123
2124    #[test]
2125    fn forward_start_heston_degenerates_to_black_scholes() {
2126        let bs = forward_start_option(PutOrCall::Call).npv();
2127        let mut heston = forward_start_option(PutOrCall::Call);
2128        heston.engine = Engine::MonteCarlo;
2129        heston.mc.model = crate::equity::montecarlo::McModel::Heston;
2130        heston.mc.paths = 50_000;
2131        heston.heston = Some(crate::equity::heston::HestonParams {
2132            v0: 0.09,
2133            kappa: 1.0,
2134            theta: 0.09,
2135            vol_of_vol: 1e-3,
2136            rho: 0.0,
2137        });
2138        assert!((heston.npv() - bs).abs() < 0.2, "heston={} bs={bs}", heston.npv());
2139    }
2140
2141    // ── Autocallables ───────────────────────────────────────────────────
2142
2143    fn autocall_note(autocall_barrier: f64, protection_barrier: f64, coupon: f64) -> EquityOption {
2144        let mut option = test_option_with(
2145            Box::new(crate::equity::autocallable::AutocallablePayoff {
2146                exercise_style: ContractStyle::European,
2147                autocall_barrier,
2148                protection_barrier,
2149                coupon,
2150                observations: 4,
2151                notional: 100.0,
2152                initial_fixing: 100.0,
2153            }),
2154            flat_5pct(),
2155        );
2156        option.engine = Engine::MonteCarlo;
2157        option.mc.paths = 20_000;
2158        option
2159    }
2160
2161    #[test]
2162    fn autocall_that_always_calls_pays_coupon_at_first_observation() {
2163        // barrier below any reachable spot: every path calls at t1 = T/4
2164        let note = autocall_note(1e-9, 50.0, 5.0);
2165        let stats = crate::equity::montecarlo::npv_with_stats(&note);
2166        let expected = 105.0 * (-0.05 * 0.25_f64).exp();
2167        assert_approx_eq!(stats.pv, expected, 1e-9);
2168        // identical path values: stderr is pure floating-point cancellation
2169        assert!(stats.std_err < 1e-6, "deterministic payoff: stderr {}", stats.std_err);
2170    }
2171
2172    #[test]
2173    fn autocall_never_called_with_full_protection_is_a_zero_coupon_bond() {
2174        let note = autocall_note(1e12, 1e-9, 5.0);
2175        let expected = 100.0 * (-0.05_f64).exp();
2176        assert_approx_eq!(note.npv(), expected, 1e-9);
2177    }
2178
2179    #[test]
2180    fn autocall_full_downside_is_the_discounted_forward() {
2181        // protection always breached, never called: pays N * S_T / S_0,
2182        // whose discounted expectation is N (q = 0)
2183        let note = autocall_note(1e12, 1e12, 0.0);
2184        assert!((note.npv() - 100.0).abs() < 0.3, "{}", note.npv());
2185    }
2186
2187    #[test]
2188    fn autocall_value_increases_with_coupon_and_lower_protection() {
2189        let base = autocall_note(105.0, 70.0, 5.0).npv();
2190        assert!(autocall_note(105.0, 70.0, 8.0).npv() > base, "higher coupon");
2191        assert!(autocall_note(105.0, 50.0, 5.0).npv() > base, "lower knock-in barrier");
2192    }
2193
2194    #[test]
2195    fn autocall_prices_under_local_vol() {
2196        // flat surface: local vol must reproduce the GBM value
2197        let gbm = autocall_note(105.0, 70.0, 5.0).npv();
2198        let mut lv = autocall_note(105.0, 70.0, 5.0);
2199        lv.mc.model = crate::equity::montecarlo::McModel::LocalVol;
2200        assert!((lv.npv() - gbm).abs() < 0.5, "lv={} gbm={gbm}", lv.npv());
2201    }
2202
2203    #[test]
2204    #[should_panic(expected = "Autocallables price on the MonteCarlo engine only")]
2205    fn analytic_engine_rejects_autocallables() {
2206        let mut note = autocall_note(105.0, 70.0, 5.0);
2207        note.engine = Engine::BlackScholes;
2208        note.npv();
2209    }
2210
2211    #[test]
2212    #[should_panic(expected = "only barriers price on the FD")]
2213    fn fd_engine_rejects_forward_start() {
2214        let mut option = forward_start_option(PutOrCall::Call);
2215        option.engine = Engine::FiniteDifference;
2216        option.npv();
2217    }
2218
2219    #[test]
2220    #[should_panic(expected = "Heston model is supported on the Analytical and MonteCarlo")]
2221    fn fd_engine_rejects_heston() {
2222        let mut option = heston_vanilla(PutOrCall::Call);
2223        option.engine = Engine::FiniteDifference;
2224        option.npv();
2225    }
2226
2227    #[test]
2228    #[should_panic(expected = "not supported on the Binomial engine")]
2229    fn tree_engine_rejects_barrier_options() {
2230        use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
2231        let mut option = barrier_option(PutOrCall::Call, Down, Out, 90.0);
2232        option.engine = Engine::Binomial;
2233        option.npv();
2234    }
2235
2236    #[test]
2237    #[should_panic(expected = "Analytical engine cannot price American")]
2238    fn analytic_engine_rejects_american_exercise() {
2239        let option = test_option_with(
2240            Box::new(VanillaPayoff {
2241                put_or_call: PutOrCall::Put,
2242                exercise_style: ContractStyle::American,
2243            }),
2244            flat_5pct(),
2245        );
2246        option.npv();
2247    }
2248
2249    #[test]
2250    fn american_put_fd_and_tree_agree_and_dominate_european() {
2251        let european_put = test_option(PutOrCall::Put, flat_5pct()).npv();
2252        let american = |engine: Engine| {
2253            let mut option = test_option_with(
2254                Box::new(VanillaPayoff {
2255                    put_or_call: PutOrCall::Put,
2256                    exercise_style: ContractStyle::American,
2257                }),
2258                flat_5pct(),
2259            );
2260            option.engine = engine;
2261            option.npv()
2262        };
2263        let fd = american(Engine::FiniteDifference);
2264        let tree = american(Engine::Binomial);
2265        assert!(fd > european_put, "american {fd} must exceed european {european_put}");
2266        assert!(tree > european_put);
2267        assert!((fd - tree).abs() < 0.02, "fd={fd} tree={tree}");
2268    }
2269
2270    #[test]
2271    fn smile_surface_prices_with_interpolated_vol() {
2272        // K=100 sits midway between the 90 and 110 pillars at the 1y expiry,
2273        // so the option must price at the interpolated 30% vol — i.e. match
2274        // the flat-30% golden values exactly.
2275        let valuation_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
2276        let surface = crate::core::vols::VolSurface::from_strike_grid(
2277            &[Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
2278            &[90.0, 100.0, 110.0],
2279            &[vec![0.32, 0.30, 0.28], vec![0.36, 0.34, 0.32]],
2280            valuation_date,
2281            DayCountConvention::Act365,
2282        )
2283        .unwrap();
2284        let mut option = test_option(PutOrCall::Call, flat_5pct());
2285        option.base.vol_surface = surface;
2286        assert_approx_eq!(option.base.volatility(), 0.30, 1e-14);
2287        assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
2288        assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
2289        // a lower strike picks up the skew: vol(95) = 0.31
2290        option.base.strike_price = 95.0;
2291        assert_approx_eq!(option.base.volatility(), 0.31, 1e-14);
2292    }
2293
2294    #[test]
2295    fn implied_vol_recovers_input_vol() {
2296        let mut option = test_option(PutOrCall::Call, flat_5pct());
2297        let target_price = option.npv(); // priced at 30% flat
2298        // start the solve from a different vol level
2299        option.base.vol_surface = crate::core::vols::VolSurface::flat(
2300            0.6,
2301            option.base.valuation_date,
2302            DayCountConvention::Act365,
2303        )
2304        .unwrap();
2305        let iv = option.imp_vol(target_price);
2306        assert_approx_eq!(iv, 0.30, 1e-10);
2307    }
2308
2309    #[test]
2310    fn zero_curve_prices_off_maturity_pillar() {
2311        // A non-flat zero curve whose 1y pillar is 5% must reproduce the
2312        // flat-5% price: discounting reads df at maturity, not any other node.
2313        let curve = YieldCurve::from_zero_rates(
2314            &[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
2315            &[0.02, 0.05, 0.07],
2316            NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
2317            DayCountConvention::Act365,
2318            Compounding::Continuous,
2319            InterpolationMethod::LogLinearDf,
2320        )
2321        .unwrap();
2322        let option = test_option(PutOrCall::Call, curve);
2323        assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
2324        assert_approx_eq!(option.base.risk_free_rate(), 0.05, 1e-12);
2325    }
2326}