use crate::derivatives::norm::{norm_cdf, norm_pdf};
use crate::derivatives::types::{
forward_moneyness, intrinsic, time_value, validate_bsm_params, BsmParams, OptionType,
ValidatedBsm,
};
use crate::util::error::FinanceResult;
use crate::{columns_with_strings, print_table_locale_opt};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BsmGreeks {
pub delta: f64,
pub gamma: f64,
pub vega: f64,
pub theta: f64,
pub rho: f64,
}
impl BsmGreeks {
#[inline]
pub fn vega_per_vol_point(self) -> f64 {
self.vega / 100.0
}
#[inline]
pub fn theta_per_calendar_day(self) -> f64 {
self.theta / 365.25
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BsmCrossGreeks {
pub vanna: f64,
pub volga: f64,
pub charm: f64,
}
impl BsmCrossGreeks {
#[inline]
pub fn charm_per_calendar_day(self) -> f64 {
self.charm / 365.25
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BsmTerms {
pub d1: f64,
pub d2: f64,
pub discount: f64,
pub dividend_discount: f64,
pub sqrt_t: f64,
}
#[derive(Clone, Debug)]
pub struct BsmSolution {
pub option_type: OptionType,
pub params: BsmParams,
pub price: f64,
pub greeks: BsmGreeks,
pub cross_greeks: BsmCrossGreeks,
pub terms: BsmTerms,
pub intrinsic: f64,
pub time_value: f64,
pub forward_moneyness: f64,
pub parity_residual: f64,
formula: String,
symbolic_formula: String,
}
impl BsmSolution {
pub fn formula(&self) -> &str {
&self.formula
}
pub fn symbolic_formula(&self) -> &str {
&self.symbolic_formula
}
pub fn print_table(&self) {
self.print_table_locale_opt(None, None);
}
pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
self.print_table_locale_opt(Some(locale), Some(precision));
}
fn print_table_locale_opt(
&self,
locale: Option<&num_format::Locale>,
precision: Option<usize>,
) {
let columns = columns_with_strings(&[
("type", "s", true),
("price", "f", true),
("delta", "f", true),
("gamma", "f", true),
("vega", "f", true),
("theta", "f", true),
("rho", "f", true),
]);
let data = vec![vec![
self.option_type.to_string(),
self.price.to_string(),
self.greeks.delta.to_string(),
self.greeks.gamma.to_string(),
self.greeks.vega.to_string(),
self.greeks.theta.to_string(),
self.greeks.rho.to_string(),
]];
print_table_locale_opt(&columns, data, locale, precision);
}
}
pub fn bsm_price(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
validate_bsm_params(params)?;
Ok(price_unchecked(params, option_type))
}
pub fn bsm_greeks(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmGreeks> {
validate_bsm_params(params)?;
Ok(greeks_unchecked(params, option_type))
}
pub fn bsm_cross_greeks(
params: BsmParams,
option_type: OptionType,
) -> FinanceResult<BsmCrossGreeks> {
validate_bsm_params(params)?;
Ok(cross_greeks_unchecked(params, option_type))
}
pub fn bsm_terms(params: BsmParams) -> FinanceResult<BsmTerms> {
validate_bsm_params(params)?;
Ok(terms_unchecked(params))
}
pub fn put_call_parity_residual(params: BsmParams) -> FinanceResult<f64> {
let c = bsm_price(params, OptionType::Call)?;
let p = bsm_price(params, OptionType::Put)?;
let disc = (-params.rate * params.time_years).exp();
let div = (-params.dividend_yield * params.time_years).exp();
Ok(c - p - (params.spot * div - params.strike * disc))
}
pub fn bsm_solution(params: BsmParams, option_type: OptionType) -> FinanceResult<BsmSolution> {
let _ = ValidatedBsm::new(params)?;
let price = price_unchecked(params, option_type);
let greeks = greeks_unchecked(params, option_type);
let cross_greeks = cross_greeks_unchecked(params, option_type);
let terms = terms_unchecked(params);
let intrinsic_v = intrinsic(params.spot, params.strike, option_type)?;
let tv = time_value(price, params.spot, params.strike, option_type)?;
let fm = forward_moneyness(params)?;
let parity = put_call_parity_residual(params)?;
let formula = format!(
"{option_type} BSM S={} K={} T={} r={} q={} σ={} → price={:.6}",
params.spot,
params.strike,
params.time_years,
params.rate,
params.dividend_yield,
params.vol,
price
);
let symbolic = match option_type {
OptionType::Call => {
"C = S e^{-qT} N(d1) - K e^{-rT} N(d2); d1 = [ln(S/K)+(r-q+σ²/2)T]/(σ√T); d2 = d1-σ√T"
.to_string()
}
OptionType::Put => "P = K e^{-rT} N(-d2) - S e^{-qT} N(-d1); d1,d2 as in call".to_string(),
};
Ok(BsmSolution {
option_type,
params,
price,
greeks,
cross_greeks,
terms,
intrinsic: intrinsic_v,
time_value: tv,
forward_moneyness: fm,
parity_residual: parity,
formula,
symbolic_formula: symbolic,
})
}
fn terms_unchecked(p: BsmParams) -> BsmTerms {
let sqrt_t = p.time_years.sqrt();
let discount = (-p.rate * p.time_years).exp();
let dividend_discount = (-p.dividend_yield * p.time_years).exp();
if p.time_years == 0.0 || p.vol == 0.0 {
let forward = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
let d1 = if forward > p.strike {
f64::INFINITY
} else if forward < p.strike {
f64::NEG_INFINITY
} else {
0.0
};
return BsmTerms {
d1,
d2: d1,
discount,
dividend_discount,
sqrt_t,
};
}
let sig_s = p.vol * sqrt_t;
let d1 = ((p.spot / p.strike).ln()
+ (p.rate - p.dividend_yield + 0.5 * p.vol * p.vol) * p.time_years)
/ sig_s;
let d2 = d1 - sig_s;
BsmTerms {
d1,
d2,
discount,
dividend_discount,
sqrt_t,
}
}
fn price_unchecked(p: BsmParams, option_type: OptionType) -> f64 {
if p.time_years == 0.0 {
return match option_type {
OptionType::Call => (p.spot - p.strike).max(0.0),
OptionType::Put => (p.strike - p.spot).max(0.0),
};
}
if p.vol == 0.0 {
let f = p.spot * ((p.rate - p.dividend_yield) * p.time_years).exp();
let disc = (-p.rate * p.time_years).exp();
return match option_type {
OptionType::Call => disc * (f - p.strike).max(0.0),
OptionType::Put => disc * (p.strike - f).max(0.0),
};
}
let t = terms_unchecked(p);
let df_q = t.dividend_discount;
let df_r = t.discount;
match option_type {
OptionType::Call => p.spot * df_q * norm_cdf(t.d1) - p.strike * df_r * norm_cdf(t.d2),
OptionType::Put => p.strike * df_r * norm_cdf(-t.d2) - p.spot * df_q * norm_cdf(-t.d1),
}
}
fn greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmGreeks {
if p.time_years == 0.0 {
let delta = match option_type {
OptionType::Call => {
if p.spot > p.strike {
1.0
} else if p.spot < p.strike {
0.0
} else {
0.5
}
}
OptionType::Put => {
if p.spot < p.strike {
-1.0
} else if p.spot > p.strike {
0.0
} else {
-0.5
}
}
};
return BsmGreeks {
delta,
gamma: 0.0,
vega: 0.0,
theta: 0.0,
rho: 0.0,
};
}
if p.vol == 0.0 {
let price_up = {
let mut q = p;
q.spot *= 1.0 + 1e-6;
price_unchecked(q, option_type)
};
let price_0 = price_unchecked(p, option_type);
let delta = (price_up - price_0) / (p.spot * 1e-6);
return BsmGreeks {
delta,
gamma: 0.0,
vega: 0.0,
theta: 0.0,
rho: 0.0,
};
}
let t = terms_unchecked(p);
let df_q = t.dividend_discount;
let df_r = t.discount;
let n_d1 = norm_pdf(t.d1);
let sqrt_t = t.sqrt_t;
let gamma = df_q * n_d1 / (p.spot * p.vol * sqrt_t);
let vega = p.spot * df_q * n_d1 * sqrt_t;
let (delta, theta, rho) = match option_type {
OptionType::Call => {
let delta = df_q * norm_cdf(t.d1);
let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
- p.rate * p.strike * df_r * norm_cdf(t.d2)
+ p.dividend_yield * p.spot * df_q * norm_cdf(t.d1);
let rho = p.strike * p.time_years * df_r * norm_cdf(t.d2);
(delta, theta, rho)
}
OptionType::Put => {
let delta = df_q * (norm_cdf(t.d1) - 1.0);
let theta = -p.spot * df_q * n_d1 * p.vol / (2.0 * sqrt_t)
+ p.rate * p.strike * df_r * norm_cdf(-t.d2)
- p.dividend_yield * p.spot * df_q * norm_cdf(-t.d1);
let rho = -p.strike * p.time_years * df_r * norm_cdf(-t.d2);
(delta, theta, rho)
}
};
BsmGreeks {
delta,
gamma,
vega,
theta,
rho,
}
}
fn cross_greeks_unchecked(p: BsmParams, option_type: OptionType) -> BsmCrossGreeks {
if p.time_years == 0.0 || p.vol == 0.0 {
return BsmCrossGreeks {
vanna: 0.0,
volga: 0.0,
charm: 0.0,
};
}
let t = terms_unchecked(p);
let df_q = t.dividend_discount;
let n_d1 = norm_pdf(t.d1);
let sqrt_t = t.sqrt_t;
let sigma = p.vol;
let tt = p.time_years;
let vanna = -df_q * n_d1 * t.d2 / sigma;
let vega = p.spot * df_q * n_d1 * sqrt_t;
let volga = vega * t.d1 * t.d2 / sigma;
let common = n_d1 * (2.0 * (p.rate - p.dividend_yield) * tt - t.d2 * sigma * sqrt_t)
/ (2.0 * tt * sigma * sqrt_t);
let charm = match option_type {
OptionType::Call => -df_q * (common + p.dividend_yield * norm_cdf(t.d1)),
OptionType::Put => -df_q * (common - p.dividend_yield * norm_cdf(-t.d1)),
};
BsmCrossGreeks {
vanna,
volga,
charm,
}
}
pub(crate) fn price_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
bsm_price(params, option_type)
}
pub(crate) fn vega_raw(params: BsmParams, option_type: OptionType) -> FinanceResult<f64> {
Ok(bsm_greeks(params, option_type)?.vega)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atm_call_textbook() {
let p = BsmParams::atm_one_year(100.0, 0.05, 0.20);
let c = bsm_price(p, OptionType::Call).unwrap();
assert!((c - 10.450_583_57).abs() < 1e-4);
}
#[test]
fn put_call_parity() {
let p = BsmParams {
spot: 100.0,
strike: 95.0,
time_years: 0.5,
rate: 0.03,
dividend_yield: 0.01,
vol: 0.25,
};
assert!(put_call_parity_residual(p).unwrap().abs() < 1e-10);
}
#[test]
fn expiry_intrinsic() {
let p = BsmParams {
spot: 110.0,
strike: 100.0,
time_years: 0.0,
rate: 0.05,
dividend_yield: 0.0,
vol: 0.2,
};
assert!((bsm_price(p, OptionType::Call).unwrap() - 10.0).abs() < 1e-12);
assert!((bsm_price(p, OptionType::Put).unwrap()).abs() < 1e-12);
}
#[test]
fn delta_bounds_call() {
let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
let d = bsm_greeks(p, OptionType::Call).unwrap().delta;
assert!(d > 0.0 && d < 1.0);
}
#[test]
fn vega_scale_helpers() {
let g = bsm_greeks(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
assert!((g.vega_per_vol_point() * 100.0 - g.vega).abs() < 1e-12);
assert!((g.theta_per_calendar_day() * 365.25 - g.theta).abs() < 1e-12);
}
#[test]
fn rejects_bad_spot() {
let mut p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
p.spot = 0.0;
assert!(bsm_price(p, OptionType::Call).is_err());
}
#[test]
fn cross_greeks_finite_and_volga_sign_atm() {
let p = BsmParams::atm_one_year(100.0, 0.05, 0.2);
let x = bsm_cross_greeks(p, OptionType::Call).unwrap();
assert!(x.vanna.is_finite() && x.volga.is_finite() && x.charm.is_finite());
assert!(x.volga > 0.0);
assert!((x.charm_per_calendar_day() * 365.25 - x.charm).abs() < 1e-12);
}
#[test]
fn vanna_matches_finite_difference() {
let p = BsmParams::atm_one_year(100.0, 0.05, 0.25);
let h = 1e-5;
let mut p_up = p;
p_up.vol += h;
let mut p_dn = p;
p_dn.vol -= h;
let d_up = bsm_greeks(p_up, OptionType::Call).unwrap().delta;
let d_dn = bsm_greeks(p_dn, OptionType::Call).unwrap().delta;
let fd = (d_up - d_dn) / (2.0 * h);
let analytic = bsm_cross_greeks(p, OptionType::Call).unwrap().vanna;
assert!((fd - analytic).abs() < 1e-4, "fd={fd} analytic={analytic}");
}
#[test]
fn volga_matches_finite_difference() {
let p = BsmParams::atm_one_year(100.0, 0.05, 0.25);
let h = 1e-4;
let mut p_up = p;
p_up.vol += h;
let mut p_dn = p;
p_dn.vol -= h;
let v_up = bsm_greeks(p_up, OptionType::Call).unwrap().vega;
let v_dn = bsm_greeks(p_dn, OptionType::Call).unwrap().vega;
let fd = (v_up - v_dn) / (2.0 * h);
let analytic = bsm_cross_greeks(p, OptionType::Call).unwrap().volga;
assert!((fd - analytic).abs() < 5e-2, "fd={fd} analytic={analytic}");
}
#[test]
fn put_delta_negative_and_call_put_sum_near_df_q() {
let p = BsmParams {
spot: 100.0,
strike: 100.0,
time_years: 0.75,
rate: 0.04,
dividend_yield: 0.01,
vol: 0.22,
};
let dc = bsm_greeks(p, OptionType::Call).unwrap().delta;
let dp = bsm_greeks(p, OptionType::Put).unwrap().delta;
let df_q = (-p.dividend_yield * p.time_years).exp();
assert!(dp < 0.0);
assert!((dc - dp - df_q).abs() < 1e-10);
}
#[test]
fn solution_includes_cross_greeks() {
let sol =
bsm_solution(BsmParams::atm_one_year(100.0, 0.05, 0.2), OptionType::Call).unwrap();
assert!(sol.cross_greeks.volga > 0.0);
}
}