finmat 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- finmat-0.1.0.dist-info/METADATA +98 -0
- finmat-0.1.0.dist-info/RECORD +23 -0
- finmat-0.1.0.dist-info/WHEEL +5 -0
- finmat-0.1.0.dist-info/top_level.txt +5 -0
- fixed_income/__init__.py +1 -0
- fixed_income/fixed_income.py +27 -0
- options/__init__.py +2 -0
- options/iv.py +33 -0
- options/option.py +23 -0
- portfolio/__init__.py +1 -0
- portfolio/metrics.py +43 -0
- pricing/__init__.py +4 -0
- pricing/black_scholes.py +107 -0
- pricing/heston.py +44 -0
- pricing/monte_carlo.py +33 -0
- pricing/trees.py +37 -0
- processes/__init__.py +6 -0
- processes/abm.py +44 -0
- processes/cir.py +46 -0
- processes/gbm.py +44 -0
- processes/jump_diffusion.py +54 -0
- processes/ou.py +46 -0
- processes/vasicek.py +48 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finmat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A financial math library for options pricing, stochastic processes, and fixed income analytics
|
|
5
|
+
Author-email: Daniel Butler <danielbutler245@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dbu79/fin-mat-library
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: scipy
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
|
|
14
|
+
# Financial Math Library
|
|
15
|
+
|
|
16
|
+
A Python library for pricing derivatives, simulating stochastic processes, and analyzing fixed income instruments.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Options Pricing**
|
|
21
|
+
- Black-Scholes analytical pricing
|
|
22
|
+
- Heston stochastic volatility model
|
|
23
|
+
- Monte Carlo simulation
|
|
24
|
+
- Binomial/trinomial trees
|
|
25
|
+
- Implied volatility calculation
|
|
26
|
+
|
|
27
|
+
- **Stochastic Processes**
|
|
28
|
+
- Arithmetic Brownian Motion (ABM)
|
|
29
|
+
- Geometric Brownian Motion (GBM)
|
|
30
|
+
- Cox-Ingersoll-Ross (CIR)
|
|
31
|
+
- Ornstein-Uhlenbeck (OU)
|
|
32
|
+
- Vasicek
|
|
33
|
+
- Jump diffusion (Merton)
|
|
34
|
+
|
|
35
|
+
- **Fixed Income**
|
|
36
|
+
- Bond pricing and yield curve tools
|
|
37
|
+
|
|
38
|
+
- **Portfolio**
|
|
39
|
+
- Risk and performance metrics
|
|
40
|
+
|
|
41
|
+
## Project Structure
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
fixed_income/
|
|
45
|
+
fixed_income.py # Fixed income pricing and analytics
|
|
46
|
+
|
|
47
|
+
options/
|
|
48
|
+
iv.py # Implied volatility solver
|
|
49
|
+
option.py # Option contract definitions
|
|
50
|
+
|
|
51
|
+
portfolio/
|
|
52
|
+
metrics.py # Portfolio risk/performance metrics
|
|
53
|
+
|
|
54
|
+
pricing/
|
|
55
|
+
black_scholes.py # Black-Scholes model
|
|
56
|
+
heston.py # Heston stochastic volatility model
|
|
57
|
+
monte_carlo.py # Monte Carlo pricing engine
|
|
58
|
+
trees.py # Binomial/trinomial tree pricing
|
|
59
|
+
|
|
60
|
+
processes/
|
|
61
|
+
abm.py # Arithmetic Brownian Motion
|
|
62
|
+
cir.py # Cox-Ingersoll-Ross process
|
|
63
|
+
gbm.py # Geometric Brownian Motion
|
|
64
|
+
jump_diffusion.py # Jump diffusion process
|
|
65
|
+
ou.py # Ornstein-Uhlenbeck process
|
|
66
|
+
vasicek.py # Vasicek interest rate model
|
|
67
|
+
|
|
68
|
+
testing.ipynb # Example usage and validation notebook
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git clone <repo-url>
|
|
75
|
+
cd <repo-name>
|
|
76
|
+
pip install -r requirements.txt
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
See `testing.ipynb` for example workflows, including:
|
|
82
|
+
|
|
83
|
+
- Pricing European options with Black-Scholes and comparing to Monte Carlo
|
|
84
|
+
- Simulating asset paths with GBM, CIR, and jump diffusion processes
|
|
85
|
+
- Calibrating the Heston model to market data
|
|
86
|
+
- Computing implied volatility surfaces
|
|
87
|
+
- Evaluating fixed income instruments and portfolio metrics
|
|
88
|
+
|
|
89
|
+
## Requirements
|
|
90
|
+
|
|
91
|
+
- Python 3.8+
|
|
92
|
+
- NumPy
|
|
93
|
+
- SciPy
|
|
94
|
+
- matplotlib (for notebook visualizations)
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
fixed_income/__init__.py,sha256=-M03eqOgKmpDdcE76SG2NOOlcv07Muaes_WgEgRSwdk,135
|
|
2
|
+
fixed_income/fixed_income.py,sha256=nmy1c0H-GogKWjTaEcbbVvBdKsnY0Dbcwb4BAN7O5fg,1047
|
|
3
|
+
options/__init__.py,sha256=wgfUg5YBENTIG-caoRvS15_zi9rgt2A6onunFwT2d24,61
|
|
4
|
+
options/iv.py,sha256=UgRGXKrMz4_Bu27dyy-rQJi9P9s7Jn3HYY5AptQmIjM,1143
|
|
5
|
+
options/option.py,sha256=eZZKQn2oqOKi49QtQ5efZXoyDLcoNUx5g9tphc75mJM,845
|
|
6
|
+
portfolio/__init__.py,sha256=6FryqsNsq8vrr9goVaWJ_9P9DbrTC2jnLNYHzGA7pVM,36
|
|
7
|
+
portfolio/metrics.py,sha256=-ppV2CQlml3ejYCwkDAzYzayOSh5wLFZpb4RXsPr2os,1670
|
|
8
|
+
pricing/__init__.py,sha256=A35P97bWugX7mqbDQaiz4CqK2X9I78SovuGd7wmrZNo,166
|
|
9
|
+
pricing/black_scholes.py,sha256=l60HxrasZGn_qzNXE_HvIhYYesJXkTqJIMUA6vZwgNY,3350
|
|
10
|
+
pricing/heston.py,sha256=7OEtU-cu9IpkCw62tuzHugkROsh6gVwkib4isdHYZww,1397
|
|
11
|
+
pricing/monte_carlo.py,sha256=XcbrK9wVpZDUsu2EjecN7kFl2nPPmymJ13BE45_9lm0,1336
|
|
12
|
+
pricing/trees.py,sha256=YIpaBlNyW5EODsP1gWl-byawwn81zN4quYft3o0fSj4,1446
|
|
13
|
+
processes/__init__.py,sha256=dPljFrEtNr4EHmeGWgiP1PEPPiazInuu_ZGzn9R_HM4,231
|
|
14
|
+
processes/abm.py,sha256=XpuqxCrk2MASU6w6xW2lS0SigbB1JzEJx7ZDOA2OZ4w,1511
|
|
15
|
+
processes/cir.py,sha256=4SHR8Y4OpvKHM1BwPDWKfoeugLMcguwDvwbpQYQgk1Y,1623
|
|
16
|
+
processes/gbm.py,sha256=gG0U6P8syxy1iA-FXmvK_sLsXxdvyfWUhq6-PH4KY7Q,1553
|
|
17
|
+
processes/jump_diffusion.py,sha256=s7eJ-dmEB-oHk7AvS0Q7dzQ4uhWcspI8wd1HfAhTIC0,2022
|
|
18
|
+
processes/ou.py,sha256=oKM3XYXRl9ZeD1YiKYjAKZnJfcy18KTun1_juHC5y-4,1638
|
|
19
|
+
processes/vasicek.py,sha256=M6zRIY2KB_cu6DicRHt1-C93_3zoIJEqXHcBPGUE-_0,1792
|
|
20
|
+
finmat-0.1.0.dist-info/METADATA,sha256=j5sC7OQWf20qHh5v009x2HHIinDUdjWBXd2c2xkP1Bs,2798
|
|
21
|
+
finmat-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
22
|
+
finmat-0.1.0.dist-info/top_level.txt,sha256=O5eTQpngFkm0pXOfoaknqSOt5dQpMYLPLDqFPJCIYrg,49
|
|
23
|
+
finmat-0.1.0.dist-info/RECORD,,
|
fixed_income/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from fixed_income import simple_interest, compound_interest, future_value, present_value, annuity_pv, annuity_fv, bond_price, convexity
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
def simple_interest(P: float, r: float, t: float) -> float:
|
|
2
|
+
return P * (1 + r * t)
|
|
3
|
+
|
|
4
|
+
def compound_interest(P: float, r: float, n: int, t: float) -> float:
|
|
5
|
+
return P * (1 + r/n)**(n*t)
|
|
6
|
+
|
|
7
|
+
def future_value(PV: float, r: float, n: int) -> float:
|
|
8
|
+
return PV * (1 + r)**n
|
|
9
|
+
|
|
10
|
+
def present_value(FV: float, r: float, n: int) -> float:
|
|
11
|
+
return FV / (1 + r)**n
|
|
12
|
+
|
|
13
|
+
def annuity_pv(PMT: float, r: float, n: int) -> float:
|
|
14
|
+
return PMT * (1 - (1 + r)**-n) / r
|
|
15
|
+
|
|
16
|
+
def annuity_fv(PMT: float, r: float, n: int) -> float:
|
|
17
|
+
return PMT * ((1 + r)**n - 1) / r
|
|
18
|
+
|
|
19
|
+
def bond_price(PMT: float, FV: float, r: float, n: int) -> float:
|
|
20
|
+
"""PMT: coupon per period, FV: face value, r: yield per period, n: number of periods"""
|
|
21
|
+
coupons = sum(PMT / (1 + r)**t for t in range(1, n + 1))
|
|
22
|
+
principal = FV / (1 + r)**n
|
|
23
|
+
return coupons + principal
|
|
24
|
+
|
|
25
|
+
def convexity(P0: float, P_plus: float, P_minus: float, dy: float) -> float:
|
|
26
|
+
"""P_plus/P_minus: prices after +dy/-dy yield shock"""
|
|
27
|
+
return (P_plus + P_minus - 2 * P0) / (P0 * dy**2)
|
options/__init__.py
ADDED
options/iv.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from .option import Option
|
|
4
|
+
|
|
5
|
+
class ImpliedVolatility:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def solve(option: Option, market_price, pricer, initial=0.2, tol=1e-8, max_iter=100, h=1e-4, seed=None):
|
|
8
|
+
sigma = initial
|
|
9
|
+
copy_ = deepcopy(option)
|
|
10
|
+
def price_at(sigma_val):
|
|
11
|
+
copy_.sigma = sigma_val
|
|
12
|
+
if seed is not None:
|
|
13
|
+
np.random.seed(seed)
|
|
14
|
+
return pricer(copy_)
|
|
15
|
+
|
|
16
|
+
for _ in range(max_iter):
|
|
17
|
+
model_price = price_at(sigma)
|
|
18
|
+
|
|
19
|
+
if abs(model_price - market_price) < tol:
|
|
20
|
+
return sigma
|
|
21
|
+
|
|
22
|
+
price_up = price_at(sigma + h)
|
|
23
|
+
price_down = price_at(sigma - h)
|
|
24
|
+
vega = (price_up - price_down) / (2 * h)
|
|
25
|
+
if abs(vega) < 1e-10 or not np.isfinite(vega):
|
|
26
|
+
return np.nan
|
|
27
|
+
|
|
28
|
+
sigma_new = sigma - (model_price - market_price) / vega
|
|
29
|
+
if not np.isfinite(sigma_new) or sigma_new <= 0:
|
|
30
|
+
return np.nan
|
|
31
|
+
sigma = sigma_new
|
|
32
|
+
|
|
33
|
+
return np.nan
|
options/option.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class Option:
|
|
2
|
+
"""European option contract with Black-Scholes-style parameters."""
|
|
3
|
+
|
|
4
|
+
VALID_TYPES = ('call', 'put')
|
|
5
|
+
|
|
6
|
+
def __init__(self, S: float, K: float, T: float, r: float, sigma: float, opt_type: str = 'call'):
|
|
7
|
+
if S <= 0:
|
|
8
|
+
raise ValueError("Spot price must be greater than 0")
|
|
9
|
+
if K <= 0:
|
|
10
|
+
raise ValueError("Strike price must be greater than 0")
|
|
11
|
+
if T < 0:
|
|
12
|
+
raise ValueError("Time to expiry must be non-negative")
|
|
13
|
+
if sigma <= 0:
|
|
14
|
+
raise ValueError("Sigma must be greater than 0")
|
|
15
|
+
if opt_type not in self.VALID_TYPES:
|
|
16
|
+
raise ValueError(f"opt_type must be one of {self.VALID_TYPES}")
|
|
17
|
+
|
|
18
|
+
self.S = S
|
|
19
|
+
self.K = K
|
|
20
|
+
self.T = T
|
|
21
|
+
self.r = r
|
|
22
|
+
self.sigma = sigma
|
|
23
|
+
self.opt_type = opt_type
|
portfolio/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from metrics import PortfolioMetrics
|
portfolio/metrics.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PortfolioMetrics:
|
|
5
|
+
"""Performance/risk metrics for a daily returns series."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, returns, risk_free_rate: float = 0.0):
|
|
8
|
+
self.returns = np.asarray(returns, dtype=float)
|
|
9
|
+
self.risk_free_rate = risk_free_rate # annualized
|
|
10
|
+
|
|
11
|
+
def annualized_return(self) -> float:
|
|
12
|
+
return np.mean(self.returns) * 252
|
|
13
|
+
|
|
14
|
+
def annualized_volatility(self) -> float:
|
|
15
|
+
return np.std(self.returns, ddof=1) * np.sqrt(252)
|
|
16
|
+
|
|
17
|
+
def sharpe_ratio(self) -> float:
|
|
18
|
+
vol = self.annualized_volatility()
|
|
19
|
+
return (self.annualized_return() - self.risk_free_rate) / vol if vol != 0 else np.inf
|
|
20
|
+
|
|
21
|
+
def downside_deviation(self) -> float:
|
|
22
|
+
daily_rf = self.risk_free_rate / 252
|
|
23
|
+
downside = self.returns[self.returns < daily_rf]
|
|
24
|
+
if len(downside) == 0:
|
|
25
|
+
return 0.0
|
|
26
|
+
return np.sqrt(np.mean((downside - daily_rf)**2)) * np.sqrt(252)
|
|
27
|
+
|
|
28
|
+
def sortino_ratio(self) -> float:
|
|
29
|
+
downside_vol = self.downside_deviation()
|
|
30
|
+
if downside_vol == 0:
|
|
31
|
+
return np.inf
|
|
32
|
+
return (self.annualized_return() - self.risk_free_rate) / downside_vol
|
|
33
|
+
|
|
34
|
+
def max_drawdown(self) -> float:
|
|
35
|
+
"""Assumes self.returns is chronologically ordered."""
|
|
36
|
+
cumulative_returns = np.cumprod(1 + self.returns)
|
|
37
|
+
running_max = np.maximum.accumulate(cumulative_returns)
|
|
38
|
+
drawdowns = (cumulative_returns - running_max) / running_max
|
|
39
|
+
return np.min(drawdowns)
|
|
40
|
+
|
|
41
|
+
def calmar_ratio(self) -> float:
|
|
42
|
+
max_dd = self.max_drawdown()
|
|
43
|
+
return self.annualized_return() / abs(max_dd) if max_dd < 0 else np.inf
|
pricing/__init__.py
ADDED
pricing/black_scholes.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from options.option import Option
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.stats import norm
|
|
4
|
+
|
|
5
|
+
class BlackScholesPricer:
|
|
6
|
+
"""Black-Scholes pricing and Greeks for European Options."""
|
|
7
|
+
@staticmethod
|
|
8
|
+
def price(option: Option):
|
|
9
|
+
S = option.S
|
|
10
|
+
K = option.K
|
|
11
|
+
T = option.T
|
|
12
|
+
r = option.r
|
|
13
|
+
sigma = option.sigma
|
|
14
|
+
opt_type = option.opt_type
|
|
15
|
+
|
|
16
|
+
d1 = (np.log(S / K) + (r + (sigma**2)/2)*T)/(sigma*np.sqrt(T))
|
|
17
|
+
d2 = d1 - sigma*np.sqrt(T)
|
|
18
|
+
|
|
19
|
+
if opt_type == 'call':
|
|
20
|
+
return S * norm.cdf(d1) - K * np.exp(-r*T) * norm.cdf(d2)
|
|
21
|
+
elif opt_type == 'put':
|
|
22
|
+
return K * np.exp(-r*T) * norm.cdf(-d2) - S * norm.cdf(-d1)
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError("Valid types are: 'call' and 'put'")
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def delta(option: Option) -> float:
|
|
28
|
+
S = option.S
|
|
29
|
+
K = option.K
|
|
30
|
+
T = option.T
|
|
31
|
+
r = option.r
|
|
32
|
+
sigma = option.sigma
|
|
33
|
+
opt_type = option.opt_type
|
|
34
|
+
|
|
35
|
+
d1 = (np.log(S / K) + (r + (sigma**2)/2)*T)/(sigma*np.sqrt(T))
|
|
36
|
+
|
|
37
|
+
if opt_type == 'call':
|
|
38
|
+
return norm.cdf(d1)
|
|
39
|
+
elif opt_type == 'put':
|
|
40
|
+
return norm.cdf(d1) - 1
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError("Valid types are: 'call' and 'put'")
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def gamma(option: Option) -> float:
|
|
46
|
+
S = option.S
|
|
47
|
+
K = option.K
|
|
48
|
+
T = option.T
|
|
49
|
+
r = option.r
|
|
50
|
+
sigma = option.sigma
|
|
51
|
+
|
|
52
|
+
d1 = (np.log(S / K) + (r + (sigma**2)/2)*T)/(sigma*np.sqrt(T))
|
|
53
|
+
return norm.pdf(d1)/(S * sigma * np.sqrt(T))
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def vega(option: Option) -> float:
|
|
57
|
+
"""Per 1% change in volatility"""
|
|
58
|
+
S = option.S
|
|
59
|
+
K = option.K
|
|
60
|
+
T = option.T
|
|
61
|
+
r = option.r
|
|
62
|
+
sigma = option.sigma
|
|
63
|
+
|
|
64
|
+
d1 = (np.log(S / K) + (r + (sigma**2)/2)*T)/(sigma*np.sqrt(T))
|
|
65
|
+
return S * norm.pdf(d1) * np.sqrt(T) / 100
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def theta(option: Option) -> float:
|
|
69
|
+
"""Per day"""
|
|
70
|
+
S = option.S
|
|
71
|
+
K = option.K
|
|
72
|
+
T = option.T
|
|
73
|
+
r = option.r
|
|
74
|
+
sigma = option.sigma
|
|
75
|
+
opt_type = option.opt_type
|
|
76
|
+
|
|
77
|
+
d1 = (np.log(S / K) + (r + (sigma**2)/2)*T)/(sigma*np.sqrt(T))
|
|
78
|
+
d2 = d1 - sigma*np.sqrt(T)
|
|
79
|
+
|
|
80
|
+
base_theta = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T))
|
|
81
|
+
if opt_type == 'call':
|
|
82
|
+
return (base_theta - (r * K * np.exp(-r * T) * norm.cdf(d2)))/365
|
|
83
|
+
elif opt_type == 'put':
|
|
84
|
+
return (base_theta + (r * K * np.exp(-r * T) * norm.cdf(-d2)))/365
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError("Valid types are 'call' and 'put'")
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def rho(option: Option) -> float:
|
|
90
|
+
"""Per 1% change in interest rate"""
|
|
91
|
+
S = option.S
|
|
92
|
+
K = option.K
|
|
93
|
+
T = option.T
|
|
94
|
+
r = option.r
|
|
95
|
+
sigma = option.sigma
|
|
96
|
+
opt_type = option.opt_type
|
|
97
|
+
|
|
98
|
+
d1 = (np.log(S / K) + (r + (sigma**2)/2)*T)/(sigma*np.sqrt(T))
|
|
99
|
+
d2 = d1 - sigma*np.sqrt(T)
|
|
100
|
+
|
|
101
|
+
if opt_type == 'call':
|
|
102
|
+
return K * T * np.exp(-r*T) * norm.cdf(d2) / 100
|
|
103
|
+
elif opt_type == 'put':
|
|
104
|
+
return -K * T * np.exp(-r * T) * norm.cdf(-d2) / 100
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError("Valid types are 'call' and 'put'")
|
|
107
|
+
|
pricing/heston.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from options.option import Option
|
|
3
|
+
|
|
4
|
+
class HestonPricer:
|
|
5
|
+
"""Heston model for option pricing."""
|
|
6
|
+
@staticmethod
|
|
7
|
+
def price(
|
|
8
|
+
option: Option,
|
|
9
|
+
kappa: float,
|
|
10
|
+
theta: float,
|
|
11
|
+
sigma: float,
|
|
12
|
+
rho: float, v0:
|
|
13
|
+
float,
|
|
14
|
+
n_paths: int = 10000,
|
|
15
|
+
n_steps: int = 252) -> float:
|
|
16
|
+
S0 = option.S
|
|
17
|
+
K = option.K
|
|
18
|
+
T = option.T
|
|
19
|
+
r = option.r
|
|
20
|
+
opt_type = option.opt_type
|
|
21
|
+
|
|
22
|
+
dt = T / n_steps
|
|
23
|
+
|
|
24
|
+
dW1 = np.random.normal(0, np.sqrt(dt), (n_paths, n_steps))
|
|
25
|
+
dW2 = rho * dW1 + np.sqrt(1 - rho**2) * np.random.normal(0, np.sqrt(dt), (n_paths, n_steps))
|
|
26
|
+
|
|
27
|
+
S = np.zeros((n_paths, n_steps + 1))
|
|
28
|
+
v = np.zeros((n_paths, n_steps + 1))
|
|
29
|
+
|
|
30
|
+
S[:, 0] = S0
|
|
31
|
+
v[:, 0] = v0
|
|
32
|
+
|
|
33
|
+
for i in range(1, n_steps + 1):
|
|
34
|
+
S[:, i] = S[:, i - 1] * np.exp((r - 0.5 * v[:, i - 1]) * dt + np.sqrt(v[:, i - 1]) * dW1[:, i - 1])
|
|
35
|
+
sqrt_v = np.sqrt(np.maximum(v[:, i-1], 0))
|
|
36
|
+
v_next = (v[:, i-1] + kappa*(theta - v[:, i-1])*dt + sigma*sqrt_v*dW2[:, i-1])
|
|
37
|
+
v[:, i] = np.maximum(v_next, 0)
|
|
38
|
+
|
|
39
|
+
S_t = S[:, -1]
|
|
40
|
+
payoff = np.maximum(S_t - K, 0) if opt_type == 'call' else np.maximum(K - S_t, 0)
|
|
41
|
+
price = np.exp(-r * T) * np.mean(payoff)
|
|
42
|
+
return price
|
|
43
|
+
|
|
44
|
+
|
pricing/monte_carlo.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from processes.gbm import GeometricBrownianMotion
|
|
2
|
+
import numpy as np
|
|
3
|
+
from options.option import Option
|
|
4
|
+
|
|
5
|
+
class MonteCarloPricer:
|
|
6
|
+
"""Monte Carlo pricing for European and Asian options."""
|
|
7
|
+
@staticmethod
|
|
8
|
+
def price_european(option: Option, n_paths: int = 10000, n_steps: int = 252) -> float:
|
|
9
|
+
gbm = GeometricBrownianMotion(S0=option.S, mu=option.r, sigma=option.sigma)
|
|
10
|
+
paths = gbm.sim_paths(T=option.T, dt=option.T/n_steps, n_paths=n_paths)
|
|
11
|
+
S_t = paths[:, -1]
|
|
12
|
+
|
|
13
|
+
if option.opt_type == 'call':
|
|
14
|
+
payoffs = np.maximum(S_t - option.K, 0)
|
|
15
|
+
else:
|
|
16
|
+
payoffs = np.maximum(option.K - S_t, 0)
|
|
17
|
+
|
|
18
|
+
price = np.exp(-option.r * option.T) * np.mean(payoffs)
|
|
19
|
+
return price
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def price_asian(option: Option, n_paths: int = 10000, n_steps: int = 252) -> float:
|
|
23
|
+
gbm = GeometricBrownianMotion(S0=option.S, mu=option.r, sigma=option.sigma)
|
|
24
|
+
paths = gbm.sim_paths(T=option.T, dt=option.T/n_steps, n_paths=n_paths)
|
|
25
|
+
path_avg = np.mean(paths, axis=1)
|
|
26
|
+
|
|
27
|
+
if option.opt_type == 'call':
|
|
28
|
+
payoffs = np.maximum(path_avg - option.K, 0)
|
|
29
|
+
else:
|
|
30
|
+
payoffs = np.maximum(option.K - path_avg, 0)
|
|
31
|
+
|
|
32
|
+
price = np.exp(-option.r * option.T) * np.mean(payoffs)
|
|
33
|
+
return price
|
pricing/trees.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from options.option import Option
|
|
3
|
+
|
|
4
|
+
class BinomialTreePricer:
|
|
5
|
+
"""Binomial tree pricing for American options."""
|
|
6
|
+
@staticmethod
|
|
7
|
+
def price_american(option: Option, u: float, d: float, n_steps: int = 100) -> float:
|
|
8
|
+
S0 = option.S
|
|
9
|
+
K = option.K
|
|
10
|
+
T = option.T
|
|
11
|
+
r = option.r
|
|
12
|
+
opt_type = option.opt_type
|
|
13
|
+
|
|
14
|
+
dt = T / n_steps
|
|
15
|
+
q = (np.exp(r * dt) - d) / (u - d)
|
|
16
|
+
discount = np.exp(-r * dt)
|
|
17
|
+
|
|
18
|
+
asset_prices = np.zeros(n_steps + 1)
|
|
19
|
+
for i in range(n_steps + 1):
|
|
20
|
+
asset_prices[i] = S0 * (u ** i) * (d ** (n_steps - i))
|
|
21
|
+
|
|
22
|
+
option_values = np.zeros(n_steps + 1)
|
|
23
|
+
for i in range(n_steps + 1):
|
|
24
|
+
if opt_type == 'call':
|
|
25
|
+
option_values[i] = np.maximum(asset_prices[i] - K, 0)
|
|
26
|
+
else:
|
|
27
|
+
option_values[i] = np.maximum(K - asset_prices[i], 0)
|
|
28
|
+
|
|
29
|
+
for i in np.arange(n_steps - 1, -1, -1):
|
|
30
|
+
for j in range(i + 1):
|
|
31
|
+
option_values[j] = discount * (q * option_values[j + 1] + (1 - q) * option_values[j])
|
|
32
|
+
asset_price = S0 * (u ** j) * (d ** (i - j))
|
|
33
|
+
if opt_type == 'call':
|
|
34
|
+
option_values[j] = np.maximum(option_values[j], asset_price - K)
|
|
35
|
+
else:
|
|
36
|
+
option_values[j] = np.maximum(option_values[j], K - asset_price)
|
|
37
|
+
return option_values[0]
|
processes/__init__.py
ADDED
processes/abm.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
class ArithmeticBrownianMotion:
|
|
5
|
+
"""Arithmetic Brownian Motion (ABM) process."""
|
|
6
|
+
def __init__(self, S0: float, mu: float, sigma: float):
|
|
7
|
+
self.S0 = S0
|
|
8
|
+
self.mu = mu
|
|
9
|
+
self.sigma = sigma
|
|
10
|
+
|
|
11
|
+
def sim_paths(self, dt: float, T: float, n_paths: int) -> np.ndarray:
|
|
12
|
+
n_steps = int(T / dt)
|
|
13
|
+
paths = np.zeros((n_paths, n_steps + 1))
|
|
14
|
+
|
|
15
|
+
dW = np.random.normal(0, 1, (n_paths, n_steps))
|
|
16
|
+
paths[:, 0] = self.S0
|
|
17
|
+
|
|
18
|
+
for i in range(1, n_steps + 1):
|
|
19
|
+
paths[:, i] = paths[:, i-1] + self.mu*dt + self.sigma*np.sqrt(dt)*dW[:, i - 1]
|
|
20
|
+
|
|
21
|
+
return paths
|
|
22
|
+
|
|
23
|
+
def plot_paths(self, paths, title='ABM Simulated Paths', xlabel='Steps', ylabel='Price', average=True, largest=True, smallest=True):
|
|
24
|
+
fig, ax = plt.subplots()
|
|
25
|
+
|
|
26
|
+
for path in paths:
|
|
27
|
+
ax.plot(path, color='#1f77b4', linewidth=0.75, alpha=0.5)
|
|
28
|
+
|
|
29
|
+
average_ = np.mean(paths, axis=0)
|
|
30
|
+
largest_ = np.max(paths, axis=0)
|
|
31
|
+
smallest_ = np.min(paths, axis=0)
|
|
32
|
+
if average:
|
|
33
|
+
ax.plot(average_, color='purple', linewidth=0.8, alpha=0.4)
|
|
34
|
+
if largest:
|
|
35
|
+
ax.plot(largest_, color='green', linewidth=0.8, alpha=0.4)
|
|
36
|
+
if smallest:
|
|
37
|
+
ax.plot(smallest_, color="red", linewidth=0.8, alpha=0.4)
|
|
38
|
+
|
|
39
|
+
ax.set_title(title)
|
|
40
|
+
ax.set_xlabel(xlabel)
|
|
41
|
+
ax.set_ylabel(ylabel)
|
|
42
|
+
plt.show()
|
|
43
|
+
|
|
44
|
+
|
processes/cir.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
class CoxIngersollRoss:
|
|
5
|
+
"""Cox-Ingersoll-Ross (CIR) process."""
|
|
6
|
+
def __init__(self, r0: float, a: float, b: float, sigma: float):
|
|
7
|
+
self.r0 = r0
|
|
8
|
+
self.a = a
|
|
9
|
+
self.b = b
|
|
10
|
+
self.sigma = sigma
|
|
11
|
+
|
|
12
|
+
def sim_paths(self, T: float, dt: float, n_paths: int) -> np.ndarray:
|
|
13
|
+
n_steps = int(T / dt)
|
|
14
|
+
paths = np.zeros((n_paths, n_steps + 1))
|
|
15
|
+
paths[:, 0] = self.r0
|
|
16
|
+
|
|
17
|
+
sqrt_dt = np.sqrt(dt)
|
|
18
|
+
for i in range(1, n_steps + 1):
|
|
19
|
+
r_prev = paths[:, i - 1]
|
|
20
|
+
z = np.random.normal(size=n_paths)
|
|
21
|
+
dr = (self.a * (self.b - r_prev) * dt
|
|
22
|
+
+ self.sigma * np.sqrt(r_prev) * sqrt_dt * z)
|
|
23
|
+
paths[:, i] = np.maximum(r_prev + dr, 0)
|
|
24
|
+
|
|
25
|
+
return paths
|
|
26
|
+
|
|
27
|
+
def plot_paths(self, paths, title='CIR Simulated Paths', xlabel='Steps', ylabel='Rate', average=True, largest=True, smallest=True):
|
|
28
|
+
fig, ax = plt.subplots()
|
|
29
|
+
|
|
30
|
+
for path in paths:
|
|
31
|
+
ax.plot(path, color='#1f77b4', linewidth=0.75, alpha=0.5)
|
|
32
|
+
|
|
33
|
+
average_ = np.mean(paths, axis=0)
|
|
34
|
+
largest_ = np.max(paths, axis=0)
|
|
35
|
+
smallest_ = np.min(paths, axis=0)
|
|
36
|
+
if average:
|
|
37
|
+
ax.plot(average_, color='purple', linewidth=0.8, alpha=0.4)
|
|
38
|
+
if largest:
|
|
39
|
+
ax.plot(largest_, color='green', linewidth=0.8, alpha=0.4)
|
|
40
|
+
if smallest:
|
|
41
|
+
ax.plot(smallest_, color="red", linewidth=0.8, alpha=0.4)
|
|
42
|
+
|
|
43
|
+
ax.set_title(title)
|
|
44
|
+
ax.set_xlabel(xlabel)
|
|
45
|
+
ax.set_ylabel(ylabel)
|
|
46
|
+
plt.show()
|
processes/gbm.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
class GeometricBrownianMotion:
|
|
5
|
+
"""Geometric Brownian Motion (GBM) process."""
|
|
6
|
+
def __init__(self, S0: float, mu: float, sigma: float):
|
|
7
|
+
self.S0 = S0
|
|
8
|
+
self.mu = mu
|
|
9
|
+
self.sigma = sigma
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sim_paths(self, T: float, dt: float, n_paths: int) -> np.ndarray:
|
|
13
|
+
n_steps = int(T / dt)
|
|
14
|
+
paths = np.zeros((n_paths, n_steps + 1))
|
|
15
|
+
paths[:, 0] = self.S0
|
|
16
|
+
|
|
17
|
+
dW = np.random.normal(0, np.sqrt(dt), (n_paths, n_steps))
|
|
18
|
+
|
|
19
|
+
for i in range(1, n_steps + 1):
|
|
20
|
+
paths[:, i] = paths[:, i-1] * np.exp(
|
|
21
|
+
(self.mu - 0.5*self.sigma**2)*dt + self.sigma*dW[:, i-1]
|
|
22
|
+
)
|
|
23
|
+
return paths
|
|
24
|
+
|
|
25
|
+
def plot_paths(self, paths, title='GBM Simulated Paths', xlabel='Steps', ylabel='Price', average=True, largest=True, smallest=True):
|
|
26
|
+
fig, ax = plt.subplots()
|
|
27
|
+
|
|
28
|
+
for path in paths:
|
|
29
|
+
ax.plot(path, color='#1f77b4', linewidth=0.75, alpha=0.5)
|
|
30
|
+
|
|
31
|
+
average_ = np.mean(paths, axis=0)
|
|
32
|
+
largest_ = np.max(paths, axis=0)
|
|
33
|
+
smallest_ = np.min(paths, axis=0)
|
|
34
|
+
if average:
|
|
35
|
+
ax.plot(average_, color='purple', linewidth=0.8, alpha=0.4)
|
|
36
|
+
if largest:
|
|
37
|
+
ax.plot(largest_, color='green', linewidth=0.8, alpha=0.4)
|
|
38
|
+
if smallest:
|
|
39
|
+
ax.plot(smallest_, color="red", linewidth=0.8, alpha=0.4)
|
|
40
|
+
|
|
41
|
+
ax.set_title(title)
|
|
42
|
+
ax.set_xlabel(xlabel)
|
|
43
|
+
ax.set_ylabel(ylabel)
|
|
44
|
+
plt.show()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
class JumpDiffusion:
|
|
5
|
+
"""Jump Diffusion process."""
|
|
6
|
+
def __init__(self, S0: float, mu: float, sigma: float, lambda_j: float, mu_j: float, sigma_j: float):
|
|
7
|
+
self.S0 = S0
|
|
8
|
+
self.mu = mu
|
|
9
|
+
self.sigma = sigma
|
|
10
|
+
self.lambda_j = lambda_j
|
|
11
|
+
self.mu_j = mu_j
|
|
12
|
+
self.sigma_j = sigma_j
|
|
13
|
+
|
|
14
|
+
def sim_paths(self, T: float, dt: float, n_paths: int) -> np.ndarray:
|
|
15
|
+
n_steps = int(T / dt)
|
|
16
|
+
paths = np.zeros((n_paths, n_steps + 1))
|
|
17
|
+
paths[:, 0] = self.S0
|
|
18
|
+
|
|
19
|
+
dW = np.random.normal(0, np.sqrt(dt), (n_paths, n_steps))
|
|
20
|
+
|
|
21
|
+
n_jumps = np.random.poisson(self.lambda_j * dt, (n_paths, n_steps))
|
|
22
|
+
jump_mean = n_jumps * self.mu_j
|
|
23
|
+
jump_std = np.sqrt(n_jumps) * self.sigma_j
|
|
24
|
+
log_jumps = np.random.normal(jump_mean, np.where(jump_std > 0, jump_std, 1e-12))
|
|
25
|
+
log_jumps = np.where(n_jumps > 0, log_jumps, 0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
for i in range(1, n_steps + 1):
|
|
30
|
+
paths[:, i] = paths[:, i-1] * np.exp(
|
|
31
|
+
(self.mu - 0.5*self.sigma**2)*dt + self.sigma*dW[:, i-1] + log_jumps[:, i-1]
|
|
32
|
+
)
|
|
33
|
+
return paths
|
|
34
|
+
|
|
35
|
+
def plot_paths(self, paths, title='Jump Diffusion Simulated Paths', xlabel='Steps', ylabel='Price', average=True, largest=True, smallest=True):
|
|
36
|
+
fig, ax = plt.subplots()
|
|
37
|
+
|
|
38
|
+
for path in paths:
|
|
39
|
+
ax.plot(path, color='#1f77b4', linewidth=0.75, alpha=0.5)
|
|
40
|
+
|
|
41
|
+
average_ = np.mean(paths, axis=0)
|
|
42
|
+
largest_ = np.max(paths, axis=0)
|
|
43
|
+
smallest_ = np.min(paths, axis=0)
|
|
44
|
+
if average:
|
|
45
|
+
ax.plot(average_, color='purple', linewidth=0.8, alpha=0.4)
|
|
46
|
+
if largest:
|
|
47
|
+
ax.plot(largest_, color='green', linewidth=0.8, alpha=0.4)
|
|
48
|
+
if smallest:
|
|
49
|
+
ax.plot(smallest_, color="red", linewidth=0.8, alpha=0.4)
|
|
50
|
+
|
|
51
|
+
ax.set_title(title)
|
|
52
|
+
ax.set_xlabel(xlabel)
|
|
53
|
+
ax.set_ylabel(ylabel)
|
|
54
|
+
plt.show()
|
processes/ou.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
class OrnsteinUhlenbeck:
|
|
5
|
+
"""Ornstein-Uhlenbeck (OU) process."""
|
|
6
|
+
def __init__(self, X0: float, theta: float, mu: float, sigma: float, T: float, dt: float):
|
|
7
|
+
self.X0 = X0
|
|
8
|
+
self.theta = theta
|
|
9
|
+
self.mu = mu
|
|
10
|
+
self.sigma = sigma
|
|
11
|
+
self.T = T
|
|
12
|
+
self.dt = dt
|
|
13
|
+
|
|
14
|
+
def process(self, n_paths: int) -> np.ndarray:
|
|
15
|
+
n_steps = int(self.T / self.dt)
|
|
16
|
+
|
|
17
|
+
paths = np.zeros((n_paths, n_steps + 1))
|
|
18
|
+
paths[:, 0] = self.X0
|
|
19
|
+
|
|
20
|
+
dW = np.random.normal(0, np.sqrt(self.dt), (n_paths, n_steps))
|
|
21
|
+
for i in range(1, n_steps + 1):
|
|
22
|
+
paths[:, i] = paths[:, i-1] + self.theta * (self.mu - paths[:, i-1]) * self.dt + self.sigma * dW[:, i-1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
return paths
|
|
26
|
+
|
|
27
|
+
def plot_paths(self, paths, title='Ornstein-Uhlenbeck Simulated Paths', xlabel='Steps', ylabel='Value', average=True, largest=True, smallest=True):
|
|
28
|
+
fig, ax = plt.subplots()
|
|
29
|
+
for path in paths:
|
|
30
|
+
ax.plot(path, color='#1f77b4', linewidth=0.8, alpha=0.5)
|
|
31
|
+
|
|
32
|
+
average_ = np.mean(paths, axis=0)
|
|
33
|
+
largest_ = np.max(paths, axis=0)
|
|
34
|
+
smallest_ = np.min(paths, axis=0)
|
|
35
|
+
if average:
|
|
36
|
+
ax.plot(average_, color='purple', linewidth=0.8, alpha=0.4)
|
|
37
|
+
if largest:
|
|
38
|
+
ax.plot(largest_, color='green', linewidth=0.8, alpha=0.4)
|
|
39
|
+
if smallest:
|
|
40
|
+
ax.plot(smallest_, color="red", linewidth=0.8, alpha=0.4)
|
|
41
|
+
|
|
42
|
+
ax.set_title(title)
|
|
43
|
+
ax.set_xlabel(xlabel)
|
|
44
|
+
ax.set_ylabel(ylabel)
|
|
45
|
+
plt.show()
|
|
46
|
+
|
processes/vasicek.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
class VasicekModel:
|
|
5
|
+
"""Vasicek model for interest rate simulation."""
|
|
6
|
+
def __init__(self, theta: float, mu: float, sigma: float, r0: float):
|
|
7
|
+
self.theta = theta
|
|
8
|
+
self.mu = mu
|
|
9
|
+
self.sigma = sigma
|
|
10
|
+
self.r0 = r0
|
|
11
|
+
|
|
12
|
+
def sim_paths(self, T: float, dt: float, n_paths: int) -> np.ndarray:
|
|
13
|
+
n_steps = int(T / dt)
|
|
14
|
+
paths = np.zeros((n_paths, n_steps + 1))
|
|
15
|
+
paths[:, 0] = self.r0
|
|
16
|
+
|
|
17
|
+
sqrt_dt = np.sqrt(dt)
|
|
18
|
+
for i in range(1, n_steps + 1):
|
|
19
|
+
r_prev = paths[:, i - 1]
|
|
20
|
+
z = np.random.normal(size=n_paths)
|
|
21
|
+
dr = self.theta * (self.mu - r_prev) * dt + self.sigma * sqrt_dt * z
|
|
22
|
+
paths[:, i] = r_prev + dr
|
|
23
|
+
|
|
24
|
+
return paths
|
|
25
|
+
|
|
26
|
+
def plot_paths(self, paths, T, dt, title='Simulated Vasicek Interest Rate Paths', xlabel='Time', ylabel='Interest Rate', average=True, largest=True, smallest=True):
|
|
27
|
+
n_steps = int(T / dt)
|
|
28
|
+
time_points = np.linspace(0, T, n_steps + 1)
|
|
29
|
+
|
|
30
|
+
fig, ax = plt.subplots()
|
|
31
|
+
for path in paths:
|
|
32
|
+
ax.plot(time_points, path, color='#1f77b4', linewidth=0.75, alpha=0.5)
|
|
33
|
+
|
|
34
|
+
average_ = np.mean(paths, axis=0)
|
|
35
|
+
largest_ = np.max(paths, axis=0)
|
|
36
|
+
smallest_ = np.min(paths, axis=0)
|
|
37
|
+
if average:
|
|
38
|
+
ax.plot(time_points, average_, color='purple', linewidth=0.8, alpha=0.4)
|
|
39
|
+
if largest:
|
|
40
|
+
ax.plot(time_points, largest_, color='green', linewidth=0.8, alpha=0.4)
|
|
41
|
+
if smallest:
|
|
42
|
+
ax.plot(time_points, smallest_, color="red", linewidth=0.8, alpha=0.4)
|
|
43
|
+
|
|
44
|
+
ax.set_title(title)
|
|
45
|
+
ax.set_xlabel(xlabel)
|
|
46
|
+
ax.set_ylabel(ylabel)
|
|
47
|
+
ax.set_xlim(0, T)
|
|
48
|
+
plt.show()
|