Repack notes
This page lists what you need to book and price a repack note: the constructor and its two sides, the gap charge, the market it needs, and an example you can run. A repack note is issued by a special-purpose vehicle (SPV) that holds a bond and swaps its cash flows into what investors want; the bank on the other side of the swap carries the gap if the bond's issuer defaults.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This packages a five-year 3.5% EUR corporate bond, starting on 5 October 2026, in an asset swap paying EURIBOR 3M plus 60 basis points, and books the note from the bank's side and the noteholder's. The issuer has a flat 2% hazard. It shows that the two sides together hold the bond, prices the gap charge, and repeats it at four recoveries.
use chrono::NaiveDate;
use helixio_docs_sheets::{EUR_CLOSE, SOFR_CLOSE_DATE};
use helixrs::{
AnyInstrument, AssetSwap, Book, CallableBond, CollateralSpec, CreditCurveId, Currency,
DayCountFraction, FixedRateBond, FlatHazard, Frequency, MarketContext, Period,
PricingRiskFactors, QuoteSheet, RepackNote, RepackRole, Survival, YieldCurve, repack_gap,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
const ISSUER: &str = "ACME/SeniorUnSec/MM14/EUR";
const FACE: f64 = 1_000_000.0;
const INDEX: &str = "EUR-EURIBOR-3M";
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn bond(issuer: Option<&CreditCurveId>) -> Outcome<CallableBond> {
let plain = FixedRateBond::bullet(
Currency::new("EUR")?,
date((2026, 10, 5)),
date((2031, 10, 5)),
0.035,
Frequency::Annual,
DayCountFraction::Thirty360,
FACE,
)?;
let callable = CallableBond::new(plain, Vec::new());
Ok(match issuer {
Some(issuer) => callable.issued_by(issuer.clone()),
None => callable,
})
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let start = date((2026, 10, 5));
let issuer: CreditCurveId = ISSUER.parse()?;
let sheet = QuoteSheet::from_rows(EUR_CLOSE.iter().copied())?;
let marked = MarketContext::as_of(close)
.with_recipe_quotes(&CollateralSpec::Currency("EUR".parse()?), &sheet)?;
let market_recovering = |recovery: f64| -> Outcome<MarketContext> {
let hazard = Survival::FlatHazard(FlatHazard::new(close, 0.02, "ACT/365.FIXED".parse()?)?);
Ok(marked
.clone()
.with_credit_curve(issuer.clone(), hazard, Some(recovery))
.with_hull_white(Currency::new("EUR")?, 0.03, 0.01))
};
// The market: the EUR close, plus what no recipe builds — the issuer's
// credit, and the Hull-White parameters the gap's swap exposure is read under.
let market = market_recovering(0.05)?;
let swap = AssetSwap::new(
bond(None)?,
start,
INDEX,
"3M".parse::<Period>()?,
FACE,
100.0,
0.0060,
)?;
let bank = RepackNote::new(swap.clone(), issuer.clone(), FACE)?;
let notes = bank.clone().with_role(RepackRole::Noteholder);
let bank_side = AnyInstrument::RepackNote(Box::new(bank.clone()));
let note_side = AnyInstrument::RepackNote(Box::new(notes));
println!("bank side {:.2}", bank_side.npv(&market)?);
println!("one note {:.6}", note_side.npv(&market)?);
// Whatever the swap does, the bank and all the notes together hold the bond,
// and nothing else: their values add to the credit-risky bond's value.
let book = Book::new()
.add("SWAP", bank_side, 1.0)
.add("NOTES", note_side, FACE)
.attach_market(market.clone());
let held = book.npv(close)?;
let collateral = AnyInstrument::Callable(Box::new(bond(Some(&issuer))?)).npv(&market)?;
println!("bank + notes {held:.2}");
println!("collateral {collateral:.2}");
// The gap is what the bank loses on the swap when the issuer defaults and the
// bond's recovery does not cover it. Its charge is the sum, over default
// dates, of the default probability times the gap expected on that date,
// discounted to today.
let factors_of = AnyInstrument::RepackNote(Box::new(bank.clone()));
let charge_on = |market: &MarketContext| -> Outcome<_> {
let lifted = market.lift::<f64>();
let view = market.view(&lifted)?;
let resolved = view.resolve(&factors_of.risk_factors()?)?;
Ok(repack_gap(&bank, &resolved)?)
};
let gap = charge_on(&market)?;
println!("default dates {}", gap.rows.len());
println!("gap charge {:.2}", gap.charge);
// The bank is paid from the bond's recovery first. The more the bond recovers,
// the less of the swap is left unpaid, and at a high enough recovery the gap
// is gone.
let recoveries = [0.02, 0.05, 0.10, 0.40];
let mut charges = Vec::new();
for recovery in recoveries {
charges.push(charge_on(&market_recovering(recovery)?)?.charge);
}
for (recovery, charge) in recoveries.iter().zip(&charges) {
println!("recovery {recovery:.2} charge {charge:.2}");
}
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE, SOFR_CLOSE_DATE
ISSUER = "ACME/SeniorUnSec/MM14/EUR"
FACE = 1_000_000.0
INDEX = "EUR-EURIBOR-3M"
START = dt.date(2026, 10, 5)
def bond(issuer=None):
plain = hlx.products.fixed_rate_bond(
"EUR", START, dt.date(2031, 10, 5), coupon=0.035, frequency="Annual", day_count="30/360", face=FACE
)
return hlx.products.callable_bond(plain, [], issuer=issuer)
# The market: the EUR close, plus what no recipe builds — the issuer's credit,
# and the Hull-White parameters the gap's swap exposure is read under.
def market_recovering(recovery):
return (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_recipe_quotes("EUR", EUR_CLOSE)
.with_credit_curve(ISSUER, hlx.curves.SurvivalCurve.flat_hazard(SOFR_CLOSE_DATE, 0.02), recovery)
.with_hull_white("EUR", 0.03, 0.01)
)
market = market_recovering(0.05)
swap = hlx.products.asset_swap(bond(), START, INDEX, "3M", notional=FACE, dirty_price=100.0, spread=0.0060)
bank = hlx.products.repack_note(swap, ISSUER, notional=FACE)
notes = hlx.products.repack_note(swap, ISSUER, notional=FACE, repack_role=hlx.products.RepackRole.Noteholder)
print(f"bank side {bank.npv(market):.2f}")
print(f"one note {notes.npv(market):.6f}")
# The bank and all the notes together hold the bond, and nothing else.
book = hlx.Book().add("SWAP", bank, 1.0).add("NOTES", notes, FACE)
book.attach_market(market)
held = book.npv(as_of=SOFR_CLOSE_DATE)
collateral = bond(issuer=ISSUER).npv(market)
print(f"bank + notes {held:.2f}")
print(f"collateral {collateral:.2f}")
# The gap is what the bank loses on the swap when the issuer defaults and the
# bond's recovery does not cover it, summed over default dates.
gap = hlx.credit.repack_gap(bank, market)
rows = gap.rows().to_dict()
print(f"default dates {len(rows['date'])}")
print(f"gap charge {gap.charge():.2f}")
# The bank is paid from the bond's recovery first, so the more the bond
# recovers, the smaller the gap.
recoveries = (0.02, 0.05, 0.10, 0.40)
charges = [hlx.credit.repack_gap(bank, market_recovering(r)).charge() for r in recoveries]
for r, charge in zip(recoveries, charges):
print(f"recovery {r:.2f} charge {charge:.2f}")bank side 26190.12
one note 0.945024
bank + notes 971213.75
collateral 971213.75
default dates 20
gap charge 403.15
recovery 0.02 charge 1347.11
recovery 0.05 charge 403.15
recovery 0.10 charge 27.38
recovery 0.40 charge 0.00
The bank's side plus every note is the credit-risky bond, to the cent. The gap charge sums each default date's probability times the gap expected then, discounted. The more the bond recovers, the less of the swap is left unpaid, and at a 40% recovery the gap is gone.
How it's priced
With the bank's swap , notes , and over default dates the probability of a default in the period, the gap expected then and the discount factor :
How the flows run through the vehicle, and where the gap comes from, is covered in how bonds work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
swap |
asset swap Instrument |
required | the bond packaged against the floating index, from products.asset_swap |
issuer |
str |
required | the bond issuer's credit key, which the market needs a credit curve for |
notional |
float |
required | the notes' total face |
repack_role |
products.RepackRole or str |
"swap_counterparty" |
which side you book: the bank as swap counterparty, or RepackRole.Noteholder, one note per unit of face |
engine |
products.RepackEngine or str |
"independent" |
how the gap is priced: Independent of rates, or Correlated through the issuer's Gaussian intensity, which needs its intensity parameters |
trigger |
products.ValuationTrigger |
None |
an optional valuation trigger |
products.repack_note(swap, issuer, *, notional, repack_role, engine, trigger) books it; in Rust,
RepackNote::new(swap, issuer, notional)?, with .with_role(RepackRole::Noteholder) for the
notes. credit.repack_gap(note, market) prices the gap.
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| , | the note from either side | RepackNote::new(..)?.with_role(..) |
products.repack_note(.., repack_role=..) |
| the bond, tagged to its issuer | CallableBond::issued_by(credit) |
products.callable_bond(.., issuer=..) |
|
| , , | each default date's row | repack_gap(¬e, &resolved)?.rows |
credit.repack_gap(note, market).rows() |
| charge | the sum of the rows | GapCharge::charge |
GapCharge.charge() |
Defaults and limits
- Market inputs: the currency's curves, the issuer's credit curve with its recovery, and Hull-White parameters for the currency, which the swap's exposure at each default date is read under.
- Recovery: the bank is paid from the bond's recovery first, so the charge falls as recovery rises.
- Rows: each row of
rows()carriesdate,default_probability,expected_gapandcontribution. - Monte Carlo: the gap can also be simulated, with crises and a valuation trigger, through
simulate_repack_gap; this page shows the analytic charge.
See also
- Source:
crates/helixrs/src/instruments/repack.rs,crates/helixrs/src/models/repack_gap.rs - Related pages:
- How bonds work, for the structure and the gap.
- Asset swap, for the swap inside the vehicle.
- Survival curves, for the issuer's default probabilities.
- Hull-White, for the model the swap's exposure is read under.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.