use crate::error::QuadraticRoughHestonError;
use optionstratlib_core::model::Positive;
use optionstratlib_core::model::decimal::{
d_div, d_sub, decimal_to_f64, f64_to_decimal, finite_decimal,
};
use optionstratlib_core::model::types::OptionStyle;
use optionstratlib_core::utils::deterministic_rng;
use rand::RngExt;
use rand::rngs::StdRng;
use rand_distr::{Distribution, StandardNormal};
use rayon::prelude::*;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::f64::consts::PI;
use std::num::NonZeroUsize;
const ALPHA_LOWER_BOUND: Decimal = dec!(0.5);
const MIN_PATHS: usize = 2;
fn reserve<T>(len: usize, buffer: &'static str) -> Result<Vec<T>, QuadraticRoughHestonError> {
let mut reserved = Vec::new();
reserved
.try_reserve_exact(len)
.map_err(|_| QuadraticRoughHestonError::BufferTooLarge { buffer, len })?;
Ok(reserved)
}
#[cold]
#[inline(never)]
fn invalid_parameter(
parameter: &'static str,
value: Decimal,
reason: &'static str,
) -> QuadraticRoughHestonError {
QuadraticRoughHestonError::InvalidParameter {
parameter,
value,
reason,
}
}
fn kernel_decimal(
raw: f64,
quantity: &'static str,
index: usize,
) -> Result<Decimal, QuadraticRoughHestonError> {
finite_decimal(raw)
.filter(|value| *value > Decimal::ZERO)
.ok_or(QuadraticRoughHestonError::KernelOutOfRange { quantity, index })
}
#[derive(Debug, Clone, PartialEq)]
pub struct QuadraticRoughHestonKernel {
alpha: Decimal,
ratio: Decimal,
weights: Vec<Decimal>,
discounts: Vec<Decimal>,
raw_weights: Vec<f64>,
raw_discounts: Vec<f64>,
}
impl QuadraticRoughHestonKernel {
pub fn try_new(
alpha: Decimal,
factors: usize,
ratio: Decimal,
) -> Result<Self, QuadraticRoughHestonError> {
if alpha <= ALPHA_LOWER_BOUND || alpha >= Decimal::ONE {
return Err(QuadraticRoughHestonError::AlphaOutOfRange { alpha });
}
if factors == 0 {
return Err(QuadraticRoughHestonError::NoFactors);
}
if ratio <= Decimal::ONE {
return Err(QuadraticRoughHestonError::RatioNotAboveOne { ratio });
}
let beta = decimal_to_f64(d_sub(
Decimal::ONE,
alpha,
"quadratic_rough_heston::kernel",
)?)?;
let log_ratio = decimal_to_f64(d_sub(
ratio,
Decimal::ONE,
"quadratic_rough_heston::kernel",
)?)?
.ln_1p(); let sin_pi_beta = (PI * beta).sin(); let weight_scale = -(-beta * log_ratio).exp_m1() * sin_pi_beta / (beta * PI);
let discount_scale = beta * ((1.0 + beta) * log_ratio).exp_m1()
/ ((1.0 + beta) * (beta * log_ratio).exp_m1());
let half_factors = factors as f64 / 2.0;
let mut weights = reserve(factors, "kernel weights")?;
let mut discounts = reserve(factors, "kernel discounts")?;
let mut raw_weights = reserve(factors, "kernel weights")?;
let mut raw_discounts = reserve(factors, "kernel discounts")?;
for index in 1..=factors {
let exponent = index as f64 - half_factors;
let raw_weight = (beta * exponent * log_ratio).exp() * weight_scale; let raw_discount = ((exponent - 1.0) * log_ratio).exp() * discount_scale; weights.push(kernel_decimal(raw_weight, "weight", index)?);
discounts.push(kernel_decimal(raw_discount, "discount", index)?);
raw_weights.push(raw_weight);
raw_discounts.push(raw_discount);
}
Ok(Self {
alpha,
ratio,
weights,
discounts,
raw_weights,
raw_discounts,
})
}
#[must_use]
#[inline]
pub fn alpha(&self) -> Decimal {
self.alpha
}
#[must_use]
#[inline]
pub fn ratio(&self) -> Decimal {
self.ratio
}
#[must_use]
#[inline]
pub fn factors(&self) -> usize {
self.weights.len()
}
#[must_use]
#[inline]
pub fn weights(&self) -> &[Decimal] {
&self.weights
}
#[must_use]
#[inline]
pub fn discounts(&self) -> &[Decimal] {
&self.discounts
}
pub fn value_at(&self, t: Positive) -> Result<Decimal, QuadraticRoughHestonError> {
let t = decimal_to_f64(t.to_dec())?;
let value: f64 = self
.raw_weights
.iter()
.zip(&self.raw_discounts)
.map(|(weight, discount)| weight * (-discount * t).exp()) .sum();
Ok(f64_to_decimal(value)?)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct QuadraticRoughHestonParams {
a: Decimal,
b: Decimal,
c: Decimal,
lambda: Decimal,
eta: Decimal,
kernel: QuadraticRoughHestonKernel,
initial_factors: Vec<Decimal>,
spot: Positive,
initial_z: Decimal,
initial_variance: Decimal,
raw: RawParams,
raw_initial: State,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct RawParams {
a: f64,
b: f64,
c: f64,
lambda: f64,
eta: f64,
}
impl QuadraticRoughHestonParams {
#[allow(clippy::too_many_arguments)]
pub fn try_new(
a: Decimal,
b: Decimal,
c: Decimal,
lambda: Decimal,
eta: Decimal,
kernel: QuadraticRoughHestonKernel,
initial_factors: Vec<Decimal>,
spot: Positive,
) -> Result<Self, QuadraticRoughHestonError> {
if a < Decimal::ZERO {
return Err(invalid_parameter("a", a, "must not be negative"));
}
if c <= Decimal::ZERO {
return Err(invalid_parameter("c", c, "must be positive"));
}
if lambda <= Decimal::ZERO {
return Err(invalid_parameter("lambda", lambda, "must be positive"));
}
if eta <= Decimal::ZERO {
return Err(invalid_parameter("eta", eta, "must be positive"));
}
if spot.to_dec() <= Decimal::ZERO {
return Err(invalid_parameter("spot", spot.to_dec(), "must be positive"));
}
if initial_factors.len() != kernel.factors() {
return Err(QuadraticRoughHestonError::InitialFactorCount {
expected: kernel.factors(),
actual: initial_factors.len(),
});
}
let raw = RawParams {
a: decimal_to_f64(a)?,
b: decimal_to_f64(b)?,
c: decimal_to_f64(c)?,
lambda: decimal_to_f64(lambda)?,
eta: decimal_to_f64(eta)?,
};
let raw_factors = initial_factors
.iter()
.map(|factor| decimal_to_f64(*factor))
.collect::<Result<Vec<f64>, _>>()?;
let (z, variance) = read(&kernel.raw_weights, &raw, &raw_factors);
let raw_initial = State {
spot: decimal_to_f64(spot.to_dec())?,
z,
variance,
factors: raw_factors,
};
let initial_z = f64_to_decimal(z)?;
let initial_variance = f64_to_decimal(variance)?;
Ok(Self {
a,
b,
c,
lambda,
eta,
kernel,
initial_factors,
spot,
initial_z,
initial_variance,
raw,
raw_initial,
})
}
#[must_use]
#[inline]
pub fn a(&self) -> Decimal {
self.a
}
#[must_use]
#[inline]
pub fn b(&self) -> Decimal {
self.b
}
#[must_use]
#[inline]
pub fn c(&self) -> Decimal {
self.c
}
#[must_use]
#[inline]
pub fn lambda(&self) -> Decimal {
self.lambda
}
#[must_use]
#[inline]
pub fn eta(&self) -> Decimal {
self.eta
}
#[must_use]
#[inline]
pub fn kernel(&self) -> &QuadraticRoughHestonKernel {
&self.kernel
}
#[must_use]
#[inline]
pub fn initial_factors(&self) -> &[Decimal] {
&self.initial_factors
}
#[must_use]
#[inline]
pub fn spot(&self) -> Positive {
self.spot
}
#[must_use]
#[inline]
pub fn initial_z(&self) -> Decimal {
self.initial_z
}
#[must_use]
#[inline]
pub fn initial_variance(&self) -> Decimal {
self.initial_variance
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuadraticRoughHestonGrid {
horizon: Decimal,
steps: usize,
dt: Decimal,
}
impl QuadraticRoughHestonGrid {
pub fn try_new(horizon: Decimal, steps: usize) -> Result<Self, QuadraticRoughHestonError> {
let non_positive = || QuadraticRoughHestonError::NonPositiveStep { horizon, steps };
if horizon <= Decimal::ZERO || steps == 0 {
return Err(non_positive());
}
let dt = d_div(
horizon,
Decimal::from(steps),
"quadratic_rough_heston::grid",
)?;
if dt.is_zero() {
return Err(non_positive());
}
Ok(Self { horizon, steps, dt })
}
#[must_use]
#[inline]
pub fn horizon(&self) -> Decimal {
self.horizon
}
#[must_use]
#[inline]
pub fn steps(&self) -> usize {
self.steps
}
#[must_use]
#[inline]
pub fn dt(&self) -> Decimal {
self.dt
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuadraticRoughHestonPath {
spot: Vec<Decimal>,
variance: Vec<Decimal>,
z: Vec<Decimal>,
factors: Vec<Decimal>,
factor_count: usize,
}
impl QuadraticRoughHestonPath {
#[must_use]
#[inline]
pub fn spot(&self) -> &[Decimal] {
&self.spot
}
#[must_use]
#[inline]
pub fn variance(&self) -> &[Decimal] {
&self.variance
}
#[must_use]
#[inline]
pub fn z(&self) -> &[Decimal] {
&self.z
}
#[must_use]
pub fn factors_at(&self, step: usize) -> Option<&[Decimal]> {
let start = step.checked_mul(self.factor_count)?;
let end = start.checked_add(self.factor_count)?;
self.factors.get(start..end)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuadraticRoughHestonEstimate {
price: Decimal,
standard_error: Decimal,
mean_terminal_spot: Decimal,
terminal_spot_standard_error: Decimal,
paths: usize,
non_positive_paths: usize,
}
impl QuadraticRoughHestonEstimate {
#[must_use]
#[inline]
pub fn price(&self) -> Decimal {
self.price
}
#[must_use]
#[inline]
pub fn standard_error(&self) -> Decimal {
self.standard_error
}
#[must_use]
#[inline]
pub fn mean_terminal_spot(&self) -> Decimal {
self.mean_terminal_spot
}
#[must_use]
#[inline]
pub fn terminal_spot_standard_error(&self) -> Decimal {
self.terminal_spot_standard_error
}
#[must_use]
#[inline]
pub fn paths(&self) -> usize {
self.paths
}
#[must_use]
#[inline]
pub fn non_positive_paths(&self) -> usize {
self.non_positive_paths
}
}
#[inline]
fn read(weights: &[f64], raw: &RawParams, factors: &[f64]) -> (f64, f64) {
let z: f64 = weights
.iter()
.zip(factors)
.map(|(weight, factor)| weight * factor)
.sum();
let shifted = z - raw.b;
(z, raw.a * shifted * shifted + raw.c)
}
#[derive(Debug, Clone, PartialEq)]
struct State {
spot: f64,
z: f64,
variance: f64,
factors: Vec<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct NonFiniteValue {
quantity: &'static str,
step: usize,
}
impl NonFiniteValue {
#[cold]
#[inline(never)]
fn into_error(self) -> QuadraticRoughHestonError {
QuadraticRoughHestonError::NonFinite {
quantity: self.quantity,
step: self.step,
}
}
}
impl State {
#[inline]
fn ensure_finite(&self, step: usize) -> Result<(), NonFiniteValue> {
let quantity = if !self.spot.is_finite() {
"spot"
} else if !self.z.is_finite() {
"z"
} else if !self.variance.is_finite() {
"variance"
} else {
return Ok(());
};
Err(NonFiniteValue { quantity, step })
}
}
#[inline(always)]
fn step(params: &QuadraticRoughHestonParams, state: &State, dw: f64, dt: f64, next: &mut State) {
let raw = ¶ms.raw;
let diffusion = state.variance.sqrt() * dw; let forcing = -raw.lambda * state.z * dt + raw.eta * diffusion;
next.spot = state.spot + state.spot * diffusion;
for ((next_factor, factor), discount) in next
.factors
.iter_mut()
.zip(&state.factors)
.zip(¶ms.kernel.raw_discounts)
{
*next_factor = (factor + forcing) / (1.0 + discount * dt);
}
(next.z, next.variance) = read(¶ms.kernel.raw_weights, raw, &next.factors);
}
struct Walk<'a> {
params: &'a QuadraticRoughHestonParams,
dt: f64,
sqrt_dt: f64,
rng: StdRng,
state: State,
scratch: State,
}
impl<'a> Walk<'a> {
fn new(params: &'a QuadraticRoughHestonParams, (dt, sqrt_dt): (f64, f64), seed: u64) -> Self {
Self {
params,
dt,
sqrt_dt,
rng: deterministic_rng(seed),
state: params.raw_initial.clone(),
scratch: params.raw_initial.clone(),
}
}
#[inline]
fn advance(&mut self, to: usize) -> Result<(), NonFiniteValue> {
let normal: f64 = StandardNormal.sample(&mut self.rng);
self.apply(self.sqrt_dt * normal, to)
}
#[inline]
fn apply(&mut self, dw: f64, to: usize) -> Result<(), NonFiniteValue> {
step(self.params, &self.state, dw, self.dt, &mut self.scratch);
std::mem::swap(&mut self.state, &mut self.scratch);
self.state.ensure_finite(to)
}
}
fn raw_step(grid: &QuadraticRoughHestonGrid) -> Result<(f64, f64), QuadraticRoughHestonError> {
let dt = decimal_to_f64(grid.dt)?;
Ok((dt, dt.sqrt())) }
#[tracing::instrument(level = "debug", skip(params))]
pub fn simulate_quadratic_rough_heston_path(
params: &QuadraticRoughHestonParams,
grid: &QuadraticRoughHestonGrid,
seed: u64,
) -> Result<QuadraticRoughHestonPath, QuadraticRoughHestonError> {
let factor_count = params.kernel.factors();
let points = grid
.steps
.checked_add(1)
.ok_or(QuadraticRoughHestonError::BufferTooLarge {
buffer: "path",
len: grid.steps,
})?;
let factor_points =
points
.checked_mul(factor_count)
.ok_or(QuadraticRoughHestonError::BufferTooLarge {
buffer: "path factors",
len: points,
})?;
let mut path = QuadraticRoughHestonPath {
spot: reserve(points, "path spots")?,
variance: reserve(points, "path variances")?,
z: reserve(points, "path z")?,
factors: reserve(factor_points, "path factors")?,
factor_count,
};
let mut walk = Walk::new(params, raw_step(grid)?, seed);
record(&mut path, &walk.state)?;
for to in 1..=grid.steps {
walk.advance(to).map_err(NonFiniteValue::into_error)?;
record(&mut path, &walk.state)?;
}
Ok(path)
}
fn record(
path: &mut QuadraticRoughHestonPath,
state: &State,
) -> Result<(), QuadraticRoughHestonError> {
path.spot.push(f64_to_decimal(state.spot)?);
path.variance.push(f64_to_decimal(state.variance)?);
path.z.push(f64_to_decimal(state.z)?);
for factor in &state.factors {
path.factors.push(f64_to_decimal(*factor)?);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct PathOutcome {
terminal_spot: f64,
non_positive: bool,
}
fn run_path(
params: &QuadraticRoughHestonParams,
grid: &QuadraticRoughHestonGrid,
step_sizes: (f64, f64),
seed: u64,
) -> Result<PathOutcome, NonFiniteValue> {
let mut walk = Walk::new(params, step_sizes, seed);
let mut non_positive = false;
for to in 1..=grid.steps {
walk.advance(to)?;
non_positive |= walk.state.spot <= 0.0;
}
Ok(PathOutcome {
terminal_spot: walk.state.spot,
non_positive,
})
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
struct Moments {
count: f64,
mean: f64,
squared_deviations: f64,
}
impl Moments {
#[inline]
fn add(&mut self, value: f64) {
self.count += 1.0;
let deviation = value - self.mean;
self.mean += deviation / self.count;
self.squared_deviations += deviation * (value - self.mean);
}
fn estimate(&self) -> Result<(Decimal, Decimal), QuadraticRoughHestonError> {
let variance_of_mean = self.squared_deviations / (self.count - 1.0) / self.count;
Ok((
f64_to_decimal(self.mean)?,
f64_to_decimal(variance_of_mean.sqrt())?, ))
}
}
#[tracing::instrument(level = "debug", skip(params))]
pub fn price_vanilla_quadratic_rough_heston(
params: &QuadraticRoughHestonParams,
grid: &QuadraticRoughHestonGrid,
style: OptionStyle,
strike: Positive,
paths: NonZeroUsize,
seed: u64,
) -> Result<QuadraticRoughHestonEstimate, QuadraticRoughHestonError> {
let path_count = paths.get();
if path_count < MIN_PATHS {
return Err(invalid_parameter(
"paths",
Decimal::from(path_count),
"a standard error needs at least two paths",
));
}
let raw_strike = decimal_to_f64(strike.to_dec())?;
let step_sizes = raw_step(grid)?;
let mut master = deterministic_rng(seed);
let mut seeds = reserve::<u64>(path_count, "path seeds")?;
seeds.extend((0..path_count).map(|_| master.random::<u64>()));
let mut outcomes = reserve(path_count, "path outcomes")?;
seeds
.par_iter()
.map(|path_seed| run_path(params, grid, step_sizes, *path_seed))
.collect_into_vec(&mut outcomes);
let non_positive_paths = outcomes
.iter()
.filter(|outcome| matches!(outcome, Ok(path) if path.non_positive))
.count();
let mut payoff = Moments::default();
let mut terminal = Moments::default();
for outcome in outcomes {
let outcome = outcome.map_err(NonFiniteValue::into_error)?;
payoff.add(match style {
OptionStyle::Call => (outcome.terminal_spot - raw_strike).max(0.0),
OptionStyle::Put => (raw_strike - outcome.terminal_spot).max(0.0),
});
terminal.add(outcome.terminal_spot);
}
let (price, standard_error) = payoff.estimate()?;
let (mean_terminal_spot, terminal_spot_standard_error) = terminal.estimate()?;
Ok(QuadraticRoughHestonEstimate {
price,
standard_error,
mean_terminal_spot,
terminal_spot_standard_error,
paths: path_count,
non_positive_paths,
})
}
#[cfg(test)]
mod tests {
use super::*;
use optionstratlib_core::pos_or_panic;
const HAND_TOLERANCE: f64 = 1e-12;
const SMALL_WEIGHTS: [f64; 2] = [0.26369654378952456, 0.37292322857805654];
const SMALL_DISCOUNTS: [f64; 2] = [0.5621320343559644, 2.2485281374238566];
fn small_kernel() -> QuadraticRoughHestonKernel {
QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, dec!(4)).unwrap()
}
fn small_params() -> QuadraticRoughHestonParams {
QuadraticRoughHestonParams::try_new(
dec!(0.5),
dec!(0.1),
dec!(0.04),
dec!(2),
dec!(0.5),
small_kernel(),
vec![dec!(0.1), dec!(0.2)],
Positive::HUNDRED,
)
.unwrap()
}
fn close(actual: f64, expected: f64) -> bool {
(actual - expected).abs() < HAND_TOLERANCE
}
#[test]
fn test_kernel_small_case_matches_hand_values() {
let kernel = small_kernel();
assert_eq!(kernel.alpha(), dec!(0.75));
assert_eq!(kernel.ratio(), dec!(4));
assert_eq!(kernel.factors(), 2);
for i in 0..2 {
assert!(close(kernel.raw_weights[i], SMALL_WEIGHTS[i]), "c_{i}");
assert!(close(kernel.raw_discounts[i], SMALL_DISCOUNTS[i]), "g_{i}");
assert!(close(
decimal_to_f64(kernel.weights()[i]).unwrap(),
SMALL_WEIGHTS[i]
));
assert!(close(
decimal_to_f64(kernel.discounts()[i]).unwrap(),
SMALL_DISCOUNTS[i]
));
}
}
#[test]
fn test_kernel_odd_factors_match_hand_values() {
let kernel = QuadraticRoughHestonKernel::try_new(dec!(0.75), 3, dec!(4)).unwrap();
let weights = [0.2217414783874054, 0.3135898060761293, 0.4434829567748108];
let discounts = [0.2810660171779822, 1.1242640687119285, 4.497056274847715];
for i in 0..3 {
assert!(close(kernel.raw_weights[i], weights[i]), "c_{i}");
assert!(close(kernel.raw_discounts[i], discounts[i]), "g_{i}");
}
}
#[test]
fn test_kernel_discount_matches_the_printed_figure() {
let kernel = QuadraticRoughHestonKernel::try_new(dec!(0.51), 10, dec!(3.92)).unwrap();
let discount = kernel.discounts().last().copied().unwrap();
assert!((discount - dec!(542.32)).abs() < dec!(0.005), "{discount}");
}
#[test]
fn test_kernel_value_at_is_the_sum_of_exponentials() {
let kernel = small_kernel();
let at_zero = kernel.value_at(Positive::ZERO).unwrap();
assert!(close(
decimal_to_f64(at_zero).unwrap(),
SMALL_WEIGHTS[0] + SMALL_WEIGHTS[1]
));
let t = 0.5_f64;
let value = kernel.value_at(pos_or_panic!(0.5)).unwrap();
let expected = SMALL_WEIGHTS[0] * (-SMALL_DISCOUNTS[0] * t).exp()
+ SMALL_WEIGHTS[1] * (-SMALL_DISCOUNTS[1] * t).exp();
assert!(close(decimal_to_f64(value).unwrap(), expected));
let far = kernel.value_at(Positive::MAX).unwrap();
assert_eq!(far, Decimal::ZERO);
}
#[test]
fn test_kernel_invalid_alpha_returns_typed_error() {
for alpha in [dec!(-1), dec!(0), dec!(0.5), dec!(1), dec!(2)] {
assert!(
matches!(
QuadraticRoughHestonKernel::try_new(alpha, 2, dec!(4)),
Err(QuadraticRoughHestonError::AlphaOutOfRange { alpha: rejected })
if rejected == alpha
),
"{alpha}"
);
}
}
#[test]
fn test_kernel_alpha_next_to_its_bounds_is_accepted() {
let tiny = Decimal::new(1, 20);
for alpha in [dec!(0.5) + tiny, Decimal::ONE - tiny] {
let kernel = QuadraticRoughHestonKernel::try_new(alpha, 2, dec!(4)).unwrap();
assert!(kernel.raw_weights.iter().all(|weight| *weight > 0.0));
}
}
#[test]
fn test_kernel_ratio_just_above_one_keeps_its_distance_from_one() {
let lowest = QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, dec!(1.0000000000000001));
assert!(lowest.is_ok());
let kernel =
QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, dec!(1.0000000000000003)).unwrap();
let weight = decimal_to_f64(kernel.weights()[0]).unwrap();
let expected = 3e-16 * (PI / 4.0).sin() / PI;
assert!(
(weight / expected - 1.0).abs() < 1e-6,
"weight {weight}, expected {expected}"
);
}
#[test]
fn test_kernel_no_factors_returns_typed_error() {
assert!(matches!(
QuadraticRoughHestonKernel::try_new(dec!(0.75), 0, dec!(4)),
Err(QuadraticRoughHestonError::NoFactors)
));
}
#[test]
fn test_kernel_ratio_not_above_one_returns_typed_error() {
for ratio in [dec!(-2), dec!(0), dec!(1)] {
assert!(
matches!(
QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, ratio),
Err(QuadraticRoughHestonError::RatioNotAboveOne { ratio: rejected })
if rejected == ratio
),
"{ratio}"
);
}
}
#[test]
fn test_kernel_out_of_decimal_range_returns_typed_error() {
assert!(matches!(
QuadraticRoughHestonKernel::try_new(dec!(0.75), 1000, dec!(4)),
Err(QuadraticRoughHestonError::KernelOutOfRange {
quantity: "weight",
index: 1
})
));
assert!(matches!(
QuadraticRoughHestonKernel::try_new(dec!(0.75), 100, dec!(4)),
Err(QuadraticRoughHestonError::KernelOutOfRange {
quantity: "discount",
index: 1
})
));
assert!(matches!(
QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, Decimal::ONE + Decimal::new(1, 28)),
Err(QuadraticRoughHestonError::KernelOutOfRange { index: 1, .. })
));
}
#[test]
fn test_kernel_unallocatable_factors_return_typed_error() {
assert!(matches!(
QuadraticRoughHestonKernel::try_new(dec!(0.75), usize::MAX, dec!(4)),
Err(QuadraticRoughHestonError::BufferTooLarge { .. })
));
}
#[test]
fn test_params_read_the_initial_state_and_keep_the_inputs() {
let params = small_params();
assert_eq!(params.a(), dec!(0.5));
assert_eq!(params.b(), dec!(0.1));
assert_eq!(params.c(), dec!(0.04));
assert_eq!(params.lambda(), dec!(2));
assert_eq!(params.eta(), dec!(0.5));
assert_eq!(params.kernel(), &small_kernel());
assert_eq!(params.initial_factors(), &[dec!(0.1), dec!(0.2)]);
assert_eq!(params.spot(), Positive::HUNDRED);
assert!(close(
decimal_to_f64(params.initial_z()).unwrap(),
0.10095430009456377
));
assert!(close(
decimal_to_f64(params.initial_variance()).unwrap(),
0.040000455344335245
));
}
#[test]
fn test_params_invalid_values_return_typed_errors() {
let build = |a, c, lambda, eta, spot| {
QuadraticRoughHestonParams::try_new(
a,
dec!(0.1),
c,
lambda,
eta,
small_kernel(),
vec![dec!(0.1), dec!(0.2)],
spot,
)
};
let one = Decimal::ONE;
let hundred = Positive::HUNDRED;
let cases = [
("a", build(dec!(-0.1), one, one, one, hundred)),
("c", build(one, Decimal::ZERO, one, one, hundred)),
("c", build(one, dec!(-1), one, one, hundred)),
("lambda", build(one, one, Decimal::ZERO, one, hundred)),
("eta", build(one, one, one, Decimal::ZERO, hundred)),
("spot", build(one, one, one, one, Positive::ZERO)),
];
for (expected, result) in cases {
assert!(
matches!(
result,
Err(QuadraticRoughHestonError::InvalidParameter { parameter, .. })
if parameter == expected
),
"{expected}"
);
}
}
#[test]
fn test_params_wrong_factor_count_returns_typed_error() {
let result = QuadraticRoughHestonParams::try_new(
dec!(0.5),
dec!(0.1),
dec!(0.04),
dec!(2),
dec!(0.5),
small_kernel(),
vec![dec!(0.1)],
Positive::HUNDRED,
);
assert!(matches!(
result,
Err(QuadraticRoughHestonError::InitialFactorCount {
expected: 2,
actual: 1
})
));
}
#[test]
fn test_params_initial_variance_out_of_decimal_range_returns_typed_error() {
let result = QuadraticRoughHestonParams::try_new(
dec!(1000000000000000000000),
dec!(1000000000000000000000),
dec!(0.04),
dec!(2),
dec!(0.5),
small_kernel(),
vec![dec!(0.1), dec!(0.2)],
Positive::HUNDRED,
);
assert!(matches!(result, Err(QuadraticRoughHestonError::Decimal(_))));
}
#[test]
fn test_grid_keeps_the_step_and_rejects_a_non_positive_one() {
let grid = QuadraticRoughHestonGrid::try_new(dec!(0.5), 4).unwrap();
assert_eq!(grid.horizon(), dec!(0.5));
assert_eq!(grid.steps(), 4);
assert_eq!(grid.dt(), dec!(0.125));
for (horizon, steps) in [
(Decimal::ZERO, 4),
(dec!(-1), 4),
(dec!(1), 0),
(Decimal::new(1, 28), 1000),
] {
assert!(
matches!(
QuadraticRoughHestonGrid::try_new(horizon, steps),
Err(QuadraticRoughHestonError::NonPositiveStep { .. })
),
"{horizon} over {steps}"
);
}
}
#[test]
fn test_step_matches_a_hand_computation_with_the_implicit_denominator() {
let params = small_params();
let state = params.raw_initial.clone();
let mut next = state.clone();
step(¶ms, &state, 0.01, 0.25, &mut next);
assert!(close(next.spot, 100.20000113835759));
assert!(close(next.factors[0], 0.044297583028316205));
assert!(close(next.factors[1], 0.09635731956970187));
assert!(close(next.z, 0.04761500225385726));
assert!(close(next.variance, 0.04137209399443169));
}
#[test]
fn test_step_zero_increment_keeps_the_spot() {
let params = small_params();
let state = params.raw_initial.clone();
let mut next = state.clone();
step(¶ms, &state, 0.0, 0.25, &mut next);
assert_eq!(next.spot, state.spot);
let expected = (0.1 - 2.0 * state.z * 0.25) / (1.0 + SMALL_DISCOUNTS[0] * 0.25);
assert!(close(next.factors[0], expected));
}
#[test]
fn test_walk_halving_the_step_on_coupled_increments_stays_within_its_tolerance() {
const COARSE_STEPS: usize = 100;
const PATHS: usize = 20_000;
const TOLERANCE: f64 = 0.02;
let kernel = QuadraticRoughHestonKernel::try_new(dec!(0.6), 5, dec!(5)).unwrap();
let params = QuadraticRoughHestonParams::try_new(
dec!(0.5),
dec!(0.1),
dec!(0.01),
dec!(1.5),
dec!(1),
kernel,
vec![dec!(-0.05); 5],
Positive::HUNDRED,
)
.unwrap();
let coarse_grid = QuadraticRoughHestonGrid::try_new(dec!(0.25), COARSE_STEPS).unwrap();
let coarse_step = raw_step(&coarse_grid).unwrap();
let fine_dt = 0.25 / (2.0 * COARSE_STEPS as f64);
let fine_sqrt_dt = fine_dt.sqrt();
let mut rng = deterministic_rng(958);
let mut change = Moments::default();
for _ in 0..PATHS {
let mut coarse = Walk::new(¶ms, coarse_step, 0);
let mut fine = params.raw_initial.clone();
let mut scratch = fine.clone();
for to in 1..=COARSE_STEPS {
let first: f64 = StandardNormal.sample(&mut rng);
let second: f64 = StandardNormal.sample(&mut rng);
let (first, second) = (fine_sqrt_dt * first, fine_sqrt_dt * second);
step(¶ms, &fine, first, fine_dt, &mut scratch);
step(¶ms, &scratch, second, fine_dt, &mut fine);
coarse.apply(first + second, to).unwrap();
}
let payoff = |spot: f64| (spot - 100.0).max(0.0);
change.add(payoff(coarse.state.spot) - payoff(fine.spot));
}
assert!(change.mean.abs() < TOLERANCE, "{change:?}");
}
#[test]
fn test_state_not_finite_names_its_quantity_and_step() {
let finite = small_params().raw_initial;
assert_eq!(finite.ensure_finite(3), Ok(()));
for (quantity, state) in [
(
"spot",
State {
spot: f64::NAN,
..finite.clone()
},
),
(
"z",
State {
z: f64::INFINITY,
..finite.clone()
},
),
(
"variance",
State {
variance: f64::INFINITY,
..finite.clone()
},
),
] {
assert_eq!(
state.ensure_finite(3),
Err(NonFiniteValue { quantity, step: 3 })
);
}
}
#[test]
fn test_moments_match_the_sample_mean_and_standard_error() {
let mut moments = Moments::default();
for value in [1.0, 2.0, 3.0, 4.0] {
moments.add(value);
}
let (mean, standard_error) = moments.estimate().unwrap();
assert_eq!(mean, dec!(2.5));
let expected = (5.0_f64 / 3.0 / 4.0).sqrt();
assert!(close(decimal_to_f64(standard_error).unwrap(), expected));
}
}