Dated payments: from a caplet to an amortizing note¶
Payoff scripts express a contract as dated observations and payments. We first write a caplet, then describe the coupon and principal payments of a six-year semiannual amortizing note. Both use the illustrative USD/SOFR market dated 24 February 2026 and a calibrated Gaussian rate model.
The note’s fixed coupon uses Actual/365 and floating coupons use Actual/360, with unadjusted dates. We value the underlying payments with issuer calls removed. A separate zero-volatility comparison checks their representation against the native instrument, while repeated stochastic runs illustrate sampling variation. Setup · Callable note.
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
from stepped_cashflows import underlying_script
market = load_market()
ref = market.quotes.reference_date
pricers = load_pricers("bond_tree.json")
Observing a rate and scheduling a payment¶
An event executes on its specified date. Here RateIndex observes the rate
for the caplet’s accrual period, cvg measures its accrual fraction and pays
adds the discounted payoff to note. The variables rate and accrual
retain intermediate quantities.
The engine resolves its rate model from context configuration. Its
result_variable selects the target for derivative and martingale-control
processing; the returned dictionary includes all averaged variables.
fixing = ref + "1Y"
payment = fixing + "3M"
scripted_caplet = qs.ScriptedProduct.from_dict(
{
"name": "SCRIPTED-CAPLET",
"currency": qs.Currency.USD,
"result_variable": "note",
"events": [
{
"date": fixing,
"code": (
f'note = 0; rate = RateIndex("SOFR", "{fixing}", "{payment}"); '
f'accrual = cvg("{fixing}", "{payment}", "Actual360"); '
f'note pays 1000000 * max(rate - 0.045, 0) * accrual on "{payment}";'
),
}
],
}
)
context = qs.PricingContext(
quotes=market.quotes,
curves=market.curves,
csa_policy=market.csa_policy,
volatility_surfaces=market.volatility_surfaces,
pricer_configurations=pricers,
)
with context:
values = context.evaluate_script(scripted_caplet, n_paths=2048, seed=42)
pd.Series(values)
note 1137.669549
rate 0.042082
accrual 0.247222
dtype: float64
The configured Hull–White model is mapped to an equivalent LGM representation for script evaluation. Its volatility schedule comes from ATM caplet fits at 3M, 6M, 1Y, 2Y, 3Y and 5Y. The last sigma segment continues beyond five years. Check the ATM basket against the source surface’s 3.5%–5.5% strike domain when changing market inputs.
The scripted caplet is valued under these Gaussian dynamics and has Monte Carlo sampling error. Comparisons with a Black price also depend on the calibration basket and the option’s strike.
The stepped note’s underlying payments¶
The contract below uses the coupon and principal schedule from the callable-bond study, with calls removed. The companion helper translates this specific semiannual schedule into dated events. Inspect it alongside the contract when adapting the payment rules.
underlying = qs.SteppedCouponBond(
identifier="STEPPED-UNDERLYING",
start_date=ref,
maturity_date=ref + "6Y",
notional=1_000_000,
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=[],
)
script = underlying_script(underlying)
pd.DataFrame(script.to_dict()["events"]).head(3)
| date | code | |
|---|---|---|
| 0 | 2026-02-24 | bond = 0; accrual = cvg("2026-02-24", "2026-08... |
| 1 | 2026-08-24 | accrual = cvg("2026-08-24", "2027-02-24", "Act... |
| 2 | 2027-02-24 | accrual = cvg("2027-02-24", "2027-08-24", "Act... |
The first event initializes bond and schedules the fixed coupon and first
principal reduction. Later events observe SOFR and apply the spread, floor
and cap before scheduling their payments. The last event returns the remaining
principal. The script uses Actual/365 for the fixed coupon and Actual/360 for
floating coupons.
We value this noncallable underlying at zero spread.
context = qs.PricingContext(
quotes=market.quotes,
curves=market.curves,
csa_policy=market.csa_policy,
volatility_surfaces=market.volatility_surfaces,
pricer_configurations=pricers,
)
with context:
stochastic = context.evaluate_script(script, n_paths=4096, seed=42)
print(f"Calibrated scripted underlying PV: USD {stochastic['bond']:,.2f}")
Calibrated scripted underlying PV: USD 1,010,885.99
Checking the payment representation¶
To compare the native and scripted payment schedules, we use zero volatility and zero Z-spread. A single path then suffices. The native tree and script should agree within the tree’s floating-coupon discretization error; the comparison below allows 0.05% of native PV.
The calibrated stochastic examples retain their earlier model settings.
deterministic_spec = pricers[0].to_dict()
deterministic_spec["models"][0]["model"]["HullWhite"]["parameter_source"] = {
"Fixed": {"sigma": 0.0}
}
deterministic_pricer = qs.PricerConfiguration.from_dict(deterministic_spec)
context = qs.PricingContext(
quotes=market.quotes,
curves=market.curves,
csa_policy=market.csa_policy,
pricer_configurations=[deterministic_pricer],
)
with context:
native = context.evaluate(
qs.Trade(underlying, ref, pricing_anchor=qs.BondPricingAnchor.z_spread(0.0)),
[qs.Request.Value],
)
scripted = context.evaluate_script(script, n_paths=1, seed=42)
pd.Series(
{
"native_pv_usd": native.price,
"scripted_pv_usd": scripted["bond"],
"difference_usd": native.price - scripted["bond"],
}
)
native_pv_usd 1.020546e+06
scripted_pv_usd 1.020546e+06
difference_usd 0.000000e+00
dtype: float64
Sampling variation¶
The zero-volatility comparison checks payment terms and amortization under matching discount assumptions. We then repeat the stochastic caplet valuation across seeds to inspect dispersion and verify that seed 42 reproduces the initial result.
The selected script variable can receive a martingale-control adjustment.
Lower-level cashflow diagnostics retain raw sampled PVs, so their sum can
differ from the controlled result at finite path counts. Variables such as
rate represent averaged state quantities; note is the payment accumulator.
with qs.PricingContext(
quotes=market.quotes,
curves=market.curves,
csa_policy=market.csa_policy,
volatility_surfaces=market.volatility_surfaces,
pricer_configurations=pricers,
) as context:
estimates = []
for seed in [41, 42, 43, 44, 45]:
estimate = context.evaluate_script(scripted_caplet, n_paths=2048, seed=seed)
estimates.append({"seed": seed, "caplet_pv_usd": estimate["note"]})
estimates = pd.DataFrame(estimates)
print(
f"Deterministic representation difference: USD {native.price - scripted['bond']:.6f}."
)
print(
f"Across-seed SD of 2,048-path caplet estimates: USD {estimates['caplet_pv_usd'].std():.4f}."
)
estimates
Deterministic representation difference: USD 0.000000.
Across-seed SD of 2,048-path caplet estimates: USD 3.4266.
| seed | caplet_pv_usd | |
|---|---|---|
| 0 | 41 | 1136.335996 |
| 1 | 42 | 1137.669549 |
| 2 | 43 | 1129.344025 |
| 3 | 44 | 1137.054234 |
| 4 | 45 | 1136.635810 |
The across-seed standard deviation measures dispersion of the repeated 2,048-path estimates. Further runs at larger path counts can assess its stability. The note comparison covers its underlying payments; issuer exercise requires the callable-bond calculation.
Python’s evaluate_script assembles rate models and returns averaged numeric
variables. Raw paths, cashflow and sensitivity tables, arbitrary FX/equity
factors and custom correlations require other interfaces. Keep terms, spread,
model assumptions and estimator choices aligned when comparing representations.