Curve Construction — No-Arbitrage Bootstrap / Multi-Curve Family Construct discount, projection, and credit-spread curves via no-arbitrage bootstrapping and multi-curve framework for downstream pricers.
Family-level model covering deterministic no-arbitrage bootstrapping of discount factors and zero rates from par yields (Treasury, OIS- SOFR, credit spreads), and the multi-curve framework that supplies separate curves for discounting vs projection. Output curves consumed by all L1 pricer families (M-130..M-135). Implementations: 2 FinView L0 engines.
Inputs, processing, outputs
Methodology
M-137 is the curve-construction foundation of the FinView pricing stack. It
takes a set of market instruments quoted as par rates — money-market
deposits / Treasury bills at the short end (≤ 1Y) and par swaps / par bonds at
the long end (> 1Y) — and produces a self-consistent term structure of
discount factors, continuously-compounded zero rates, and forward rates that
every downstream pricer consumes. The engine family is FinView-resident
(finlib/curve_builder.py); the InsightALM model registry binds it as
M-137 via three engine entries: finview.L0.curve_builder (the bootstrap),
finview.L0.curve_set (the multi-curve container), and
finview.L0.interpolation (the log-linear / PCHIP interpolators).
Sequential bootstrap. bootstrap_sofr_curve strips discount factors one
tenor at a time via an initial forward pass (each node sees only earlier
nodes), exploiting the par condition that a par instrument prices to 1.0:
- Short end (T ≤ 1Y) — treated as a single-payment money-market instrument:
DF(T) = 1 / (1 + c·T), wherecis the par (simple) rate. No coupon stripping is required because there is one cash flow. - Long end (T > 1Y) — treated as a semi-annual par coupon bond. The par
condition
1 = Σ (c/2)·DF(t_j) + DF(T)is solved for the unknown terminalDF(T). The PV of the prior coupons (t_j < T) is evaluated against the discount factors already bootstrapped at earlier tenors, with intermediate coupon-date factors filled in by log-linear interpolation of discount factors (_interpolate_df). Rearranging givesDF(T) = (1 − PV_prior_coupons) / (1 + c/2).
Self-consistency refinement and self-check (post-INV-028, #92). The engine now contains two consistency facilities, both implemented but defaulted OFF:
refine_passes(default 0) — after the forward pass, runs N additional full-curve sweeps that re-solve every coupon node against the complete current curve (rather than only earlier nodes), driving the bootstrap to reprice its own calibrating instruments to par. With refinement on the long-end residual collapses toward ~0 (see Output Snapshot).self_check_tol_bps(default None) — when set, the engine callsreprice_residuals()after building and emits alogger.warningif the max reprice residual exceeds the tolerance. The self-check is therefore implemented but not yet enabled-by-default; it warns, it does not raise.
Both knobs default OFF deliberately, to preserve the frozen gold curves downstream pricers calibrate against — so default/gold output (refine_passes=0, self_check_tol_bps=None) still carries the long-end residual documented below. Turning either on by default changes every downstream gold curve and is held for ratification (INV-028 follow-up; see Limitations note).
Zero and forward rates. Once the discount-factor curve is built, zero rates
are recovered by inverting the continuous-compounding relation
z(T) = −ln DF(T) / T, and instantaneous-ish forward rates between adjacent
grid tenors are recovered from the discount-factor ratio
f(T_{i-1}→T_i) = (DF_{i-1}/DF_i − 1) / Δt. The first forward is pinned to the
first zero.
Interpolation scheme. Discount factors are interpolated log-linearly
(linear in ln DF, equivalent to piecewise-constant forward rates between
knots) — the workhorse used inside the bootstrap itself. For off-grid zero-rate
queries, interpolate_curve offers either plain linear interpolation
(default, backward-compatible) or PCHIP (monotone cubic Hermite, the
Hagan–West "monotone-convex"-family choice that suppresses the spurious forward
oscillation linear schemes can produce). PCHIP is also available for the
discount-factor interpolation path.
Multi-curve framework. Post-2008 / post-LIBOR pricing requires separating
the discounting curve (OIS / SOFR) from the projection curve (the
forward-rate curve for the floating leg) and from government (Treasury)
benchmarks. build_sofr_curve bootstraps an OIS/SOFR discount curve from a SOFR
overnight rate plus a dictionary of par swap rates (2Y…30Y), with optional
short-end calibration from 90-day / 180-day SOFR averages. The CurveSet
dataclass is the container that holds Treasury, discount (OIS/SOFR), and
projection curves side by side, plus a rating-keyed dictionary of credit
spreads. Its get_discount_dfs / get_projection_fwds accessors fall back to
the Treasury curve when an OIS/projection curve is absent, so single-curve and
multi-curve callers share one interface.
Where it sits in the stack. M-137 is the Tier-2 gate that the FinView
derivative pricers stand on: M-130 (bond pricer), M-132 (interest-rate
swap pricer — its swap_terms input is paired with a curve_set), M-133
(swaption pricer), and M-135 (SOFR-futures pricer) all consume the curves
M-137 produces. If the curve is wrong, every price above it is wrong — which is
why this card is the foundation the others reference. The full dependent set
(6 models: M-130–M-135) is enumerated in the Dependencies section above.
Key Assumptions and Their Justification
| Assumption | Value / Choice | Justification |
|---|---|---|
| Calibrating instruments | Deposits/bills (≤ 1Y) + semi-annual par swaps or par bonds (> 1Y), each input as a par rate | Par instruments are the liquid, market-quoted points; the par condition (price = 1.0) is what makes the sequential bootstrap closed-form and exact at each knot. |
| Coupon frequency (> 1Y) | Semi-annual (c/2 every 0.5y) |
Hard-coded convention matching USD swap / Treasury market practice; documented in the CurveBuilderGovernance docstring. |
| Compounding (zero rates) | Continuous (z = −ln DF / T) |
Standard for analytic curve representation; makes zero/forward algebra additive and is the form the downstream pricers expect. |
| Discount-factor interpolation | Log-linear (linear in ln DF) by default; PCHIP optional |
Log-linear ⇒ piecewise-constant forwards (no negative forwards on a well-behaved curve); PCHIP (Hagan–West family) is the smooth, non-oscillatory upgrade when forward smoothness matters. |
| Day-count convention | Uniform actual/actual assumed — tenors are passed as decimal years; no explicit business-day / day-count calendar | Acknowledged simplification (engine docstring: "uniform day-count convention (actual/actual assumed)"). Adequate for the synthetic / benchmark curves this card exercises; see Limitation 2. |
| Futures convexity | None | The bootstrap consumes par swap/deposit rates, not futures prices; no convexity adjustment is applied. Documented limitation in the engine docstring; see Limitation 3. |
| SOFR–Treasury basis | Additive parallel spread in bps (sofr_treasury_spread_bps), default 0 |
A first-order basis adjustment; lowers all discount factors uniformly (verified by test_spread_adjustment). A true OIS/SOFR curve is built separately via build_sofr_curve rather than spreading Treasury. |
Prose. The model is deliberately a par-rate bootstrapper, not a global least-squares / spline-fit calibrator. The advantage is that every grid knot reprices its calibrating instrument exactly (residual ≈ 0 at the knot — see Output Snapshot), the method is deterministic and order-stable, and there are no optimizer convergence concerns. The cost is that accuracy between widely-spaced knots depends entirely on the interpolation scheme: coupon cash flows that fall between sparse long-end tenors are priced off log-linearly interpolated discount factors, which is where the only material reprice error appears (Limitation 1).
Output Snapshot
Deterministic curve construction from a synthetic par-instrument set —
reproducible, requires no live market data (BV-032 immune: the input is a
hand-specified upward-sloping par curve, not a market pull). Reproduce with
python scripts/model_snapshots.py M-137; the underlying engine behaviour is
asserted by FinView tests/test_curve_builder.py (12 tests).
Input: Treasury par curve, tenors [0.25, 0.5, 1, 2, 3, 5, 7, 10, 20, 30]y,
par yields 4.30% → 5.30% (upward-sloping; mirrors the upward_curve test
fixture). OIS/SOFR overlay: SOFR overnight 4.30% + par swaps 2Y 4.10% / 5Y
4.20% / 10Y 4.30% / 30Y 4.50%.
| output | value | meaning |
|---|---|---|
| DF(1y) | 0.9569 | 1-year discount factor (Treasury) |
| DF(5y) | 0.7882 | 5-year discount factor |
| DF(10y) | 0.6077 | 10-year discount factor |
| DF(30y) | 0.2005 | 30-year discount factor |
| zero(10y) | 4.9801% | continuously-compounded 10-year zero rate |
| zero(30y) | 5.3564% | 30-year zero rate (curve is upward-sloping, as expected) |
| fwd(1y→2y) | 4.8174% | forward rate over the 1→2y window |
| fwd(7y→10y) | 5.6812% | forward rate over the 7→10y window |
| has_multi_curve | True | CurveSet holds a distinct OIS/SOFR discount curve |
| SOFR_DF(10y) | 0.6523 | OIS/SOFR 10-year discount factor (> Treasury DF — lower SOFR curve) |
| TSY−SOFR DF spread @10y | −445.37 bps | OIS/SOFR discounts less steeply here (SOFR ~4.3% flat vs Treasury 5.0% @10y) |
| max reprice residual (default forward pass) | 66.10 price-bps | worst calibrating-instrument reprice error (par = 1.0), at the 20y instrument; reproduced 2026-06-06 via python scripts/model_snapshots.py M-137 (refine_passes=0 default) |
| max reprice residual (refinement on — available but OFF) | ~0.14 price-bps | same fixture with refine_passes=6; the available-but-off improvement, held for ratification (INV-028 follow-up) — not the default/gold output |
| instruments calibrated | 10 | all 10 par instruments restruck off the bootstrapped curve |
Reprice residual is the validation that matters. A correct bootstrap must
reprice its own calibrating instruments to par. Per-tenor residuals are
effectively zero at the short end and small through 10y (0.00 bps at ≤1y,
0.33 bps at 2y, 1.55 bps at 5y, 4.62 bps at 10y) and then balloon at the
sparse long end (66.10 bps at 20y, 12.69 bps at 30y). This is not a
bootstrap bug — the terminal DF(T) knot itself is solved exactly; the residual
comes from re-pricing the coupon cash flows that fall on the 10y→20y→30y gaps,
where log-linear discount-factor interpolation over a 10-year window is a coarse
approximation. Tightening the long-end tenor grid (adding 15y/25y knots),
switching the coupon-date fill to PCHIP, or running the engine's refine_passes
self-consistency loop collapses this residual — with refine_passes=6 on the
same fixture the max residual drops to ~0.14 bps. Refinement is off by
default to preserve frozen gold curves, so the held/default figure remains
66.10 bps; default-on enablement + gold re-freeze is the INV-028 follow-up held
for ratification (see Limitation 1).
The off-grid zero-rate interpolation is well-behaved: at 4y/8y/15y the log-linear and PCHIP zeros agree to within ~3 bps (e.g. 15y: 5.1342% log-linear vs 5.1636% PCHIP), confirming the curve is smooth enough that interpolation choice is second-order for rate queries.
Captured 2026-06-04 · deterministic, no live data.
Limitations and Known Gaps
- Long-end reprice error under a sparse tenor grid. With knots at
10y/20y/30y, coupon cash flows on the 20y and 30y par bonds are discounted
off log-linearly interpolated DFs across 10-year gaps, producing a worst-case
~66 bps reprice residual at 20y (vs < 5 bps through 10y) on the default
forward pass. The terminal knot is exact; the residual is an
interpolation-gap artifact. Mitigation: denser long-end knots, PCHIP
coupon-date interpolation, or the engine's own
refine_passesself-consistency loop (post-INV-028, #92) which collapses the residual toward ~0 when enabled. A calibration self-check (reprice_residuals+self_check_tol_bpswarning path) is implemented but not yet enabled-by-default — it warns rather than fails, and refinement is off by default to preserve frozen gold curves (see Methodology and Validation Packet). Enablement-by-default + gold re-freeze is tracked as the INV-028 follow-up (held for ratification, see note below). - Uniform day-count / no calendar. Tenors are decimal years; there is no business-day adjustment, holiday calendar, or per-leg day-count basis (actual/360 for SOFR floating, 30/360 for fixed, etc.). The engine docstring states "uniform day-count convention (actual/actual assumed)." This biases accrual-sensitive prices for real-dated instruments and is acceptable only for benchmark / synthetic curves, not for trade-date production marking.
- No futures convexity adjustment. The bootstrap calibrates to par deposit/swap rates, not futures prices. A futures-based short end would need a convexity correction (the engine docstring flags this explicitly); it is absent. M-135 (SOFR-futures pricer) consumes the curve but the curve is not itself futures-calibrated.
- Additive parallel SOFR–Treasury basis is first-order. The
sofr_treasury_spread_bpspath applies a flat parallel spread to all par rates; a real SOFR–Treasury basis is tenor-dependent. The richer path (build_sofr_curve) bootstraps OIS/SOFR independently, which is preferred — the flat-spread option remains a convenience approximation. - No global no-arbitrage / monotone-convex guarantee. Log-linear DF interpolation gives piecewise-constant (non-smooth) forwards; PCHIP is monotone but not the full Hagan–West "monotone convex" forward interpolator. Neither path enforces a hard non-negative-forward constraint at calibration time — a steeply inverted input curve could in principle yield a negative interpolated forward without raising an error.
- Cross-repo ownership / change-control (FinView-resident). The engine lives
in
[redacted], outside the InsightALM governance repos. The snapshot inserts the FinView source tree onsys.pathto run. Change-control binding between the InsightALM model registry (M-137) and the FinView source is by registry path reference only; there is no automated drift gate that fails if the FinView signature changes — should be tracked as a governance issue, same class as the M-040 cross-repo finding. - Aspirational docstring line — no wired Bloomberg validation. The
CurveBuilderGovernanceengine docstring states "Validation: cross-check bootstrapped zeros against Bloomberg SOFR curve." This is an aspirational docstring line, not a performed validation: the engine and the snapshot are numpy-only and have no Bloomberg dependency. The actual implemented self-consistency check isreprice_residuals()against the model's own calibrating instruments (Validation Packet). Noted here so the docstring is not read as implying a market-data cross-check that does not exist.
Tracked for ratification (not applied in this documentation pass). The following are output-changing / modeling-code items left for ratification: (a) INV-028 follow-up — enabling
refine_passesand/orself_check_tol_bpsby default inbootstrap_sofr_curve/build_sofr_curveand re-freezing the gold curves (refinement-on changes every downstream gold curve and the M-137 snapshot); (b) INV-032 — a packaged/versioned interface + CI drift gate for the FinView-residentfinliblibrary consumed cross-repo by InsModel viasys.path(Limitation 6); (c) a hard non-negative-forward / monotone-convex calibration-time constraint (Limitation 5); (d) day-count/calendar support (Limitation 2) and futures convexity (Limitation 3). None applied here.
Validation Packet
| Check | Where | Evidence |
|---|---|---|
| DFs in (0,1] and monotonically decreasing | FinView tests/test_curve_builder.py::test_flat_curve_discount_factors |
flat 5% curve → every 0 < DF ≤ 1 and DF_i < DF_{i-1} ✓ |
| Zero rates positive | test_zero_rates_positive |
positive par yields → positive zeros ✓ |
| Flat curve recovers par | test_flat_curve_zero_rates_near_yield |
flat 5% → all zeros within 50 bps of 5% ✓ |
| Upward slope preserved | test_upward_curve |
upward par yields → zero(30y) > zero(0.25y) ✓ (snapshot: 5.3564% > ~4.28%) |
| Interpolation exact at knots | test_interpolate_curve |
exact at 1y/5y/10y, monotone between ✓ |
| Spread lowers DFs | test_spread_adjustment |
+50 bps SOFR–TSY spread → every DF_spread < DF_base ✓ |
| Multi-curve DFs valid | TestMultiCurve::test_build_sofr_curve_valid_dfs |
build_sofr_curve → 10 DFs, all in (0,1] ✓ |
| Custom tenor grid honored | test_build_sofr_curve_custom_tenors |
3-tenor grid → 3 DFs ✓ |
| SOFR ≠ Treasury | test_sofr_curve_differs_from_treasury |
curves differ when rates differ ✓ (snapshot: multi-curve True, −445 bps DF spread @10y) |
| CurveSet fallback | test_curveset_fallback_to_treasury |
no OIS → get_discount_dfs() returns Treasury ✓ |
| CurveSet OIS routing | test_curveset_with_ois |
OIS present → get_discount_dfs() returns OIS ✓ |
| Credit-spread lookup | test_credit_spread_retrieval |
rating-keyed spread dict; unknown rating → None ✓ |
| Self-reprice (calibration consistency) | scripts/model_snapshots.py M-137 |
par instruments restruck off bootstrapped curve; residual < 5 bps ≤ 10y, 66 bps @20y (Limitation 1) ✓/⚠ |
| Calibration self-check function (implemented) | FinView finlib/curve_builder.py::reprice_residuals (INV-028 fix #92) |
reprices each calibrating par instrument off the built curve, returns max residual in price-bps; wired into bootstrap_sofr_curve/build_sofr_curve via self_check_tol_bps (logger.warning above tolerance). Implemented; defaulted off (warns, does not raise) ✓ |
| Full curve-builder suite | pytest tests/test_curve_builder.py |
12 passed ✓ |
Self-reprice invariant: at each grid knot the bootstrapped terminal
DF(T) reprices its calibrating par instrument to 1.0 by construction; the only
non-trivial residual is interpolation-driven at the sparse long end (Limitation
1), which is a known, bounded artifact rather than a correctness failure. This
invariant is now machine-checkable via reprice_residuals() (implemented,
defaulted off — Validation Packet row above).
Cross-venv reproducibility: the snapshot is numpy-only (no InsModel
dependency) and produces byte-identical output under both the FinView venv
([redacted]) and the InsModel venv
([redacted], importing finlib off the
FinView source on sys.path).
Open MRM items: (a) calibration self-check implemented
(reprice_residuals, INV-028 fix #92) — enablement-by-default
(self_check_tol_bps / refine_passes) + gold re-freeze pending (INV-028
follow-up, Limitation 1); (b) cross-repo FinView change-control / drift gate
(Limitation 6 / INV-032). Both should be filed as tracked governance issues.
References
Curve construction / interpolation literature:
- P. S. Hagan and G. West, "Interpolation Methods for Curve Construction,"
Applied Mathematical Finance 13(2), 2006 — the reference for log-linear vs
monotone-convex discount-factor interpolation and the forward-oscillation
pathologies the PCHIP path avoids.
- P. S. Hagan and G. West, "Methods for Constructing a Yield Curve," Wilmott
Magazine, 2008 — practitioner treatment of bootstrap + interpolation trade-offs.
- F. M. Ametrano and M. Bianchetti, "Everything You Always Wanted to Know About
Multiple Interest Rate Curve Bootstrapping But Were Afraid to Ask" (2013) —
the multi-curve (OIS-discounting / projection-curve) framework that
build_sofr_curve + CurveSet implement.
- L. B. G. Andersen and V. V. Piterbarg, Interest Rate Modeling, Vol. I, Ch. 6
— multi-curve discounting and forward-curve construction post-LIBOR.
Engine source:
- FinView:finlib/curve_builder.py — bootstrap_sofr_curve, build_sofr_curve,
interpolate_curve, _interpolate_df, and the CurveSet dataclass; governance
sentinels CurveBuilderGovernance (finview.L0.curve_builder),
CurveSetGovernance (finview.L0.curve_set), InterpolationGovernance
(finview.L0.interpolation).
- FinView:tests/test_curve_builder.py — 12-test validation suite
(TestCurveBuilder, TestMultiCurve); fixtures flat_curve_5pct /
upward_curve in tests/conftest.py.
Internal:
- insightalm/modelling/engine_registry.yaml — engine entries
finview.L0.curve_builder, finview.L0.curve_set, finview.L0.interpolation
(all tier-2-gate / tier-3-infra, model_membership: [M-137] for the first
two), owner engine-owner-foundation.
- Downstream consumers: M-130 (bond pricer), M-132 (IRS pricer, input
curve_set), M-133 (swaption pricer), M-135 (SOFR-futures pricer).
- BV-032 (firm-data divergence) — this card's snapshot is BV-032-immune by
construction (synthetic par inputs, no live market pull).
Change Log
Card change history. Code-side change history lives in git log of the component files.
- 2026-05-08 — stub created from registry data per Decision 023 Phase 5 / B-07.
- 2026-06-04 — Tier-2 hand-authoring of Methodology, Key Assumptions and
Their Justification, Output Snapshot, Limitations and Known Gaps, Validation
Packet, and References from
finlib/curve_builder.py+ engine_registry + bound DS/A entries. Stub marker advanced to ``. - 2026-06-06 — code-grounded documentation-accuracy pass against the
post-INV-028 engine state (FinView #92). Added Standards Coverage (ASOP-56
reliance + SR 26-2 aggregate/concentration) and Dependencies (no upstream
models; 6 downstream consumers M-130/M-132/M-133/M-135 + family) sections.
Updated Methodology + Limitation 1 to reflect that
reprice_residuals()self-check and therefine_passesself-consistency loop exist but default OFF (refine_passes=0,self_check_tol_bps=None) to preserve frozen gold curves — so default/gold output still carries the long-end residual; replaced "not yet wired into a calibration self-check" with "self-check implemented but not yet enabled-by-default". Reconciled the Output Snapshot residual by re-runningscripts/model_snapshots.py M-137(default forward pass reproduces 66.10 bps; noted the refinement-on improvement ~0.14 bps as available-but- off). Added Validation-Packet row forreprice_residualsand reworded open MRM item (a). Noted the engine docstring's "cross-check against Bloomberg SOFR curve" as an aspirational line, not a wired validation (numpy-only, no Bloomberg dependency — Limitation 7). Noted gated/ratification items (INV-028 default-on + gold re-freeze, INV-032 drift gate, non-negative-forward guard, day-count / futures convexity) under Limitations. No model outputs, back-test numbers, or validation results were fabricated or changed. Code-side history stays in FinView git log.
Open findings (2)
Independent 2nd-line review (INV-2026-06) — implemented capability vs registered scope. Each carries a recommended fix and is tracked in insightalm-mrm until closed.
Validation evidence + change logs missing across most of the inventory
Only M-001/M-020/M-050 carried full documentation packs before this pass. Most models record validation_evidence: missing and change_log: missing with peer_review: pending. Gold tests freeze behaviour but many assert only structural invariants (e.g. reserve>0), not correctness against external truth. The flagship T0-vs-10-K match is circular (BV-032).
Recommendation: For each Tier-1 model: produce a validation-evidence pack (back-test vs disclosed results once BV-032 re-calibration lands, sensitivity suite, challenger comparison), a change log, and a 2L ratification. Sequence behind BV-032 (firm-data) for anything needing 10-K reconciliation.
FinView-resident pricing/CECL engines reached cross-repo with no drift gate
M-040 (CECL) and M-130-137 (asset pricing) are FinView-resident, consumed by InsModel only via sys.path insertion. No packaged interface or drift gate — a finlib signature change silently breaks the InsModel snapshot harness / consumers.
Recommendation: Define a packaged interface (or a versioned contract) for the FinView pricing library and a CI drift gate, so cross-repo consumers fail loudly on signature change. Tie to D041 ownership.
Per-tier expectations
Per MRM Framework §10.2 + §10.3, this model's regulatory_frameworks tag list activates the following overlays:
| component | tier-2 expectation | status |
|---|---|---|
| Registry entry | required | present |
| Model card (§10.5 doc pack) | required | present |
| Validation evidence | required | present |
| Change log | required | present |
| Independent effective challenge (2L) | required | attested |
Ratified — RAT-137-v1.0.1
Latest ratification on file: RAT-137-v1.0.1. Authored by 2L (mrm-peer-reviewer) per Decision 028 charter §5 Pattern A.