diff --git a/tf_quant_finance/BUILD b/tf_quant_finance/BUILD index 6bc5903b8..855d53117 100644 --- a/tf_quant_finance/BUILD +++ b/tf_quant_finance/BUILD @@ -28,6 +28,7 @@ py_library( "//tf_quant_finance/math", "//tf_quant_finance/models", "//tf_quant_finance/rates", + "//tf_quant_finance/volatility", ], ) diff --git a/tf_quant_finance/__init__.py b/tf_quant_finance/__init__.py index 711c369f2..8456c7540 100644 --- a/tf_quant_finance/__init__.py +++ b/tf_quant_finance/__init__.py @@ -75,6 +75,7 @@ def _ensure_tf_install(): # pylint: disable=g-statement-before-imports from tf_quant_finance import math from tf_quant_finance import models from tf_quant_finance import rates +from tf_quant_finance import volatility from tensorflow.python.util.all_util import remove_undocumented # pylint: disable=g-direct-tensorflow-import _allowed_symbols = [ @@ -83,6 +84,7 @@ def _ensure_tf_install(): # pylint: disable=g-statement-before-imports "experimental", "math", "models", + "volatility", "rates", ] diff --git a/tf_quant_finance/models/__init__.py b/tf_quant_finance/models/__init__.py index bae3cd826..dbb4d67c6 100644 --- a/tf_quant_finance/models/__init__.py +++ b/tf_quant_finance/models/__init__.py @@ -15,6 +15,8 @@ """TensorFlow Quantitative Finance tools to build Diffusion Models.""" from tf_quant_finance.models import euler_sampling +from tf_quant_finance.models import heston +from tf_quant_finance.models import heston_model from tf_quant_finance.models import hull_white from tf_quant_finance.models.generic_ito_process import GenericItoProcess from tf_quant_finance.models.geometric_brownian_motion.multivariate_geometric_brownian_motion import MultivariateGeometricBrownianMotion @@ -27,6 +29,8 @@ _allowed_symbols = [ 'euler_sampling', + 'heston', + 'heston_model', 'HestonModel', 'hull_white', 'GenericItoProcess', diff --git a/tf_quant_finance/models/heston/__init__.py b/tf_quant_finance/models/heston/__init__.py new file mode 100644 index 000000000..222d72aa8 --- /dev/null +++ b/tf_quant_finance/models/heston/__init__.py @@ -0,0 +1,25 @@ +# Lint as: python3 +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""TensorFlow Quantitative Finance tools to build Hull White type models.""" + +from tf_quant_finance.models.heston import approximations + +from tensorflow.python.util.all_util import remove_undocumented # pylint: disable=g-direct-tensorflow-import + +_allowed_symbols = [ + 'approximations', +] + +remove_undocumented(__name__, _allowed_symbols) diff --git a/tf_quant_finance/models/heston/approximations/__init__.py b/tf_quant_finance/models/heston/approximations/__init__.py new file mode 100644 index 000000000..0f174010c --- /dev/null +++ b/tf_quant_finance/models/heston/approximations/__init__.py @@ -0,0 +1,24 @@ +# Lint as: python3 +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Approximations to the Heston European option price.""" + +from tf_quant_finance.models.heston.approximations.heston import eu_option_price +from tensorflow.python.util.all_util import remove_undocumented # pylint: disable=g-direct-tensorflow-import + +_allowed_symbols = [ + 'eu_option_price', +] + +remove_undocumented(__name__, _allowed_symbols) diff --git a/tf_quant_finance/models/heston/approximations/heston.py b/tf_quant_finance/models/heston/approximations/heston.py new file mode 100644 index 000000000..d0acfeba1 --- /dev/null +++ b/tf_quant_finance/models/heston/approximations/heston.py @@ -0,0 +1,326 @@ +# Lint as: python3 +# Copyright 2020 Google LLC +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import tensorflow as tf + +import math +import tf_quant_finance as tff + +"""Method for semi-analytical Heston option price. + +Heston originally published in 1993 his eponymous model. He provided a semi- +analytical formula for pricing European option via Fourrier transform under his +model. However, as noted by Albrecher, the characteric function used in Heston +paper can suffer numerical issues because of the discontinuous nature of the +square root function in the complex plane, and a second version of the +characteric function which doesn't suffer this shortcoming should be used +instead. Attari further refined the numerical method by reducing the number of +numerical integrations (only one Fourrier transform instead of two) and with +an integrand function with better convergence (converging in 1 / u **2 +instead of 1 / u). Attari's numerical method is implemented here. + +## References + +[1] Hansjorg Albrecher, The Little Heston Trap +https://perswww.kuleuven.be/~u0009713/HestonTrap.pdf + +[2] Mukarram Attari, Option Pricing Using Fourier Transforms: A Numerically +Efficient Simplification +https://papers.ssrn.com/sol3/papers.cfm?abstract_id=520042 + +[3] Steven L. Heston, A Closed-Form Solution for Options with Stochastic +Volatility with Applications to Bond and Currency Options +http://faculty.baruch.cuny.edu/lwu/890/Heston93.pdf +""" + +def eu_option_price(*, + strikes=None, + expiries=None, + is_call_options=None, + variances=None, + kappas=None, + thetas=None, + sigmas=None, + rhos=None, + spots=None, + forwards=None, + discount_rates=None, + continuous_dividends=None, + cost_of_carries=None, + discount_factors=None, + dtype=None, + name=None): + """Calculates European option prices under the Heston model. + + This uses the numerical method proposed by Attari in 2004. + Heston model: + dF/F = sqrt(V) * dW_1 + dV = kappa * (theta - V) * dt * sigma * sqrt(V) * dW_2 + = rho *dt + The variance V follows a square root process. + + #### Example + + ```python + prices = eu_option_price( + variances=np.asarray([[0.11]]), + strikes=np.asarray([[102.0]]), + expiries=np.asarray([[1.2]]), + forwards=np.asarray([[100.0]]), + is_call_options=np.asarray([True], dtype=np.bool), + kappas = np.asarray([[2.0]]), + thetas=np.asarray([[0.5]]), + sigmas=np.asarray([[0.15]]), + rhos=np.asarray([[0.3]]), + discount_factors=np.asarray([[1.0]]), + ) + + # Expected print output of prices: + # [[24.82219619]] + ``` + + ## References + + [1] Mukarram Attari, Option Pricing Using Fourier Transforms: A Numerically + Efficient Simplification + https://papers.ssrn.com/sol3/papers.cfm?abstract_id=520042 + + Args: + strikes: A real `Tensor` of any shape and dtype. The strikes of the options + to be priced. + expiries: A real `Tensor` of the same dtype and compatible shape as + `strikes`. The expiry of each option. + is_call_options: A boolean `Tensor` of a shape compatible with + `strikes`. Indicates whether the option is a call (if True) or a put + (if False). If not supplied, call options are assumed. + variances: A real `Tensor` of the same dtype and compatible shape as + `strikes`. The initial value of the variance. + kappas: A real `Tensor` of the same dtype and compatible shape as + `strikes`. The mean reversion strength of the variance square root + process. + thetas: A real `Tensor` of the same dtype and compatible shape as + `strikes`. The mean reversion level of the variance square root process. + sigmas: A real `Tensor` of the same dtype and compatible shape as + `strikes`. The volatility of the variance square root process (volatility + of volatility) + rhos: A real `Tensor` of the same dtype and compatible shape as + `strikes`. The correlation between spot and variance. + spots: A real `Tensor` of any shape that broadcasts to the shape of the + `volatilities`. The current spot price of the underlying. Either this + argument or the `forwards` (but not both) must be supplied. + forwards: A real `Tensor` of any shape that broadcasts to the shape of + `strikes`. The forwards to maturity. Either this argument or the + `spots` must be supplied but both must not be supplied. + discount_rates: An optional real `Tensor` of same dtype as the + `strikes` and of the shape that broadcasts with `strikes`. + If not `None`, discount factors are calculated as e^(-rT), + where r are the discount rates, or risk free rates. At most one of + discount_rates and discount_factors can be supplied. + Default value: `None`, equivalent to r = 0 and discount factors = 1 when + discount_factors also not given. + continuous_dividends: An optional real `Tensor` of same dtype as the + `strikes` and of the shape that broadcasts with `strikes`. + If not `None`, `cost_of_carries` is calculated as r - q, + where r are the `discount_rates` and q is `continuous_dividends`. Either + this or `cost_of_carries` can be given. + Default value: `None`, equivalent to q = 0. + cost_of_carries: An optional real `Tensor` of same dtype as the + `strikes` and of the shape that broadcasts with `strikes`. + Cost of storing a physical commodity, the cost of interest paid when + long, or the opportunity cost, or the cost of paying dividends when short. + If not `None`, and `spots` is supplied, used to calculate forwards from + `spots`: F = e^(bT) * S, where F is the forwards price, b is the cost of + carries, T is expiries and S is the spot price. If `None`, value assumed + to be equal to the `discount_rate` - `continuous_dividends` + Default value: `None`, equivalent to b = r. + discount_factors: An optional real `Tensor` of same dtype as the + `strikes`. If not `None`, these are the discount factors to expiry + (i.e. e^(-rT)). Mutually exclusive with discount_rate and cost_of_carry. + If neither is given, no discounting is applied (i.e. the undiscounted + option price is returned). If `spots` is supplied and `discount_factors` + is not `None` then this is also used to compute the forwards to expiry. + At most one of discount_rates and discount_factors can be supplied. + Default value: `None`, which maps to -log(discount_factors) / expiries + dtype: Optional `tf.DType`. If supplied, the dtype to be used for conversion + of any supplied non-`Tensor` arguments to `Tensor`. + Default value: None which maps to the default dtype inferred by + TensorFlow. + name: str. The name for the ops created by this function. + Default value: None which is mapped to the default name + `heston_price`. + + Returns: + A `Tensor` of the same shape as the input data which is the price of + European options under the Heston model. + + WARNING ABOUT CURRENT NUMERICAL ISSUES WITH CURRENT INTEGRATION METHOD + PLEASE READ: + The integral should be performed from 0 to +infty if we could integrate + with a tensorflow equivalent of scipy.integrate.quad, but this isn't + currently available: + - lower bound has to be strictly positive as integrand not defined in 0 as + current integration method can't deal with that + - upper bound has to be finite as current integration method can't deal + with infinite boundary + """ + if (spots is None) == (forwards is None): + raise ValueError('Either spots or forwards must be supplied but not both.') + if (discount_rates is not None) and (discount_factors is not None): + raise ValueError('At most one of discount_rates and discount_factors may ' + 'be supplied') + if (continuous_dividends is not None) and (cost_of_carries is not None): + raise ValueError('At most one of continuous_dividends and cost_of_carries ' + 'may be supplied') + + + with tf.compat.v1.name_scope(name, default_name='eu_option_price'): + strikes = tf.convert_to_tensor(strikes, dtype=dtype, name='strikes') + dtype = strikes.dtype + expiries = tf.convert_to_tensor(expiries, dtype=dtype, name='expiries') + kappas = tf.convert_to_tensor(kappas, dtype=dtype, name='kappas') + thetas = tf.convert_to_tensor(thetas, dtype=dtype, name='thetas') + sigmas = tf.convert_to_tensor(sigmas, dtype=dtype, name='sigmas') + rhos = tf.convert_to_tensor(rhos, dtype=dtype, name='rhos') + variances = tf.convert_to_tensor(variances, dtype=dtype, name='variances') + forwards = tf.convert_to_tensor(forwards, dtype=dtype, name='forwards') + + if discount_rates is not None: + discount_rates = tf.convert_to_tensor( + discount_rates, dtype=dtype, name='discount_rates') + elif discount_factors is not None: + discount_rates = -tf.math.log(discount_factors) / expiries + else: + discount_rates = tf.convert_to_tensor( + 0.0, dtype=dtype, name='discount_rates') + + if continuous_dividends is None: + continuous_dividends = tf.convert_to_tensor( + 0.0, dtype=dtype, name='continuous_dividends') + + if cost_of_carries is not None: + cost_of_carries = tf.convert_to_tensor( + cost_of_carries, dtype=dtype, name='cost_of_carries') + else: + cost_of_carries = discount_rates - continuous_dividends + + if discount_factors is not None: + discount_factors = tf.convert_to_tensor( + discount_factors, dtype=dtype, name='discount_factors') + else: + discount_factors = tf.exp(-discount_rates * expiries) + + if forwards is not None: + forwards = tf.convert_to_tensor(forwards, dtype=dtype, name='forwards') + else: + spots = tf.convert_to_tensor(spots, dtype=dtype, name='spots') + forwards = spots * tf.exp(cost_of_carries * expiries) + + # Cast as complex for the characteristic function calculation + expiries_real = tf.complex(expiries, tf.zeros_like(expiries)) + kappas_real = tf.complex(kappas, tf.zeros_like(kappas)) + thetas_real = tf.complex(thetas, tf.zeros_like(thetas)) + sigmas_real = tf.complex(sigmas, tf.zeros_like(sigmas)) + rhos_real = tf.complex(rhos, tf.zeros_like(rhos)) + variances_real = tf.complex(variances, tf.zeros_like(variances)) + + def char_fun(u): + """Using 'second formula' for the (first) characteristic function of + log( spot_T / forwards ) + (noted 'phi_2' in 'The Little Heston Trap', (Albrecher)) + """ + u_real = tf.complex(u, tf.zeros_like(u)) + u_imag = tf.complex(tf.zeros_like(u), u) + + s = rhos_real * sigmas_real * u_imag + + d = (s - kappas_real) ** 2 - sigmas_real ** 2 * (-u_imag - u_real ** 2) + d = tf.math.sqrt(d) + g = (kappas_real - s - d) / (kappas_real - s + d) + a = kappas_real * thetas_real + h = g * tf.math.exp(-d * expiries_real) + m = 2 * tf.math.log((1 - h) / (1 - g)) + C = (a / sigmas_real ** 2) * ((kappas_real - s - d) * expiries_real - m) + e = (1 - tf.math.exp(-d * expiries_real)) + D = (kappas_real - s - d) / sigmas_real ** 2 * (e / (1 - h)) + + return tf.math.exp(C + D * variances_real) + + def integrand_function(u, k): + """ Note that with [2], integrand is in 1 / u**2, + which converges faster than Heston 1993 (which is in 1 /u) + """ + char_fun_complex = char_fun(u) + char_fun_real_part = tf.math.real(char_fun_complex) + char_fun_imag_part = tf.math.imag(char_fun_complex) + + a = (char_fun_real_part + char_fun_imag_part / u) * tf.math.cos(u * k) + b = (char_fun_imag_part - char_fun_real_part / u) * tf.math.sin(u * k) + + return (a + b) / (1.0 + u * u) + + k = tf.math.log(strikes / forwards) + + """ + """ + + # TODO: implement a quad integration (like scipy.integrate.quad) in tff + lower_bound = 1e-9 + upper_bound = 100 + + integral = tff.math.integration.integrate( + lambda u: integrand_function(u, k), + lower_bound, + upper_bound, + num_points=10001, + dtype=dtype + ) + + pi = math.pi + undiscounted_call_prices = forwards - strikes * (0.5 + 1 / pi * integral) + + if is_call_options is None: + return undiscounted_call_prices * discount_factors + else: + is_call_options = tf.convert_to_tensor(is_call_options, dtype=tf.bool, + name='is_call_options') + + # Use call-put parity for Put + undiscounted_put_prices = undiscounted_call_prices - forwards + strikes + + undiscount_prices = tf.where( + is_call_options, + undiscounted_call_prices, + undiscounted_put_prices) + + return undiscount_prices * discount_factors + +if __name__ == "__main__": + import os + + os.environ["CUDA_VISIBLE_DEVICES"] = "-1" + + import numpy as np + prices = eu_option_price( + variances=np.asarray([[0.11]]), + strikes=np.asarray([[102.0]]), + expiries=np.asarray([[1.2]]), + forwards=np.asarray([[100.0]]), + is_call_options=np.asarray([True], dtype=np.bool), + kappas=np.asarray([[2.0]]), + thetas=np.asarray([[0.5]]), + sigmas=np.asarray([[0.15]]), + rhos=np.asarray([[0.3]]), + discount_factors=np.asarray([[1.0]]), + ) + print(prices) diff --git a/tf_quant_finance/models/heston/approximations/heston_test.py b/tf_quant_finance/models/heston/approximations/heston_test.py new file mode 100644 index 000000000..9e0e59d3c --- /dev/null +++ b/tf_quant_finance/models/heston/approximations/heston_test.py @@ -0,0 +1,157 @@ +# Lint as: python3 +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for Heston Price method.""" + +import numpy as np +import tensorflow.compat.v2 as tf +from absl.testing import parameterized +from scipy import integrate +from tensorflow.python.framework import test_util # pylint: disable=g-direct-tensorflow-import + +import math +import tf_quant_finance as tff + + +def get_heston_prices(kappa=None, + theta=None, + sigma=None, + rho=None, + v0=None, + forward=None, + expiry=None, + strike=None, + discount_factor=None, + ): + """ Calculates Heston call and put prices using Attari paper. + + ## References + + [1] Mukarram Attari, Option Pricing Using Fourier Transforms: A Numerically + Efficient Simplification + https://papers.ssrn.com/sol3/papers.cfm?abstract_id=520042 + """ + def char_fun(u): + d = (rho * sigma * u * 1j - kappa) ** 2 - sigma ** 2 * (-u * 1j - u ** 2) + d = np.sqrt(d) + s = rho * sigma * u * 1j + g = (kappa - s - d) / (kappa - rho * sigma * u * 1j + d) + a = kappa * theta + h = g * np.exp(-d * expiry) + m = 2 * np.log((1 - h) / (1 - g)) + C = (a / sigma ** 2) * ((kappa - s - d) * expiry - m) + D = (kappa - s - d) / sigma ** 2 * ((1 - np.exp(-d * expiry)) / (1 - h)) + + return np.exp(C + D * v0) + + def integrand_function(u, k): + char_fun_value = char_fun(u) + a = (char_fun_value.real + char_fun_value.imag / u) * np.cos(u * k) + b = (char_fun_value.imag - char_fun_value.real / u) * np.sin(u * k) + + return (a + b) / (1.0 + u * u) + + k = np.log(strike / forward) + + integral = integrate.quad( + lambda u: integrand_function(u, k), + 0, + float("inf") + )[0] + + undiscount_call_price = (forward - strike * (0.5 + 1 / math.pi * integral)) + undiscount_put_price = undiscount_call_price - forward + strike + call_price = undiscount_call_price * discount_factor + put_price = undiscount_put_price * discount_factor + + return call_price, put_price + + +@test_util.run_all_in_graph_and_eager_modes +class HestonPriceTest(parameterized.TestCase, tf.test.TestCase): + """Tests for Heston Price method.""" + + # TODO: Need to test single precision (test wouldn't pass with simpsons + # integration) + @parameterized.named_parameters( + { + 'testcase_name': 'DoublePrecision', + 'dtype': np.float64 + }) + def test_heston_price(self, dtype): + kappas = np.asarray([0.1, 10.0], dtype=dtype) + thetas = np.asarray([0.1, 0.5], dtype=dtype) + v0s = np.asarray([0.1, 0.5], dtype=dtype) + forwards = np.asarray([10.0], dtype=dtype) + sigmas = np.asarray([1.0], dtype=dtype) + strikes = np.asarray([9.7, 10.0, 10.3], dtype=dtype) + expiries = np.asarray([1.0], dtype=dtype) + discount_factors = np.asarray([0.99], dtype=dtype) + + # TODO: test rhos = [-0.5, 0, 0.5] + # (test wouldn't pass with simpsons integration) + rhos = np.asarray([0], dtype=dtype) + + for kappa in kappas: + for theta in thetas: + for sigma in sigmas: + for rho in rhos: + for v0 in v0s: + for forward in forwards: + for expiriy in expiries: + for strike in strikes: + for discount_factor in discount_factors: + tff_prices = self.evaluate( + tff.models.heston.approximations.eu_option_price( + kappas=np.asarray([kappa]), + thetas=np.asarray([theta]), + sigmas=np.asarray([sigma]), + rhos=np.asarray([rho]), + variances=np.asarray([v0]), + forwards=np.asarray([forward]), + expiries=np.asarray([expiriy]), + strikes=np.asarray([strike]), + discount_factors=np.asarray([discount_factor]), + is_call_options=np.asarray([True, False], dtype=np.bool) + )) + + params = { + "kappa": kappa, + "theta": theta, + "sigma": sigma, + "rho": rho, + "v0": v0, + "forward": forward, + "expiry": expiriy, + "strike": strike, + "discount_factor": discount_factor, + } + + target_call_price, target_put_price = get_heston_prices(**params) + + # Normalize error in basis point + call_error = abs(tff_prices[0] - target_call_price) / forward + msg = "Found error = {0}bp".format(call_error * 1e4) + self.assertLess(call_error, 1e-5, msg) + + put_error = abs(tff_prices[1] - target_put_price) / forward + msg = "Found error = {0}bp".format(put_error * 1e4) + self.assertLess(put_error, 1e-5, msg) + + +if __name__ == '__main__': + import os + os.environ["CUDA_VISIBLE_DEVICES"] = "-1" + + tf.test.main() \ No newline at end of file