use crate::error::PricingError;
use crate::kernels::{big_n, d1, d2, discount_factor};
use optionstratlib_core::model::Options;
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{
d_add, d_div, d_exp, d_ln, d_mul, d_sqrt, d_sub, decimal_to_f64,
};
use optionstratlib_core::model::types::OptionType;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
pub fn chooser_black_scholes(option: &Options) -> Result<Decimal, PricingError> {
match &option.option_type {
OptionType::Chooser { choice_date } => {
simple_chooser_price(option, decimal_to_f64(choice_date.to_dec())?)
}
_ => Err(PricingError::other(
"chooser_black_scholes requires OptionType::Chooser",
)),
}
}
fn simple_chooser_price(option: &Options, choice_date_days: f64) -> Result<Decimal, PricingError> {
let s = option.underlying_price;
let k = option.strike_price;
let r = option.risk_free_rate;
let q = option.dividend_yield.to_dec();
let sigma = option.implied_volatility;
let t_big = option
.expiration_date
.get_years()
.map_err(|e| PricingError::other(&e.to_string()))?;
let t_choice = Positive::new(choice_date_days / 365.0)?;
if t_choice >= t_big {
return price_at_choice_equals_expiry(option);
}
if t_big == Positive::ZERO {
let call_intrinsic =
d_sub(s.to_dec(), k.to_dec(), "pricing::chooser::intrinsic::call")?.max(Decimal::ZERO);
let put_intrinsic =
d_sub(k.to_dec(), s.to_dec(), "pricing::chooser::intrinsic::put")?.max(Decimal::ZERO);
return Ok(apply_side(call_intrinsic.max(put_intrinsic), option));
}
if sigma == Positive::ZERO {
let discount_t = discount_factor(
r,
t_big.to_dec(),
"pricing::chooser::zero_vol::neg_rt",
"pricing::chooser::zero_vol::discount",
)?;
let forward = d_mul(
s.to_dec(),
d_exp(
d_mul(
d_sub(r, q, "pricing::chooser::zero_vol::carry")?,
t_big.to_dec(),
"pricing::chooser::zero_vol::carry_t",
)?,
"pricing::chooser::zero_vol::growth",
)?,
"pricing::chooser::zero_vol::forward",
)?;
let call_intrinsic = d_sub(
forward,
k.to_dec(),
"pricing::chooser::zero_vol::call::intrinsic",
)?
.max(Decimal::ZERO);
let put_intrinsic = d_sub(
k.to_dec(),
forward,
"pricing::chooser::zero_vol::put::intrinsic",
)?
.max(Decimal::ZERO);
let call_val = d_mul(
call_intrinsic,
discount_t,
"pricing::chooser::zero_vol::call::discounted",
)?;
let put_val = d_mul(
put_intrinsic,
discount_t,
"pricing::chooser::zero_vol::put::discounted",
)?;
return Ok(apply_side(call_val.max(put_val), option));
}
let b = d_sub(r, q, "pricing::chooser::carry")?;
let t_big_dec = t_big.to_dec();
let t_choice_dec = t_choice.to_dec();
let sqrt_t_choice = d_sqrt(t_choice_dec, "pricing::chooser::sqrt_t_choice")?;
let d1_val = d1(s, k, b, t_big, sigma)
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
let d2_val = d2(s, k, b, t_big, sigma)
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
let sigma_dec = sigma.to_dec();
let sigma_sqrt_t_choice = d_mul(
sigma_dec,
sqrt_t_choice,
"pricing::chooser::sigma_sqrt_t_choice",
)?;
let carry_to_expiry = d_mul(b, t_big_dec, "pricing::chooser::carry_to_expiry")?;
let half_variance_to_choice = d_mul(
d_div(
d_mul(sigma_dec, sigma_dec, "pricing::chooser::variance")?,
dec!(2),
"pricing::chooser::half_variance",
)?,
t_choice_dec,
"pricing::chooser::half_variance_to_choice",
)?;
let drift = d_add(
carry_to_expiry,
half_variance_to_choice,
"pricing::chooser::drift",
)?;
let moneyness = d_div(s.to_dec(), k.to_dec(), "pricing::chooser::moneyness")?;
let y1_numerator = if moneyness.is_zero() {
None
} else {
Some(d_add(
d_ln(moneyness, "pricing::chooser::log_moneyness")?,
drift,
"pricing::chooser::y1_numerator",
)?)
};
let (n_neg_y1, n_neg_y2) = match (y1_numerator, sigma_sqrt_t_choice.is_zero()) {
(None, _) => (Decimal::ONE, Decimal::ONE),
(Some(numerator), true) => {
if numerator < Decimal::ZERO {
(Decimal::ONE, Decimal::ONE)
} else {
(Decimal::ZERO, Decimal::ZERO)
}
}
(Some(numerator), false) => {
let y1 = d_div(numerator, sigma_sqrt_t_choice, "pricing::chooser::y1")?;
let y2 = d_sub(y1, sigma_sqrt_t_choice, "pricing::chooser::y2")?;
(big_n(-y1)?, big_n(-y2)?)
}
};
let n_d1 = big_n(d1_val)?;
let n_d2 = big_n(d2_val)?;
let dividend_discount_t = discount_factor(
q,
t_big_dec,
"pricing::chooser::neg_qt",
"pricing::chooser::dividend_discount_t",
)?;
let discount_t = discount_factor(
r,
t_big_dec,
"pricing::chooser::neg_rt",
"pricing::chooser::discount_t",
)?;
let s_pv = d_mul(
s.to_dec(),
dividend_discount_t,
"pricing::chooser::price::s_pv",
)?;
let k_pv = d_mul(k.to_dec(), discount_t, "pricing::chooser::price::k_pv")?;
let leg_s_t = d_mul(s_pv, n_d1, "pricing::chooser::price::leg_s_t")?;
let leg_k_t = d_mul(k_pv, n_d2, "pricing::chooser::price::leg_k_t")?;
let leg_k_choice = d_mul(k_pv, n_neg_y2, "pricing::chooser::price::leg_k_choice")?;
let leg_s_choice = d_mul(s_pv, n_neg_y1, "pricing::chooser::price::leg_s_choice")?;
let diff1 = d_sub(leg_s_t, leg_k_t, "pricing::chooser::price::diff1")?;
let diff2 = d_add(diff1, leg_k_choice, "pricing::chooser::price::diff2")?;
let price = d_sub(diff2, leg_s_choice, "pricing::chooser::price")?;
Ok(apply_side(price.max(Decimal::ZERO), option))
}
fn price_at_choice_equals_expiry(option: &Options) -> Result<Decimal, PricingError> {
let s = option.underlying_price;
let k = option.strike_price;
let r = option.risk_free_rate;
let q = option.dividend_yield.to_dec();
let sigma = option.implied_volatility;
let t = option
.expiration_date
.get_years()
.map_err(|e| PricingError::other(&e.to_string()))?;
if t == Positive::ZERO {
let call_intrinsic = d_sub(
s.to_dec(),
k.to_dec(),
"pricing::chooser::expiry::call::intrinsic",
)?
.max(Decimal::ZERO);
let put_intrinsic = d_sub(
k.to_dec(),
s.to_dec(),
"pricing::chooser::expiry::put::intrinsic",
)?
.max(Decimal::ZERO);
return Ok(apply_side(call_intrinsic.max(put_intrinsic), option));
}
let b = d_sub(r, q, "pricing::chooser::expiry::carry")?;
let d1_val = d1(s, k, b, t, sigma)
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
let d2_val = d2(s, k, b, t, sigma)
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
let n_d1 = big_n(d1_val)?;
let n_d2 = big_n(d2_val)?;
let n_neg_d1 = big_n(-d1_val)?;
let n_neg_d2 = big_n(-d2_val)?;
let t_dec = t.to_dec();
let dividend_discount = discount_factor(
q,
t_dec,
"pricing::chooser::expiry::neg_qt",
"pricing::chooser::expiry::dividend_discount",
)?;
let discount = discount_factor(
r,
t_dec,
"pricing::chooser::expiry::neg_rt",
"pricing::chooser::expiry::discount",
)?;
let s_pv = d_mul(
s.to_dec(),
dividend_discount,
"pricing::chooser::expiry::s_pv",
)?;
let k_pv = d_mul(k.to_dec(), discount, "pricing::chooser::expiry::k_pv")?;
let call_s_leg = d_mul(s_pv, n_d1, "pricing::chooser::expiry::call_s_leg")?;
let call_k_leg = d_mul(k_pv, n_d2, "pricing::chooser::expiry::call_k_leg")?;
let put_k_leg = d_mul(k_pv, n_neg_d2, "pricing::chooser::expiry::put_k_leg")?;
let put_s_leg = d_mul(s_pv, n_neg_d1, "pricing::chooser::expiry::put_s_leg")?;
let call_price = d_sub(call_s_leg, call_k_leg, "pricing::chooser::expiry::call")?;
let put_price = d_sub(put_k_leg, put_s_leg, "pricing::chooser::expiry::put")?;
let price = d_add(call_price, put_price, "pricing::chooser::expiry::price")?;
Ok(apply_side(price.max(Decimal::ZERO), option))
}
fn apply_side(price: Decimal, option: &Options) -> Decimal {
match option.side {
optionstratlib_core::model::types::Side::Long => price,
optionstratlib_core::model::types::Side::Short => -price,
}
}
#[cfg(test)]
mod tests {
use super::*;
use optionstratlib_core::assert_decimal_eq;
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::model::decimal::f64_to_decimal;
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use optionstratlib_core::pos_or_panic;
use rust_decimal_macros::dec;
fn create_chooser_option(choice_date_days: f64) -> Options {
Options::new(
OptionType::Chooser {
choice_date: pos_or_panic!(choice_date_days),
},
Side::Long,
"TEST".to_string(),
Positive::HUNDRED, ExpirationDate::Days(pos_or_panic!(182.5)), pos_or_panic!(0.25), Positive::ONE, Positive::HUNDRED, dec!(0.05), OptionStyle::Call, Positive::ZERO, None,
)
}
#[test]
fn test_simple_chooser() {
let option = create_chooser_option(45.0); let price = chooser_black_scholes(&option).unwrap();
assert!(
price > Decimal::ZERO,
"Chooser should be positive: {}",
price
);
}
#[test]
fn test_chooser_more_valuable_than_call() {
let chooser = create_chooser_option(45.0);
let chooser_price = chooser_black_scholes(&chooser).unwrap();
let call = Options::new(
OptionType::European,
Side::Long,
"TEST".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(182.5)),
pos_or_panic!(0.25),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
OptionStyle::Call,
Positive::ZERO,
None,
);
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
assert!(
chooser_price >= call_price,
"Chooser {} should be >= call {}",
chooser_price,
call_price
);
}
#[test]
fn test_chooser_more_valuable_than_put() {
let chooser = create_chooser_option(45.0);
let chooser_price = chooser_black_scholes(&chooser).unwrap();
let put = Options::new(
OptionType::European,
Side::Long,
"TEST".to_string(),
Positive::HUNDRED,
ExpirationDate::Days(pos_or_panic!(182.5)),
pos_or_panic!(0.25),
Positive::ONE,
Positive::HUNDRED,
dec!(0.05),
OptionStyle::Put,
Positive::ZERO,
None,
);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
assert!(
chooser_price >= put_price,
"Chooser {} should be >= put {}",
chooser_price,
put_price
);
}
#[test]
fn test_early_choice_date() {
let option = create_chooser_option(1.0);
let price = chooser_black_scholes(&option).unwrap();
assert!(price > Decimal::ZERO, "Early choice date price: {}", price);
}
#[test]
fn test_late_choice_date() {
let option = create_chooser_option(180.0);
let price = chooser_black_scholes(&option).unwrap();
assert!(price > Decimal::ZERO, "Late choice date price: {}", price);
}
#[test]
fn test_choice_at_expiry() {
let option = create_chooser_option(182.5);
let price = chooser_black_scholes(&option).unwrap();
assert!(price > Decimal::ZERO, "Choice at expiry price: {}", price);
}
#[test]
fn test_short_chooser_option() {
let mut option = create_chooser_option(45.0);
let long_price = chooser_black_scholes(&option).unwrap();
option.side = Side::Short;
let short_price = chooser_black_scholes(&option).unwrap();
assert_decimal_eq!(long_price, -short_price, dec!(1e-10));
}
#[test]
fn test_zero_time_to_expiry() {
let mut option = create_chooser_option(0.0);
option.expiration_date = ExpirationDate::Days(Positive::ZERO);
let price = chooser_black_scholes(&option).unwrap();
assert_decimal_eq!(price, Decimal::ZERO, dec!(0.01));
}
#[test]
fn test_itm_call_at_expiry() {
let mut option = create_chooser_option(0.0);
option.underlying_price = pos_or_panic!(110.0);
option.expiration_date = ExpirationDate::Days(Positive::ZERO);
let price = chooser_black_scholes(&option).unwrap();
assert_decimal_eq!(price, dec!(10.0), dec!(0.01));
}
#[test]
fn test_itm_put_at_expiry() {
let mut option = create_chooser_option(0.0);
option.underlying_price = pos_or_panic!(90.0);
option.expiration_date = ExpirationDate::Days(Positive::ZERO);
let price = chooser_black_scholes(&option).unwrap();
assert_decimal_eq!(price, dec!(10.0), dec!(0.01));
}
#[allow(clippy::too_many_arguments)]
fn chooser_with(
underlying: f64,
strike: f64,
expiry_days: f64,
choice_days: f64,
sigma: f64,
rate: Decimal,
dividend: f64,
) -> Options {
Options::new(
OptionType::Chooser {
choice_date: pos_or_panic!(choice_days),
},
Side::Long,
"TEST".to_string(),
pos_or_panic!(strike),
ExpirationDate::Days(pos_or_panic!(expiry_days)),
pos_or_panic!(sigma),
Positive::ONE,
pos_or_panic!(underlying),
rate,
OptionStyle::Call,
pos_or_panic!(dividend),
None,
)
}
fn european(
underlying: f64,
strike: f64,
expiry_days: f64,
sigma: f64,
rate: Decimal,
dividend: f64,
style: OptionStyle,
) -> Options {
Options::new(
OptionType::European,
Side::Long,
"TEST".to_string(),
pos_or_panic!(strike),
ExpirationDate::Days(pos_or_panic!(expiry_days)),
pos_or_panic!(sigma),
Positive::ONE,
pos_or_panic!(underlying),
rate,
style,
pos_or_panic!(dividend),
None,
)
}
#[test]
fn test_simple_chooser_haug_example_matches_reference() {
let option = chooser_with(50.0, 50.0, 182.5, 91.25, 0.25, dec!(0.08), 0.0);
let price = chooser_black_scholes(&option).unwrap();
assert_decimal_eq!(price, dec!(6.1071), dec!(1e-4));
}
#[test]
fn test_simple_chooser_equals_call_plus_forward_struck_put() {
let (s, k, expiry_days, choice_days, sigma, r) =
(50.0, 50.0, 182.5, 91.25, 0.25, dec!(0.08));
let chooser = chooser_with(s, k, expiry_days, choice_days, sigma, r, 0.0);
let chooser_price = chooser_black_scholes(&chooser).unwrap();
let call = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Call);
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
let carry_gap = 0.08 * (expiry_days - choice_days) / 365.0;
let adjusted_strike = k * (-carry_gap).exp();
let put = european(
s,
adjusted_strike,
choice_days,
sigma,
r,
0.0,
OptionStyle::Put,
);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
let parity = d_add(call_price, put_price, "test::chooser::parity").unwrap();
assert_decimal_eq!(chooser_price, parity, dec!(1e-6));
}
#[test]
fn test_simple_chooser_equals_call_plus_forward_struck_put_with_dividend() {
let (s, k, expiry_days, choice_days, sigma, r, q) =
(100.0, 95.0, 273.75, 91.25, 0.30, dec!(0.06), 0.03);
let chooser = chooser_with(s, k, expiry_days, choice_days, sigma, r, q);
let chooser_price = chooser_black_scholes(&chooser).unwrap();
let call = european(s, k, expiry_days, sigma, r, q, OptionStyle::Call);
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
let gap_years = (expiry_days - choice_days) / 365.0;
let adjusted_strike = k * (-(0.06 - q) * gap_years).exp();
let put = european(
s,
adjusted_strike,
choice_days,
sigma,
r,
q,
OptionStyle::Put,
);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
let scale = f64_to_decimal((-q * gap_years).exp()).unwrap();
let scaled_put = d_mul(put_price, scale, "test::chooser::scaled_put").unwrap();
let parity = d_add(call_price, scaled_put, "test::chooser::parity").unwrap();
assert_decimal_eq!(chooser_price, parity, dec!(1e-6));
}
#[test]
fn test_simple_chooser_zero_choice_horizon_follows_the_forward_not_the_spot() {
let (s, k, expiry_days, sigma, r) = (99.0, 100.0, 182.5, 0.25, dec!(0.08));
let option = chooser_with(s, k, expiry_days, 0.0, sigma, r, 0.0);
let price = chooser_black_scholes(&option).unwrap();
let call = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Call);
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
let put = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Put);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
assert!(
call_price > put_price,
"forward is above the strike, so the call must be richer: {call_price} vs {put_price}"
);
assert_decimal_eq!(price, call_price, dec!(1e-8));
}
#[test]
fn test_simple_chooser_zero_choice_horizon_deep_otm_call_returns_the_put() {
let (s, k, expiry_days, sigma, r) = (80.0, 100.0, 182.5, 0.25, dec!(0.08));
let option = chooser_with(s, k, expiry_days, 0.0, sigma, r, 0.0);
let price = chooser_black_scholes(&option).unwrap();
let put = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Put);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
assert_decimal_eq!(price, put_price, dec!(1e-8));
}
#[test]
fn test_simple_chooser_zero_choice_horizon_at_forward_parity_prices_the_tie() {
let (k, expiry_days, sigma, r) = (100.0, 182.5, 0.25, dec!(0.08));
let s = k * (-0.04f64).exp();
let option = chooser_with(s, k, expiry_days, 0.0, sigma, r, 0.0);
let price = chooser_black_scholes(&option).unwrap();
let call = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Call);
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
let put = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Put);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
assert_decimal_eq!(call_price, put_price, dec!(1e-8));
assert_decimal_eq!(price, call_price, dec!(1e-8));
}
#[test]
fn test_choice_at_expiry_equals_straddle() {
let (s, k, expiry_days, sigma, r) = (100.0, 100.0, 182.5, 0.25, dec!(0.05));
let option = chooser_with(s, k, expiry_days, expiry_days, sigma, r, 0.0);
let price = chooser_black_scholes(&option).unwrap();
let call = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Call);
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
let put = european(s, k, expiry_days, sigma, r, 0.0, OptionStyle::Put);
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
let straddle = d_add(call_price, put_price, "test::chooser::straddle").unwrap();
assert_decimal_eq!(price, straddle, dec!(1e-8));
}
#[test]
fn test_simple_chooser_converges_to_the_straddle_as_choice_nears_expiry() {
let (s, k, expiry_days, sigma, r) = (100.0, 100.0, 182.5, 0.25, dec!(0.05));
let price =
chooser_black_scholes(&chooser_with(s, k, expiry_days, 182.4, sigma, r, 0.0)).unwrap();
let straddle =
chooser_black_scholes(&chooser_with(s, k, expiry_days, expiry_days, sigma, r, 0.0))
.unwrap();
assert!(
price < straddle,
"an earlier choice cannot be worth more than choosing at expiry: {price} vs {straddle}"
);
assert_decimal_eq!(price, straddle, dec!(0.01));
}
#[test]
fn test_higher_vol_means_higher_chooser_value() {
let low_vol = create_chooser_option(45.0);
let low_vol_price = chooser_black_scholes(&low_vol).unwrap();
let mut high_vol = low_vol.clone();
high_vol.implied_volatility = pos_or_panic!(0.4);
let high_vol_price = chooser_black_scholes(&high_vol).unwrap();
assert!(
high_vol_price > low_vol_price,
"Higher vol {} should mean higher chooser value: {}",
high_vol_price,
low_vol_price
);
}
}