Floating-rate note
This page lists what you need to price a floating-rate note: the constructor, the coupon table it reports, its value off two curves, the discount and simple margins it trades on, its spread duration, and an example you can run. A floating-rate note pays a term index plus a fixed margin each period, and repays its face at maturity.
Quick example
The example reads its quotes from the desk sheets; load them once and it runs as shown.
This books five years of quarterly coupons on EURIBOR 3M plus 50 basis points, issued on 17 December 2025 and valued on 2 March 2026, so its first coupon's index has already fixed, at 2.05%, for a 2.55% coupon. It reads that coupon and the accrued, values the note on a EUR close, finds the margins that reprice a 99.20 clean quote, and reads the spread duration.
use chrono::NaiveDate;
use helixio_docs_sheets::EUR_CLOSE_MARCH;
use helixrs::{
AnyIndex, AnyInstrument, CollateralSpec, Column, Currency, Curve, DayCountFraction, FixingKey,
FixingStore, FlatSpread, FloatingRateNote, IborIndex, MarketContext, PricingRiskFactors,
QuoteSheet, Records, YieldCurve, discount_margin, index, instrument_cashflow_records,
spread_duration,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
const INDEX: &str = "EUR-EURIBOR-3M";
const MARGIN: f64 = 0.0050;
const FIXING: f64 = 0.0205;
fn euribor() -> IborIndex {
match index(INDEX) {
Some(AnyIndex::Ibor(index)) => index,
_ => panic!("a registered term index"),
}
}
fn note_with(security_spread: Option<&str>) -> AnyInstrument {
let mut note = FloatingRateNote::new(
&euribor(),
date(2025, 12, 17),
date(2030, 12, 17),
MARGIN,
100.0,
);
if let Some(key) = security_spread {
note = note.spread_by(key);
}
AnyInstrument::FloatingRateNote(Box::new(note))
}
fn npv(note: &AnyInstrument, market: &MarketContext) -> f64 {
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
view.npv(note).expect("a priced note")
}
fn numbers(records: &Records, name: &str) -> Vec<f64> {
match records.iter().find(|(n, _)| *n == name) {
Some((_, Column::Number(values))) => values.clone(),
_ => panic!("a number column"),
}
}
fn dates(records: &Records, name: &str) -> Vec<Option<NaiveDate>> {
match records.iter().find(|(n, _)| *n == name) {
Some((_, Column::Date(values))) => values.clone(),
_ => panic!("a date column"),
}
}
fn texts(records: &Records, name: &str) -> Vec<String> {
match records.iter().find(|(n, _)| *n == name) {
Some((_, Column::Text(values))) => values.clone(),
_ => panic!("a text column"),
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let valuation = date(2026, 3, 2);
let maturity = date(2030, 12, 17);
let eur = Currency::new("EUR").expect("a registered currency");
let fixings =
FixingStore::from_observations_checked(&[(FixingKey::from(INDEX), date(2025, 12, 15), FIXING)])?;
let sheet = QuoteSheet::from_rows(EUR_CLOSE_MARCH.iter().copied())?;
let market = MarketContext::as_of(valuation)
.with_fixings(fixings)
.with_recipe_quotes(&CollateralSpec::Currency(eur), &sheet)?;
let note = note_with(None);
let lifted = market.lift::<f64>();
let view = market.view(&lifted)?;
let AnyInstrument::FloatingRateNote(frn) = ¬e else {
unreachable!("built as a note")
};
let resolved = view.resolve(&frn.risk_factors().expect("a note declares its curves"))?;
// The running coupon fixed two business days before it started. Its rate
// is the published fixing plus the margin, whatever the curves now say.
let rows = instrument_cashflow_records(¬e, &resolved)?;
let (rate, amount, pv) = (numbers(&rows, "rate"), numbers(&rows, "amount"), numbers(&rows, "pv"));
let accrual_start = dates(&rows, "accrual_start");
let fixed_rate = rate[0];
let start = accrual_start[0].expect("a coupon accrues");
let accrued = fixed_rate * DayCountFraction::Act360.year_fraction(start, valuation) * 100.0;
println!(
"period {} to {}",
start,
dates(&rows, "accrual_end")[0].expect("a coupon accrues")
);
println!("fixed on {}", dates(&rows, "fixing_date")[0].expect("a fixed coupon"));
println!("coupon rate {:.4}%", fixed_rate * 100.0);
println!("coupon {:.6}", amount[0]);
println!("accrued now {accrued:.6}");
// Every later coupon projects the index's forward over its own window, adds
// the margin, and is discounted on the other curve.
let value = npv(¬e, &market);
for k in 1..4 {
println!(
"{} rate {:.6}% pv {:.6}",
accrual_start[k].expect("a coupon accrues"),
rate[k] * 100.0,
pv[k]
);
}
println!("coupons {}, then the face", amount.len() - 1);
println!("value {value:.6}");
// The note trades at 99.20 clean. The discount margin is the flat spread on
// the discount curve that reprices that quote; the simple margin skips the
// curves and spreads the pull to par over the remaining years.
let quoted_clean = 99.20;
let dm = discount_margin(¬e, &market, quoted_clean)?;
let sm = frn.simple_margin(quoted_clean, valuation);
println!("quoted clean {quoted_clean:.2}");
println!("discount margin {:.4} bp", dm * 10_000.0);
println!("simple margin {:.4} bp", sm * 10_000.0);
// A floater's rate risk resets every quarter; its spread risk runs to
// maturity.
let sd = spread_duration(¬e, &market)?;
println!("spread duration {sd:.6}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import EUR_CLOSE_MARCH
VALUATION = dt.date(2026, 3, 2)
INDEX = "EUR-EURIBOR-3M"
MARGIN = 0.0050
MATURITY = dt.date(2030, 12, 17)
FIXING = 0.0205
ACT360 = hlx.dates.DayCountFraction.Act360
# A EUR close on 2 March 2026: €STR swaps discount, and EURIBOR 3M swaps project.
def note_with(security_spread=None):
return hlx.products.floating_rate_note(
INDEX, dt.date(2025, 12, 17), MATURITY, margin=MARGIN, face=100.0,
security_spread=security_spread,
)
MARKET = (
hlx.MarketContext(VALUATION)
.with_fixings([(INDEX, dt.date(2025, 12, 15), FIXING)])
.with_recipe_quotes("EUR", EUR_CLOSE_MARCH)
)
NOTE = note_with()
# The running coupon fixed two business days before it started. Its rate is
# the published fixing plus the margin, whatever the curves now say.
rows = NOTE.cashflow_records(MARKET).to_dict()
fixed_rate = rows["rate"][0]
accrued = fixed_rate * ACT360.year_fraction(rows["accrual_start"][0], VALUATION) * 100.0
print(f"period {rows['accrual_start'][0]} to {rows['accrual_end'][0]}")
print(f"fixed on {rows['fixing_date'][0]}")
print(f"coupon rate {fixed_rate * 100:.4f}%")
print(f"coupon {rows['amount'][0]:.6f}")
print(f"accrued now {accrued:.6f}")
# Every later coupon projects the index's forward over its own window, adds
# the margin, and is discounted on the other curve.
value = NOTE.npv(MARKET)
for k in range(1, 4):
print(f"{rows['accrual_start'][k]} rate {rows['rate'][k] * 100:.6f}% pv {rows['pv'][k]:.6f}")
print(f"coupons {len(rows['kind']) - 1}, then the face")
print(f"value {value:.6f}")
# The note trades at 99.20 clean. The discount margin is the flat spread on
# the discount curve that reprices that quote; the simple margin skips the
# curves and spreads the pull to par over the remaining years.
quoted_clean = 99.20
dm = hlx.bonds.discount_margin(NOTE, MARKET, quoted_clean)
sm = hlx.bonds.simple_margin(NOTE, quoted_clean, VALUATION)
print(f"quoted clean {quoted_clean:.2f}")
print(f"discount margin {dm * 10_000:.4f} bp")
print(f"simple margin {sm * 10_000:.4f} bp")
# A floater's rate risk resets every quarter; its spread risk runs to maturity.
sd = hlx.bonds.spread_duration(NOTE, MARKET)
print(f"spread duration {sd:.6f}")period 2025-12-17 to 2026-03-17
fixed on 2025-12-15
coupon rate 2.5500%
coupon 0.637500
accrued now 0.531250
2026-03-17 rate 2.798724% pv 0.710619
2026-06-17 rate 2.798724% pv 0.706679
2026-09-17 rate 2.798651% pv 0.695145
coupons 20, then the face
value 103.332291
quoted clean 99.20
discount margin 79.3512 bp
simple margin 66.4477 bp
spread duration 4.473866
The running coupon pays the published fixing plus the margin; every later coupon projects the EURIBOR forward instead. The note pays 50 basis points over an index that projects above the €STR discount curve, so with its accrued it's worth more than par. A 99.20 quote sits below that value, so it needs a positive discount margin to reach it. The spread duration is close to the note's remaining life.
How it's priced
Each coupon pays the index rate plus the margin over its accrual fraction; the value is every flow at its discount factor; and the discount margin is the flat spread that reprices a clean quote , the projected coupons held fixed:
The simple margin is over the years left, and spread duration is for a flat spread . Why a floater's rate risk resets while its spread risk doesn't is covered in how bonds work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
index |
str |
required | the term index the coupons fix on, such as "EUR-EURIBOR-3M" |
issue, maturity |
date |
required | the first accrual start and the redemption |
margin |
float |
required | the quoted margin over the index |
face |
float |
required | the face per note |
security_spread |
str |
None |
a key whose spread curve the note discounts under, for bumping or marking a spread |
issuer |
str |
None |
tags the issuer, so a credit curve values the note risky |
gearing |
float |
None, a gearing of 1 |
a multiplier on the index rate |
cap, floor |
float |
None |
bounds on each coupon's rate |
outstanding |
list[float] |
None, a bullet |
the fraction of face outstanding in each period, starting at 1 and never rising |
day_count, accrual_calendar, business_day_convention |
str |
the index's | the note's own accrual terms where they differ from the index's; fixing dates stay the index's |
projection |
str |
"index_tenor" |
the window each coupon forwards over: "index_tenor" or "accrual_period" |
products.floating_rate_note(index, issue, maturity, *, margin, face, ...) builds the
note; in Rust, FloatingRateNote::new(&index, issue, maturity, margin, face), with
.spread_by(key) for a security spread.
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| the period's index rate | IborIndex::forecast(&curve, start), or the stored fixing |
Index.forecast(curve, start), or MarketContext.with_fixings |
|
| , | each flow and its accrual | instrument_cashflow_records(¬e, &resolved) |
Instrument.cashflow_records(market) |
| value against the market | MarketView::npv on AnyInstrument::FloatingRateNote |
Instrument.npv |
|
| discount margin | discount_margin(¬e, &market, clean) |
bonds.discount_margin(note, market, clean) |
|
| simple margin | FloatingRateNote::simple_margin(clean, settlement) |
bonds.simple_margin(note, clean, settlement) |
|
| spread duration | spread_duration(¬e, &market) |
bonds.spread_duration(note, market) |
|
| a flat security spread | MarketContext::with_security_spread(key, curve) |
MarketContext.with_security_spread(key, curve) |
Defaults and limits
- Market inputs: the currency's discount curve, the index's projection curve, and the fixing of
any coupon whose fixing date is on or before valuation.
fixings_needed(valuation)lists those fixings; a market without one refuses the valuation and names it. - The running coupon pays the stored fixing plus the margin, whatever the curves now say.
- The accrued isn't returned by the note: it's the running rate times the year fraction since the period started, on the index's day count.
- Units: per 100 face, like the quote.
See also
- Source:
crates/helixrs/src/instruments/bonds.rs - Related pages:
- How bonds work, for discount margin, simple margin and spread duration.
- Cashflow records, for every column of the coupon table.
- Fixings: prints and projections, for how a stored fixing meets a projected rate.
- Spread measures, for spreads on fixed-rate bonds.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.