Skip to content

Commit de65aa1

Browse files
authored
Merge pull request #198 from PyFE/ousv
OusvMC updated
2 parents 1127dc5 + 2c60ec4 commit de65aa1

3 files changed

Lines changed: 203 additions & 47 deletions

File tree

pyfeng/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
from .rheston_mc import RoughHestonMcMaWu2022
2929

30-
from .ousv import OusvUncorrBallRoma1994, OusvMcTimeDisc, OusvMcChoi2023KL
30+
from .ousv import OusvUncorrBallRoma1994, OusvMcTimeDisc, OusvMcChoi2025KL
3131
from .svi import Svi
3232

3333
from .multiasset import (

pyfeng/ousv.py

Lines changed: 199 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -499,37 +499,99 @@ def cond_states_step(self, dt, vol_0, nz_theta=True):
499499
return vol_t, avgvar, avgvol
500500

501501

502-
class OusvMcChoi2023KL(OusvMcABC):
502+
class OusvMcChoi2025KL(OusvMcABC):
503+
"""
504+
Exact Monte Carlo simulation for the OUSV model using Karhunen–Loève (KL) expansions.
505+
506+
The stochastic volatility σ_t follows an Ornstein–Uhlenbeck (OU) process:
507+
508+
dS_t / S_t = r dt + σ_t (ρ dZ_t + √(1-ρ²) dW_t)
509+
dσ_t = κ(θ - σ_t) dt + ξ dZ_t
510+
511+
where κ (`mr`) is the mean-reversion speed, θ (`theta`) the long-term equilibrium
512+
volatility, ξ (`vov`) the volatility of volatility, and ρ (`rho`) the
513+
price–volatility correlation.
514+
515+
The volatility path is expressed as an infinite sine series via the KL expansion
516+
of the OU bridge process (Eq. (11) of the reference):
517+
518+
σ_t = θ + σ̄₀ e^{-κt} + σ̂_T sinh(κt)/sinh(κT)
519+
+ ξ√T Σ_{n=1}^∞ a_n sin(nπt/T) Z_n,
520+
521+
where a_n = √(2 / ((κT)² + (nπ)²)) and Z_n are i.i.d. standard normals.
522+
523+
This representation allows the time integrals of volatility (U_{0,T}) and
524+
variance (V_{0,T}), which are the sufficient statistics for pricing via
525+
conditional simulation, to be computed analytically as finite sums of
526+
independent normal random variables. The first L = `n_sin` sine terms are
527+
sampled explicitly; the truncated tail is approximated by four normal
528+
random variables (G_L, P_L, Q_L, R_L) whose joint covariance is given
529+
analytically (Eqs. (13)–(17) of the reference).
530+
531+
The method is several hundred times faster than the numerical Fourier-inversion
532+
approach of Li and Wu (2019). Variance is further reduced by conditional
533+
Monte Carlo simulation and a martingale-preserving control variate.
534+
535+
Examples:
536+
>>> import numpy as np
537+
>>> import pyfeng as pf
538+
>>> m = pf.OusvMcChoi2025KL(sigma=0.2, vov=0.1, mr=4, rho=-0.7, theta=0.2)
539+
>>> m.set_num_params(n_path=100000, dt=None, rn_seed=42, n_sin=4)
540+
>>> m.price(np.arange(80, 121, 10), spot=100, texp=1)
541+
542+
References:
543+
- Choi J (2025) Exact simulation scheme for the Ornstein–Uhlenbeck driven
544+
stochastic volatility model with the Karhunen–Loève expansions.
545+
Operations Research Letters 60:107280.
546+
https://doi.org/10.1016/j.orl.2025.107280
547+
"""
503548

504549
n_sin = 2
505550

506551
def set_num_params(self, n_path=10000, dt=None, rn_seed=None, antithetic=True, n_sin=2):
507552
"""
508-
Set MC parameters
553+
Set Monte Carlo parameters.
509554
510555
Args:
511-
n_path: number of paths
512-
dt: time step for Euler/Milstein steps
513-
rn_seed: random number seed
514-
antithetic: antithetic
515-
"""
516-
assert n_sin % 2 == 0
556+
n_path: number of simulation paths
557+
dt: time step size. If None (default), the entire period [0, T] is
558+
simulated in a single exact step (no discretization error).
559+
rn_seed: random number seed for reproducibility
560+
antithetic: if True (default), use antithetic variates for variance reduction
561+
n_sin: number of sine terms L in the KL expansion (must be a positive
562+
even integer). Higher values reduce the truncation error at the
563+
cost of slightly more computation. Default is 2.
564+
"""
565+
if n_sin % 2 != 0:
566+
raise ValueError(f"n_sin must be an even integer, got {n_sin}.")
517567
self.n_sin = n_sin
518568

519569
super().set_num_params(n_path, dt, rn_seed, antithetic)
520570

521571
@classmethod
522572
def _a2sum(cls, mr_t, ns=0, odd=None):
523573
"""
524-
sum_{n=ns+1}^\infty a_n^2 where a_n = sqrt(2 / (mr_t^2 + (n*pi)^2))
574+
Tail sum Σ_{n=ns+1}^∞ a_n² where a_n = √(2 / ((κT)² + (nπ)²)).
575+
576+
Used when pre-specified normal variates `zn` are passed to `cond_states_step`.
577+
In that case the tail quadratic contribution Σ_{n>L} a_n² (Z_n² - 1) is
578+
replaced by its correction term −Σ_{n>L} a_n² = −`_a2sum(mr_t, ns=L)`,
579+
so that the combined result Σ_{n=1}^L a_n² Z_n² − Σ_{n=1}^∞ a_n²
580+
is computed correctly (see in-code comment "`-r_m` is the correction to
581+
include only `an2 @ z_sin**2`").
582+
583+
The closed-form for the full sum (ns=0) is:
584+
Σ a_n² = (κT / tanh(κT) - 1) / (κT)²
525585
526586
Args:
527-
mr_t: mean reversion * time step
528-
ns: number of truncated terms. Must be an even number
529-
odd: sum all terms if None (default), odd terms only if odd=1, or even terms only if odd=2.
587+
mr_t: κT = mean-reversion speed × time step
588+
ns: number of leading terms already summed explicitly (must be even).
589+
Returns the tail sum from n=ns+1 onward.
590+
odd: None sums all terms; odd=1 sums odd-indexed terms only;
591+
odd=2 sums even-indexed terms only.
530592
531593
Returns:
532-
sum
594+
tail sum value (scalar)
533595
"""
534596
if odd == 2: # even
535597
rv = cls._a2sum(mr_t / 2) / 2**2
@@ -555,15 +617,24 @@ def _a2sum(cls, mr_t, ns=0, odd=None):
555617
@classmethod
556618
def _a2overn2sum(cls, mr_t, ns=0, odd=None):
557619
"""
558-
sum_{n=ns+1}^\infty a_n^2 / (n pi)^2 where a_n = sqrt(2 / (mr_t^2 + (n*pi)^2))
620+
Tail sum Σ_{n=ns+1}^∞ a_n² / (nπ)² where a_n = √(2 / ((κT)² + (nπ)²)).
621+
622+
This is f_L in Eq. (13) of the reference. It gives the variance of G_L,
623+
the truncated-tail contribution to Ũ_{0,T} from odd-indexed sine terms:
624+
Var(G_L) = f̃_L (odd-index tail of this sum).
625+
626+
The closed-form for the full sum (ns=0) is:
627+
Σ a_n²/(nπ)² = (1/3 - (κT/tanh(κT) - 1)/(κT)²) / (κT)²
559628
560629
Args:
561-
mr_t: mean reversion * time step
562-
ns: number of truncated terms. Must be an even number
563-
odd: sum all terms if None (default), odd terms only if odd=1, or even terms only if odd=2.
630+
mr_t: κT = mean-reversion speed × time step
631+
ns: number of leading terms already summed explicitly (must be even).
632+
Returns the tail sum from n=ns+1 onward.
633+
odd: None sums all terms; odd=1 sums odd-indexed terms only (f̃_L);
634+
odd=2 sums even-indexed terms only.
564635
565636
Returns:
566-
sum
637+
tail sum value (scalar)
567638
"""
568639

569640
if odd == 2: # even
@@ -590,15 +661,25 @@ def _a2overn2sum(cls, mr_t, ns=0, odd=None):
590661
@classmethod
591662
def _a4sum(cls, mr_t, ns=0, odd=None):
592663
"""
593-
sum_{n=ns+1}^\infty a_n^4 where a_n = sqrt(2 / (mr_t^2 + (n*pi)^2))
664+
Tail sum Σ_{n=ns+1}^∞ a_n⁴ where a_n = √(2 / ((κT)² + (nπ)²)).
665+
666+
This is c_L in Eq. (13) of the reference. It determines:
667+
- Var(R_L) = 2 c_L (variance of the quadratic correction term)
668+
- Cov(G_L, P_L) = c̃_L (cross-covariance between tail G and P, odd terms)
669+
used in the sampling scheme of Eq. (16).
670+
671+
The closed-form for the full sum (ns=0) is:
672+
Σ a_n⁴ = (κT/tanh(κT) + (κT)²/sinh²(κT) - 2) / (κT)⁴
594673
595674
Args:
596-
mr_t: mean reversion * time step
597-
ns: number of truncated terms. Must be an even number
598-
odd: sum all terms if None (default), odd terms only if odd=1, or even terms only if odd=2.
675+
mr_t: κT = mean-reversion speed × time step
676+
ns: number of leading terms already summed explicitly (must be even).
677+
Returns the tail sum from n=ns+1 onward.
678+
odd: None sums all terms; odd=1 sums odd-indexed terms only (c̃_L);
679+
odd=2 sums even-indexed terms only.
599680
600681
Returns:
601-
sum
682+
tail sum value (scalar)
602683
"""
603684

604685
if odd == 2: # even
@@ -625,15 +706,24 @@ def _a4sum(cls, mr_t, ns=0, odd=None):
625706
@classmethod
626707
def _a6sum(cls, mr_t, ns=0, odd=None):
627708
"""
628-
sum_{n=ns+1}^\infty a_n^6 where a_n = sqrt(2 / (mr_t^2 + (n*pi)^2))
709+
Tail sum Σ_{n=ns+1}^∞ a_n⁶ where a_n = √(2 / ((κT)² + (nπ)²)).
710+
711+
Auxiliary sum used internally to compute `_a6n2sum` via the identity
712+
Σ (nπ)² a_n⁶ = 2 Σ a_n⁴ - (κT)² Σ a_n⁶.
713+
714+
The closed-form for the full sum (ns=0) is:
715+
Σ a_n⁶ = (3κT/tanh(κT) + (3 + 2κT/tanh(κT))(κT)²/sinh²(κT) - 8)
716+
/ (2(κT)⁶)
629717
630718
Args:
631-
mr_t: mean reversion * time step
632-
ns: number of truncated terms. Must be an even number
633-
odd: sum all terms if None (default), odd terms only if odd=1, or even terms only if odd=2.
719+
mr_t: κT = mean-reversion speed × time step
720+
ns: number of leading terms already summed explicitly (must be even).
721+
Returns the tail sum from n=ns+1 onward.
722+
odd: None sums all terms; odd=1 sums odd-indexed terms only;
723+
odd=2 sums even-indexed terms only.
634724
635725
Returns:
636-
sum
726+
tail sum value (scalar)
637727
"""
638728

639729
if odd == 2: # even
@@ -661,15 +751,24 @@ def _a6sum(cls, mr_t, ns=0, odd=None):
661751
@classmethod
662752
def _a6n2sum(cls, mr_t, ns=0, odd=None):
663753
"""
664-
sum_{n=ns+1}^\infty (n pi)^2 a_n^6 where a_n = sqrt(2 / (mr_t^2 + (n*pi)^2))
754+
Tail sum Σ_{n=ns+1}^∞ (nπ)² a_n⁶ where a_n = √(2 / ((κT)² + (nπ)²)).
755+
756+
This is g_L in Eq. (13) of the reference. It determines the variances of
757+
the truncated-tail contributions P_L (odd) and Q_L (even) to Ṽ_{0,T}:
758+
Var(P_L) = g̃_L (odd-index tail), Var(Q_L) = ğ_L (even-index tail).
759+
760+
Computed via the identity (see Appendix B of the reference):
761+
Σ (nπ)² a_n⁶ = 2 Σ a_n⁴ - (κT)² Σ a_n⁶.
665762
666763
Args:
667-
mr_t: mean reversion * time step
668-
ns: number of truncated terms. Must be an even number
669-
odd: sum all terms if None (default), odd terms only if odd=1, or even terms only if odd=2.
764+
mr_t: κT = mean-reversion speed × time step
765+
ns: number of leading terms already summed explicitly (must be even).
766+
Returns the tail sum from n=ns+1 onward.
767+
odd: None sums all terms; odd=1 sums odd-indexed terms only (g̃_L);
768+
odd=2 sums even-indexed terms only (ğ_L).
670769
671770
Returns:
672-
sum
771+
tail sum value (scalar)
673772
"""
674773

675774
if odd == 2: # even
@@ -695,16 +794,40 @@ def _a6n2sum(cls, mr_t, ns=0, odd=None):
695794

696795
def cond_states_step(self, dt, vol_0, nz_theta=True, zn=None):
697796
"""
698-
Final volatility (sigma), average variance and volatilityu over dt given vol_0
797+
Exact simulation of (σ_T, V_{0,T}, U_{0,T}) over one time step dt.
798+
799+
Samples the triplet of sufficient statistics for the OUSV model using the
800+
KL expansion with L = `n_sin` explicit sine terms. The truncated tail
801+
(n > L) is approximated by four normal random variables G_L, P_L, Q_L, R_L
802+
whose joint distribution is given analytically in Eqs. (15)–(17) of the
803+
reference:
804+
805+
- G_L ~ N(0, f̃_L) (odd-term tail of Ũ_{0,T}, correlated with P_L)
806+
- P_L ~ N(0, g̃_L) (odd-term tail of Ṽ_{0,T})
807+
- Q_L ~ N(0, ğ_L) (even-term tail of Ṽ_{0,T}, independent of G_L, P_L)
808+
- R_L ≈ √(c_L) (W₄² - 1) (quadratic correction, Eq. (17))
809+
810+
Here f̃_L, g̃_L, ğ_L, and c_L are the tail sums computed by `_a2overn2sum`,
811+
`_a6n2sum` (odd/even), and `_a4sum`, respectively (see Eq. (13)).
699812
700813
Args:
701-
dt: time-to-expiry
702-
vol_0: initial volatility
703-
zn: normal RVs to specify in the (1+n_sin, n_path) format. None by default.
704-
nz_theta: non-zero theta. True by default. If False, assume theta=0 making computation simpler.
814+
dt: time step size T
815+
vol_0: initial volatility σ_0 (scalar or array of shape (n_path,))
816+
nz_theta: if True (default), include the long-term mean θ (non-zero theta).
817+
If False, assumes θ = 0 for a simpler computation.
818+
zn: pre-specified normal random variables of shape (1 + n_sin, n_path).
819+
Row 0 is used for σ_T; rows 1: are the Z_n sine coefficients.
820+
If None (default), random variates are drawn internally.
705821
706822
Returns:
707-
(final vol, average var, average vol)
823+
tuple (vol_t, vv_t, uu_t):
824+
- vol_t: terminal volatility σ_T, shape (n_path,)
825+
- vv_t: average variance V_{0,T} = (1/T) ∫₀ᵀ σ_t² dt, shape (n_path,)
826+
- uu_t: average volatility U_{0,T} = (1/T) ∫₀ᵀ σ_t dt, shape (n_path,)
827+
828+
References:
829+
- Choi J (2025) Eqs. (12), (15)–(18). Operations Research Letters 60:107280.
830+
https://doi.org/10.1016/j.orl.2025.107280
708831
"""
709832

710833
mr_t = self.mr * dt
@@ -781,6 +904,21 @@ def cond_states_step(self, dt, vol_0, nz_theta=True, zn=None):
781904
return vol_t, vv_t, uu_t
782905

783906
def unexplained_var_ratio(self, mr_t, ns=None):
907+
"""
908+
Fraction of variance in Ṽ_{0,T} not explained by the first `ns` sine terms.
909+
910+
Computed as c̃_L / c_0 = Σ_{n=ns+1}^∞ a_n⁴ / Σ_{n=1}^∞ a_n⁴, where
911+
c_L = `_a4sum(mr_t, ns=ns)` is the tail of the a_n⁴ series (Eq. (13)).
912+
A value close to 0 indicates that `ns` sine terms capture most of the
913+
variance, so the truncation error is small.
914+
915+
Args:
916+
mr_t: κT = mean-reversion speed × time step
917+
ns: number of explicit sine terms L. If None, uses `self.n_sin`.
918+
919+
Returns:
920+
unexplained variance ratio in [0, 1]
921+
"""
784922

785923
if ns is None:
786924
ns = self.n_sin
@@ -795,13 +933,31 @@ def strike_var_swap_analytic(self, texp, dt=None):
795933

796934
def vol_path_sin(self, tobs, zn=None):
797935
"""
798-
vol path composed of sin terms
936+
Simulate the full volatility path σ_t using the KL sine-series expansion.
937+
938+
Constructs the path according to Eq. (11) of the reference:
939+
940+
σ_t = θ + σ̄₀ e^{-κt} + σ̂_T sinh(κt)/sinh(κT)
941+
+ ξ√T Σ_{n=1}^L a_n sin(nπt/T) Z_n
942+
943+
where σ̄₀ = σ_0 - θ, σ̂_T is the centered terminal value, and
944+
a_n = √(2/((κT)² + (nπ)²)). Only the L = `n_sin` explicit sine terms
945+
are included (no tail correction).
946+
799947
Args:
800-
tobs: observation time (n_time, )
801-
zn: specified normal rvs to use (n_sin + 1, n_path). None by default
948+
tobs: observation times in [0, T], shape (n_time,). The last element
949+
is taken as T.
950+
zn: pre-specified normal random variables of shape (n_sin + 1, n_path).
951+
Row 0 is used for the terminal value σ_T (via `vol_step`);
952+
rows 1: are the Z_n sine coefficients. If None (default),
953+
variates are drawn internally using `rng_spawn[2]`.
802954
803955
Returns:
804-
vol path (n_time, n_path)
956+
volatility path of shape (n_time, n_path)
957+
958+
References:
959+
- Choi J (2025) Eq. (11). Operations Research Letters 60:107280.
960+
https://doi.org/10.1016/j.orl.2025.107280
805961
"""
806962
dt = tobs[-1]
807963
mr_t = self.mr * dt

tests/test_ousv.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_infinite_sum(self):
2828
An2n2 = An2 / n_pi_2
2929
An6n2 = n_pi_2 * An6
3030

31-
m = pf.OusvMcChoi2023KL(1, 1, 0, 1, theta=mrt)
31+
m = pf.OusvMcChoi2025KL(1, 1, 0, 1, theta=mrt)
3232

3333
assert np.isclose(1, sum(An2[ns:])/m._a2sum(mrt, ns=ns), atol=1e-4)
3434
assert np.isclose(1, sum(An2[ns::2])/m._a2sum(mrt, ns=ns, odd=1), atol=1e-4)
@@ -59,7 +59,7 @@ def test_integarl_sin_path(self):
5959
6060
"""
6161
sheet_no = 1
62-
m, p, rv = pf.OusvMcChoi2023KL.init_benchmark(sheet_no)
62+
m, p, rv = pf.OusvMcChoi2025KL.init_benchmark(sheet_no)
6363
n_sin = 6
6464
n_path = 50
6565
m.set_num_params(n_path=n_path, rn_seed=123456, n_sin=4, dt=None)
@@ -82,7 +82,7 @@ def test_MomentsIntVariance(self):
8282
"""
8383
Unconditional mean/var == E(conditional)
8484
"""
85-
m = pf.OusvMcChoi2023KL(sigma=1, vov=0.75, mr=2.5)
85+
m = pf.OusvMcChoi2025KL(sigma=1, vov=0.75, mr=2.5)
8686
zz, ww = spsp.roots_hermitenorm(31)
8787
ww /= np.sqrt(2 * np.pi)
8888

0 commit comments

Comments
 (0)