SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 3.6.1 Eigenvalues and eigenvectorsConcept · 35 min
      2. 3.6.2 Diagonalisation and matrix powersConcept · 30 min
      3. 3.6.3 Symmetric matrices and the spectral theoremConcept · 30 min
      4. 3.6.4 Practice set: eigenvaluesPractice set · 20 min
      5. 3.6.5 Case study: principal components of the yield curveCase study · 35 min

Level 3, Linear algebra 3.6 Eigenvalues and eigenvectors Lesson 5 of 5

Case studyLesson 3.6.5 in Level 3, Linear algebra. About 35 minutes.

Case study: principal components of the yield curve

Five yields, one covariance matrix

The data are daily changes in the yields of government bonds maturing in 1, 2, 5, 10 and 30 years. They cover 1,000 trading days, about four years.

Changes are in basis points (bp), hundredths of a percentage point (case study 1.6.4). A yield going from 4.00% to 4.05% has risen 5 bp.

The numbers are invented for illustration, generated with the seed np.random.default_rng(36). Each day is built from three hidden moves plus noise:

  • a level move of typical size (standard deviation) 5 bp, shifting every yield the same way: the 2-year a little more, the 30-year less;
  • a slope move of typical size 2 bp, tilting the curve: the short end one way, the long end the other;
  • a curvature move of typical size 1 bp, bending the middle against the two ends;
  • and independent noise of 0.5 bp at each maturity.

The analysis below does not use these recipes. It recovers the three shapes from the data alone, which is what a desk must do with real yields. Case study 3.8.4 shows that the singular value decomposition finds the same components without forming the covariance matrix.

Put the changes in a 1000×51000 \times 5 matrix: row tt holds day tt's five changes. On the first day every yield fell, by between 4.53 and 6.41 bp. On the second, the 1-year fell 3.21 bp while the 30-year rose 1.25 bp.

Their sample covariance matrix SS is np.cov, as in case study 3.2.5. Its entries are in bp² per day:

SS1y2y5y10y30y
1y28.1127.2323.5519.6915.95
2y27.2328.1325.5721.9118.07
5y23.5525.5725.3122.2919.12
10y19.6921.9122.2921.1419.32
30y15.9518.0719.1219.3220.13

The daily volatilities, the square roots of the diagonal, are 5.30, 5.30, 5.03, 4.60 and 4.49 bp. The correlations of neighbouring maturities are high, 0.968 between the 1-year and the 2-year, and fall with distance: 0.67 between the 1-year and the 30-year. The curve moves mostly together, but not rigidly.

Try it

moves holds the three hidden moves for each day, and moves @ shapes turns them into five yield changes. The noise is added last.

rowvar=False tells np.cov that the maturities are the columns (case study 3.2.5). The last array is the correlation matrix: SS divided entry by entry by σiσj\sigma_i\sigma_j, where σi\sigma_i is the volatility at maturity ii.

⌘/Ctrl + Enter runs the cell

Principal components

SS is symmetric, so lesson 3.6.3 applies: eigh gives five real eigenvalues and five orthonormal eigenvectors. Each eigenvector qk\mathbf{q}_k is a shape of curve move, with one number, its loading, for each maturity. These shapes are the principal components of the yield changes.

On any day, the size of the move along qk\mathbf{q}_k is the coordinate zk=qk⋅Δyz_k = \mathbf{q}_k \cdot \Delta\mathbf{y}, where Δy\Delta\mathbf{y} is that day's vector of changes (lesson 3.6.3). It is called the day's score on component kk.

The score is a portfolio of yield changes with weights qk\mathbf{q}_k, so its variance is qk⊤S qk\mathbf{q}_k^\top S\,\mathbf{q}_k (case study 3.2.5). Since Sqk=λkqkS\mathbf{q}_k = \lambda_k\mathbf{q}_k and qk⋅qk=1\mathbf{q}_k \cdot \mathbf{q}_k = 1, that is exactly λk\lambda_k.

Rule: Principal components

S=QΛQ⊤Var⁡(qk⋅Δy)=λk\begin{gathered} S = Q\Lambda Q^\top \\[4pt] \operatorname{Var}(\mathbf{q}_k \cdot \Delta\mathbf{y}) = \lambda_k \end{gathered}

Moves along different eigenvectors of the covariance matrix are uncorrelated, and each eigenvalue is the variance along its eigenvector. The eigenvalues add up to the trace, the sum of the five variances, so λk/tr⁡S\lambda_k / \operatorname{tr} S is the share of the total that component kk explains.

componentλk\lambda_k (bp²)λk\sqrt{\lambda_k} (bp a day)sharerunning total
1110.63810.5290.08%90.08%
29.8893.148.05%98.13%
31.7871.341.46%99.58%
40.2720.520.22%99.81%
50.2380.490.19%100%

The total is tr⁡S=122.824\operatorname{tr} S = 122.824 bp², the sum of the five daily variances.

Reading the loadings

An eigenvector's sign is arbitrary (lesson 3.6.1), so fix one before reading it. Here each is flipped to make its 30-year loading positive.

loading1y2y5y10y30y
q1\mathbf{q}_1, level0.46930.49370.47050.42130.3705
q2\mathbf{q}_2, slope−0.5731-0.5731−0.3161-0.31610.06180.35550.6644
q3\mathbf{q}_3, curvature0.5029−0.1176-0.1176−0.6181-0.6181−0.2172-0.21720.5515
  • Level. Every loading is positive: the whole curve moves up or down together. The 2-year moves most and the 30-year least, so a level move is not quite parallel.
  • Slope. Negative at the short end, positive at the long end, crossing zero near 5 years. A positive score steepens the curve; a negative one flattens it.
  • Curvature. Positive at the ends and negative in the middle: the 5-year moves against the 1-year and the 30-year.

The three shapes recovered from the covariance matrix: level, slope and curvature. Almost all of the variance is in the first, and almost none in the last two.

Try it

eigh returns the eigenvalues in increasing order, so the cell reverses them and the columns. np.sign(Q[-1]) flips each column whose 30-year entry is negative.

The last line checks that QQ is orthogonal and that q1⊤Sq1=λ1\mathbf{q}_1^\top S\mathbf{q}_1 = \lambda_1.

⌘/Ctrl + Enter runs the cell

How many components?

One component explains 90.08% of the variance, two explain 98.13% and three 99.58%. After the third, the eigenvalues level off: 0.272 and 0.238 bp², close to 0.52=0.250.5^2 = 0.25, the variance of the noise put in at each maturity. The last two components are that noise.

So three numbers a day describe the five yield changes. A common rule keeps components until the running total passes 99%, or until the eigenvalues stop falling and flatten out; here both say three.

Each day in three numbers

The scores zkz_k are the coordinates of each day's change along the components. They behave as the theory says: their sample variances are the eigenvalues, and they are uncorrelated, because the eigenvectors are perpendicular (lesson 3.6.3).

np.cov subtracts each column's mean before it multiplies, as case study 3.1.4 centred return vectors. So the scores are computed from the centred changes, and rebuilding a day adds the mean back: Δy≈yˉ+z1q1+z2q2+z3q3\Delta\mathbf{y} \approx \bar{\mathbf{y}} + z_1\mathbf{q}_1 + z_2\mathbf{q}_2 + z_3\mathbf{q}_3, with yˉ\bar{\mathbf{y}} the mean change.

Take the day with the largest move, day 808 counting from 0:

1y2y5y10y30y
actual change12.4115.516.6616.5916.78
level only16.1617.0216.2914.5812.77
three components12.6215.1316.7716.7616.69

Its scores are 34.87 on level and 6.03 on slope: a large sell-off that also steepened the curve. The level score is 3.32 times the level's daily volatility of 10.52 bp. Level alone misses by up to 4.01 bp, at the 30-year; three components come within 0.37 bp at every maturity.

Over all 1,000 days, the share of the squared changes left out is 9.92% after one component, 1.87% after two and 0.42% after three. Each is one minus the running total of shares.

Try it

Z holds every day's five scores. The first lines check their variances and that their covariance matrix is Λ\Lambda.

Then the largest day, rebuilt from one and from three components with the largest miss, and the share left out over all days.

⌘/Ctrl + Enter runs the cell
Check yourself
A risk report keeps only the first two components of these yield changes. What share of the daily variance does it leave out?

A position's exposure to each component

The 10-year position of the hook has a DV01 of $10,000: it loses about that much when the 10-year yield rises 1 bp (case study 2.2.4). Write its DV01 at each maturity as a vector, d=(0,0,0,10000,0)\mathbf{d} = (0, 0, 0, 10000, 0), in dollars per bp. A day's change in value is then about −d⋅Δy-\mathbf{d} \cdot \Delta\mathbf{y}.

Split the day's change along the components, Δy−yˉ=z1q1+⋯+z5q5\Delta\mathbf{y} - \bar{\mathbf{y}} = z_1\mathbf{q}_1 + \cdots + z_5\mathbf{q}_5 (lesson 3.6.3). Then d⋅(Δy−yˉ)\mathbf{d} \cdot (\Delta\mathbf{y} - \bar{\mathbf{y}}) splits too, with one term zk (d⋅qk)z_k\,(\mathbf{d} \cdot \mathbf{q}_k) per component.

Rule: Exposure to a component

ek=d⋅qkd⊤S d=∑kλkek2\begin{gathered} e_k = \mathbf{d} \cdot \mathbf{q}_k \\[4pt] \mathbf{d}^\top S\,\mathbf{d} = \textstyle\sum_k \lambda_k e_k^2 \end{gathered}

eke_k is the dollars lost for a score of 1 on component kk. The variance of the day's change in value splits into one part per component, because S=∑kλkqkqk⊤S = \sum_k \lambda_k\mathbf{q}_k\mathbf{q}_k^\top (lesson 3.6.3).

For the 10-year position, each eke_k is 10,000 times the 10-year loading: 4212.6 on level, 3554.6 on slope and −2171.9-2171.9 on curvature. Its daily volatility, d⊤Sd\sqrt{\mathbf{d}^\top S\mathbf{d}}, is $45,983. Level carries 92.86% of the variance, slope 5.91% and curvature 0.40%.

Hedging the level move

Sell 2-year bonds with a DV01 of hh dollars: the hedged position is d=(0,−h,0,10000,0)\mathbf{d} = (0, -h, 0, 10000, 0).

A DV01-matched hedge sells h=10,000h = 10{,}000, so that a parallel shift of 1 bp at every maturity costs nothing. But the level component moves the 2-year more than the 10-year, by 0.4937 against 0.4213. So this hedge sells too much: its level exposure is −724.2-724.2.

A level-neutral hedge sets e1=10000×0.4213−h×0.4937=0e_1 = 10000 \times 0.4213 - h \times 0.4937 = 0. So h=10000×0.42130.4937h = 10000 \times \frac{0.4213}{0.4937}: 8533.1 with the unrounded loadings, whose ratio is 0.8533.

hedge2-year DV01 soldlevel exposure e1e_1daily volatilitylevel's share
none04212.6$45,98392.86%
DV01-matched10,000−724.2-724.2$23,35410.64%
level-neutral8,533.10$20,5780%

What is left after the level hedge is almost all slope, 91.29% of the variance. The 2-year and the 10-year load on slope with opposite signs, so long one and short the other is a bet on the slope.

Case study 3.5.5's minimum-variance hedge, h=10000×Cov⁡(Δy10,Δy2)Var⁡(Δy2)=7788.8h = 10000 \times \frac{\operatorname{Cov}(\Delta y_{10}, \Delta y_2)}{\operatorname{Var}(\Delta y_2)} = 7788.8, leaves a little less, $20,196 a day, by trading some level exposure for less slope exposure.

To remove slope too, add a second hedge: one instrument for each component, found by solving a 2×22 \times 2 system (lesson 3.3.1). With the 2-year and the 30-year, the system says to sell $3,329.1 and $6,933.9 of DV01. The daily volatility falls to $9,519, and curvature is now the largest part, 61.96% of what is left.

Try it

report prints the first three exposures, the daily volatility in dollars, and each component's share of the variance. The second line checks d⊤Sd=∑kλkek2\mathbf{d}^\top S\mathbf{d} = \sum_k \lambda_k e_k^2.

Then the three hedges of the table, the minimum-variance hedge, and the two-bond hedge, whose system has the hedges' level and slope loadings as rows.

⌘/Ctrl + Enter runs the cell
Check yourself
A trader sells $10,000 of 2-year DV01 against the 10-year position, so a parallel 1 bp shift costs nothing. Why does the position still lose money on days when the whole curve rises?

On the desk

Try it

pca repeats the steps above for any block of data. The lines follow the notes: the level's annual volatility, four windows and a 60-day one, the 30-year in percent, the levels, and correlations.

Each window line prints the first three shares, then the cosine of each of its five components with the full-sample one. Absolute values ignore the sign.

⌘/Ctrl + Enter runs the cell
Exercise

Principal components and a component hedge

Write two functions, with NumPy.

  1. curve_pca(changes, k=3) returns (variances, loadings, shares) for a 2-D array of daily changes, one row per day and one column per maturity. variances holds the kk largest eigenvalues of the sample covariance matrix, largest first. loadings has one column per component, each a unit eigenvector with a positive last entry. shares divides each variance by the total variance, the trace.
  2. neutral_hedge(dv01, hedge_columns, loadings) returns the DV01 to sell at each maturity in hedge_columns. After the sale, the position has zero exposure to every component in loadings. Solve one equation per component, as in the two-bond hedge above.

Raise ValueError in curve_pca unless the data are 2-D with at least 2 rows, every value is finite, and 1≤k≤1 \le k \le the number of columns. In neutral_hedge, raise it when the counts of hedges and components differ, when dv01 has the wrong length, or when the system is singular.

The tests use this case study's data, the same data shifted by 3 bp a day, a small example and a missing value. They also try the level and the two-bond hedges.

See the tests
import numpy as np

SHAPES = np.array([[1.0, 1.05, 1.0, 0.9, 0.8], [-1.0, -0.6, 0.0, 0.5, 1.0], [0.6, -0.2, -0.8, -0.3, 0.7]])
_rng = np.random.default_rng(36)
_moves = _rng.standard_normal((1000, 3)) * np.array([5.0, 2.0, 1.0])
CHANGES = _moves @ SHAPES + 0.5 * _rng.standard_normal((1000, 5))   # the case study's data, bp per day
TEN_YEAR = np.array([0, 0, 0, 10_000.0, 0])                         # $10,000 per bp at 10 years


def _raises(fn, what):
    try:
        fn()
    except ValueError:
        return
    raise AssertionError(f"expected ValueError for {what}")


def test_variances():
    """The case study's data give variances 110.638, 9.889 and 1.787 bp², largest first"""
    variances, _, _ = curve_pca(CHANGES)
    expect_close(variances, [110.638, 9.889, 1.787], what="variances", rel=2e-4)


def test_all_five_descending():
    """With k = 5, all five variances come back, largest first"""
    variances, loadings, _ = curve_pca(CHANGES, k=5)
    expect_equal(len(variances), 5, what="number of variances")
    expect_equal(bool(np.all(np.diff(variances) <= 0)), True, what="variances in decreasing order")
    expect_equal(loadings.shape, (5, 5), what="shape of loadings")


def test_shares_of_the_total():
    """Shares are of the total variance, 122.824, not of the three kept: 90.08%, 8.05%, 1.46%"""
    _, _, shares = curve_pca(CHANGES)
    expect_close(shares, [0.90078, 0.08051, 0.01455], what="shares", rel=1e-3)


def test_level_loading():
    """The first loading is the level: (0.4693, 0.4937, 0.4705, 0.4213, 0.3705)"""
    _, loadings, _ = curve_pca(CHANGES)
    expect_close(loadings[:, 0], [0.4693, 0.4937, 0.4705, 0.4213, 0.3705], what="level loading", abs_tol=1e-4)


def test_signs():
    """Each loading has a positive 30-year entry, so the slope runs from −0.5731 to 0.6644"""
    _, loadings, _ = curve_pca(CHANGES)
    expect_equal(bool(np.all(loadings[-1] > 0)), True, what="30-year entries all positive")
    expect_close(loadings[[0, -1], 1], [-0.5731, 0.6644], what="slope loading at 1 and 30 years", abs_tol=1e-4)


def test_orthonormal():
    """The loadings are orthonormal: their columns give LᵀL = I"""
    _, loadings, _ = curve_pca(CHANGES)
    expect_close(loadings.T @ loadings, np.eye(3), what="LᵀL", abs_tol=1e-10)


def test_centred():
    """Adding 3 bp to every change leaves the components unchanged: the data are centred first"""
    variances, loadings, _ = curve_pca(CHANGES + 3.0)
    expect_close(variances, [110.638, 9.889, 1.787], what="variances of the shifted data", rel=2e-4)
    expect_close(loadings[:, 0], [0.4693, 0.4937, 0.4705, 0.4213, 0.3705], what="level loading of the shifted data", abs_tol=1e-4)


def test_small_example():
    """Changes (2, 2), (−2, −2), (1, −1), (−1, 1) give variances 16/3 and 4/3, shares 0.8 and 0.2"""
    X = np.array([[2, 2], [-2, -2], [1, -1], [-1, 1]])
    variances, loadings, shares = curve_pca(X, k=2)
    expect_close(variances, [16 / 3, 4 / 3], what="variances")
    expect_close(shares, [0.8, 0.2], what="shares")
    expect_close(loadings, np.array([[1, -1], [1, 1]]) / np.sqrt(2), what="loadings (last entry positive)")


def test_bad_inputs():
    """A missing value, k = 0, k = 6, a single day or a 1-D array: ValueError"""
    gap = CHANGES.copy()
    gap[10, 2] = np.nan
    _raises(lambda: curve_pca(gap), "a NaN in the data")
    _raises(lambda: curve_pca(CHANGES, k=0), "k = 0")
    _raises(lambda: curve_pca(CHANGES, k=6), "k = 6 with 5 maturities")
    _raises(lambda: curve_pca(CHANGES[:1]), "a single day")
    _raises(lambda: curve_pca(CHANGES[:, 0]), "a 1-D array")


def test_level_hedge():
    """Hedging the 10-year's level exposure with the 2-year sells $8,533.1 of DV01"""
    _, loadings, _ = curve_pca(CHANGES)
    h = neutral_hedge(TEN_YEAR, [1], loadings[:, :1])
    expect_close(h, [8533.1], what="2-year DV01 to sell", rel=1e-5)


def test_level_and_slope_hedge():
    """Hedging level and slope with the 2-year and 30-year sells $3,329.1 and $6,933.9 of DV01"""
    _, loadings, _ = curve_pca(CHANGES)
    h = neutral_hedge(TEN_YEAR, [1, 4], loadings[:, :2])
    expect_close(h, [3329.1, 6933.9], what="2-year and 30-year DV01 to sell", rel=1e-4)
    hedged = TEN_YEAR.copy()
    hedged[[1, 4]] -= h
    expect_close(hedged @ loadings[:, :2], [0.0, 0.0], what="exposures after the hedge", abs_tol=1e-6)


def test_hedge_errors():
    """Two hedges for one component, or the same maturity twice: ValueError"""
    _, loadings, _ = curve_pca(CHANGES)
    _raises(lambda: neutral_hedge(TEN_YEAR, [1, 4], loadings[:, :1]), "two hedges for one component")
    _raises(lambda: neutral_hedge(TEN_YEAR, [1, 1], loadings[:, :2]), "the 2-year used twice")
    _raises(lambda: neutral_hedge(TEN_YEAR[:4], [1], loadings[:, :1]), "a dv01 with 4 entries for 5 maturities")

Summary card

  • The principal components of a set of changes are the eigenvectors of their covariance matrix S=QΛQ⊤S = Q\Lambda Q^\top, found with eigh (lesson 3.6.3). Each eigenvalue is the variance along its eigenvector, and λk/tr⁡S\lambda_k / \operatorname{tr} S is its share of the total.
  • For these invented yield changes (seed 36), level, slope and curvature explain 90.08%, 8.05% and 1.46%: three components carry 99.58%. The last two eigenvalues, near the noise variance 0.25 bp², are noise.
  • A day's scores zk=qk⋅(Δy−yˉ)z_k = \mathbf{q}_k \cdot (\Delta\mathbf{y} - \bar{\mathbf{y}}) are uncorrelated with variances λk\lambda_k; three of them rebuild the day's curve move within 0.37 bp here.
  • A position with DV01 vector d\mathbf{d} has exposure ek=d⋅qke_k = \mathbf{d} \cdot \mathbf{q}_k to component kk, and daily variance d⊤Sd=∑kλkek2\mathbf{d}^\top S\mathbf{d} = \sum_k \lambda_k e_k^2.
  • A DV01-matched hedge assumes parallel moves; a level-neutral one uses the level loadings. Each further component needs one more hedge and one more equation.
  • Keep every maturity in one unit and work with changes, not levels. Fix the order and the signs, and do not trust components whose eigenvalues are nearly equal.

Free further reading

Optional. This lesson covers what you need; these add more examples and practice. All are free to read.