Task: IR curve bootstrapping engine: inverse solve over the raw instrument grid

Table of Contents

This page documents a task in the IR curve bootstrapping + official curve republish story. It captures the goal, current status, acceptance, and any notes or results.

Goal

Build the actual bootstrapper: given an ir_curve_bootstrap_config + its pillar list, and the raw pillar rates already sitting in market_observations (published by the IR curve family feed — IR Rates synthetic data generation, DONE), solve tenor-by-tenor for the discount factors that reprice every pillar at its observed rate. This is the inverse of ores.analytics.quant::service::curve_instrument_pricer — that class derives a par rate from a discount factor (deposit_rate, fra_rate, swap_par_rate, all forward calculations used by the synthetic generator); this engine goes the other way, solving for the discount factor a published rate implies, tenor by tenor from the short end, taking all shorter-maturity discount factors as already known (see Interest Rate Curves's bootstrapping section).

Applies the bootstrap config's interpolation_method and day_count_convention within each segment, and honours the Funding-before-Projection build order: a PROJECTION config's bootstrap run reads its discount_curve_config_id's already-published output series to discount its own pillars, rather than bootstrapping in isolation (see Multi-Curve Construction).

This task owns enforcing that order at run time — the config schema (bootstrap-config task) only records the dependency; nothing else in this story's task breakdown schedules or checks it. Concretely: before running a PROJECTION config's bootstrap, this engine must confirm its discount_curve_config_id's output series already has a discount-factor generation covering the required as-of, and fail/defer cleanly (not silently bootstrap against stale or absent discount factors) if it doesn't. Whether that's a same-process dependency check, a small queue, or a DAG walk over discount_curve_config_id across all of a tenant's configs is an implementation decision for this task's * Plan, not decided up front here.

The existing FOMC-dated OIS short end with flat-forward interpolation task is the concrete first interpolation-method instance this engine must support (flat-forward step short end, continuous long end, split at split_tenor_code) — sequenced after this task, not duplicated by it.

Depends on the bootstrap-config task for the config/pillar entities this engine reads.

Status

Field Value
State DONE
Parent story IR curve bootstrapping + official curve republish
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-08-05

Acceptance

  • A pure engine function/class exists (mirroring curve_instrument_pricer's pure, discount-factor-in/rate-out shape, inverted) that takes a bootstrap config's pillar list + the raw observed rate per pillar and returns one discount factor per tenor, reproducing each pillar instrument's observed market rate to within a documented numerical tolerance.
  • Day-count conventions are applied per pillar per day_count_convention, not a single hardcoded convention across all instrument types.
  • The configured interpolation_method governs discount factors between pillars; the FOMC-dated task's flat-forward/continuous split is provable as one configuration of this engine, not special-cased in the engine's code.
  • A PROJECTION config's bootstrap consumes its discount_curve_config_id's already-published discount factors rather than re-deriving them; a FUNDING config's bootstrap has no such dependency.
  • A PROJECTION bootstrap attempted before its discount curve's required generation exists fails/defers cleanly rather than running against stale or absent discount factors — this engine, not any other task, owns that check.
  • Unit tests cover at least: a single-segment curve, a two-segment (FOMC-style) curve, and a Projection curve discounted off a previously-bootstrapped Funding curve.

Plan

(Implementation strategy. Written when work starts; key decisions are distilled into the parent story's * Decisions at close, but the plan itself stays — it is the historical record of what we did.)

Two new pure, static, data-oriented components in ores.analytics.quant::service, in the same style as the existing curve_instrument_pricer (no classes with state, no inheritance, explicit structs passed by value):

  • day_count_calculator::year_fraction(start, end, convention_code) — supports A360, A365, A365F, and 30/360 for v1; throws for any other code (including the seeded-but-unimplemented ActAct*, 30E/360, Business252) rather than silently guessing. Adding a convention later is a self-contained addition to this one function's dispatch, no interface change.
  • curve_bootstrap_engine::bootstrap(value_date, pillars, day_count_convention, interpolation_method, discount_curve = nullptr) — walks pillars in maturity order and algebraically inverts curve_instrument_pricer's existing forward formulas (deposit_rate, fra_rate, swap_par_rate) to solve the one new discount factor each pillar's observed rate implies, given everything solved so far (interpolating where a needed date isn't an exact prior pillar). Plus a standalone interpolate_discount_factor(points, query_date, interpolation_method), independently testable.

Scoping decisions made explicit here rather than silently narrowed:

  • Single-curve, self-discounting only. curve_instrument_pricer's forward formulas take one homogeneous discount-factor concept used identically on both legs — there is no formula today for discounting a PROJECTION curve's cashflows off a separate FUNDING curve's discount factors while the PROJECTION curve's own solved values represent forward-rate projections. Genuine dual-curve discounting needs new pricer math beyond this task's scope; it mirrors the already-tracked "Dual-curve (discount + projection) short-rate model" task on the generation side. What this engine does implement for a discount_curve parameter is the Funding-before-Projection build-order check the acceptance criteria call for: an optional external curve that must already cover every pillar's dates, raising discount_curve_required_error (distinct from std::invalid_argument, since it's a "defer, don't crash" signal for the future official-curve-republish task) when it doesn't. It is not yet wired into any actual discounting math.
  • LOG_LINEAR_DISCOUNT and FLAT_FORWARD_THEN_LOG_LINEAR are one code path. For interpolating between two already-bootstrapped points, flat-forward (constant instantaneous forward over the segment) and log-linear-on-discount-factor are the same formula — proven by a dedicated equivalence test rather than asserted. The two labels only need to diverge once a non-local method (e.g. a spline) exists for the long end, which is exactly the FOMC-dated task's concern, sequenced after this one. CUBIC_SPLINE is named but not implemented; it throws.
  • SWAP fixed-leg schedules must align with the curve's own tenor grid. An intermediate fixed-leg payment date falling strictly between the last bootstrapped point and the current pillar's own (unsolved) maturity is a genuine algebraic circularity under log-linear interpolation — its discount factor would itself be a fractional power of the still-unknown df_end. That only resolves via iterative root-finding (e.g. Newton-Raphson), deliberately not implemented here. The engine instead requires such intermediate dates to coincide with a previously-bootstrapped pillar and raises a specific, clear error otherwise (distinct from the generic "beyond-frontier" error for DEPOSIT/FRA start dates).
  • Not a QuantLib port: independently derived from curve_instrument_pricer's existing forward formulas and standard bootstrapping identities, not from reading QuantLib's own implementation. Reconciling against QuantLib's actual functionality to find gaps is a reasonable follow-on task, not done here.

Notes

Interpolation method, day-count convention, and curve role are all strongly typed enums (day_count_convention_code, interpolation_method_code, bootstrap_curve_role_code), not plain strings, at the engine's own API boundary — an invalid or malformed code cannot compile into a call site, only fail at the one explicit parse_*_code() function that translates a refdata code string into its enum on the way in. Enumerator names are spelled identically to their refdata code string (e.g. DEPOSIT, A365, LOG_LINEAR_DISCOUNT) so each parser is a one-line delegation to magic_enum::enum_cast (already the codebase's standard for this, see ores.utility, ores.marketdata, ores.cli); the one exception is 30/360, renamed to the enumerator THIRTY_360 since '/' is not a valid identifier character, mapped by hand before falling back to magic_enum. This is still the extension seam for the future (a new day-count convention or interpolation method is a new enumerator plus a new switch arm, not a signature change) — it just fails at compile time for typos instead of only at runtime.

Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario, run through the QA Validation Runner panel) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
     

PRs

PR Title
#1863 [analytics.quant] IR curve bootstrapping engine: inverse solve over the raw instrument grid

Review

# Comment summary File Decision Notes
1 DEPOSIT pillar silently assumes start_date == value_date curve_bootstrap_engine.cpp Accepted Added explicit validation throwing std::invalid_argument, plus a rejection test.
2 thirty_three_sixty reads as "33/60" not "30/360" day_count_calculator.cpp Accepted Renamed to thirty_360.
3 No sanity bound on observed_rate producing a non-positive discount factor curve_bootstrap_engine.cpp Declined Deferred – engine inverts already-observed market rates; bad upstream data validation is a separate concern.
4 interpolate_discount_factor does two linear scans per call curve_bootstrap_engine.cpp Declined Fine at today's curve sizes (tens of pillars); binary search is a follow-up if this becomes a hot path.
5 discount_curve coverage check assumes the vector is sorted ascending, undocumented on the parameter curve_bootstrap_engine.cpp Declined Always the output of a prior bootstrap() call; documenting further is a minor follow-up, not blocking.

Result

Implemented day_count_calculator and curve_bootstrap_engine in ores.analytics.quant::service, pure/static, algebraically inverting curve_instrument_pricer's forward formulas to bootstrap DEPOSIT/FRA/SWAP pillars tenor-by-tenor from the short end. day_count_calculator supports A360/A365/A365F/30-360, throwing on any other code. curve_bootstrap_engine supports LOG_LINEAR_DISCOUNT and FLAT_FORWARD_THEN_LOG_LINEAR (proven equivalent for local interpolation by a dedicated test), throws on CUBIC_SPLINE, and enforces the Funding-before-Projection build order via an optional discount_curve parameter that raises discount_curve_required_error when a PROJECTION config's dependency isn't already covered — the build-order check only; genuine dual-curve discounting math is out of scope (see * Plan scoping decisions). All acceptance criteria met, including single-segment, two-segment (FOMC-style), and Projection-discounted-off-Funding unit test coverage. 229/229 ores.analytics.quant.tests cases passed locally.

Emacs 29.3 (Org mode 9.6.15)