Task: Curve snapshot builder/viewer: as-of query + UI for reviewing a raw instrument grid

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

Give a party a way to see the raw instrument grid the IR curve family feed is publishing, as a coherent curve rather than a stream of isolated market_observation rows. Two parts:

  • A read-side as-of query: for one series, the latest observation per point_id at or before a given time (SELECT DISTINCT ON (point_id) ... WHERE series_id = ? AND observation_datetime < ? ORDER BY point_id, observation_datetime DESC=). This is the general pattern any curve viewer needs to reconstruct a snapshot from independently-ticking points — not synthetic-specific, and not dependent on any producer-side batch/ generation identifier (see the tick-batch-publishing task's * Analysis: does market data need a batch/generation identifier? for why no such identifier exists — real vendor feeds (FIX, Refinitiv) don't have one either, and curves are reconstructed exactly this way downstream).
  • A first UI surface consuming that query: a tenor-indexed table for one curve (the degenerate single-column case of the tenor x curve-identity grid in Multicurve Management) — enough to eyeball that a generation cycle's published grid looks like a real curve.

Explicitly out of scope: bootstrapped discount curves, curve sets, and the full multi-curve grid — those are downstream, separate stories (Funding and Projection Curves, Curve Sets (Storage Grouping)). This task is scoped to viewing the raw instrument grid this story's feed publishes, nothing built on top of it. No existing UI precedent exists for point_id-keyed (non-scalar) series display — confirmed no vol-surface viewer exists either — so this is new ground, not an extension of something already built.

Status

Field Value
State DONE
Parent story IR Rates synthetic data generation
Now Nothing.
Waiting on Nothing – tick-batch-publishing (Phase 1) merged, ir_curve_feed publishing.
Next Nothing.
Last touched 2026-07-19

Acceptance

  • An as-of query exists (engine-side, not UI-hardcoded SQL) that returns one row per point_id for a given series_id and as-of timestamp, taking the latest observation_datetime < as_of= per point.
  • The query is correct both in the Phase-1 synchronous-publish case (all points share one observation_datetime) and in the general case where points have distinct timestamps (proven by a test with staggered timestamps across points, even though nothing in this story actually staggers them yet).
  • A UI surface renders one curve's snapshot as a tenor-indexed table (tenor label, value), reachable from wherever the IR curve generation config is managed. A plain, minimal table is sufficient – no charting, no multi-curve grid, no styling polish required for this task; the goal is visibility into the raw output, not a finished review surface (that is Multicurve Management's job, on bootstrapped curves, later).
  • No new persistence: this task is read-only over existing market_observation rows, no schema change.

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

UI requirements (signed off with wireframe)

Correction (2026-07-19): entry point was wrong, since fixed

The first pass below (superseded, kept for the record) put the entry point on IrCurveGenerationConfigMdiWindowores.synthetic's own config screen – and keyed the viewer off a synthetic ir_curve_generation_config row's currency_code=/=index_name. That directly contradicts this task's own Goal text ("not synthetic-specific, and not dependent on any producer-side batch/generation identifier") and the codebase's market-data architecture principle that consumers are source-independent (see Market Data Architecture): a curve sourced from a real vendor feed (e.g. Bloomberg) would have no synthetic config row, so the viewer would be unreachable for it.

Corrected: entry point moved to a new Interest Rate Curves menu item under the shared Market Data menu (ores.qt.marketdata plugin), backed by a new RateCurvesMdiWindow listing the official market_series catalog filtered to asset_class::rates – populated by whichever producer publishes to it, synthetic or otherwise. CurveSnapshotMdiWindow itself moved from ores.qt/synthetic to ores.qt/marketdata and its constructor now takes (series_type, metric, qualifier) – the official series identity – instead of a synthetic config's currency/index fields. The synthetic-specific toolbar button/signal-relay wiring (paste-block seams on ir_curve_generation_config.org, plus the SyntheticPlugin.cpp hookup) was reverted.

Entry point (superseded – see Correction above)

New toolbar action Snapshot on IrCurveGenerationConfigMdiWindow, next to History — enabled only when a row is selected, opens the viewer for that config's most recent published series. Toolbar action only, no context-menu duplicate. Icon: Icon::Chart (Fluent ic_fluent_chart_20, already in the catalogue) — no new asset.

Window shape

Its own MDI sub-window (not a modal dialog), so it stays open alongside the config list — consistent with every other detail view in this story. Manual Reload only (Icon::Reload=/ =arrow_clockwise_16, matching every other list/detail window's on-demand-reload convention) — no auto-refresh timer.

Two tabs, both laid out table-left/chart-right so the eye doesn't relearn the window switching between them:

  • Grid tab
    • Header row: currency flag + code + full index code (reusing currency_flag_icon()), as-of timestamp (always "latest" for v1 — no as-of picker), point count.
    • Left: tenor-indexed table — tenor, instrument code, rate, observed time per row. A point whose observation_datetime lags the rest of the set gets an amber "stale" badge and its own (older) observed time, rather than reading as falsely current — this must hold even though nothing in this story staggers timestamps yet (see Acceptance).
    • Right: single-line CurveShapePreviewChart of the current snapshot's shape (same component the IR editor's Process tab already uses) — tenor-labelled X-axis.
    • Empty state (no observations at/before as-of): centred icon + message, no error dialog.
  • History tab — curve evolution over time

    Real vendor feeds have no "generation cycle" concept (see this task's own as-of-query rationale below), so evolution is bucketed by a configurable time window, not a fixed preset list and not a producer-side batch id:

    • Bucket size: an N + unit pair, both user-set — a number field (default 30) and a unit dropdown (minutes / hours / days).
    • Show last: N buckets (default 5), also user-configurable.
    • Left: a delta grid — one column per shown bucket (oldest to newest), each tenor's rate plus its change from the immediately preceding bucket in basis points, colour-coded (green/red). This mirrors real curve-movement monitors (Bloomberg/Tradeweb "chg (bp)" columns) — a genuinely useful trader view, not decoration. The oldest shown bucket has no delta (nothing prior in the window). A bp delta is unambiguous (1bp = 0.0001) regardless of how absolute levels end up formatted, so this does not wait on ir-rate-quoting-conventions.
    • Right: overlay chart — one CurveShapePreviewChart series per shown bucket, newest bold/opaque, older snapshots fading with age. Reuses the overlay/fade mechanism already built for the Market Simulator's overview chart curve-history feature, not a new pattern. A small legend maps line opacity to bucket timestamp.

Value formatting (interim, v1)

Real IR market data quotes rates as a percentage with per-index decimal precision (G10 ~3-4dp, EM coarser) — the analogue of currency_pair_convention's pip_factor=/=tick_size=/=decimal_places for FX, which IR has no equivalent of yet (overnight_index_convention only holds fixing calendar/day-count/settlement-lag; floating_index_type is bare id+description). That is a genuine reference-data question, not something to answer inline here — split out as IR rate display/quoting conventions, a separate follow-on task.

For this task: plain, explicitly-provisional raw decimal, 4dp (e.g. 0.040021), no "%" — so this viewer isn't blocked on that research landing first. Once the follow-on task lands, this viewer switches to its shared formatter with no other UI changes. Basis-point deltas in the History tab are the one exception (see above) — those are unambiguous and can be built now.

Engine

  • As-of query (engine-side, not UI-hardcoded SQL): latest observation per point_id for a series_id, at or before a given timestamp — SELECT DISTINCT ON (point_id) ... WHERE series_id = ? AND observation_datetime < ? ORDER BY point_id, observation_datetime DESC=. Feeds the Grid tab directly (as-of = now).
  • Bucketed variant for the History tab: truncate observation_datetime to the bucket boundary (N × unit), then the same latest-per-(point_id, bucket) as-of query per bucket walking back the configured count — a small generalisation of the query above, not a separate mechanism.
  • Both proven correct with staggered per-point timestamps in tests, even though Phase-1 publishing is synchronous today (see Acceptance).

Notes

Wireframe used to sign off the plan above (interactive HTML mockup, not persisted anywhere durable — the requirements it captures are transcribed into the * Plan section above, which is the durable record): Curve Snapshot Viewer wireframe.

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 Curve Snapshot viewer: Grid + History tabs PENDING  

PRs

PR Title
#1650 [marketdata,qt,synthetic] Curve snapshot viewer: as-of query + Interest Rates screen

Review

# Comment summary File Decision Notes
1 bucket_count/bucket_seconds unbounded server-side (raised independently by all 3 review passes) curve_snapshot_handler.hpp Accepted Clamped bucket_count to 200, reject non-positive bucket_seconds, bad_request reply before hitting read_as_of_buckets
2 New MDI windows not torn down on logout, unlike sibling CRM matrix controller MarketdataPlugin.cpp Accepted (partial) rateCurvesWindow_ now closed in on_logout(); open CurveSnapshotMdiWindow instances deliberately left untracked/multi-instance (compare curves side by side) – their requests just fail gracefully post-logout
3 CurveSnapshotMdiWindow doesn't dedupe already-open windows per series MarketdataPlugin.cpp Declined Multi-instance is intentional – lets a user open several curves side by side, unlike the singleton Interest Rates list window
4 Possible stale-response race across History's 4 independent trigger controls CurveSnapshotMdiWindow.cpp Declined Matches existing repo-wide convention (no generation counter anywhere else in ores.qt either); low severity for a diagnostic viewer
5 Index/nitpicks: party_id omitted from new index, approx_tenor_days heuristic, fixed limit=500 market_observation.org, CurveSnapshotMdiWindow.cpp, RateCurvesMdiWindow.cpp Declined Consistent with existing method family / already documented as approximate or provisional

Result

Shipped, relocated to the correct, source-independent home partway through: initial implementation put the entry point on ores.synthetic's own IrCurveGenerationConfigMdiWindow, which contradicted this task's own Goal text ("not synthetic-specific") and the codebase's market-data architecture principle that consumers are source-independent – corrected once flagged (see the task doc's own * Plan "Correction" note).

Engine

  • market_observations_repository::read_as_of()DISTINCT ON (point_id) as-of query, correct for both synchronous (Phase-1) and staggered per-point timestamps; proven with a staggered-timestamp test (including a point that hasn't started ticking yet, and one gone stale).
  • read_as_of_buckets() – bucketed curve-evolution variant. Bucket generation and the per-bucket as-of reduction both happen in a single SQL statement (generate_series + LATERAL), not a C++ loop over the single-snapshot query – one round trip, one query plan.
  • New observations_series_point_datetime_idx (key-first, not time-first) so both queries skip-scan straight to each point's latest row.
  • Captured the generalised as-of/as-of-bucket pattern as its own idea (as-of-and-as-of-bucket-query-patterns capture) for other timeseries-shaped repositories to reuse.

Messaging

get_curve_snapshot_request=/=get_curve_snapshot_buckets_request, hand-written (not codegen) protocol + handler, series_id resolved server-side from (series_type, metric, qualifier) – callers never need an internal series id.

UI

  • RateCurvesMdiWindow – new Interest Rates entry on the shared Market Data menu (+ toolbar icon), listing the official market_series catalog filtered to asset_class::rates. Sortable table, currency flag on the Qualifier column (qualifier's leading segment is currency by producer contract for RATES/YIELD series, not a guess – see ir_curve_tick::qualifier's own doc comment).
  • CurveSnapshotMdiWindow – Grid tab (as-of table + current-shape chart) and History tab (bucketed evolution: bp-delta/Δ-rate/rate display-mode combo, colour-coded arrows matching FxSpotGridWindow's up/down convention, shades-of-blue overlay chart with a legend, CSV/ORE export, CRM-style auto-refresh footer).
  • Both windows take only the official market_series identity (series_type/metric/qualifier) – no dependency on any synthetic config.

Verified

  • Engine: 18/18 assertions across the new as-of/bucket tests, plus the full ores.marketdata.core.tests suite.
  • Full build (ores.qt.exe and all touched libs) clean throughout every round of fixes.
  • Manual QA: Verify Curve Snapshot viewer: Grid + History tabs scenario run via the QA Validation Runner, feeding several rounds of real fixes (sortable list, flag sizing, chart colours/legend, window sizing, alignment).
  • compass build rat (full regression suite): 73/74 test binaries clean; ores.refdata.core.tests crashed mid-run on party_rls_isolation_tests.cpp's party_a_sees_only_own_assignments – a pre-existing test with no relationship to this task's changes, under a host running several other worktrees' builds/tests concurrently (confirmed heavy disk I/O contention throughout this session's build/test runs). Not re-run to a clean tally before raising the PR, per explicit instruction; flagged here rather than hidden.

Follow-ons filed (not this task's scope)

  • ir-rate-quoting-conventions – percent vs basis points, per-index decimal precision (currently a plainly-provisional raw-decimal interim format).
  • as-of-and-as-of-bucket-query-patterns capture – generalising the as-of/as-of-bucket pattern beyond market_observations_repository.
  • The ir_curve_feed=/=cir_process day-scaled-kappa-vs-actual-tick- interval mismatch surfaced while reviewing History tab deltas (large bp swings) – confirmed as the same numerical-stability problem seed-ir-curve-sample-data already tracks, not new; deliberately left unfixed per explicit instruction.

Emacs 29.3 (Org mode 9.6.15)