use crate::error::FinError;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
fn phi(x: f64) -> f64 {
crate::normal::pdf(x)
}
fn big_phi(x: f64) -> f64 {
crate::normal::cdf(x)
}
fn to_f64(d: Decimal) -> Result<f64, FinError> {
d.to_f64().ok_or(FinError::ArithmeticOverflow)
}
fn from_f64(f: f64) -> Result<Decimal, FinError> {
if !f.is_finite() {
return Err(FinError::ArithmeticOverflow);
}
Decimal::try_from(f).map_err(|_| FinError::ArithmeticOverflow)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OptionType {
Call,
Put,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OptionSpec {
pub strike: Decimal,
pub expiry_days: u32,
pub spot: Decimal,
pub risk_free_rate: Decimal,
pub volatility: Decimal,
pub option_type: OptionType,
}
impl OptionSpec {
fn validate(&self) -> Result<(), FinError> {
if self.strike <= Decimal::ZERO {
return Err(FinError::InvalidInput(
"strike must be positive".to_owned(),
));
}
if self.expiry_days == 0 {
return Err(FinError::InvalidInput(
"expiry_days must be > 0".to_owned(),
));
}
if self.spot <= Decimal::ZERO {
return Err(FinError::InvalidInput(
"spot must be positive".to_owned(),
));
}
if self.volatility <= Decimal::ZERO {
return Err(FinError::InvalidInput(
"volatility must be positive".to_owned(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OptionGreeks {
pub delta: Decimal,
pub gamma: Decimal,
pub theta: Decimal,
pub vega: Decimal,
pub rho: Decimal,
}
pub struct BlackScholes;
impl BlackScholes {
pub fn greeks(spec: &OptionSpec) -> Result<OptionGreeks, FinError> {
spec.validate()?;
let s = to_f64(spec.spot)?;
let k = to_f64(spec.strike)?;
let r = to_f64(spec.risk_free_rate)?;
let v = to_f64(spec.volatility)?;
let t = f64::from(spec.expiry_days) / 365.0;
let sqrt_t = t.sqrt();
let ln_sk = (s / k).ln();
let d1 = (ln_sk + (r + 0.5 * v * v) * t) / (v * sqrt_t);
let d2 = d1 - v * sqrt_t;
let exp_rt = (-r * t).exp();
let (delta, gamma, theta, vega, rho) = match spec.option_type {
OptionType::Call => {
let nd1 = big_phi(d1);
let nd2 = big_phi(d2);
let phi_d1 = phi(d1);
let delta = nd1;
let gamma = phi_d1 / (s * v * sqrt_t);
let theta =
(-(s * phi_d1 * v) / (2.0 * sqrt_t) - r * k * exp_rt * nd2) / 365.0;
let vega = s * phi_d1 * sqrt_t / 100.0; let rho = k * t * exp_rt * nd2 / 100.0;
(delta, gamma, theta, vega, rho)
}
OptionType::Put => {
let nd1_neg = big_phi(-d1); let nd2_neg = big_phi(-d2); let phi_d1 = phi(d1);
let delta = -nd1_neg;
let gamma = phi_d1 / (s * v * sqrt_t);
let theta =
(-(s * phi_d1 * v) / (2.0 * sqrt_t) + r * k * exp_rt * nd2_neg) / 365.0;
let vega = s * phi_d1 * sqrt_t / 100.0;
let rho = -k * t * exp_rt * nd2_neg / 100.0;
(delta, gamma, theta, vega, rho)
}
};
Ok(OptionGreeks {
delta: from_f64(delta)?,
gamma: from_f64(gamma)?,
theta: from_f64(theta)?,
vega: from_f64(vega)?,
rho: from_f64(rho)?,
})
}
pub fn price(spec: &OptionSpec) -> Result<Decimal, FinError> {
spec.validate()?;
let s = to_f64(spec.spot)?;
let k = to_f64(spec.strike)?;
let r = to_f64(spec.risk_free_rate)?;
let v = to_f64(spec.volatility)?;
let t = f64::from(spec.expiry_days) / 365.0;
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r + 0.5 * v * v) * t) / (v * sqrt_t);
let d2 = d1 - v * sqrt_t;
let exp_rt = (-r * t).exp();
let price = match spec.option_type {
OptionType::Call => s * big_phi(d1) - k * exp_rt * big_phi(d2),
OptionType::Put => k * exp_rt * big_phi(-d2) - s * big_phi(-d1),
};
from_f64(price)
}
pub fn implied_vol(market_price: Decimal, spec: &OptionSpec) -> Result<Decimal, FinError> {
if market_price <= Decimal::ZERO {
return Err(FinError::InvalidInput(
"market_price must be positive".to_owned(),
));
}
if spec.strike <= Decimal::ZERO {
return Err(FinError::InvalidInput(
"strike must be positive".to_owned(),
));
}
if spec.expiry_days == 0 {
return Err(FinError::InvalidInput(
"expiry_days must be > 0".to_owned(),
));
}
if spec.spot <= Decimal::ZERO {
return Err(FinError::InvalidInput(
"spot must be positive".to_owned(),
));
}
let target = to_f64(market_price)?;
const LOW_VOL: f64 = 1e-6;
const HIGH_VOL: f64 = 5.0;
const TOL: f64 = 1e-7;
const MAX_ITER: usize = 200;
let price_at = |vol: f64| -> Result<f64, FinError> {
let trial_spec = OptionSpec {
volatility: from_f64(vol)?,
..spec.clone()
};
to_f64(Self::price(&trial_spec)?)
};
let mut lo = LOW_VOL;
let mut hi = HIGH_VOL;
let p_lo = price_at(lo)?;
let p_hi = price_at(hi)?;
if target < p_lo || target > p_hi {
return Err(FinError::InvalidInput(
"market_price is outside no-arbitrage vol bounds [1e-6, 500%]".to_owned(),
));
}
for _ in 0..MAX_ITER {
let mid = (lo + hi) / 2.0;
let p_mid = price_at(mid)?;
let err = p_mid - target;
if err.abs() < TOL {
return from_f64(mid);
}
if err < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
from_f64((lo + hi) / 2.0)
}
}
#[derive(Debug, Clone)]
pub struct Leg {
pub spec: OptionSpec,
pub quantity: i32,
}
impl Leg {
pub fn new(spec: OptionSpec, quantity: i32) -> Self {
Self { spec, quantity }
}
fn scaled_greeks(&self) -> Result<OptionGreeks, FinError> {
let g = BlackScholes::greeks(&self.spec)?;
let q = from_f64(f64::from(self.quantity))?;
Ok(OptionGreeks {
delta: g.delta * q,
gamma: g.gamma * q,
theta: g.theta * q,
vega: g.vega * q,
rho: g.rho * q,
})
}
}
#[derive(Debug, Clone)]
pub struct SpreadGreeks {
legs: Vec<Leg>,
}
impl SpreadGreeks {
pub fn new(legs: Vec<Leg>) -> Self {
Self { legs }
}
pub fn bull_call_spread(
spot: Decimal,
low_strike: Decimal,
high_strike: Decimal,
expiry_days: u32,
risk_free_rate: Decimal,
volatility: Decimal,
) -> Self {
let base = |strike| OptionSpec {
strike,
expiry_days,
spot,
risk_free_rate,
volatility,
option_type: OptionType::Call,
};
Self::new(vec![
Leg::new(base(low_strike), 1),
Leg::new(base(high_strike), -1),
])
}
pub fn bear_put_spread(
spot: Decimal,
low_strike: Decimal,
high_strike: Decimal,
expiry_days: u32,
risk_free_rate: Decimal,
volatility: Decimal,
) -> Self {
let base = |strike, ot| OptionSpec {
strike,
expiry_days,
spot,
risk_free_rate,
volatility,
option_type: ot,
};
Self::new(vec![
Leg::new(base(high_strike, OptionType::Put), 1),
Leg::new(base(low_strike, OptionType::Put), -1),
])
}
pub fn straddle(
spot: Decimal,
strike: Decimal,
expiry_days: u32,
risk_free_rate: Decimal,
volatility: Decimal,
) -> Self {
let base = |ot| OptionSpec {
strike,
expiry_days,
spot,
risk_free_rate,
volatility,
option_type: ot,
};
Self::new(vec![
Leg::new(base(OptionType::Call), 1),
Leg::new(base(OptionType::Put), 1),
])
}
#[allow(clippy::too_many_arguments)]
pub fn iron_condor(
spot: Decimal,
put_low: Decimal,
put_high: Decimal,
call_low: Decimal,
call_high: Decimal,
expiry_days: u32,
risk_free_rate: Decimal,
volatility: Decimal,
) -> Self {
let mk = |strike, ot, qty| {
Leg::new(
OptionSpec {
strike,
expiry_days,
spot,
risk_free_rate,
volatility,
option_type: ot,
},
qty,
)
};
Self::new(vec![
mk(put_low, OptionType::Put, 1),
mk(put_high, OptionType::Put, -1),
mk(call_low, OptionType::Call, -1),
mk(call_high, OptionType::Call, 1),
])
}
pub fn net_greeks(&self) -> Result<OptionGreeks, FinError> {
let mut delta = Decimal::ZERO;
let mut gamma = Decimal::ZERO;
let mut theta = Decimal::ZERO;
let mut vega = Decimal::ZERO;
let mut rho = Decimal::ZERO;
for leg in &self.legs {
let g = leg.scaled_greeks()?;
delta += g.delta;
gamma += g.gamma;
theta += g.theta;
vega += g.vega;
rho += g.rho;
}
Ok(OptionGreeks { delta, gamma, theta, vega, rho })
}
pub fn leg_count(&self) -> usize {
self.legs.len()
}
}
#[derive(Debug, Clone, Copy)]
pub struct BSParams {
pub spot: f64,
pub strike: f64,
pub time_to_expiry: f64,
pub risk_free_rate: f64,
pub volatility: f64,
pub option_type: OptionType,
}
#[derive(Debug, Clone, Copy)]
pub struct Greeks {
pub delta: f64,
pub gamma: f64,
pub theta: f64,
pub vega: f64,
pub rho: f64,
pub vanna: f64,
pub volga: f64,
pub charm: f64,
pub speed: f64,
}
pub struct BSCalculator;
impl BSCalculator {
pub fn price(p: &BSParams) -> Option<f64> {
let (d1, d2) = Self::d1_d2(p)?;
let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
let price = match p.option_type {
OptionType::Call => p.spot * norm_cdf(d1) - p.strike * exp_rt * norm_cdf(d2),
OptionType::Put => p.strike * exp_rt * norm_cdf(-d2) - p.spot * norm_cdf(-d1),
};
Some(price)
}
pub fn greeks(p: &BSParams) -> Option<Greeks> {
let (d1, d2) = Self::d1_d2(p)?;
let s = p.spot;
let k = p.strike;
let r = p.risk_free_rate;
let v = p.volatility;
let t = p.time_to_expiry;
let sqrt_t = t.sqrt();
let exp_rt = (-r * t).exp();
let phi_d1 = norm_pdf(d1);
let (delta, theta, rho) = match p.option_type {
OptionType::Call => {
let nd1 = norm_cdf(d1);
let nd2 = norm_cdf(d2);
let delta = nd1;
let theta = (-(s * phi_d1 * v) / (2.0 * sqrt_t)
- r * k * exp_rt * nd2)
/ 365.0;
let rho = k * t * exp_rt * nd2 / 100.0;
(delta, theta, rho)
}
OptionType::Put => {
let nd1_neg = norm_cdf(-d1);
let nd2_neg = norm_cdf(-d2);
let delta = nd1_neg - 1.0;
let theta = (-(s * phi_d1 * v) / (2.0 * sqrt_t)
+ r * k * exp_rt * nd2_neg)
/ 365.0;
let rho = -k * t * exp_rt * nd2_neg / 100.0;
(delta, theta, rho)
}
};
let gamma = phi_d1 / (s * v * sqrt_t);
let vega = s * phi_d1 * sqrt_t / 100.0;
let vanna = -phi_d1 * d2 / v;
let volga = vega * d1 * d2 / v;
let charm = match p.option_type {
OptionType::Call => {
(-phi_d1
* (2.0 * r * t - d2 * v * sqrt_t)
/ (2.0 * t * v * sqrt_t))
/ 365.0
}
OptionType::Put => {
(phi_d1
* (2.0 * r * t - d2 * v * sqrt_t)
/ (2.0 * t * v * sqrt_t))
/ 365.0
}
};
let speed = -gamma / s * (d1 / (v * sqrt_t) + 1.0);
Some(Greeks { delta, gamma, theta, vega, rho, vanna, volga, charm, speed })
}
pub fn delta(p: &BSParams) -> Option<f64> {
let (d1, _) = Self::d1_d2(p)?;
Some(match p.option_type {
OptionType::Call => norm_cdf(d1),
OptionType::Put => norm_cdf(-d1) - 1.0,
})
}
pub fn gamma(p: &BSParams) -> Option<f64> {
let (d1, _) = Self::d1_d2(p)?;
Some(norm_pdf(d1) / (p.spot * p.volatility * p.time_to_expiry.sqrt()))
}
pub fn theta(p: &BSParams) -> Option<f64> {
let (d1, d2) = Self::d1_d2(p)?;
let phi_d1 = norm_pdf(d1);
let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
let sqrt_t = p.time_to_expiry.sqrt();
let base = -(p.spot * phi_d1 * p.volatility) / (2.0 * sqrt_t);
let theta = match p.option_type {
OptionType::Call => (base - p.risk_free_rate * p.strike * exp_rt * norm_cdf(d2)) / 365.0,
OptionType::Put => (base + p.risk_free_rate * p.strike * exp_rt * norm_cdf(-d2)) / 365.0,
};
Some(theta)
}
pub fn vega(p: &BSParams) -> Option<f64> {
let (d1, _) = Self::d1_d2(p)?;
Some(p.spot * norm_pdf(d1) * p.time_to_expiry.sqrt() / 100.0)
}
pub fn rho(p: &BSParams) -> Option<f64> {
let (_, d2) = Self::d1_d2(p)?;
let exp_rt = (-p.risk_free_rate * p.time_to_expiry).exp();
Some(match p.option_type {
OptionType::Call => p.strike * p.time_to_expiry * exp_rt * norm_cdf(d2) / 100.0,
OptionType::Put => -p.strike * p.time_to_expiry * exp_rt * norm_cdf(-d2) / 100.0,
})
}
pub fn implied_volatility(
market_price: f64,
p: &BSParams,
tolerance: f64,
max_iter: usize,
) -> Option<f64> {
if market_price <= 0.0 || p.spot <= 0.0 || p.strike <= 0.0 || p.time_to_expiry <= 0.0 {
return None;
}
let mut sigma = (2.0 * std::f64::consts::PI / p.time_to_expiry).sqrt()
* market_price
/ p.spot;
sigma = sigma.clamp(1e-4, 5.0);
for _ in 0..max_iter {
let trial = BSParams { volatility: sigma, ..*p };
let price = Self::price(&trial)?;
let error = price - market_price;
if error.abs() < tolerance {
return Some(sigma);
}
let v = Self::vega(&trial)?;
let vega_raw = v * 100.0;
if vega_raw.abs() < 1e-10 {
break; }
sigma -= error / vega_raw;
sigma = sigma.clamp(1e-4, 5.0);
}
let trial = BSParams { volatility: sigma, ..*p };
let final_price = Self::price(&trial)?;
if (final_price - market_price).abs() < tolerance * 10.0 {
Some(sigma)
} else {
None
}
}
fn d1_d2(p: &BSParams) -> Option<(f64, f64)> {
if p.spot <= 0.0 || p.strike <= 0.0 || p.volatility <= 0.0 || p.time_to_expiry <= 0.0 {
return None;
}
let sqrt_t = p.time_to_expiry.sqrt();
let d1 = ((p.spot / p.strike).ln()
+ (p.risk_free_rate + 0.5 * p.volatility * p.volatility) * p.time_to_expiry)
/ (p.volatility * sqrt_t);
let d2 = d1 - p.volatility * sqrt_t;
Some((d1, d2))
}
}
fn norm_pdf(x: f64) -> f64 {
crate::normal::pdf(x)
}
fn norm_cdf(x: f64) -> f64 {
crate::normal::cdf(x)
}
#[cfg(test)]
mod bs_tests {
use super::*;
fn atm_call() -> BSParams {
BSParams {
spot: 100.0,
strike: 100.0,
time_to_expiry: 30.0 / 365.0,
risk_free_rate: 0.05,
volatility: 0.20,
option_type: OptionType::Call,
}
}
fn atm_put() -> BSParams {
BSParams { option_type: OptionType::Put, ..atm_call() }
}
#[test]
fn price_call_positive() {
let p = BSCalculator::price(&atm_call()).unwrap();
assert!(p > 0.0 && p < 10.0, "call price out of range: {p}");
}
#[test]
fn put_call_parity() {
let c = BSCalculator::price(&atm_call()).unwrap();
let p = BSCalculator::price(&atm_put()).unwrap();
let params = atm_call();
let forward = params.spot
- params.strike * (-params.risk_free_rate * params.time_to_expiry).exp();
assert!((c - p - forward).abs() < 1e-6, "put-call parity violated: {}", c - p - forward);
}
#[test]
fn delta_call_between_zero_and_one() {
let d = BSCalculator::delta(&atm_call()).unwrap();
assert!(d > 0.0 && d < 1.0, "call delta out of range: {d}");
}
#[test]
fn delta_put_between_neg_one_and_zero() {
let d = BSCalculator::delta(&atm_put()).unwrap();
assert!(d > -1.0 && d < 0.0, "put delta out of range: {d}");
}
#[test]
fn gamma_positive() {
let g = BSCalculator::gamma(&atm_call()).unwrap();
assert!(g > 0.0, "gamma should be positive: {g}");
}
#[test]
fn theta_negative_call() {
let t = BSCalculator::theta(&atm_call()).unwrap();
assert!(t < 0.0, "theta should be negative for long call: {t}");
}
#[test]
fn vega_positive() {
let v = BSCalculator::vega(&atm_call()).unwrap();
assert!(v > 0.0, "vega should be positive: {v}");
}
#[test]
fn all_greeks_available() {
let g = BSCalculator::greeks(&atm_call()).unwrap();
assert!(g.delta > 0.0);
assert!(g.gamma > 0.0);
assert!(g.theta < 0.0);
assert!(g.vega > 0.0);
}
#[test]
fn implied_vol_roundtrip() {
let params = atm_call();
let market_price = BSCalculator::price(¶ms).unwrap();
let iv = BSCalculator::implied_volatility(market_price, ¶ms, 1e-6, 100).unwrap();
assert!((iv - params.volatility).abs() < 1e-4, "IV roundtrip error: {}", iv - params.volatility);
}
#[test]
fn implied_vol_invalid_price_returns_none() {
assert!(BSCalculator::implied_volatility(-1.0, &atm_call(), 1e-6, 100).is_none());
}
#[test]
fn invalid_params_return_none() {
let bad = BSParams { spot: -1.0, ..atm_call() };
assert!(BSCalculator::price(&bad).is_none());
assert!(BSCalculator::greeks(&bad).is_none());
}
#[test]
fn vanna_sign_for_atm_call() {
let g = BSCalculator::greeks(&atm_call()).unwrap();
assert!(g.vanna.is_finite(), "vanna should be finite: {}", g.vanna);
}
#[test]
fn speed_is_finite() {
let g = BSCalculator::greeks(&atm_call()).unwrap();
assert!(g.speed.is_finite(), "speed should be finite: {}", g.speed);
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn sample_call() -> OptionSpec {
OptionSpec {
strike: dec!(100),
expiry_days: 30,
spot: dec!(100),
risk_free_rate: dec!(0.05),
volatility: dec!(0.20),
option_type: OptionType::Call,
}
}
fn sample_put() -> OptionSpec {
OptionSpec { option_type: OptionType::Put, ..sample_call() }
}
#[test]
fn test_bs_price_call_atm_sanity() {
let price = BlackScholes::price(&sample_call()).unwrap();
assert!(price > dec!(1) && price < dec!(5), "call price={price}");
}
#[test]
fn test_bs_price_put_atm_sanity() {
let price = BlackScholes::price(&sample_put()).unwrap();
assert!(price > dec!(1) && price < dec!(5), "put price={price}");
}
#[test]
fn test_put_call_parity() {
let call_price = to_f64(BlackScholes::price(&sample_call()).unwrap()).unwrap();
let put_price = to_f64(BlackScholes::price(&sample_put()).unwrap()).unwrap();
let s = 100.0_f64;
let k = 100.0_f64;
let r = 0.05_f64;
let t = 30.0_f64 / 365.0;
let parity_rhs = s - k * (-r * t).exp();
let diff = (call_price - put_price - parity_rhs).abs();
assert!(diff < 1e-6, "put-call parity violation: {diff}");
}
#[test]
fn test_greeks_call_delta_between_zero_and_one() {
let g = BlackScholes::greeks(&sample_call()).unwrap();
assert!(g.delta > dec!(0) && g.delta < dec!(1));
}
#[test]
fn test_greeks_put_delta_between_neg_one_and_zero() {
let g = BlackScholes::greeks(&sample_put()).unwrap();
assert!(g.delta > dec!(-1) && g.delta < dec!(0));
}
#[test]
fn test_greeks_gamma_positive() {
let g = BlackScholes::greeks(&sample_call()).unwrap();
assert!(g.gamma > dec!(0));
}
#[test]
fn test_greeks_theta_negative_call() {
let g = BlackScholes::greeks(&sample_call()).unwrap();
assert!(g.theta < dec!(0));
}
#[test]
fn test_greeks_vega_positive() {
let g = BlackScholes::greeks(&sample_call()).unwrap();
assert!(g.vega > dec!(0));
}
#[test]
fn test_implied_vol_roundtrip() {
let spec = sample_call();
let market_price = BlackScholes::price(&spec).unwrap();
let iv = BlackScholes::implied_vol(market_price, &spec).unwrap();
let diff = (iv - spec.volatility).abs();
assert!(diff < dec!(0.0001), "IV roundtrip error: {diff}");
}
#[test]
fn test_invalid_strike_errors() {
let spec = OptionSpec { strike: dec!(0), ..sample_call() };
assert!(BlackScholes::price(&spec).is_err());
}
#[test]
fn test_invalid_spot_errors() {
let spec = OptionSpec { spot: dec!(-1), ..sample_call() };
assert!(BlackScholes::greeks(&spec).is_err());
}
#[test]
fn test_straddle_delta_near_zero_atm() {
let spread = SpreadGreeks::straddle(
dec!(100),
dec!(100),
30,
dec!(0.05),
dec!(0.20),
);
let g = spread.net_greeks().unwrap();
assert!(g.delta.abs() < dec!(0.1), "straddle delta={}", g.delta);
}
#[test]
fn test_bull_call_spread_positive_delta() {
let spread = SpreadGreeks::bull_call_spread(
dec!(100),
dec!(95),
dec!(105),
30,
dec!(0.05),
dec!(0.20),
);
let g = spread.net_greeks().unwrap();
assert!(g.delta > dec!(0));
}
#[test]
fn test_iron_condor_has_four_legs() {
let spread = SpreadGreeks::iron_condor(
dec!(100),
dec!(85),
dec!(90),
dec!(110),
dec!(115),
30,
dec!(0.05),
dec!(0.20),
);
assert_eq!(spread.leg_count(), 4);
}
#[test]
fn test_bear_put_spread_negative_delta() {
let spread = SpreadGreeks::bear_put_spread(
dec!(100),
dec!(95),
dec!(105),
30,
dec!(0.05),
dec!(0.20),
);
let g = spread.net_greeks().unwrap();
assert!(g.delta < dec!(0));
}
}