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 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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.1.0"
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
+ )