Skip to main content

tenor

A fixed-income curve toolkit, in pure Python with no dependencies.

Fixed income is a field where the arithmetic is easy and the conventions decide the answer. A bond priced on the wrong day count basis is wrong by a few basis points, which is the size of the spread anybody is trying to measure — so it is not approximately right, it is answering a different question. This library treats every convention as an argument with a name, never a default, and checks each one against the published rules rather than against itself.

All seven phases in ROADMAP.md are built: dates, day counts, holiday calendars and payment schedules; discount curves, compounding conventions and three interpolation schemes; a bootstrapper over deposits, futures and par swaps; bond analytics — price, yield, duration, convexity and basis point values; curve risk — key rate durations, shape shifts and instrument-by-instrument risk; spreads — Z-spread, I-spread, asset swap spreads in both conventions, a calibrated short rate lattice, American exercise and option-adjusted spread; and a command line with a worked example.

What is checked, and against what

The claim above is that the numerics are checked against things outside this repository. These are the figures that says, each one produced by examples/worked.py, which runs in CI and exits non-zero if any of them moves:

Check Result
A bootstrapped curve reprices every instrument it was built from 5.6e-17 worst error, log-linear on discount factors
The same, under monotone convex interpolation 3.2e-16, after 5 sweeps
Hull's futures convexity adjustment, 1.2% volatility at 8 years 47.52 bp against his 47.5
The three ISDA 4.16 conventions all called 30/360, over one period 183, 182 and 180 days
Z-spread of a bond priced on its own curve zero to within 1e-8 bp; prints below 1e-12
Option-adjusted spread of a callable priced on its own curve zero to within 1e-8 bp; prints below 1e-12
Macaulay duration at a 6% yield, against the closed form 7.89499734
Premium leg of a contract already running, against a midpoint rule inside 1e-10 relative; prints 9.1e-13, falling as the square of the step
Protection before the valuation date, priced from inside the schedule zero, to the last bit, against the same contract struck today
A settlement amount against its own split into principal and accrued zero
A defaultable bond's forward price, against the riskless one at no hazard inside 1e-13, at the reference date and seven years out

The two spread rows are the ones worth looking at twice. A spread measured against the curve a bond was priced on has to be zero, and "a plausibly small number" is exactly what a subtly wrong implementation returns. These come back at the size of floating-point noise, which is a different statement.

They are quoted as bounds rather than digits on purpose. The last bits of a root-found quantity are not reproducible across interpreters — the Z-spread above comes back at -8.3e-14 on CPython 3.12 and +1.3e-13 on 3.11 — so a figure quoted to ten significant digits would be a number that happens to be true where it was measured. The bound is the claim, and it is the bound the worked example enforces.

python examples/worked.py     # all nine sections, non-zero if a figure moves

Behind them: 1989 tests, run on Python 3.10 through 3.13.

Installing

pip install tenor-curves

The distribution is tenor-curves and the import is tenor. The two differ because tenor on the index is an unrelated project that claimed the name and published nothing; the module, the command and every identifier are unchanged, and tenor-curves also works as a command so that uvx tenor-curves resolves.

Not on the package index yet. The pip install line above is what it will be; until the first release lands, install from source:

pip install "git+https://github.com/DaniyalMlk/tenor.git"

Python 3.10 or newer. The library imports only the standard library, so there is nothing else to resolve and nothing to compile.

Using it

from datetime import date
from tenor import Basis, year_fraction

year_fraction(date(2021, 2, 28), date(2021, 8, 31), Basis.ACT_365F)          # 0.504110
year_fraction(date(2021, 2, 28), date(2021, 8, 31), Basis.THIRTY_360_BOND)   # 0.508333
year_fraction(date(2021, 2, 28), date(2021, 8, 31), Basis.THIRTY_360_US)     # 0.500000
from tenor import Frequency, Rolling, calendar_from, generate

us = calendar_from("US", [date(2021, 7, 5), date(2021, 11, 25)])

schedule = generate(
    date(2021, 3, 10), date(2024, 8, 15), Frequency.SEMI_ANNUAL,
    calendar=us, rolling=Rolling.MODIFIED_FOLLOWING,
)

schedule.payment_dates     # business days, rolled
schedule.has_stub          # True — the first period is odd
schedule.stubs[0].stub     # Stub.SHORT_FIRST
schedule[0].start          # unadjusted, for accrual
schedule[0].adjusted_end   # rolled, for payment
from tenor import Basis, Compounding, DiscountCurve, Interpolation

curve = DiscountCurve.from_zeros(
    date(2021, 1, 4),
    [(date(2022, 1, 4), 0.010), (date(2026, 1, 4), 0.018)],
    basis=Basis.ACT_365F,
    interpolation=Interpolation.LOG_LINEAR_DISCOUNT,
)

curve.discount(date(2024, 6, 1))
curve.zero_rate(date(2024, 6, 1), Compounding.SEMI_ANNUAL)
curve.forward_rate(date(2023, 1, 4), date(2024, 1, 4))
curve.instantaneous_forward(2.5)   # what the interpolation is really doing
curve.reprices_pillars()           # True, and asserted rather than assumed

smooth = curve.with_interpolation(Interpolation.MONOTONE_CONVEX)
smooth.instantaneous_forward(2.5)  # continuous across the pillars, and positive
smooth.monotone.segments[1].region # which of the four shapes that interval took
from tenor import Deposit, Future, Swap, bootstrap, ho_lee_convexity

built = bootstrap(
    date(2021, 1, 4),
    [
        Deposit(date(2021, 1, 4), date(2021, 7, 5), 0.0035, Basis.ACT_360),
        Future(date(2021, 9, 15), date(2021, 12, 15), 99.55, Basis.ACT_360,
               convexity=ho_lee_convexity(0.01, 0.70, 0.95)),
        Swap(date(2021, 1, 4), date(2031, 1, 6), 0.0195),
    ],
    basis=Basis.ACT_365F,
    interpolation=Interpolation.MONOTONE_CONVEX,
)

built.curve.zero_rate(date(2028, 6, 1))
built.sweeps          # how many passes it took to settle
built.worst_error     # ~1e-15: every quote still prices to par
built.reprices()      # the identity, asserted rather than assumed
built.solutions[2]    # the solve itself: iterations, bracket, residual
from tenor import Bond, Compounding

bond = Bond(date(2021, 1, 4), date(2031, 1, 4), 0.05)   # 5% of 2031

bond.accrued(date(2021, 4, 15))              # per 100, on the stated convention
bond.clean_price(0.043, date(2021, 4, 15))   # quoted price
bond.yield_from_clean(96.5, date(2021, 4, 15))  # solved, with its convergence

bond.macaulay_duration(0.043, date(2021, 1, 4))   # a time, in years
bond.modified_duration(0.043, date(2021, 1, 4))   # a sensitivity to its yield
bond.convexity(0.043, date(2021, 1, 4))           # in closed form

curve = built.curve
bond.price_from_curve(curve, date(2021, 1, 4))
bond.pv01(bond.curve_yield(curve, date(2021, 1, 4)).value, date(2021, 1, 4))
bond.dv01(curve, date(2021, 1, 4), compounding=Compounding.SEMI_ANNUAL)
bond.effective_duration(curve, date(2021, 1, 4))
from tenor import buckets_from, instrument_risk, key_rates, level, shape_duration, slope

value = lambda c: bond.price_from_curve(c, date(2021, 1, 4))

shape_duration(value, curve, level())             # the total, as one number
shape_duration(value, curve, slope(curve.times[-1]))   # response to a steepening

for one in key_rates(value, curve, buckets_from(curve, [0.5, 10])):
    one.bucket.name, one.duration, one.value      # where the exposure sits
    # (a bucket in a gap between pillars is refused rather than returned as zero)

for one in instrument_risk(value, built):
    one.name, one.value                           # what to trade against it
from tenor import FloatingNote

note = FloatingNote(
    date(2022, 1, 17), date(2029, 1, 17), quoted_margin=0.0075,
    frequency=Frequency.QUARTERLY, basis=Basis.ACT_360,
)

note.dirty_price(curve, date(2024, 1, 17), margin=0.0075)   # exactly 100.0
note.rate_duration(curve, date(2024, 1, 17), margin=0.0075) # exactly 0.0
note.spread_duration(curve, date(2024, 1, 17), margin=0.0075)      # 4.77 years
note.discount_margin(curve, 99.0, date(2024, 1, 17)).value         # 0.009608
from tenor import LinkedBond, ReferenceIndex, breakeven_inflation

index = ReferenceIndex({(2025, 6): 106.5, (2025, 7): 106.7, (2025, 8): 107.0})
index.last_known_date()                     # 2025-11-01: the lag reaches this far

linker = LinkedBond(
    date(2022, 4, 1), date(2032, 4, 1), coupon=0.015, base_index=100.5,
    frequency=Frequency.SEMI_ANNUAL, basis=Basis.ACT_ACT_ISDA,
)

linker.index_ratio(index, date(2025, 9, 15))            # 1.065498
linker.real_clean_price(0.011, date(2025, 9, 15))       # 102.5187, quoted real
linker.settlement_amount(102.5187, index, date(2025, 9, 15))   # 109.9626, money
linker.real_modified_duration(0.011, date(2025, 9, 15)) # 6.19 years

breakeven_inflation(0.0425, 0.011)                      # 0.031157, Fisher exact
breakeven_inflation(0.0425, 0.011, exact=False)         # 0.031500, as quoted

The command line

$ tenor curve examples/quotes.txt --reference 2021-01-05 --basis ACT_365F
reference: 2021-01-05
interpolation: log-linear on discount factors
sweeps: 1
worst_repricing_error: 5.551115123e-17
reprices: True
pillars:
  date=2021-07-05  years=0.49589  discount=0.9989954546  zero=0.002026758939
  ...

$ tenor option examples/quotes.txt --reference 2021-01-05 --basis ACT_365F \
      --maturity 2031-01-05 --coupon 0.05 --volatility 0.15 \
      --first-call 2026-01-05
bullet: 125.5612364
with_option: 116.7589796
option_value: 8.802256883
z_spread: 0.008904973855
oas: 1.208616557e-17
option_cost: 0.008904973855

$ tenor horizon examples/quotes.txt --reference 2021-01-05 --basis ACT_365F \
      --maturity 2031-01-05 --coupon 0.03 --horizon 2022-01-05
start_price: 107.2969819
forward_price: 104.7956417
rolled_price: 107.2879716
coupon_income: 3.005534685
forward_price_change: -2.501340175
financing_cost: 0.5041945095
carry: 0.5041945095
roll_down: 2.49232992
total_return_bps: 279.2738787
arbitrage_free: True

$ tenor floating examples/quotes.txt --reference 2021-01-05 --basis ACT_365F \
      --maturity 2026-01-05 --quoted-margin 0.0075
note: index + 75.0bp of 2026-01-05
on_reset_date: True
discount_margin: 0.0075
dirty: 100
clean: 100
spread_duration: 4.827613483
rate_duration: -7.105427358e-13
margin_value_of_a_basis_point: 0.0482635851
coupon_count: 20

$ tenor linker examples/quotes.txt --reference 2021-01-05 --basis ACT_365F \
      --index examples/cpi.txt --maturity 2031-01-05 --coupon 0.015 \
      --base-index 100.5 --issued 2019-01-05 --projection 0.02 \
      --real-yield 0.005 --nominal-yield 0.022
bond: 1.500% linker of 2031-01-05
index_published_to: 2020-11
reference_index_known_to: 2021-02-01
index_ratio: 1.050937655
real_yield: 0.005
real_clean: 109.7422441
real_modified_duration: 9.338612259
settlement_amount: 115.3322567
redemption_index_ratio: 1.281190635
deflation_floor_binds: False
inflation_implied_by_the_invoice: 0.01718356716
breakeven_exact: 0.01691542289
breakeven_quoted: 0.017
accretion_one_year: {'total': 0.01997172, 'published': 0.00143816,
                     'projected': 0.01850694, 'published_share': 0.07200994,
                     'published_through': '2021-02-01'}

--basis is required, not defaulted. Also price, risk, horizon, floating and linker; --json on any of them. Every subcommand reports the identity that goes with its numbers — the repricing error, the key rate sum against the total duration, whether the lattice reprices the curve — rather than only the numbers.

examples/worked.py runs the whole library in one pass and exits non-zero if any published figure moves.

Every command takes dates as ISO strings, and a date that is not one says which option it came from. That reads like a small thing and is not: several commands take four or five dates, and the standard library's message for a bad one names the value without naming the option, so a mistyped --effective on a command that also takes --expiry and --swap-maturity told you only that one of the three was wrong. All nineteen date options go through one helper, nothing reads a date around it, and the option names in the messages are checked against the parser rather than against a list — that string is the part that goes stale.

Design

A floater at its quoted margin has no rate risk at all

Not a little, because its coupon resets soon. None. The identity is this: with the discount margin equal to the quoted margin, each coupon is exactly its own discount denominator minus one, so the sum telescopes and the price is exactly par for any curve of any shape at any level on any day count basis. That is asserted in the tests to 5e-13 across four curve shapes, four bases and three frequencies, not to a basis point.

Measuring it showed the consequence is stronger than the textbook statement. Substituting the telescoping sum for the periods after the current one collapses the whole price to

(1 + (fixing + m) tau_1) / (1 + (fixing + m) tau_remaining)

in which no curve appears at all. So the rate duration is exactly zero on any settlement date, not only on a reset date, provided the current period's fixing is known.

What a floater has away from par is the rate sensitivity of the annuity left over when the discount margin differs from the quoted one. It is linear in that difference and it changes sign with the side of par. On the five-year quarterly note in the tests, against a quoted 75bp:

discount margin price rate duration
25bp 102.42 +0.061 years
75bp 100.00 0.000
125bp 97.65 −0.061
275bp 90.94 −0.250

A floater trading cheap to its quoted margin gains when rates rise, because the negative annuity it is carrying gets discounted harder. Not having the current fixing adds a separate −0.057 years at par three weeks into a quarterly period, which is the cost of a missing fixings history stated as risk rather than as an apology.

Spread duration is the one that survives, and it is a whole-life number because the margin is discounted across every remaining period: 1.014 times the modified duration of a fixed bond of the same maturity at three years, 1.005 at five and 0.974 at ten. Both are reported, and the command line prints both, because reporting one as "the duration" would be reporting the wrong one.

Two conventions are settled rather than left implicit. The projected index rate is backed out of the discount factors against the coupon's own accrual fraction, not taken from DiscountCurve.forward_rate — the curve's forward is a simple rate over the curve's year fraction and the coupon does not accrue over that one. On an ACT/360 note over an ACT/365F curve the two differ by the ratio of the bases, 20.72bp against 21.01bp on the first period of the test note; the price effect is half a basis point at a wide margin and 0.05bp on the implied discount margin, which is the honest size of it. And the discount margin is applied to the projected forward period by period rather than added to a zero rate, because adding a constant to the zero curve is a Z-spread and that is a different number.

The par identity does not check either of those. The telescoping needs only that the coupon and the discount denominator use the same rate over the same fraction; it does not care whether that rate is right. A note projected with its index scaled by an arbitrary factor still prices at exactly par, and there is a test asserting that so nobody relies on par to catch a projection error.

Two things a floater cannot do, both refusals rather than plausible numbers. Accrued interest needs the current fixing, which is not on any curve — returning the margin's accrual alone would be a number and would be wrong by the whole of the index, about 85% of it on a note paying 4% over 75bp. And a settlement date inside a period that began before the curve's reference date cannot be projected at all, because there is no discount factor at the reset to take a forward from; that is the ordinary case on any date that is not a reset date, so the message names the fixing rather than the curve.

Carry earns nothing; roll-down is the whole of it

A bond held to a horizon on a curve that evolves to its own forwards earns its funding cost and nothing else. Coupon income and the pull of the price towards its forward offset the financing exactly. This is an identity, not an approximation, and tenor.horizon asserts it to machine precision — measured at 2e-15 per 100 of notional across coupons from 0% to 9%, three curve shapes, every interpolation scheme, and a year of settlement dates.

So the expected excess return of a bond position is not carry. It is the curve failing to evolve to its forwards: the bond ages, its remaining maturity shortens, and on an unchanged spot curve it is repriced off a lower point. That is roll-down, and it is all of it.

On the bundled quote screen — a curve running from 20bp to 220bp — a 3% 2031 bond held for a year returns 279 basis points, of which 50 is the financing cost and 249 is roll-down.

This needs two curves and they are different objects:

what it is what it is for
forward_curve every discount factor divided by the horizon's the no-arbitrage half
rolled_curve the same zero rate at each tenor, reference moved the trader's unchanged curve

Building the first and calling it the second is the natural mistake, and it makes roll-down come out as exactly zero every time — which looks plausible and is the answer to a different question. On a flat curve the two coincide, which is precisely why roll-down is zero there.

The roll is applied in days, not by adding a calendar period. A pillar 1826 days out stays 1826 days out; adding months instead moves pillars by unequal amounts and quietly changes the curve's shape, which is the one thing this must not do.

Why both definitions of "carry" are reported. The market uses the word for at least two quantities. Here carry is coupon income plus the forward price change — the no-arbitrage total, identically the financing cost. income_less_financing is the other one. They are not interchangeable and the second can point the wrong way:

income − financing actual return
9% of 2031 +4.83 443.6 bp
0% of 2031 −2.00 470.7 bp

Nearly seven points of apparent carry separate the two bonds, the zero-coupon one looks far worse on it, and the zero-coupon one actually earns more. The high coupon is paid for by a price falling towards par by the same amount. A decomposition whose leading term ranks two positions backwards is worse than no decomposition, which is why both are reported and neither is called "carry" without saying which.

Coupons paid inside the window are reinvested at the curve's own forward rates. That is the only assumption under which the identity holds; reinvesting at a chosen rate would make carry differ from the financing cost by the size of the view, which is a fact about the view and not about the bond.

Half of a linker's next quarter of inflation is already published

An index-linked bond references a price index three months back, interpolated by day of month, because the index for this month does not exist yet when this month settles. The consequence is not symmetrical and is easy to state wrongly. The reference index is determined out to the first of the month three months after the last published figure — from mid-September with a series through August, that is 1 November, six weeks away rather than three months. On a 2% path from that date, 51.4% of the next quarter's accretion is arithmetic, 25.7% of the next six months' and 12.7% of the next year's, and the share falls through each month until the next print lands. index_accretion splits the two, because a caller hedging a three-month inflation exposure with a linker is trading something about half of whose payoff is already fixed.

The interpolation itself is worth 15bp of index level by the end of a month: on the series in the tests the 28th of September reads 107.159 interpolated against 107.000 stepped. Both conventions exist in the market — modern linkers interpolate daily, index-linked gilts before 2005 stepped once a month on an eight-month lag — so IndexInterpolation names the choice rather than assuming one.

A linker is quoted in real terms and settles in money

The price and yield on the screen are real; the invoice is the real dirty price times the index ratio at settlement. Indexing the clean price and adding unindexed accrued is the plausible mistake, and it is invisible on a new issue because the ratio is near one — on the bond above, with 6.5% of cumulative accretion behind it, it is 4 cents per 100 and growing. Every method here says which space it is in, and the real ones delegate to the fixed-coupon bond rather than restating the street discounting convention.

Real duration is not comparable with a nominal bond's. The linker above has a real modified duration of 6.19 years against 5.59 for a 4% nominal of the same maturity, and the gap is almost entirely the coupon: a 1.5% real coupon puts more of its value at the end. It is a sensitivity to the real yield, and the invoice also moves with the index ratio — which is the instrument's reason to exist rather than a risk to hedge.

The deflation floor is on the principal, and the last coupon is not principal

On the US structure the principal returned is the greater of the index ratio and one, and the coupons are unfloored. So a bond in cumulative deflation redeems at par while its coupons keep shrinking, and redemption_ratio reports whether the floor binds rather than applying it silently.

This produced a defect worth recording. The fixed-coupon bond bundles the final coupon into the redemption payment, as it should — they are one wire. Flooring that bundle's index ratio floors the final coupon as well, which is a different and more valuable bond. It can only show up on one in cumulative deflation, so nothing else in the suite would have caught it. The final coupon and the principal are now separate flows on the same day.

Breakeven inflation is not the difference of two yields

It is quoted as one, and the Fisher relation is multiplicative. At a 4.5% nominal against a 2.0% real the quoted difference is 250.0bp and the exact rate is 245.1bp; the gap is the cross term, so it widens with the level of both and reaches 19.2bp at 9% against 4%. Both forms are available and the exact one is the default, because 4.9bp is wider than the bid-offer on the spread it is quoted as.

Neither number is an inflation expectation. Both are that plus an inflation risk premium plus the liquidity difference between two bonds, and two prices do not identify three things. implied_inflation_from_price is the other route — the constant inflation path that reprices the linker off a nominal curve — and it is worth having both, because that one takes the curve as given instead of inheriting a second bond's liquidity.

The two routes agreeing is a useful check on both, since they share no code: on the bundled example the invoice implies 1.7184% against a two-yield breakeven of 1.6915%, 2.7bp apart, which is the shape of the curve against the single yield rather than an error in either.

A gap in an index series is refused

A missing monthly print, interpolated across, becomes a plausible number that nobody published. ReferenceIndex requires a contiguous series and names the month that is absent. Projecting past the end of the series is a different matter: it is the ordinary condition of every flow beyond the next quarter, so it raises Unpublished — which the caller answers by supplying an inflation rate, not by fixing the call.

"30/360" names three different rules

From 28 February 2021 to 31 August 2021:

convention days year fraction
ACT/360 184 0.511111
ACT/365F 184 0.504110
ACT/ACT ISDA 184 0.504110
30/360 Bond Basis (ISDA 4.16(f)) 183 0.508333
30E/360 Eurobond (ISDA 4.16(g)) 182 0.505556
30/360 US (SIA) 180 0.500000

Six answers for the same six months, three of them from conventions all written "30/360" on a screen. They are separate members of the enumeration rather than one convention with a flag, because a flag invites a default and there is no right one.

The rules are transcribed in the order they are applied, which is where the differences live. Bond Basis adjusts a second date of 31 only when the first is already past the 29th, and has no end-of-February rule at all. The US rules reach that state through the end-of-February adjustment, which is the entire difference between them and is worth three days on a February-to-August accrual. Eurobond rules never condition on the first date — an implementation that caps both dates independently is the Eurobond rule wearing another name, whatever the docstring says.

ACT/ACT ISDA is not a single ratio

It splits the period at each year boundary and divides each part by the length of its own year, so a period spanning a leap year gets both 365 and 366 as denominators. Actual days over 365.25, or over the length of the start year, is the shortcut; over 1 November 2019 to 1 February 2021 it is wrong by nearly a full day of accrual, in a direction that depends on where in the calendar the trade sits.

The property that catches a wrong implementation is additivity: splitting a period and adding the parts must give the whole, and the single-denominator version fails that as soon as the split crosses a year boundary.

The cases where a plausible implementation is still wrong

Three rules in this library are indistinguishable from a simpler, wrong version almost all of the time, which is exactly why they are pinned directly.

Modified following. It is plain following except when a roll would cross into the next month, where it turns back instead. Over a whole year the two agree on every date but a handful, so an implementation missing the exception is correct for months and then is not. The test counts the disagreements and asserts each one crosses a month boundary, rather than asserting there are none.

End of month. If the anchor is the last day of its month, every subsequent date should be the last day of its month. The rule bites exactly where the anchor is a month end that is not the 31st — a 31st already clamps onto each month's end by itself — so an implementation can pass every January-anchored test and fail every February one. Both cases are in the suite.

Measuring months, not stepping them. Dates are always computed from the original anchor. Stepping a month at a time loses month ends permanently: 31 January gives 28 February, then 28 March, 28 April, and never recovers. The test computes both and asserts they differ.

Calendars compose by union

A trade settling in two centres needs both open, so the joint calendar holds every holiday of each. Intersecting them — keeping only the days both are closed — is the intuitive-sounding mistake and produces a calendar open on almost every holiday either centre observes. The test uses two calendars that share no holidays at all, so an intersection would be empty and could not pass by accident.

A calendar with no business days at all is refused rather than searched, and a run of closures longer than a fortnight is reported as a misconfiguration — usually holidays loaded for the wrong year — instead of being looped on forever.

Schedules are generated backwards, and say where the stub is

A bond maturing on 15 August pays on the 15th of February and August whatever its issue date was, so the schedule is generated backwards from maturity and any odd period lands at the front. Generating forwards from the effective date puts the payments on the issue day of the month and leaves the stub next to redemption, which is the one place it is least often intended. Both are available; neither is implied.

A stub is a fact about the trade rather than a rounding error, so it is classified and reported on the schedule rather than left for the reader to find by comparing period lengths. A stub nobody noticed is a coupon that does not match the counterparty's.

Unadjusted boundaries are kept alongside the adjusted ones, because accrual under the thirty-day conventions runs on unadjusted dates while payment runs on adjusted ones. A schedule that discards the unadjusted dates cannot compute its own accruals afterwards.

A rate without its compounding convention is not a rate

The same discount factor of 0.90 over five years is 2.2222% simple, 2.1296% annual, 2.1184% semi-annual and 2.1072% continuous. Eleven and a half basis points across the conventions, which is wider than the bid-offer on most of the curve, and nothing in a bare float says which one it is. So a rate is paired with its convention as a type rather than by naming, since a naming convention is something a caller can get wrong silently.

Simple compounding is not annual with one period. It is 1 / (1 + r t) rather than a power, so the two agree at exactly one year and nowhere else — which is the one point somebody checking an implementation is most likely to pick.

Conversions route through the discount factor rather than through a closed form per pair. Six conventions make thirty ordered pairs, and thirty chances to transpose a sign, against one shared path already tested in both directions.

Interpolation is a choice, so the curve names it

Log-linear on discount factors is linear in log P, which makes the instantaneous forward piecewise constant — flat within each pillar interval and jumping at the pillars. Unrealistic as a picture of the market, extremely well behaved as arithmetic: the forwards are positive whenever the discount factors decrease, which is exactly the arbitrage-free condition.

Linear on zero rates is the one most people reach for, and it does something its name does not advertise. Interpolating z(t) linearly makes z(t) * t quadratic and the forward its derivative, so forwards are piecewise linear with jumps at the pillars — and they can go negative from inputs containing no arbitrage at all.

Two pillars show it. z(1y) = 6% and z(2y) = 3.5% give discount factors of 0.941765 and 0.932394, strictly decreasing, so the inputs are clean and the average forward over the year is a positive 1%. Log-linear returns exactly that, flat. Linear on zero rates returns a ramp from +3% down to −1%, crossing zero around 1.7 years — a negative rate manufactured by the interpolation from data that contained none. Both figures are in the test suite, and instantaneous_forward makes them inspectable on any curve.

Monotone convex, the scheme of Hagan and West, declines the trade-off. It interpolates the forward rate itself, under the constraint that it must average back to each interval's discrete forward — so the pillars are repriced by an identity rather than by luck, the forward curve is continuous across them, and positivity is imposed rather than hoped for. On the same two pillars its forward runs from +2% at the one-year pillar to exactly 0% at the two-year pillar, straight down and never below: it moves across the interval, as the data says it should, and stays where log-linear's flat 1% average says it can. The 0% at the far end is the positivity clamp doing its work — the unconstrained node forward there extrapolates to −0.25%.

The construction is worth a sentence, because the interesting part is not the quadratic. Write the forward on an interval as the discrete forward plus a deviation G(x), pinned at each end by the node forward there. Repricing both pillars is then exactly ∫₀¹ G = 0, and the natural choice is the quadratic through both endpoints. But a quadratic with a fixed integral has no freedom left to stay inside its own endpoints, and for endpoint pairs far enough apart it overshoots — which in a rising curve is a dip and in a falling one can be a negative forward. The answer is to hold G flat over part of the interval and curve it over the rest, with the split point chosen to keep the integral at zero. Which of the four shapes applies depends on nothing but the ratio of the two endpoint deviations, with the regions meeting at −2 and −½, and tenor.monotone.classify is written that way rather than as the eight sign-and-magnitude conditions the method is usually stated with.

What it costs: pillars implying a negative discrete forward are refused rather than interpolated. The positivity clamp bounds each node into [0, 2·min(f)] over its neighbouring discrete forwards, and that range is empty once one of them is negative. Those inputs contain an arbitrage — discount factors that rise over an interval — so the honest answer is the error rather than a confident number from a clamp that no longer means anything.

A sequential bootstrap is wrong for a smooth interpolation

Each instrument pins down the discount factor at its own maturity given every shorter one, so the obvious method is to solve them one at a time in order. That is right for a local interpolation and wrong for any scheme whose shape at one maturity depends on its neighbours — monotone convex, any spline. Adding pillar i + 1 changes the curve before t_i too, and the instruments already solved stop repricing.

Nothing raises. No discount factor looks unreasonable. The curve is out by around ten basis points in the middle of an interval, which is the size of the thing being measured. On the test strip, one sequential pass under monotone convex leaves the September future mispriced by 2.6e-05 of present value on unit notional — and leaves both local schemes exactly right, which is how the bug survives.

So the bootstrapper sweeps: the sequential pass, then repeated passes re-solving every pillar against the shape the later ones imposed, until all instruments reprice at once. It reports how many it took — one for the local schemes, seven for monotone convex — because that number describes how much work the curve took and a strip that suddenly needs many more is saying something.

The unknown solved for is the discount factor itself rather than a rate parametrising it, which buys an exact bracket: a pillar's arbitrage-free range is its two neighbouring discount factors. Solving in rate space means guessing a range and widening it, and widening the wrong way builds a trial curve that monotone convex then refuses — an exception to catch in place of a bracket that was already known.

Futures are not forwards

A futures contract is margined daily and a forward settles once, so the holder receives cash exactly when rates rise and reinvests it at the higher rate. The futures rate therefore sits above the forward rate it stands in for, and the gap grows with the product of the two maturities.

The number is bigger than people expect. On 1% normal volatility a three-month contract starting in one year is adjusted by 0.6 basis points; the same contract starting in ten years is adjusted by 51. Future takes the adjustment explicitly and defaults it to zero rather than computing one, because computing it needs a volatility and defaulting would be asserting one. ho_lee_convexity provides it when a volatility is available, and reproduces Hull's worked example — 47.5 basis points at 1.2% and eight years — in the test suite.

DV01 and PV01 are not the same number, for two reasons

One moves the bond's own yield by a basis point; the other moves the whole curve. They are routinely treated as interchangeable, which is harmless right up until one is used to hedge the other.

The reason usually given is shape: a yield is a weighted average of the curve over the bond's flows, so shifting every zero rate by a basis point moves that average by a basis point only if the curve is flat. On the test curve, for a fifteen-year bullet, that is worth +1.01%.

The reason not usually given is that a basis point is not one quantity. A basis point of continuously compounded zero rate moves the equivalent semi-annual rate by exp(z/2) times as much — 1.5% at a 3% level. Against a semi-annual yield that puts the two numbers −1.54% apart on a perfectly flat curve, where the shape explanation predicts no gap at all.

And the two have opposite signs. Make both mistakes at once — a continuous shift measured against a semi-annual yield, on a sloped curve — and the answer comes out 0.23% apart, closer than either error alone would put it. The discrepancy looks most negligible exactly where it is least understood.

shifted() and dv01() therefore take the compounding as an argument. Match it to the bond and only shape and convexity remain; leave it continuous and what is left is not the curve's shape, however much it looks like it. All four numbers are in the test suite.

Accrued interest is a fraction of one of two things

Either the day count fraction since the last coupon, or the fraction of the coupon period. Under 30/360 they agree exactly, because a semi-annual period is half a year by construction — which is why the disagreement survives, since the case people test is the one where it cannot appear.

Under ACT/365F they differ by twice the period's actual length over 365. Three months into a 181-day period on a 5% coupon, that is just over a cent per 100, about 0.8% of accrued. Accrual names both; neither is a default that happens silently.

The total risk is the market's; the hedge is the curve builder's

There are two ways to break a duration down and they are not one set of numbers regrouped.

Key rate durations shift the zero curve in a shape localised around one bucket. They answer where the exposure sits. Their shapes are built to add to one at every pillar, so they sum back to the total duration — an identity, and one the tests check by measuring the residual at three bump sizes and asserting it falls as the square of the bump, rather than by accepting a small number once.

Instrument risk shifts a quote, rebuilds the curve and measures that. It answers what to trade. A ten-year swap quote does not move the ten-year zero rate alone; it moves every zero rate out to ten years, because the quote is a statement about a whole annuity.

The difference is not academic. For the same bond on the same quotes, across the three interpolations:

total 5y swap 10y swap 20y swap
log-linear 0.1509 +0.0039 +0.0534 +0.0926
linear on zeros 0.1529 +0.0041 +0.0762 +0.0715
monotone convex 0.1516 −0.0066 +0.0878 +0.0694

The totals span 1.3%. The ten-year entry moves by 64%, the twenty-year by −25%, and the five-year changes sign. The total is what the quotes determine; the split between them is what the interpolation decided, which is an assumption about the gaps between quotes rather than anything the market said.

The negative entry is a real answer, not an artefact. Raising the five-year par swap rate while the longer par rates are held fixed forces the forwards beyond five years down, and for a bond whose risk sits past ten years that fall can outweigh the rise nearer in. It happens under every scheme here; under monotone convex the long-end effect is about three times larger, and that is enough to flip the sign.

A bond with no spread has an I-spread anyway

Z-spread adds a constant to every zero rate until the whole curve reproduces the bond's price. I-spread subtracts one par swap rate from one yield.

The difference is clearest on a bond that has no spread at all — priced exactly on the curve. Z-spread answers zero, by construction. I-spread, on the test curve, answers:

maturity I-spread Z-spread
2 years −0.14bp 0
5 years −0.46bp 0
10 years −1.62bp 0
15 years −6.03bp 0

None of that is spread. It is a single benchmark point at the maturity being compared against coupons discounted across the whole curve, and it grows with maturity and with distance from par. On a flat curve every figure is zero to two hundredths of a basis point, which is why a flat curve tests nothing here.

The asset swap spread is not a credit spread, and the gap is measurable

Z-spread and I-spread both measure a bond against the curve. Neither is the number a credit desk transacts on, which is the asset swap spread: what a buyer receives over the floating index once the bond's fixed coupons have been handed to a swap counterparty.

It is a question about a structure, so it carries the structure's conventions. A par-par buyer pays 100 and receives the index plus a spread on 100; a proceeds buyer pays the market price and receives it on that. The two differ by exactly 100 / dirty price — a bond at 80 quotes 478.4bp on one convention and 391.8bp on the other, 22.1% apart. They coincide at one price only, a dirty price of par.

And the spread moves with the coupon even when the credit does not. Five bonds on one curve, same maturity, same Z-spread of 100bp:

coupon price asset swap
1% 80.94 86.8bp
2% 89.63 91.6bp
4% 107.02 101.2bp
6% 124.41 110.9bp
8% 141.80 120.5bp

33.7bp across bonds that are by construction equally risky, widening to 94.8bp at a Z-spread of 300bp. asset_swap_spread splits the quote into the part that is the bond's coupon against the curve and the part that is only its distance from par, which is where that dispersion comes from.

The floating leg's own convention is worth basis points too. The structure's value does not depend on the leg's frequency or day count — the leg telescopes — so every convention amortises the same money and differs only in the annuity it divides by: 284.25bp quarterly ACT/360 up to 289.13bp semi-annual 30/360 on the same bond at the same price. leg_sensitivity prints that table.

Z-spread minus OAS is what the option costs

A callable bond's cash flows are not known: whether the issuer redeems depends on where rates are when the call date arrives. One discounting curve cannot say, because there is only one of it. So the curve becomes a Black-Derman-Toy tree, calibrated so that it reprices the curve's own zero coupon bonds to 1e-16 — exactly, because everything measured on it is quoted in basis points and a calibration error is indistinguishable from a spread.

Then one price, two questions. What spread reproduces it if the option is ignored? That is the Z-spread. What spread reproduces it if the option is modelled? That is the OAS. The difference is the option. For a 5% bond callable at par from year five, on the test curve at 15% volatility, it is 89 basis points — which is what the holder is being paid for being short the call, and what a Z-spread would have counted as income.

A put runs the other way and its option cost is negative, because the holder is long it. Implementing one and negating it for the other produces a number that still looks like a spread, so both directions are tested, along with the two cases where an option never binds and the bullet must come back exactly.

Extrapolation is refused

Past the last pillar there is no information. Returning the last discount factor is not a neutral default — it asserts that every forward rate beyond the curve is zero, which is a strong opinion stated silently. A caller who wants a flat extension can add a pillar and say so.

Negative periods are refused

A year fraction with the end before the start is a mistake in the caller — almost always a schedule generated backwards — not a negative quantity. Returned as a number it travels a long way before anything notices, so it is refused at the point it is asked for.

Default risk, and what the credit triangle is actually worth

Everything above discounts a cashflow that arrives. tenor.credit prices the possibility that it does not: a survival curve held as a piecewise-constant forward hazard rate, credit default swaps with both legs, a bootstrap from par spreads, and bonds discounted for default with recovery on face.

tenor credit quotes.txt --reference 2026-06-15 --basis ACT_365F \
  --spreads spreads.txt --bond-maturity 2031-06-15 --bond-coupon 0.05

# and mark one contract, struck on the March roll, against that curve
tenor credit quotes.txt --reference 2026-06-15 --basis ACT_365F \
  --spreads spreads.txt --position-maturity 2031-06-20 \
  --position-effective 2026-03-20 --position-coupon 0.01

The integrals are closed form

A protection leg is (1 - R) integral DF(s) dQ(s), and the temptation is to put a fine grid under it. There is no need. On any interval where the hazard rate and the instantaneous forward rate are both constant, DF(s) S(s) = DF(a) S(a) exp(-(r + h)(s - a)) and the integral is elementary. Take the grid to be the union of the discount curve's pillars, the hazard pillars and the coupon dates and both are constant on every piece, so the answer is exact for the curve as interpolated rather than convergent to it. The accrual-on-default term — the same integral weighted by time since the last coupon — is elementary for the same reason and is computed, not approximated by half a period.

The credit triangle is exact, and then it is not

spread = hazard x (1 - recovery) is the rule everybody uses, and the first surprise is how good it is. With accrual on default and a zero interest rate it is not an approximation at all: it is exact, for any hazard rate and any premium frequency. Integrating the accrual term by parts turns each period's contribution into the integral of the survival probability over that period; the discrete premium dates cancel completely and the annuity collapses to (1 - S(T)) / h, which divides the protection leg to give exactly h (1 - R).

So the error is not about the credit. Measured, it is two effects of the same shape, both linear in the length of a premium period:

relative effect measured, quarterly at r = 3%
discounting lifts the true spread above the triangle + r x period / 2 +0.374% (predicted 0.375%)
dropping accrual on default lifts it further + h x period / 2 +1.257% at h = 10% (predicted 1.250%)

Both hold across annual, semi-annual and quarterly premiums and across rates from 1% to 10%, to within a few per cent of themselves — the residue is second order. The first says something unintuitive: the triangle's error depends on the interest rate and the premium frequency and hardly at all on the hazard rate, so it is no worse on a distressed name than on an investment grade one.

Day counts add a third, larger effect that is nothing to do with either. Market premiums accrue on actual/360 while a curve counts actual/365, so a premium year is 365/360 of a hazard year and the fair spread is lower in the same proportion: 1.389%, against which the discounting adds back 0.375%, leaving the triangle high by 1.01%. The suite asserts that composition rather than the total.

Two defects the tests found

The recovery leg on a bond was not scaled by face. Recovery is a fraction of the redemption amount, which is 100 in the convention this package prices in and not 1 — so the term was two orders of magnitude too small while everything else was very nearly right, which is the sort of error that survives a smoke test.

And the bootstrap did not reprice its own inputs. A pillar placed at a quote's maturity leaves that quote's final premium — paid on the rolled maturity, a day or three later — discounted at the next quote's hazard rate. The error is about three parts in a hundred million, which reads exactly like solver noise. Pillars now sit at the last date their quote touches, and the repricing is exact to 1e-13 of a basis point.

One thing the command line will not let you misread

The triangle produces a flat hazard rate to a maturity, so the quantity to compare it against is the average hazard to that date and not the forward rate over the last bucket. On an upward-sloping curve those diverge: at ten years the forward is half as much again as the average, and a reader seeing the forward beside the triangle would conclude the rule of thumb is wildly wrong when it is out by nine per cent. Both are printed, labelled.

A contract that has already started

Everything above this prices a contract from the day it begins. No credit default swap is ever looked at on that day. The standard contract rolls on the IMM dates and is marked every day in between, so the valuation date sits inside a premium period that has partly accrued and has not yet paid — and that was the one state this module could not price. protection_leg opened with curve.time_to(schedule.effective), which refuses a date before the reference, so a three-month-old contract raised an error about a mistake in the schedule when the schedule was right and the contract was simply running. The next line was a max(start, 0.0) written for exactly that case and unreachable, because no negative value could ever arrive at it.

Three conventions decide the answer, and two of them are easy to confuse.

The coupon paid at the next date is the whole period's. The contract does not pro-rate it for the part of the period that has gone by. A buyer who pays mid-period still owes the whole coupon on the payment date, and gets the elapsed part back through the accrued premium instead.

The accrual owed on a default runs from the period's start, not from the valuation date. A default tomorrow owes everything that has accrued since the period began. So the integration clamps to the stub that is left while the weight's origin stays back where the period started — two different clamps, and clamping both is the mistake. The gap between them is not a residual to be bounded: adding a constant to the integrand makes it an exact product, the elapsed accrual times the discounted chance of defaulting inside the rest of the period. It understates the premium leg and so overstates the par spread.

That product is why the convention can be ignored on a good name and cannot be on a bad one. On a contract most of a quarter old, the two origins differ by 0.0005bp of par spread at 57bp, and by 18.3bp at 4826bp — four orders of magnitude of sensitivity to the one input nobody checks, arriving exactly where a seasoned mark matters most.

A period is gone when its money moves, not when it stops accruing. Under a payment lag those are different days, and testing the wrong one throws away a coupon still owed. It also means the next period is already accruing while the previous coupon is outstanding, so the two coexist: the unpaid coupon is a known cashflow and belongs in the valuation, while the accrued covers only the period now running.

A defaultable bond priced for a date that is not today

risky_bond_price takes a settlement argument. For any value but its default it returned a number on a different basis from the rest of the package.

Every term in it carries a discount factor and a survival probability measured from the curve's reference date, and nothing divided them back out — so the answer was a present value today of a bond bought later, where Bond.price_from_curve for the same settlement returns a price on that date. That function's own docstring already says skipping the division "prices the bond for value now, which is a different and smaller number". Two functions in one package, both called a price, differing by a discount factor.

The reference that settles it costs nothing to state: with no hazard and no recovery a risky bond is a riskless one, so the two routes to its forward price have to agree. They differed by exactly the discount factor to settlement — 1.00000000 at the reference date, 0.94168713 two years out, which is 5.8% of the price. Nothing caught it because the default is the only value anything passes, so every test of the function was reading the one settlement where both factors are one and the answer was already right.

Survival was not conditioned out either. A forward price is quoted for a bond you only own if it has not already defaulted, and at a 5% hazard the chance of lasting two years is 90.47%.

One division by DF(settlement) * S(settlement) corrects both, which is a tidier fix than it is a test: a check on the product cannot say which factor was missing. So the two are pinned separately — the discounting by switching the hazard off, the survival by switching the rate off — and they are different sizes, which is what makes the pair informative rather than one number read twice. Two years out at a 5% hazard, grossing up for the discounting is worth 6.192% and for survival 10.532%, and the two together 17.377%.

Six ways of getting the division wrong were each confirmed to fail the suite, including dividing by one factor and not the other, and dividing the cashflows while leaving the recovery leg alone.

The two halves of a settlement are not measured the same way

The value of everything still to come is one number, and the market quotes two: a principal and an accrued premium, both paid by the buyer. So the split is a split — cash == principal + accrued holds exactly, by construction, and the suite asserts it in both directions rather than computing the halves separately and hoping.

What is worth knowing is that the halves are measured differently. The accrued is a convention: a day count, undiscounted, owed whatever happens next. The money inside the settlement standing for that same slice of coupon is discounted to the payment date and paid only on survival to it. Switch off the rate and the hazard and the two are the same number to rounding, which is what makes them comparable at all; switch them on and the quoted accrued is 0.22% more than the slice is worth on the worked example's contract. Small, real, and nowhere near large enough to justify quoting a present value instead — which is a thing worth having measured rather than assumed, so both are reported and accrued_value is the other half.

Deliverable bond futures, and why the switch is at six per cent

A short-term interest rate future here is a rate contract: one accrual period, cash settled, nothing for either side to choose. A deliverable bond future is a different instrument. The short hands over an actual bond from a published basket, picks which one and picks when inside the delivery month, and the contract normalises those choices with a conversion factor — the price of that bond, per unit of face, at a notional yield the exchange fixes and the market then moves away from.

$ tenor futures examples/basket.txt --settlement 2026-11-20 \
      --first-delivery 2026-12-01 --delivery 2026-12-31 \
      --price 118.75 --repo 0.042 --switch 0.045 0.06 0.075
deliverables:
  bond=1.750% of 2046  conversion_factor=0.5153  gross_basis=2.590125  carry=-0.1123241831  net_basis=2.702449183  implied_repo=-0.3273588363  breakeven_futures_price=123.9944191
  bond=3.000% of 2046  conversion_factor=0.6555  gross_basis=1.954375  carry=-0.04210528223  net_basis=1.996480282  implied_repo=-0.177574815  breakeven_futures_price=121.7957365
  bond=4.500% of 2047  conversion_factor=0.8266  gross_basis=1.17825  carry=0.02052300308  net_basis=1.157726997  implied_repo=-0.05912505087  breakeven_futures_price=120.1505892
  bond=6.250% of 2047  conversion_factor=1.029  gross_basis=0.30325  carry=0.121516019  net_basis=0.181733981  implied_repo=0.02898262597  breakeven_futures_price=118.9266122
(the invoice price, accrued interest, financed balance and interim coupon columns
are omitted here; the command prints them, and `--json` is the machine form)
cheapest_to_deliver:
  by_implied_repo: 6.250% of 2047
  by_net_basis: 6.250% of 2047
  by_futures_price: 6.250% of 2047
  unanimous: True
  implied_futures_price: 118.9266122
  quote_less_implied: -0.1766122265

It is the only subcommand that takes no curve. The whole calculation is the bond's quoted price, its conversion factor and a money-market financing rate, and asking for a file of swap quotes to compute it would be asking for something it does not read.

The conversion factor is a bond price, so it is checked by pricing a bond

The exchange formula is five lines of algebra, and a formula transcribed once and asserted against itself passes whatever it happens to say. So the suite reaches the same number a second way: it builds the rounded bond with this package's own schedule generator, prices it at the notional yield, and compares. Eighty cases, all four stub lengths, coupons from zero to 12.5% — worst disagreement 1.8e-15.

Two facts worth having as tests rather than as comments came out of it. A bond paying exactly the notional coupon with a whole number of years to run has a factor of exactly one, which is the check the formula's shape is easiest to fail. And at exactly the notional coupon the discount factor to the rounded maturity cancels out of the formula, so the factor stops depending on the maturity at all — a six per cent bond with a three-month stub has the same factor at three years as at twenty-nine, and 0.999889 rather than 1.0, which is the rounding convention showing through and not an error. A basis point off the notional coupon, the maturity matters again and matters strongly.

Net basis and the implied repo rate are one number written twice

Exactly:

net basis = (repo - implied repo) * financed balance

where the financed balance is the dirty price over the holding period less each interim coupon over its own stub. Both sides are computed by different routes — one adds up income and financing, the other divides a break-even — and they agree to 1e-12, which is a statement about the definitions rather than about floating point. The same identity through the third convention, net basis = (break-even futures price - quote) * conversion factor, holds to the same precision.

The implied repo rate is not solved for. The cash-and-carry break-even is linear in the financing rate, which appears once in the cost of carrying the bond and once in the reinvestment of any coupon falling inside the holding period, both as simple interest. So it divides. The suite checks the rearrangement against a bisection on the cash flows themselves, because a rearrangement is exactly the kind of step that can be done backwards without any test noticing.

The criterion that disagrees is not the one you would expect

Three conventions name the cheapest bond: the highest implied repo rate, the lowest net basis, and the lowest futures price a bond can justify. All three rank on the same gap between a bond's break-even price and the quote, scaled by one, by the conversion factor, and by the factor over the financed balance.

The natural expectation is that the rate and the price part company, because a deep-discount long bond finances around half the balance of a high-coupon short one. Measured on the four-bond basket above, that is backwards. The conversion factors span a ratio of 2.00; the factor over the balance spans 4.65%, because the balance is nearly the dirty price and the dirty price is nearly the factor times the futures price, so the factor cancels. The rate and the price rank a basket alike.

It is the net basis that is on its own scale — points per 100 of the bond's face rather than per contract. Walking the quote down from the basket-implied price, it takes 5.00 points, or 4.20%, before the net-basis ranking picks a different bond from the other two, and by then every deliverable shows several points of arbitrage. So comparing raw net bases across a basket does compare quantities in different units, and gets away with it because a liquid contract never trades five points from fair. Dividing the net basis by the conversion factor recovers the price criterion exactly.

Being close to a coupon date is a cost, not a benefit

Two bonds, same coupon, one paying inside the holding period and one not. The one that pays looks better: a coupon in hand, reinvested to delivery. It carries worse.

Coupon income over a fixed 41 days is the same 41 days of coupon whenever it lands — 0.5613 against 0.5663 here, the gap being the day-count noise of measuring 41 days against two different period lengths. What separates the two is the balance financed, and the bond about to pay carries 2.158 of accrued interest into the trade against the other's 0.912. Financing that 1.247 difference costs 0.00596; the received coupon earns 0.00467 back over its 16-day stub. The test that found this asserted the opposite twice before it was a measurement.

The switch is at the notional coupon, to six decimal places

The factors were computed at the notional coupon, so at that yield every bond's price over its factor is the same number and the basket is exactly indifferent. Away from it the ranking is by duration, because price over factor falls fastest where there is most of it. So the cheapest bond is the shortest in the basket below the notional coupon and the longest above it, and the exchange did not choose where that happens — the notional coupon did.

Measured on a pair of notional-coupon bonds maturing a whole number of years from the first delivery day, which makes the rounding convention a no-op and forces both coupon schedules onto the same months, the crossing is at 6.000000%, and neither the repo rate nor rounding the factor to four decimals moves it. With real odd maturities the rounding convention displaces it: 6.0011% with exact factors and no carry, pulled back to 6.0002% once carry and the published factor are included — which is a coincidence of that pair and is stated as one.

The switch is described and not valued. The margin column is what the short's choice is worth conditional on arriving at a level, and a parallel walk says nothing about the chance of arriving anywhere, so a number called the value of the delivery option would need a model of how the curve moves. This package does not have one and does not pretend to.

A conversion factor below one raises the contract's risk

futures_dv01 divides the deliverable's pv01 by the conversion factor, because the invoice divides by it. So a bond with a factor of 0.5153 moves the futures price nearly twice as far as it moves itself. Hedging a bond position one for one with the contract, on the grounds that a factor below one is conservative, under-hedges by that whole ratio.

A bond that has redeemed cannot be delivered

The conversion factor only needs the bond to outlive the first day of the delivery month, so a bond maturing on the 15th has a perfectly good factor and cannot be handed over on the 31st. That bond is refused rather than priced — and the guard is load-bearing, because Bond bundles the final coupon into the redemption flow, so such a bond would contribute 102.5 to the coupons received inside the holding period rather than 2.5. Nothing subtracts the principal back off: a subtraction that can never fire reads as a case somebody handled. The test sweeps every maturity for a month either side of the delivery month at a daily step, looking for the counterexample.

Two curves, and what the second one is actually worth

A floating leg forecast off the curve it is discounted on collapses to one subtraction. Each period's projected growth is its own discount ratio, so the sum telescopes, every intermediate date cancels, and P(start) - P(maturity) prices the whole leg without computing a single forward rate. That is exact for any curve of any shape, and it is how Swap values its floating leg.

It stopped describing the market in 2008. A three-month deposit rate and an overnight-indexed rate had sat within a basis point or two of each other for a decade; they separated and stayed separated, because the index a swap forecasts carries term credit and funding risk and the rate a collateralised swap discounts at does not. Once the two differ, nothing cancels and the leg has to be valued coupon by coupon.

$ tenor multicurve examples/ois.txt --reference 2026-01-15 --basis ACT_365F \
      --forecast examples/forecast.txt --maturity 2036-01-15
reference: 2026-01-15
index: 3m index
forecast_curve:
  solving_for: 3m index
  quotes_used: 7
  short_leg_projected_on: itself
  sweeps: 1
  worst_repricing_error: 2.775557562e-16
  reprices: True
  ...
forward_basis:
  start=2026-04-15  end=2026-07-15  projected=0.03309  discounting=0.03063  basis_points=24.65
  start=2027-01-15  end=2027-04-15  projected=0.03540  discounting=0.03315  basis_points=22.43
  ...
swap:
  maturity: 2036-01-15
  par_rate: 0.0389
  par_rate_on_one_curve: 0.03648486931
  basis_points_from_separating: 24.15130694
  risk:
    from_the_discount_curve: -1.14551659e-06
    from_the_forecast_curve: 0.0008480843596
    from_both_together: 0.0008469389715
    cross_term: 1.285412055e-10

Two files go in. The first is the ordinary quote screen and builds the discount curve; the second is quotes on the index, and a projection curve is solved out of them with the discount curve held fixed. That is the order the market works in, and it is the reason the bootstrap takes the discount curve as an argument rather than returning a pair.

The identity, kept as a test

Point both curves at the same object and the coupon-by-coupon leg has to reproduce the subtraction. On the ten-year quarterly leg it does, to 7.2e-16 of the leg's value — not bit for bit, since the two expressions accumulate rounding in different orders, but to the last few bits, which is what says the new path carries no error of its own.

What the identity needs turned out to be narrower than it looked, and the first draft of this got it wrong. The cancellation is between one period's P(end) and the next period's P(start), so it needs the payment to land on the accrual end. The rolling convention was the suspect and it is innocent: under modified following a period ending on a Saturday rolls to the Monday, and the payment rolls with it, so the dates still meet. A payment lag is the culprit, since it moves the payment without moving the accrual it pays for. At the two business days an overnight-indexed leg settles on, the gap is 0.095 basis points of par rate; at five days, 0.262. The lag is therefore a field on the index, it defaults to zero, and that zero is load-bearing.

The discount curve is worth almost nothing on a par rate

Separating the curves is usually introduced as the thing that repriced swaps. For a par rate it does not. Shifting the projection curve by 100 basis points moves the ten-year par rate by 101.62 basis points. Shifting the discount curve by the same amount moves it by -0.12 basis points — the other way, and smaller by a factor of 843.

That is not a surprise once the par rate is written down as what it is: a discount-weighted average of the forwards the leg projects. The projection curve moves every term in the average; the discount curve only reweights them. On an upward-sloping curve, heavier discounting tilts the weights towards the earlier and lower forwards, so the average falls a little.

The split in the output above is the same statement in money: a par swap's response to the discount curve is -1.1e-06 against the forecast curve's 8.5e-04. The two do not add to the joint response, because a swap's value is bilinear in the two curves; the cross term is 1.3e-10, which is 1.4e-07 of the joint response and grows with the square of the shift.

Where the discount curve is worth something is a swap that is not at par. A ten-year swap struck 100 basis points away from the market is the rate difference times the annuity, and the annuity is all discount curve: that same shift moves its mark by 4.67% of itself, against the 0.031% it moves the par rate's level. Third-order for a new trade and first-order for a book of old ones, which is the reverse of the order the choice is usually introduced in.

The basis passes through at 1.0146, and that factorises exactly

Raise every projected forward by a flat 20 basis points and the ten-year par swap rate rises by 20.29. The pass-through is not one, for two reasons that pull opposite ways: the two legs' annuities stand in the ratio 1.0191 — actual/360 floating against 30/360 fixed — and a flat shift to a continuously compounded curve lifts a simply compounded quarterly forward by 0.9956 of itself. Their product is 1.014643 and the measured pass-through is 1.014643. They agree to 5.8e-15, which is what distinguishes a decomposition from an explanation that happens to come out near the right size.

A curve that reprices every quote is not the curve that produced them

The projection bootstrap fits its inputs to 1e-16 and settles in one sweep under log-linear discounts, five under monotone convex. Feed it par rates generated off a known curve and ask how close the result is to that curve: beyond a year, within 0.03 basis points of zero rate. Between the three-month forward quote and the one-year swap, 5.30 basis points out at 0.49 years. Nothing in that gap is pinned by an input, so the interpolation decides it, and an exact fit is a statement about the quotes rather than about the curve.

The same hole has a sharper cost in a basis curve. Bootstrapping a six-month projection curve from tenor basis spreads of 5 to 9 basis points, with nothing quoted inside the first year, leaves an implied six-month-over-three-month forward basis of 16.87 basis points over the first period — the one-year quote spread backwards by the interpolation, and three times the number it came from. One forward quote on the long index puts the first period at exactly the 5.00 it says.

$ tenor multicurve examples/ois.txt --reference 2026-01-15 --basis ACT_365F \
      --forecast examples/basis.txt --flat-frequency SEMI_ANNUAL

A basis file solves the longer tenor's curve with the shorter one taken as known, so a file mixing par swap rates with basis spreads is asking one solve for two curves and is refused rather than resolved. And since the only curve this command has been handed is the discount curve, the short leg is projected off that — an assumption rather than a quote, so it is named in the output rather than left to be inferred from a number that is partly a proxy.

Swaptions, and where the discount curve finally matters

The section above measured a factor of 843 between what the projection curve and the discount curve are worth to a par swap rate. The obvious next question is where the discount curve does matter, and it has a structural answer: anything whose value is an annuity times something. A swaption is the first such instrument.

$ tenor swaption examples/ois-long.txt --reference 2026-01-15 --basis ACT_365F \
      --forecast examples/forecast-long.txt --expiry 2036-01-15 \
      --swap-maturity 2046-01-15 --volatility 0.20
forward_swap_rate: 0.04037356175
strike: 0.04037356175
annuity: 5.739934379
value: 0.05752663246
discount_shift:
  value: 0.04971483016
  relative_change_in_value: -0.1357945348
  relative_change_in_forward: 0.001240501324
  relative_change_in_annuity: -0.1384831056
strip:
  value: 0.06696852659
  over_the_option: 0.1641308335
  periods: 40
implied_volatility_from_its_own_premium: 0.2

Read the three numbers under discount_shift. A hundred basis points on the discount curve moves the forward swap rate by 0.124% of its level and the option by -13.58% — a hundred and nine times as much — and the decomposition says why: the annuity falls 13.85% and the rate's rise claws 0.12% back. Under the annuity measure the forward swap rate is a martingale and the swaption is a European call on it, so the discount curve has been factored into the numeraire. That is precisely why it comes back multiplicatively.

Both conventions, and how far apart they are

A negative forward swap rate has no lognormal volatility, so --normal reads the volatility as a Bachelier one in absolute units of the rate. The two are not conversions of each other. At the money both give numeraire * vol * sqrt(2T/pi) to leading order, so the product of a lognormal volatility and the forward is the right first guess for the normal one — and it is 39.78 basis points against the product's 39.80 at a 10% volatility over one year, 4.2e-04 apart; 4.2e-03 apart over ten years; and 1.6e-02 apart at a 20% volatility over ten years. The gap grows with σ√T, which is worth knowing before converting a quote at a long expiry.

A strip of options is not an option on the strip

The strip block prices the cap of the same strike and tenor beside the swaption. The cap's holder chooses period by period, so it is worth more: 16.4% more on the structure above, and 78.1% more on the five-year quarterly structure in the tests at a 20% volatility. The premium shrinks as volatility rises — 82.8% at 10% against 74.8% at 40% — which is the reverse of the natural guess.

That inequality is Jensen's, and it is the sharpest check available that both prices are built on the same curve: an error in either breaks it. Over twenty-five strikes and volatilities it holds twenty-four times and fails once, by 6.98e-05, and the failure turned out to be the most informative part of the phase.

It is not a pricing error. The fixed leg's annuity accrues on unadjusted boundaries under 30/360; the caplet weights accrue on adjusted ones under actual/360. So the two numeraires differ by 4.655e-05 relative, and the forward swap rate and the weight-average forward differ by 4.654e-05 — the same number. Deep in the money at a low volatility both prices collapse to their intrinsics, the ratio collapses to those two mismatches, and (1 + 4.655e-05)(1 - 1.1635e-04) is 0.99993019 against a measured 0.99993019. The inequality is exact in the mathematics and violated in the conventions, by precisely the amount the conventions differ.

One parameter set, and the identities that check it

tenor.options prices a swaption on the annuity measure and takes the volatility as an input. tenor.lattice prices a callable bond on a tree and says nothing about a swaption. tenor.hullwhite is the smallest thing that prices both from one specification:

from tenor import HullWhite, swaption_price, reprices_curve

model = HullWhite(a=0.08, sigma=0.009)
reprices_curve(curve, model)        # 0.0 -- an identity, not a fit
trade = swaption_price(curve, model, option, reference)
trade.jamshidian, trade.quadrature  # two routes
trade.gap                           # -8.9e-16 between them

The short rate is Gaussian with its drift fitted to the initial curve through A(t,T), so the model reprices every discount factor it was given by construction. Measured across a seventeen-pillar humped curve at every mean reversion from 0.01 to 0.5 and every volatility to 2%, the worst relative error is exactly 0.0.

The forward-measure mean is implied, and comes out exact

Pricing by quadrature needs the law of r(T) under the T-forward measure. Rather than copy a drift adjustment, forward_measure implies it from the fact that every forward bond price is a martingale under that measure — which over-determines it, since each probe maturity gives its own answer. The answers agree to 0.0, because the terms cancel identically: the B²v/2 from the lognormal expectation is exactly the convexity term inside A, leaving E^T[r(T)] = f(0,T). The forward-measure expectation of the future short rate is the instantaneous forward, with nothing left over.

The obvious quadrature is wrong by twelve per cent

A Gaussian weight invites Gauss-Hermite, and a 64-point rule got the near-the-money prices to five digits and a deep out-of-the-money one wrong by 1.25e-01 relative. A Gauss rule converges at that rate on a kink and an option payoff is nothing but a kink. The symptom was misleading: both routes agreed on the payer-less-receiver difference to 1e-12 while both were wrong, because the error is in the straddle and cancels in parity, so every identity still held and only the level was out. Putting the exercise boundary at a panel edge of a composite Gauss-Legendre rule turned five digits into fifteen, and the two routes now agree to 3.1e-14 over eighteen combinations of expiry, coupon and side.

Mean reversion is an argument, because one quote cannot choose

A ten-into-ten payer at its forward is repriced exactly at every reversion from 0.01 to 0.30: the refitted volatility runs 0.492% to 3.226%, a factor of 6.55, with a refit error of at most 2.2e-16 at every point. A routine fitting both parameters to one quote would return whichever point on that ridge its guess fell nearest and report a residual of zero either way. What separates them is the term structure — the pair fitted to the ten-year expiry prices a one-year-into-ten-year from 23.4% below to 57.2% above the a = 0.08 answer:

tenor hullwhite ois.txt --reference 2026-01-15 --basis ACT_365F \
  --expiry 2036-01-15 --swap-maturity 2046-01-15 \
  --mean-reversion 0.08 --volatility 0.009 --ridge 0.02 0.08 0.3

And the model's native convention is the normal one, measurably: its own prices imply a normal volatility moving 1.06 basis points on a level of 45.55 across strikes from 60% to 140% of the forward — 2.33% wide, and monotone rather than curved — against the lognormal convention's 41.91% on the same prices.

A second factor, because one cannot decorrelate two rates

The model above is a real one and has a limitation calibration cannot touch. With a single factor every bond price is affine in the same scalar, so two maturities are correlated exactly one at every parameter setting: the short end and the long end move together, always. r = x + y + phi(t) with two speeds and a correlation between the drivers removes that and keeps every closed form.

The exact fit is better than the one-factor model's, for the same reason. Both factors start at zero and the three variance terms telescope at t = 0, so the curve reprices with an error of identically 0.0.

Equal mean reversions collapse it to Hull-White exactly, which is why they are allowed rather than refused: with both speeds equal the bond price depends on x + y, itself an Ornstein-Uhlenbeck process with volatility sqrt(sigma^2 + eta^2 + 2 rho sigma eta). Measured against tenor.hullwhite, the option volatility agrees to 1e-16, the option price to 1e-15, the bond price to 1e-14 and the implied correlation to 1.0 within 1e-12. That check crosses modules, and it earned its keep immediately by catching the bond option's call/put convention pointing the opposite way to the one-factor module's — it disagreed by exactly the parity amount at every parameter setting.

What the second factor buys: at a 0.50 fast speed, a 0.05 slow speed and a driver correlation of -0.9, the one-year and ten-year zero rates come out correlated 0.3592 at a one-year horizon. The work is done by the gap between the speeds, and it closes as they meet — 0.3592, 0.3387, 0.4255, 0.8862, 0.9999, then exactly 1.0000 — with a shallow minimum on the way that is worth knowing before fitting the pair.

A cap cannot identify any of it. A cap is a strip of options on single bonds, so its price depends only on each bond's own volatility and never on the joint law of two maturities. Refitting the fast volatility to hold a one-year to ten-year cap at 3.8% fixed, seven driver correlations from -0.9 to 0.9 give cap prices equal to within 2.4e-15 relative while the one-year-to-ten-year correlation runs from 0.0674 to 0.9965 — seven calibrations a cap market cannot tell apart.

That refit is also where this phase's bug was. A cap price is not monotone in the fast volatility: at a negative driver correlation the bond option variance is a parabola in sigma with its minimum inside the domain, so raising sigma from nothing makes the cap cheaper. A bisection from a floor to a ceiling returned a price a third away from the target while reporting success. fit_fast_volatility searches for its bracket on a grid, takes the crossing from below, and raises NoRoot naming the cheapest attainable price when there is none.

tenor g2 ois.txt --reference 2026-01-15 --basis ACT_365F \
  --start 2027-01-15 --end 2036-01-15 --frequency SEMI_ANNUAL --strike 0.035 \
  --mean-reversion 0.50 --volatility 0.011 \
  --slow-mean-reversion 0.05 --slow-volatility 0.007 --correlation 0.0 --identify

A swap rate paid outside the measure it is a martingale under

Every option above is priced by making the forward swap rate a martingale under the fixed leg's annuity, which is the measure a swaption settles against. A constant maturity swap leaves it: the rate is observed once and paid once, on a single date, so the expectation wanted is under that date's forward measure, and the rate is not a martingale there. The forward read off the curve is a biased forecast of what the contract pays.

tenor.cms values one by static replication. The change of measure carries alpha(S) = P(T, Tp) / A(T) — a function of the rate, not a constant — and everything above that is the Carr-Madan expansion of g alpha around the forward and a quadrature over the swaptions already in the package. So a CMS is valued off the surface a swaption book is marked on rather than off a separate model.

from datetime import date
from tenor import ConstantMaturity, Frequency

fixing = ConstantMaturity(
    expiry=date(2031, 1, 15),      # when the rate fixes
    effective=date(2031, 1, 15),
    maturity=date(2041, 1, 15),    # a ten-year rate
    payment=date(2031, 7, 15),     # paid six months after it fixes
    frequency=Frequency.ANNUAL,
)
swaplet = fixing.swaplet(discount, projection, 0.24)
swaplet.forward         # 0.041053  the forward swap rate
swaplet.rate            # 0.043659  what it pays, in expectation
swaplet.basis_points    # 26.06     the convexity adjustment

A single period reduces exactly, which is the check that carries the file

If the underlying swap has one fixed period and the CMS pays at the end of it, then A(T) = delta P(T, Tp) identically, so alpha is 1 / delta — flat in the rate, every derivative of it zero, and the adjustment identically 0.0. It has to be: that contract is a forward rate paid in arrears and needs no adjustment, as anyone can say without a model. The replication is then the point mass at the kink and nothing else, and it matches tenor.options to 5e-16 across four strikes and both payoffs. It is the only check here that would catch the point mass carrying the wrong sign — the error that cost the two-factor model a call and a put.

The quadrature itself is checked against arithmetic from outside the package. Against that same flat mapping, replicating S**2 must return F**2 exp(sigma**2 T) under Black and F**2 + sigma**2 T under Bachelier. Both come back at rounding.

Widening the ceiling made it worse, by eight orders of magnitude

The second moment is the one functional here sensitive to the far tail, and it is how the panel layout was found out. The lognormal ceiling is F exp(w sigma sqrt(T)), so it moves out exponentially in the width while the panel count stays put: every extra standard deviation coarsens the mesh at the forward, where the integrand lives. With uniform panels the relative error ran 1.4e-09, 3.2e-08, 7.9e-04 and 1.8e-01 as the ceiling went from six standard deviations to twenty — monotonically worse for covering more of the distribution. Spacing the edges evenly in log K holds every ceiling from eight to thirty at rounding, and sixteen panels then reach 5.1e-13.

The flat-curve model is arbitrageable twice, and both are measured

The mapping is evaluated by assuming the curve is flat at whatever level the rate fixes at, which makes P(T, Tp) and A(T) explicit powers of 1 + S/q. Raw, it returns the flat curve's ratio at the forward rather than the real one, and prices a zero-coupon bond 0.88% wrong on the upward-sloping curve in the tests — a quarter of the adjustment it exists to compute. annuity_map scales that away at the forward, and the constant payoff is then still worth 0.217% more than the bond it is, because matching at a point does not match an expectation. The replication divides by the replicated unit payoff, which enforces the one no-arbitrage condition available and moves the adjustment by 3.6%. It is a no-op wherever the mapping is constant or the volatility is zero, so no reduction above is disturbed, and measure_error reports the gap rather than hiding it.

Paying later unwinds the convexity rather than compounding it

This came out against the guess. Moving the payment of that fixing from the fixing date out to ten years past it takes the adjustment 29.28, 26.06, 22.92, 16.83, 5.43, 0.07 and -23.86 basis points — monotonically down, and through zero. The sign is the sign of alpha'(F), and alpha'(F) vanishes where the payment date meets the annuity's own annuity-weighted mean payment time: 5.1872 years here, against a slope that crosses zero at 62 months, which is 5.1667. Structural rather than coincidental — alpha is one discount factor over a weighted sum of them, so putting the payment at the weighted mean makes numerator and denominator respond to the rate alike.

It is a smile instrument, and an upward skew has no answer at all

The adjustment is an integral of swaption prices across every strike, so it reads the whole surface and not the at-the-money point. Against surfaces that leave every at-the-money swaption worth exactly what the flat one does, it comes to 100.0%, 96.9%, 94.3%, 92.1% and 88.5% of the flat number at skews of 0, -0.10, -0.20, -0.30 and -0.50 per unit of rate. An 8% error from a marking choice no at-the-money quote can see.

A surface sloping up in strike is worse than inaccurate. A volatility rising without bound stops the swaption price decaying, so the integral diverges and whatever comes back is a function of the ceiling: the same fixing against a +0.30 skew reads 35.6 basis points at a six standard deviation ceiling and 36565 at fourteen. Every one of those is wrong and none looks it, so Replication.truncation measures the share of the value beyond the ceiling — 5.5e-24 flat, exactly 0.0 against a downward skew, 120% here — and the quadrature's tolerance refuses it rather than returning a number whose only real input was the ceiling.

tenor cms ois.txt --reference 2026-01-15 --basis ACT_365F --forecast forward.txt \
  --expiry 2031-01-15 --tenor-years 10 --delay-months 6 \
  --fixed-frequency ANNUAL --volatility 0.24 \
  --delays 0 6 24 60 120 --skews 0.0 -0.3 0.3

Several exercise dates, and no formula for any of them

Every option above has one exercise date, which is exactly what lets tenor.hullwhite price it twice in closed form: the payoff is a function of the short rate at that date, and both routes integrate it against that rate's density. A Bermudan swaption breaks the structure rather than complicating it. The value of not exercising today is the value of still holding the right tomorrow, so the quantity being integrated is the answer to the same problem one date later, and there is nothing to write down.

tenor.bermudan runs the recursion on a Hull-White trinomial tree built from the same (a, sigma) the Europeans use, fitted to the curve by forward induction. Exercise dates may be given as the unadjusted anniversary or as the business day it rolls to.

from datetime import date
from tenor import Basis, HullWhite, flat_curve
from tenor.bermudan import BermudanSwaption, bermudan_swaption

reference = date(2026, 1, 15)
curve = flat_curve(reference, date(2046, 1, 15), 0.03, basis=Basis.ACT_365F)

option = BermudanSwaption(
    expiries=tuple(date(year, 1, 15) for year in range(2029, 2036)),
    maturity=date(2036, 1, 15),
    strike=0.03,
)
value = bermudan_swaption(curve, HullWhite(a=0.05, sigma=0.01), option, reference)

value.price           # 0.041895
value.best_european   # 0.032385 -- the best single date, priced in closed form
value.switch_value    # 0.009510 -- 29.4% of it, which is what the dates buy

The switch value is the number, not the price

A Bermudan is worth at least the best of its co-terminal Europeans, because exercising only on that one date is a strategy available to it. So the price on its own does not say whether a desk is paying for optionality or for a European with extra steps; the excess over that best single date does. Here the seven annual dates are worth 29.4% on top of the best of them.

Rounding to the nearest node removes the cap on the tree

The textbook Hull-White tree branches up-mid-down from a fixed offset and needs a cap on its width, because far from the centre the mean reversion pulls the conditional mean past the neighbouring node and a probability turns negative. Branching instead to the three nodes around each node's own conditional mean bounds the residual drift by half a spacing, which keeps every probability positive everywhere. Nothing is truncated, so total state-price mass at each step is the discount factor to it rather than a share of it, and the width stops growing on its own once the drift pulls back a full node.

Exercise dates land on nodes, which equal steps cannot deliver

An exercise decision is a kink in the value function, and a time step that straddles one smears it. Exercise dates are swap anniversaries whose day counts differ by a day or three, so a single spacing is shared across steps of slightly different length and each gap is split into whole sub-steps. Positivity then needs the step variance between a quarter and three quarters of dx**2 — the uniform case sits at a third — and the bound is checked against the grid produced rather than assumed from the request.

Forward induction recovers the convexity term

Fitting one rate per step to reproduce the next zero looks like a statement about the step's average instantaneous forward, which is a curve quantity with no model in it. The fitted shift sits above that average by exactly the second term of phi(t): 8.9e-04 against an analytic 8.9e-04 at a = 0.05, sigma = 0.01 and five years, agreeing to 1.3e-05 relative at four steps a year and 9.0e-08 at forty-eight, and identically on a flat, a sloped and a humped curve. The discount-weighted spread of the state across a step is that convexity, so fitting a discount factor finds it.

What is exact, and what is only first order

Two things are identities and are tested as such. The grid's zeros reprice to 2.2e-16. And a model with no volatility has no decision to make, so the price collapses to the best discounted intrinsic value computed off the curve alone — to 5.9e-17 across twelve curve, payoff and mesh combinations.

The rest is first order in the step, and it is visible without going through a price. State prices at a node, weighted by analytic prices of a bond that outlives the grid, fall short of today's discount factor by 9.7e-05 at twelve steps a year and halve at every doubling: a ratio of 2.00 each time, the same on all three curves, always negative. forward_bond_error reports it, which is why it is the diagnostic to read when choosing a step density.

A single mesh does not measure convergence

The exercise boundary does not fall on a node and moves between nodes as the mesh changes, so the error against the analytic European oscillates in sign and size. At 48 steps a year this instrument came within 1.9e-06 of the analytic price while the worst case over neighbouring meshes was 1.8e-05, and a convergence ratio read off that point came out at -865. Over bands the worst case runs 4.8e-05, 1.8e-05 and 6.5e-06 across 24-40, 48-64 and 96-112 steps a year.

The boundary falls on a flat curve, and need not elsewhere

The exercise boundary — the short rate at which taking the swap stops being worse than waiting — runs 3.92%, 3.80%, 3.68%, 3.54%, 3.40%, 3.21%, 2.95% across the seven dates above. A shorter remaining swap gives up less by being taken, so the bar falls. That reasoning leaves out where the forwards are going: on a curve rising 15 basis points a year the boundary reverses once, from 3.128% to 3.212%, and on a humped curve it rises over four consecutive dates. Only the last date is reliably the lowest bar.

Payer-receiver parity is the identity to not reach for

A European payer less a European receiver is the forward swap exactly, since the two payoffs are the positive and negative parts of one number. Two Bermudans are maxima over different stopping rules, and a difference of maxima is not the maximum of a difference: measured at 0.00212 against a swap worth 0.00136. Using it as a check would report a 56% pricing error on correct prices.

tenor bermudan ois.txt --reference 2026-01-15 --basis ACT_365F \
  --expiry 2031-01-15 2032-01-15 2033-01-15 2034-01-15 \
  --swap-maturity 2036-01-15 --mean-reversion 0.05 --volatility 0.01

Reading a quote convention back into a model input

Cap.value takes one volatility for the whole strip, which is how a cap is quoted and not how anything is priced. tenor.stripping is the inverse: a bootstrap in maturity where each longer cap's new periods get one volatility from a root solve, while the earlier periods stay where previous steps put them.

tenor caplets ois.txt --reference 2026-01-15 --basis ACT_365F \
    --caps caps.txt --effective 2026-01-15 --strike 0.035 --basis-spread 0.0020
reference: 2026-01-15
strike: 0.035
buckets:
  maturity=2027-01-15  periods=3  quoted=0.18  repriced=0.18  caplet=0.18        sensitivity=1.000  indeterminacy=9.6e-14
  maturity=2028-01-15  periods=4  quoted=0.20  repriced=0.20  caplet=0.2075780   sensitivity=1.385  indeterminacy=2.5e-13
  maturity=2029-01-15  periods=4  quoted=0.22  repriced=0.22  caplet=0.2419188   sensitivity=2.095  indeterminacy=5.3e-13
  maturity=2030-01-15  periods=4  quoted=0.24  repriced=0.24  caplet=0.2764117   sensitivity=2.830  indeterminacy=8.1e-13
  maturity=2031-01-15  periods=4  quoted=0.26  repriced=0.26  caplet=0.3120089   sensitivity=3.631  indeterminacy=1.1e-12
worst_repricing_error: 1.942890293e-16

The repriced column is the only check on the bootstrap that does not go through another approximation, and it costs one solve per quote. The other two columns are the reason this command exists.

The stripped curve outruns the quotes

A flat volatility is a premium-weighted average of the caplet volatilities under it, not an average of the volatilities themselves, so a rising quote curve needs a caplet curve that rises faster. Quotes of 18% to 26% strip to 18.0% to 31.2%: the far bucket sits 5.2 volatility points above the quote that set it, and the slope between the last two buckets is 1.77 times the slope between the last two quotes. The first bucket's volatility is its quote exactly — a one-bucket strip has nothing to average — which is the one row that can be checked by hand.

A hump is worse than a slope. Quotes of 18, 26, 30, 26 and 22 per cent strip to 18.0, 29.3, 34.5, 18.6 and 11.5, so a four-point fall in the quotes is a sixteen-point fall in the buckets: the later periods have to undo an average the earlier ones are holding up.

Monotone is not steep, and that is the trap at both ends

The premium rises with the volatility everywhere, so a bracket on [0, ceiling] always contains a root and a solver always returns something.

At the far maturities it returns something badly conditioned, which the sensitivity column reports: a basis point on the one-year quote moves its bucket by 1.00 basis points, and a basis point on the five-year quote moves the last bucket by 3.63, because each bucket is a smaller share of its cap's premium than the last.

Deep in the money it returns nonsense, which is what the indeterminacy column is for. An option eleven standard deviations into the money is worth its intrinsic value to the same double, so every volatility explains the quote equally well; a bucket whose repricing set is wider than one basis point of volatility is refused as unidentified rather than reported. Measured at an 18% quote against forwards of 3.23% to 3.39%, the answer is pinned to 1.2e-13 at a strike of 3.5%, 1.3e-09 at 2%, 6.2e-06 at 1.5%, and the lowest strike the quote says anything about at all is 1.3901%.

That guard is also what stops a falling quote curve, which was the surprise. Holding the one-year quote at 18% and walking the two-year quote down, the strip survives a fall of 1202 basis points and not 1203 — and at the last quote it accepts, the bucket's indeterminacy has come within 0.3% of the 1.0e-04 limit, with the premium unattainable one step below. The two refusals coincide because they are one statement measured two ways. The usual worry about a sequential volatility bootstrap is a negative variance, and that is not what binds.

The least obvious consequence: a zero quote identifies a zero volatility only exactly at the money. Vega vanishes with the volatility away from the strike, so an interval of volatilities all produce the intrinsic value. At the money the premium is linear in the volatility and zero is pinned to 2.8e-16.

Four quantities, one identity, and the residual the market quotes anyway

Everything above prices in a single currency. A foreign exchange forward is not a model: borrow domestic, buy foreign spot, lend foreign, sell the proceeds forward, and the four prices have to multiply to one. So F = S D_f / D_d, and the currency with the higher rate has the smaller discount factor and therefore trades at a forward discount — a high interest rate is not a reason to expect appreciation, it is the reason the forward is below the spot.

Which means two curves, a spot and a strip of forwards over-determine the system. tenor.fx computes any of the four from the others, and when all four are given it names what is left over: the cross-currency basis.

tenor fx usd.txt --reference 2026-01-15 --basis ACT_365F \
    --foreign-quotes eur.txt --spot 1.10 --spot-date 2026-01-19 \
    --forward 2027-01-15:1.1172 2031-01-15:1.1932
spot: 1.1
spot_date: 2026-01-19
forwards:
  date=2027-01-15  outright=1.116132299  points=161.3229935  lag_pips=1.84309719
  date=2031-01-15  outright=1.183841302  points=838.4130218  lag_pips=1.95490676
basis:
  date=2027-01-15  time=1               basis_bp=-9.561503525
  date=2031-01-15  time=5.002739726     basis_bp=-15.73993838
par_spread_bp: -15.58936815
par_spread_accrual: ACT/360
periods: 20

The implied curve is built by inversion, not by bootstrapping. A swap bootstrap solves each pillar because a swap's value depends on every pillar before it; a forward's does not, so each pillar here is one division. There is no solver, no iteration count and no way to fail to converge — and the round trip from a forward to a discount factor and back returns the same float, not a close one.

The lag column is the point of printing it

A foreign exchange spot trade settles two business days out, so parity runs between the spot date and the forward date and both discount factors have to be taken relative to the spot date. Skipping it leaves the forward still looking like a forward.

What makes that dangerous is that the error barely moves with tenor: on the example above it is 1.82 pips at three months, 1.84 at one year and 1.95 at five. Against the forward points that is 4.7% at three months, 1.1% at one year and 0.23% at five. The convention matters most exactly where the forward is cheapest and most actively quoted, and it is invisible at the long end where somebody checking the code would look first.

The same lag is the one thing a strip of forwards cannot determine. Parity fixes the ratio of foreign discount factors from the spot date, not their level, so building a curve from today needs the foreign discount factor to the spot date from elsewhere. Defaulting it to the domestic one — the two currencies discounting alike over two days — costs 1.47 basis points of implied basis at one year and 0.29 at five, because the error enters divided by the maturity. So it is an argument, with the cost of its default measured rather than waved at.

The par spread comes out of a telescoping identity

A floating leg projected and discounted on the same curve, with notional received at the start and repaid at maturity, is worth exactly nothing: each coupon is the difference of two neighbouring discount factors and the sum collapses onto the two ends. A cross-currency leg breaks that by projecting on one curve and discounting on another, and the spread that restores it is the leftover divided by the annuity. One expression, no bracket, no iteration — and between a curve and itself the numerator telescopes to 1e-16, which is the identity rather than a tolerance.

It is a spread on an accrual, and two corrections pull opposite ways

Against a flat continuously compounded basis of 25 basis points, a five-year quarterly leg accruing on ACT/365F pays 25.196 — +0.785%, the whole of the compounding effect, since a spread paid quarterly is worth more than the same number compounded continuously. Move the accrual to ACT/360, which is what a dollar leg uses, and the accruals grow by 365/360 so the spread shrinks by 1.37%: 24.851, or -0.595%. The net is smaller than either part, and quoting it alone would leave a reader thinking the correction had one sign.

The gap is close to proportional in the basis — -0.0614bp at 10, -0.1488 at 25, -0.5020 at 100 — which is how a convention is told from an approximation.

A term structure is averaged over the forward basis, not the zero basis

With a zero basis rising linearly from 10 to 40 basis points over five years, the forward basis is twice as steep at the long end: differentiating b(t) t adds t b'(t), so it reaches 70. The par spread is the annuity-weighted average of that, and comes out at 39.02 basis points. A reader who averages the zero basis over time gets 25, understating by 36%; one who reads its short end gets 11.5, understating by 70%. That is the reason the function exists rather than a subtraction of two zero rates.

One failure needed a message of its own

The last forward's date is the swap's maturity, and a maturity on a weekend rolls forward — so the final payment lands a day or two past the pillar that forward itself created, where both curves have nothing. Shortening the swap by a day would price a different trade under the right name, so the command names both dates and stops.

A rate read every day, and the argument about which days

A term floating leg fixes once per period. An overnight leg reads a rate every business day and compounds it over the accrual, which raises a question the term leg never has to answer: which days? The accrual's own are the obvious answer and nobody uses them unmodified, because the last fixing would be published on the payment date. So the window moves — a lookback, an observation shift, a lockout — and how it moves is a term of the trade.

from tenor import Observation, OvernightIndex, compounded_rate, replication_factor

start, end = date(2021, 3, 16), date(2021, 6, 16)
plain = compounded_rate(curve, start, end)

plain.rate                 # the compounded rate, over the accrual's own day count
plain.days                 # 66 business days observed
plain.weight               # their weights, summing to plain.accrual

# Replicated by two zero-coupon bonds. Rounding on any curve, not a tolerance.
plain.growth / replication_factor(curve, start, end, plain.index) - 1.0

lagged = OvernightIndex(observation=Observation.LOOKBACK, days=5)
compounded_rate(curve, start, end, lagged).rate - plain.rate    # the lag, as a rate
replication_factor(curve, start, end, lagged)                   # refused: no such pair

The plain case is an identity, not an approximation

A projected overnight rate is P(a)/P(b) - 1 over its day count, so each daily growth factor is a ratio of discount factors and the product telescopes to P(start)/P(end). Compounded in arrears needs no convexity adjustment of any kind — and that is asserted at 4.4e-16 relative on a curve with a 50bp rate step inside the period, not to a chosen tolerance.

An observation shift keeps the identity over the shifted window, to 6.7e-16. A lookback pairs one day's rate with another day's weight and a lockout repeats a factor, so neither telescopes at all. replication_factor refuses them rather than returning an approximation, because which conventions are replicable is a structural fact and not a question of how large a difference is.

A smooth curve hides the entire subject

The size of a window convention is the curve's local slope times the lag. On a curve built by interpolating zero rates, a five-day lookback over three months is worth 0.000bp and a two-day one 0.007bp: the conventions look like pedantry. Put a 50bp policy step in the overnight forward inside the period and the same five-day lookback is worth -3.84bp, a ten-day one -7.67bp. The conventions exist because of the steps.

Lookback and observation shift coincide at the lag everyone uses

The difference between them is entirely in the weights. Five business days is exactly seven calendar days on a weekends-only calendar, so not one of the sixty-six days in the period carries a different weight and the two conventions agree bit for bit — at five days and at ten. At two, three and four days, 26 of the 66 do, with the total untouched, and the same curve gives -1.10bp for the lookback against -2.19bp for the shift. A factor of two, from a redistribution that the usual lag choice conceals. At one day the total itself moves, by two calendar days' worth.

A lockout is worth nothing unless the step is inside the locked window

Exactly 0.0000bp at every lag with the step mid-period, because the frozen days all carried the same rate anyway. Move the period so the step falls five business days before its end and the same lockout is worth -4.46bp, identically at five and ten days. Quoting a lockout's effect without saying where the step is says nothing at all.

Compounding against averaging is r^2 T / 2

An arithmetic average is a different contract, not a worse estimate of the same one, and the gap has a closed form: 1.33bp against 1.37bp predicted at three months, and 1.10bp, 2.76bp and 7.22bp at three, six and twelve months on the smooth curve. The closed form is high by 2.1% to 3.0% in all six measurements, always the same way — the third-order term it drops. The gap grows with the square of the tenor, which makes an averaged leg quoted against a compounded one a level-dependent basis rather than a spread.

At the level of a leg they compress, and one of them moves no rate at all

$ tenor overnight examples/ois.txt --reference 2026-01-15 --basis ACT_365F \
    --start 2026-03-16 --end 2026-06-16 --maturity 2029-02-02
...
conventions:
  convention=in arrears  rate=0.03009786979  basis_points_against_arrears=0  telescopes=True  replication_error=2.220446049e-16
  convention=lookback 5d  rate=0.02997416464  basis_points_against_arrears=-1.237051526  telescopes=False
  convention=observation shift 5d  rate=0.02997416464  basis_points_against_arrears=-1.237051526  telescopes=True  replication_error=2.220446049e-16
  convention=payment lag 5d  rate=0.03009786979  basis_points_against_arrears=0  telescopes=True  replication_error=2.220446049e-16

On a three-year quarterly leg across a single 50bp step, only one period of twelve spans it, so the -3.84bp a five-day lookback is worth inside that period becomes -0.337bp as a spread — the same for the shift, -0.241bp for the lockout, -1.493bp for arithmetic averaging. A five-day payment delay is worth -0.236bp while changing every period's rate by exactly zero, which is the clearest demonstration available that it belongs in a different group from the other four.

Which convention is worth most is a property of the curve rather than of the convention: on the bundled quote curve, steep and at 20 to 220 basis points, the window conventions dominate the averaging basis, and on a 3% curve with a step the averaging basis dominates them.

A lookback leg wants fixings from before its own effective date

The first period's first observation is the lag's worth of business days before the accrual starts, so a leg traded on its effective date is asking for rates published before the trade existed. Refused rather than filled with the first projectable rate, which would be silently wrong by the whole move since the window opened.

The value of the short's option, not just its geometry

delivery_switch walks a flat yield across a deliverable basket and says which bond is cheapest at each level. Its own docstring said what that was and was not: "the geometry of the short's option, not its value. Valuing it needs a model of how the curve moves between now and delivery." This puts a measure on that line. The yield level at delivery is normal around a forward, each bond keeps its own spread to it, and the futures price is the expectation of the lowest price over conversion factor.

tenor futures basket.csv --settlement 2026-11-20 --first-delivery 2026-12-01 \
    --delivery 2026-12-31 --price 118.75 --repo 0.042 \
    --volatility 0.009 --forward-level 0.059

Two effects, and their sum is not a delivery option

The right to choose can only lower the price: a minimum over the basket is at most any member of it. But each bond's price is convex in its yield, so letting the level move raises the expectation of whichever bond is delivered — and that has nothing to do with having a choice. On a basket of one the switch term is exactly zero and the net is positive.

On the long basket, at a forward level sitting on its crossing:

yield vol deviation at delivery convexity switch net in 32nds
20bp 6.8bp +0.0061 -0.0806 -0.0745 -2.4
50bp 16.9bp +0.0383 -0.2140 -0.1757 -5.6
90bp 30.4bp +0.1241 -0.3984 -0.2743 -8.8
200bp 67.5bp +0.6148 -0.9465 -0.3316 -10.6
400bp 135.0bp +2.4921 -2.1196 +0.3725 +11.9

The switch is roughly linear in the deviation and the convexity quadratic, so the convexity overtakes it and the net changes sign. It changes sign in the level too — twenty basis points either side of the crossing one bond is cheapest with probability 0.999 and the net is positive:

forward level cheapest net largest delivery probability
3.0% 6.250% of 2047 +0.1535 1.0000
5.9% 1.750% of 2046 -0.2743 0.5047
7.0% 1.750% of 2046 +0.1004 0.9999

So "the delivery option is worth X" as a single number is a sign error waiting to happen, and the two terms are reported apart for that reason.

A tenth of a basis point, and what the published rounding does to it

The intermediate bonds of this basket are barely deliverable. With the conversion factors unrounded, the 4.500% of 2047 is cheapest inside a window 1.195 basis points wide; with the published four decimal places, 0.131 basis points — the rounding narrows it by a factor of nine, and its chance of being delivered from 1.57% to 0.17%. The 3.000% of 2046 is never cheapest at either rounding. A four-bond basket has two live members.

The kinks are solved for rather than scanned, which is what makes those numbers stable. Every candidate is a crossing of some pair of normalised prices, each pair's difference brackets a sign change over the range, and bisection finds it exactly; the envelope's kinks are the subset at which the argument of the minimum actually changes. The first implementation scanned the envelope on a grid, and a grid fine enough to resolve a tenth of a basis point at one volatility is not fine enough at another — so the reported kinks, and the delivery probabilities that come from them, moved with the volatility for no reason but the grid.

Priced at delivery, which is not where the geometry is priced

delivery_switch prices the basket at settlement, because it is describing the basis a desk is looking at today. The choice is made at delivery and the invoice is computed there, so this prices it at the delivery date — and the two sets of crossings are nine to ten basis points apart on this basket, against a window a tenth of a basis point wide. Borrowing one date's crossings for the other's measure would put every region in the wrong place.

Where the usual rule earns less than usual

Putting every discontinuity on a quadrature panel edge is the house rule, and here the regions have to be found anyway — the delivery probabilities are differences of the normal distribution function over them, exact rather than quadrature. But it is worth saying what the alignment buys, because it is much less than usual: the kink is in the first derivative only, and the two bonds that cross have nearly the same slope where they do, so a uniform grid that straddles the kinks is wrong by about 2e-6 of a price point rather than the 35 to 700 times a jump in the value has cost elsewhere in this library. What it does cost is monotone convergence: the uniform grid's error at 24, 48, 96, 192 and 384 panels is -2.3e-6, +1.6e-8, +7.7e-7, +4.7e-7, +4.5e-8, so a two-point convergence check on it reports the method diverging.

A curve with fewer parameters than quotes

Everything above passes exactly through its inputs. bootstrap solves one pillar per instrument, so the parameter count equals the quote count and the fit is an identity. That is right for a swap curve and wrong for a government bond curve, where thirty bonds of differing coupon, age and liquidity disagree with each other and no curve prices all of them.

tenor fit gilts.csv --reference 2026-01-15 --settlement 2026-01-20 --basis ACT_365F

Ten quotes, four parameters:

root_mean_square: 0.0001716
worst: 0.0002855 on 4.000% of 2031-07-15
decays: 2.704
decays_lowest: 2.026   decays_highest: 3.516   decay_ratio: 1.735
level_spread: 0.00277, 0.00744, 0.00565

fit_zeros fits observed zero rates and fit_bonds fits clean prices; ParametricCurve.to_discount_curve samples the form onto pillars, so bond analytics, key rates and spreads work on a fitted curve without knowing it was fitted.

The integral, not the average

The zero rate is conventionally written as the average forward, which carries a (1 - exp(-x)) / x and a division by zero at the anchor. Discounting wants the integral, which is that average times t and has no division in it. So the integral is the primitive here and the zero rate is derived from it: the anchor of every curve stops being a special case. Checked against a Simpson rule on the instantaneous forward to 1e-13.

Coordinate sweeps cannot fit a Svensson curve

With the decays held fixed the levels are linear, so each candidate decay is one least-squares solve and only the decays need searching — which made the search look easy and is not. Svensson's objective has a diagonal valley: lengthening one decay while shortening the other barely moves the curve. Fitted to its own exact zero rates, coordinate-wise golden section returns decays of 1.73 and 5.99 against a truth of 1.6 and 9.0, at a root mean square of 3.2e-05 that reads as a respectable fit. The objective at the truth is 1e-17. The refinement ends in a simplex over the log decays, which finds it.

The failure mode is worth the sentence: not a crash, not an obviously wrong number, a plausible parameter set. Generating the data from a known curve is what exposes it.

The curve is determined; the parameters are not

Every fit reports the band of decays that reprice the quotes within a stated tolerance, found by stepping outward from the optimum and bisecting:

tolerance decay band factor level travel
0.1bp 2.36 to 2.63 1.11 0.0006 to 0.0022
1bp 1.92 to 3.06 1.59 0.0022 to 0.0069
10bp 0.10 to 10.77 104 0.028, then 4,700

Read the last row. Across a band every point of which reprices the same ten quotes within ten basis points, the level parameter moves by 0.028 and the slope and hump coefficients move by about 4,700 — because a decay going to zero and a slope coefficient going to infinity are the same curve. A fitted b1 quoted as "the slope of the curve" is a number nobody measured.

What the conditioning costs

Svensson's two humps become the same function as the decays converge: condition number 3.1e+04 with them a thousandth apart. The normal equations work at the square of that and lose nine digits — recovering a known parameter set, the Householder reflections are wrong by 6.4e-14 and the normal equations by 6.6e-10. Four orders of magnitude on a matrix with four columns.

It is not monotone in the ratio of the decays either: 3,125 at a ratio of 1.01, 46 at 3.0, 239 at 8.0. The humps coincide at one end and the second hump leaves the quoted range at the other, so a sweep that stopped at one end would conclude the form is fine.

Weighting, in both directions

Equal weight on price errors is not equal weight on yield errors, because a one-year bond's yield moves its price a fortieth as much as a thirty-year bond's. On the same quotes the worst yield error is 3.94 basis points under PRICE and 2.60 under YIELD, nearly all of the difference at the one-year point — and the worst price error goes the other way, 0.063 to 0.120 per 100. Yield weighting buys the short end by giving up the long. Both figures are tested, because the first alone reads as free.

A contract nobody discounts, and the measure that leaves in

A deposit, a forward rate agreement and a swap are discounted. A future is margined every day in cash, so its settlement rate is a martingale under the risk-neutral measure rather than under the measure that discounts its own accrual period. The gap is the convexity adjustment, and Future has always taken it as an argument because the sign is not a modelling choice: margin pays the holder when rates rise, which is worth something.

tenor convexity quotes.txt --reference 2021-01-04 --basis ACT_365F \
  --mean-reversion 0.03 --volatility 0.01 \
  --start 2021-03-17 2021-12-15 2023-03-15 2025-03-19 2028-03-15

A strip rather than a contract, because one contract cannot show the thing worth seeing:

start       T1      term    compounded   averaged   ho_lee   term/ho_lee
2021-03-17  0.197   0.067     0.088        0.054     0.044      1.521
2021-12-15  0.945   0.650     0.670        0.555     0.563      1.154
2023-03-15  2.192   2.732     2.753        2.509     2.678      1.020
2025-03-19  4.205   8.633     8.654        8.251     9.373      0.921
2028-03-15  7.197  22.200    22.221       21.614    26.807      0.828

convexity_adjustment returns the total and the two pieces it is made of; expected_settlement_rate and forward_settlement_rate are the two rates behind it. Everything is exact — under Hull-White the short rate is Gaussian and both the bond price and the integral of the rate are exponentials of Gaussians, so each expectation is a lognormal mean and nothing is expanded.

0.5 sigma^2 T1 T2 is low at the front and high at the back

The last column above crosses one between the third contract and the fourth. ho_lee_convexity is the adjustment to a continuously compounded rate, and the contract settles on a simply compounded one, so the Jensen term is missing — in the zero-reversion limit the two pieces stand in the ratio 1 + T1/L, which is a 50% understatement for a one-year accrual one year out and 4.8% for a quarterly one five years out. Pulling the other way, Ho-Lee has no mean reversion in it, so it overstates wherever the rate reverts: at a = 0.3 a five-year contract's exact adjustment is 27% of the Ho-Lee figure. The crossover sits at T1 = 5.31 years when a = 0.01, 2.82 when a = 0.03 and 1.39 when a = 0.1, all three found by bisection rather than read off a grid.

Three settlement conventions, and only one of them is linear

A term rate fixed at the start and a rate compounded in arrears share a value under the end-of-period forward measure and not under the risk-neutral one. The compounded one never adjusts less and barely adjusts more: 11.952 basis points against 11.931 for a quarterly contract five years out, on a flat 4% curve at a hundred basis points.

An arithmetic average is linear in the rate, so curvature contributes nothing and its whole adjustment is the drift, 11.285 basis points at the same point. Its reference is also different, and that matters more. The simple forward over a quarterly window five years out sits 2.01 basis points above the continuously compounded forward over the same window, where the convexity adjustment one year out is 0.73. Taking the wrong reference for an averaging contract is a larger error than ignoring convexity altogether anywhere inside two years, so forward_settlement_rate picks it from the convention.

Measured on a curve, not on a quote

Twenty quarterly contracts generated from a known flat 4% curve, then rebuilt from the prices alone. With the adjustment supplied the bootstrap returns the curve it started from. With it left at zero the five-year zero comes out 3.99 basis points high, and the error accumulates monotonically along the strip — 0.20, 0.74, 1.56, 2.65, 3.99 at one through five years — where the largest single adjustment in the strip is 11.6. The curve error is neither the largest adjustment nor the first one, which is why checking one contract would have been reassuring and wrong.

Development

pip install -e ".[dev]"
pytest          # 1535 tests
mypy --strict
ruff check .

Continuous integration runs the suite on Python 3.10 through 3.13, type-checks, lints, and installs the wheel and the sdist into separate clean environments to confirm each can produce a number — a distribution that imports but cannot compute is not a working library, and the two artefacts are built by different code paths, so a file missing from one can be present in the other.

Releasing

The version lives in pyproject.toml and is mirrored by tenor.__version__; a test asserts the two agree, and the release refuses to run if the tag disagrees with either.

git tag v0.1.0
git push origin v0.1.0

The tag builds both artefacts, has twine read the metadata the way the index will, installs each into a clean environment and bootstraps the bundled quote screen from it, and then publishes through the index's trusted-publishing flow — so there is no API token in this repository, in the workflow, or in the repository's secrets. Registering the publisher is a one-time step done on the index, naming this repository, release.yml and the pypi environment.

Licence

MIT.

Metadata

Release files for tenor-curves 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tenor-curves 0.1.0
File Size Uploaded Provenance
tenor_curves-0.1.0.tar.gz 573.0 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for tenor-curves 0.1.0
File Interpreter ABI Platform
tenor_curves-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 880.7 kB

Release files / tenor_curves-0.1.0.tar.gz

Download URL tenor_curves-0.1.0.tar.gz
Size 573.0 kB
Tags Source
SHA-256 checksum
How to use checksums
cc396f9fd046d4d650376bb10b815bb54486c9c1d12611bbc9473fe1a14bdfd5
BLAKE2b-256 checksum
How to use checksums
52260b6e63922d12c10474e4777b434f71bf6787e5bde8c80c8bb29a178da455
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / tenor_curves-0.1.0-py3-none-any.whl

Download URL tenor_curves-0.1.0-py3-none-any.whl
Size 307.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
67bdf1377f126307143490cea06d6b9a8f164c8f8b6bcdf9177cd9bb9a5d63d0
BLAKE2b-256 checksum
How to use checksums
e6dab43b1ace7ce6f40bec9eef7b7f260c9b38cc9ce931bdcf4495704073d2a6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for tenor-curves 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for tenor_curves-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page