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 +21 -0
- package/README.md +53 -0
- package/dist/index.cjs +212 -0
- package/dist/index.d.cts +73 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +184 -0
- package/package.json +55 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|