1use crate::error::QuadraticRoughHestonError;
12use optionstratlib_core::model::Positive;
13use optionstratlib_core::model::decimal::{
14 d_div, d_sub, decimal_to_f64, f64_to_decimal, finite_decimal,
15};
16use optionstratlib_core::model::types::OptionStyle;
17use optionstratlib_core::utils::deterministic_rng;
18use rand::RngExt;
19use rand::rngs::StdRng;
20use rand_distr::{Distribution, StandardNormal};
21use rayon::prelude::*;
22use rust_decimal::Decimal;
23use rust_decimal_macros::dec;
24use std::f64::consts::PI;
25use std::num::NonZeroUsize;
26
27const ALPHA_LOWER_BOUND: Decimal = dec!(0.5);
29
30const MIN_PATHS: usize = 2;
32
33fn reserve<T>(len: usize, buffer: &'static str) -> Result<Vec<T>, QuadraticRoughHestonError> {
35 let mut reserved = Vec::new();
36 reserved
37 .try_reserve_exact(len)
38 .map_err(|_| QuadraticRoughHestonError::BufferTooLarge { buffer, len })?;
39 Ok(reserved)
40}
41
42#[cold]
43#[inline(never)]
44fn invalid_parameter(
45 parameter: &'static str,
46 value: Decimal,
47 reason: &'static str,
48) -> QuadraticRoughHestonError {
49 QuadraticRoughHestonError::InvalidParameter {
50 parameter,
51 value,
52 reason,
53 }
54}
55
56fn kernel_decimal(
58 raw: f64,
59 quantity: &'static str,
60 index: usize,
61) -> Result<Decimal, QuadraticRoughHestonError> {
62 finite_decimal(raw)
63 .filter(|value| *value > Decimal::ZERO)
64 .ok_or(QuadraticRoughHestonError::KernelOutOfRange { quantity, index })
65}
66
67#[derive(Debug, Clone, PartialEq)]
83pub struct QuadraticRoughHestonKernel {
84 alpha: Decimal,
85 ratio: Decimal,
86 weights: Vec<Decimal>,
87 discounts: Vec<Decimal>,
88 raw_weights: Vec<f64>,
89 raw_discounts: Vec<f64>,
90}
91
92impl QuadraticRoughHestonKernel {
93 pub fn try_new(
105 alpha: Decimal,
106 factors: usize,
107 ratio: Decimal,
108 ) -> Result<Self, QuadraticRoughHestonError> {
109 if alpha <= ALPHA_LOWER_BOUND || alpha >= Decimal::ONE {
110 return Err(QuadraticRoughHestonError::AlphaOutOfRange { alpha });
111 }
112 if factors == 0 {
113 return Err(QuadraticRoughHestonError::NoFactors);
114 }
115 if ratio <= Decimal::ONE {
116 return Err(QuadraticRoughHestonError::RatioNotAboveOne { ratio });
117 }
118
119 let beta = decimal_to_f64(d_sub(
125 Decimal::ONE,
126 alpha,
127 "quadratic_rough_heston::kernel",
128 )?)?;
129 let log_ratio = decimal_to_f64(d_sub(
130 ratio,
131 Decimal::ONE,
132 "quadratic_rough_heston::kernel",
133 )?)?
134 .ln_1p(); let sin_pi_beta = (PI * beta).sin(); let weight_scale = -(-beta * log_ratio).exp_m1() * sin_pi_beta / (beta * PI);
137 let discount_scale = beta * ((1.0 + beta) * log_ratio).exp_m1()
138 / ((1.0 + beta) * (beta * log_ratio).exp_m1());
139 let half_factors = factors as f64 / 2.0;
140
141 let mut weights = reserve(factors, "kernel weights")?;
142 let mut discounts = reserve(factors, "kernel discounts")?;
143 let mut raw_weights = reserve(factors, "kernel weights")?;
144 let mut raw_discounts = reserve(factors, "kernel discounts")?;
145 for index in 1..=factors {
146 let exponent = index as f64 - half_factors;
147 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)?);
150 discounts.push(kernel_decimal(raw_discount, "discount", index)?);
151 raw_weights.push(raw_weight);
152 raw_discounts.push(raw_discount);
153 }
154
155 Ok(Self {
156 alpha,
157 ratio,
158 weights,
159 discounts,
160 raw_weights,
161 raw_discounts,
162 })
163 }
164
165 #[must_use]
167 #[inline]
168 pub fn alpha(&self) -> Decimal {
169 self.alpha
170 }
171
172 #[must_use]
174 #[inline]
175 pub fn ratio(&self) -> Decimal {
176 self.ratio
177 }
178
179 #[must_use]
181 #[inline]
182 pub fn factors(&self) -> usize {
183 self.weights.len()
184 }
185
186 #[must_use]
188 #[inline]
189 pub fn weights(&self) -> &[Decimal] {
190 &self.weights
191 }
192
193 #[must_use]
195 #[inline]
196 pub fn discounts(&self) -> &[Decimal] {
197 &self.discounts
198 }
199
200 pub fn value_at(&self, t: Positive) -> Result<Decimal, QuadraticRoughHestonError> {
207 let t = decimal_to_f64(t.to_dec())?;
208 let value: f64 = self
209 .raw_weights
210 .iter()
211 .zip(&self.raw_discounts)
212 .map(|(weight, discount)| weight * (-discount * t).exp()) .sum();
214 Ok(f64_to_decimal(value)?)
215 }
216}
217
218#[derive(Debug, Clone, PartialEq)]
321pub struct QuadraticRoughHestonParams {
322 a: Decimal,
323 b: Decimal,
324 c: Decimal,
325 lambda: Decimal,
326 eta: Decimal,
327 kernel: QuadraticRoughHestonKernel,
328 initial_factors: Vec<Decimal>,
329 spot: Positive,
330 initial_z: Decimal,
331 initial_variance: Decimal,
332 raw: RawParams,
333 raw_initial: State,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq)]
338struct RawParams {
339 a: f64,
340 b: f64,
341 c: f64,
342 lambda: f64,
343 eta: f64,
344}
345
346impl QuadraticRoughHestonParams {
347 #[allow(clippy::too_many_arguments)]
360 pub fn try_new(
361 a: Decimal,
362 b: Decimal,
363 c: Decimal,
364 lambda: Decimal,
365 eta: Decimal,
366 kernel: QuadraticRoughHestonKernel,
367 initial_factors: Vec<Decimal>,
368 spot: Positive,
369 ) -> Result<Self, QuadraticRoughHestonError> {
370 if a < Decimal::ZERO {
371 return Err(invalid_parameter("a", a, "must not be negative"));
372 }
373 if c <= Decimal::ZERO {
374 return Err(invalid_parameter("c", c, "must be positive"));
375 }
376 if lambda <= Decimal::ZERO {
377 return Err(invalid_parameter("lambda", lambda, "must be positive"));
378 }
379 if eta <= Decimal::ZERO {
380 return Err(invalid_parameter("eta", eta, "must be positive"));
381 }
382 if spot.to_dec() <= Decimal::ZERO {
383 return Err(invalid_parameter("spot", spot.to_dec(), "must be positive"));
384 }
385 if initial_factors.len() != kernel.factors() {
386 return Err(QuadraticRoughHestonError::InitialFactorCount {
387 expected: kernel.factors(),
388 actual: initial_factors.len(),
389 });
390 }
391
392 let raw = RawParams {
393 a: decimal_to_f64(a)?,
394 b: decimal_to_f64(b)?,
395 c: decimal_to_f64(c)?,
396 lambda: decimal_to_f64(lambda)?,
397 eta: decimal_to_f64(eta)?,
398 };
399 let raw_factors = initial_factors
400 .iter()
401 .map(|factor| decimal_to_f64(*factor))
402 .collect::<Result<Vec<f64>, _>>()?;
403 let (z, variance) = read(&kernel.raw_weights, &raw, &raw_factors);
404 let raw_initial = State {
405 spot: decimal_to_f64(spot.to_dec())?,
406 z,
407 variance,
408 factors: raw_factors,
409 };
410 let initial_z = f64_to_decimal(z)?;
411 let initial_variance = f64_to_decimal(variance)?;
412
413 Ok(Self {
414 a,
415 b,
416 c,
417 lambda,
418 eta,
419 kernel,
420 initial_factors,
421 spot,
422 initial_z,
423 initial_variance,
424 raw,
425 raw_initial,
426 })
427 }
428
429 #[must_use]
431 #[inline]
432 pub fn a(&self) -> Decimal {
433 self.a
434 }
435
436 #[must_use]
438 #[inline]
439 pub fn b(&self) -> Decimal {
440 self.b
441 }
442
443 #[must_use]
445 #[inline]
446 pub fn c(&self) -> Decimal {
447 self.c
448 }
449
450 #[must_use]
452 #[inline]
453 pub fn lambda(&self) -> Decimal {
454 self.lambda
455 }
456
457 #[must_use]
459 #[inline]
460 pub fn eta(&self) -> Decimal {
461 self.eta
462 }
463
464 #[must_use]
466 #[inline]
467 pub fn kernel(&self) -> &QuadraticRoughHestonKernel {
468 &self.kernel
469 }
470
471 #[must_use]
473 #[inline]
474 pub fn initial_factors(&self) -> &[Decimal] {
475 &self.initial_factors
476 }
477
478 #[must_use]
480 #[inline]
481 pub fn spot(&self) -> Positive {
482 self.spot
483 }
484
485 #[must_use]
487 #[inline]
488 pub fn initial_z(&self) -> Decimal {
489 self.initial_z
490 }
491
492 #[must_use]
494 #[inline]
495 pub fn initial_variance(&self) -> Decimal {
496 self.initial_variance
497 }
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub struct QuadraticRoughHestonGrid {
503 horizon: Decimal,
504 steps: usize,
505 dt: Decimal,
506}
507
508impl QuadraticRoughHestonGrid {
509 pub fn try_new(horizon: Decimal, steps: usize) -> Result<Self, QuadraticRoughHestonError> {
517 let non_positive = || QuadraticRoughHestonError::NonPositiveStep { horizon, steps };
518 if horizon <= Decimal::ZERO || steps == 0 {
519 return Err(non_positive());
520 }
521 let dt = d_div(
522 horizon,
523 Decimal::from(steps),
524 "quadratic_rough_heston::grid",
525 )?;
526 if dt.is_zero() {
527 return Err(non_positive());
528 }
529 Ok(Self { horizon, steps, dt })
530 }
531
532 #[must_use]
534 #[inline]
535 pub fn horizon(&self) -> Decimal {
536 self.horizon
537 }
538
539 #[must_use]
541 #[inline]
542 pub fn steps(&self) -> usize {
543 self.steps
544 }
545
546 #[must_use]
548 #[inline]
549 pub fn dt(&self) -> Decimal {
550 self.dt
551 }
552}
553
554#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct QuadraticRoughHestonPath {
559 spot: Vec<Decimal>,
560 variance: Vec<Decimal>,
561 z: Vec<Decimal>,
562 factors: Vec<Decimal>,
563 factor_count: usize,
564}
565
566impl QuadraticRoughHestonPath {
567 #[must_use]
569 #[inline]
570 pub fn spot(&self) -> &[Decimal] {
571 &self.spot
572 }
573
574 #[must_use]
576 #[inline]
577 pub fn variance(&self) -> &[Decimal] {
578 &self.variance
579 }
580
581 #[must_use]
583 #[inline]
584 pub fn z(&self) -> &[Decimal] {
585 &self.z
586 }
587
588 #[must_use]
590 pub fn factors_at(&self, step: usize) -> Option<&[Decimal]> {
591 let start = step.checked_mul(self.factor_count)?;
592 let end = start.checked_add(self.factor_count)?;
593 self.factors.get(start..end)
594 }
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub struct QuadraticRoughHestonEstimate {
600 price: Decimal,
601 standard_error: Decimal,
602 mean_terminal_spot: Decimal,
603 terminal_spot_standard_error: Decimal,
604 paths: usize,
605 non_positive_paths: usize,
606}
607
608impl QuadraticRoughHestonEstimate {
609 #[must_use]
611 #[inline]
612 pub fn price(&self) -> Decimal {
613 self.price
614 }
615
616 #[must_use]
618 #[inline]
619 pub fn standard_error(&self) -> Decimal {
620 self.standard_error
621 }
622
623 #[must_use]
625 #[inline]
626 pub fn mean_terminal_spot(&self) -> Decimal {
627 self.mean_terminal_spot
628 }
629
630 #[must_use]
632 #[inline]
633 pub fn terminal_spot_standard_error(&self) -> Decimal {
634 self.terminal_spot_standard_error
635 }
636
637 #[must_use]
639 #[inline]
640 pub fn paths(&self) -> usize {
641 self.paths
642 }
643
644 #[must_use]
646 #[inline]
647 pub fn non_positive_paths(&self) -> usize {
648 self.non_positive_paths
649 }
650}
651
652#[inline]
654fn read(weights: &[f64], raw: &RawParams, factors: &[f64]) -> (f64, f64) {
655 let z: f64 = weights
656 .iter()
657 .zip(factors)
658 .map(|(weight, factor)| weight * factor)
659 .sum();
660 let shifted = z - raw.b;
661 (z, raw.a * shifted * shifted + raw.c)
662}
663
664#[derive(Debug, Clone, PartialEq)]
666struct State {
667 spot: f64,
668 z: f64,
669 variance: f64,
670 factors: Vec<f64>,
671}
672
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676struct NonFiniteValue {
677 quantity: &'static str,
678 step: usize,
679}
680
681impl NonFiniteValue {
682 #[cold]
683 #[inline(never)]
684 fn into_error(self) -> QuadraticRoughHestonError {
685 QuadraticRoughHestonError::NonFinite {
686 quantity: self.quantity,
687 step: self.step,
688 }
689 }
690}
691
692impl State {
693 #[inline]
695 fn ensure_finite(&self, step: usize) -> Result<(), NonFiniteValue> {
696 let quantity = if !self.spot.is_finite() {
697 "spot"
698 } else if !self.z.is_finite() {
699 "z"
700 } else if !self.variance.is_finite() {
701 "variance"
702 } else {
703 return Ok(());
704 };
705 Err(NonFiniteValue { quantity, step })
706 }
707}
708
709#[inline(always)]
713fn step(params: &QuadraticRoughHestonParams, state: &State, dw: f64, dt: f64, next: &mut State) {
714 let raw = ¶ms.raw;
715 let diffusion = state.variance.sqrt() * dw; let forcing = -raw.lambda * state.z * dt + raw.eta * diffusion;
717 next.spot = state.spot + state.spot * diffusion;
718 for ((next_factor, factor), discount) in next
719 .factors
720 .iter_mut()
721 .zip(&state.factors)
722 .zip(¶ms.kernel.raw_discounts)
723 {
724 *next_factor = (factor + forcing) / (1.0 + discount * dt);
725 }
726 (next.z, next.variance) = read(¶ms.kernel.raw_weights, raw, &next.factors);
727}
728
729struct Walk<'a> {
731 params: &'a QuadraticRoughHestonParams,
732 dt: f64,
733 sqrt_dt: f64,
734 rng: StdRng,
735 state: State,
736 scratch: State,
737}
738
739impl<'a> Walk<'a> {
740 fn new(params: &'a QuadraticRoughHestonParams, (dt, sqrt_dt): (f64, f64), seed: u64) -> Self {
741 Self {
742 params,
743 dt,
744 sqrt_dt,
745 rng: deterministic_rng(seed),
746 state: params.raw_initial.clone(),
747 scratch: params.raw_initial.clone(),
748 }
749 }
750
751 #[inline]
753 fn advance(&mut self, to: usize) -> Result<(), NonFiniteValue> {
754 let normal: f64 = StandardNormal.sample(&mut self.rng);
755 self.apply(self.sqrt_dt * normal, to)
756 }
757
758 #[inline]
760 fn apply(&mut self, dw: f64, to: usize) -> Result<(), NonFiniteValue> {
761 step(self.params, &self.state, dw, self.dt, &mut self.scratch);
762 std::mem::swap(&mut self.state, &mut self.scratch);
763 self.state.ensure_finite(to)
764 }
765}
766
767fn raw_step(grid: &QuadraticRoughHestonGrid) -> Result<(f64, f64), QuadraticRoughHestonError> {
769 let dt = decimal_to_f64(grid.dt)?;
770 Ok((dt, dt.sqrt())) }
772
773#[tracing::instrument(level = "debug", skip(params))]
785pub fn simulate_quadratic_rough_heston_path(
786 params: &QuadraticRoughHestonParams,
787 grid: &QuadraticRoughHestonGrid,
788 seed: u64,
789) -> Result<QuadraticRoughHestonPath, QuadraticRoughHestonError> {
790 let factor_count = params.kernel.factors();
791 let points = grid
792 .steps
793 .checked_add(1)
794 .ok_or(QuadraticRoughHestonError::BufferTooLarge {
795 buffer: "path",
796 len: grid.steps,
797 })?;
798 let factor_points =
799 points
800 .checked_mul(factor_count)
801 .ok_or(QuadraticRoughHestonError::BufferTooLarge {
802 buffer: "path factors",
803 len: points,
804 })?;
805 let mut path = QuadraticRoughHestonPath {
806 spot: reserve(points, "path spots")?,
807 variance: reserve(points, "path variances")?,
808 z: reserve(points, "path z")?,
809 factors: reserve(factor_points, "path factors")?,
810 factor_count,
811 };
812
813 let mut walk = Walk::new(params, raw_step(grid)?, seed);
814 record(&mut path, &walk.state)?;
815 for to in 1..=grid.steps {
816 walk.advance(to).map_err(NonFiniteValue::into_error)?;
817 record(&mut path, &walk.state)?;
818 }
819 Ok(path)
820}
821
822fn record(
824 path: &mut QuadraticRoughHestonPath,
825 state: &State,
826) -> Result<(), QuadraticRoughHestonError> {
827 path.spot.push(f64_to_decimal(state.spot)?);
828 path.variance.push(f64_to_decimal(state.variance)?);
829 path.z.push(f64_to_decimal(state.z)?);
830 for factor in &state.factors {
831 path.factors.push(f64_to_decimal(*factor)?);
832 }
833 Ok(())
834}
835
836#[derive(Debug, Clone, Copy, PartialEq)]
838struct PathOutcome {
839 terminal_spot: f64,
840 non_positive: bool,
841}
842
843fn run_path(
845 params: &QuadraticRoughHestonParams,
846 grid: &QuadraticRoughHestonGrid,
847 step_sizes: (f64, f64),
848 seed: u64,
849) -> Result<PathOutcome, NonFiniteValue> {
850 let mut walk = Walk::new(params, step_sizes, seed);
851 let mut non_positive = false;
852 for to in 1..=grid.steps {
853 walk.advance(to)?;
854 non_positive |= walk.state.spot <= 0.0;
855 }
856 Ok(PathOutcome {
857 terminal_spot: walk.state.spot,
858 non_positive,
859 })
860}
861
862#[derive(Debug, Clone, Copy, Default, PartialEq)]
864struct Moments {
865 count: f64,
866 mean: f64,
867 squared_deviations: f64,
868}
869
870impl Moments {
871 #[inline]
872 fn add(&mut self, value: f64) {
873 self.count += 1.0;
874 let deviation = value - self.mean;
875 self.mean += deviation / self.count;
876 self.squared_deviations += deviation * (value - self.mean);
877 }
878
879 fn estimate(&self) -> Result<(Decimal, Decimal), QuadraticRoughHestonError> {
881 let variance_of_mean = self.squared_deviations / (self.count - 1.0) / self.count;
882 Ok((
883 f64_to_decimal(self.mean)?,
884 f64_to_decimal(variance_of_mean.sqrt())?, ))
886 }
887}
888
889#[tracing::instrument(level = "debug", skip(params))]
906pub fn price_vanilla_quadratic_rough_heston(
907 params: &QuadraticRoughHestonParams,
908 grid: &QuadraticRoughHestonGrid,
909 style: OptionStyle,
910 strike: Positive,
911 paths: NonZeroUsize,
912 seed: u64,
913) -> Result<QuadraticRoughHestonEstimate, QuadraticRoughHestonError> {
914 let path_count = paths.get();
915 if path_count < MIN_PATHS {
916 return Err(invalid_parameter(
917 "paths",
918 Decimal::from(path_count),
919 "a standard error needs at least two paths",
920 ));
921 }
922 let raw_strike = decimal_to_f64(strike.to_dec())?;
923 let step_sizes = raw_step(grid)?;
924
925 let mut master = deterministic_rng(seed);
926 let mut seeds = reserve::<u64>(path_count, "path seeds")?;
927 seeds.extend((0..path_count).map(|_| master.random::<u64>()));
928
929 let mut outcomes = reserve(path_count, "path outcomes")?;
930 seeds
931 .par_iter()
932 .map(|path_seed| run_path(params, grid, step_sizes, *path_seed))
933 .collect_into_vec(&mut outcomes);
934
935 let non_positive_paths = outcomes
936 .iter()
937 .filter(|outcome| matches!(outcome, Ok(path) if path.non_positive))
938 .count();
939 let mut payoff = Moments::default();
940 let mut terminal = Moments::default();
941 for outcome in outcomes {
942 let outcome = outcome.map_err(NonFiniteValue::into_error)?;
943 payoff.add(match style {
944 OptionStyle::Call => (outcome.terminal_spot - raw_strike).max(0.0),
945 OptionStyle::Put => (raw_strike - outcome.terminal_spot).max(0.0),
946 });
947 terminal.add(outcome.terminal_spot);
948 }
949
950 let (price, standard_error) = payoff.estimate()?;
951 let (mean_terminal_spot, terminal_spot_standard_error) = terminal.estimate()?;
952 Ok(QuadraticRoughHestonEstimate {
953 price,
954 standard_error,
955 mean_terminal_spot,
956 terminal_spot_standard_error,
957 paths: path_count,
958 non_positive_paths,
959 })
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965 use optionstratlib_core::pos_or_panic;
966
967 const HAND_TOLERANCE: f64 = 1e-12;
969
970 const SMALL_WEIGHTS: [f64; 2] = [0.26369654378952456, 0.37292322857805654];
975 const SMALL_DISCOUNTS: [f64; 2] = [0.5621320343559644, 2.2485281374238566];
976
977 fn small_kernel() -> QuadraticRoughHestonKernel {
978 QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, dec!(4)).unwrap()
979 }
980
981 fn small_params() -> QuadraticRoughHestonParams {
983 QuadraticRoughHestonParams::try_new(
984 dec!(0.5),
985 dec!(0.1),
986 dec!(0.04),
987 dec!(2),
988 dec!(0.5),
989 small_kernel(),
990 vec![dec!(0.1), dec!(0.2)],
991 Positive::HUNDRED,
992 )
993 .unwrap()
994 }
995
996 fn close(actual: f64, expected: f64) -> bool {
997 (actual - expected).abs() < HAND_TOLERANCE
998 }
999
1000 #[test]
1001 fn test_kernel_small_case_matches_hand_values() {
1002 let kernel = small_kernel();
1003 assert_eq!(kernel.alpha(), dec!(0.75));
1004 assert_eq!(kernel.ratio(), dec!(4));
1005 assert_eq!(kernel.factors(), 2);
1006 for i in 0..2 {
1007 assert!(close(kernel.raw_weights[i], SMALL_WEIGHTS[i]), "c_{i}");
1008 assert!(close(kernel.raw_discounts[i], SMALL_DISCOUNTS[i]), "g_{i}");
1009 assert!(close(
1010 decimal_to_f64(kernel.weights()[i]).unwrap(),
1011 SMALL_WEIGHTS[i]
1012 ));
1013 assert!(close(
1014 decimal_to_f64(kernel.discounts()[i]).unwrap(),
1015 SMALL_DISCOUNTS[i]
1016 ));
1017 }
1018 }
1019
1020 #[test]
1022 fn test_kernel_odd_factors_match_hand_values() {
1023 let kernel = QuadraticRoughHestonKernel::try_new(dec!(0.75), 3, dec!(4)).unwrap();
1024 let weights = [0.2217414783874054, 0.3135898060761293, 0.4434829567748108];
1025 let discounts = [0.2810660171779822, 1.1242640687119285, 4.497056274847715];
1026 for i in 0..3 {
1027 assert!(close(kernel.raw_weights[i], weights[i]), "c_{i}");
1028 assert!(close(kernel.raw_discounts[i], discounts[i]), "g_{i}");
1029 }
1030 }
1031
1032 #[test]
1035 fn test_kernel_discount_matches_the_printed_figure() {
1036 let kernel = QuadraticRoughHestonKernel::try_new(dec!(0.51), 10, dec!(3.92)).unwrap();
1037 let discount = kernel.discounts().last().copied().unwrap();
1038 assert!((discount - dec!(542.32)).abs() < dec!(0.005), "{discount}");
1039 }
1040
1041 #[test]
1043 fn test_kernel_value_at_is_the_sum_of_exponentials() {
1044 let kernel = small_kernel();
1045 let at_zero = kernel.value_at(Positive::ZERO).unwrap();
1046 assert!(close(
1047 decimal_to_f64(at_zero).unwrap(),
1048 SMALL_WEIGHTS[0] + SMALL_WEIGHTS[1]
1049 ));
1050 let t = 0.5_f64;
1051 let value = kernel.value_at(pos_or_panic!(0.5)).unwrap();
1052 let expected = SMALL_WEIGHTS[0] * (-SMALL_DISCOUNTS[0] * t).exp()
1053 + SMALL_WEIGHTS[1] * (-SMALL_DISCOUNTS[1] * t).exp();
1054 assert!(close(decimal_to_f64(value).unwrap(), expected));
1055 let far = kernel.value_at(Positive::MAX).unwrap();
1056 assert_eq!(far, Decimal::ZERO);
1057 }
1058
1059 #[test]
1060 fn test_kernel_invalid_alpha_returns_typed_error() {
1061 for alpha in [dec!(-1), dec!(0), dec!(0.5), dec!(1), dec!(2)] {
1062 assert!(
1063 matches!(
1064 QuadraticRoughHestonKernel::try_new(alpha, 2, dec!(4)),
1065 Err(QuadraticRoughHestonError::AlphaOutOfRange { alpha: rejected })
1066 if rejected == alpha
1067 ),
1068 "{alpha}"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn test_kernel_alpha_next_to_its_bounds_is_accepted() {
1075 let tiny = Decimal::new(1, 20);
1076 for alpha in [dec!(0.5) + tiny, Decimal::ONE - tiny] {
1077 let kernel = QuadraticRoughHestonKernel::try_new(alpha, 2, dec!(4)).unwrap();
1078 assert!(kernel.raw_weights.iter().all(|weight| *weight > 0.0));
1079 }
1080 }
1081
1082 #[test]
1083 fn test_kernel_ratio_just_above_one_keeps_its_distance_from_one() {
1084 let lowest = QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, dec!(1.0000000000000001));
1086 assert!(lowest.is_ok());
1087
1088 let kernel =
1091 QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, dec!(1.0000000000000003)).unwrap();
1092 let weight = decimal_to_f64(kernel.weights()[0]).unwrap();
1093 let expected = 3e-16 * (PI / 4.0).sin() / PI;
1094 assert!(
1095 (weight / expected - 1.0).abs() < 1e-6,
1096 "weight {weight}, expected {expected}"
1097 );
1098 }
1099
1100 #[test]
1101 fn test_kernel_no_factors_returns_typed_error() {
1102 assert!(matches!(
1103 QuadraticRoughHestonKernel::try_new(dec!(0.75), 0, dec!(4)),
1104 Err(QuadraticRoughHestonError::NoFactors)
1105 ));
1106 }
1107
1108 #[test]
1109 fn test_kernel_ratio_not_above_one_returns_typed_error() {
1110 for ratio in [dec!(-2), dec!(0), dec!(1)] {
1111 assert!(
1112 matches!(
1113 QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, ratio),
1114 Err(QuadraticRoughHestonError::RatioNotAboveOne { ratio: rejected })
1115 if rejected == ratio
1116 ),
1117 "{ratio}"
1118 );
1119 }
1120 }
1121
1122 #[test]
1125 fn test_kernel_out_of_decimal_range_returns_typed_error() {
1126 assert!(matches!(
1127 QuadraticRoughHestonKernel::try_new(dec!(0.75), 1000, dec!(4)),
1128 Err(QuadraticRoughHestonError::KernelOutOfRange {
1129 quantity: "weight",
1130 index: 1
1131 })
1132 ));
1133 assert!(matches!(
1134 QuadraticRoughHestonKernel::try_new(dec!(0.75), 100, dec!(4)),
1135 Err(QuadraticRoughHestonError::KernelOutOfRange {
1136 quantity: "discount",
1137 index: 1
1138 })
1139 ));
1140 assert!(matches!(
1141 QuadraticRoughHestonKernel::try_new(dec!(0.75), 2, Decimal::ONE + Decimal::new(1, 28)),
1142 Err(QuadraticRoughHestonError::KernelOutOfRange { index: 1, .. })
1143 ));
1144 }
1145
1146 #[test]
1147 fn test_kernel_unallocatable_factors_return_typed_error() {
1148 assert!(matches!(
1149 QuadraticRoughHestonKernel::try_new(dec!(0.75), usize::MAX, dec!(4)),
1150 Err(QuadraticRoughHestonError::BufferTooLarge { .. })
1151 ));
1152 }
1153
1154 #[test]
1155 fn test_params_read_the_initial_state_and_keep_the_inputs() {
1156 let params = small_params();
1157 assert_eq!(params.a(), dec!(0.5));
1158 assert_eq!(params.b(), dec!(0.1));
1159 assert_eq!(params.c(), dec!(0.04));
1160 assert_eq!(params.lambda(), dec!(2));
1161 assert_eq!(params.eta(), dec!(0.5));
1162 assert_eq!(params.kernel(), &small_kernel());
1163 assert_eq!(params.initial_factors(), &[dec!(0.1), dec!(0.2)]);
1164 assert_eq!(params.spot(), Positive::HUNDRED);
1165 assert!(close(
1167 decimal_to_f64(params.initial_z()).unwrap(),
1168 0.10095430009456377
1169 ));
1170 assert!(close(
1171 decimal_to_f64(params.initial_variance()).unwrap(),
1172 0.040000455344335245
1173 ));
1174 }
1175
1176 #[test]
1177 fn test_params_invalid_values_return_typed_errors() {
1178 let build = |a, c, lambda, eta, spot| {
1179 QuadraticRoughHestonParams::try_new(
1180 a,
1181 dec!(0.1),
1182 c,
1183 lambda,
1184 eta,
1185 small_kernel(),
1186 vec![dec!(0.1), dec!(0.2)],
1187 spot,
1188 )
1189 };
1190 let one = Decimal::ONE;
1191 let hundred = Positive::HUNDRED;
1192 let cases = [
1193 ("a", build(dec!(-0.1), one, one, one, hundred)),
1194 ("c", build(one, Decimal::ZERO, one, one, hundred)),
1195 ("c", build(one, dec!(-1), one, one, hundred)),
1196 ("lambda", build(one, one, Decimal::ZERO, one, hundred)),
1197 ("eta", build(one, one, one, Decimal::ZERO, hundred)),
1198 ("spot", build(one, one, one, one, Positive::ZERO)),
1199 ];
1200 for (expected, result) in cases {
1201 assert!(
1202 matches!(
1203 result,
1204 Err(QuadraticRoughHestonError::InvalidParameter { parameter, .. })
1205 if parameter == expected
1206 ),
1207 "{expected}"
1208 );
1209 }
1210 }
1211
1212 #[test]
1213 fn test_params_wrong_factor_count_returns_typed_error() {
1214 let result = QuadraticRoughHestonParams::try_new(
1215 dec!(0.5),
1216 dec!(0.1),
1217 dec!(0.04),
1218 dec!(2),
1219 dec!(0.5),
1220 small_kernel(),
1221 vec![dec!(0.1)],
1222 Positive::HUNDRED,
1223 );
1224 assert!(matches!(
1225 result,
1226 Err(QuadraticRoughHestonError::InitialFactorCount {
1227 expected: 2,
1228 actual: 1
1229 })
1230 ));
1231 }
1232
1233 #[test]
1235 fn test_params_initial_variance_out_of_decimal_range_returns_typed_error() {
1236 let result = QuadraticRoughHestonParams::try_new(
1237 dec!(1000000000000000000000),
1238 dec!(1000000000000000000000),
1239 dec!(0.04),
1240 dec!(2),
1241 dec!(0.5),
1242 small_kernel(),
1243 vec![dec!(0.1), dec!(0.2)],
1244 Positive::HUNDRED,
1245 );
1246 assert!(matches!(result, Err(QuadraticRoughHestonError::Decimal(_))));
1247 }
1248
1249 #[test]
1250 fn test_grid_keeps_the_step_and_rejects_a_non_positive_one() {
1251 let grid = QuadraticRoughHestonGrid::try_new(dec!(0.5), 4).unwrap();
1252 assert_eq!(grid.horizon(), dec!(0.5));
1253 assert_eq!(grid.steps(), 4);
1254 assert_eq!(grid.dt(), dec!(0.125));
1255 for (horizon, steps) in [
1256 (Decimal::ZERO, 4),
1257 (dec!(-1), 4),
1258 (dec!(1), 0),
1259 (Decimal::new(1, 28), 1000),
1260 ] {
1261 assert!(
1262 matches!(
1263 QuadraticRoughHestonGrid::try_new(horizon, steps),
1264 Err(QuadraticRoughHestonError::NonPositiveStep { .. })
1265 ),
1266 "{horizon} over {steps}"
1267 );
1268 }
1269 }
1270
1271 #[test]
1276 fn test_step_matches_a_hand_computation_with_the_implicit_denominator() {
1277 let params = small_params();
1278 let state = params.raw_initial.clone();
1279 let mut next = state.clone();
1280 step(¶ms, &state, 0.01, 0.25, &mut next);
1281 assert!(close(next.spot, 100.20000113835759));
1282 assert!(close(next.factors[0], 0.044297583028316205));
1283 assert!(close(next.factors[1], 0.09635731956970187));
1284 assert!(close(next.z, 0.04761500225385726));
1285 assert!(close(next.variance, 0.04137209399443169));
1286 }
1287
1288 #[test]
1290 fn test_step_zero_increment_keeps_the_spot() {
1291 let params = small_params();
1292 let state = params.raw_initial.clone();
1293 let mut next = state.clone();
1294 step(¶ms, &state, 0.0, 0.25, &mut next);
1295 assert_eq!(next.spot, state.spot);
1296 let expected = (0.1 - 2.0 * state.z * 0.25) / (1.0 + SMALL_DISCOUNTS[0] * 0.25);
1297 assert!(close(next.factors[0], expected));
1298 }
1299
1300 #[test]
1312 fn test_walk_halving_the_step_on_coupled_increments_stays_within_its_tolerance() {
1313 const COARSE_STEPS: usize = 100;
1314 const PATHS: usize = 20_000;
1315 const TOLERANCE: f64 = 0.02;
1316 let kernel = QuadraticRoughHestonKernel::try_new(dec!(0.6), 5, dec!(5)).unwrap();
1317 let params = QuadraticRoughHestonParams::try_new(
1318 dec!(0.5),
1319 dec!(0.1),
1320 dec!(0.01),
1321 dec!(1.5),
1322 dec!(1),
1323 kernel,
1324 vec![dec!(-0.05); 5],
1325 Positive::HUNDRED,
1326 )
1327 .unwrap();
1328 let coarse_grid = QuadraticRoughHestonGrid::try_new(dec!(0.25), COARSE_STEPS).unwrap();
1329 let coarse_step = raw_step(&coarse_grid).unwrap();
1330 let fine_dt = 0.25 / (2.0 * COARSE_STEPS as f64);
1331 let fine_sqrt_dt = fine_dt.sqrt();
1332
1333 let mut rng = deterministic_rng(958);
1334 let mut change = Moments::default();
1335 for _ in 0..PATHS {
1336 let mut coarse = Walk::new(¶ms, coarse_step, 0);
1337 let mut fine = params.raw_initial.clone();
1338 let mut scratch = fine.clone();
1339 for to in 1..=COARSE_STEPS {
1340 let first: f64 = StandardNormal.sample(&mut rng);
1341 let second: f64 = StandardNormal.sample(&mut rng);
1342 let (first, second) = (fine_sqrt_dt * first, fine_sqrt_dt * second);
1343 step(¶ms, &fine, first, fine_dt, &mut scratch);
1344 step(¶ms, &scratch, second, fine_dt, &mut fine);
1345 coarse.apply(first + second, to).unwrap();
1346 }
1347 let payoff = |spot: f64| (spot - 100.0).max(0.0);
1348 change.add(payoff(coarse.state.spot) - payoff(fine.spot));
1349 }
1350 assert!(change.mean.abs() < TOLERANCE, "{change:?}");
1351 }
1352
1353 #[test]
1354 fn test_state_not_finite_names_its_quantity_and_step() {
1355 let finite = small_params().raw_initial;
1356 assert_eq!(finite.ensure_finite(3), Ok(()));
1357 for (quantity, state) in [
1358 (
1359 "spot",
1360 State {
1361 spot: f64::NAN,
1362 ..finite.clone()
1363 },
1364 ),
1365 (
1366 "z",
1367 State {
1368 z: f64::INFINITY,
1369 ..finite.clone()
1370 },
1371 ),
1372 (
1373 "variance",
1374 State {
1375 variance: f64::INFINITY,
1376 ..finite.clone()
1377 },
1378 ),
1379 ] {
1380 assert_eq!(
1381 state.ensure_finite(3),
1382 Err(NonFiniteValue { quantity, step: 3 })
1383 );
1384 }
1385 }
1386
1387 #[test]
1388 fn test_moments_match_the_sample_mean_and_standard_error() {
1389 let mut moments = Moments::default();
1390 for value in [1.0, 2.0, 3.0, 4.0] {
1391 moments.add(value);
1392 }
1393 let (mean, standard_error) = moments.estimate().unwrap();
1394 assert_eq!(mean, dec!(2.5));
1395 let expected = (5.0_f64 / 3.0 / 4.0).sqrt();
1397 assert!(close(decimal_to_f64(standard_error).unwrap(), expected));
1398 }
1399}