tensorquantlib 0.3.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.
- tensorquantlib/__init__.py +313 -0
- tensorquantlib/__main__.py +315 -0
- tensorquantlib/backtest/__init__.py +48 -0
- tensorquantlib/backtest/engine.py +240 -0
- tensorquantlib/backtest/metrics.py +320 -0
- tensorquantlib/backtest/strategy.py +348 -0
- tensorquantlib/core/__init__.py +6 -0
- tensorquantlib/core/ops.py +70 -0
- tensorquantlib/core/second_order.py +465 -0
- tensorquantlib/core/tensor.py +928 -0
- tensorquantlib/data/__init__.py +16 -0
- tensorquantlib/data/market.py +160 -0
- tensorquantlib/finance/__init__.py +52 -0
- tensorquantlib/finance/american.py +263 -0
- tensorquantlib/finance/basket.py +291 -0
- tensorquantlib/finance/black_scholes.py +219 -0
- tensorquantlib/finance/credit.py +199 -0
- tensorquantlib/finance/exotics.py +885 -0
- tensorquantlib/finance/fx.py +204 -0
- tensorquantlib/finance/greeks.py +133 -0
- tensorquantlib/finance/heston.py +543 -0
- tensorquantlib/finance/implied_vol.py +277 -0
- tensorquantlib/finance/ir_derivatives.py +203 -0
- tensorquantlib/finance/jump_diffusion.py +203 -0
- tensorquantlib/finance/local_vol.py +146 -0
- tensorquantlib/finance/rates.py +381 -0
- tensorquantlib/finance/risk.py +344 -0
- tensorquantlib/finance/variance_reduction.py +420 -0
- tensorquantlib/finance/volatility.py +355 -0
- tensorquantlib/py.typed +0 -0
- tensorquantlib/tt/__init__.py +43 -0
- tensorquantlib/tt/decompose.py +576 -0
- tensorquantlib/tt/ops.py +386 -0
- tensorquantlib/tt/pricing.py +304 -0
- tensorquantlib/tt/surrogate.py +634 -0
- tensorquantlib/utils/__init__.py +5 -0
- tensorquantlib/utils/validation.py +126 -0
- tensorquantlib/viz/__init__.py +27 -0
- tensorquantlib/viz/plots.py +331 -0
- tensorquantlib-0.3.0.dist-info/METADATA +602 -0
- tensorquantlib-0.3.0.dist-info/RECORD +44 -0
- tensorquantlib-0.3.0.dist-info/WHEEL +5 -0
- tensorquantlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- tensorquantlib-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exotic options: Asian, digital (binary), and barrier options.
|
|
3
|
+
|
|
4
|
+
Provides both analytic closed-forms (where available) and Monte Carlo pricing
|
|
5
|
+
for the following option types built on log-normal GBM dynamics:
|
|
6
|
+
|
|
7
|
+
Asian (arithmetic average):
|
|
8
|
+
asian_price_mc -- Monte Carlo arithmetic Asian call/put
|
|
9
|
+
asian_geometric_price -- Analytic geometric-average Asian (closed-form)
|
|
10
|
+
|
|
11
|
+
Digital (binary):
|
|
12
|
+
digital_price -- Analytic cash-or-nothing and asset-or-nothing
|
|
13
|
+
digital_price_mc -- Monte Carlo digital price (validation)
|
|
14
|
+
|
|
15
|
+
Barrier options (single barrier, European):
|
|
16
|
+
barrier_price -- Analytic single-barrier option (Reiner-Rubinstein)
|
|
17
|
+
barrier_price_mc -- Monte Carlo barrier option price
|
|
18
|
+
|
|
19
|
+
All analytic formulas assume GBM with constant parameters.
|
|
20
|
+
|
|
21
|
+
References:
|
|
22
|
+
Kemna & Vorst (1990). A Pricing Method for Options Based on Average Asset Values.
|
|
23
|
+
Journal of Banking and Finance, 14(1), 113-129.
|
|
24
|
+
|
|
25
|
+
Rubinstein, M. & Reiner, E. (1991). Breaking Down the Barriers. Risk 4(8), 28-35.
|
|
26
|
+
|
|
27
|
+
Reiner, E. (1992). Quanto Mechanics. Risk 5(3), 59-63.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Optional, Union
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
from scipy.stats import norm
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------ #
|
|
39
|
+
# Helper
|
|
40
|
+
# ------------------------------------------------------------------ #
|
|
41
|
+
|
|
42
|
+
def _gbm_paths(
|
|
43
|
+
S: float,
|
|
44
|
+
T: float,
|
|
45
|
+
r: float,
|
|
46
|
+
sigma: float,
|
|
47
|
+
q: float,
|
|
48
|
+
n_paths: int,
|
|
49
|
+
n_steps: int,
|
|
50
|
+
rng: np.random.Generator,
|
|
51
|
+
) -> np.ndarray:
|
|
52
|
+
"""Simulate GBM paths. Returns shape (n_steps+1, n_paths)."""
|
|
53
|
+
dt = T / n_steps
|
|
54
|
+
z = rng.standard_normal((n_steps, n_paths))
|
|
55
|
+
log_increments = (r - q - 0.5 * sigma ** 2) * dt + sigma * np.sqrt(dt) * z
|
|
56
|
+
log_S = np.vstack([
|
|
57
|
+
np.full(n_paths, np.log(S)),
|
|
58
|
+
np.log(S) + np.cumsum(log_increments, axis=0),
|
|
59
|
+
])
|
|
60
|
+
return np.exp(log_S)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ================================================================== #
|
|
64
|
+
# ASIAN OPTIONS
|
|
65
|
+
# ================================================================== #
|
|
66
|
+
|
|
67
|
+
def asian_price_mc(
|
|
68
|
+
S: float,
|
|
69
|
+
K: float,
|
|
70
|
+
T: float,
|
|
71
|
+
r: float,
|
|
72
|
+
sigma: float,
|
|
73
|
+
q: float = 0.0,
|
|
74
|
+
option_type: str = "call",
|
|
75
|
+
average_type: str = "arithmetic",
|
|
76
|
+
*,
|
|
77
|
+
n_paths: int = 100_000,
|
|
78
|
+
n_steps: int = 252,
|
|
79
|
+
seed: Optional[int] = None,
|
|
80
|
+
return_stderr: bool = False,
|
|
81
|
+
) -> Union[float, tuple[float, float]]:
|
|
82
|
+
"""Price an Asian average-rate option by Monte Carlo.
|
|
83
|
+
|
|
84
|
+
The payoff is based on the average of the asset price over [0, T]:
|
|
85
|
+
Call: max(avg(S) - K, 0) * exp(-rT)
|
|
86
|
+
Put: max(K - avg(S), 0) * exp(-rT)
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
S: Spot price.
|
|
90
|
+
K: Strike.
|
|
91
|
+
T: Time to expiry (years).
|
|
92
|
+
r: Risk-free rate.
|
|
93
|
+
sigma: Volatility.
|
|
94
|
+
q: Dividend yield.
|
|
95
|
+
option_type: 'call' or 'put'.
|
|
96
|
+
average_type: 'arithmetic' or 'geometric'.
|
|
97
|
+
n_paths: Monte Carlo paths.
|
|
98
|
+
n_steps: Averaging time steps.
|
|
99
|
+
seed: Random seed.
|
|
100
|
+
return_stderr: If True, return (price, stderr).
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Asian option price, or (price, stderr).
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
>>> price = asian_price_mc(100, 100, 1.0, 0.05, 0.2, seed=0)
|
|
107
|
+
>>> 5.0 < price < 12.0
|
|
108
|
+
True
|
|
109
|
+
"""
|
|
110
|
+
rng = np.random.default_rng(seed)
|
|
111
|
+
paths = _gbm_paths(S, T, r, sigma, q, n_paths, n_steps, rng)
|
|
112
|
+
|
|
113
|
+
# Exclude time-0 from average (average over [dt, T])
|
|
114
|
+
obs = paths[1:] # shape: (n_steps, n_paths)
|
|
115
|
+
|
|
116
|
+
if average_type == "arithmetic":
|
|
117
|
+
avg = obs.mean(axis=0)
|
|
118
|
+
elif average_type == "geometric":
|
|
119
|
+
avg = np.exp(np.log(obs).mean(axis=0))
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError(f"average_type must be 'arithmetic' or 'geometric', got {average_type!r}")
|
|
122
|
+
|
|
123
|
+
if option_type == "call":
|
|
124
|
+
payoffs = np.maximum(avg - K, 0.0)
|
|
125
|
+
else:
|
|
126
|
+
payoffs = np.maximum(K - avg, 0.0)
|
|
127
|
+
|
|
128
|
+
discount = np.exp(-r * T)
|
|
129
|
+
price = discount * float(np.mean(payoffs))
|
|
130
|
+
stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths))
|
|
131
|
+
|
|
132
|
+
return (price, stderr) if return_stderr else price
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def asian_geometric_price(
|
|
136
|
+
S: float,
|
|
137
|
+
K: float,
|
|
138
|
+
T: float,
|
|
139
|
+
r: float,
|
|
140
|
+
sigma: float,
|
|
141
|
+
q: float = 0.0,
|
|
142
|
+
option_type: str = "call",
|
|
143
|
+
) -> float:
|
|
144
|
+
"""Closed-form price for continuous geometric-average Asian option (Kemna & Vorst 1990).
|
|
145
|
+
|
|
146
|
+
Applicable to continuous monitoring (limit of n_steps → ∞).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
S, K, T, r, sigma, q: Standard Black-Scholes inputs.
|
|
150
|
+
option_type: 'call' or 'put'.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Geometric Asian option price.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
>>> p = asian_geometric_price(100, 100, 1.0, 0.05, 0.2)
|
|
157
|
+
>>> 5.0 < p < 12.0
|
|
158
|
+
True
|
|
159
|
+
"""
|
|
160
|
+
# Adjusted parameters for geometric average
|
|
161
|
+
sigma_geo = sigma / np.sqrt(3.0)
|
|
162
|
+
b = 0.5 * (r - q - sigma ** 2 / 6.0)
|
|
163
|
+
|
|
164
|
+
d1 = (np.log(S / K) + (b + 0.5 * sigma_geo ** 2) * T) / (sigma_geo * np.sqrt(T))
|
|
165
|
+
d2 = d1 - sigma_geo * np.sqrt(T)
|
|
166
|
+
|
|
167
|
+
if option_type == "call":
|
|
168
|
+
price = S * np.exp((b - r) * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
|
|
169
|
+
else:
|
|
170
|
+
price = K * np.exp(-r * T) * norm.cdf(-d2) - S * np.exp((b - r) * T) * norm.cdf(-d1)
|
|
171
|
+
|
|
172
|
+
return float(price)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ================================================================== #
|
|
176
|
+
# DIGITAL (BINARY) OPTIONS
|
|
177
|
+
# ================================================================== #
|
|
178
|
+
|
|
179
|
+
def digital_price(
|
|
180
|
+
S: float,
|
|
181
|
+
K: float,
|
|
182
|
+
T: float,
|
|
183
|
+
r: float,
|
|
184
|
+
sigma: float,
|
|
185
|
+
q: float = 0.0,
|
|
186
|
+
option_type: str = "call",
|
|
187
|
+
payoff_type: str = "cash",
|
|
188
|
+
payoff_amount: float = 1.0,
|
|
189
|
+
) -> float:
|
|
190
|
+
"""Analytic Black-Scholes price for a digital (binary) option.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
S: Spot price.
|
|
194
|
+
K: Strike.
|
|
195
|
+
T: Time to expiry (years).
|
|
196
|
+
r: Risk-free rate.
|
|
197
|
+
sigma: Volatility.
|
|
198
|
+
q: Dividend yield.
|
|
199
|
+
option_type: 'call' (pays if S_T > K) or 'put' (pays if S_T < K).
|
|
200
|
+
payoff_type: 'cash' (fixed cash) or 'asset' (deliver asset if triggered).
|
|
201
|
+
payoff_amount: Size of the cash payment if payoff_type='cash' (default 1.0).
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Digital option price.
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
>>> p = digital_price(100, 100, 1.0, 0.05, 0.2, payoff_type='cash')
|
|
208
|
+
>>> 0.0 < p < 1.0
|
|
209
|
+
True
|
|
210
|
+
"""
|
|
211
|
+
d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
|
|
212
|
+
d2 = d1 - sigma * np.sqrt(T)
|
|
213
|
+
|
|
214
|
+
if payoff_type == "cash":
|
|
215
|
+
# Cash-or-nothing: pays payoff_amount if in the money at expiry
|
|
216
|
+
if option_type == "call":
|
|
217
|
+
price = payoff_amount * np.exp(-r * T) * norm.cdf(d2)
|
|
218
|
+
else:
|
|
219
|
+
price = payoff_amount * np.exp(-r * T) * norm.cdf(-d2)
|
|
220
|
+
elif payoff_type == "asset":
|
|
221
|
+
# Asset-or-nothing: delivers the asset if in the money
|
|
222
|
+
if option_type == "call":
|
|
223
|
+
price = S * np.exp(-q * T) * norm.cdf(d1)
|
|
224
|
+
else:
|
|
225
|
+
price = S * np.exp(-q * T) * norm.cdf(-d1)
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError(f"payoff_type must be 'cash' or 'asset', got {payoff_type!r}")
|
|
228
|
+
|
|
229
|
+
return float(price)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def digital_greeks(
|
|
233
|
+
S: float,
|
|
234
|
+
K: float,
|
|
235
|
+
T: float,
|
|
236
|
+
r: float,
|
|
237
|
+
sigma: float,
|
|
238
|
+
q: float = 0.0,
|
|
239
|
+
option_type: str = "call",
|
|
240
|
+
payoff_type: str = "cash",
|
|
241
|
+
payoff_amount: float = 1.0,
|
|
242
|
+
) -> dict[str, float]:
|
|
243
|
+
"""Analytic Black-Scholes Greeks for digital options.
|
|
244
|
+
|
|
245
|
+
Returns delta, gamma, vega, theta, rho.
|
|
246
|
+
|
|
247
|
+
Example:
|
|
248
|
+
>>> g = digital_greeks(100, 100, 1.0, 0.05, 0.2)
|
|
249
|
+
>>> 'delta' in g and 'gamma' in g
|
|
250
|
+
True
|
|
251
|
+
"""
|
|
252
|
+
d1 = (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
|
|
253
|
+
d2 = d1 - sigma * np.sqrt(T)
|
|
254
|
+
phi_d2 = float(norm.pdf(d2))
|
|
255
|
+
phi_d1 = float(norm.pdf(d1))
|
|
256
|
+
|
|
257
|
+
if payoff_type == "cash":
|
|
258
|
+
sign = 1.0 if option_type == "call" else -1.0
|
|
259
|
+
factor = payoff_amount * np.exp(-r * T)
|
|
260
|
+
delta = sign * factor * phi_d2 / (S * sigma * np.sqrt(T))
|
|
261
|
+
gamma = -sign * factor * phi_d2 * d1 / (S ** 2 * sigma ** 2 * T)
|
|
262
|
+
vega = -sign * factor * phi_d2 * d1 / sigma
|
|
263
|
+
theta = (sign * factor * r * float(norm.cdf(sign * d2)) +
|
|
264
|
+
sign * factor * phi_d2 * (d1 / (2 * T) - r / (sigma * np.sqrt(T))))
|
|
265
|
+
rho = -T * float(digital_price(S, K, T, r, sigma, q, option_type, payoff_type, payoff_amount))
|
|
266
|
+
else:
|
|
267
|
+
# Asset-or-nothing greeks
|
|
268
|
+
sign = 1.0 if option_type == "call" else -1.0
|
|
269
|
+
factor = S * np.exp(-q * T)
|
|
270
|
+
ncdf_d1 = float(norm.cdf(sign * d1))
|
|
271
|
+
delta = np.exp(-q * T) * (ncdf_d1 + sign * phi_d1 / (sigma * np.sqrt(T)))
|
|
272
|
+
gamma = sign * np.exp(-q * T) * phi_d1 * (1.0 / (S * sigma * np.sqrt(T))) * (1.0 - d2 / (sigma * np.sqrt(T)))
|
|
273
|
+
vega = sign * factor * phi_d1 * (np.sqrt(T) - d1 / sigma)
|
|
274
|
+
theta = float("nan") # complex expression omitted here; use FD
|
|
275
|
+
rho = float("nan")
|
|
276
|
+
|
|
277
|
+
return {"delta": float(delta), "gamma": float(gamma), "vega": float(vega), "theta": float(theta), "rho": float(rho)}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def digital_price_mc(
|
|
281
|
+
S: float,
|
|
282
|
+
K: float,
|
|
283
|
+
T: float,
|
|
284
|
+
r: float,
|
|
285
|
+
sigma: float,
|
|
286
|
+
q: float = 0.0,
|
|
287
|
+
option_type: str = "call",
|
|
288
|
+
payoff_type: str = "cash",
|
|
289
|
+
payoff_amount: float = 1.0,
|
|
290
|
+
*,
|
|
291
|
+
n_paths: int = 100_000,
|
|
292
|
+
seed: Optional[int] = None,
|
|
293
|
+
return_stderr: bool = False,
|
|
294
|
+
) -> Union[float, tuple[float, float]]:
|
|
295
|
+
"""Monte Carlo price for a digital option (validation).
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
>>> p = digital_price_mc(100, 100, 1.0, 0.05, 0.2, seed=0)
|
|
299
|
+
>>> 0.0 < p < 1.0
|
|
300
|
+
True
|
|
301
|
+
"""
|
|
302
|
+
rng = np.random.default_rng(seed)
|
|
303
|
+
z = rng.standard_normal(n_paths)
|
|
304
|
+
S_T = S * np.exp((r - q - 0.5 * sigma ** 2) * T + sigma * np.sqrt(T) * z)
|
|
305
|
+
|
|
306
|
+
if option_type == "call":
|
|
307
|
+
triggered = S_T > K
|
|
308
|
+
else:
|
|
309
|
+
triggered = S_T < K
|
|
310
|
+
|
|
311
|
+
if payoff_type == "cash":
|
|
312
|
+
payoffs = np.where(triggered, payoff_amount, 0.0)
|
|
313
|
+
else:
|
|
314
|
+
payoffs = np.where(triggered, S_T, 0.0)
|
|
315
|
+
|
|
316
|
+
discount = np.exp(-r * T)
|
|
317
|
+
price = discount * float(np.mean(payoffs))
|
|
318
|
+
stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths))
|
|
319
|
+
|
|
320
|
+
return (price, stderr) if return_stderr else price
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ================================================================== #
|
|
324
|
+
# BARRIER OPTIONS
|
|
325
|
+
# ================================================================== #
|
|
326
|
+
|
|
327
|
+
def _bs_call(S: float, K: float, T: float, r: float, b: float, sigma: float) -> float:
|
|
328
|
+
"""Generalised Black-Scholes call price with cost-of-carry b = r - q."""
|
|
329
|
+
if T <= 0 or K <= 0 or S <= 0:
|
|
330
|
+
return max(S - K, 0.0)
|
|
331
|
+
sqrt_T = np.sqrt(T)
|
|
332
|
+
d1 = (np.log(S / K) + (b + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
|
|
333
|
+
d2 = d1 - sigma * sqrt_T
|
|
334
|
+
return float(S * np.exp((b - r) * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _bs_put(S: float, K: float, T: float, r: float, b: float, sigma: float) -> float:
|
|
338
|
+
"""Generalised Black-Scholes put price with cost-of-carry b = r - q."""
|
|
339
|
+
if T <= 0 or K <= 0 or S <= 0:
|
|
340
|
+
return max(K - S, 0.0)
|
|
341
|
+
sqrt_T = np.sqrt(T)
|
|
342
|
+
d1 = (np.log(S / K) + (b + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
|
|
343
|
+
d2 = d1 - sigma * sqrt_T
|
|
344
|
+
return float(-S * np.exp((b - r) * T) * norm.cdf(-d1) + K * np.exp(-r * T) * norm.cdf(-d2))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def barrier_price(
|
|
348
|
+
S: float,
|
|
349
|
+
K: float,
|
|
350
|
+
T: float,
|
|
351
|
+
r: float,
|
|
352
|
+
sigma: float,
|
|
353
|
+
barrier: float,
|
|
354
|
+
barrier_type: str,
|
|
355
|
+
q: float = 0.0,
|
|
356
|
+
option_type: str = "call",
|
|
357
|
+
rebate: float = 0.0,
|
|
358
|
+
) -> float:
|
|
359
|
+
"""Analytic price for European single-barrier options (Rubinstein-Reiner 1991).
|
|
360
|
+
|
|
361
|
+
Supports all 8 standard barrier option types:
|
|
362
|
+
|
|
363
|
+
'down-and-in' call/put -- activated if S crosses H from above
|
|
364
|
+
'down-and-out' call/put -- extinguished if S crosses H from above
|
|
365
|
+
'up-and-in' call/put -- activated if S crosses H from below
|
|
366
|
+
'up-and-out' call/put -- extinguished if S crosses H from below
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
S: Spot price.
|
|
370
|
+
K: Strike price.
|
|
371
|
+
T: Time to expiry.
|
|
372
|
+
r: Risk-free rate.
|
|
373
|
+
sigma: Volatility.
|
|
374
|
+
barrier: Barrier level H.
|
|
375
|
+
barrier_type: One of {'down-and-in', 'down-and-out', 'up-and-in', 'up-and-out'}.
|
|
376
|
+
q: Dividend yield.
|
|
377
|
+
option_type: 'call' or 'put'.
|
|
378
|
+
rebate: Cash rebate paid if barrier is not hit (for out options).
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Barrier option price.
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
ValueError: If barrier_type or option_type are invalid.
|
|
385
|
+
|
|
386
|
+
Example:
|
|
387
|
+
>>> p = barrier_price(100, 100, 1.0, 0.05, 0.2, barrier=90, barrier_type='down-and-out')
|
|
388
|
+
>>> p > 0
|
|
389
|
+
True
|
|
390
|
+
"""
|
|
391
|
+
valid_barrier_types = {"down-and-in", "down-and-out", "up-and-in", "up-and-out"}
|
|
392
|
+
if barrier_type not in valid_barrier_types:
|
|
393
|
+
raise ValueError(f"barrier_type must be one of {valid_barrier_types}, got {barrier_type!r}")
|
|
394
|
+
if option_type not in ("call", "put"):
|
|
395
|
+
raise ValueError(f"option_type must be 'call' or 'put', got {option_type!r}")
|
|
396
|
+
|
|
397
|
+
H = float(barrier)
|
|
398
|
+
b = r - q # cost-of-carry
|
|
399
|
+
sqrt_T = np.sqrt(T)
|
|
400
|
+
v = sigma
|
|
401
|
+
v2 = v * v
|
|
402
|
+
|
|
403
|
+
# Vanilla prices
|
|
404
|
+
c = _bs_call(S, K, T, r, b, v)
|
|
405
|
+
p = _bs_put(S, K, T, r, b, v)
|
|
406
|
+
|
|
407
|
+
# Haug/Rubinstein-Reiner notation (following FinancePy fx_barrier_option.py exactly)
|
|
408
|
+
# ll = (b + sigma^2/2) / sigma^2
|
|
409
|
+
# y = log(H^2/(S*K))/(sigma*sqrt(T)) + ll*sigma*sqrt(T)
|
|
410
|
+
# x1 = log(S/H)/(sigma*sqrt(T)) + ll*sigma*sqrt(T)
|
|
411
|
+
# y1 = log(H/S)/(sigma*sqrt(T)) + ll*sigma*sqrt(T)
|
|
412
|
+
sigma_rt = v * sqrt_T
|
|
413
|
+
ll = (b + 0.5 * v2) / v2
|
|
414
|
+
y = np.log(H * H / (S * K)) / sigma_rt + ll * sigma_rt
|
|
415
|
+
x1 = np.log(S / H) / sigma_rt + ll * sigma_rt
|
|
416
|
+
y1 = np.log(H / S) / sigma_rt + ll * sigma_rt
|
|
417
|
+
|
|
418
|
+
dq = np.exp((b - r) * T) # S discount factor (= exp(-q*T))
|
|
419
|
+
df = np.exp(-r * T) # K discount factor
|
|
420
|
+
h_over_s = H / S
|
|
421
|
+
pow_ll = h_over_s ** (2.0 * ll)
|
|
422
|
+
pow_ll2 = h_over_s ** (2.0 * ll - 2.0)
|
|
423
|
+
|
|
424
|
+
N = norm.cdf
|
|
425
|
+
|
|
426
|
+
def c_di() -> float:
|
|
427
|
+
"""Down-and-in call, H <= K."""
|
|
428
|
+
return (
|
|
429
|
+
S * dq * pow_ll * N(y)
|
|
430
|
+
- K * df * pow_ll2 * N(y - sigma_rt)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def c_di_H_gt_K() -> float:
|
|
434
|
+
"""Down-and-in call, H > K."""
|
|
435
|
+
return (
|
|
436
|
+
S * dq * N(x1) - K * df * N(x1 - sigma_rt)
|
|
437
|
+
- S * dq * pow_ll * (N(-y) - N(-y1))
|
|
438
|
+
+ K * df * pow_ll2 * (N(-y + sigma_rt) - N(-y1 + sigma_rt))
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def c_uo() -> float:
|
|
442
|
+
"""Up-and-out call, H > K (the only meaningful case for up-out call)."""
|
|
443
|
+
return (
|
|
444
|
+
S * dq * N(x1) - K * df * N(x1 - sigma_rt)
|
|
445
|
+
- S * dq * pow_ll * N(y1)
|
|
446
|
+
+ K * df * pow_ll2 * N(y1 - sigma_rt)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def c_ui() -> float:
|
|
450
|
+
"""Up-and-in call, H >= K."""
|
|
451
|
+
return (
|
|
452
|
+
S * dq * N(x1) - K * df * N(x1 - sigma_rt)
|
|
453
|
+
- S * dq * pow_ll * (N(-y) - N(-y1))
|
|
454
|
+
+ K * df * pow_ll2 * (N(-y + sigma_rt) - N(-y1 + sigma_rt))
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def p_ui() -> float:
|
|
458
|
+
"""Up-and-in put, H >= K."""
|
|
459
|
+
return (
|
|
460
|
+
-S * dq * pow_ll * N(-y)
|
|
461
|
+
+ K * df * pow_ll2 * N(-y + sigma_rt)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def p_ui_H_lt_K() -> float:
|
|
465
|
+
"""Up-and-in put, H < K."""
|
|
466
|
+
return (
|
|
467
|
+
-S * dq * N(-x1) + K * df * N(-x1 + sigma_rt)
|
|
468
|
+
+ S * dq * pow_ll * (N(y) - N(y1))
|
|
469
|
+
- K * df * pow_ll2 * (N(y - sigma_rt) - N(y1 - sigma_rt))
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def p_di() -> float:
|
|
473
|
+
"""Down-and-in put, H < K."""
|
|
474
|
+
return (
|
|
475
|
+
-S * dq * N(-x1) + K * df * N(-x1 + sigma_rt)
|
|
476
|
+
+ S * dq * pow_ll * (N(y) - N(y1))
|
|
477
|
+
- K * df * pow_ll2 * (N(y - sigma_rt) - N(y1 - sigma_rt))
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Main dispatch
|
|
481
|
+
if option_type == "call":
|
|
482
|
+
if barrier_type == "down-and-in":
|
|
483
|
+
price = c_di() if H <= K else c_di_H_gt_K()
|
|
484
|
+
elif barrier_type == "down-and-out":
|
|
485
|
+
price = c - (c_di() if H <= K else c_di_H_gt_K())
|
|
486
|
+
elif barrier_type == "up-and-in":
|
|
487
|
+
if H >= K:
|
|
488
|
+
price = c_ui()
|
|
489
|
+
else:
|
|
490
|
+
price = c # barrier is below strike: always knocked in already
|
|
491
|
+
else: # up-and-out
|
|
492
|
+
if H >= K:
|
|
493
|
+
price = c - c_ui()
|
|
494
|
+
else:
|
|
495
|
+
price = 0.0 # barrier <= strike: call knocked out before it can pay
|
|
496
|
+
else: # put
|
|
497
|
+
if barrier_type == "up-and-in":
|
|
498
|
+
price = p_ui() if H >= K else p_ui_H_lt_K()
|
|
499
|
+
elif barrier_type == "up-and-out":
|
|
500
|
+
price = p - (p_ui() if H >= K else p_ui_H_lt_K())
|
|
501
|
+
elif barrier_type == "down-and-in":
|
|
502
|
+
if H < K:
|
|
503
|
+
price = p_di()
|
|
504
|
+
else: # H >= K: barrier is at or above strike, always knocked in
|
|
505
|
+
price = p
|
|
506
|
+
else: # down-and-out
|
|
507
|
+
if H < K:
|
|
508
|
+
price = p - p_di()
|
|
509
|
+
else:
|
|
510
|
+
price = 0.0
|
|
511
|
+
|
|
512
|
+
return float(max(price, 0.0))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _d1(S: float, K: float, T: float, r: float, sigma: float, q: float) -> float:
|
|
516
|
+
return (np.log(S / K) + (r - q + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _d2(S: float, K: float, T: float, r: float, sigma: float, q: float) -> float:
|
|
520
|
+
return _d1(S, K, T, r, sigma, q) - sigma * np.sqrt(T)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _indicator_hit(S: float, H: float, barrier_type: str, T: float, r: float, sigma: float, q: float) -> float:
|
|
524
|
+
"""Probability of hitting the barrier (approximation via reflection principle)."""
|
|
525
|
+
mu = (r - q - 0.5 * sigma ** 2) / (sigma ** 2)
|
|
526
|
+
x = np.log(H / S) / (sigma * np.sqrt(T))
|
|
527
|
+
if "down" in barrier_type:
|
|
528
|
+
return float(norm.cdf(-x + mu * sigma * np.sqrt(T)) + (H / S) ** (2 * mu) * norm.cdf(-x - mu * sigma * np.sqrt(T)))
|
|
529
|
+
else:
|
|
530
|
+
return float(norm.cdf(x - mu * sigma * np.sqrt(T)) + (H / S) ** (2 * mu) * norm.cdf(x + mu * sigma * np.sqrt(T)))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def barrier_price_mc(
|
|
534
|
+
S: float,
|
|
535
|
+
K: float,
|
|
536
|
+
T: float,
|
|
537
|
+
r: float,
|
|
538
|
+
sigma: float,
|
|
539
|
+
barrier: float,
|
|
540
|
+
barrier_type: str,
|
|
541
|
+
q: float = 0.0,
|
|
542
|
+
option_type: str = "call",
|
|
543
|
+
rebate: float = 0.0,
|
|
544
|
+
*,
|
|
545
|
+
n_paths: int = 200_000,
|
|
546
|
+
n_steps: int = 252,
|
|
547
|
+
seed: Optional[int] = None,
|
|
548
|
+
return_stderr: bool = False,
|
|
549
|
+
) -> Union[float, tuple[float, float]]:
|
|
550
|
+
"""Monte Carlo price for a European single-barrier option.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
S, K, T, r, sigma, q: Standard parameters.
|
|
554
|
+
barrier: Barrier level.
|
|
555
|
+
barrier_type: 'down-and-in', 'down-and-out', 'up-and-in', 'up-and-out'.
|
|
556
|
+
option_type: 'call' or 'put'.
|
|
557
|
+
rebate: Rebate paid when knocked out/never knocked in.
|
|
558
|
+
n_paths, n_steps, seed: Simulation parameters.
|
|
559
|
+
return_stderr: Return (price, stderr) if True.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
Price, or (price, stderr).
|
|
563
|
+
|
|
564
|
+
Example:
|
|
565
|
+
>>> p = barrier_price_mc(100, 100, 1.0, 0.05, 0.2, barrier=90, barrier_type='down-and-out', seed=0)
|
|
566
|
+
>>> p > 0
|
|
567
|
+
True
|
|
568
|
+
"""
|
|
569
|
+
rng = np.random.default_rng(seed)
|
|
570
|
+
paths = _gbm_paths(S, T, r, sigma, q, n_paths, n_steps, rng)
|
|
571
|
+
H = barrier
|
|
572
|
+
|
|
573
|
+
# Track barrier crossing
|
|
574
|
+
if "down" in barrier_type:
|
|
575
|
+
crossed = np.any(paths <= H, axis=0) # shape: (n_paths,)
|
|
576
|
+
else:
|
|
577
|
+
crossed = np.any(paths >= H, axis=0)
|
|
578
|
+
|
|
579
|
+
S_T = paths[-1]
|
|
580
|
+
if option_type == "call":
|
|
581
|
+
payoff_vanilla = np.maximum(S_T - K, 0.0)
|
|
582
|
+
else:
|
|
583
|
+
payoff_vanilla = np.maximum(K - S_T, 0.0)
|
|
584
|
+
|
|
585
|
+
if "out" in barrier_type:
|
|
586
|
+
# Knocked out when barrier is crossed
|
|
587
|
+
payoffs = np.where(crossed, rebate, payoff_vanilla)
|
|
588
|
+
else:
|
|
589
|
+
# Knocked in: only alive when barrier was crossed
|
|
590
|
+
payoffs = np.where(crossed, payoff_vanilla, rebate)
|
|
591
|
+
|
|
592
|
+
discount = np.exp(-r * T)
|
|
593
|
+
price = discount * float(np.mean(payoffs))
|
|
594
|
+
stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths))
|
|
595
|
+
|
|
596
|
+
return (price, stderr) if return_stderr else price
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# Lookback options
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
def lookback_fixed_analytic(
|
|
604
|
+
S: float,
|
|
605
|
+
K: float,
|
|
606
|
+
T: float,
|
|
607
|
+
r: float,
|
|
608
|
+
sigma: float,
|
|
609
|
+
q: float = 0.0,
|
|
610
|
+
option_type: str = "call",
|
|
611
|
+
) -> float:
|
|
612
|
+
"""Analytic fixed-strike lookback option price (Goldman-Sosin-Gatto 1979).
|
|
613
|
+
|
|
614
|
+
For a fixed-strike lookback call, the payoff is max(S_max - K, 0).
|
|
615
|
+
For a fixed-strike lookback put, the payoff is max(K - S_min, 0).
|
|
616
|
+
|
|
617
|
+
Parameters
|
|
618
|
+
----------
|
|
619
|
+
S, K, T, r, sigma, q, option_type : standard option parameters.
|
|
620
|
+
|
|
621
|
+
Returns
|
|
622
|
+
-------
|
|
623
|
+
float
|
|
624
|
+
Option price.
|
|
625
|
+
"""
|
|
626
|
+
from scipy.stats import norm
|
|
627
|
+
|
|
628
|
+
b = r - q # cost of carry
|
|
629
|
+
s2 = sigma ** 2
|
|
630
|
+
|
|
631
|
+
if option_type == "call":
|
|
632
|
+
d1 = (np.log(S / K) + (b + 0.5 * s2) * T) / (sigma * np.sqrt(T))
|
|
633
|
+
d2 = d1 - sigma * np.sqrt(T)
|
|
634
|
+
|
|
635
|
+
price = (
|
|
636
|
+
S * np.exp((b - r) * T) * norm.cdf(d1)
|
|
637
|
+
- K * np.exp(-r * T) * norm.cdf(d2)
|
|
638
|
+
+ S * np.exp(-r * T) * (s2 / (2.0 * b)) * (
|
|
639
|
+
-(S / K) ** (-2.0 * b / s2) * norm.cdf(d1 - 2.0 * b * np.sqrt(T) / sigma)
|
|
640
|
+
+ np.exp(b * T) * norm.cdf(d1)
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
else:
|
|
644
|
+
d1 = (np.log(S / K) + (b + 0.5 * s2) * T) / (sigma * np.sqrt(T))
|
|
645
|
+
d2 = d1 - sigma * np.sqrt(T)
|
|
646
|
+
|
|
647
|
+
price = (
|
|
648
|
+
K * np.exp(-r * T) * norm.cdf(-d2)
|
|
649
|
+
- S * np.exp((b - r) * T) * norm.cdf(-d1)
|
|
650
|
+
+ S * np.exp(-r * T) * (s2 / (2.0 * b)) * (
|
|
651
|
+
(S / K) ** (-2.0 * b / s2) * norm.cdf(-d1 + 2.0 * b * np.sqrt(T) / sigma)
|
|
652
|
+
- np.exp(b * T) * norm.cdf(-d1)
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
return float(price)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def lookback_floating_analytic(
|
|
660
|
+
S: float,
|
|
661
|
+
T: float,
|
|
662
|
+
r: float,
|
|
663
|
+
sigma: float,
|
|
664
|
+
q: float = 0.0,
|
|
665
|
+
option_type: str = "call",
|
|
666
|
+
) -> float:
|
|
667
|
+
"""Analytic floating-strike lookback option price.
|
|
668
|
+
|
|
669
|
+
At inception S_min = S_max = S.
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
float
|
|
674
|
+
Option price.
|
|
675
|
+
"""
|
|
676
|
+
from scipy.stats import norm
|
|
677
|
+
|
|
678
|
+
b = r - q
|
|
679
|
+
s2 = sigma ** 2
|
|
680
|
+
sqT = sigma * np.sqrt(T)
|
|
681
|
+
a1 = (b + 0.5 * s2) * T / sqT
|
|
682
|
+
a2 = a1 - sqT
|
|
683
|
+
|
|
684
|
+
if option_type == "call":
|
|
685
|
+
if abs(b) < 1e-12:
|
|
686
|
+
price = S * sqT * (2.0 * norm.pdf(a1) + a1 * (2.0 * norm.cdf(a1) - 1.0))
|
|
687
|
+
else:
|
|
688
|
+
price = (
|
|
689
|
+
S * np.exp((b - r) * T) * norm.cdf(a1)
|
|
690
|
+
- S * np.exp(-r * T) * norm.cdf(a2)
|
|
691
|
+
+ S * np.exp(-r * T) * (s2 / (2.0 * b)) * (
|
|
692
|
+
np.exp(b * T) * norm.cdf(a1) - norm.cdf(a2)
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
else:
|
|
696
|
+
if abs(b) < 1e-12:
|
|
697
|
+
price = S * sqT * (2.0 * norm.pdf(a1) - a1 * (2.0 * norm.cdf(a1) - 1.0))
|
|
698
|
+
else:
|
|
699
|
+
price = (
|
|
700
|
+
-S * np.exp((b - r) * T) * norm.cdf(-a1)
|
|
701
|
+
+ S * np.exp(-r * T) * norm.cdf(-a2)
|
|
702
|
+
+ S * np.exp(-r * T) * (s2 / (2.0 * b)) * (
|
|
703
|
+
-np.exp(b * T) * norm.cdf(-a1) + norm.cdf(-a2)
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
return float(max(price, 0.0))
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def lookback_price_mc(
|
|
711
|
+
S: float,
|
|
712
|
+
K: float | None,
|
|
713
|
+
T: float,
|
|
714
|
+
r: float,
|
|
715
|
+
sigma: float,
|
|
716
|
+
q: float = 0.0,
|
|
717
|
+
option_type: str = "call",
|
|
718
|
+
strike_type: str = "fixed",
|
|
719
|
+
n_paths: int = 100_000,
|
|
720
|
+
n_steps: int = 252,
|
|
721
|
+
seed: int | None = None,
|
|
722
|
+
) -> tuple[float, float]:
|
|
723
|
+
"""Monte Carlo lookback option price.
|
|
724
|
+
|
|
725
|
+
Returns
|
|
726
|
+
-------
|
|
727
|
+
tuple
|
|
728
|
+
(price, standard_error)
|
|
729
|
+
"""
|
|
730
|
+
rng = np.random.default_rng(seed)
|
|
731
|
+
dt = T / n_steps
|
|
732
|
+
drift = (r - q - 0.5 * sigma ** 2) * dt
|
|
733
|
+
vol = sigma * np.sqrt(dt)
|
|
734
|
+
|
|
735
|
+
Z = rng.standard_normal((n_paths, n_steps))
|
|
736
|
+
log_S = np.log(S) + np.cumsum(drift + vol * Z, axis=1)
|
|
737
|
+
paths = np.exp(log_S)
|
|
738
|
+
paths = np.concatenate([np.full((n_paths, 1), S), paths], axis=1)
|
|
739
|
+
|
|
740
|
+
S_T = paths[:, -1]
|
|
741
|
+
S_max = np.max(paths, axis=1)
|
|
742
|
+
S_min = np.min(paths, axis=1)
|
|
743
|
+
|
|
744
|
+
if strike_type == "fixed":
|
|
745
|
+
if K is None:
|
|
746
|
+
raise ValueError("K required for fixed-strike lookback")
|
|
747
|
+
if option_type == "call":
|
|
748
|
+
payoffs = np.maximum(S_max - K, 0.0)
|
|
749
|
+
else:
|
|
750
|
+
payoffs = np.maximum(K - S_min, 0.0)
|
|
751
|
+
else:
|
|
752
|
+
if option_type == "call":
|
|
753
|
+
payoffs = S_T - S_min
|
|
754
|
+
else:
|
|
755
|
+
payoffs = S_max - S_T
|
|
756
|
+
|
|
757
|
+
discount = np.exp(-r * T)
|
|
758
|
+
price = discount * float(np.mean(payoffs))
|
|
759
|
+
stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths))
|
|
760
|
+
return price, stderr
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
# Cliquet (ratchet) options
|
|
765
|
+
# ---------------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
def cliquet_price_mc(
|
|
768
|
+
S: float,
|
|
769
|
+
T: float,
|
|
770
|
+
r: float,
|
|
771
|
+
sigma: float,
|
|
772
|
+
q: float = 0.0,
|
|
773
|
+
n_periods: int = 4,
|
|
774
|
+
cap: float | None = None,
|
|
775
|
+
floor: float | None = None,
|
|
776
|
+
global_cap: float | None = None,
|
|
777
|
+
global_floor: float | None = None,
|
|
778
|
+
n_paths: int = 100_000,
|
|
779
|
+
n_steps_per_period: int = 63,
|
|
780
|
+
seed: int | None = None,
|
|
781
|
+
) -> tuple[float, float]:
|
|
782
|
+
"""Monte Carlo cliquet (ratchet) option pricer.
|
|
783
|
+
|
|
784
|
+
Returns
|
|
785
|
+
-------
|
|
786
|
+
tuple
|
|
787
|
+
(price, standard_error)
|
|
788
|
+
"""
|
|
789
|
+
rng = np.random.default_rng(seed)
|
|
790
|
+
dt = T / (n_periods * n_steps_per_period)
|
|
791
|
+
drift = (r - q - 0.5 * sigma ** 2) * dt
|
|
792
|
+
vol = sigma * np.sqrt(dt)
|
|
793
|
+
|
|
794
|
+
total_returns = np.zeros(n_paths)
|
|
795
|
+
S_start = np.full(n_paths, S)
|
|
796
|
+
|
|
797
|
+
for _ in range(n_periods):
|
|
798
|
+
log_S = np.log(S_start)
|
|
799
|
+
for _step in range(n_steps_per_period):
|
|
800
|
+
Z = rng.standard_normal(n_paths)
|
|
801
|
+
log_S = log_S + drift + vol * Z
|
|
802
|
+
S_end = np.exp(log_S)
|
|
803
|
+
period_return = (S_end - S_start) / S_start
|
|
804
|
+
|
|
805
|
+
if cap is not None:
|
|
806
|
+
period_return = np.minimum(period_return, cap)
|
|
807
|
+
if floor is not None:
|
|
808
|
+
period_return = np.maximum(period_return, floor)
|
|
809
|
+
|
|
810
|
+
total_returns += period_return
|
|
811
|
+
S_start = S_end
|
|
812
|
+
|
|
813
|
+
if global_cap is not None:
|
|
814
|
+
total_returns = np.minimum(total_returns, global_cap)
|
|
815
|
+
if global_floor is not None:
|
|
816
|
+
total_returns = np.maximum(total_returns, global_floor)
|
|
817
|
+
|
|
818
|
+
payoffs = S * np.maximum(total_returns, 0.0)
|
|
819
|
+
|
|
820
|
+
discount = np.exp(-r * T)
|
|
821
|
+
price = discount * float(np.mean(payoffs))
|
|
822
|
+
stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths))
|
|
823
|
+
return price, stderr
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ---------------------------------------------------------------------------
|
|
827
|
+
# Rainbow options (best-of / worst-of)
|
|
828
|
+
# ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
def rainbow_price_mc(
|
|
831
|
+
spots: np.ndarray,
|
|
832
|
+
K: float,
|
|
833
|
+
T: float,
|
|
834
|
+
r: float,
|
|
835
|
+
sigmas: np.ndarray,
|
|
836
|
+
corr: np.ndarray,
|
|
837
|
+
q: np.ndarray | None = None,
|
|
838
|
+
option_type: str = "call",
|
|
839
|
+
rainbow_type: str = "best-of",
|
|
840
|
+
n_paths: int = 100_000,
|
|
841
|
+
n_steps: int = 252,
|
|
842
|
+
seed: int | None = None,
|
|
843
|
+
) -> tuple[float, float]:
|
|
844
|
+
"""Monte Carlo rainbow option pricer (best-of / worst-of).
|
|
845
|
+
|
|
846
|
+
Returns
|
|
847
|
+
-------
|
|
848
|
+
tuple
|
|
849
|
+
(price, standard_error)
|
|
850
|
+
"""
|
|
851
|
+
spots = np.asarray(spots, dtype=float)
|
|
852
|
+
sigmas = np.asarray(sigmas, dtype=float)
|
|
853
|
+
corr = np.asarray(corr, dtype=float)
|
|
854
|
+
n_assets = len(spots)
|
|
855
|
+
|
|
856
|
+
q_arr = np.zeros(n_assets) if q is None else np.asarray(q, dtype=float)
|
|
857
|
+
|
|
858
|
+
rng = np.random.default_rng(seed)
|
|
859
|
+
dt = T / n_steps
|
|
860
|
+
L = np.linalg.cholesky(corr)
|
|
861
|
+
|
|
862
|
+
log_S = np.tile(np.log(spots), (n_paths, 1))
|
|
863
|
+
for _step in range(n_steps):
|
|
864
|
+
Z = rng.standard_normal((n_paths, n_assets))
|
|
865
|
+
Z_corr = Z @ L.T
|
|
866
|
+
drift = (r - q_arr - 0.5 * sigmas ** 2) * dt
|
|
867
|
+
vol = sigmas * np.sqrt(dt) * Z_corr
|
|
868
|
+
log_S += drift + vol
|
|
869
|
+
|
|
870
|
+
S_T = np.exp(log_S)
|
|
871
|
+
|
|
872
|
+
if rainbow_type == "best-of":
|
|
873
|
+
selected = np.max(S_T, axis=1)
|
|
874
|
+
else:
|
|
875
|
+
selected = np.min(S_T, axis=1)
|
|
876
|
+
|
|
877
|
+
if option_type == "call":
|
|
878
|
+
payoffs = np.maximum(selected - K, 0.0)
|
|
879
|
+
else:
|
|
880
|
+
payoffs = np.maximum(K - selected, 0.0)
|
|
881
|
+
|
|
882
|
+
discount = np.exp(-r * T)
|
|
883
|
+
price = discount * float(np.mean(payoffs))
|
|
884
|
+
stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths))
|
|
885
|
+
return price, stderr
|