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
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.