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_idat 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_idfor a givenseries_idand as-of timestamp, taking the latestobservation_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_observationrows, 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 IrCurveGenerationConfigMdiWindow – ores.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_datetimelags 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
CurveShapePreviewChartof 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.
- Header row: currency flag + code + full index code (reusing
- 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
CurveShapePreviewChartseries 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_idfor aseries_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_datetimeto 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-patternscapture) 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 officialmarket_seriescatalog filtered toasset_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 – seeir_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 matchingFxSpotGridWindow'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.testssuite. - Full build (
ores.qt.exeand 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.testscrashed mid-run onparty_rls_isolation_tests.cpp'sparty_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-patternscapture – generalising the as-of/as-of-bucket pattern beyondmarket_observations_repository.- The
ir_curve_feed=/=cir_processday-scaled-kappa-vs-actual-tick- interval mismatch surfaced while reviewing History tab deltas (large bp swings) – confirmed as the same numerical-stability problemseed-ir-curve-sample-dataalready tracks, not new; deliberately left unfixed per explicit instruction.