Skip to content

Commit 57fa597

Browse files
authored
Fix bugs and removed dead code (#51)
1 parent 7b08b77 commit 57fa597

10 files changed

Lines changed: 254 additions & 58 deletions

File tree

‎docs/theory/levy.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ While $\tau_t$ is always continuous, $\lambda$ can exhibit jumps. Since the time
4141
\Phi_{y_t, u} = {\mathbb E}\left[e^{i u x_{\tau_t}}\right] = {\mathbb E}\left[{\mathbb E}\left[\left.e^{i u x_s}\right|\tau_t=s\right]\right]
4242
\end{equation}
4343

44-
where the inside expectation is taken on $x_{\tau_t}$ conditional on a fixed value of $\tau_t = s$ and the outside expectation is on all possible values of $\tau_t$. If the random time $\tau_t$ is independent of $x_t$, the randomness due to the Lévy process can be integrated out using the characteristic function of $x_t$:
44+
where the inside expectation is taken on $x_{\tau_t}$ conditional on a fixed value of $\tau_t = s$ and the outside expectation is on all possible values of $\tau_t$.
45+
46+
If the random time $\tau_t$ is independent of $x_t$, the randomness due to the Lévy process can be integrated out using the characteristic function of $x_t$:
4547

4648
\begin{equation}
4749
\Phi_{y_t, u} = {\mathbb E}\left[e^{-\tau_t \phi_{x,u}}\right] = {\mathbb L}_{\tau_t}\left(\phi_{x_1,u}\right)

‎quantflow/sp/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def integrated_log_laplace(
204204
r"""The log-Laplace transform of the cumulative process:
205205
206206
\begin{equation}
207-
e^{\phi_{t, u}} = {\mathbb E} \left[e^{i u \int_0^t x_s ds}\right]
207+
\phi_{t, u} = \log {\mathbb E} \left[e^{-u \int_0^t x_s ds}\right]
208208
\end{equation}
209209
"""
210210

‎quantflow/sp/bns.py‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,29 @@ def sample(self, n: int, time_horizon: float = 1, time_steps: int = 100) -> Path
101101
return self.sample_from_draws(Paths.normal_draws(n, time_horizon, time_steps))
102102

103103
def sample_from_draws(self, path_dw: Paths, *args: Paths) -> Paths:
104+
kappa = self.variance_process.kappa
105+
time_steps = path_dw.time_steps
106+
time_horizon = path_dw.t
107+
dt = path_dw.dt
108+
104109
if args:
105110
path_dz = args[0]
106111
else:
107-
# generate the background driving process samples if not provided
112+
# BDLP runs at kappa-rescaled time, so sample over [0, kappa*T]
108113
path_dz = self.variance_process.bdlp.sample(
109-
path_dw.samples, path_dw.t, path_dw.time_steps
114+
path_dw.samples, kappa * time_horizon, time_steps
110115
)
111-
dt = path_dw.dt
112-
# sample the activity rate process
113-
v = self.variance_process.sample_from_draws(path_dz)
114-
# create the time-changed Brownian motion
115-
dw = path_dw.data * np.sqrt(v.data * dt)
116-
paths = np.zeros(dw.shape)
117-
paths[1:] = np.cumsum(dw[:-1], axis=0) + path_dz.data
118-
return Paths(t=path_dw.t, data=paths)
116+
117+
# Variance via the OU recursion v_{i+1} = v_i * e^{-kappa dt} + Delta z_{i+1}
118+
decay = np.exp(-kappa * dt)
119+
dz = np.diff(path_dz.data, axis=0)
120+
v = np.zeros_like(path_dz.data)
121+
v[0] = self.variance_process.rate
122+
for i in range(time_steps):
123+
v[i + 1] = v[i] * decay + dz[i]
124+
125+
# Price: x_t = integral sqrt(v) dW + rho * z_{kappa t}
126+
diffusion = np.sqrt(v[:-1] * dt) * path_dw.data[:-1]
127+
paths = np.zeros_like(path_dw.data)
128+
paths[1:] = np.cumsum(diffusion, axis=0) + self.rho * path_dz.data[1:]
129+
return Paths(t=time_horizon, data=paths)

‎quantflow/sp/dsp.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ def support(self, mean: float, std: float, points: int) -> FloatArray:
3434
return np.linspace(0, points, points + 1)
3535

3636
def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector:
37-
phi = self.poisson.characteristic_exponent(t, u)
38-
return -self.intensity.integrated_log_laplace(t, phi)
37+
# phi_x is the per-unit-time Lévy exponent of the inner Poisson;
38+
# the integrated log-Laplace already absorbs the time horizon
39+
phi_x = self.poisson.characteristic_exponent(1, u)
40+
return -self.intensity.integrated_log_laplace(t, phi_x)
3941

4042
def arrivals(self, t: float = 1) -> FloatArray:
4143
paths = self.intensity.sample(1, t, math.ceil(100 * t)).integrate()

‎quantflow/sp/heston.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,13 @@ def characteristic_exponent(
315315
t, u
316316
) + self.jumps.characteristic_exponent(t, u)
317317

318+
def sample_from_draws(self, path1: Paths, *args: Paths) -> Paths:
319+
diffusion = super().sample_from_draws(path1, *args)
320+
jump_path = self.jumps.sample(
321+
diffusion.samples, diffusion.t, diffusion.time_steps
322+
)
323+
return Paths(t=diffusion.t, data=diffusion.data + jump_path.data)
324+
318325

319326
class DoubleHeston(StochasticProcess1D):
320327
r"""Double Heston stochastic volatility model.

‎quantflow/sp/ou.py‎

Lines changed: 112 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,25 @@
55

66
import numpy as np
77
from pydantic import Field
8-
from scipy.optimize import Bounds
98
from scipy.stats import gamma, norm
109

1110
from ..ta.paths import Paths
1211
from ..utils.distributions import Exponential
1312
from ..utils.types import Float, FloatArrayLike, Vector
14-
from .base import Im, IntensityProcess
13+
from .base import IntensityProcess, StochasticProcess1D
1514
from .poisson import CompoundPoissonProcess, D
1615
from .weiner import WeinerProcess
1716

1817

19-
class Vasicek(IntensityProcess):
20-
r"""Gaussian OU process, also know as the
21-
[Vasiceck model](https://en.wikipedia.org/wiki/Vasicek_model)
18+
class Vasicek(StochasticProcess1D):
19+
r"""Gaussian OU process, also known as the
20+
[Vasicek model](https://en.wikipedia.org/wiki/Vasicek_model).
2221
2322
Historically, the Vasicek model was used to model the short rate, but it can be
2423
used to model any process that reverts to a mean level at a rate proportional to
25-
the difference between the current level and the mean level.
24+
the difference between the current level and the mean level. Unlike intensity
25+
processes, the Vasicek process can take negative values, so it is not constrained
26+
to a positive domain.
2627
2728
\begin{equation}
2829
dx_t = \kappa (\theta - x_t) dt + \sigma dw_t
@@ -37,11 +38,15 @@ class Vasicek(IntensityProcess):
3738
\end{equation}
3839
"""
3940

41+
rate: float = Field(default=1.0, description=r"Initial value $x_0$")
42+
kappa: float = Field(
43+
default=1.0, gt=0, description=r"Mean reversion speed $\kappa$"
44+
)
45+
theta: float = Field(default=1.0, description=r"Mean level $\theta$")
4046
bdlp: WeinerProcess = Field(
4147
default_factory=WeinerProcess,
42-
description="Background driving Weiner process",
48+
description="Background driving Wiener process",
4349
)
44-
theta: float = Field(default=1.0, gt=0, description=r"Mean rate $\theta$")
4550

4651
def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector:
4752
r"""The characteristic exponent of the Vasicek process is given by
@@ -54,9 +59,6 @@ def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector:
5459
var = self.analytical_variance(t)
5560
return u * (-1j * mu + 0.5 * var * u)
5661

57-
def integrated_log_laplace(self, t: FloatArrayLike, u: Vector) -> Vector:
58-
raise NotImplementedError
59-
6062
def sample(self, n: int, time_horizon: float = 1, time_steps: int = 100) -> Paths:
6163
paths = Paths.normal_draws(n, time_horizon, time_steps)
6264
return self.sample_from_draws(paths)
@@ -74,8 +76,8 @@ def sample_from_draws(self, draws: Paths, *args: Paths) -> Paths:
7476
paths[t + 1, :] = x + dx
7577
return Paths(t=draws.t, data=paths)
7678

77-
def domain_range(self) -> Bounds:
78-
return Bounds(-np.inf, np.inf)
79+
def ekt(self, t: FloatArrayLike) -> FloatArrayLike:
80+
return np.exp(-self.kappa * t)
7981

8082
def analytical_mean(self, t: FloatArrayLike) -> FloatArrayLike:
8183
r"""The analytical mean of the Vasicek process is given by
@@ -149,10 +151,12 @@ def intensity(self) -> float:
149151

150152
@abstractmethod
151153
def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector:
152-
r"""
154+
r"""Characteristic exponent of a non-Gaussian OU process driven by a
155+
Lévy process $Z$ with characteristic exponent $\phi_Z$:
156+
153157
\begin{equation}
154-
\phi_{x_t,u} = iu x_0 e^{-\kappa t} +
155-
\int_{ue^{-\kappa t}}^{u} \frac{\psi_Z(v)}{v} , dv
158+
\phi_{x_t, u} = - iu\, x_0\, e^{-\kappa t}
159+
+ \int_{u e^{-\kappa t}}^{u} \frac{\phi_Z(v)}{v}\,dv
156160
\end{equation}
157161
"""
158162

@@ -180,30 +184,82 @@ def create(cls, rate: float = 1, decay: float = 1, kappa: float = 1) -> Self:
180184
)
181185

182186
def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector:
183-
r"""
187+
r"""Closed form of the [NGOU][quantflow.sp.ou.NGOU] characteristic
188+
exponent when the BDLP is a compound Poisson process with intensity
189+
$\lambda$ and exponential jumps of decay $\beta$:
190+
191+
\begin{equation}
192+
\phi_{x_t, u} =
193+
- iu\, x_0\, e^{-\kappa t}
194+
+ \lambda \log\!\left(
195+
\frac{\beta - iu}{\beta - iu\, e^{-\kappa t}}
196+
\right)
197+
\end{equation}
198+
199+
Derivation. The characteristic function of the
200+
[Exponential][quantflow.utils.distributions.Exponential.characteristic]
201+
jumps is
202+
184203
\begin{equation}
185-
\phi_{x_t,u} =
186-
- iu x_0 e^{-\kappa t}
187-
+ \lambda \log\!\left(\frac{\beta - iu}{\beta - iu e^{-\kappa t}}\right)
204+
\Phi_J(v) = \frac{\beta}{\beta - iv},
188205
\end{equation}
206+
207+
so the BDLP unit-time characteristic exponent
208+
([CompoundPoissonProcess][quantflow.sp.poisson.CompoundPoissonProcess]
209+
at $t = 1$) is
210+
211+
\begin{equation}
212+
\phi_Z(v) = \lambda\bigl(1 - \Phi_J(v)\bigr)
213+
= -\frac{i v\, \lambda}{\beta - iv},
214+
\end{equation}
215+
216+
and $\phi_Z(v)/v = -i\lambda / (\beta - iv)$. Substituting
217+
$w = \beta - iv$ (so $dv = i\, dw$) in the NGOU integral gives
218+
219+
\begin{equation}
220+
\int_{u e^{-\kappa t}}^{u} \frac{\phi_Z(v)}{v}\, dv
221+
= \lambda \int_{\beta - iu\, e^{-\kappa t}}^{\beta - iu}
222+
\frac{dw}{w}
223+
= \lambda \log\!\left(
224+
\frac{\beta - iu}{\beta - iu\, e^{-\kappa t}}
225+
\right),
226+
\end{equation}
227+
228+
which combined with the drift term $-iu\, x_0\, e^{-\kappa t}$ yields
229+
the closed form above.
189230
"""
190231
b = self.beta
191-
iu = Im * u
232+
iu = 1j * u
192233
c1 = iu * np.exp(-self.kappa * t)
193234
c0 = self.intensity * np.log((b - c1) / (b - iu))
194235
return -c0 - c1 * self.rate
195236

196237
def integrated_log_laplace(self, t: FloatArrayLike, u: Vector) -> Vector:
238+
r"""Log-Laplace transform of the time-integrated Gamma-OU process.
239+
240+
\begin{equation}
241+
\begin{aligned}
242+
\phi(t, u) &= \log E\!\left[e^{-u \int_0^t x_s\, ds}\right] \\
243+
&= -u\, x_0\, \frac{1 - e^{-\kappa t}}{\kappa}
244+
+ \frac{\lambda}{u + \beta\kappa}
245+
\!\left[\beta\kappa\,
246+
\log\!\frac{\beta\kappa + u(1 - e^{-\kappa t})}{\beta\kappa}
247+
- u\kappa t\right]
248+
\end{aligned}
249+
\end{equation}
250+
251+
where $x_0$ is the initial rate, $\lambda$ is the BDLP intensity and
252+
$\beta$ the exponential jump decay.
253+
"""
197254
kappa = self.kappa
198255
b = self.beta
199-
iu = Im * u
200-
iuk = iu / kappa
256+
muk = -u / kappa
201257
ekt = np.exp(-kappa * t)
202-
c1 = iuk * (1 - ekt)
258+
c1 = muk * (1 - ekt)
203259
c0 = self.intensity * (
204-
b * np.log(b / (iuk + (b - iuk) / ekt)) / (iuk - b) - kappa * t
260+
b * np.log(b / (muk + (b - muk) / ekt)) / (muk - b) - kappa * t
205261
)
206-
return np.exp(c0 + c1 * self.rate)
262+
return c0 + c1 * self.rate
207263

208264
def sample(self, n: int, time_horizon: float = 1, time_steps: int = 100) -> Paths:
209265
dt = time_horizon / time_steps
@@ -235,28 +291,43 @@ def _advance(
235291
) -> int:
236292
x = pp[i - 1]
237293
kappa = self.kappa
238-
t0 = i * dt
239-
t1 = t0 + dt
294+
# step i fills pp[i] from pp[i-1] over the interval [(i-1) dt, i dt]
295+
t0 = (i - 1) * dt
296+
t1 = i * dt
240297
a = arrival or t1
241298
pp[i] = x - kappa * x * (a - t0) - kappa * (x + jump) * (t1 - a) + jump
242299
return i + 1
243300

244-
def cumulative_characteristic2(self, t: FloatArrayLike, u: Vector) -> Vector:
245-
"""Formula from a paper"""
246-
kappa = self.kappa
247-
b = self.beta
248-
iu = Im * u
249-
iuk = iu / kappa
250-
ekt = np.exp(-kappa * t)
251-
c1 = iuk * (1 - ekt)
252-
c0 = self.intensity * (b * np.log(b / (b - c1)) - iu * t) / (iuk - b)
253-
return np.exp(c0 + c1 * self.rate)
254-
255301
def analytical_mean(self, t: FloatArrayLike) -> FloatArrayLike:
256-
return self.intensity / self.beta
302+
r"""Analytical mean of the Gamma-OU process at time $t$.
303+
304+
\begin{equation}
305+
E[x_t] = x_0\, e^{-\kappa t}
306+
+ \frac{\lambda}{\beta}\bigl(1 - e^{-\kappa t}\bigr)
307+
\end{equation}
308+
309+
which converges to the stationary mean $\lambda / \beta$ as
310+
$t \to \infty$.
311+
"""
312+
ekt = self.ekt(t)
313+
return self.rate * ekt + (self.intensity / self.beta) * (1 - ekt)
257314

258315
def analytical_variance(self, t: FloatArrayLike) -> FloatArrayLike:
259-
return self.intensity / self.beta / self.beta
316+
r"""Analytical variance of the Gamma-OU process at time $t$.
317+
318+
\begin{equation}
319+
\mathrm{Var}(x_t) = \frac{\lambda}{\beta^2}
320+
\bigl(1 - e^{-2\kappa t}\bigr)
321+
\end{equation}
322+
323+
which converges to the stationary variance $\lambda / \beta^2$ as
324+
$t \to \infty$.
325+
"""
326+
return self.intensity * (1 - self.ekt(2 * t)) / (self.beta * self.beta)
260327

261328
def analytical_pdf(self, t: FloatArrayLike, x: FloatArrayLike) -> FloatArrayLike:
329+
"""Stationary marginal density of the Gamma-OU process. The transient
330+
density is not available in closed form; use `pdf_from_characteristic`
331+
for finite $t$.
332+
"""
262333
return gamma.pdf(x, self.intensity, scale=1 / self.beta)

‎quantflow_tests/test_bns.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ def test_bns_horizon(bns: BNS) -> None:
3232

3333
def test_bns_no_leverage_matches_integrated_laplace(bns: BNS) -> None:
3434
# at rho=0: E[exp(iu x_t)] = E[exp(-u^2/2 * int v_s ds)]
35-
# which is the GammaOU integrated Laplace transform at i*u^2/2
36-
u = np.array([0.1, 0.5, 1.0, 2.0, 5.0], dtype=complex)
35+
# which is the GammaOU integrated Laplace transform evaluated at u^2/2
36+
u = np.array([0.1, 0.5, 1.0, 2.0, 5.0])
3737
cf_bns = bns.characteristic(1, u)
38-
cf_via_ou = bns.variance_process.integrated_log_laplace(1, 1j * u * u / 2)
38+
cf_via_ou = np.exp(bns.variance_process.integrated_log_laplace(1, u * u / 2))
3939
np.testing.assert_allclose(cf_bns, cf_via_ou, atol=1e-12)
4040

4141

@@ -45,3 +45,16 @@ def test_bns_leverage(bns_leverage: BNS) -> None:
4545
assert bns_leverage.characteristic(1, 0) == 1
4646
# E[x_t] = rho * kappa * t * intensity / beta = -0.5 * 1 * 1 * 1.25 / 5
4747
assert m.mean_from_characteristic() == pytest.approx(-0.125, 1e-3)
48+
49+
50+
def test_bns_sample_moments(bns_leverage: BNS) -> None:
51+
np.random.seed(42)
52+
paths = bns_leverage.sample(5000, time_horizon=1.0, time_steps=200)
53+
assert paths.data.shape == (201, 5000)
54+
# Compare empirical moments at T=1 against the characteristic-function moments
55+
m = bns_leverage.marginal(1)
56+
terminal = paths.data[-1]
57+
assert terminal.mean() == pytest.approx(
58+
float(m.mean_from_characteristic()), abs=0.02
59+
)
60+
assert terminal.std() == pytest.approx(float(m.std_from_characteristic()), abs=0.02)

‎quantflow_tests/test_heston.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import numpy as np
12
import pytest
23

34
from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ
@@ -83,3 +84,21 @@ def test_double_heston_jumps_characteristic(
8384
characteristic_tests(m)
8485
assert m.mean() == pytest.approx(0.0, abs=1e-6)
8586
assert m.std() == pytest.approx(0.5, rel=0.05)
87+
88+
89+
def test_heston_jumps_sample_includes_jumps(
90+
heston_jumps: HestonJ[DoubleExponential],
91+
) -> None:
92+
np.random.seed(42)
93+
paths = heston_jumps.sample(5000, time_horizon=1.0, time_steps=200)
94+
# If jumps were dropped, std would collapse to sqrt(1 - jump_fraction) * 0.5
95+
# ~ 0.418, well below 0.5
96+
assert paths.data[-1].std() == pytest.approx(0.5, abs=0.03)
97+
98+
99+
def test_double_heston_jumps_sample_includes_jumps(
100+
double_heston_jumps: DoubleHestonJ[DoubleExponential],
101+
) -> None:
102+
np.random.seed(42)
103+
paths = double_heston_jumps.sample(5000, time_horizon=1.0, time_steps=200)
104+
assert paths.data[-1].std() == pytest.approx(0.5, abs=0.03)

0 commit comments

Comments
 (0)