diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index d8639b93..1995fa31 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -27,6 +27,9 @@ jobs: - name: Bump version run: git branch --show-current | sed 's|release/||' | xargs poetry version | { printf '::set-output name=PR_TITLE::'; cat; } id: bump + + - name: Bump pfhedge.__version__ + run: git branch --show-current | sed 's|release/||' | xargs -I {} echo '__version__ = "{}"' > pfhedge/version.py - name: Create pull request uses: peter-evans/create-pull-request@v3 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6a58207..298fdc55 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,10 @@ name: Publish on: + push: + branches: main + release: + types: [published] workflow_dispatch: jobs: diff --git a/docs/source/nn.functional.rst b/docs/source/nn.functional.rst index f507ed5d..e842cc28 100644 --- a/docs/source/nn.functional.rst +++ b/docs/source/nn.functional.rst @@ -19,6 +19,16 @@ Payoff Functions european_binary_payoff european_forward_start_payoff +Nonlinear activation functions +------------------------------ + +.. autosummary:: + :nosignatures: + :toctree: generated + + leaky_clamp + clamp + Criterion Functions ------------------- @@ -32,21 +42,48 @@ Criterion Functions expected_shortfall value_at_risk +Black-Scholes formulas +---------------------- + +.. autosummary:: + :nosignatures: + :toctree: generated + + bs_european_price + bs_european_delta + bs_european_gamma + bs_european_vega + bs_european_theta + bs_american_binary_price + bs_american_binary_delta + bs_american_binary_gamma + bs_american_binary_vega + bs_american_binary_theta + bs_european_binary_price + bs_european_binary_delta + bs_european_binary_gamma + bs_european_binary_vega + bs_european_binary_theta + bs_lookback_price + bs_lookback_delta + bs_lookback_gamma + bs_lookback_vega + bs_lookback_theta + Other Functions ------------------ +--------------- .. autosummary:: :nosignatures: :toctree: generated - leaky_clamp - clamp - topp - realized_variance - realized_volatility - terminal_value - ncdf - npdf + bilerp d1 d2 + ncdf + npdf + realized_variance + realized_volatility svi_variance + terminal_value + topp diff --git a/docs/source/notes/adding_clause.rst b/docs/source/notes/adding_clause.rst new file mode 100644 index 00000000..dbee6b56 --- /dev/null +++ b/docs/source/notes/adding_clause.rst @@ -0,0 +1,69 @@ +Adding clause to derivative +=========================== + +One can customize a derivative by registering additional clauses; +see :func:`pfhedge.instruments.BaseDerivative.add_clause`. + +Let us see how to add clauses to derivatives by taking European capped call option as an example. +This option is a variant of a European option, which is given by + +.. code:: python + + >>> from pfhedge.instruments import BrownianStock + >>> from pfhedge.instruments import EuropeanOption + ... + >>> strike = 1.0 + >>> maturity = 1.0 + >>> stock = BrownianStock() + >>> european = EuropeanOption(stock, strike=strike, maturity=maturity) + +The capped call associates ''a barrier clause''; +if the underlier's spot reaches the barrier price :math:`B`, +being greater than the strike :math:`K` and the spot at inception, +the option immediately expires and pays off its intrinsic value at that moment :math:`B - K`. + +This clause can be registered to a derivative as follows: + +.. code:: python + + >>> def cap_clause(derivative, payoff): + ... barrier = 1.4 + ... max_spot = derivative.ul().spot.max(-1).values + ... capped_payoff = torch.full_like(payoff, barrier - strike) + ... return torch.where(max_spot < barrier, payoff, capped_payoff) + ... + >>> capped_european = EuropeanOption(stock, strike=strike, maturity=maturity) + >>> capped_european.add_clause("cap_clause", cap_clause) + +The method ``add_clause`` adds the clause and its name to the derivative. +Here the function ``cap_caluse`` represents the clause to modify the payoff depending on the state of the derivative. +The clause function should have the signature ``clause(derivative, payoff) -> modified payoff``. + +The payoff would be capped as intended: + +.. code:: python + + >>> n_paths = 100000 + >>> capped_european.simulate(n_paths=n_paths) + >>> european.payoff().max() + >>> # 1.2... + >>> capped_european.payoff().max() + >>> # 0.4 + +The price of the capped European call option can be evaluated by using a European option as a control variates. + +.. code:: python + + >>> from math import sqrt + ... + >>> payoff_european = european.payoff() + >>> payoff_capped_european = capped_european.payoff() + >>> bs_price = BlackScholes(european).price(0.0, european.maturity, stock.sigma).item() + >>> price = bs_price + (payoff_capped_european - payoff_european).mean().item() + >>> error = (payoff_capped_european - payoff_european).std().item() / sqrt(n_paths) + >>> bs_price + >>> # 0.07967... + >>> price + >>> # 0.07903... + >>> error + >>> # 0.00012... diff --git a/examples/example_adding_clause.py b/examples/example_adding_clause.py new file mode 100644 index 00000000..c5a6ff5a --- /dev/null +++ b/examples/example_adding_clause.py @@ -0,0 +1,57 @@ +import sys + +sys.path.append("..") + +from math import sqrt + +import torch + +from pfhedge.instruments import BrownianStock +from pfhedge.instruments import EuropeanOption +from pfhedge.nn import BlackScholes + + +def main(): + torch.manual_seed(42) + + strike = 1.0 + maturity = 1.0 + stock = BrownianStock() + european = EuropeanOption(stock, strike=strike, maturity=maturity) + + def cap_clause(derivative, payoff): + barrier = 1.4 + max_spot = derivative.ul().spot.max(-1).values + capped_payoff = torch.full_like(payoff, barrier - strike) + return torch.where(max_spot < barrier, payoff, capped_payoff) + + capped_european = EuropeanOption(stock, strike=strike, maturity=maturity) + capped_european.add_clause("cap_clause", cap_clause) + + n_paths = 100000 + capped_european.simulate(n_paths=n_paths) + + payoff_european = european.payoff() + payoff_capped_european = capped_european.payoff() + + max_spot = payoff_european.max().item() + capped_max_spot = payoff_capped_european.max().item() + print("Max payoff of vanilla European:", max_spot) + print("Max payoff of capped European:", capped_max_spot) + + # Price using control variates + bs_price = BlackScholes(european).price(0.0, european.maturity, stock.sigma).item() + value0 = payoff_capped_european.mean().item() + value1 = bs_price + (payoff_capped_european - payoff_european).mean().item() + error0 = payoff_capped_european.std().item() / sqrt(n_paths) + error1 = (payoff_capped_european - payoff_european).std().item() / sqrt(n_paths) + + print("BS price of vanilla European:", bs_price) + print("Price of capped European without control variates:", value0) + print("Price of capped European with control variates:", value1) + print("Error of capped European without control variates:", error0) + print("Error of capped European with control variates:", error1) + + +if __name__ == "__main__": + main() diff --git a/pfhedge/__init__.py b/pfhedge/__init__.py index 29da3648..a267f9be 100644 --- a/pfhedge/__init__.py +++ b/pfhedge/__init__.py @@ -1,3 +1,5 @@ # Users can import Hedger either as `from pfhedge import Hedger` or # `from pfhedge.nn import Hedger` from pfhedge.nn import Hedger + +from .version import __version__ diff --git a/pfhedge/_utils/bisect.py b/pfhedge/_utils/bisect.py index f2010810..44da9fcf 100644 --- a/pfhedge/_utils/bisect.py +++ b/pfhedge/_utils/bisect.py @@ -1,3 +1,4 @@ +from typing import Any from typing import Callable from typing import Union @@ -86,7 +87,7 @@ def find_implied_volatility( upper: float = 1.000, precision: float = 1e-6, max_iter: int = 100, - **params, + **params: Any, ) -> Tensor: """Find implied volatility by binary search. diff --git a/pfhedge/_utils/operations.py b/pfhedge/_utils/operations.py index bce4952f..bb67663d 100644 --- a/pfhedge/_utils/operations.py +++ b/pfhedge/_utils/operations.py @@ -1,3 +1,4 @@ +from typing import Any from typing import Callable import torch @@ -5,7 +6,7 @@ def ensemble_mean( - function: Callable[..., Tensor], n_times: int = 1, *args, **kwargs + function: Callable[..., Tensor], n_times: int = 1, *args: Any, **kwargs: Any ) -> Tensor: """Compute ensemble mean from function. diff --git a/pfhedge/_utils/parse.py b/pfhedge/_utils/parse.py index 1ed5c3b7..987e24bc 100644 --- a/pfhedge/_utils/parse.py +++ b/pfhedge/_utils/parse.py @@ -1,4 +1,5 @@ from numbers import Real +from typing import Any from typing import Optional from typing import Union @@ -16,7 +17,7 @@ def parse_spot( strike: Optional[Tensor] = None, moneyness: Optional[Tensor] = None, log_moneyness: Optional[Tensor] = None, - **kwargs + **kwargs: Any ) -> Tensor: spot = _as_optional_tensor(spot) strike = _as_optional_tensor(strike) @@ -34,7 +35,10 @@ def parse_spot( def parse_volatility( - *, volatility: Optional[Tensor] = None, variance: Optional[Tensor] = None, **kwargs + *, + volatility: Optional[Tensor] = None, + variance: Optional[Tensor] = None, + **kwargs: Any ) -> Tensor: if volatility is not None: return volatility @@ -45,7 +49,7 @@ def parse_volatility( def parse_time_to_maturity( - *, time_to_maturity: Optional[Tensor] = None, **kwargs + *, time_to_maturity: Optional[Tensor] = None, **kwargs: Any ) -> Tensor: if time_to_maturity is not None: return time_to_maturity diff --git a/pfhedge/_utils/testing.py b/pfhedge/_utils/testing.py index 752ec6d1..798e6c72 100644 --- a/pfhedge/_utils/testing.py +++ b/pfhedge/_utils/testing.py @@ -1,3 +1,4 @@ +from typing import Any from typing import Callable import torch @@ -62,7 +63,7 @@ def assert_convex( def assert_cash_invariant( - fn: Callable[[Tensor], Tensor], x: Tensor, c: float, **kwargs + fn: Callable[[Tensor], Tensor], x: Tensor, c: float, **kwargs: Any ) -> None: """Assert cash invariance. @@ -78,7 +79,7 @@ def assert_cash_invariant( def assert_cash_equivalent( - fn: Callable[[Tensor], Tensor], x: Tensor, c: float, **kwargs + fn: Callable[[Tensor], Tensor], x: Tensor, c: float, **kwargs: Any ) -> None: """Assert ``c`` is the cash equivalent of ``x``. ``fn(x) = fn(torch.full_like(x, c))`` diff --git a/pfhedge/autogreek.py b/pfhedge/autogreek.py index 2e0d5d88..62366854 100644 --- a/pfhedge/autogreek.py +++ b/pfhedge/autogreek.py @@ -1,4 +1,5 @@ from inspect import signature +from typing import Any from typing import Callable import torch @@ -10,7 +11,7 @@ def delta( - pricer: Callable[..., Tensor], *, create_graph: bool = False, **params + pricer: Callable[..., Tensor], *, create_graph: bool = False, **params: Any ) -> Tensor: """Computes and returns delta of a derivative using automatic differentiation. @@ -42,12 +43,14 @@ def delta( >>> import pfhedge.autogreek as autogreek >>> from pfhedge.nn import BSEuropeanOption >>> + >>> # TODO(simaki): Rewrite using functional >>> pricer = BSEuropeanOption().price >>> autogreek.delta( ... pricer, ... log_moneyness=torch.zeros(3), ... time_to_maturity=torch.ones(3), ... volatility=torch.tensor([0.18, 0.20, 0.22]), + ... strike=1.0, ... ) tensor([0.5359, 0.5398, 0.5438]) @@ -81,11 +84,6 @@ def delta( >>> autogreek.delta(pricer, spot=torch.tensor(1.0)) tensor(0.5...) """ - if params.get("strike") is None and params.get("spot") is None: - # Since delta does not depend on strike, - # assign an arbitrary value (1.0) to strike if not given. - params["strike"] = torch.tensor(1.0) - spot = parse_spot(**params).requires_grad_() params["spot"] = spot if "strike" in params: @@ -108,7 +106,7 @@ def delta( def gamma( - pricer: Callable[..., Tensor], *, create_graph: bool = False, **params + pricer: Callable[..., Tensor], *, create_graph: bool = False, **params: Any ) -> Tensor: """Computes and returns gamma of a derivative. @@ -134,7 +132,6 @@ def gamma( torch.Tensor Examples: - Gamma of a European option can be evaluated as follows. >>> import pfhedge.autogreek as autogreek @@ -175,7 +172,7 @@ def gamma( def vega( - pricer: Callable[..., Tensor], *, create_graph: bool = False, **params + pricer: Callable[..., Tensor], *, create_graph: bool = False, **params: Any ) -> Tensor: """Computes and returns vega of a derivative using automatic differentiation. @@ -200,7 +197,6 @@ def vega( torch.Tensor Examples: - Vega of a European option can be evaluated as follows. >>> import pfhedge.autogreek as autogreek @@ -235,7 +231,7 @@ def vega( def theta( - pricer: Callable[..., Tensor], *, create_graph: bool = False, **params + pricer: Callable[..., Tensor], *, create_graph: bool = False, **params: Any ) -> Tensor: """Computes and returns theta of a derivative using automatic differentiation. @@ -259,7 +255,6 @@ def theta( torch.Tensor Examples: - Theta of a European option can be evaluated as follows. >>> import pfhedge.autogreek as autogreek diff --git a/pfhedge/features/_getter.py b/pfhedge/features/_getter.py index ff5f0884..643c2258 100644 --- a/pfhedge/features/_getter.py +++ b/pfhedge/features/_getter.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from typing import Any from typing import Dict from typing import Iterator from typing import Tuple @@ -74,7 +75,7 @@ def get_class(self, name: str) -> Type[Feature]: ) return self._features[name] - def get_instance(self, name: str, *args, **kwargs) -> Feature: + def get_instance(self, name: str, **kwargs: Any) -> Feature: """Returns the feature with the given name. Parameters: @@ -83,22 +84,21 @@ def get_instance(self, name: str, *args, **kwargs) -> Feature: Returns: Feature: feature. """ - return self.get_class(name)(*args, **kwargs) # type: ignore + return self.get_class(name)(**kwargs) # type: ignore -def get_feature(feature: Union[str, Feature], *args, **kwargs) -> Feature: +def get_feature(feature: Union[str, Feature], **kwargs: Any) -> Feature: """Get feature from name. Args: name (str): Name of feature. - *args: Arguments to pass to feature constructor. - *kwargs: Keyword arguments to pass to feature constructor. + **kwargs: Keyword arguments to pass to feature constructor. Returns: Feature """ if isinstance(feature, str): - feature = FeatureFactory().get_instance(feature, *args, **kwargs) + feature = FeatureFactory().get_instance(feature, **kwargs) elif not isinstance(feature, Feature): raise TypeError(f"{feature} is not an instance of Feature.") return feature diff --git a/pfhedge/features/container.py b/pfhedge/features/container.py index df38d6a5..cff67de7 100644 --- a/pfhedge/features/container.py +++ b/pfhedge/features/container.py @@ -14,6 +14,7 @@ from ._getter import get_feature T = TypeVar("T", bound="FeatureList") +TM = TypeVar("TM", bound="ModuleOutput") class FeatureList(Feature): @@ -73,7 +74,6 @@ class ModuleOutput(Feature, Module): inputs (list[Feature]): The input features to the module. Examples: - >>> from torch.nn import Linear >>> from pfhedge.instruments import BrownianStock >>> from pfhedge.instruments import EuropeanOption @@ -121,7 +121,7 @@ def forward(self, input: Tensor) -> Tensor: def get(self, time_step: Optional[int]) -> Tensor: return self(self.inputs.get(time_step)) - def of(self, derivative=None, hedger=None): + def of(self: TM, derivative=None, hedger=None) -> TM: self.inputs = self.inputs.of(derivative, hedger) return self diff --git a/pfhedge/instruments/base.py b/pfhedge/instruments/base.py index 584621c9..5157179a 100644 --- a/pfhedge/instruments/base.py +++ b/pfhedge/instruments/base.py @@ -1,5 +1,6 @@ from abc import ABC from abc import abstractmethod +from typing import Any from typing import List from typing import Optional from typing import TypeVar @@ -42,7 +43,7 @@ def simulate(self, n_paths: int, time_horizon: float, **kwargs) -> None: """ @abstractmethod - def to(self: T, *args, **kwargs) -> T: + def to(self: T, *args: Any, **kwargs: Any) -> T: """Moves and/or casts the buffers of the instrument. This can be called as @@ -144,6 +145,18 @@ def half(self: T) -> T: """ return self.to(torch.float16) + def float64(self: T) -> T: + """Alias for :meth:`double()`.""" + return self.double() + + def float32(self: T) -> T: + """Alias for :meth:`float()`.""" + return self.float() + + def float16(self: T) -> T: + """Alias for :meth:`half()`.""" + return self.half() + def bfloat16(self: T) -> T: """Casts all floating point parameters and buffers to ``torch.bfloat16`` datatype. @@ -195,8 +208,8 @@ def _dinfo(self) -> List[str]: class Instrument(BaseInstrument): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) # type: ignore raise DeprecationWarning( "Instrument is deprecated. Use BaseInstrument instead." ) diff --git a/pfhedge/instruments/derivative/american_binary.py b/pfhedge/instruments/derivative/american_binary.py index 4ca3e912..039990e3 100644 --- a/pfhedge/instruments/derivative/american_binary.py +++ b/pfhedge/instruments/derivative/american_binary.py @@ -14,15 +14,7 @@ class AmericanBinaryOption(BaseOption): - r"""American binary Option. - - An American binary call option pays an unit amount of cash if and only if - the maximum of the underlying asset's price until maturity is equal or greater - than the strike price. - - An American binary put option pays an unit amount of cash if and only if - the minimum of the underlying asset's price until maturity is equal or smaller - than the strike price. + r"""American binary option. The payoff of an American binary call option is given by: @@ -31,10 +23,11 @@ class AmericanBinaryOption(BaseOption): \begin{cases} 1 & (\mathrm{Max} \geq K) \\ 0 & (\text{otherwise}) - \end{cases} + \end{cases} , - Here, :math:`\mathrm{Max}` is the maximum of the underlying asset's price - until maturity and :math:`K` is the strike price (`strike`) of the option. + where + :math:`\mathrm{Max}` is the maximum of the underlier's spot price until maturity and + :math:`K` is the strike. The payoff of an American binary put option is given by: @@ -43,12 +36,13 @@ class AmericanBinaryOption(BaseOption): \begin{cases} 1 & (\mathrm{Min} \leq K) \\ 0 & (\text{otherwise}) - \end{cases} + \end{cases} , - Here, :math:`\mathrm{Min}` is the minimum of the underlying asset's price. + where + :math:`\mathrm{Min}` is the minimum of the underlier's spot price until maturity. .. seealso:: - :func:`pfhedge.nn.functional.american_binary_payoff`: Payoff function. + - :func:`pfhedge.nn.functional.american_binary_payoff` Args: underlier (:class:`BasePrimary`): The underlying instrument of the option. diff --git a/pfhedge/instruments/derivative/base.py b/pfhedge/instruments/derivative/base.py index a716693e..41ceedbb 100644 --- a/pfhedge/instruments/derivative/base.py +++ b/pfhedge/instruments/derivative/base.py @@ -3,6 +3,7 @@ from typing import Any from typing import Callable from typing import Dict +from typing import Iterator from typing import Optional from typing import Tuple from typing import TypeVar @@ -19,6 +20,7 @@ from ..primary.base import BasePrimary T = TypeVar("T", bound="BaseDerivative") +Clause = Callable[[T, Tensor], Tensor] class BaseDerivative(BaseInstrument): @@ -80,7 +82,7 @@ def ul(self) -> BasePrimary: """Alias for ``self.underlier``.""" return self.underlier - def to(self: T, *args, **kwargs) -> T: + def to(self: T, *args: Any, **kwargs: Any) -> T: self.underlier.to(*args, **kwargs) return self @@ -116,7 +118,7 @@ def payoff(self) -> Tensor: torch.Tensor """ payoff = self.payoff_fn() - for clause in self._clauses.values(): + for clause in self.clauses(): payoff = clause(self, payoff) return payoff @@ -142,14 +144,13 @@ def delist(self: T) -> None: After this method self will be a private derivative. """ self.pricer = None + self.cost = 0.0 @property def is_listed(self) -> bool: return self.pricer is not None - def add_clause( - self, name: str, clause: Callable[["BaseDerivative", Tensor], Tensor] - ) -> None: + def add_clause(self, name: str, clause: Clause) -> None: """Adds a clause to the derivative. The clause will be called after :meth:`payoff_fn` method @@ -173,8 +174,23 @@ def add_clause( raise KeyError('clause name can\'t contain ".", got: {}'.format(name)) elif name == "": raise KeyError('clause name can\'t be empty string ""') + + if not hasattr(self, "_clauses"): + raise AttributeError( + "cannot assign clause before BaseDerivative.__init__() call" + ) + self._clauses[name] = clause + def named_clauses(self) -> Iterator[Tuple[str, Clause]]: + if hasattr(self, "_clauses"): + for name, clause in self._clauses.items(): + yield name, clause + + def clauses(self) -> Iterator[Clause]: + for _, clause in self.named_clauses(): + yield clause + @property def spot(self) -> Tensor: """Returns ``self.pricer(self)`` if self is listed. @@ -198,8 +214,8 @@ def __repr__(self) -> str: class Derivative(BaseDerivative): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) # type: ignore raise DeprecationWarning( "Derivative is deprecated. Use BaseDerivative instead." ) diff --git a/pfhedge/instruments/derivative/cliquet.py b/pfhedge/instruments/derivative/cliquet.py index d1e11115..b9a3406c 100644 --- a/pfhedge/instruments/derivative/cliquet.py +++ b/pfhedge/instruments/derivative/cliquet.py @@ -21,15 +21,18 @@ class EuropeanForwardStartOption(BaseDerivative): \mathrm{payoff} = \max(S_T / S_{T'} - K, 0) , where - :math:`S_T` is the underlying asset's price at maturity, - :math:`S_{T'}` is the underlying asset's price at start, and - :math:`K` is the strike value of the option. + :math:`S_T` is the underlier's spot price at maturity, + :math:`S_{T'}` is the underlier's spot price at ``start``, and + :math:`K` is the strike. Note: If ``start`` is not divisible by the interval of the time step, it is rounded off so that the start time is the largest value that is divisible by the interval and less than ``start``. + .. seealso:: + - :func:`pfhedge.nn.functional.european_forward_start_payoff` + Args: underlier (:class:`BasePrimary`): The underlying instrument of the option. strike (float, default=1.0): The strike value of the option. diff --git a/pfhedge/instruments/derivative/european.py b/pfhedge/instruments/derivative/european.py index 8009b488..21f26342 100644 --- a/pfhedge/instruments/derivative/european.py +++ b/pfhedge/instruments/derivative/european.py @@ -16,25 +16,22 @@ class EuropeanOption(BaseOption): r"""European option. - A European option provides its holder the right to buy (for call option) - or sell (for put option) an underlying asset with the strike price - on the date of maturity. - The payoff of a European call option is given by: .. math:: - \mathrm{payoff} = \max(S - K, 0) + \mathrm{payoff} = \max(S - K, 0) , - Here, :math:`S` is the underlying asset's price at maturity and - :math:`K` is the strike price (`strike`) of the option. + where + :math:`S` is the underlier's spot price at maturity and + :math:`K` is the strike. The payoff of a European put option is given by: .. math:: - \mathrm{payoff} = \max(K - S, 0) + \mathrm{payoff} = \max(K - S, 0) . .. seealso:: - :func:`pfhedge.nn.functional.european_payoff`: Payoff function. + - :func:`pfhedge.nn.functional.european_payoff` Args: underlier (:class:`BasePrimary`): The underlying instrument of the option. diff --git a/pfhedge/instruments/derivative/european_binary.py b/pfhedge/instruments/derivative/european_binary.py index e252dfeb..86a72c24 100644 --- a/pfhedge/instruments/derivative/european_binary.py +++ b/pfhedge/instruments/derivative/european_binary.py @@ -16,41 +16,36 @@ class EuropeanBinaryOption(BaseOption): r"""European binary option. - An American binary call option pays an unit amount of cash if and only if - the underlying asset's price at maturity is equal or greater than the strike price. - - An American binary put option pays an unit amount of cash if and only if - the underlying asset's price at maturity is equal or smaller than the strike price. - - The payoff of an American binary call option is given by: + The payoff of a European binary call option is given by .. math:: \mathrm{payoff} = \begin{cases} 1 & (S \geq K) \\ 0 & (\text{otherwise}) - \end{cases} + \end{cases} , - with :math:`S` being the underlying asset's price at maturity and - :math:`K` being the strike price (`strike`) of the option + where + :math:`S` is the underlier's spot price at maturity and + :math:`K` is the strike. - The payoff of an American binary put option is given by: + The payoff of a European binary put option is given by .. math:: \mathrm{payoff} = \begin{cases} 1 & (S \leq K) \\ 0 & (\text{otherwise}) - \end{cases} + \end{cases} . .. seealso:: - :func:`pfhedge.nn.functional.european_binary_payoff`: Payoff function. + - :func:`pfhedge.nn.functional.european_binary_payoff` Args: underlier (:class:`BasePrimary`): The underlying instrument of the option. call (bool, default=True): Specifies whether the option is call or put. strike (float, default=1): The strike price of the option. - maturity (float, default=20/250) The maturity of the option. + maturity (float, default=20/250): The maturity of the option. Attributes: dtype (torch.dtype): The dtype with which the simulated time-series are diff --git a/pfhedge/instruments/derivative/lookback.py b/pfhedge/instruments/derivative/lookback.py index 4d02e942..7a80025f 100644 --- a/pfhedge/instruments/derivative/lookback.py +++ b/pfhedge/instruments/derivative/lookback.py @@ -16,29 +16,25 @@ class LookbackOption(BaseOption): r"""Lookback option with fixed strike. - A lookback call option provides its holder the right to buy an underlying with - the strike price and to sell with the highest price until the date of maturity. - - A lookback put option provides its holder the right to sell an underlying with - the strike price and to buy with the lowest price until the date of maturity. - - The payoff of a lookback call option is given by: + The payoff of a lookback call option is given by .. math:: - \mathrm{payoff} = \max(\mathrm{Max} - K, 0) + \mathrm{payoff} = \max(\mathrm{Max} - K, 0) , - Here, :math:`\mathrm{Max}` is the maximum of the underlying asset's price - until maturity and :math:`K` is the strike price (`strike`) of the option. + where + :math:`\mathrm{Max}` is the maximum of the underlier's spot price until maturity and + :math:`K` is the strike. - The payoff of a lookback put option is given by: + The payoff of a lookback put option is given by .. math:: - \mathrm{payoff} = \max(K - \mathrm{Min}, 0) + \mathrm{payoff} = \max(K - \mathrm{Min}, 0) , - Here, :math:`\mathrm{Min}` is the minimum of the underlying asset's price. + where + :math:`\mathrm{Max}` is the minimum of the underlier's spot price until maturity. .. seealso:: - :func:`pfhedge.nn.functional.lookback_payoff`: Payoff function. + - :func:`pfhedge.nn.functional.lookback_payoff` Args: underlier (:class:`BasePrimary`): The underlying instrument of the option. diff --git a/pfhedge/instruments/derivative/variance_swap.py b/pfhedge/instruments/derivative/variance_swap.py index 37e8f0b0..0d61a4df 100644 --- a/pfhedge/instruments/derivative/variance_swap.py +++ b/pfhedge/instruments/derivative/variance_swap.py @@ -15,19 +15,21 @@ class VarianceSwap(BaseDerivative): r"""Variance swap. - A variance swap pays cash in the amount of the realized variance - until the maturity and levies the cash of the strike variance. - The payoff of a variance swap is given by .. math:: - \mathrm{payoff} = \sigma^2 - K + \mathrm{payoff} = \sigma^2 - K , + + where + :math:`\sigma^2` is the realized variance of the underlying asset until maturity and + :math:`K` is the strike. + + See :func:`pfhedge.nn.functional.realized_variance` + for the definition of the realized variance. - where :math:`\sigma^2` is the realized variance of the underlying asset - until maturity and :math:`K` is the strike variance (``strike``). - See :func:`pfhedge.nn.functional.realized_variance` for the definition of - the realized variance. + .. seealso:: + :func:`pfhedge.nn.functional.realized_variance` Args: underlier (:class:`BasePrimary`): The underlying instrument. diff --git a/pfhedge/instruments/primary/base.py b/pfhedge/instruments/primary/base.py index 602d6b93..aafd05ad 100644 --- a/pfhedge/instruments/primary/base.py +++ b/pfhedge/instruments/primary/base.py @@ -1,5 +1,6 @@ from abc import abstractmethod from collections import OrderedDict +from typing import Any from typing import Dict from typing import Iterator from typing import Optional @@ -40,18 +41,18 @@ class BasePrimary(BaseInstrument): dt: float cost: float - _buffers: Dict[str, Optional[Tensor]] + _buffers: Dict[str, Tensor] dtype: Optional[torch.dtype] device: Optional[torch.device] def __init__(self) -> None: super().__init__() self._buffers = OrderedDict() - self.register_buffer("spot", None) @property def default_init_state(self) -> Tuple[TensorOrScalar, ...]: """Returns the default initial state of simulation.""" + return () # TODO(simaki): Remove @no_type_check once BrownianStock and HestonStock get # unified signatures. @@ -78,7 +79,7 @@ def simulate( (See :attr:`default_init_state`). """ - def register_buffer(self, name: str, tensor: Optional[Tensor]) -> None: + def register_buffer(self, name: str, tensor: Tensor) -> None: """Adds a buffer to the instrument. The dtype and device of the buffer are the instrument's dtype and device. @@ -133,14 +134,22 @@ def buffers(self) -> Iterator[Tensor]: for _, buffer in self.named_buffers(): yield buffer - def __getattr__(self, name: str) -> Tensor: + def get_buffer(self, name: str) -> Tensor: + """Returns the buffer given by target if it exists, otherwise throws an error. + + Args: + name (str): the name of the buffer. + + Returns: + torch.Tensor + """ 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) - ) + if name in self._buffers: + return self._buffers[name] + raise AttributeError(self._get_name() + " has no buffer named " + name) + + def __getattr__(self, name: str) -> Tensor: + return self.get_buffer(name) @property def spot(self) -> Tensor: @@ -158,7 +167,7 @@ def spot(self) -> Tensor: def is_listed(self) -> bool: return True - def to(self: T, *args, **kwargs) -> T: + def to(self: T, *args: Any, **kwargs: Any) -> T: device, dtype, *_ = self._parse_to(*args, **kwargs) if dtype is not None and not dtype.is_floating_point: @@ -178,7 +187,7 @@ def to(self: T, *args, **kwargs) -> T: return self @staticmethod - def _parse_to(*args, **kwargs): + def _parse_to(*args: Any, **kwargs: Any): # Can be called as: # to(device=None, dtype=None) # to(tensor) diff --git a/pfhedge/nn/functional.py b/pfhedge/nn/functional.py index 6b2becb9..8b81fd83 100644 --- a/pfhedge/nn/functional.py +++ b/pfhedge/nn/functional.py @@ -8,12 +8,16 @@ from torch.distributions.normal import Normal from torch.distributions.utils import broadcast_all +import pfhedge.autogreek as autogreek from pfhedge._utils.typing import TensorOrScalar def european_payoff(input: Tensor, call: bool = True, strike: float = 1.0) -> Tensor: """Returns the payoff of a European option. + .. seealso:: + - :class:`pfhedge.instruments.EuropeanOption` + Args: input (torch.Tensor): The input tensor representing the price trajectory. call (bool, default=True): Specifies whether the option is call or put. @@ -37,6 +41,9 @@ def european_payoff(input: Tensor, call: bool = True, strike: float = 1.0) -> Te def lookback_payoff(input: Tensor, call: bool = True, strike: float = 1.0) -> Tensor: """Returns the payoff of a lookback option with a fixed strike. + .. seealso:: + - :class:`pfhedge.instruments.LookbackOption` + Args: input (torch.Tensor): The input tensor representing the price trajectory. call (bool, default=True): Specifies whether the option is call or put. @@ -62,6 +69,9 @@ def american_binary_payoff( ) -> Tensor: """Returns the payoff of an American binary option. + .. seealso:: + - :class:`pfhedge.instruments.AmericanBinaryOption` + Args: input (torch.Tensor): The input tensor representing the price trajectory. call (bool, default=True): Specifies whether the option is call or put. @@ -87,6 +97,9 @@ def european_binary_payoff( ) -> Tensor: """Returns the payoff of a European binary option. + .. seealso:: + - :class:`pfhedge.instruments.EuropeanBinaryOption` + Args: input (torch.Tensor): The input tensor representing the price trajectory. call (bool, default=True): Specifies whether the option is call or put. @@ -108,14 +121,18 @@ def european_binary_payoff( def european_forward_start_payoff( - input: Tensor, strike: float = 1.0, start_index: int = 0 + input: Tensor, strike: float = 1.0, start_index: int = 0, end_index: int = -1 ) -> Tensor: """Returns the payoff of a European forward start option. + .. seealso:: + - :class:`pfhedge.instruments.EuropeanForwardStartOption` + Args: input (torch.Tensor): The input tensor representing the price trajectory. - start_index (torch.Tensor): The time index at which the option starts. strike (float, default=1.0): The strike price of the option. + start_index (int, default=0): The time index at which the option starts. + end_index (int, default=-1): The time index at which the option ends. Shape: - input: :math:`(*, T)` where @@ -126,7 +143,7 @@ def european_forward_start_payoff( Returns: torch.Tensor """ - return fn.relu(input[..., -1] / input[..., start_index] - strike) + return fn.relu(input[..., end_index] / input[..., start_index] - strike) def exp_utility(input: Tensor, a: float = 1.0) -> Tensor: @@ -517,11 +534,15 @@ def npdf(input: Tensor) -> Tensor: return Normal(0.0, 1.0).log_prob(input).exp() -def d1(log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor) -> Tensor: +def d1( + log_moneyness: TensorOrScalar, + time_to_maturity: TensorOrScalar, + volatility: TensorOrScalar, +) -> Tensor: r"""Returns :math:`d_1` in the Black-Scholes formula. .. math:: - d_1 = \frac{s + \frac12 \sigma^2 t}{\sigma \sqrt{t}} + d_1 = \frac{s}{\sigma \sqrt{t}} + \frac{\sigma \sqrt{t}}{2} where :math:`s` is the log moneyness, @@ -544,19 +565,21 @@ def d1(log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor) -> T raise ValueError("all elements in time_to_maturity have to be non-negative") if not (v >= 0).all(): raise ValueError("all elements in volatility have to be non-negative") - numerator = s + (v.square() / 2) * t - denominator = v * t.sqrt() - output = numerator / denominator - return torch.where( - (numerator == 0).logical_and(denominator == 0), torch.zeros_like(output), output - ) + variance = v * t.sqrt() + output = s / variance + variance / 2 + # TODO(simaki): Replace zeros_like with 0.0 once https://github.com/pytorch/pytorch/pull/62084 is merged + return output.where((s != 0).logical_or(variance != 0), torch.zeros_like(output)) -def d2(log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor) -> Tensor: +def d2( + log_moneyness: TensorOrScalar, + time_to_maturity: TensorOrScalar, + volatility: TensorOrScalar, +) -> Tensor: r"""Returns :math:`d_2` in the Black-Scholes formula. .. math:: - d_2 = \frac{s - \frac12 \sigma^2 t}{\sigma \sqrt{t}} + d_2 = \frac{s}{\sigma \sqrt{t}} - \frac{\sigma \sqrt{t}}{2} where :math:`s` is the log moneyness, @@ -579,12 +602,10 @@ def d2(log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor) -> T raise ValueError("all elements in time_to_maturity have to be non-negative") if not (v >= 0).all(): raise ValueError("all elements in volatility have to be non-negative") - numerator = s - (v.square() / 2) * t - denominator = v * t.sqrt() - output = numerator / denominator - return torch.where( - (numerator == 0).logical_and(denominator == 0), torch.zeros_like(output), output - ) + variance = v * t.sqrt() + output = s / variance - variance / 2 + # TODO(simaki): Replace zeros_like with 0.0 once https://github.com/pytorch/pytorch/pull/62084 is merged + return output.where((s != 0).logical_or(variance != 0), torch.zeros_like(output)) def ww_width( @@ -633,3 +654,474 @@ def svi_variance( """ k_m = torch.as_tensor(input - m) # k - m return a + b * (rho * k_m + (k_m.square() + sigma ** 2).sqrt()) + + +def bilerp( + input1: Tensor, + input2: Tensor, + input3: Tensor, + input4: Tensor, + weight1: TensorOrScalar, + weight2: TensorOrScalar, +) -> Tensor: + r"""Does a bilinear interpolation of four tensors based on a scalar or tensor weights and + returns the resulting tensor. + + The output is given by + + .. math:: + \text{output}_i + & = (1 - w_1) (1 - w_2) \cdot \text{input1}_i + + w_1 (1 - w_2) \cdot \text{input2}_i \\ + & \quad + (1 - w_1) w_2 \cdot \text{input3}_i + + w_1 w_2 \cdot \text{input4}_i , + + where :math:`w_1` and :math:`w_2` are the weights. + + The shapes of inputs must be broadcastable. + If ``weight`` is a tensor, then the shapes of ``weight`` must also be broadcastable. + + Args: + input1 (torch.Tensor): The input tensor. + input2 (torch.Tensor): The input tensor. + input3 (torch.Tensor): The input tensor. + input4 (torch.Tensor): The input tensor. + weight1 (float or torch.Tensor): The weight tensor. + weight2 (float or torch.Tensor): The weight tensor. + + Returns: + torch.Tensor + """ + lerp1 = torch.lerp(input1, input2, weight1) + lerp2 = torch.lerp(input3, input4, weight1) + return torch.lerp(lerp1, lerp2, weight2) + + +def bs_european_price( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar = 1.0, + call: bool = True, +) -> Tensor: + """Returns Black-Scholes price of a European option. + + See :func:`pfhedge.nn.BSEuropeanOption.price` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + + price = strike * (s.exp() * ncdf(d1(s, t, v)) - ncdf(d2(s, t, v))) + price = price + strike * (1 - s.exp()) if not call else price # put-call parity + + return price + + +def bs_european_delta( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + call: bool = True, +) -> Tensor: + """Returns Black-Scholes delta of a European option. + + See :func:`pfhedge.nn.BSEuropeanOption.delta` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + + delta = ncdf(d1(s, t, v)) + delta = delta - 1 if not call else delta # put-call parity + + return delta + + +def bs_european_gamma( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar = 1.0, +) -> Tensor: + """Returns Black-Scholes gamma of a European option. + + See :func:`pfhedge.nn.BSEuropeanOption.gamma` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + spot = strike * s.exp() + numerator = npdf(d1(s, t, v)) + denominator = spot * v * t.sqrt() + output = numerator / denominator + return torch.where( + (numerator == 0).logical_and(denominator == 0), torch.zeros_like(output), output + ) + + +def bs_european_vega( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes vega of a European option. + + See :func:`pfhedge.nn.BSEuropeanOption.vega` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + price = strike * s.exp() + return npdf(d1(s, t, v)) * price * t.sqrt() + + +def bs_european_theta( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes theta of a European option. + + See :func:`pfhedge.nn.BSEuropeanOption.theta` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + price = strike * s.exp() + numerator = -npdf(d1(s, t, v)) * price * v + denominator = 2 * t.sqrt() + output = numerator / denominator + return torch.where( + (numerator == 0).logical_and(denominator == 0), torch.zeros_like(output), output + ) + + +def bs_european_binary_price( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + call: bool = True, +) -> Tensor: + """Returns Black-Scholes price of a European binary option. + + See :func:`pfhedge.nn.BSEuropeanBinaryOption.price` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + + price = ncdf(d2(s, t, v)) + price = 1.0 - price if not call else price # put-call parity + + return price + + +def bs_european_binary_delta( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + call: bool = True, + strike: TensorOrScalar = 1.0, +) -> Tensor: + """Returns Black-Scholes delta of a European binary option. + + See :func:`pfhedge.nn.BSEuropeanBinaryOption.delta` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + + spot = s.exp() * strike + + numerator = npdf(d2(s, t, v)) + denominator = spot * v * t.sqrt() + delta = numerator / denominator + delta = torch.where( + (numerator == 0).logical_and(denominator == 0), torch.zeros_like(delta), delta + ) + delta = -delta if not call else delta # put-call parity + + return delta + + +def bs_european_binary_gamma( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar = 1.0, +) -> Tensor: + """Returns Black-Scholes gamma of a European binary option. + + See :func:`pfhedge.nn.BSEuropeanBinaryOption.gamma` for details. + """ + # TODO(simaki): Directly compute gamma. + return autogreek.gamma( + bs_european_binary_price, + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_european_binary_vega( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar = 1.0, +) -> Tensor: + """Returns Black-Scholes vega of a European binary option. + + See :func:`pfhedge.nn.BSEuropeanBinaryOption.vega` for details. + """ + # TODO(simaki): Directly compute gamma. + return autogreek.vega( + bs_european_binary_price, + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_european_binary_theta( + log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar = 1.0, +) -> Tensor: + """Returns Black-Scholes theta of a European binary option. + + See :func:`pfhedge.nn.BSEuropeanBinaryOption.theta` for details. + """ + # TODO(simaki): Directly compute theta. + return autogreek.theta( + bs_european_binary_price, + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_american_binary_price( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, +) -> Tensor: + """Returns Black-Scholes price of an American binary option. + + See :func:`pfhedge.nn.BSAmericanBinaryOption.price` for details. + """ + # This formula is derived using the results in Section 7.3.3 of Shreve's book. + # Price is I_2 - I_4 where the interval of integration is [k --> -inf, b]. + # By this substitution we get N([log(S(0) / K) + ...] / sigma T) --> 1. + + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + p = ncdf(d2(s, t, v)) + s.exp() * (1 - ncdf(d2(-s, t, v))) + + return p.where(max_log_moneyness < 0, torch.ones_like(p)) + + +def bs_american_binary_delta( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes delta of an American binary option. + + See :func:`pfhedge.nn.BSAmericanBinaryOption.delta` for details. + """ + s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) + spot = s.exp() * strike + # ToDo: fix 0/0 issue + p = ( + npdf(d2(s, t, v)) / (spot * v * t.sqrt()) + - (1 - ncdf(d2(-s, t, v))) / strike + + npdf(d2(-s, t, v)) / (strike * v * t.sqrt()) + ) + + return p.where(max_log_moneyness < 0, torch.zeros_like(p)) + + +def bs_american_binary_gamma( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes gamma of an American binary option. + + See :func:`pfhedge.nn.BSAmericanBinaryOption.gamma` for details. + """ + # TODO(simaki): Compute analytically + return autogreek.gamma( + bs_american_binary_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_american_binary_vega( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes vega of an American binary option. + + See :func:`pfhedge.nn.BSAmericanBinaryOption.vega` for details. + """ + # TODO(simaki): Compute analytically + return autogreek.vega( + bs_american_binary_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_american_binary_theta( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes theta of an American binary option. + + See :func:`pfhedge.nn.BSAmericanBinaryOption.theta` for details. + """ + # TODO(simaki): Compute analytically + return autogreek.theta( + bs_american_binary_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_lookback_price( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes price of a lookback option. + + See :func:`pfhedge.nn.BSLookbackOption.price` for details. + """ + s, m, t, v = map( + torch.as_tensor, + (log_moneyness, max_log_moneyness, time_to_maturity, volatility), + ) + + spot = s.exp() * strike + max = m.exp() * strike + d1_value = d1(s, t, v) + d2_value = d2(s, t, v) + m1 = d1(s - m, t, v) # d' in the paper + m2 = d2(s - m, t, v) + + # when max < strike + price_0 = spot * ( + ncdf(d1_value) + v * t.sqrt() * (d1_value * ncdf(d1_value) + npdf(d1_value)) + ) - strike * ncdf(d2_value) + # when max >= strike + price_1 = ( + spot * (ncdf(m1) + v * t.sqrt() * (m1 * ncdf(m1) + npdf(m1))) + - strike + + max * (1 - ncdf(m2)) + ) + + return torch.where(max < strike, price_0, price_1) + + +def bs_lookback_delta( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes delta of a lookback option. + + See :func:`pfhedge.nn.BSLookbackOption.delta` for details. + """ + # TODO(simaki): Calculate analytically + return autogreek.delta( + bs_lookback_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_lookback_gamma( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes gamma of a lookback option. + + See :func:`pfhedge.nn.BSLookbackOption.gamma` for details. + """ + # TODO(simaki): Calculate analytically + return autogreek.gamma( + bs_lookback_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_lookback_vega( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes vega of a lookback option. + + See :func:`pfhedge.nn.BSLookbackOption.vega` for details. + """ + # TODO(simaki): Calculate analytically + return autogreek.vega( + bs_lookback_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) + + +def bs_lookback_theta( + log_moneyness: Tensor, + max_log_moneyness: Tensor, + time_to_maturity: Tensor, + volatility: Tensor, + strike: TensorOrScalar, +) -> Tensor: + """Returns Black-Scholes theta of a lookback option. + + See :func:`pfhedge.nn.BSLookbackOption.theta` for details. + """ + # TODO(simaki): Calculate analytically + return autogreek.theta( + bs_lookback_price, + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=strike, + ) diff --git a/pfhedge/nn/modules/bs/_base.py b/pfhedge/nn/modules/bs/_base.py index c9e15c3e..84406766 100644 --- a/pfhedge/nn/modules/bs/_base.py +++ b/pfhedge/nn/modules/bs/_base.py @@ -1,5 +1,8 @@ from inspect import signature from typing import List +from typing import Optional +from typing import Tuple +from typing import Union from typing import no_type_check import torch @@ -7,6 +10,10 @@ from torch.nn import Module import pfhedge.autogreek as autogreek +from pfhedge.instruments import AmericanBinaryOption +from pfhedge.instruments import EuropeanBinaryOption +from pfhedge.instruments import EuropeanOption +from pfhedge.instruments import LookbackOption class BSModuleMixin(Module): @@ -85,3 +92,82 @@ def inputs(self) -> List[str]: list """ return list(signature(self.delta).parameters.keys()) + + +def acquire_params_from_derivative_0( + derivative: Optional[ + Union[ + EuropeanOption, EuropeanBinaryOption, AmericanBinaryOption, LookbackOption + ] + ], + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, +) -> Tuple[Tensor, Tensor]: + if log_moneyness is None: + if derivative is None: + 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: + raise ValueError( + "time_to_maturity is required if derivative is not set at this initialization." + ) + time_to_maturity = derivative.time_to_maturity() + return log_moneyness, time_to_maturity + + +def acquire_params_from_derivative_1( + derivative: Optional[ + Union[ + EuropeanOption, EuropeanBinaryOption, AmericanBinaryOption, LookbackOption + ] + ], + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, +) -> Tuple[Tensor, Tensor, Tensor]: + log_moneyness, time_to_maturity = acquire_params_from_derivative_0( + derivative=derivative, + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + ) + if volatility is None: + if derivative is None: + raise ValueError( + "time_to_maturity is required if derivative is not set at this initialization." + ) + if derivative.ul().volatility is None: + raise AttributeError( + "please simulate first and check if volatility exists in the derivative's underlier." + ) + volatility = derivative.ul().volatility + return log_moneyness, time_to_maturity, volatility + + +def acquire_params_from_derivative_2( + derivative: Optional[Union[AmericanBinaryOption, LookbackOption]], + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, +) -> Tuple[Tensor, Tensor, Tensor, Tensor]: + log_moneyness, time_to_maturity, volatility = acquire_params_from_derivative_1( + derivative=derivative, + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + ) + if max_log_moneyness is None: + if derivative is None: + 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/bs/american_binary.py b/pfhedge/nn/modules/bs/american_binary.py index 5b06a1f6..86b73647 100644 --- a/pfhedge/nn/modules/bs/american_binary.py +++ b/pfhedge/nn/modules/bs/american_binary.py @@ -1,25 +1,30 @@ from math import sqrt +from typing import Optional import torch from torch import Tensor -from torch.distributions.utils import broadcast_all from pfhedge._utils.bisect import find_implied_volatility from pfhedge._utils.doc import _set_attr_and_docstring from pfhedge._utils.str import _format_float -from pfhedge.nn.functional import d1 -from pfhedge.nn.functional import d2 -from pfhedge.nn.functional import ncdf -from pfhedge.nn.functional import npdf +from pfhedge.instruments import AmericanBinaryOption +from pfhedge.nn.functional import bs_american_binary_delta +from pfhedge.nn.functional import bs_american_binary_price from ._base import BSModuleMixin +from ._base import acquire_params_from_derivative_0 +from ._base import acquire_params_from_derivative_2 +from .black_scholes import BlackScholesModuleFactory class BSAmericanBinaryOption(BSModuleMixin): - """Black-Scholes formula for an american binary option. + """Black-Scholes formula for an American binary option. Note: - Risk-free rate is set to zero. + - The formulas are for continuous monitoring while + :class:`pfhedge.instruments.AmericanBinaryOption` monitors spot prices discretely. + To get adjustment for discrete monitoring, see, for instance, + Broadie, Glasserman, and Kou (1999). Args: call (bool, default=True): Specifies whether the option is call or put. @@ -41,6 +46,9 @@ class BSAmericanBinaryOption(BSModuleMixin): References: - Shreve, S.E., 2004. Stochastic calculus for finance II: Continuous-time models (Vol. 11). Springer Science & Business Media. + - Broadie, M., Glasserman, P. and Kou, S.G., 1999. + Connecting discrete and continuous path-dependent options. + Finance and Stochastics, 3(1), pp.55-82. Examples: >>> from pfhedge.nn import BSAmericanBinaryOption @@ -58,7 +66,12 @@ class BSAmericanBinaryOption(BSModuleMixin): [...]]) """ - def __init__(self, call: bool = True, strike: float = 1.0): + def __init__( + self, + call: bool = True, + strike: float = 1.0, + derivative: Optional[AmericanBinaryOption] = None, + ): if not call: raise ValueError( f"{self.__class__.__name__} for a put option is not yet supported." @@ -67,6 +80,7 @@ def __init__(self, call: bool = True, strike: float = 1.0): super().__init__() self.call = call self.strike = strike + self.derivative = derivative @classmethod def from_derivative(cls, derivative): @@ -88,7 +102,9 @@ def from_derivative(cls, derivative): >>> m BSAmericanBinaryOption(strike=1.1000) """ - return cls(call=derivative.call, strike=derivative.strike) + return cls( + call=derivative.call, strike=derivative.strike, derivative=derivative + ) def extra_repr(self) -> str: params = [] @@ -97,18 +113,18 @@ def extra_repr(self) -> str: def price( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns price of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -120,30 +136,46 @@ def price( Returns: torch.Tensor + + Note: + Parameters are not optional if the module has not accepted a derivative in its initialization. """ # This formula is derived using the results in Section 7.3.3 of Shreve's book. # Price is I_2 - I_4 where the interval of integration is [k --> -inf, b]. # By this substitution we get N([log(S(0) / K) + ...] / sigma T) --> 1. - - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - p = ncdf(d2(s, t, v)) + s.exp() * (1 - ncdf(d2(-s, t, v))) - - return p.where(max_log_moneyness < 0, torch.ones_like(p)) + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) + return bs_american_binary_price( + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + ) def delta( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns delta of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -155,33 +187,45 @@ def delta( Returns: torch.Tensor + + Note: + Parameters are not optional if the module has not accepted a derivative in its initialization. """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - spor = s.exp() * self.strike - # ToDo: fix 0/0 issue - p = ( - npdf(d2(s, t, v)) / (spor * v * t.sqrt()) - - (1 - ncdf(d2(-s, t, v))) / self.strike - + npdf(d2(-s, t, v)) / (self.strike * v * t.sqrt()) + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) + return bs_american_binary_delta( + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=self.strike, ) - - return p.where(max_log_moneyness < 0, torch.zeros_like(p)) @torch.enable_grad() def gamma( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns gamma of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -193,30 +237,45 @@ def gamma( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().gamma( - strike=self.strike, log_moneyness=log_moneyness, max_log_moneyness=max_log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, + strike=self.strike, ) @torch.enable_grad() def vega( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns vega of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -228,30 +287,45 @@ def vega( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().vega( - strike=self.strike, log_moneyness=log_moneyness, max_log_moneyness=max_log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, + strike=self.strike, ) @torch.enable_grad() def theta( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns theta of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -266,32 +340,47 @@ def theta( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().theta( - strike=self.strike, log_moneyness=log_moneyness, max_log_moneyness=max_log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, + strike=self.strike, ) def implied_volatility( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - price: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + price: Optional[Tensor] = None, precision: float = 1e-6, ) -> Tensor: """Returns implied volatility of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. - precision (float, default=1e-6): Computational precision of the implied - volatility. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. + price (torch.Tensor): Price of the derivative. + precision (float, default=1e-6): Computational precision of the implied volatility. Shape: - log_moneyness: :math:`(N, *)` where @@ -302,7 +391,18 @@ def implied_volatility( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. + price seems optional in typing, but it isn't. It is set for the compatibility to the previous versions. """ + (log_moneyness, time_to_maturity) = acquire_params_from_derivative_0( + self.derivative, log_moneyness, time_to_maturity + ) + if price is None: + raise ValueError( + "price is required in this method. None is set only for compatibility to the previous versions." + ) return find_implied_volatility( self.price, price=price, @@ -313,6 +413,9 @@ def implied_volatility( ) +factory = BlackScholesModuleFactory() +factory.register_module("AmericanBinaryOption", BSAmericanBinaryOption) + # Assign docstrings so they appear in Sphinx documentation _set_attr_and_docstring(BSAmericanBinaryOption, "inputs", BSModuleMixin.inputs) _set_attr_and_docstring(BSAmericanBinaryOption, "forward", BSModuleMixin.forward) diff --git a/pfhedge/nn/modules/bs/black_scholes.py b/pfhedge/nn/modules/bs/black_scholes.py index 27e68749..392b9c1f 100644 --- a/pfhedge/nn/modules/bs/black_scholes.py +++ b/pfhedge/nn/modules/bs/black_scholes.py @@ -1,13 +1,47 @@ +from collections import OrderedDict from typing import Callable +from typing import Dict +from typing import Iterator from typing import List +from typing import Tuple +from typing import Type from torch import Tensor from torch.nn import Module -from .american_binary import BSAmericanBinaryOption -from .european import BSEuropeanOption -from .european_binary import BSEuropeanBinaryOption -from .lookback import BSLookbackOption + +class BlackScholesModuleFactory: + + _modules: Dict[str, Type[Module]] + + # singleton + def __new__(cls, *args, **kwargs): + if not hasattr(cls, "_instance"): + cls._instance = super().__new__(cls) + cls._instance._modules = OrderedDict() + return cls._instance + + def register_module(self, name: str, cls: Type[Module]) -> None: + self._modules[name] = cls + + def named_modules(self) -> Iterator[Tuple[str, Type[Module]]]: + for name, module in self._modules.items(): + if module is not None: + yield name, module + + def names(self) -> Iterator[str]: + for name, _ in self.named_modules(): + yield name + + def features(self) -> Iterator[Type[Module]]: + for _, module in self.named_modules(): + yield module + + def get_class(self, name: str) -> Type[Module]: + return self._modules[name] + + def get_class_from_derivative(self, derivative) -> Type[Module]: + return self.get_class(derivative.__class__.__name__).from_derivative(derivative) # type: ignore class BlackScholes(Module): @@ -42,16 +76,6 @@ class BlackScholes(Module): >>> m = BlackScholes(derivative) >>> m BSEuropeanOption(strike=1.1000) - >>> m.inputs() - ['log_moneyness', 'time_to_maturity', 'volatility'] - >>> input = torch.tensor([ - ... [-0.01, 0.1, 0.2], - ... [ 0.00, 0.1, 0.2], - ... [ 0.01, 0.1, 0.2]]) - >>> m(input) - tensor([[0.4497], - [0.5126], - [0.5752]]) Instantiating :class:`BSLookbackOption` using a :class:`pfhedge.instruments.LookbackOption`. @@ -62,27 +86,14 @@ class BlackScholes(Module): >>> m = BlackScholes(derivative) >>> m BSLookbackOption(strike=1.0300) - >>> m.inputs() - ['log_moneyness', 'max_log_moneyness', 'time_to_maturity', 'volatility'] - >>> input = torch.tensor([ - ... [-0.01, -0.01, 0.1, 0.2], - ... [ 0.00, 0.00, 0.1, 0.2], - ... [ 0.01, 0.01, 0.1, 0.2]]) - >>> m(input) - tensor([[...], - [...], - [...]]) """ inputs: Callable[..., List[str]] # inputs(self) -> List[str] price: Callable[..., Tensor] # price(self, ...) -> Tensor delta: Callable[..., Tensor] # delta(self, ...) -> Tensor gamma: Callable[..., Tensor] # gamma(self, ...) -> Tensor + vega: Callable[..., Tensor] # vega(self, ...) -> Tensor + theta: Callable[..., Tensor] # theta(self, ...) -> Tensor def __new__(cls, derivative): - return { - "EuropeanOption": BSEuropeanOption, - "LookbackOption": BSLookbackOption, - "AmericanBinaryOption": BSAmericanBinaryOption, - "EuropeanBinaryOption": BSEuropeanBinaryOption, - }[derivative.__class__.__name__].from_derivative(derivative) + return BlackScholesModuleFactory().get_class_from_derivative(derivative) diff --git a/pfhedge/nn/modules/bs/european.py b/pfhedge/nn/modules/bs/european.py index b1e510db..69720596 100644 --- a/pfhedge/nn/modules/bs/european.py +++ b/pfhedge/nn/modules/bs/european.py @@ -1,16 +1,22 @@ +from typing import Optional + import torch from torch import Tensor -from torch.distributions.utils import broadcast_all from pfhedge._utils.bisect import find_implied_volatility from pfhedge._utils.doc import _set_attr_and_docstring from pfhedge._utils.str import _format_float -from pfhedge.nn.functional import d1 -from pfhedge.nn.functional import d2 -from pfhedge.nn.functional import ncdf -from pfhedge.nn.functional import npdf +from pfhedge.instruments import EuropeanOption +from pfhedge.nn.functional import bs_european_delta +from pfhedge.nn.functional import bs_european_gamma +from pfhedge.nn.functional import bs_european_price +from pfhedge.nn.functional import bs_european_theta +from pfhedge.nn.functional import bs_european_vega from ._base import BSModuleMixin +from ._base import acquire_params_from_derivative_0 +from ._base import acquire_params_from_derivative_1 +from .black_scholes import BlackScholesModuleFactory class BSEuropeanOption(BSModuleMixin): @@ -55,10 +61,16 @@ class BSEuropeanOption(BSModuleMixin): [0.5752]]) """ - def __init__(self, call: bool = True, strike: float = 1.0): + def __init__( + self, + call: bool = True, + strike: float = 1.0, + derivative: Optional[EuropeanOption] = None, + ) -> None: super().__init__() self.call = call self.strike = strike + self.derivative = derivative @classmethod def from_derivative(cls, derivative): @@ -80,7 +92,9 @@ def from_derivative(cls, derivative): >>> m BSEuropeanOption(call=False, strike=1.) """ - return cls(call=derivative.call, strike=derivative.strike) + return cls( + call=derivative.call, strike=derivative.strike, derivative=derivative + ) def extra_repr(self) -> str: params = [] @@ -90,14 +104,17 @@ def extra_repr(self) -> str: return ", ".join(params) def delta( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns delta of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -108,23 +125,36 @@ def delta( Returns: torch.Tensor - """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - - delta = ncdf(d1(s, t, v)) - delta = delta - 1 if not self.call else delta # put-call parity - return delta + Note: + Parameters are not optional if the module has not accepted a derivative in its initialization. + """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_delta( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + call=self.call, + ) def gamma( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns gamma of the derivative. Args: - log_moneyness: (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -135,28 +165,36 @@ def gamma( Returns: torch.Tensor - """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - price = self.strike * s.exp() - numerator = npdf(d1(s, t, v)) - denominator = price * v * t.sqrt() - output = numerator / denominator - return torch.where( - (numerator == 0).logical_and(denominator == 0), - torch.zeros_like(output), - output, + Note: + args are not optional if it doesn't accept derivative in this initialization. + """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_gamma( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=self.strike, ) def vega( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns vega of the derivative. Args: - log_moneyness: (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -167,22 +205,36 @@ def vega( Returns: torch.Tensor - """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - price = self.strike * s.exp() - vega = npdf(d1(s, t, v)) * price * t.sqrt() - return vega + Note: + args are not optional if it doesn't accept derivative in this initialization. + """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_vega( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=self.strike, + ) def theta( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns theta of the derivative. Args: - log_moneyness: (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -196,27 +248,36 @@ def theta( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - price = self.strike * s.exp() - numerator = -npdf(d1(s, t, v)) * price * v - denominator = 2 * t.sqrt() - output = numerator / denominator - return torch.where( - (numerator == 0).logical_and(denominator == 0), - torch.zeros_like(output), - output, + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_theta( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=self.strike, ) def price( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns price of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -227,31 +288,37 @@ def price( Returns: torch.Tensor - """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - n1 = ncdf(d1(s, t, v)) - n2 = ncdf(d2(s, t, v)) - - price = self.strike * (s.exp() * n1 - n2) - - if not self.call: - price += self.strike * (1 - s.exp()) # put-call parity - - return price + Note: + args are not optional if it doesn't accept derivative in this initialization. + """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_price( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=self.strike, + call=self.call, + ) def implied_volatility( self, - log_moneyness: Tensor, - time_to_maturity: Tensor, - price: Tensor, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + price: Optional[Tensor] = None, precision: float = 1e-6, ) -> Tensor: """Returns implied volatility of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. price (torch.Tensor): Price of the derivative. precision (float): Computational precision of the implied volatility. @@ -265,7 +332,19 @@ def implied_volatility( Returns torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. + price seems optional in typing, but it isn't. It is set for the compatibility to the previous versions. """ + (log_moneyness, time_to_maturity) = acquire_params_from_derivative_0( + self.derivative, log_moneyness, time_to_maturity + ) + if price is None: + raise ValueError( + "price is required in this method. None is set only for compatibility to the previous versions." + ) + return find_implied_volatility( self.price, price=price, @@ -275,6 +354,9 @@ def implied_volatility( ) +factory = BlackScholesModuleFactory() +factory.register_module("EuropeanOption", BSEuropeanOption) + # Assign docstrings so they appear in Sphinx documentation _set_attr_and_docstring(BSEuropeanOption, "inputs", BSModuleMixin.inputs) _set_attr_and_docstring(BSEuropeanOption, "forward", BSModuleMixin.forward) diff --git a/pfhedge/nn/modules/bs/european_binary.py b/pfhedge/nn/modules/bs/european_binary.py index 60914f25..949b8a56 100644 --- a/pfhedge/nn/modules/bs/european_binary.py +++ b/pfhedge/nn/modules/bs/european_binary.py @@ -1,19 +1,21 @@ from typing import List +from typing import Optional import torch from torch import Tensor -from torch.distributions.utils import broadcast_all from pfhedge._utils.bisect import find_implied_volatility 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.nn.functional import d1 -from pfhedge.nn.functional import d2 -from pfhedge.nn.functional import ncdf -from pfhedge.nn.functional import npdf +from pfhedge.instruments import EuropeanBinaryOption +from pfhedge.nn.functional import bs_european_binary_delta +from pfhedge.nn.functional import bs_european_binary_price from ._base import BSModuleMixin +from ._base import acquire_params_from_derivative_0 +from ._base import acquire_params_from_derivative_1 +from .black_scholes import BlackScholesModuleFactory class BSEuropeanBinaryOption(BSModuleMixin): @@ -58,10 +60,16 @@ class BSEuropeanBinaryOption(BSModuleMixin): [6.1953]]) """ - def __init__(self, call: bool = True, strike: float = 1.0): + def __init__( + self, + call: bool = True, + strike: float = 1.0, + derivative: Optional[EuropeanBinaryOption] = None, + ): super().__init__() self.call = call self.strike = strike + self.derivative = derivative @classmethod def from_derivative(cls, derivative): @@ -83,7 +91,9 @@ def from_derivative(cls, derivative): >>> m BSEuropeanBinaryOption(strike=1.1000) """ - return cls(call=derivative.call, strike=derivative.strike) + return cls( + call=derivative.call, strike=derivative.strike, derivative=derivative + ) def extra_repr(self) -> str: params = [] @@ -96,14 +106,17 @@ def inputs(self) -> List[str]: return ["log_moneyness", "time_to_maturity", "volatility"] def price( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns price of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional)): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional)): Time to expiry of the option. + volatility (torch.Tensor, optional)): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` @@ -113,24 +126,37 @@ def price( Returns: torch.Tensor - """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - - price = ncdf(d2(s, t, v)) - price = 1.0 - price if not self.call else price # put-call parity - return price + Note: + args are not optional if it doesn't accept derivative in this initialization. + """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_binary_price( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + call=self.call, + ) @torch.enable_grad() def delta( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns delta of the derivative. Args: - log_moneyness: (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness: (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` @@ -140,32 +166,37 @@ def delta( Returns: torch.Tensor - """ - s, t, v = broadcast_all(log_moneyness, time_to_maturity, volatility) - spot = s.exp() * self.strike - - numerator = npdf(d2(s, t, v)) - denominator = spot * v * t.sqrt() - delta = numerator / denominator - delta = torch.where( - (numerator == 0).logical_and(denominator == 0), - torch.zeros_like(delta), - delta, + Note: + Parameters are not optional if the module has not accepted a derivative in its initialization. + """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) + return bs_european_binary_delta( + log_moneyness=log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + call=self.call, + strike=self.strike, ) - delta = -delta if not self.call else delta # put-call parity - - return delta def gamma( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns gamma of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` @@ -175,24 +206,37 @@ def gamma( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) # TODO(simaki): Directly compute gamma. return super().gamma( - strike=self.strike, log_moneyness=log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, + strike=self.strike, ) def vega( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns vega of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` @@ -202,24 +246,37 @@ def vega( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) # TODO: Directly compute theta. return super().vega( - strike=self.strike, log_moneyness=log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, + strike=self.strike, ) def theta( - self, log_moneyness: Tensor, time_to_maturity: Tensor, volatility: Tensor + self, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns theta of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` @@ -232,37 +289,60 @@ def theta( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_1( + self.derivative, log_moneyness, time_to_maturity, volatility + ) # TODO: Directly compute theta. return super().theta( - strike=self.strike, log_moneyness=log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, + strike=self.strike, ) def implied_volatility( self, - log_moneyness: Tensor, - time_to_maturity: Tensor, - price: Tensor, + log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + price: Optional[Tensor] = None, precision: float = 1e-6, ) -> Tensor: """Returns implied volatility of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - time_to_maturity (torch.Tensor): Time to expiry of the option. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. price (torch.Tensor): Price of the derivative. + precision (float): Computational precision of the + implied volatility. Shape: - log_moneyness: :math:`(N, *)` - time_to_maturity: :math:`(N, *)` + - price: :math:`(N, *)` - output: :math:`(N, *)` Returns: torch.Tensor + Note: + args are not optional if it doesn't accept derivative in this initialization. + price seems optional in typing, but it isn't. It is set for the compatibility to the previous versions. """ + (log_moneyness, time_to_maturity) = acquire_params_from_derivative_0( + self.derivative, log_moneyness, time_to_maturity + ) + if price is None: + raise ValueError( + "price is required in this method. None is set only for compatibility to the previous versions." + ) return find_implied_volatility( self.price, price=price, @@ -272,6 +352,9 @@ def implied_volatility( ) +factory = BlackScholesModuleFactory() +factory.register_module("EuropeanBinaryOption", BSEuropeanBinaryOption) + # Assign docstrings so they appear in Sphinx documentation _set_docstring(BSEuropeanBinaryOption, "inputs", BSModuleMixin.inputs) _set_attr_and_docstring(BSEuropeanBinaryOption, "forward", BSModuleMixin.forward) diff --git a/pfhedge/nn/modules/bs/lookback.py b/pfhedge/nn/modules/bs/lookback.py index 72d83551..3c868a11 100644 --- a/pfhedge/nn/modules/bs/lookback.py +++ b/pfhedge/nn/modules/bs/lookback.py @@ -1,4 +1,5 @@ from typing import List +from typing import Optional import torch from torch import Tensor @@ -7,19 +8,27 @@ 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.nn.functional import d1 as compute_d1 -from pfhedge.nn.functional import d2 as compute_d2 -from pfhedge.nn.functional import ncdf -from pfhedge.nn.functional import npdf +from pfhedge.instruments import LookbackOption +from pfhedge.nn.functional import bs_lookback_delta +from pfhedge.nn.functional import bs_lookback_gamma +from pfhedge.nn.functional import bs_lookback_price +from pfhedge.nn.functional import bs_lookback_theta +from pfhedge.nn.functional import bs_lookback_vega from ._base import BSModuleMixin +from ._base import acquire_params_from_derivative_0 +from ._base import acquire_params_from_derivative_2 +from .black_scholes import BlackScholesModuleFactory class BSLookbackOption(BSModuleMixin): """Black-Scholes formula for a lookback option with a fixed strike. Note: - Risk-free rate is set to zero. + - The formulas are for continuous monitoring while + :class:`pfhedge.instruments.LookbackOption` monitors spot prices discretely. + To get adjustment for discrete monitoring, see, for instance, + Broadie, Glasserman, and Kou (1999). .. seealso:: - :class:`pfhedge.nn.BlackScholes`: @@ -30,6 +39,9 @@ class BSLookbackOption(BSModuleMixin): References: - Conze, A., 1991. Path dependent options: The case of lookback options. The Journal of Finance, 46(5), pp.1893-1907. + - Broadie, M., Glasserman, P. and Kou, S.G., 1999. + Connecting discrete and continuous path-dependent options. + Finance and Stochastics, 3(1), pp.55-82. Args: call (bool, default=True): Specifies whether the option is call or put. @@ -58,7 +70,12 @@ class BSLookbackOption(BSModuleMixin): [1.0515]]) """ - def __init__(self, call: bool = True, strike: float = 1.0) -> None: + def __init__( + self, + call: bool = True, + strike: float = 1.0, + derivative: Optional[LookbackOption] = None, + ) -> None: if not call: raise ValueError( f"{self.__class__.__name__} for a put option is not yet supported." @@ -67,6 +84,7 @@ def __init__(self, call: bool = True, strike: float = 1.0) -> None: super().__init__() self.call = call self.strike = strike + self.derivative = derivative @classmethod def from_derivative(cls, derivative): @@ -88,7 +106,9 @@ def from_derivative(cls, derivative): >>> m BSLookbackOption(strike=1.1000) """ - return cls(call=derivative.call, strike=derivative.strike) + return cls( + call=derivative.call, strike=derivative.strike, derivative=derivative + ) def extra_repr(self) -> str: params = [] @@ -100,10 +120,10 @@ def inputs(self) -> List[str]: def price( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: r"""Returns price of the derivative. @@ -126,10 +146,10 @@ def price( :math:`d_2' = [\log(S(0) / M) - \frac12 \sigma^2 T] / \sigma \sqrt{T}`. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: - log_moneyness: :math:`(N, *)` where @@ -141,48 +161,46 @@ def price( Returns: torch.Tensor + + Note: + Parameters are not optional if the module has not accepted a derivative in its initialization. """ - s, m, t, v = map( - torch.as_tensor, - (log_moneyness, max_log_moneyness, time_to_maturity, volatility), + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, ) - - spot = s.exp() * self.strike - max = m.exp() * self.strike - d1 = compute_d1(s, t, v) - d2 = compute_d2(s, t, v) - m1 = compute_d1(s - m, t, v) # d' in the paper - m2 = compute_d2(s - m, t, v) - - # when max < strike - price_0 = spot * ( - ncdf(d1) + v * t.sqrt() * (d1 * ncdf(d1) + npdf(d1)) - ) - self.strike * ncdf(d2) - # when max >= strike - price_1 = ( - spot * (ncdf(m1) + v * t.sqrt() * (m1 * ncdf(m1) + npdf(m1))) - - self.strike - + max * (1 - ncdf(m2)) + return bs_lookback_price( + log_moneyness=log_moneyness, + max_log_moneyness=max_log_moneyness, + time_to_maturity=time_to_maturity, + volatility=volatility, + strike=self.strike, ) - return torch.where(max < self.strike, price_0, price_1) - @torch.enable_grad() def delta( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, create_graph: bool = False, ) -> Tensor: """Returns delta of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): Volatility of the underlying asset. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. create_graph (bool, default=False): If True, graph of the derivative will be constructed. This option is used to compute gamma. @@ -196,30 +214,46 @@ def delta( Returns: torch.Tensor + + Note: + Parameters are not optional if the module has not accepted a derivative in its initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().delta( log_moneyness=log_moneyness, max_log_moneyness=max_log_moneyness, time_to_maturity=time_to_maturity, volatility=volatility, create_graph=create_graph, + strike=self.strike, ) @torch.enable_grad() def gamma( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns gamma of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: @@ -232,7 +266,22 @@ def gamma( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().gamma( strike=self.strike, log_moneyness=log_moneyness, @@ -244,18 +293,18 @@ def gamma( @torch.enable_grad() def vega( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns vega of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: @@ -268,7 +317,22 @@ def vega( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().vega( strike=self.strike, log_moneyness=log_moneyness, @@ -280,18 +344,18 @@ def vega( @torch.enable_grad() def theta( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - volatility: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + volatility: Optional[Tensor] = None, ) -> Tensor: """Returns theta of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. - volatility (torch.Tensor): + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. + volatility (torch.Tensor, optional): Volatility of the underlying asset. Shape: @@ -307,7 +371,22 @@ def theta( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. """ + ( + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) = acquire_params_from_derivative_2( + self.derivative, + log_moneyness, + max_log_moneyness, + time_to_maturity, + volatility, + ) return super().theta( strike=self.strike, log_moneyness=log_moneyness, @@ -318,18 +397,18 @@ def theta( def implied_volatility( self, - log_moneyness: Tensor, - max_log_moneyness: Tensor, - time_to_maturity: Tensor, - price: Tensor, + log_moneyness: Optional[Tensor] = None, + max_log_moneyness: Optional[Tensor] = None, + time_to_maturity: Optional[Tensor] = None, + price: Optional[Tensor] = None, precision: float = 1e-6, ) -> Tensor: """Returns implied volatility of the derivative. Args: - log_moneyness (torch.Tensor): Log moneyness of the underlying asset. - max_log_moneyness (torch.Tensor): Cumulative maximum of the log moneyness. - time_to_maturity (torch.Tensor): Time to expiry of the option. + log_moneyness (torch.Tensor, optional): Log moneyness of the underlying asset. + max_log_moneyness (torch.Tensor, optional): Cumulative maximum of the log moneyness. + time_to_maturity (torch.Tensor, optional): Time to expiry of the option. price (torch.Tensor): Price of the derivative. precision (float, default=1e-6): Precision of the implied volatility. @@ -343,7 +422,18 @@ def implied_volatility( Returns: torch.Tensor + + Note: + args are not optional if it doesn't accept derivative in this initialization. + price seems optional in typing, but it isn't. It is set for the compatibility to the previous versions. """ + (log_moneyness, time_to_maturity) = acquire_params_from_derivative_0( + self.derivative, log_moneyness, time_to_maturity + ) + if price is None: + raise ValueError( + "price is required in this method. None is set only for compatibility to the previous versions." + ) return find_implied_volatility( self.price, price=price, @@ -354,6 +444,9 @@ def implied_volatility( ) +factory = BlackScholesModuleFactory() +factory.register_module("LookbackOption", BSLookbackOption) + # Assign docstrings so they appear in Sphinx documentation _set_docstring(BSLookbackOption, "inputs", BSModuleMixin.inputs) _set_attr_and_docstring(BSLookbackOption, "forward", BSModuleMixin.forward) diff --git a/pfhedge/nn/modules/hedger.py b/pfhedge/nn/modules/hedger.py index c11c1884..3927cc2c 100644 --- a/pfhedge/nn/modules/hedger.py +++ b/pfhedge/nn/modules/hedger.py @@ -1,3 +1,4 @@ +from typing import Any from typing import Callable from typing import List from typing import Optional @@ -539,7 +540,7 @@ def fit( """ optimizer = self._configure_optimizer(derivative, optimizer) - def compute_loss(**kwargs) -> Tensor: + def compute_loss(**kwargs: Any) -> Tensor: return self.compute_loss( derivative, hedge=hedge, diff --git a/pfhedge/stochastic/brownian.py b/pfhedge/stochastic/brownian.py index 21715897..34a88650 100644 --- a/pfhedge/stochastic/brownian.py +++ b/pfhedge/stochastic/brownian.py @@ -82,7 +82,7 @@ def generate_brownian( init_value = init_state[0] # randn = torch.randn((n_paths, n_steps), dtype=dtype, device=device) - randn = engine((n_paths, n_steps), dtype=dtype, device=device) + randn = engine(*(n_paths, n_steps), dtype=dtype, device=device) randn[:, 0] = 0.0 drift = mu * dt * torch.arange(n_steps).to(randn) brown = randn.new_tensor(dt).sqrt() * randn.cumsum(1) diff --git a/pfhedge/stochastic/cir.py b/pfhedge/stochastic/cir.py index d835f816..7340fbe2 100644 --- a/pfhedge/stochastic/cir.py +++ b/pfhedge/stochastic/cir.py @@ -8,7 +8,7 @@ from pfhedge._utils.typing import TensorOrScalar -def _get_epsilon(dtype: Optional[torch.dtype]): +def _get_epsilon(dtype: Optional[torch.dtype]) -> float: return torch.finfo(dtype).tiny if dtype else torch.finfo().tiny diff --git a/pfhedge/stochastic/engine.py b/pfhedge/stochastic/engine.py index 112f2a9f..68db2a72 100644 --- a/pfhedge/stochastic/engine.py +++ b/pfhedge/stochastic/engine.py @@ -15,7 +15,7 @@ def _box_muller(input0: Tensor, input1: Tensor) -> Tuple[Tensor, Tensor]: return z0, z1 -def _get_numel(size: Tuple[int, ...]): +def _get_numel(size: Tuple[int, ...]) -> int: out = 1 for dim in size: out *= dim @@ -42,11 +42,11 @@ def __init__(self, scramble: bool = False, seed: Optional[int] = None): def __call__( self, - *size: Tuple[int, ...], + *size: int, dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None, ) -> Tensor: - numel = _get_numel(*size) + numel = _get_numel(tuple(size)) output = self._generate_1d(numel, dtype=dtype, device=device) output.resize_(*size) return output diff --git a/pfhedge/stochastic/random.py b/pfhedge/stochastic/random.py index 3bee1fbc..711e7388 100644 --- a/pfhedge/stochastic/random.py +++ b/pfhedge/stochastic/random.py @@ -1,9 +1,18 @@ +from typing import Optional + import torch +from torch import Tensor from .engine import RandnSobolBoxMuller -def randn_antithetic(*size, dtype=None, device=None, dim=0, shuffle=True): +def randn_antithetic( + *size: int, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, + dim: int = 0, + shuffle: bool = True +) -> Tensor: """Returns a tensor filled with random numbers obtained by an antithetic sampling. The output should be a normal distribution with mean 0 and variance 1 @@ -40,8 +49,8 @@ def randn_antithetic(*size, dtype=None, device=None, dim=0, shuffle=True): if dim != 0: raise ValueError("dim != 0 is not supported.") - size = list(size) - size_half = [-(-size[0] // 2)] + size[1:] + size_list = list(size) + size_half = [-(-size_list[0] // 2)] + size_list[1:] randn = torch.randn(*size_half, dtype=dtype, device=device) output = torch.cat((randn, -randn), dim=0) @@ -49,12 +58,18 @@ def randn_antithetic(*size, dtype=None, device=None, dim=0, shuffle=True): if shuffle: output = output[torch.randperm(output.size(dim))] - output = output[: size[0]] + output = output[: size_list[0]] return output -def randn_sobol_boxmuller(*size, dtype=None, device=None, scramble=True, seed=None): +def randn_sobol_boxmuller( + *size: int, + dtype: Optional[torch.dtype] = None, + device: Optional[torch.device] = None, + scramble: bool = True, + seed: Optional[int] = None +) -> Tensor: """Returns a tensor filled with random numbers obtained by a Sobol sequence applied with the Box-Muller transformation. @@ -80,7 +95,7 @@ def randn_sobol_boxmuller(*size, dtype=None, device=None, scramble=True, seed=No >>> from pfhedge.stochastic import randn_sobol_boxmuller >>> >>> _ = torch.manual_seed(42) - >>> output = randn_sobol_boxmuller((4, 3)) + >>> output = randn_sobol_boxmuller(4, 3) >>> output tensor([[ 0.0559, 0.4954, -0.8578], [-0.7492, -1.0370, -0.4778], diff --git a/pfhedge/version.py b/pfhedge/version.py new file mode 100644 index 00000000..1317d755 --- /dev/null +++ b/pfhedge/version.py @@ -0,0 +1 @@ +__version__ = "0.18.0" diff --git a/pyproject.toml b/pyproject.toml index d0951426..5a7055a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pfhedge" -version = "0.17.0" +version = "0.18.0" description = "Deep Hedging in PyTorch" authors = ["Shota Imaki "] license = "MIT" diff --git a/tests/instruments/primary/test_brownian.py b/tests/instruments/primary/test_brownian.py index b536f4aa..ad0f829c 100644 --- a/tests/instruments/primary/test_brownian.py +++ b/tests/instruments/primary/test_brownian.py @@ -146,18 +146,36 @@ def test_to_dtype(self, dtype): assert s.dtype == torch.float64 assert s.spot.dtype == torch.float64 + s = BrownianStock() + s.simulate() + s.float64() + assert s.dtype == torch.float64 + assert s.spot.dtype == torch.float64 + s = BrownianStock() s.simulate() s.float() assert s.dtype == torch.float32 assert s.spot.dtype == torch.float32 + s = BrownianStock() + s.simulate() + s.float32() + assert s.dtype == torch.float32 + assert s.spot.dtype == torch.float32 + s = BrownianStock() s.simulate() s.half() assert s.dtype == torch.float16 assert s.spot.dtype == torch.float16 + s = BrownianStock() + s.simulate() + s.float16() + assert s.dtype == torch.float16 + assert s.spot.dtype == torch.float16 + s = BrownianStock() s.simulate() s.bfloat16() diff --git a/tests/nn/modules/bs/test_american_binary.py b/tests/nn/modules/bs/test_american_binary.py index ad25e78c..7d275d27 100644 --- a/tests/nn/modules/bs/test_american_binary.py +++ b/tests/nn/modules/bs/test_american_binary.py @@ -2,7 +2,6 @@ import pytest import torch -from torch import Tensor from torch.testing import assert_close from pfhedge.features._getter import get_feature @@ -62,6 +61,107 @@ def test_check_delta(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_delta_2(self, call: bool): + m = BSAmericanBinaryOption(call=call) + with pytest.raises(ValueError): + m.delta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.delta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_delta_3(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + m2 = BSAmericanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.delta() + with pytest.raises(AttributeError): + m.delta(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.delta(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.delta(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.delta(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.delta() + expect = m2.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.delta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.delta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.delta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_gamma(self): m = BSAmericanBinaryOption() @@ -85,6 +185,106 @@ def test_check_gamma(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_gamma_2(self, call: bool): + m = BSAmericanBinaryOption(call=call) + with pytest.raises(ValueError): + m.gamma( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.gamma( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_gamma_3(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + m2 = BSAmericanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.gamma(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.gamma(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.gamma(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.gamma(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.gamma() + expect = m2.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.gamma( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.gamma( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_vega(self): m = BSAmericanBinaryOption() @@ -108,6 +308,106 @@ def test_check_vega(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_vega_2(self, call: bool): + m = BSAmericanBinaryOption(call=call) + with pytest.raises(ValueError): + m.vega( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.vega( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_vega_3(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + m2 = BSAmericanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.vega(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.vega(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.vega(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.vega(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.vega() + expect = m2.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.vega( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.vega( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_theta(self): m = BSAmericanBinaryOption() @@ -131,6 +431,106 @@ def test_check_theta(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_theta_2(self, call: bool): + m = BSAmericanBinaryOption(call=call) + with pytest.raises(ValueError): + m.theta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.theta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_theta_3(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + m2 = BSAmericanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.theta(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.theta(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.theta(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.theta(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.theta() + expect = m2.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.theta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.theta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_price(self): m = BSAmericanBinaryOption() @@ -154,6 +554,105 @@ def test_check_price(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_price_3(self, call: bool): + m = BSAmericanBinaryOption(call=call) + with pytest.raises(ValueError): + m.price( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.price( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_price_4(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + m2 = BSAmericanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.price(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.price(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.price(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.price(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.price() + expect = m2.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.price( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.price( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.price( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_price_monte_carlo(self): torch.manual_seed(42) @@ -198,6 +697,27 @@ def test_vega_and_gamma(self): gamma = m.gamma(spot.log(), spot.log(), t, v) assert_close(vega, spot.square() * v * t * gamma, atol=1e-3, rtol=0) + @pytest.mark.parametrize("call", [True]) + def test_vega_and_gamma_2(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + vega = m.vega() + gamma = m.gamma() + # ToDo: [..., :-1] should be removed + assert_close( + vega[..., :-1], + ( + derivative.underlier.spot.square() + * derivative.underlier.volatility + * derivative.time_to_maturity() + * gamma + )[..., :-1], + atol=1e-3, + rtol=0, + ) + def test_features(self): m = BSAmericanBinaryOption() assert m.inputs() == [ @@ -226,6 +746,81 @@ def test_implied_volatility(self): expect = input[:, -1] assert_close(result, expect, atol=1e-4, rtol=1e-4, check_stride=False) + @pytest.mark.parametrize("call", [True]) + def test_implied_volatility_2(self, call: bool): + derivative = AmericanBinaryOption(BrownianStock(), call=call) + m = BSAmericanBinaryOption.from_derivative(derivative) + m2 = BSAmericanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.implied_volatility( + None, torch.tensor(1), torch.tensor(2), torch.tensor(3) + ) + with pytest.raises(AttributeError): + m.implied_volatility( + torch.tensor(1), None, torch.tensor(2), torch.tensor(3) + ) + with pytest.raises(AttributeError): + m.implied_volatility( + torch.tensor(1), torch.tensor(2), None, torch.tensor(3) + ) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.implied_volatility(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + with pytest.raises(ValueError): + m.implied_volatility() + result = m.implied_volatility(price=derivative.underlier.spot) + expect = m2.implied_volatility( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.spot, + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.implied_volatility( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + with pytest.raises(ValueError): + m2.implied_volatility( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + with pytest.raises(ValueError): + m2.implied_volatility( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.spot, + ) + def test_shape(self): torch.distributions.Distribution.set_default_validate_args(False) diff --git a/tests/nn/modules/bs/test_european.py b/tests/nn/modules/bs/test_european.py index d93b01f4..5b4e253d 100644 --- a/tests/nn/modules/bs/test_european.py +++ b/tests/nn/modules/bs/test_european.py @@ -230,6 +230,48 @@ def test_delta_3(self, call: bool): expect = torch.full_like(result, 0.5 if call else -0.5) assert_close(result, expect) + @pytest.mark.parametrize("call", [True, False]) + def test_delta_4(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + m2 = BSEuropeanOption(call=call) + with pytest.raises(AttributeError): + m.delta(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.delta(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.delta(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.delta() + expect = m2.delta( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.delta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.delta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.delta(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.delta(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_gamma_1(self): m = BSEuropeanOption() result = m.gamma(torch.tensor(0.0), torch.tensor(1.0), torch.tensor(0.2)) @@ -265,6 +307,48 @@ def test_gamma_3(self, call: bool): expect = torch.full_like(result, float("inf")) assert_close(result, expect) + @pytest.mark.parametrize("call", [True, False]) + def test_gamma_4(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + m2 = BSEuropeanOption(call=call) + with pytest.raises(AttributeError): + m.gamma(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.gamma(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.gamma(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.gamma() + expect = m2.gamma( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.gamma( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.gamma( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.gamma( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.gamma( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.gamma(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.gamma(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_price_1(self): m = BSEuropeanOption() result = m.price(0.0, 1.0, 0.2) @@ -306,6 +390,48 @@ def test_price_3(self, call: bool): expect = torch.full_like(result, 0.0) assert_close(result, expect) + @pytest.mark.parametrize("call", [True, False]) + def test_price_4(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + m2 = BSEuropeanOption(call=call) + with pytest.raises(AttributeError): + m.price(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.price(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.price(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.price() + expect = m2.price( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.price( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.price( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.price(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.price(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_implied_volatility(self): input = torch.tensor([[0.0, 0.1, 0.01], [0.0, 0.1, 0.02], [0.0, 0.1, 0.03]]) m = BSEuropeanOption() @@ -315,6 +441,46 @@ def test_implied_volatility(self): expect = input[:, 2] assert_close(result, expect, check_stride=False) + @pytest.mark.parametrize("call", [True, False]) + def test_implied_volatility_2(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + m2 = BSEuropeanOption(call=call) + with pytest.raises(AttributeError): + m.implied_volatility(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.implied_volatility(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.implied_volatility(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + with pytest.raises(ValueError): + m.implied_volatility() + result = m.implied_volatility(price=derivative.underlier.spot) + expect = m2.implied_volatility( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + None, derivative.time_to_maturity(), derivative.underlier.spot + ) + assert_close(result, expect) + result = m.implied_volatility( + derivative.log_moneyness(), None, derivative.underlier.spot + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.implied_volatility( + None, derivative.time_to_maturity(), derivative.underlier.spot + ) + with pytest.raises(ValueError): + m2.implied_volatility( + derivative.log_moneyness(), None, derivative.underlier.spot + ) + def test_vega(self): input = torch.tensor([[0.0, 0.1, 0.2], [0.0, 0.2, 0.2], [0.0, 0.3, 0.2]]) m = BSEuropeanOption() @@ -349,6 +515,46 @@ def test_vega_2(self, call: bool): expect = torch.full_like(result, 0.0) assert_close(result, expect) + @pytest.mark.parametrize("call", [True, False]) + def test_vega_3(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + m2 = BSEuropeanOption(call=call) + with pytest.raises(AttributeError): + m.vega(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.vega(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.vega(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.vega() + expect = m2.vega( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.vega( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.vega( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.vega(derivative.log_moneyness(), derivative.time_to_maturity(), None) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.vega( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.vega(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.vega(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_vega_and_gamma(self): m = BSEuropeanOption() # vega = spot^2 * sigma * (T - t) * gamma @@ -360,6 +566,24 @@ def test_vega_and_gamma(self): gamma = m.gamma(spot.log(), t, v) assert_close(vega, spot.square() * v * t * gamma, atol=1e-3, rtol=0) + @pytest.mark.parametrize("call", [True, False]) + def test_vega_and_gamma_2(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + vega = m.vega() + gamma = m.gamma() + assert_close( + vega, + derivative.underlier.spot.square() + * derivative.underlier.volatility + * derivative.time_to_maturity() + * gamma, + atol=1e-3, + rtol=0, + ) + def test_theta(self): input = torch.tensor([[0.0, 0.1, 0.2], [0.0, 0.2, 0.2], [0.0, 0.3, 0.2]]) m = BSEuropeanOption(strike=100) @@ -394,6 +618,48 @@ def test_theta_2(self, call: bool): expect = torch.full_like(result, -0.0) assert_close(result, expect) + @pytest.mark.parametrize("call", [True, False]) + def test_theta_3(self, call: bool): + derivative = EuropeanOption(BrownianStock(), call=call) + m = BSEuropeanOption.from_derivative(derivative) + m2 = BSEuropeanOption(call=call) + with pytest.raises(AttributeError): + m.theta(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.theta(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.theta(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.theta() + expect = m2.theta( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.theta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.theta( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.theta( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.theta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.theta(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.theta(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_example(self): from pfhedge.instruments import BrownianStock from pfhedge.instruments import EuropeanOption diff --git a/tests/nn/modules/bs/test_european_binary.py b/tests/nn/modules/bs/test_european_binary.py index f822195d..90d391d5 100644 --- a/tests/nn/modules/bs/test_european_binary.py +++ b/tests/nn/modules/bs/test_european_binary.py @@ -1,6 +1,5 @@ import pytest import torch -from torch import Tensor from torch.testing import assert_close from pfhedge.features._getter import get_feature @@ -185,18 +184,183 @@ def test_delta(self): expect = torch.tensor(6.3047) assert_close(result, expect, atol=1e-4, rtol=1e-4) + @pytest.mark.parametrize("call", [True, False]) + def test_delta_2(self, call: bool): + m = BSEuropeanBinaryOption(call=call) + with pytest.raises(ValueError): + m.delta(torch.tensor(0.0), torch.tensor(-1.0), torch.tensor(0.2)) + with pytest.raises(ValueError): + m.delta(torch.tensor(0.0), torch.tensor(1.0), torch.tensor(-0.2)) + result = m.delta(torch.tensor(1.0), torch.tensor(1.0), torch.tensor(0)) + expect = torch.full_like(result, 0.0) + assert_close(result, expect) + result = m.delta(torch.tensor(1.0), torch.tensor(0.0), torch.tensor(0.1)) + expect = torch.full_like(result, 0.0) + assert_close(result, expect) + result = m.delta(torch.tensor(1.0), torch.tensor(0.0), torch.tensor(0.0)) + expect = torch.full_like(result, 0.0) + assert_close(result, expect) + result = m.delta(torch.tensor(0.0), torch.tensor(0.0), torch.tensor(0.1)) + expect = torch.full_like(result, float("inf") if call else -float("inf")) + assert_close(result, expect) + result = m.delta(torch.tensor(0.0), torch.tensor(0.0), torch.tensor(0.0)) + expect = torch.full_like(result, float("inf") if call else -float("inf")) + assert_close(result, expect) + + @pytest.mark.parametrize("call", [True, False]) + def test_delta_3(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + m2 = BSEuropeanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.delta(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.delta(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.delta(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.delta() + expect = m2.delta( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.delta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.delta( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.delta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.delta(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.delta(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_gamma(self): m = BSEuropeanBinaryOption() result = m.gamma(torch.tensor(0.01), torch.tensor(1.0), torch.tensor(0.2)) expect = torch.tensor(-1.4645787477493286) assert_close(result, expect) + @pytest.mark.parametrize("call", [True, False]) + def test_gamma_2(self, call: bool): + m = BSEuropeanBinaryOption(call=call) + with pytest.raises(ValueError): + m.gamma(torch.tensor(0.0), torch.tensor(-1.0), torch.tensor(0.2)) + with pytest.raises(ValueError): + m.gamma(torch.tensor(0.0), torch.tensor(1.0), torch.tensor(-0.2)) + + @pytest.mark.parametrize("call", [True, False]) + def test_gamma_3(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + m2 = BSEuropeanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.gamma(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.gamma(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.gamma(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.gamma() + expect = m2.gamma( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.gamma( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.gamma(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.gamma(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_vega(self): m = BSEuropeanBinaryOption() result = m.vega(torch.tensor(0.0), torch.tensor(0.1), torch.tensor(0.2)) expect = torch.tensor(-0.06305) assert_close(result, expect, atol=1e-4, rtol=1e-4) + @pytest.mark.parametrize("call", [True, False]) + def test_vega_2(self, call: bool): + m = BSEuropeanBinaryOption(call=call) + with pytest.raises(ValueError): + m.vega(torch.tensor(0.0), torch.tensor(-1.0), torch.tensor(0.2)) + with pytest.raises(ValueError): + m.vega(torch.tensor(0.0), torch.tensor(1.0), torch.tensor(-0.2)) + + @pytest.mark.parametrize("call", [True, False]) + def test_vega_3(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + m2 = BSEuropeanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.vega(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.vega(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.vega(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.vega() + expect = m2.vega( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega(derivative.log_moneyness(), derivative.time_to_maturity(), None) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.vega( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.vega(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.vega(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_vega_and_gamma(self): m = BSEuropeanBinaryOption() # vega = spot^2 * sigma * (T - t) * gamma @@ -208,12 +372,84 @@ def test_vega_and_gamma(self): gamma = m.gamma(spot.log(), t, v) assert_close(vega, spot.square() * v * t * gamma, atol=1e-3, rtol=0) + @pytest.mark.parametrize("call", [True, False]) + def test_vega_and_gamma_2(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + vega = m.vega() + gamma = m.gamma() + # ToDo: [..., :-1] should be removed + assert_close( + vega[..., :-1], + ( + derivative.underlier.spot.square() + * derivative.underlier.volatility + * derivative.time_to_maturity() + * gamma + )[..., :-1], + atol=1e-3, + rtol=0, + ) + def test_theta(self): m = BSEuropeanBinaryOption() result = m.theta(torch.tensor(0.0), torch.tensor(0.1), torch.tensor(0.2)) expect = torch.tensor(0.0630) assert_close(result, expect, atol=1e-4, rtol=1e-4) + @pytest.mark.parametrize("call", [True, False]) + def test_theta_2(self, call: bool): + m = BSEuropeanBinaryOption(call=call) + with pytest.raises(ValueError): + m.theta(torch.tensor(0.0), torch.tensor(-1.0), torch.tensor(0.2)) + with pytest.raises(ValueError): + m.theta(torch.tensor(0.0), torch.tensor(1.0), torch.tensor(-0.2)) + + @pytest.mark.parametrize("call", [True, False]) + def test_theta_3(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + m2 = BSEuropeanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.theta(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.theta(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.theta(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.theta() + expect = m2.theta( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.theta( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.theta(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.theta(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_price(self): m = BSEuropeanBinaryOption() @@ -225,6 +461,77 @@ def test_price(self): expect = torch.tensor(0.4880) assert_close(result, expect, atol=1e-4, rtol=1e-4) + @pytest.mark.parametrize("call", [True, False]) + def test_price_2(self, call: bool): + m = BSEuropeanBinaryOption(call=call) + with pytest.raises(ValueError): + m.price(torch.tensor(0.0), torch.tensor(-1.0), torch.tensor(0.2)) + with pytest.raises(ValueError): + m.price(torch.tensor(0.0), torch.tensor(1.0), torch.tensor(-0.2)) + result = m.price( + torch.tensor(1.0 if call else -1.0), torch.tensor(1.0), torch.tensor(0) + ) + expect = torch.full_like(result, 1) + assert_close(result, expect) + result = m.price( + torch.tensor(1.0 if call else -1.0), torch.tensor(0.0), torch.tensor(0.1) + ) + expect = torch.full_like(result, 1) + assert_close(result, expect) + result = m.price( + torch.tensor(1.0 if call else -1.0), torch.tensor(0.0), torch.tensor(0.0) + ) + expect = torch.full_like(result, 1) + assert_close(result, expect) + result = m.price(torch.tensor(0.0), torch.tensor(0.0), torch.tensor(0.1)) + expect = torch.full_like(result, 0.5) + assert_close(result, expect) + result = m.price(torch.tensor(0.0), torch.tensor(0.0), torch.tensor(0.0)) + expect = torch.full_like(result, 0.5) + assert_close(result, expect) + + @pytest.mark.parametrize("call", [True, False]) + def test_price_3(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + m2 = BSEuropeanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.price(None, torch.tensor(1), torch.tensor(2)) + with pytest.raises(AttributeError): + m.price(torch.tensor(1), None, torch.tensor(2)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.price(torch.tensor(1), torch.tensor(2), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.price() + expect = m2.price( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result, expect) + result = m.price( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), None, derivative.underlier.volatility + ) + assert_close(result, expect) + result = m.price( + derivative.log_moneyness(), derivative.time_to_maturity(), None + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.price( + None, derivative.time_to_maturity(), derivative.underlier.volatility + ) + with pytest.raises(ValueError): + m2.price(derivative.log_moneyness(), None, derivative.underlier.volatility) + with pytest.raises(ValueError): + m2.price(derivative.log_moneyness(), derivative.time_to_maturity(), None) + def test_implied_volatility(self): lm = torch.full((3,), -0.01) t = torch.full((3,), 0.1) @@ -236,6 +543,48 @@ def test_implied_volatility(self): result = BSEuropeanBinaryOption().price(lm, t, iv) assert_close(result, price, check_stride=False) + @pytest.mark.parametrize("call", [True, False]) + def test_implied_volatility_2(self, call: bool): + derivative = EuropeanBinaryOption(BrownianStock(), call=call) + m = BSEuropeanBinaryOption.from_derivative(derivative) + m2 = BSEuropeanBinaryOption(call=call) + with pytest.raises(AttributeError): + m.implied_volatility() + with pytest.raises(AttributeError): + m.implied_volatility(None, torch.tensor(1), torch.tensor(1)) + with pytest.raises(AttributeError): + m.implied_volatility(torch.tensor(1), None, torch.tensor(1)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.implied_volatility(torch.tensor(0), torch.tensor(0), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + with pytest.raises(ValueError): + m.implied_volatility() + result = m.implied_volatility(price=derivative.underlier.spot) + expect = m2.implied_volatility( + derivative.log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + None, derivative.time_to_maturity(), derivative.underlier.spot + ) + assert_close(result, expect) + result = m.implied_volatility( + derivative.log_moneyness(), None, derivative.underlier.spot + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.implied_volatility( + None, derivative.time_to_maturity(), derivative.underlier.spot + ) + with pytest.raises(ValueError): + m2.implied_volatility( + derivative.log_moneyness(), None, derivative.underlier.spot + ) + def test_example(self): from pfhedge.instruments import BrownianStock from pfhedge.instruments import EuropeanBinaryOption diff --git a/tests/nn/modules/bs/test_lookback.py b/tests/nn/modules/bs/test_lookback.py index 61690824..a66f20ca 100644 --- a/tests/nn/modules/bs/test_lookback.py +++ b/tests/nn/modules/bs/test_lookback.py @@ -66,6 +66,106 @@ def test_check_delta(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_delta_2(self, call: bool): + m = BSLookbackOption(call=call) + with pytest.raises(ValueError): + m.delta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.delta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_delta_3(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + m2 = BSLookbackOption(call=call) + with pytest.raises(AttributeError): + m.delta(None, torch.zeros(1), torch.zeros(2), torch.zeros(3)) + with pytest.raises(AttributeError): + m.delta(torch.zeros(1), None, torch.zeros(2), torch.zeros(3)) + with pytest.raises(AttributeError): + m.delta(torch.zeros(1), torch.zeros(2), None, torch.zeros(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.delta(torch.zeros(1), torch.zeros(2), torch.zeros(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.delta() + expect = m2.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.delta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.delta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.delta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.delta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.delta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_gamma(self): m = BSLookbackOption() @@ -97,6 +197,106 @@ def test_check_gamma(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_gamma_2(self, call: bool): + m = BSLookbackOption(call=call) + with pytest.raises(ValueError): + m.gamma( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.gamma( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_gamma_3(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + m2 = BSLookbackOption(call=call) + with pytest.raises(AttributeError): + m.gamma(None, torch.zeros(1), torch.zeros(2), torch.zeros(3)) + with pytest.raises(AttributeError): + m.gamma(torch.zeros(1), None, torch.zeros(2), torch.zeros(3)) + with pytest.raises(AttributeError): + m.gamma(torch.zeros(1), torch.zeros(2), None, torch.zeros(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.gamma(torch.zeros(1), torch.zeros(2), torch.zeros(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.gamma() + expect = m2.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.gamma( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.gamma( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.gamma( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_vega(self): m = BSLookbackOption() @@ -128,6 +328,106 @@ def test_check_vega(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_vega_2(self, call: bool): + m = BSLookbackOption(call=call) + with pytest.raises(ValueError): + m.vega( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.vega( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_vega_3(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + m2 = BSLookbackOption(call=call) + with pytest.raises(AttributeError): + m.vega(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.vega(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.vega(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.vega(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.vega() + expect = m2.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.vega( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.vega( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.vega( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_theta(self): m = BSLookbackOption() @@ -159,6 +459,106 @@ def test_check_theta(self): expect = torch.tensor([0.0]) assert_close(result, expect) + @pytest.mark.parametrize("call", [True]) + def test_theta_2(self, call: bool): + m = BSLookbackOption(call=call) + with pytest.raises(ValueError): + m.theta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.theta( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_theta_3(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + m2 = BSLookbackOption(call=call) + with pytest.raises(AttributeError): + m.theta(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.theta(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.theta(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.theta(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.theta() + expect = m2.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.theta( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.theta( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.theta( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_price(self): m = BSLookbackOption() @@ -197,6 +597,106 @@ def test_check_price(self): result1 = compute_price(m, torch.tensor([[1e-5, 1e-5, 0.1, 0.2]])) assert_close(result0, result1, atol=1e-4, rtol=0) + @pytest.mark.parametrize("call", [True]) + def test_price_3(self, call: bool): + m = BSLookbackOption(call=call) + with pytest.raises(ValueError): + m.price( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(-1.0), + torch.tensor(0.2), + ) + with pytest.raises(ValueError): + m.price( + torch.tensor(0.0), + torch.tensor(0.0), + torch.tensor(1.0), + torch.tensor(-0.2), + ) + + @pytest.mark.parametrize("call", [True]) + def test_price_4(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + m2 = BSLookbackOption(call=call) + with pytest.raises(AttributeError): + m.price(None, torch.tensor(1), torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.price(torch.tensor(1), None, torch.tensor(2), torch.tensor(3)) + with pytest.raises(AttributeError): + m.price(torch.tensor(1), torch.tensor(2), None, torch.tensor(3)) + # ToDo: #530 + # with pytest.raises(AttributeError): + # m.price(torch.tensor(1), torch.tensor(2), torch.tensor(3), None) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + result = m.price() + expect = m2.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + # ToDo: [..., :-1] should be removed + assert_close(result[..., :-1], expect[..., :-1]) + result = m.price( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.price( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + assert_close(result[..., :-1], expect[..., :-1]) + result = m.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + assert_close(result[..., :-1], expect[..., :-1]) + with pytest.raises(ValueError): + m2.price( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.price( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.volatility, + ) + with pytest.raises(ValueError): + m2.price( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + None, + ) + def test_check_price_monte_carlo(self): torch.manual_seed(42) @@ -251,6 +751,68 @@ def test_implied_volatility(self): expect = input[:, -1] assert_close(result, expect, atol=1e-4, rtol=1e-4, check_stride=False) + @pytest.mark.parametrize("call", [True]) + def test_implied_volatility_2(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + m2 = BSLookbackOption(call=call) + with pytest.raises(AttributeError): + m.implied_volatility() + torch.manual_seed(42) + derivative.simulate(n_paths=1) + with pytest.raises(ValueError): + m.implied_volatility() + result = m.implied_volatility(price=derivative.underlier.spot) + expect = m2.implied_volatility( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + assert_close(result, expect) + result = m.implied_volatility( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.spot, + ) + assert_close(result, expect) + with pytest.raises(ValueError): + m2.implied_volatility( + None, + derivative.max_log_moneyness(), + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + with pytest.raises(ValueError): + m2.implied_volatility( + derivative.log_moneyness(), + None, + derivative.time_to_maturity(), + derivative.underlier.spot, + ) + with pytest.raises(ValueError): + m2.implied_volatility( + derivative.log_moneyness(), + derivative.max_log_moneyness(), + None, + derivative.underlier.spot, + ) + def test_vega_and_gamma(self): m = BSLookbackOption() # vega = spot^2 * sigma * (T - t) * gamma @@ -262,6 +824,27 @@ def test_vega_and_gamma(self): gamma = m.gamma(spot.log(), spot.log(), t, v) assert_close(vega, spot.square() * v * t * gamma, atol=1e-3, rtol=0) + @pytest.mark.parametrize("call", [True]) + def test_vega_and_gamma_2(self, call: bool): + derivative = LookbackOption(BrownianStock(), call=call) + m = BSLookbackOption.from_derivative(derivative) + torch.manual_seed(42) + derivative.simulate(n_paths=1) + vega = m.vega() + gamma = m.gamma() + # ToDo: [..., :-1] should be removed + assert_close( + vega[..., :-1], + ( + derivative.underlier.spot.square() + * derivative.underlier.volatility + * derivative.time_to_maturity() + * gamma + )[..., :-1], + atol=1e-3, + rtol=0, + ) + def test_put_notimplemented(self): with pytest.raises(ValueError): # not yet supported diff --git a/tests/nn/test_functional.py b/tests/nn/test_functional.py index bb3f5876..357ee647 100644 --- a/tests/nn/test_functional.py +++ b/tests/nn/test_functional.py @@ -4,6 +4,7 @@ import torch from torch.testing import assert_close +from pfhedge.nn.functional import bilerp from pfhedge.nn.functional import clamp from pfhedge.nn.functional import d1 from pfhedge.nn.functional import d2 @@ -293,3 +294,31 @@ def test_d2_2(): time_to_maturity=torch.as_tensor([1.0, 0, 0]), volatility=torch.as_tensor([0.2, 0.2, -1.0]), ) + + +def test_bilerp(): + torch.manual_seed(42) + + i1 = torch.randn(2, 3) + i2 = torch.randn(2, 3) + i3 = torch.randn(2, 3) + i4 = torch.randn(2, 3) + + # edge cases + result = bilerp(i1, i2, i3, i4, 0.0, 0.0) + assert_close(result, i1) + result = bilerp(i1, i2, i3, i4, 1.0, 0.0) + assert_close(result, i2) + result = bilerp(i1, i2, i3, i4, 0.0, 1.0) + assert_close(result, i3) + result = bilerp(i1, i2, i3, i4, 1.0, 1.0) + assert_close(result, i4) + + # w1 or w2 = 0 reduces to lerp + result = bilerp(i1, i2, i3, i4, 0.1, 0.0) + assert_close(result, torch.lerp(i1, i2, 0.1)) + result = bilerp(i1, i2, i3, i4, 0.0, 0.1) + assert_close(result, torch.lerp(i1, i3, 0.1)) + + result = bilerp(i1, i2, i3, i4, 0.5, 0.5) + assert_close(result, (i1 + i2 + i3 + i4) / 4) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..8eda65cf --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,10 @@ +import configparser +import os.path + +import pfhedge + + +def test_version(): + parser = configparser.ConfigParser() + parser.read(os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")) + assert pfhedge.__version__ == parser["tool.poetry"]["version"].replace('"', "")