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