FinanceModels 7, ActuaryUtilities 6, MortalityTables 3 and FinanceCore 3

JuliaActuary is an ecosystem of packages that makes Julia the easiest language to get started for actuarial workflows.
code
blog
Author

Alec Loudenback

Published

September 30, 2026

WarningDraft: these releases are not yet available

FinanceModels 7.0, ActuaryUtilities 6.0, MortalityTables 3.0 and EconomicScenarioGenerators 0.7.1 are not yet released, so installing the packages today gives the previous versions (only FinanceCore 3.0 is released). The code in this post ran on the release candidates, and details may change before release.

Four major JuliaActuary releases bring sensitivities through curve calibration, curve blending, more consistent risk measures, and corrections to discounting and mortality calculations. Some changes alter the numbers existing models produce: portfolio values, the sign of DV01, zero rates beyond a curve’s last knot, and the rates of eight SOA tables. Others simplify the APIs or improve performance. This post explains what changes, why, and how to migrate.

FinanceModels 7.0, ActuaryUtilities 6.0, MortalityTables 3.0 and FinanceCore 3.0 are major releases with breaking changes; FinanceCore 3.0 includes the fixes of FinanceCore 2.8. Migration guides and release notes are linked under Upgrading.

  1. New capabilities
  2. Changes affecting existing models
  3. Performance and tradeoffs
  4. Upgrading

All the code below runs as part of this post.

using FinanceModels, FinanceCore, ActuaryUtilities, MortalityTables, ForwardDiff

New capabilities

Sensitivities through calibration (FinanceModels)

You can now measure how a valuation changes when the market quotes used to calibrate its curve change. Spline fits support ForwardDiff differentiation through those quotes. Here is a 20-year level liability valued on a curve fitted to five zero-coupon yields:

tenors = [1.0, 2.0, 5.0, 10.0, 30.0]
rates = [0.040, 0.042, 0.045, 0.047, 0.050]
cfs, times = fill(10.0, 20), 1.0:20.0

liability(r) = pv(fit(Spline.Linear(), ZCBYield.(Continuous.(r), tenors)), cfs, times)

liability(rates)
126.65633549555342

Its gradient with respect to the five quotes:

∂pv = ForwardDiff.gradient(liability, rates)
5-element Vector{Float64}:
   -9.607894391523232
  -47.14967754400351
 -172.05932550264362
 -693.8427485681342
 -204.15905202464222

These first-order sensitivities come from the fitted pricing equations through the implicit function theorem. They require no finite-difference bump size and preserve the ordinary fit’s value.

A bond’s yield to maturity is defined implicitly, as the rate at which present value equals price. To see how the yield moves with the price, you don’t rerun the root-finder: differentiating PV(y) = P at the answer gives dy/dP = 1 / PV′(y), the familiar duration relationship. A curve fit is the same idea with many quotes at once. After fitting, FinanceModels differentiates the repricing equations and solves for the quote sensitivities, avoiding a separate refit for each bumped quote. Their accuracy depends on the fit’s tolerance and conditioning, which FinanceModels checks.

A central finite difference agrees closely:

bump(i, h) = rates .+ h .* (eachindex(rates) .== i)
fd = [(liability(bump(i, 1e-6)) - liability(bump(i, -1e-6))) / 2e-6 for i in eachindex(rates)]
maximum(abs, fd .- ∂pv)
7.723339763288095e-6
Plotting code
fig = Figure(size = (720, 380))
ax = Axis(fig[1, 1];
    title = "Change in the liability's value per +1bp in each quote",
    xlabel = "Quoted maturity (years)", ylabel = "Δ value",
    xticks = (eachindex(tenors), string.(Int.(tenors))))
barplot!(ax, eachindex(tenors), ∂pv .* 1e-4)
fig

The liability’s cashflows end at 20 years, yet the 30-year quote matters: the linear curve between the 10- and 30-year knots depends on both.

Calibration sensitivities support loss fits with each spline interpolation method, linear bootstraps, and FX forward curves with a spline foreign curve. The fit must pass repricing and conditioning checks, and its derivative must exist. Higher-order calibration derivatives and derivatives with respect to quote maturities or cashflow times are not supported. The Sensitivities Through Calibration guide explains the restrictions, including interpolation kinks.

implied_quote (FinanceModels)

implied_quote(curve, family, maturity) returns the rate or price a curve implies for an instrument of the requested maturity. It also supports derivatives with respect to the curve:

curve = ZeroRateCurve(rates, tenors)
implied_quote(curve, CMTYield, 7.0)
0.04626336826742401

The family can be a quote constructor such as CMTYield, OISYield, ZCBYield or ZCBPrice, or a closure such as (r, t) -> ParYield(r, t; frequency = 1).

Extrapolation policies (FinanceModels)

Curves built on DataInterpolations (Spline.Linear(), Quadratic(), Cubic(), PCHIP(), Akima(), BSpline(d)) take an extrapolation policy for the long end:

  • :flat_forward (the default) holds the last discrete forward constant;
  • :flat_zero holds the last zero rate;
  • :linear extends the zero rate linearly;
  • Yield.FlatForwardAt(rate) holds a forward you choose;
  • :extension continues the last interpolation piece, as 6.x did.
c = ZeroRateCurve(rates, tenors, Spline.Cubic(); extrapolation = Yield.FlatForwardAt(Continuous(0.035)))
forward(c, 40.0, 60.0)
Continuous(0.03500000000000001)

Here are the one-year forwards of the same cubic curve under each policy, by the start of each year. The curves agree through the 30-year knot; one-year forwards begin to differ when their end date passes it. Beyond it, 6.x’s :extension keeps climbing:

Plotting code
policies = [
    ":flat_forward (default)" => :flat_forward,
    ":flat_zero" => :flat_zero,
    ":linear" => :linear,
    "FlatForwardAt(3.5%)" => Yield.FlatForwardAt(Continuous(0.035)),
    ":extension (6.x)" => :extension,
]
starts = 0.0:0.25:59.0
fig = Figure(size = (720, 420))
ax = Axis(fig[1, 1]; title = "One-year forward rates beyond the last knot",
    xlabel = "Start of the one-year period (years)", ylabel = "Forward rate")
for (label, policy) in policies
    ci = ZeroRateCurve(rates, tenors, Spline.Cubic(); extrapolation = policy)
    lines!(ax, starts, [rate(forward(ci, s, s + 1)) for s in starts]; label)
end
vlines!(ax, [30.0]; color = :gray, linestyle = :dash)
axislegend(ax; position = :lt)
fig

One interface for knot curves (FinanceModels)

Knot curves now share an interface for inspecting and updating their rates and tenors. ZeroRateCurve returns the concrete curve it builds: a Yield.MonotoneConvex for the default interpolation, a Yield.Spline otherwise. Use knot_rates (the knot zero rates, as Continuous rates) and knot_tenors to inspect it, and reconstruct(curve; rates, tenors, spline, extrapolation) to build a modified copy.

This matters when you start from a curve someone else built, such as a vendor’s valuation-date curve, a scenario generator’s or another team’s, and need variations of it: stress tests, sensitivity runs, or a roll-forward to new rates. Rebuilding the curve by hand from its rates can drop settings the rates don’t show. A different interpolation method moves values between the knots, and a default tail moves every value beyond the last knot. reconstruct changes only what you pass and keeps everything else.

For example, here is a steepening stress on a curve delivered with cubic interpolation and its long end held at a 3.5% forward:

delivered = ZeroRateCurve(rates, tenors, Spline.Cubic(); extrapolation = Yield.FlatForwardAt(Continuous(0.035)))
steepener = Continuous.([0.0, 0.001, 0.0025, 0.004, 0.005])   # +0bp at 1 year, rising to +50bp at 30 years
stressed = reconstruct(delivered; rates = knot_rates(delivered) .+ steepener)
ZeroRateCurve([0.04, 0.043000000000000003, 0.0475, 0.051000000000000004, 0.055], [1.0, 2.0, 5.0, 10.0, 30.0], FinanceModels.Spline.PolynomialSpline(3); extrapolation = Yield.FlatForwardAt(Continuous(0.035)))

The stressed curve prints as the call that rebuilds it, so the interpolation and tail it kept are visible, and the printout can go straight into a report, a test or a bug report. Knot curves also compare by value, so stressed == delivered tells you whether a curve changed between runs. The stress lowers the liability’s value:

(delivered = pv(delivered, cfs, times), stressed = pv(stressed, cfs, times))
(delivered = 126.2883461617498, stressed = 121.72597237984294)

reconstruct also accepts dual numbers, so sensitivities to a curve’s own knot rates are a one-liner. These are the exposures to hedge when your inputs are the knots themselves. Here they are for the monotone convex curve from earlier:

ForwardDiff.gradient(z -> pv(reconstruct(curve; rates = z), cfs, times), rate.(knot_rates(curve)))
5-element Vector{Float64}:
   -7.419928800250587
  -33.95067280823388
  -67.64308450097408
 -820.0414473970203
 -193.95053909993328

(On a cubic spline like delivered, the same sensitivities swing between positive and negative, because moving one knot reshapes the whole curve. That’s one reason local interpolants such as Spline.Linear() or the monotone convex default are easier to hedge against.)

Blending curves (FinanceModels)

Yield.Blend combines curves with weights that sum to 1. The space you choose says what is averaged:

  • Yield.DiscountFactors() averages discount factors, D(t) = Σ wᵢ Dᵢ(t). This is a mixture of scenarios: with constant weights, its present value is the weighted average of the curves’ present values.
  • Yield.ZeroRates() averages zero rates. With constant weights it is the same curve as w * a + (1 - w) * b.
  • Yield.ForwardRates(period) averages forward rates period by period, reading a weight that varies with tenor at the start of each period.

Yield.Blend(a, b, w, space) weights a by w and b by 1 - w, and Yield.Blend(curves, weights, space) takes any number of curves. A weight is a number or a function of tenor.

Grading a market curve into an ultimate rate is a forward-rate blend. Here the monotone convex curve from earlier applies to 30 years. Its annual forwards then move linearly to a 3.5% ultimate rate at 60 years, and the ultimate rate applies after that:

ultimate = Yield.Constant(Continuous(0.035))
grade(t) = clamp((60 - t) / 30, 0, 1)   # the market curve's weight
graded = Yield.Blend(curve, ultimate, grade, Yield.ForwardRates(1.0))
[rate(forward(graded, s, s + 1)) for s in (30, 45, 60)]
3-element Vector{Float64}:
 0.05250000000000021
 0.043749999999999845
 0.03500000000000014

The grading starts after the first example’s liability ends, at 20 years, so the liability’s value is unchanged:

(market = pv(curve, cfs, times), graded = pv(graded, cfs, times))
(market = 126.39304481426217, graded = 126.39304481426217)

Grading the zero rates with the same weights gives a different curve. A zero rate averages its curve’s forwards from time 0, so shifting weight between the curves as the tenor grows shifts that whole history at once. The forward rate absorbs the shift: a weight that varies with tenor adds w′(t) (Lₐ(t) − L_b(t)) to it, where L = −log D. Here that term pulls the forwards far below both curves:

zero_graded = Yield.Blend(curve, ultimate, grade, Yield.ZeroRates())
[rate(forward(zero_graded, s, s + 1)) for s in (30, 45, 59)]
3-element Vector{Float64}:
 0.03691666666666693
 0.019416666666666416
 0.0030833333333331048
Plotting code
years = 0:79
fig = Figure(size = (720, 420))
ax = Axis(fig[1, 1]; title = "Grading one-year forwards into a 3.5% ultimate rate",
    xlabel = "Start of the one-year period (years)", ylabel = "Forward rate")
vspan!(ax, [30.0], [60.0]; color = (CF_INK_SOFT, 0.12))
for (label, c) in ["Market curve" => curve, "Forward-rate blend" => graded, "Zero-rate blend, same weights" => zero_graded]
    lines!(ax, years, [rate(forward(c, s, s + 1)) for s in years]; label)
end
hlines!(ax, [0.035]; color = :gray, linestyle = :dash, label = "Ultimate rate")
axislegend(ax; position = :lb)
fig

A discount-factor blend is a mixture. A 50/50 mixture of a 2% and a 6% scenario values cashflows at the average of the two present values. That is more than discounting at the average rate, 4%:

low, high = Yield.Constant(Continuous(0.02)), Yield.Constant(Continuous(0.06))
mixture = Yield.Blend(low, high, 0.5, Yield.DiscountFactors())
average = Yield.Blend(low, high, 0.5, Yield.ZeroRates())
(mixture = pv(mixture, cfs, times),
 mean_of_pvs = (pv(low, cfs, times) + pv(high, cfs, times)) / 2,
 average_rate = pv(average, cfs, times))
(mixture = 138.10280622116062, mean_of_pvs = 138.1028062211606, average_rate = 134.93275900301012)

The mixture’s zero rate starts near 4% and falls toward the low scenario’s 2%, because the high scenario’s discount factors become negligible at long tenors:

[rate(zero(mixture, t)) for t in (1, 10, 30, 100, 1000)]
5-element Vector{Float64}:
 0.03980001333191128
 0.03801319281599927
 0.0343288237740638
 0.026749972526421355
 0.020693147180559944

The same mixture over simulated short-rate paths, Yield.Blend(collect(paths), fill(1 / n, n), Yield.DiscountFactors()), is their expected discount factor. It gives the expected present value of cashflows that don’t vary by scenario. Weights that don’t sum to 1 throw an ArgumentError, and discount-factor weights must lie in [0, 1]. The blending guide covers each space, including a mixture rebased to a later date.

Parallel risk without tenor grids (ActuaryUtilities)

ActuaryUtilities 6 measures parallel risk directly, with new forms that need no key-rate grid:

  • a DV01 do-block for any valuation;
  • two-curve callbacks for IR01 and CS01;
  • two-curve convexity, returned as blocks keyed by role, such as base.credit;
  • sensitivities(valuation, curve), the value with its duration and DV01 from one derivative pass (SecondOrder() adds convexity and dollar convexity);
  • dollar convexity, convexity(DollarConvexity(), curve, cfs, times): the second derivative of value, not divided by it, so it is defined at zero value and adds across positions.

A valuation callback is a function you write that takes a curve (or a rate) and returns a value. ActuaryUtilities shifts the curve, calls your function with each shifted curve, and differentiates the result with ForwardDiff. Because the callback recomputes the whole valuation under each shift, it captures cashflows that depend on rates. Here a five-year floater pays the one-year forward rate plus 1%, so its coupons reset on whatever curve it receives. Treating today’s projected coupons as fixed cashflows misses the reset and gives a bond-like duration:

frn_value(c) = sum(1:5) do t
    coupon = 100 * (1 / discount(c, t - 1, t) - 1 + 0.01)   # resets on the shifted curve
    (coupon + (t == 5 ? 100 : 0)) * discount(c, t)
end
frozen = [100 * (1 / discount(curve, t - 1, t) - 1 + 0.01) + (t == 5 ? 100 : 0) for t in 1:5]

(callback = duration(frn_value, curve), frozen = duration(curve, frozen, 1:5))
(callback = 0.12239237945330604, frozen = 4.524908001390014)

Only the fixed 1% margin has rate risk, so the floater’s duration is small. These are the effective and spread durations of the floater contract in One call shape for every sensitivity, which needs no callback: fixed cashflows and contracts go after the curve, and ActuaryUtilities values them itself.

The callback comes first, so it can be a named function, an anonymous function, a callable struct, or a do block, which Julia passes as the first argument: duration(DV01(), curve) do c ... end. It must accept ForwardDiff’s dual numbers, so avoid converting values to Float64 inside it. With two curves it takes both, as in (base, credit) -> …, and with a NamedTuple of curves it takes the named tuple.

Take a 10-year 4.5% bond held against a 25-year annuity liability:

bond, bond_times = [fill(4.5, 9); 104.5], 1.0:10.0
annuity, annuity_times = fill(-7.0, 25), 1.0:25.0

duration(DV01(), curve) do c
    pv(c, bond, bond_times) + pv(c, annuity, annuity_times)
end
-0.023933625584342588

Rate and credit sensitivities differ only when a valuation uses the two curves differently. Compare the bond, whose fixed cashflows are discounted at rate + credit, with a five-year floater paying the one-year forward rate plus 150bp. The floater’s coupons reset on the rate curve, while every payment is discounted at rate + credit:

credit = Yield.Constant(Continuous(0.01))
margin = 0.015

bond_value(rate, credit) = pv(rate + credit, bond, bond_times)
floater_value(rate, credit) = sum(1:5) do t
    coupon = 100 * (1 / discount(rate, t - 1, t) - 1 + margin)   # resets on the rate curve
    (coupon + (t == 5 ? 100 : 0)) * discount(rate + credit, t)  # discounted at rate + credit
end

(bond = (ir01 = duration(bond_value, IR01(), curve, credit), cs01 = duration(bond_value, CS01(), curve, credit)),
 floater = (ir01 = duration(floater_value, IR01(), curve, credit), cs01 = duration(floater_value, CS01(), curve, credit)))
(bond = (ir01 = 0.0734321958187586, cs01 = 0.0734321958187586), floater = (ir01 = 0.0010003949893000992, cs01 = 0.04564592106665357))

The floater’s IR01 is small: a rate rise raises its projected coupons by almost as much as it lowers their discounted value. Its CS01 is close to that of a five-year bond, because the credit curve discounts every payment. The two-curve convexity blocks show the same split:

(bond = convexity(bond_value, curve, credit), floater = convexity(floater_value, curve, credit))
(bond = (base = (base = 75.16521133538198, credit = 75.16521133538198), credit = (base = 75.16521133538198, credit = 75.16521133538198)), floater = (base = (base = 0.403859931498768, credit = 8.68950257849735), credit = (base = 8.68950257849735, credit = 21.355741885569508)))
Plotting code
instruments = ["10-year fixed bond", "5-year floater"]
ir01s = [duration(v, IR01(), curve, credit) for v in (bond_value, floater_value)]
cs01s = [duration(v, CS01(), curve, credit) for v in (bond_value, floater_value)]
fig = Figure(size = (720, 380))
ax = Axis(fig[1, 1]; title = "Rate vs credit sensitivity",
    ylabel = "Value lost per +1bp", xticks = (1:2, instruments))
colors = [CF_BLUE, CF_AMBER]
barplot!(ax, [1, 2, 1, 2], [ir01s; cs01s]; dodge = [1, 1, 2, 2], color = colors[[1, 1, 2, 2]])
Legend(fig[1, 2], [PolyElement(polycolor = c) for c in colors], ["IR01 (rate curve)", "CS01 (credit curve)"])
fig

NoteEqual rate and credit sensitivities are the usual case

When a valuation depends only on the combined rate, as fixed cashflows discounted at base + credit do, its IR01, CS01 and combined-curve DV01 are equal, and so are its four convexity blocks. A one-basis-point move in either curve is a one-basis-point move in the rate the cashflows are discounted at. Fixed cashflows are always in this case, so their two-curve forms are removed: use duration(DV01(), base + credit, cfs, times) and convexity(base + credit, cfs, times).

Key-rate results still use a grid, now given as a KeyRates(tenors) marker before the curve argument. These sensitivities differentiate prescribed changes to the curve’s continuous zero rates; calibration sensitivities instead change market quotes and refit the curve. Dollar measures retain the position’s sign:

grid = [1.0, 2.0, 5.0, 10.0, 20.0, 30.0]
asset_kr = duration(DV01(), KeyRates(grid), curve, bond, bond_times)
liability_kr = duration(DV01(), KeyRates(grid), curve, annuity, annuity_times)
6-element Vector{Float64}:
 -0.0006725526074066262
 -0.003298407685321483
 -0.012033880313419369
 -0.03419136433575766
 -0.04656535699752384
 -0.0077159551285951936
Plotting code
fig = Figure(size = (720, 400))
ax = Axis(fig[1, 1]; title = "Key-rate DV01: a 10-year bond against a 25-year annuity",
    xlabel = "Key rate (years)", ylabel = "DV01",
    xticks = (eachindex(grid), string.(Int.(grid))))
n = length(grid)
xs = repeat(eachindex(grid), 3)
group = repeat(1:3; inner = n)
colors = [CF_BLUE, CF_RED, CF_AMBER]
barplot!(ax, xs, [asset_kr; liability_kr; asset_kr .+ liability_kr]; dodge = group, color = colors[group])
elements = [PolyElement(polycolor = c) for c in colors]
Legend(fig[1, 2], elements, ["Bond (asset)", "Annuity (liability)", "Net"])
hlines!(ax, [0.0]; color = :gray)
fig

Valuation callbacks can be callable structs, and the parallel measures accept cashflow amounts as arrays, tuples or finite generators.

One call shape for every sensitivity (ActuaryUtilities)

ActuaryUtilities 6 takes arguments in one order: the valuation callback (if any), the order marker of sensitivities, a measure marker such as DV01() or KeyRates(tenors), the curve or curves, then the cashflows or the contract. A contract’s index curve is the keyword index, which defaults to the discount curve:

Measure Fixed cashflows Valuation callback Contract or portfolio
duration duration(curve, cfs, times) duration(valuation, curve) duration(discount, contract; index)
DV01 duration(DV01(), curve, cfs, times) duration(valuation, DV01(), curve) duration(DV01(), discount, contract; index)
convexity convexity(curve, cfs, times) convexity(valuation, curve) convexity(discount, contract; index)
dollar convexity convexity(DollarConvexity(), curve, cfs, times) convexity(valuation, DollarConvexity(), curve) convexity(DollarConvexity(), discount, contract; index)
key rates duration(KeyRates(t), curve, cfs, times) duration(valuation, KeyRates(t), curve) duration(KeyRates(t), discount, contract; index)
all at once sensitivities([SecondOrder(),] curve, cfs, times) sensitivities(valuation, [SecondOrder(),] curve) sensitivities([SecondOrder(),] discount, contract; index)

sensitivities returns the value, duration and DV01 from one derivative pass, and SecondOrder() adds convexity and dollar convexity. Dollar convexity is the unscaled second derivative of value: a shift Δs contributes ½ × dollar_convexity × Δs². value is a number. Without KeyRates the derivative fields are numbers; with it, duration and DV01 are per-tenor vectors and convexity and dollar convexity are matrices. With several curves, each field is keyed by curve role, the convexities in blocks such as base.credit. SecondOrder() needs second derivatives: a Hull–White swaption, whose critical rate FinanceModels differentiates to first order only, throws, as do calibration sensitivities, which are first order. For a floater, the discount role is spread risk, the index role is coupon reset, and effective duration is their sum:

frn = Bond.Floating(0.01, Periodic(1), 5.0, "OIS")
risk = sensitivities(curve, frn)
(risk.duration, effective = risk.duration.discount + risk.duration.index)
(duration = (discount = 4.524908001390014, index = -4.4025156219367085), effective = 0.12239237945330572)

The upgrade guide maps every 5.x call and result field to its replacement.

One mortality interface for tables and laws (MortalityTables)

  • cumhazard is defined for every parametric law: in closed form where MortalityTables implements one (Makeham, Gompertz, Weibull, Kannisto and others), and by numerical integration otherwise, as for Beard; survival follows from it.
  • Parametric models accept a DeathDistribution in survival, decrement and complete_life_expectancy for compatibility with table-based calls. They ignore that argument: the law already specifies mortality between integer ages.
  • UltimateMortality accepts any AbstractVector (ranges, views, vectors with missing) and wraps it without copying.
  • Loading a table from CSV is a package extension, activated by using CSV.
m = MortalityTables.Beard()
cumhazard(m, 40, 50), survival(m, 40, 50)
(4.127968753618562, 0.01611558031314834)

Age-basis conversions (MortalityTables)

age_nearest_to_age_last and age_last_to_age_nearest convert ultimate rates between age-nearest-birthday (ANB) and age-last-birthday (ALB) bases, assuming deaths are uniform over each year of age. The formula comes from the 1981 Transactions of the Society of Actuaries paper that published the 1980 CSO and CET tables on an age-last-birthday basis. The result keeps the input’s ages and number type, and the last rate must be one. Converting the 1980 CSO Male ANB table reproduces the published ALB table to its five-decimal rounding:

anb = MortalityTables.table(42).ultimate   # 1980 CSO Male, ANB
alb = MortalityTables.table(41).ultimate   # 1980 CSO Male, ALB
maximum(abs, age_nearest_to_age_last(anb) .- alb)
4.9981493152465636e-6

Thanks to @aminemanai2003 for this contribution (#154).

IRR derivatives of every order (FinanceCore 2.8)

An IRR is defined implicitly: it is the rate y at which the cashflows’ present value is zero, Σ aₖ (1 + y)^(-tₖ) = 0. As for a curve fit (see the implicit-function-theorem note under Sensitivities through calibration), its derivatives come from that equation at the root, not from the root-finder’s steps: ∂y/∂aₖ = -(∂PV/∂aₖ) / (∂PV/∂y), and similarly for the times and for higher orders. So irr supports gradients, Hessians and higher-order derivatives with respect to both amounts and times, whichever stage of the solver finds the root; before 2.8, a root found by the fallback stage silently had zero derivatives. Differentiating at a repeated root throws, because the sensitivity is undefined there.

For an outlay of 100 repaid with 110 a year later, the IRR is 10%: y = -a₂/a₁ - 1. Paying 1 less up front raises it by 1.1 points, and receiving 1 more at the end raises it by 1 point:

ForwardDiff.gradient(a -> rate(irr(a, [0.0, 1.0])), [-100.0, 110.0])
2-element Vector{Float64}:
 0.010999999999999998
 0.009999999999999995

The Hessian shows how those sensitivities change; the IRR is linear in the repayment, so that entry is zero up to rounding. Delaying the repayment lowers the IRR by about 10.5 points per year of delay, -1.1 ln(1.1):

(hessian = ForwardDiff.hessian(a -> rate(irr(a, [0.0, 1.0])), [-100.0, 110.0]),
 delay = ForwardDiff.derivative(t -> rate(irr([-100.0, 110.0], [0.0, t])), 1.0))
(hessian = [0.00021999999999999998 9.999999999999995e-5; 9.999999999999994e-5 -4.0657581468206416e-20], delay = -0.1048411977847573)

Changes affecting existing models

These changes can alter valuations, risk measures or accepted inputs. Check the assumptions and conventions your existing models use when upgrading.

FinanceModels

  • Portfolios and composites are worth the sum of their parts. 6.x passed each contract’s position in a collection as a valuation time: a 3-year zero-coupon bond at a continuous 3% was worth 0.9139 alone but 0.9418 inside [bond], and [a, b] and [b, a] differed. present_value(model, contract) is now the one valuation, and a collection or a Composite is worth the sum of its contracts’ values. Options, caps, floors and swaptions, which have closed forms but no cashflow projection, now combine too; they threw. These laws hold for contracts that carry their own payment times and whose values share a reporting currency, and sums taken in another order agree to rounding:
bs_model = Equity.BlackScholesMerton(0.01, 0.02, 0.15)
call_1y = Option.EuroCall(CommonEquity(), 1.0, 1.0)
put_1y = Option.EuroPut(CommonEquity(), 1.0, 1.0)
(composite = pv(bs_model, Composite(call_1y, put_1y)),
 portfolio = pv(bs_model, [put_1y, call_1y]),
 sum = pv(bs_model, call_1y) + pv(bs_model, put_1y))
(composite = 0.11805304448046065, portfolio = 0.11805304448046065, sum = 0.11805304448046065)
  • Valuation contexts. A contract that reads other models by key, such as a floater’s index curve, is valued under Models(model; index = curve) or Models(model, store), which replace Projection(contract; index); Models(curve) uses one curve for both. A custom closed form is defined on the contract, present_value(ctx, c::MyContract), reading discount(ctx, t), ctx[key] and valuation_model(ctx). Using valuation_model(ctx) keeps the model’s closed-form kernel under Models; keyed observables come from the store. Wrappers that act on the cashflow stream (Forward, FX.Converted, transducers) need a cashflow projection.

A fixed-rate bond needs a curve to discount its payments. A floater also needs an index curve to set its coupons. A valuation context supplies both:

discount_curve = Yield.Constant(Continuous(0.03))
index_curve = Yield.Constant(Continuous(0.04))

# Unit principal, annual coupons, 1% margin over the SOFR index
floater = Bond.Floating(Periodic(0.01, 1), Periodic(1), 5.0, :SOFR)
ctx = Models(discount_curve; index = index_curve)

(value = pv(ctx, floater), cashflows = collect(Projection(floater, ctx)))
(value = 1.0931047541091208, cashflows = Cashflow{Float64, Float64}[Cashflow{Float64, Float64}(0.050810774192388226, 1.0), Cashflow{Float64, Float64}(0.050810774192388226, 2.0), Cashflow{Float64, Float64}(0.05081077419238822, 3.0), Cashflow{Float64, Float64}(0.05081077419238823, 4.0), Cashflow{Float64, Float64}(1.0508107741923882, 5.0)])

The floater projects its coupons from index_curve, and pv discounts the payments on discount_curve. Projection shows the payments before discounting.

Contracts get their inputs through three operations:

Operation What it supplies under Models(model, store)
discount(ctx, t) or discount(ctx, from, to) Discount factors from model
ctx[key] The model stored under that key
valuation_model(ctx) model itself, for a model-specific pricing formula

Models(curve) uses one curve for discounting and every index lookup. Models(model; index = curve) returns curve for every key. Use an explicit store when different keys need different models:

term_curve = Yield.Constant(Continuous(0.045))
keyed = Models(discount_curve, (; SOFR = index_curve, TERM_SOFR = term_curve))

term_floater = Bond.Floating(Periodic(0.01, 1), Periodic(1), 5.0, :TERM_SOFR)
pv(keyed, [floater, term_floater])
2.2100712566101404

Each floater reads its own index, and both use the same discount curve. With an explicit store, a missing or misspelled key throws instead of silently using another curve.

The first model can also supply a closed-form price. An equity option uses the Black–Scholes model carried by its context:

bsm = Equity.BlackScholesMerton(0.03, 0.01, 0.20)
option = Option.EuroCall(CommonEquity(), 1.0, 1.0)

(plain = pv(bsm, option), bundled = pv(Models(bsm; index = index_curve), option))
(plain = 0.08827321225352117, bundled = 0.08827321225352117)

The values agree because the option uses the Black–Scholes model and reads nothing from the store. A contract that reads ctx[key] changes value when the stored model changes.

For a custom closed form, define FinanceCore.present_value(ctx, c::MyContract) with the operations above. If its formula depends on the pricing model’s type, pass valuation_model(ctx) to a method of FinanceModels.closed_form(model, contract).

Portfolios and Composites add their members’ values, so contracts valued only by closed forms can be members, if their values share a reporting currency. Forward, FX.Converted and transducers act on cashflow streams, so the contracts inside them must support projection; a valuation context does not supply one.

  • One reporting currency per FX context. An FX.Forwards context, alone or as the model of Models, reports in its quote currency, and FX.Forward is one quote-currency cashflow. Contracts that carry a currency are checked against it: an FX.Converted must convert into it, and a base-currency FX.BasisSwapLeg throws unless the context reports in its base currency or is a plain yield curve; 6.x added its base-currency value to quote-currency values. Value the leg on a base-currency curve or convert it with FX.Converted. Inside FX.Converted, a leg must pay in the pair’s base currency, and an FX.Forward, which prices on the context’s own FX model, is not supported. The reporting currency is not a collateral choice: converting a present value at spot does not change the collateral assumptions the curves encode.
  • No valuation-time argument. present_value(model, contract, t) is removed: the third argument meant payment times for numbers, nothing for a Cashflow, and a valuation time for contracts. pv(model, contract) now also accumulates cashflows before time 0, which 6.x’s default valuation time of 0 dropped. On a deterministic curve, value the cashflows at or after t as of t by discounting each one over [t, T]. The boundary is explicit in your code (inclusive here, as in 6.x):
asof(model, contract, t) = sum(cf.amount * discount(model, t, cf.time)
    for cf in collect(Projection(contract, model)) if cf.time >= t; init = zero(discount(model, t, t)))
asof(Yield.Constant(Continuous(0.04)), Bond.Fixed(0.05, Periodic(1), 5), 2.0)
1.0754617478299633

Don’t accumulate a time-0 value instead. accumulation(model, t) * pv(model, contract) also counts the cashflows before t. If the time-0 present value includes only cashflows at or after t, the product agrees in exact arithmetic, but where the accumulation overflows and the time-0 value underflows it is Inf * 0 = NaN.

  • Transducers over contracts apply in the order written. Inside a projection, bond |> double |> add1 projected as [2.1, 4.1] instead of [1.1, 3.1], so its present value was wrong while collect was right. Stateful and early-terminating transducers (Take, Scan) now also work inside a projection, a portfolio or a Composite; they threw.

  • Rate results carry their convention. knot_rates, Yield.instantaneous_forward and short_rate return Continuous rates. They were bare continuous numbers, which FinanceCore reads as annual effective, so passing one on, as in Yield.Constant(instantaneous_forward(c, 5)), silently used the wrong rate. Use rate(x) for the number, for example to seed a ForwardDiff gradient. Coupons, margins and interest-rate strikes also accept a Periodic rate of the contract’s frequency, and other rate inputs accept a Rate in any convention; numbers keep their meaning.

  • Bootstrapping requires linear interpolation. With a smoother interpolant, adding a knot reshapes earlier segments, so bootstrapped coupon quotes silently stopped repricing (residuals up to 0.40% of par). Fit.Bootstrap() now accepts only Spline.Linear() and reprices every quote on the returned curve. Fit smooth curves to all quotes at once with Fit.Loss:

fit(Spline.Cubic(), CMTYield.(rates, tenors), Fit.Bootstrap())
ArgumentError: Fit.Bootstrap does not support FinanceModels.Spline.PolynomialSpline(3): adding a knot would change earlier curve segments and reprice earlier quotes. Use Spline.Linear(), or fit this strategy across all quotes with Fit.Loss(x -> x^2).
Stacktrace:
 [1] fit(mod0::FinanceModels.Spline.PolynomialSpline, quotes::Vector{Quote{Float64, FinanceModels.Bond.Fixed{Periodic, Float64, Float64}}}, method::FinanceModels.Fit.Bootstrap; extrapolation::Symbol)
   @ FinanceModels ~/.julia/dev/pr-FinanceModels-v7-master/src/fit.jl:519
 [2] fit(mod0::FinanceModels.Spline.PolynomialSpline, quotes::Vector{Quote{Float64, FinanceModels.Bond.Fixed{Periodic, Float64, Float64}}}, method::FinanceModels.Fit.Bootstrap)
   @ FinanceModels ~/.julia/dev/pr-FinanceModels-v7-master/src/fit.jl:515
 [3] top-level scope
   @ ~/prog/JuliaActuary.org/posts/2026-fall-releases/index.qmd:556
  • An explicit long-end assumption. Continuing the last interpolation polynomial past the last knot let that polynomial drive the long-end forwards, as in the figure above. The :flat_forward default changes zero rates and present values beyond the last knot of DataInterpolations curves; Spline.MonotoneConvex(), the ZeroRateCurve default, is unchanged. For a linear bootstrap, the 30-year zero rate under the new default and under 6.x’s :extension:
quotes = ZCBYield.([0.02, 0.025, 0.031, 0.036], [1, 2, 5, 10])
zero(fit(Spline.Linear(), quotes, Fit.Bootstrap()), 30.0),
zero(fit(Spline.Linear(), quotes, Fit.Bootstrap(); extrapolation = :extension), 30.0)
(Continuous(0.03859243637227028), Continuous(0.054718899047165113))
  • Flat before the first knot. DataInterpolations curves hold the first knot’s zero rate between time 0 and the first knot, so their zero rates before it change; MonotoneConvex curves don’t. Bootstrapped values are unchanged, but the extra t = 0 knot is gone, so a curve has exactly one knot rate per quote.
  • Curve arithmetic is factor arithmetic. curve1 + curve2 and curve1 - curve2 multiply and divide discount factors. Those are the only supported CompositeYield operations. For a pointwise change to one curve’s zero rates, use a TenorShift, written curve + ((z, t) -> ...). This one floors the zero rate at 0:
negative = ZeroRateCurve([-0.01, 0.0, 0.01], [1.0, 2.0, 5.0])
floored = negative + ((z, t) -> max(z, Continuous(0.0)))
zero(negative, 1.0), zero(floored, 1.0)
(Continuous(-0.01), Continuous(0.0))
  • Explicit quote conventions. OISYield builds annual-pay swaps beyond one year; it previously used quarterly payments. ParSwapYield and InterestRateSwap require an explicit frequency. ParYield throws when an explicit frequency conflicts with a Periodic rate’s own compounding; the keyword used to be ignored. Every frequency takes an integer or a Periodic.
  • Failed fits throw. An optimizer fit that doesn’t converge throws a FitConvergenceError; previously it returned the unfitted starting model. Optimizer-backed fits now accept solve_kwargs, passed to the solver, such as (; maxiters = 10_000, g_tol = 1e-12). Here one iteration is too few:
fit(Yield.NelsonSiegel(), CMTYield.(rates, tenors); solve_kwargs = (; maxiters = 1))
FitConvergenceError: model fitting did not converge (optimizer return code Failure)
Stacktrace:
 [1] __minimize(::Val{1}, loss::FinanceModels.var"#loss#77"{FinanceModels.Fit.Loss{FinanceModels.var"#__default_loss##0#__default_loss##1"}, FinanceModels.var"#build#76"{AccessibleModels.AccessibleModel{Nothing, FinanceModels.Yield.NelsonSiegel{Float64}, Tuple{Accessors.PropertyLens{:τ₁}, Accessors.PropertyLens{:β₀}, Accessors.PropertyLens{:β₁}, Accessors.PropertyLens{:β₂}}, NTuple{4, Distributions.Uniform{Float64}}, Distributions.ProductDistribution{1, 0, NTuple{4, Distributions.Uniform{Float64}}, Distributions.Continuous, Float64}}}}, x0::Vector{Float64}, quotes::Vector{Quote{Float64, FinanceModels.Bond.Fixed{Periodic, Float64, Float64}}}, optimizer::Optim.LBFGS{Nothing, LineSearches.InitialStatic{Float64}, LineSearches.HagerZhang{Float64, Base.RefValue{Bool}}, Returns{Nothing}}, solve_kwargs::@NamedTuple{maxiters::Int64}; lb::Vector{Float64}, ub::Vector{Float64}, who::String, hint::String)
   @ FinanceModels ~/.julia/dev/pr-FinanceModels-v7-master/src/fit.jl:240
 [2] __minimize(loss::Function, x0::Vector{Float64}, quotes::Vector{Quote{Float64, FinanceModels.Bond.Fixed{Periodic, Float64, Float64}}}, optimizer::Optim.LBFGS{Nothing, LineSearches.InitialStatic{Float64}, LineSearches.HagerZhang{Float64, Base.RefValue{Bool}}, Returns{Nothing}}, solve_kwargs::@NamedTuple{maxiters::Int64}; lb::Vector{Float64}, ub::Vector{Float64}, who::String, hint::String)
   @ FinanceModels ~/.julia/dev/pr-FinanceModels-v7-master/src/fit.jl:227
 [3] fit(mod0::FinanceModels.Yield.NelsonSiegel{Float64}, quotes::Vector{Quote{Float64, FinanceModels.Bond.Fixed{Periodic, Float64, Float64}}}, method::FinanceModels.Fit.Loss{FinanceModels.var"#__default_loss##0#__default_loss##1"}; variables::Tuple{Pair{Accessors.PropertyLens{:τ₁}, IntervalSets.ClosedInterval{Float64}}, Pair{Accessors.PropertyLens{:β₀}, IntervalSets.ClosedInterval{Float64}}, Pair{Accessors.PropertyLens{:β₁}, IntervalSets.ClosedInterval{Float64}}, Pair{Accessors.PropertyLens{:β₂}, IntervalSets.ClosedInterval{Float64}}}, optimizer::Optim.LBFGS{Nothing, LineSearches.InitialStatic{Float64}, LineSearches.HagerZhang{Float64, Base.RefValue{Bool}}, Returns{Nothing}}, solve_kwargs::@NamedTuple{maxiters::Int64})
   @ FinanceModels ~/.julia/dev/pr-FinanceModels-v7-master/src/fit.jl:422
 [4] fit
   @ ~/.julia/dev/pr-FinanceModels-v7-master/src/fit.jl:394 [inlined]
 [5] top-level scope
   @ ~/prog/JuliaActuary.org/posts/2026-fall-releases/index.qmd:581
  • Derivatives at kinks are defined or refused. Where MonotoneConvex, PCHIP or Akima switch formula, ForwardDiff used to return a derivative matching neither an up nor a down bump. Flat PCHIP gave NaN, and flat MonotoneConvex could be off by a factor of two. At a kink, MonotoneConvex now uses the average of the up- and down-bump derivatives as a convention; on a flat stretch, these partials need not sum to the parallel-shift derivative. PCHIP and Akima throw, as does differentiation through a fit whose resulting curve lies on a kink.

  • Correct swaption Greeks. Jamshidian’s critical rate was found by a bisection that dropped its derivatives, so ForwardDiff Greeks of Vasicek and Hull–White swaptions were wrong. On a 1×5 payer swaption, Vasicek’s ∂/∂a had the wrong sign and vega was 9% too high. Now automatic differentiation matches a finite difference:

swaption = Option.Swaption(1.0, 6.0, 0.035, 1)
price(a) = pv(ShortRate.Vasicek(a, 0.04, 0.01, Continuous(0.03)), swaption)
ForwardDiff.derivative(price, 0.1), (price(0.1 + 1e-6) - price(0.1 - 1e-6)) / 2e-6
(0.008339500292175683, 0.008339500321211113)
  • Short-rate models near zero mean reversion. Vasicek’s -log P was off by up to 4.5e-7 at small aτ, and Hull–White’s derivatives with respect to a vanished near a = 0. Near zero mean reversion, including a = 0, both now match high-precision references to a few units of rounding over the tested parameters, and Hull–White’s derivatives with respect to a no longer vanish.
  • Vasicek under negative mean reversion. With a < 0, terms of Vasicek’s -log P grow like e^{|a|τ} and nearly cancel when the short rate is near its long-run level. 6.x computed them separately, so it lost the price. A constant 3% short rate (σ = 0, r = b) priced its 500-year bond at 0 instead of e^{-15} with a = −0.1. With a = −1, its 50-year price was 4.5 times too high, and prices were NaN from about 355 years. The terms are now grouped, so they keep their accuracy relative to their size, and a term with a zero weight stays zero when e^{|a|τ} overflows. That rate now prices exactly at every horizon (each ratio to e^{-0.03τ} is 1):
constant_3pct = ShortRate.Vasicek(-1.0, 0.03, 0.0, Continuous(0.03))
[discount(constant_3pct, τ) / exp(-0.03τ) for τ in (50.0, 500.0, 1000.0)]
3-element Vector{Float64}:
 1.0
 1.0
 1.0
  • Stable discounting between distant dates. Interval discounts no longer divide two discount factors that have underflowed to zero. This interval 20,000 years out was NaN (0/0) and is now approximately exp(-0.05):
discount(Yield.NelsonSiegel(1.0, 0.05, -0.02, 0.01), 20000, 20001)
0.9512294245007573

Time derivatives at t = 0 are also corrected where several curves gave NaN.

  • Stable CIR prices. The textbook formula raises a number within σ² of 1 to the power 2ab/σ², so a 10-year discount factor was 5.6e57 at σ = 1e-10, and a 10,000-year factor at σ = 0.01 was NaN. Prices now come from one stable formula for −log P, which agrees with a high-precision reference to about 1e-13, relative.

  • Forward-starting contracts read the index on their own clock. Forward(s, floater) fixed the floater’s coupons on the index from time 0: a one-year floater starting at time 2 paid 1.0202 instead of 1.0305 on an upward curve. Wrapper contracts also nest in any order now, such as a Forward of a Forward or of a Composite; those threw a MethodError.

  • Simulated paths end at their horizon. Evaluating a RatePath beyond the horizon it was simulated to now throws. It used to extend the last step, so a one-year simulation priced a payment at year 10. A horizon a whole number of steps away, up to roundoff, no longer gets an extra step: horizon = 0.07, timestep = 0.01 took 8 steps and now takes 7, which changes those paths, and such a path ends at the horizon itself.

  • Short-rate limits. zero(curve, 0) is the short rate where it was NaN (Vasicek, CIR, Hull–White, Smith–Wilson, ForwardStarting), and Yield.instantaneous_forward works for every curve; at a knot it is the right-hand derivative of −log D, the forward of the piece that starts there. Hull–White conditional prices no longer turn NaN in the far tail, and Nelson–Siegel’s zero rate no longer jumps near t = 0. ### ActuaryUtilities

  • Dollar risk keeps its sign. Dollar sensitivities add across positions and reverse sign when a position is reversed. Ordinary liability payments entered as negative cashflows contribute negative DV01, and the new dollar convexity, convexity(DollarConvexity(), …), behaves the same way:

duration(DV01(), curve, bond, bond_times), duration(DV01(), curve, annuity, annuity_times)
(0.08054389148368157, -0.10447751706802416)

The convention is DV01 = −∂V/∂s × 10⁻⁴, where s is the applicable rate shift. Aggregate as asset_dv01 + liability_dv01, and remove sign corrections added for the old absolute values. A mixed position’s DV01 need not share the sign of its present value, and a position worth zero can still have dollar risk.

  • Convexity uses continuous-zero shocks, for every yield model. It now equals the sum of the full key-rate convexity matrix. For [5, 5, 105] at times [1, 2, 3] under Yield.Constant(Periodic(0.04, 1)), convexity changes from 11.26 in 5.x to:
convexity(Yield.Constant(Periodic(0.04, 1)), [5.0, 5.0, 105.0], [1.0, 2.0, 3.0])
8.40087191197841
  • Fixed cashflows on two curves take the combined curve. The fixed-cashflow IR01, CS01 and two-curve convexity forms are removed; use duration(DV01(), base + credit, cfs, times) and convexity(base + credit, cfs, times). Mixed input types used to measure different one-basis-point moves: a curve with a scalar spread gave CS01 ≈ IR01 / (1 + spread). The IR01 and CS01 callback forms remain for valuations that use the curves differently.
  • Grids are only needed for key-rate results. Parallel measures no longer take a tenors argument. The grid never changed their values, and its position let swapped arguments return wrong numbers silently. The old call shapes throw a MethodError, and the upgrade guide maps each to its replacement.
  • Cashflow objects keep their payment times. A Cashflow is paid at its own time even when a times vector is also passed: in scalar, key-rate and bundled sensitivities, and in present_values, which now returns numbers. present_values used the paired time and returned Cashflows, so a Cashflow at year 2 paired with year 10 was discounted over ten years.
  • Contract measures value in FinanceModels valuation contexts. Swaps now work in contract duration, sensitivities and zspread; they threw a MethodError. The measures differentiate present_value(Models(discount; index), contract). The bumped discount curve discounts cashflows and prices closed forms; the bumped index curve stands for every model the contract reads by key, such as a floater’s index. Closed-form contracts now have risk inside a Composite or a portfolio too. A custom contract needs a cashflow projection or a closed form present_value(ctx, c::MyContract); with neither, it throws instead of being treated as fixed. reproject is removed: value under Models(discount; index = curve), and use collect(Projection(contract, Models(discount; index = curve))) for the projected cashflows.
  • Contracts come after the curve. Contract measures take the discount curve first, as fixed cashflows do, and the index curve as the keyword index, which defaults to the discount curve: duration(Effective(), discount, contract; index), sensitivities(discount, contract; index) and zspread(discount, contract, price; index). The contract-first orders and the positional (forward, credit) curves throw a MethodError.
  • sensitivities has one result shape. It returns (; value, duration, dv01), and with SecondOrder() also convexity and dollar_convexity; 5.x key-rate forms returned convexities by default. value is a number. The derivative fields are numbers; with KeyRates, duration and DV01 are vectors and the convexities matrices; with several curves they are keyed by role: s.dv01.discount (spread risk) and s.dv01.index (coupon reset) for a contract. Second-order results need second derivatives, so a Hull–White swaption throws. The upgrade guide maps the 5.x fields.
  • The valuation callback comes first. Every callback form takes the valuation first, then any marker, then the rate or curves: duration(valuation, curve), duration(valuation, DV01(), rate), sensitivities(valuation, KeyRates(tenors), curve). Do-block calls are unchanged, and now also work for scalar and Rate inputs. The callback-second orders throw a MethodError.
  • Hull–White simulation is requested with Scenarios. A bare HullWhite model is a curve to every measure: a bump moves its curve, and caps and swaptions keep their Hull–White closed forms. sensitivities(KeyRates(tenors), Scenarios(hw; horizon, n_scenarios, rng), cfs, times) differentiates the simulated value; in 5.x the same hw was a curve to duration but a scenario generator to sensitivities, so one position could get two different key-rate vectors. The horizon is required and must be a whole number of timesteps. The constructor draws one seed, so every valuation reuses the same paths: values and dollar derivatives add across calls, and portfolio duration and convexity are value-weighted.
  • wasserstein no longer wraps around on integer samples. Gaps between quantiles were taken in the samples’ own integer type, so UInt8 samples 0 and 1 were 255 apart. Integer gaps are now subtracted exactly and then converted to the result’s floating-point type, so adjacent integers near typemax stay one apart:
wasserstein(UInt8[0], UInt8[1]), wasserstein(Int8[-100], Int8[100]), wasserstein([typemax(Int) - 1], [typemax(Int)])
(1.0, 200.0, 1.0)
  • zspread returns a Continuous rate. Added to an ordinary discount curve, credit + zspread(…).zspread reprices a contract valued from its cashflows, with the index curve held fixed. It was a bare number, which FinanceCore reads as annual effective. For Hull–White closed forms (caps, swaptions), shift hw.curve and rebuild the model instead: hw + spread is a composite curve, which has no closed forms.
  • spread and zspread no longer depend on the notional. Convergence uses the Newton step in rate units, and spread also checks that its answer reprices relative to the size of the discounted cashflows. At a notional of 1e-10, a true 2% z-spread used to come back as about 1.96%.
  • moic follows distributions divided by contributions. Contributions with no distributions give 0.0; positive distributions with no contributions give Inf; neither gives NaN. These cases previously threw:
moic([-1.0, 0.0]), moic([1.0, 2.0]), moic(Float64[])
(0.0, Inf, NaN)

MortalityTables

  • Reversed intervals give backward accumulation factors. For b > a and positive forward survival, survival(v, b, a) = 1 / survival(v, a, b). The backward factor can exceed one and is not a survival probability; it is useful for projecting a cohort backward from its surviving population. A zero forward factor gives an infinite reciprocal. This matches parametric laws and discount factors in FinanceCore and FinanceModels. 2.x returned 1.0 for reversed whole-age intervals.
  • Life expectancy is curtate or complete by name. life_expectancy is removed. curtate_life_expectancy(m, age) sums survival to each whole year after age, and complete_life_expectancy(m, age, dist = UniformDeaths()) integrates it, for tables and laws alike. In 2.x, life_expectancy was curtate on a table but complete on a law. Complete table values now integrate one year of age at a time and change by up to 9e-6, relative. A law’s complete expectancy throws when its integral does not converge; 2.x returned the unconverged value, about 6.5e8 years for InverseWeibull() at 30:
(curtate = curtate_life_expectancy(anb, 65), complete = complete_life_expectancy(anb, 65))
(curtate = 13.537564178565761, complete = 14.036830415963374)
  • Death distributions are renamed. Uniform and Constant are UniformDeaths and ConstantForce; the old names are removed. Law keywords with Unicode names also accept ASCII spellings, such as sigma for σ.
  • Fractional ages condition on survival. Survival from a fractional age under UniformDeaths() and Balducci() is now conditional on surviving to that age. Under UniformDeaths() with q = 0.2, survival from age 0.5 to 1 was 0.9 and is now 0.8/0.9, so two half-years multiply to the full year’s 0.8:
q = UltimateMortality([0.2, 0.3, 0.4])
(from_half = survival(q, 0.5, 1.0, UniformDeaths()),
 two_halves = survival(q, 0.0, 0.5, UniformDeaths()) * survival(q, 0.5, 1.0, UniformDeaths()),
 reversed = survival(q, 2, 0))
(from_half = 0.8888888888888888, two_halves = 0.7999999999999999, reversed = 1.7857142857142858)
  • Numeric types are preserved. A Float32 table gives Float32 results, and a BigFloat table gives BigFloat results, even for empty intervals:
typeof(survival(UltimateMortality(Float32[0.01, 0.02, 0.03]), 0, 2))
Float32
  • Index rate vectors, not tables. Tables no longer forward indexing: use table.ultimate[age] or a select rate vector. Indexing a parametric model (m[x]) is replaced by hazard(m, x) or m(x).

  • XTbML rates placed by their labels. Eight mort.SOA.org tables label grouped ages. Table 352 (“1946-49 Basic Table, ANB”) has issue ages 12, 17, 22 and so on, and 2.x placed them at consecutive ages, so most ages returned another age’s rates: select[17] gave issue age 37’s. Every rate now sits at its own age, with missing between the groups, and CSV tables get the same fix. Separately, 34 more bundled tables, whose metadata has an empty element, now load.

Plotting code
t352 = MortalityTables.table(352)
fig = Figure(size = (720, 400))
ax = Axis(fig[1, 1]; title = "Table 352: select rates by attained age, one line per issue age",
    xlabel = "Attained age", ylabel = "q", yscale = log10)
for issue_age in 12:10:62
    sel = t352.select[issue_age]
    ages = collect(eachindex(sel))
    lines!(ax, ages, collect(sel); label = string(issue_age))
end
axislegend(ax, "Issue age"; position = :lt)
fig

  • Mortality-law fixes.
    • Kannisto’s closed-form cumulative hazard had its parameters swapped: survival(Kannisto(), 0) was 1.76.
    • Several laws no longer overflow to NaN at old ages, and their derivatives stay finite too.
    • Small decrements are no longer rounded away: a rate of 1e-18 gave 0.0.
    • Laws whose formula ends at a finite age, such as Wittstein and VanderMaen, have a life expectancy: survival is integrated to the law’s omega, giving the expected remaining lifetime capped at omega. If survival is still positive just before omega, the remaining probability of death is placed at omega, as at a table’s last age. 2.x integrated past omega and threw.
(kannisto_at_0 = survival(MortalityTables.Kannisto(), 0),
 no_overflow = survival(MortalityTables.Kannisto(a = 0.5, b = 10), 80, 81),
 tiny_decrement = decrement(UltimateMortality(fill(1e-18, 3)), 0, 1))
(kannisto_at_0 = 1.0, no_overflow = 0.36787944117144233, tiny_decrement = 1.0e-18)

FinanceCore

  • Collections of contracts are valued on each contract’s own timing. A collection of contracts, or a Composite, is worth the sum of its parts (see FinanceModels). A number in a collection is still paid at its position, so a numeric vector is a payment schedule: reversing [10, 20] changes the economics. Present values of numeric vectors without payment times are bitwise unchanged:
flat = Continuous(0.03)
a, b = Cashflow(100.0, 1.0), Cashflow(50.0, 3.0)
(pv(flat, [a]) === pv(flat, a), pv(flat, [a, b]) ≈ pv(flat, [b, a]) ≈ pv(flat, a) + pv(flat, b))
(true, true)
  • The third argument is always payment times. pv(rate, cf, t) with a Cashflow ignored t and is removed: use pv(rate, cf), or pv(rate, amount(cf), t) to pay the amount at another time. No method takes a valuation time; see the as-of reduction under FinanceModels.
  • Amounts and times pair by position, and their lengths must match. pv(rate, amounts, times) pairs vectors, offset vectors, tuples, generators and a scalar time with a collection by position, and throws a DimensionMismatch when one runs out first; 2.8 dropped the extra amounts or times. Empty amounts still give zero, whatever the times. irr pairs the same way:
pv(0.05, (10.0, 10.0, 10.0), (1.0, 2.0))
DimensionMismatch: amounts and times have different lengths
Stacktrace:
 [1] _throw_length_mismatch()
   @ FinanceCore ~/.julia/packages/FinanceCore/poZGd/src/pv.jl:93
 [2] _sum_present_values(r::Float64, x::Tuple{Float64, Float64, Float64}, times::Tuple{Float64, Float64})
   @ FinanceCore ~/.julia/packages/FinanceCore/poZGd/src/pv.jl:87
 [3] present_value(r::Float64, x::Tuple{Float64, Float64, Float64}, times::Tuple{Float64, Float64})
   @ FinanceCore ~/.julia/packages/FinanceCore/poZGd/src/pv.jl:57
 [4] top-level scope
   @ ~/prog/JuliaActuary.org/posts/2026-fall-releases/index.qmd:723
  • Cashflows add only at equal times. + and - accepted times within isapprox tolerance, so addition was not associative. The times must now be equal. FinanceCore.aggregate(cfs; key) combines cashflows by key(time): it preserves nominal totals for any key, but present value only when key leaves the payment times unchanged.
  • Interval discounts are defined for constant rates. discount(rate, from, to) and accumulation(rate, from, to) take a Rate or a number. The untyped fallback computed discount(model, to - from) for any model, which is wrong unless the rate is constant; a model outside FinanceModels without its own interval method now throws a MethodError. FinanceModels curves still need only discount(curve, t).
  • Converting conventions preserves the force of interest exactly (2.8). The old round trip through a nominal quote lost it. Here the annual rate rounds to −100%, yet converts back exactly:
annual = convert(Periodic(1), Continuous(-40.0))
annual, convert(Continuous(), annual)
(Periodic(-1.0, 1), Continuous(-40.0))
  • No unconverged IRRs (2.8). 2.7 could return Newton’s last finite iterate after 100 steps, with a material NPV residual. Unconverged iterates now go to the robust root search.
  • Rate + Rate adds in the left operand’s convention (documented in 2.8). It adds nominally, the way a spread is quoted, so mixed-convention sums depend on the order. Continuous(a) + b gives the convention-independent sum:
Periodic(0.04, 1) + Continuous(0.01), Continuous(0.01) + Periodic(0.04, 1)
(Periodic(0.05005016708416806, 1), Continuous(0.0492207131532813))
  • Empty portfolios have zero value and no identifiable IRR (2.8). present_value returns zero for an empty collection, using the valuation’s numeric type, including BigFloat and automatic-differentiation types. An empty or all-zero stream has no identifiable IRR because every rate solves its pricing equation, so irr returns nothing:
pv(0.05, Any[]), irr(Float64[])
(0.0, nothing)

FinanceModels

  • Knot curves copy and validate their inputs and keep read-only knot vectors. Negative times throw a DomainError.
  • Spline.MonotoneConvex() is the one monotone convex selector; Yield.build_model and the Yield.MonotoneConvex() placeholder are removed.
  • Spline.PolynomialSpline accepts orders 1 to 3, and Spline.BSpline requires degree 1 or more.
  • discount(curve, Inf) returns the correct limit under a zero tail forward (it was NaN), and throws under :extension.
  • implied_quote, the swaption critical rate and differentiated fits check convergence relative to the size of the quote, so tiny notionals are handled.
  • PCHIP and Akima fits on evenly spaced maturities start away from formula switches, so they converge.
  • fit(curve, quotes) refits a knot curve on its own knots, method and extrapolation, and an FX.Forwards with a knot-curve foreign curve refits it through the implied foreign quotes.
  • Every fit accepts single-pass iterators of quotes.
  • A fit throws when a ForwardDiff dual number from outside it reaches the optimizer’s parameters or loss, such as a price captured inside a custom contract, including through nested fits. It used to return a wrong derivative, often zero. The spline calibration sensitivities above use implicit differentiation and are supported.
  • A Smith–Wilson fit differentiates with respect to coupons as well as quote prices.
  • maturity is defined for Forward, Option.EuroCall and Option.EuroPut, so pv_mc’s default horizon works for forward-starting contracts.
  • Empty projections (including contracts valued past maturity) and caps or floors with no caplets value to a zero in the model’s number type, such as BigFloat, instead of a Float64 0.0.
  • The extension hooks are public, not exported: FinanceModels.default_variables and FinanceModels.default_optimizer (renamed from __default_optic and __default_optim), FinanceModels.closed_form and Yield.force_of_interest.
  • Yield.TransformedYield, deprecated in 6.1, is removed; use Yield.TenorShift.

ActuaryUtilities

  • The finite-difference KeyRateDuration API (KeyRate, KeyRateZero, KeyRatePar, krd_points) is removed. KeyRates applies the same triangular zero-rate bumps with ForwardDiff. For par-rate sensitivities, use the market-input callback (see the upgrade guide).
  • Unmarked contract and portfolio measures default to Effective().
  • Zero cashflow streams return zeros typed like a nonempty result.
  • dv01 is removed; duration(DV01(), …) takes the same forms, with the valuation first for callbacks and DV01() before Effective() or Spread() for contracts.
  • price is removed; use present_value, or abs(present_value(…)).
  • The date function duration(issue_date, date) is policy_duration.
  • Amounts and times pair by position, so offset arrays work. KeyRates keeps its own copy of the grid.
  • Fixed-cashflow duration, DV01 and convexity are analytic for scalars, Rates and yield models. Scalar and Rate DV01, which 5.x computed by automatic differentiation, can move in the last bits, and a Float32 rate with Float32 amounts gives a Float32 DV01.

MortalityTables

  • mortality_vector is an alias of UltimateMortality, and MortalityTables.μ of hazard.
  • omega of a parametric model is the last age where its law is defined.
  • Life expectancy past a table’s last age is a BoundsError; past a bounded law’s omega, the law itself throws a DomainError.
  • Tables with more than two blocks of rates raise an error naming the table.

FinanceCore

  • IRR handles zero amounts, tiny notionals and unsigned integer times.
  • present_value(rate, amounts, times) sums without building the vector of discounted amounts, so it no longer allocates. Results can differ from 2.8 in the last bits.
  • convert(to, rate) replaces the three-argument convert(to, rate, from), which ignored from. Converted rates are unchanged.
  • Rate(r, f) converts a rate to the convention f, as convert(f, r) does: Rate(Continuous(0.05), Periodic(2)) is Periodic(0.0506302…, 2). Rate(r) returns r.
  • discount(rate, t) and accumulation(rate, t) take a Rate or a number; a model without its own method gets a MethodError from them.

Performance and tradeoffs

These provisional measurements compare the release candidates, benchmarked on October 2, with the latest tagged releases (FinanceCore 2.8, FinanceModels 6.5, MortalityTables 2.6, ActuaryUtilities 5.12), using BenchmarkTools on Julia 1.12 and the minimum of three alternating runs. They will be rerun against the final releases before publication.

Plotting code
speedups = [
    "MortalityTables: table curtate_life_expectancy" => 27.4,
    "MortalityTables: Beard survival (numerical cumhazard)" => 19.4,
    "MortalityTables: Gompertz life expectancy" => 17.7,
    "MortalityTables: parsing a select and ultimate XTbML table" => 23.3,
    "ActuaryUtilities: effective duration of a bond" => 56.0,
    "ActuaryUtilities: key rates of a bond" => 8.2,
    "ActuaryUtilities: curve DV01 of fixed cashflows" => 3.2,
    "FinanceCore: present value with explicit times" => 3.1,
    "FinanceModels: MonotoneConvex fit" => 2.4,
    "FinanceModels: Vasicek swaption" => 2.3,
    "FinanceModels: portfolio of a bond, floater and swap" => 1.7,
    "FinanceModels: swap on its index curve" => 1.4,
]
slowdowns = [   # times slower
    "MortalityTables: fractional-age survival, UniformDeaths()" => 3.23,
    "FinanceModels: point discount, Linear curve" => 1.55,
    "FinanceModels: Hull–White discount(t)" => 1.54,
    "FinanceModels: point discount, MonotoneConvex" => 1.33,
    "FinanceModels: forward(Linear, 2, 3)" => 1.22,
    "FinanceModels: CIR discount" => 1.17,
    "FinanceModels: Vasicek discount" => 1.14,
    "ActuaryUtilities: convexity of a callback on a curve" => 1.13,
    "FinanceModels: Hull–White cap" => 1.09,
    "FinanceModels: bond on a Linear curve" => 1.08,
]
labels = [first.(speedups); first.(slowdowns)]
ratios = [last.(speedups); 1 ./ last.(slowdowns)]   # new speed ÷ old speed
faster = ratios .> 1
fig = Figure(size = (800, 720))
ax = Axis(fig[1, 1]; xscale = log10, xlabel = "speed relative to the tagged release (log scale)",
    yticks = (eachindex(labels), labels), yreversed = true,
    xticks = ([0.25, 0.5, 1, 2, 5, 10, 20, 50], ["¼×", "½×", "1×", "2×", "5×", "10×", "20×", "50×"]))
barplot!(ax, eachindex(labels), ratios; direction = :x, fillto = 1, color = ifelse.(faster, CF_BLUE, CF_RED))
vlines!(ax, [1.0]; color = CF_INK_SOFT)
# faster: the speedup at the bar's end; slower: the slowdown just right of 1×, where the row is empty
text!(ax, ifelse.(faster, ratios, 1.0), eachindex(labels);
    text = [f ? string(round(r; digits = 1), "× faster") : string(round(1 / r; digits = 2), "× slower") for (r, f) in zip(ratios, faster)],
    align = (:left, :center), offset = (4, 0))
xlims!(ax, 0.25, 600)
Label(fig[0, 1], "Speed against the latest tagged releases"; font = :bold, tellwidth = false)
fig

Package Workload Speedup
MortalityTables curtate_life_expectancy on a table (now linear in the remaining ages) 27×
MortalityTables Parametric survival, with a closed-form (Gompertz) or numerical (Beard) cumulative hazard 14–19×
MortalityTables Parametric complete life expectancy (Gompertz, Makeham, Weibull, Kannisto) 9–18×
MortalityTables Parsing an XTbML table from disk (XML.jl replaces libxml2): the 1980 CSO ultimate table, the 2001 VBT select and ultimate table 2.4×, 23×
ActuaryUtilities Effective and spread duration of a bond or floater 46–56×
ActuaryUtilities Key rates of a bond or floater, on one or two curves 8–10×
ActuaryUtilities Scalar and Rate DV01 3.4–6.1×
ActuaryUtilities Curve duration, DV01 and convexity of fixed cashflows, now allocation-free 2.3–3.4×
FinanceCore Present value of amounts with explicit times, now allocation-free 3.1–3.7×
FinanceModels Curve fits (Nelson–Siegel, Linear, MonotoneConvex) 1.3–2.4×
FinanceModels Vasicek swaption 2.3×
FinanceModels A portfolio of a bond, a floater and a swap 1.7×
FinanceModels A swap, a Composite or a floater valued on its index curve 1.2–1.4×

Fitting a knot curve now rebuilds it once per optimizer candidate instead of once per knot.

FinanceCore 2.8, already released, made IRRs of far-dated cashflows correct and up to 18× faster than 2.7. Newton now measures time from the first nonzero cashflow, which leaves the roots unchanged but lets it converge where it used to stall. Here 2.7 returned 9.99%:

irr([-100.0, 110.0], [1000.0, 1001.0])
Periodic(0.09999999999999998, 1)

The same comparison found slower paths, in red in the plot:

  • point discounts on Linear and MonotoneConvex curves, by a few nanoseconds per call (up to 55%), from the new checks (a negative time throws, t = Inf returns the tail’s limit) and the curve’s layout. Hull–White discount factors, which come from its curve, slow down the same way, and a bond on a Linear curve is 8% slower;
  • forward(curve, from, to) on a Linear curve, by 22%: it is computed from cumulative log-discounts, which don’t underflow;
  • Vasicek and CIR discount factors, by 14% and 17%, from the numerically stable kernels;
  • a Hull–White cap, by 9%;
  • convexity of a valuation callback on a curve, by 13%;
  • survival from a fractional age under UniformDeaths(), from 3 to 10 ns, now that it is conditional on surviving to that age.

Upgrading

Use FinanceModels 7 and ActuaryUtilities 6 together for the APIs shown here. The planned release set uses Julia 1.10 or newer:

Package Release Migration guidance
FinanceModels 7.0 Migration guide and NEWS
ActuaryUtilities 6.0 Version upgrade guide, including replacements for removed calls
MortalityTables 3.0 Release notes
FinanceCore 3.0 Release notes, including a migration table
EconomicScenarioGenerators 0.7.1 (final planned release) Release notes

FinanceModels 7 and ActuaryUtilities 6 require FinanceCore 3, which includes the 2.8 fixes described here. FinanceModels 7 also requires Optimization 5. EconomicScenarioGenerators 0.7.1 supports FinanceModels 7 and is ESG’s final planned release. FinanceModels’ short-rate models, with simulate and pv_mc, succeed its interest-rate generators; its correlated and equity scenarios are planned for FinanceModels 7.1. Under FinanceModels 7, an ESG YieldCurve holds the zero rate flat before its first gridpoint and the forward flat after its last; values at the gridpoints don’t change.

If something you rely on behaves differently and isn’t covered, open an issue in FinanceModels, ActuaryUtilities, MortalityTables or FinanceCore, or ask on the JuliaActuary discussions.