use crate::Options;
use crate::error::PricingError;
use crate::greeks::big_n;
use crate::model::decimal::{d_add, d_div, d_exp, d_ln, d_mul, d_sqrt, d_sub};
use crate::model::types::{OptionType, Side};
use rust_decimal::Decimal;
use rust_decimal::prelude::*;
use rust_decimal_macros::dec;
pub fn exchange_black_scholes(option: &Options) -> Result<Decimal, PricingError> {
let second_asset_price = match &option.option_type {
OptionType::Exchange { second_asset } => second_asset.to_dec(),
_ => {
return Err(PricingError::other(
"exchange_black_scholes requires OptionType::Exchange",
));
}
};
let params = option
.exotic_params
.as_ref()
.ok_or_else(|| PricingError::other("Exchange options require exotic_params"))?;
let sigma2 = params
.exchange_second_asset_volatility
.ok_or_else(|| PricingError::other("Missing exchange_second_asset_volatility"))?;
let q2 = params
.exchange_second_asset_dividend
.unwrap_or(positive::Positive::ZERO);
let rho = params
.exchange_correlation
.ok_or_else(|| PricingError::other("Missing exchange_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 q1 = Decimal::from(option.dividend_yield);
let sigma1 = Decimal::from(option.implied_volatility);
let t = Decimal::from(option.expiration_date.get_years()?);
let price = margrabe_formula(
s1,
s2,
q1,
Decimal::from(q2),
sigma1,
Decimal::from(sigma2),
rho,
t,
)?;
Ok(apply_side(price, option))
}
#[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::exchange::intrinsic")?.max(dec!(0.0)));
}
let sigma_sq = d_sub(
d_add(
d_mul(sigma1, sigma1, "pricing::exchange::var1")?,
d_mul(sigma2, sigma2, "pricing::exchange::var2")?,
"pricing::exchange::variance_sum",
)?,
d_mul(
d_mul(
d_mul(dec!(2.0), rho, "pricing::exchange::two_rho")?,
sigma1,
"pricing::exchange::two_rho_sigma1",
)?,
sigma2,
"pricing::exchange::covariance",
)?,
"pricing::exchange::sigma_sq",
)?;
let sigma = sigma_sq
.sqrt()
.ok_or_else(|| PricingError::other("Failed to compute combined volatility"))?;
let s1_pv = d_mul(
s1,
d_exp(
d_mul(-q1, t, "pricing::exchange::neg_q1t")?,
"pricing::exchange::discount1",
)?,
"pricing::exchange::s1_pv",
)?;
let s2_pv = d_mul(
s2,
d_exp(
d_mul(-q2, t, "pricing::exchange::neg_q2t")?,
"pricing::exchange::discount2",
)?,
"pricing::exchange::s2_pv",
)?;
let sqrt_t = d_sqrt(t, "pricing::exchange::sqrt_t")?;
let denominator = d_mul(sigma, sqrt_t, "pricing::exchange::denominator")?;
if sigma <= dec!(0.0) || denominator.is_zero() {
return Ok(d_sub(s1_pv, s2_pv, "pricing::exchange::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::exchange::log_s1")?,
d_ln(s2, "pricing::exchange::log_s2")?,
"pricing::exchange::log_ratio",
)?;
let d1 = d_div(
d_add(
log_ratio,
d_mul(
d_add(
d_sub(q2, q1, "pricing::exchange::carry")?,
d_div(
d_mul(sigma, sigma, "pricing::exchange::variance")?,
dec!(2.0),
"pricing::exchange::half_variance",
)?,
"pricing::exchange::drift_rate",
)?,
t,
"pricing::exchange::drift",
)?,
"pricing::exchange::d1_numerator",
)?,
denominator,
"pricing::exchange::d1",
)?;
let d2 = d_sub(d1, denominator, "pricing::exchange::d2")?;
let price = d_sub(
d_mul(s1_pv, big_n(d1)?, "pricing::exchange::leg1")?,
d_mul(s2_pv, big_n(d2)?, "pricing::exchange::leg2")?,
"pricing::exchange::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 crate::ExpirationDate;
use crate::model::option::ExoticParams;
use crate::model::types::OptionStyle;
use positive::{Positive, pos_or_panic};
use rust_decimal_macros::dec;
fn create_exchange_option() -> Options {
Options::new(
OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
},
Side::Long,
"TEST".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(90.0)),
pos_or_panic!(0.2),
Positive::ONE,
pos_or_panic!(105.0),
dec!(0.05),
OptionStyle::Call,
pos_or_panic!(0.02),
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: None,
spread_second_asset_dividend: None,
spread_correlation: None,
quanto_fx_volatility: None,
quanto_fx_correlation: None,
quanto_foreign_rate: None,
exchange_second_asset_volatility: Some(pos_or_panic!(0.25)),
exchange_second_asset_dividend: Some(pos_or_panic!(0.01)),
exchange_correlation: Some(dec!(0.5)),
}),
)
}
#[test]
fn test_exchange_option_positive_value() {
let option = create_exchange_option();
let price = exchange_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Exchange option should have positive value, got {}",
price
);
}
#[test]
fn test_exchange_option_atm() {
let mut option = create_exchange_option();
option.underlying_price = pos_or_panic!(100.0);
option.option_type = OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
};
let price = exchange_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"ATM exchange option should have positive time value"
);
}
#[test]
fn test_exchange_correlation_impact() {
let mut low_corr = create_exchange_option();
if let Some(ref mut params) = low_corr.exotic_params {
params.exchange_correlation = Some(dec!(0.0));
}
let mut high_corr = create_exchange_option();
if let Some(ref mut params) = high_corr.exotic_params {
params.exchange_correlation = Some(dec!(0.9));
}
let low_price = exchange_black_scholes(&low_corr).unwrap();
let high_price = exchange_black_scholes(&high_corr).unwrap();
assert!(
low_price > high_price,
"Lower correlation should give higher exchange option value"
);
}
#[test]
fn test_exchange_invalid_correlation() {
let mut option = create_exchange_option();
if let Some(ref mut params) = option.exotic_params {
params.exchange_correlation = Some(dec!(1.5));
}
let result = exchange_black_scholes(&option);
assert!(result.is_err(), "Should reject correlation > 1");
}
#[test]
fn test_exchange_missing_params() {
let option = Options::new(
OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
},
Side::Long,
"TEST".to_string(),
Positive::HUNDRED,
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 = exchange_black_scholes(&option);
assert!(result.is_err(), "Should fail without exotic_params");
}
#[test]
fn test_exchange_short_position() {
let mut option = create_exchange_option();
option.side = Side::Short;
let price = exchange_black_scholes(&option).unwrap();
assert!(
price < dec!(0.0),
"Short position should have negative value"
);
}
#[test]
fn test_exchange_deep_itm() {
let mut option = create_exchange_option();
option.underlying_price = pos_or_panic!(150.0);
option.option_type = OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
};
let price = exchange_black_scholes(&option).unwrap();
let intrinsic = dec!(150.0) - dec!(100.0);
assert!(
price >= intrinsic * dec!(0.8),
"Deep ITM exchange option should be close to intrinsic value"
);
}
#[test]
fn test_exchange_deep_otm() {
let mut option = create_exchange_option();
option.underlying_price = pos_or_panic!(50.0);
option.option_type = OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
};
let price = exchange_black_scholes(&option).unwrap();
assert!(
price < dec!(5.0),
"Deep OTM exchange option should have small value"
);
}
#[test]
fn test_exchange_negative_correlation() {
let mut option = create_exchange_option();
if let Some(ref mut params) = option.exotic_params {
params.exchange_correlation = Some(dec!(-0.5));
}
let price = exchange_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Exchange option with negative correlation should have positive value"
);
}
#[test]
fn test_exchange_zero_dividend() {
let mut option = create_exchange_option();
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.exchange_second_asset_dividend = Some(Positive::ZERO);
}
let price = exchange_black_scholes(&option).unwrap();
assert!(
price > dec!(0.0),
"Exchange option with zero dividends should have positive value"
);
}
#[test]
fn test_exchange_underflowing_ratio_with_offsetting_carry_prices_first_pv() {
let mut option = create_exchange_option();
option.underlying_price = Positive::new_decimal(Decimal::new(1, 27)).unwrap();
option.option_type = OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
};
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
option.implied_volatility = pos_or_panic!(0.2);
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.exchange_second_asset_volatility = Some(pos_or_panic!(0.2));
params.exchange_second_asset_dividend = Some(pos_or_panic!(100.0));
params.exchange_correlation = Some(dec!(0.5));
}
let price = exchange_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_exchange_underflowing_ratio_without_carry_is_zero() {
let mut option = create_exchange_option();
option.underlying_price = Positive::new_decimal(Decimal::new(1, 27)).unwrap();
option.option_type = OptionType::Exchange {
second_asset: pos_or_panic!(100.0),
};
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
option.implied_volatility = pos_or_panic!(0.2);
option.dividend_yield = Positive::ZERO;
if let Some(ref mut params) = option.exotic_params {
params.exchange_second_asset_volatility = Some(pos_or_panic!(0.2));
params.exchange_second_asset_dividend = Some(Positive::ZERO);
params.exchange_correlation = Some(dec!(0.5));
}
let price = exchange_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_exchange_perfect_correlation() {
let mut option = create_exchange_option();
if let Some(ref mut params) = option.exotic_params {
params.exchange_correlation = Some(dec!(1.0));
}
let price = exchange_black_scholes(&option).unwrap();
assert!(
price >= dec!(0.0),
"Exchange option with perfect correlation should have non-negative value"
);
}
}