1use crate::derivatives::norm::{norm_cdf, norm_pdf};
65use crate::derivatives::types::{
66 forward_moneyness, intrinsic, time_value, validate_bsm_params, BsmParams, OptionType,
67 ValidatedBsm,
68};
69use crate::util::error::FinanceResult;
70use crate::{columns_with_strings, print_table_locale_opt};
71
72#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct BsmGreeks {
88 pub delta: f64,
89 pub gamma: f64,
90 pub vega: f64,
92 pub theta: f64,
94 pub rho: f64,
95}
96
97impl BsmGreeks {
98 #[inline]
102 pub fn vega_per_vol_point(self) -> f64 {
103 self.vega / 100.0
104 }
105
106 #[inline]
110 pub fn theta_per_calendar_day(self) -> f64 {
111 self.theta / 365.25
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq)]
127pub struct BsmCrossGreeks {
128 pub vanna: f64,
129 pub volga: f64,
130 pub charm: f64,
133}
134
135impl BsmCrossGreeks {
136 #[inline]
138 pub fn charm_per_calendar_day(self) -> f64 {
139 self.charm / 365.25
140 }
141}
142
143#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct BsmTerms {
149 pub d1: f64,
150 pub d2: f64,
151 pub discount: f64,
152 pub dividend_discount: f64,
153 pub sqrt_t: f64,
154}
155
156#[derive(Clone, Debug)]
160pub struct BsmSolution {
161 pub option_type: OptionType,
162 pub params: BsmParams,
163 pub price: f64,
164 pub greeks: BsmGreeks,
165 pub cross_greeks: BsmCrossGreeks,
166 pub terms: BsmTerms,
167 pub intrinsic: f64,
168 pub time_value: f64,
169 pub forward_moneyness: f64,
170 pub parity_residual: f64,
172 formula: String,
173 symbolic_formula: String,
174}
175
176impl BsmSolution {
177 pub fn formula(&self) -> &str {
178 &self.formula
179 }
180 pub fn symbolic_formula(&self) -> &str {
181 &self.symbolic_formula
182 }
183
184 pub fn print_table(&self) {
192 self.print_table_locale_opt(None, None);
193 }
194
195 pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
196 self.print_table_locale_opt(Some(locale), Some(precision));
197 }
198
199 fn print_table_locale_opt(
200 &self,
201 locale: Option<&num_format::Locale>,
202 precision: Option<usize>,
203 ) {
204 let columns = columns_with_strings(&[
205 ("type", "s", true),
206 ("price", "f", true),
207 ("delta", "f", true),
208 ("gamma", "f", true),
209 ("vega", "f", true),
210 ("theta", "f", true),
211 ("rho", "f", true),
212 ]);
213 let data = vec![vec![
214 self.option_type.to_string(),
215 self.price.to_string(),
216 self.greeks.delta.to_string(),
217 self.greeks.gamma.to_string(),
218 self.greeks.vega.to_string(),
219 self.greeks.theta.to_string(),
220 self.greeks.rho.to_string(),
221 ]];
222 print_table_locale_opt(&columns, data, locale, precision);
223 }
224}
225
226pub fn bsm_price(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
234 validate_bsm_params(params)?;
235 Ok(price_unchecked(params, option_type))
236}
237
238pub fn bsm_greeks(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmGreeks> {
243 validate_bsm_params(params)?;
244 Ok(greeks_unchecked(params, option_type))
245}
246
247pub fn bsm_cross_greeks(
252 params: BsmParams,
253 option_type: OptionType,
254) -> FinanceResult<BsmCrossGreeks> {
255 validate_bsm_params(params)?;
256 Ok(cross_greeks_unchecked(params, option_type))
257}
258
259pub fn bsm_terms(params: BsmParams) -> FinanceResult<BsmTerms> {
261 validate_bsm_params(params)?;
262 Ok(terms_unchecked(params))
263}
264
265pub fn put_call_parity_residual(params: BsmParams) -> FinanceResult<f64> {
271 let c = bsm_price(params, OptionType::Call)?;
272 let p = bsm_price(params, OptionType::Put)?;
273 let disc = (-params.rate * params.time_years).exp();
274 let div = (-params.dividend_yield * params.time_years).exp();
275 Ok(c - p - (params.spot * div - params.strike * disc))
276}
277
278pub fn bsm_solution(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmSolution> {
290 let _ = ValidatedBsm::new(params)?;
291 let price = price_unchecked(params, option_type);
292 let greeks = greeks_unchecked(params, option_type);
293 let cross_greeks = cross_greeks_unchecked(params, option_type);
294 let terms = terms_unchecked(params);
295 let intrinsic_v = intrinsic(params.spot, params.strike, option_type)?;
296 let tv = time_value(price, params.spot, params.strike, option_type)?;
297 let fm = forward_moneyness(params)?;
298 let parity = put_call_parity_residual(params)?;
299 let formula = format!(
300 "{option_type} BSM S={} K={} T={} r={} q={} σ={} → price={:.6}",
301 params.spot,
302 params.strike,
303 params.time_years,
304 params.rate,
305 params.dividend_yield,
306 params.vol,
307 price
308 );
309 let symbolic = match option_type {
310 OptionType::Call => {
311 "C = S e^{-qT} N(d1) - K e^{-rT} N(d2); d1 = [ln(S/K)+(r-q+σ²/2)T]/(σ√T); d2 = d1-σ√T"
312 .to_string()
313 }
314 OptionType::Put => "P = K e^{-rT} N(-d2) - S e^{-qT} N(-d1); d1,d2 as in call".to_string(),
315 };
316 Ok(BsmSolution {
317 option_type,
318 params,
319 price,
320 greeks,
321 cross_greeks,
322 terms,
323 intrinsic: intrinsic_v,
324 time_value: tv,
325 forward_moneyness: fm,
326 parity_residual: parity,
327 formula,
328 symbolic_formula: symbolic,
329 })
330}
331
332fn terms_unchecked(p: BsmParams) -> BsmTerms {
333 let sqrt_t = p.time_years.sqrt();
334 let discount = (-p.rate * p.time_years).exp();
335 let dividend_discount = (-p.dividend_yield * p.time_years).exp();
336
337 if p.time_years == 0.0 || p.vol == 0.0 {
338 let forward = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
339 let d1 = if forward > p.strike {
340 f64::INFINITY
341 } else if forward < p.strike {
342 f64::NEG_INFINITY
343 } else {
344 0.0
345 };
346 return BsmTerms {
347 d1,
348 d2: d1,
349 discount,
350 dividend_discount,
351 sqrt_t,
352 };
353 }
354
355 let sig_s = p.vol * sqrt_t;
356 let d1 = ((p.spot / p.strike).ln()
357 + (p.rate - p.dividend_yield + 0.5 * p.vol * p.vol) * p.time_years)
358 / sig_s;
359 let d2 = d1 - sig_s;
360 BsmTerms {
361 d1,
362 d2,
363 discount,
364 dividend_discount,
365 sqrt_t,
366 }
367}
368
369fn price_unchecked(p: BsmParams, option_type: OptionType) -> f64 {
370 if p.time_years == 0.0 {
371 return match option_type {
372 OptionType::Call => (p.spot - p.strike).max(0.0),
373 OptionType::Put => (p.strike - p.spot).max(0.0),
374 };
375 }
376 if p.vol == 0.0 {
377 let f = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
378 let disc = (-p.rate * p.time_years).exp();
379 return match option_type {
380 OptionType::Call => disc * (f - p.strike).max(0.0),
381 OptionType::Put => disc * (p.strike - f).max(0.0),
382 };
383 }
384
385 let t = terms_unchecked(p);
386 let df_q = t.dividend_discount;
387 let df_r = t.discount;
388 match option_type {
389 OptionType::Call => p.spot * df_q * norm_cdf(t.d1) - p.strike * df_r * norm_cdf(t.d2),
390 OptionType::Put => p.strike * df_r * norm_cdf(-t.d2) - p.spot * df_q * norm_cdf(-t.d1),
391 }
392}
393
394fn greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmGreeks {
395 if p.time_years == 0.0 {
396 let delta = match option_type {
397 OptionType::Call => {
398 if p.spot > p.strike {
399 1.0
400 } else if p.spot < p.strike {
401 0.0
402 } else {
403 0.5
404 }
405 }
406 OptionType::Put => {
407 if p.spot < p.strike {
408 -1.0
409 } else if p.spot > p.strike {
410 0.0
411 } else {
412 -0.5
413 }
414 }
415 };
416 return BsmGreeks {
417 delta,
418 gamma: 0.0,
419 vega: 0.0,
420 theta: 0.0,
421 rho: 0.0,
422 };
423 }
424
425 if p.vol == 0.0 {
426 let price_up = {
427 let mut q = p;
428 q.spot *= 1.0 + 1e-6;
429 price_unchecked(q, option_type)
430 };
431 let price_0 = price_unchecked(p, option_type);
432 let delta = (price_up - price_0) / (p.spot * 1e-6);
433 return BsmGreeks {
434 delta,
435 gamma: 0.0,
436 vega: 0.0,
437 theta: 0.0,
438 rho: 0.0,
439 };
440 }
441
442 let t = terms_unchecked(p);
443 let df_q = t.dividend_discount;
444 let df_r = t.discount;
445 let n_d1 = norm_pdf(t.d1);
446 let sqrt_t = t.sqrt_t;
447 let gamma = df_q * n_d1 / (p.spot * p.vol * sqrt_t);
448 let vega = p.spot * df_q * n_d1 * sqrt_t;
449
450 let (delta, theta, rho) = match option_type {
451 OptionType::Call => {
452 let delta = df_q * norm_cdf(t.d1);
453 let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
454 - p.rate * p.strike * df_r * norm_cdf(t.d2)
455 + p.dividend_yield * p.spot * df_q * norm_cdf(t.d1);
456 let rho = p.strike * p.time_years * df_r * norm_cdf(t.d2);
457 (delta, theta, rho)
458 }
459 OptionType::Put => {
460 let delta = df_q * (norm_cdf(t.d1) - 1.0);
461 let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
462 + p.rate * p.strike * df_r * norm_cdf(-t.d2)
463 - p.dividend_yield * p.spot * df_q * norm_cdf(-t.d1);
464 let rho = -p.strike * p.time_years * df_r * norm_cdf(-t.d2);
465 (delta, theta, rho)
466 }
467 };
468
469 BsmGreeks {
470 delta,
471 gamma,
472 vega,
473 theta,
474 rho,
475 }
476}
477
478fn cross_greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmCrossGreeks {
479 if p.time_years == 0.0 || p.vol == 0.0 {
480 return BsmCrossGreeks {
481 vanna: 0.0,
482 volga: 0.0,
483 charm: 0.0,
484 };
485 }
486
487 let t = terms_unchecked(p);
488 let df_q = t.dividend_discount;
489 let n_d1 = norm_pdf(t.d1);
490 let sqrt_t = t.sqrt_t;
491 let sigma = p.vol;
492 let tt = p.time_years;
493
494 let vanna = -df_q * n_d1 * t.d2 / sigma;
496 let vega = p.spot * df_q * n_d1 * sqrt_t;
498 let volga = vega * t.d1 * t.d2 / sigma;
499
500 let common = n_d1 * (2.0 * (p.rate - p.dividend_yield) * tt - t.d2 * sigma * sqrt_t)
501 / (2.0 * tt * sigma * sqrt_t);
502 let charm = match option_type {
503 OptionType::Call => -df_q * (common + p.dividend_yield * norm_cdf(t.d1)),
504 OptionType::Put => -df_q * (common - p.dividend_yield * norm_cdf(-t.d1)),
505 };
506
507 BsmCrossGreeks {
508 vanna,
509 volga,
510 charm,
511 }
512}
513
514pub(crate) fn price_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
515 bsm_price(params, option_type)
516}
517
518pub(crate) fn vega_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
519 Ok(bsm_greeks(params, option_type)?.vega)
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn atm_call_textbook() {
528 let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
529 let c = bsm_price(p, OptionType::Call).unwrap();
530 assert!((c - 10.450_583_57).abs() < 1e-4);
531 }
532
533 #[test]
534 fn put_call_parity() {
535 let p = BsmParams {
536 spot: 100.0,
537 strike: 95.0,
538 time_years: 0.5,
539 rate: 0.03,
540 dividend_yield: 0.01,
541 vol: 0.25,
542 };
543 assert!(put_call_parity_residual(p).unwrap().abs() < 1e-10);
544 }
545
546 #[test]
547 fn expiry_intrinsic() {
548 let p = BsmParams {
549 spot: 110.0,
550 strike: 100.0,
551 time_years: 0.0,
552 rate: 0.05,
553 dividend_yield: 0.0,
554 vol: 0.2,
555 };
556 assert!((bsm_price(p, OptionType::Call).unwrap() - 10.0).abs() < 1e-12);
557 assert!((bsm_price(p, OptionType::Put).unwrap()).abs() < 1e-12);
558 }
559
560 #[test]
561 fn delta_bounds_call() {
562 let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
563 let d = bsm_greeks(p, OptionType::Call).unwrap().delta;
564 assert!(d > 0.0 && d < 1.0);
565 }
566
567 #[test]
568 fn vega_scale_helpers() {
569 let g = bsm_greeks(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
570 assert!((g.vega_per_vol_point() * 100.0 - g.vega).abs() < 1e-12);
571 assert!((g.theta_per_calendar_day() * 365.25 - g.theta).abs() < 1e-12);
572 }
573
574 #[test]
575 fn rejects_bad_spot() {
576 let mut p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
577 p.spot = 0.0;
578 assert!(bsm_price(p, OptionType::Call).is_err());
579 }
580
581 #[test]
582 fn cross_greeks_finite_and_volga_sign_atm() {
583 let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
584 let x = bsm_cross_greeks(p, OptionType::Call).unwrap();
585 assert!(x.vanna.is_finite() && x.volga.is_finite() && x.charm.is_finite());
586 assert!(x.volga > 0.0);
588 assert!((x.charm_per_calendar_day() * 365.25 - x.charm).abs() < 1e-12);
589 }
590
591 #[test]
592 fn vanna_matches_finite_difference() {
593 let p = BsmParams::atm_one_year(100.0, 0.05, 0.25);
594 let h = 1e-5;
595 let mut p_up = p;
596 p_up.vol += h;
597 let mut p_dn = p;
598 p_dn.vol -= h;
599 let d_up = bsm_greeks(p_up, OptionType::Call).unwrap().delta;
600 let d_dn = bsm_greeks(p_dn, OptionType::Call).unwrap().delta;
601 let fd = (d_up - d_dn) / (2.0 * h);
602 let analytic = bsm_cross_greeks(p, OptionType::Call).unwrap().vanna;
603 assert!((fd - analytic).abs() < 1e-4, "fd={fd} analytic={analytic}");
604 }
605
606 #[test]
607 fn volga_matches_finite_difference() {
608 let p = BsmParams::atm_one_year(100.0, 0.05, 0.25);
609 let h = 1e-4;
610 let mut p_up = p;
611 p_up.vol += h;
612 let mut p_dn = p;
613 p_dn.vol -= h;
614 let v_up = bsm_greeks(p_up, OptionType::Call).unwrap().vega;
615 let v_dn = bsm_greeks(p_dn, OptionType::Call).unwrap().vega;
616 let fd = (v_up - v_dn) / (2.0 * h);
617 let analytic = bsm_cross_greeks(p, OptionType::Call).unwrap().volga;
618 assert!((fd - analytic).abs() < 5e-2, "fd={fd} analytic={analytic}");
619 }
620
621 #[test]
622 fn put_delta_negative_and_call_put_sum_near_df_q() {
623 let p = BsmParams {
624 spot: 100.0,
625 strike: 100.0,
626 time_years: 0.75,
627 rate: 0.04,
628 dividend_yield: 0.01,
629 vol: 0.22,
630 };
631 let dc = bsm_greeks(p, OptionType::Call).unwrap().delta;
632 let dp = bsm_greeks(p, OptionType::Put).unwrap().delta;
633 let df_q = (-p.dividend_yield * p.time_years).exp();
634 assert!(dp < 0.0);
635 assert!((dc - dp - df_q).abs() < 1e-10);
636 }
637
638 #[test]
639 fn solution_includes_cross_greeks() {
640 let sol =
641 bsm_solution(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
642 assert!(sol.cross_greeks.volga > 0.0);
643 }
644}