Task: Vintage-aware seeding for ir_curve_feed
Table of Contents
This page documents a task in the IR Rates synthetic data: dataset seeding, index cleanup, dual-curve, quoting conventions story. It captures the goal, current status, acceptance, and any notes or results.
Goal
Give IR curve synthetic feeds the same "grounded in real data, easy to
demo" quality FX spot feeds already have. Today an
ir_curve_generation_config must be hand-entered with arbitrary
kappa=/=theta=/=sigma=/=initial_rate values — nothing stops (and
nothing guides away from) unrealistic numbers, since there's no
reference. ir_curve_feed=/=make_ir_curve_feed has no
vintage_source=/=vintage_date plumbing at all today — initial_rate
always comes straight from the hand-entered config, unlike FX's
feed_controller::start(), which resolves it from a real imported
observation when a vintage is requested. This task adds the equivalent
path for IR curves, consuming the dataset sourced by
the sibling sourcing task.
Mirrors synthetic_fx_spot_configs_basic_populate.sql /
synthetic_fx_spot_configs_realistic_populate.sql, which both seed
initial_price from a real vintage (2016-02-05 Fed H.10), differing
only in per-pair-calibrated vs exaggerated-uniform volatility.
Already delivered by prior tasks (not this one)
synthetic.ir_curve_configs.basic=/.realistic= DQ datasets, day- scaled defaultkappa=/=sigma— tick-batch-publishing task (615FD100).- The manual per-tenant start/stop/list control-plane
(
ir_curve_feed_config_handler, request-scoped context via JWT) and its Qt toolbar entry point onIrCurveGenerationConfigMdiWindow— delivered directly ahead of this task's own start, see commit19c74d4d9. A dedicated "IR Simulator" window (mirroring the FX Market Simulator) remains future work, not required by this task's acceptance.
Split out of this task
- Source vintage historical IR rate dataset + populate script — the external data sourcing this task consumes.
- Fix Vasicek day-scaled kappa/sigma numerical instability — a pre-existing calibration bug surfaced during manual verification, independent of vintage seeding.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | IR Rates synthetic data: dataset seeding, index cleanup, dual-curve, quoting conventions |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-18 |
Acceptance
ir_curve_feed=/=make_ir_curve_feedgains a vintage-aware seeding path (mirroringfeed_controller::start()'svintage_source=/ =vintage_date), soinitial_ratecomes from a real imported observation rather than an arbitrary config value.- Both the auto-start path and the on-demand
ir_curve_feed_config_handler::start()path use this vintage resolution identically, so they can never drift (same patternmake_ir_curve_feedalready uses to keep both paths in sync). - Missing vintage data is rejected with an actionable error, mirroring
feed_controller'svintage_data_missingresult — not a silent fallback to an arbitrary default.
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.)
Mirrors fx_spot_generation_config's price_source=/=vintage_source=/
=vintage_date discriminator (see feed_controller::start()'s
vintage_data_available()) onto ir_curve_generation_config and
curve_feed_controller. IR's initial_rate plays the role FX's
gmm_initial_price plays.
1. Model the three new fields (codegen source of truth)
ir_curve_generation_config is codegen'd (SQL/domain/table/entity/
mapper files all carry "AUTO-GENERATED — DO NOT EDIT MANUALLY"
banners) — the fields go into
projects/ores.synthetic/modeling/ores.synthetic.ir_curve_generation_config.org
first, mirroring
ores.synthetic.fx_spot_generation_config.org's own
price_source=/=vintage_source=/=vintage_date sections and Checks
table entries, then regenerate (codegen-add-domain-type /
code-run-build codegen step) rather than hand-editing generated
output:
price_source(text, default"fixed", not"vintage"— IR curve configs today are all hand-entered kappa/theta/sigma/initial_rate, unlike FX which defaults new configs to vintage-seeded).vintage_source(text, default empty).vintage_date(text, default empty).- SQL check mirroring FX's:
"price_source" in ('fixed', 'vintage')and the paired"fixed" and "initial_rate" > 0 and vintage cols empty/"vintage" and "initial_rate" = 0 and vintage cols non-emptyconstraint — swappinggmm_initial_priceforinitial_rate. - Regeneration touches (confirm the generated diff matches this list):
ir_curve_generation_config.hpp(domain struct),_table.hpp/cpp,_json_io.hpp/cpp,_entity.hpp(core repository),_mapper.cpp,_generator.cpp, the SQL create script (synthetic_ir_curve_generation_configs_create.sql), and the Qt generated pieces (Detail fields=/=Columnstables already listauto_startfrom the sibling task — add the three new rows there too, same pattern:priceSourceEdit=/=vintageSourceEdit=/ =vintageDateEditwidgets,PriceSource=/=VintageSource=/ =VintageDatemodel columns).
2. curve_feed_controller: vintage resolution, mirroring feed_controller
Port feed_controller's vintage_data_available() (parse ORE
key → market_data_client::find_series() → paged
list_observations_page() scan matching source=/=point_id=/
=observation_datetime date part) onto curve configs. Differences from
FX to account for:
- Decision: resolve a single scalar, not a per-entry vector.
initial_rateis a parameter of the short-rate process itself (kappa=/=theta=/=sigma=/=initial_rate, seeprocess_factory::make_yield_curve_process()), not of each published tenor point — everyentries_tick is derived from that one process viaprice_ir_curve_entry()(ir_curve_feed.cpp:129), mirroring exactly how FX's singlegmm_initial_pricescalar seeds the process that all subsequent ticks derive from. So there is nothing to resolve per-entry. - Anchor point: after
resolve(entries, refctx, ...)builds their_curve_resolved_entrylist, pick the entry withcurve_role ="DEPO"= and the smallest tenor (the point instrument closest to an overnight/short rate — the natural real-world analog to a short-rate model'sinitial_rate), and look up its vintage observation by(vintage_source, vintage_date, point_id)— samefind_series+ pagedlist_observations_page()scan as FX, keyed on that entry's ownpoint_idinstead of a hardcoded"SPOT". A template with noDEPOentry is an actionablevintage_data_missingerror, not a fallback to a different role. - Add
curve_feed_controller::start_result::vintage_data_missing(alongside the existingstarted=/=already_running=/ =qualifier_conflict), returned with an actionableerror_detailwhen the vintage lookup fails — never a silent fallback to the config's hand-enteredinitial_rate. add()(auto-start path) andstart()(on-demand path) both run the same resolution before constructing theIYieldCurveProcess, so they can never drift — same invariant the task's Acceptance calls out, same shapemake_ir_curve_feedalready keeps in sync today.
3. make_ir_curve_feed=/=ir_curve_feed_config_handler wiring
make_ir_curve_feedgains the vintage resolution call (or takes a pre-resolvedinitial_ratefrom the caller, matching whichever splitcurve_feed_controllerends up owning per step 2) instead of readingcfg.initial_rateunconditionally as it does today (ir_curve_feed.cpp:187).ir_curve_feed_config_handler::start()threads througherror_detail=/=caller_bearer_tokenthe same waymarket_feed_config_handlerdoes today for FX, so avintage_data_missingresult reaches the Qt/shell caller as an actionable message rather than a generic failure.vintage_validity_handler.hpp'sValidate Vintageaction already covers IR curve configs (delivered by the sibling sourcing task) — confirm it composes with this task's resolution path rather than duplicating it (ideally both call the same underlying resolver).
4. Verification
- Unit tests around the new
curve_feed_controllervintage resolution (missing vintage →vintage_data_missingwith actionable detail; present vintage → correct seed value(s) reach the constructed process) — mirroring whatever coverage (or documented gap) exists forfeed_controller's ownvintage_data_available(). - Manual QA scenario via the QA Validation Runner: start an
auto_start=falseIR curve config in vintage mode against the legacy/recent vintage datasets the sibling task seeded, confirminitial_rateis seeded from the real imported observation and a deliberately-wrongvintage_dateproduces the actionable rejection message.
Notes
Implementation status (code complete, manual QA scenario still pending):
price_source=/=vintage_source=/=vintage_dateadded toir_curve_generation_configvia the codegen model and regenerated (domain/table/entity/mapper/SQL/Qt Detail+Columns all updated).price_sourcedefaults to"fixed"(non-breaking for existing rows), unlike FX's"vintage"default.make_ir_curve_feedresolvesinitial_ratefrom a realmarket_observationwhenprice_sourceis"vintage", anchored on the resolved Curve Template's shortest-tenorDEPOSITentry (seeselect_vintage_anchor_entry(), unit-tested in isolation since the full resolution path needs a livemarket_data_client). Throws the newvintage_data_missing_errorwith an actionable detail on failure – mirrorsfeed_controller'svintage_data_missingresult contract for FX, as an exception instead of a result enum sincemake_ir_curve_feedalready throws for other construction failures.- Both call sites (auto-start in
application.cpp, on-demand inir_curve_feed_config_handler) go through this same function and now thread an authenticatednats_client(svc_nats=/=auth_nats) throughregistrar, so the on-demand path can delegate the lookup with the caller's own bearer token. - Full build green;
ores.synthetic.service.testsextended withir_curve_feed_vintage_tests.cpp(4 new cases covering anchor selection: shortest-tenor pick, non-DEPOSIT entries ignored, empty template, tie-breaking) – 21/21 cases passing.
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 |
|---|---|---|
| Verify vintage-aware seeding for ir_curve_feed | PASSED | All 6 steps pass against a fresh Barclays provision |
PRs
| PR | Title |
|---|---|
| #1705 | [dq,sql,qt] Vintage-aware seeding for ir_curve_feed |
Review
| Comment summary | File | Decision | Notes |
|---|---|---|---|
Result
All acceptance criteria met and manually verified end-to-end against a freshly provisioned Barclays Plc tenant:
ir_curve_feed=/=make_ir_curve_feedresolvesinitial_ratefrom a realmarket_observationwhenprice_source=vintage, anchored on the resolved Curve Template's shortest-tenorDEPOSITentry;vintage_data_missing_erroron a missing observation, mirroringfeed_controller's contract for FX.- Both the auto-start and on-demand (
ir_curve_feed_config_handler) paths share this resolution, so they can't drift. IrCurveEditorgained theprice_source=/=vintage_source=/ =vintage_datefields (mirroring FX), including a Browse-available- vintages picker.ores.synthetic.service.testsextended withir_curve_feed_vintage_tests.cpp(4 new cases); full suite green (74/74).
Two real bugs surfaced and fixed along the way, both required for the scenario to even reach a runnable state:
publication_service::list_bundle_publishable_datasetsdidn't resolve a bundle member's own dependencies, so a vintage-mode config's dependency on the real market data it resolves against was silently dropped from bundle publish.refdata.calendarshad no declared dependency oniso.countriesinores_dq_dataset_dependencies_tbl, so the dependency-graph resolution above could (and did) order calendars before the country rows (including theZZsupranational sentinel) their owncountry_codecolumn requires – breaking real GLEIF tenant provisioning, not just this story's synthetic bundles. Also fixed a duplicateaccount_contact_informations_tenant_isolation_policyRLS policy block iniam_rls_policies_create.sqlblocking DB recreate entirely, unrelated pre-existing bug hit along the way.
Three follow-up gaps discovered during manual QA, filed as separate BACKLOG tasks in this story rather than fixed here (out of scope for this task's acceptance):