Fitting the Nelson-Siegel curve¶
The curve's three factors from observed yields.
- Requires
- observed yields at their tenors; the decay tau.
- Provides
- the
Level,SlopeandCurvaturewhose curve is nearest in least squares. - Law
- at a fixed tau the curve is linear in its three factors, so the fit is the least-squares solution over the loadings (1, (1 - e^-x)/x, (1 - e^-x)/x - e^-x), x = tenor / tau.
- Source
- Nelson & Siegel, Parsimonious Modeling of Yield Curves (1987); Diebold & Li (2006).
- Proof
- invariant, limit.
No Trait form: a tool, not a step law: a least-squares fit of the curve's factors to the day's yields, over the whole curve at once; it runs where a game prepares its data, never in a step, so it has no Trait form.
finance.nelson_siegel_fit(tenors, yields, tau)Uses, without proving it here: finance.nelson_siegel.
In [1]:
Copied!
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import dmath as D, library
from lusine.kernels.finance.nelson_siegel import nelson_siegel
from lusine.kernels.finance.nelson_siegel_fit import nelson_siegel_fit
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import dmath as D, library
from lusine.kernels.finance.nelson_siegel import nelson_siegel
from lusine.kernels.finance.nelson_siegel_fit import nelson_siegel_fit
The reference¶
The kernel's reference, in numpy: the body under the declaration above, and what every section below runs. It stays as the kernel's test beside its Trait form.
In [2]:
Copied!
library.reference(nelson_siegel_fit)
library.reference(nelson_siegel_fit)
Out[2]:
the reference, nelson_siegel_fit.py
def nelson_siegel_fit(tenors, yields, tau=1.5):
"""THE NELSON-SIEGEL FIT: the curve's three factors from observed yields.
Requires: observed yields at their tenors; the decay tau.
Provides: the `Level`, `Slope` and `Curvature` whose curve is nearest in
least squares.
Law: at a fixed tau the curve is linear in its three factors, so the fit is
the least-squares solution over the loadings (1, (1 - e^-x)/x,
(1 - e^-x)/x - e^-x), x = tenor / tau.
Source: Nelson & Siegel, Parsimonious Modeling of Yield Curves (1987);
Diebold & Li (2006).
Proof: invariant, limit."""
a, b, c = _ns_loads(tenors, tau)
X = np.stack([a, b, c], axis=1)
y = np.asarray(yields, dtype=np.float64)
A = [[float(sum(X[k, i] * X[k, j] for k in range(len(y)))) for j in range(3)] for i in range(3)]
r = [float(sum(X[k, i] * y[k] for k in range(len(y)))) for i in range(3)]
for i in range(3): # elimination with partial pivoting, row order fixed
piv = max(range(i, 3), key=lambda k: abs(A[k][i]))
A[i], A[piv], r[i], r[piv] = A[piv], A[i], r[piv], r[i]
for k in range(i + 1, 3):
f = A[k][i] / A[i][i]
A[k] = [A[k][j] - f * A[i][j] for j in range(3)]
r[k] -= f * r[i]
out = [0.0, 0.0, 0.0]
for i in (2, 1, 0):
out[i] = (r[i] - sum(A[i][j] * out[j] for j in range(i + 1, 3))) / A[i][i]
return tuple(out)
Invariant: a curve's own yields give back its factors¶
Yields generated from known factors are fitted back to them exactly, the fit being linear at a fixed decay.
In [3]:
Copied!
tenors = np.array([0.25, 1, 2, 5, 7, 10, 20, 30.])
rng = np.random.default_rng(4)
for _ in range(20):
f = (rng.uniform(0.01, 0.06), rng.uniform(-0.03, 0.03), rng.uniform(-0.04, 0.04))
got = nelson_siegel_fit(tenors, nelson_siegel(tenors, *f))
assert np.allclose(got, f, atol=1e-12)
print('factors recovered')
tenors = np.array([0.25, 1, 2, 5, 7, 10, 20, 30.])
rng = np.random.default_rng(4)
for _ in range(20):
f = (rng.uniform(0.01, 0.06), rng.uniform(-0.03, 0.03), rng.uniform(-0.04, 0.04))
got = nelson_siegel_fit(tenors, nelson_siegel(tenors, *f))
assert np.allclose(got, f, atol=1e-12)
print('factors recovered')
factors recovered
Limit: a flat curve is all level¶
Yields the same at every tenor fit to that level with no slope and no curvature, and noise is fitted in least squares.
In [4]:
Copied!
got = nelson_siegel_fit(tenors, np.full(len(tenors), 0.035))
assert np.allclose(got, (0.035, 0.0, 0.0), atol=1e-12)
noisy = nelson_siegel(tenors, 0.04, -0.01, 0.02) + rng.normal(0, 1e-4, len(tenors))
fit = nelson_siegel_fit(tenors, noisy)
assert np.max(np.abs(nelson_siegel(tenors, *fit) - noisy)) < 5e-4
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(tenors, noisy * 100, 'o', label='observed')
t = np.linspace(0.1, 30, 200)
ax.plot(t, nelson_siegel(t, *fit) * 100, label='fit')
ax.legend()
plt.show()
got = nelson_siegel_fit(tenors, np.full(len(tenors), 0.035))
assert np.allclose(got, (0.035, 0.0, 0.0), atol=1e-12)
noisy = nelson_siegel(tenors, 0.04, -0.01, 0.02) + rng.normal(0, 1e-4, len(tenors))
fit = nelson_siegel_fit(tenors, noisy)
assert np.max(np.abs(nelson_siegel(tenors, *fit) - noisy)) < 5e-4
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(tenors, noisy * 100, 'o', label='observed')
t = np.linspace(0.1, 30, 200)
ax.plot(t, nelson_siegel(t, *fit) * 100, label='fit')
ax.legend()
plt.show()