From e19efce73c54cc34cffcfc3ab1fd5903233304d5 Mon Sep 17 00:00:00 2001 From: simaki Date: Mon, 14 Mar 2022 11:15:00 +0900 Subject: [PATCH] Release/0.19.0 (#545) * ENH: Add `box_muller` (#534) * ENH: Add `VasicekRate` (close #505) (#538) * ENH: Add `LocalVolatilityStock` (#539) * DOC: Add documentation of features (#541) * DOC: Miscellaneous updates (#537) (#547) (#542) (#543) (#548) * MAINT: Fix primary spot typing (#530) (#533) * MAINT: Add `extra_repr` to `SVIVariance` (#535) * MAINT: Reimplement looking ahead to multiple underliers (#536) * MAINT: Add `OptionMixin` and deprecate `BaseOption` (#544) * `BaseOption` is deprecated. Inherit `BaseDerivative` and `OptionMixin` instead. * MAINT: Bumping version from 0.18.0 to 0.19.0 (#546) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .gitignore | 2 +- docs/source/conf.py | 2 +- docs/source/features.rst | 60 ++++ docs/source/index.rst | 1 + docs/source/instruments.rst | 9 +- docs/source/nn.functional.rst | 28 ++ docs/source/nn.rst | 16 +- docs/source/stochastic.rst | 24 +- pfhedge/_utils/typing.py | 2 + pfhedge/features/__init__.py | 4 +- pfhedge/features/_base.py | 4 + pfhedge/features/_getter.py | 13 +- pfhedge/features/container.py | 13 +- pfhedge/features/features.py | 267 +++++++++++++++--- pfhedge/instruments/__init__.py | 3 + .../instruments/derivative/american_binary.py | 14 +- pfhedge/instruments/derivative/base.py | 108 +++++-- pfhedge/instruments/derivative/cliquet.py | 2 +- pfhedge/instruments/derivative/european.py | 14 +- .../instruments/derivative/european_binary.py | 14 +- pfhedge/instruments/derivative/lookback.py | 14 +- .../instruments/derivative/variance_swap.py | 2 +- pfhedge/instruments/primary/base.py | 3 +- pfhedge/instruments/primary/brownian.py | 6 +- pfhedge/instruments/primary/cir.py | 15 +- pfhedge/instruments/primary/heston.py | 5 +- .../instruments/primary/local_volatility.py | 150 ++++++++++ pfhedge/instruments/primary/vasicek.py | 118 ++++++++ pfhedge/nn/functional.py | 33 +++ pfhedge/nn/modules/bs/_base.py | 4 - pfhedge/nn/modules/hedger.py | 3 +- pfhedge/nn/modules/svi.py | 10 + pfhedge/stochastic/__init__.py | 2 + pfhedge/stochastic/_utils.py | 37 +++ pfhedge/stochastic/brownian.py | 21 +- pfhedge/stochastic/cir.py | 15 +- pfhedge/stochastic/engine.py | 11 +- pfhedge/stochastic/heston.py | 9 +- pfhedge/stochastic/local_volatility.py | 111 ++++++++ pfhedge/stochastic/vasicek.py | 114 ++++++++ pfhedge/version.py | 2 +- pyproject.toml | 2 +- .../primary/test_local_volatility.py | 33 +++ tests/instruments/primary/test_vasicek.py | 30 ++ tests/nn/modules/test_svi.py | 5 + tests/nn/test_functional.py | 26 ++ 46 files changed, 1198 insertions(+), 183 deletions(-) create mode 100644 docs/source/features.rst create mode 100644 pfhedge/instruments/primary/local_volatility.py create mode 100644 pfhedge/instruments/primary/vasicek.py create mode 100644 pfhedge/stochastic/_utils.py create mode 100644 pfhedge/stochastic/local_volatility.py create mode 100644 pfhedge/stochastic/vasicek.py create mode 100644 tests/instruments/primary/test_local_volatility.py create mode 100644 tests/instruments/primary/test_vasicek.py diff --git a/.gitignore b/.gitignore index a05d6b61..138f1a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ poetry.lock -docs/source/generated +docs/source/generated/ examples/output/* !examples/output/.gitkeep diff --git a/docs/source/conf.py b/docs/source/conf.py index 1af0b200..5e9ee073 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -69,7 +69,7 @@ # Disable displaying type annotations, these can be very verbose autodoc_typehints = "none" -autodoc_default_options = {"show-inheritance": True} +# autodoc_default_options = {"show-inheritance": True} intersphinx_mapping = { "python": ("https://docs.python.org/3", None), diff --git a/docs/source/features.rst b/docs/source/features.rst new file mode 100644 index 00000000..f9b2f772 --- /dev/null +++ b/docs/source/features.rst @@ -0,0 +1,60 @@ +.. _features: + +pfhedge.features +================ + +`pfhedge.features` provides features for :class:`pfhedge.nn.Hedger`. + +.. currentmodule:: pfhedge.features + +Creation +-------- + +.. autosummary:: + :nosignatures: + :toctree: generated + + get_feature + list_feature_names + +Derivatives Features +-------------------- + +.. autosummary:: + :nosignatures: + :toctree: generated + :template: classtemplate.rst + + Moneyness + LogMoneyness + MaxMoneyness + MaxLogMoneyness + Barrier + PrevHedge + TimeToMaturity + UnderlierSpot + Variance + Volatility + +Tensor Creation Ops +------------------- + +.. autosummary:: + :nosignatures: + :toctree: generated + :template: classtemplate.rst + + Empty + Ones + Zeros + +Containers +---------- + +.. autosummary:: + :nosignatures: + :toctree: generated + :template: classtemplate.rst + + ModuleOutput + FeatureList diff --git a/docs/source/index.rst b/docs/source/index.rst index 4a9fa8c4..56806970 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -29,6 +29,7 @@ Install: instruments stochastic autogreek + features | diff --git a/docs/source/instruments.rst b/docs/source/instruments.rst index 4786a998..61bfb99b 100644 --- a/docs/source/instruments.rst +++ b/docs/source/instruments.rst @@ -10,33 +10,36 @@ Base Class ---------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst instruments.BaseInstrument instruments.BasePrimary instruments.BaseDerivative + instruments.OptionMixin instruments.BaseOption Primary Instruments ------------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst instruments.BrownianStock instruments.HestonStock + instruments.LocalVolatilityStock instruments.CIRRate + instruments.VasicekRate Derivative Instruments ---------------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst instruments.AmericanBinaryOption diff --git a/docs/source/nn.functional.rst b/docs/source/nn.functional.rst index e842cc28..5d6e90d4 100644 --- a/docs/source/nn.functional.rst +++ b/docs/source/nn.functional.rst @@ -45,6 +45,9 @@ Criterion Functions Black-Scholes formulas ---------------------- +European option +~~~~~~~~~~~~~~~ + .. autosummary:: :nosignatures: :toctree: generated @@ -54,16 +57,40 @@ Black-Scholes formulas bs_european_gamma bs_european_vega bs_european_theta + +American binary option +~~~~~~~~~~~~~~~~~~~~~~ + +.. autosummary:: + :nosignatures: + :toctree: generated + bs_american_binary_price bs_american_binary_delta bs_american_binary_gamma bs_american_binary_vega bs_american_binary_theta + +European binary option +~~~~~~~~~~~~~~~~~~~~~~ + +.. autosummary:: + :nosignatures: + :toctree: generated + bs_european_binary_price bs_european_binary_delta bs_european_binary_gamma bs_european_binary_vega bs_european_binary_theta + +Lookback option +~~~~~~~~~~~~~~~ + +.. autosummary:: + :nosignatures: + :toctree: generated + bs_lookback_price bs_lookback_delta bs_lookback_gamma @@ -78,6 +105,7 @@ Other Functions :toctree: generated bilerp + box_muller d1 d2 ncdf diff --git a/docs/source/nn.rst b/docs/source/nn.rst index 8a310122..d54497bb 100644 --- a/docs/source/nn.rst +++ b/docs/source/nn.rst @@ -4,7 +4,7 @@ pfhedge.nn ========== -`pfhedge.nn` provides :class:`torch.nn.Module` that are useful for Deep Hedging. +``pfhedge.nn`` provides :class:`torch.nn.Module` that are useful for Deep Hedging. See `PyTorch Documentation `_ for general usage of :class:`torch.nn.Module`. @@ -15,8 +15,8 @@ Hedger Module ------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.Hedger @@ -25,8 +25,8 @@ Black-Scholes Layers -------------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.BlackScholes @@ -39,8 +39,8 @@ Whalley-Wilmott Layers ---------------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.WhalleyWilmott @@ -49,8 +49,8 @@ Nonlinear Activations --------------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.Clamp @@ -60,8 +60,8 @@ Loss Functions -------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.HedgeLoss @@ -74,8 +74,8 @@ Multi Layer Perceptron ---------------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.MultiLayerPerceptron @@ -84,8 +84,8 @@ Other Modules ------------- .. autosummary:: - :toctree: generated :nosignatures: + :toctree: generated :template: classtemplate.rst nn.Naked diff --git a/docs/source/stochastic.rst b/docs/source/stochastic.rst index bac86b2d..7cbca392 100644 --- a/docs/source/stochastic.rst +++ b/docs/source/stochastic.rst @@ -6,33 +6,19 @@ pfhedge.stochastic .. currentmodule:: pfhedge.stochastic -Brownian Motion ---------------- +Stochastic Processes +-------------------- .. autosummary:: :nosignatures: :toctree: generated generate_brownian - generate_geometric_brownian - -Cox-Ingersoll-Ross Process --------------------------- - -.. autosummary:: - :nosignatures: - :toctree: generated - generate_cir - -Heston Process --------------- - -.. autosummary:: - :nosignatures: - :toctree: generated - + generate_geometric_brownian generate_heston + generate_local_volatility_process + generate_vasicek Generators ---------- diff --git a/pfhedge/_utils/typing.py b/pfhedge/_utils/typing.py index c5d1956f..774d41d0 100644 --- a/pfhedge/_utils/typing.py +++ b/pfhedge/_utils/typing.py @@ -1,5 +1,7 @@ +from typing import Callable from typing import Union from torch import Tensor TensorOrScalar = Union[Tensor, float, int] +LocalVolatilityFunction = Callable[[Tensor, Tensor], Tensor] diff --git a/pfhedge/features/__init__.py b/pfhedge/features/__init__.py index 6361ef78..4ccd93db 100644 --- a/pfhedge/features/__init__.py +++ b/pfhedge/features/__init__.py @@ -1,14 +1,16 @@ from ._getter import get_feature +from ._getter import list_feature_names from ._getter import list_features from .container import FeatureList from .container import ModuleOutput from .features import Barrier from .features import Empty -from .features import ExpiryTime +from .features import ExpiryTime # deprecated from .features import LogMoneyness from .features import MaxLogMoneyness from .features import MaxMoneyness from .features import Moneyness +from .features import Ones from .features import PrevHedge from .features import Spot from .features import TimeToMaturity diff --git a/pfhedge/features/_base.py b/pfhedge/features/_base.py index ba592552..ef4a16dc 100644 --- a/pfhedge/features/_base.py +++ b/pfhedge/features/_base.py @@ -18,6 +18,7 @@ class Feature(ABC): All features should subclass this class. """ + name: str derivative: BaseDerivative hedger: Optional[Module] @@ -73,6 +74,9 @@ def is_state_dependent(self) -> bool: # If a feature uses the state of a hedger, it is state dependent. return getattr(self, "hedger") is not None + def __str__(self): + return self.name + # TODO(simaki) Remove later def __getitem__(self, time_step: Optional[int]) -> Tensor: # raise DeprecationWarning("Use `.get(time_step)` instead") diff --git a/pfhedge/features/_getter.py b/pfhedge/features/_getter.py index 643c2258..66995316 100644 --- a/pfhedge/features/_getter.py +++ b/pfhedge/features/_getter.py @@ -96,6 +96,12 @@ def get_feature(feature: Union[str, Feature], **kwargs: Any) -> Feature: Returns: Feature + + Examples: + >>> from pfhedge.features import get_feature + ... + >>> get_feature("moneyness") + """ if isinstance(feature, str): feature = FeatureFactory().get_instance(feature, **kwargs) @@ -109,7 +115,12 @@ def list_feature_dict() -> dict: def list_feature_names() -> list: - return list(FeatureFactory().names()) + """Returns the list of the names of available features. + + Returns: + list[str] + """ + return sorted(list(FeatureFactory().names())) def list_features() -> list: diff --git a/pfhedge/features/container.py b/pfhedge/features/container.py index cff67de7..5e17e580 100644 --- a/pfhedge/features/container.py +++ b/pfhedge/features/container.py @@ -48,9 +48,12 @@ def get(self, time_step: Optional[int]) -> Tensor: # Return size: (N, T, F) return torch.cat([f.get(time_step) for f in self.features], dim=-1) - def __repr__(self) -> str: + def __str__(self) -> str: return str(list(map(str, self.features))) + def __repr__(self) -> str: + return str(self) + def of(self: T, derivative: BaseDerivative, hedger: Optional[Module] = None) -> T: output = copy.copy(self) output.features = [f.of(derivative, hedger) for f in self.features] @@ -61,15 +64,15 @@ def is_state_dependent(self) -> bool: class ModuleOutput(Feature, Module): - """The feature computed as an output of a :class:`torch.nn.Module`. + r"""The feature computed as an output of a :class:`torch.nn.Module`. Args: module (torch.nn.Module): Module to compute the value of the feature. The input and output shapes should be - :math:`(N, *, H_{\\math{in}}) -> (N, *, H_{\\math{out}})` where + :math:`(N, *, H_{\mathrm{in}}) \to (N, *, H_{\mathrm{out}})` where :math:`N` is the number of simulated paths of the underlying instrument, - :math:`H_{\\math{in}}` is the number of input features, - :math:`H_{\\math{out}}` is the number of output features, and + :math:`H_{\mathrm{in}}` is the number of input features, + :math:`H_{\mathrm{out}}` is the number of output features, and :math:`*` means any number of additional dimensions. inputs (list[Feature]): The input features to the module. diff --git a/pfhedge/features/features.py b/pfhedge/features/features.py index 2a222121..69935b73 100644 --- a/pfhedge/features/features.py +++ b/pfhedge/features/features.py @@ -7,21 +7,50 @@ from torch.nn import Module from pfhedge._utils.str import _format_float -from pfhedge.instruments.derivative.base import BaseOption +from pfhedge.instruments.derivative.base import BaseDerivative +from pfhedge.instruments.derivative.base import OptionMixin from ._base import Feature from ._base import StateIndependentFeature from ._getter import FeatureFactory -class Moneyness(StateIndependentFeature): - """Moneyness of the underlying instrument of the derivative. +# for mypy only +class OptionType(BaseDerivative, OptionMixin): + pass - Args: - log (bool, default=False): If ``True``, represents log moneyness. + +class Moneyness(StateIndependentFeature): + """Moneyness of the derivative. + + Moneyness reads :math:`S / K` where + :math:`S` is the spot price of the underlying instrument and + :math:`K` is the strike of the derivative. + + Name: + ``'moneyness'`` + + Examples: + >>> from pfhedge.features import Moneyness + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> _ = torch.manual_seed(42) + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> derivative.underlier.spot + tensor([[1.0000, 1.0016, 1.0044, 1.0073, 0.9930, 0.9906]]) + >>> f = Moneyness().of(derivative) + >>> f.get() + tensor([[[0.5000], + [0.5008], + [0.5022], + [0.5036], + [0.4965], + [0.4953]]]) """ - derivative: BaseOption + derivative: OptionType def __init__(self, log: bool = False) -> None: super().__init__() @@ -35,21 +64,48 @@ def get(self, time_step: Optional[int] = None) -> Tensor: class LogMoneyness(Moneyness): - """Log moneyness of the underlying instrument of the derivative.""" + r"""Log-moneyness of the derivative. + + Log-moneyness reads :math:`\log(S / K)` where + :math:`S` is the spot price of the underlying instrument and + :math:`K` is the strike of the derivative. + + Name: + ``'log_moneyness'`` + """ - derivative: BaseOption + derivative: OptionType def __init__(self) -> None: super().__init__(log=True) class TimeToMaturity(StateIndependentFeature): - """Remaining time to the maturity of the derivative.""" - - derivative: BaseOption + """Remaining time to the maturity of the derivative. + + Name: + ``'time_to_maturity'`` + + Examples: + >>> from pfhedge.features import Moneyness + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> _ = torch.manual_seed(42) + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> f = TimeToMaturity().of(derivative) + >>> f.get() + tensor([[[0.0200], + [0.0160], + [0.0120], + [0.0080], + [0.0040], + [0.0000]]]) + """ - def __str__(self) -> str: - return "time_to_maturity" + derivative: OptionType + name = "time_to_maturity" def get(self, time_step: Optional[int] = None) -> Tensor: return self.derivative.time_to_maturity(time_step).unsqueeze(-1) @@ -63,7 +119,11 @@ def __str__(self) -> str: class UnderlierSpot(StateIndependentFeature): - """Spot of the underlier of the derivative.""" + """Spot price of the underlier of the derivative. + + Name: + ``'underlier_spot'`` + """ def __init__(self, log: bool = False) -> None: super().__init__() @@ -80,8 +140,23 @@ def get(self, time_step: Optional[int] = None) -> Tensor: return output +class UnderlierLogSpot(UnderlierSpot): + """Logarithm of the spot price of the underlier of the derivative. + + Name: + ``'underlier_log_spot'`` + """ + + def __init__(self): + super().__init__(log=True) + + class Spot(StateIndependentFeature): - """Spot of the derivative.""" + """Spot price of the derivative. + + Name: + ``'spot'`` + """ def __init__(self, log: bool = False) -> None: super().__init__() @@ -99,10 +174,15 @@ def get(self, time_step: Optional[int] = None) -> Tensor: class Volatility(StateIndependentFeature): - """Volatility of the underlier of the derivative.""" + """Volatility of the underlier of the derivative. - def __str__(self) -> str: - return "volatility" + Name: + ``'volatility'`` + + Examples: + """ + + name = "volatility" def get(self, time_step: Optional[int] = None) -> Tensor: index = [time_step] if isinstance(time_step, int) else ... @@ -110,10 +190,13 @@ def get(self, time_step: Optional[int] = None) -> Tensor: class Variance(StateIndependentFeature): - """Variance of the underlier of the derivative.""" + """Variance of the underlier of the derivative. - def __str__(self) -> str: - return "variance" + Name: + ``'variance'`` + """ + + name = "variance" def get(self, time_step: Optional[int]) -> Tensor: index = [time_step] if isinstance(time_step, int) else ... @@ -121,12 +204,14 @@ def get(self, time_step: Optional[int]) -> Tensor: class PrevHedge(Feature): - """Previous holding of underlier.""" + """Previous holding of underlier. - hedger: Module + Name: + ``'prev_hedge'`` + """ - def __str__(self) -> str: - return "prev_hedge" + hedger: Module + name = "prev_hedge" def get(self, time_step: Optional[int] = None) -> Tensor: if time_step is None: @@ -144,6 +229,25 @@ class Barrier(StateIndependentFeature): up (bool, default True): If ``True``, signifies whether the price has exceeded the barrier upward. If ``False``, signifies whether the price has exceeded the barrier downward. + + Examples: + >>> from pfhedge.features import Barrier + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> _ = torch.manual_seed(42) + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> derivative.underlier.spot + tensor([[1.0000, 1.0016, 1.0044, 1.0073, 0.9930, 0.9906]]) + >>> f = Barrier(threshold=1.004, up=True).of(derivative) + >>> f.get() + tensor([[[0.], + [0.], + [1.], + [1.], + [1.], + [1.]]]) """ def __init__(self, threshold: float, up: bool = True) -> None: @@ -175,21 +279,89 @@ def get(self, time_step: Optional[int] = None) -> Tensor: class Zeros(StateIndependentFeature): - """A feature of which value is always zero.""" + """A feature filled with the scalar value 0. + + Name: + ``'zeros'`` + + Examples: + >>> from pfhedge.features import Zeros + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> f = Zeros().of(derivative) + >>> f.get() + tensor([[[0.], + [0.], + [0.], + [0.], + [0.], + [0.]]]) + """ - def __str__(self) -> str: - return "zeros" + name = "zeros" def get(self, time_step: Optional[int] = None) -> Tensor: index = [time_step] if time_step is not None else ... return torch.zeros_like(self.derivative.ul().spot[..., index]).unsqueeze(-1) +class Ones(StateIndependentFeature): + """A feature filled with the scalar value 1. + + Name: + ``'ones'`` + + Examples: + >>> from pfhedge.features import Ones + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> f = Ones().of(derivative) + >>> f.get() + tensor([[[1.], + [1.], + [1.], + [1.], + [1.], + [1.]]]) + """ + + name = "ones" + + def get(self, time_step: Optional[int] = None) -> Tensor: + index = [time_step] if time_step is not None else ... + return torch.ones_like(self.derivative.ul().spot[..., index]).unsqueeze(-1) + + class Empty(StateIndependentFeature): - """A feature of which value is always empty.""" + """A feature filled with uninitialized data. + + Name: + ``'empty'`` + + Examples: + >>> from pfhedge.features import Empty + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> f = Empty().of(derivative) + >>> f.get() + tensor([[[...], + [...], + [...], + [...], + [...], + [...]]]) + """ - def __str__(self) -> str: - return "empty" + name = "empty" def get(self, time_step: Optional[int] = None) -> Tensor: index = [time_step] if time_step is not None else ... @@ -199,11 +371,30 @@ def get(self, time_step: Optional[int] = None) -> Tensor: class MaxMoneyness(StateIndependentFeature): """Cumulative maximum of moneyness. - Args: - log (bool, default=False): If ``True``, represents log moneyness. + Name: + ``'max_moneyness'`` + + Examples: + >>> from pfhedge.features import MaxMoneyness + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> _ = torch.manual_seed(42) + >>> derivative = EuropeanOption(BrownianStock(), maturity=5/250, strike=2.0) + >>> derivative.simulate() + >>> derivative.underlier.spot + tensor([[1.0000, 1.0016, 1.0044, 1.0073, 0.9930, 0.9906]]) + >>> f = MaxMoneyness().of(derivative) + >>> f.get() + tensor([[[0.5000], + [0.5008], + [0.5022], + [0.5036], + [0.5036], + [0.5036]]]) """ - derivative: BaseOption + derivative: OptionType def __init__(self, log: bool = False) -> None: super().__init__() @@ -217,9 +408,13 @@ def get(self, time_step: Optional[int] = None) -> Tensor: class MaxLogMoneyness(MaxMoneyness): - """Cumulative maximum of log Moneyness.""" + """Cumulative maximum of log Moneyness. + + Name: + ``'max_log_moneyness'`` + """ - derivative: BaseOption + derivative: OptionType def __init__(self) -> None: super().__init__(log=True) diff --git a/pfhedge/instruments/__init__.py b/pfhedge/instruments/__init__.py index 4dc1ebd7..cf472490 100644 --- a/pfhedge/instruments/__init__.py +++ b/pfhedge/instruments/__init__.py @@ -4,6 +4,7 @@ from .derivative.base import BaseDerivative from .derivative.base import BaseOption from .derivative.base import Derivative +from .derivative.base import OptionMixin from .derivative.cliquet import EuropeanForwardStartOption from .derivative.european import EuropeanOption from .derivative.european_binary import EuropeanBinaryOption @@ -14,3 +15,5 @@ from .primary.brownian import BrownianStock from .primary.cir import CIRRate from .primary.heston import HestonStock +from .primary.local_volatility import LocalVolatilityStock +from .primary.vasicek import VasicekRate diff --git a/pfhedge/instruments/derivative/american_binary.py b/pfhedge/instruments/derivative/american_binary.py index 039990e3..730c997d 100644 --- a/pfhedge/instruments/derivative/american_binary.py +++ b/pfhedge/instruments/derivative/american_binary.py @@ -10,10 +10,10 @@ from ..primary.base import BasePrimary from .base import BaseDerivative -from .base import BaseOption +from .base import OptionMixin -class AmericanBinaryOption(BaseOption): +class AmericanBinaryOption(BaseDerivative, OptionMixin): r"""American binary option. The payoff of an American binary call option is given by: @@ -81,7 +81,7 @@ def __init__( device: Optional[torch.device] = None, ) -> None: super().__init__() - self.underlier = underlier + self.register_underlier("underlier", underlier) self.call = call self.strike = strike self.maturity = maturity @@ -114,8 +114,10 @@ def payoff_fn(self) -> Tensor: _set_attr_and_docstring(AmericanBinaryOption, "ul", BaseDerivative.ul) _set_attr_and_docstring(AmericanBinaryOption, "list", BaseDerivative.list) _set_docstring(AmericanBinaryOption, "payoff", BaseDerivative.payoff) -_set_attr_and_docstring(AmericanBinaryOption, "moneyness", BaseOption.moneyness) -_set_attr_and_docstring(AmericanBinaryOption, "log_moneyness", BaseOption.log_moneyness) +_set_attr_and_docstring(AmericanBinaryOption, "moneyness", OptionMixin.moneyness) _set_attr_and_docstring( - AmericanBinaryOption, "time_to_maturity", BaseOption.time_to_maturity + AmericanBinaryOption, "log_moneyness", OptionMixin.log_moneyness +) +_set_attr_and_docstring( + AmericanBinaryOption, "time_to_maturity", OptionMixin.time_to_maturity ) diff --git a/pfhedge/instruments/derivative/base.py b/pfhedge/instruments/derivative/base.py index 41ceedbb..e5a632e4 100644 --- a/pfhedge/instruments/derivative/base.py +++ b/pfhedge/instruments/derivative/base.py @@ -47,21 +47,33 @@ class BaseDerivative(BaseInstrument): cost: float maturity: float pricer: Optional[Callable[[Any], Tensor]] - _clauses: Dict[str, Callable[["BaseDerivative", Tensor], Tensor]] + _clauses: Dict[str, Clause] + _underliers: Dict[str, BasePrimary] def __init__(self) -> None: super().__init__() self.pricer = None self.cost = 0.0 self._clauses = OrderedDict() + self._underliers = OrderedDict() @property def dtype(self) -> Optional[torch.dtype]: - return self.underlier.dtype + if len(list(self.underliers())) == 1: + return self.ul(0).dtype + else: + raise AttributeError( + "dtype is not well-defined for a derivative with multiple underliers" + ) @property def device(self) -> Optional[torch.device]: - return self.underlier.device + if len(list(self.underliers())) == 1: + return self.ul(0).device + else: + raise AttributeError( + "device is not well-defined for a derivative with multiple underliers" + ) def simulate( self, n_paths: int = 1, init_state: Optional[Tuple[TensorOrScalar, ...]] = None @@ -74,16 +86,18 @@ def simulate( the underlier. **kwargs: Other parameters passed to ``self.underlier.simulate()``. """ - self.underlier.simulate( - n_paths=n_paths, time_horizon=self.maturity, init_state=init_state - ) + for underlier in self.underliers(): + underlier.simulate( + n_paths=n_paths, time_horizon=self.maturity, init_state=init_state + ) - def ul(self) -> BasePrimary: + def ul(self, index: int = 0) -> BasePrimary: """Alias for ``self.underlier``.""" - return self.underlier + return list(self.underliers())[index] def to(self: T, *args: Any, **kwargs: Any) -> T: - self.underlier.to(*args, **kwargs) + for underlier in self.underliers(): + underlier.to(*args, **kwargs) return self @abstractmethod @@ -128,8 +142,7 @@ def list(self: T, pricer: Callable[[T], Tensor], cost: float = 0.0) -> None: After this method self will be a exchange-traded derivative which can be transacted at any time with the spot price given by ``self.spot``. - See an example in :class:`EuropeanOption` for a usage. - + See an example in :class:`EuropeanOption` for a usage.derivative Args: pricer (Callable[[BaseDerivative], Tensor]]): A function that takes self and returns the spot price tensor of self. @@ -166,14 +179,14 @@ def add_clause(self, name: str, clause: Clause) -> None: """ if not isinstance(name, torch._six.string_classes): raise TypeError( - "clause name should be a string. Got {}".format(torch.typename(name)) + f"clause name should be a string. Got {torch.typename(name)}" ) elif hasattr(self, name) and name not in self._clauses: - raise KeyError("attribute '{}' already exists".format(name)) + raise KeyError(f"attribute '{name}' already exists") elif "." in name: - raise KeyError('clause name can\'t contain ".", got: {}'.format(name)) + raise KeyError(f'clause name cannot contain ".", got: {name}') elif name == "": - raise KeyError('clause name can\'t be empty string ""') + raise KeyError('clause name cannot be empty string ""') if not hasattr(self, "_clauses"): raise AttributeError( @@ -191,6 +204,46 @@ def clauses(self) -> Iterator[Clause]: for _, clause in self.named_clauses(): yield clause + def register_underlier(self, name: str, underlier: BasePrimary) -> None: + if not isinstance(name, torch._six.string_classes): + raise TypeError(f"name should be a string. Got {torch.typename(name)}") + elif hasattr(self, name) and name not in self._underliers: + raise KeyError(f"attribute '{name}' already exists") + elif "." in name: + raise KeyError(f'name cannot contain ".", got: {name}') + elif name == "": + raise KeyError('name cannot be empty string ""') + + if not hasattr(self, "_underliers"): + raise AttributeError( + "cannot assign underlier before BaseDerivative.__init__() call" + ) + + self._underliers[name] = underlier + + def named_underliers(self) -> Iterator[Tuple[str, BasePrimary]]: + if hasattr(self, "_underliers"): + for name, underlier in self._underliers.items(): + yield name, underlier + + def underliers(self) -> Iterator[BasePrimary]: + for _, underlier in self.named_underliers(): + yield underlier + + def get_underlier(self, name: str) -> BasePrimary: + if "_underliers" in self.__dict__: + if name in self._underliers: + return self._underliers[name] + raise AttributeError(self._get_name() + " has no attribute " + name) + + def __getattr__(self, name: str) -> BasePrimary: + return self.get_underlier(name) + + def __setattr__(self, name, value) -> None: + if isinstance(value, BasePrimary): + self.register_underlier(name, value) + super().__setattr__(name, value) + @property def spot(self) -> Tensor: """Returns ``self.pricer(self)`` if self is listed. @@ -221,8 +274,8 @@ def __init__(self, *args, **kwargs) -> None: ) -class BaseOption(BaseDerivative): - """Base class of options.""" +class OptionMixin: + """Mixin class for options.""" underlier: BasePrimary strike: float @@ -231,6 +284,10 @@ class BaseOption(BaseDerivative): def moneyness(self, time_step: Optional[int] = None, log: bool = False) -> Tensor: """Returns the moneyness of self. + Moneyness reads :math:`S / K` where + :math:`S` is the spot price of the underlying instrument and + :math:`K` is the strike of the derivative. + Args: time_step (int, optional): The time step to calculate the moneyness. If ``None`` (default), the moneyness is calculated @@ -253,7 +310,12 @@ def moneyness(self, time_step: Optional[int] = None, log: bool = False) -> Tenso return output def log_moneyness(self, time_step: Optional[int] = None) -> Tensor: - """Returns ``self.moneyness(time_step).log()``. + r"""Returns log-moneyness of self. + + Log-moneyness reads :math:`\log(S / K)` where + :math:`S` is the spot price of the underlying instrument and + :math:`K` is the strike of the derivative. + Returns: torch.Tensor @@ -323,6 +385,16 @@ def max_log_moneyness(self, time_step: Optional[int] = None) -> Tensor: return self.max_moneyness(time_step, log=True) +class BaseOption(BaseDerivative, OptionMixin): + """(deprecated) Base class for options.""" + + def __init__(self): + super().__init__() + raise DeprecationWarning( + "BaseOption is deprecated. Inherit `BaseDerivative` and `OptionMixin` instead." + ) + + # Assign docstrings so they appear in Sphinx documentation _set_docstring(BaseDerivative, "to", BaseInstrument.to) _set_attr_and_docstring(BaseDerivative, "cpu", BaseInstrument.cpu) diff --git a/pfhedge/instruments/derivative/cliquet.py b/pfhedge/instruments/derivative/cliquet.py index b9a3406c..1ddd9522 100644 --- a/pfhedge/instruments/derivative/cliquet.py +++ b/pfhedge/instruments/derivative/cliquet.py @@ -69,7 +69,7 @@ def __init__( start: float = 10 / 250, ) -> None: super().__init__() - self.underlier = underlier + self.register_underlier("underlier", underlier) self.strike = strike self.maturity = maturity self.start = start diff --git a/pfhedge/instruments/derivative/european.py b/pfhedge/instruments/derivative/european.py index 21f26342..604505e1 100644 --- a/pfhedge/instruments/derivative/european.py +++ b/pfhedge/instruments/derivative/european.py @@ -10,10 +10,10 @@ from ..primary.base import BasePrimary from .base import BaseDerivative -from .base import BaseOption +from .base import OptionMixin -class EuropeanOption(BaseOption): +class EuropeanOption(BaseDerivative, OptionMixin): r"""European option. The payoff of a European call option is given by: @@ -118,7 +118,7 @@ def __init__( device: Optional[torch.device] = None, ) -> None: super().__init__() - self.underlier = underlier + self.register_underlier("underlier", underlier) self.call = call self.strike = strike self.maturity = maturity @@ -149,6 +149,8 @@ def payoff_fn(self) -> Tensor: _set_attr_and_docstring(EuropeanOption, "ul", BaseDerivative.ul) _set_attr_and_docstring(EuropeanOption, "list", BaseDerivative.list) _set_docstring(EuropeanOption, "payoff", BaseDerivative.payoff) -_set_attr_and_docstring(EuropeanOption, "moneyness", BaseOption.moneyness) -_set_attr_and_docstring(EuropeanOption, "log_moneyness", BaseOption.log_moneyness) -_set_attr_and_docstring(EuropeanOption, "time_to_maturity", BaseOption.time_to_maturity) +_set_attr_and_docstring(EuropeanOption, "moneyness", OptionMixin.moneyness) +_set_attr_and_docstring(EuropeanOption, "log_moneyness", OptionMixin.log_moneyness) +_set_attr_and_docstring( + EuropeanOption, "time_to_maturity", OptionMixin.time_to_maturity +) diff --git a/pfhedge/instruments/derivative/european_binary.py b/pfhedge/instruments/derivative/european_binary.py index 86a72c24..1b824428 100644 --- a/pfhedge/instruments/derivative/european_binary.py +++ b/pfhedge/instruments/derivative/european_binary.py @@ -10,10 +10,10 @@ from ..primary.base import BasePrimary from .base import BaseDerivative -from .base import BaseOption +from .base import OptionMixin -class EuropeanBinaryOption(BaseOption): +class EuropeanBinaryOption(BaseDerivative, OptionMixin): r"""European binary option. The payoff of a European binary call option is given by @@ -77,7 +77,7 @@ def __init__( device: Optional[torch.device] = None, ) -> None: super().__init__() - self.underlier = underlier + self.register_underlier("underlier", underlier) self.call = call self.strike = strike self.maturity = maturity @@ -110,8 +110,10 @@ def payoff_fn(self) -> Tensor: _set_attr_and_docstring(EuropeanBinaryOption, "ul", BaseDerivative.ul) _set_attr_and_docstring(EuropeanBinaryOption, "list", BaseDerivative.list) _set_docstring(EuropeanBinaryOption, "payoff", BaseDerivative.payoff) -_set_attr_and_docstring(EuropeanBinaryOption, "moneyness", BaseOption.moneyness) -_set_attr_and_docstring(EuropeanBinaryOption, "log_moneyness", BaseOption.log_moneyness) +_set_attr_and_docstring(EuropeanBinaryOption, "moneyness", OptionMixin.moneyness) _set_attr_and_docstring( - EuropeanBinaryOption, "time_to_maturity", BaseOption.time_to_maturity + EuropeanBinaryOption, "log_moneyness", OptionMixin.log_moneyness +) +_set_attr_and_docstring( + EuropeanBinaryOption, "time_to_maturity", OptionMixin.time_to_maturity ) diff --git a/pfhedge/instruments/derivative/lookback.py b/pfhedge/instruments/derivative/lookback.py index 7a80025f..e5281504 100644 --- a/pfhedge/instruments/derivative/lookback.py +++ b/pfhedge/instruments/derivative/lookback.py @@ -10,10 +10,10 @@ from ..primary.base import BasePrimary from .base import BaseDerivative -from .base import BaseOption +from .base import OptionMixin -class LookbackOption(BaseOption): +class LookbackOption(BaseDerivative, OptionMixin): r"""Lookback option with fixed strike. The payoff of a lookback call option is given by @@ -72,7 +72,7 @@ def __init__( device: Optional[torch.device] = None, ) -> None: super().__init__() - self.underlier = underlier + self.register_underlier("underlier", underlier) self.call = call self.strike = strike self.maturity = maturity @@ -103,6 +103,8 @@ def payoff_fn(self) -> Tensor: _set_attr_and_docstring(LookbackOption, "ul", BaseDerivative.ul) _set_attr_and_docstring(LookbackOption, "list", BaseDerivative.list) _set_docstring(LookbackOption, "payoff", BaseDerivative.payoff) -_set_attr_and_docstring(LookbackOption, "moneyness", BaseOption.moneyness) -_set_attr_and_docstring(LookbackOption, "log_moneyness", BaseOption.log_moneyness) -_set_attr_and_docstring(LookbackOption, "time_to_maturity", BaseOption.time_to_maturity) +_set_attr_and_docstring(LookbackOption, "moneyness", OptionMixin.moneyness) +_set_attr_and_docstring(LookbackOption, "log_moneyness", OptionMixin.log_moneyness) +_set_attr_and_docstring( + LookbackOption, "time_to_maturity", OptionMixin.time_to_maturity +) diff --git a/pfhedge/instruments/derivative/variance_swap.py b/pfhedge/instruments/derivative/variance_swap.py index 0d61a4df..8e02c494 100644 --- a/pfhedge/instruments/derivative/variance_swap.py +++ b/pfhedge/instruments/derivative/variance_swap.py @@ -68,7 +68,7 @@ def __init__( device: Optional[torch.device] = None, ) -> None: super().__init__() - self.underlier = underlier + self.register_underlier("underlier", underlier) self.strike = strike self.maturity = maturity diff --git a/pfhedge/instruments/primary/base.py b/pfhedge/instruments/primary/base.py index aafd05ad..bb874f42 100644 --- a/pfhedge/instruments/primary/base.py +++ b/pfhedge/instruments/primary/base.py @@ -172,8 +172,7 @@ def to(self: T, *args: Any, **kwargs: Any) -> T: if dtype is not None and not dtype.is_floating_point: raise TypeError( - "to() only accepts floating point " - "dtypes, but got desired dtype=" + str(dtype) + f"to() only accepts floating point dtypes, but got desired dtype={dtype}" ) if not hasattr(self, "dtype") or dtype is not None: diff --git a/pfhedge/instruments/primary/brownian.py b/pfhedge/instruments/primary/brownian.py index 80004f59..ffde5e8f 100644 --- a/pfhedge/instruments/primary/brownian.py +++ b/pfhedge/instruments/primary/brownian.py @@ -70,7 +70,7 @@ def __init__( dt: float = 1 / 250, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, - ): + ) -> None: super().__init__() self.sigma = sigma @@ -90,7 +90,7 @@ def volatility(self) -> Tensor: It is a tensor filled with ``self.sigma``. """ - return torch.full_like(self.spot, self.sigma) + return torch.full_like(self.get_buffer("spot"), self.sigma) @property def variance(self) -> Tensor: @@ -98,7 +98,7 @@ def variance(self) -> Tensor: It is a tensor filled with the square of ``self.sigma``. """ - return torch.full_like(self.spot, self.sigma ** 2) + return torch.full_like(self.get_buffer("spot"), self.sigma ** 2) def simulate( self, diff --git a/pfhedge/instruments/primary/cir.py b/pfhedge/instruments/primary/cir.py index bd4f2646..51560c78 100644 --- a/pfhedge/instruments/primary/cir.py +++ b/pfhedge/instruments/primary/cir.py @@ -14,16 +14,16 @@ class CIRRate(BasePrimary): - """A rate which follow the CIR process. + r"""A rate which follow the CIR process. .. seealso:: - :func:`pfhedge.stochastic.generate_cir`: The stochastic 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`. + 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`. 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. @@ -43,9 +43,8 @@ class CIRRate(BasePrimary): :math:`T` is the number of time steps. Examples: - - >>> from pfhedge.instruments import HestonStock - >>> + >>> from pfhedge.instruments import CIRRate + ... >>> _ = torch.manual_seed(42) >>> rate = CIRRate() >>> rate.simulate(n_paths=2, time_horizon=5/250) @@ -63,7 +62,7 @@ def __init__( dt: float = 1 / 250, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, - ): + ) -> None: super().__init__() self.kappa = kappa diff --git a/pfhedge/instruments/primary/heston.py b/pfhedge/instruments/primary/heston.py index 1627dc3c..b49cf293 100644 --- a/pfhedge/instruments/primary/heston.py +++ b/pfhedge/instruments/primary/heston.py @@ -50,7 +50,6 @@ class HestonStock(BasePrimary): The shape is :math:`(N, T)`. Examples: - >>> from pfhedge.instruments import HestonStock >>> >>> _ = torch.manual_seed(42) @@ -80,7 +79,7 @@ def __init__( dt: float = 1 / 250, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, - ): + ) -> None: super().__init__() self.kappa = kappa @@ -99,7 +98,7 @@ def default_init_state(self) -> Tuple[float, ...]: @property def volatility(self) -> Tensor: """An alias for ``self.variance.sqrt()``.""" - return self.variance.clamp(min=0.0).sqrt() + return self.get_buffer("variance").clamp(min=0.0).sqrt() def simulate( self, diff --git a/pfhedge/instruments/primary/local_volatility.py b/pfhedge/instruments/primary/local_volatility.py new file mode 100644 index 00000000..762f9373 --- /dev/null +++ b/pfhedge/instruments/primary/local_volatility.py @@ -0,0 +1,150 @@ +from math import ceil +from typing import Callable +from typing import Optional +from typing import Tuple +from typing import cast + +import torch +from torch import Tensor + +from pfhedge._utils.doc import _set_attr_and_docstring +from pfhedge._utils.doc import _set_docstring +from pfhedge._utils.str import _format_float +from pfhedge._utils.typing import LocalVolatilityFunction +from pfhedge._utils.typing import TensorOrScalar +from pfhedge.stochastic import generate_local_volatility_process + +from .base import BasePrimary + + +class LocalVolatilityStock(BasePrimary): + r"""A stock of which spot prices follow the local volatility model. + + .. seealso:: + - :func:`pfhedge.stochastic.generate_local_volatility_process`: + The stochastic process. + + Args: + sigma_fn (callable): The local volatility function. + Its signature is ``sigma_fn(time: Tensor, spot: Tensor) -> Tensor``. + 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 :func:`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 :func:`torch.set_default_tensor_type()`). + ``device`` will be the CPU for CPU tensor types and + the current CUDA device for CUDA tensor types. + + Buffers: + - spot (:class:`torch.Tensor`): The spot prices of the instrument. + This attribute is set by a method :meth:`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. + """ + + spot: Tensor + volatility: Tensor + + def __init__( + self, + sigma_fn: LocalVolatilityFunction, + cost: float = 0.0, + dt: float = 1 / 250, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, + ) -> None: + super().__init__() + + self.sigma_fn = sigma_fn + self.cost = cost + self.dt = dt + + self.to(dtype=dtype, device=device) + + @property + def default_init_state(self) -> Tuple[float, ...]: + return (1.0,) + + @property + def variance(self) -> Tensor: + """Returns the volatility of self. + + It is a tensor filled with the square of ``self.sigma``. + """ + return self.volatility.square() + + def simulate( + self, + n_paths: int = 1, + time_horizon: float = 20 / 250, + init_state: Optional[Tuple[TensorOrScalar]] = 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[torch.Tensor | float], optional): The initial state of + the instrument. + This is specified by a tuple :math:`(S(0),)` where + :math:`S(0)` is the initial value of the spot price. + If ``None`` (default), it uses the default value + (See :attr:`default_init_state`). + It also accepts a :class:`float` or a :class:`torch.Tensor`. + + Examples: + >>> from pfhedge.instruments import LocalVolatilityStock + ... + >>> def sigma_fn(time: Tensor, spot: Tensor) -> Tensor: + ... a, b, sigma = 0.0001, 0.0004, 0.10 + ... sqrt_term = (spot.log().square() + sigma ** 2).sqrt() + ... return ((a + b * sqrt_term) / time.clamp(min=1/250)).sqrt() + ... + >>> _ = torch.manual_seed(42) + >>> stock = LocalVolatilityStock(sigma_fn) + >>> stock.simulate(n_paths=2, time_horizon=5 / 250) + >>> stock.spot + tensor([[1.0000, 1.0040, 1.0055, 1.0075, 1.0091, 1.0024], + [1.0000, 1.0261, 1.0183, 1.0223, 1.0242, 1.0274]]) + >>> stock.volatility + tensor([[0.1871, 0.1871, 0.1323, 0.1081, 0.0936, 0.0837], + [0.1871, 0.1880, 0.1326, 0.1084, 0.0939, 0.0841]]) + """ + if init_state is None: + init_state = cast(Tuple[float], self.default_init_state) + + output = generate_local_volatility_process( + n_paths=n_paths, + n_steps=ceil(time_horizon / self.dt + 1), + sigma_fn=self.sigma_fn, + init_state=init_state, + dt=self.dt, + dtype=self.dtype, + device=self.device, + ) + + self.register_buffer("spot", output.spot) + self.register_buffer("volatility", output.volatility) + + def extra_repr(self) -> str: + params = [] + if self.cost != 0.0: + params.append("cost=" + _format_float(self.cost)) + params.append("dt=" + _format_float(self.dt)) + return ", ".join(params) + + +# Assign docstrings so they appear in Sphinx documentation +_set_docstring( + LocalVolatilityStock, "default_init_state", BasePrimary.default_init_state +) +_set_attr_and_docstring(LocalVolatilityStock, "to", BasePrimary.to) diff --git a/pfhedge/instruments/primary/vasicek.py b/pfhedge/instruments/primary/vasicek.py new file mode 100644 index 00000000..13adfa7a --- /dev/null +++ b/pfhedge/instruments/primary/vasicek.py @@ -0,0 +1,118 @@ +from math import ceil +from typing import Optional +from typing import Tuple + +import torch +from torch import Tensor + +from pfhedge._utils.doc import _set_attr_and_docstring +from pfhedge._utils.doc import _set_docstring +from pfhedge._utils.str import _format_float +from pfhedge._utils.typing import TensorOrScalar +from pfhedge.stochastic import generate_vasicek + +from .base import BasePrimary + + +class VasicekRate(BasePrimary): + r"""A rate which follow the Vasicek model. + + .. seealso:: + - :func:`pfhedge.stochastic.generate_vasicek`: + The stochastic process. + + Args: + kappa (float, default=1.0): The parameter :math:`\kappa`. + theta (float, default=0.04): The parameter :math:`\theta`. + sigma (float, default=0.04): The parameter :math:`\sigma`. + 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 :func:`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 :func:`torch.set_default_tensor_type()`). + ``device`` will be the CPU for CPU tensor types and + the current CUDA device for CUDA tensor types. + + Buffers: + - spot (:class:`torch.Tensor`): The spot rate of the instrument. + This attribute is set by a method :meth:`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. + + Examples: + >>> from pfhedge.instruments import VasicekRate + ... + >>> _ = torch.manual_seed(42) + >>> rate = VasicekRate() + >>> rate.simulate(n_paths=2, time_horizon=5/250) + >>> rate.spot + tensor([[0.0400, 0.0409, 0.0412, 0.0418, 0.0423, 0.0395], + [0.0400, 0.0456, 0.0439, 0.0451, 0.0457, 0.0471]]) + """ + + def __init__( + self, + kappa: float = 1.0, + theta: float = 0.04, + sigma: float = 0.04, + cost: float = 0.0, + dt: float = 1 / 250, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, + ) -> None: + super().__init__() + + self.kappa = kappa + self.theta = theta + self.sigma = sigma + self.cost = cost + self.dt = dt + + self.to(dtype=dtype, device=device) + + @property + def default_init_state(self) -> Tuple[float, ...]: + return (self.theta,) + + def simulate( + self, + n_paths: int = 1, + time_horizon: float = 20 / 250, + init_state: Optional[Tuple[TensorOrScalar, ...]] = None, + ) -> None: + if init_state is None: + init_state = self.default_init_state + + spot = generate_vasicek( + n_paths=n_paths, + n_steps=ceil(time_horizon / self.dt + 1), + init_state=init_state, + kappa=self.kappa, + theta=self.theta, + sigma=self.sigma, + dt=self.dt, + dtype=self.dtype, + device=self.device, + ) + + self.register_buffer("spot", spot) + + def extra_repr(self) -> str: + params = [ + "kappa=" + _format_float(self.kappa), + "theta=" + _format_float(self.theta), + "sigma=" + _format_float(self.sigma), + ] + if self.cost != 0.0: + params.append("cost=" + _format_float(self.cost)) + params.append("dt=" + _format_float(self.dt)) + return ", ".join(params) + + +# Assign docstrings so they appear in Sphinx documentation +_set_docstring(VasicekRate, "default_init_state", BasePrimary.default_init_state) +_set_attr_and_docstring(VasicekRate, "to", BasePrimary.to) diff --git a/pfhedge/nn/functional.py b/pfhedge/nn/functional.py index 8b81fd83..7e22c208 100644 --- a/pfhedge/nn/functional.py +++ b/pfhedge/nn/functional.py @@ -1,5 +1,7 @@ from math import ceil +from math import pi as kPI from typing import Optional +from typing import Tuple from typing import Union import torch @@ -211,6 +213,10 @@ def topp(input: Tensor, p: float, dim: Optional[int] = None, largest: bool = Tru A namedtuple of ``(values, indices)`` is returned, where the ``indices`` are the indices of the elements in the original ``input`` tensor. + .. seealso:: + - :func:`torch.topk`: Returns the ``k`` largest elements of the given input tensor + along a given dimension. + Args: input (torch.Tensor): The input tensor. p (float): The quantile level. @@ -1125,3 +1131,30 @@ def bs_lookback_theta( volatility=volatility, strike=strike, ) + + +def box_muller( + input1: Tensor, input2: Tensor, epsilon: float = 1e-10 +) -> Tuple[Tensor, Tensor]: + r"""Returns two tensors obtained by applying Box-Muller transformation to two input tensors. + + .. math:: + & \mathrm{output1}_i + = \sqrt{- 2 \log (\mathrm{input1}_i)} \cos(2 \pi \cdot \mathrm{input2}_i) , \\ + & \mathrm{output2}_i + = \sqrt{- 2 \log (\mathrm{input1}_i)} \sin(2 \pi \cdot \mathrm{input2}_i) . + + Args: + input1 (torch.Tensor): The first input tensor. + input2 (torch.Tensor): The second input tensor. + epsilon (float, default=1e-10): A small constant to avoid evaluating :math:`\log(0)`. + The tensor ``input1`` will be clamped with this value being the minimum. + + Returns: + (torch.Tensor, torch.Tensor) + """ + radius = (-2 * input1.clamp(min=epsilon).log()).sqrt() + angle = 2 * kPI * input2 + output1 = radius * angle.cos() + output2 = radius * angle.sin() + return output1, output2 diff --git a/pfhedge/nn/modules/bs/_base.py b/pfhedge/nn/modules/bs/_base.py index 84406766..f0e9cedb 100644 --- a/pfhedge/nn/modules/bs/_base.py +++ b/pfhedge/nn/modules/bs/_base.py @@ -108,8 +108,6 @@ def acquire_params_from_derivative_0( raise ValueError( "log_moneyness is required if derivative is not set at this initialization." ) - if derivative.ul().spot is None: - raise AttributeError("please simulate first") log_moneyness = derivative.log_moneyness() if time_to_maturity is None: if derivative is None: @@ -166,8 +164,6 @@ def acquire_params_from_derivative_2( raise ValueError( "max_log_moneyness is required if derivative is not set at this initialization." ) - if derivative.ul().spot is None: - raise AttributeError("please simulate first") max_log_moneyness = derivative.max_log_moneyness() return log_moneyness, max_log_moneyness, time_to_maturity, volatility diff --git a/pfhedge/nn/modules/hedger.py b/pfhedge/nn/modules/hedger.py index 3927cc2c..edb050a3 100644 --- a/pfhedge/nn/modules/hedger.py +++ b/pfhedge/nn/modules/hedger.py @@ -48,7 +48,8 @@ class Hedger(Module): :math:`H` is the number of hedging instruments. inputs (list[str|Feature]): List of the names of the input features that will be fed to the model. - See ``list(map(str, pfhedge.features.FEATURES))`` for valid options. + See :func:`pfhedge.features.list_feature_names` for available feature names + and see :ref:`features` for the details of features. criterion (HedgeLoss, default=EntropicRiskMeasure()): Loss function to minimize by hedging. Default: :class:`pfhedge.nn.EntropicRiskMeasure()` . diff --git a/pfhedge/nn/modules/svi.py b/pfhedge/nn/modules/svi.py index 594b699f..425d5989 100644 --- a/pfhedge/nn/modules/svi.py +++ b/pfhedge/nn/modules/svi.py @@ -55,3 +55,13 @@ def forward(self, input: Tensor) -> Tensor: return svi_variance( input, a=self.a, b=self.b, rho=self.rho, m=self.m, sigma=self.sigma ) + + def extra_repr(self) -> str: + params = ( + f"a={self.a}", + f"b={self.b}", + f"rho={self.rho}", + f"m={self.m}", + f"sigma={self.sigma}", + ) + return ", ".join(params) diff --git a/pfhedge/stochastic/__init__.py b/pfhedge/stochastic/__init__.py index 564f4c31..7a8cd4de 100644 --- a/pfhedge/stochastic/__init__.py +++ b/pfhedge/stochastic/__init__.py @@ -2,5 +2,7 @@ from .brownian import generate_geometric_brownian from .cir import generate_cir from .heston import generate_heston +from .local_volatility import generate_local_volatility_process from .random import randn_antithetic from .random import randn_sobol_boxmuller +from .vasicek import generate_vasicek diff --git a/pfhedge/stochastic/_utils.py b/pfhedge/stochastic/_utils.py new file mode 100644 index 00000000..719b9022 --- /dev/null +++ b/pfhedge/stochastic/_utils.py @@ -0,0 +1,37 @@ +from typing import Optional +from typing import Tuple +from typing import Union +from typing import cast + +import torch +from torch import Tensor + +from pfhedge._utils.typing import TensorOrScalar + + +def cast_state( + state: Union[Tuple[TensorOrScalar, ...], TensorOrScalar], + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, +) -> Tuple[Tensor, ...]: + """Cast ``init_state`` to a tuple of tensors. + + Args: + init_state (torch.Tensor | float | tuple[(torch.Tensor | float), ...]): + The initial state. + dtype (torch.dtype, optional): The desired dtype. + device (torch.device, optional): The desired device. + + Returns: + tuple[torch.Tensor, ...] + """ + if isinstance(state, (Tensor, float, int)): + state_tuple: Tuple[TensorOrScalar, ...] = (state,) + else: + state_tuple = state + + # Cast to init_state: Tuple[Tensor, ...] with desired dtype and device + state_tensor_tuple: Tuple[Tensor, ...] = tuple(map(torch.as_tensor, state_tuple)) + state_tensor_tuple = tuple(map(lambda t: t.to(device, dtype), state_tensor_tuple)) + + return state_tensor_tuple diff --git a/pfhedge/stochastic/brownian.py b/pfhedge/stochastic/brownian.py index 34a88650..17ae4db2 100644 --- a/pfhedge/stochastic/brownian.py +++ b/pfhedge/stochastic/brownian.py @@ -1,4 +1,3 @@ -from typing import Any from typing import Callable from typing import Optional from typing import Tuple @@ -10,6 +9,8 @@ from pfhedge._utils.typing import TensorOrScalar +from ._utils import cast_state + def generate_brownian( n_paths: int, @@ -71,14 +72,7 @@ def generate_brownian( tensor([[ 0.0000, 0.0016, 0.0046, 0.0075, -0.0067], [ 0.0000, 0.0279, 0.0199, 0.0257, 0.0291]]) """ - # Accept Union[float, Tensor] as well because making a tuple with a single element - # is troublesome - if isinstance(init_state, (float, Tensor)): - init_state = (init_state,) - - # Cast to init_state: Tuple[Tensor, ...] with desired dtype and device - init_state = cast(Tuple[Tensor, ...], tuple(map(torch.as_tensor, init_state))) - init_state = tuple(map(lambda t: t.to(dtype=dtype, device=device), init_state)) + init_state = cast_state(init_state, dtype=dtype, device=device) init_value = init_state[0] # randn = torch.randn((n_paths, n_steps), dtype=dtype, device=device) @@ -150,14 +144,7 @@ def generate_geometric_brownian( tensor([[1.0000, 1.0016, 1.0044, 1.0073, 0.9930], [1.0000, 1.0282, 1.0199, 1.0258, 1.0292]]) """ - # Accept Union[float, Tensor] as well because making a tuple with a single element - # is troublesome - if isinstance(init_state, (float, Tensor)): - init_state = (init_state,) - - # Cast to init_state: Tuple[Tensor, ...] with desired dtype and device - init_state = cast(Tuple[Tensor, ...], tuple(map(torch.as_tensor, init_state))) - init_state = tuple(map(lambda t: t.to(dtype=dtype, device=device), init_state)) + init_state = cast_state(init_state, dtype=dtype, device=device) brownian = generate_brownian( n_paths=n_paths, diff --git a/pfhedge/stochastic/cir.py b/pfhedge/stochastic/cir.py index 7340fbe2..682b69bc 100644 --- a/pfhedge/stochastic/cir.py +++ b/pfhedge/stochastic/cir.py @@ -7,6 +7,8 @@ from pfhedge._utils.typing import TensorOrScalar +from ._utils import cast_state + def _get_epsilon(dtype: Optional[torch.dtype]) -> float: return torch.finfo(dtype).tiny if dtype else torch.finfo().tiny @@ -29,7 +31,7 @@ def generate_cir( .. math:: - dX(t) = \kappa (\theta - X(t)) dt + \sigma \sqrt{X(t)} dW(t) \,. + dX(t) = \kappa (\theta - X(t)) dt + \sigma \sqrt{X(t)} dW(t) . Time series is generated by Andersen's QE-M method (See Reference for details). @@ -78,21 +80,14 @@ def generate_cir( if init_state is None: init_state = (theta,) - # Accept Union[float, Tensor] as well because making a tuple with a single element - # is troublesome - if isinstance(init_state, (float, Tensor)): - init_state = (init_state,) - - # Cast to init_state: Tuple[Tensor, ...] with desired dtype and device - init_state = cast(Tuple[Tensor, ...], tuple(map(torch.as_tensor, init_state))) - init_state = tuple(map(lambda t: t.to(dtype=dtype, device=device), init_state)) + init_state = cast_state(init_state, dtype=dtype, device=device) # PSI_CRIT in [1.0, 2.0]. See section 3.2.3 PSI_CRIT = 1.5 # Prevent zero division EPSILON = _get_epsilon(dtype) - output = torch.empty((n_paths, n_steps), dtype=dtype, device=device) + output = torch.empty(*(n_paths, n_steps), dtype=dtype, device=device) output[:, 0] = init_state[0] randn = torch.randn_like(output) diff --git a/pfhedge/stochastic/engine.py b/pfhedge/stochastic/engine.py index 68db2a72..4aa0af41 100644 --- a/pfhedge/stochastic/engine.py +++ b/pfhedge/stochastic/engine.py @@ -1,4 +1,3 @@ -import math from typing import Optional from typing import Tuple @@ -6,13 +5,7 @@ from torch import Tensor from torch.quasirandom import SobolEngine - -def _box_muller(input0: Tensor, input1: Tensor) -> Tuple[Tensor, Tensor]: - EPSILON = 1e-10 - radius = (-2 * input0.clamp(min=EPSILON).log()).sqrt() - z0 = radius * (2 * math.pi * input1).cos() - z1 = radius * (2 * math.pi * input1).sin() - return z0, z1 +from pfhedge.nn.functional import box_muller def _get_numel(size: Tuple[int, ...]) -> int: @@ -59,5 +52,5 @@ def _generate_1d( ) -> Tensor: engine = SobolEngine(2, scramble=self.scramble, seed=self.seed) rand = engine.draw(n // 2 + 1).to(dtype=dtype, device=device) - z0, z1 = _box_muller(rand[:, 0], rand[:, 1]) + z0, z1 = box_muller(rand[:, 0], rand[:, 1]) return torch.cat((z0, z1), dim=0)[:n] diff --git a/pfhedge/stochastic/heston.py b/pfhedge/stochastic/heston.py index a28f0aad..696bc4ac 100644 --- a/pfhedge/stochastic/heston.py +++ b/pfhedge/stochastic/heston.py @@ -10,6 +10,7 @@ from pfhedge._utils.str import _addindent from pfhedge._utils.typing import TensorOrScalar +from ._utils import cast_state from .cir import generate_cir @@ -93,9 +94,8 @@ def generate_heston( (torch.Tensor, torch.Tensor): A namedtuple ``(spot, variance)``. Examples: - >>> from pfhedge.stochastic import generate_heston - >>> + ... >>> _ = torch.manual_seed(42) >>> spot, variance = generate_heston(2, 5) >>> spot @@ -107,9 +107,8 @@ def generate_heston( """ if init_state is None: init_state = (1.0, theta) - # Cast to init_state: Tuple[Tensor, ...] with desired dtype and device - init_state = cast(Tuple[Tensor, ...], tuple(map(torch.as_tensor, init_state))) - init_state = tuple(map(lambda t: t.to(dtype=dtype, device=device), init_state)) + + init_state = cast_state(init_state, dtype=dtype, device=device) GAMMA1 = 0.5 GAMMA2 = 0.5 diff --git a/pfhedge/stochastic/local_volatility.py b/pfhedge/stochastic/local_volatility.py new file mode 100644 index 00000000..fa062d30 --- /dev/null +++ b/pfhedge/stochastic/local_volatility.py @@ -0,0 +1,111 @@ +from collections import namedtuple +from typing import Optional +from typing import Tuple +from typing import Union +from typing import cast + +import torch +from torch import Tensor + +from pfhedge._utils.str import _addindent +from pfhedge._utils.typing import LocalVolatilityFunction +from pfhedge._utils.typing import TensorOrScalar + +from ._utils import cast_state + + +class LocalVolatilityTuple(namedtuple("LocalVolatilityTuple", ["spot", "volatility"])): + + __module__ = "pfhedge.stochastic" + + def __repr__(self) -> str: + items_str_list = [] + for field, tensor in self._asdict().items(): + + items_str_list.append(field + "=\n" + str(tensor)) + items_str = _addindent("\n".join(items_str_list), 2) + return self.__class__.__name__ + "(\n" + items_str + "\n)" + + @property + def variance(self) -> Tensor: + return self.volatility.square() + + +def generate_local_volatility_process( + n_paths: int, + n_steps: int, + sigma_fn: LocalVolatilityFunction, + init_state: Union[Tuple[TensorOrScalar, ...], TensorOrScalar] = (1.0,), + dt: float = 1 / 250, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, +) -> LocalVolatilityTuple: + r"""Returns time series following the local volatility model. + + The time evolution of the process is given by: + + .. math:: + dS(t) = \sigma_{\mathrm{LV}}(t, S(t)) dW(t) , + + where :math:`\sigma_{\mathrm{LV}}` is the local volatility function. + + Args: + n_paths (int): The number of simulated paths. + n_steps (int): The number of time steps. + init_state (tuple[torch.Tensor | float], default=(0.0,)): The initial state of + the time series. + This is specified by a tuple :math:`(S(0),)`. + It also accepts a :class:`torch.Tensor` or a :class:`float`. + sigma_fn (callable): The local volatility function. + Its signature is ``sigma_fn(time: Tensor, spot: Tensor) -> Tensor``. + 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 :func:`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 :func:`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 namedtuple ``(spot, volatility)``. + + Examples: + >>> from pfhedge.stochastic import generate_local_volatility_process + ... + >>> def sigma_fn(time: Tensor, spot: Tensor) -> Tensor: + ... a, b, sigma = 0.0001, 0.0004, 0.1000 + ... sqrt_term = (spot.log().square() + sigma ** 2).sqrt() + ... return ((a + b * sqrt_term) / time.clamp(min=1/250)).sqrt() + ... + >>> _ = torch.manual_seed(42) + >>> spot, volatility = generate_local_volatility_process(2, 5, sigma_fn) + >>> spot + tensor([[1.0000, 1.0040, 1.0055, 1.0075, 1.0091], + [1.0000, 0.9978, 1.0239, 1.0184, 1.0216]]) + >>> volatility + tensor([[0.1871, 0.1871, 0.1323, 0.1081, 0.0936], + [0.1871, 0.1871, 0.1328, 0.1083, 0.0938]]) + """ + init_state = cast_state(init_state, dtype=dtype, device=device) + + spot = torch.empty(*(n_paths, n_steps), dtype=dtype, device=device) + spot[:, 0] = init_state[0] + volatility = torch.empty_like(spot) + + time = dt * torch.arange(n_steps).to(spot) + dw = torch.randn_like(spot) * torch.as_tensor(dt).sqrt() + + for i_step in range(n_steps): + sigma = sigma_fn(time[i_step], spot[:, i_step]) + volatility[:, i_step] = sigma + if i_step != n_steps - 1: + spot[:, i_step + 1] = spot[:, i_step] * (1 + sigma * dw[:, i_step]) + + return LocalVolatilityTuple(spot, volatility) diff --git a/pfhedge/stochastic/vasicek.py b/pfhedge/stochastic/vasicek.py new file mode 100644 index 00000000..3b5d63c6 --- /dev/null +++ b/pfhedge/stochastic/vasicek.py @@ -0,0 +1,114 @@ +from typing import Optional +from typing import Tuple +from typing import cast + +import torch +from torch import Tensor + +from pfhedge._utils.typing import TensorOrScalar + + +def generate_vasicek( + n_paths: int, + n_steps: int, + init_state: Optional[Tuple[TensorOrScalar, ...]] = None, + kappa: TensorOrScalar = 1.0, + theta: TensorOrScalar = 0.04, + sigma: TensorOrScalar = 0.04, + dt: TensorOrScalar = 1 / 250, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, +) -> Tensor: + r"""Returns time series following Vasicek model. + + The time evolution of the process is given by: + + .. math:: + + dX(t) = \kappa (\theta - X(t)) dt + \sigma dW(t) . + + References: + - Gillespie, D.T., 1996. + Exact numerical simulation of the Ornstein-Uhlenbeck process and its integral. + Physical review E, 54(2), p.2084. + + Args: + n_paths (int): The number of simulated paths. + n_steps (int): The number of time steps. + init_state (tuple[torch.Tensor | float], optional): The initial state of + the time series. + This is specified by a tuple :math:`(X(0),)`. + It also accepts a :class:`torch.Tensor` or a :class:`float`. + If ``None`` (default), it uses :math:`(\theta, )`. + kappa (torch.Tensor or float, default=1.0): The parameter :math:`\kappa`. + theta (torch.Tensor or float, default=0.04): The parameter :math:`\theta`. + sigma (torch.Tensor or float, default=0.04): The parameter :math:`\sigma`. + dt (torch.Tensor or 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 :func:`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 :func:`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_vasicek + ... + >>> _ = torch.manual_seed(42) + >>> generate_vasicek(2, 5) + tensor([[0.0400, 0.0409, 0.0412, 0.0418, 0.0423], + [0.0400, 0.0395, 0.0451, 0.0435, 0.0446]]) + """ + if init_state is None: + init_state = (theta,) + + # Accept Union[float, Tensor] as well because making a tuple with a single element + # is troublesome + if isinstance(init_state, (float, Tensor)): + init_state = (torch.as_tensor(init_state),) + + if init_state[0] != 0: + new_init_state = (init_state[0] - theta,) + return theta + generate_vasicek( + n_paths=n_paths, + n_steps=n_steps, + init_state=new_init_state, + kappa=kappa, + theta=0.0, + sigma=sigma, + dt=dt, + dtype=dtype, + device=device, + ) + + # Cast to init_state: Tuple[Tensor, ...] with desired dtype and device + init_state = cast(Tuple[Tensor, ...], tuple(map(torch.as_tensor, init_state))) + init_state = tuple(map(lambda t: t.to(dtype=dtype, device=device), init_state)) + + output = torch.empty(*(n_paths, n_steps), dtype=dtype, device=device) + output[:, 0] = init_state[0] + + # Cast to Tensor with desired dtype and device + kappa, theta, sigma, dt = map(torch.as_tensor, (kappa, theta, sigma, dt)) + kappa, theta, sigma, dt = map(lambda t: t.to(output), (kappa, theta, sigma, dt)) + + randn = torch.randn_like(output) + + # Compute \mu: Equation (3.3) + mu = (-kappa * dt).exp() + for i_step in range(n_steps - 1): + # Compute \sigma_X: Equation (3.4) + vola = sigma * ((1 - mu.square()) / 2 / kappa).sqrt() + output[:, i_step + 1] = mu * output[:, i_step] + vola * randn[:, i_step] + + return output diff --git a/pfhedge/version.py b/pfhedge/version.py index 1317d755..11ac8e1a 100644 --- a/pfhedge/version.py +++ b/pfhedge/version.py @@ -1 +1 @@ -__version__ = "0.18.0" +__version__ = "0.19.0" diff --git a/pyproject.toml b/pyproject.toml index 5a7055a9..888e3058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pfhedge" -version = "0.18.0" +version = "0.19.0" description = "Deep Hedging in PyTorch" authors = ["Shota Imaki "] license = "MIT" diff --git a/tests/instruments/primary/test_local_volatility.py b/tests/instruments/primary/test_local_volatility.py new file mode 100644 index 00000000..3741e71d --- /dev/null +++ b/tests/instruments/primary/test_local_volatility.py @@ -0,0 +1,33 @@ +import torch +from torch.testing import assert_close + +from pfhedge.instruments import LocalVolatilityStock + + +def test_local_volatility_zero_volatility(): + def zeros(time, spot): + return torch.zeros_like(time) + + stock = LocalVolatilityStock(zeros) + stock.simulate() + + assert_close(stock.spot, torch.ones_like(stock.spot)) + assert_close(stock.volatility, torch.zeros_like(stock.volatility)) + + +def test_local_volatility(): + def zeros(time, spot): + zero = torch.zeros_like(time) + nonzero = torch.full_like(time, 0.2) + return torch.where(time > 10 / 250, zero, nonzero) + + stock = LocalVolatilityStock(zeros) + stock.simulate() + + result = stock.spot[:, 11:] + expect = stock.spot[:, 10].unsqueeze(0).expand(-1, stock.spot[:, 11:].size(1)) + assert_close(result, expect) + + result = stock.volatility[:, 10:] + expect = torch.zeros_like(stock.volatility[:, 10:]) + assert_close(result, expect) diff --git a/tests/instruments/primary/test_vasicek.py b/tests/instruments/primary/test_vasicek.py new file mode 100644 index 00000000..31351be8 --- /dev/null +++ b/tests/instruments/primary/test_vasicek.py @@ -0,0 +1,30 @@ +import pytest +import torch + +from pfhedge.instruments import VasicekRate + + +class TestVasicekRate: + @pytest.mark.parametrize("seed", range(1)) + def test_values_are_finite(self, seed): + torch.manual_seed(seed) + + s = VasicekRate() + s.simulate(n_paths=1000) + + assert not s.spot.isnan().any() + + def test_repr(self): + s = VasicekRate(cost=1e-4) + expect = """\ +VasicekRate(kappa=1., theta=0.0400, sigma=0.0400, cost=1.0000e-04, dt=0.0040)""" + assert repr(s) == expect + + def test_simulate_shape(self): + s = VasicekRate(dt=0.1) + s.simulate(time_horizon=0.2, n_paths=10) + assert s.spot.size() == torch.Size((10, 3)) + + s = VasicekRate(dt=0.1) + s.simulate(time_horizon=0.25, n_paths=10) + assert s.spot.size() == torch.Size((10, 4)) diff --git a/tests/nn/modules/test_svi.py b/tests/nn/modules/test_svi.py index 43a472d3..87ac4bcc 100644 --- a/tests/nn/modules/test_svi.py +++ b/tests/nn/modules/test_svi.py @@ -26,3 +26,8 @@ def test_svi(): result = m0(input) expect = m1(input + m1.m) assert_allclose(result, expect) + + +def test_svi_repr(): + m = SVIVariance(a=1.0, b=0, rho=0.1, m=0.2, sigma=0.3) + assert repr(m) == "SVIVariance(a=1.0, b=0, rho=0.1, m=0.2, sigma=0.3)" diff --git a/tests/nn/test_functional.py b/tests/nn/test_functional.py index 357ee647..f29df3b5 100644 --- a/tests/nn/test_functional.py +++ b/tests/nn/test_functional.py @@ -5,6 +5,7 @@ from torch.testing import assert_close from pfhedge.nn.functional import bilerp +from pfhedge.nn.functional import box_muller from pfhedge.nn.functional import clamp from pfhedge.nn.functional import d1 from pfhedge.nn.functional import d2 @@ -322,3 +323,28 @@ def test_bilerp(): result = bilerp(i1, i2, i3, i4, 0.5, 0.5) assert_close(result, (i1 + i2 + i3 + i4) / 4) + + +def test_box_muller(): + torch.manual_seed(42) + + # correct radius + input1 = torch.rand(10) + input2 = torch.rand(10) + output1, output2 = box_muller(input1, input2) + result = output1.square() + output2.square() + expect = -2 * input1.clamp(min=1e-10).log() + assert_close(result, expect) + + # correct angle + input1 = torch.rand(10) + input2 = torch.zeros(10) + output1, output2 = box_muller(input1, input2) + assert_close(output2, torch.zeros_like(output2)) + + # no nan even when input1 is zero + input1 = torch.zeros(10) + input2 = torch.rand(10) + output1, output2 = box_muller(input1, input2) + assert not output1.isnan().any() + assert not output2.isnan().any()