use crate::error::PricingError;
use crate::kernels::{big_n, discount_factor};
use optionstratlib_core::model::Options;
use optionstratlib_core::model::decimal::{d_add, d_div, d_ln, d_mul, d_sqrt, d_sub};
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
pub fn spread_black_scholes(option: &Options) -> Result<Decimal, PricingError> {
let second_asset_price = match &option.option_type {
OptionType::Spread { second_asset } => second_asset.to_dec(),
_ => {
return Err(PricingError::other(
"spread_black_scholes requires OptionType::Spread",
));
}
};
let params = option
.exotic_params
.as_ref()
.ok_or_else(|| PricingError::other("Spread options require exotic_params"))?;
let sigma2 = params
.spread_second_asset_volatility
.ok_or_else(|| PricingError::other("Missing spread_second_asset_volatility"))?;
let q2 = params
.spread_second_asset_dividend
.unwrap_or(optionstratlib_core::model::Positive::ZERO);
let rho = params
.spread_correlation
.ok_or_else(|| PricingError::other("Missing spread_correlation"))?;
if rho < dec!(-1.0) || rho > dec!(1.0) {
return Err(PricingError::other("Correlation must be between -1 and 1"));
}
let s1 = Decimal::from(option.underlying_price);
let s2 = second_asset_price;
let k = Decimal::from(option.strike_price);
let r = option.risk_free_rate;
let q1 = Decimal::from(option.dividend_yield);
let sigma1 = Decimal::from(option.implied_volatility);
let t = Decimal::from(option.expiration_date.get_years()?);
let price = if k.abs() < dec!(0.0001) {
match option.option_style {
OptionStyle::Call => margrabe_formula(
s1,
s2,
q1,
Decimal::from(q2),
sigma1,
Decimal::from(sigma2),
rho,
t,
)?,
OptionStyle::Put => margrabe_formula(
s2,
s1,
Decimal::from(q2),
q1,
Decimal::from(sigma2),
sigma1,
rho,
t,
)?,
}
} else {
kirk_approximation(
s1,
s2,
k,
r,
q1,
Decimal::from(q2),
sigma1,
Decimal::from(sigma2),
rho,
t,
&option.option_style,
)?
};
Ok(apply_side(price, option))
}
#[allow(clippy::too_many_arguments)]
fn kirk_approximation(
s1: Decimal,
s2: Decimal,
k: Decimal,
r: Decimal,
q1: Decimal,
q2: Decimal,
sigma1: Decimal,
sigma2: Decimal,
rho: Decimal,
t: Decimal,
style: &OptionStyle,
) -> Result<Decimal, PricingError> {
if t <= dec!(0.0) {
let spread = d_sub(s1, s2, "pricing::spread::kirk::intrinsic::spread")?;
return match style {
OptionStyle::Call => {
Ok(d_sub(spread, k, "pricing::spread::kirk::intrinsic::call")?.max(dec!(0.0)))
}
OptionStyle::Put => {
Ok(d_sub(k, spread, "pricing::spread::kirk::intrinsic::put")?.max(dec!(0.0)))
}
};
}
if d_add(s2, k, "pricing::spread::kirk::adjusted_strike")? <= dec!(0.0) {
return Err(PricingError::other(
"Adjusted strike (S2 + K) must be positive",
));
}
let s1_pv = d_mul(
s1,
discount_factor(
q1,
t,
"pricing::spread::kirk::neg_q1t",
"pricing::spread::kirk::dividend_discount",
)?,
"pricing::spread::kirk::s1_pv",
)?;
let s2_pv = d_mul(
s2,
discount_factor(
q2,
t,
"pricing::spread::kirk::neg_q2t",
"pricing::spread::kirk::dividend_discount2",
)?,
"pricing::spread::kirk::s2_pv",
)?;
let strike_pv = d_mul(
k,
discount_factor(
r,
t,
"pricing::spread::kirk::neg_rt",
"pricing::spread::kirk::discount",
)?,
"pricing::spread::kirk::strike_pv",
)?;
let adjusted_strike_pv = d_add(
s2_pv,
strike_pv,
"pricing::spread::kirk::adjusted_strike_pv",
)?;
if adjusted_strike_pv.is_zero() {
return Ok(match style {
OptionStyle::Call => s1_pv,
OptionStyle::Put => dec!(0.0),
});
}
let s2_ratio = d_div(s2_pv, adjusted_strike_pv, "pricing::spread::kirk::s2_ratio")?;
let sigma_sq = d_sub(
d_add(
d_mul(sigma1, sigma1, "pricing::spread::kirk::var1")?,
d_mul(
d_mul(s2_ratio, s2_ratio, "pricing::spread::kirk::s2_ratio_sq")?,
d_mul(sigma2, sigma2, "pricing::spread::kirk::var2")?,
"pricing::spread::kirk::weighted_var2",
)?,
"pricing::spread::kirk::variance_sum",
)?,
d_mul(
d_mul(
d_mul(
d_mul(dec!(2.0), rho, "pricing::spread::kirk::two_rho")?,
sigma1,
"pricing::spread::kirk::two_rho_sigma1",
)?,
sigma2,
"pricing::spread::kirk::two_rho_sigma1_sigma2",
)?,
s2_ratio,
"pricing::spread::kirk::covariance",
)?,
"pricing::spread::kirk::sigma_sq",
)?;
let sigma = d_sqrt(sigma_sq, "pricing::spread::kirk::sigma")
.map_err(|_| PricingError::other("Failed to compute adjusted volatility"))?;
let sqrt_t = d_sqrt(t, "pricing::spread::kirk::sqrt_t")?;
let denominator = d_mul(sigma, sqrt_t, "pricing::spread::kirk::denominator")?;
let (n_d1, n_d2, n_neg_d1, n_neg_d2) = if denominator.is_zero() {
if s1_pv >= adjusted_strike_pv {
(dec!(1.0), dec!(1.0), dec!(0.0), dec!(0.0))
} else {
(dec!(0.0), dec!(0.0), dec!(1.0), dec!(1.0))
}
} else if s1_pv.is_zero() {
(dec!(0.0), dec!(0.0), dec!(1.0), dec!(1.0))
} else {
let log_moneyness = d_sub(
d_ln(s1_pv, "pricing::spread::kirk::log_s1_pv")?,
d_ln(
adjusted_strike_pv,
"pricing::spread::kirk::log_adjusted_strike_pv",
)?,
"pricing::spread::kirk::log_moneyness",
)?;
let d1 = d_div(
d_add(
log_moneyness,
d_mul(
d_div(
d_mul(sigma, sigma, "pricing::spread::kirk::variance")?,
dec!(2.0),
"pricing::spread::kirk::half_variance",
)?,
t,
"pricing::spread::kirk::drift",
)?,
"pricing::spread::kirk::d1_numerator",
)?,
denominator,
"pricing::spread::kirk::d1",
)?;
let d2 = d_sub(d1, denominator, "pricing::spread::kirk::d2")?;
(big_n(d1)?, big_n(d2)?, big_n(-d1)?, big_n(-d2)?)
};
match style {
OptionStyle::Call => {
let call = d_sub(
d_mul(s1_pv, n_d1, "pricing::spread::kirk::call::spot")?,
d_mul(
adjusted_strike_pv,
n_d2,
"pricing::spread::kirk::call::strike",
)?,
"pricing::spread::kirk::call",
)?;
Ok(call.max(dec!(0.0)))
}
OptionStyle::Put => {
let put = d_sub(
d_mul(
adjusted_strike_pv,
n_neg_d2,
"pricing::spread::kirk::put::strike",
)?,
d_mul(s1_pv, n_neg_d1, "pricing::spread::kirk::put::spot")?,
"pricing::spread::kirk::put",
)?;
Ok(put.max(dec!(0.0)))
}
}
}
#[allow(clippy::too_many_arguments)]
fn margrabe_formula(
s1: Decimal,
s2: Decimal,
q1: Decimal,
q2: Decimal,
sigma1: Decimal,
sigma2: Decimal,
rho: Decimal,
t: Decimal,
) -> Result<Decimal, PricingError> {
if t <= dec!(0.0) {
return Ok(d_sub(s1, s2, "pricing::spread::margrabe::intrinsic")?.max(dec!(0.0)));
}
let sigma_sq = d_sub(
d_add(
d_mul(sigma1, sigma1, "pricing::spread::margrabe::var1")?,
d_mul(sigma2, sigma2, "pricing::spread::margrabe::var2")?,
"pricing::spread::margrabe::variance_sum",
)?,
d_mul(
d_mul(
d_mul(dec!(2.0), rho, "pricing::spread::margrabe::two_rho")?,
sigma1,
"pricing::spread::margrabe::two_rho_sigma1",
)?,
sigma2,
"pricing::spread::margrabe::covariance",
)?,
"pricing::spread::margrabe::sigma_sq",
)?;
let sigma = d_sqrt(sigma_sq, "pricing::spread::margrabe::sigma")
.map_err(|_| PricingError::other("Failed to compute combined volatility"))?;
let sqrt_t = d_sqrt(t, "pricing::spread::margrabe::sqrt_t")?;
let denominator = d_mul(sigma, sqrt_t, "pricing::spread::margrabe::denominator")?;
let s1_pv = d_mul(
s1,
discount_factor(
q1,
t,
"pricing::spread::margrabe::neg_q1t",
"pricing::spread::margrabe::discount1",
)?,
"pricing::spread::margrabe::s1_pv",
)?;
let s2_pv = d_mul(
s2,
discount_factor(
q2,
t,
"pricing::spread::margrabe::neg_q2t",
"pricing::spread::margrabe::discount2",
)?,
"pricing::spread::margrabe::s2_pv",
)?;
if denominator.is_zero() {
return Ok(d_sub(s1_pv, s2_pv, "pricing::spread::margrabe::deterministic")?.max(dec!(0.0)));
}
if s2.is_zero() {
return Ok(s1_pv.max(dec!(0.0)));
}
if s1.is_zero() {
return Ok(dec!(0.0));
}
let log_ratio = d_sub(
d_ln(s1, "pricing::spread::margrabe::log_s1")?,
d_ln(s2, "pricing::spread::margrabe::log_s2")?,
"pricing::spread::margrabe::log_ratio",
)?;
let d1 = d_div(
d_add(
log_ratio,
d_mul(
d_add(
d_sub(q2, q1, "pricing::spread::margrabe::carry")?,
d_div(
d_mul(sigma, sigma, "pricing::spread::margrabe::variance")?,
dec!(2.0),
"pricing::spread::margrabe::half_variance",
)?,
"pricing::spread::margrabe::drift_rate",
)?,
t,
"pricing::spread::margrabe::drift",
)?,
"pricing::spread::margrabe::d1_numerator",
)?,
denominator,
"pricing::spread::margrabe::d1",
)?;
let d2 = d_sub(d1, denominator, "pricing::spread::margrabe::d2")?;
let price = d_sub(
d_mul(s1_pv, big_n(d1)?, "pricing::spread::margrabe::leg1")?,
d_mul(s2_pv, big_n(d2)?, "pricing::spread::margrabe::leg2")?,
"pricing::spread::margrabe::price",
)?;
Ok(price.max(dec!(0.0)))
}
fn apply_side(price: Decimal, option: &Options) -> Decimal {
match option.side {
Side::Long => price,
Side::Short => -price,
}
}
#[cfg(test)]
mod tests {
use super::*;
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::model::option::ExoticParams;
use optionstratlib_core::{model::Positive, pos_or_panic};
use rust_decimal::MathematicalOps;
use rust_decimal_macros::dec;
fn create_spread_option(strike: Positive, option_style: OptionStyle) -> Options {
Options::new(
OptionType::Spread {
second_asset: pos_or_panic!(100.0),
},
Side::Long,
"TEST".to_string(),
strike,
ExpirationDate::Days(pos_or_panic!(90.0)),
pos_or_panic!(0.2),
Positive::ONE,
pos_or_panic!(105.0),
dec!(0.05),
option_style,
Positive::ZERO,
Some(ExoticParams {
spot_prices: None,
spot_min: None,
spot_max: None,
cliquet_local_cap: None,
cliquet_local_floor: None,
cliquet_global_cap: None,
cliquet_global_floor: None,
rainbow_second_asset_price: None,
rainbow_second_asset_volatility: None,
rainbow_second_asset_dividend: None,
rainbow_correlation: None,
spread_second_asset_volatility: Some(pos_or_panic!(0.25)),
spread_second_asset_dividend: Some(Positive::ZERO),
spread_correlation: Some(dec!(0.5)),
quanto_fx_volatility: None,
quanto_fx_correlation: None,
quanto_foreign_rate: None,
foreign_rate: None,
exchange_second_asset_volatility: None,
exchange_second_asset_dividend: None,
exchange_correlation: None,
}),
)
}
#[test]
fn test_margrabe_underflowing_ratio_with_offsetting_carry_prices_first_pv() {
let mut option = create_spread_option(Positive::ZERO, OptionStyle::Call);
option.underlying_price = Positive::new_decimal(Decimal::new(1, 27)).unwrap();
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.spread_second_asset_volatility = Some(pos_or_panic!(0.2));
params.spread_second_asset_dividend = Some(pos_or_panic!(100.0));
}
let price = spread_black_scholes(&option).unwrap();
assert_eq!(
price,
Decimal::new(1, 27),
"vanished S2 present value should leave S1's present value, got {}",
price
);
}
#[test]
fn test_margrabe_underflowing_ratio_without_carry_is_zero() {
let mut option = create_spread_option(Positive::ZERO, OptionStyle::Call);
option.underlying_price = Positive::new_decimal(Decimal::new(1, 27)).unwrap();
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.spread_second_asset_volatility = Some(pos_or_panic!(0.2));
}
let price = spread_black_scholes(&option).unwrap();
assert_eq!(
price,
Decimal::ZERO,
"a ratio that vanishes in the limit should still price at zero, got {}",
price
);
}
#[test]
fn test_kirk_underflowing_moneyness_with_offsetting_carry_prices_first_pv() {
let mut option = create_spread_option(pos_or_panic!(50.0), OptionStyle::Call);
option.option_type = OptionType::Spread {
second_asset: pos_or_panic!(50.0),
};
option.underlying_price = Positive::new_decimal(Decimal::new(1, 27)).unwrap();
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
option.risk_free_rate = dec!(100.0);
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.spread_second_asset_volatility = Some(pos_or_panic!(0.2));
params.spread_second_asset_dividend = Some(pos_or_panic!(100.0));
}
let price = spread_black_scholes(&option).unwrap();
assert_eq!(
price,
Decimal::new(1, 27),
"vanished adjusted-strike present value should leave S1's present value, got {}",
price
);
}
#[test]
fn test_kirk_underflowing_moneyness_without_carry_is_zero() {
let mut option = create_spread_option(pos_or_panic!(50.0), OptionStyle::Call);
option.option_type = OptionType::Spread {
second_asset: pos_or_panic!(50.0),
};
option.underlying_price = Positive::new_decimal(Decimal::new(1, 27)).unwrap();
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
option.risk_free_rate = dec!(0.0);
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.spread_second_asset_volatility = Some(pos_or_panic!(0.2));
}
let call = spread_black_scholes(&option).unwrap();
assert_eq!(
call,
Decimal::ZERO,
"a moneyness that vanishes in the limit should still price at zero, got {}",
call
);
option.option_style = OptionStyle::Put;
let put = spread_black_scholes(&option).unwrap();
optionstratlib_core::assert_decimal_eq!(put, dec!(100.0), dec!(1e-20));
}
#[test]
fn test_spread_call_positive_value() {
let option = create_spread_option(Positive::ZERO, OptionStyle::Call);
let price = spread_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Spread call should have positive value, got {}",
price
);
}
#[test]
fn test_spread_put_positive_value() {
let option = create_spread_option(pos_or_panic!(10.0), OptionStyle::Put);
let price = spread_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Spread put should have positive value, got {}",
price
);
}
#[test]
fn test_margrabe_exchange_option() {
let option = create_spread_option(Positive::ZERO, OptionStyle::Call);
let price = spread_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Exchange option (K=0) should have positive value"
);
}
fn spread_with(
spot1: f64,
spot2: f64,
strike: Positive,
style: OptionStyle,
days: f64,
q1: Positive,
q2: Positive,
) -> Options {
let mut option = create_spread_option(strike, style);
option.option_type = OptionType::Spread {
second_asset: pos_or_panic!(spot2),
};
option.underlying_price = pos_or_panic!(spot1);
option.expiration_date = ExpirationDate::Days(pos_or_panic!(days));
option.dividend_yield = q1;
if let Some(params) = option.exotic_params.as_mut() {
params.spread_second_asset_dividend = Some(q2);
}
option
}
#[test]
fn test_zero_strike_spread_at_expiry_is_the_payoff() {
use optionstratlib_core::model::payoff::{Payoff, PayoffInfo};
for (spot1, spot2) in [(105.0, 100.0), (95.0, 100.0)] {
for style in [OptionStyle::Call, OptionStyle::Put] {
let option = spread_with(
spot1,
spot2,
Positive::ZERO,
style,
0.0,
Positive::ZERO,
Positive::ZERO,
);
let payoff = option
.option_type
.payoff(&PayoffInfo {
spot: option.underlying_price,
strike: option.strike_price,
style,
side: Side::Long,
spot_prices: None,
spot_min: None,
spot_max: None,
exotic_params: option.exotic_params.clone(),
})
.unwrap();
let expected = match style {
OptionStyle::Call => (spot1 - spot2).max(0.0),
OptionStyle::Put => (spot2 - spot1).max(0.0),
};
assert_eq!(payoff, Decimal::from_f64_retain(expected).unwrap());
assert_eq!(
spread_black_scholes(&option).unwrap(),
payoff,
"S1={spot1} S2={spot2} {style:?}"
);
}
}
}
#[test]
fn test_small_strike_spread_put_call_parity() {
let q1 = pos_or_panic!(0.01);
let q2 = pos_or_panic!(0.03);
for strike in [Positive::ZERO, pos_or_panic!(0.00005)] {
for (spot1, spot2) in [(105.0, 100.0), (95.0, 100.0)] {
let call = spread_with(spot1, spot2, strike, OptionStyle::Call, 90.0, q1, q2);
let put = spread_with(spot1, spot2, strike, OptionStyle::Put, 90.0, q1, q2);
let call_price = spread_black_scholes(&call).unwrap();
let put_price = spread_black_scholes(&put).unwrap();
let t = Decimal::from(call.expiration_date.get_years().unwrap());
let s1_pv = Decimal::from_f64_retain(spot1).unwrap() * (-q1.to_dec() * t).exp();
let s2_pv = Decimal::from_f64_retain(spot2).unwrap() * (-q2.to_dec() * t).exp();
let exchange_gap = (call_price - put_price - (s1_pv - s2_pv)).abs();
assert!(
exchange_gap < dec!(0.000000001),
"K={strike} S1={spot1}: C={call_price} P={put_price}, gap {exchange_gap}"
);
let k_pv = strike.to_dec() * (-call.risk_free_rate * t).exp();
let full_gap = (call_price - put_price - (s1_pv - s2_pv - k_pv)).abs();
assert!(full_gap <= strike.to_dec() + dec!(0.000000001));
}
}
}
#[test]
fn test_spread_put_is_continuous_across_the_zero_strike_threshold() {
let below = spread_with(
105.0,
100.0,
pos_or_panic!(0.00009),
OptionStyle::Put,
90.0,
Positive::ZERO,
Positive::ZERO,
);
let above = spread_with(
105.0,
100.0,
pos_or_panic!(0.00011),
OptionStyle::Put,
90.0,
Positive::ZERO,
Positive::ZERO,
);
let gap =
(spread_black_scholes(&below).unwrap() - spread_black_scholes(&above).unwrap()).abs();
assert!(gap < dec!(0.001), "gap {gap}");
}
#[test]
fn test_kirk_approximation_nonzero_strike() {
let option = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
let price = spread_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Kirk approximation should produce positive value"
);
}
#[test]
fn test_spread_correlation_impact() {
let mut low_corr = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
if let Some(ref mut params) = low_corr.exotic_params {
params.spread_correlation = Some(dec!(0.0));
}
let mut high_corr = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
if let Some(ref mut params) = high_corr.exotic_params {
params.spread_correlation = Some(dec!(0.9));
}
let low_price = spread_black_scholes(&low_corr).unwrap();
let high_price = spread_black_scholes(&high_corr).unwrap();
assert!(
low_price > high_price,
"Lower correlation should give higher spread option value (more uncertainty in spread)"
);
}
#[test]
fn test_spread_invalid_correlation() {
let mut option = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
if let Some(ref mut params) = option.exotic_params {
params.spread_correlation = Some(dec!(1.5));
}
let result = spread_black_scholes(&option);
assert!(result.is_err(), "Should reject correlation > 1");
}
#[test]
fn test_spread_missing_params() {
let option = Options::new(
OptionType::Spread {
second_asset: pos_or_panic!(100.0),
},
Side::Long,
"TEST".to_string(),
pos_or_panic!(5.0),
ExpirationDate::Days(pos_or_panic!(90.0)),
pos_or_panic!(0.2),
Positive::ONE,
pos_or_panic!(105.0),
dec!(0.05),
OptionStyle::Call,
Positive::ZERO,
None,
);
let result = spread_black_scholes(&option);
assert!(result.is_err(), "Should fail without exotic_params");
}
#[test]
fn test_spread_short_position() {
let mut option = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
option.side = Side::Short;
let price = spread_black_scholes(&option).unwrap();
assert!(
price < dec!(0.0),
"Short position should have negative value"
);
}
#[test]
fn test_spread_put_call_parity() {
let call = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
let put = create_spread_option(pos_or_panic!(5.0), OptionStyle::Put);
let call_price = spread_black_scholes(&call).unwrap();
let put_price = spread_black_scholes(&put).unwrap();
let s1 = Decimal::from(call.underlying_price);
let s2 = dec!(100.0);
let k = dec!(5.0);
let r = call.risk_free_rate;
let t = Decimal::from(call.expiration_date.get_years().unwrap());
let forward_spread = s1 - s2;
let k_pv = k * (-r * t).exp();
let parity_diff = (call_price - put_price - forward_spread + k_pv).abs();
assert!(
parity_diff < dec!(0.000000001),
"Put-call parity should hold, diff = {}",
parity_diff
);
}
#[test]
fn test_spread_deep_itm_call() {
let mut option = create_spread_option(Positive::ZERO, OptionStyle::Call);
option.underlying_price = pos_or_panic!(150.0);
let price = spread_black_scholes(&option).unwrap();
let intrinsic = dec!(150.0) - dec!(100.0);
assert!(
price >= intrinsic * dec!(0.9),
"Deep ITM spread call should be close to intrinsic value"
);
}
#[test]
fn test_spread_deep_otm_call() {
let mut option = create_spread_option(pos_or_panic!(50.0), OptionStyle::Call);
option.underlying_price = pos_or_panic!(80.0);
let price = spread_black_scholes(&option).unwrap();
assert!(
price < dec!(5.0),
"Deep OTM spread call should have small value"
);
}
#[test]
fn test_spread_negative_correlation() {
let mut option = create_spread_option(pos_or_panic!(5.0), OptionStyle::Call);
if let Some(ref mut params) = option.exotic_params {
params.spread_correlation = Some(dec!(-0.5));
}
let price = spread_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Spread option with negative correlation should have positive value"
);
}
}
#[cfg(test)]
mod tests_kirk_zero_volatility {
use super::*;
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::model::option::ExoticParams;
use optionstratlib_core::{model::Positive, pos_or_panic};
use rust_decimal_macros::dec;
fn zero_vol_spread(option_style: OptionStyle, dividend_yield: Positive) -> Options {
Options::new(
OptionType::Spread {
second_asset: Positive::HUNDRED,
},
Side::Long,
"TEST".to_string(),
pos_or_panic!(25.0),
ExpirationDate::Days(pos_or_panic!(365.0)),
pos_or_panic!(0.2),
Positive::ONE,
pos_or_panic!(130.0),
Decimal::ZERO,
option_style,
dividend_yield,
Some(ExoticParams {
spot_prices: None,
spot_min: None,
spot_max: None,
cliquet_local_cap: None,
cliquet_local_floor: None,
cliquet_global_cap: None,
cliquet_global_floor: None,
rainbow_second_asset_price: None,
rainbow_second_asset_volatility: None,
rainbow_second_asset_dividend: None,
rainbow_correlation: None,
spread_second_asset_volatility: Some(pos_or_panic!(0.25)),
spread_second_asset_dividend: Some(Positive::ZERO),
spread_correlation: Some(Decimal::ONE),
quanto_fx_volatility: None,
quanto_fx_correlation: None,
quanto_foreign_rate: None,
foreign_rate: None,
exchange_second_asset_volatility: None,
exchange_second_asset_dividend: None,
exchange_correlation: None,
}),
)
}
fn price(option: &Options) -> Decimal {
match spread_black_scholes(option) {
Ok(price) => price,
Err(e) => panic!("the spread should price: {e}"),
}
}
#[test]
fn test_zero_volatility_call_uses_present_values_not_spot_moneyness() {
let call = price(&zero_vol_spread(OptionStyle::Call, pos_or_panic!(0.10)));
assert_eq!(call, Decimal::ZERO, "zero-vol call priced at {call}");
let put = price(&zero_vol_spread(OptionStyle::Put, pos_or_panic!(0.10)));
assert!(
put > dec!(7.37) && put < dec!(7.38),
"zero-vol put priced at {put}, expected the discounted intrinsic near 7.371"
);
}
#[test]
fn test_zero_volatility_put_is_worthless_when_the_call_is_in_the_money() {
let put = price(&zero_vol_spread(OptionStyle::Put, Positive::ZERO));
assert_eq!(put, Decimal::ZERO, "put priced at {put}");
let call = price(&zero_vol_spread(OptionStyle::Call, Positive::ZERO));
assert_eq!(call, dec!(5), "call priced at {call}");
}
#[test]
fn test_zero_volatility_step_flips_with_the_carry() {
let call = price(&zero_vol_spread(OptionStyle::Call, Positive::ZERO));
let put = price(&zero_vol_spread(OptionStyle::Put, Positive::ZERO));
assert!(call > Decimal::ZERO && put.is_zero(), "{call} / {put}");
let call = price(&zero_vol_spread(OptionStyle::Call, pos_or_panic!(0.10)));
let put = price(&zero_vol_spread(OptionStyle::Put, pos_or_panic!(0.10)));
assert!(call.is_zero() && put > Decimal::ZERO, "{call} / {put}");
}
}