use libm::exp;
use crate::core::utils::{norm_pdf, norm_cdf};
use crate::core::trade::PutOrCall;
use super::asian::{self, AsianStrikeType, AveragingType};
use super::barrier;
use super::vanilla_option::{AsianPayoff, BarrierPayoff, BinaryPayoff, BinaryType, EquityOption};
use super::utils::PayoffType;
use crate::core::errors::RustyQLibError;
pub struct BlackScholesPricer;
impl BlackScholesPricer {
pub fn new() -> Self {
BlackScholesPricer
}
pub fn npv(&self, bsd_option: &EquityOption) -> f64 {
assert!(bsd_option.time_to_maturity() >= 0.0, "Option is expired or negative time");
assert!(bsd_option.market.spot.mid() >= 0.0, "Negative underlying price not allowed");
if bsd_option.base.is_futures_option() {
return self.npv_black76(bsd_option);
}
match &bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => self.npv_vanilla(bsd_option),
PayoffType::Binary => self.npv_binary(bsd_option),
PayoffType::Barrier => self.npv_barrier(bsd_option),
PayoffType::Asian => self.npv_asian(bsd_option),
PayoffType::Lookback => Self::lookback_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0),
PayoffType::ForwardStart => self.npv_forward_start(bsd_option),
_ => {0.0}
}
}
pub fn delta(&self, bsd_option: &EquityOption) -> f64 {
assert!(bsd_option.time_to_maturity() >= 0.0, "Option is expired or negative time");
assert!(bsd_option.market.spot.mid() >= 0.0, "Negative underlying price not allowed");
if bsd_option.base.is_futures_option() {
return self.delta_black76(bsd_option);
}
match &bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => self.delta_vanilla(bsd_option),
PayoffType::Binary => self.delta_binary(bsd_option),
PayoffType::Barrier => self.delta_barrier(bsd_option),
PayoffType::Asian => self.delta_asian(bsd_option),
PayoffType::ForwardStart => self.delta_forward_start(bsd_option),
PayoffType::Lookback => self.delta_lookback(bsd_option),
_ => {0.0}
}
}
pub fn gamma(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.gamma_black76(bsd_option);
}
match &bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => self.gamma_vanilla(bsd_option),
PayoffType::Binary => self.gamma_binary(bsd_option),
PayoffType::Barrier => self.gamma_barrier(bsd_option),
PayoffType::Asian => self.gamma_asian(bsd_option),
PayoffType::ForwardStart => self.gamma_forward_start(bsd_option),
PayoffType::Lookback => self.gamma_lookback(bsd_option),
_ => {0.0}
}
}
pub fn vega(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.vega_black76(bsd_option);
}
match &bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => self.vega_vanilla(bsd_option),
PayoffType::Binary => self.vega_binary(bsd_option),
PayoffType::Barrier => self.vega_barrier(bsd_option),
PayoffType::Asian => self.vega_asian(bsd_option),
PayoffType::ForwardStart => self.vega_forward_start(bsd_option),
PayoffType::Lookback => self.vega_lookback(bsd_option),
_ => {0.0}
}
}
pub fn theta(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.theta_black76(bsd_option);
}
match &bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => self.theta_vanilla(bsd_option),
PayoffType::Binary => self.theta_binary(bsd_option),
PayoffType::Barrier => self.theta_barrier(bsd_option),
PayoffType::Asian => self.theta_asian(bsd_option),
PayoffType::ForwardStart => self.theta_forward_start(bsd_option),
PayoffType::Lookback => self.theta_lookback(bsd_option),
_ => {0.0}
}
}
pub fn rho(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.rho_black76(bsd_option);
}
match &bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => self.rho_vanilla(bsd_option),
PayoffType::Binary => self.rho_binary(bsd_option),
PayoffType::Barrier => self.rho_barrier(bsd_option),
PayoffType::Asian => self.rho_asian(bsd_option),
PayoffType::ForwardStart => self.rho_forward_start(bsd_option),
PayoffType::Lookback => self.rho_lookback(bsd_option),
_ => {0.0}
}
}
pub fn vanna(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.vanna_black76(bsd_option);
}
if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
return bs_vanna(
bsd_option.effective_spot(), bsd_option.base.strike_price,
bsd_option.risk_free_rate(), bsd_option.carry_yield(),
bsd_option.volatility(), bsd_option.time_to_maturity(),
);
}
self.vanna_bumped(bsd_option)
}
pub fn charm(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.charm_black76(bsd_option);
}
if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
return bs_charm(
bsd_option.effective_spot(), bsd_option.base.strike_price,
bsd_option.risk_free_rate(), bsd_option.carry_yield(),
bsd_option.volatility(), bsd_option.time_to_maturity(),
*bsd_option.payoff.put_or_call(),
);
}
self.charm_bumped(bsd_option)
}
pub fn gamma_p(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.gamma_p_black76(bsd_option);
}
let delta = self.delta(bsd_option);
if delta == 0.0 {
f64::NAN
} else {
bsd_option.market.spot.value() * self.gamma(bsd_option) / delta
}
}
pub fn zomma(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.zomma_black76(bsd_option);
}
if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
return bs_zomma(
bsd_option.effective_spot(), bsd_option.base.strike_price,
bsd_option.risk_free_rate(), bsd_option.carry_yield(),
bsd_option.volatility(), bsd_option.time_to_maturity(),
);
}
self.zomma_bumped(bsd_option)
}
pub fn volga(&self, bsd_option: &EquityOption) -> f64 {
if bsd_option.base.is_futures_option() {
return self.volga_black76(bsd_option);
}
if matches!(bsd_option.payoff.payoff_kind(), PayoffType::Vanilla) {
return bs_volga(
bsd_option.effective_spot(), bsd_option.base.strike_price,
bsd_option.risk_free_rate(), bsd_option.carry_yield(),
bsd_option.volatility(), bsd_option.time_to_maturity(),
);
}
self.volga_bumped(bsd_option)
}
fn black76_inputs(bsd_option: &EquityOption)
-> (f64, f64, f64, f64, f64, PutOrCall, crate::equity::black76::FuturesSettlement)
{
let f = bsd_option.market.spot.value();
let k = bsd_option.base.strike_price;
let r = bsd_option.risk_free_rate();
let t = bsd_option.time_to_maturity();
let sigma = bsd_option.market.vol_surface.vol(k, f, t);
let settlement = bsd_option
.base
.futures_settlement
.expect("black76 pricer called on a non-futures option");
(f, k, r, sigma, t, *bsd_option.payoff.put_or_call(), settlement)
}
fn npv_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
crate::equity::black76::price(f, k, r, sig, t, pc, s)
}
fn delta_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
crate::equity::black76::delta(f, k, r, sig, t, pc, s)
}
fn gamma_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
crate::equity::black76::gamma(f, k, r, sig, t, s)
}
fn vega_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
crate::equity::black76::vega(f, k, r, sig, t, s)
}
fn theta_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
crate::equity::black76::theta(f, k, r, sig, t, pc, s)
}
fn rho_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
crate::equity::black76::rho(f, k, r, sig, t, pc, s)
}
fn vanna_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
crate::equity::black76::vanna(f, k, r, sig, t, s)
}
fn charm_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
crate::equity::black76::charm(f, k, r, sig, t, pc, s)
}
fn gamma_p_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, pc, s) = Self::black76_inputs(o);
crate::equity::black76::gamma_p(f, k, r, sig, t, pc, s)
}
fn zomma_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
crate::equity::black76::zomma(f, k, r, sig, t, s)
}
fn volga_black76(&self, o: &EquityOption) -> f64 {
let (f, k, r, sig, t, _pc, s) = Self::black76_inputs(o);
crate::equity::black76::volga(f, k, r, sig, t, s)
}
fn npv_vanilla(&self, bsd_option: &EquityOption) -> f64 {
let n_d1 = norm_cdf(bsd_option.d1());
let n_d2 = norm_cdf(bsd_option.d2());
let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
let df_r = bsd_option.maturity_discount_factor();
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => {bsd_option.effective_spot()*n_d1 *df_d
-bsd_option.base.strike_price*n_d2*df_r
}
PutOrCall::Put => {bsd_option.base.strike_price*norm_cdf(-bsd_option.d2())*df_r-
bsd_option.effective_spot()*norm_cdf(-bsd_option.d1()) *df_d
}
}
}
fn delta_vanilla(&self, bsd_option: &EquityOption) -> f64 {
let n_d1 = norm_cdf(bsd_option.d1());
let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => {n_d1 * df_d }
PutOrCall::Put => {(n_d1-1.0) * df_d }
}
}
fn gamma_vanilla(&self, bsd_option: &EquityOption) -> f64 {
let dn_d1 = norm_pdf(bsd_option.d1());
let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
let var_sqrt = bsd_option.volatility() * (bsd_option.time_to_maturity().sqrt());
dn_d1 * df_d / (bsd_option.effective_spot() * var_sqrt)
}
fn vega_vanilla(&self, bsd_option: &EquityOption) -> f64 {
let dn_d1 = norm_pdf(bsd_option.d1());
let df_d = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
let df_s = bsd_option.effective_spot() * df_d;
let vega = df_s * dn_d1 * bsd_option.time_to_maturity().sqrt();
vega
}
fn theta_vanilla(&self, bsd_option: &EquityOption) -> f64 {
let q = bsd_option.carry_yield();
let r = bsd_option.risk_free_rate();
let k = bsd_option.base.strike_price;
let dn_d1 = norm_pdf(bsd_option.d1());
let n_d1 = norm_cdf(bsd_option.d1());
let n_d2 = norm_cdf(bsd_option.d2());
let df_d = exp(-q * bsd_option.time_to_maturity());
let df_r = bsd_option.maturity_discount_factor();
let df_s = bsd_option.effective_spot() * df_d;
let t1 = -df_s * dn_d1 * bsd_option.volatility()
/ (2.0 * bsd_option.time_to_maturity().sqrt());
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => {
t1 + q * df_s * n_d1 - r * k * df_r * n_d2
}
PutOrCall::Put => {
t1 - q * df_s * norm_cdf(-bsd_option.d1()) + r * k * df_r * norm_cdf(-bsd_option.d2())
}
}
}
fn rho_vanilla(&self, bsd_option: &EquityOption) -> f64 {
let n_d2 = norm_cdf(bsd_option.d2());
let df_r = bsd_option.maturity_discount_factor();
let r1 = bsd_option.time_to_maturity()*bsd_option.base.strike_price;
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => {
r1*n_d2*df_r
}
PutOrCall::Put => {-r1*norm_cdf(-bsd_option.d2())*df_r
}
}
}
pub(crate) fn price_with(
bsd_option: &EquityOption,
ds: f64,
dsigma: f64,
dr: f64,
dt_shift: f64,
) -> f64 {
match bsd_option.payoff.payoff_kind() {
PayoffType::Vanilla => bs_price(
bsd_option.effective_spot() + ds,
bsd_option.base.strike_price,
bsd_option.risk_free_rate() + dr,
bsd_option.carry_yield(),
bsd_option.volatility() + dsigma,
bsd_option.time_to_maturity() + dt_shift,
*bsd_option.payoff.put_or_call(),
),
PayoffType::Binary => Self::binary_price_with(bsd_option, ds, dsigma, dr, dt_shift),
PayoffType::Barrier => Self::barrier_price_with(bsd_option, ds, dsigma, dr, dt_shift),
PayoffType::Asian => Self::asian_price_with(bsd_option, ds, dsigma, dr, dt_shift),
PayoffType::ForwardStart => Self::forward_start_price_with(bsd_option, ds, dsigma, dr, dt_shift),
PayoffType::Lookback => Self::lookback_price_with(bsd_option, ds, dsigma, dr, dt_shift),
_ => panic!("cross-Greeks are not available for this analytic payoff"),
}
}
fn vanna_bumped(&self, bsd_option: &EquityOption) -> f64 {
let hs = bsd_option.market.spot.value() * 1e-4;
let hv = 1e-4;
(Self::price_with(bsd_option, hs, hv, 0.0, 0.0)
- Self::price_with(bsd_option, -hs, hv, 0.0, 0.0)
- Self::price_with(bsd_option, hs, -hv, 0.0, 0.0)
+ Self::price_with(bsd_option, -hs, -hv, 0.0, 0.0))
/ (4.0 * hs * hv)
}
fn charm_bumped(&self, bsd_option: &EquityOption) -> f64 {
let hs = bsd_option.market.spot.value() * 1e-4;
let ht = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
-(Self::price_with(bsd_option, hs, 0.0, 0.0, ht)
- Self::price_with(bsd_option, -hs, 0.0, 0.0, ht)
- Self::price_with(bsd_option, hs, 0.0, 0.0, -ht)
+ Self::price_with(bsd_option, -hs, 0.0, 0.0, -ht))
/ (4.0 * hs * ht)
}
fn zomma_bumped(&self, bsd_option: &EquityOption) -> f64 {
let hv = 1e-4;
let hs = bsd_option.market.spot.value() * 1e-3;
let gamma_at_vol = |dsigma: f64| {
(Self::price_with(bsd_option, hs, dsigma, 0.0, 0.0)
- 2.0 * Self::price_with(bsd_option, 0.0, dsigma, 0.0, 0.0)
+ Self::price_with(bsd_option, -hs, dsigma, 0.0, 0.0))
/ (hs * hs)
};
(gamma_at_vol(hv) - gamma_at_vol(-hv)) / (2.0 * hv)
}
fn volga_bumped(&self, bsd_option: &EquityOption) -> f64 {
let hv = 1e-3;
(Self::price_with(bsd_option, 0.0, hv, 0.0, 0.0)
- 2.0 * Self::price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
+ Self::price_with(bsd_option, 0.0, -hv, 0.0, 0.0))
/ (hv * hv)
}
fn binary_details(bsd_option: &EquityOption) -> (BinaryType, f64) {
let payoff = bsd_option
.payoff
.as_any()
.downcast_ref::<BinaryPayoff>()
.expect("payoff of kind Binary must be a BinaryPayoff");
(payoff.binary_type, payoff.cash)
}
fn binary_price_with(
bsd_option: &EquityOption,
ds: f64,
dsigma: f64,
dr: f64,
dt_shift: f64,
) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let s = bsd_option.effective_spot() + ds;
let k = bsd_option.base.strike_price;
let r = bsd_option.risk_free_rate() + dr;
let q = bsd_option.carry_yield();
let sigma = bsd_option.volatility() + dsigma;
let t = bsd_option.time_to_maturity() + dt_shift;
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
let d2 = d1 - sigma * t.sqrt();
match (binary_type, bsd_option.payoff.put_or_call()) {
(BinaryType::CashOrNothing, PutOrCall::Call) => cash * (-r * t).exp() * norm_cdf(d2),
(BinaryType::CashOrNothing, PutOrCall::Put) => cash * (-r * t).exp() * norm_cdf(-d2),
(BinaryType::AssetOrNothing, PutOrCall::Call) => s * (-q * t).exp() * norm_cdf(d1),
(BinaryType::AssetOrNothing, PutOrCall::Put) => s * (-q * t).exp() * norm_cdf(-d1),
}
}
fn npv_binary(&self, bsd_option: &EquityOption) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let df_r = bsd_option.maturity_discount_factor();
let df_q = exp(-bsd_option.carry_yield() * bsd_option.time_to_maturity());
let s = bsd_option.effective_spot();
match (binary_type, bsd_option.payoff.put_or_call()) {
(BinaryType::CashOrNothing, PutOrCall::Call) => cash * df_r * norm_cdf(bsd_option.d2()),
(BinaryType::CashOrNothing, PutOrCall::Put) => cash * df_r * norm_cdf(-bsd_option.d2()),
(BinaryType::AssetOrNothing, PutOrCall::Call) => s * df_q * norm_cdf(bsd_option.d1()),
(BinaryType::AssetOrNothing, PutOrCall::Put) => s * df_q * norm_cdf(-bsd_option.d1()),
}
}
fn delta_binary(&self, bsd_option: &EquityOption) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let t = bsd_option.time_to_maturity();
let sigma = bsd_option.volatility();
let s = bsd_option.effective_spot();
let vol_sqrt_t = sigma * t.sqrt();
match binary_type {
BinaryType::CashOrNothing => {
let df_r = bsd_option.maturity_discount_factor();
let delta_call = cash * df_r * norm_pdf(bsd_option.d2()) / (s * vol_sqrt_t);
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => delta_call,
PutOrCall::Put => -delta_call,
}
}
BinaryType::AssetOrNothing => {
let df_q = exp(-bsd_option.market.dividend_yield * t);
let d1 = bsd_option.d1();
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => df_q * (norm_cdf(d1) + norm_pdf(d1) / vol_sqrt_t),
PutOrCall::Put => df_q * (norm_cdf(-d1) - norm_pdf(d1) / vol_sqrt_t),
}
}
}
}
fn gamma_binary(&self, bsd_option: &EquityOption) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let t = bsd_option.time_to_maturity();
let sigma = bsd_option.volatility();
let s = bsd_option.effective_spot();
let vol_sqrt_t = sigma * t.sqrt();
let gamma_call = match binary_type {
BinaryType::CashOrNothing => {
let df_r = bsd_option.maturity_discount_factor();
-cash * df_r * norm_pdf(bsd_option.d2()) * bsd_option.d1()
/ (s * s * sigma * sigma * t)
}
BinaryType::AssetOrNothing => {
let df_q = exp(-bsd_option.market.dividend_yield * t);
let d1 = bsd_option.d1();
df_q * norm_pdf(d1) * (1.0 - d1 / vol_sqrt_t) / (s * vol_sqrt_t)
}
};
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => gamma_call,
PutOrCall::Put => -gamma_call,
}
}
fn vega_binary(&self, bsd_option: &EquityOption) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let t = bsd_option.time_to_maturity();
let sigma = bsd_option.volatility();
let s = bsd_option.effective_spot();
let vega_call = match binary_type {
BinaryType::CashOrNothing => {
let df_r = bsd_option.maturity_discount_factor();
-cash * df_r * norm_pdf(bsd_option.d2()) * bsd_option.d1() / sigma
}
BinaryType::AssetOrNothing => {
let df_q = exp(-bsd_option.market.dividend_yield * t);
-s * df_q * norm_pdf(bsd_option.d1()) * bsd_option.d2() / sigma
}
};
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => vega_call,
PutOrCall::Put => -vega_call,
}
}
fn theta_binary(&self, bsd_option: &EquityOption) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let r = bsd_option.risk_free_rate();
let q = bsd_option.carry_yield();
let t = bsd_option.time_to_maturity();
let sigma = bsd_option.volatility();
let s = bsd_option.effective_spot();
match binary_type {
BinaryType::CashOrNothing => {
let df_r = bsd_option.maturity_discount_factor();
let d2 = bsd_option.d2();
let dd2_dt = (r - q - 0.5 * sigma * sigma) / (sigma * t.sqrt()) - d2 / (2.0 * t);
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => cash * (r * df_r * norm_cdf(d2) - df_r * norm_pdf(d2) * dd2_dt),
PutOrCall::Put => cash * (r * df_r * norm_cdf(-d2) + df_r * norm_pdf(d2) * dd2_dt),
}
}
BinaryType::AssetOrNothing => {
let df_q = exp(-q * t);
let d1 = bsd_option.d1();
let dd1_dt = (r - q + 0.5 * sigma * sigma) / (sigma * t.sqrt()) - d1 / (2.0 * t);
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => q * s * df_q * norm_cdf(d1) - s * df_q * norm_pdf(d1) * dd1_dt,
PutOrCall::Put => q * s * df_q * norm_cdf(-d1) + s * df_q * norm_pdf(d1) * dd1_dt,
}
}
}
}
fn rho_binary(&self, bsd_option: &EquityOption) -> f64 {
let (binary_type, cash) = Self::binary_details(bsd_option);
let t = bsd_option.time_to_maturity();
let sigma = bsd_option.volatility();
let s = bsd_option.effective_spot();
match binary_type {
BinaryType::CashOrNothing => {
let df_r = bsd_option.maturity_discount_factor();
let d2 = bsd_option.d2();
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => cash * (-t * df_r * norm_cdf(d2) + df_r * norm_pdf(d2) * t.sqrt() / sigma),
PutOrCall::Put => cash * (-t * df_r * norm_cdf(-d2) - df_r * norm_pdf(d2) * t.sqrt() / sigma),
}
}
BinaryType::AssetOrNothing => {
let df_q = exp(-bsd_option.market.dividend_yield * t);
let rho_call = s * df_q * norm_pdf(bsd_option.d1()) * t.sqrt() / sigma;
match bsd_option.payoff.put_or_call() {
PutOrCall::Call => rho_call,
PutOrCall::Put => -rho_call,
}
}
}
}
fn barrier_price_with(
bsd_option: &EquityOption,
ds: f64,
dsigma: f64,
dr: f64,
dt_shift: f64,
) -> f64 {
let payoff = bsd_option
.payoff
.as_any()
.downcast_ref::<BarrierPayoff>()
.expect("payoff of kind Barrier must be a BarrierPayoff");
let s = bsd_option.effective_spot() + ds;
let k = bsd_option.base.strike_price;
let r = bsd_option.risk_free_rate() + dr;
let q = bsd_option.carry_yield();
let sigma = bsd_option.volatility() + dsigma;
let t = bsd_option.time_to_maturity() + dt_shift;
let pc = *bsd_option.payoff.put_or_call();
match payoff.barrier2 {
Some(b2) => {
assert!(
payoff.rebate == 0.0,
"double-barrier rebates are not supported analytically; use MonteCarlo (rebate at expiry)"
);
let (lo, hi) = (payoff.barrier.min(b2), payoff.barrier.max(b2));
barrier::double_barrier_price(s, k, lo, hi, r, q, sigma, t, payoff.knock, pc)
}
None => {
let timing = if payoff.rebate_at_hit {
barrier::RebateTiming::AtHit
} else {
barrier::RebateTiming::AtExpiry
};
barrier::barrier_price_with_rebate(
s, k, payoff.barrier, payoff.rebate, r, q, sigma, t,
payoff.direction, payoff.knock, timing, pc,
)
}
}
}
fn npv_barrier(&self, bsd_option: &EquityOption) -> f64 {
Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
}
fn delta_barrier(&self, bsd_option: &EquityOption) -> f64 {
let h = bsd_option.market.spot.value() * 1e-4;
(Self::barrier_price_with(bsd_option, h, 0.0, 0.0, 0.0)
- Self::barrier_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
/ (2.0 * h)
}
fn gamma_barrier(&self, bsd_option: &EquityOption) -> f64 {
let h = bsd_option.market.spot.value() * 1e-3;
(Self::barrier_price_with(bsd_option, h, 0.0, 0.0, 0.0)
- 2.0 * Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
+ Self::barrier_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
/ (h * h)
}
fn vega_barrier(&self, bsd_option: &EquityOption) -> f64 {
let h = 1e-4;
(Self::barrier_price_with(bsd_option, 0.0, h, 0.0, 0.0)
- Self::barrier_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
/ (2.0 * h)
}
fn theta_barrier(&self, bsd_option: &EquityOption) -> f64 {
let h = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
-(Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, h)
- Self::barrier_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
/ (2.0 * h)
}
fn rho_barrier(&self, bsd_option: &EquityOption) -> f64 {
let h = 1e-5;
(Self::barrier_price_with(bsd_option, 0.0, 0.0, h, 0.0)
- Self::barrier_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
/ (2.0 * h)
}
fn asian_price_with(
bsd_option: &EquityOption,
ds: f64,
dsigma: f64,
dr: f64,
dt_shift: f64,
) -> f64 {
let payoff = bsd_option
.payoff
.as_any()
.downcast_ref::<AsianPayoff>()
.expect("payoff of kind Asian must be an AsianPayoff");
let s = bsd_option.effective_spot() + ds;
let k = bsd_option.base.strike_price;
let r = bsd_option.risk_free_rate() + dr;
let q = bsd_option.carry_yield();
let sigma = bsd_option.volatility() + dsigma;
let t = bsd_option.time_to_maturity() + dt_shift;
let pc = *bsd_option.payoff.put_or_call();
match (payoff.strike_type, payoff.averaging) {
(AsianStrikeType::FixedStrike, AveragingType::Geometric) => {
asian::geometric_asian_price(s, k, r, q, sigma, t, None, pc)
}
(AsianStrikeType::FixedStrike, AveragingType::Arithmetic) => {
asian::turnbull_wakeman_price(s, k, r, q, sigma, t, pc)
}
(AsianStrikeType::FloatingStrike, AveragingType::Geometric) => {
asian::geometric_average_strike_price(s, r, q, sigma, t, None, pc)
}
(AsianStrikeType::FloatingStrike, AveragingType::Arithmetic) => {
asian::turnbull_wakeman_average_strike_price(s, r, q, sigma, t, pc)
}
}
}
fn lookback_price_with(
bsd_option: &EquityOption,
ds: f64,
dsigma: f64,
dr: f64,
dt_shift: f64,
) -> f64 {
let payoff = bsd_option
.payoff
.as_any()
.downcast_ref::<crate::equity::vanilla_option::LookbackPayoff>()
.expect("payoff of kind Lookback must be a LookbackPayoff");
let anchor = bsd_option.effective_spot();
let s = anchor + ds;
let k = bsd_option.base.strike_price;
let r = bsd_option.risk_free_rate() + dr;
let q = bsd_option.carry_yield();
let sigma = bsd_option.volatility() + dsigma;
let t = bsd_option.time_to_maturity() + dt_shift;
let pc = *bsd_option.payoff.put_or_call();
use crate::equity::vanilla_option::LookbackType;
match (payoff.lookback_type, pc) {
(LookbackType::FloatingStrike, PutOrCall::Call) => {
crate::equity::lookback::floating_strike_lookback_price(
s, anchor.min(s), r, q, sigma, t, pc,
)
}
(LookbackType::FloatingStrike, PutOrCall::Put) => {
crate::equity::lookback::floating_strike_lookback_price(
s, anchor.max(s), r, q, sigma, t, pc,
)
}
(LookbackType::FixedStrike, PutOrCall::Call) => {
crate::equity::lookback::fixed_strike_lookback_price(
s, k, anchor.max(s), r, q, sigma, t, pc,
)
}
(LookbackType::FixedStrike, PutOrCall::Put) => {
crate::equity::lookback::fixed_strike_lookback_price(
s, k, anchor.min(s), r, q, sigma, t, pc,
)
}
}
}
fn delta_lookback(&self, o: &EquityOption) -> f64 {
let h = o.market.spot.value() * 1e-4;
(Self::lookback_price_with(o, h, 0.0, 0.0, 0.0)
- Self::lookback_price_with(o, -h, 0.0, 0.0, 0.0))
/ (2.0 * h)
}
fn gamma_lookback(&self, o: &EquityOption) -> f64 {
let h = o.market.spot.value() * 1e-3;
(Self::lookback_price_with(o, h, 0.0, 0.0, 0.0)
- 2.0 * Self::lookback_price_with(o, 0.0, 0.0, 0.0, 0.0)
+ Self::lookback_price_with(o, -h, 0.0, 0.0, 0.0))
/ (h * h)
}
fn vega_lookback(&self, o: &EquityOption) -> f64 {
let h = 1e-4;
(Self::lookback_price_with(o, 0.0, h, 0.0, 0.0)
- Self::lookback_price_with(o, 0.0, -h, 0.0, 0.0))
/ (2.0 * h)
}
fn theta_lookback(&self, o: &EquityOption) -> f64 {
let h = (1.0 / 365.0_f64).min(0.5 * o.time_to_maturity());
-(Self::lookback_price_with(o, 0.0, 0.0, 0.0, h)
- Self::lookback_price_with(o, 0.0, 0.0, 0.0, -h))
/ (2.0 * h)
}
fn rho_lookback(&self, o: &EquityOption) -> f64 {
let h = 1e-5;
(Self::lookback_price_with(o, 0.0, 0.0, h, 0.0)
- Self::lookback_price_with(o, 0.0, 0.0, -h, 0.0))
/ (2.0 * h)
}
fn npv_asian(&self, bsd_option: &EquityOption) -> f64 {
Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
}
fn delta_asian(&self, bsd_option: &EquityOption) -> f64 {
let h = bsd_option.market.spot.value() * 1e-4;
(Self::asian_price_with(bsd_option, h, 0.0, 0.0, 0.0)
- Self::asian_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
/ (2.0 * h)
}
fn gamma_asian(&self, bsd_option: &EquityOption) -> f64 {
let h = bsd_option.market.spot.value() * 1e-3;
(Self::asian_price_with(bsd_option, h, 0.0, 0.0, 0.0)
- 2.0 * Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
+ Self::asian_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
/ (h * h)
}
fn vega_asian(&self, bsd_option: &EquityOption) -> f64 {
let h = 1e-4;
(Self::asian_price_with(bsd_option, 0.0, h, 0.0, 0.0)
- Self::asian_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
/ (2.0 * h)
}
fn theta_asian(&self, bsd_option: &EquityOption) -> f64 {
let h = (1.0 / 365.0_f64).min(0.5 * bsd_option.time_to_maturity());
-(Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, h)
- Self::asian_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
/ (2.0 * h)
}
fn rho_asian(&self, bsd_option: &EquityOption) -> f64 {
let h = 1e-5;
(Self::asian_price_with(bsd_option, 0.0, 0.0, h, 0.0)
- Self::asian_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
/ (2.0 * h)
}
fn forward_start_price_with(
bsd_option: &EquityOption,
ds: f64,
dsigma: f64,
dr: f64,
dt_shift: f64,
) -> f64 {
let payoff = bsd_option
.payoff
.as_any()
.downcast_ref::<crate::equity::forward_start_option::ForwardStartPayoff>()
.expect("payoff of kind ForwardStart must be a ForwardStartPayoff");
let t = bsd_option.time_to_maturity() + dt_shift;
crate::equity::forward_start_option::forward_start_price(
bsd_option.effective_spot() + ds,
payoff.strike_fraction,
bsd_option.risk_free_rate() + dr,
bsd_option.carry_yield(),
bsd_option.volatility() + dsigma,
payoff.start_fraction * t,
t,
*bsd_option.payoff.put_or_call(),
)
}
fn npv_forward_start(&self, bsd_option: &EquityOption) -> f64 {
Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
}
fn delta_forward_start(&self, bsd_option: &EquityOption) -> f64 {
let h = bsd_option.market.spot.value() * 1e-4;
(Self::forward_start_price_with(bsd_option, h, 0.0, 0.0, 0.0)
- Self::forward_start_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
/ (2.0 * h)
}
fn gamma_forward_start(&self, bsd_option: &EquityOption) -> f64 {
let h = bsd_option.market.spot.value() * 1e-3;
(Self::forward_start_price_with(bsd_option, h, 0.0, 0.0, 0.0)
- 2.0 * Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, 0.0)
+ Self::forward_start_price_with(bsd_option, -h, 0.0, 0.0, 0.0))
/ (h * h)
}
fn vega_forward_start(&self, bsd_option: &EquityOption) -> f64 {
let h = 1e-4;
(Self::forward_start_price_with(bsd_option, 0.0, h, 0.0, 0.0)
- Self::forward_start_price_with(bsd_option, 0.0, -h, 0.0, 0.0))
/ (2.0 * h)
}
fn theta_forward_start(&self, bsd_option: &EquityOption) -> f64 {
let h = (1.0 / 365.0_f64).min(0.25 * bsd_option.time_to_maturity());
-(Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, h)
- Self::forward_start_price_with(bsd_option, 0.0, 0.0, 0.0, -h))
/ (2.0 * h)
}
fn rho_forward_start(&self, bsd_option: &EquityOption) -> f64 {
let h = 1e-5;
(Self::forward_start_price_with(bsd_option, 0.0, 0.0, h, 0.0)
- Self::forward_start_price_with(bsd_option, 0.0, 0.0, -h, 0.0))
/ (2.0 * h)
}
}
pub fn bs_price(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64, put_or_call: PutOrCall) -> f64 {
if t <= 0.0 || sigma <= 0.0 {
return match put_or_call {
PutOrCall::Call => (s * exp(-q * t) - k * exp(-r * t)).max(0.0),
PutOrCall::Put => (k * exp(-r * t) - s * exp(-q * t)).max(0.0),
};
}
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
let d2 = d1 - sigma * sqrt_t;
match put_or_call {
PutOrCall::Call => s * exp(-q * t) * norm_cdf(d1) - k * exp(-r * t) * norm_cdf(d2),
PutOrCall::Put => k * exp(-r * t) * norm_cdf(-d2) - s * exp(-q * t) * norm_cdf(-d1),
}
}
pub fn bs_vega(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
s * exp(-q * t) * norm_pdf(d1) * sqrt_t
}
pub fn bs_vanna(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
exp(-q * t) * norm_pdf(d1) * (sqrt_t - d1 / sigma)
}
pub fn bs_charm(
s: f64,
k: f64,
r: f64,
q: f64,
sigma: f64,
t: f64,
put_or_call: PutOrCall,
) -> f64 {
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
let df_q = exp(-q * t);
let d1_dt = (r - q + 0.5 * sigma * sigma) / (sigma * sqrt_t) - d1 / (2.0 * t);
let delta_component = match put_or_call {
PutOrCall::Call => norm_cdf(d1),
PutOrCall::Put => norm_cdf(d1) - 1.0,
};
q * df_q * delta_component - df_q * norm_pdf(d1) * d1_dt
}
pub fn bs_zomma(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
let d2 = d1 - sigma * sqrt_t;
let gamma = exp(-q * t) * norm_pdf(d1) / (s * sigma * sqrt_t);
gamma * (d1 * d2 - 1.0) / sigma
}
pub fn bs_volga(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64) -> f64 {
let sqrt_t = t.sqrt();
let d1 = ((s / k).ln() + (r - q + 0.5 * sigma * sigma) * t) / (sigma * sqrt_t);
let d2 = d1 - sigma * sqrt_t;
let vega = s * exp(-q * t) * norm_pdf(d1) * sqrt_t;
vega * d1 * d2 / sigma
}
const IMPLIED_VOL_MIN: f64 = 1e-4;
const IMPLIED_VOL_MAX: f64 = 5.0;
pub fn implied_vol_from_price(
s: f64,
k: f64,
r: f64,
q: f64,
t: f64,
target: f64,
put_or_call: PutOrCall,
) -> Result<f64, RustyQLibError> {
if t <= 0.0 {
return Err(RustyQLibError::NumericalError("option is expired".to_string()));
}
let lower_bound = bs_price(s, k, r, q, 0.0, t, put_or_call);
let upper_bound = match put_or_call {
PutOrCall::Call => s * exp(-q * t),
PutOrCall::Put => k * exp(-r * t),
};
if target < lower_bound - 1e-12 || target > upper_bound + 1e-12 {
return Err(RustyQLibError::NumericalError(format!(
"price {target} violates arbitrage bounds [{lower_bound}, {upper_bound}]"
)));
}
let (lo, hi) = (IMPLIED_VOL_MIN, IMPLIED_VOL_MAX);
if bs_price(s, k, r, q, lo, t, put_or_call) > target {
return Ok(lo); }
if bs_price(s, k, r, q, hi, t, put_or_call) < target {
return Err(RustyQLibError::NumericalError(format!("implied vol above {IMPLIED_VOL_MAX}")));
}
let tol = 1e-12 * target.max(1.0);
let root = crate::core::solvers::Solver1d::new(tol, 100).newton_safeguarded(
|sigma| bs_price(s, k, r, q, sigma, t, put_or_call) - target,
|sigma| bs_vega(s, k, r, q, sigma, t),
lo,
hi,
0.5,
);
Ok(root.x)
}
#[cfg(test)]
mod tests {
use assert_approx_eq::assert_approx_eq;
use super::*;
use chrono::NaiveDate;
use crate::core::curves::{Compounding, InterpolationMethod, Tenor, YieldCurve};
use crate::core::daycount::DayCountConvention;
use crate::core::quotes::Quote;
use crate::core::traits::Instrument;
use crate::core::utils::ContractStyle;
use crate::core::vols::VolSurface;
use crate::equity::utils::{Engine, LongShort, Payoff};
use crate::equity::vanilla_option::{EquityOptionBase, VanillaPayoff};
fn test_option_with(payoff: Box<dyn Payoff>, curve: YieldCurve) -> EquityOption {
let valuation_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let base = EquityOptionBase {
symbol: "TEST".to_string(),
currency: None,
exchange: None,
name: None,
cusip: None,
isin: None,
settlement_type: None,
strike_price: 100.0,
maturity_date: NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
futures_settlement: None,
multiplier: 1.0,
current_price: Quote::new(0.0),
entry_price: 0.0,
long_short: LongShort::LONG,
};
let market = crate::equity::vanilla_option::EquityMarketData {
valuation_date,
spot: Quote::new(100.0),
dividend_yield: 0.0,
borrow_cost: 0.0,
cash_dividends: vec![],
vol_surface: std::sync::Arc::new(
VolSurface::flat(0.3, valuation_date, DayCountConvention::Act365).unwrap(),
),
discount_curve: std::sync::Arc::new(curve),
};
EquityOption {
base,
market,
payoff,
engine: crate::equity::utils::PricingEngine::BlackScholes,
model: crate::equity::utils::Model::Gbm,
}
}
fn test_option(put_or_call: PutOrCall, curve: YieldCurve) -> EquityOption {
test_option_with(
Box::new(VanillaPayoff { put_or_call, exercise_style: ContractStyle::European }),
curve,
)
}
fn binary_option_of(
put_or_call: PutOrCall,
binary_type: BinaryType,
cash: f64,
) -> EquityOption {
test_option_with(
Box::new(BinaryPayoff {
put_or_call,
exercise_style: ContractStyle::European,
binary_type,
cash,
}),
flat_5pct(),
)
}
fn binary_option(put_or_call: PutOrCall) -> EquityOption {
binary_option_of(put_or_call, BinaryType::CashOrNothing, 1.0)
}
fn flat_5pct() -> YieldCurve {
YieldCurve::flat(
0.05,
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
DayCountConvention::Act365,
Compounding::Continuous,
)
.unwrap()
}
#[test]
fn golden_call_npv_and_greeks() {
let option = test_option(PutOrCall::Call, flat_5pct());
assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
assert_approx_eq!(option.delta(), 0.6242517279, 1e-8);
assert_approx_eq!(option.gamma(), 0.0126477644, 1e-8);
assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
assert_approx_eq!(option.theta(), -8.1011898970, 1e-8);
assert_approx_eq!(option.rho(), 48.1939180046, 1e-8);
}
#[test]
fn golden_put_npv_and_greeks() {
let option = test_option(PutOrCall::Put, flat_5pct());
assert_approx_eq!(option.npv(), 9.3541972361, 1e-8);
assert_approx_eq!(option.delta(), -0.3757482721, 1e-8);
assert_approx_eq!(option.gamma(), 0.0126477644, 1e-8);
assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
assert_approx_eq!(option.theta(), -3.3450427745, 1e-8);
assert_approx_eq!(option.rho(), -46.9290244455, 1e-8);
}
#[test]
fn vanilla_vanna_and_charm_match_mixed_price_bumps() {
let option = test_option(PutOrCall::Call, flat_5pct());
let s = option.effective_spot();
let k = option.base.strike_price;
let r = option.risk_free_rate();
let q = option.carry_yield();
let sigma = option.volatility();
let t = option.time_to_maturity();
let hs = 1e-3;
let hv = 1e-5;
let ht = 1e-5;
let pc = PutOrCall::Call;
let vanna_bump = (bs_price(s + hs, k, r, q, sigma + hv, t, pc)
- bs_price(s - hs, k, r, q, sigma + hv, t, pc)
- bs_price(s + hs, k, r, q, sigma - hv, t, pc)
+ bs_price(s - hs, k, r, q, sigma - hv, t, pc))
/ (4.0 * hs * hv);
let charm_bump = -(bs_price(s + hs, k, r, q, sigma, t + ht, pc)
- bs_price(s - hs, k, r, q, sigma, t + ht, pc)
- bs_price(s + hs, k, r, q, sigma, t - ht, pc)
+ bs_price(s - hs, k, r, q, sigma, t - ht, pc))
/ (4.0 * hs * ht);
assert_approx_eq!(option.vanna(), vanna_bump, 1e-6);
assert_approx_eq!(option.charm(), charm_bump, 1e-6);
}
#[test]
fn vanilla_gamma_p_and_zomma_match_definitions() {
let option = test_option(PutOrCall::Call, flat_5pct());
let s = option.market.spot.value();
assert_approx_eq!(option.gamma_p(), s * option.gamma() / option.delta(), 1e-12);
let h = 1e-5;
let k = option.base.strike_price;
let r = option.risk_free_rate();
let q = option.carry_yield();
let sigma = option.volatility();
let t = option.time_to_maturity();
let gamma_at_vol = |vol: f64| {
let d1 = ((s / k).ln() + (r - q + 0.5 * vol * vol) * t) / (vol * t.sqrt());
(-q * t).exp() * norm_pdf(d1) / (s * vol * t.sqrt())
};
let zomma_bump = (gamma_at_vol(sigma + h) - gamma_at_vol(sigma - h)) / (2.0 * h);
assert_approx_eq!(option.zomma(), zomma_bump, 1e-6);
}
#[test]
fn vanilla_volga_matches_vega_bump_and_closed_form() {
let option = test_option(PutOrCall::Call, flat_5pct());
let s = option.effective_spot();
let k = option.base.strike_price;
let r = option.risk_free_rate();
let q = option.carry_yield();
let sigma = option.volatility();
let t = option.time_to_maturity();
assert_approx_eq!(option.volga(), bs_volga(s, k, r, q, sigma, t), 1e-12);
let h = 1e-5;
let vega_at_vol = |vol: f64| bs_vega(s, k, r, q, vol, t);
let volga_bump = (vega_at_vol(sigma + h) - vega_at_vol(sigma - h)) / (2.0 * h);
assert_approx_eq!(option.volga(), volga_bump, 1e-6);
let put = test_option(PutOrCall::Put, flat_5pct());
assert_approx_eq!(option.volga(), put.volga(), 1e-12);
}
#[test]
fn volga_agrees_across_engines() {
let analytic = test_option(PutOrCall::Call, flat_5pct()).volga();
let mut fd = test_option(PutOrCall::Call, flat_5pct());
fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
assert!((fd.volga() - analytic).abs() < 0.5, "fd {} vs analytic {analytic}", fd.volga());
let mut mc = test_option(PutOrCall::Call, flat_5pct());
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().paths = 200_000;
assert!((mc.volga() - analytic).abs() < 1.5, "mc {} vs analytic {analytic}", mc.volga());
}
#[test]
fn put_call_parity() {
let call = test_option(PutOrCall::Call, flat_5pct());
let put = test_option(PutOrCall::Put, flat_5pct());
let s = call.market.spot.value();
let k_df = call.base.strike_price * call.maturity_discount_factor();
assert_approx_eq!(call.npv() - put.npv(), s - k_df, 1e-10);
}
#[test]
fn golden_binary_call_npv_and_greeks() {
let option = binary_option(PutOrCall::Call);
assert_approx_eq!(option.npv(), 0.4819391800, 1e-8);
assert_approx_eq!(option.delta(), 0.0126477644, 1e-8);
assert_approx_eq!(option.gamma(), -0.0001335042, 1e-8);
assert_approx_eq!(option.vega(), -0.4005125405, 1e-8);
assert_approx_eq!(option.theta(), 0.0209350179, 1e-8);
assert_approx_eq!(option.rho(), 0.7828372637, 1e-8);
}
#[test]
fn golden_binary_put_npv_and_greeks() {
let option = binary_option(PutOrCall::Put);
assert_approx_eq!(option.npv(), 0.4692902445, 1e-8);
assert_approx_eq!(option.delta(), -0.0126477644, 1e-8);
assert_approx_eq!(option.gamma(), 0.0001335042, 1e-8);
assert_approx_eq!(option.vega(), 0.4005125405, 1e-8);
assert_approx_eq!(option.theta(), 0.0266264533, 1e-8);
assert_approx_eq!(option.rho(), -1.7340666882, 1e-8);
}
#[test]
fn binary_call_plus_put_equals_discount_factor() {
let call = binary_option(PutOrCall::Call);
let put = binary_option(PutOrCall::Put);
assert_approx_eq!(call.npv() + put.npv(), call.maturity_discount_factor(), 1e-12);
}
#[test]
fn cash_amount_scales_cash_or_nothing_linearly() {
let unit = binary_option(PutOrCall::Call);
let sized = binary_option_of(PutOrCall::Call, BinaryType::CashOrNothing, 1000.0);
assert_approx_eq!(sized.npv(), 1000.0 * unit.npv(), 1e-9);
assert_approx_eq!(sized.delta(), 1000.0 * unit.delta(), 1e-9);
assert_approx_eq!(sized.vega(), 1000.0 * unit.vega(), 1e-9);
}
#[test]
fn golden_asset_or_nothing_call_npv_and_greeks() {
let mut option = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
option.market.dividend_yield = 0.02;
assert_approx_eq!(option.npv(), 58.6851146135, 1e-8);
assert_approx_eq!(option.delta(), 1.8502230631, 1e-8);
assert_approx_eq!(option.gamma(), 0.0021056199, 1e-8);
assert_approx_eq!(option.vega(), 6.3168595850, 1e-8);
assert_approx_eq!(option.theta(), -3.5639423965, 1e-8);
assert_approx_eq!(option.rho(), 126.3371917001, 1e-8);
}
#[test]
fn golden_asset_or_nothing_put_npv_and_greeks() {
let mut option = binary_option_of(PutOrCall::Put, BinaryType::AssetOrNothing, 0.0);
option.market.dividend_yield = 0.02;
assert_approx_eq!(option.npv(), 39.3347527172, 1e-8);
assert_approx_eq!(option.delta(), -0.8700243898, 1e-8);
assert_approx_eq!(option.gamma(), -0.0021056199, 1e-8);
assert_approx_eq!(option.vega(), -6.3168595850, 1e-8);
assert_approx_eq!(option.theta(), 5.5243397431, 1e-8);
assert_approx_eq!(option.rho(), -126.3371917001, 1e-8);
}
#[test]
fn asset_call_plus_put_equals_forward_leg() {
let call = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
let put = binary_option_of(PutOrCall::Put, BinaryType::AssetOrNothing, 0.0);
assert_approx_eq!(call.npv() + put.npv(), 100.0, 1e-10);
}
#[test]
fn asset_digital_replicated_by_call_plus_cash_digitals() {
let k = 100.0;
let asset = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
let vanilla = test_option(PutOrCall::Call, flat_5pct());
let cash = binary_option_of(PutOrCall::Call, BinaryType::CashOrNothing, k);
assert_approx_eq!(asset.npv(), vanilla.npv() + cash.npv(), 1e-10);
assert_approx_eq!(asset.delta(), vanilla.delta() + cash.delta(), 1e-10);
assert_approx_eq!(asset.gamma(), vanilla.gamma() + cash.gamma(), 1e-10);
assert_approx_eq!(asset.vega(), vanilla.vega() + cash.vega(), 1e-10);
assert_approx_eq!(asset.theta(), vanilla.theta() + cash.theta(), 1e-10);
assert_approx_eq!(asset.rho(), vanilla.rho() + cash.rho(), 1e-10);
}
#[test]
fn asset_digital_replication_holds_across_engines() {
let k = 100.0;
let priced = |engine: Engine, payoff: Box<dyn Payoff>| {
let mut option = test_option_with(payoff, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
option.npv()
};
let asset_payoff = || -> Box<dyn Payoff> {
Box::new(BinaryPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
binary_type: BinaryType::AssetOrNothing,
cash: 0.0,
})
};
let cash_payoff = || -> Box<dyn Payoff> {
Box::new(BinaryPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
binary_type: BinaryType::CashOrNothing,
cash: k,
})
};
let vanilla_payoff = || -> Box<dyn Payoff> {
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
})
};
for (engine, tol) in [
(Engine::FiniteDifference, 0.01),
(Engine::Binomial, 0.05),
(Engine::MonteCarlo, 0.05),
] {
let asset = priced(engine.clone(), asset_payoff());
let replicated =
priced(engine.clone(), vanilla_payoff()) + priced(engine.clone(), cash_payoff());
assert!(
(asset - replicated).abs() < tol,
"{engine:?}: asset={asset} replicated={replicated}"
);
}
}
#[test]
fn asset_digital_matches_analytic_across_engines() {
let analytic = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0).npv();
for (engine, tol) in [
(Engine::FiniteDifference, 0.05),
(Engine::Binomial, 2.0), (Engine::MonteCarlo, 0.05),
] {
let mut option = binary_option_of(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0);
option.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
let value = option.npv();
assert!(
(value - analytic).abs() < tol,
"{engine:?}: {value} vs analytic {analytic}"
);
}
}
#[test]
fn finite_difference_matches_analytic_vanilla() {
for pc in [PutOrCall::Call, PutOrCall::Put] {
let mut option = test_option(pc, flat_5pct());
let analytic = option.npv();
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let fd = option.npv();
assert!(
(fd - analytic).abs() < 0.01,
"{pc:?}: fd={fd} analytic={analytic}"
);
}
}
#[test]
fn finite_difference_matches_analytic_binary() {
for pc in [PutOrCall::Call, PutOrCall::Put] {
let mut option = binary_option(pc);
let analytic = option.npv();
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let fd = option.npv();
assert!(
(fd - analytic).abs() < 0.002,
"{pc:?}: fd={fd} analytic={analytic}"
);
}
}
#[test]
fn binomial_matches_analytic_vanilla_and_binary() {
for pc in [PutOrCall::Call, PutOrCall::Put] {
let mut vanilla = test_option(pc, flat_5pct());
let analytic = vanilla.npv();
vanilla.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
let tree = vanilla.npv();
assert!((tree - analytic).abs() < 0.02, "vanilla {pc:?}: tree={tree} bs={analytic}");
let mut binary = binary_option(pc);
let analytic = binary.npv();
binary.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
let tree = binary.npv();
assert!((tree - analytic).abs() < 0.02, "binary {pc:?}: tree={tree} bs={analytic}");
}
}
#[test]
fn monte_carlo_matches_analytic_vanilla_and_binary() {
for pc in [PutOrCall::Call, PutOrCall::Put] {
let mut vanilla = test_option(pc, flat_5pct());
let analytic = vanilla.npv();
vanilla.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
let mc = vanilla.npv();
assert!((mc - analytic).abs() < 0.02, "vanilla {pc:?}: mc={mc} bs={analytic}");
let mut binary = binary_option(pc);
let analytic = binary.npv();
binary.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
let mc = binary.npv();
assert!((mc - analytic).abs() < 0.005, "binary {pc:?}: mc={mc} bs={analytic}");
}
}
#[test]
fn monte_carlo_sobol_beats_default_tolerance_and_is_reproducible() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
let first = option.npv();
let second = option.npv();
assert_eq!(first, second, "deterministic sampler must reproduce exactly");
assert!((first - 14.2312547860).abs() < 0.02, "sobol mc = {first}");
}
#[test]
fn monte_carlo_path_wise_starts_at_spot_and_schemes_converge() {
let analytic = test_option(PutOrCall::Call, flat_5pct()).npv();
for scheme in ["exact", "euler", "milstein"] {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().scheme = scheme.parse().unwrap();
option.mc_cfg_mut().time_steps = 252;
option.mc_cfg_mut().paths = 50_000;
let mc = option.npv();
assert!(
(mc - analytic).abs() < 0.35,
"{scheme}: mc={mc} analytic={analytic}"
);
}
}
#[test]
fn monte_carlo_greeks_match_analytic() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
assert!((option.delta() - 0.6242517279).abs() < 0.01, "delta {}", option.delta());
assert!((option.gamma() - 0.0126477644).abs() < 0.003, "gamma {}", option.gamma());
assert!((option.vega() - 37.9432933117).abs() < 1.0, "vega {}", option.vega());
assert!((option.theta() - -8.1011898970).abs() < 0.5, "theta {}", option.theta());
assert!((option.rho() - 48.1939180046).abs() < 0.5, "rho {}", option.rho());
}
#[test]
fn lsmc_american_put_close_to_tree_and_dominates_european() {
let european = test_option(PutOrCall::Put, flat_5pct()).npv();
let mut tree_option = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
tree_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
let tree = tree_option.npv();
let mut lsmc_option = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
lsmc_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
lsmc_option.mc_cfg_mut().paths = 20_000;
let lsmc = lsmc_option.npv();
assert!(lsmc > european, "lsmc {lsmc} must exceed european {european}");
assert!((lsmc - tree).abs() < 0.25, "lsmc={lsmc} tree={tree}");
}
#[test]
fn implied_vol_round_trips_across_strikes_and_vols() {
let (s, r, q) = (100.0, 0.05, 0.02);
for pc in [PutOrCall::Call, PutOrCall::Put] {
for k in [50.0, 80.0, 100.0, 120.0, 200.0] {
for vol in [0.05, 0.2, 0.6, 1.5] {
for t in [0.05, 0.5, 2.0] {
let price = bs_price(s, k, r, q, vol, t, pc);
if price - bs_price(s, k, r, q, 0.0, t, pc) < 1e-10 {
continue;
}
let iv = implied_vol_from_price(s, k, r, q, t, price, pc).unwrap();
assert!(
(iv - vol).abs() < 1e-5,
"{pc:?} K={k} vol={vol} t={t}: recovered {iv}"
);
}
}
}
}
}
#[test]
fn implied_vol_rejects_arbitrage_violating_prices() {
assert!(implied_vol_from_price(100.0, 80.0, 0.05, 0.0, 1.0, 10.0, PutOrCall::Call)
.is_err());
assert!(implied_vol_from_price(100.0, 100.0, 0.05, 0.0, 1.0, 101.0, PutOrCall::Call)
.is_err());
}
fn smile_vol(k: f64, base: f64) -> f64 {
base - 0.001 * (k - 100.0)
}
fn quoted_option(
k: f64,
maturity: NaiveDate,
market_price: f64,
) -> Box<EquityOption> {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.base.strike_price = k;
option.base.maturity_date = maturity;
option.base.current_price = Quote::new(market_price);
Box::new(option)
}
fn build_surface_from_quotes() -> crate::core::vols::VolSurface {
let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let maturities = [
(NaiveDate::from_ymd_opt(2026, 7, 2).unwrap(), 0.23),
(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(), 0.25),
];
let mut quotes = Vec::new();
for (maturity, base) in maturities {
let t = (maturity - valuation).num_days() as f64 / 365.0;
for i in 0..13 {
let k = 70.0 + 5.0 * i as f64;
let vol = smile_vol(k, base);
let price = bs_price(100.0, k, 0.05, 0.0, vol, t, PutOrCall::Call);
quotes.push(quoted_option(k, maturity, price));
}
}
crate::equity::vol_surface::build_implied_vol_surface("es).unwrap()
}
#[test]
fn implied_surface_recovers_input_vols() {
let surface = build_surface_from_quotes();
for (t, base) in [(182.0 / 365.0, 0.23), (1.0, 0.25)] {
for k in [70.0, 85.0, 100.0, 115.0, 130.0] {
let vol = surface.vol(k, 100.0, t);
assert!(
(vol - smile_vol(k, base)).abs() < 1e-7,
"K={k} t={t}: {vol} vs {}",
smile_vol(k, base)
);
}
}
}
fn local_vol_option(surface: crate::core::vols::VolSurface, k: f64) -> EquityOption {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.base.strike_price = k;
option.market.vol_surface = std::sync::Arc::new(surface);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.model = crate::equity::utils::Model::LocalVol;
option.mc_cfg_mut().paths = 20_000;
option
}
#[test]
fn local_vol_prices_back_vanilla_from_calibrated_surface() {
let surface = build_surface_from_quotes();
for k in [90.0, 100.0, 110.0] {
let expected = bs_price(100.0, k, 0.05, 0.0, smile_vol(k, 0.25), 1.0, PutOrCall::Call);
let lv_price = local_vol_option(surface.clone(), k).npv();
assert!(
(lv_price - expected).abs() < 0.3,
"K={k}: local vol {lv_price} vs BS {expected}"
);
}
}
#[test]
fn local_vol_flat_surface_reproduces_black_scholes() {
let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let surface =
crate::core::vols::VolSurface::flat(0.3, valuation, DayCountConvention::Act365)
.unwrap();
let expected = 14.2312547860; let lv_price = local_vol_option(surface, 100.0).npv();
assert!((lv_price - expected).abs() < 0.3, "{lv_price} vs {expected}");
}
#[test]
fn local_vol_term_structure_reproduces_terminal_implied() {
let valuation = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let surface = crate::core::vols::VolSurface::from_strike_smiles(
&[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0)],
&[vec![(100.0, 0.20)], vec![(100.0, 0.25)]],
valuation,
DayCountConvention::Act365,
)
.unwrap();
let expected = bs_price(100.0, 100.0, 0.05, 0.0, 0.25, 1.0, PutOrCall::Call);
let lv_price = local_vol_option(surface, 100.0).npv();
assert!((lv_price - expected).abs() < 0.3, "{lv_price} vs {expected}");
}
fn barrier_option(
put_or_call: PutOrCall,
direction: crate::equity::barrier::BarrierDirection,
knock: crate::equity::barrier::KnockType,
barrier: f64,
) -> EquityOption {
let mut option = test_option_with(
Box::new(BarrierPayoff {
put_or_call,
exercise_style: ContractStyle::European,
direction,
knock,
barrier,
barrier2: None,
rebate: 0.0,
rebate_at_hit: false,
}),
flat_5pct(),
);
option.market.dividend_yield = 0.02; option
}
#[test]
fn golden_barrier_prices_all_eight_types() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let cases = [
(Down, In, PutOrCall::Call, 90.0, 4.5095197744),
(Down, Out, PutOrCall::Call, 90.0, 8.5107614943),
(Down, In, PutOrCall::Put, 90.0, 10.0710164338),
(Down, Out, PutOrCall::Put, 90.0, 0.0523399543),
(Up, In, PutOrCall::Call, 120.0, 12.5974705742),
(Up, Out, PutOrCall::Call, 120.0, 0.4228106946),
(Up, In, PutOrCall::Put, 120.0, 1.4297711810),
(Up, Out, PutOrCall::Put, 120.0, 8.6935852071),
];
for (direction, knock, pc, h, expected) in cases {
let option = barrier_option(pc, direction, knock, h);
assert_approx_eq!(option.npv(), expected, 1e-8);
}
}
#[test]
fn barrier_greeks_satisfy_in_out_parity() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let ki = barrier_option(PutOrCall::Call, Down, In, 90.0);
let ko = barrier_option(PutOrCall::Call, Down, Out, 90.0);
let mut vanilla = test_option(PutOrCall::Call, flat_5pct());
vanilla.market.dividend_yield = 0.02;
assert_approx_eq!(ki.npv() + ko.npv(), vanilla.npv(), 1e-10);
assert_approx_eq!(ki.delta() + ko.delta(), vanilla.delta(), 1e-5);
assert_approx_eq!(ki.gamma() + ko.gamma(), vanilla.gamma(), 1e-4);
assert_approx_eq!(ki.vega() + ko.vega(), vanilla.vega(), 1e-4);
assert_approx_eq!(ki.theta() + ko.theta(), vanilla.theta(), 1e-4);
assert_approx_eq!(ki.rho() + ko.rho(), vanilla.rho(), 1e-4);
}
#[test]
fn monte_carlo_barrier_matches_analytic() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let cases = [
(Down, Out, PutOrCall::Call, 90.0),
(Down, In, PutOrCall::Put, 90.0),
(Up, Out, PutOrCall::Put, 120.0),
(Up, In, PutOrCall::Call, 110.0),
];
for (direction, knock, pc, h) in cases {
let analytic = barrier_option(pc, direction, knock, h).npv();
let mut option = barrier_option(pc, direction, knock, h);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().paths = 50_000;
let mc = option.npv();
assert!(
(mc - analytic).abs() < 0.3,
"{direction:?} {knock:?} {pc:?} H={h}: mc={mc} analytic={analytic}"
);
}
}
fn asian_option(
put_or_call: PutOrCall,
averaging: crate::equity::asian::AveragingType,
strike_type: crate::equity::asian::AsianStrikeType,
) -> EquityOption {
let mut option = test_option_with(
Box::new(AsianPayoff {
put_or_call,
exercise_style: ContractStyle::European,
averaging,
strike_type,
}),
flat_5pct(),
);
option.market.dividend_yield = 0.02; option
}
#[test]
fn golden_asian_analytic_prices() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
let geo = asian_option(PutOrCall::Call, Geometric, FixedStrike);
assert_approx_eq!(geo.npv(), 6.953600, 1e-5);
let arith = asian_option(PutOrCall::Call, Arithmetic, FixedStrike);
assert_approx_eq!(arith.npv(), 7.409272, 1e-5);
}
#[test]
fn geometric_asian_mc_matches_discrete_closed_form() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
let mut option = asian_option(PutOrCall::Call, Geometric, FixedStrike);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().paths = 50_000;
let mc = option.npv(); let closed = crate::equity::asian::geometric_asian_price(
100.0, 100.0, 0.05, 0.02, 0.3, 1.0, Some(100), PutOrCall::Call,
);
assert!((mc - closed).abs() < 0.15, "mc={mc} closed={closed}");
}
#[test]
fn geometric_average_strike_analytic_matches_monte_carlo() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
let analytic_option = asian_option(PutOrCall::Call, Geometric, FloatingStrike);
let analytic = analytic_option.npv();
let closed = crate::equity::asian::geometric_average_strike_price(
100.0, 0.05, 0.02, 0.3, 1.0, None, PutOrCall::Call,
);
assert_approx_eq!(analytic, closed, 1e-12);
assert!(analytic_option.delta() > 0.0 && analytic_option.vega() > 0.0);
let mut mc_option = asian_option(PutOrCall::Call, Geometric, FloatingStrike);
mc_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc_option.mc_cfg_mut().paths = 50_000;
let mc = mc_option.npv();
let discrete = crate::equity::asian::geometric_average_strike_price(
100.0, 0.05, 0.02, 0.3, 1.0, Some(100), PutOrCall::Call,
);
assert!((mc - discrete).abs() < 0.10, "mc={mc} closed={discrete}");
}
#[test]
fn arithmetic_average_strike_analytic_matches_monte_carlo() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
for pc in [PutOrCall::Call, PutOrCall::Put] {
let analytic_option = asian_option(pc, Arithmetic, FloatingStrike);
let analytic = analytic_option.npv();
let direct = crate::equity::asian::turnbull_wakeman_average_strike_price(
100.0, 0.05, 0.02, 0.3, 1.0, pc,
);
assert_approx_eq!(analytic, direct, 1e-9);
assert!(analytic_option.vega() > 0.0, "{pc:?}");
let mut mc_option = asian_option(pc, Arithmetic, FloatingStrike);
mc_option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc_option.mc_cfg_mut().paths = 50_000;
let mc = mc_option.npv();
assert!((mc - analytic).abs() < 0.2, "{pc:?}: mc={mc} analytic={analytic}");
}
}
#[test]
fn double_barriers_and_rebates_price_across_engines() {
use crate::equity::barrier::KnockType;
use crate::equity::builder::EquityOptionBuilder;
use chrono::NaiveDate;
let build = || {
EquityOptionBuilder::new()
.symbol("DKO")
.spot(100.0)
.strike(100.0)
.flat_vol(0.3)
.flat_rate(0.05)
.dividend_yield(0.02)
.valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
};
let dko = build().double_barrier(PutOrCall::Call, KnockType::Out, 85.0, 120.0)
.engine(Engine::BlackScholes).build().expect("option must build");
let direct = crate::equity::barrier::double_barrier_price(
100.0, 100.0, 85.0, 120.0, 0.05, 0.02, 0.3, 1.0, KnockType::Out, PutOrCall::Call,
);
assert_approx_eq!(dko.npv(), direct, 1e-9);
let mut mc = build().double_barrier(PutOrCall::Call, KnockType::Out, 85.0, 120.0)
.engine(Engine::MonteCarlo).build().expect("option must build");
mc.mc_cfg_mut().paths = 50_000;
mc.mc_cfg_mut().time_steps = 500;
let mc_px = mc.npv();
assert!(mc_px > dko.npv() - 0.02 && (mc_px - dko.npv()).abs() < 0.30,
"mc {mc_px} vs analytic {}", dko.npv());
let rebate = 5.0;
let plain = build()
.barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
KnockType::Out, 120.0)
.engine(Engine::BlackScholes).build().expect("option must build").npv();
let rebated = build()
.barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
KnockType::Out, 120.0)
.barrier_rebate(rebate, false)
.engine(Engine::BlackScholes).build().expect("option must build");
assert!(rebated.npv() > plain && rebated.npv() < plain + rebate);
let mut mc_rebate = build()
.barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
KnockType::Out, 120.0)
.barrier_rebate(rebate, false)
.engine(Engine::MonteCarlo).build().expect("option must build");
mc_rebate.mc_cfg_mut().paths = 50_000;
mc_rebate.mc_cfg_mut().time_steps = 500;
assert!((mc_rebate.npv() - rebated.npv()).abs() < 0.30,
"mc {} vs analytic {}", mc_rebate.npv(), rebated.npv());
let at_hit = build()
.barrier(PutOrCall::Call, crate::equity::barrier::BarrierDirection::Up,
KnockType::Out, 120.0)
.barrier_rebate(rebate, true)
.engine(Engine::BlackScholes).build().expect("option must build");
assert!(at_hit.npv() > rebated.npv(), "earlier payment is worth more");
}
#[test]
fn lookback_analytic_engine_matches_the_closed_forms_and_mc_converges() {
use crate::equity::vanilla_option::{LookbackPayoff, LookbackType};
let lookback = |lookback_type, pc| {
let mut option = test_option_with(
Box::new(LookbackPayoff {
put_or_call: pc,
exercise_style: ContractStyle::European,
lookback_type,
}),
flat_5pct(),
);
option.market.dividend_yield = 0.02;
option
};
let float_call = lookback(LookbackType::FloatingStrike, PutOrCall::Call);
let direct = crate::equity::lookback::floating_strike_lookback_price(
100.0, 100.0, 0.05, 0.02, 0.3, 1.0, PutOrCall::Call,
);
assert_approx_eq!(float_call.npv(), direct, 1e-9);
assert!(float_call.delta() > 0.1 && float_call.delta() < 0.4, "{}", float_call.delta());
assert!(float_call.vega() > 30.0, "{}", float_call.vega());
let mut coarse = lookback(LookbackType::FloatingStrike, PutOrCall::Call);
coarse.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
coarse.mc_cfg_mut().paths = 40_000;
coarse.mc_cfg_mut().time_steps = 50;
let coarse_px = coarse.npv();
let mut fine = lookback(LookbackType::FloatingStrike, PutOrCall::Call);
fine.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
fine.mc_cfg_mut().paths = 40_000;
fine.mc_cfg_mut().time_steps = 400;
let fine_px = fine.npv();
assert!(coarse_px < direct && fine_px < direct, "{coarse_px} {fine_px} vs {direct}");
assert!(direct - fine_px < direct - coarse_px, "finer grid must close the gap");
assert!(direct - fine_px < 1.5, "fine-grid gap {}", direct - fine_px);
let fixed_put = lookback(LookbackType::FixedStrike, PutOrCall::Put);
let direct_put = crate::equity::lookback::fixed_strike_lookback_price(
100.0, 100.0, 100.0, 0.05, 0.02, 0.3, 1.0, PutOrCall::Put,
);
assert_approx_eq!(fixed_put.npv(), direct_put, 1e-9);
let mut mc_put = lookback(LookbackType::FixedStrike, PutOrCall::Put);
mc_put.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc_put.mc_cfg_mut().paths = 40_000;
mc_put.mc_cfg_mut().time_steps = 400;
let mc_px = mc_put.npv();
assert!(mc_px < direct_put && direct_put - mc_px < 1.5, "{mc_px} vs {direct_put}");
}
#[test]
fn phoenix_certificate_prices_and_orders_sensibly() {
use crate::equity::builder::EquityOptionBuilder;
use chrono::NaiveDate;
let build = |coupon_barrier: f64, memory: bool| {
EquityOptionBuilder::new()
.symbol("PHX")
.spot(100.0)
.strike(100.0)
.flat_vol(0.25)
.flat_rate(0.03)
.valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.maturity_date(NaiveDate::from_ymd_opt(2028, 1, 1).unwrap())
.phoenix(110.0, coupon_barrier, 60.0, 2.0, 8, 100.0, memory)
.engine(Engine::MonteCarlo)
.paths(30_000)
.build().expect("option must build")
};
let plain = build(80.0, false).npv();
let with_memory = build(80.0, true).npv();
let lower_barrier = build(60.0, false).npv();
assert!(with_memory > plain, "memory {with_memory} vs {plain}");
assert!(lower_barrier > plain, "cb60 {lower_barrier} vs cb80 {plain}");
assert!(plain > 80.0 && with_memory < 100.0 + 16.0 + 1.0, "{plain} {with_memory}");
}
#[test]
fn arithmetic_asian_cv_mc_close_to_turnbull_wakeman() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
let analytic = asian_option(PutOrCall::Call, Arithmetic, FixedStrike).npv();
let mut option = asian_option(PutOrCall::Call, Arithmetic, FixedStrike);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().paths = 50_000;
let mc = option.npv(); assert!((mc - analytic).abs() < 0.08, "cv-mc={mc} tw={analytic}");
}
#[test]
fn arithmetic_average_dominates_geometric_on_same_paths() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
let price_mc = |averaging| {
let mut option = asian_option(PutOrCall::Call, averaging, FixedStrike);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().paths = 20_000;
option.mc_cfg_mut().scheme = crate::equity::montecarlo::DiscretizationScheme::Euler;
option.mc_cfg_mut().time_steps = 100;
option.npv()
};
assert!(price_mc(Arithmetic) > price_mc(Geometric), "AM-GM inequality");
}
#[test]
fn arithmetic_floating_strike_asian_prices_on_mc() {
use crate::equity::asian::{AsianStrikeType::*, AveragingType::*};
let mut option = asian_option(PutOrCall::Call, Arithmetic, FloatingStrike);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().paths = 20_000;
let price = option.npv();
let vanilla = test_option(PutOrCall::Call, flat_5pct()).npv();
assert!(price > 0.0 && price < vanilla, "{price}");
}
#[test]
fn fd_grid_greeks_match_analytic_for_european() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
assert!((option.delta() - 0.6242517279).abs() < 1e-3, "delta {}", option.delta());
assert!((option.gamma() - 0.0126477644).abs() < 1e-4, "gamma {}", option.gamma());
assert!((option.theta() - -8.1011898970).abs() < 0.03, "theta {}", option.theta());
assert!((option.vega() - 37.9432933117).abs() < 0.05, "vega {}", option.vega());
assert!((option.rho() - 48.1939180046).abs() < 0.05, "rho {}", option.rho());
}
#[test]
fn fd_american_put_greeks_differ_from_european_correctly() {
let mut american = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
american.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let european_delta = -0.3757482721; assert!(
american.delta() < european_delta,
"american delta {} vs european {european_delta}",
american.delta()
);
assert!(american.npv() > test_option(PutOrCall::Put, flat_5pct()).npv());
}
#[test]
fn fd_brennan_schwartz_american_matches_tree() {
let mut fd = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let mut tree = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
tree.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
assert!((fd.npv() - tree.npv()).abs() < 0.02, "fd={} tree={}", fd.npv(), tree.npv());
}
#[test]
fn fd_barrier_matches_reiner_rubinstein() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
for (direction, knock, pc, h) in [
(Down, Out, PutOrCall::Call, 90.0),
(Down, In, PutOrCall::Call, 90.0),
(Up, Out, PutOrCall::Put, 120.0),
(Up, In, PutOrCall::Put, 120.0),
] {
let analytic = barrier_option(pc, direction, knock, h).npv();
let mut option = barrier_option(pc, direction, knock, h);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let fd = option.npv();
assert!(
(fd - analytic).abs() < 0.02,
"{direction:?} {knock:?} {pc:?} H={h}: fd={fd} analytic={analytic}"
);
}
}
#[test]
fn fd_barrier_in_out_parity_on_grid() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let mut ki = barrier_option(PutOrCall::Call, Down, In, 90.0);
let mut ko = barrier_option(PutOrCall::Call, Down, Out, 90.0);
ki.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
ko.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let mut vanilla = test_option(PutOrCall::Call, flat_5pct());
vanilla.market.dividend_yield = 0.02;
vanilla.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
assert!((ki.npv() + ko.npv() - vanilla.npv()).abs() < 1e-9);
assert!((ki.delta() + ko.delta() - vanilla.delta()).abs() < 1e-9);
}
#[test]
fn fd_local_vol_flat_surface_matches_black_scholes() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
option.model = crate::equity::utils::Model::LocalVol;
assert_approx_eq!(option.npv(), 14.2312547860, 5e-3);
}
#[test]
fn fd_grid_is_configurable() {
let mut coarse = test_option(PutOrCall::Call, flat_5pct());
coarse.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
coarse.fd_cfg_mut().spot_steps = 100;
coarse.fd_cfg_mut().time_steps = 50;
assert!((coarse.npv() - 14.2312547860).abs() < 0.02, "{}", coarse.npv());
}
#[test]
fn qmc_path_wise_prices_accurately() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().time_steps = 64;
option.mc_cfg_mut().paths = 20_000;
let qmc = option.npv();
assert!((qmc - 14.2312547860).abs() < 0.05, "qmc path-wise {qmc}");
}
#[test]
fn mc_stats_reports_consistent_standard_error() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().sampler = crate::equity::montecarlo::Sampler::PseudoRandom;
let stats = crate::equity::montecarlo::npv_with_stats(&option);
assert!(stats.std_err > 0.0 && stats.std_err < 1.0);
assert!(stats.paths == 100_000 && stats.steps == 1);
assert!(
(stats.pv - 14.2312547860).abs() < 5.0 * stats.std_err,
"pv={} stderr={}",
stats.pv,
stats.std_err
);
}
#[test]
fn parallel_paths_are_bit_reproducible() {
for sampler in
[crate::equity::montecarlo::Sampler::Sobol, crate::equity::montecarlo::Sampler::PseudoRandom]
{
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().sampler = sampler;
option.mc_cfg_mut().time_steps = 32;
option.mc_cfg_mut().paths = 30_000;
assert_eq!(option.npv(), option.npv());
}
}
fn heston_option(payoff: Box<dyn Payoff>) -> EquityOption {
let mut option = test_option_with(payoff, flat_5pct());
option.market.dividend_yield = 0.02;
option.model = crate::equity::utils::Model::Heston(crate::equity::heston::HestonParams {
v0: 0.09,
kappa: 2.0,
theta: 0.09,
vol_of_vol: 0.4,
rho: -0.7,
});
option
}
fn heston_vanilla(pc: PutOrCall) -> EquityOption {
heston_option(Box::new(VanillaPayoff {
put_or_call: pc,
exercise_style: ContractStyle::European,
}))
}
#[test]
fn heston_mc_matches_semi_analytic() {
for pc in [PutOrCall::Call, PutOrCall::Put] {
let analytic = heston_vanilla(pc).npv();
let mut mc = heston_vanilla(pc);
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().paths = 50_000;
let mc_price = mc.npv();
assert!(
(mc_price - analytic).abs() < 0.15,
"{pc:?}: mc={mc_price} analytic={analytic}"
);
}
}
#[test]
fn heston_binary_mc_matches_semi_analytic() {
let payoff = || -> Box<dyn Payoff> {
Box::new(BinaryPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
binary_type: BinaryType::CashOrNothing,
cash: 1.0,
})
};
let analytic = heston_option(payoff()).npv();
let mut mc = heston_option(payoff());
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().paths = 50_000;
assert!((mc.npv() - analytic).abs() < 0.01, "mc={} analytic={analytic}", mc.npv());
}
#[test]
fn heston_greeks_are_consistent() {
let call = heston_vanilla(PutOrCall::Call);
let put = heston_vanilla(PutOrCall::Put);
let dfq = (-0.02_f64).exp();
assert!((call.delta() - put.delta() - dfq).abs() < 1e-4);
assert!((call.gamma() - put.gamma()).abs() < 1e-6);
assert!((call.vega() - put.vega()).abs() < 1e-4);
assert!(call.vega() > 0.0);
}
#[test]
fn heston_barrier_and_asian_price_on_mc() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let vanilla = {
let mut o = heston_vanilla(PutOrCall::Call);
o.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
o.mc_cfg_mut().paths = 20_000;
o.npv()
};
let mut ko = heston_option(Box::new(BarrierPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
direction: Down,
knock: Out,
barrier: 90.0,
barrier2: None,
rebate: 0.0,
rebate_at_hit: false,
}));
ko.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
ko.mc_cfg_mut().paths = 20_000;
let ko_price = ko.npv();
assert!(ko_price > 0.0 && ko_price < vanilla, "ko={ko_price} vanilla={vanilla}");
let mut asian = heston_option(Box::new(AsianPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
averaging: crate::equity::asian::AveragingType::Arithmetic,
strike_type: crate::equity::asian::AsianStrikeType::FixedStrike,
}));
asian.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
asian.mc_cfg_mut().paths = 20_000;
let asian_price = asian.npv();
assert!(asian_price > 0.0 && asian_price < vanilla);
}
fn heston_american_put() -> Box<dyn Payoff> {
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
})
}
#[test]
fn heston_american_put_degenerates_to_the_lattice_price() {
let mut lattice = test_option_with(heston_american_put(), flat_5pct());
lattice.market.dividend_yield = 0.02;
lattice.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
let reference = lattice.npv();
let mut mc = heston_option(heston_american_put());
mc.model = crate::equity::utils::Model::Heston(crate::equity::heston::HestonParams {
v0: 0.09,
kappa: 1.0,
theta: 0.09,
vol_of_vol: 1e-3,
rho: 0.0,
});
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().paths = 50_000;
let lsmc = mc.npv();
assert!((lsmc - reference).abs() < 0.2, "lsmc={lsmc} lattice={reference}");
}
#[test]
fn heston_american_put_carries_an_early_exercise_premium() {
let european = heston_vanilla(PutOrCall::Put).npv();
let mut american = heston_option(heston_american_put());
american.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
american.mc_cfg_mut().paths = 50_000;
let am = american.npv();
assert!(am > european + 0.05, "american={am} european={european}");
assert!(am - european < 3.0, "american={am} european={european}");
}
#[test]
fn borrow_cost_is_equivalent_to_extra_dividend_yield() {
for engine in [Engine::BlackScholes, Engine::FiniteDifference, Engine::MonteCarlo] {
let mut with_borrow = test_option(PutOrCall::Call, flat_5pct());
with_borrow.market.dividend_yield = 0.01;
with_borrow.market.borrow_cost = 0.03;
with_borrow.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
let mut with_yield = test_option(PutOrCall::Call, flat_5pct());
with_yield.market.dividend_yield = 0.04;
with_yield.engine = crate::equity::utils::PricingEngine::from_kind(engine.clone());
assert!(
(with_borrow.npv() - with_yield.npv()).abs() < 1e-12,
"{engine:?}: borrow {} vs yield {}",
with_borrow.npv(),
with_yield.npv()
);
}
}
fn dividend_paying_option(pc: PutOrCall) -> EquityOption {
let mut option = test_option(pc, flat_5pct());
option.market.cash_dividends =
vec![(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), 3.0)];
option
}
#[test]
fn cash_dividend_prices_as_escrowed_spot_analytically() {
let option = dividend_paying_option(PutOrCall::Call);
let t_div = (NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
- NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.num_days() as f64
/ 365.0;
let s_eff = 100.0 - 3.0 * (-0.05 * t_div).exp();
assert!((option.effective_spot() - s_eff).abs() < 1e-10);
let expected = bs_price(s_eff, 100.0, 0.05, 0.0, 0.3, 1.0, PutOrCall::Call);
assert_approx_eq!(option.npv(), expected, 1e-10);
}
#[test]
fn put_call_parity_with_dividends_and_borrow() {
let mut call = dividend_paying_option(PutOrCall::Call);
let mut put = dividend_paying_option(PutOrCall::Put);
call.market.borrow_cost = 0.02;
put.market.borrow_cost = 0.02;
let parity = call.effective_spot() * (-call.carry_yield()).exp()
- 100.0 * (-0.05_f64).exp();
assert_approx_eq!(call.npv() - put.npv(), parity, 1e-10);
}
#[test]
fn mc_dividend_jumps_close_to_escrowed_analytic() {
let analytic = dividend_paying_option(PutOrCall::Call).npv();
let mut mc = dividend_paying_option(PutOrCall::Call);
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().time_steps = 100;
mc.mc_cfg_mut().paths = 50_000;
assert!((mc.npv() - analytic).abs() < 0.3, "mc={} analytic={analytic}", mc.npv());
}
#[test]
fn fd_dividend_jump_condition_consistent_with_mc_jump_model() {
let mut fd = dividend_paying_option(PutOrCall::Call);
fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let mut mc = dividend_paying_option(PutOrCall::Call);
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().time_steps = 100;
mc.mc_cfg_mut().paths = 50_000;
assert!((fd.npv() - mc.npv()).abs() < 0.1, "fd={} mc={}", fd.npv(), mc.npv());
let escrowed = dividend_paying_option(PutOrCall::Call).npv();
assert!((fd.npv() - escrowed).abs() < 0.3, "fd={} escrowed={escrowed}", fd.npv());
}
#[test]
fn forward_price_reflects_borrow_and_cash_dividends() {
let mut option = dividend_paying_option(PutOrCall::Call);
option.market.borrow_cost = 0.02;
let expected = option.effective_spot() * ((0.05 - 0.02) * 1.0_f64).exp();
assert_approx_eq!(option.forward_price(), expected, 1e-10);
}
#[test]
fn cash_dividend_with_carry_discounts_at_net_carry() {
let carry = 0.03;
let mut option = dividend_paying_option(PutOrCall::Call);
option.market.borrow_cost = carry;
let (r, s, d, t) = (0.05, 100.0, 3.0, 1.0);
let t_div = (NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()
- NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.num_days() as f64
/ 365.0;
let s_eff = s - d * (-(r - carry) * t_div).exp();
assert_approx_eq!(option.effective_spot(), s_eff, 1e-10);
let jump_forward = s_eff * ((r - carry) * t).exp();
assert_approx_eq!(option.forward_price(), jump_forward, 1e-10);
}
#[test]
fn net_carry_discounting_flows_through_to_price_and_stays_near_jump_engines() {
let carry = 0.03;
let mut analytic = dividend_paying_option(PutOrCall::Call);
analytic.market.borrow_cost = carry;
let a = analytic.npv();
let expected =
bs_price(analytic.effective_spot(), 100.0, 0.05, carry, 0.3, 1.0, PutOrCall::Call);
assert_approx_eq!(a, expected, 1e-10);
let mut fd = dividend_paying_option(PutOrCall::Call);
fd.market.borrow_cost = carry;
fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
assert!((a - fd.npv()).abs() < 0.2, "analytic {a} vs fd {}", fd.npv());
}
fn futures_option(
pc: PutOrCall,
settlement: crate::equity::black76::FuturesSettlement,
) -> EquityOption {
crate::equity::builder::EquityOptionBuilder::new()
.symbol("FUT")
.spot(100.0) .strike(100.0)
.flat_vol(0.30)
.flat_rate(0.05)
.valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
.vanilla(pc)
.on_future(settlement)
.engine(Engine::BlackScholes)
.build().expect("option must build")
}
#[test]
fn black76_option_api_matches_closed_form() {
use crate::equity::black76::FuturesSettlement::*;
for (settlement, gold) in [(Discounted, 11.34202064), (Margined, 11.92353847)] {
let option = futures_option(PutOrCall::Call, settlement);
assert_approx_eq!(option.npv(), gold, 1e-7);
}
let call = futures_option(PutOrCall::Call, Discounted);
assert_approx_eq!(call.delta(), 0.53232482, 1e-7);
assert_approx_eq!(call.rho(), -11.34202064, 1e-6);
}
#[test]
fn margined_futures_option_has_zero_rho_and_exceeds_discounted() {
use crate::equity::black76::FuturesSettlement::*;
let disc = futures_option(PutOrCall::Call, Discounted).npv();
let marg = futures_option(PutOrCall::Call, Margined);
assert_eq!(marg.rho(), 0.0);
assert!(marg.npv() > disc);
assert_approx_eq!(marg.npv(), disc * (0.05_f64).exp(), 1e-9);
}
#[test]
fn black76_on_the_forward_equals_spot_black_scholes() {
let (s, q, r, t): (f64, f64, f64, f64) = (100.0, 0.02, 0.05, 1.0);
let fwd = s * ((r - q) * t).exp();
let futures_opt = crate::equity::builder::EquityOptionBuilder::new()
.spot(fwd)
.strike(100.0)
.flat_vol(0.30)
.flat_rate(r)
.valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
.vanilla(PutOrCall::Call)
.on_future(crate::equity::black76::FuturesSettlement::Discounted)
.build().expect("option must build");
let spot_opt = crate::equity::builder::EquityOptionBuilder::new()
.spot(s)
.strike(100.0)
.flat_vol(0.30)
.flat_rate(r)
.dividend_yield(q)
.valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
.vanilla(PutOrCall::Call)
.build().expect("option must build");
assert_approx_eq!(futures_opt.npv(), spot_opt.npv(), 1e-10);
}
#[test]
fn put_call_parity_on_futures_both_styles() {
use crate::equity::black76::FuturesSettlement::*;
for (settlement, df) in [(Discounted, (-0.05_f64).exp()), (Margined, 1.0)] {
let c = futures_option(PutOrCall::Call, settlement).npv();
let p = futures_option(PutOrCall::Put, settlement).npv();
assert_approx_eq!(c - p, df * (100.0 - 100.0), 1e-10);
}
}
#[test]
#[should_panic(expected = "Options on futures (Black-76) price on the Analytical engine only")]
fn futures_option_rejects_non_analytic_engine() {
let mut option =
futures_option(PutOrCall::Call, crate::equity::black76::FuturesSettlement::Discounted);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.npv();
}
fn forward_start_option(pc: PutOrCall) -> EquityOption {
test_option_with(
Box::new(crate::equity::forward_start_option::ForwardStartPayoff {
put_or_call: pc,
exercise_style: ContractStyle::European,
strike_fraction: 1.0,
start_fraction: 0.5,
}),
flat_5pct(),
)
}
#[test]
fn forward_start_analytic_matches_monte_carlo() {
let analytic = forward_start_option(PutOrCall::Call).npv();
let mut mc = forward_start_option(PutOrCall::Call);
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().paths = 50_000;
assert!((mc.npv() - analytic).abs() < 0.15, "mc={} analytic={analytic}", mc.npv());
}
#[test]
fn forward_start_heston_degenerates_to_black_scholes() {
let bs = forward_start_option(PutOrCall::Call).npv();
let mut heston = forward_start_option(PutOrCall::Call);
heston.engine = crate::equity::utils::PricingEngine::MonteCarlo(
crate::equity::montecarlo::MonteCarloConfig { paths: 50_000, ..Default::default() },
);
heston.model = crate::equity::utils::Model::Heston(crate::equity::heston::HestonParams {
v0: 0.09,
kappa: 1.0,
theta: 0.09,
vol_of_vol: 1e-3,
rho: 0.0,
});
assert!((heston.npv() - bs).abs() < 0.2, "heston={} bs={bs}", heston.npv());
}
fn autocall_note(autocall_barrier: f64, protection_barrier: f64, coupon: f64) -> EquityOption {
let mut option = test_option_with(
Box::new(crate::equity::autocallable::AutocallablePayoff {
exercise_style: ContractStyle::European,
autocall_barrier,
protection_barrier,
coupon,
observations: 4,
observation_times: None,
notional: 100.0,
initial_fixing: 100.0,
coupon_barrier: None,
memory: false,
}),
flat_5pct(),
);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
option.mc_cfg_mut().paths = 20_000;
option
}
#[test]
fn autocall_that_always_calls_pays_coupon_at_first_observation() {
let note = autocall_note(1e-9, 50.0, 5.0);
let stats = crate::equity::montecarlo::npv_with_stats(¬e);
let expected = 105.0 * (-0.05 * 0.25_f64).exp();
assert_approx_eq!(stats.pv, expected, 1e-9);
assert!(stats.std_err < 1e-6, "deterministic payoff: stderr {}", stats.std_err);
}
#[test]
fn autocall_never_called_with_full_protection_is_a_zero_coupon_bond() {
let note = autocall_note(1e12, 1e-9, 5.0);
let expected = 100.0 * (-0.05_f64).exp();
assert_approx_eq!(note.npv(), expected, 1e-9);
}
#[test]
fn autocall_full_downside_is_the_discounted_forward() {
let note = autocall_note(1e12, 1e12, 0.0);
assert!((note.npv() - 100.0).abs() < 0.3, "{}", note.npv());
}
#[test]
fn autocall_value_increases_with_coupon_and_lower_protection() {
let base = autocall_note(105.0, 70.0, 5.0).npv();
assert!(autocall_note(105.0, 70.0, 8.0).npv() > base, "higher coupon");
assert!(autocall_note(105.0, 50.0, 5.0).npv() > base, "lower knock-in barrier");
}
#[test]
fn autocall_prices_under_local_vol() {
let gbm = autocall_note(105.0, 70.0, 5.0).npv();
let mut lv = autocall_note(105.0, 70.0, 5.0);
lv.model = crate::equity::utils::Model::LocalVol;
assert!((lv.npv() - gbm).abs() < 0.5, "lv={} gbm={gbm}", lv.npv());
}
#[test]
#[should_panic(expected = "Autocallables and accumulators price on the MonteCarlo engine only")]
fn analytic_engine_rejects_autocallables() {
let mut note = autocall_note(105.0, 70.0, 5.0);
note.engine = crate::equity::utils::PricingEngine::from_kind(Engine::BlackScholes);
note.npv();
}
#[test]
#[should_panic(expected = "only barriers price on the FD")]
fn fd_engine_rejects_forward_start() {
let mut option = forward_start_option(PutOrCall::Call);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
option.npv();
}
#[test]
fn adjoint_greeks_cover_the_approximate_schemes() {
let analytic = test_option(PutOrCall::Call, flat_5pct());
for scheme in [
crate::equity::montecarlo::DiscretizationScheme::Euler,
crate::equity::montecarlo::DiscretizationScheme::Milstein,
] {
let mut mc = test_option(PutOrCall::Call, flat_5pct());
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().scheme = scheme;
mc.mc_cfg_mut().time_steps = 252;
mc.mc_cfg_mut().paths = 100_000;
let g = crate::equity::montecarlo::aad_greeks(&mc)
.expect("AAD must cover approximate schemes");
assert!(
(g.delta - analytic.delta()).abs() < 0.01,
"{scheme:?}: delta {} vs {}",
g.delta,
analytic.delta()
);
assert!(
(g.vega - analytic.vega()).abs() < 0.6,
"{scheme:?}: vega {} vs {}",
g.vega,
analytic.vega()
);
}
}
#[test]
fn heston_adjoint_greeks_match_the_characteristic_function() {
use crate::equity::heston::heston_price;
let mut mc = heston_vanilla(PutOrCall::Call);
mc.engine = crate::equity::utils::PricingEngine::from_kind(Engine::MonteCarlo);
mc.mc_cfg_mut().paths = 100_000;
let g = crate::equity::montecarlo::aad_greeks(&mc)
.expect("Heston AAD must cover European vanillas");
let delta_cf = crate::equity::heston::native_vanilla_delta(&heston_vanilla(
PutOrCall::Call,
))
.unwrap();
assert!((g.delta - delta_cf).abs() < 0.01, "delta {} vs {delta_cf}", g.delta);
let hp = crate::equity::heston::HestonParams {
v0: 0.09,
kappa: 2.0,
theta: 0.09,
vol_of_vol: 0.4,
rho: -0.7,
};
let (s, k, r, q, t) = (100.0, 100.0, 0.05, 0.02, 1.0);
let h = 0.005;
let vega_cf = (heston_price(s, k, r, q, t, &hp.with_vol_shift(h), PutOrCall::Call)
- heston_price(s, k, r, q, t, &hp.with_vol_shift(-h), PutOrCall::Call))
/ (2.0 * h);
assert!(
(g.vega - vega_cf).abs() < 0.03 * vega_cf.abs() + 0.05,
"vega {} vs {vega_cf}",
g.vega
);
let hr = 1e-4;
let rho_cf = (heston_price(s, k, r + hr, q, t, &hp, PutOrCall::Call)
- heston_price(s, k, r - hr, q, t, &hp, PutOrCall::Call))
/ (2.0 * hr);
assert!(
(g.rho - rho_cf).abs() < 0.03 * rho_cf.abs() + 0.05,
"rho {} vs {rho_cf}",
g.rho
);
}
#[test]
fn fd_engine_prices_heston_on_the_adi_grid() {
let analytic = heston_vanilla(PutOrCall::Call).npv();
let mut option = heston_vanilla(PutOrCall::Call);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
let fd = option.npv();
assert!((fd - analytic).abs() < 0.05, "adi {fd:.4} vs cf {analytic:.4}");
}
#[test]
#[should_panic(expected = "vanilla and binary payoffs")]
fn fd_engine_rejects_heston_barriers() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let mut option = heston_option(Box::new(BarrierPayoff {
put_or_call: PutOrCall::Call,
exercise_style: ContractStyle::European,
direction: Down,
knock: Out,
barrier: 90.0,
barrier2: None,
rebate: 0.0,
rebate_at_hit: false,
}));
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
option.npv();
}
#[test]
#[should_panic(expected = "not supported on the Binomial engine")]
fn tree_engine_rejects_barrier_options() {
use crate::equity::barrier::{BarrierDirection::*, KnockType::*};
let mut option = barrier_option(PutOrCall::Call, Down, Out, 90.0);
option.engine = crate::equity::utils::PricingEngine::from_kind(Engine::Binomial);
option.npv();
}
#[test]
#[should_panic(expected = "Analytical engine cannot price early exercise")]
fn analytic_engine_rejects_american_exercise() {
let option = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
option.npv();
}
#[test]
fn american_put_fd_and_tree_agree_and_dominate_european() {
let european_put = test_option(PutOrCall::Put, flat_5pct()).npv();
let american = |engine: Engine| {
let mut option = test_option_with(
Box::new(VanillaPayoff {
put_or_call: PutOrCall::Put,
exercise_style: ContractStyle::American,
}),
flat_5pct(),
);
option.engine = crate::equity::utils::PricingEngine::from_kind(engine);
option.npv()
};
let fd = american(Engine::FiniteDifference);
let tree = american(Engine::Binomial);
assert!(fd > european_put, "american {fd} must exceed european {european_put}");
assert!(tree > european_put);
assert!((fd - tree).abs() < 0.02, "fd={fd} tree={tree}");
}
#[test]
fn smile_surface_prices_with_interpolated_vol() {
let valuation_date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let surface = crate::core::vols::VolSurface::from_strike_grid(
&[Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
&[90.0, 100.0, 110.0],
&[vec![0.32, 0.30, 0.28], vec![0.36, 0.34, 0.32]],
valuation_date,
DayCountConvention::Act365,
)
.unwrap();
let mut option = test_option(PutOrCall::Call, flat_5pct());
option.market.vol_surface = std::sync::Arc::new(surface);
assert_approx_eq!(option.volatility(), 0.30, 1e-14);
assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
assert_approx_eq!(option.vega(), 37.9432933117, 1e-8);
option.base.strike_price = 95.0;
assert_approx_eq!(option.volatility(), 0.31, 1e-14);
}
#[test]
fn implied_vol_recovers_input_vol() {
let mut option = test_option(PutOrCall::Call, flat_5pct());
let target_price = option.npv(); option.market.vol_surface = std::sync::Arc::new(
crate::core::vols::VolSurface::flat(
0.6,
option.market.valuation_date,
DayCountConvention::Act365,
)
.unwrap(),
);
let iv = option.imp_vol(target_price);
assert_approx_eq!(iv, 0.30, 1e-10);
}
#[test]
fn zero_curve_prices_off_maturity_pillar() {
let curve = YieldCurve::from_zero_rates(
&[Tenor::YearFraction(0.5), Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)],
&[0.02, 0.05, 0.07],
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
DayCountConvention::Act365,
Compounding::Continuous,
InterpolationMethod::LogLinearDf,
)
.unwrap();
let option = test_option(PutOrCall::Call, curve);
assert_approx_eq!(option.npv(), 14.2312547860, 1e-8);
assert_approx_eq!(option.risk_free_rate(), 0.05, 1e-12);
}
}