diff --git a/README.md b/README.md index fc4d3a9b..a6fa3395 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ This project is owned by [Preferred Networks](https://www.preferred.jp/en/) and ## References -* Hans Bühler, Lukas Gonon, Josef Teichmann and Ben Wood, "[Deep hedging][deep-hedging-qf]". Quantitative Finance, 2019, 19, 1271-1291. arXiv:[1609.05213][deep-hedging-arxiv] \[q-fin.CP\]. +* Hans Bühler, Lukas Gonon, Josef Teichmann and Ben Wood, "[Deep hedging][deep-hedging-qf]". Quantitative Finance, 2019, 19, 1271-1291. arXiv:[1802.03042][deep-hedging-arxiv] \[q-fin.CP\]. * Hans Bühler, Lukas Gonon, Josef Teichmann, Ben Wood, Baranidharan Mohan and Jonathan Kochems, [Deep Hedging: Hedging Derivatives Under Generic Market Frictions Using Reinforcement Learning][deep-hedging-wp] (March 19, 2019). Swiss Finance Institute Research Paper No. 19-80. * Shota Imaki, Kentaro Imajo, Katsuya Ito, Kentaro Minami and Kei Nakagawa, "No-Transaction Band Network: A Neural Network Architecture for Efficient Deep Hedging". arXiv:[2103.01775][ntb-network-arxiv] \[q-fin.CP\]. diff --git a/docs/source/instruments.rst b/docs/source/instruments.rst index 66223459..3a512be8 100644 --- a/docs/source/instruments.rst +++ b/docs/source/instruments.rst @@ -16,6 +16,7 @@ Primary Instruments instruments.Primary instruments.BrownianStock + instruments.HestonStock Derivative Instruments ---------------------- diff --git a/docs/source/stochastic.rst b/docs/source/stochastic.rst index 08059365..585626f8 100644 --- a/docs/source/stochastic.rst +++ b/docs/source/stochastic.rst @@ -11,3 +11,13 @@ Brownian Motion .. autofunction:: generate_brownian .. autofunction:: generate_geometric_brownian + +Cox-Ingersoll-Ross Process +-------------------------- + +.. autofunction:: generate_cir + +Heston Process +-------------- + +.. autofunction:: generate_heston diff --git a/pfhedge/instruments/__init__.py b/pfhedge/instruments/__init__.py index 0e3de500..4be750ef 100644 --- a/pfhedge/instruments/__init__.py +++ b/pfhedge/instruments/__init__.py @@ -1,7 +1,8 @@ -from .american_binary import AmericanBinaryOption -from .base import Derivative -from .base import Primary -from .european import EuropeanOption -from .european_binary import EuropeanBinaryOption -from .lookback import LookbackOption -from .underlier import BrownianStock +from .derivative.american_binary import AmericanBinaryOption +from .derivative.base import Derivative +from .derivative.european import EuropeanOption +from .derivative.european_binary import EuropeanBinaryOption +from .derivative.lookback import LookbackOption +from .primary.base import Primary +from .primary.brownian import BrownianStock +from .primary.heston import HestonStock diff --git a/pfhedge/instruments/base.py b/pfhedge/instruments/base.py index 747bec38..f1688270 100644 --- a/pfhedge/instruments/base.py +++ b/pfhedge/instruments/base.py @@ -76,124 +76,3 @@ def dinfo(self) -> list: dinfo.append("device='" + str(device) + "'") return dinfo - - -class Primary(Instrument): - """Base class for all primary instruments. - - A primary instrument is a basic financial instrument which is traded on a market - and therefore the price is accessible as the market price. - Examples include stocks, bonds, commodities, and currencies. - - Derivatives are issued based on primary instruments - (See :class:`Derivative` for details). - - Attributes: - dtype (torch.dtype): The dtype with which the simulated time-series are - represented. - device (torch.device): The device where the simulated time-series are. - """ - - spot: torch.Tensor - dtype: torch.dtype - device: torch.device - - @abstractmethod - def simulate( - self, n_paths: int, time_horizon: float, init_state: Optional[tuple] = None - ) -> None: - """Simulate time series associated with the instrument and add them as buffers. - - Args: - n_paths (int): The number of paths to simulate. - time_horizon (float): The period of time to simulate the price. - init_state (tuple, optional): The initial state of the instrument. - If `None` (default), sensible default value is used. - """ - - def to(self: T, *args, **kwargs) -> T: - device, dtype, *_ = torch._C._nn._parse_to(*args, **kwargs) - - if dtype is not None and not dtype.is_floating_point: - raise TypeError( - f"Instrument.to only accepts floating point " - f"dtypes, but got desired dtype={dtype}" - ) - - if not hasattr(self, "dtype") or dtype is not None: - self.dtype = dtype - if not hasattr(self, "device") or device is not None: - self.device = device - - # If the buffers have been already simulated, move it - if hasattr(self, "spot"): - self.spot = self.spot.to(*args, **kwargs) - - return self - - -class Derivative(Instrument): - """Base class for all derivatives. - - A derivative is a financial instrument whose payoff is contingent on - a primary instrument (or a set of primary instruments). - A (over-the-counter) derivative is not traded on the market and therefore the price - is not directly accessible. - Examples include options and swaps. - - A derivative relies on primary assets (See :class:`Primary` for details), such as - stocks, bonds, commodities, and currencies. - - Attributes: - underlier (:class:`Primary`): The underlying asset on which the derivative's - payoff relies. - dtype (torch.dtype): The dtype with which the simulated time-series are - represented. - device (torch.device): The device where the simulated time-series are. - """ - - underlier: Primary - maturity: float - - @property - def dtype(self) -> torch.dtype: - return self.underlier.dtype - - @property - def device(self) -> torch.device: - return self.underlier.device - - def simulate( - self, n_paths: int = 1, init_state: Optional[tuple] = None, **kwargs - ) -> None: - """Simulate time series associated with the underlier. - - Args: - n_paths (int): The number of paths to simulate. - init_state (tuple, optional): The initial state of the underlying - instrument. If `None` (default), sensible default values are used. - **kwargs: Other parameters passed to `self.underlier.simulate()`. - """ - self.underlier.simulate( - n_paths=n_paths, time_horizon=self.maturity, init_state=init_state, **kwargs - ) - - def to(self: T, *args, **kwargs) -> T: - self.underlier.to(*args, **kwargs) - return self - - @abstractmethod - def payoff(self) -> Tensor: - """Returns the payoffs of the derivative. - - Shape: - - Output: :math:`(N)` where :math:`N` stands for the number of simulated - paths. - - Returns: - torch.Tensor - """ - - -Primary.to.__doc__ = Instrument.to.__doc__ -Derivative.to.__doc__ = Instrument.to.__doc__ diff --git a/pfhedge/instruments/derivative/__init__.py b/pfhedge/instruments/derivative/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pfhedge/instruments/american_binary.py b/pfhedge/instruments/derivative/american_binary.py similarity index 98% rename from pfhedge/instruments/american_binary.py rename to pfhedge/instruments/derivative/american_binary.py index ee2c19cb..47cb6471 100644 --- a/pfhedge/instruments/american_binary.py +++ b/pfhedge/instruments/derivative/american_binary.py @@ -1,7 +1,7 @@ import torch from torch import Tensor -from ..nn.functional import american_binary_payoff +from ...nn.functional import american_binary_payoff from .base import Derivative diff --git a/pfhedge/instruments/derivative/base.py b/pfhedge/instruments/derivative/base.py new file mode 100644 index 00000000..153041c0 --- /dev/null +++ b/pfhedge/instruments/derivative/base.py @@ -0,0 +1,77 @@ +from abc import ABC +from abc import abstractmethod +from typing import Optional +from typing import TypeVar + +import torch +from torch import Tensor + +from ..base import Instrument + +T = TypeVar("T") + + +class Derivative(Instrument): + """Base class for all derivatives. + + A derivative is a financial instrument whose payoff is contingent on + a primary instrument (or a set of primary instruments). + A (over-the-counter) derivative is not traded on the market and therefore the price + is not directly accessible. + Examples include options and swaps. + + A derivative relies on primary assets (See :class:`Primary` for details), such as + stocks, bonds, commodities, and currencies. + + Attributes: + underlier (:class:`Primary`): The underlying asset on which the derivative's + payoff relies. + dtype (torch.dtype): The dtype with which the simulated time-series are + represented. + device (torch.device): The device where the simulated time-series are. + """ + + underlier: "Primary" + maturity: float + + @property + def dtype(self) -> torch.dtype: + return self.underlier.dtype + + @property + def device(self) -> torch.device: + return self.underlier.device + + def simulate( + self, n_paths: int = 1, init_state: Optional[tuple] = None, **kwargs + ) -> None: + """Simulate time series associated with the underlier. + + Args: + n_paths (int): The number of paths to simulate. + init_state (tuple, optional): The initial state of the underlying + instrument. If `None` (default), sensible default values are used. + **kwargs: Other parameters passed to `self.underlier.simulate()`. + """ + self.underlier.simulate( + n_paths=n_paths, time_horizon=self.maturity, init_state=init_state, **kwargs + ) + + def to(self: T, *args, **kwargs) -> T: + self.underlier.to(*args, **kwargs) + return self + + @abstractmethod + def payoff(self) -> Tensor: + """Returns the payoffs of the derivative. + + Shape: + - Output: :math:`(N)` where :math:`N` stands for the number of simulated + paths. + + Returns: + torch.Tensor + """ + + +Derivative.to.__doc__ = Instrument.to.__doc__ diff --git a/pfhedge/instruments/european.py b/pfhedge/instruments/derivative/european.py similarity index 98% rename from pfhedge/instruments/european.py rename to pfhedge/instruments/derivative/european.py index 301816b1..e35574c4 100644 --- a/pfhedge/instruments/european.py +++ b/pfhedge/instruments/derivative/european.py @@ -1,7 +1,7 @@ import torch from torch import Tensor -from ..nn.functional import european_payoff +from ...nn.functional import european_payoff from .base import Derivative diff --git a/pfhedge/instruments/european_binary.py b/pfhedge/instruments/derivative/european_binary.py similarity index 98% rename from pfhedge/instruments/european_binary.py rename to pfhedge/instruments/derivative/european_binary.py index f3e1344c..d7c66c5d 100644 --- a/pfhedge/instruments/european_binary.py +++ b/pfhedge/instruments/derivative/european_binary.py @@ -1,7 +1,7 @@ import torch from torch import Tensor -from ..nn.functional import european_binary_payoff +from ...nn.functional import european_binary_payoff from .base import Derivative diff --git a/pfhedge/instruments/lookback.py b/pfhedge/instruments/derivative/lookback.py similarity index 98% rename from pfhedge/instruments/lookback.py rename to pfhedge/instruments/derivative/lookback.py index 4422b783..4af41ee1 100644 --- a/pfhedge/instruments/lookback.py +++ b/pfhedge/instruments/derivative/lookback.py @@ -1,7 +1,7 @@ import torch from torch import Tensor -from ..nn.functional import lookback_payoff +from ...nn.functional import lookback_payoff from .base import Derivative diff --git a/pfhedge/instruments/primary/__init__.py b/pfhedge/instruments/primary/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pfhedge/instruments/primary/base.py b/pfhedge/instruments/primary/base.py new file mode 100644 index 00000000..047f30c4 --- /dev/null +++ b/pfhedge/instruments/primary/base.py @@ -0,0 +1,140 @@ +from abc import ABC +from abc import abstractmethod +from collections import OrderedDict +from typing import Iterator +from typing import Optional +from typing import Tuple +from typing import TypeVar +from typing import Union + +import torch +from torch import Tensor + +T = TypeVar("T", bound="Primary") + + +from ..base import Instrument + + +class Primary(Instrument): + """Base class for all primary instruments. + + A primary instrument is a basic financial instrument which is traded on a market + and therefore the price is accessible as the market price. + Examples include stocks, bonds, commodities, and currencies. + + Derivatives are issued based on primary instruments + (See :class:`Derivative` for details). + + Attributes: + dtype (torch.dtype): The dtype with which the simulated time-series are + represented. + device (torch.device): The device where the simulated time-series are. + """ + + spot: torch.Tensor + dtype: torch.dtype + device: torch.device + + def __init__(self): + self._buffers = OrderedDict() + + @property + def default_init_state(self): + """Returns the default initial state of simulation.""" + + @abstractmethod + def simulate( + self, n_paths: int, time_horizon: float, init_state: Optional[tuple] = None + ) -> None: + """Simulate time series associated with the instrument and add them as buffers. + + Args: + n_paths (int): The number of paths to simulate. + time_horizon (float): The period of time to simulate the price. + init_state (tuple, optional): The initial state of the instrument. + If `None` (default), sensible default value is used. + """ + + def register_buffer(self, name: str, tensor: Optional[Tensor]) -> None: + """Adds a buffer to the module. + + Buffers can be accessed as attributes using given names. + + Args: + name (string): name of the buffer. The buffer can be accessed + from this module using the given name + tensor (Tensor or None): buffer to be registered. If ``None``, then operations + that run on buffers, such as :attr:`cuda`, are ignored. If ``None``, + the buffer is **not** included in the module's :attr:`state_dict`. + """ + # Implementation here refers to `torch.nn.Module.register_buffer`. + if "_buffers" not in self.__dict__: + raise AttributeError("cannot assign buffer before Primary.__init__() call") + elif not isinstance(name, torch._six.string_classes): + raise TypeError( + "buffer name should be a string. " "Got {}".format(torch.typename(name)) + ) + elif "." in name: + raise KeyError('buffer name can\'t contain "."') + elif name == "": + raise KeyError('buffer name can\'t be empty string ""') + elif hasattr(self, name) and name not in self._buffers: + raise KeyError("attribute '{}' already exists".format(name)) + elif tensor is not None and not isinstance(tensor, torch.Tensor): + raise TypeError( + "cannot assign '{}' object to buffer '{}' " + "(torch Tensor or None required)".format(torch.typename(tensor), name) + ) + else: + self._buffers[name] = tensor + + def named_buffers(self) -> Iterator[Tuple[str, Tensor]]: + """Returns an iterator over module buffers, yielding both the + name of the buffer as well as the buffer itself. + + Yields: + (string, torch.Tensor): Tuple containing the name and buffer + """ + for name, buffer in self._buffers.items(): + yield name, buffer + + def buffers(self) -> Iterator[Tensor]: + r"""Returns an iterator over module buffers. + + Yields: + torch.Tensor: module buffer + """ + for _, buffer in self.named_buffers(): + yield buffer + + def __getattr__(self, name: str) -> Union[Tensor, "Module"]: + if "_buffers" in self.__dict__: + _buffers = self.__dict__["_buffers"] + if name in _buffers: + return _buffers[name] + raise AttributeError( + "'{}' object has no attribute '{}'".format(type(self).__name__, name) + ) + + def to(self: T, *args, **kwargs) -> T: + device, dtype, *_ = torch._C._nn._parse_to(*args, **kwargs) + + if dtype is not None and not dtype.is_floating_point: + raise TypeError( + f"Instrument.to only accepts floating point " + f"dtypes, but got desired dtype={dtype}" + ) + + if not hasattr(self, "dtype") or dtype is not None: + self.dtype = dtype + if not hasattr(self, "device") or device is not None: + self.device = device + + for name, buffer in self.named_buffers(): + self.register_buffer(name, buffer.to(*args, **kwargs)) + + return self + + +Primary.to.__doc__ = Instrument.to.__doc__ diff --git a/pfhedge/instruments/underlier.py b/pfhedge/instruments/primary/brownian.py similarity index 89% rename from pfhedge/instruments/underlier.py rename to pfhedge/instruments/primary/brownian.py index 019c9086..d3223643 100644 --- a/pfhedge/instruments/underlier.py +++ b/pfhedge/instruments/primary/brownian.py @@ -2,7 +2,7 @@ import torch -from ..stochastic import generate_geometric_brownian +from ...stochastic import generate_geometric_brownian from .base import Primary @@ -53,8 +53,8 @@ def __init__( volatility: float = 0.2, cost: float = 0.0, dt: float = 1 / 250, - dtype: torch.dtype = None, - device: torch.device = None, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, ): super().__init__() @@ -64,6 +64,10 @@ def __init__( self.to(dtype=dtype, device=device) + @property + def default_init_state(self) -> tuple: + return (1.0,) + def __repr__(self): params = [f"volatility={self.volatility:.2e}"] if self.cost != 0.0: @@ -90,7 +94,7 @@ def simulate( the price. init_state (tuple, optional): The initial state of the instrument. `init_state` should be a 1-tuple `(spot,)` - where spot is the initial spot price. + where `spot` is the initial spot price. If `None` (default), the default value `(1.0,)` is chosen. Examples: @@ -103,10 +107,9 @@ def simulate( [2.0000, 2.0565, 2.0398, 2.0516, 2.0584]]) """ if init_state is None: - # Default value - init_state = (1.0,) + init_state = self.default_init_state - self.spot = generate_geometric_brownian( + spot = generate_geometric_brownian( n_paths=n_paths, n_steps=int(time_horizon / self.dt), init_value=init_state[0], @@ -116,7 +119,10 @@ def simulate( device=self.device, ) + self.register_buffer("spot", spot) + # Assign docstrings so they appear in Sphinx documentation +BrownianStock.default_init_state.__doc__ = Primary.default_init_state.__doc__ BrownianStock.to = Primary.to BrownianStock.to.__doc__ = Primary.to.__doc__ diff --git a/pfhedge/instruments/primary/heston.py b/pfhedge/instruments/primary/heston.py new file mode 100644 index 00000000..14e341ff --- /dev/null +++ b/pfhedge/instruments/primary/heston.py @@ -0,0 +1,139 @@ +from typing import Optional + +import torch + +from ...stochastic import generate_heston +from .base import Primary + + +class HestonStock(Primary): + """A stock of which spot price and variance follow Heston process. + + See :func:`pfhedge.stochastic.generate_heston` for details of the Heston process. + + Args: + kappa (float, default=1.0): The parameter :math:`\\kappa`. + theta (float, default=0.04): The parameter :math:`\\theta`. + sigma (float, default=2.0): The parameter :math:`\\sigma`. + rho (float, default=-0.7): The parameter :math:`\\rho`. + cost (float, default=0.0): The transaction cost rate. + dt (float, default=1/250): The intervals of the time steps. + dtype (torch.device, optional): Desired device of returned tensor. + Default: If None, uses a global default + (see `torch.set_default_tensor_type()`). + device (torch.device, optional): Desired device of returned tensor. + Default: if None, uses the current device for the default tensor type + (see `torch.set_default_tensor_type()`). + `device` will be the CPU for CPU tensor types and + the current CUDA device for CUDA tensor types. + + Attributes: + spot (torch.Tensor): The spot price of the instrument. + This attribute is set by a method :func:`simulate()`. + The shape is :math:`(N, T)`, where :math:`N` is the number of simulated + paths and :math:`T` is the number of time steps. + variance (torch.Tensor): The variance of the spot of the instrument. + This attribute is set by a method :func:`simulate()`. + The shape is :math:`(N, T)`. + + Examples: + + >>> from pfhedge.instruments import HestonStock + >>> + >>> _ = torch.manual_seed(42) + >>> stock = HestonStock() + >>> stock.simulate(n_paths=2, time_horizon=5/250) + >>> stock.spot + tensor([[1.0000, 0.9958, 0.9940, 0.9895, 0.9765], + [1.0000, 1.0064, 1.0117, 1.0116, 1.0117]]) + >>> stock.variance + tensor([[0.0400, 0.0433, 0.0406, 0.0423, 0.0441], + [0.0400, 0.0251, 0.0047, 0.0000, 0.0000]]) + """ + + def __init__( + self, + kappa: float = 1.0, + theta: float = 0.04, + sigma: float = 2.0, + rho: float = -0.7, + cost: float = 0.0, + dt: float = 1 / 250, + dtype: torch.dtype = None, + device: torch.device = None, + ): + super().__init__() + + self.kappa = kappa + self.theta = theta + self.sigma = sigma + self.rho = rho + self.cost = cost + self.dt = dt + + self.to(dtype=dtype, device=device) + + @property + def default_init_state(self) -> tuple: + return (1.0, 0.04) + + def simulate( + self, + n_paths: int = 1, + time_horizon: float = 20 / 250, + init_state: Optional[tuple] = None, + ) -> None: + """Simulate the spot price and add it as a buffer named `spot`. + + The shape of the spot is :math:`(N, T)`, where :math:`N` is the number of + simulated paths and :math:`T` is the number of time steps. + The number of time steps is determinded from `dt` and `time_horizon`. + + Args: + n_paths (int, default=1): The number of paths to simulate. + time_horizon (float, default=20/250): The period of time to simulate + the price. + init_state (tuple, optional): The initial state of the instrument. + `init_state` should be a 2-tuple `(spot, variance)` + where `spot` is the initial spot price and `variance` is the initial + variance. + If `None` (default), the default value is chosen + (See :func:`default_init_state`). + """ + if init_state is None: + init_state = self.default_init_state + + spot, variance = generate_heston( + n_paths=n_paths, + n_steps=int(time_horizon / self.dt), + init_state=init_state, + kappa=self.kappa, + theta=self.theta, + sigma=self.sigma, + rho=self.rho, + dt=self.dt, + dtype=self.dtype, + device=self.device, + ) + + self.register_buffer("spot", spot) + self.register_buffer("variance", variance) + + def __repr__(self): + params = [ + f"kappa={self.kappa:.2e}", + f"theta={self.theta:.2e}", + f"sigma={self.sigma:.2e}", + f"rho={self.rho:.2e}", + ] + if self.cost != 0.0: + params.append(f"cost={self.cost:.2e}") + params.append(f"dt={self.dt:.2e}") + params += self.dinfo + return self.__class__.__name__ + "(" + ", ".join(params) + ")" + + +# Assign docstrings so they appear in Sphinx documentation +HestonStock.default_init_state.__doc__ = Primary.default_init_state.__doc__ +HestonStock.to = Primary.to +HestonStock.to.__doc__ = Primary.to.__doc__ diff --git a/pfhedge/stochastic/__init__.py b/pfhedge/stochastic/__init__.py index 4338ffcf..ea753f7e 100644 --- a/pfhedge/stochastic/__init__.py +++ b/pfhedge/stochastic/__init__.py @@ -1,2 +1,4 @@ from .brownian import generate_brownian from .brownian import generate_geometric_brownian +from .cir import generate_cir +from .heston import generate_heston diff --git a/pfhedge/stochastic/cir.py b/pfhedge/stochastic/cir.py new file mode 100644 index 00000000..d48979d1 --- /dev/null +++ b/pfhedge/stochastic/cir.py @@ -0,0 +1,104 @@ +import torch +from torch import Tensor + + +def generate_cir( + n_paths: int, + n_steps: int, + init_value: float = 0.04, + kappa: float = 1.0, + theta: float = 0.04, + sigma: float = 2.0, + dt: float = 1 / 250, + dtype: torch.dtype = None, + device: torch.device = None, +) -> Tensor: + """Returns time series following Cox-Ingersoll-Ross process. + + The time evolution of CIR process is given by: + + .. math :: + + dX(t) = \\kappa (\\theta - X(t)) + \\sigma \\sqrt{X(t)} dW(t) \\,. + + Args: + n_paths (int): The number of simulated paths. + n_steps (int): The number of time steps. + init_value (float, default=0.04): The initial value of the time series. + kappa (float, default=1.0): The parameter :math:`\\kappa`. + theta (float, default=0.04): The parameter :math:`\\theta`. + sigma (float, default=2.0): The parameter :math:`\\sigma`. + dt (float, default=1/250): The intervals of the time steps. + dtype (torch.dtype, optional): The desired data type of returned tensor. + Default: If `None`, uses a global default + (see `torch.set_default_tensor_type()`). + device (torch.device, optional): The desired device of returned tensor. + Default: if None, uses the current device for the default tensor type + (see `torch.set_default_tensor_type()`). + `device` will be the CPU for CPU tensor types and the current CUDA device + for CUDA tensor types. + + Shape: + - Output: :math:`(N, T)`, where :math:`N` is the number of paths and + :math:`T` is the number of time steps. + + Returns: + torch.Tensor + + Examples: + + >>> from pfhedge.stochastic import generate_cir + >>> + >>> _ = torch.manual_seed(42) + >>> generate_cir(2, 5) + tensor([[0.0400, 0.0433, 0.0406, 0.0423, 0.0441], + [0.0400, 0.0251, 0.0047, 0.0000, 0.0000]]) + + References: + - Andersen, Leif B.G., Efficient Simulation of the Heston Stochastic + Volatility Model (January 23, 2007). Available at SSRN: + https://ssrn.com/abstract=946405 or http://dx.doi.org/10.2139/ssrn.946404 + """ + # PSI_CRIT in [1.0, 2.0]. See section 3.2.3 + PSI_CRIT = 1.5 + # Prevent zero division + EPSILON = 1e-8 + + output = torch.empty((n_paths, n_steps), dtype=dtype, device=device) + output[:, 0] = init_value + + randn = torch.randn_like(output) + rand = torch.rand_like(output) + + kappa = torch.tensor(kappa, dtype=dtype, device=device) + theta = torch.tensor(theta, dtype=dtype, device=device) + sigma = torch.tensor(sigma, dtype=dtype, device=device) + dt = torch.tensor(dt, dtype=dtype, device=device) + + for i_step in range(n_steps - 1): + v = output[:, i_step] + + # Compute m, s, psi: Eq(17,18) + exp = (-kappa * dt).exp() + m = theta + (v - theta) * exp + s2 = sum( + v * (sigma ** 2) * exp * (1 - exp) / kappa, + theta * (sigma ** 2) * ((1 - exp) ** 2) / (2 * kappa), + ) + psi = s2 / (m ** 2 + EPSILON) + + # Compute V(t + dt) where psi <= PSI_CRIT: Eq(23, 27, 28) + b = ((2 / psi) - 1 + (2 / psi).sqrt() * (2 / psi - 1).sqrt()).sqrt() + a = m / (1 + b ** 2) + next_0 = a * (b + randn[:, i_step]) ** 2 + + # Compute V(t + dt) where psi > PSI_CRIT: Eq(25) + u = rand[:, i_step] + p = (psi - 1) / (psi + 1) + beta = (1 - p) / (m + EPSILON) + pinv = ((1 - p) / (1 - u + EPSILON)).log() / beta + next_1 = torch.where(u > p, pinv, torch.zeros_like(u)) + + output[:, i_step + 1] = torch.where(psi <= PSI_CRIT, next_0, next_1) + + return output diff --git a/pfhedge/stochastic/heston.py b/pfhedge/stochastic/heston.py new file mode 100644 index 00000000..639a716b --- /dev/null +++ b/pfhedge/stochastic/heston.py @@ -0,0 +1,117 @@ +from typing import Optional +from typing import Tuple + +import torch +from torch import Tensor + +from .cir import generate_cir + + +def generate_heston( + n_paths: int, + n_steps: int, + init_state: Tuple[float, float] = (1.0, 0.04), + kappa: float = 1.0, + theta: float = 0.04, + sigma: float = 2.0, + rho: float = -0.7, + dt: float = 1 / 250, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, +) -> Tuple[Tensor, Tensor]: + """Returns time series following Heston model. + + The time evolution of Heston process is given by: + + .. math :: + + dS(t) = S(t) \\sqrt{V(t)} dW_1(t) \\,, \\\\ + dV(t) = \\kappa (\\theta - V(t)) + \\sigma \\sqrt{V(t)} dW_2(t) \\,. + + The correlation between :math:`dW_1` and :math:`dW_2` is :math:`\\rho`. + The correlation between :math:`dW_1` and :math:`dW_2` is `\\rho`. + + Args: + n_paths (int): The number of simulated paths. + n_steps (int): The number of time steps. + init_state (tuple, default=(1.0, 0.04)): Initial state of a simulation. + `init_state` should be a 2-tuple `(spot, variance)` where `spot` is the + initial spot price and `variance` of the initial variance. + kappa (float, default=1.0): The parameter :math:`\\kappa`. + theta (float, default=0.04): The parameter :math:`\\theta`. + sigma (float, default=2.0): The parameter :math:`\\sigma`. + rho (float, default=-0.7): The parameter :math:`\\rho`. + dt (float, default=1/250): The intervals of the time steps. + dtype (torch.dtype, optional): The desired data type of returned tensor. + Default: If `None`, uses a global default + (see `torch.set_default_tensor_type()`). + device (torch.device, optional): The desired device of returned tensor. + Default: if None, uses the current device for the default tensor type + (see `torch.set_default_tensor_type()`). + `device` will be the CPU for CPU tensor types and the current CUDA device + for CUDA tensor types. + + Shape: + - Output: :math:`(N, T)`, where :math:`N` is the number of paths and + :math:`T` is the number of time steps. + + Returns: + (torch.Tensor, torch.Tensor): A tuple of spot and variance. + + Examples: + + >>> from pfhedge.stochastic import generate_heston + >>> + >>> _ = torch.manual_seed(42) + >>> spot, variance = generate_heston(2, 5) + >>> spot + tensor([[1.0000, 0.9958, 0.9940, 0.9895, 0.9765], + [1.0000, 1.0064, 1.0117, 1.0116, 1.0117]]) + >>> variance + tensor([[0.0400, 0.0433, 0.0406, 0.0423, 0.0441], + [0.0400, 0.0251, 0.0047, 0.0000, 0.0000]]) + + References: + - Andersen, Leif B.G., Efficient Simulation of the Heston Stochastic + Volatility Model (January 23, 2007). Available at SSRN: + https://ssrn.com/abstract=946405 or http://dx.doi.org/10.2139/ssrn.946404 + """ + GAMMA1 = 0.5 + GAMMA2 = 0.5 + + init_spot, init_var = init_state + + variance = generate_cir( + n_paths=n_paths, + n_steps=n_steps, + init_value=init_var, + kappa=kappa, + theta=theta, + sigma=sigma, + dt=dt, + dtype=dtype, + device=device, + ) + log_spot = torch.empty(n_paths, n_steps) + log_spot[:, 0] = torch.tensor(init_spot).log() + + randn = torch.randn(n_paths, n_steps) + + for i_step in range(n_steps - 1): + # Compute log S(t + 1): Eq(33) + k0 = -rho * kappa * theta * dt / sigma + k1 = GAMMA1 * dt * (kappa * rho / sigma - 0.5) - rho / sigma + k2 = GAMMA2 * dt * (kappa * rho / sigma - 0.5) + rho / sigma + k3 = GAMMA1 * dt * (1 - rho ** 2) + k4 = GAMMA2 * dt * (1 - rho ** 2) + v0 = variance[:, i_step] + v1 = variance[:, i_step + 1] + log_spot[:, i_step + 1] = sum( + ( + log_spot[:, i_step], + k0 + k1 * v0 + k2 * v1, + (k3 * v0 + k4 * v1).sqrt() * randn[:, i_step], + ) + ) + + return (log_spot.exp(), variance) diff --git a/pyproject.toml b/pyproject.toml index 96d5254d..87607bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pfhedge" -version = "0.6.2" +version = "0.7.0" description = "Deep Hedging in PyTorch" authors = ["Shota Imaki "] license = "MIT" diff --git a/tests/instruments/test_heston.py b/tests/instruments/test_heston.py new file mode 100644 index 00000000..530f7f2c --- /dev/null +++ b/tests/instruments/test_heston.py @@ -0,0 +1,24 @@ +import pytest +import torch + +from pfhedge.instruments import HestonStock + + +class TestHestonStock: + @pytest.mark.parametrize("seed", range(1)) + def test_values_are_finite(self, seed): + torch.manual_seed(seed) + + s = HestonStock() + s.simulate(n_paths=1000) + + print(s) + assert not s.variance.isnan().any() + + def test_repr(self): + s = HestonStock(cost=1e-4) + expect = ( + "HestonStock(kappa=1.00e+00, theta=4.00e-02, sigma=2.00e+00, " + "rho=-7.00e-01, cost=1.00e-04, dt=4.00e-03)" + ) + assert repr(s) == expect diff --git a/tests/instruments/test_underlier.py b/tests/instruments/test_underlier.py index fbbc4c93..3a39c95d 100644 --- a/tests/instruments/test_underlier.py +++ b/tests/instruments/test_underlier.py @@ -2,6 +2,20 @@ import torch from pfhedge.instruments import BrownianStock +from pfhedge.instruments import Primary + + +def test_buffer_attribute_error(): + class MyPrimary(Primary): + # Primary without super().__init__() + def __init__(self): + pass + + def simulate(self): + self.register_buffer("a", torch.empty(10)) + + with pytest.raises(AttributeError): + MyPrimary().simulate() class TestBrownianStock: @@ -12,35 +26,72 @@ class TestBrownianStock: def test_repr(self): s = BrownianStock(dt=1 / 100) assert repr(s) == "BrownianStock(volatility=2.00e-01, dt=1.00e-02)" + s = BrownianStock(dt=1 / 100, cost=0.001) - assert ( - repr(s) == "BrownianStock(volatility=2.00e-01, cost=1.00e-03, dt=1.00e-02)" - ) + expect = "BrownianStock(volatility=2.00e-01, cost=1.00e-03, dt=1.00e-02)" + assert repr(s) == expect + s = BrownianStock(dt=1 / 100, dtype=torch.float64) - assert ( - repr(s) - == "BrownianStock(volatility=2.00e-01, dt=1.00e-02, dtype=torch.float64)" - ) + expect = "BrownianStock(volatility=2.00e-01, dt=1.00e-02, dtype=torch.float64)" + assert repr(s) == expect + s = BrownianStock(dt=1 / 100, device="cuda:0") - assert ( - repr(s) - == "BrownianStock(volatility=2.00e-01, dt=1.00e-02, device='cuda:0')" - ) + expect = "BrownianStock(volatility=2.00e-01, dt=1.00e-02, device='cuda:0')" + assert repr(s) == expect + + def test_register_buffer(self): + s = BrownianStock() + with pytest.raises(TypeError): + s.register_buffer(None, torch.empty(10)) + with pytest.raises(KeyError): + s.register_buffer("a.b", torch.empty(10)) + with pytest.raises(KeyError): + s.register_buffer("", torch.empty(10)) + with pytest.raises(KeyError): + s.register_buffer("simulate", torch.empty(10)) + with pytest.raises(TypeError): + s.register_buffer("a", torch.nn.ReLU()) + + def test_buffers(self): + torch.manual_seed(42) + a = torch.randn(10) + b = torch.randn(10) + c = torch.randn(10) + + s = BrownianStock() + s.register_buffer("a", a) + s.register_buffer("b", b) + s.register_buffer("c", c) + result = list(s.named_buffers()) + expect = [("a", a), ("b", b), ("c", c)] + assert result == expect + + result = list(s.buffers()) + expect = [a, b, c] + assert result == expect @pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) def test_dtype(self, dtype): + # __init__ s = BrownianStock(dtype=dtype) s.simulate() assert s.spot.dtype == dtype - s = BrownianStock().to(dtype=dtype) + # to() before simulate + s = BrownianStock().to(dtype) + s.simulate() + assert s.spot.dtype == dtype + + # to() after simulate + s = BrownianStock() s.simulate() + s.to(dtype) assert s.spot.dtype == dtype def test_device(self): ... - def test_to(self): + def test_to_device(self): s = BrownianStock() s.to(device="cuda:0") assert s.device == torch.device("cuda:0") @@ -51,13 +102,6 @@ def test_to(self): s.to(dtype=torch.float64) assert s.dtype == torch.float64 - s = BrownianStock() - s.simulate() - s.to(dtype=torch.float32) - assert s.spot.dtype == torch.float32 - s.to(dtype=torch.float64) - assert s.spot.dtype == torch.float64 - def test_to_error(self): with pytest.raises(TypeError): BrownianStock().to(dtype=torch.int32) diff --git a/tests/stochastic/__init__.py b/tests/stochastic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/stochastic/test_cir.py b/tests/stochastic/test_cir.py new file mode 100644 index 00000000..d0566ec0 --- /dev/null +++ b/tests/stochastic/test_cir.py @@ -0,0 +1,11 @@ +import torch + +from pfhedge.stochastic import generate_cir + + +def test_dtype(): + output = generate_cir(2, 3, dtype=torch.float32) + assert output.dtype == torch.float32 + + output = generate_cir(2, 3, dtype=torch.float64) + assert output.dtype == torch.float64