Story: IR Rates synthetic data generation
Table of Contents
This page documents a story in Sprint 23. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
Carried from Sprint 22 as a not-yet-started BACKLOG story. Sprint 22's Stories table incorrectly marked this ABANDONED; the story doc records no such decision, so it is being carried forward as real work.
Goal
Extend synthetic market data generation from FX spot to interest-rate curves: a short-rate stochastic engine (Hull-White/CIR/Vasicek) drives a configurable "Curve Template" (short-end deposits, mid-curve FRAs/futures, long-end swaps) per currency+index, publishing tick batches of raw instrument observations. Deliberately scoped to raw instrument generation only — no bootstrapping, no vol surfaces — but the output shape must be validated against what those two, imminent, follow-on stories will need.
No QuantLib dependency: the short-rate process is pure closed-form
math (same shape as the FX spot engines already in
projects/ores.synthetic/service/src/processes/), consistent with
keeping ores.synthetic lean for future GPU/high-instrument-count
optimisation (target: 30-50 FX rates, hundreds of IR rates, tens of
vol surfaces).
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 23 |
| Now | Closed DONE 2026-07-20: own Acceptance met (short-rate engine, Curve Template, tenor-collision validation, tick-batch publishing all shipped and validated). Remaining forward-looking tasks (dataset seeding, index cleanup, dual-curve, quoting conventions) split into a product-backlog story. Known limitation: even after day-scaling kappa and sigma for the "1 tick = 1 calendar day" convention (kappa=0.5/365), the published synthetic.ir_curve_configs.basic dataset still produces unrealistic (~150-250) rates over longer tick counts (a 2Y entry needs 730 daily ticks) — suspected cause is the Vasicek discount-factor recursion's variance term (proportional to sigma^2/kappa^2) going numerically unstable at that small a kappa, not simply a missing "divide by 365". The tick-batch pipeline itself (generation/batching/publish/ingest/persistence) is proven correct via this same dataset; only the kappa and sigma calibration is unresolved. Tracked in the dataset-seeding follow-up task. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-20 |
Acceptance
- A short-rate stochastic engine (Hull-White, CIR, or Vasicek — the
team's choice among the three) is implemented as a new
IStochasticProcessinprojects/ores.synthetic/service/src/processes/and wired intoprocess_factory, with zero QuantLib dependency. - A new sub-config (e.g.
ir_curve_generation_config, owned bymarket_data_generation_config, mirroringfx_spot_generation_config) lets a party configure one Curve Template per currency+index (e.g. USD-SOFR, EUR-ESTR): short-end deposits, mid-curve FRAs/futures, long-end swaps. - A lightweight, QuantLib-free tenor type (parses labels like "3M",
"2Y", "1W" and supports simple
std::chrono-based date-window overlap checks — no business-day calendar machinery) exists and is used by the tenor-collision validator below. No such type exists anywhere in the codebase today (confirmed by investigation); this is new, reusable infrastructure, not an adaptation of an existing utility. - The Curve Template is validated at configuration time against
tenor collisions/gaps (e.g. a swap tenor landing inside a futures
instrument's active delivery window) using that tenor type — a
pure, engine-side validator in
ores.synthetic.api(same pattern asvalidate_process_parametersfrom the GMM improvements story), not UI-hardcoded logic. This directly prevents the "multi-dimensional iterative solver collapse" failure mode a downstream bootstrapper would otherwise hit. - Each generation step publishes a "tick batch": N individual ticks
(one per configured tenor/instrument), each its own
market_observationrow sharing oneobservation_datetime, withpoint_idset to the tenor label (asset_class::rates,series_subclass::yield/fra/basisas appropriate — no schema changes needed, these already exist). - The shared
observation_datetimeacross a tick batch is confirmed sufficient for a future bootstrap consumer to group "ticks belonging to one generation cycle" without building that consumer here.
Analysis: does a single short-rate draw actually guarantee a bootstrappable curve? (resolved, see Decisions)
Flagged against ab-notebook: reproducible interest rate curve bootstrapping (Ballabio's replication of Ametrano & Bianchetti 2013) — the reference case study for what a consistent multi-instrument curve actually requires to bootstrap cleanly.
The Goal and Acceptance above say the short-rate process "drives" the Curve Template, but never spell out how individual instrument quotes (deposit, FRA, swap, at whatever tenors the template specifies) are derived from one process realisation so that they remain mutually consistent. That distinction matters:
- If each tenor's tick is generated from an independent draw/noise term (the same pattern as the FX spot engines, where each currency pair is its own independent process), nothing guarantees the resulting instrument grid is internally consistent — a downstream bootstrapper could easily fail on exactly the "iterative solver collapse" this story's tenor-collision validator was designed to prevent, just from a different cause (inconsistent quotes rather than colliding tenors).
- Mean-reverting short-rate models (Vasicek, CIR, Hull-White) admit a
closed-form affine zero-coupon bond price,
P(t,T) = A(t,T)·exp(-B(t,T)·r_t), as a function of the single short-rate stater_tat generation time. This is the "latent IR curve": every instrument's rate (deposit, FRA, swap) at every tenor in the Curve Template should be derived from that oneP(t,T)formula, evaluated at the currentr_t— not from separate independent noise per tenor. Doing so guarantees the published tick batch is, by construction, a slice of one coherent curve that a future bootstrapper can actually reconstruct.
Resolved — see the corresponding Decision below and the
short-rate-process and curve-template-config tasks' Acceptance,
which now require the process to expose P(t,T), not just a path of
r_t samples.
Explicitly not required (would over-scope this story)
- Curve bootstrapping itself (deterministic yield-curve construction, throttled/dirty-flag evaluation) — separate future story.
- FX/IR volatility surfaces, frozen-pool interpolation, CIP-derived
forwards — separate future story. Gemini's analysis
(
doc/analysis/gemini_ir_rates_synthetic_support.org) conflates this with IR rate generation; this story deliberately does not.
Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Lightweight QuantLib-free tenor type | DONE | 2026-07-11 | 2026-07-13 | Parse tenor labels (3M, 2Y, 1W) and support simple std::chrono-based date-window overlap checks, with no business-day calendar machinery and no QuantLib dependency. |
| Short-rate stochastic process (Hull-White/CIR/Vasicek) | DONE | 2026-07-12 | 2026-07-13 | New IStochasticProcess implementation for mean-reverting short-rate dynamics, wired into process_factory, mirroring ou_process's shape. |
| Curve Template sub-config (ir_curve_generation_config) | DONE | 2026-07-14 | 2026-07-14 | New sub-config owned by market_data_generation_config, one per currency+index, configuring the raw instrument grid (deposits, FRAs/futures, swaps). |
| Tenor-collision/gap validation on the Curve Template | DONE | 2026-07-14 | 2026-07-15 | Pure, engine-side validator (ores.synthetic.api) rejecting instrument grids where tenors overlap or collide with an active delivery window, using the new tenor type. |
| Tick-batch publishing and persistence for curve instruments | DONE | 2026-07-15 | 2026-07-19 | Each generation step publishes N individual ticks (one per tenor), each its own market_observation row sharing one observation_datetime, point_id set to the tenor label. |
| Term structure/tenor knowledge docs: Pillar, provenance/ladder cleanup | DONE | 2026-07-12 | 2026-07-12 | Add a Pillar knowledge doc and rework Curve Point Provenance/Multicurve Management to link rather than duplicate definitions, following Zettelkasten conventions. |
| Foundation-layer population for tenor reference data (tenor/tenor_anchor/tenor_convention) | DONE | 2026-07-13 | 2026-07-13 | Idempotent Foundation-layer populate scripts seeding the standard tenor catalog, tenor anchors, and tenor conventions/resolutions, plus the runtime resolver reading them. |
| Tenor management UI: tenant provisioner copy fix + hand-crafted management screen | DONE | 2026-07-13 | Fix: tenant provisioner did not copy tenor/tenor_anchor/tenor_convention/tenor_convention_resolution from the system tenant into new tenants (RLS would have hidden them). UI: hand-crafted Qt screen joining tenor x tenor_convention x tenor_convention_resolution so a user can inspect the full resolved data in one view, since codegen does not generate UI for junction models. | |
| Document stochastic processes: knowledge hub + per-process pages | DONE | 2026-07-16 | 2026-07-16 | A stochastic-processes knowledge hub (GBM, Wiener/Brownian motion, Ito's lemma, OU/Vasicek, CIR, Hull-White) plus one atomic doc per process: layperson description, chart where applicable, paper summary and related-work references. |
| Curve snapshot builder/viewer: as-of query + UI for reviewing a raw instrument grid | DONE | 2026-07-19 | 2026-07-20 | A read-side as-of query (latest observation per point_id, as-of a given time, for one series) that reconstructs a curve/grid snapshot from independently-ticking market_observation rows, plus a first UI surface to view it. |
Decisions
- A curve/market-data viewer's entry point and identity must be the
official
market_series(series_type/metric/qualifier), never a producer-specific config – a first pass oncurve-snapshot-builder-viewerput the entry point onores.synthetic's own config screen and keyed the viewer off a synthetic config's currency/index fields, which would have made the viewer unreachable for any curve sourced from a real vendor feed. Corrected: moved to a new "Interest Rates" entry on the shared Market Data menu, backed bymarket_seriesdirectly (populated the same way regardless of producer). - The
DISTINCT ON (key) ... ORDER BY key, time DESC"as-of" pattern (latest value per independently-updating key at or before a given time) and its bucketed evolution form (generate_series+LATERAL, one DB round trip, not a C++ loop) are general-purpose timeseries-repository patterns, not curve-specific – captured separately (as-of-and-as-of-bucket-query-patterns) for reuse beyondmarket_observations_repository. - Short-rate SDEs (Hull-White/CIR/Vasicek) chosen over the sprint-21
approach doc's GMM-fit-on-historical-par-rate-vectors technique
(
doc/analysis/intermediate_analysis_technique_to_asset_class_mapping.org, Step 1). The GMM-on-vectors technique is a batch/snapshot draw, not a streaming per-tick generation — it doesn't fit theIStochasticProcess=/=process_factory=/=feed_controller=/NATS-tick architecture already built for FX spot. Short-rate SDEs are structurally identical in shape to the =ou_processjust added (mean-reverting, closed-form transition density), so they slot into the existing architecture directly. This decision supersedes the sprint-21 doc's IR generation technique. - No QuantLib dependency, confirmed unnecessary: the short-rate process is pure math (no calendar/date types needed), and the tenor-collision validator only needs simple date-window overlap (confirmed sufficient — no business-day-calendar-aware scheduling required for that check specifically). Full QuantLib-grade calendar logic would only be needed for actual bootstrapping, which is out of scope.
- Validated against prior art already in the repo before committing
to this scope:
market_data_generation_config's docstring already anticipated "vol surface, interest-rate curves later" as sibling sub-configs;ores.marketdata's schema already hasasset_class::rates,series_subclass::yield/fra/basis/xccy, andmarket_observation.point_idwas explicitly designed for tenor/surface coordinates (non-scalar series) — no schema redesign needed for this story. - Tenor-collision validation is in scope here (raw-instrument-grid
concern); the throttled/dirty-flag bootstrap evaluation pattern
Luigi Ballabio's principles call for is downstream consumer work,
out of scope — this story's only obligation to it is ensuring the
tick-batch contract (shared
observation_datetime) carries enough signal for a future consumer to throttle. - Resolved (2026-07-11): instrument quotes derive from one latent
curve, not independent per-tenor noise. Every instrument rate in a
tick batch (deposit, FRA, swap) is computed from the short-rate
process's closed-form affine zero-coupon bond price,
P(t,T) = A(t,T)·exp(-B(t,T)·r_t), evaluated at the batch's singler_tdraw — never from an independent noise term per tenor (the FX spot pattern, where cross-pair consistency isn't a concern). This guarantees the published grid is, by construction, a slice of one internally consistent curve a future bootstrapper can actually reconstruct, rather than risking the same "iterative solver collapse" failure mode from inconsistent quotes that the tenor-collision validator already guards against from colliding tenors. See ab-notebook: reproducible interest rate curve bootstrapping for the reference case study this was checked against. Reflected in the short-rate-process and curve-template-config tasks' Acceptance. - Resolved (2026-07-13): tenor is a persisted, orderable reference-data
entity, not just an in-process value type. The original tenor-type
task's hand-authored value type (
ores.marketdata::domain::tenor, PR #1522) modeled a tenor as pure parsing logic with no persisted identity — but users need to see, manage, and order the standard tenor set, the same wayday_count_fraction_typeis already a full codegen entity rather than a hardcoded enum. Redesigned as four codegen entities:tenor(the label catalog),tenor_anchor(SPOT/TODAY/ TOMORROW/NEAR_LEG/IMM_ROLL reference points),tenor_convention(one row per asset-class resolution scheme, carrying aresolution_algorithmso credit/CDS's genuinely different IMM-roll algorithm fits without a future schema change), andtenor_convention_resolution(junction recording which tenors belong to which convention, with a per-tenor anchor/offset override forSPECIALtenors likeO/Nwhose duration itself varies by convention). A first runtime companion (tenor_period, PR #1535) hardcoded that per-label resolution knowledge independently of this model and was closed/folded in once caught — the final runtime resolver is a pure function over the persisted rows instead, proven by a test that resolves the same tenor to different dates under different conventions. Seeded via the Foundation layer (universal reference data, same category as day-count conventions), not the DQ Librarian/bundle pattern (which is for optional, party-selectable seed data). See Foundation-layer population for tenor reference data for what shipped. - Resolved (2026-07-14): "instrument role" generalised into a full,
reusable product catalogue rather than a narrow 3-value list.
Rather than hardcoding deposit/FRA/swap as the only Curve Template
roles,
instrument_codecatalogues ORE's entireoreTradeTypeenumeration (external/ore/xsd/instruments.xsd, 114 entries), each tagged with anasset_class_code(fx, rates, credit, equity, commodity, inflation, bond, cross_asset). This makes the catalogue reusable well beyond this one config, and is itself a test of populating a codegen entity from a real, complete external enumeration rather than a hand-picked subset. Since ORE has no distinct money-market-deposit trade type of its own (it models a deposit as a single-periodSwap), one documented ORE Studio-specific addition,Deposit, was added (code 115) so the Curve Template can label its short end distinctly; its pricing still derives from the same par-rate formula a single-periodSwapwould use. - Resolved (2026-07-15): ir_curve_template_entry models every entry as
a genuine
[start, end)tenor period, not a single maturity label. The tenor-collision validator's acceptance criteria required detecting a swap tenor landing inside a FRA's active delivery window, but the entity only stored onetenor_code– no data to express a period's start distinct from "now." Rejected a bolt-on nullableperiod_start_tenor_code(a "sometimes meaningful" field) and deriving periods implicitly fromsequence_indexadjacency (fragile – reordering rows would silently change meaning) in favour of renamingtenor_codetoend_tenor_codeand adding a requiredstart_tenor_code, both ordinary tenor references resolved through the existingresolve_end_date()machinery. Point instruments (deposits, swaps) setstart_tenor_codeto'SPOT'– a real, already-catalogued zero-durationPERIOD/DAYtenor already inRATES_SPOT_FORWARD's tenor set – rather than a null-means-horizon sentinel; interval instruments (FRAs) set it to the period's own front tenor. This is what lets the validator use a single, uniformwindows_overlap()check without a special case for point vs. interval instruments (point instruments are represented as a degenerate[maturity, maturity)window, so they never trivially collide with each other purely by sharingSPOTas their start). See the tenor-collision-validation task for the validator itself. - Resolved (2026-07-15): ores.synthetic.service was missing live NATS
eventing entirely. While wiring the validator's dependency on
ores.refdata.api's tenor resolution, discoveredores.synthetic.servicehad nopostgres_event_source=/=event_buspipeline at all – every entity in the component was missing live change events, a pre-existing gap unrelated to this story's own work. Fixed by wiring it following the exact pattern already established inores.refdata.service(hand-maintainedevent_registraraggregator + per-entity generatedregister_<entity>_event_mapping()functions), covering all fiveores.syntheticentities in one pass. - Resolved (2026-07-19): IR curve identity fields follow FX's own
conventions exactly, not a parallel shape.
folder_id(real folder-tree placement) and a persisted, editablesource_name(synthetic.<collection>.<pair>, e.g. "synthetic.realistic.usdsofr") now mirrorfx_spot_generation_configfield-for-field, replacing an earlierir_curve.<ccy>.<idx>string computed independently at every consumer.index_nameis FK-validated againstores.refdata.floating_index_type(storing the full ISDA code to reuse that entity's existing single-argument validator rather than a bespoke composite one the codegen Insert-trigger mechanism can't express). - Resolved (2026-07-19): single-curve (self-discounting) modeling is correct for the currently-seeded overnight RFR indices (SOFR/ESTR/ SONIA/etc.), not a simplification that happens to work. An OIS swap referencing an overnight index genuinely is single-curve in real markets. The mismatch a dual discount/projection curve would resolve is specifically for IBOR-style term indices (EURIBOR, legacy LIBOR, term SOFR) — those should not be added to the seeded universe until the dual-curve follow-on task lands.
- Market Simulator's IR integration deliberately copies
FxSpotRateEditor's UI conventions where there is no IR-specific reason to differ (segmented Simple/Advanced toggle, slider shape, editable+completer currency combo), and diverges only with a stated reason where rates genuinely warrant it (the curve-shape chart, not sample-paths, is the hero view — an IR-only chart FX has no equivalent of).
Out of scope
- Curve bootstrapping (see Acceptance).
- FX/IR volatility surfaces, frozen-pool interpolation (see Acceptance).
- Business-day-calendar-aware tenor arithmetic (the tenor type here is deliberately simple date-window overlap only).