A callable stepped-coupon bond¶
We value an amortizing six-year USD note whose first coupon is fixed and whose later coupons float with a cap and floor. The issuer can call during specified windows. The exercise right makes the value depend on future rates, so we calibrate a short-rate model and compare two backward-pricing engines.
This notebook concentrates on the contract and its valuation. File loading is in the local loading helper, numerical/model settings are in data/bond_tree.json and data/bond_fourier.json, and script construction has moved to its own walkthrough. See 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, load_pricers
market = load_market()
ref = market.quotes.reference_date
tree_configuration = load_pricers("bond_tree.json")
fourier_configuration = load_pricers("bond_fourier.json")
print("Market reference date:", ref)
Market reference date: 2026-02-24
Write the contract in financial terms¶
FixedRateCoupon, FloatingRateCoupon, and OptionEmbeddedCoupon describe coupon economics; CallScheduleEntry
describes issuer exercise. Typed dates and enums keep those decisions visible.
The coupon and outstanding-principal lists are positional by payment period:
the last element repeats for all later periods. Thus the 4.5% coupon applies
only to the first half-year, not the first year.
Outstanding principal is 100% in period one, 90% in period two, then 75% through maturity. Reductions are paid at period ends and the remaining principal is repaid at maturity. Fixed accrual uses Actual/365 here; the floating coupon uses SOFR’s Actual/360 convention. The cap and floor bound the all-in floating rate including its 60 bp spread.
bond = qs.SteppedCouponBond(
identifier="USD-STEPPED-CALL-6Y",
start_date=ref,
maturity_date=ref + "6Y",
notional=1_000_000,
units=100.0,
currency=qs.Currency.USD,
payment_frequency=qs.Frequency.Semiannual,
coupon_day_counter=qs.DayCounter.Actual365,
coupons=[
qs.FixedRateCoupon(rate=qs.InterestRate(rate=0.045)),
qs.OptionEmbeddedCoupon(
index=qs.MarketIndex.SOFR,
payoff=(qs.PayoffOps.Index + 0.006).max(0.0).min(0.06),
),
],
notional_percentages=[1.0, 0.90, 0.75],
call_schedule=[
qs.CallScheduleEntry.window(ref + "3Y", ref + "3Y3M", 101.0),
qs.CallScheduleEntry.window(ref + "4Y", ref + "4Y3M", 100.0),
],
)
bond
SteppedCouponBond(business_day_convention=None, calendar=None, call_schedule=[{'end_date': '2029-05-24', 'start_date': '2029-02-24', 'strike': 101.0}, {'end_date': '2030-05-24', 'start_date': '2030-02-24', 'strike': 100.0}], coupon_day_counter='Actual365', coupons=[{'FixedRateCoupon': {'notional': 1.0, 'rate': {'rate': 0.045, 'rate_definition': {'compounding': 'Simple', 'day_counter': 'Actual360', 'frequency': 'Annual'}}}}, {'OptionEmbeddedCoupon': {'day_counter': 'Actual360', 'fixing': None, 'index': 'SOFR', 'notional': 1.0, 'payoff': {'Min': [{'Max': [{'Plus': ['Index', {'Const': 0.006}]}, {'Const': 0.0}]}, {'Const': 0.06}]}, 'spread': 0.0}}], currency='USD', date_generation_rule=None, end_of_month=None, first_coupon_date=None, identifier='USD-STEPPED-CALL-6Y', maturity_date='2032-02-24', next_to_last_coupon_date=None, notional=1000000.0, notional_percentages=[1.0, 0.9, 0.75], payment_frequency='Semiannual', start_date='2026-02-24', termination_date_convention=None, units=100.0)
Each call window includes both boundaries. Strikes are quoted per 100 of outstanding principal. On exercise, contractual coupon and amortization due that day are paid, then the strike caps the remaining bond value. The numerical time grid determines the exercise opportunities inside each window.
The context’s fixed-income policy maps USD payments to SOFR for discounting. This policy is supplied separately from the contract.
Model and numerical method¶
Both configuration files assign a Hull–White model to SOFR with mean reversion
alpha = 0.05. Its sigma schedule is calibrated from ATM caplet prices at
3M, 6M, 1Y, 2Y, 3Y and 5Y. The calibration converts Black quotes into absolute
short-rate sigmas, and the last segment continues beyond five years for this
six-year note. Check that selected ATM strikes remain within the source smiles
when changing inputs.
Both engines use 48 time steps per year, capped at 4,096. The tree performs backward induction on a trinomial lattice. The Fourier engine uses a 256-point short-rate state grid with a half-width of ten standard deviations.
We compare their values, then refine time resolution to 96 steps per year and the Fourier grid to 512 points. Inspect price and risk stability across further refinements, including the state-domain width, when setting a valuation tolerance.
def price_with(configuration, anchor, requests, instrument=bond):
context = qs.PricingContext(
quotes=market.quotes,
curves=market.curves,
csa_policy=market.csa_policy,
volatility_surfaces=market.volatility_surfaces,
pricer_configurations=configuration,
)
with context:
return context.evaluate(
qs.Trade(instrument, ref, pricing_anchor=anchor), requests
)
# 75 bp, held fixed for both engines. Only the tree risk is used below.
anchor = qs.BondPricingAnchor.OAS(0.0075)
tree = price_with(
tree_configuration,
anchor,
[qs.Request.Value, qs.Request.OAS, qs.Request.Sensitivities],
)
fourier = price_with(fourier_configuration, anchor, [qs.Request.Value, qs.Request.OAS])
comparison = pd.DataFrame(
[
{"engine": "Tree", "pv_usd": tree.price, "oas_bp": tree.oas * 10_000},
{
"engine": "Backward Fourier",
"pv_usd": fourier.price,
"oas_bp": fourier.oas * 10_000,
},
]
).set_index("engine")
print(f"Absolute engine difference: USD {abs(tree.price - fourier.price):,.4f}")
comparison
Absolute engine difference: USD 25.4486
| pv_usd | oas_bp | |
|---|---|---|
| engine | ||
| Tree | 974386.001812 | 75.0 |
| Backward Fourier | 974360.553252 | 75.0 |
The comparison reports signed USD position PV and the supplied OAS. Engine differences arise from their numerical discretizations. We calculate tree sensitivities for the later risk report; the Fourier comparison requires value and spread only.
Inferring OAS from a price¶
An OAS anchor gives value at a specified spread. A price anchor instead solves the spread that reproduces the quote. We convert the tree value to a dirty quote and solve OAS again as a round-trip check.
Settlement is the issue date, so outstanding principal equals the original notional and accrued interest is zero. A seasoned or amortized bond requires its settlement balance and accrued interest in that conversion.
dirty_quote = tree.price / 1_000_000 * 100
implied = price_with(
tree_configuration,
qs.BondPricingAnchor.dirty_price(dirty_quote),
[qs.Request.Value, qs.Request.OAS],
)
pd.Series(
{
"dirty_price_per_100": dirty_quote,
"implied_oas_bp": implied.oas * 10_000,
"position_pv_usd": implied.price,
}
)
dirty_price_per_100 97.438600
implied_oas_bp 75.000000
position_pv_usd 974386.001812
dtype: float64
Read curve, volatility, and OAS risk separately¶
The sensitivity table contains original curve quotes, caplet quotes used in
calibration, and an independent <identifier>.OAS factor. For a price-anchored
valuation, the solved OAS is then held fixed when reporting curve and volatility
risk. Re-solving OAS after every shock would answer a different question.
Scale rate and OAS derivatives by 1e-4 for +1 bp, and Black-volatility
derivatives by 0.01 for +1 vol point. Unlike a straight bond, this callable
product has no yield-to-maturity or modified-duration request; its exercise
behavior is described by model value, OAS, and sensitivities.
risk = tree.sensitivities.set_index("pillar")["value"]
curve_labels = market.curves[0].to_dict()["quotes"]
vol_labels = market.volatility_surfaces[0].to_dict()["quotes"]
report_groups = [
("rate +1 bp", curve_labels, 1e-4),
("Black vol +1 point", vol_labels, 0.01),
("OAS +1 bp", [bond.identifier + ".OAS"], 1e-4),
]
risk_tables = []
for move, labels, change in report_groups:
pnl = risk.reindex(labels, fill_value=0) * change
table = pd.DataFrame({"pnl_usd": pnl, "move": move})
risk_tables.append(table)
risk_report = pd.concat(risk_tables)
largest_risks = risk_report["pnl_usd"].abs().sort_values(ascending=False).head(12).index
risk_report.loc[largest_risks]
| pnl_usd | move | |
|---|---|---|
| pillar | ||
| USD-STEPPED-CALL-6Y.OAS | -328.814193 | OAS +1 bp |
| CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.045_Straddle_Black | -80.936469 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_2Y_Absolute_0.035_Straddle_Black | -80.888198 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_3Y_Absolute_0.035_Straddle_Black | -63.296194 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.045_Straddle_Black | -57.766632 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_3M_Absolute_0.045_Straddle_Black | -56.755505 | Black vol +1 point |
| OIS_USD_SOFR_1Y | -47.276635 | rate +1 bp |
| CapletFloorlet_USD_SOFR_3M_1Y_Absolute_0.035_Straddle_Black | -38.849426 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_6M_Absolute_0.055_Straddle_Black | -19.331666 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_3M_Absolute_0.055_Straddle_Black | -18.993291 | Black vol +1 point |
| CapletFloorlet_USD_SOFR_3M_3Y_Absolute_0.045_Straddle_Black | -17.624702 | Black vol +1 point |
| OIS_USD_SOFR_5Y | -16.493556 | rate +1 bp |
Issuer option value and grid refinement¶
At a call opportunity, the holder’s remaining value is capped by the issuer’s redemption cost after payments due that day. Removing the calls therefore gives an upper bound for the callable note under matching coupon and model assumptions.
We value that noncallable underlying with a 75 bp continuous Z-spread, then refine both callable engines. Currency differences are divided by 10,000 to report changes per 100 of original USD 1 million principal. Further refinement, state-width and risk checks are needed to establish a chosen tolerance.
underlying_terms = bond.to_dict()
underlying_terms["call_schedule"] = []
underlying = qs.SteppedCouponBond.from_dict(underlying_terms)
noncallable = price_with(
tree_configuration,
qs.BondPricingAnchor.z_spread(0.0075),
[qs.Request.Value],
underlying,
)
refined_rows = []
for label, configuration, coarse in [
("Tree", tree_configuration, tree),
("Backward Fourier", fourier_configuration, fourier),
]:
specification = configuration[0].to_dict()
engine = next(iter(specification["pricer"].values()))
engine["time_grid"]["steps_per_year"] = 96
if "fourier_grid" in engine:
engine["fourier_grid"]["points"] = 512
refined_configuration = [qs.PricerConfiguration.from_dict(specification)]
refined = price_with(refined_configuration, anchor, [qs.Request.Value])
refined_rows.append(
{
"engine": label,
"teaching_grid_pv_usd": coarse.price,
"refined_grid_pv_usd": refined.price,
"change_per_100_original_principal": (refined.price - coarse.price)
/ 10_000,
}
)
print(
f"Noncallable PV: USD {noncallable.price:,.2f}; callable PV: USD {tree.price:,.2f}."
)
print(f"Issuer call-option cost to holder: USD {noncallable.price - tree.price:,.2f}.")
refinement = pd.DataFrame(refined_rows).set_index("engine")
refinement
Noncallable PV: USD 988,569.06; callable PV: USD 974,386.00.
Issuer call-option cost to holder: USD 14,183.06.
| teaching_grid_pv_usd | refined_grid_pv_usd | change_per_100_original_principal | |
|---|---|---|---|
| engine | |||
| Tree | 974386.001812 | 974459.894944 | 0.007389 |
| Backward Fourier | 974360.553252 | 974449.997276 | 0.008944 |
The issuer option cost is the difference between the matching noncallable and callable values. OAS inversion checks quotation consistency, while grid changes measure numerical stability under the selected model. The volatility risk includes the calibration response supplied by the individual sigma fits; curve-induced recalibration is held fixed.
The scripted-cashflow study values the noncallable underlying and includes a zero-volatility representation check. Issuer exercise remains part of the native callable-bond calculation.