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.