Chapter 02
Three ways to bootstrap
The pricing identity \(C\mathbf{d} = \mathbf{P}\) has one solution. Getting to it is a choice, and the three routes taken here have almost nothing in common.
Method one — the linear solve
The cash-flow matrix is square and invertible, so the honest first move is simply to solve the system. Formally that is \(\mathbf{d} = C^{-1}\mathbf{P}\), though in practice one never forms the inverse:
Computed with a factorisation rather than an explicit inverse — faster, and numerically better behaved.
df = np.linalg.solve(self.cash_flows, self.bonds[:, 1])
This is exact up to floating-point rounding. There is no tolerance to set, no starting guess, no iteration count. For a square, well-conditioned system it is the right answer and it is the baseline the other two are measured against.
np.linalg.inv? Inverting and then multiplying does more arithmetic and accumulates more error than solving directly. The result is the same in exact arithmetic and slightly worse in floating point — a small habit that matters on larger systems.
Method two — the global solver
The second route abandons linear algebra and treats the problem as optimisation. Rather than asking “what \(\mathbf{d}\) satisfies the equations”, it asks “what \(\mathbf{d}\) makes the model prices closest to the market prices”:
Minimise the sum of squared pricing errors. At the optimum the residual is zero, because an exact solution exists.
result = minimize(lambda d: np.sum((self.cash_flows @ d - prices) ** 2),
x0=np.ones(n))
For this problem the optimiser is doing more work than necessary — it is searching a ten-dimensional space for a point that could have been computed directly. It earns its place for a different reason: it is the only one of the three that still works when the problem stops being square.
Real curve construction is rarely square. Markets quote more instruments than there are nodes on the curve, maturities overlap and gap, and no exact solution exists. The system becomes overdetermined and the question changes from “solve” to “fit”. The linear solve has nothing to say there; least squares carries over unchanged.
Method three — forward substitution
The third route uses the shape of \(C\). Because the matrix is lower-triangular, the first equation contains a single unknown. The one-year bond pays exactly once, at maturity:
With \(d_1\) known, the two-year bond’s equation now has one unknown left, and so on down the ladder. In general, once \(d_1,\dots,d_{k-1}\) are known:
Strip out the coupons already accounted for, then divide what remains by the final payment.
This is bootstrapping in the original sense — each maturity pulling the next one up behind it. It is how the curve was built before anyone had a matrix library, and it is still how it is explained on a whiteboard, because each step has a plain reading: what is left of this bond’s price once its earlier coupons are paid for at prices already established.
Do they agree?
Yes — to four decimal places, which is every digit the notebook prints:
| Method | 1Y | 2Y | 3Y | 4Y | 5Y | 6Y | 7Y | 8Y | 9Y | 10Y |
|---|---|---|---|---|---|---|---|---|---|---|
| Matrix | 0.9517 | 0.9046 | 0.8612 | 0.8227 | 0.7892 | 0.7604 | 0.7361 | 0.7156 | 0.6985 | 0.6842 |
| Global solver | 0.9517 | 0.9046 | 0.8612 | 0.8227 | 0.7892 | 0.7604 | 0.7361 | 0.7156 | 0.6985 | 0.6842 |
| Iterative | 0.9517 | 0.9046 | 0.8612 | 0.8227 | 0.7892 | 0.7604 | 0.7361 | 0.7156 | 0.6985 | 0.6842 |
Agreement to four decimals is a weaker statement than it looks, and worth being precise about. The matrix solve and the recursion are both exact procedures; they differ only in the order they do the arithmetic, so they agree to the last bit a float64 can represent. The optimiser is different in kind — it stops when its convergence tolerance is met, not when the residual is zero. It lands very close, but its agreement is a property of the tolerance it was given, not of the mathematics.
The stronger test
Methods agreeing with each other only shows they made the same assumptions. The test that actually bites is repricing: push the recovered factors back through the cash-flow matrix and check that all ten market prices come back. They do — exactly.
y.cash_flows @ y.DiscountFactors('Matrix operations')
# [96.6000 93.7100 91.5600 90.2400 89.7400 90.0400 91.0900 92.8200 95.1900 98.1400]
Those are the input prices, returned unchanged. The curve reprices the instruments it was built from, which is the minimum any curve must do before it is used for anything else.