use crate::error::PricingError;
use crate::kernels::discount_factor;
use crate::pricing::utils::simulate_returns;
use num_traits::FromPrimitive;
#[cfg(test)]
use num_traits::ToPrimitive;
use optionstratlib_core::error::DecimalError;
use optionstratlib_core::model::Options;
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{
d_add, d_div, d_mul, d_powd, d_sqrt, d_sub, d_sum_iter, decimal_to_f64, finite_decimal,
};
use rand::{Rng, RngExt};
use rand_distr::{Distribution, StandardNormal};
use rust_decimal::{Decimal, MathematicalOps};
use rust_decimal_macros::dec;
use std::num::NonZeroUsize;
use tracing::{debug, warn};
#[derive(Debug, Clone)]
pub struct TelegraphProcess {
lambda_up: Decimal,
lambda_down: Decimal,
current_state: i8,
}
impl TelegraphProcess {
#[must_use]
pub fn new<R: Rng + ?Sized>(lambda_up: Decimal, lambda_down: Decimal, rng: &mut R) -> Self {
let initial_state = if rng.random::<f64>() < 0.5 { 1 } else { -1 };
TelegraphProcess {
lambda_up,
lambda_down,
current_state: initial_state,
}
}
pub fn next_state<R: Rng + ?Sized>(
&mut self,
dt: Decimal,
rng: &mut R,
) -> Result<i8, PricingError> {
let lambda = if self.current_state == 1 {
self.lambda_down
} else {
self.lambda_up
};
if rng.random::<f64>() < flip_probability(lambda, dt)? {
self.current_state *= -1;
}
Ok(self.current_state)
}
#[must_use]
pub fn get_current_state(&self) -> i8 {
self.current_state
}
}
fn flip_probability(lambda: Decimal, dt: Decimal) -> Result<f64, PricingError> {
let probability = match (-lambda).checked_mul(dt) {
None if lambda.is_sign_negative() == dt.is_sign_negative() => Decimal::ONE,
None => Decimal::ZERO,
Some(lambda_dt) if lambda_dt < dec!(-11.7) => Decimal::ONE,
Some(lambda_dt) => match lambda_dt.checked_exp() {
Some(decay) => Decimal::ONE.checked_sub(decay).unwrap_or(Decimal::ZERO),
None if lambda_dt.is_sign_negative() => Decimal::ONE,
None => Decimal::ZERO,
},
};
Ok(decimal_to_f64(probability)?)
}
pub(crate) fn estimate_telegraph_parameters(
returns: &[Decimal],
threshold: Decimal,
) -> Result<(Decimal, Decimal), DecimalError> {
let first = returns.first().ok_or_else(|| {
DecimalError::invalid_value(0.0, "returns must contain at least one observation")
})?;
let mut current_state = if *first > threshold {
Decimal::ONE
} else {
Decimal::NEGATIVE_ONE
};
let mut current_duration = Decimal::ONE;
let mut up_durations = Vec::new();
let mut down_durations = Vec::new();
for &ret in returns.iter().skip(1) {
let new_state = if ret > threshold {
Decimal::ONE
} else {
Decimal::NEGATIVE_ONE
};
if new_state == current_state {
current_duration = d_add(
current_duration,
Decimal::ONE,
"pricing::telegraph::estimate::duration",
)?;
} else {
if current_state == Decimal::ONE {
up_durations.push(current_duration);
} else {
down_durations.push(current_duration);
}
current_state = new_state;
current_duration = Decimal::ONE;
}
}
if current_state == Decimal::ONE {
up_durations.push(current_duration);
} else {
down_durations.push(current_duration);
}
if down_durations.is_empty() {
return Err(DecimalError::InvalidValue {
value: 0.0,
reason: "No transitions from state +1 to -1 found. All returns are above threshold."
.to_string(),
});
}
if up_durations.is_empty() {
return Err(DecimalError::InvalidValue {
value: 0.0,
reason: "No transitions from state -1 to +1 found. All returns are below threshold."
.to_string(),
});
}
let sum_down = d_sum_iter(
down_durations.iter().copied(),
"pricing::telegraph::estimate::sum_down",
)?;
let sum_up = d_sum_iter(
up_durations.iter().copied(),
"pricing::telegraph::estimate::sum_up",
)?;
if sum_down == Decimal::ZERO {
return Err(DecimalError::InvalidValue {
value: 0.0,
reason: "Sum of down durations must be non-zero".to_string(),
});
}
if sum_up == Decimal::ZERO {
return Err(DecimalError::InvalidValue {
value: 0.0,
reason: "Sum of up durations must be non-zero".to_string(),
});
}
let down_len = Decimal::from_usize(down_durations.len()).ok_or_else(|| {
DecimalError::invalid_value(
down_durations.len() as f64,
"down_durations length not representable as Decimal",
)
})?;
let up_len = Decimal::from_usize(up_durations.len()).ok_or_else(|| {
DecimalError::invalid_value(
up_durations.len() as f64,
"up_durations length not representable as Decimal",
)
})?;
let lambda_up = d_mul(
d_div(
Decimal::ONE,
sum_down,
"pricing::telegraph::estimate::inv_sum_down",
)?,
down_len,
"pricing::telegraph::estimate::lambda_up",
)?;
let lambda_down = d_mul(
d_div(
Decimal::ONE,
sum_up,
"pricing::telegraph::estimate::inv_sum_up",
)?,
up_len,
"pricing::telegraph::estimate::lambda_down",
)?;
Ok((lambda_up, lambda_down))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RegimeVolatility {
sigma_plus: Positive,
sigma_minus: Positive,
}
impl RegimeVolatility {
pub fn new(sigma_plus: Positive, sigma_minus: Positive) -> Result<Self, PricingError> {
Ok(Self {
sigma_plus: strictly_positive_sigma("sigma_plus", sigma_plus)?,
sigma_minus: strictly_positive_sigma("sigma_minus", sigma_minus)?,
})
}
pub fn constant(sigma: Positive) -> Result<Self, PricingError> {
Self::new(sigma, sigma)
}
#[must_use]
#[inline]
pub fn sigma_plus(&self) -> Positive {
self.sigma_plus
}
#[must_use]
#[inline]
pub fn sigma_minus(&self) -> Positive {
self.sigma_minus
}
}
fn strictly_positive_sigma(
parameter: &'static str,
sigma: Positive,
) -> Result<Positive, PricingError> {
if sigma.is_zero() {
return Err(PricingError::invalid_parameter(
parameter,
sigma.to_dec(),
"a regime volatility must be strictly positive",
));
}
Ok(sigma)
}
fn non_negative_rate(
parameter: &'static str,
rate: Option<Decimal>,
) -> Result<Option<Decimal>, PricingError> {
match rate {
Some(value) if value.is_sign_negative() && !value.is_zero() => {
Err(PricingError::invalid_parameter(
parameter,
value,
"a transition rate must be non-negative",
))
}
_ => Ok(rate),
}
}
#[derive(Debug, Clone, Copy)]
struct RegimeStep {
drift_dt: f64,
diffusion: f64,
}
impl RegimeStep {
fn new(
carry: Decimal,
sigma: Positive,
dt: Decimal,
sqrt_dt: Decimal,
) -> Result<Self, PricingError> {
let sigma = sigma.to_dec();
let drift = d_sub(
carry,
d_mul(
dec!(0.5),
d_powd(sigma, Decimal::TWO, "pricing::telegraph::variance")?,
"pricing::telegraph::half_variance",
)?,
"pricing::telegraph::drift",
)?;
let drift_dt = d_mul(drift, dt, "pricing::telegraph::drift_dt")?;
let diffusion = d_mul(sigma, sqrt_dt, "pricing::telegraph::diffusion")?;
Ok(Self {
drift_dt: decimal_to_f64(drift_dt).map_err(|_| {
PricingError::method_error("telegraph", "drift * dt not representable as f64")
})?,
diffusion: decimal_to_f64(diffusion).map_err(|_| {
PricingError::method_error("telegraph", "sigma * sqrt(dt) not representable as f64")
})?,
})
}
}
pub const TELEGRAPH_PATHS: NonZeroUsize = match NonZeroUsize::new(10_000) {
Some(paths) => paths,
None => NonZeroUsize::MIN,
};
#[tracing::instrument(skip(option, rng), level = "debug")]
pub fn telegraph<R: Rng + ?Sized>(
option: &Options,
no_steps: NonZeroUsize,
no_paths: NonZeroUsize,
lambda_up: Option<Decimal>,
lambda_down: Option<Decimal>,
volatility: RegimeVolatility,
rng: &mut R,
) -> Result<Decimal, PricingError> {
let lambda_up = non_negative_rate("lambda_up", lambda_up)?;
let lambda_down = non_negative_rate("lambda_down", lambda_down)?;
let no_steps_raw = no_steps.get();
let no_paths_raw = no_paths.get();
let no_steps_dec = Decimal::from_usize(no_steps_raw).ok_or_else(|| {
PricingError::method_error("telegraph", &format!("invalid no_steps: {no_steps_raw}"))
})?;
let no_paths_dec = Decimal::from_usize(no_paths_raw).ok_or_else(|| {
PricingError::method_error("telegraph", &format!("invalid no_paths: {no_paths_raw}"))
})?;
let time_to_expiration = option.time_to_expiration()?.to_dec();
let dt = d_div(time_to_expiration, no_steps_dec, "pricing::telegraph::dt")?;
let one_over_252 = finite_decimal(1.0 / 252.0)
.ok_or_else(|| PricingError::non_finite("pricing::telegraph::one_over_252", 1.0 / 252.0))?;
let (lambda_up_temp, lambda_down_temp) = match (lambda_up, lambda_down) {
(None, None) => {
let returns = simulate_returns(
Decimal::ZERO,
option.implied_volatility,
100,
one_over_252,
rng,
)?;
estimate_telegraph_parameters(&returns, Decimal::ZERO)?
}
(Some(l_up), None) => {
let returns = simulate_returns(
Decimal::ZERO,
option.implied_volatility,
100,
one_over_252,
rng,
)?;
let (_, l_down) = estimate_telegraph_parameters(&returns, Decimal::ZERO)?;
(l_up, l_down)
}
(None, Some(l_down)) => {
let returns = simulate_returns(
Decimal::ZERO,
option.implied_volatility,
100,
one_over_252,
rng,
)?;
let (l_up, _) = estimate_telegraph_parameters(&returns, Decimal::ZERO)?;
(l_up, l_down)
}
(Some(l_up), Some(l_down)) => (l_up, l_down),
};
let flip_from_up = flip_probability(lambda_down_temp, dt)?;
let flip_from_down = flip_probability(lambda_up_temp, dt)?;
let carry = d_sub(
option.risk_free_rate,
option.dividend_yield.to_dec(),
"pricing::telegraph::carry",
)?;
let sqrt_dt = d_sqrt(dt, "pricing::telegraph::sqrt_dt")
.map_err(|_| PricingError::method_error("telegraph", "non-finite dt sqrt"))?;
let step_up = RegimeStep::new(carry, volatility.sigma_plus, dt, sqrt_dt)?;
let step_down = RegimeStep::new(carry, volatility.sigma_minus, dt, sqrt_dt)?;
let spot = option.underlying_price.to_dec();
let mut payoff_sum = Decimal::ZERO;
let mut payoff_sum_f64 = 0.0_f64;
let mut payoff_sum_sq_f64 = 0.0_f64;
for _ in 0..no_paths_raw {
let mut state_up = rng.random::<f64>() < 0.5;
let mut log_return = 0.0_f64;
for _ in 0..no_steps_raw {
let flip = if state_up {
flip_from_up
} else {
flip_from_down
};
if rng.random::<f64>() < flip {
state_up = !state_up;
}
let z: f64 = StandardNormal.sample(rng);
let step = if state_up { step_up } else { step_down };
log_return += step.drift_dt + step.diffusion * z;
}
let growth_f64 = log_return.exp(); let growth = finite_decimal(growth_f64)
.ok_or_else(|| PricingError::non_finite("pricing::telegraph::growth", growth_f64))?;
let terminal =
Positive::new_decimal(d_mul(spot, growth, "pricing::telegraph::terminal_price")?)?;
let payoff = option.payoff_at_price(&terminal)?;
payoff_sum = d_add(payoff_sum, payoff, "pricing::telegraph::payoff_sum")?;
let payoff_f64 = decimal_to_f64(payoff)?;
payoff_sum_f64 += payoff_f64;
payoff_sum_sq_f64 += payoff_f64 * payoff_f64;
}
let n = no_paths_raw as f64;
let mean_f64 = payoff_sum_f64 / n;
let variance_f64 = (payoff_sum_sq_f64 / n - mean_f64 * mean_f64).max(0.0);
let std_error_f64 = (variance_f64 / n).sqrt(); debug!(
paths = no_paths_raw,
steps = no_steps_raw,
payoff_mean = mean_f64,
payoff_std_error = std_error_f64,
"telegraph Monte-Carlo estimate"
);
let mean_payoff = d_div(payoff_sum, no_paths_dec, "pricing::telegraph::mean_payoff")?;
let discount = discount_factor(
option.risk_free_rate,
time_to_expiration,
"pricing::telegraph::discount_exponent",
"pricing::telegraph::discount",
)?;
let result = d_mul(mean_payoff, discount, "pricing::telegraph::price")?;
Ok(result)
}
#[cfg(test)]
fn next_state_ok<R: Rng + ?Sized>(tp: &mut TelegraphProcess, dt: Decimal, rng: &mut R) -> i8 {
match tp.next_state(dt, rng) {
Ok(state) => state,
Err(e) => panic!("next_state failed: {e:?}"),
}
}
#[cfg(test)]
fn regimes_20() -> RegimeVolatility {
RegimeVolatility::constant(optionstratlib_core::pos_or_panic!(0.2)).unwrap()
}
#[cfg(test)]
mod tests_telegraph_process_basis {
use super::*;
use optionstratlib_core::utils::{DETERMINISTIC_RNG_DEFAULT_SEED, deterministic_rng};
use optionstratlib_core::{model::Positive, pos_or_panic};
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use rust_decimal_macros::dec;
#[test]
fn test_telegraph_process_new() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let tp = TelegraphProcess::new(dec!(0.5), dec!(0.3), &mut rng);
assert_eq!(tp.lambda_up, dec!(0.5));
assert_eq!(tp.lambda_down, dec!(0.3));
assert!(tp.current_state == 1 || tp.current_state == -1);
}
#[test]
fn test_telegraph_process_next_state() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let mut tp = TelegraphProcess::new(Decimal::ONE, Decimal::ONE, &mut rng);
let _initial_state = tp.get_current_state();
let new_state = next_state_ok(&mut tp, dec!(0.1), &mut rng);
assert!(new_state == 1 || new_state == -1);
}
#[test]
fn test_next_state_empirical_flip_rate_matches_poisson() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let lambda_f = 0.5_f64;
let dt_f = 0.01_f64;
let mut tp = TelegraphProcess::new(dec!(0.5), dec!(0.5), &mut rng);
let n = 100_000_u64;
let mut prev = tp.get_current_state();
let mut flips: u64 = 0;
for _ in 0..n {
let next = next_state_ok(&mut tp, dec!(0.01), &mut rng);
if next != prev {
flips += 1;
}
prev = next;
}
let empirical = flips as f64 / n as f64;
let expected = 1.0 - (-lambda_f * dt_f).exp();
let std_err = (expected * (1.0 - expected) / n as f64).sqrt();
assert!(
(empirical - expected).abs() < 5.0 * std_err,
"empirical flip rate {empirical} differs from expected {expected} by more than 5σ ({})",
5.0 * std_err
);
assert!(empirical < 0.05, "flip rate suspiciously high: {empirical}");
}
#[test]
fn test_telegraph_process_get_current_state() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let tp = TelegraphProcess::new(dec!(0.5), dec!(0.5), &mut rng);
let state = tp.get_current_state();
assert!(state == 1 || state == -1);
}
#[test]
fn test_estimate_telegraph_parameters() {
let returns = vec![
dec!(-0.01),
dec!(0.02),
dec!(0.01),
dec!(-0.02),
dec!(0.03),
dec!(-0.01),
dec!(0.01),
dec!(-0.03),
];
let threshold = dec!(0.01);
let (lambda_up, lambda_down) = estimate_telegraph_parameters(&returns, threshold).unwrap();
assert!(lambda_up > Decimal::ZERO);
assert!(lambda_down > Decimal::ZERO);
}
#[test]
fn test_telegraph() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = Options {
option_type: OptionType::European,
side: Side::Long,
underlying_price: Positive::HUNDRED,
strike_price: Positive::ONE,
risk_free_rate: dec!(0.05),
option_style: OptionStyle::Call,
dividend_yield: Positive::ZERO,
implied_volatility: pos_or_panic!(0.2),
underlying_symbol: "".to_string(),
expiration_date: Default::default(),
quantity: Positive::ONE,
contract_size: Positive::ONE,
exotic_params: None,
};
let _price = telegraph(
&option,
optionstratlib_core::nz!(1000),
optionstratlib_core::nz!(1_000),
Some(dec!(0.7)),
Some(dec!(0.5)),
regimes_20(),
&mut rng,
);
}
}
#[cfg(test)]
mod tests_telegraph_process_extended {
use super::*;
use optionstratlib_core::utils::{DETERMINISTIC_RNG_DEFAULT_SEED, deterministic_rng};
use optionstratlib_core::{model::Positive, pos_or_panic};
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use rust_decimal_macros::dec;
fn create_mock_option() -> Options {
Options {
option_type: OptionType::European,
side: Side::Long,
underlying_price: Positive::HUNDRED,
strike_price: Positive::HUNDRED,
risk_free_rate: dec!(0.05),
option_style: OptionStyle::Call,
dividend_yield: Positive::ZERO,
implied_volatility: pos_or_panic!(0.2),
underlying_symbol: "".to_string(),
expiration_date: Default::default(),
quantity: Positive::ZERO,
contract_size: Positive::ONE,
exotic_params: None,
}
}
#[test]
fn test_telegraph_process_new() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let tp = TelegraphProcess::new(dec!(0.5), dec!(0.3), &mut rng);
assert_eq!(tp.lambda_up, dec!(0.5));
assert_eq!(tp.lambda_down, dec!(0.3));
assert!(tp.get_current_state() == 1 || tp.get_current_state() == -1);
}
#[test]
fn test_telegraph_process_next_state() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let mut tp = TelegraphProcess::new(dec!(1000.0), dec!(1000.0), &mut rng); let initial_state = tp.get_current_state();
let new_state = next_state_ok(&mut tp, dec!(0.1), &mut rng);
assert_ne!(initial_state, new_state);
}
#[test]
fn test_telegraph_process_get_current_state() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let tp = TelegraphProcess::new(dec!(0.5), dec!(0.5), &mut rng);
let state = tp.get_current_state();
assert!(state == 1 || state == -1);
}
#[test]
fn test_estimate_telegraph_parameters() {
let returns = vec![
dec!(-0.01),
dec!(0.02),
dec!(0.01),
dec!(-0.02),
dec!(0.03),
dec!(-0.01),
dec!(0.01),
dec!(-0.03),
];
let threshold = Decimal::ZERO;
let result = estimate_telegraph_parameters(&returns, threshold);
assert!(result.is_ok());
let (lambda_up, lambda_down) = result.unwrap();
assert!(lambda_up > Decimal::ZERO);
assert!(lambda_down > Decimal::ZERO);
}
#[test]
fn test_estimate_telegraph_parameters_all_positive() {
let returns = vec![
dec!(0.01),
dec!(0.02),
dec!(0.01),
dec!(0.02),
dec!(0.03),
dec!(0.01),
dec!(0.01),
dec!(0.03),
];
let threshold = Decimal::ZERO;
assert!(estimate_telegraph_parameters(&returns, threshold).is_err());
}
#[test]
fn test_estimate_telegraph_parameters_all_negative() {
let returns = vec![
dec!(-0.01),
dec!(-0.02),
dec!(-0.01),
dec!(-0.02),
dec!(-0.03),
dec!(-0.01),
dec!(-0.01),
dec!(-0.03),
];
let threshold = dec!(0.01);
assert!(estimate_telegraph_parameters(&returns, threshold).is_err());
}
#[test]
fn test_telegraph_with_provided_parameters() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = create_mock_option();
let _price = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut rng,
);
}
#[test]
fn test_telegraph_with_estimated_parameters() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = create_mock_option();
let _price = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
None,
None,
regimes_20(),
&mut rng,
);
}
#[test]
fn test_telegraph_with_one_estimated_parameter() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = create_mock_option();
let _price_up = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
Some(dec!(0.5)),
None,
regimes_20(),
&mut rng,
);
let _price_down = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
None,
Some(dec!(0.5)),
regimes_20(),
&mut rng,
);
}
#[test]
fn test_telegraph_different_no_steps() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = create_mock_option();
let _price_100 = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut rng,
);
let _price_1000 = telegraph(
&option,
optionstratlib_core::nz!(1000),
optionstratlib_core::nz!(1_000),
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut rng,
);
}
#[test]
fn test_regime_volatility_zero_sigma_rejected() {
for (plus, minus, name) in [
(Positive::ZERO, pos_or_panic!(0.2), "sigma_plus"),
(pos_or_panic!(0.2), Positive::ZERO, "sigma_minus"),
] {
match RegimeVolatility::new(plus, minus) {
Err(PricingError::InvalidParameter { parameter, .. }) => {
assert_eq!(parameter, name)
}
other => panic!("expected InvalidParameter for {name}, got {other:?}"),
}
}
assert!(RegimeVolatility::constant(Positive::ZERO).is_err());
}
#[test]
fn test_telegraph_negative_rate_rejected() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = create_mock_option();
for (up, down, name) in [
(Some(dec!(-0.5)), Some(dec!(0.5)), "lambda_up"),
(Some(dec!(0.5)), Some(dec!(-0.5)), "lambda_down"),
(None, Some(dec!(-0.1)), "lambda_down"),
] {
match telegraph(
&option,
optionstratlib_core::nz!(10),
optionstratlib_core::nz!(10),
up,
down,
regimes_20(),
&mut rng,
) {
Err(PricingError::InvalidParameter { parameter, .. }) => {
assert_eq!(parameter, name)
}
other => panic!("expected InvalidParameter for {name}, got {other:?}"),
}
}
}
#[test]
fn test_telegraph_zero_risk_free_rate() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let mut option = create_mock_option();
option.risk_free_rate = Decimal::ZERO;
let _price = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut rng,
);
}
#[test]
fn test_telegraph_zero_time_to_expiration() {
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let option = create_mock_option();
let price = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut rng,
)
.unwrap();
assert_eq!(
price,
option.payoff_at_price(&option.underlying_price).unwrap()
);
}
}
#[cfg(test)]
mod tests_telegraph_seeded {
use super::*;
use optionstratlib_core::model::ExpirationDate;
use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
use optionstratlib_core::pos_or_panic;
use optionstratlib_core::utils::{DETERMINISTIC_RNG_DEFAULT_SEED, deterministic_rng};
use rust_decimal_macros::dec;
const PINNED_TELEGRAPH_PRICE: Decimal = dec!(40.237916421487854754626272623);
const PINNED_TELEGRAPH_TOLERANCE: Decimal = dec!(1e-13);
fn option_30d() -> Options {
Options {
option_type: OptionType::European,
side: Side::Long,
underlying_price: Positive::HUNDRED,
strike_price: pos_or_panic!(60.0),
risk_free_rate: dec!(0.05),
option_style: OptionStyle::Call,
dividend_yield: Positive::ZERO,
implied_volatility: pos_or_panic!(0.2),
underlying_symbol: "TEST".to_string(),
expiration_date: ExpirationDate::Days(pos_or_panic!(30.0)),
quantity: Positive::ONE,
contract_size: Positive::ONE,
exotic_params: None,
}
}
fn state_path(seed: u64, steps: usize) -> Vec<i8> {
let mut rng = deterministic_rng(seed);
let mut tp = TelegraphProcess::new(dec!(1.0), dec!(2.0), &mut rng);
let mut path = vec![tp.get_current_state()];
path.extend((0..steps).map(|_| next_state_ok(&mut tp, dec!(0.1), &mut rng)));
path
}
#[test]
fn test_telegraph_process_same_seed_identical_state_path() {
assert_eq!(state_path(21, 500), state_path(21, 500));
assert_ne!(state_path(21, 500), state_path(22, 500));
}
#[test]
fn test_telegraph_process_new_seeded_initial_state_takes_both_values() {
let states: Vec<i8> = (0..64)
.map(|seed| {
TelegraphProcess::new(Decimal::ONE, Decimal::ONE, &mut deterministic_rng(seed))
.get_current_state()
})
.collect();
assert!(states.contains(&1));
assert!(states.contains(&-1));
}
#[test]
fn test_telegraph_process_seeded_occupancy_matches_stationary_law() {
let lambda_up = 2.0_f64;
let lambda_down = 1.0_f64;
let dt = 0.01_f64;
let mut rng = deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED);
let mut tp = TelegraphProcess::new(dec!(2.0), dec!(1.0), &mut rng);
let n = 200_000_u64;
let mut up: u64 = 0;
for _ in 0..n {
if next_state_ok(&mut tp, dec!(0.01), &mut rng) == 1 {
up += 1;
}
}
let p_up = 1.0 - (-lambda_up * dt).exp();
let p_down = 1.0 - (-lambda_down * dt).exp();
let expected = p_up / (p_up + p_down);
let share = up as f64 / n as f64;
assert!(
(share - expected).abs() < 0.043,
"share of time in +1 {share} differs from stationary {expected}"
);
}
#[test]
fn test_telegraph_same_seed_identical_price() {
let option = option_30d();
for lambdas in [
(Some(dec!(0.5)), Some(dec!(0.5))),
(Some(dec!(0.5)), None),
(None, Some(dec!(0.5))),
(None, None),
] {
let price = |seed: u64| {
telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
lambdas.0,
lambdas.1,
regimes_20(),
&mut deterministic_rng(seed),
)
.unwrap()
};
assert_eq!(price(3), price(3), "lambdas {lambdas:?}");
}
}
#[test]
fn test_telegraph_seeded_regression_pinned() {
let price = telegraph(
&option_30d(),
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
optionstratlib_core::assert_decimal_eq!(
price,
PINNED_TELEGRAPH_PRICE,
PINNED_TELEGRAPH_TOLERANCE
);
}
#[test]
fn test_calculate_price_telegraph_matches_kernel_on_same_seed() {
use crate::pricing::OptionPricing;
let option = option_30d();
let via_trait = option
.calculate_price_telegraph(
optionstratlib_core::nz!(100),
regimes_20(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let via_kernel = telegraph(
&option,
optionstratlib_core::nz!(100),
TELEGRAPH_PATHS,
None,
None,
regimes_20(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
assert_eq!(via_trait, via_kernel);
}
fn option_1y_atm(style: OptionStyle) -> Options {
Options {
option_type: OptionType::European,
side: Side::Long,
underlying_price: Positive::HUNDRED,
strike_price: Positive::HUNDRED,
risk_free_rate: dec!(0.05),
option_style: style,
dividend_yield: Positive::ZERO,
implied_volatility: pos_or_panic!(0.2),
underlying_symbol: "TEST".to_string(),
expiration_date: ExpirationDate::Days(pos_or_panic!(365.0)),
quantity: Positive::ONE,
contract_size: Positive::ONE,
exotic_params: None,
}
}
#[test]
fn test_telegraph_switching_disabled_converges_to_black_scholes() {
use crate::pricing::black_scholes_model::black_scholes;
for (style, band) in [
(OptionStyle::Call, dec!(0.37)),
(OptionStyle::Put, dec!(0.22)),
] {
let option = option_1y_atm(style);
let price = telegraph(
&option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(40_000),
Some(Decimal::ZERO),
Some(Decimal::ZERO),
regimes_20(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let reference = black_scholes(&option).unwrap();
assert!(
(price - reference).abs() < band,
"{style:?}: telegraph {price} vs Black-Scholes {reference}"
);
}
}
#[test]
fn test_telegraph_dividend_yield_switching_disabled_converges_to_black_scholes() {
use crate::pricing::black_scholes_model::black_scholes;
for (style, band) in [
(OptionStyle::Call, dec!(0.33)),
(OptionStyle::Put, dec!(0.22)),
] {
let mut option = option_1y_atm(style);
option.dividend_yield = pos_or_panic!(0.03);
let price = telegraph(
&option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(40_000),
Some(Decimal::ZERO),
Some(Decimal::ZERO),
regimes_20(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let reference = black_scholes(&option).unwrap();
assert!(
(price - reference).abs() < band,
"{style:?}: telegraph {price} vs Black-Scholes {reference}"
);
}
}
#[test]
fn test_telegraph_dispersion_across_seeds_shrinks_with_paths() {
let option = option_1y_atm(OptionStyle::Call);
let spread = |paths: NonZeroUsize| {
let prices: Vec<f64> = (0..32_u64)
.map(|seed| {
telegraph(
&option,
optionstratlib_core::nz!(8),
paths,
Some(dec!(0.5)),
Some(dec!(0.5)),
regimes_20(),
&mut deterministic_rng(seed),
)
.unwrap()
.to_f64()
.unwrap()
})
.collect();
let n = prices.len() as f64;
let mean = prices.iter().sum::<f64>() / n;
(prices.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt()
};
let coarse = spread(optionstratlib_core::nz!(100));
let fine = spread(optionstratlib_core::nz!(1_600));
assert!(
fine < coarse / 2.0,
"spread with 1600 paths {fine} not well below spread with 100 paths {coarse}"
);
}
#[test]
fn test_telegraph_put_call_parity_on_same_seed() {
let price = |style| {
telegraph(
&option_1y_atm(style),
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(40_000),
Some(dec!(1.0)),
Some(dec!(2.0)),
regimes_split(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap()
};
let forward_gap = dec!(100) - dec!(100) * (-dec!(0.05)).exp();
let parity = price(OptionStyle::Call) - price(OptionStyle::Put);
assert!(
(parity - forward_gap).abs() < dec!(0.5),
"C - P = {parity}, expected about {forward_gap}"
);
}
fn regimes_split() -> RegimeVolatility {
RegimeVolatility::new(pos_or_panic!(0.3), pos_or_panic!(0.1)).unwrap()
}
fn black_scholes_at(option: &Options, sigma: Positive) -> Decimal {
let mut option = option.clone();
option.implied_volatility = sigma;
crate::pricing::black_scholes_model::black_scholes(&option).unwrap()
}
fn price_split(style: OptionStyle, lambda_up: Decimal, lambda_down: Decimal) -> Decimal {
telegraph(
&option_1y_atm(style),
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(40_000),
Some(lambda_up),
Some(lambda_down),
regimes_split(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap()
}
#[test]
fn test_telegraph_equal_regimes_with_switching_converges_to_black_scholes() {
for (style, band) in [
(OptionStyle::Call, dec!(0.37)),
(OptionStyle::Put, dec!(0.22)),
] {
let option = option_1y_atm(style);
let price = telegraph(
&option,
optionstratlib_core::nz!(12),
optionstratlib_core::nz!(40_000),
Some(dec!(1.0)),
Some(dec!(2.0)),
regimes_20(),
&mut deterministic_rng(DETERMINISTIC_RNG_DEFAULT_SEED),
)
.unwrap();
let reference = black_scholes_at(&option, pos_or_panic!(0.2));
assert!(
(price - reference).abs() < band,
"{style:?}: telegraph {price} vs Black-Scholes {reference}"
);
}
}
#[test]
fn test_telegraph_frozen_regimes_match_black_scholes_mixture() {
let option = option_1y_atm(OptionStyle::Call);
let price = price_split(OptionStyle::Call, Decimal::ZERO, Decimal::ZERO);
let mixture = (black_scholes_at(&option, pos_or_panic!(0.3))
+ black_scholes_at(&option, pos_or_panic!(0.1)))
/ Decimal::TWO;
assert!(
(price - mixture).abs() < dec!(0.5),
"telegraph {price} vs Black-Scholes mixture {mixture}"
);
}
#[test]
fn test_telegraph_more_time_in_high_vol_regime_raises_price() {
for style in [OptionStyle::Call, OptionStyle::Put] {
let mostly_high = price_split(style, dec!(19), dec!(1));
let balanced = price_split(style, dec!(1), dec!(1));
let mostly_low = price_split(style, dec!(1), dec!(19));
assert!(
mostly_high > balanced + Decimal::ONE && balanced > mostly_low + Decimal::ONE,
"{style:?}: prices {mostly_high} / {balanced} / {mostly_low} not ordered by the time in the high-volatility regime"
);
}
}
#[test]
fn test_telegraph_split_regimes_price_between_black_scholes_bounds() {
for style in [OptionStyle::Call, OptionStyle::Put] {
let option = option_1y_atm(style);
let price = price_split(style, dec!(1), dec!(2));
let low = black_scholes_at(&option, pos_or_panic!(0.1));
let high = black_scholes_at(&option, pos_or_panic!(0.3));
assert!(
low + dec!(0.5) < price && price + dec!(0.5) < high,
"{style:?}: telegraph {price} outside [{low}, {high}]"
);
}
}
}