├── mcdxa ├── pricers │ ├── __init__.py │ ├── european.py │ └── american.py ├── __init__.py ├── utils.py ├── exceptions.py ├── analytics.py ├── monte_carlo.py ├── bsm.py ├── merton.py ├── bates.py ├── heston.py ├── payoffs.py └── models.py ├── tests ├── conftest.py ├── test_monte_carlo.py ├── test_models.py ├── test_custom_payoff.py ├── test_payoffs.py ├── test_pricers_european.py ├── test_bates.py └── test_pricers_american.py ├── setup.py ├── .gitignore ├── README.md └── scripts ├── exotics.py ├── benchmarks ├── benchmark_bsm.py ├── benchmark_american.py ├── benchmark_heston.py ├── benchmark_mjd.py └── benchmark_bates.py ├── orchestrate.py ├── mcdxa.ipynb └── benchmark.py /mcdxa/pricers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | High-level pricers for option valuation. 3 | """ 4 | -------------------------------------------------------------------------------- /mcdxa/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Option Pricing package initialization. 3 | """ 4 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /mcdxa/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def discount_factor(r: float, T: float) -> float: 5 | """Compute discount factor exp(-r*T).""" 6 | return np.exp(-r * T) -------------------------------------------------------------------------------- /mcdxa/exceptions.py: -------------------------------------------------------------------------------- 1 | class OptionPricingError(Exception): 2 | """Base exception for option pricing errors.""" 3 | pass 4 | 5 | class ModelError(OptionPricingError): 6 | """Error in model configuration or simulation.""" 7 | pass 8 | 9 | class PayoffError(OptionPricingError): 10 | """Error in payoff definition or evaluation.""" 11 | pass -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | 5 | # allow tests to import the top-level package 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 7 | 8 | @pytest.fixture(autouse=True) 9 | def fixed_seed(monkeypatch): 10 | """Fix the RNG seed for reproducible Monte Carlo tests.""" 11 | import numpy as np 12 | rng = np.random.default_rng(12345) 13 | monkeypatch.setattr(np.random, 'default_rng', lambda *args, **kwargs: rng) 14 | yield 15 | -------------------------------------------------------------------------------- /tests/test_monte_carlo.py: -------------------------------------------------------------------------------- 1 | 2 | import numpy as np 3 | import pytest 4 | 5 | from mcdxa.monte_carlo import price_mc 6 | from mcdxa.models import BSM 7 | from mcdxa.payoffs import CallPayoff 8 | 9 | 10 | def test_price_mc_zero_volatility(): 11 | model = BSM(r=0.05, sigma=0.0, q=0.0) 12 | payoff = CallPayoff(strike=100.0) 13 | # With zero volatility, the payoff is deterministic and matches BSM analytic 14 | price, stderr = price_mc(payoff, model, S0=100.0, T=1.0, r=0.05, n_paths=50, n_steps=1) 15 | from mcdxa.analytics import bsm_price 16 | expected = bsm_price(100.0, 100.0, 1.0, 0.05, 0.0, option_type='call') 17 | assert stderr == pytest.approx(0.0, abs=1e-12) 18 | assert price == pytest.approx(expected, rel=1e-6) 19 | 20 | 21 | def test_price_mc_in_the_money(): 22 | model = BSM(r=0.0, sigma=0.0, q=0.0) 23 | payoff = CallPayoff(strike=50.0) 24 | price, stderr = price_mc(payoff, model, S0=100.0, T=1.0, r=0.0, n_paths=10, n_steps=1) 25 | # deterministic S=100, payoff=50, no discount 26 | assert stderr == 0.0 27 | assert price == pytest.approx(50.0) 28 | -------------------------------------------------------------------------------- /mcdxa/analytics.py: -------------------------------------------------------------------------------- 1 | # core pricing functions 2 | from .bsm import norm_cdf, bsm_price 3 | from .merton import merton_price 4 | from .heston import heston_price 5 | from .bates import simulate_bates, bates_price 6 | 7 | # Test script with provided parameters 8 | if __name__ == "__main__": 9 | # Model parameters 10 | S0 = 100.0 # Initial stock price 11 | K = 100.0 # Strike price 12 | T = 1.0 # Time to maturity (1 year) 13 | r = 0.05 # Risk-free rate 14 | q = 0.0 # Dividend yield 15 | kappa = 2.0 # Mean reversion rate 16 | theta = 0.04 # Long-term variance 17 | xi = 0.2 # Volatility of variance 18 | rho = -0.7 # Correlation 19 | v0 = 0.02 # Initial variance 20 | 21 | # Calculate the call option price with a finite integration limit 22 | for K in [50, 75, 100, 125, 150]: 23 | call_price = heston_price( 24 | S0, K, T, r, 25 | kappa, theta, xi, rho, v0, 26 | q=q, 27 | integration_limit=50 28 | ) 29 | print(f"European Call Option Price (Heston Model): {call_price:.4f}") 30 | -------------------------------------------------------------------------------- /mcdxa/monte_carlo.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def price_mc(payoff, model, S0: float, T: float, r: float, 5 | n_paths: int, n_steps: int = 1, 6 | rng: np.random.Generator = None) -> tuple: 7 | """ 8 | Generic Monte Carlo pricer. 9 | 10 | Args: 11 | payoff (callable): Payoff function on terminal prices. 12 | model: Model instance with a simulate method. 13 | S0 (float): Initial asset price. 14 | T (float): Time to maturity. 15 | r (float): Risk-free rate. 16 | n_paths (int): Number of Monte Carlo paths. 17 | n_steps (int): Number of time steps per path. 18 | rng (np.random.Generator, optional): Random generator. 19 | 20 | Returns: 21 | price (float): Discounted Monte Carlo price. 22 | stderr (float): Standard error of the estimate. 23 | """ 24 | paths = model.simulate(S0, T, n_paths, n_steps, rng=rng) 25 | ST = paths[:, -1] 26 | payoffs = payoff(ST) 27 | discounted = np.exp(-r * T) * payoffs 28 | price = discounted.mean() 29 | stderr = discounted.std(ddof=1) / np.sqrt(n_paths) 30 | return price, stderr -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import math 3 | import pytest 4 | 5 | from mcdxa.models import BSM, Heston, Merton 6 | 7 | 8 | def test_bsm_deterministic_growth(): 9 | model = BSM(r=0.05, sigma=0.0, q=0.0) 10 | paths = model.simulate(S0=100.0, T=1.0, n_paths=3, n_steps=4) 11 | # With zero volatility and zero dividend, S = S0 * exp(r * t) 12 | t_grid = np.linspace(0, 1.0, 5) 13 | expected = 100.0 * np.exp(0.05 * t_grid) 14 | for p in paths: 15 | assert np.allclose(p, expected) 16 | 17 | 18 | def test_heston_nonnegative_and_shape(): 19 | model = Heston(r=0.03, kappa=1.0, theta=0.04, xi=0.2, rho=0.0, v0=0.04, q=0.0) 20 | paths = model.simulate(S0=100.0, T=1.0, n_paths=10, n_steps=5) 21 | assert paths.shape == (10, 6) 22 | assert np.all(paths >= 0) 23 | 24 | 25 | def test_mjd_jump_effect(): 26 | # With high jump intensity and zero diffusion, expect jumps 27 | model = Merton(r=0.0, sigma=0.0, lam=10.0, mu_j=0.0, sigma_j=0.0, q=0.0) 28 | paths = model.simulate(S0=1.0, T=1.0, n_paths=1000, n_steps=1) 29 | # With zero jump size variance and mu_j=0, jumps yield Y=1, so S should equal S0 30 | assert paths.shape == (1000, 2) 31 | assert np.allclose(paths[:, -1], 1.0) 32 | -------------------------------------------------------------------------------- /tests/test_custom_payoff.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from mcdxa.payoffs import CustomPayoff 5 | 6 | 7 | def test_custom_payoff_requires_callable(): 8 | with pytest.raises(TypeError): 9 | CustomPayoff(123) 10 | 11 | 12 | @pytest.mark.parametrize("values, K, func, expected", [ 13 | # call-style: max(sqrt(S) - K, 0) 14 | (np.array([0, 1, 4, 9, 16]), 3, 15 | lambda s: np.maximum(np.sqrt(s) - 3, 0), 16 | np.maximum(np.sqrt(np.array([0, 1, 4, 9, 16])) - 3, 0)), 17 | # put-style: max(K - sqrt(S), 0) 18 | (np.array([0, 1, 4, 9, 16]), 3, 19 | lambda s: np.maximum(3 - np.sqrt(s), 0), 20 | np.maximum(3 - np.sqrt(np.array([0, 1, 4, 9, 16])), 0)), 21 | ]) 22 | def test_custom_payoff_terminal(values, K, func, expected): 23 | payoff = CustomPayoff(func) 24 | result = payoff(values) 25 | assert np.allclose(result, expected) 26 | 27 | 28 | def test_custom_payoff_on_paths(): 29 | # values as paths: terminal prices in last column 30 | paths = np.array([[1, 4, 9], [16, 25, 36]]) 31 | K = 5 32 | payoff = CustomPayoff(lambda s: np.maximum(np.sqrt(s) - K, 0)) 33 | # terminal prices are 9 and 36 34 | expected = np.maximum(np.sqrt(np.array([9, 36])) - 5, 0) 35 | result = payoff(paths) 36 | assert np.allclose(result, expected) 37 | -------------------------------------------------------------------------------- /mcdxa/pricers/european.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ..monte_carlo import price_mc 4 | 5 | 6 | class EuropeanPricer: 7 | """ 8 | Monte Carlo pricer for European options. 9 | 10 | Attributes: 11 | model: Asset price model with simulate method. 12 | payoff: Payoff callable. 13 | n_paths (int): Number of simulation paths. 14 | n_steps (int): Number of time steps per path. 15 | rng: numpy random generator. 16 | """ 17 | def __init__(self, model, payoff, n_paths: int = 100_000, 18 | n_steps: int = 1, seed: int = None): 19 | self.model = model 20 | self.payoff = payoff 21 | self.n_paths = n_paths 22 | self.n_steps = n_steps 23 | self.rng = None if seed is None else np.random.default_rng(seed) 24 | 25 | def price(self, S0: float, T: float, r: float) -> tuple: 26 | """ 27 | Price the option via Monte Carlo simulation. 28 | 29 | Args: 30 | S0 (float): Initial asset price. 31 | T (float): Time to maturity. 32 | r (float): Risk-free rate. 33 | 34 | Returns: 35 | tuple: (price, stderr) 36 | """ 37 | return price_mc( 38 | self.payoff, self.model, S0, T, r, 39 | self.n_paths, self.n_steps, rng=self.rng 40 | ) 41 | -------------------------------------------------------------------------------- /tests/test_payoffs.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from mcdxa.payoffs import ( 5 | CallPayoff, PutPayoff, 6 | AsianCallPayoff, AsianPutPayoff, 7 | LookbackCallPayoff, LookbackPutPayoff, 8 | ) 9 | 10 | 11 | @pytest.mark.parametrize("payoff_cls, spot, strike, expected", [ 12 | (CallPayoff, np.array([50, 100, 150]), 100, np.array([0, 0, 50])), 13 | (PutPayoff, np.array([50, 100, 150]), 100, np.array([50, 0, 0])), 14 | ]) 15 | def test_vanilla_payoff(payoff_cls, spot, strike, expected): 16 | payoff = payoff_cls(strike) 17 | result = payoff(spot) 18 | assert np.allclose(result, expected) 19 | 20 | 21 | @pytest.mark.parametrize("payoff_cls, path, expected", [ 22 | (AsianCallPayoff, np.array([[1, 3, 5], [2, 2, 2]]), np.array([max((1+3+5)/3 - 3, 0), max((2+2+2)/3 - 3, 0)])), 23 | (AsianPutPayoff, np.array([[1, 3, 5], [2, 2, 2]]), np.array([max(3 - (1+3+5)/3, 0), max(3 - (2+2+2)/3, 0)])), 24 | (LookbackCallPayoff, np.array([[1, 4], [3, 2]]), np.array([max(4-2, 0), max(3-2, 0)])), 25 | (LookbackPutPayoff, np.array([[1, 4], [3, 2]]), np.array([max(2-1, 0), max(2-3, 0)])), 26 | ]) 27 | def test_path_payoff(payoff_cls, path, expected): 28 | strike = 3 if 'Asian' in payoff_cls.__name__ else 2 29 | payoff = payoff_cls(strike) 30 | result = payoff(path) 31 | assert np.allclose(result, expected) 32 | -------------------------------------------------------------------------------- /mcdxa/bsm.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | def norm_cdf(x: float) -> float: 4 | """Standard normal cumulative distribution function.""" 5 | return 0.5 * (1.0 + math.erf(x / math.sqrt(2))) 6 | 7 | def bsm_price( 8 | S0: float, 9 | K: float, 10 | T: float, 11 | r: float, 12 | sigma: float, 13 | q: float = 0.0, 14 | option_type: str = "call", 15 | ) -> float: 16 | """ 17 | Black-Scholes-Merton (BSM) price for European call or put option. 18 | 19 | Parameters: 20 | - S0: Spot price 21 | - K: Strike price 22 | - T: Time to maturity (in years) 23 | - r: Risk-free interest rate 24 | - sigma: Volatility of the underlying asset 25 | - q: Dividend yield 26 | - option_type: 'call' or 'put' 27 | 28 | Returns: 29 | - price: Option price (call or put) 30 | """ 31 | # handle zero volatility (degenerate case) 32 | if sigma <= 0 or T <= 0: 33 | # forward intrinsic value, floored at zero 34 | forward = S0 * math.exp(-q * T) - K * math.exp(-r * T) 35 | if option_type == "call": 36 | return max(forward, 0.0) 37 | else: 38 | return max(-forward, 0.0) 39 | 40 | d1 = (math.log(S0 / K) + (r - q + 0.5 * sigma ** 2) * T) / \ 41 | (sigma * math.sqrt(T)) 42 | d2 = d1 - sigma * math.sqrt(T) 43 | if option_type == "call": 44 | return S0 * math.exp(-q * T) * norm_cdf(d1) - K * math.exp(-r * T) * norm_cdf(d2) 45 | elif option_type == "put": 46 | return K * math.exp(-r * T) * norm_cdf(-d2) - S0 * math.exp(-q * T) * norm_cdf(-d1) 47 | else: 48 | raise ValueError("option_type must be 'call' or 'put'") 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import os 6 | from setuptools import setup, find_packages 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | # Get version from mcdxa/__init__.py 11 | about = {} 12 | with io.open(os.path.join(here, 'mcdxa', '__init__.py'), 'r', encoding='utf-8') as f: 13 | exec(f.read(), about) 14 | 15 | # Read the long description from README.md 16 | with io.open(os.path.join(here, 'README.md'), 'r', encoding='utf-8') as f: 17 | long_description = f.read() 18 | 19 | setup( 20 | name='mcdxa', 21 | version=about.get('__version__', '0.0.0'), 22 | author='The Python Quants GmbH', 23 | author_email='', 24 | description='Python package for pricing European and American options via Monte Carlo simulation and analytic models', 25 | long_description=long_description, 26 | long_description_content_type='text/markdown', 27 | url='https://github.com/yhilpisch/mcdxa', 28 | packages=find_packages(exclude=['tests', 'scripts']), 29 | classifiers=[ 30 | 'Development Status :: 4 - Beta', 31 | 'Intended Audience :: Financial and Insurance Industry', 32 | 'Intended Audience :: Science/Research', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Programming Language :: Python :: 3.8', 36 | 'Programming Language :: Python :: 3.9', 37 | 'Programming Language :: Python :: 3.10', 38 | 'Topic :: Office/Business :: Financial :: Investment', 39 | ], 40 | python_requires='>=3.7', 41 | install_requires=[ 42 | 'numpy', 43 | 'scipy', 44 | ], 45 | include_package_data=True, 46 | zip_safe=False, 47 | ) 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifics 2 | *.swp 3 | *.cfg 4 | _build/ 5 | .DS_Store 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | -------------------------------------------------------------------------------- /tests/test_pricers_european.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | from mcdxa.models import BSM 4 | from mcdxa.payoffs import CallPayoff, PutPayoff, CustomPayoff 5 | from mcdxa.pricers.european import EuropeanPricer 6 | from mcdxa.analytics import bsm_price 7 | 8 | 9 | @pytest.mark.parametrize("opt_type", ["call", "put"]) 10 | def test_european_pricer_matches_bsm(opt_type): 11 | S0, K, T, r, sigma = 100.0, 100.0, 1.0, 0.05, 0.0 12 | model = BSM(r, sigma) 13 | payoff = CallPayoff(K) if opt_type == "call" else PutPayoff(K) 14 | pricer = EuropeanPricer(model, payoff, n_paths=20, n_steps=1, seed=42) 15 | price_mc, stderr = pricer.price(S0, T, r) 16 | price_bs = bsm_price(S0, K, T, r, sigma, option_type=opt_type) 17 | # Zero volatility: MC should produce deterministic result equal to analytic 18 | assert stderr == pytest.approx(0.0) 19 | assert price_mc == pytest.approx(price_bs) 20 | 21 | 22 | @pytest.mark.parametrize("opt_type,S0,K", [ 23 | ("call", 80.0, 100.0), 24 | ("put", 80.0, 100.0), 25 | ("call", 100.0, 80.0), 26 | ("put", 100.0, 80.0), 27 | ]) 28 | def test_european_pricer_custom_plain_vanilla(opt_type, S0, K): 29 | """ 30 | Test that CustomPayoff can express vanilla call/put and matches analytic BSM for zero volatility. 31 | """ 32 | T, r, sigma = 1.0, 0.05, 0.0 33 | model = BSM(r, sigma) 34 | # define equivalent vanilla payoff via CustomPayoff 35 | if opt_type == "call": 36 | func = lambda s: np.maximum(s - K, 0) 37 | else: 38 | func = lambda s: np.maximum(K - s, 0) 39 | payoff = CustomPayoff(func) 40 | pricer = EuropeanPricer(model, payoff, n_paths=20, n_steps=1, seed=42) 41 | price_mc, stderr = pricer.price(S0, T, r) 42 | price_bs = bsm_price(S0, K, T, r, sigma, option_type=opt_type) 43 | assert stderr == pytest.approx(0.0) 44 | assert price_mc == pytest.approx(price_bs) 45 | -------------------------------------------------------------------------------- /tests/test_bates.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from mcdxa.models import Bates 7 | from mcdxa.bates import simulate_bates, bates_price 8 | from mcdxa.analytics import heston_price 9 | 10 | 11 | def test_simulate_bates_shape_and_values(): 12 | # basic shape and initial value 13 | rng = np.random.default_rng(123) 14 | paths = simulate_bates( 15 | S0=100.0, T=1.0, r=0.05, 16 | kappa=2.0, theta=0.04, xi=0.2, rho=0.0, v0=0.02, 17 | lam=0.3, mu_j=-0.1, sigma_j=0.2, q=0.0, 18 | n_paths=5, n_steps=3, rng=rng 19 | ) 20 | # Expect shape (n_paths, n_steps+1) 21 | assert paths.shape == (5, 4) 22 | # initial column should equal S0 23 | assert np.allclose(paths[:, 0], 100.0) 24 | 25 | 26 | @pytest.mark.parametrize("opt_type", ["call", "put"]) 27 | def test_bates_price_zero_jumps_equals_heston(opt_type): 28 | # When lam=0, Bates reduces to Heston model 29 | S0, K, T, r = 100.0, 100.0, 1.0, 0.05 30 | kappa, theta, xi, rho, v0 = 2.0, 0.04, 0.2, -0.5, 0.03 31 | # zero jumps 32 | p_bates = bates_price( 33 | S0, K, T, r, 34 | kappa, theta, xi, rho, v0, 35 | lam=0.0, mu_j=-0.1, sigma_j=0.2, 36 | q=0.0, option_type=opt_type 37 | ) 38 | p_heston = heston_price( 39 | S0, K, T, r, 40 | kappa, theta, xi, rho, v0, 41 | q=0.0, option_type=opt_type 42 | ) 43 | assert p_bates == pytest.approx(p_heston, rel=1e-7) 44 | 45 | 46 | def test_bates_put_call_parity(): 47 | # Test put-call parity for Bates 48 | S0, K, T, r = 100.0, 100.0, 1.0, 0.03 49 | params = dict( 50 | kappa=1.5, theta=0.05, xi=0.3, rho=0.0, v0=0.04, 51 | lam=0.2, mu_j=-0.05, sigma_j=0.15, q=0.0, 52 | ) 53 | call = bates_price(S0, K, T, r, **params, option_type="call") 54 | put = bates_price(S0, K, T, r, **params, option_type="put") 55 | # Parity: call - put = S0*exp(-qT) - K*exp(-rT) 56 | expected = S0 * math.exp(-params['q'] * T) - K * math.exp(-r * T) 57 | assert (call - put) == pytest.approx(expected, rel=1e-7) 58 | -------------------------------------------------------------------------------- /mcdxa/merton.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.integrate import quad 3 | import math 4 | 5 | def merton_price( 6 | S0: float, 7 | K: float, 8 | T: float, 9 | r: float, 10 | sigma: float, 11 | lam: float = 0.0, 12 | mu_j: float = 0.0, 13 | sigma_j: float = 0.0, 14 | q: float = 0.0, 15 | option_type: str = "call", 16 | integration_limit: float = 250, 17 | ) -> float: 18 | """ 19 | European option price under the Merton (1976) jump-diffusion model via Lewis (2001) 20 | single-integral formula. 21 | 22 | Parameters: 23 | - S0: Initial stock price 24 | - K: Strike price 25 | - T: Time to maturity (in years) 26 | - r: Risk-free interest rate 27 | - sigma: Volatility of the diffusion component 28 | - lam: Jump intensity (lambda) 29 | - mu_j: Mean of log jump size 30 | - sigma_j: Standard deviation of log jump size 31 | - q: Dividend yield 32 | - option_type: 'call' or 'put' 33 | - integration_limit: Upper bound for numerical integration 34 | 35 | Returns: 36 | - price: Price of the European option (call or put) 37 | """ 38 | def _char(u): 39 | # Jump-diffusion characteristic function of log-returns under risk-neutral measure 40 | kappa_j = math.exp(mu_j + 0.5 * sigma_j ** 2) - 1 41 | drift = r - q - lam * kappa_j - 0.5 * sigma ** 2 42 | return np.exp( 43 | (1j * u * drift - 0.5 * u ** 2 * sigma ** 2) * T 44 | + lam * T * (np.exp(1j * u * mu_j - 0.5 * 45 | u ** 2 * sigma_j ** 2) - 1) 46 | ) 47 | 48 | def _lewis_integrand(u): 49 | # Lewis (2001) integrand for call under jump-diffusion 50 | cf_val = _char(u - 0.5j) 51 | return 1.0 / (u ** 2 + 0.25) * (np.exp(1j * u * math.log(S0 / K)) * cf_val).real 52 | 53 | integral_value = quad(_lewis_integrand, 0, integration_limit)[0] 54 | call_price = S0 * np.exp(-q * T) - np.exp(-r * T) * \ 55 | np.sqrt(S0 * K) / np.pi * integral_value 56 | 57 | if option_type == "call": 58 | price = call_price 59 | elif option_type == "put": 60 | price = call_price - S0 * math.exp(-q * T) + K * math.exp(-r * T) 61 | else: 62 | raise ValueError("Option type must be 'call' or 'put'.") 63 | 64 | return max(price, 0.0) 65 | -------------------------------------------------------------------------------- /tests/test_pricers_american.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | 4 | from mcdxa.models import BSM 5 | from mcdxa.payoffs import CallPayoff, PutPayoff 6 | from mcdxa.pricers.american import AmericanBinomialPricer, LongstaffSchwartzPricer 7 | from mcdxa.payoffs import CustomPayoff 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def fix_seed(monkeypatch): 12 | import numpy as np 13 | rng = np.random.default_rng(12345) 14 | monkeypatch.setattr(np.random, 'default_rng', lambda *args, **kwargs: rng) 15 | 16 | 17 | def test_crr_binomial_call_no_dividend_intrinsic(): 18 | model = BSM(r=0.05, sigma=0.0, q=0.0) 19 | K, S0, T = 100.0, 110.0, 1.0 20 | payoff = CallPayoff(K) 21 | pricer = AmericanBinomialPricer(model, payoff, n_steps=10) 22 | price = pricer.price(S0, T, r=0.05) 23 | assert price == pytest.approx(S0 - K) 24 | 25 | 26 | def test_crr_binomial_custom_call_intrinsic(): 27 | """Ensure CustomPayoff works with AmericanBinomialPricer for intrinsic call payoff.""" 28 | model = BSM(r=0.05, sigma=0.0, q=0.0) 29 | K, S0, T = 100.0, 110.0, 1.0 30 | payoff = CustomPayoff(lambda s: np.maximum(s - K, 0)) 31 | pricer = AmericanBinomialPricer(model, payoff, n_steps=10) 32 | price = pricer.price(S0, T, r=0.05) 33 | assert price == pytest.approx(S0 - K) 34 | 35 | 36 | def test_lsm_put_no_volatility_exercise(): 37 | model = BSM(r=0.0, sigma=0.0, q=0.0) 38 | K, S0, T = 100.0, 90.0, 1.0 39 | payoff = PutPayoff(K) 40 | pricer = LongstaffSchwartzPricer( 41 | model, payoff, n_paths=50, n_steps=5, seed=42) 42 | price, stderr = pricer.price(S0, T, r=0.0) 43 | # Zero vol: always exercise immediately, price equals intrinsic 44 | assert stderr == pytest.approx(0.0) 45 | assert price == pytest.approx(K - S0) 46 | 47 | 48 | def test_lsm_custom_put_no_volatility_exercise(): 49 | """Ensure CustomPayoff works with LongstaffSchwartzPricer for intrinsic put payoff.""" 50 | model = BSM(r=0.0, sigma=0.0, q=0.0) 51 | K, S0, T = 100.0, 90.0, 1.0 52 | payoff = CustomPayoff(lambda s: np.maximum(K - s, 0)) 53 | pricer = LongstaffSchwartzPricer( 54 | model, payoff, n_paths=50, n_steps=5, seed=42 55 | ) 56 | price, stderr = pricer.price(S0, T, r=0.0) 57 | # Zero vol: always exercise immediately, price equals intrinsic 58 | assert stderr == pytest.approx(0.0) 59 | assert price == pytest.approx(K - S0) 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcdxa 2 | 3 | ![The Python Quants GmbH Logo](https://hilpisch.com/tpq_logo.png) 4 | 5 | mcdxa is a Python package for pricing European and American options with arbitrary payoffs via Monte Carlo simulation and analytic models. It provides a modular framework that cleanly separates stochastic asset price models, payoff definitions (including plain‑vanilla, path‑dependent, and custom functions), Monte Carlo engines, and pricer classes for European and American-style options. 6 | 7 | ## Features 8 | 9 | - **Stochastic models**: Black–Scholes–Merton (GBM), Merton jump‑diffusion (Merton), Heston stochastic volatility, and Bates (Heston + Merton jumps). 10 | - **Payoffs**: vanilla calls/puts, arithmetic Asian, lookback, and fully custom payoff functions via `CustomPayoff`. 11 | - **Monte Carlo engine**: generic path generator and pricing framework with standard error estimation. 12 | - **European pricer**: Monte Carlo wrapper with direct comparison to Black–Scholes analytic formulas. 13 | - **American pricers**: Cox‑Ross‑Rubinstein binomial tree and Longstaff‑Schwartz least-squares Monte Carlo. 14 | - **Analytics**: built‑in functions for Black–Scholes and Merton jump‑diffusion analytic pricing. 15 | 16 | ## Installation 17 | 18 | Install directly from GitHub: 19 | 20 | ```bash 21 | pip install git+https://github.com/yhilpisch/mcdxa.git 22 | ``` 23 | 24 | Or clone the repository and install in editable mode: 25 | 26 | ```bash 27 | git clone https://github.com/yhilpisch/mcdxa.git 28 | cd mcdxa 29 | pip install -e . 30 | ``` 31 | 32 | ## Quickstart 33 | 34 | ```python 35 | import numpy as np 36 | from mcdxa.models import BSM 37 | from mcdxa.payoffs import CallPayoff 38 | from mcdxa.pricers.european import EuropeanPricer 39 | from mcdxa.analytics import bsm_price 40 | 41 | # Parameters 42 | S0, K, T, r, sigma = 100.0, 100.0, 1.0, 0.05, 0.2 43 | 44 | # Define model and payoff 45 | model = BSM(r, sigma) 46 | payoff = CallPayoff(K) 47 | 48 | # Monte Carlo pricing 49 | pricer = EuropeanPricer(model, payoff, n_paths=50_000, n_steps=50, seed=42) 50 | price_mc, stderr = pricer.price(S0, T, r) 51 | 52 | # Analytic Black–Scholes price for comparison 53 | price_bs = bsm_price(S0, K, T, r, sigma, option_type='call') 54 | 55 | print(f"MC Price: {price_mc:.4f} ± {stderr:.4f}") 56 | print(f"BS Price: {price_bs:.4f}") 57 | ``` 58 | 59 | ## Documentation and Examples 60 | 61 | Explore the [Jupyter notebook tutorial](scripts/mcdxa.ipynb) for detailed examples on custom payoffs, path simulations, convergence plots, and American option pricing. 62 | 63 | ## Testing 64 | 65 | Run the full test suite with pytest: 66 | 67 | ```bash 68 | pytest -q 69 | ``` 70 | 71 | ## Company 72 | 73 | **The Python Quants GmbH** 74 | 75 | © 2025 The Python Quants GmbH. All rights reserved. 76 | -------------------------------------------------------------------------------- /scripts/exotics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Benchmark path-dependent European payoffs (Asian, Lookback) under BSM Monte Carlo. 4 | Shows ATM/ITM/OTM results for calls and puts. 5 | """ 6 | 7 | import os 8 | import sys 9 | import time 10 | import argparse 11 | 12 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 13 | import numpy as np 14 | 15 | from mcdxa.models import BSM 16 | from mcdxa.payoffs import ( 17 | AsianCallPayoff, AsianPutPayoff, 18 | LookbackCallPayoff, LookbackPutPayoff 19 | ) 20 | from mcdxa.pricers.european import EuropeanPricer 21 | 22 | 23 | def main(): 24 | parser = argparse.ArgumentParser( 25 | description="Benchmark path-dependent European payoffs under BSM Monte Carlo" 26 | ) 27 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 28 | parser.add_argument("--T", type=float, default=1.0, help="Time to maturity") 29 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 30 | parser.add_argument("--sigma", type=float, default=0.2, help="Volatility") 31 | parser.add_argument("--n_paths", type=int, default=100_000, 32 | help="Number of Monte Carlo paths") 33 | parser.add_argument("--n_steps", type=int, default=50, 34 | help="Number of time steps per path") 35 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 36 | args = parser.parse_args() 37 | 38 | # Base BSM model 39 | model = BSM(args.r, args.sigma) 40 | 41 | # Scenarios: ATM, ITM, OTM (varying S0, fixed K) 42 | moneyness = 0.10 43 | scenarios = [ 44 | ("ATM", args.K), 45 | ("ITM", args.K * (1 + moneyness)), 46 | ("OTM", args.K * (1 - moneyness)), 47 | ] 48 | 49 | payoffs = [ 50 | ("AsianCall", AsianCallPayoff), 51 | ("AsianPut", AsianPutPayoff), 52 | ("LookbackCall", LookbackCallPayoff), 53 | ("LookbackPut", LookbackPutPayoff), 54 | ] 55 | 56 | header = f"{'Payoff':<15}{'Case':<6}{'Price':>12}{'StdErr':>12}{'Time(s)':>10}" 57 | print(header) 58 | print('-' * len(header)) 59 | 60 | for name, payoff_cls in payoffs: 61 | for case, S0 in scenarios: 62 | payoff = payoff_cls(args.K) 63 | pricer = EuropeanPricer( 64 | model, payoff, 65 | n_paths=args.n_paths, 66 | n_steps=args.n_steps, 67 | seed=args.seed 68 | ) 69 | t0 = time.time() 70 | price, stderr = pricer.price(S0, args.T, args.r) 71 | dt = time.time() - t0 72 | print(f"{name:<15}{case:<6}{price:12.6f}{stderr:12.6f}{dt:10.4f}") 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /mcdxa/bates.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import math 3 | from scipy.integrate import quad 4 | from .models import Bates 5 | 6 | 7 | def simulate_bates( 8 | S0: float, 9 | T: float, 10 | r: float, 11 | kappa: float, 12 | theta: float, 13 | xi: float, 14 | rho: float, 15 | v0: float, 16 | lam: float, 17 | mu_j: float, 18 | sigma_j: float, 19 | q: float = 0.0, 20 | n_paths: int = 10000, 21 | n_steps: int = 50, 22 | rng: np.random.Generator = None, 23 | ) -> np.ndarray: 24 | """ 25 | Wrapper: instantiate Bates model and simulate via its .simulate() method. 26 | """ 27 | model = Bates(r, kappa, theta, xi, rho, v0, lam, mu_j, sigma_j, q) 28 | return model.simulate(S0, T, n_paths, n_steps, rng=rng) 29 | 30 | 31 | def bates_price( 32 | S0: float, 33 | K: float, 34 | T: float, 35 | r: float, 36 | kappa: float, 37 | theta: float, 38 | xi: float, 39 | rho: float, 40 | v0: float, 41 | lam: float, 42 | mu_j: float, 43 | sigma_j: float, 44 | q: float = 0.0, 45 | option_type: str = "call", 46 | integration_limit: float = 250, 47 | ) -> float: 48 | """ 49 | Bates (1996) model price for European call or put via Lewis (2001) single-integral. 50 | 51 | Combines Heston stochastic volatility characteristic function 52 | with log-normal jumps (Merton). 53 | """ 54 | def _char_heston(u): 55 | d = np.sqrt((kappa - rho * xi * u * 1j) ** 2 + (u ** 2 + u * 1j) * xi ** 2) 56 | g = (kappa - rho * xi * u * 1j - d) / (kappa - rho * xi * u * 1j + d) 57 | # add jump drift compensator E[Y - 1] 58 | kappa_j = math.exp(mu_j + 0.5 * sigma_j ** 2) - 1 59 | C = ( 60 | (r - q - lam * kappa_j) * u * 1j * T 61 | + (kappa * theta / xi ** 2) 62 | * ((kappa - rho * xi * u * 1j - d) * T 63 | - 2 * np.log((1 - g * np.exp(-d * T)) / (1 - g))) 64 | ) 65 | D = ((kappa - rho * xi * u * 1j - d) / xi ** 2) * \ 66 | ((1 - np.exp(-d * T)) / (1 - g * np.exp(-d * T))) 67 | return np.exp(C + D * v0) 68 | 69 | def _char_bates(u): 70 | # add jump component to characteristic function 71 | jump_cf = np.exp(lam * T * (np.exp(1j * u * mu_j - 0.5 * u ** 2 * sigma_j ** 2) - 1)) 72 | return _char_heston(u) * jump_cf 73 | 74 | def _lewis_integrand(u): 75 | cf_val = _char_bates(u - 0.5j) 76 | return (np.exp(1j * u * math.log(S0 / K)) * cf_val).real / (u ** 2 + 0.25) 77 | 78 | integral_value = quad(_lewis_integrand, 0, integration_limit)[0] 79 | call_price = S0 * math.exp(-q * T) - math.exp(-r * T) * math.sqrt(S0 * K) / math.pi * integral_value 80 | if option_type == "call": 81 | price = call_price 82 | elif option_type == "put": 83 | price = call_price - S0 * math.exp(-q * T) + K * math.exp(-r * T) 84 | else: 85 | raise ValueError("Option type must be 'call' or 'put'.") 86 | return max(price, 0.0) 87 | -------------------------------------------------------------------------------- /scripts/benchmarks/benchmark_bsm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Benchmark European option pricing (MC vs analytical BSM) for ATM, ITM, and OTM cases. 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | import math 10 | import argparse 11 | 12 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) 13 | import numpy as np 14 | from mcdxa.models import BSM 15 | from mcdxa.payoffs import CallPayoff, PutPayoff 16 | from mcdxa.pricers.european import EuropeanPricer 17 | from mcdxa.analytics import bsm_price 18 | 19 | 20 | def main(): 21 | parser = argparse.ArgumentParser( 22 | description="Benchmark European options: MC vs analytical BSM" 23 | ) 24 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 25 | parser.add_argument("--T", type=float, default=1.0, help="Time to maturity") 26 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 27 | parser.add_argument("--sigma", type=float, default=0.2, help="Volatility") 28 | parser.add_argument( 29 | "--n_paths", type=int, default=100000, help="Number of Monte Carlo paths" 30 | ) 31 | parser.add_argument( 32 | "--n_steps", type=int, default=50, help="Number of time steps per path" 33 | ) 34 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 35 | parser.add_argument("--q", type=float, default=0.0, help="Dividend yield") 36 | args = parser.parse_args() 37 | 38 | model = BSM(args.r, args.sigma, q=args.q) 39 | moneyness = 0.10 40 | scenarios = [] 41 | for opt_type, payoff_cls in [("call", CallPayoff), ("put", PutPayoff)]: 42 | if opt_type == "call": 43 | S0_cases = [args.K * (1 + moneyness), args.K, args.K * (1 - moneyness)] 44 | else: 45 | S0_cases = [args.K * (1 - moneyness), args.K, args.K * (1 + moneyness)] 46 | for case, S0_case in zip(["ITM", "ATM", "OTM"], S0_cases): 47 | scenarios.append((opt_type, payoff_cls, case, S0_case)) 48 | 49 | header = f"{'Type':<6}{'Case':<6}{'MC Price':>12}{'StdErr':>12}{'BSM':>12}{'Abs Err':>12}{'% Err':>10}{'Time(s)':>12}" 50 | print(header) 51 | print('-' * len(header)) 52 | 53 | for opt_type, payoff_cls, case, S0_case in scenarios: 54 | payoff = payoff_cls(args.K) 55 | pricer = EuropeanPricer( 56 | model, payoff, n_paths=args.n_paths, n_steps=args.n_steps, seed=args.seed 57 | ) 58 | t0 = time.time() 59 | price_mc, stderr = pricer.price(S0_case, args.T, args.r) 60 | dt = time.time() - t0 61 | 62 | price_bs = bsm_price( 63 | S0_case, args.K, args.T, args.r, args.sigma, 64 | q=args.q, option_type=opt_type 65 | ) 66 | 67 | abs_err = abs(price_mc - price_bs) 68 | pct_err = abs_err / price_bs * 100.0 if price_bs != 0 else float('nan') 69 | print(f"{opt_type.capitalize():<6}{case:<6}{price_mc:12.6f}{stderr:12.6f}{price_bs:12.6f}{abs_err:12.6f}{pct_err:10.2f}{dt:12.4f}") 70 | 71 | 72 | if __name__ == "__main__": 73 | main() 74 | -------------------------------------------------------------------------------- /scripts/benchmarks/benchmark_american.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Benchmark American option pricing (LSM MC vs CRR binomial) for ITM, ATM, and OTM cases. 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | import argparse 10 | 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) 12 | import numpy as np 13 | from mcdxa.models import BSM 14 | from mcdxa.payoffs import CallPayoff, PutPayoff 15 | from mcdxa.pricers.european import EuropeanPricer 16 | from mcdxa.pricers.american import AmericanBinomialPricer, LongstaffSchwartzPricer 17 | 18 | 19 | def main(): 20 | parser = argparse.ArgumentParser( 21 | description="Benchmark American exercise pricing: MC (LSM) vs CRR binomial" 22 | ) 23 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 24 | parser.add_argument("--T", type=float, default=1.0, help="Time to maturity") 25 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 26 | parser.add_argument("--sigma", type=float, default=0.2, help="Volatility") 27 | parser.add_argument( 28 | "--n_paths", type=int, default=100000, help="Number of Monte Carlo paths" 29 | ) 30 | parser.add_argument( 31 | "--n_steps", type=int, default=50, help="Number of time steps per path" 32 | ) 33 | parser.add_argument("--n_tree", type=int, default=200, help="Number of binomial steps") 34 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 35 | parser.add_argument("--q", type=float, default=0.0, help="Dividend yield") 36 | args = parser.parse_args() 37 | 38 | model = BSM(args.r, args.sigma, q=args.q) 39 | moneyness = 0.10 40 | scenarios = [] 41 | for opt_type, payoff_cls in [("call", CallPayoff), ("put", PutPayoff)]: 42 | if opt_type == "call": 43 | S0_cases = [args.K * (1 + moneyness), args.K, args.K * (1 - moneyness)] 44 | else: 45 | S0_cases = [args.K * (1 - moneyness), args.K, args.K * (1 + moneyness)] 46 | for case, S0_case in zip(["ITM", "ATM", "OTM"], S0_cases): 47 | scenarios.append((opt_type, payoff_cls, case, S0_case)) 48 | 49 | header = f"{'Type':<6}{'Case':<6}{'MC Price':>12}{'StdErr':>12}{'CRR Price':>12}{'Abs Err':>12}{'% Err':>10}{'MC Time(s)':>12}{'Tree Time(s)':>12}" 50 | print(header) 51 | print('-' * len(header)) 52 | 53 | for opt_type, payoff_cls, case, S0_case in scenarios: 54 | payoff = payoff_cls(args.K) 55 | # LSM Monte Carlo 56 | lsm = LongstaffSchwartzPricer( 57 | model, payoff, n_paths=args.n_paths, n_steps=args.n_steps, seed=args.seed 58 | ) 59 | t0 = time.time() 60 | price_mc, stderr = lsm.price(S0_case, args.T, args.r) 61 | t_mc = time.time() - t0 62 | 63 | # CRR binomial 64 | binom = AmericanBinomialPricer(model, payoff, n_steps=args.n_tree) 65 | t0 = time.time() 66 | price_crr = binom.price(S0_case, args.T, args.r) 67 | t_crr = time.time() - t0 68 | 69 | abs_err = abs(price_mc - price_crr) 70 | pct_err = abs_err / price_crr * 100.0 if price_crr != 0 else float('nan') 71 | print(f"{opt_type.capitalize():<6}{case:<6}{price_mc:12.6f}{stderr:12.6f}{price_crr:12.6f}{abs_err:12.6f}{pct_err:10.2f}{t_mc:12.4f}{t_crr:12.4f}") 72 | 73 | 74 | if __name__ == "__main__": 75 | main() 76 | -------------------------------------------------------------------------------- /scripts/orchestrate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Orchestrate all benchmark suites: BSM, Merton jump-diffusion, Heston, and American. 4 | """ 5 | 6 | import os 7 | import sys 8 | import subprocess 9 | import argparse 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser( 14 | description="Orchestrate all benchmark suites: BSM, MJD, Bates, Heston, American" 15 | ) 16 | # global flags for any benchmark 17 | parser.add_argument("--K", type=float, help="Strike price") 18 | parser.add_argument("--T", type=float, help="Time to maturity") 19 | parser.add_argument("--r", type=float, help="Risk-free rate") 20 | parser.add_argument("--sigma", type=float, help="Volatility") 21 | parser.add_argument("--lam", type=float, help="Jump intensity (lambda)") 22 | parser.add_argument("--mu_j", type=float, help="Jump mean mu_j") 23 | parser.add_argument("--sigma_j", type=float, 24 | help="Jump volatility sigma_j") 25 | parser.add_argument("--kappa", type=float, 26 | help="Heston mean-reversion speed kappa") 27 | parser.add_argument("--theta", type=float, 28 | help="Heston long-term variance theta") 29 | parser.add_argument("--xi", type=float, help="Heston vol-of-vol xi") 30 | parser.add_argument("--rho", type=float, help="Heston correlation rho") 31 | parser.add_argument("--v0", type=float, help="Initial variance v0") 32 | parser.add_argument("--n_paths", type=int, 33 | help="Number of Monte Carlo paths") 34 | parser.add_argument("--n_steps", type=int, 35 | help="Number of time steps per path") 36 | parser.add_argument("--n_tree", type=int, help="Number of binomial steps") 37 | parser.add_argument("--seed", type=int, help="Random seed") 38 | parser.add_argument("--q", type=float, help="Dividend yield") 39 | args = parser.parse_args() 40 | 41 | scripts = [ 42 | 'benchmarks/benchmark_bsm.py', 43 | 'benchmarks/benchmark_mjd.py', 44 | 'benchmarks/benchmark_bates.py', 45 | 'benchmarks/benchmark_heston.py', 46 | 'benchmarks/benchmark_american.py', 47 | ] 48 | # which flags apply to each benchmark 49 | script_args = { 50 | 'benchmark_bsm.py': ["K", "T", "r", "sigma", "n_paths", "n_steps", "seed", "q"], 51 | 'benchmark_mjd.py': ["K", "T", "r", "sigma", "lam", "mu_j", "sigma_j", 52 | "n_paths", "n_steps", "seed", "q"], 53 | 'benchmark_bates.py': ["K", "T", "r", "kappa", "theta", "xi", "rho", "v0", 54 | "lam", "mu_j", "sigma_j", "n_paths", "n_steps", "seed", "q"], 55 | 'benchmark_heston.py': ["K", "T", "r", "kappa", "theta", "xi", "rho", "v0", 56 | "n_paths", "n_steps", "seed", "q"], 57 | 'benchmark_american.py': ["K", "T", "r", "sigma", "n_paths", "n_steps", 58 | "n_tree", "seed", "q"], 59 | } 60 | here = os.path.dirname(__file__) 61 | for script in scripts: 62 | path = os.path.join(here, script) 63 | print(f"\nRunning {script}...\n") 64 | cmd = [sys.executable, path] 65 | name = os.path.basename(script) 66 | for key in script_args.get(name, []): 67 | val = getattr(args, key) 68 | if val is not None: 69 | cmd += [f"--{key}", str(val)] 70 | subprocess.run(cmd) 71 | 72 | 73 | if __name__ == '__main__': 74 | main() 75 | -------------------------------------------------------------------------------- /scripts/benchmarks/benchmark_heston.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Benchmark Heston stochastic-volatility European options (MC vs semi-analytic) for ITM, ATM, and OTM cases. 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | import argparse 10 | 11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) 12 | import numpy as np 13 | from mcdxa.models import Heston 14 | from mcdxa.payoffs import CallPayoff, PutPayoff 15 | from mcdxa.pricers.european import EuropeanPricer 16 | from mcdxa.analytics import heston_price 17 | 18 | 19 | def main(): 20 | parser = argparse.ArgumentParser( 21 | description="Benchmark Heston model European options: MC vs semi-analytic" 22 | ) 23 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 24 | parser.add_argument("--T", type=float, default=1.0, help="Time to maturity") 25 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 26 | parser.add_argument("--kappa", type=float, default=2.0, help="Mean-reversion speed kappa") 27 | parser.add_argument("--theta", type=float, default=0.04, help="Long-term variance theta") 28 | parser.add_argument("--xi", type=float, default=0.2, help="Vol-of-vol xi") 29 | parser.add_argument("--rho", type=float, default=-0.7, help="Correlation rho") 30 | parser.add_argument("--v0", type=float, default=0.02, help="Initial variance v0") 31 | parser.add_argument("--n_paths", type=int, default=100000, help="Number of Monte Carlo paths") 32 | parser.add_argument("--n_steps", type=int, default=50, help="Number of time steps per path") 33 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 34 | parser.add_argument("--q", type=float, default=0.0, help="Dividend yield") 35 | args = parser.parse_args() 36 | 37 | model = Heston( 38 | args.r, args.kappa, args.theta, args.xi, args.rho, args.v0, q=args.q 39 | ) 40 | moneyness = 0.10 41 | scenarios = [] 42 | for opt_type, payoff_cls in [("call", CallPayoff), ("put", PutPayoff)]: 43 | if opt_type == "call": 44 | S0_cases = [args.K * (1 + moneyness), args.K, args.K * (1 - moneyness)] 45 | else: 46 | S0_cases = [args.K * (1 - moneyness), args.K, args.K * (1 + moneyness)] 47 | for case, S0_case in zip(["ITM", "ATM", "OTM"], S0_cases): 48 | scenarios.append((opt_type, payoff_cls, case, S0_case)) 49 | 50 | header = f"{'Type':<6}{'Case':<6}{'MC Price':>12}{'StdErr':>12}{'Analytic':>14}" 51 | header += f"{'Abs Err':>12}{'% Err':>10}{'Time(s)':>12}" 52 | print(header) 53 | print('-' * len(header)) 54 | 55 | for opt_type, payoff_cls, case, S0_case in scenarios: 56 | payoff = payoff_cls(args.K) 57 | pricer = EuropeanPricer( 58 | model, payoff, n_paths=args.n_paths, n_steps=args.n_steps, seed=args.seed 59 | ) 60 | t0 = time.time() 61 | price_mc, stderr = pricer.price(S0_case, args.T, args.r) 62 | dt = time.time() - t0 63 | 64 | price_an = heston_price( 65 | S0_case, args.K, args.T, args.r, 66 | args.kappa, args.theta, args.xi, args.rho, args.v0, 67 | q=args.q, option_type=opt_type 68 | ) 69 | 70 | abs_err = abs(price_mc - price_an) 71 | pct_err = abs_err / price_an * 100.0 if price_an != 0 else float('nan') 72 | print(f"{opt_type.capitalize():<6}{case:<6}{price_mc:12.6f}{stderr:12.6f}{price_an:14.6f}{abs_err:12.6f}{pct_err:10.2f}{dt:12.4f}") 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /mcdxa/heston.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.integrate import quad 3 | import math 4 | 5 | # the following Heston pricing implementation is from Gemini, after numerous tries with 6 | # different LLMs, basically none was able to provide a properly working implementation; 7 | # Gemini only came up with that one after having seen my own reference implementation 8 | # from my book "Derivatives Analytics with Python"; basically the first time that 9 | # none of the AI Assistants was able to provide a solution to such a quant finance problem 10 | 11 | def heston_price( 12 | S0: float, 13 | K: float, 14 | T: float, 15 | r: float, 16 | kappa: float, 17 | theta: float, 18 | xi: float, 19 | rho: float, 20 | v0: float, 21 | q: float = 0.0, 22 | option_type: str = "call", 23 | integration_limit: float = 250, 24 | ) -> float: 25 | """ 26 | Heston (1993) model price for European call or put option via Lewis (2001) 27 | single-integral formula. Negative prices are floored at zero. 28 | 29 | Parameters: 30 | - S0: Initial stock price 31 | - K: Strike price 32 | - T: Time to maturity (in years) 33 | - r: Risk-free interest rate 34 | - kappa: Mean reversion rate of variance 35 | - theta: Long-term variance 36 | - xi: Volatility of variance (vol of vol) 37 | - rho: Correlation between stock price and variance processes 38 | - v0: Initial variance 39 | - q: Dividend yield 40 | - option_type: 'call' or 'put' 41 | - integration_limit: Upper bound for numerical integration 42 | 43 | Returns: 44 | - price: Price of the European option (call or put) 45 | """ 46 | 47 | def _lewis_integrand(u, S0, K, T, r, q, kappa, theta, xi, rho, v0): 48 | """The integrand for the Lewis (2001) single-integral formula.""" 49 | 50 | # Calculate the characteristic function value at the complex point u - i/2 51 | char_func_val = _lewis_char_func( 52 | u - 0.5j, T, r, q, kappa, theta, xi, rho, v0) 53 | 54 | # The Lewis formula integrand 55 | integrand = 1 / (u**2 + 0.25) * \ 56 | (np.exp(1j * u * np.log(S0 / K)) * char_func_val).real 57 | 58 | return integrand 59 | 60 | def _lewis_char_func(u, T, r, q, kappa, theta, xi, rho, v0): 61 | """The Heston characteristic function of the log-price.""" 62 | 63 | d = np.sqrt((kappa - rho * xi * u * 1j)**2 + (u**2 + u * 1j) * xi**2) 64 | 65 | g = (kappa - rho * xi * u * 1j - d) / (kappa - rho * xi * u * 1j + d) 66 | 67 | C = (r - q) * u * 1j * T + (kappa * theta / xi**2) * ( 68 | (kappa - rho * xi * u * 1j - d) * T - 2 * 69 | np.log((1 - g * np.exp(-d * T)) / (1 - g)) 70 | ) 71 | 72 | D = ((kappa - rho * xi * u * 1j - d) / xi**2) * \ 73 | ((1 - np.exp(-d * T)) / (1 - g * np.exp(-d * T))) 74 | 75 | return np.exp(C + D * v0) 76 | 77 | # Perform the integration 78 | integral_value = quad( 79 | lambda u: _lewis_integrand( 80 | u, S0, K, T, r, q, kappa, theta, xi, rho, v0), 81 | 0, 82 | integration_limit 83 | )[0] 84 | 85 | # Calculate the final call price using the Lewis formula 86 | call_price = S0 * np.exp(-q * T) - np.exp(-r * T) * \ 87 | np.sqrt(S0 * K) / np.pi * integral_value 88 | 89 | if option_type == "call": 90 | price = call_price 91 | elif option_type == "put": 92 | price = call_price - S0 * math.exp(-q * T) + K * math.exp(-r * T) 93 | else: 94 | raise ValueError("Option type must be 'call' or 'put'.") 95 | 96 | return max(price, 0.0) 97 | -------------------------------------------------------------------------------- /mcdxa/payoffs.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class Payoff: 5 | """Base class for payoff definitions.""" 6 | def __call__(self, S: np.ndarray) -> np.ndarray: 7 | raise NotImplementedError 8 | 9 | 10 | class CustomPayoff(Payoff): 11 | """ 12 | Custom payoff defined by an arbitrary function of the terminal asset price. 13 | 14 | Args: 15 | func (callable): Function mapping terminal price array (n_paths,) 16 | or scalar to payoff values. The function should accept a numpy 17 | array or scalar and return an array or scalar of payoffs. 18 | 19 | Example: 20 | # payoff = max(sqrt(S_T) - K, 0) 21 | payoff = CustomPayoff(lambda s: np.maximum(np.sqrt(s) - K, 0)) 22 | """ 23 | def __init__(self, func): 24 | if not callable(func): 25 | raise TypeError(f"func must be callable, got {type(func)}") 26 | self.func = func 27 | 28 | def __call__(self, S: np.ndarray) -> np.ndarray: 29 | S = np.asarray(S) 30 | # extract terminal price if full path provided 31 | S_end = S[:, -1] if S.ndim == 2 else S 32 | # apply custom function to terminal prices 33 | return np.asarray(self.func(S_end)) 34 | 35 | 36 | class CallPayoff(Payoff): 37 | """European call option payoff.""" 38 | def __init__(self, strike: float): 39 | self.strike = strike 40 | 41 | def __call__(self, S: np.ndarray) -> np.ndarray: 42 | S = np.asarray(S) 43 | # handle terminal price if full path provided 44 | S_end = S[:, -1] if S.ndim == 2 else S 45 | return np.maximum(S_end - self.strike, 0.0) 46 | 47 | 48 | class PutPayoff(Payoff): 49 | """European put option payoff.""" 50 | def __init__(self, strike: float): 51 | self.strike = strike 52 | 53 | def __call__(self, S: np.ndarray) -> np.ndarray: 54 | S = np.asarray(S) 55 | S_end = S[:, -1] if S.ndim == 2 else S 56 | return np.maximum(self.strike - S_end, 0.0) 57 | 58 | 59 | class AsianCallPayoff(Payoff): 60 | """Arithmetic Asian (path-dependent) European call payoff.""" 61 | def __init__(self, strike: float): 62 | self.strike = strike 63 | 64 | def __call__(self, S: np.ndarray) -> np.ndarray: 65 | S = np.asarray(S) 66 | # average price over the path 67 | avg = S.mean(axis=1) if S.ndim == 2 else S 68 | return np.maximum(avg - self.strike, 0.0) 69 | 70 | 71 | class AsianPutPayoff(Payoff): 72 | """Arithmetic Asian (path-dependent) European put payoff.""" 73 | def __init__(self, strike: float): 74 | self.strike = strike 75 | 76 | def __call__(self, S: np.ndarray) -> np.ndarray: 77 | S = np.asarray(S) 78 | avg = S.mean(axis=1) if S.ndim == 2 else S 79 | return np.maximum(self.strike - avg, 0.0) 80 | 81 | 82 | class LookbackCallPayoff(Payoff): 83 | """Lookback (path-dependent) European call payoff (max(S) - strike).""" 84 | def __init__(self, strike: float): 85 | self.strike = strike 86 | 87 | def __call__(self, S: np.ndarray) -> np.ndarray: 88 | S = np.asarray(S) 89 | high = S.max(axis=1) if S.ndim == 2 else S 90 | return np.maximum(high - self.strike, 0.0) 91 | 92 | 93 | class LookbackPutPayoff(Payoff): 94 | """Lookback (path-dependent) European put payoff (strike - min(S)).""" 95 | def __init__(self, strike: float): 96 | self.strike = strike 97 | 98 | def __call__(self, S: np.ndarray) -> np.ndarray: 99 | S = np.asarray(S) 100 | low = S.min(axis=1) if S.ndim == 2 else S 101 | return np.maximum(self.strike - low, 0.0) 102 | -------------------------------------------------------------------------------- /scripts/benchmarks/benchmark_mjd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Benchmark Merton jump-diffusion European options (MC vs analytic) for ITM, ATM, and OTM cases. 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | import math 10 | import argparse 11 | 12 | sys.path.insert(0, os.path.abspath( 13 | os.path.join(os.path.dirname(__file__), '..', '..'))) 14 | import numpy as np 15 | from mcdxa.models import Merton 16 | from mcdxa.payoffs import CallPayoff, PutPayoff 17 | from mcdxa.pricers.european import EuropeanPricer 18 | from mcdxa.analytics import merton_price 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser( 23 | description="Benchmark Merton jump-diffusion European options: MC vs analytic" 24 | ) 25 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 26 | parser.add_argument("--T", type=float, default=1.0, 27 | help="Time to maturity") 28 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 29 | parser.add_argument("--sigma", type=float, default=0.2, 30 | help="Diffusion volatility") 31 | parser.add_argument("--lam", type=float, default=0.3, 32 | help="Jump intensity (lambda)") 33 | parser.add_argument("--mu_j", type=float, default=- 34 | 0.1, help="Mean jump size (mu_j)") 35 | parser.add_argument("--sigma_j", type=float, default=0.2, 36 | help="Jump volatility (sigma_j)") 37 | parser.add_argument("--n_paths", type=int, default=100000, 38 | help="Number of Monte Carlo paths") 39 | parser.add_argument("--n_steps", type=int, default=50, 40 | help="Number of time steps per path") 41 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 42 | parser.add_argument("--q", type=float, default=0.0, help="Dividend yield") 43 | args = parser.parse_args() 44 | 45 | model = Merton( 46 | args.r, args.sigma, args.lam, args.mu_j, args.sigma_j, q=args.q 47 | ) 48 | moneyness = 0.10 49 | scenarios = [] 50 | for opt_type, payoff_cls in [("call", CallPayoff), ("put", PutPayoff)]: 51 | if opt_type == "call": 52 | S0_cases = [args.K * (1 + moneyness), args.K, 53 | args.K * (1 - moneyness)] 54 | else: 55 | S0_cases = [args.K * (1 - moneyness), args.K, 56 | args.K * (1 + moneyness)] 57 | for case, S0_case in zip(["ITM", "ATM", "OTM"], S0_cases): 58 | scenarios.append((opt_type, payoff_cls, case, S0_case)) 59 | 60 | header = f"{'Type':<6}{'Case':<6}{'MC Price':>12}{'StdErr':>12}{'Analytic':>12}{'Abs Err':>12}{'% Err':>10}{'Time(s)':>12}" 61 | print(header) 62 | print('-' * len(header)) 63 | 64 | for opt_type, payoff_cls, case, S0_case in scenarios: 65 | payoff = payoff_cls(args.K) 66 | pricer = EuropeanPricer( 67 | model, payoff, n_paths=args.n_paths, n_steps=args.n_steps, seed=args.seed 68 | ) 69 | t0 = time.time() 70 | price_mc, stderr = pricer.price(S0_case, args.T, args.r) 71 | dt = time.time() - t0 72 | 73 | price_an = merton_price( 74 | S0_case, args.K, args.T, args.r, args.sigma, 75 | args.lam, args.mu_j, args.sigma_j, 76 | q=args.q, option_type=opt_type 77 | ) 78 | 79 | abs_err = abs(price_mc - price_an) 80 | pct_err = abs_err / price_an * 100.0 if price_an != 0 else float('nan') 81 | print(f"{opt_type.capitalize():<6}{case:<6}{price_mc:12.6f}{stderr:12.6f}{price_an:12.6f}{abs_err:12.6f}{pct_err:10.2f}{dt:12.4f}") 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /scripts/benchmarks/benchmark_bates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Benchmark Bates (1996) jump-diffusion with stochastic volatility European options 4 | (Heston+Merton jumps): MC vs semi-analytic via Lewis (2001) 5 | """ 6 | 7 | import os 8 | import sys 9 | import time 10 | import argparse 11 | 12 | sys.path.insert(0, os.path.abspath( 13 | os.path.join(os.path.dirname(__file__), '..', '..'))) 14 | import numpy as np 15 | from mcdxa.models import Bates 16 | from mcdxa.payoffs import CallPayoff, PutPayoff 17 | from mcdxa.pricers.european import EuropeanPricer 18 | from mcdxa.analytics import bates_price 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser( 23 | description="Benchmark Bates model European options: MC vs semi-analytic" 24 | ) 25 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 26 | parser.add_argument("--T", type=float, default=1.0, help="Time to maturity") 27 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 28 | parser.add_argument("--kappa", type=float, default=2.0, help="Heston mean-reversion speed kappa") 29 | parser.add_argument("--theta", type=float, default=0.04, help="Heston long-term variance theta") 30 | parser.add_argument("--xi", type=float, default=0.2, help="Heston vol-of-vol xi") 31 | parser.add_argument("--rho", type=float, default=-0.7, help="Heston correlation rho") 32 | parser.add_argument("--v0", type=float, default=0.02, help="Heston initial variance v0") 33 | parser.add_argument("--lam", type=float, default=0.3, help="Jump intensity lambda") 34 | parser.add_argument("--mu_j", type=float, default=-0.1, help="Jump mean mu_j") 35 | parser.add_argument("--sigma_j", type=float, default=0.2, help="Jump volatility sigma_j") 36 | parser.add_argument("--n_paths", type=int, default=100000, help="Number of Monte Carlo paths") 37 | parser.add_argument("--n_steps", type=int, default=50, help="Number of time steps per path") 38 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 39 | parser.add_argument("--q", type=float, default=0.0, help="Dividend yield") 40 | args = parser.parse_args() 41 | 42 | model = Bates( 43 | args.r, args.kappa, args.theta, args.xi, args.rho, 44 | args.v0, args.lam, args.mu_j, args.sigma_j, q=args.q 45 | ) 46 | moneyness = 0.10 47 | scenarios = [] 48 | for opt_type, payoff_cls in [("call", CallPayoff), ("put", PutPayoff)]: 49 | if opt_type == "call": 50 | S0_cases = [args.K * (1 + moneyness), args.K, args.K * (1 - moneyness)] 51 | else: 52 | S0_cases = [args.K * (1 - moneyness), args.K, args.K * (1 + moneyness)] 53 | for case, S0_case in zip(["ITM", "ATM", "OTM"], S0_cases): 54 | scenarios.append((opt_type, payoff_cls, case, S0_case)) 55 | 56 | header = f"{'Type':<6}{'Case':<6}{'MC Price':>12}{'StdErr':>12}{'Analytic':>12}{'Abs Err':>12}{'% Err':>10}{'Time(s)':>12}" 57 | print(header) 58 | print('-' * len(header)) 59 | 60 | for opt_type, payoff_cls, case, S0_case in scenarios: 61 | payoff = payoff_cls(args.K) 62 | pricer = EuropeanPricer( 63 | model, payoff, n_paths=args.n_paths, n_steps=args.n_steps, seed=args.seed 64 | ) 65 | t0 = time.time() 66 | price_mc, stderr = pricer.price(S0_case, args.T, args.r) 67 | dt = time.time() - t0 68 | 69 | price_an = bates_price( 70 | S0_case, args.K, args.T, args.r, 71 | args.kappa, args.theta, args.xi, args.rho, args.v0, 72 | args.lam, args.mu_j, args.sigma_j, 73 | q=args.q, option_type=opt_type 74 | ) 75 | 76 | abs_err = abs(price_mc - price_an) 77 | pct_err = abs_err / price_an * 100.0 if price_an != 0 else float('nan') 78 | print(f"{opt_type.capitalize():<6}{case:<6}{price_mc:12.6f}{stderr:12.6f}{price_an:12.6f}{abs_err:12.6f}{pct_err:10.2f}{dt:12.4f}") 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /mcdxa/pricers/american.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | 4 | 5 | class AmericanBinomialPricer: 6 | """ 7 | Cox-Ross-Rubinstein binomial pricer for American options. 8 | 9 | Attributes: 10 | model: Asset price model with attributes r, sigma, q. 11 | payoff: Payoff callable. 12 | n_steps: Number of binomial steps. 13 | """ 14 | def __init__(self, model, payoff, n_steps: int = 200): 15 | self.model = model 16 | self.payoff = payoff 17 | self.n_steps = n_steps 18 | 19 | def price(self, S0: float, T: float, r: float) -> float: 20 | """ 21 | Price the American option using the CRR binomial model. 22 | 23 | Args: 24 | S0 (float): Initial asset price. 25 | T (float): Time to maturity. 26 | r (float): Risk-free rate. 27 | 28 | Returns: 29 | float: American option price. 30 | """ 31 | sigma = self.model.sigma 32 | # degenerate zero-volatility: immediate exercise 33 | if sigma <= 0 or T <= 0: 34 | return float(self.payoff(S0)) 35 | q = getattr(self.model, 'q', 0.0) 36 | n = self.n_steps 37 | dt = T / n 38 | u = math.exp(sigma * math.sqrt(dt)) 39 | d = 1 / u 40 | disc = math.exp(-r * dt) 41 | p = (math.exp((r - q) * dt) - d) / (u - d) 42 | 43 | prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)] 44 | values = [float(self.payoff(price)) for price in prices] 45 | 46 | for i in range(n - 1, -1, -1): 47 | for j in range(i + 1): 48 | cont = disc * (p * values[j] + (1 - p) * values[j + 1]) 49 | exercise = float(self.payoff(S0 * (u ** (i - j)) * (d ** j))) 50 | values[j] = max(exercise, cont) 51 | return values[0] 52 | 53 | 54 | class LongstaffSchwartzPricer: 55 | """ 56 | Longstaff-Schwartz least-squares Monte Carlo pricer for American options. 57 | 58 | Attributes: 59 | model: Asset price model with simulate method. 60 | payoff: Payoff callable (vectorized). 61 | n_paths: Number of Monte Carlo paths. 62 | n_steps: Number of time steps per path. 63 | rng: numpy random generator. 64 | """ 65 | def __init__(self, model, payoff, n_paths: int = 100_000, 66 | n_steps: int = 50, seed: int = None): 67 | self.model = model 68 | self.payoff = payoff 69 | self.n_paths = n_paths 70 | self.n_steps = n_steps 71 | self.rng = None if seed is None else np.random.default_rng(seed) 72 | 73 | def price(self, S0: float, T: float, r: float) -> tuple: 74 | """ 75 | Price the American option via Least-Squares Monte Carlo. 76 | 77 | Args: 78 | S0: Initial asset price. 79 | T: Time to maturity. 80 | r: Risk-free rate. 81 | 82 | Returns: 83 | (price, stderr): discounted price and its standard error. 84 | """ 85 | dt = T / self.n_steps 86 | paths = self.model.simulate(S0, T, self.n_paths, self.n_steps, rng=self.rng) 87 | n_paths, _ = paths.shape 88 | cashflow = self.payoff(paths[:, -1]) 89 | tau = np.full(n_paths, self.n_steps, dtype=int) 90 | 91 | disc = math.exp(-r * dt) 92 | for t in range(self.n_steps - 1, 0, -1): 93 | St = paths[:, t] 94 | immediate = self.payoff(St) 95 | itm = immediate > 0 96 | if not np.any(itm): 97 | continue 98 | Y = cashflow[itm] * (disc ** (tau[itm] - t)) 99 | X = St[itm] 100 | A = np.vstack([np.ones_like(X), X, X**2]).T 101 | coeffs, *_ = np.linalg.lstsq(A, Y, rcond=None) 102 | continuation = coeffs[0] + coeffs[1] * X + coeffs[2] * X**2 103 | exercise = immediate[itm] > continuation 104 | idx = np.where(itm)[0][exercise] 105 | cashflow[idx] = immediate[idx] 106 | tau[idx] = t 107 | 108 | discounts = np.exp(-r * dt * tau) 109 | discounted = cashflow * discounts 110 | price = discounted.mean() 111 | stderr = discounted.std(ddof=1) / np.sqrt(self.n_paths) 112 | return price, stderr 113 | -------------------------------------------------------------------------------- /scripts/mcdxa.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "e32fba29-1962-446c-97fd-476e05aa155a", 6 | "metadata": {}, 7 | "source": [ 8 | "\"The
" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "id": "2f1c8a9f", 14 | "metadata": {}, 15 | "source": [ 16 | "# mcdxa Package Tutorial\n", 17 | "\n", 18 | "This notebook demonstrates how to use the **mcdxa** package for option pricing.\n" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "id": "1e4ad6e6-89f2-4750-b5c6-2d1797ed67ad", 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "import numpy as np\n", 29 | "import matplotlib.pyplot as plt\n", 30 | "plt.style.use('seaborn-v0_8')\n", 31 | "%config InlineBackend.figure_format = 'svg'" 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": null, 37 | "id": "ad9403b3-23da-44fa-95f2-b9a9f50b1e74", 38 | "metadata": {}, 39 | "outputs": [], 40 | "source": [ 41 | "import sys\n", 42 | "sys.path.append('..')" 43 | ] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "id": "24f3cb5a-6343-4785-9ccd-9f14731d2df0", 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [ 52 | "from mcdxa.models import BSM, Heston, Merton, Bates\n", 53 | "from mcdxa.payoffs import CallPayoff, PutPayoff, AsianCallPayoff, CustomPayoff\n", 54 | "from mcdxa.pricers.european import EuropeanPricer\n", 55 | "from mcdxa.pricers.american import AmericanBinomialPricer, LongstaffSchwartzPricer\n", 56 | "from mcdxa.analytics import bsm_price" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "id": "b828bccd", 62 | "metadata": {}, 63 | "source": [ 64 | "## Payoff Examples\n" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": null, 70 | "id": "52204e91", 71 | "metadata": {}, 72 | "outputs": [], 73 | "source": [ 74 | "K = 100\n", 75 | "spots = [80, 100, 120]\n", 76 | "call = CallPayoff(K)\n", 77 | "put = PutPayoff(K)\n", 78 | "print('Call payoff:', call(spots))\n", 79 | "print('Put payoff :', put(spots))" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "id": "7f71ebef", 85 | "metadata": {}, 86 | "source": [ 87 | "## Custom Payoff\n" 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": null, 93 | "id": "01cf75cf", 94 | "metadata": {}, 95 | "outputs": [], 96 | "source": [ 97 | "sqrt_call = CustomPayoff(lambda s: np.maximum(np.sqrt(s) - 9, 0))\n", 98 | "spots = [81, 100, 121]\n", 99 | "print('Custom sqrt call payoff:', sqrt_call(spots))" 100 | ] 101 | }, 102 | { 103 | "cell_type": "markdown", 104 | "id": "133e2577", 105 | "metadata": {}, 106 | "source": [ 107 | "## Path-dependent Payoff (Asian)\n" 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": null, 113 | "id": "ca67cbb6", 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "paths = np.array([[90, 110, 130], [120, 100, 80]])\n", 118 | "asian_call = AsianCallPayoff(100)\n", 119 | "print('Asian call payoff on sample paths:', asian_call(paths))" 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "id": "bdb6355e", 125 | "metadata": {}, 126 | "source": [ 127 | "## Simulate and Plot BSM Paths\n" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": null, 133 | "id": "5bcc0e6d", 134 | "metadata": {}, 135 | "outputs": [], 136 | "source": [ 137 | "model = BSM(r=0.05, sigma=0.2)\n", 138 | "paths = model.simulate(100, 1, n_paths=5, n_steps=50)\n", 139 | "plt.figure(figsize=(8,4))\n", 140 | "for i, path in enumerate(paths):\n", 141 | " plt.plot(path, label=f'Path {i}')\n", 142 | "plt.legend(); plt.title('BSM Sample Paths'); plt.xlabel('Step'); plt.ylabel('Price')\n", 143 | "plt.show()" 144 | ] 145 | }, 146 | { 147 | "cell_type": "markdown", 148 | "id": "935790bc", 149 | "metadata": {}, 150 | "source": [ 151 | "## European Option Pricing via Monte Carlo\n" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": null, 157 | "id": "848d3e75", 158 | "metadata": {}, 159 | "outputs": [], 160 | "source": [ 161 | "model = BSM(r=0.05, sigma=0.2)\n", 162 | "payoff = CallPayoff(100)\n", 163 | "pricer = EuropeanPricer(model, payoff, n_paths=5000, n_steps=50, seed=42)\n", 164 | "price_mc, stderr = pricer.price(100, 1, 0.05)\n", 165 | "price_bs = bsm_price(100, 100, 1, 0.05, 0.2, option_type='call')\n", 166 | "print(f'MC price: {price_mc:.4f} ± {stderr:.4f}, BSM price: {price_bs:.4f}')" 167 | ] 168 | }, 169 | { 170 | "cell_type": "markdown", 171 | "id": "e06c5c8a", 172 | "metadata": {}, 173 | "source": [ 174 | "## American Option Pricing\n" 175 | ] 176 | }, 177 | { 178 | "cell_type": "code", 179 | "execution_count": null, 180 | "id": "9aa98105", 181 | "metadata": {}, 182 | "outputs": [], 183 | "source": [ 184 | "amer = AmericanBinomialPricer(model, CallPayoff(100), n_steps=100)\n", 185 | "price_amer = amer.price(100, 1, 0.05)\n", 186 | "print('CRR American call price:', price_amer)\n", 187 | "lsm = LongstaffSchwartzPricer(model, PutPayoff(100), n_paths=5000, n_steps=50, seed=42)\n", 188 | "price_lsm, stderr_lsm = lsm.price(90, 1, 0.05)\n", 189 | "print(f'LSM American put price: {price_lsm:.4f} ± {stderr_lsm:.4f}')" 190 | ] 191 | }, 192 | { 193 | "cell_type": "markdown", 194 | "id": "29b7acf4-7d13-4a6c-8932-b9d6841ac3ce", 195 | "metadata": {}, 196 | "source": [ 197 | "\"The
" 198 | ] 199 | } 200 | ], 201 | "metadata": { 202 | "kernelspec": { 203 | "display_name": "Python 3 (ipykernel)", 204 | "language": "python", 205 | "name": "python3" 206 | }, 207 | "language_info": { 208 | "codemirror_mode": { 209 | "name": "ipython", 210 | "version": 3 211 | }, 212 | "file_extension": ".py", 213 | "mimetype": "text/x-python", 214 | "name": "python", 215 | "nbconvert_exporter": "python", 216 | "pygments_lexer": "ipython3", 217 | "version": "3.12.2" 218 | } 219 | }, 220 | "nbformat": 4, 221 | "nbformat_minor": 5 222 | } 223 | -------------------------------------------------------------------------------- /mcdxa/models.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import math 3 | 4 | 5 | class BSM: 6 | """ 7 | Black-Scholes-Merton model for risk-neutral asset price simulation. 8 | 9 | Attributes: 10 | r (float): Risk-free interest rate. 11 | sigma (float): Volatility. 12 | q (float): Dividend yield. 13 | """ 14 | def __init__(self, r: float, sigma: float, q: float = 0.0): 15 | self.r = r 16 | self.sigma = sigma 17 | self.q = q 18 | 19 | def simulate(self, 20 | S0: float, 21 | T: float, 22 | n_paths: int, 23 | n_steps: int = 1, 24 | rng: np.random.Generator = None) -> np.ndarray: 25 | """ 26 | Simulate asset price paths under the risk-neutral measure. 27 | 28 | Args: 29 | S0 (float): Initial asset price. 30 | T (float): Time to maturity. 31 | n_paths (int): Number of simulation paths. 32 | n_steps (int): Number of time steps per path. 33 | rng (np.random.Generator, optional): Random generator. 34 | 35 | Returns: 36 | np.ndarray: Simulated asset prices, shape (n_paths, n_steps+1). 37 | """ 38 | if rng is None: 39 | rng = np.random.default_rng() 40 | 41 | dt = T / n_steps 42 | drift = (self.r - self.q - 0.5 * self.sigma ** 2) * dt 43 | diffusion = self.sigma * np.sqrt(dt) 44 | 45 | paths = np.empty((n_paths, n_steps + 1)) 46 | paths[:, 0] = S0 47 | 48 | for t in range(1, n_steps + 1): 49 | z = rng.standard_normal(n_paths) 50 | paths[:, t] = paths[:, t - 1] * np.exp(drift + diffusion * z) 51 | 52 | return paths 53 | 54 | 55 | class Merton: 56 | """ 57 | Merton jump-diffusion model for risk-neutral asset price simulation. 58 | 59 | dS/S = (r - q - lam * kappa - 0.5 * sigma**2) dt + sigma dW 60 | + (Y - 1) dN, where log Y ~ N(mu_j, sigma_j**2), N~Poisson(lam dt) 61 | 62 | Attributes: 63 | r (float): Risk-free interest rate. 64 | sigma (float): Diffusion volatility. 65 | lam (float): Jump intensity (Poisson rate). 66 | mu_j (float): Mean of jump size log-normal distribution. 67 | sigma_j (float): Volatility of jump size log-normal distribution. 68 | q (float): Dividend yield. 69 | """ 70 | def __init__(self, 71 | r: float, 72 | sigma: float, 73 | lam: float, 74 | mu_j: float, 75 | sigma_j: float, 76 | q: float = 0.0): 77 | self.r = r 78 | self.sigma = sigma 79 | self.lam = lam 80 | self.mu_j = mu_j 81 | self.sigma_j = sigma_j 82 | self.q = q 83 | # compensator to keep martingale: E[Y - 1] 84 | self.kappa = math.exp(mu_j + 0.5 * sigma_j ** 2) - 1 85 | 86 | def simulate(self, 87 | S0: float, 88 | T: float, 89 | n_paths: int, 90 | n_steps: int = 1, 91 | rng=None) -> np.ndarray: 92 | """ 93 | Simulate asset price paths under risk-neutral Merton jump-diffusion. 94 | 95 | Args: 96 | S0 (float): Initial asset price. 97 | T (float): Time to maturity. 98 | n_paths (int): Number of simulation paths. 99 | n_steps (int): Number of time steps per path. 100 | rng (np.random.Generator, optional): Random number generator. 101 | 102 | Returns: 103 | np.ndarray: Simulated asset prices, shape (n_paths, n_steps+1). 104 | """ 105 | if rng is None: 106 | rng = np.random.default_rng() 107 | 108 | dt = T / n_steps 109 | drift = (self.r - self.q - self.lam * self.kappa - 0.5 * self.sigma ** 2) * dt 110 | diff_coeff = self.sigma * math.sqrt(dt) 111 | jump_mu = self.mu_j 112 | jump_sigma = self.sigma_j 113 | 114 | paths = np.empty((n_paths, n_steps + 1)) 115 | paths[:, 0] = S0 116 | 117 | for t in range(1, n_steps + 1): 118 | # diffusion component 119 | z = rng.standard_normal(n_paths) 120 | # jumps: number of jumps ~ Poisson(lam dt) 121 | nj = rng.poisson(self.lam * dt, size=n_paths) 122 | # aggregate jump-size log-return: sum of nj iid normals 123 | # if nj=0, jump_log = 0 124 | jump_log = np.where( 125 | nj > 0, 126 | rng.normal(nj * jump_mu, np.sqrt(nj) * jump_sigma), 127 | 0.0, 128 | ) 129 | paths[:, t] = ( 130 | paths[:, t - 1] 131 | * np.exp(drift + diff_coeff * z + jump_log) 132 | ) 133 | return paths 134 | 135 | 136 | # This class has been corrected by Gemini with regard to the discretization 137 | # approach to yield better convergence and valuation results. 138 | class Heston: 139 | """ 140 | Heston stochastic volatility model. 141 | 142 | dS_t = (r - q) S_t dt + sqrt(v_t) S_t dW1 143 | dv_t = kappa*(theta - v_t) dt + xi*sqrt(max(v_t,0)) dW2 144 | corr(dW1, dW2) = rho 145 | 146 | Attributes: 147 | r (float): Risk-free rate. 148 | kappa (float): Mean reversion speed of variance. 149 | theta (float): Long-run variance. 150 | xi (float): Volatility of variance (vol-of-vol). 151 | rho (float): Correlation between asset and variance. 152 | v0 (float): Initial variance. 153 | q (float): Dividend yield. 154 | """ 155 | def __init__(self, 156 | r: float, 157 | kappa: float, 158 | theta: float, 159 | xi: float, 160 | rho: float, 161 | v0: float, 162 | q: float = 0.0): 163 | self.r = r 164 | self.kappa = kappa 165 | self.theta = theta 166 | self.xi = xi 167 | self.rho = rho 168 | self.v0 = v0 169 | self.q = q 170 | 171 | def simulate(self, 172 | S0: float, 173 | T: float, 174 | n_paths: int, 175 | n_steps: int = 50, 176 | rng: np.random.Generator = None) -> np.ndarray: 177 | """ 178 | Simulate asset price under Heston model via Euler full-truncation. 179 | 180 | Returns: 181 | np.ndarray: Asset paths shape (n_paths, n_steps+1). 182 | """ 183 | if rng is None: 184 | rng = np.random.default_rng() 185 | 186 | dt = T / n_steps 187 | S = np.full((n_paths, n_steps + 1), S0, dtype=float) 188 | v = np.full(n_paths, self.v0, dtype=float) 189 | 190 | for t in range(1, n_steps + 1): 191 | z1 = rng.standard_normal(n_paths) 192 | z2 = rng.standard_normal(n_paths) 193 | w1 = z1 194 | w2 = self.rho * z1 + math.sqrt(1 - self.rho ** 2) * z2 195 | 196 | v_pos = np.maximum(v, 0) 197 | 198 | S[:, t] = ( 199 | S[:, t - 1] 200 | * np.exp((self.r - self.q - 0.5 * v_pos) * dt + np.sqrt(v_pos * dt) * w1) 201 | ) 202 | 203 | v = v + self.kappa * (self.theta - v) * dt + self.xi * np.sqrt(v_pos * dt) * w2 204 | return S 205 | 206 | 207 | class Bates: 208 | """ 209 | Bates (1996) jump-diffusion with stochastic volatility (Heston + Merton jumps). 210 | 211 | Simulates dS_t and v_t dynamics with correlated diffusion and Poisson jumps. 212 | """ 213 | def __init__(self, 214 | r: float, 215 | kappa: float, 216 | theta: float, 217 | xi: float, 218 | rho: float, 219 | v0: float, 220 | lam: float, 221 | mu_j: float, 222 | sigma_j: float, 223 | q: float = 0.0): 224 | self.r = r 225 | self.kappa = kappa 226 | self.theta = theta 227 | self.xi = xi 228 | self.rho = rho 229 | self.v0 = v0 230 | self.lam = lam 231 | self.mu_j = mu_j 232 | self.sigma_j = sigma_j 233 | self.q = q 234 | # jump compensator E[Y - 1] 235 | self.kappa_j = math.exp(mu_j + 0.5 * sigma_j ** 2) - 1 236 | 237 | def simulate(self, 238 | S0: float, 239 | T: float, 240 | n_paths: int, 241 | n_steps: int = 50, 242 | rng: np.random.Generator = None) -> np.ndarray: 243 | """ 244 | Simulate asset paths for the Bates model via Euler full-truncation plus jumps. 245 | 246 | Returns: 247 | np.ndarray: Simulated paths (n_paths, n_steps+1). 248 | """ 249 | if rng is None: 250 | rng = np.random.default_rng() 251 | 252 | dt = T / n_steps 253 | S = np.full((n_paths, n_steps + 1), S0, dtype=float) 254 | v = np.full(n_paths, self.v0, dtype=float) 255 | 256 | for t in range(1, n_steps + 1): 257 | z1 = rng.standard_normal(n_paths) 258 | z2 = rng.standard_normal(n_paths) 259 | w1 = z1 260 | w2 = self.rho * z1 + math.sqrt(1 - self.rho ** 2) * z2 261 | 262 | v_pos = np.maximum(v, 0.0) 263 | 264 | S[:, t] = ( 265 | S[:, t - 1] 266 | * np.exp((self.r - self.q - self.lam * self.kappa_j - 0.5 * v_pos) * dt 267 | + np.sqrt(v_pos * dt) * w1) 268 | ) 269 | v = ( 270 | v 271 | + self.kappa * (self.theta - v) * dt 272 | + self.xi * np.sqrt(v_pos * dt) * w2 273 | ) 274 | 275 | Nj = rng.poisson(self.lam * dt, size=n_paths) 276 | jump_log = np.where( 277 | Nj > 0, 278 | rng.normal(Nj * self.mu_j, np.sqrt(Nj) * self.sigma_j), 279 | 0.0, 280 | ) 281 | S[:, t] *= np.exp(jump_log) 282 | 283 | return S 284 | -------------------------------------------------------------------------------- /scripts/benchmark.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Benchmark Monte Carlo European option pricing vs analytical BSM and 4 | CRR binomial American option pricing (ATM/ITM/OTM scenarios). 5 | """ 6 | 7 | import os 8 | import sys 9 | import time 10 | import math 11 | import numpy as np 12 | import argparse 13 | 14 | sys.path.insert(0, os.path.abspath( 15 | os.path.join(os.path.dirname(__file__), '..'))) 16 | from mcdxa.models import BSM, Merton, Heston 17 | from mcdxa.payoffs import CallPayoff, PutPayoff 18 | from mcdxa.pricers.european import EuropeanPricer 19 | from mcdxa.pricers.american import AmericanBinomialPricer, LongstaffSchwartzPricer 20 | from mcdxa.analytics import norm_cdf, bsm_price, merton_price, heston_price 21 | 22 | 23 | def main(): 24 | parser = argparse.ArgumentParser( 25 | description="MC European/American vs. BSM and CRR American option pricing benchmark" 26 | ) 27 | parser.add_argument("--S0", type=float, default=100.0, 28 | help="Initial spot price") 29 | parser.add_argument("--K", type=float, default=100.0, help="Strike price") 30 | parser.add_argument("--T", type=float, default=1.0, 31 | help="Time to maturity") 32 | parser.add_argument("--r", type=float, default=0.05, help="Risk-free rate") 33 | parser.add_argument("--sigma", type=float, default=0.2, help="Volatility") 34 | parser.add_argument( 35 | "--n_paths", type=int, default=100000, help="Number of Monte Carlo paths" 36 | ) 37 | parser.add_argument( 38 | "--n_steps", type=int, default=50, help="Number of time steps per path" 39 | ) 40 | parser.add_argument("--seed", type=int, default=42, help="Random seed") 41 | parser.add_argument( 42 | "--n_tree", type=int, default=200, 43 | help="Number of steps for CRR binomial tree pricing" 44 | ) 45 | parser.add_argument( 46 | "--lam", type=float, default=0.3, 47 | help="Jump intensity (lambda) for Merton jump-diffusion" 48 | ) 49 | parser.add_argument( 50 | "--mu_j", type=float, default=-0.1, 51 | help="Mean jump size (lognormal mu_j) for Merton model" 52 | ) 53 | parser.add_argument( 54 | "--sigma_j", type=float, default=0.2, 55 | help="Jump size volatility (sigma_j) for Merton model" 56 | ) 57 | parser.add_argument("--q", type=float, default=0.0, 58 | help="Dividend yield" 59 | ) 60 | parser.add_argument( 61 | "--kappa", type=float, default=2.0, 62 | help="Mean-reversion speed for Heston model" 63 | ) 64 | parser.add_argument( 65 | "--theta", type=float, default=0.04, 66 | help="Long-run variance theta for Heston model" 67 | ) 68 | parser.add_argument( 69 | "--xi", type=float, default=0.2, 70 | help="Vol-of-vol xi for Heston model" 71 | ) 72 | parser.add_argument( 73 | "--rho", type=float, default=-0.5, 74 | help="Correlation rho for Heston model" 75 | ) 76 | parser.add_argument( 77 | "--v0", type=float, default=0.02, 78 | help="Initial variance v0 for Heston model" 79 | ) 80 | args = parser.parse_args() 81 | 82 | model = BSM(args.r, args.sigma, q=args.q) 83 | # jump-diffusion model for Merton series benchmark 84 | model_mjd = Merton( 85 | args.r, args.sigma, args.lam, args.mu_j, args.sigma_j, q=args.q 86 | ) 87 | # Define scenarios: ATM, ITM and OTM with a fixed moneyness percentage 88 | moneyness = 0.10 89 | scenarios = [] 90 | for opt_type, payoff_cls in [("call", CallPayoff), ("put", PutPayoff)]: 91 | scenarios.append((opt_type, payoff_cls, "ATM", args.K)) 92 | # ITM and OTM by moneyness 93 | if opt_type == "call": 94 | scenarios.append( 95 | (opt_type, payoff_cls, "ITM", args.K * (1 + moneyness))) 96 | scenarios.append( 97 | (opt_type, payoff_cls, "OTM", args.K * (1 - moneyness))) 98 | else: 99 | scenarios.append( 100 | (opt_type, payoff_cls, "ITM", args.K * (1 - moneyness))) 101 | scenarios.append( 102 | (opt_type, payoff_cls, "OTM", args.K * (1 + moneyness))) 103 | 104 | # 1) European options: MC vs analytical BSM 105 | results_eur = [] 106 | for opt_type, payoff_cls, case, S0_case in scenarios: 107 | payoff = payoff_cls(args.K) 108 | eur_mc = EuropeanPricer(model, payoff, 109 | n_paths=args.n_paths, 110 | n_steps=args.n_steps, 111 | seed=args.seed) 112 | t0 = time.time() 113 | price_mc, stderr = eur_mc.price(S0_case, args.T, args.r) 114 | t_mc = time.time() - t0 115 | 116 | price_bs = bsm_price( 117 | S0_case, args.K, args.T, args.r, args.sigma, 118 | option_type=opt_type 119 | ) 120 | 121 | abs_err = abs(price_mc - price_bs) 122 | pct_err = abs_err / price_bs * 100.0 if price_bs != 0 else float("nan") 123 | results_eur.append((opt_type.capitalize(), case, 124 | price_mc, price_bs, 125 | abs_err, pct_err, t_mc)) 126 | 127 | # Print European results 128 | print("\nEuropean option pricing (MC vs BSM):") 129 | header_eur = ( 130 | f"{'Type':<6}{'Case':<6}" 131 | f"{'MC Price':>12}{'BSM Price':>12}{'Abs Err':>12}{'% Err':>10}{'MC Time(s)':>12}" 132 | ) 133 | print(header_eur) 134 | print('-' * len(header_eur)) 135 | for typ, case, mc, bsm, err, pct, tmc in results_eur: 136 | print( 137 | f"{typ:<6}{case:<6}{mc:12.6f}{bsm:12.6f}" 138 | f"{err:12.6f}{pct:10.2f}{tmc:12.4f}" 139 | ) 140 | 141 | # 2) Merton jump-diffusion European options: MC vs analytic 142 | results_mjd = [] 143 | for opt_type, payoff_cls, case, S0_case in scenarios: 144 | payoff = payoff_cls(args.K) 145 | mjd_mc = EuropeanPricer( 146 | model_mjd, payoff, 147 | n_paths=args.n_paths, 148 | n_steps=args.n_steps, 149 | seed=args.seed 150 | ) 151 | t0 = time.time() 152 | price_mc_jd, stderr_jd = mjd_mc.price(S0_case, args.T, args.r) 153 | t_mc_jd = time.time() - t0 154 | 155 | price_call_jd = merton_price( 156 | S0_case, args.K, args.T, args.r, args.sigma, 157 | args.lam, args.mu_j, args.sigma_j 158 | ) 159 | if opt_type == 'call': 160 | price_anal_jd = price_call_jd 161 | else: 162 | price_anal_jd = ( 163 | price_call_jd 164 | - S0_case * math.exp(-args.q * args.T) 165 | + args.K * math.exp(-args.r * args.T) 166 | ) 167 | 168 | abs_err_jd = abs(price_mc_jd - price_anal_jd) 169 | pct_err_jd = abs_err_jd / price_anal_jd * \ 170 | 100.0 if price_anal_jd != 0 else float('nan') 171 | results_mjd.append(( 172 | opt_type.capitalize(), case, 173 | price_mc_jd, price_anal_jd, 174 | abs_err_jd, pct_err_jd, 175 | t_mc_jd 176 | )) 177 | 178 | print("\nMerton jump-diffusion European (MC vs analytic):") 179 | header_mjd = ( 180 | f"{'Type':<6}{'Case':<6}" 181 | f"{'MC Price':>12}{'Analytic':>12}{'Abs Err':>12}{'% Err':>10}{'MC Time(s)':>12}" 182 | ) 183 | print(header_mjd) 184 | print('-' * len(header_mjd)) 185 | for typ, case, mc, an, err, pct, tmc in results_mjd: 186 | print( 187 | f"{typ:<6}{case:<6}{mc:12.6f}{an:12.6f}" 188 | f"{err:12.6f}{pct:10.2f}{tmc:12.4f}" 189 | ) 190 | 191 | # 3) Heston stochastic-volatility European options: MC vs semi-analytic 192 | model_hes = Heston( 193 | args.r, args.kappa, args.theta, 194 | args.xi, args.rho, args.v0, q=args.q 195 | ) 196 | results_hes = [] 197 | for opt_type, payoff_cls, case, S0_case in scenarios: 198 | payoff = payoff_cls(args.K) 199 | hes_mc = EuropeanPricer( 200 | model_hes, payoff, 201 | n_paths=args.n_paths, 202 | n_steps=args.n_steps, 203 | seed=args.seed 204 | ) 205 | t0 = time.time() 206 | price_mc_hes, stderr_hes = hes_mc.price(S0_case, args.T, args.r) 207 | t_mc_hes = time.time() - t0 208 | 209 | price_an_hes = heston_price( 210 | S0_case, args.K, args.T, args.r, 211 | args.kappa, args.theta, args.xi, args.rho, args.v0, 212 | q=args.q, 213 | option_type=opt_type 214 | ) 215 | 216 | abs_err = abs(price_mc_hes - price_an_hes) 217 | pct_err = abs_err / price_an_hes * \ 218 | 100.0 if price_an_hes != 0 else float('nan') 219 | results_hes.append(( 220 | opt_type.capitalize(), case, 221 | price_mc_hes, price_an_hes, 222 | abs_err, pct_err, 223 | t_mc_hes 224 | )) 225 | 226 | print("\nHeston SV model European (MC vs semi-analytic):") 227 | header_hes = ( 228 | f"{'Type':<6}{'Case':<6}" 229 | f"{'MC Price':>12}{'Analytic':>14}{'Abs Err':>12}{'% Err':>10}{'MC Time(s)':>12}" 230 | ) 231 | print(header_hes) 232 | print('-' * len(header_hes)) 233 | for typ, case, mc, an, err, pct, tmc in results_hes: 234 | print( 235 | f"{typ:<6}{case:<6}{mc:12.6f}{an:14.6f}" 236 | f"{err:12.6f}{pct:10.2f}{tmc:12.4f}" 237 | ) 238 | 239 | # 4) American options: MC (LSM) vs CRR binomial 240 | results_amer = [] 241 | for opt_type, payoff_cls, case, S0_case in scenarios: 242 | payoff = payoff_cls(args.K) 243 | am_mc = LongstaffSchwartzPricer(model, payoff, 244 | n_paths=args.n_paths, 245 | n_steps=args.n_steps, 246 | seed=args.seed) 247 | t0 = time.time() 248 | price_am_mc, stderr_am = am_mc.price(S0_case, args.T, args.r) 249 | t_mc_am = time.time() - t0 250 | 251 | am_crr = AmericanBinomialPricer(model, payoff, n_steps=args.n_tree) 252 | t0 = time.time() 253 | price_crr = am_crr.price(S0_case, args.T, args.r) 254 | t_crr = time.time() - t0 255 | 256 | abs_err = abs(price_am_mc - price_crr) 257 | pct_err = abs_err / price_crr * \ 258 | 100.0 if price_crr != 0 else float("nan") 259 | results_amer.append((opt_type.capitalize(), case, 260 | price_am_mc, price_crr, 261 | abs_err, pct_err, 262 | t_mc_am, t_crr)) 263 | 264 | # Print American results 265 | print("\nAmerican option pricing (LSM MC vs CRR):") 266 | header_amer = ( 267 | f"{'Type':<6}{'Case':<6}" 268 | f"{'MC Price':>12}{'CRR Price':>12}{'Abs Err':>12}{'% Err':>10}" 269 | f"{'MC Time(s)':>12} {'Tree Time(s)':>12}" 270 | ) 271 | print(header_amer) 272 | print('-' * len(header_amer)) 273 | for typ, case, mc, crr, err, pct, tmc, tcrr in results_amer: 274 | print( 275 | f"{typ:<6}{case:<6}{mc:12.6f}{crr:12.6f}" 276 | f"{err:12.6f}{pct:10.2f}{tmc:12.4f} {tcrr:12.4f}" 277 | ) 278 | 279 | 280 | if __name__ == "__main__": 281 | main() 282 | --------------------------------------------------------------------------------