hagan-sabr 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moshe Malka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # hagan-sabr
2
+
3
+ The **SABR** stochastic-volatility model — the Hagan et al. (2002) implied-volatility expansions (**lognormal** and **normal**), the **Obłój** correction, and **(α, ρ, ν) calibration** to a market smile. Zero dependencies.
4
+
5
+ ```
6
+ npm install hagan-sabr
7
+ ```
8
+
9
+ ## Why
10
+
11
+ SABR is the market-standard model for interest-rate swaption and FX volatility smiles — but there was no focused JavaScript/TypeScript implementation. `@quantlib/ql` has it buried in a 28 MB WASM bundle behind a C++-style API. This is the Hagan formulas as a handful of typed functions, and its lognormal output **matches QuantLib's `sabrVolatility` to 1e-9** across 315 test cases.
12
+
13
+ ```ts
14
+ import { lognormalVol, calibrate } from "hagan-sabr";
15
+
16
+ const p = { alpha: 0.2, beta: 0.5, rho: -0.3, nu: 0.4 };
17
+ lognormalVol(100, 100, 1, p); // ATM Black vol ≈ 0.0202
18
+ lognormalVol(100, 80, 1, p); // the smile at a lower strike
19
+
20
+ // Fit (α, ρ, ν) to a market smile (β fixed):
21
+ const fit = calibrate(100, 2, [
22
+ { strike: 80, vol: 0.245 },
23
+ { strike: 100, vol: 0.202 },
24
+ { strike: 120, vol: 0.199 },
25
+ // …
26
+ ], { beta: 0.5 });
27
+ fit.alpha; fit.rho; fit.nu; fit.rmse;
28
+ ```
29
+
30
+ ## API
31
+
32
+ Parameters: forward `F`, strike `K`, expiry `T` (years), and `{ alpha, beta, rho, nu }` — α > 0, 0 ≤ β ≤ 1, −1 < ρ < 1, ν ≥ 0. Vols are decimals.
33
+
34
+ - **`lognormalVol(F, K, T, params)`** — Hagan Black/lognormal implied vol (matches QuantLib).
35
+ - **`normalVol(F, K, T, params)`** — Hagan Bachelier/normal implied vol (eq. 2.18).
36
+ - **`oblojVol(F, K, T, params)`** — the Obłój (2008) refinement that fixes the small-strike / long-maturity leading term.
37
+ - **`calibrate(F, T, quotes, opts?)`** — least-squares fit of (α, ρ, ν) with β fixed (default 0.5), via Nelder–Mead; returns the params plus fit `rmse`.
38
+
39
+ Invalid inputs throw `RangeError`.
40
+
41
+ ## Correctness
42
+
43
+ The lognormal expansion is validated against **QuantLib's `sabrVolatility`** — 315 fixtures spanning β ∈ {0, 0.3, 0.5, 0.7, 1}, forwards from 0.02 to 100, expiries to 5y, and strikes from deep ITM to deep OTM — all matching to **1e-9** ([`test/generate-fixtures.py`](test/generate-fixtures.py)). The normal and Obłój variants are cross-checked against an independent reference implementation, and calibration is verified to recover known parameters from a synthetic smile.
44
+
45
+ ## Related
46
+
47
+ Pairs with [`svi-vol-surface`](https://github.com/moshejs/svi-vol-surface) (arbitrage-free surface parametrization). Part of a quant/fixed-income toolkit: [`compounded-sofr`](https://github.com/moshejs/compounded-sofr) · [`day-count`](https://github.com/moshejs/day-count) · [`instrument-identifiers`](https://github.com/moshejs/instrument-identifiers) · [`newyorkfed`](https://github.com/moshejs/newyorkfed).
48
+
49
+ ## Author
50
+
51
+ Built by **[Moshe Malka](https://moshemalka.com)** — engineering leader in New York City. Studio work at [Quentin.Code](https://www.quentin.software/).
52
+
53
+ MIT © Moshe Malka
package/dist/index.cjs ADDED
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ calibrate: () => calibrate,
24
+ lognormalVol: () => lognormalVol,
25
+ normalVol: () => normalVol,
26
+ oblojVol: () => oblojVol
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+ function validate(F, K, T, p) {
30
+ if (!(F > 0)) throw new RangeError(`forward must be positive, got ${F}`);
31
+ if (!(K > 0)) throw new RangeError(`strike must be positive, got ${K}`);
32
+ if (!(T > 0)) throw new RangeError(`expiry must be positive, got ${T}`);
33
+ if (!(p.alpha > 0)) throw new RangeError(`alpha must be positive, got ${p.alpha}`);
34
+ if (p.beta < 0 || p.beta > 1) throw new RangeError(`beta must be in [0,1], got ${p.beta}`);
35
+ if (p.rho <= -1 || p.rho >= 1) throw new RangeError(`rho must be in (-1,1), got ${p.rho}`);
36
+ if (p.nu < 0) throw new RangeError(`nu must be non-negative, got ${p.nu}`);
37
+ }
38
+ function zOverX(z, rho) {
39
+ if (Math.abs(z) < 1e-12) {
40
+ return 1 - rho * z / 2 + (2 - 3 * rho * rho) * z * z / 12;
41
+ }
42
+ const x = Math.log((Math.sqrt(1 - 2 * rho * z + z * z) + z - rho) / (1 - rho));
43
+ return z / x;
44
+ }
45
+ function lognormalVol(F, K, T, p) {
46
+ validate(F, K, T, p);
47
+ const { alpha, beta, rho, nu } = p;
48
+ const oneMinusBeta = 1 - beta;
49
+ const logFK = Math.log(F / K);
50
+ const FKbeta = Math.pow(F * K, oneMinusBeta / 2);
51
+ const term1 = oneMinusBeta * oneMinusBeta * alpha * alpha / (24 * FKbeta * FKbeta);
52
+ const term2 = rho * beta * nu * alpha / (4 * FKbeta);
53
+ const term3 = (2 - 3 * rho * rho) * nu * nu / 24;
54
+ const timeFactor = 1 + (term1 + term2 + term3) * T;
55
+ if (Math.abs(logFK) < 1e-12) {
56
+ return alpha / Math.pow(F, oneMinusBeta) * timeFactor;
57
+ }
58
+ const logFK2 = logFK * logFK;
59
+ const logFK4 = logFK2 * logFK2;
60
+ const denom = FKbeta * (1 + oneMinusBeta * oneMinusBeta * logFK2 / 24 + oneMinusBeta * oneMinusBeta * oneMinusBeta * oneMinusBeta * logFK4 / 1920);
61
+ const z = nu / alpha * FKbeta * logFK;
62
+ return alpha / denom * zOverX(z, rho) * timeFactor;
63
+ }
64
+ function normalVol(F, K, T, p) {
65
+ validate(F, K, T, p);
66
+ const { alpha, beta, rho, nu } = p;
67
+ const oneMinusBeta = 1 - beta;
68
+ const FKpow1mb = Math.pow(F * K, oneMinusBeta);
69
+ const FKhalf = Math.pow(F * K, oneMinusBeta / 2);
70
+ const c1 = -beta * (2 - beta) * alpha * alpha / (24 * FKpow1mb);
71
+ const c2 = rho * beta * nu * alpha / (4 * FKhalf);
72
+ const c3 = (2 - 3 * rho * rho) * nu * nu / 24;
73
+ const timeFactor = 1 + (c1 + c2 + c3) * T;
74
+ const level = alpha * Math.pow(F * K, beta / 2);
75
+ if (Math.abs(F - K) < 1e-12 * Math.max(1, F)) {
76
+ return level * timeFactor;
77
+ }
78
+ const logFK = Math.log(F / K);
79
+ const logFK2 = logFK * logFK;
80
+ const logFK4 = logFK2 * logFK2;
81
+ const numerator = 1 + logFK2 / 24 + logFK4 / 1920;
82
+ const denominator = 1 + oneMinusBeta * oneMinusBeta * logFK2 / 24 + oneMinusBeta * oneMinusBeta * oneMinusBeta * oneMinusBeta * logFK4 / 1920;
83
+ const z = nu / alpha * FKhalf * logFK;
84
+ return level * (numerator / denominator) * zOverX(z, rho) * timeFactor;
85
+ }
86
+ function oblojVol(F, K, T, p) {
87
+ validate(F, K, T, p);
88
+ const { alpha, beta, rho, nu } = p;
89
+ const oneMinusBeta = 1 - beta;
90
+ const logFK = Math.log(F / K);
91
+ const FKbeta = Math.pow(F * K, oneMinusBeta / 2);
92
+ const term1 = oneMinusBeta * oneMinusBeta * alpha * alpha / (24 * FKbeta * FKbeta);
93
+ const term2 = rho * beta * nu * alpha / (4 * FKbeta);
94
+ const term3 = (2 - 3 * rho * rho) * nu * nu / 24;
95
+ const timeFactor = 1 + (term1 + term2 + term3) * T;
96
+ let leading;
97
+ if (Math.abs(logFK) < 1e-12) {
98
+ leading = alpha / Math.pow(F, oneMinusBeta);
99
+ } else if (oneMinusBeta < 1e-8) {
100
+ const z = nu / alpha * logFK;
101
+ leading = nu * logFK / (z === 0 ? 1 : Math.log((Math.sqrt(1 - 2 * rho * z + z * z) + z - rho) / (1 - rho)));
102
+ } else {
103
+ const z = nu / alpha * (Math.pow(F, oneMinusBeta) - Math.pow(K, oneMinusBeta)) / oneMinusBeta;
104
+ const xz = Math.log((Math.sqrt(1 - 2 * rho * z + z * z) + z - rho) / (1 - rho));
105
+ leading = nu * logFK / (xz === 0 ? 1 : xz);
106
+ }
107
+ return leading * timeFactor;
108
+ }
109
+ function calibrate(F, T, quotes, opts = {}) {
110
+ if (!(F > 0)) throw new RangeError(`forward must be positive, got ${F}`);
111
+ if (!(T > 0)) throw new RangeError(`expiry must be positive, got ${T}`);
112
+ if (quotes.length < 3) throw new RangeError("calibration needs at least 3 quotes");
113
+ const beta = opts.beta ?? 0.5;
114
+ const atm = [...quotes].sort((a, b) => Math.abs(a.strike - F) - Math.abs(b.strike - F))[0];
115
+ const alpha0 = opts.initial?.alpha ?? atm.vol * Math.pow(F, 1 - beta);
116
+ const rho0 = opts.initial?.rho ?? 0;
117
+ const nu0 = opts.initial?.nu ?? 0.3;
118
+ const objective = (v) => {
119
+ const alpha2 = Math.exp(v[0]);
120
+ const rho2 = Math.tanh(v[1]);
121
+ const nu2 = Math.exp(v[2]);
122
+ let sse = 0;
123
+ for (const q of quotes) {
124
+ const model = lognormalVol(F, q.strike, T, { alpha: alpha2, beta, rho: rho2, nu: nu2 });
125
+ const w = q.weight ?? 1;
126
+ const e = model - q.vol;
127
+ sse += w * e * e;
128
+ }
129
+ return sse;
130
+ };
131
+ const start = [Math.log(alpha0), Math.atanh(clamp(rho0, -0.999, 0.999)), Math.log(Math.max(nu0, 1e-6))];
132
+ const { point, iterations } = nelderMead(objective, start, opts.maxIter ?? 2e3);
133
+ const alpha = Math.exp(point[0]);
134
+ const rho = Math.tanh(point[1]);
135
+ const nu = Math.exp(point[2]);
136
+ const totalW = quotes.reduce((s, q) => s + (q.weight ?? 1), 0);
137
+ const rmse = Math.sqrt(objective(point) / totalW);
138
+ return { alpha, beta, rho, nu, rmse, iterations };
139
+ }
140
+ function clamp(x, lo, hi) {
141
+ return Math.max(lo, Math.min(hi, x));
142
+ }
143
+ function nelderMead(f, x0, maxIter) {
144
+ const n = x0.length;
145
+ const alpha = 1;
146
+ const gamma = 2;
147
+ const rho = 0.5;
148
+ const sigma = 0.5;
149
+ const simplex = [x0.slice()];
150
+ for (let i = 0; i < n; i++) {
151
+ const p = x0.slice();
152
+ const xi = p[i];
153
+ p[i] = xi + (xi !== 0 ? 0.1 * xi : 0.1);
154
+ simplex.push(p);
155
+ }
156
+ let fvals = simplex.map(f);
157
+ let iter = 0;
158
+ for (; iter < maxIter; iter++) {
159
+ const order = fvals.map((v, i) => i).sort((a, b) => fvals[a] - fvals[b]);
160
+ const s = order.map((i) => simplex[i]);
161
+ const fs = order.map((i) => fvals[i]);
162
+ for (let i = 0; i < s.length; i++) {
163
+ simplex[i] = s[i];
164
+ fvals[i] = fs[i];
165
+ }
166
+ if (Math.abs(fvals[n] - fvals[0]) < 1e-14) break;
167
+ const centroid = new Array(n).fill(0);
168
+ for (let i = 0; i < n; i++) {
169
+ for (let j = 0; j < n; j++) centroid[j] += simplex[i][j];
170
+ }
171
+ for (let j = 0; j < n; j++) centroid[j] /= n;
172
+ const worst = simplex[n];
173
+ const reflected = centroid.map((c, j) => c + alpha * (c - worst[j]));
174
+ const fr = f(reflected);
175
+ if (fr < fvals[0]) {
176
+ const expanded = centroid.map((c, j) => c + gamma * (reflected[j] - c));
177
+ const fe = f(expanded);
178
+ if (fe < fr) {
179
+ simplex[n] = expanded;
180
+ fvals[n] = fe;
181
+ } else {
182
+ simplex[n] = reflected;
183
+ fvals[n] = fr;
184
+ }
185
+ } else if (fr < fvals[n - 1]) {
186
+ simplex[n] = reflected;
187
+ fvals[n] = fr;
188
+ } else {
189
+ const contracted = centroid.map((c, j) => c + rho * (worst[j] - c));
190
+ const fc = f(contracted);
191
+ if (fc < fvals[n]) {
192
+ simplex[n] = contracted;
193
+ fvals[n] = fc;
194
+ } else {
195
+ for (let i = 1; i <= n; i++) {
196
+ simplex[i] = simplex[0].map((b, j) => b + sigma * (simplex[i][j] - b));
197
+ fvals[i] = f(simplex[i]);
198
+ }
199
+ }
200
+ }
201
+ }
202
+ let best = 0;
203
+ for (let i = 1; i < fvals.length; i++) if (fvals[i] < fvals[best]) best = i;
204
+ return { point: simplex[best], value: fvals[best], iterations: iter };
205
+ }
206
+ // Annotate the CommonJS export names for ESM import in node:
207
+ 0 && (module.exports = {
208
+ calibrate,
209
+ lognormalVol,
210
+ normalVol,
211
+ oblojVol
212
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * hagan-sabr — the SABR stochastic-volatility model.
3
+ *
4
+ * The Hagan et al. (2002) implied-volatility expansions — lognormal (Black)
5
+ * and normal (Bachelier) — with the Obłój (2008) refinement, plus
6
+ * least-squares calibration of (α, ρ, ν) to a market smile.
7
+ *
8
+ * Parameters:
9
+ * F forward
10
+ * K strike
11
+ * T time to expiry (years)
12
+ * alpha instantaneous vol level (α > 0)
13
+ * beta CEV exponent (0 ≤ β ≤ 1)
14
+ * rho correlation (−1 < ρ < 1)
15
+ * nu vol-of-vol (ν ≥ 0)
16
+ *
17
+ * Volatilities are returned as decimals (0.20 = 20%).
18
+ */
19
+ interface SabrParams {
20
+ alpha: number;
21
+ beta: number;
22
+ rho: number;
23
+ nu: number;
24
+ }
25
+ /**
26
+ * Hagan (2002) lognormal (Black) implied volatility. This matches
27
+ * QuantLib's `sabrVolatility`.
28
+ *
29
+ * ```ts
30
+ * lognormalVol(100, 100, 1, { alpha: 0.2, beta: 0.5, rho: -0.3, nu: 0.4 });
31
+ * // ATM ≈ 0.0202 (= alpha / F^(1-beta) × …)
32
+ * ```
33
+ */
34
+ declare function lognormalVol(F: number, K: number, T: number, p: SabrParams): number;
35
+ /**
36
+ * Hagan (2002) normal (Bachelier) implied volatility (eq. 2.18), in the same
37
+ * units as the forward. Parallels the lognormal formula: the same z and
38
+ * z/x(z), a `(FK)^{β/2}` level, and a normal-specific time correction.
39
+ */
40
+ declare function normalVol(F: number, K: number, T: number, p: SabrParams): number;
41
+ /**
42
+ * Lognormal vol with the Obłój (2008) correction, which fixes an
43
+ * inconsistency in the original expansion for small strikes / long maturities.
44
+ * Differs from {@link lognormalVol} only in the leading (zeroth-order) term.
45
+ */
46
+ declare function oblojVol(F: number, K: number, T: number, p: SabrParams): number;
47
+ interface MarketQuote {
48
+ strike: number;
49
+ /** Observed lognormal (Black) implied vol as a decimal. */
50
+ vol: number;
51
+ /** Optional weight for the least-squares fit (default 1). */
52
+ weight?: number;
53
+ }
54
+ interface CalibrationOptions {
55
+ /** Fixed β (SABR β is usually fixed, not fitted). Default 0.5. */
56
+ beta?: number;
57
+ /** Initial guesses. */
58
+ initial?: Partial<Pick<SabrParams, "alpha" | "rho" | "nu">>;
59
+ /** Max Nelder–Mead iterations. Default 2000. */
60
+ maxIter?: number;
61
+ }
62
+ interface CalibrationResult extends SabrParams {
63
+ /** Root-mean-squared vol error at the optimum. */
64
+ rmse: number;
65
+ iterations: number;
66
+ }
67
+ /**
68
+ * Calibrate (α, ρ, ν) to a market smile by least squares (β fixed), using a
69
+ * Nelder–Mead simplex. Returns the fitted SABR parameters and fit RMSE.
70
+ */
71
+ declare function calibrate(F: number, T: number, quotes: readonly MarketQuote[], opts?: CalibrationOptions): CalibrationResult;
72
+
73
+ export { type CalibrationOptions, type CalibrationResult, type MarketQuote, type SabrParams, calibrate, lognormalVol, normalVol, oblojVol };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * hagan-sabr — the SABR stochastic-volatility model.
3
+ *
4
+ * The Hagan et al. (2002) implied-volatility expansions — lognormal (Black)
5
+ * and normal (Bachelier) — with the Obłój (2008) refinement, plus
6
+ * least-squares calibration of (α, ρ, ν) to a market smile.
7
+ *
8
+ * Parameters:
9
+ * F forward
10
+ * K strike
11
+ * T time to expiry (years)
12
+ * alpha instantaneous vol level (α > 0)
13
+ * beta CEV exponent (0 ≤ β ≤ 1)
14
+ * rho correlation (−1 < ρ < 1)
15
+ * nu vol-of-vol (ν ≥ 0)
16
+ *
17
+ * Volatilities are returned as decimals (0.20 = 20%).
18
+ */
19
+ interface SabrParams {
20
+ alpha: number;
21
+ beta: number;
22
+ rho: number;
23
+ nu: number;
24
+ }
25
+ /**
26
+ * Hagan (2002) lognormal (Black) implied volatility. This matches
27
+ * QuantLib's `sabrVolatility`.
28
+ *
29
+ * ```ts
30
+ * lognormalVol(100, 100, 1, { alpha: 0.2, beta: 0.5, rho: -0.3, nu: 0.4 });
31
+ * // ATM ≈ 0.0202 (= alpha / F^(1-beta) × …)
32
+ * ```
33
+ */
34
+ declare function lognormalVol(F: number, K: number, T: number, p: SabrParams): number;
35
+ /**
36
+ * Hagan (2002) normal (Bachelier) implied volatility (eq. 2.18), in the same
37
+ * units as the forward. Parallels the lognormal formula: the same z and
38
+ * z/x(z), a `(FK)^{β/2}` level, and a normal-specific time correction.
39
+ */
40
+ declare function normalVol(F: number, K: number, T: number, p: SabrParams): number;
41
+ /**
42
+ * Lognormal vol with the Obłój (2008) correction, which fixes an
43
+ * inconsistency in the original expansion for small strikes / long maturities.
44
+ * Differs from {@link lognormalVol} only in the leading (zeroth-order) term.
45
+ */
46
+ declare function oblojVol(F: number, K: number, T: number, p: SabrParams): number;
47
+ interface MarketQuote {
48
+ strike: number;
49
+ /** Observed lognormal (Black) implied vol as a decimal. */
50
+ vol: number;
51
+ /** Optional weight for the least-squares fit (default 1). */
52
+ weight?: number;
53
+ }
54
+ interface CalibrationOptions {
55
+ /** Fixed β (SABR β is usually fixed, not fitted). Default 0.5. */
56
+ beta?: number;
57
+ /** Initial guesses. */
58
+ initial?: Partial<Pick<SabrParams, "alpha" | "rho" | "nu">>;
59
+ /** Max Nelder–Mead iterations. Default 2000. */
60
+ maxIter?: number;
61
+ }
62
+ interface CalibrationResult extends SabrParams {
63
+ /** Root-mean-squared vol error at the optimum. */
64
+ rmse: number;
65
+ iterations: number;
66
+ }
67
+ /**
68
+ * Calibrate (α, ρ, ν) to a market smile by least squares (β fixed), using a
69
+ * Nelder–Mead simplex. Returns the fitted SABR parameters and fit RMSE.
70
+ */
71
+ declare function calibrate(F: number, T: number, quotes: readonly MarketQuote[], opts?: CalibrationOptions): CalibrationResult;
72
+
73
+ export { type CalibrationOptions, type CalibrationResult, type MarketQuote, type SabrParams, calibrate, lognormalVol, normalVol, oblojVol };
package/dist/index.js ADDED
@@ -0,0 +1,184 @@
1
+ // src/index.ts
2
+ function validate(F, K, T, p) {
3
+ if (!(F > 0)) throw new RangeError(`forward must be positive, got ${F}`);
4
+ if (!(K > 0)) throw new RangeError(`strike must be positive, got ${K}`);
5
+ if (!(T > 0)) throw new RangeError(`expiry must be positive, got ${T}`);
6
+ if (!(p.alpha > 0)) throw new RangeError(`alpha must be positive, got ${p.alpha}`);
7
+ if (p.beta < 0 || p.beta > 1) throw new RangeError(`beta must be in [0,1], got ${p.beta}`);
8
+ if (p.rho <= -1 || p.rho >= 1) throw new RangeError(`rho must be in (-1,1), got ${p.rho}`);
9
+ if (p.nu < 0) throw new RangeError(`nu must be non-negative, got ${p.nu}`);
10
+ }
11
+ function zOverX(z, rho) {
12
+ if (Math.abs(z) < 1e-12) {
13
+ return 1 - rho * z / 2 + (2 - 3 * rho * rho) * z * z / 12;
14
+ }
15
+ const x = Math.log((Math.sqrt(1 - 2 * rho * z + z * z) + z - rho) / (1 - rho));
16
+ return z / x;
17
+ }
18
+ function lognormalVol(F, K, T, p) {
19
+ validate(F, K, T, p);
20
+ const { alpha, beta, rho, nu } = p;
21
+ const oneMinusBeta = 1 - beta;
22
+ const logFK = Math.log(F / K);
23
+ const FKbeta = Math.pow(F * K, oneMinusBeta / 2);
24
+ const term1 = oneMinusBeta * oneMinusBeta * alpha * alpha / (24 * FKbeta * FKbeta);
25
+ const term2 = rho * beta * nu * alpha / (4 * FKbeta);
26
+ const term3 = (2 - 3 * rho * rho) * nu * nu / 24;
27
+ const timeFactor = 1 + (term1 + term2 + term3) * T;
28
+ if (Math.abs(logFK) < 1e-12) {
29
+ return alpha / Math.pow(F, oneMinusBeta) * timeFactor;
30
+ }
31
+ const logFK2 = logFK * logFK;
32
+ const logFK4 = logFK2 * logFK2;
33
+ const denom = FKbeta * (1 + oneMinusBeta * oneMinusBeta * logFK2 / 24 + oneMinusBeta * oneMinusBeta * oneMinusBeta * oneMinusBeta * logFK4 / 1920);
34
+ const z = nu / alpha * FKbeta * logFK;
35
+ return alpha / denom * zOverX(z, rho) * timeFactor;
36
+ }
37
+ function normalVol(F, K, T, p) {
38
+ validate(F, K, T, p);
39
+ const { alpha, beta, rho, nu } = p;
40
+ const oneMinusBeta = 1 - beta;
41
+ const FKpow1mb = Math.pow(F * K, oneMinusBeta);
42
+ const FKhalf = Math.pow(F * K, oneMinusBeta / 2);
43
+ const c1 = -beta * (2 - beta) * alpha * alpha / (24 * FKpow1mb);
44
+ const c2 = rho * beta * nu * alpha / (4 * FKhalf);
45
+ const c3 = (2 - 3 * rho * rho) * nu * nu / 24;
46
+ const timeFactor = 1 + (c1 + c2 + c3) * T;
47
+ const level = alpha * Math.pow(F * K, beta / 2);
48
+ if (Math.abs(F - K) < 1e-12 * Math.max(1, F)) {
49
+ return level * timeFactor;
50
+ }
51
+ const logFK = Math.log(F / K);
52
+ const logFK2 = logFK * logFK;
53
+ const logFK4 = logFK2 * logFK2;
54
+ const numerator = 1 + logFK2 / 24 + logFK4 / 1920;
55
+ const denominator = 1 + oneMinusBeta * oneMinusBeta * logFK2 / 24 + oneMinusBeta * oneMinusBeta * oneMinusBeta * oneMinusBeta * logFK4 / 1920;
56
+ const z = nu / alpha * FKhalf * logFK;
57
+ return level * (numerator / denominator) * zOverX(z, rho) * timeFactor;
58
+ }
59
+ function oblojVol(F, K, T, p) {
60
+ validate(F, K, T, p);
61
+ const { alpha, beta, rho, nu } = p;
62
+ const oneMinusBeta = 1 - beta;
63
+ const logFK = Math.log(F / K);
64
+ const FKbeta = Math.pow(F * K, oneMinusBeta / 2);
65
+ const term1 = oneMinusBeta * oneMinusBeta * alpha * alpha / (24 * FKbeta * FKbeta);
66
+ const term2 = rho * beta * nu * alpha / (4 * FKbeta);
67
+ const term3 = (2 - 3 * rho * rho) * nu * nu / 24;
68
+ const timeFactor = 1 + (term1 + term2 + term3) * T;
69
+ let leading;
70
+ if (Math.abs(logFK) < 1e-12) {
71
+ leading = alpha / Math.pow(F, oneMinusBeta);
72
+ } else if (oneMinusBeta < 1e-8) {
73
+ const z = nu / alpha * logFK;
74
+ leading = nu * logFK / (z === 0 ? 1 : Math.log((Math.sqrt(1 - 2 * rho * z + z * z) + z - rho) / (1 - rho)));
75
+ } else {
76
+ const z = nu / alpha * (Math.pow(F, oneMinusBeta) - Math.pow(K, oneMinusBeta)) / oneMinusBeta;
77
+ const xz = Math.log((Math.sqrt(1 - 2 * rho * z + z * z) + z - rho) / (1 - rho));
78
+ leading = nu * logFK / (xz === 0 ? 1 : xz);
79
+ }
80
+ return leading * timeFactor;
81
+ }
82
+ function calibrate(F, T, quotes, opts = {}) {
83
+ if (!(F > 0)) throw new RangeError(`forward must be positive, got ${F}`);
84
+ if (!(T > 0)) throw new RangeError(`expiry must be positive, got ${T}`);
85
+ if (quotes.length < 3) throw new RangeError("calibration needs at least 3 quotes");
86
+ const beta = opts.beta ?? 0.5;
87
+ const atm = [...quotes].sort((a, b) => Math.abs(a.strike - F) - Math.abs(b.strike - F))[0];
88
+ const alpha0 = opts.initial?.alpha ?? atm.vol * Math.pow(F, 1 - beta);
89
+ const rho0 = opts.initial?.rho ?? 0;
90
+ const nu0 = opts.initial?.nu ?? 0.3;
91
+ const objective = (v) => {
92
+ const alpha2 = Math.exp(v[0]);
93
+ const rho2 = Math.tanh(v[1]);
94
+ const nu2 = Math.exp(v[2]);
95
+ let sse = 0;
96
+ for (const q of quotes) {
97
+ const model = lognormalVol(F, q.strike, T, { alpha: alpha2, beta, rho: rho2, nu: nu2 });
98
+ const w = q.weight ?? 1;
99
+ const e = model - q.vol;
100
+ sse += w * e * e;
101
+ }
102
+ return sse;
103
+ };
104
+ const start = [Math.log(alpha0), Math.atanh(clamp(rho0, -0.999, 0.999)), Math.log(Math.max(nu0, 1e-6))];
105
+ const { point, iterations } = nelderMead(objective, start, opts.maxIter ?? 2e3);
106
+ const alpha = Math.exp(point[0]);
107
+ const rho = Math.tanh(point[1]);
108
+ const nu = Math.exp(point[2]);
109
+ const totalW = quotes.reduce((s, q) => s + (q.weight ?? 1), 0);
110
+ const rmse = Math.sqrt(objective(point) / totalW);
111
+ return { alpha, beta, rho, nu, rmse, iterations };
112
+ }
113
+ function clamp(x, lo, hi) {
114
+ return Math.max(lo, Math.min(hi, x));
115
+ }
116
+ function nelderMead(f, x0, maxIter) {
117
+ const n = x0.length;
118
+ const alpha = 1;
119
+ const gamma = 2;
120
+ const rho = 0.5;
121
+ const sigma = 0.5;
122
+ const simplex = [x0.slice()];
123
+ for (let i = 0; i < n; i++) {
124
+ const p = x0.slice();
125
+ const xi = p[i];
126
+ p[i] = xi + (xi !== 0 ? 0.1 * xi : 0.1);
127
+ simplex.push(p);
128
+ }
129
+ let fvals = simplex.map(f);
130
+ let iter = 0;
131
+ for (; iter < maxIter; iter++) {
132
+ const order = fvals.map((v, i) => i).sort((a, b) => fvals[a] - fvals[b]);
133
+ const s = order.map((i) => simplex[i]);
134
+ const fs = order.map((i) => fvals[i]);
135
+ for (let i = 0; i < s.length; i++) {
136
+ simplex[i] = s[i];
137
+ fvals[i] = fs[i];
138
+ }
139
+ if (Math.abs(fvals[n] - fvals[0]) < 1e-14) break;
140
+ const centroid = new Array(n).fill(0);
141
+ for (let i = 0; i < n; i++) {
142
+ for (let j = 0; j < n; j++) centroid[j] += simplex[i][j];
143
+ }
144
+ for (let j = 0; j < n; j++) centroid[j] /= n;
145
+ const worst = simplex[n];
146
+ const reflected = centroid.map((c, j) => c + alpha * (c - worst[j]));
147
+ const fr = f(reflected);
148
+ if (fr < fvals[0]) {
149
+ const expanded = centroid.map((c, j) => c + gamma * (reflected[j] - c));
150
+ const fe = f(expanded);
151
+ if (fe < fr) {
152
+ simplex[n] = expanded;
153
+ fvals[n] = fe;
154
+ } else {
155
+ simplex[n] = reflected;
156
+ fvals[n] = fr;
157
+ }
158
+ } else if (fr < fvals[n - 1]) {
159
+ simplex[n] = reflected;
160
+ fvals[n] = fr;
161
+ } else {
162
+ const contracted = centroid.map((c, j) => c + rho * (worst[j] - c));
163
+ const fc = f(contracted);
164
+ if (fc < fvals[n]) {
165
+ simplex[n] = contracted;
166
+ fvals[n] = fc;
167
+ } else {
168
+ for (let i = 1; i <= n; i++) {
169
+ simplex[i] = simplex[0].map((b, j) => b + sigma * (simplex[i][j] - b));
170
+ fvals[i] = f(simplex[i]);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ let best = 0;
176
+ for (let i = 1; i < fvals.length; i++) if (fvals[i] < fvals[best]) best = i;
177
+ return { point: simplex[best], value: fvals[best], iterations: iter };
178
+ }
179
+ export {
180
+ calibrate,
181
+ lognormalVol,
182
+ normalVol,
183
+ oblojVol
184
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "hagan-sabr",
3
+ "version": "1.0.0",
4
+ "description": "The SABR stochastic-volatility model — Hagan (2002) lognormal & normal implied-vol expansions, the Obłój correction, and (alpha, rho, nu) smile calibration. Matches QuantLib. Zero dependencies.",
5
+ "keywords": [
6
+ "sabr",
7
+ "hagan",
8
+ "implied-volatility",
9
+ "volatility-smile",
10
+ "stochastic-volatility",
11
+ "swaption",
12
+ "options",
13
+ "quant",
14
+ "quantitative-finance",
15
+ "derivatives",
16
+ "finance"
17
+ ],
18
+ "homepage": "https://moshemalka.com",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/moshejs/hagan-sabr.git"
22
+ },
23
+ "bugs": "https://github.com/moshejs/hagan-sabr/issues",
24
+ "author": "Moshe Malka <hello@moshemalka.com> (https://moshemalka.com)",
25
+ "license": "MIT",
26
+ "type": "module",
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "require": "./dist/index.cjs"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "sideEffects": false,
41
+ "scripts": {
42
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit",
45
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
46
+ },
47
+ "devDependencies": {
48
+ "tsup": "^8.5.0",
49
+ "typescript": "^5.8.0",
50
+ "vitest": "^3.2.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ }
55
+ }