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 IStochasticProcess in projects/ores.synthetic/service/src/processes/ and wired into process_factory, with zero QuantLib dependency.
  • A new sub-config (e.g. ir_curve_generation_config, owned by market_data_generation_config, mirroring fx_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 as validate_process_parameters from 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_observation row sharing one observation_datetime, with point_id set to the tenor label (asset_class::rates, series_subclass::yield/fra/basis as appropriate — no schema changes needed, these already exist).
  • The shared observation_datetime across 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 state r_t at 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 one P(t,T) formula, evaluated at the current r_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 on curve-snapshot-builder-viewer put the entry point on ores.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 by market_series directly (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 beyond market_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 the IStochasticProcess=/=process_factory=/=feed_controller=/NATS-tick architecture already built for FX spot. Short-rate SDEs are structurally identical in shape to the =ou_process just 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 has asset_class::rates, series_subclass::yield/fra/basis/xccy, and market_observation.point_id was 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 single r_t draw — 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 way day_count_fraction_type is 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 a resolution_algorithm so credit/CDS's genuinely different IMM-roll algorithm fits without a future schema change), and tenor_convention_resolution (junction recording which tenors belong to which convention, with a per-tenor anchor/offset override for SPECIAL tenors like O/N whose 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_code catalogues ORE's entire oreTradeType enumeration (external/ore/xsd/instruments.xsd, 114 entries), each tagged with an asset_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-period Swap), 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-period Swap would 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 one tenor_code – no data to express a period's start distinct from "now." Rejected a bolt-on nullable period_start_tenor_code (a "sometimes meaningful" field) and deriving periods implicitly from sequence_index adjacency (fragile – reordering rows would silently change meaning) in favour of renaming tenor_code to end_tenor_code and adding a required start_tenor_code, both ordinary tenor references resolved through the existing resolve_end_date() machinery. Point instruments (deposits, swaps) set start_tenor_code to 'SPOT' – a real, already-catalogued zero-duration PERIOD/DAY tenor already in RATES_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, uniform windows_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 sharing SPOT as 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, discovered ores.synthetic.service had no postgres_event_source=/=event_bus pipeline 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 in ores.refdata.service (hand-maintained event_registrar aggregator + per-entity generated register_<entity>_event_mapping() functions), covering all five ores.synthetic entities 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, editable source_name (synthetic.<collection>.<pair>, e.g. "synthetic.realistic.usdsofr") now mirror fx_spot_generation_config field-for-field, replacing an earlier ir_curve.<ccy>.<idx> string computed independently at every consumer. index_name is FK-validated against ores.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).

Emacs 29.3 (Org mode 9.6.15)