Bond total return swap
This page lists what you need to book and value a bond total return swap (TRS): the
bond_total_return_swap call and its funding arguments, how each reset period is valued, and what
happens when a valuation date falls after a reset. A bond TRS passes a bond's coupons and price
change to one side in exchange for funding on the cash the bond would cost.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This swaps the return on 10 million of a ten-year 4.25% bond, struck at 99 and resetting quarterly, funded on USD GC. It values the swap on the close, then on a date a month after the first reset, where the swap has to be restated from that reset. Restated at the price struck then, 98, it's funded at a fixed margin, and one point less margin is checked by hand.
use chrono::NaiveDate;
use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE, USD_SOFR_PRINTS};
use helixrs::{
AnyInstrument, BondTotalReturnSwap, CollateralSpec, Curve, DayCountFraction, FixedRateBond,
FixingKey, FixingStore, Frequency, MarketContext, QuoteSheet, RepoSpread, YieldCurve,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn market_on(day: NaiveDate) -> Outcome<MarketContext> {
let sheet = QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?;
let prints: Vec<(FixingKey, NaiveDate, f64)> = USD_SOFR_PRINTS
.iter()
.map(|(index, d, value)| (FixingKey::Typed((*index).to_owned()), date(*d), *value))
.collect();
let gc = RepoSpread::new(day, &[(date((2027, 10, 1)), -0.0010)], DayCountFraction::Act360)?;
Ok(MarketContext::as_of(day)
.with_fixings(FixingStore::from_observations_checked(&prints)?)
.with_recipe_quotes(&CollateralSpec::Currency("USD".parse()?), &sheet)?
.with_repo_spread("USD", Curve::RepoSpread(gc), "discount(USD)".parse()?))
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let resets = vec![date((2026, 10, 2)), date((2027, 1, 4)), date((2027, 4, 1))];
let restarted = resets[1..].to_vec();
let bond = FixedRateBond::bullet(
"USD".parse()?,
date((2025, 8, 15)),
date((2035, 8, 15)),
0.0425,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
)?;
// Struck at 99 on 10 million, resetting quarterly, funded on USD GC.
let trs = AnyInstrument::BondTotalReturnSwap(Box::new(BondTotalReturnSwap::new(
bond.clone(),
10_000_000.0,
99.0,
resets,
"USD",
)?));
let npv = trs.npv(&market_on(close)?)?;
println!("npv {npv:.2}");
// The total-return receiver starts at 99 on a bond the curve prices lower, so
// the price leg runs against it and the position is worth less than nothing.
// A month after the first reset, the swap needs the price struck then.
let reset_refusal = trs
.npv(&market_on(date((2027, 2, 1)))?)
.expect_err("a reset in the past must be restated")
.to_string();
println!(
"refused, naming the 2027-01-04 reset: {}",
if reset_refusal.contains("2027-01-04") { "yes" } else { "no" }
);
// Restated from the January reset at the price struck then, 98, the swap's
// first funding period has already begun. Floating with no index to read,
// that period's rate cannot be known, and the swap is refused by name.
let later = market_on(date((2027, 2, 1)))?;
let floating = AnyInstrument::BondTotalReturnSwap(Box::new(BondTotalReturnSwap::new(
bond.clone(),
10_000_000.0,
98.0,
restarted.clone(),
"USD",
)?));
let funding_refusal = floating
.npv(&later)
.expect_err("a begun period floating on no index must be refused")
.to_string();
println!(
"refused, asking for a fixed margin: {}",
if funding_refusal.contains("fixed margin") { "yes" } else { "no" }
);
// Zero gearing funds at the margin alone: fixed coupons, known in advance.
let at_margin = |margin: f64| -> Outcome<AnyInstrument> {
Ok(AnyInstrument::BondTotalReturnSwap(Box::new(
BondTotalReturnSwap::new(bond.clone(), 10_000_000.0, 98.0, restarted.clone(), "USD")?
.with_funding_gearing(0.0)
.with_funding_margin(margin),
)))
};
let (four, three) = (at_margin(0.04)?.npv(&later)?, at_margin(0.03)?.npv(&later)?);
// A point less margin saves one point on the financed cash, N·P0/100, over the
// ACT/360 period, paid at its end and discounted there.
let (start, end) = (restarted[0], restarted[1]);
let (discount, _) = later.curve(&"discount(USD)".parse()?).expect("the USD curve");
let saving = 10_000_000.0 * 98.0 / 100.0 * 0.01 * (end - start).num_days() as f64 / 360.0
* discount.view::<f64>().discount(end)?;
println!("npv at 4% {four:.2}");
println!("a point {:.2}", three - four);
println!("by hand {saving:.2}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE, USD_SOFR_PRINTS
RESETS = [dt.date(2026, 10, 2), dt.date(2027, 1, 4), dt.date(2027, 4, 1)]
LATER = dt.date(2027, 2, 1)
def market_on(day):
gc = hlx.curves.RepoSpreadCurve(day, [(dt.date(2027, 10, 1), -0.0010)])
return (
hlx.MarketContext(day)
.with_fixings(USD_SOFR_PRINTS)
.with_recipe_quotes("USD", USD_SOFR_CLOSE)
.with_repo_spread("USD", gc, "discount(USD)")
)
bond = hlx.products.fixed_rate_bond(
"USD", dt.date(2025, 8, 15), dt.date(2035, 8, 15),
coupon=0.0425, frequency="Semiannual", day_count="ACT/ACT.ICMA",
)
# Struck at 99 on 10 million, resetting quarterly, funded on USD GC.
trs = hlx.products.bond_total_return_swap(
bond, notional=10_000_000.0, initial_price=99.0, reset_schedule=RESETS, repo_key="USD"
)
npv = trs.npv(market_on(SOFR_CLOSE_DATE))
print(f"npv {npv:.2f}")
# The total-return receiver starts at 99 on a bond the curve prices lower, so
# the price leg runs against it and the position is worth less than nothing.
# A month after the first reset, the swap needs the price struck then.
try:
trs.npv(market_on(LATER))
except hlx.PricingError as refused:
reset_refusal = str(refused)
else:
raise AssertionError("a reset in the past must be restated")
print(f"refused, naming the 2027-01-04 reset: {'yes' if '2027-01-04' in reset_refusal else 'no'}")
# Restated from the January reset at the price struck then, 98, the swap's first
# funding period has already begun. Floating with no index to read, that
# period's rate cannot be known, and the swap is refused by name.
restarted = RESETS[1:]
later = market_on(LATER)
floating = hlx.products.bond_total_return_swap(
bond, notional=10_000_000.0, initial_price=98.0, reset_schedule=restarted, repo_key="USD"
)
try:
floating.npv(later)
except hlx.PricingError as refused:
funding_refusal = str(refused)
else:
raise AssertionError("a begun period floating on no index must be refused")
print(f"refused, asking for a fixed margin: {'yes' if 'fixed margin' in funding_refusal else 'no'}")
def at_margin(margin):
# Zero gearing funds at the margin alone: fixed coupons, known in advance.
return hlx.products.bond_total_return_swap(
bond, notional=10_000_000.0, initial_price=98.0, reset_schedule=restarted, repo_key="USD",
funding_gearing=0.0, funding_margin=margin,
)
four, three = at_margin(0.04).npv(later), at_margin(0.03).npv(later)
# A point less margin saves one point on the financed cash, N·P0/100, over the
# ACT/360 period, paid at its end and discounted there.
start, end = restarted
saving = 10_000_000.0 * 98.0 / 100.0 * 0.01 * (end - start).days / 360.0 * later.curve("discount(USD)").discount(end)
print(f"npv at 4% {four:.2f}")
print(f"a point {three - four:.2f}")
print(f"by hand {saving:.2f}")npv -312168.69
refused, naming the 2027-01-04 reset: yes
refused, asking for a fixed margin: yes
npv at 4% -76866.09
a point 23529.73
by hand 23529.73
The total-return receiver starts at 99 on a bond the curve prices lower, so the price leg runs against it and the swap is worth less than nothing. After the reset, a swap still struck from the original date is refused, naming the reset. Restated but floating on no index, its first funding period has begun with no rate to read, so it's refused too. At a fixed margin it prices, and a point of margin is worth exactly the financed cash's point over the period.
How it's priced
Over reset period , on notional , with bond price per 100, the total-return receiver gets
and pays funding on the financed cash : at the period's funding rate , scaled by the gearing , plus the margin , over the ACT/360 fraction . Each period's net payment is discounted on the funding curve, and forward bond prices come from the repo curve, as for a bond forward. Why the swap must be restated after a reset is covered in how financing works.
API
products.bond_total_return_swap(bond, *, notional, initial_price, reset_schedule, repo_key, ...):
| Argument | Type | Default | Meaning |
|---|---|---|---|
bond |
FixedRateBond |
required | the bond whose return is swapped |
notional |
float |
required | the bond face the return is paid on |
initial_price |
float |
required | the clean price per 100 the first period starts from |
reset_schedule |
list[date] |
required | the reset dates in ascending order, the first being the start; at least two |
repo_key |
str |
required | the repo curve the bond's forward prices are read on |
paying_total_return |
bool |
False |
pays the bond's return and receives the funding, instead of the reverse |
funding_margin |
float |
0.0 |
the margin over the funding rate |
funding_index |
str |
none | the index the funding floats on; without one it floats on the bond currency's cash collateral rate, ACT/360 |
funding_gearing |
float |
1.0 |
the gearing on the funding rate; 0.0 funds at the margin alone |
funding_notional |
float |
notional × initial_price / 100 |
the cash the funding accrues on |
funding_rate |
float |
none | a fixed funding rate, in place of a margin, a gearing and an index |
bonds.fair_margin(trs, market) returns the funding margin that values the swap at zero. In Rust
the swap is BondTotalReturnSwap::new(bond, notional, initial_price, resets, repo_key) in
AnyInstrument::BondTotalReturnSwap, with .with_funding_margin(m), .with_funding_gearing(g) and
.paying_total_return().
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| , , the resets | the swap | BondTotalReturnSwap::new |
products.bond_total_return_swap |
| the funding margin | .with_funding_margin(m) |
funding_margin= |
|
| the funding gearing | .with_funding_gearing(g) |
funding_gearing= |
|
| the value | net of both legs | AnyInstrument::npv |
Instrument.npv |
Defaults and limits
- Side: the swap receives the total return and pays the funding unless
paying_total_return=True. - What the market needs: the bond's discount curve and a repo spread curve under
repo_key. - A reset in the past: a market dated after a reset doesn't hold the price struck then, so the
swap is refused with a
PricingErrornaming the reset. Restate it from that reset, with the struck price asinitial_price. - A begun funding period: if the first funding period started before the valuation date and
floats on no index, its rate can't be read, and the swap is refused with a
PricingErrorasking for a fixed margin. Give afunding_index, or fund at the margin alone withfunding_gearing=0.0, or set afunding_rate. - Coupons: a coupon the bond pays during a period is passed to the receiver at the period's reset.
- Baskets: a total return swap on several bonds is
products.basket_total_return_swap.
See also
- Source:
crates/helixrs/src/instruments/financing.rs - Related pages:
- How financing works, for resets and restating.
- Bond forward, for the forward prices the price leg reads.
- Repo, for the funding curve.
- Equity total return swap, for the same swap on shares.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.