Prism Data Lab

Every analysis here ships its dataset and the code that produced every figure. Nothing on this site is investment advice.

Methods2,101 words, 10 minutes

Bond math from first principles: price, yield, duration, convexity

Price, yield to maturity, Macaulay and modified duration, and convexity derived and coded from scratch, then applied to the Treasury par curve of 2026-09-09.

Every number a bond desk quotes, from a price to a DV01, comes out of one sum: the present value of the cash flows. This article builds that sum in about forty lines of standard-library Python, differentiates it twice, and checks each derivative against a finite difference before trusting it. It then applies the result to a real curve, the eleven Treasury constant-maturity yields the Federal Reserve publishes and FRED redistributes, as they stood on 2026-09-09. The dataset back to 2000 and the script are attached; every figure below is printed by that script, and nothing here is advice about owning any bond.

Where the yields come from, and why they price at par

The H.15 release describes the series precisely: yields on Treasury nominal securities at "constant maturity" are "interpolated by the U.S. Treasury from the daily yield curve for non-inflation-indexed Treasury securities", a curve that "is based on the closing market bid yields on actively traded Treasury securities in the over-the-counter market." The values are "read from the yield curve at fixed maturities, currently 1, 3, and 6 months and 1, 2, 3, 5, 7, 10, 20, and 30 years", which, as the release notes, "provides a yield for a 10-year maturity, for example, even if no outstanding security has exactly 10 years remaining to maturity."

The Treasury's own methodology page adds the property that makes the arithmetic below clean: "The Treasury's official yield curve is a par yield curve derived using a monotone convex method", built from "indicative, bid-side market price quotations (not actual transactions) for the most recently auctioned securities obtained by the Federal Reserve Bank of New York at or near 3:30 PM each trading day." A par yield is the coupon rate at which a bond of that maturity would price at exactly face value. So a hypothetical 10-year bond carrying a 4.83 percent coupon, the DGS10 print for 2026-09-09, prices at 100.0000 when discounted at 4.83 percent. That is not a coincidence the script discovers; it is what the series means, and the script asserts it as a self-check.

Two conventions follow from how Treasuries pay. TreasuryDirect states that "Notes pay a fixed rate of interest every six months until they mature", so the coupon frequency is two and yields compound semiannually. The three bill maturities carry no coupon, so we treat them as single payments discounted at the quoted investment-basis yield with the same compounding; the price of a six-month zero at 4.01 percent is 98.0344, and its Macaulay duration is exactly its maturity, 0.500 years, which is the cleanest illustration of what duration measures.

Price, from the definition

A bond with face F, annual coupon rate c paid f times a year, and T years to maturity pays CF = Fc/f at times 1/f, 2/f, ... T, plus F at T. At a yield y compounded f times a year each payment is discounted by (1 + y/f) raised to the power of f times its date. The whole pricing function is a sum:

def cash_flows(coupon, years, face=100.0, freq=2):
    n = round(years * freq)
    flows = [(k / freq, face * coupon / freq) for k in range(1, n + 1)]
    t_n, cf_n = flows[-1]
    flows[-1] = (t_n, cf_n + face)
    return flows

def price(coupon, ytm, years, face=100.0, freq=2):
    return sum(cf * (1 + ytm / freq) ** (-freq * t)
               for t, cf in cash_flows(coupon, years, face, freq))

The derivatives come from the same sum. Differentiating each term once with respect to y gives dP/dy as minus the sum of CF times t times (1 + y/f) to the power of (-ft - 1); differentiating again gives the sum of CF times t times (t + 1/f) times (1 + y/f) to the power of (-ft - 2). Rather than take those on faith, the script's self_check compares each against a central finite difference with a step of one millionth on four different bonds, and stops if they disagree beyond tolerance. It also confirms that Macaulay duration divided by (1 + y/f) equals the modified duration computed from the derivative, which is the identity textbooks quote and rarely verify numerically.

Yield from price

The inverse problem, finding the yield that reproduces a quoted price, has no closed form for a coupon bond. But the price is strictly decreasing in the yield, so the root is unique and bisection cannot fail. For a 10-year bond with a 4.83 percent coupon:

price yield solved reprices to
110.00 3.6287% 110.000000
105.00 4.2122% 105.000000
100.00 4.8300% 100.000000
95.00 5.4863% 95.000000
90.00 6.1859% 90.000000

Two things are visible even in a five-row table. A five-point fall in price from par costs 65.6 basis points of yield, while a five-point rise buys back only 61.8; the price-yield relation is not a straight line. And the relation is convex, which is the whole subject of the next two sections.

Duration, three ways of saying one thing

Macaulay duration is the present-value-weighted average time to the cash flows, in years. Modified duration is Macaulay divided by (1 + y/f), and it is also exactly minus the slope of the price-yield curve divided by the price, which is why it is the number that predicts price change: a one percentage point rise in yield moves the price by approximately minus the modified duration, in percent. DV01 multiplies that by the price and by one basis point to get a dollar figure per 100 of face. Here is the whole curve as of 2026-09-09, from python code/bond-math-price-yield-duration-convexity.py:

series years yield price Macaulay modified convexity DV01 per 100
DGS1MO 1/12 3.81% 99.6860 0.083 0.082 0.05 0.0008
DGS3MO 0.25 3.95% 99.0269 0.250 0.245 0.18 0.0024
DGS6MO 0.5 4.01% 98.0344 0.500 0.490 0.48 0.0048
DGS1 1 4.17% 100.0000 0.990 0.970 1.42 0.0097
DGS2 2 4.43% 100.0000 1.936 1.894 4.58 0.0189
DGS3 3 4.49% 100.0000 2.840 2.778 9.33 0.0278
DGS5 5 4.61% 100.0000 4.522 4.420 22.95 0.0442
DGS7 7 4.71% 100.0000 6.044 5.905 41.09 0.0590
DGS10 10 4.83% 100.0000 8.047 7.858 74.49 0.0786
DGS20 20 5.28% 100.0000 12.584 12.261 204.13 0.1226
DGS30 30 5.28% 100.0000 15.369 14.973 335.32 0.1497

Duration grows more slowly than maturity: the 10-year par bond has a Macaulay duration of 8.05 years and the 30-year only 15.37, because a coupon bond returns a large share of its value long before the final payment. Convexity, by contrast, grows roughly with the square of duration, from 74 at ten years to 335 at thirty. That difference in growth rates is why the two longest bonds behave so differently from everything else in the next table.

Convexity, and how wrong the straight line gets

The first-order estimate of a price change is minus the modified duration times the yield shift. The second-order estimate adds half the convexity times the shift squared. Because convexity is positive for these bonds, the straight-line estimate always understates the price, for a rise or a fall in yields alike. The script reprices the 2-, 10-, and 30-year par bonds exactly for parallel shifts of 25 to 300 basis points and writes every row to datasets/bond-math-price-yield-duration-convexity-shocks.csv; here are the ends of the range:

bond shift exact price duration only error with convexity error
2-year -300 bp 105.894 105.682 -0.212 105.888 -0.006
2-year +300 bp 94.518 94.318 -0.200 94.524 +0.006
10-year -100 bp 108.243 107.858 -0.386 108.230 -0.013
10-year +100 bp 92.502 92.142 -0.360 92.515 +0.012
10-year -300 bp 127.301 123.573 -3.729 126.925 -0.377
10-year +300 bp 79.460 76.427 -3.033 79.780 +0.319
30-year -100 bp 116.806 114.973 -1.833 116.650 -0.156
30-year +100 bp 86.568 85.027 -1.541 86.703 +0.136
30-year -300 bp 164.928 144.920 -20.008 160.009 -4.918
30-year +300 bp 66.945 55.080 -11.865 70.170 +3.224
Left: the 30-year par bond's exact price against a parallel yield shift, with the duration-only tangent line and the duration-plus-convexity parabola. Right: approximation error in price points for the 2-, 10-, and 30-year bonds, duration-only as solid lines and with convexity as dashed lines.
The straight line is the duration estimate; the exact curve bends away from it in both directions, and the gap is what convexity measures. Computed from the 2026-09-09 curve by the attached script.

For the 2-year bond the straight line is fine: two tenths of a price point wrong at a 300 basis point move, which almost nothing else in a portfolio would resolve. For the 10-year the error is a third of a point at 100 basis points and between three and four points at 300. For the 30-year bond a 300 basis point fall in yields produces an exact price of 164.93; duration alone predicts 144.92, twenty points short. Adding the convexity term recovers most of it, to 160.01, but is still five points off, and on the other side, at plus 300, the second-order estimate overshoots by three points. The Taylor series is doing exactly what a Taylor series does: it is accurate near the point of expansion and degrades with the cube of the distance from it. Nothing in the second-order formula is wrong; it is just being asked to describe a curve 300 basis points away from where it was fitted, and for a 15-duration bond that is a long way.

The right-hand panel of the figure makes the asymmetry explicit. Duration-only error is always negative and roughly parabolic. The convexity-corrected error changes sign at zero and is small over roughly plus or minus 100 basis points for every maturity shown, which is the range in which the second-order approximation is genuinely useful for the long end of the curve and overkill for the short end.

What twenty-six years of one series does to duration

Duration is a function of the yield as well as the coupon and maturity, and a par bond's coupon changes with the yield, so the interest-rate sensitivity of "a 10-year Treasury" has not been constant. The script evaluates the modified duration of a 10-year par bond at every DGS10 observation since 2000-01-03, all 6,675 of them, and writes the result to datasets/bond-math-price-yield-duration-convexity-history.csv:

observation date DGS10 modified duration convexity DV01 per 100
first in the dataset 2000-01-03 6.58% 7.243 66.14 0.0724
highest yield 2000-01-20 6.79% 7.174 65.21 0.0717
lowest yield 2020-08-04 0.52% 9.732 101.08 0.0973
latest 2026-09-09 4.83% 7.858 74.49 0.0786
Two stacked panels sharing a time axis from 2000 to 2026: the 10-year constant-maturity yield in percent above, and the modified duration in years of a 10-year par bond evaluated at that yield below; the second is a mirror image of the first.
Lower yields mean a lower coupon and heavier weight on the distant principal payment, so the same maturity carries more duration. FRED DGS10, 2000-01-03 to 2026-09-09, evaluated by the attached script.

The range is 7.17 to 9.73 years, a 36 percent difference in rate sensitivity for the same maturity, driven entirely by the level of yields. The yearly means make the drift plain: 7.43 in 2000, 8.50 in 2010, 9.55 in 2020, and 8.02 so far in 2026. When the DGS10 print was 0.52 percent on 2020-08-04, a freshly issued par 10-year would have lost about 0.97 percent of its price per ten basis points of yield; at the 2026-09-09 print the same move costs about 0.79 percent. That is a mechanical consequence of the formula, not a statement about what yields will do, and the script prints the whole yearly table so the drift can be inspected rather than summarised.

Reproducibility and limits

datasets/bond-math-price-yield-duration-convexity.csv is the raw pull: date, then one column per series DGS1MO through DGS30, percent per annum, with FRED's "." rendered as an empty cell; DGS1MO begins 2001-07-31 and the others 2000-01-03. -shocks.csv holds 75 rows: curve_date, series, years, coupon_pct, shift_bp, yield_pct, price_exact, price_duration, price_duration_convexity, and the two error columns. -history.csv holds 6,675 rows: date, DGS10_pct, modified_duration_10y_par, convexity_10y_par, dv01_per_100. Run python code/bond-math-price-yield-duration-convexity.py --download --figures to refresh everything including the two charts; the numbers need only the standard library, and the charts need matplotlib 3.10.9.

The limits are the conventions. Every bond here is hypothetical, a par instrument whose coupon equals the quoted yield, with exactly a whole number of semiannual periods to maturity, no accrued interest, and no settlement lag; a real Treasury note between coupon dates needs the actual day count and a dirty-to-clean price adjustment that this script does not perform. The shifts are parallel, meaning every point on the curve moves by the same amount, which is the assumption duration itself makes and which the curve routinely violates: on 2026-09-09 the 2-year sat 40 basis points below the 10-year and the 20-year printed the same yield as the 30-year, and neither of those shapes is a parallel move from the 2000 curve. Bill yields are quoted on an investment basis and we have discounted them with semiannual compounding, which is a simplification of how bills actually trade. One further thing we did not resolve: H.15's footnote 9 says the 30-year constant maturity series "was discontinued on February 18, 2002, and reintroduced on February 9, 2006", with the Treasury publishing an adjustment factor in between, yet the FRED file carries 995 DGS30 observations across that window. We have left those values in the dataset as FRED serves them and have not used them for any figure above; how they were derived is a question for the source, not something to assume. And none of this is a reason to hold or avoid any maturity; it is the arithmetic that any such decision would have to start from.

This article is analysis and education, not investment, tax, or legal advice. Figures are cited to their source and dated; check them before relying on them.