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.
Files changed (44) hide show
  1. tensorquantlib/__init__.py +313 -0
  2. tensorquantlib/__main__.py +315 -0
  3. tensorquantlib/backtest/__init__.py +48 -0
  4. tensorquantlib/backtest/engine.py +240 -0
  5. tensorquantlib/backtest/metrics.py +320 -0
  6. tensorquantlib/backtest/strategy.py +348 -0
  7. tensorquantlib/core/__init__.py +6 -0
  8. tensorquantlib/core/ops.py +70 -0
  9. tensorquantlib/core/second_order.py +465 -0
  10. tensorquantlib/core/tensor.py +928 -0
  11. tensorquantlib/data/__init__.py +16 -0
  12. tensorquantlib/data/market.py +160 -0
  13. tensorquantlib/finance/__init__.py +52 -0
  14. tensorquantlib/finance/american.py +263 -0
  15. tensorquantlib/finance/basket.py +291 -0
  16. tensorquantlib/finance/black_scholes.py +219 -0
  17. tensorquantlib/finance/credit.py +199 -0
  18. tensorquantlib/finance/exotics.py +885 -0
  19. tensorquantlib/finance/fx.py +204 -0
  20. tensorquantlib/finance/greeks.py +133 -0
  21. tensorquantlib/finance/heston.py +543 -0
  22. tensorquantlib/finance/implied_vol.py +277 -0
  23. tensorquantlib/finance/ir_derivatives.py +203 -0
  24. tensorquantlib/finance/jump_diffusion.py +203 -0
  25. tensorquantlib/finance/local_vol.py +146 -0
  26. tensorquantlib/finance/rates.py +381 -0
  27. tensorquantlib/finance/risk.py +344 -0
  28. tensorquantlib/finance/variance_reduction.py +420 -0
  29. tensorquantlib/finance/volatility.py +355 -0
  30. tensorquantlib/py.typed +0 -0
  31. tensorquantlib/tt/__init__.py +43 -0
  32. tensorquantlib/tt/decompose.py +576 -0
  33. tensorquantlib/tt/ops.py +386 -0
  34. tensorquantlib/tt/pricing.py +304 -0
  35. tensorquantlib/tt/surrogate.py +634 -0
  36. tensorquantlib/utils/__init__.py +5 -0
  37. tensorquantlib/utils/validation.py +126 -0
  38. tensorquantlib/viz/__init__.py +27 -0
  39. tensorquantlib/viz/plots.py +331 -0
  40. tensorquantlib-0.3.0.dist-info/METADATA +602 -0
  41. tensorquantlib-0.3.0.dist-info/RECORD +44 -0
  42. tensorquantlib-0.3.0.dist-info/WHEEL +5 -0
  43. tensorquantlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  44. 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