use ndarray::Array2;
use super::heston::MultiHestonParams;
use super::kernel;
use crate::traits::FloatExt;
#[derive(Clone, Debug)]
pub enum MtPayoff<T: FloatExt> {
Call { asset: usize, strike: T },
Put { asset: usize, strike: T },
DigitalPut2D { strikes: [T; 2] },
BasketCall { weights: Vec<T>, strike: T },
WorstOfPut { strike: T },
}
impl<T: FloatExt> MtPayoff<T> {
pub fn evaluate(&self, st: &[T]) -> T {
match self {
Self::Call { asset, strike } => (st[*asset] - *strike).max(T::zero()),
Self::Put { asset, strike } => (*strike - st[*asset]).max(T::zero()),
Self::DigitalPut2D { strikes } => {
if st[0] <= strikes[0] && st[1] <= strikes[1] {
T::one()
} else {
T::zero()
}
}
Self::BasketCall { weights, strike } => {
let basket = weights
.iter()
.zip(st)
.map(|(&w, &s)| w * s)
.fold(T::zero(), |a, b| a + b);
(basket - *strike).max(T::zero())
}
Self::WorstOfPut { strike } => {
let worst = st.iter().copied().fold(T::infinity(), |a, b| a.min(b));
(*strike - worst).max(T::zero())
}
}
}
}
#[derive(Debug, Clone)]
pub struct MtGreeks<T: FloatExt> {
pub params: MultiHestonParams<T>,
pub h: T,
pub n_paths: usize,
}
#[derive(Clone, Debug)]
struct MtLocalization<T: FloatExt> {
kink_eps: T,
box_hi: Vec<T>,
box_width: Vec<T>,
}
impl<T: FloatExt + ndarray_linalg::Lapack> MtGreeks<T> {
pub fn new(params: MultiHestonParams<T>, h: T, n_paths: usize) -> Self {
Self { params, h, n_paths }
}
pub fn delta(&self, payoff: &MtPayoff<T>, asset: usize) -> T {
self.delta_from_sampler(payoff, asset, || self.params.sample())
}
pub fn delta_with_seed(&self, payoff: &MtPayoff<T>, asset: usize, seed: u64) -> T {
let mut seed_state = seed;
self.delta_from_sampler(payoff, asset, || {
self
.params
.sample_with_seed(crate::simd_rng::derive_seed(&mut seed_state))
})
}
fn delta_from_sampler<F>(&self, payoff: &MtPayoff<T>, asset: usize, mut sample: F) -> T
where
F: FnMut() -> super::heston::MultiHestonPaths<T>,
{
let d = self.params.n_assets();
let discount = <T as num_traits::Float>::exp(-self.params.r * self.params.tau);
let spots: Vec<T> = self.params.assets.iter().map(|a| a.s0).collect();
let localization = self.localization(payoff);
let mut sum = T::zero();
for _ in 0..self.n_paths {
let paths = sample();
let st = paths.terminal_prices();
let gamma_inv = paths.gamma_inv(&self.params.cross_corr, self.params.tau);
let h_w = paths.malliavin_weights(&gamma_inv, asset, self.params.r, self.params.tau, &spots);
let g = match localization.as_ref() {
Some(loc) => self.compute_g_localized(payoff, &st, loc),
None => self.compute_g(payoff, &st),
};
let pw_contrib = localization
.as_ref()
.map(|loc| {
let grad = self.pathwise_tail_gradient(payoff, &st, loc);
grad[asset] * st[asset] / spots[asset]
})
.unwrap_or_else(T::zero);
let contrib = (0..d)
.map(|i| g[[i, asset]] * h_w[i])
.fold(T::zero(), |a, b| a + b);
sum += discount * (contrib + pw_contrib);
}
sum / T::from_usize_(self.n_paths)
}
pub fn all_deltas(&self, payoff: &MtPayoff<T>) -> Vec<T> {
self.all_deltas_from_sampler(payoff, || self.params.sample())
}
pub fn all_deltas_with_seed(&self, payoff: &MtPayoff<T>, seed: u64) -> Vec<T> {
let mut seed_state = seed;
self.all_deltas_from_sampler(payoff, || {
self
.params
.sample_with_seed(crate::simd_rng::derive_seed(&mut seed_state))
})
}
fn all_deltas_from_sampler<F>(&self, payoff: &MtPayoff<T>, mut sample: F) -> Vec<T>
where
F: FnMut() -> super::heston::MultiHestonPaths<T>,
{
let d = self.params.n_assets();
let discount = <T as num_traits::Float>::exp(-self.params.r * self.params.tau);
let spots: Vec<T> = self.params.assets.iter().map(|a| a.s0).collect();
let localization = self.localization(payoff);
let mut sums = vec![T::zero(); d];
for _ in 0..self.n_paths {
let paths = sample();
let st = paths.terminal_prices();
let gamma_inv = paths.gamma_inv(&self.params.cross_corr, self.params.tau);
let g = match localization.as_ref() {
Some(loc) => self.compute_g_localized(payoff, &st, loc),
None => self.compute_g(payoff, &st),
};
let grad_pw = localization
.as_ref()
.map(|loc| self.pathwise_tail_gradient(payoff, &st, loc));
for p in 0..d {
let h_w = paths.malliavin_weights(&gamma_inv, p, self.params.r, self.params.tau, &spots);
let contrib = (0..d)
.map(|i| g[[i, p]] * h_w[i])
.fold(T::zero(), |a, b| a + b);
let pw_contrib = grad_pw
.as_ref()
.map(|grad| grad[p] * st[p] / spots[p])
.unwrap_or_else(T::zero);
sums[p] += discount * (contrib + pw_contrib);
}
}
sums
.iter()
.map(|&s| s / T::from_usize_(self.n_paths))
.collect()
}
pub fn cross_gamma(&self, payoff: &MtPayoff<T>, asset_a: usize, asset_b: usize) -> T {
let bump = self.params.assets[asset_b].s0 * T::from_f64_fast(0.01);
let mut up = self.params.clone();
up.assets[asset_b].s0 += bump;
let mut dn = self.params.clone();
dn.assets[asset_b].s0 -= bump;
let d_up = MtGreeks::new(up, self.h, self.n_paths).delta(payoff, asset_a);
let d_dn = MtGreeks::new(dn, self.h, self.n_paths).delta(payoff, asset_a);
(d_up - d_dn) / (bump + bump)
}
pub fn cross_gamma_fd(&self, payoff: &MtPayoff<T>, asset_a: usize, asset_b: usize) -> T {
self.cross_gamma(payoff, asset_a, asset_b)
}
pub fn vega(&self, payoff: &MtPayoff<T>, asset: usize) -> T {
self.vega_with_seed(payoff, asset, 0x9E37_79B9_7F4A_7C15_u64 ^ asset as u64)
}
pub fn vega_with_seed(&self, payoff: &MtPayoff<T>, asset: usize, seed: u64) -> T {
let bump = self.params.assets[asset].v0 * T::from_f64_fast(0.01);
let mut up = self.params.clone();
up.assets[asset].v0 += bump;
let mut dn = self.params.clone();
dn.assets[asset].v0 -= bump;
let p_up = MtGreeks::new(up, self.h, self.n_paths).price_with_seed(payoff, seed);
let p_dn = MtGreeks::new(dn, self.h, self.n_paths).price_with_seed(payoff, seed);
(p_up - p_dn) / (bump + bump)
}
pub fn price(&self, payoff: &MtPayoff<T>) -> T {
self.price_from_sampler(payoff, || self.params.sample())
}
pub fn price_with_seed(&self, payoff: &MtPayoff<T>, seed: u64) -> T {
let mut seed_state = seed;
self.price_from_sampler(payoff, || {
self
.params
.sample_with_seed(crate::simd_rng::derive_seed(&mut seed_state))
})
}
fn price_from_sampler<F>(&self, payoff: &MtPayoff<T>, mut sample: F) -> T
where
F: FnMut() -> super::heston::MultiHestonPaths<T>,
{
let disc = <T as num_traits::Float>::exp(-self.params.r * self.params.tau);
let mut sum = T::zero();
for _ in 0..self.n_paths {
let st = sample().terminal_prices();
sum += disc * payoff.evaluate(&st);
}
sum / T::from_usize_(self.n_paths)
}
fn compute_g(&self, payoff: &MtPayoff<T>, st: &[T]) -> Array2<T> {
let d = self.params.n_assets();
match payoff {
MtPayoff::DigitalPut2D { strikes } if d == 2 => {
let raw = kernel::g_digital_put_2d([st[0], st[1]], *strikes);
let mut g = Array2::<T>::zeros((2, 2));
g[[0, 0]] = raw[0][0];
g[[0, 1]] = raw[0][1];
g[[1, 0]] = raw[1][0];
g[[1, 1]] = raw[1][1];
g
}
_ if d == 2 => {
let payoff_fn = |x: &[T]| payoff.evaluate(x);
let (lo, hi) = self.quadrature_bounds(payoff, st);
kernel::g_kernel_numerical_2d(
&[st[0], st[1]],
&payoff_fn,
self.h,
&[lo[0], lo[1]],
&[hi[0], hi[1]],
self.quadrature_points_per_axis(d),
)
}
_ => {
let payoff_fn = |x: &[T]| payoff.evaluate(x);
let (lo, hi) = self.quadrature_bounds(payoff, st);
kernel::g_kernel_numerical_nd(
st,
&payoff_fn,
self.h,
&lo,
&hi,
self.quadrature_points_per_axis(d),
)
}
}
}
fn compute_g_localized(
&self,
payoff: &MtPayoff<T>,
st: &[T],
loc: &MtLocalization<T>,
) -> Array2<T> {
let d = self.params.n_assets();
let hi: Vec<T> = loc
.box_hi
.iter()
.zip(&loc.box_width)
.map(|(&hi_j, &w_j)| hi_j + w_j)
.collect();
let lo = vec![T::zero(); d];
let payoff_fn = |x: &[T]| self.localized_mt_piece(payoff, x, loc);
if d == 2 {
kernel::g_kernel_numerical_2d(
&[st[0], st[1]],
&payoff_fn,
self.h,
&[lo[0], lo[1]],
&[hi[0], hi[1]],
self.quadrature_points_per_axis(d),
)
} else {
kernel::g_kernel_numerical_nd(
st,
&payoff_fn,
self.h,
&lo,
&hi,
self.quadrature_points_per_axis(d),
)
}
}
fn localization(&self, payoff: &MtPayoff<T>) -> Option<MtLocalization<T>> {
match payoff {
MtPayoff::Call { strike, .. } | MtPayoff::Put { strike, .. } => {
let base = self
.params
.assets
.iter()
.map(|a| <T as num_traits::Float>::abs(a.s0))
.fold(<T as num_traits::Float>::abs(*strike), |a, b| a.max(b))
.max(T::one());
let box_hi = self
.params
.assets
.iter()
.map(|a| T::from_f64_fast(2.0) * <T as num_traits::Float>::abs(a.s0).max(base))
.collect();
let box_width = vec![T::from_f64_fast(0.5) * base; self.params.n_assets()];
Some(MtLocalization {
kink_eps: T::from_f64_fast(0.05) * base,
box_hi,
box_width,
})
}
MtPayoff::BasketCall { strike, .. } => {
let base = self
.params
.assets
.iter()
.map(|a| <T as num_traits::Float>::abs(a.s0))
.fold(<T as num_traits::Float>::abs(*strike), |a, b| a.max(b))
.max(T::one());
let box_hi = self
.params
.assets
.iter()
.map(|a| T::from_f64_fast(2.5) * <T as num_traits::Float>::abs(a.s0).max(base))
.collect();
let box_width = vec![T::from_f64_fast(0.75) * base; self.params.n_assets()];
Some(MtLocalization {
kink_eps: T::from_f64_fast(0.05) * base,
box_hi,
box_width,
})
}
_ => None,
}
}
fn localized_mt_piece(&self, payoff: &MtPayoff<T>, x: &[T], loc: &MtLocalization<T>) -> T {
let Some((u, _)) = self.exercise_coordinate_and_gradient(payoff, x) else {
return payoff.evaluate(x);
};
let payoff_pos = u.max(T::zero());
let (beta, _) = self.beta_and_derivative(u, loc.kink_eps);
let (chi, _) = self.box_cutoff_and_gradient(x, loc);
payoff_pos * beta * chi
}
fn pathwise_tail_gradient(
&self,
payoff: &MtPayoff<T>,
x: &[T],
loc: &MtLocalization<T>,
) -> Vec<T> {
let Some((u, grad_u)) = self.exercise_coordinate_and_gradient(payoff, x) else {
return vec![T::zero(); x.len()];
};
let payoff_pos = u.max(T::zero());
let indicator = if u > T::zero() { T::one() } else { T::zero() };
let (beta, beta_prime) = self.beta_and_derivative(u, loc.kink_eps);
let (chi, grad_chi) = self.box_cutoff_and_gradient(x, loc);
let beta_chi = beta * chi;
(0..x.len())
.map(|k| {
indicator * (T::one() - beta_chi) * grad_u[k]
- payoff_pos * (chi * beta_prime * grad_u[k] + beta * grad_chi[k])
})
.collect()
}
fn exercise_coordinate_and_gradient(&self, payoff: &MtPayoff<T>, x: &[T]) -> Option<(T, Vec<T>)> {
match payoff {
MtPayoff::Call { asset, strike } => {
let mut grad = vec![T::zero(); x.len()];
grad[*asset] = T::one();
Some((x[*asset] - *strike, grad))
}
MtPayoff::Put { asset, strike } => {
let mut grad = vec![T::zero(); x.len()];
grad[*asset] = -T::one();
Some((*strike - x[*asset], grad))
}
MtPayoff::BasketCall { weights, strike } => {
assert_eq!(weights.len(), x.len(), "basket weights dimension mismatch");
let basket = weights
.iter()
.zip(x)
.map(|(&w, &xi)| w * xi)
.fold(T::zero(), |a, b| a + b);
Some((basket - *strike, weights.clone()))
}
_ => None,
}
}
fn beta_and_derivative(&self, u: T, eps: T) -> (T, T) {
let abs_u = <T as num_traits::Float>::abs(u);
if abs_u <= eps {
return (T::one(), T::zero());
}
if abs_u >= eps + eps {
return (T::zero(), T::zero());
}
let s = (abs_u - eps) / eps;
let s2 = s * s;
let s3 = s2 * s;
let s4 = s3 * s;
let s5 = s4 * s;
let beta = T::one() - T::from_f64_fast(10.0) * s3 + T::from_f64_fast(15.0) * s4
- T::from_f64_fast(6.0) * s5;
let dp_ds =
-T::from_f64_fast(30.0) * s2 + T::from_f64_fast(60.0) * s3 - T::from_f64_fast(30.0) * s4;
let sign = if u >= T::zero() { T::one() } else { -T::one() };
(beta, dp_ds * sign / eps)
}
fn box_cutoff_and_gradient(&self, x: &[T], loc: &MtLocalization<T>) -> (T, Vec<T>) {
assert_eq!(x.len(), loc.box_hi.len(), "box_hi dimension mismatch");
assert_eq!(x.len(), loc.box_width.len(), "box_width dimension mismatch");
let d = x.len();
let mut cutoff = vec![T::one(); d];
let mut deriv = vec![T::zero(); d];
for j in 0..d {
let hi = loc.box_hi[j];
let width = loc.box_width[j];
if x[j] <= hi {
continue;
}
if x[j] >= hi + width {
cutoff[j] = T::zero();
deriv[j] = T::zero();
continue;
}
let s = (x[j] - hi) / width;
let s2 = s * s;
let s3 = s2 * s;
let s4 = s3 * s;
let s5 = s4 * s;
cutoff[j] = T::one() - T::from_f64_fast(10.0) * s3 + T::from_f64_fast(15.0) * s4
- T::from_f64_fast(6.0) * s5;
deriv[j] = (-T::from_f64_fast(30.0) * s2 + T::from_f64_fast(60.0) * s3
- T::from_f64_fast(30.0) * s4)
/ width;
}
let chi = cutoff.iter().copied().fold(T::one(), |a, b| a * b);
let grad = (0..d)
.map(|j| {
let mut prod = deriv[j];
for (k, &cutoff_k) in cutoff.iter().enumerate() {
if k != j {
prod *= cutoff_k;
}
}
prod
})
.collect();
(chi, grad)
}
fn quadrature_bounds(&self, payoff: &MtPayoff<T>, st: &[T]) -> (Vec<T>, Vec<T>) {
let spot_scale = self
.params
.assets
.iter()
.map(|a| <T as num_traits::Float>::abs(a.s0))
.fold(T::zero(), |a, b| a.max(b));
let terminal_scale = st
.iter()
.copied()
.map(<T as num_traits::Float>::abs)
.fold(T::zero(), |a, b| a.max(b));
let payoff_scale = match payoff {
MtPayoff::Call { strike, .. } => <T as num_traits::Float>::abs(*strike),
MtPayoff::Put { strike, .. } => <T as num_traits::Float>::abs(*strike),
MtPayoff::DigitalPut2D { strikes } => {
<T as num_traits::Float>::abs(strikes[0]).max(<T as num_traits::Float>::abs(strikes[1]))
}
MtPayoff::BasketCall { strike, .. } => <T as num_traits::Float>::abs(*strike),
MtPayoff::WorstOfPut { strike } => <T as num_traits::Float>::abs(*strike),
};
let upper = T::from_f64_fast(3.0)
* spot_scale
.max(terminal_scale)
.max(payoff_scale)
.max(T::one());
(vec![T::zero(); st.len()], vec![upper; st.len()])
}
fn quadrature_points_per_axis(&self, d: usize) -> usize {
if d <= 2 {
64
} else {
let max_nodes = 1024.0_f64;
((max_nodes.powf(1.0 / d as f64)).floor() as usize).max(3)
}
}
}
#[cfg(test)]
mod tests {
use ndarray::Array2;
use owens_t::biv_norm;
use stochastic_rs_distributions::special::norm_cdf;
use stochastic_rs_distributions::special::norm_pdf;
use super::*;
use crate::OptionType;
use crate::pricing::bsm::BSMCoc;
use crate::pricing::bsm::BSMPricer;
use crate::pricing::malliavin_thalmaier::AssetParams;
use crate::traits::PricerExt;
fn two_asset_params() -> MultiHestonParams<f64> {
let a = AssetParams {
s0: 100.0,
v0: 0.04,
kappa: 2.0,
theta: 0.04,
xi: 0.3,
rho: -0.7,
};
let mut cross = Array2::<f64>::eye(2);
cross[[0, 1]] = 0.5;
cross[[1, 0]] = 0.5;
MultiHestonParams {
assets: vec![a.clone(), a],
cross_corr: cross,
r: 0.05,
tau: 1.0,
n_steps: 100,
}
}
fn gbm_like_params() -> MultiHestonParams<f64> {
let a = AssetParams {
s0: 100.0,
v0: 0.04, kappa: 10.0,
theta: 0.04,
xi: 1e-6, rho: 0.0,
};
MultiHestonParams {
assets: vec![a.clone(), a],
cross_corr: Array2::<f64>::eye(2),
r: 0.05,
tau: 1.0,
n_steps: 252,
}
}
fn gbm_like_params_3d() -> MultiHestonParams<f64> {
let a = AssetParams {
s0: 100.0,
v0: 0.04,
kappa: 10.0,
theta: 0.04,
xi: 1e-6,
rho: 0.0,
};
MultiHestonParams {
assets: vec![a.clone(), a.clone(), a],
cross_corr: Array2::<f64>::eye(3),
r: 0.05,
tau: 1.0,
n_steps: 128,
}
}
fn paper_bs_digital_put_params(s01: f64, n_steps: usize) -> MultiHestonParams<f64> {
let a1 = AssetParams {
s0: s01,
v0: 0.3 * 0.3,
kappa: 10.0,
theta: 0.3 * 0.3,
xi: 1e-8,
rho: 0.0,
};
let a2 = AssetParams {
s0: 100.0,
v0: 0.2 * 0.2,
kappa: 10.0,
theta: 0.2 * 0.2,
xi: 1e-8,
rho: 0.0,
};
let mut cross = Array2::<f64>::eye(2);
cross[[0, 1]] = 0.2;
cross[[1, 0]] = 0.2;
MultiHestonParams {
assets: vec![a1, a2],
cross_corr: cross,
r: 0.0,
tau: 1.0,
n_steps,
}
}
fn bs_bivariate_digital_put_price_delta(
s1: f64,
s2: f64,
k1: f64,
k2: f64,
sigma1: f64,
sigma2: f64,
rho: f64,
r: f64,
tau: f64,
) -> (f64, f64) {
let root_t = tau.sqrt();
let a1 = ((k1 / s1).ln() - (r - 0.5 * sigma1 * sigma1) * tau) / (sigma1 * root_t);
let a2 = ((k2 / s2).ln() - (r - 0.5 * sigma2 * sigma2) * tau) / (sigma2 * root_t);
let disc = (-r * tau).exp();
let cdf = |x: f64, y: f64, corr: f64| -> f64 { biv_norm(-x, -y, corr) };
let price = disc * cdf(a1, a2, rho);
let conditional = norm_cdf((a2 - rho * a1) / (1.0 - rho * rho).sqrt());
let delta = disc * (-(norm_pdf(a1) * conditional) / (s1 * sigma1 * root_t));
(price, delta)
}
#[test]
fn delta_digital_put_2d_finite() {
let e = MtGreeks::new(two_asset_params(), 0.01, 5_000);
let p = MtPayoff::DigitalPut2D {
strikes: [100.0, 100.0],
};
for (i, &d) in e.all_deltas(&p).iter().enumerate() {
assert!(d.is_finite(), "Delta[{i}] = {d}");
}
}
#[test]
fn digital_put_2d_mt_vs_fd() {
let n = 30_000;
let params = two_asset_params();
let payoff = MtPayoff::DigitalPut2D {
strikes: [100.0, 100.0],
};
let mt = MtGreeks::new(params.clone(), 0.01, n).all_deltas_with_seed(&payoff, 0xD161_7A1);
let bump = 0.5;
let mut fd = vec![0.0; 2];
for p in 0..2 {
let mut up = params.clone();
up.assets[p].s0 += bump;
let mut dn = params.clone();
dn.assets[p].s0 -= bump;
let seed = 0xFD_D161_7A_u64 ^ p as u64;
fd[p] = (MtGreeks::new(up, 0.01, n).price_with_seed(&payoff, seed)
- MtGreeks::new(dn, 0.01, n).price_with_seed(&payoff, seed))
/ (2.0 * bump);
}
assert!(
mt[0] < 0.0 && mt[1] < 0.0,
"MT deltas should be < 0: [{:.4}, {:.4}]",
mt[0],
mt[1]
);
assert!(
fd[0] < 0.0 && fd[1] < 0.0,
"FD deltas should be < 0: [{:.4}, {:.4}]",
fd[0],
fd[1]
);
assert_eq!(mt[0] < 0.0, fd[0] < 0.0, "sign mismatch asset 0");
assert_eq!(mt[1] < 0.0, fd[1] < 0.0, "sign mismatch asset 1");
}
#[test]
fn digital_put_2d_price_bounded() {
let e = MtGreeks::new(two_asset_params(), 0.01, 20_000);
let p = MtPayoff::DigitalPut2D {
strikes: [100.0, 100.0],
};
let price = e.price(&p);
let disc = (-0.05_f64).exp();
assert!(
price > 0.0 && price < disc,
"price {price:.4} not in (0, {disc:.4})"
);
}
#[test]
fn delta_call_finite() {
let e = MtGreeks::new(two_asset_params(), 0.01, 10_000);
let p = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let d = e.delta(&p, 0);
assert!(d.is_finite() && d.abs() < 5.0, "Delta = {d}");
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "expensive 3D MC smoke test; run with --release --features openblas"
)]
fn delta_call_finite_in_3d() {
let e = MtGreeks::new(gbm_like_params_3d(), 0.01, 20_000);
let p = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let d = e.delta_with_seed(&p, 0, 7);
let bs = BSMPricer {
s: 100.0,
v: 0.2,
k: 100.0,
r: 0.05,
r_d: None,
r_f: None,
q: Some(0.0),
tau: Some(1.0),
eval: None,
expiration: None,
option_type: OptionType::Call,
b: BSMCoc::Bsm1973,
};
let bs_delta = bs.delta();
let err = (d - bs_delta).abs();
assert!(d.is_finite(), "3D delta is not finite: {d}");
assert!(
err < 0.20,
"3D delta = {d}, BS delta = {bs_delta}, err = {err}"
);
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "expensive 3D MC smoke test; run with --release --features openblas"
)]
fn delta_put_finite_in_3d() {
let e = MtGreeks::new(gbm_like_params_3d(), 0.01, 20_000);
let p = MtPayoff::Put {
asset: 0,
strike: 100.0,
};
let d = e.delta_with_seed(&p, 0, 11);
let bs = BSMPricer {
s: 100.0,
v: 0.2,
k: 100.0,
r: 0.05,
r_d: None,
r_f: None,
q: Some(0.0),
tau: Some(1.0),
eval: None,
expiration: None,
option_type: OptionType::Put,
b: BSMCoc::Bsm1973,
};
let bs_delta = bs.delta();
let err = (d - bs_delta).abs();
assert!(d.is_finite(), "3D put delta is not finite: {d}");
assert!(
err < 0.20,
"3D put delta = {d}, BS delta = {bs_delta}, err = {err}"
);
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "expensive MC reference test; run with --release --features openblas"
)]
fn paper_scenario_digital_put_price_matches_bs_reference() {
let params = paper_bs_digital_put_params(100.0, 512);
let payoff = MtPayoff::DigitalPut2D {
strikes: [100.0, 100.0],
};
let engine = MtGreeks::new(params, 0.01, 20_000);
let mc_price = engine.price_with_seed(&payoff, 42);
let (ref_price, _) =
bs_bivariate_digital_put_price_delta(100.0, 100.0, 100.0, 100.0, 0.3, 0.2, 0.2, 0.0, 1.0);
let rel_err = (mc_price - ref_price).abs() / ref_price;
assert!(
rel_err < 0.06,
"paper-scenario price = {mc_price:.6}, reference = {ref_price:.6}, rel_err = {rel_err:.4}"
);
}
#[test]
#[cfg_attr(
debug_assertions,
ignore = "expensive MC reference test; run with --release --features openblas"
)]
fn paper_scenario_digital_put_delta_matches_bs_reference() {
let payoff = MtPayoff::DigitalPut2D {
strikes: [100.0, 100.0],
};
let seeds = [7_u64, 17_u64, 29_u64];
let mt_delta: f64 = seeds
.iter()
.map(|&seed| {
let engine = MtGreeks::new(paper_bs_digital_put_params(100.0, 512), 0.01, 20_000);
engine.delta_with_seed(&payoff, 0, seed)
})
.sum::<f64>()
/ seeds.len() as f64;
let (_, ref_delta) =
bs_bivariate_digital_put_price_delta(100.0, 100.0, 100.0, 100.0, 0.3, 0.2, 0.2, 0.0, 1.0);
let abs_err = (mt_delta - ref_delta).abs();
assert!(
abs_err < 0.0020,
"paper-scenario delta = {mt_delta:.6}, reference = {ref_delta:.6}, abs_err = {abs_err:.6}"
);
}
#[test]
fn price_call_positive() {
let e = MtGreeks::new(two_asset_params(), 0.01, 10_000);
let p = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let v = e.price(&p);
assert!(v > 0.0 && v < 50.0, "price = {v}");
}
#[test]
fn price_worst_of_put_positive() {
let e = MtGreeks::new(two_asset_params(), 0.01, 10_000);
let p = MtPayoff::WorstOfPut { strike: 110.0 };
assert!(e.price(&p) > 0.0);
}
#[test]
fn price_converges_to_bs_when_xi_zero() {
let params = gbm_like_params();
let e = MtGreeks::new(params, 0.01, 30_000);
let payoff = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let mc_price = e.price(&payoff);
let bs = BSMPricer {
s: 100.0,
v: 0.2,
k: 100.0,
r: 0.05,
r_d: None,
r_f: None,
q: Some(0.0),
tau: Some(1.0),
eval: None,
expiration: None,
option_type: OptionType::Call,
b: BSMCoc::Bsm1973,
};
let (bs_call, _) = bs.calculate_call_put();
let rel_err = (mc_price - bs_call).abs() / bs_call;
println!("MC price = {mc_price:.4}, BS price = {bs_call:.4}, rel_err = {rel_err:.4}");
assert!(
rel_err < 0.10,
"MC price ({mc_price:.4}) should be within 10% of BS ({bs_call:.4}), rel_err = {rel_err:.4}"
);
}
#[test]
fn delta_vs_finite_difference() {
let params = gbm_like_params();
let payoff = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let n_paths = 30_000;
let bump = 1.0; let mut up = params.clone();
up.assets[0].s0 += bump;
let mut dn = params.clone();
dn.assets[0].s0 -= bump;
let fd_delta = (MtGreeks::new(up, 0.01, n_paths).price(&payoff)
- MtGreeks::new(dn, 0.01, n_paths).price(&payoff))
/ (2.0 * bump);
let mt_delta = MtGreeks::new(params, 0.01, n_paths).delta(&payoff, 0);
assert!(
fd_delta > 0.0 && fd_delta < 1.5,
"FD delta = {fd_delta} out of range"
);
assert!(mt_delta.is_finite(), "M-T delta = {mt_delta} not finite");
}
#[test]
fn fd_delta_matches_bs_delta() {
let params = gbm_like_params();
let payoff = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let n_paths = 200_000;
let bump = 0.5;
let mut up = params.clone();
up.assets[0].s0 += bump;
let mut dn = params.clone();
dn.assets[0].s0 -= bump;
let fd_delta = (MtGreeks::new(up, 0.01, n_paths).price(&payoff)
- MtGreeks::new(dn, 0.01, n_paths).price(&payoff))
/ (2.0 * bump);
let bs = BSMPricer {
s: 100.0,
v: 0.2,
k: 100.0,
r: 0.05,
r_d: None,
r_f: None,
q: Some(0.0),
tau: Some(1.0),
eval: None,
expiration: None,
option_type: OptionType::Call,
b: BSMCoc::Bsm1973,
};
let bs_delta = bs.delta();
let err = (fd_delta - bs_delta).abs();
println!("FD delta = {fd_delta:.4}, BS delta = {bs_delta:.4}, err = {err:.4}");
assert!(
err < 0.20,
"FD delta ({fd_delta:.4}) should be within 0.20 of BS delta ({bs_delta:.4}), err = {err:.4}"
);
}
#[test]
fn put_call_parity() {
let params = gbm_like_params();
let n_paths = 30_000;
let e = MtGreeks::new(params, 0.01, n_paths);
let call = e.price(&MtPayoff::Call {
asset: 0,
strike: 100.0,
});
let put = e.price(&MtPayoff::Put {
asset: 0,
strike: 100.0,
});
let parity = 100.0 - 100.0 * (-0.05_f64).exp();
let err = ((call - put) - parity).abs();
assert!(
err < 5.0,
"Put-call parity: C−P = {:.4}, expected {parity:.4}, err = {err:.4}",
call - put
);
}
#[test]
fn cross_check_vs_existing_heston_malliavin_delta() {
use crate::pricing::malliavin_greeks::HestonMalliavinGreeks;
let existing = HestonMalliavinGreeks {
s0: 100.0,
v0: 0.04,
kappa: 2.0,
theta: 0.04,
xi: 0.3,
rho: -0.7,
r: 0.05,
tau: 1.0,
k: 100.0,
n_paths: 30_000,
n_steps: 252,
};
let ref_delta = existing.delta();
let a = AssetParams {
s0: 100.0,
v0: 0.04,
kappa: 2.0,
theta: 0.04,
xi: 0.3,
rho: -0.7,
};
let a2 = AssetParams {
s0: 50.0,
v0: 0.01,
kappa: 1.0,
theta: 0.01,
xi: 0.1,
rho: 0.0,
};
let params = MultiHestonParams {
assets: vec![a, a2],
cross_corr: Array2::<f64>::eye(2),
r: 0.05,
tau: 1.0,
n_steps: 252,
};
let bump = 0.5;
let payoff = MtPayoff::Call {
asset: 0,
strike: 100.0,
};
let mut up = params.clone();
up.assets[0].s0 += bump;
let mut dn = params.clone();
dn.assets[0].s0 -= bump;
let n = 30_000;
let fd_delta = (MtGreeks::new(up, 0.01, n).price(&payoff)
- MtGreeks::new(dn, 0.01, n).price(&payoff))
/ (2.0 * bump);
assert!(
ref_delta > 0.0 && ref_delta < 1.0,
"Existing Heston Malliavin delta = {ref_delta} out of (0,1)"
);
assert!(
fd_delta > 0.0 && fd_delta < 1.0,
"M-T FD delta = {fd_delta} out of (0,1)"
);
assert!(
(ref_delta > 0.0) == (fd_delta > 0.0),
"Sign mismatch: existing = {ref_delta:.4}, M-T FD = {fd_delta:.4}"
);
}
}