Price, yield, duration and Z-spread for a fixed-rate bond

Consider a five-year USD bond paying a 4.5% coupon and quoted at 99.25 clean. We value USD 1 million principal at origination on 24 February 2026, solve the Z-spread implied by that quote, and compare yield sensitivity with curve-quote risk.

Coupons are semiannual, with Actual/365 accrual and semiannual compounding; dates are unadjusted. Settlement is the valuation date, so accrued interest is zero. The spread-adjusted receipt value is

\[V(z)=\sum_i C_iP(0,T_i)e^{-z t_i}.\]

The quote is per 100 principal, while \(V\) is in USD. We use a constant Z-spread over SOFR; default is outside this valuation model. Setup.

Computational study. Canonical notebook · Companion files and setup. Download this notebook.

This page renders saved outputs; the documentation build does not execute it. Run the canonical notebook from its own directory with the checkout-built binding to reproduce the computation. Each study records its market inputs and numerical assumptions.

import pandas as pd
import quantsupport as qs
from market_data import load_market

market = load_market()
ref = market.quotes.reference_date

From a price quote to a spread

We first price the bond at zero Z-spread to obtain its curve-implied value. A clean-price anchor of 99.25 then asks the pricer to solve the constant spread that reproduces the quoted value. Applying that spread as a new anchor provides a round-trip check.

Yield to maturity discounts the same cashflows using the bond’s rate conventions. Modified duration measures their sensitivity to that yield. Z-spread instead adjusts the market curve with a continuous spread.

bond = qs.FixedRateBond(
    identifier="USD-FIXED-BOND-5Y",
    start_date=ref,
    maturity_date=ref + "5Y",
    notional=1_000_000,
    rate=0.045,
    currency=qs.Currency.USD,
    day_counter=qs.DayCounter.Actual365,
    compounding=qs.Compounding.Compounded,
    rate_frequency=qs.Frequency.Semiannual,
    payment_frequency=qs.Frequency.Semiannual,
)
trade = qs.Trade(bond, ref, pricing_anchor=qs.BondPricingAnchor.z_spread(0.0))
context = qs.PricingContext(
    quotes=market.quotes, curves=market.curves, csa_policy=market.csa_policy
)

with context:
    curve_value = context.evaluate(trade, [qs.Request.Value])
    bond_result = context.evaluate(
        trade.with_pricing_anchor(qs.BondPricingAnchor.clean_price(99.25)),
        [
            qs.Request.Value,
            qs.Request.YieldToMaturity,
            qs.Request.ModifiedDuration,
            qs.Request.ZSpread,
            qs.Request.Cashflows,
            qs.Request.Sensitivities,
        ],
    )
    spread_value = context.evaluate(
        trade.with_pricing_anchor(qs.BondPricingAnchor.z_spread(bond_result.z_spread)),
        [qs.Request.Value],
    )

pd.Series(
    {
        "curve_only_pv_usd": curve_value.price,
        "price_anchored_pv_usd": bond_result.price,
        "spread_anchored_pv_usd": spread_value.price,
        "anchor_pv_difference_usd": spread_value.price - bond_result.price,
        "ytm": bond_result.yield_to_maturity,
        "modified_duration_years": bond_result.modified_duration,
        "z_spread_bp": bond_result.z_spread * 10_000,
    }
)
curve_only_pv_usd           1.018167e+06
price_anchored_pv_usd       9.925000e+05
spread_anchored_pv_usd      9.925000e+05
anchor_pv_difference_usd    0.000000e+00
ytm                         4.669820e-02
modified_duration_years     4.429526e+00
z_spread_bp                 5.547644e+01
dtype: float64

The spread is an independent risk factor after it has been solved. The <identifier>.ZSpread row measures sensitivity to that spread; the other rows measure curve-quote sensitivity holding the solved spread fixed. They do not describe re-solving the spread to keep the observed clean price constant under every market shock.

bond_result.sensitivities.assign(
    pnl_for_plus_1bp_usd=lambda frame: frame["value"] * 1e-4
)
pillar value pnl_for_plus_1bp_usd
0 FixedRateDeposit_USD_SOFR_1D -2.756600e+03 -0.275660
1 OIS_USD_SOFR_10Y 0.000000e+00 0.000000
2 OIS_USD_SOFR_1Y -7.328608e+03 -0.732861
3 OIS_USD_SOFR_2Y -7.142079e+03 -0.714208
4 OIS_USD_SOFR_30Y 0.000000e+00 0.000000
5 OIS_USD_SOFR_3Y -2.408953e+04 -2.408953
6 OIS_USD_SOFR_5Y -4.319454e+06 -431.945412
7 OIS_USD_SOFR_7Y 0.000000e+00 0.000000
8 USD-FIXED-BOND-5Y.ZSpread -4.564245e+06 -456.424490
print("The solved Z-spread reproduces the price-anchored position value.")
The solved Z-spread reproduces the price-anchored position value.

Reconstruct value, yield, and modified duration

With semiannual yield \(y\), \(V(y)=\sum_i C_i(1+y/2)^{-2t_i}\) and \(D_{\rm mod}=-(1/V)\partial V/\partial y\). Here yield time uses the bond’s Actual/365 convention; spread time uses the discount curve’s convention. These clocks need not be identical. The duration check bumps a single yield; the Z-spread check holds the curve fixed. Neither equals a parallel move of all calibrated market quotes.

import math

spread_bump = 1e-5
payments = bond_result.cashflows.copy()
payments = payments.loc[payments["payment_date"] > str(ref)].copy()
with context:
    curve = context.curve(qs.MarketIndex.SOFR)
    spread_basis = curve.day_counter
    payments["curve_df"] = payments["payment_date"].map(curve.discount_factor)
    plus_spread = context.evaluate(
        trade.with_pricing_anchor(
            qs.BondPricingAnchor.z_spread(bond_result.z_spread + spread_bump)
        ),
        [qs.Request.Value],
    ).price
    minus_spread = context.evaluate(
        trade.with_pricing_anchor(
            qs.BondPricingAnchor.z_spread(bond_result.z_spread - spread_bump)
        ),
        [qs.Request.Value],
    ).price
payments["spread_time"] = payments["payment_date"].map(
    lambda date: spread_basis.year_fraction(ref, qs.Date.parse(date))
)
payments["yield_time"] = payments["payment_date"].map(
    lambda date: qs.DayCounter.Actual365.year_fraction(ref, qs.Date.parse(date))
)
payments["spread_pv_usd"] = [
    amount * discount * math.exp(-bond_result.z_spread * time)
    for amount, discount, time in zip(
        payments["amount"], payments["curve_df"], payments["spread_time"]
    )
]

def value_at_yield(annual_yield):
    return sum(
        amount * (1 + annual_yield / 2) ** (-2 * time)
        for amount, time in zip(payments["amount"], payments["yield_time"])
    )

yield_value = value_at_yield(bond_result.yield_to_maturity)
yield_bump = 1e-5
duration_from_bump = -(
    value_at_yield(bond_result.yield_to_maturity + yield_bump)
    - value_at_yield(bond_result.yield_to_maturity - yield_bump)
) / (2 * yield_bump * yield_value)
spread_derivative = (plus_spread - minus_spread) / (2 * spread_bump)
spread_aad = bond_result.sensitivities.set_index("pillar").loc[
    bond.identifier + ".ZSpread", "value"
]
print(
    f"Yield {bond_result.yield_to_maturity:.6%}; Z-spread {bond_result.z_spread * 10_000:.4f} bp."
)
print(
    f"Modified duration {duration_from_bump:.6f} years; +1 bp yield P&L ≈ USD {-duration_from_bump * yield_value * 1e-4:.2f}."
)
payments[["payment_date", "type", "amount", "spread_pv_usd"]]
Yield 4.669820%; Z-spread 55.4764 bp.
Modified duration 4.429526 years; +1 bp yield P&L ≈ USD -439.63.
payment_date type amount spread_pv_usd
1 2031-02-24 Redemption 1000000.000000 794688.089455
2 2026-08-24 FixedRateCoupon 22315.068493 21730.944929
3 2027-02-24 FixedRateCoupon 22684.931507 21503.273856
4 2027-08-24 FixedRateCoupon 22315.068493 20659.915976
5 2028-02-24 FixedRateCoupon 22684.931507 20507.189603
6 2028-08-24 FixedRateCoupon 22438.356164 19859.097378
7 2029-02-24 FixedRateCoupon 22684.931507 19653.539726
8 2029-08-24 FixedRateCoupon 22315.068493 18923.656219
9 2030-02-24 FixedRateCoupon 22684.931507 18823.030219
10 2030-08-24 FixedRateCoupon 22315.068493 18123.817761
11 2031-02-24 FixedRateCoupon 22684.931507 18027.444879

The yield and spread calculations use different discounting descriptions of the same receipts. Their separate bump checks help interpret duration and spread sensitivity. Curve-quote risk holds the initially solved Z-spread fixed; reanchoring to the same clean quote after every curve move would change the risk experiment. For issuer exercise and OAS, continue with the callable stepped bond.