Task: Tick-batch publishing and persistence for curve instruments

Table of Contents

This page documents a task in the IR Rates synthetic data generation story. It captures the goal, current status, acceptance, and any notes or results.

Goal

Extend the FX spot streaming pipeline (projects/ores.synthetic/service/src/feed_controller.hpp, fx_spot_feed) to the "one process step → N tenor values" shape a curve needs, unlike FX spot's one process step → one scalar. Each generation step publishes a tick batch: N individual NATS ticks (one per Curve Template tenor), each landing as its own market_observation row sharing one observation_datetime (asset_class::rates, series_subclass::yield/fra/basis per instrument role, point_id = tenor label) — mirroring the source=/ =point_id fix already made to feed_ingest_loop.cpp for FX spot in the GMM improvements story.

Status

Field Value
State DONE
Parent story IR Rates synthetic data generation
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-07-16

Acceptance

  • One short-rate process step yields a batch of N tenor values (per the Curve Template); each is published as its own tick, all sharing one observation_datetime.
  • Ingest persists each tick as its own market_observation row with correct asset_class=/=series_subclass=/=point_id — no schema changes required (already supports this).
  • Confirms the shared observation_datetime is sufficient signal for a future bootstrap consumer to group a batch into one generation cycle, without building that consumer.

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.)

Design discussion (before implementation)

A proper swap par rate needs its fixed-leg payment schedule (a strip of periodic discount factors), not just its two endpoints — treating a Swap entry as a single-period deposit-equivalent (an early draft of this plan) was rejected as defeating the purpose of modelling a swap at all. This requires:

  • A genuine, catalog-driven classification of "how do I price this instrument against a curve" (deposit / fra / swap), rather than inferring it from start_tenor_code = "SPOT"= (Deposit and Swap are both point instruments but need different formulas).
  • A payment frequency for the swap's fixed leg, to build its intermediate payment dates between spot and maturity.

Payment frequency: consolidating a genuine duplication

ORE's canonical frequencyType enumeration (external/ore/xsd/ore_types.xsd: Once, Annual, Semiannual, Quarterly, Bimonthly, Monthly, Lunarmonth, Weekly, Daily) was already modelled once, as ores.trading.payment_frequency_type (used by trade-leg conventions). Building a second, separate ores.refdata entity for the same concept (an early draft of this plan) was rejected once spotted as a real duplication, not a justified split — there is no case for two types here ("payment frequency conventions" vs "payment frequencies" is a flimsy distinction); it is one type with properties.

Resolved: a single payment_frequency entity, promoted into ores.refdata (genuinely shared reference data — used by trading leg conventions and curve-template schedule building, not trading-owned), replacing ores.trading.payment_frequency_type entirely:

  • code (PK): the canonical long-form codes above.
  • period_unit: FK-validated against tenor_unit.code (DAY=/=WEEK=/=MONTH=/=YEAR=/=NONE) via the standard Insert-trigger Validations mechanism — reuses the existing tenor_unit catalog (including its NONE sentinel row) rather than duplicating the vocabulary a second time as an inline CHECK.
  • period_multiplier (nullable int): null only when period_unit = NONE (i.e. Once).
  • Tenant scope: system. This is a statement of fact (ORE's own enumeration), not a per-tenant convention — same category as tenor_unit=/=book_status, not swap_convention.
  • Badge on period_unit (the referencing column), reusing tenor_unit's already-seeded badge domain/mappings (colors for DAY=/=WEEK=/=MONTH=/=YEAR=/=NONE were seeded earlier this sprint) — no new badge definitions needed. Not badged on its own code: an earlier draft self-badged code, which was caught as breaking the established "badge the referencing column, not the code table itself" convention (instrument_code.asset_class, not asset_class_code.code).
  • Migration: delete ores.trading.payment_frequency_type (entity + every generated layer + SQL + Qt UI + menu wiring), and repoint every ores.trading column that stores a payment-frequency code string to validate against the new ores.refdata.payment_frequency instead — swap_leg.payment_frequency_code (the only one with a real FK today), plus credit_instrument.payment_frequency_code, commodity_instrument.payment_frequency_code, and equity_swap_instrument.payment_frequency (currently unvalidated free text) — wiring FK validation onto all of them is a deliberate test of the consolidated data model, not just a like-for-like swap.

Still to design (not yet resolved)

  • instrument_code's curve_role classification (deposit/fra/swap/none).
  • ir_curve_generation_config.fixed_leg_payment_frequency (FK to the new payment_frequency) and the periodic-schedule-building pure function it feeds.
  • The ir_curve_feed producer itself and ingest-side wiring.

Payment frequency migration: completed

The payment_frequency consolidation described above is implemented:

  • New ores.refdata.payment_frequency entity (full codegen facet set: API/core/service/Qt/SQL), seeded with the 9 canonical rows, badge on period_unit reusing tenor_unit's existing badge domain, DQ publish-from-dq wired.
  • ores.trading.payment_frequency_type deleted entirely: modeling .org, all generated API/core C++, Qt list/detail/history dialogs + menu wiring, CLI entity registration, and its ores.trading.module.org catalog row.
  • All four ores.trading consumer columns (swap_leg=/=credit_instrument=/=commodity_instrument=/=equity_swap_instrument) repointed to validate against the new entity, including the three that previously had no FK validation at all.
  • Schema validated clean (253 tables, 0 warnings) after db recreate.

Bugs found and fixed while UI-testing the migration

Testing the payment-frequency UI and ORE trade re-import surfaced three genuine, pre-existing bugs unrelated to the migration design itself, exposed by finally adding real FK validation where none existed before:

  • History provider not registered: ores.refdata/core/src/messaging/registrar.cpp never wired a history_registry().register_history_provider(...) entry for ores.refdata.payment_frequency (only currency and country had one) — added it, mirroring the existing pattern.
  • ORE importer stored raw tenor strings, not frequency names: every mapper that populates a payment-frequency FK column (swap_instrument_mapper.cpp, credit_instrument_mapper.cpp, equity_instrument_mapper.cpp) copied the ORE schedule rule's Tenor string (e.g. "3M") straight into the FK column instead of converting it to the canonical name (Quarterly) the new FK validates against. Added a shared, bidirectional tenor_to_payment_frequency()=/=payment_frequency_to_tenor() conversion in a new ores.ore.core/domain/payment_frequency_conversion.hpp, wired into both forward (import) and reverse (export) directions.
  • Missing trade_type_code on 8 swap-family instrument types: swap_instrument_mapper.cpp's forward_swap=/=forward_inflation_swap=/ =forward_fra=/=forward_capfloor=/=forward_swaption=/=forward_callable_swap=/ =forward_flexi_swap=/=forward_balance_guaranteed_swap never set identity.trade_type_code, so every one of these instruments failed its NOT-NULL check on import. Set it explicitly per function.
  • stamp() silently no-op'd on nested audit fields: the documented ores::service::messaging::stamp() helper (which is supposed to overwrite modified_by=/=performed_by from the request context) was never called by any of the 8 trading instrument-save handlers (bond=/=commodity=/=composite=/=credit=/=rates[9 variants]/=scripted=/ typed_equity=/=typed_fx), so every ORE-imported instrument kept the mapper's hardcoded, invalid "ores" literal for modified_by. Fixed by calling stamp(req->data.audit, ctx) in each handler — the fields live under a nested audit sub-struct (ores::dq::domain::audit_record), not directly on the instrument, which is why stamp(req->data, ctx)'s if constexpr (requires {...}) check was silently matching nothing.

Confirmed via ores.trading.service logs before/after: "Invalid modified_by: ores", "Invalid payment_frequency: 3M…", and "Invalid trade_type: value cannot be null or empty" are all gone after the fix and rebuild. Left out of scope (unrelated to payment_frequency, called out explicitly rather than silently ignored): check-constraint violations on composite=/=scripted=/=commodity=/=swaption=/=credit instruments (trade_type_code, quantity, unit, average_type, script_name, long_short, tenor fields with other unmapped/invalid values) and invalid input syntax for type date: "" on bond=/=equity_swap=/=vanilla_swap=/=cap_floor imports — pre-existing ORE-importer gaps for instrument types the payment_frequency migration doesn't touch.

Analysis: two-phase design (family feed vs. disaggregation) and market-data grouping (2026-07-16)

Phase split: family feed vs. per-point disaggregation

The producer side splits into two independent concerns, not one task:

  • Phase 1 (this task's actual scope): the family feed. One ir_curve_feed, mirroring fx_spot_feed, where one short-rate process step fans out to N curve_instrument_pricer calls (one per ir_curve_template_entry) and publishes N ticks synchronously, sharing one observation_datetime – exactly this task's original Acceptance.
  • Phase 2 (not this task): per-point disaggregation/staggered timing. Making each tenor in a family tick independently, at its own arrival time, rather than all N points landing together. This is not IR-specific: it is the same concern as the existing BACKLOG story Stochastic tick arrival times for synthetic feeds, whose arrival_profile_config abstraction is designed to wrap any feed. Applying it per point_id instead of per source for curve feeds is that story's future work, not a bespoke IR disaggregator – so Phase 1 does not need to anticipate any special interface for it. No task filed here for Phase 2; tracked via the link to that story instead.

Two subjects, not two message shapes

The family feed publishes on its own namespace, synthetic.v1.curve_family.<source>, distinct from the plain-tick namespace synthetic.v1.tick.<source> scalar feeds already use – still N individual NATS messages (not one aggregate payload), so ingest stays a dumb per-message consumer. When Phase 2 eventually lands, the disaggregation adapter would subscribe to curve_family.* and republish onto tick.*, so from ingest's point of view a disaggregated IR point looks identical to any other tick (already carries point_id) – no ingest-side special-casing needed either now or later. A consumer wanting the raw family (e.g. a future bootstrap-testing consumer) subscribes to curve_family.*; a consumer simulating live market consumption subscribes to tick.*. This is a producer/subject-routing decision, not a per-config "family vs individual" toggle.

What the wire format is not: one aggregate JSON per curve

Considered and rejected: publishing one large JSON object containing the whole curve per generation step, so a UI could "open it up and plot the curves" directly. Rejected because market_observation is already row-per-point (series_id, observation_datetime, point_id) – the same shape non-scalar series (vol surfaces) already use – and an aggregate wire object would either force ingest to explode it back into rows anyway, or require a new non-relational storage shape outside this story's scope. It would also break Phase 2 cleanly, which needs to peel off individual points to re-time them independently – an aggregate object is strictly worse for that than row-per-tick. "Open it up and plot it" is a read/query-side concern instead – see the new curve-snapshot-builder-viewer task.

Analysis: does market data need a batch/generation identifier?

Raised: since a tick batch's points may one day be published at staggered times (Phase 2), how does a consumer know which points belong to the same coherent draw, if not by matching observation_datetime? Considered adding a generation_id=/ =batch_id column to market_observation, stamped once per process draw and carried by every point derived from it, so grouping survives staggered publish times.

Rejected after checking how this actually works in the real world (no vendor feed exists in this codebase to check against – ores.bloomberg is an unstarted BACKLOG story – so checked the market data protocols instead):

  • FIX MarketDataSnapshotFullRefresh (W): always one instrument per message, even when a single MDReqID groups a request for many instruments – responses are still separate messages per instrument. No message-level construct ties multiple instruments' data together as one coherent unit.
  • Refinitiv/LSEG RIC records: each instrument (e.g. USSW10Y= for a 10Y swap rate) is its own independent real-time record, ticking on its own schedule. A "curve" is not a wire-level object at all – it is many independently-updating RICs a downstream consumer groups by naming convention.

Conclusion: no batch/generation identifier exists in real market data feeds, and none is added here either. Vendor feeds never assert "these N points are mutually consistent, published together" – a curve is reconstructed downstream, by taking the latest tick <= an as-of time independently per point. Adding a synthetic-only batch_id would have been exactly the kind of source-specific extension the architecture (see Market Data Architecture) is meant to avoid. The correct pattern is a read-side as-of querySELECT DISTINCT ON (point_id) ... WHERE series_id = ? AND observation_datetime < ? ORDER BY point_id, observation_datetime DESC= – which degenerates correctly to "all points share one timestamp" in this task's Phase-1 synchronous-publish case, and keeps working unmodified once Phase 2 staggers publish times. No schema change. Tracked as its own task: curve-snapshot-builder-viewer.

Analysis: Market Simulator IR support – plan (2026-07-18)

Live UI testing found the first Market Simulator integration attempt built a parallel, IR-specific structure instead of reusing the existing FX machinery generically, per explicit correction ("we do not want a separate control for IR … use existing logic in simulator"). Three concrete defects identified by reading the code (not guessed):

  • Parallel folder tree: ir_curve_generation_config has no folder_id column, unlike fx_spot_generation_config. Meanwhile synthetic_publish_from_dq_create.sql already resolves/creates the real folder chain (Root -> Collection -> "Rates" -> "IR Curves") for IR curves and then has nowhere to store it. buildTree() worked around the missing column by inventing a synthetic flat "IR Curves" group directly under root – a second tree bypassing the real folders already in the DB.
  • Two flags for one currency: buildIrCurveFeedItem() called the FX pair-flag overload currency_flag_icon(imageCache, ccy, ccy) (base+quote composite) with the same code twice, instead of the existing single-flag overload currency_flag_icon(imageCache, isoCode) already used elsewhere (ClientBookModel, ClientCrmTopologyConfigModel) for single-currency entities.
  • No summary/chart for IR leaves: showSummaryForCurrent()'s NodeType::Feed case only ever looks up fxPairs_ and calls showFxPairSummary(); it never checks irCurves_, so selecting an IR curve leaf shows nothing – no hero, no tick chart, no start/stop detail panel.

Plan

  1. Add folder_id to ir_curve_generation_config (org spec + regenerate: SQL table, C++ domain struct, repository, JSON I/O), mirroring fx_spot_generation_config.
  2. Update synthetic_publish_from_dq_create.sql's IR insert to store the v_folder_id it already computes but currently discards.
  3. Rewrite buildTree() to fold IR curves into the same generic folder walk as FX (now that folder_id exists) instead of the separate flat-group special case – one tree-building path for both asset classes, extensible to future asset classes without a new parallel branch each time.
  4. Fix the flag icon in buildIrCurveFeedItem() to the single-flag overload.
  5. Add IR curve summary/hero support: extend showSummaryForCurrent()'s Feed case to check irCurves_ too, add showIrCurveSummary() (single-flag hero, reusing the existing tick chart subscription machinery keyed by irCurveSourceName()).
  6. Build, restart services/client with --open-scenario, re-verify live in Market Simulator.

Steps 1-6: done

Confirmed live: folder_id regenerated across all codegen layers (SQL/domain/repository/JSON I/O), synthetic_publish_from_dq_create.sql now stores the v_folder_id it resolves, and after a DB recreate + Barclays re-provision the IR curve configs' folder_id correctly points at the real Root -> Realistic -> Rates -> "IR Curves" folder (ba1e1e67-...) – queried directly, not guessed. buildTree() now folds IR curves into the same folder walk as FX (irCurvesByFolder=/=pairsByFolder are structurally identical). Flag icon fixed to the single-currency overload. Naming uses idiomatic rates convention (space-separated, e.g. "USD SOFR"), not FX's slash-separated pair notation.

Analysis: complete rates process editor – plan (2026-07-18, continued)

Testing steps 1-6 surfaced a further gap: Market Simulator's Edit action for an IR curve leaf does nothing (editEntity()'s Feed case only ever looks up fxPairs_, silently no-ops for IR curves) – there is currently no way to see or change a curve's process parameters (kappa/theta/sigma/etc.) or its Curve Template (tenor grid) from the Simulator at all. Read FxSpotRateEditor (2200+ lines) to understand the FX shape before designing the rates equivalent, rather than guessing an interface.

Mapping FX concepts to rates

FX (FxSpotRateEditor) Rates equivalent Note
gmm_component mixture (N components) no equivalent – one process (Vasicek/CIR/Hull-White) Simple/Advanced mixture-table split doesn't apply; one param set only.
price-path chart (closed-form GBM/OU, client-side) short-rate sample-path chart (closed-form Euler-Maruyama, client-side) Vasicek is an OU process – FX's existing "ou" slider/half-life machinery is directly reusable, not reinvented.
(nothing) curve-shape preview chart (rate vs. tenor) Needs IYieldCurveProcess::discount_factor() + curve_instrument_pricer, which live server-side only (ores.analytics.quant=/=ores.synthetic.service) – a new non-persisting preview NATS request, not a client-side reimplementation.
gmm_component table (Advanced tab) ir_curve_template_entry table (tenor grid) Same one-config-many-children shape; entity already has full CRUD codegen (repository/protocol/mapper) – this is new editor UI wiring an existing entity, not new backend modeling.
Provenance tab Provenance tab Shared DetailDialogBase widget, no change.

Confirmed reusable without duplication (checked, not assumed):

  • ores.synthetic.api/domain/curve_template_validation.hpp/.cpp (validate_curve_template) is a pure function in the api layer (linked into Qt already via domain structs) – the tenor-overlap check can run client-side in the Curve Template tab exactly as written, no server round-trip needed for that part.
  • yield_curve_process_type, ores.refdata.instrument_code, ores.refdata.payment_frequency, ores.refdata.tenor all have full protocol/repository codegen already – every combo box in the wireframe below is backed by an existing request, none new.
  • curve_instrument_pricer (ores.analytics.quant) and IYieldCurveProcess::discount_factor() are service-layer only – confirmed Qt has no dependency on ores.analytics.quant or ores.synthetic.service, so the curve-shape chart genuinely needs a new preview request, not a missed existing one.

Wireframe

┌─ IR Curve: USD SOFR ──────────────────────────────────────── [Save] [Cancel] ┐
│ [Instrument] [Process] [Curve Template] [Provenance]                         │
├────────────────────────────────────────────────────────────────────────────┤
│ INSTRUMENT TAB                                                               │
│  Currency:        [USD        ▼]      Index Name: [SOFR________]            │
│  Fixed leg freq:   [Annual     ▼]      Enabled:    [x]                      │
│  New tick every:  [1____] sec   (from ticks_per_hour)                       │
│  Source:  ir_curve.usd.sofr   (read-only, derived)                          │
└────────────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────────────┤
│ PROCESS TAB                                                                  │
│  Engine: (•) Vasicek  ( ) CIR  ( ) Hull-White                               │
│                                                                                │
│  Initial rate r0  [====|=========] 3.25 %                                   │
│  Mean level  θ    [======|=======] 3.50 %                                   │
│  Reversion   κ    [===|==========] half-life ≈ 45 days   (log-scale slider) │
│  Volatility  σ    [==|===========] 0.80 %                                   │
│                                                                                │
│  ┌─ Sample short-rate paths (5 draws) ──────┐ ┌─ Curve shape preview ──────┐ │
│  │      ╱‾╲___                              │ │  rate                      │ │
│  │  ___╱    ╲___╱‾╲___                      │ │   │      ╱‾‾‾‾             │ │
│  │ ╱                  ╲___                  │ │   │   ╱‾╱                  │ │
│  │______________________________  ticks     │ │   │__╱____________ tenor   │ │
│  └───────────────────────────────────────────┘ └─────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────────────┤
│ CURVE TEMPLATE TAB                                                           │
│  # │ Start Tenor │ End Tenor │ Instrument         │                        │
│  0 │ SPOT        │ 1M        │ Deposit         ▼  │  [↑][↓][Remove]        │
│  1 │ SPOT        │ 3M        │ Deposit         ▼  │  [↑][↓][Remove]        │
│  2 │ 3M          │ 6M        │ ForwardRateAgree ▼ │  [↑][↓][Remove]        │
│  3 │ SPOT        │ 2Y        │ Swap            ▼  │  [↑][↓][Remove]        │
│                                                     [+ Add tenor entry]      │
│  ⚠ warning banner here if tenor windows overlap (validate_curve_template)   │
└────────────────────────────────────────────────────────────────────────────┘

┌────────────────────────────────────────────────────────────────────────────┤
│ PROVENANCE TAB — shared DetailDialogBase widget, same as every other entity │
└────────────────────────────────────────────────────────────────────────────┘

Plan

Backend: non-persisting curve preview request

  1. Design preview_ir_curve_request=/=response protocol in ores.synthetic.api/messaging/ir_curve_preview_protocol.hpp: request carries process_type/kappa/theta/sigma/initial_rate plus the current (possibly unsaved) Curve Template entry list; response carries, per entry, {sequence_index, start_tenor_code, end_tenor_code, tenor label, rate}.
  2. Implement the handler in ores.synthetic.service reusing build_ir_curve_refdata_context() + ir_curve_template_resolver::resolve()

    • curve_instrument_pricer exactly as ir_curve_feed does today –

    compute only, no DB writes.

  3. Wire the NATS subject + registrar entry (mirrors every other synthetic handler's request-scoped make_request_context() pattern).
  4. No SQL/schema change – pure compute endpoint.

Qt: charts

  1. Short-rate sample-path chart: new lightweight chart widget doing closed-form Euler-Maruyama simulation (dr = κ(θ−r)dt + σ dW, CIR adds √r) client-side – same category of local math SamplePricePathsChart already does for FX, reusing that component's shape if generic enough or a sibling class if not.
  2. Curve-shape preview chart: new chart widget, populated by a debounced call to the new preview request on every slider/table change.

Qt: IrCurveEditor widget

  1. New IrCurveEditor.hpp/.cpp extending DetailDialogBase, two constructors (new/edit) mirroring FxSpotRateEditor's shape.
  2. Instrument tab: currency combo (reuse populateCurrencyCombo-style pattern), index name line edit, fixed leg payment frequency combo (ores.refdata.payment_frequency), enabled checkbox, ticks-per-hour spin + human-readable echo, read-only derived source-name label.
  3. Process tab: engine combo sourced from yield_curve_process_type; κ/θ/σ/r0 sliders + spinboxes reusing FX's "ou" log-scale-κ/half-life pattern, per-engine label/tooltip variants (Vasicek/CIR/Hull-White); wires both new charts.
  4. Curve Template tab: QTableWidget of ir_curve_template_entry rows – tenor combos sourced from ores.refdata.tenor, instrument combo sourced from ores.refdata.instrument_code (filtered by curve role), add/remove/reorder buttons, client-side validate_curve_template overlap check with a warning banner.
  5. Provenance tab: reuse the shared widget, no change.
  6. Save: persist config fields via the existing ir_curve_generation_config_protocol, diff-sync template entries (insert/update/delete against originalEntryIds_) via the existing ir_curve_template_entry_protocol – same pattern FxSpotRateEditor uses for gmm_component rows.

MarketSimulatorWindow wiring

  1. Replace the no-op IR branch in editEntity() with openIrCurveEditorForEdit(), opening the new IrCurveEditor in a DetachableMdiSubWindow, mirroring openFxEditorForEdit().
  2. Add the equivalent "New IR Curve" creation path, mirroring onNewFxRateClicked()=/=openFxEditorForNew().

Verification

  1. Build (ores.qt.synthetic.lib first, then full build).
  2. No DB recreate needed (no schema change in this phase).
  3. Restart services/client with --open-scenario, live-verify: create a new IR curve process end-to-end (Instrument -> Process with live charts -> Curve Template -> Save), edit an existing one (DQ-published Barclays USD SOFR/EUR ESTR/GBP SONIA), confirm ticks reflect edited parameters.

Analysis: aligning IrCurveEditor with FxSpotRateEditor (2026-07-19)

Live review of the shipped editor found real, verifiable structural gaps against FxSpotRateEditor – not vague "doesn't look right" feedback, but confirmed by reading both dialogs' source side by side. Per-item decision below: copy FX where there is no IR-specific reason to differ (a spurious difference), diverge only where rates genuinely warrant it (stated explicitly), never a blind copy either way.

Confirmed differences and decisions

  1. Simple/Advanced toggle: FX uses two checkable/auto-exclusive QPushButton's, explicitly QSS-styled into a joined accent-colour pill (min-height: 30px, bold, filled when checked), placed at the right end of the header row. The shipped editor used two plain QRadioButton's with no styling, which is why it read as "no toggle button at all". Decision: copy FX verbatim – no IR reason for this affordance to look different.
  2. Simple-mode sliders: FX has no spinbox beside the slider – a title + gray value-echo label sits above a full-width slider; precise entry is Advanced-only. The shipped editor paired every slider with an adjacent spinbox in Simple mode. Decision: copy FX – drop the paired spinbox, echo the value in a label instead; Advanced's table remains the only precise-entry surface.
  3. Three-zone tab layout: FX is header row (engine + frequency + toggle) / middle row (controls left + compact chart right, capped ~380px) / bottom row (prominent full-width chart in its own titled QGroupBox). The shipped editor stacked both charts full-width below the controls – no side-by-side zone at all. Decision: copy FX's structure, but invert which chart is compact vs prominent – FX's compact side chart is the supporting stat (return distribution), prominent bottom chart is the hero view (sample paths). For IR the priority is reversed: curve shape is the "what will I actually publish" hero view (curve shape is a genuine IR-only chart FX has no equivalent of), sample paths is the supporting/exploratory view. So: middle row = controls + compact sample-paths; bottom row = prominent full-width curve-shape. Same structural pattern, content assignment flipped for a stated reason.
  4. Currency combo: FX's baseCombo_=/=quoteCombo_ are editable with a QCompleter (PopupCompletion, MatchContains, case-insensitive) over the typed OreCurrencyComboBox class. The shipped editor used a plain non-editable QComboBox. Decision: copy FX – same ~180-row ISO currency list, same "pick 1 of many quickly" use case, no IR reason to make this worse.
  5. Index name combo: Decision: deliberately not like FX's currency combos – post-filter (currency-scoped, two-segment codes only) this list is typically 1-3 items, not ~180; a completer would solve a problem that doesn't exist here. Stays a plain non-editable combo.
  6. Fixed-leg-frequency / engine combos: short fixed enumerations (9 and 3 rows) – matches the codegen "standard" (Portfolio's purposeTypeCombo=/=statusCombo, also plain non-editable, no completer anywhere in the codebase for combos this short). No change – already correct.
  7. Instrument tab framing: every codegen .ui dialog (Portfolio, Book) wraps its QFormLayout fields in a titled QGroupBox (e.g. "Basic Information"); the shipped editor put the form directly on the tab with no framing. Decision: add one ("Curve Identity").
  8. insertPolicy: every codegen .ui combo explicitly declares QComboBox::NoInsert; the shipped editor never sets it (functionally inert since none are editable except the currency combo post-item-4, but cheap to match literally). Decision: set it explicitly.
  9. Curve Template table combos: already the only precedent in the codebase for inline table-cell combos (FX's component table); already matches it. No change.

Notes

Test Scenarios

Scenario State Notes
Verify ir_curve_feed tick-batch publishing PENDING Manual verification via Qt client.

PRs

PR Title
#1624 [synthetic,marketdata] Implement ir_curve_feed producer and curve family ingest
#1610 [refdata,synthetic,qt] Add curve_role and yield_curve_process_type entities
#1597 [refdata,trading,ore] Consolidate payment frequency into ores.refdata

Review

Comment summary File Decision Notes
       

Result

Shipped well beyond the original Phase-1 Acceptance (ir_curve_feed tick-batch publishing), which itself was completed and independently verified via direct SQL before this session's work began. This session took the feature from "backend mechanism works" to "usable end-to-end through the real UI":

  • ir_curve_generation_config gained folder_id (real folder-tree placement, matching fx_spot_generation_config) and a persisted, editable source_name column following FX's exact synthetic.<collection>.<pair> convention (replacing a computed ir_curve.<ccy>.<idx> string every consumer had to re-derive).
  • index_name is now FK-validated against ores.refdata.floating_index_type (stored as the full ISDA code to reuse that entity's existing validator) instead of free text.
  • MarketSimulatorWindow ("Market Simulator") now shows IR curves in the same real folder tree FX pairs use (no more parallel synthetic group), with correct single-flag icons and idiomatic rates naming ("USD SOFR").
  • A complete IrCurveEditor (Instrument / Process / Curve Template / Provenance tabs) was built, aligned item-by-item against FxSpotRateEditor (segmented Simple/Advanced toggle, slider shape, currency combo, chart zone layout) – copying FX where there was no IR-specific reason to differ, diverging deliberately (curve-shape chart as the hero view, not sample-paths) where rates genuinely warranted it. Two new stateless preview NATS endpoints (synthetic.v1.ir_curve.simulate_paths, synthetic.v1.ir_curve.preview_shape) back its live charts.
  • Market Simulator's own overview tick chart was fixed to route to the correct NATS subject/payload shape for IR curves (it silently never ticked before) and redesigned from an undifferentiated single line into a proper curve-shape overlay (last N batches, oldest faded, configurable via a spin box).
  • The synthetic.ir_curve_configs.realistic DQ dataset now seeds one overnight-RFR Vasicek curve per each of the top 20 currencies by FX turnover (was 3), each with its own realistic per-market calibration, backed by 15 new floating_index_type reference rows.
  • Fixed several genuine, pre-existing bugs surfaced by being the first real exerciser of these code paths: ccache worktree corruption, JetStream stream-creation-vs-update semantics, a NATS subject wildcard token-count mismatch, and (this session) Barclays never having its own floating_index_type rows (missing from the tenant provisioner's copy-from-system-tenant step) and an editable QComboBox's flag icon not following programmatic text changes.

Verified via the verify-ir-curve-feed test scenario: all 5 steps now PASS through the real UI (previously 2 of 5 failed only because Market Simulator had no IR view at all).

Deliberately deferred, each with its own filed follow-on task: seed-ir-curve-sample-data (vintage sourcing, numerical-stability tuning), normalize-floating-index-refdata (the floating_index_type=/=overnight_index_convention duplication), and dual-curve-discounting-projection-model (IBOR-style term indices need a genuine discount/projection split; the currently-seeded overnight RFRs are correctly single-curve as-is).

Emacs 29.3 (Org mode 9.6.15)