use crate::error::PricingError;
use crate::kernels::discount_factor;
use crate::pricing::utils::wiener_sqrt_dt;
use num_traits::FromPrimitive;
use optionstratlib_core::model::Options;
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{
d_add, d_div, d_mul, d_sub, decimal_to_f64, finite_decimal,
};
use optionstratlib_core::model::types::{OptionStyle, Side};
use rand::Rng;
use rand_distr::{Distribution, Normal};
use rust_decimal::Decimal;
use std::num::NonZeroUsize;
use tracing::instrument;
#[instrument(skip(option, rng), fields(
steps = steps.get(),
simulations = simulations.get(),
strike = %option.strike_price,
style = ?option.option_style,
side = ?option.side,
))]
pub fn monte_carlo_option_pricing<R: Rng + ?Sized>(
option: &Options,
steps: NonZeroUsize, simulations: NonZeroUsize, rng: &mut R,
) -> Result<Decimal, PricingError> {
let steps_raw = steps.get();
let simulations_raw = simulations.get();
let steps_dec = Decimal::from_f64(steps_raw as f64)
.ok_or_else(|| PricingError::non_finite("pricing::monte_carlo::steps", steps_raw as f64))?;
let dt = option
.expiration_date
.get_years()?
.checked_div_dec(steps_dec)?;
let mut payoff_sum = 0.0;
let dt_dec = dt.to_dec();
let carry = d_sub(
option.risk_free_rate,
option.dividend_yield.to_dec(),
"pricing::monte_carlo::gbm::carry",
)?;
let drift = d_mul(carry, dt_dec, "pricing::monte_carlo::gbm::drift")?;
let one_plus_drift = decimal_to_f64(d_add(
Decimal::ONE,
drift,
"pricing::monte_carlo::gbm::growth_drift",
)?)?;
let sigma = decimal_to_f64(option.implied_volatility.to_dec())?;
let spot = decimal_to_f64(option.underlying_price.to_dec())?;
let strike = decimal_to_f64(option.strike_price.to_dec())?;
let sqrt_dt = decimal_to_f64(wiener_sqrt_dt(dt_dec)?)?;
let normal = Normal::new(0.0, 1.0).map_err(|e| {
PricingError::from(optionstratlib_core::error::DecimalError::arithmetic_error(
"Normal::new(0.0, 1.0)",
&e.to_string(),
))
})?;
for _ in 0..simulations_raw {
let mut st = spot;
for _ in 0..steps_raw {
let sample = normal.sample(rng);
let w = sample * sqrt_dt;
let diffusion = sigma * w;
st *= one_plus_drift + diffusion;
}
if !st.is_finite() {
return Err(PricingError::non_finite(
"pricing::monte_carlo::gbm::step",
st,
));
}
let payoff = match option.option_style {
OptionStyle::Call => st - strike,
OptionStyle::Put => strike - st,
}
.max(0.0);
if !payoff.is_finite() {
return Err(PricingError::non_finite(
"pricing::monte_carlo::gbm::payoff",
payoff,
));
}
payoff_sum += payoff;
}
let rate_f64 = decimal_to_f64(option.risk_free_rate)
.map_err(|_| PricingError::non_finite("pricing::monte_carlo::rate_f64::cast", f64::NAN))?;
if !rate_f64.is_finite() {
return Err(PricingError::non_finite(
"pricing::monte_carlo::rate_f64",
rate_f64,
));
}
let years = decimal_to_f64(option.expiration_date.get_years()?.to_dec())?;
if !years.is_finite() {
return Err(PricingError::non_finite(
"pricing::monte_carlo::years",
years,
));
}
let discount = (-rate_f64 * years).exp(); if !discount.is_finite() {
return Err(PricingError::non_finite(
"pricing::monte_carlo::discount",
discount,
));
}
let average_payoff = (payoff_sum / simulations_raw as f64) * discount;
if !average_payoff.is_finite() {
return Err(PricingError::non_finite(
"pricing::monte_carlo::average_payoff",
average_payoff,
));
}
let long_price = finite_decimal(average_payoff).ok_or_else(|| {
PricingError::non_finite("pricing::monte_carlo::average_payoff::cast", average_payoff)
})?;
Ok(match option.side {
Side::Long => long_price,
Side::Short => -long_price,
})
}
pub fn price_option_monte_carlo(
option: &Options,
final_prices: &[Positive],
) -> Result<Positive, PricingError> {
let num_simulations = final_prices.len();
if num_simulations == 0 {
return Ok(Positive::ZERO);
}
let discount_factor = discount_factor(
option.risk_free_rate,
option.expiration_date.get_years()?.to_dec(),
"pricing::monte_carlo::discount_exponent",
"pricing::monte_carlo::discount_factor",
)?;
let total_payoff = final_prices
.iter()
.try_fold(Decimal::ZERO, |sum, final_price| {
let payoff = option.payoff_at_price(final_price)?;
Ok::<Decimal, PricingError>(d_add(sum, payoff, "pricing::monte_carlo::total_payoff")?)
})?;
let n_dec = Decimal::from_usize(num_simulations).ok_or_else(|| {
PricingError::method_error(
"price_option_monte_carlo",
&format!("num_simulations not representable as Decimal: {num_simulations}"),
)
})?;
let mean_payoff = d_div(total_payoff, n_dec, "pricing::monte_carlo::mean")?;
let avg_payoff = d_mul(discount_factor, mean_payoff, "pricing::monte_carlo::price")?;
Ok(Positive::new_decimal(avg_payoff.abs())?)
}
#[cfg(test)]
mod tests {
use super::*;
use optionstratlib_core::constants::DAYS_IN_A_YEAR;
use optionstratlib_core::constants::ZERO;
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use optionstratlib_core::utils::{DETERMINISTIC_RNG_DEFAULT_SEED, deterministic_rng};
use optionstratlib_core::{assert_decimal_eq, f2du};
use optionstratlib_core::{model::Positive, pos_or_panic};
use rust_decimal::MathematicalOps;
use rust_decimal_macros::dec;
const PINNED_SEEDED_PRICE: Decimal = dec!(9.88846375599113);
fn create_test_option() -> Options {
Options {
option_type: OptionType::European,
side: Side::Long,
underlying_symbol: "TEST".to_string(),
strike_price: Positive::HUNDRED,
expiration_date: ExpirationDate::Days(DAYS_IN_A_YEAR), implied_volatility: pos_or_panic!(0.2),
quantity: Positive::ONE,
contract_size: Positive::ONE,
underlying_price: Positive::HUNDRED,
risk_free_rate: dec!(0.05),
option_style: OptionStyle::Call,
dividend_yield: Positive::ZERO,
exotic_params: None,
}
}
#[test]
fn test_monte_carlo_option_pricing_at_the_money() {
let option = create_test_option();
let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(252),
optionstratlib_core::nz!(1000),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let expected_price = dec!(9.100); assert_decimal_eq!(price, expected_price, dec!(5));
}
#[test]
fn test_monte_carlo_option_pricing_zero_volatility() {
let mut option = create_test_option();
option.implied_volatility = Positive::ZERO;
let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(25),
optionstratlib_core::nz!(100),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let expected_price = f64::max(
(option.underlying_price - option.strike_price * (-option.risk_free_rate).exp()).into(),
ZERO,
);
assert_decimal_eq!(price, f2du!(expected_price).unwrap(), dec!(0.1));
}
#[test]
fn test_monte_carlo_option_pricing_high_volatility() {
let mut option = create_test_option();
option.implied_volatility = pos_or_panic!(0.5);
let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(252),
optionstratlib_core::nz!(100),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
assert!(price > dec!(10.0));
}
#[test]
fn test_monte_carlo_option_pricing_short_expiration() {
let mut option = create_test_option();
option.expiration_date = ExpirationDate::Days(pos_or_panic!(30.0)); let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(30),
optionstratlib_core::nz!(100),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
assert!(price < dec!(5.0));
}
#[test]
fn test_monte_carlo_option_pricing_same_seed_identical_price() {
let option = create_test_option();
let price = |seed: u64| {
monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(50),
optionstratlib_core::nz!(200),
&mut deterministic_rng(seed),
)
.unwrap()
};
assert_eq!(price(7), price(7));
assert_ne!(price(7), price(8));
}
#[test]
fn test_monte_carlo_option_pricing_seeded_regression_pinned() {
let option = create_test_option();
let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(500),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
assert_decimal_eq!(price, PINNED_SEEDED_PRICE, dec!(1e-9));
}
#[test]
fn test_monte_carlo_option_pricing_seeded_converges_to_black_scholes() {
let option = create_test_option();
let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(4000),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
assert_decimal_eq!(price, dec!(10.4506), dec!(0.92));
}
#[test]
fn test_monte_carlo_option_pricing_dividend_yield_converges_to_black_scholes() {
use crate::pricing::black_scholes_model::black_scholes;
let mut option = create_test_option();
option.dividend_yield = pos_or_panic!(0.03);
let price = monte_carlo_option_pricing(
&option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(20_000),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let reference = black_scholes(&option).unwrap();
assert_decimal_eq!(price, reference, dec!(0.41));
}
fn seeded_price(option: &Options) -> Decimal {
monte_carlo_option_pricing(
option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(20_000),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap()
}
#[test]
fn test_monte_carlo_option_pricing_put_converges_to_black_scholes() {
use crate::pricing::black_scholes_model::black_scholes;
let mut option = create_test_option();
option.option_style = OptionStyle::Put;
let reference = black_scholes(&option).unwrap();
assert_decimal_eq!(reference, dec!(5.5735), dec!(0.0001));
assert_decimal_eq!(seeded_price(&option), reference, dec!(0.28));
}
#[test]
fn test_monte_carlo_option_pricing_call_converges_to_black_scholes_20k() {
use crate::pricing::black_scholes_model::black_scholes;
let option = create_test_option();
let reference = black_scholes(&option).unwrap();
assert_decimal_eq!(seeded_price(&option), reference, dec!(0.46));
}
#[test]
fn test_monte_carlo_option_pricing_put_call_parity() {
let mut call = create_test_option();
call.dividend_yield = pos_or_panic!(0.03);
let mut put = call.clone();
put.option_style = OptionStyle::Put;
let forward = dec!(100) * (-dec!(0.03)).exp() - dec!(100) * (-dec!(0.05)).exp();
assert_decimal_eq!(forward, dec!(1.9216), dec!(0.0001));
let parity = seeded_price(&call) - seeded_price(&put);
assert_decimal_eq!(parity, forward, dec!(0.61));
}
#[test]
fn test_monte_carlo_option_pricing_short_is_negated_long() {
for style in [OptionStyle::Call, OptionStyle::Put] {
let mut long = create_test_option();
long.option_style = style;
let mut short = long.clone();
short.side = Side::Short;
let long_price = seeded_price(&long);
assert!(long_price > Decimal::ZERO, "{style:?}: {long_price}");
assert_eq!(seeded_price(&short), -long_price, "{style:?}");
}
}
}
#[cfg(test)]
mod tests_price_option_monte_carlo {
use super::*;
use optionstratlib_core::model::utils::create_sample_option;
use optionstratlib_core::model::{ExpirationDate, OptionStyle, Side};
use optionstratlib_core::{assert_pos_relative_eq, model::Positive, pos_or_panic};
use rust_decimal_macros::dec;
#[test]
fn test_empty_prices_returns_zero() {
let option = create_sample_option(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED,
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
);
let empty_prices = &[];
let result = price_option_monte_carlo(&option, empty_prices);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Positive::ZERO);
}
#[test]
fn test_price_option_monte_carlo_unrepresentable_payoff_returns_error() {
let mut option = create_sample_option(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED,
pos_or_panic!(1000.0),
Positive::HUNDRED,
pos_or_panic!(0.2),
);
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
let prices = vec![pos_or_panic!(110.0), pos_or_panic!(1e27)];
let result = price_option_monte_carlo(&option, &prices);
assert!(
matches!(result, Err(PricingError::Options(_))),
"unrepresentable payoff: {result:?}"
);
}
#[test]
fn test_call_option_pricing() {
let mut option = create_sample_option(
OptionStyle::Call,
Side::Long,
Positive::HUNDRED,
Positive::ONE,
Positive::HUNDRED,
pos_or_panic!(0.2),
);
option.risk_free_rate = dec!(0.05);
option.dividend_yield = pos_or_panic!(0.02);
option.expiration_date = ExpirationDate::Days(pos_or_panic!(365.0));
let prices = vec![
pos_or_panic!(110.0),
pos_or_panic!(90.0),
pos_or_panic!(105.0),
];
let result = price_option_monte_carlo(&option, &prices);
assert!(result.is_ok());
assert_pos_relative_eq!(
result.unwrap(),
pos_or_panic!(4.75614712),
pos_or_panic!(0.001)
);
}
}
#[cfg(test)]
mod tests_f64_path_kernel {
use super::*;
use crate::pricing::utils::wiener_increment;
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::model::types::OptionType;
use optionstratlib_core::pos_or_panic;
use optionstratlib_core::utils::deterministic_rng;
use rust_decimal_macros::dec;
fn decimal_path_price<R: Rng + ?Sized>(
option: &Options,
steps: usize,
simulations: usize,
rng: &mut R,
) -> Decimal {
let steps_dec = Decimal::from(steps);
let dt = option.expiration_date.get_years().unwrap().to_dec() / steps_dec;
let carry = option.risk_free_rate - option.dividend_yield.to_dec();
let growth_drift = Decimal::ONE + carry * dt;
let sigma = option.implied_volatility.to_dec();
let strike = option.strike_price.to_dec();
let sqrt_dt = wiener_sqrt_dt(dt).unwrap();
let mut payoff_sum = 0.0;
for _ in 0..simulations {
let mut st = option.underlying_price.to_dec();
for _ in 0..steps {
let w = wiener_increment(sqrt_dt, rng).unwrap();
st *= growth_drift + sigma * w;
}
let payoff = match option.option_style {
OptionStyle::Call => st - strike,
OptionStyle::Put => strike - st,
}
.max(Decimal::ZERO);
payoff_sum += decimal_to_f64(payoff).unwrap();
}
let rate = decimal_to_f64(option.risk_free_rate).unwrap();
let years = decimal_to_f64(option.expiration_date.get_years().unwrap().to_dec()).unwrap();
let price =
finite_decimal(payoff_sum / simulations as f64 * (-rate * years).exp()).unwrap();
match option.side {
Side::Long => price,
Side::Short => -price,
}
}
fn option(spot: Decimal, sigma: Decimal, style: OptionStyle, side: Side) -> Options {
Options {
option_type: OptionType::European,
side,
underlying_symbol: "TEST".to_string(),
strike_price: Positive::HUNDRED,
expiration_date: ExpirationDate::Days(pos_or_panic!(90.0)),
implied_volatility: Positive::new_decimal(sigma).unwrap(),
quantity: Positive::ONE,
contract_size: Positive::ONE,
underlying_price: Positive::new_decimal(spot).unwrap(),
risk_free_rate: dec!(0.05),
option_style: style,
dividend_yield: pos_or_panic!(0.02),
exotic_params: None,
}
}
#[test]
fn test_f64_path_kernel_within_the_owner_bound() {
let steps = NonZeroUsize::new(30).unwrap();
let simulations = NonZeroUsize::new(400).unwrap();
let mut worst = Decimal::ZERO;
for spot in [dec!(70), dec!(95), dec!(100), dec!(105), dec!(140)] {
for sigma in [dec!(0.05), dec!(0.2), dec!(0.8)] {
for style in [OptionStyle::Call, OptionStyle::Put] {
for side in [Side::Long, Side::Short] {
let option = option(spot, sigma, style, side);
let fast = monte_carlo_option_pricing(
&option,
steps,
simulations,
&mut deterministic_rng(859),
)
.unwrap();
let reference = decimal_path_price(
&option,
steps.get(),
simulations.get(),
&mut deterministic_rng(859),
);
let scale = reference.abs().max(dec!(0.01));
let error = (fast - reference).abs() / scale;
assert!(
error <= dec!(0.000000000001),
"S={spot} sigma={sigma} {style:?} {side:?}: {fast} vs {reference}"
);
worst = worst.max(error);
}
}
}
}
assert!(worst < dec!(0.0000000000005), "worst {worst}");
}
}