Inflation-linked bond
This page lists what you need to price an inflation-linked bond: the constructor and its options, the index ratio, the value off an inflation curve, the real price and yield, the settlement invoice, and examples you can run. A linker pays a fixed real coupon on a principal that grows with a consumer price index.
Quick example
The example reads its swap quotes from the desk sheets; load them once and it runs as shown.
This takes a ten-year TIPS-style linker paying a 1.875% real coupon semiannually, indexed to US CPI-U with a three-month lag from a base of 320, on 2 March 2026. The December and January prints and a rising CPI curve are stated in the file. It reads the index ratio today and in July, values the linker, moves between its real price and real yield, and works out the settlement invoice for a real quote.
use chrono::{Datelike, NaiveDate};
use helixio_docs_sheets::USD_CLOSE_MARCH;
use helixrs::LinkerConvention;
use helixrs::{
AnyInstrument, CollateralSpec, Currency, DayCountFraction, FixedRateBond, FixingKey,
FixingStore, Frequency, Inflation, InflationCurve, InflationLinkedBond, LevelInterpolation,
MarketContext, PiecewiseInflation, PriceBasis, PricingRiskFactors, QuoteSheet, YieldBasis,
YieldCurve, dirty_price, invoice, real_price, street_index_ratio,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
const INDEX: &str = "US-CPI-U";
const BASE: f64 = 320.0;
const REAL_COUPON: f64 = 0.01875;
const DECEMBER: f64 = 324.0;
const JANUARY: f64 = 325.2;
fn inflation_on(reference: NaiveDate, nodes: &[(NaiveDate, f64)]) -> Inflation {
Inflation::Piecewise(
PiecewiseInflation::new(
reference,
nodes,
LevelInterpolation::Linear,
DayCountFraction::Act365Fixed,
)
.expect("well-formed nodes"),
)
}
fn linker(floored: bool) -> InflationLinkedBond {
InflationLinkedBond::new(
Currency::new("USD").expect("a registered currency"),
INDEX,
BASE,
REAL_COUPON,
3,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
date(2025, 7, 15),
date(2035, 7, 15),
)
.expect("a well-formed linker")
.with_redemption_floor(floored)
}
fn market_on(nominal_market: &MarketContext, cpi: Inflation) -> MarketContext {
let fixings = FixingStore::from_observations_checked(&[
(FixingKey::from(INDEX), date(2025, 12, 1), DECEMBER),
(FixingKey::from(INDEX), date(2026, 1, 1), JANUARY),
])
.expect("a registered index");
nominal_market
.clone()
.with_inflation_curve(INDEX, cpi)
.with_fixings(fixings)
}
fn npv(bond: &InflationLinkedBond, market: &MarketContext) -> f64 {
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
view.npv(&AnyInstrument::InflationLinkedBond(Box::new(bond.clone())))
.expect("a priced linker")
}
/// The pricing index ratio at `at`, read through the market the bond resolves.
fn index_ratio(bond: &InflationLinkedBond, market: &MarketContext, at: NaiveDate) -> f64 {
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
let resolved = view
.resolve(&bond.risk_factors().expect("a linker declares its curves"))
.expect("the market carries its curves");
bond.index_ratio(&resolved, at)
.expect("an observable index")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let valuation = date(2026, 3, 2);
// The nominal market, marked from the morning's SOFR swaps. The CPI curve
// joins it with the market's own verb.
let usd = Currency::new("USD").expect("a registered currency");
let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
let nominal_market = MarketContext::as_of(valuation)
.with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)
.expect("a marked market");
let rising = inflation_on(
valuation,
&[
(date(2026, 4, 1), 326.0),
(date(2031, 4, 1), 360.0),
(date(2036, 4, 1), 398.0),
],
);
let market = market_on(&nominal_market, rising.clone());
let bond = linker(true);
let tips: LinkerConvention = "tips".parse().expect("a linker street");
// The index ratio on a day in March reads December and January, three
// months back, interpolated by the day of the month. A flow in July reads
// April and May, which aren't published yet, so the inflation curve
// supplies them.
let ratio = index_ratio(&bond, &market, valuation);
let july = date(2026, 7, 15);
let july_ratio = index_ratio(&bond, &market, july);
println!("index ratio {valuation} {ratio:.6}");
println!("index ratio {july} {july_ratio:.6}");
// Each real coupon and the real redemption is scaled by the ratio at its
// date and discounted on the nominal curve.
let value = npv(&bond, &market);
println!("value {value:.6}");
// A TIPS quote is a real price at a real yield on the uninflated flows,
// compounded semiannually. The index never enters it.
let real_yield = 0.0180;
let real_clean = real_price(&bond, tips, real_yield, valuation, PriceBasis::Clean)?;
let real_dirty = real_price(&bond, tips, real_yield, valuation, PriceBasis::Dirty)?;
let yield_back = helixrs::real_yield(&bond, tips, real_clean, valuation)?;
println!("real yield {:.4}%", real_yield * 100.0);
println!("real clean {real_clean:.6}");
println!("real dirty {real_dirty:.6}");
println!("yield back {:.10}%", yield_back * 100.0);
// Settlement inflates the real quote by the street index ratio: TIPS
// rounds the reference index and the ratio to five decimals.
let lifted = market.lift::<f64>();
let view = market.view(&lifted)?;
let resolved = view.resolve(&bond.risk_factors()?)?;
let curve = resolved.inflation.as_ref().expect("an inflation curve");
let invoiced = invoice(&bond, tips, real_clean, valuation, &resolved.fixings, curve)?;
let dirty = invoiced.dirty;
println!("street ratio {:.5}", invoiced.index_ratio);
println!("clean {:.6}", invoiced.clean);
println!("accrued {:.6}", invoiced.accrued);
println!("dirty {dirty:.6}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import USD_CLOSE_MARCH
VALUATION = dt.date(2026, 3, 2)
INDEX = "US-CPI-U"
BASE = 320.0
REAL_COUPON = 0.01875
ISSUE = dt.date(2025, 7, 15)
MATURITY = dt.date(2035, 7, 15)
DECEMBER, JANUARY = 324.0, 325.2
# The nominal market, marked from the morning's SOFR swaps. The CPI curve joins
# it with the market's own verb, below.
NOMINAL = hlx.MarketContext(VALUATION).with_recipe_quotes("USD", USD_CLOSE_MARCH)
def linker(floored=True, outstanding=None):
return hlx.products.inflation_linked_bond(
"USD", INDEX, base_index=BASE, real_coupon=REAL_COUPON, lag_months=3, frequency=hlx.dates.Frequency.SemiAnnual,
day_count=hlx.dates.DayCountFraction.ActActIcma, face=100.0, issue=ISSUE, maturity=MATURITY,
redemption_floor=floored, outstanding=outstanding,
)
def market_on(cpi):
return (
NOMINAL
.with_inflation_curve(INDEX, cpi)
.with_fixings([(INDEX, dt.date(2025, 12, 1), DECEMBER), (INDEX, dt.date(2026, 1, 1), JANUARY)])
)
RISING = hlx.curves.InflationCurve(
VALUATION, [(dt.date(2026, 4, 1), 326.0), (dt.date(2031, 4, 1), 360.0), (dt.date(2036, 4, 1), 398.0)]
)
MARKET = market_on(RISING)
BOND = linker()
# The index ratio on a day in March reads December and January, three months
# back, interpolated by the day of the month. A flow in July reads April and
# May, which aren't published yet, so the inflation curve supplies them.
ratio = hlx.linker.index_ratio(BOND, MARKET, VALUATION)
july = dt.date(2026, 7, 15)
july_ratio = hlx.linker.index_ratio(BOND, MARKET, july)
print(f"index ratio {VALUATION} {ratio:.6f}")
print(f"index ratio {july} {july_ratio:.6f}")
# Each real coupon and the real redemption is scaled by the ratio at its date
# and discounted on the nominal curve.
value = BOND.npv(MARKET)
print(f"value {value:.6f}")
# A TIPS quote is a real price at a real yield on the uninflated flows,
# compounded semiannually. The index never enters it.
real_yield = 0.0180
real_clean = hlx.linker.real_price(BOND, "tips", real_yield, VALUATION)
real_dirty = hlx.linker.real_price(BOND, "tips", real_yield, VALUATION, hlx.bonds.PriceBasis.Dirty)
yield_back = hlx.linker.real_yield(BOND, "tips", real_clean, VALUATION)
print(f"real yield {real_yield * 100:.4f}%")
print(f"real clean {real_clean:.6f}")
print(f"real dirty {real_dirty:.6f}")
print(f"yield back {yield_back * 100:.10f}%")
# Settlement inflates the real quote by the street index ratio: TIPS rounds
# the reference index and the ratio to five decimals.
invoice_ratio, clean, accrued, dirty = hlx.linker.invoice(BOND, MARKET, "tips", real_clean, VALUATION)
print(f"street ratio {invoice_ratio:.5f}")
print(f"clean {clean:.6f}")
print(f"accrued {accrued:.6f}")
print(f"dirty {dirty:.6f}")index ratio 2026-03-02 1.012621
index ratio 2026-07-15 1.019538
value 99.056828
real yield 1.8000%
real clean 100.643416
real dirty 100.881675
yield back 1.8000000000%
street ratio 1.01262
clean 101.913536
accrued 0.241267
dirty 102.154802
Today's ratio interpolates the December and January prints; July's reads April and May off the curve, since they aren't published yet. The real price and yield never touch the index. The invoice inflates the real clean price and the real accrued by the street ratio, which TIPS rounds to five decimals.
How it's priced
With and the two monthly levels a date sits between, after the lag, and each real flow :
With a floor the redemption is . Why the quote is real and the cash nominal, and how the lag works, is covered in how bonds work.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
currency, index |
str, str |
required | the bond's currency and its price index, such as "US-CPI-U" |
base_index |
float |
required | the index level the ratio divides by |
real_coupon |
float |
required | the real coupon rate |
lag_months |
int |
required | the observation lag, three for TIPS |
frequency, day_count |
dates.Frequency, dates.DayCountFraction |
required | the coupon schedule and accrual |
face, issue, maturity |
float, date, date |
required | the face and the dates |
redemption_floor |
bool |
None, floored |
repays at least the face, even after deflation; False drops the floor on the redemption and on every paydown |
outstanding |
list[float] |
None, a bullet |
the fraction of face alive in each period |
index_interpolation |
str |
the index's registered convention | "Month" reads the lagged month alone; "Interpolated" blends it with the next by the day of the month |
issuer, security_spread |
str |
None |
the issuer's credit, or a spread key on the discounting |
payment_calendar, payment_convention |
str |
None, "following" |
how payment dates roll |
accrual_calendar, business_day_convention |
str |
None, "following" |
how accrual dates roll |
products.inflation_linked_bond(currency, index, *, base_index, real_coupon, ...) books it; in
Rust, InflationLinkedBond::new(..)?, with .with_redemption_floor, .with_amortisation and
.with_interpolation.
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| index ratio | InflationLinkedBond::index_ratio(&resolved, d) |
linker.index_ratio(bond, market, d) |
|
| value against the market | MarketView::npv on AnyInstrument::InflationLinkedBond |
Instrument.npv |
|
| real price at a real yield | real_price(&bond, street, y_r, s, basis)? |
linker.real_price(bond, "tips", y_r, s, basis) |
|
| real yield from a real clean price | real_yield(&bond, street, clean, s)? |
linker.real_yield(bond, "tips", clean, s) |
|
| street index ratio | street_index_ratio(&bond, street, s, &fixings, curve)? |
linker.street_index_ratio(bond, market, "tips", s) |
|
| the invoice | street ratio, clean, accrued, dirty | invoice(&bond, street, clean, s, &fixings, curve)? |
linker.invoice(bond, market, "tips", clean, s) |
Defaults and limits
- Market inputs: the currency's discount curve, an inflation curve for the index
(
MarketContext.with_inflation_curve) for months not yet published, and the published prints as fixings (MarketContext.with_fixings). - Streets:
"tips"is the US street; each market's rounding of the reference index and the ratio is in bond conventions. - The floor is valued at its intrinsic amount on the projected index.
- Amortising linkers: each coupon is indexed on the face alive and each principal slice at its own date's ratio, floored at par. The UK money price and yield, the Brazilian price and the Australian price are defined on a bullet, and are refused for a linker that amortises.
More examples
The same linker under a falling CPI curve with and without its floor, repaying half its face in July 2030, and observing the index by month or interpolated.
use helixrs::statics::indices::price::IndexInterpolation;
use chrono::{Datelike, NaiveDate};
use helixio_docs_sheets::USD_CLOSE_MARCH;
use helixrs::LinkerConvention;
use helixrs::{
AnyInstrument, CollateralSpec, Currency, DayCountFraction, FixedRateBond, FixingKey,
FixingStore, Frequency, Inflation, InflationCurve, InflationLinkedBond, LevelInterpolation,
MarketContext, PiecewiseInflation, PriceBasis, PricingRiskFactors, QuoteSheet, YieldBasis,
YieldCurve, dirty_price, invoice, real_price, street_index_ratio,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
const INDEX: &str = "US-CPI-U";
const BASE: f64 = 320.0;
const REAL_COUPON: f64 = 0.01875;
const DECEMBER: f64 = 324.0;
const JANUARY: f64 = 325.2;
fn inflation_on(reference: NaiveDate, nodes: &[(NaiveDate, f64)]) -> Inflation {
Inflation::Piecewise(
PiecewiseInflation::new(
reference,
nodes,
LevelInterpolation::Linear,
DayCountFraction::Act365Fixed,
)
.expect("well-formed nodes"),
)
}
fn linker(floored: bool) -> InflationLinkedBond {
InflationLinkedBond::new(
Currency::new("USD").expect("a registered currency"),
INDEX,
BASE,
REAL_COUPON,
3,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
date(2025, 7, 15),
date(2035, 7, 15),
)
.expect("a well-formed linker")
.with_redemption_floor(floored)
}
fn market_on(nominal_market: &MarketContext, cpi: Inflation) -> MarketContext {
let fixings = FixingStore::from_observations_checked(&[
(FixingKey::from(INDEX), date(2025, 12, 1), DECEMBER),
(FixingKey::from(INDEX), date(2026, 1, 1), JANUARY),
])
.expect("a registered index");
nominal_market
.clone()
.with_inflation_curve(INDEX, cpi)
.with_fixings(fixings)
}
fn npv(bond: &InflationLinkedBond, market: &MarketContext) -> f64 {
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
view.npv(&AnyInstrument::InflationLinkedBond(Box::new(bond.clone())))
.expect("a priced linker")
}
/// The pricing index ratio at `at`, read through the market the bond resolves.
fn index_ratio(bond: &InflationLinkedBond, market: &MarketContext, at: NaiveDate) -> f64 {
let lifted = market.lift::<f64>();
let view = market.view(&lifted).expect("a market that binds");
let resolved = view
.resolve(&bond.risk_factors().expect("a linker declares its curves"))
.expect("the market carries its curves");
bond.index_ratio(&resolved, at)
.expect("an observable index")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let valuation = date(2026, 3, 2);
let maturity = date(2035, 7, 15);
// The nominal market, marked from the morning's SOFR swaps. The CPI curve
// joins it with the market's own verb.
let usd = Currency::new("USD").expect("a registered currency");
let sheet = QuoteSheet::from_rows(USD_CLOSE_MARCH.iter().copied()).expect("a sheet");
let nominal_market = MarketContext::as_of(valuation)
.with_recipe_quotes(&CollateralSpec::Currency(usd), &sheet)
.expect("a marked market");
let rising = inflation_on(
valuation,
&[
(date(2026, 4, 1), 326.0),
(date(2031, 4, 1), 360.0),
(date(2036, 4, 1), 398.0),
],
);
let market = market_on(&nominal_market, rising.clone());
let bond = linker(true);
// If prices fall below the base, the floor still repays the face at par.
let falling = inflation_on(valuation, &[(date(2026, 4, 1), 326.0), (date(2036, 4, 1), 300.0)]);
let deflating = market_on(&nominal_market, falling);
let floored = npv(&linker(true), &deflating);
let unfloored = npv(&linker(false), &deflating);
let final_ratio = index_ratio(&bond, &deflating, maturity);
println!("final ratio {final_ratio:.6}");
println!("floored {floored:.6}");
println!("unfloored {unfloored:.6}");
println!("floor worth {:.6}", floored - unfloored);
// The same linker repaying half its face on 15 July 2030. Each coupon is
// indexed on the face alive in its period, and each principal payment at
// its own date's ratio, floored at par.
let mut profile = vec![1.0; 10];
profile.extend(vec![0.5; 10]);
let sinking = linker(true).with_amortisation(profile.clone())?;
let sinking_value = npv(&sinking, &market);
let sinking_deflating = npv(&sinking, &deflating);
println!("rising prices {sinking_value:.6}");
println!("falling prices {sinking_deflating:.6}");
// How each index ratio observes the index inside its month: "Month" reads
// the lagged reference month's print alone; "Interpolated" blends it with
// the next month by the day of the month. US CPI's registered convention
// is interpolated, so stating it changes nothing.
let flat = npv(&linker(true).with_interpolation(IndexInterpolation::Month), &market);
let linear = npv(&linker(true).with_interpolation(IndexInterpolation::Interpolated), &market);
println!("Month {flat:.6}");
println!("Interpolated {linear:.6}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import USD_CLOSE_MARCH
VALUATION = dt.date(2026, 3, 2)
INDEX = "US-CPI-U"
BASE = 320.0
REAL_COUPON = 0.01875
ISSUE = dt.date(2025, 7, 15)
MATURITY = dt.date(2035, 7, 15)
DECEMBER, JANUARY = 324.0, 325.2
# The nominal market, marked from the morning's SOFR swaps. The CPI curve joins
# it with the market's own verb, below.
NOMINAL = hlx.MarketContext(VALUATION).with_recipe_quotes("USD", USD_CLOSE_MARCH)
def linker(floored=True, outstanding=None):
return hlx.products.inflation_linked_bond(
"USD", INDEX, base_index=BASE, real_coupon=REAL_COUPON, lag_months=3, frequency=hlx.dates.Frequency.SemiAnnual,
day_count=hlx.dates.DayCountFraction.ActActIcma, face=100.0, issue=ISSUE, maturity=MATURITY,
redemption_floor=floored, outstanding=outstanding,
)
def market_on(cpi):
return (
NOMINAL
.with_inflation_curve(INDEX, cpi)
.with_fixings([(INDEX, dt.date(2025, 12, 1), DECEMBER), (INDEX, dt.date(2026, 1, 1), JANUARY)])
)
RISING = hlx.curves.InflationCurve(
VALUATION, [(dt.date(2026, 4, 1), 326.0), (dt.date(2031, 4, 1), 360.0), (dt.date(2036, 4, 1), 398.0)]
)
MARKET = market_on(RISING)
BOND = linker()
# If prices fall below the base, the floor still repays the face at par.
falling = hlx.curves.InflationCurve(
VALUATION, [(dt.date(2026, 4, 1), 326.0), (dt.date(2036, 4, 1), 300.0)]
)
deflating = market_on(falling)
floored = linker(True).npv(deflating)
unfloored = linker(False).npv(deflating)
final_ratio = hlx.linker.index_ratio(BOND, deflating, MATURITY)
print(f"final ratio {final_ratio:.6f}")
print(f"floored {floored:.6f}")
print(f"unfloored {unfloored:.6f}")
print(f"floor worth {floored - unfloored:.6f}")
# The same linker repaying half its face on 15 July 2030. Each coupon is
# indexed on the face alive in its period, and each principal payment at its
# own date's ratio, floored at par.
PROFILE = [1.0] * 10 + [0.5] * 10
sinking = linker(True, PROFILE)
sinking_value = sinking.npv(MARKET)
sinking_deflating = sinking.npv(deflating)
print(f"rising prices {sinking_value:.6f}")
print(f"falling prices {sinking_deflating:.6f}")
# How each index ratio observes the index inside its month: "Month" reads the
# lagged reference month's print alone; "Interpolated" blends it with the next
# month by the day of the month. US CPI's registered convention is
# interpolated, so stating it changes nothing.
def observed(spelling):
return hlx.products.inflation_linked_bond(
"USD", INDEX, base_index=BASE, real_coupon=REAL_COUPON, lag_months=3, frequency=hlx.dates.Frequency.SemiAnnual,
day_count=hlx.dates.DayCountFraction.ActActIcma, face=100.0, issue=ISSUE, maturity=MATURITY,
redemption_floor=True, index_interpolation=spelling,
).npv(MARKET)
flat, linear = observed("Month"), observed("Interpolated")
print(f"Month {flat:.6f}")
print(f"Interpolated {linear:.6f}")final ratio 0.945339
floored 82.237179
unfloored 78.534814
floor worth 3.702365
rising prices 100.143061
falling prices 87.105630
Month 98.984796
Interpolated 99.056828
Under deflation the final ratio is below one, and the floor is worth the shortfall on the face, discounted from maturity. The amortising linker is worth less as prices fall. US CPI's registered convention is interpolated, so stating it changes nothing, and reading by month gives a different value.
See also
- Source:
crates/helixrs/src/instruments/bonds/linker.rs - Related pages:
- How bonds work, for real quotes, the lag and the floor.
- Inflation curves, for the curve that projects the index.
- Inflation calibration, for solving one from swap quotes.
- Inflation swaps, for the swaps on the same index.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.