Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,49 @@
Release Changelog
-----------------

0.8.0 (unreleased)
~~~~~~~~~~~~~~~~~~~

* Add ``HestonProcess``: Heston (1993) stochastic volatility model — a
bivariate diffusion for joint simulation of asset price and variance.
Implements the log-Euler scheme for the price and Euler–Maruyama with
full truncation (Lord *et al.*, 2010) for the variance.
* Add ``HestonProcess.sample_paths(n, paths, initial, antithetic)`` for
vectorised Monte Carlo simulation (~31× faster than serial calls).
Antithetic variates (``antithetic=True``) reduce Monte Carlo variance
at no additional simulation cost.
* Add ``HestonProcess.sample_at(times, initial)`` for simulation on
arbitrary non-uniform time grids, consistent with the library-wide API.
* Add ``HestonProcess.sample_paths_at(times, paths, initial)`` — the
vectorised counterpart of ``sample_at``.
* Add ``scheme`` parameter to ``HestonProcess`` supporting four variance
discretisation schemes: ``'full-truncation'`` (default), ``'reflection'``,
``'partial-truncation'`` (all from Lord *et al.*, 2010), and
``'quadratic-exponential'`` (Andersen, 2008) which samples the exact
conditional distribution of :math:`V_{t+\Delta t}|V_t`.
* Add analytical moment methods: ``expected_variance(t)``,
``variance_of_variance(t)``, and ``expected_log_return(t)``.
* Add ``HestonProcess.price_european(strike, risk_free_rate, ...)`` —
convenience Monte Carlo European option pricer with put-call parity.
* Add ``HestonProcess.price_european_fft(risk_free_rate, ...)`` —
Carr–Madan (1999) FFT option pricer: prices a full strike grid of N=4096
options simultaneously in ~0.5 ms, using the analytic characteristic
function; accuracy matches Gil–Pelaez quadrature to 4 decimal places.
* Add ``HestonProcess.variance_swap_rate(t)`` — closed-form fair strike
of a variance swap: :math:`K_{\mathrm{var}}=\theta+(V_0-\theta)(1-e^{-\kappa T})/(\kappa T)`.
* Add ``HestonProcess.implied_vol_smile(risk_free_rate, ...)`` — Black-Scholes
implied volatility surface extracted from the FFT option prices via
Brent's method inversion; returns (strikes, implied_vols) arrays.
* Add ``HestonProcess.fit(log_returns, dt)`` — MLE calibration from
observed log-return series.
* Add ``HestonProcess.fit_to_smile(strikes, market_prices, risk_free_rate, t)`` —
calibrate Heston parameters to a cross-section of observed call prices via
L-BFGS-B minimisation of the in-sample RMSE; uses the FFT pricer internally
so calibration is numerically cheap (~0.5 ms per function evaluation).
* Add Python type annotations to all files in
``stochastic.processes.diffusion``.
* Add ``HestonProcess`` to ``stochastic.processes.diffusion.__all__``.

0.7.0 (2022-07-11)
~~~~~~~~~~~~~~~~~~

Expand Down
68 changes: 68 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ the package.
* ConstantElasticityVarianceProcess
* CoxIngersollRossProcess
* ExtendedVasicekProcess
* **HestonProcess** *(new — Heston 1993 stochastic volatility)*
* OrnsteinUhlenbeckProcess
* VasicekProcess

Expand Down Expand Up @@ -183,3 +184,70 @@ process-specific implementations.

fgn = FractionalGaussianNoise(hurst=0.6, t=1)
s = fgn.sample(32, algorithm='hosking')


Heston stochastic volatility
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

``HestonProcess`` implements the Heston (1993) stochastic volatility model —
a bivariate diffusion for the joint evolution of an asset price :math:`S_t`
and its instantaneous variance :math:`V_t`.

.. code-block:: python

import numpy as np
from stochastic.processes.diffusion import HestonProcess

proc = HestonProcess(
mu=0.05, # risk-neutral drift
kappa=2.0, # mean-reversion speed
theta=0.04, # long-run variance (≈ 20 % ann. vol)
sigma=0.3, # volatility of variance
rho=-0.7, # leverage-effect correlation
initial_variance=0.04,
t=1.0, # 1-year horizon
rng=np.random.default_rng(42),
)

# Single path: returns (prices, variances) each of length n+1
prices, variances = proc.sample(252)

# Vectorised Monte Carlo: ~31× faster than a loop of sample()
prices_mc, vars_mc = proc.sample_paths(252, paths=10_000)

# Arbitrary time grid (e.g. quarterly observation dates)
t_obs = np.array([0.25, 0.5, 0.75, 1.0])
prices_at, vars_at = proc.sample_at(t_obs)

# Analytical moments
print(proc.expected_variance(1.0)) # E[V_1]
print(proc.expected_log_return(1.0)) # E[log(S_1/S_0)]

# European option pricing (Monte Carlo)
price, std_error = proc.price_european(
strike=1.0, risk_free_rate=0.05, paths=50_000
)

# FFT option pricer: 4096 strikes in ~0.5 ms (exact CF, no simulation)
strikes, call_prices = proc.price_european_fft(risk_free_rate=0.05)

# Implied volatility smile (BS inversion of FFT prices)
strikes, implied_vols = proc.implied_vol_smile(risk_free_rate=0.05)

# Variance swap fair strike (closed-form)
kvar = proc.variance_swap_rate() # annualised fair variance

# Characteristic function for Fourier-based pricing
phi = proc.characteristic_function(np.linspace(0, 10, 100), t=1.0)

# Calibration from observed log-returns
log_returns = np.diff(np.log(prices))
fitted = HestonProcess.fit(log_returns, dt=1 / 252)

Four variance discretisation schemes are available via the ``scheme``
parameter:

* ``'full-truncation'`` (default) — Lord *et al.* (2010)
* ``'reflection'`` — Lord *et al.* (2010)
* ``'partial-truncation'`` — Lord *et al.* (2010)
* ``'quadratic-exponential'`` — Andersen (2008), highest accuracy
24 changes: 21 additions & 3 deletions stochastic/processes/diffusion/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
"""Diffusion processes."""
from stochastic.processes.diffusion.constant_elasticity_variance import (
ConstantElasticityVarianceProcess,
)
from stochastic.processes.diffusion.cox_ingersoll_ross import CoxIngersollRossProcess
from stochastic.processes.diffusion.cox_ingersoll_ross import (
CoxIngersollRossProcess,
)
from stochastic.processes.diffusion.diffusion import DiffusionProcess
from stochastic.processes.diffusion.extended_vasicek import ExtendedVasicekProcess
from stochastic.processes.diffusion.ornstein_uhlenbeck import OrnsteinUhlenbeckProcess
from stochastic.processes.diffusion.extended_vasicek import (
ExtendedVasicekProcess,
)
from stochastic.processes.diffusion.heston import HestonProcess
from stochastic.processes.diffusion.ornstein_uhlenbeck import (
OrnsteinUhlenbeckProcess,
)
from stochastic.processes.diffusion.vasicek import VasicekProcess

__all__ = [
"ConstantElasticityVarianceProcess",
"CoxIngersollRossProcess",
"DiffusionProcess",
"ExtendedVasicekProcess",
"HestonProcess",
"OrnsteinUhlenbeckProcess",
"VasicekProcess",
]
75 changes: 50 additions & 25 deletions stochastic/processes/diffusion/constant_elasticity_variance.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Constant elasticity of variance (CEV) process."""
from __future__ import annotations

from typing import Optional

import numpy as np

from stochastic.processes.diffusion.diffusion import DiffusionProcess
from stochastic.utils import ensure_single_arg_constant_function
from stochastic.utils import single_arg_constant_function
Expand All @@ -11,32 +17,39 @@ class ConstantElasticityVarianceProcess(DiffusionProcess):
.. image:: _static/constant_elasticity_variance_process.png
:scale: 50%

The process :math:`X_t` that satisfies the following stochastic
differential equation with Wiener process :math:`W_t`:
The process :math:`X_t` satisfies the stochastic differential equation
with Wiener process :math:`W_t`:

.. math::

dX_t = \mu X_t dt + \sigma X_t^\gamma dW_t
dX_t = \mu X_t \, dt + \sigma X_t^\gamma \, dW_t

Realizations are generated using the Euler-Maruyama method.
Realisations are generated using the EulerMaruyama method.

.. note::

Since the family of diffusion processes have parameters which
generalize to functions of ``t``, parameter attributes will be returned
as callables, even if they are initialized as constants. e.g. a
``speed`` parameter of 1 accessed from an instance attribute will return
a function which accepts a single argument and always returns 1.

:param float drift: the drift coefficient, or :math:`\mu` above
:param float vol: the volatility coefficient, or :math:`\sigma` above
:param float volexp: the volatility-price exponent, or :math:`\gamma` above
:param float t: the right hand endpoint of the time interval :math:`[0,t]`
for the process
:param numpy.random.Generator rng: a custom random number generator
generalise to functions of ``t``, parameter attributes are returned
as callables, even when initialised as constants. For example, a
``speed`` parameter of ``1`` returns a function that always returns
``1``.

:param float drift: the drift coefficient :math:`\mu`.
:param float vol: the volatility coefficient :math:`\sigma`.
:param float volexp: the volatility-price exponent :math:`\gamma`.
:param float t: the right-hand endpoint of the time interval
:math:`[0, t]`.
:param numpy.random.Generator rng: a custom random-number generator.
"""

def __init__(self, drift=1, vol=1, volexp=1, t=1, rng=None):
def __init__(
self,
drift: float = 1,
vol: float = 1,
volexp: float = 1,
t: float = 1,
rng: Optional[np.random.Generator] = None,
) -> None:
super().__init__(
speed=single_arg_constant_function(-drift),
mean=single_arg_constant_function(1),
Expand All @@ -47,23 +60,35 @@ def __init__(self, drift=1, vol=1, volexp=1, t=1, rng=None):
)
self.drift = drift

def __str__(self):
return "Constant elasticity of variance process with drift={m}, vol={v}, volexp={e} on [0, {t}]".format(
m=str(self.drift), v=str(self.vol), e=str(self.volexp), t=str(self.t)
def __str__(self) -> str:
return (
"Constant elasticity of variance process with drift={m}, "
"vol={v}, volexp={e} on [0, {t}]"
).format(
m=str(self.drift),
v=str(self.vol),
e=str(self.volexp),
t=str(self.t),
)

def __repr__(self):
return "ConstantElasticityVarianceProcess(drift={d}, vol={v}, volexp={e}, t={t})".format(
v=str(self.vol), d=str(self.drift), e=str(self.volexp), t=str(self.t)
def __repr__(self) -> str:
return (
"ConstantElasticityVarianceProcess("
"drift={d}, vol={v}, volexp={e}, t={t})"
).format(
v=str(self.vol),
d=str(self.drift),
e=str(self.volexp),
t=str(self.t),
)

@property
def drift(self):
"""Drift, or Mu."""
def drift(self) -> float:
r"""Drift coefficient :math:`\mu`."""
return self._drift

@drift.setter
def drift(self, value):
def drift(self, value: float) -> None:
check_numeric(value, "Drift coefficient.")
self._drift = ensure_single_arg_constant_function(value)
self.speed = ensure_single_arg_constant_function(-value)
72 changes: 46 additions & 26 deletions stochastic/processes/diffusion/cox_ingersoll_ross.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,52 @@
"""Cox-Ingersoll-Ross process."""
from __future__ import annotations

from typing import Optional

import numpy as np

from stochastic.processes.diffusion.diffusion import DiffusionProcess
from stochastic.utils import single_arg_constant_function


class CoxIngersollRossProcess(DiffusionProcess):
r"""Cox-Ingersoll-Ross process.

A model for instantaneous interest rate.

.. image:: _static/cox_ingersoll_ross_process.png
:scale: 50%

A model for instantaneous interest rate.

The process :math:`X_t` that satisfies the following stochastic
differential equation with Wiener process :math:`W_t`:
The process :math:`X_t` satisfies the stochastic differential equation
with Wiener process :math:`W_t`:

.. math::

dX_t = \theta (\mu - X_t) dt + \sigma \sqrt{X_t} dW_t
dX_t = \theta(\mu - X_t) \, dt + \sigma \sqrt{X_t} \, dW_t

Realizations are generated using the Euler-Maruyama method.
Realisations are generated using the EulerMaruyama method.

.. note::

Since the family of diffusion processes have parameters which
generalize to functions of ``t``, parameter attributes will be returned
as callables, even if they are initialized as constants. e.g. a
``speed`` parameter of 1 accessed from an instance attribute will return
a function which accepts a single argument and always returns 1.

:param float speed: the speed of reversion, or :math:`\theta` above
:param float mean: the mean of the process, or :math:`\mu` above
:param float vol: volatility coefficient of the process, or :math:`\sigma`
above
:param float t: the right hand endpoint of the time interval :math:`[0,t]`
for the process
:param numpy.random.Generator rng: a custom random number generator
The *Feller condition* :math:`2\theta\mu > \sigma^2` ensures that
:math:`X_t > 0` almost surely.

:param float speed: the speed of mean reversion :math:`\theta > 0`.
:param float mean: the long-run mean :math:`\mu > 0`.
:param float vol: the volatility coefficient :math:`\sigma > 0`.
:param float t: the right-hand endpoint of the time interval
:math:`[0, t]`.
:param numpy.random.Generator rng: a custom random-number generator.
"""

def __init__(self, speed=1, mean=0, vol=1, t=1, rng=None):
def __init__(
self,
speed: float = 1,
mean: float = 0,
vol: float = 1,
t: float = 1,
rng: Optional[np.random.Generator] = None,
) -> None:
super().__init__(
speed=single_arg_constant_function(speed),
mean=single_arg_constant_function(mean),
Expand All @@ -47,12 +56,23 @@ def __init__(self, speed=1, mean=0, vol=1, t=1, rng=None):
rng=rng,
)

def __str__(self):
return "Cox-Ingersoll-Ross process with speed={s}, mean={m}, vol={v} on [0, {t}]".format(
s=str(self.speed), m=str(self.mean), v=str(self.vol), t=str(self.t)
def __str__(self) -> str:
return (
"Cox-Ingersoll-Ross process with speed={s}, mean={m}, "
"vol={v} on [0, {t}]"
).format(
s=str(self.speed),
m=str(self.mean),
v=str(self.vol),
t=str(self.t),
)

def __repr__(self):
return "CoxIngersollRossProcess(speed={s}, mean={m}, vol={v}, t={t})".format(
s=str(self.speed), m=str(self.mean), v=str(self.vol), t=str(self.t)
def __repr__(self) -> str:
return (
"CoxIngersollRossProcess(speed={s}, mean={m}, vol={v}, t={t})"
).format(
s=str(self.speed),
m=str(self.mean),
v=str(self.vol),
t=str(self.t),
)
Loading