├── exceptions.py ├── options.py ├── tests ├── test_black_scholes.py ├── test_integration.py └── test_anderson_lake.py ├── LICENSE ├── README.md ├── working-example.py ├── .gitignore ├── models.py ├── integration.py └── pricers.py /exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class NoConvergenceError(Exception): 4 | def __init__(self, message): 5 | super().__init__(message) 6 | -------------------------------------------------------------------------------- /options.py: -------------------------------------------------------------------------------- 1 | '# -*- coding: utf-8 -*-' 2 | import numpy as np 3 | 4 | class EuropeanCallOption: 5 | def __init__(self, tau, strike): 6 | self.tau = tau 7 | self.strike = strike 8 | 9 | def __call__(self, forward): 10 | return np.maximum(forward - self.strike, 0) 11 | 12 | def __str__(self): 13 | out_str = f"tau: {self.tau}\n\r" +\ 14 | f"strike: {self.strike}\n\r" 15 | return out_str 16 | -------------------------------------------------------------------------------- /tests/test_black_scholes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | import numpy as np 4 | 5 | from models import BlackScholesModel 6 | from options import EuropeanCallOption 7 | from pricers import bs_option_price 8 | 9 | class TestBlackScholes(unittest.TestCase): 10 | def test_itm(self): 11 | model = BlackScholesModel(300 * np.exp(0.03 * 1), 0.15, 0.03) 12 | option = EuropeanCallOption(1, 250) 13 | 14 | result = bs_option_price(model, option) 15 | expected = 58.82 16 | 17 | self.assertAlmostEqual(result, expected, 2) 18 | 19 | 20 | if __name__ == '__main__': 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | import integration 4 | import numpy as np 5 | 6 | class TestExpSinhQuadrature(unittest.TestCase): 7 | def test_exp_density(self): 8 | scheme = integration.ExpSinhQuadrature(0.5, 1e-8, 100) 9 | 10 | def exp_dist(x): return np.exp(-x) 11 | self.assertAlmostEqual(scheme(exp_dist), 1.) 12 | 13 | class TestGaussianQuadrature(unittest.TestCase): 14 | def test_exp_density(self): 15 | scheme = integration.GaussianQuadrature(1e-8, 1e-8, 100) 16 | 17 | def exp_dist(x): return np.exp(-x) 18 | self.assertAlmostEqual(scheme(exp_dist), 1.) 19 | 20 | if __name__ == '__main__': 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 HrPedersen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Implementation of the Anderson-Lake pricing scheme for the Heston Stochastic Volatility model in Python 2 | Full Python implementation of the Heston pricing scheme developed by Leif Anderson and Mark Lake in their article Robust High-Precision Option Pricing by Fourier Transforms: Contour Deformations and Double-Exponential Quadrature. 3 | 4 | The file working-example.py contains a working example of how to use the two Anderson-Lake functions. 5 | ## Overview of files in project 6 | 1. working-example.py contains a working example of how to use the two functions **anderson_lake** and **anderson_lake_expsinh**. 7 | 2. pricers.py contains the function **anderson_lake** and the simpler version **anderson_lake_expsinh**, which computes the call option price in the Heston model. 8 | The file also includes a closed-form Black-Scholes formula **bs_call_option** and a Monte Carlo implementation of the Heston model **heston_monte_carlo** capable of calculating prices for any type of simple option. 9 | 3. models.py contains the class **HestonModel** used in the functions anderson_lake and heston_monte_carlo. 10 | 4. options.py contains the class **EuropeanCallOption** used in all pricer functions. 11 | 5. integration.py contains the class **GaussianQuadrature** (suggested) and **ExpSinhQuadrature** used by **anderson_lake** and implicitly in **anderson_lake_expsinh**. The classes computes integrals over the positive real line. 12 | -------------------------------------------------------------------------------- /working-example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # import local modules 4 | from pricers import anderson_lake, anderson_lake_expsinh 5 | from integration import ExpSinhQuadrature, GaussianQuadrature 6 | from models import HestonModel 7 | from options import EuropeanCallOption 8 | 9 | # ============================================================================== 10 | # === Example using the simpler function anderson_lake_expsinh 11 | 12 | # Define the model 13 | # The parameters are in order: 14 | # forward, initial volatility, kappa, theta, sigma, rho, interest rate 15 | model = HestonModel(100, 0.1197**2, 1.98937, 0.108977**2, 0.33147, 0.0258519, 0) 16 | 17 | # Define the call option 18 | # The arguments are in order: 19 | # time to maturity, strike 20 | option = EuropeanCallOption(1, 100) 21 | 22 | # Calculate the price (should return 4.171) 23 | price = anderson_lake_expsinh(model, option) 24 | print(f"Price of option using anderson_lake_expsinh: {price}.") 25 | 26 | # ============================================================================== 27 | # === Example using the less simple function anderson_lake 28 | # Define the quadrature method the function should use to integrate. Here the 29 | # one suggested in the Anderson-Lake article is used. It is implemented in 30 | # integration.py 31 | scheme = ExpSinhQuadrature(0.5, 1e-12, 1000) 32 | 33 | # Define the model and option as in the simple case. 34 | model = HestonModel(100, 0.1197**2, 1.98937, 0.108977**2, 0.33147, 0.0258519, 0) 35 | option = EuropeanCallOption(1, 100) 36 | 37 | # Calculate the price (should return 4.171) 38 | price = anderson_lake(model, option, scheme) 39 | print(f"Price of option using anderson_lake: {price}.") 40 | 41 | # ============================================================================== 42 | # === Example using the less simple function anderson_lake 43 | # Define the quadrature method the function should use to integrate. Here the 44 | # scipy implemented GaussianQuadrature is used. It is implemented in 45 | # integration.py. This is the suggested method! 46 | scheme = GaussianQuadrature(1e-12, 1e-12, 1000) 47 | 48 | # Define the model and option as in the simple case. 49 | model = HestonModel(100, 0.1197**2, 1.98937, 0.108977**2, 0.33147, 0.0258519, 0) 50 | option = EuropeanCallOption(1, 100) 51 | 52 | # Calculate the price (should return 4.171) 53 | price = anderson_lake(model, option, scheme) 54 | print(f"Price of option using anderson_lake: {price}.") 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | litterature/* -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import numpy as np 3 | 4 | NUMPY_COMPLEX128_MAX = np.finfo(np.complex128).max 5 | NUMPY_LOG_COMPLEX128_MAX = np.log(NUMPY_COMPLEX128_MAX) 6 | 7 | class HestonModel: 8 | def __init__(self, forward, vol, kappa, theta, sigma, rho, rate): 9 | self.forward = forward 10 | self.vol = vol 11 | self.kappa = kappa 12 | self.theta = theta 13 | self.sigma = sigma 14 | self.rho = rho 15 | self.rate = rate 16 | 17 | def __str__(self): 18 | out_str = f"forward: {self.forward}\n\r" +\ 19 | f"vol: {self.vol}\n\r" +\ 20 | f"kappa: {self.kappa}\n\r" +\ 21 | f"theta: {self.theta}\n\r" +\ 22 | f"sigma: {self.sigma}\n\r" +\ 23 | f"rho: {self.rho}\n\r" + \ 24 | f"rate: {self.rate}\n\r" 25 | return out_str 26 | 27 | def cf(self, z, tau) -> complex: 28 | beta = self.kappa - 1j * self.sigma * self.rho * z 29 | sigma_sq = self.sigma * self.sigma 30 | 31 | D = np.sqrt(beta * beta + sigma_sq * z * (z + 1j)) 32 | 33 | if beta.real * D.real + beta.imag * D.imag > 0: 34 | r = - sigma_sq * z * (z + 1j) / (beta + D) 35 | else: 36 | r = beta - D 37 | 38 | if D != 0: 39 | y = np.expm1(-D * tau) / (2 * D) 40 | else: 41 | y = -tau / 2 42 | 43 | A = self.kappa * self.theta / sigma_sq * \ 44 | (r * tau - 2 * np.log1p(- r * y)) 45 | 46 | B = z * (z + 1j) * y / (1 - r * y) 47 | 48 | exponent = A + B * self.vol 49 | 50 | if exponent > NUMPY_LOG_COMPLEX128_MAX: 51 | raise OverflowError("too large exponent in characteristic function") 52 | 53 | return np.exp(exponent) 54 | 55 | def log_cf_real(self, alpha, tau) -> float: 56 | # Evaluation of ln HestomModel.cf(-1j * (1 + alpha)) 57 | beta = self.kappa - self.rho * self.sigma * (1 + alpha) 58 | Dsq = beta**2 - self.sigma**2 * (1 + alpha) * alpha 59 | 60 | if Dsq > 0: 61 | D = np.sqrt(Dsq) 62 | coshdt = np.cosh(D * tau / 2) 63 | sinhdt = np.sinh(D * tau / 2) / D 64 | nume = coshdt + beta * sinhdt 65 | 66 | else: 67 | # D = 1j * x 68 | x = np.sqrt(-Dsq) 69 | coshdt = np.cos(x * tau / 2) 70 | sinhdt = np.sin(x * tau / 2) / x 71 | nume = coshdt + beta * sinhdt 72 | 73 | A = self.kappa * self.theta / self.sigma**2 *\ 74 | (beta * tau - np.log(nume**2)) 75 | B = alpha * (1 + alpha) * sinhdt / nume 76 | 77 | return A + B * self.vol 78 | 79 | class BlackScholesModel(): 80 | def __init__(self, forward, vol, rate): 81 | self.forward = forward 82 | self.vol = vol 83 | self.rate = rate 84 | 85 | def cf(self, z, tau): 86 | return np.exp(-0.5 * self.vol * tau * z * (z + 1j)) 87 | -------------------------------------------------------------------------------- /integration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import numpy as np 3 | import scipy 4 | from exceptions import NoConvergenceError 5 | 6 | class ExpSinhQuadrature(): 7 | def __init__(self, init_step_size, error_tol, max_iter): 8 | self.init_step_size = init_step_size 9 | self.error_tol = error_tol 10 | self.max_iter = max_iter 11 | 12 | self.step_size = self.init_step_size 13 | 14 | def __call__(self, func): 15 | new_estimate = 0 16 | old_estimate = 0 17 | self.step_size = self.init_step_size 18 | 19 | for n in range(self.max_iter): 20 | old_estimate = new_estimate; 21 | new_estimate = self.compute_integral(func) 22 | 23 | if abs(old_estimate - new_estimate) < self.error_tol: 24 | break 25 | else: 26 | self.step_size /= 2 27 | else: 28 | raise NoConvergenceError("integral did not converge within max_iter") 29 | 30 | return new_estimate 31 | 32 | def abscissa(self, n): 33 | return np.exp(np.pi / 2. * np.sinh(n * self.step_size)) 34 | 35 | def weight(self, n): 36 | return np.pi / 2. * np.cosh(n * self.step_size) * self.abscissa(n) 37 | 38 | def eval_transformed_func(self, func, n): 39 | return self.weight(n) * func(self.abscissa(n)) 40 | 41 | def compute_integral(self, func): 42 | # Positive n 43 | new_term = 0 44 | threshold = 0 45 | positive_n_sum = 0 46 | 47 | for n in range(1, self.max_iter): 48 | old_term = new_term; 49 | new_term = self.eval_transformed_func(func, n); 50 | 51 | if abs(new_term - old_term) < threshold and n >= 2: 52 | break 53 | else: 54 | positive_n_sum += new_term; 55 | threshold = abs(positive_n_sum * self.error_tol) 56 | else: 57 | raise NoConvergenceError("positive partial sum did not converge") 58 | 59 | # Negative n 60 | new_term = threshold = 0; 61 | negative_n_sum = 0; 62 | for n in range(-1, -self.max_iter, -1): 63 | old_term = new_term 64 | new_term = self.eval_transformed_func(func, n) 65 | 66 | if abs(new_term - old_term) < threshold and n <= -2: 67 | break 68 | else: 69 | negative_n_sum += new_term; 70 | threshold = abs(negative_n_sum * self.error_tol) 71 | else: 72 | raise NoConvergenceError("negative partial sum did not converge") 73 | 74 | # Zero n 75 | zero_n_sum = self.eval_transformed_func(func, 0); 76 | 77 | return self.step_size * (negative_n_sum + zero_n_sum + positive_n_sum) 78 | 79 | 80 | class GaussianQuadrature: 81 | def __init__(self, abs_tol, relative_tol, max_iter): 82 | self.abs_tol = abs_tol 83 | self.relative_tol = relative_tol 84 | self.max_iter = max_iter 85 | 86 | def __call__(self, func): 87 | return scipy.integrate.quad(func, 0, np.inf, epsabs=self.abs_tol, 88 | epsrel=self.relative_tol, 89 | limit=self.max_iter)[0] 90 | -------------------------------------------------------------------------------- /tests/test_anderson_lake.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | import numpy as np 4 | 5 | from pricers import anderson_lake 6 | from integration import ExpSinhQuadrature, GaussianQuadrature 7 | from models import HestonModel 8 | from options import EuropeanCallOption 9 | 10 | class TestAndersonLakeWithExpSinhQuadrature(unittest.TestCase): 11 | # All expected prices found at https://kluge.in-chemnitz.de/tools/pricer/ 12 | 13 | def setUp(self): 14 | self.scheme = ExpSinhQuadrature(0.5, 1e-12, 10000) 15 | 16 | def test_atm(self): 17 | model = HestonModel(100, 0.1197**2, 1.98937, 0.108977**2, 0.33147, \ 18 | 0.0258519, 0) 19 | option = EuropeanCallOption(1, 100) 20 | 21 | result = anderson_lake(model, option, self.scheme) 22 | expected = 4.170956582 23 | 24 | self.assertAlmostEqual(result, expected) 25 | 26 | def test_itm(self): 27 | model = HestonModel(121.17361017736597, 0.1197**2, 1.98937, \ 28 | 0.108977**2, 0.33147, -0.5, np.log(1.0005)) 29 | option = EuropeanCallOption(0.50137, 150) 30 | 31 | result = anderson_lake(model, option, self.scheme) 32 | expected = 0.008644233552 33 | self.assertAlmostEqual(result, expected) 34 | 35 | def test_otm(self): 36 | model = HestonModel(11, 0.2**2, 2.0, 0.2**2, 0.3, -0.8, 0) 37 | option = EuropeanCallOption(2., 10) 38 | result = anderson_lake(model, option, self.scheme) 39 | expected = 1.7475020828 # result from finite difference method 40 | 41 | self.assertAlmostEqual(result, expected, 3) 42 | 43 | 44 | class TestAndersonLakeWithGaussianQuadrature(unittest.TestCase): 45 | # All expected prices found at https://kluge.in-chemnitz.de/tools/pricer/ 46 | 47 | def setUp(self): 48 | self.scheme = GaussianQuadrature(1e-12, 1e-12, 10000) 49 | 50 | def test_atm(self): 51 | model = HestonModel(100, 0.1197**2, 1.98937, 0.108977**2, 0.33147, \ 52 | 0.0258519, 0) 53 | option = EuropeanCallOption(1, 100) 54 | 55 | result = anderson_lake(model, option, self.scheme) 56 | expected = 4.170956582 57 | 58 | self.assertAlmostEqual(result, expected) 59 | 60 | def test_itm(self): 61 | model = HestonModel(121.17361017736597, 0.1197**2, 1.98937, \ 62 | 0.108977**2, 0.33147, -0.5, np.log(1.0005)) 63 | option = EuropeanCallOption(0.50137, 150) 64 | 65 | result = anderson_lake(model, option, self.scheme) 66 | expected = 0.008644233552 67 | self.assertAlmostEqual(result, expected) 68 | 69 | def test_otm(self): 70 | model = HestonModel(11, 0.2**2, 2.0, 0.2**2, 0.3, -0.8, 0) 71 | option = EuropeanCallOption(2., 10) 72 | result = anderson_lake(model, option, self.scheme) 73 | expected = 1.7475020828 # result from finite difference method 74 | 75 | self.assertAlmostEqual(result, expected, 3) 76 | 77 | def test_low_tau_low_vol(self): 78 | model = HestonModel(100, 0.01, 2.0, 0.01, 0.01, -0.95, 0.05) 79 | option = EuropeanCallOption(0.01, 125) 80 | 81 | result = anderson_lake(model, option, self.scheme) 82 | self.assertTrue(result > 0) 83 | self.assertAlmostEqual(result, 0.0, places=100) 84 | 85 | if __name__ == '__main__': 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /pricers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import numpy as np 3 | from torch.quasirandom import SobolEngine 4 | 5 | from scipy.optimize import brentq, fminbound 6 | from scipy.stats import norm 7 | 8 | # Local modules 9 | from integration import ExpSinhQuadrature 10 | from models import HestonModel, BlackScholesModel 11 | from options import EuropeanCallOption 12 | 13 | # ============================================================================== 14 | # === Black and Scholes 15 | def bs_option_price(model: BlackScholesModel, 16 | option: EuropeanCallOption) -> float: 17 | d1 = ( np.log(model.forward / option.strike) + \ 18 | (model.vol**2 / 2) * option.tau ) / (model.vol * np.sqrt(option.tau)) 19 | d2 = d1 - model.vol * np.sqrt(option.tau) 20 | 21 | return np.exp(-model.rate * option.tau) * (model.forward * norm.cdf(d1) - \ 22 | option.strike * norm.cdf(d2)) 23 | 24 | # ============================================================================== 25 | # === Anderson-Lake Heston closed form pricing function 26 | def anderson_lake_expsinh(model: HestonModel, 27 | option: EuropeanCallOption) -> float: 28 | scheme = ExpSinhQuadrature(init_step_size=0.5, error_tol=1e-12, 29 | max_iter=10000) 30 | return anderson_lake(model, option, scheme) 31 | 32 | 33 | def anderson_lake(model: HestonModel, option: EuropeanCallOption, 34 | scheme: ExpSinhQuadrature) -> float: 35 | omega = calc_omega(model, option) 36 | phi = calc_phi(model, option) 37 | alpha = calc_alpha(model, option) 38 | 39 | # Define integrand 40 | tphi = np.tan(phi) 41 | tphip = 1 + 1j * tphi 42 | 43 | def Q(z): return model.cf(z - 1j, option.tau) / (z * (z - 1j)) 44 | 45 | def integrand(x): 46 | dexp = np.exp(-x * tphi * omega + 1j * x * omega) 47 | return (dexp * Q(-1j * alpha + x * tphip) * tphip).real 48 | 49 | I = np.exp(alpha * omega) * scheme(integrand) 50 | R = model.forward * (alpha <= 0) - option.strike * (alpha <= -1) - \ 51 | 0.5 * (model.forward * (alpha == 0) - option.strike * (alpha == -1)) 52 | return np.exp(- model.rate * option.tau) * (R - model.forward / np.pi * I) 53 | 54 | # ============================================================================== 55 | # === Helper functions 56 | def calc_omega(model: HestonModel, option: EuropeanCallOption) -> float: 57 | return np.log(model.forward / option.strike) 58 | 59 | def calc_phi(model: HestonModel, option: EuropeanCallOption) -> float: 60 | omega = calc_omega(model, option) 61 | r = model.rho - model.sigma * omega / \ 62 | ( model.vol + model.kappa * model.theta * option.tau ) 63 | if r * omega < 0: 64 | return np.pi / 12 * np.sign(omega) 65 | else: 66 | return 0 67 | 68 | # ============================================================================== 69 | # === Function for finding optimal alpha 70 | def calc_alpha(model: HestonModel, option: EuropeanCallOption) -> float: 71 | omega = calc_omega(model, option) 72 | 73 | # The interval in which to locate alpha is technically an open interval, 74 | # so a small number is added/substracted to/from the boundaries. 75 | eps = np.sqrt(np.finfo(np.float64).eps) 76 | 77 | alpha_min, alpha_max = alpha_min_max(model, option) 78 | if omega >= 0: 79 | alpha, val = locate_optimal_alpha(model, option, alpha_min, -1 - eps) 80 | elif omega < 0 and model.kappa - model.rho * model.sigma > 0 \ 81 | and abs(alpha_max) > 1e-3: 82 | alpha, val = locate_optimal_alpha(model, option, eps, alpha_max) 83 | else: 84 | alpha, val = locate_optimal_alpha(model, option, eps, alpha_max) 85 | if val > 9: 86 | alpha, val = locate_optimal_alpha(model, option, 87 | alpha_min, -1 - eps) 88 | return alpha 89 | 90 | 91 | def locate_optimal_alpha(model, option, a, b): 92 | omega = np.log(model.forward / option.strike) 93 | obj_func = lambda alpha: model.log_cf_real(alpha, option.tau) -\ 94 | np.log(alpha * (alpha + 1)) + alpha * omega 95 | 96 | alpha, val = fminbound(obj_func, a, b, full_output=True)[0:2] 97 | return alpha.real, val.real 98 | 99 | 100 | def k_plus_minus(x: float, sign: int, model: HestonModel, 101 | option: EuropeanCallOption) -> float: 102 | A = model.sigma - 2 * model.rho * model.kappa 103 | B = (model.sigma - 2 * model.rho * model.kappa)**2 +\ 104 | 4 * (model.kappa**2 + x**2 / option.tau**2) * (1 - model.rho**2) 105 | C = 2 * model.sigma * (1 - model.rho**2) 106 | 107 | return (A + sign * np.sqrt(B)) / C 108 | 109 | 110 | def critical_moments_func(k: float, model: HestonModel, 111 | option: EuropeanCallOption) -> float: 112 | kminus = k_plus_minus(0, -1, model, option) 113 | kplus = k_plus_minus(0, 1, model, option) 114 | 115 | beta = model.kappa - model.rho * model.sigma * k 116 | D = np.sqrt(beta**2 + model.sigma**2 * (-1j * k) * ((-1j * k) + 1j)) 117 | 118 | if k > kplus or k < kminus: 119 | D = abs(D) 120 | return np.cos(D * option.tau / 2) + \ 121 | beta * np.sin(D * option.tau / 2) / D 122 | else: 123 | D = D.real 124 | return np.cosh(D * option.tau / 2) + \ 125 | beta * np.sinh(D * option.tau / 2) / D 126 | 127 | 128 | def alpha_min_max(model: HestonModel, 129 | option: EuropeanCallOption) -> (float, float): 130 | kminus = k_plus_minus(0, -1, model, option) 131 | kplus = k_plus_minus(0, 1, model, option) 132 | 133 | # The interval in which to locate k is technically an open interval, 134 | # so a small number is added/substracted to/from the boundaries. 135 | eps = np.sqrt(np.finfo(np.float64).eps) 136 | 137 | # Find kmin 138 | kmin2pi = k_plus_minus(2 * np.pi, -1, model, option) 139 | kmin = brentq(critical_moments_func, kmin2pi + eps, kminus - eps, 140 | args=(model, option)) 141 | 142 | # Find kmax 143 | kps = model.kappa - model.rho * model.sigma 144 | if kps > 0: 145 | a, b = kplus, k_plus_minus(2 * np.pi, 1, model, option) 146 | elif kps < 0: 147 | T = -2 / (model.kappa - model.rho * model.sigma * kplus) 148 | if option.tau < T: 149 | a, b = kplus, k_plus_minus(np.pi, 1, model, option) 150 | else: 151 | a, b = 1, kplus 152 | else: 153 | a, b = kplus, k_plus_minus(np.pi, 1, model, option) 154 | kmax = brentq(critical_moments_func, a + eps, b - eps, 155 | args=(model, option)) 156 | 157 | return kmin - 1, kmax - 1 158 | 159 | # ============================================================================== 160 | # === Heston Monte Carlo 161 | def heston_monte_carlo(model: HestonModel, option, num_steps, num_paths): 162 | # Generate brownian motion increments from a sobol sequence 163 | num_stochastic_processes = 2 164 | sobol = SobolEngine(num_stochastic_processes * num_steps) 165 | samples = norm.ppf(sobol.draw(num_paths)) 166 | increment1, increment2 = np.hsplit(samples, num_stochastic_processes) 167 | 168 | kappa, theta, sigma, rho = model.kappa, model.theta, model.sigma, model.rho 169 | 170 | dt = 1 / num_steps 171 | 172 | ones = np.ones(num_paths).reshape(-1, 1) 173 | trunc_vol = ones * model.vol 174 | true_vol = trunc_vol 175 | moved_forward = ones * model.forward 176 | 177 | dw1 = np.array(np.sqrt(dt) * increment1) 178 | dw2 = np.array(rho * np.sqrt(dt) * increment1 + \ 179 | np.sqrt(1.0 - rho**2) * np.sqrt(dt) * increment2) 180 | 181 | for num_step in range(int(num_steps * option.tau)): 182 | moved_forward *= np.exp(np.sqrt(true_vol) * dw1[:, num_step, None]) 183 | 184 | positive_part_trunc_vol = np.maximum(trunc_vol, 0) 185 | trunc_vol += kappa * (theta - positive_part_trunc_vol) * dt + \ 186 | sigma * np.sqrt(positive_part_trunc_vol) * dw2[:, num_step, None] 187 | 188 | true_vol = np.maximum(trunc_vol, 0) 189 | 190 | return np.exp(- model.rate * option.tau) * np.mean(option(moved_forward)) 191 | --------------------------------------------------------------------------------