radonlab 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.
- radonlab/__init__.py +72 -0
- radonlab/_special.py +37 -0
- radonlab/_version.py +3 -0
- radonlab/analytics.py +190 -0
- radonlab/asian.py +155 -0
- radonlab/benchmark.py +88 -0
- radonlab/estimators/__init__.py +16 -0
- radonlab/estimators/base.py +57 -0
- radonlab/estimators/conditional_mc.py +111 -0
- radonlab/estimators/finite_difference.py +84 -0
- radonlab/estimators/likelihood_ratio.py +97 -0
- radonlab/estimators/pathwise.py +57 -0
- radonlab/estimators/smoothing.py +78 -0
- radonlab/merton.py +175 -0
- radonlab/models.py +102 -0
- radonlab/payoffs.py +160 -0
- radonlab/py.typed +0 -0
- radonlab/rng.py +83 -0
- radonlab/types.py +107 -0
- radonlab-0.1.0.dist-info/METADATA +198 -0
- radonlab-0.1.0.dist-info/RECORD +23 -0
- radonlab-0.1.0.dist-info/WHEEL +4 -0
- radonlab-0.1.0.dist-info/licenses/LICENSE +201 -0
radonlab/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""radonlab -- Monte Carlo Greeks for discontinuous payoffs.
|
|
2
|
+
|
|
3
|
+
A small, dependency-light library for estimating option sensitivities when the
|
|
4
|
+
payoff is discontinuous (digitals, barriers) and the textbook pathwise method
|
|
5
|
+
breaks down. It implements, tests and benchmarks the classic remedies side by
|
|
6
|
+
side: the likelihood-ratio method, payoff smoothing, and Brownian-bridge
|
|
7
|
+
conditional Monte Carlo, against finite-difference and pathwise baselines and
|
|
8
|
+
closed-form Black-Scholes references.
|
|
9
|
+
|
|
10
|
+
Quick start
|
|
11
|
+
-----------
|
|
12
|
+
>>> from radonlab import GeometricBrownianMotion, CashOrNothingCall, Greek
|
|
13
|
+
>>> from radonlab import likelihood_ratio, analytics
|
|
14
|
+
>>> model = GeometricBrownianMotion(S0=100, r=0.03, sigma=0.2, T=1.0)
|
|
15
|
+
>>> payoff = CashOrNothingCall(strike=100)
|
|
16
|
+
>>> est = likelihood_ratio(model, payoff, Greek.DELTA, n_paths=500_000)
|
|
17
|
+
>>> ref = analytics.cash_or_nothing_call(100, 100, 0.03, 0.2, 1.0, greek="delta")
|
|
18
|
+
>>> abs(est.value - ref) < 4 * est.std_error
|
|
19
|
+
True
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from . import analytics, asian, benchmark, merton
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from .estimators import (
|
|
27
|
+
conditional_mc,
|
|
28
|
+
finite_difference,
|
|
29
|
+
likelihood_ratio,
|
|
30
|
+
pathwise,
|
|
31
|
+
smoothing,
|
|
32
|
+
suggest_bandwidth,
|
|
33
|
+
)
|
|
34
|
+
from .models import GeometricBrownianMotion
|
|
35
|
+
from .payoffs import (
|
|
36
|
+
AssetOrNothingCall,
|
|
37
|
+
CashOrNothingCall,
|
|
38
|
+
DownAndOutCall,
|
|
39
|
+
VanillaCall,
|
|
40
|
+
VanillaPut,
|
|
41
|
+
)
|
|
42
|
+
from .rng import NormalSampler
|
|
43
|
+
from .types import Greek, GreekResult, MarketError
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"__version__",
|
|
47
|
+
# model & rng
|
|
48
|
+
"GeometricBrownianMotion",
|
|
49
|
+
"NormalSampler",
|
|
50
|
+
# payoffs
|
|
51
|
+
"VanillaCall",
|
|
52
|
+
"VanillaPut",
|
|
53
|
+
"CashOrNothingCall",
|
|
54
|
+
"AssetOrNothingCall",
|
|
55
|
+
"DownAndOutCall",
|
|
56
|
+
# estimators
|
|
57
|
+
"likelihood_ratio",
|
|
58
|
+
"pathwise",
|
|
59
|
+
"finite_difference",
|
|
60
|
+
"smoothing",
|
|
61
|
+
"suggest_bandwidth",
|
|
62
|
+
"conditional_mc",
|
|
63
|
+
# types
|
|
64
|
+
"Greek",
|
|
65
|
+
"GreekResult",
|
|
66
|
+
"MarketError",
|
|
67
|
+
# modules
|
|
68
|
+
"analytics",
|
|
69
|
+
"benchmark",
|
|
70
|
+
"merton",
|
|
71
|
+
"asian",
|
|
72
|
+
]
|
radonlab/_special.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Numerical helpers.
|
|
2
|
+
|
|
3
|
+
The normal CDF/PDF here are deliberately implemented on top of
|
|
4
|
+
:func:`scipy.special.erfc`, which accepts complex arguments. This lets the
|
|
5
|
+
closed-form pricing functions in :mod:`radonlab.analytics` be evaluated at
|
|
6
|
+
complex inputs, which in turn enables *complex-step differentiation* -- a
|
|
7
|
+
machine-precision way to obtain reference Greeks that the test-suite checks the
|
|
8
|
+
explicit analytic formulas against.
|
|
9
|
+
|
|
10
|
+
Nothing in this module is specific to any payoff or model; it is pure math.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import special
|
|
17
|
+
|
|
18
|
+
__all__ = ["norm_cdf", "norm_pdf", "SQRT_2PI", "SQRT_2"]
|
|
19
|
+
|
|
20
|
+
SQRT_2PI: float = float(np.sqrt(2.0 * np.pi))
|
|
21
|
+
SQRT_2: float = float(np.sqrt(2.0))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def norm_cdf(x):
|
|
25
|
+
"""Standard normal cumulative distribution function.
|
|
26
|
+
|
|
27
|
+
Works for real and complex ``x`` (element-wise for arrays). The complex
|
|
28
|
+
branch is what makes complex-step differentiation of the pricing formulas
|
|
29
|
+
possible.
|
|
30
|
+
"""
|
|
31
|
+
return 0.5 * special.erfc(-np.asarray(x) / SQRT_2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def norm_pdf(x):
|
|
35
|
+
"""Standard normal probability density function (real or complex ``x``)."""
|
|
36
|
+
x = np.asarray(x)
|
|
37
|
+
return np.exp(-0.5 * x * x) / SQRT_2PI
|
radonlab/_version.py
ADDED
radonlab/analytics.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Closed-form Black-Scholes / Black-Scholes-Merton analytics.
|
|
2
|
+
|
|
3
|
+
These functions are the *ground truth* the Monte Carlo estimators are validated
|
|
4
|
+
against. Everything is derived under a geometric Brownian motion with constant
|
|
5
|
+
risk-free rate ``r``, continuous dividend yield ``q`` and volatility ``sigma``:
|
|
6
|
+
|
|
7
|
+
S_T = S_0 * exp((r - q - 0.5 * sigma**2) * T + sigma * sqrt(T) * Z),
|
|
8
|
+
Z ~ N(0, 1).
|
|
9
|
+
|
|
10
|
+
The explicit Greek formulas are checked against complex-step differentiation of
|
|
11
|
+
the corresponding price formulas in the test-suite, so any algebra error is
|
|
12
|
+
caught automatically.
|
|
13
|
+
|
|
14
|
+
References
|
|
15
|
+
----------
|
|
16
|
+
- Black, F. and Scholes, M. (1973). The Pricing of Options and Corporate
|
|
17
|
+
Liabilities. Journal of Political Economy.
|
|
18
|
+
- Reiner, E. and Rubinstein, M. (1991). Breaking Down the Barriers. Risk.
|
|
19
|
+
- Haug, E. G. (2007). The Complete Guide to Option Pricing Formulas, 2nd ed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from ._special import norm_cdf, norm_pdf
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"d1_d2",
|
|
30
|
+
"vanilla_call",
|
|
31
|
+
"vanilla_put",
|
|
32
|
+
"cash_or_nothing_call",
|
|
33
|
+
"asset_or_nothing_call",
|
|
34
|
+
"down_and_out_call",
|
|
35
|
+
"down_and_in_call",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def d1_d2(S, K, r, sigma, T, q=0.0):
|
|
40
|
+
"""Return the Black-Scholes ``d1`` and ``d2`` terms."""
|
|
41
|
+
S = (
|
|
42
|
+
np.asarray(S, dtype=complex)
|
|
43
|
+
if np.iscomplexobj(S)
|
|
44
|
+
else np.asarray(S, dtype=float)
|
|
45
|
+
)
|
|
46
|
+
vol_t = sigma * np.sqrt(T)
|
|
47
|
+
d1 = (np.log(S / K) + (r - q + 0.5 * sigma * sigma) * T) / vol_t
|
|
48
|
+
d2 = d1 - vol_t
|
|
49
|
+
return d1, d2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
# Vanilla European call/put
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
def vanilla_call(S, K, r, sigma, T, q=0.0, greek="price"):
|
|
56
|
+
"""Price or Greek of a European call.
|
|
57
|
+
|
|
58
|
+
``greek`` is one of ``price``, ``delta``, ``gamma``, ``vega``, ``theta``,
|
|
59
|
+
``rho``.
|
|
60
|
+
"""
|
|
61
|
+
d1, d2 = d1_d2(S, K, r, sigma, T, q)
|
|
62
|
+
disc_r = np.exp(-r * T)
|
|
63
|
+
disc_q = np.exp(-q * T)
|
|
64
|
+
sqrt_t = np.sqrt(T)
|
|
65
|
+
|
|
66
|
+
if greek == "price":
|
|
67
|
+
return S * disc_q * norm_cdf(d1) - K * disc_r * norm_cdf(d2)
|
|
68
|
+
if greek == "delta":
|
|
69
|
+
return disc_q * norm_cdf(d1)
|
|
70
|
+
if greek == "gamma":
|
|
71
|
+
return disc_q * norm_pdf(d1) / (S * sigma * sqrt_t)
|
|
72
|
+
if greek == "vega":
|
|
73
|
+
return S * disc_q * norm_pdf(d1) * sqrt_t
|
|
74
|
+
if greek == "rho":
|
|
75
|
+
return K * T * disc_r * norm_cdf(d2)
|
|
76
|
+
if greek == "theta":
|
|
77
|
+
term1 = -S * disc_q * norm_pdf(d1) * sigma / (2 * sqrt_t)
|
|
78
|
+
term2 = q * S * disc_q * norm_cdf(d1)
|
|
79
|
+
term3 = -r * K * disc_r * norm_cdf(d2)
|
|
80
|
+
return term1 + term2 + term3
|
|
81
|
+
raise ValueError(f"Unknown greek {greek!r}.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def vanilla_put(S, K, r, sigma, T, q=0.0, greek="price"):
|
|
85
|
+
"""Price or Greek of a European put (via put-call parity relations)."""
|
|
86
|
+
d1, d2 = d1_d2(S, K, r, sigma, T, q)
|
|
87
|
+
disc_r = np.exp(-r * T)
|
|
88
|
+
disc_q = np.exp(-q * T)
|
|
89
|
+
sqrt_t = np.sqrt(T)
|
|
90
|
+
|
|
91
|
+
if greek == "price":
|
|
92
|
+
return K * disc_r * norm_cdf(-d2) - S * disc_q * norm_cdf(-d1)
|
|
93
|
+
if greek == "delta":
|
|
94
|
+
return disc_q * (norm_cdf(d1) - 1.0)
|
|
95
|
+
if greek == "gamma":
|
|
96
|
+
return disc_q * norm_pdf(d1) / (S * sigma * sqrt_t)
|
|
97
|
+
if greek == "vega":
|
|
98
|
+
return S * disc_q * norm_pdf(d1) * sqrt_t
|
|
99
|
+
if greek == "rho":
|
|
100
|
+
return -K * T * disc_r * norm_cdf(-d2)
|
|
101
|
+
if greek == "theta":
|
|
102
|
+
term1 = -S * disc_q * norm_pdf(d1) * sigma / (2 * sqrt_t)
|
|
103
|
+
term2 = -q * S * disc_q * norm_cdf(-d1)
|
|
104
|
+
term3 = r * K * disc_r * norm_cdf(-d2)
|
|
105
|
+
return term1 + term2 + term3
|
|
106
|
+
raise ValueError(f"Unknown greek {greek!r}.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
# Cash-or-nothing digital call: pays `cash` if S_T > K
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
def cash_or_nothing_call(S, K, r, sigma, T, q=0.0, cash=1.0, greek="price"):
|
|
113
|
+
"""Price or Greek of a cash-or-nothing digital call.
|
|
114
|
+
|
|
115
|
+
This is the canonical *discontinuous* payoff for which naive pathwise
|
|
116
|
+
differentiation fails and the likelihood-ratio / smoothing / conditional
|
|
117
|
+
Monte Carlo methods earn their keep.
|
|
118
|
+
"""
|
|
119
|
+
d1, d2 = d1_d2(S, K, r, sigma, T, q)
|
|
120
|
+
disc_r = np.exp(-r * T)
|
|
121
|
+
vol_t = sigma * np.sqrt(T)
|
|
122
|
+
|
|
123
|
+
if greek == "price":
|
|
124
|
+
return cash * disc_r * norm_cdf(d2)
|
|
125
|
+
if greek == "delta":
|
|
126
|
+
return cash * disc_r * norm_pdf(d2) / (S * vol_t)
|
|
127
|
+
if greek == "gamma":
|
|
128
|
+
return (
|
|
129
|
+
-cash * disc_r * norm_pdf(d2) * (d2 + vol_t) / (S * S * sigma * sigma * T)
|
|
130
|
+
)
|
|
131
|
+
if greek == "vega":
|
|
132
|
+
# d(N(d2))/dsigma = n(d2) * d(d2)/dsigma, d2 = d1 - vol_t
|
|
133
|
+
dd2_dsigma = -d1 / sigma
|
|
134
|
+
return cash * disc_r * norm_pdf(d2) * dd2_dsigma
|
|
135
|
+
if greek == "rho":
|
|
136
|
+
# d/dr [ e^{-rT} N(d2) ]; d(d2)/dr = sqrt(T)/sigma
|
|
137
|
+
return cash * disc_r * (-T * norm_cdf(d2) + norm_pdf(d2) * np.sqrt(T) / sigma)
|
|
138
|
+
raise ValueError(f"Unknown greek {greek!r}.")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# Asset-or-nothing digital call: pays S_T if S_T > K
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
def asset_or_nothing_call(S, K, r, sigma, T, q=0.0, greek="price"):
|
|
145
|
+
"""Price or Greek of an asset-or-nothing digital call."""
|
|
146
|
+
d1, d2 = d1_d2(S, K, r, sigma, T, q)
|
|
147
|
+
disc_q = np.exp(-q * T)
|
|
148
|
+
vol_t = sigma * np.sqrt(T)
|
|
149
|
+
|
|
150
|
+
if greek == "price":
|
|
151
|
+
return S * disc_q * norm_cdf(d1)
|
|
152
|
+
if greek == "delta":
|
|
153
|
+
return disc_q * (norm_cdf(d1) + norm_pdf(d1) / vol_t)
|
|
154
|
+
if greek == "vega":
|
|
155
|
+
dd1_dsigma = -d2 / sigma
|
|
156
|
+
return S * disc_q * norm_pdf(d1) * dd1_dsigma
|
|
157
|
+
raise ValueError(f"Unknown greek {greek!r}.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
# Continuously-monitored down-and-out / down-and-in call, B <= K, no rebate
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
def _barrier_lambda(r, q, sigma):
|
|
164
|
+
return (r - q + 0.5 * sigma * sigma) / (sigma * sigma)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def down_and_in_call(S, K, B, r, sigma, T, q=0.0, greek="price"):
|
|
168
|
+
"""Continuously-monitored down-and-in call price (requires ``B <= K``)."""
|
|
169
|
+
if greek != "price":
|
|
170
|
+
raise ValueError("Only 'price' is provided in closed form for barriers.")
|
|
171
|
+
lam = _barrier_lambda(r, q, sigma)
|
|
172
|
+
vol_t = sigma * np.sqrt(T)
|
|
173
|
+
disc_r = np.exp(-r * T)
|
|
174
|
+
disc_q = np.exp(-q * T)
|
|
175
|
+
y = np.log(B * B / (S * K)) / vol_t + lam * vol_t
|
|
176
|
+
return S * disc_q * (B / S) ** (2 * lam) * norm_cdf(y) - K * disc_r * (B / S) ** (
|
|
177
|
+
2 * lam - 2
|
|
178
|
+
) * norm_cdf(y - vol_t)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def down_and_out_call(S, K, B, r, sigma, T, q=0.0, greek="price"):
|
|
182
|
+
"""Continuously-monitored down-and-out call price (requires ``B <= K``).
|
|
183
|
+
|
|
184
|
+
Uses the in-out parity ``C_do = C_vanilla - C_di``.
|
|
185
|
+
"""
|
|
186
|
+
if greek != "price":
|
|
187
|
+
raise ValueError("Only 'price' is provided in closed form for barriers.")
|
|
188
|
+
vanilla = vanilla_call(S, K, r, sigma, T, q, greek="price")
|
|
189
|
+
knock_in = down_and_in_call(S, K, B, r, sigma, T, q, greek="price")
|
|
190
|
+
return vanilla - knock_in
|
radonlab/asian.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Path-dependent Greeks: the geometric Asian digital.
|
|
2
|
+
|
|
3
|
+
The payoff ``1{A > K}`` depends on the geometric average
|
|
4
|
+
``A = (prod_i S_{t_i})^{1/m}`` of the price at ``m`` monitoring dates, so it is
|
|
5
|
+
both path-dependent and discontinuous. Naive pathwise differentiation fails for
|
|
6
|
+
the same reason it fails on a European digital.
|
|
7
|
+
|
|
8
|
+
Because ``ln A`` is Gaussian under geometric Brownian motion, this payoff has a
|
|
9
|
+
closed-form price and delta, which we use as ground truth. The estimator is the
|
|
10
|
+
likelihood-ratio / Malliavin weight for the law of ``A``:
|
|
11
|
+
|
|
12
|
+
delta = e^{-rT} E[ 1{A > K} * G / (v_A * S0) ],
|
|
13
|
+
G = ln A - E[ln A], v_A = Var(ln A).
|
|
14
|
+
|
|
15
|
+
The weight ``G/(v_A S0)`` is the score of the (Gaussian) law of ``ln A`` with
|
|
16
|
+
respect to the spot; it coincides with the Malliavin integration-by-parts weight
|
|
17
|
+
projected onto ``A``. Crucially ``G`` is built from the entire simulated path,
|
|
18
|
+
which is what makes this a genuinely path-dependent estimator rather than a
|
|
19
|
+
terminal one.
|
|
20
|
+
|
|
21
|
+
References
|
|
22
|
+
----------
|
|
23
|
+
- Fournie, E., Lasry, J.-M., Lebuchoux, J., Lions, P.-L. and Touzi, N. (1999).
|
|
24
|
+
Applications of Malliavin calculus to Monte Carlo methods in finance.
|
|
25
|
+
Finance and Stochastics 3(4), 391-412.
|
|
26
|
+
- Glasserman, P. (2003). Monte Carlo Methods in Financial Engineering, ch. 7.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from ._special import norm_cdf, norm_pdf
|
|
37
|
+
from .estimators.base import make_result
|
|
38
|
+
from .types import Greek, GreekResult
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"GeometricAsianDigital",
|
|
42
|
+
"geometric_asian_digital_closed_form",
|
|
43
|
+
"geometric_asian_digital_delta_lrm",
|
|
44
|
+
"geometric_asian_digital_delta_pathwise",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class GeometricAsianDigital:
|
|
50
|
+
"""Discretely-monitored geometric Asian cash-or-nothing call under GBM.
|
|
51
|
+
|
|
52
|
+
Monitoring dates are ``t_i = i * T / m`` for ``i = 1..m``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
S0: float
|
|
56
|
+
K: float
|
|
57
|
+
r: float
|
|
58
|
+
sigma: float
|
|
59
|
+
T: float
|
|
60
|
+
m: int
|
|
61
|
+
q: float = 0.0
|
|
62
|
+
cash: float = 1.0
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if self.S0 <= 0 or self.sigma <= 0 or self.T <= 0:
|
|
66
|
+
raise ValueError("S0, sigma, T must be positive.")
|
|
67
|
+
if self.m < 1:
|
|
68
|
+
raise ValueError("m must be at least 1.")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def times(self) -> np.ndarray:
|
|
72
|
+
return np.arange(1, self.m + 1) * (self.T / self.m)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def _mean_var(self):
|
|
76
|
+
"""Mean and variance of ln A."""
|
|
77
|
+
t = self.times
|
|
78
|
+
mean = math.log(self.S0) + (self.r - self.q - 0.5 * self.sigma**2) * t.mean()
|
|
79
|
+
cov_sum = np.minimum.outer(t, t).sum()
|
|
80
|
+
var = (self.sigma**2 / self.m**2) * cov_sum
|
|
81
|
+
return float(mean), float(var)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def discount(self) -> float:
|
|
85
|
+
return math.exp(-self.r * self.T)
|
|
86
|
+
|
|
87
|
+
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
|
|
88
|
+
"""Sample ``n`` geometric averages ``A``."""
|
|
89
|
+
dt = self.T / self.m
|
|
90
|
+
incr = rng.standard_normal((n, self.m)) * math.sqrt(dt)
|
|
91
|
+
w = np.cumsum(incr, axis=1) # Brownian motion at monitoring dates
|
|
92
|
+
t = self.times
|
|
93
|
+
log_s = (
|
|
94
|
+
math.log(self.S0)
|
|
95
|
+
+ (self.r - self.q - 0.5 * self.sigma**2) * t
|
|
96
|
+
+ self.sigma * w
|
|
97
|
+
)
|
|
98
|
+
log_a = log_s.mean(axis=1)
|
|
99
|
+
return np.exp(log_a)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def geometric_asian_digital_closed_form(
|
|
103
|
+
opt: GeometricAsianDigital, greek: str = "price"
|
|
104
|
+
) -> float:
|
|
105
|
+
"""Closed-form price or delta of the geometric Asian digital."""
|
|
106
|
+
mean, var = opt._mean_var
|
|
107
|
+
sd = math.sqrt(var)
|
|
108
|
+
d = (mean - math.log(opt.K)) / sd
|
|
109
|
+
if greek == "price":
|
|
110
|
+
return opt.cash * opt.discount * norm_cdf(d)
|
|
111
|
+
if greek == "delta":
|
|
112
|
+
# d(mean)/dS0 = 1/S0, so d(d)/dS0 = 1/(S0 sd).
|
|
113
|
+
return opt.cash * opt.discount * norm_pdf(d) / (opt.S0 * sd)
|
|
114
|
+
raise ValueError("greek must be 'price' or 'delta'.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def geometric_asian_digital_delta_lrm(
|
|
118
|
+
opt: GeometricAsianDigital, n_paths: int = 1_000_000, seed: int = 0
|
|
119
|
+
) -> GreekResult:
|
|
120
|
+
"""Likelihood-ratio / Malliavin delta of the geometric Asian digital.
|
|
121
|
+
|
|
122
|
+
Unbiased for the discontinuous payoff, using a weight built from the whole
|
|
123
|
+
path.
|
|
124
|
+
"""
|
|
125
|
+
rng = np.random.default_rng(seed)
|
|
126
|
+
mean, var = opt._mean_var
|
|
127
|
+
a = opt.sample(n_paths, rng)
|
|
128
|
+
g = np.log(a) - mean # centered Gaussian, a path functional
|
|
129
|
+
weight = g / (var * opt.S0)
|
|
130
|
+
payoff = opt.cash * (a > opt.K).astype(float)
|
|
131
|
+
contrib = opt.discount * payoff * weight
|
|
132
|
+
return make_result(
|
|
133
|
+
contrib,
|
|
134
|
+
greek=Greek.DELTA,
|
|
135
|
+
method="geometric-asian-lrm",
|
|
136
|
+
runtime_s=None,
|
|
137
|
+
n_paths=n_paths,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def geometric_asian_digital_delta_pathwise(
|
|
142
|
+
opt: GeometricAsianDigital, n_paths: int = 1_000_000, seed: int = 0
|
|
143
|
+
) -> GreekResult:
|
|
144
|
+
"""Naive pathwise delta, retained to show it collapses to zero (biased)."""
|
|
145
|
+
rng = np.random.default_rng(seed)
|
|
146
|
+
a = opt.sample(n_paths, rng)
|
|
147
|
+
# d/dA of 1{A>K} is zero almost everywhere.
|
|
148
|
+
contrib = np.zeros_like(a)
|
|
149
|
+
return make_result(
|
|
150
|
+
contrib,
|
|
151
|
+
greek=Greek.DELTA,
|
|
152
|
+
method="geometric-asian-pathwise",
|
|
153
|
+
runtime_s=None,
|
|
154
|
+
n_paths=n_paths,
|
|
155
|
+
)
|
radonlab/benchmark.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Benchmark harness.
|
|
2
|
+
|
|
3
|
+
Runs a set of estimators for the same Greek and tabulates their point estimate,
|
|
4
|
+
standard error, absolute error against a known analytic reference, the number of
|
|
5
|
+
standard errors that error represents, and runtime. This is what turns the
|
|
6
|
+
library from "some estimators" into "here is *which one to use and why*".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from .models import GeometricBrownianMotion
|
|
14
|
+
from .payoffs import TerminalPayoff
|
|
15
|
+
from .rng import NormalSampler
|
|
16
|
+
from .types import Greek, GreekResult, MarketError
|
|
17
|
+
|
|
18
|
+
try: # pandas is optional; the harness degrades to a list of dicts without it.
|
|
19
|
+
import pandas as _pd
|
|
20
|
+
|
|
21
|
+
_HAS_PANDAS = True
|
|
22
|
+
except Exception: # pragma: no cover
|
|
23
|
+
_HAS_PANDAS = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _record(result: GreekResult, reference: float, bias_z: float) -> dict[str, object]:
|
|
27
|
+
err = MarketError(result, reference)
|
|
28
|
+
# A method is flagged biased if its estimate sits many standard errors from
|
|
29
|
+
# the reference, or if it reports zero variance yet is plainly wrong (the
|
|
30
|
+
# degenerate pathwise-on-a-digital case).
|
|
31
|
+
biased = err.z_score > bias_z or (result.std_error == 0.0 and err.abs_error > 1e-8)
|
|
32
|
+
return {
|
|
33
|
+
"method": result.method,
|
|
34
|
+
"greek": str(result.greek),
|
|
35
|
+
"value": result.value,
|
|
36
|
+
"std_error": result.std_error,
|
|
37
|
+
"abs_error": err.abs_error,
|
|
38
|
+
"rel_error": err.rel_error,
|
|
39
|
+
"z_score": err.z_score,
|
|
40
|
+
"biased": biased,
|
|
41
|
+
"n_paths": result.n_paths,
|
|
42
|
+
"runtime_s": result.runtime_s,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def benchmark_terminal(
|
|
47
|
+
model: GeometricBrownianMotion,
|
|
48
|
+
payoff: TerminalPayoff,
|
|
49
|
+
greek: Greek,
|
|
50
|
+
reference: float,
|
|
51
|
+
estimators: dict[str, Callable[..., GreekResult]],
|
|
52
|
+
n_paths: int = 200_000,
|
|
53
|
+
seed: int = 0,
|
|
54
|
+
sampler_kwargs: dict | None = None,
|
|
55
|
+
bias_z: float = 6.0,
|
|
56
|
+
):
|
|
57
|
+
"""Run each estimator once and compare against ``reference``.
|
|
58
|
+
|
|
59
|
+
``estimators`` maps a display label to a callable
|
|
60
|
+
``fn(model, payoff, greek, n_paths=..., sampler=...) -> GreekResult``.
|
|
61
|
+
Each estimator gets an independent sampler spawned from the same seed so the
|
|
62
|
+
comparison is fair and reproducible.
|
|
63
|
+
|
|
64
|
+
Results are ranked by *standard error* (the estimator's inherent noise, the
|
|
65
|
+
metric that actually matters), with any biased estimator sorted last and
|
|
66
|
+
flagged, rather than by the single-run absolute error, which is itself a
|
|
67
|
+
noisy draw.
|
|
68
|
+
|
|
69
|
+
Returns a :class:`pandas.DataFrame` if pandas is installed, else a list of
|
|
70
|
+
dicts.
|
|
71
|
+
"""
|
|
72
|
+
sampler_kwargs = dict(sampler_kwargs or {})
|
|
73
|
+
parent = NormalSampler(seed=seed, **sampler_kwargs)
|
|
74
|
+
|
|
75
|
+
records: list[dict[str, object]] = []
|
|
76
|
+
for _label, fn in estimators.items():
|
|
77
|
+
result = fn(model, payoff, greek, n_paths=n_paths, sampler=parent.spawn())
|
|
78
|
+
records.append(_record(result, reference, bias_z))
|
|
79
|
+
|
|
80
|
+
def _key(r: dict[str, object]):
|
|
81
|
+
# Unbiased first, then lowest standard error.
|
|
82
|
+
return (bool(r["biased"]), float(r["std_error"]))
|
|
83
|
+
|
|
84
|
+
if _HAS_PANDAS:
|
|
85
|
+
df = _pd.DataFrame.from_records(records)
|
|
86
|
+
df = df.sort_values(["biased", "std_error"]).reset_index(drop=True)
|
|
87
|
+
return df
|
|
88
|
+
return sorted(records, key=_key)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Greek estimators for terminal and path-dependent payoffs."""
|
|
2
|
+
|
|
3
|
+
from .conditional_mc import conditional_mc
|
|
4
|
+
from .finite_difference import finite_difference
|
|
5
|
+
from .likelihood_ratio import likelihood_ratio
|
|
6
|
+
from .pathwise import pathwise
|
|
7
|
+
from .smoothing import smoothing, suggest_bandwidth
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"conditional_mc",
|
|
11
|
+
"finite_difference",
|
|
12
|
+
"likelihood_ratio",
|
|
13
|
+
"pathwise",
|
|
14
|
+
"smoothing",
|
|
15
|
+
"suggest_bandwidth",
|
|
16
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared estimator machinery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..types import Greek, GreekResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mc_mean_se(samples: np.ndarray) -> tuple[float, float]:
|
|
15
|
+
"""Monte Carlo mean and standard error of the mean.
|
|
16
|
+
|
|
17
|
+
Uses the sample standard deviation with ``ddof=1``. For antithetic draws
|
|
18
|
+
this slightly overstates the true error (the pairs are negatively
|
|
19
|
+
correlated), which is the conservative direction.
|
|
20
|
+
"""
|
|
21
|
+
samples = np.asarray(samples, dtype=float)
|
|
22
|
+
n = samples.size
|
|
23
|
+
mean = float(samples.mean())
|
|
24
|
+
if n < 2:
|
|
25
|
+
return mean, float("inf")
|
|
26
|
+
se = float(samples.std(ddof=1) / np.sqrt(n))
|
|
27
|
+
return mean, se
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def _timer() -> Iterator[list]:
|
|
32
|
+
box: list = [None]
|
|
33
|
+
start = time.perf_counter()
|
|
34
|
+
try:
|
|
35
|
+
yield box
|
|
36
|
+
finally:
|
|
37
|
+
box[0] = time.perf_counter() - start
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_result(
|
|
41
|
+
samples: np.ndarray,
|
|
42
|
+
greek: Greek,
|
|
43
|
+
method: str,
|
|
44
|
+
runtime_s: float | None = None,
|
|
45
|
+
**extra,
|
|
46
|
+
) -> GreekResult:
|
|
47
|
+
"""Build a :class:`GreekResult` from per-path contribution ``samples``."""
|
|
48
|
+
mean, se = mc_mean_se(samples)
|
|
49
|
+
return GreekResult(
|
|
50
|
+
value=mean,
|
|
51
|
+
std_error=se,
|
|
52
|
+
greek=greek,
|
|
53
|
+
method=method,
|
|
54
|
+
n_paths=int(np.asarray(samples).size),
|
|
55
|
+
runtime_s=runtime_s,
|
|
56
|
+
extra=dict(extra),
|
|
57
|
+
)
|