Task: Tenor-collision/gap validation on the Curve Template
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
Prevent Curve Templates that would break a future bootstrapper:
per Ballabio's production-bootstrapping principles, instrument tenors
that cross over or sit too close together cause the multi-dimensional
iterative solver to fail to converge or produce erratic forward
curves. Add a pure validator (same shape as
ores.synthetic.api::domain::validate_process_parameters, added in
GMM improvements: tidy up synthetic data generation loose ends)
that rejects a Curve Template if any two instrument tenors
overlap/collide (e.g. a swap tenor landing inside a futures
instrument's active delivery window), using the new tenor type.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | IR Rates synthetic data generation |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-15 |
Acceptance
- Pure function in
ores.synthetic.api, taking a Curve Template (tenor/role list) and returning ok/error-message — no UI or service dependency, callable from both the Qt client and server-side save handlers. - Rejects at minimum: two instruments whose date windows overlap; a swap tenor whose start falls inside a futures instrument's active delivery window.
- Not wired into UI-hardcoded logic (matches the "engine validates, UI just calls it" pattern established in the GMM improvements story's PR #1409 review).
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 up front, before touching schema or code (see the
parent story's * Decisions for the full reasoning): the original
acceptance criteria describe checking "a swap tenor whose start falls
inside a futures instrument's active delivery window," but
ir_curve_template_entry only stored a single tenor_code per row —
no data to express a period's start distinct from "now." Rejected two
alternatives (a bolt-on nullable period_start_tenor_code; deriving
periods implicitly from sequence_index adjacency) in favour of
making every entry a genuine [start, end) period:
tenor_code renamed to end_tenor_code, plus a new required
start_tenor_code. Point instruments (deposits, swaps) set
start_tenor_code to 'SPOT' — a real, already-catalogued
zero-duration PERIOD/DAY tenor (multiplier 0), already a member of
RATES_SPOT_FORWARD's tenor set — rather than inventing a
null-means-horizon sentinel. Interval instruments (FRAs) set
start_tenor_code to the period's own front tenor. Both fields
resolve through the exact same
ores::refdata::domain::resolve_end_date() machinery the "Lightweight
QuantLib-free tenor type" task already built — no new anchor/sum-type
concept needed at the entry level.
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 — a pre-existing
gap across every entity in the component, not something introduced by
prior work. Wired it following the exact pattern already established
in ores.refdata.service (hand-maintained event_registrar
aggregator + per-entity generated register_<entity>_event_mapping()
functions) before continuing, since "most services need eventing" and
leaving synthetic entities without live change events indefinitely
wasn't a good default to leave in place. Fixed for all five
ores.synthetic entities in one pass: market_data_generation_config,
fx_spot_generation_config, gmm_component,
ir_curve_generation_config, ir_curve_template_entry.
The validator itself (ores::synthetic::domain::validate_curve_template
in ores.synthetic.api) resolves every entry's
start_tenor_code=/=end_tenor_code to concrete dates, represents point
instruments (start_tenor_code = "SPOT") as a degenerate
=[maturity, maturity) window (so multiple deposits/swaps at different
maturities never trivially "collide" merely by sharing SPOT as their
start — placing several maturity nodes from now is exactly how a curve
is built, not a collision), represents interval instruments as their
genuine [start, end) period, and checks every pair via the existing
windows_overlap(). Touching endpoints are not a collision
(back-to-back FRA periods are exactly how a bootstrappable strip is
built). Never throws — exceptions from the underlying tenor resolver
are caught and turned into the ok/error-message result pair the
acceptance criteria call for.
Live wiring into an actual save handler (UI or server-side) was
considered and deliberately deferred: doing so would require resolving
a currency's spot-day convention into a concrete horizon/spot date pair
and fetching sibling entries for the same ir_curve_config_id — real
design work with no natural call site yet, since there's no combined
Curve Template editor UI (deferred out of scope by the parent Curve
Template task itself, same reasoning as FxSpotRateEditor's combined
editor for FX). The pure, tested, exported function satisfies this
task's acceptance criteria as written; wiring it into a save path is a
natural follow-up once that editor exists.
Notes
Before starting this task's own implementation, this branch first
carried incidental follow-up work from the just-merged Curve Template
task: an asset_class badge + full descriptions for the product
catalogue, redesigning instrument_code.code as a short FIX-inspired
mnemonic with a new ore_trade_type column preserving ORE's literal
string, wiring the previously-unreachable
ir_curve_generation_config=/=ir_curve_template_entry Qt CRUD into a
menu, and an unrelated fix for a crm_topology_bundles DQ artefact
seed pointing at the wrong (marketdata) NATS subject. That work is
raised as its own PR, separate from this task's actual tenor-collision
validator, which has not yet been started.
PRs
Review
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | opt_str<T>'s bool branch is dead code (only ever instantiated with T=std::string) | instrument_code_table.cpp | Declined | Shared codegen table-printer template, identical across every entity with an optional field (currency_table.cpp, deposit_convention_table.cpp…); not something to special-case for one entity. |
| 2 | instrument_code_history_field_mapper isn't wired into registrar.cpp or any Qt dialog yet | instrument_code_history_field_mapper.cpp | Declined | Matches the existing not-yet-wired state of most other *_history_field_mapper files (tenor, calendar, business_centre…); only currency/country are wired today. Pre-existing rollout gap, not introduced by this PR. |
| 3 | 13 templated instrument descriptions carry grammar bugs (stray "the", wrong plurals) | refdata_instrument_codes_populate.sql | Accepted | Fixed in 31fe486be. |
| 4 | crm_topology_bundles subject fix is a no-op against already-provisioned databases (ON CONFLICT DO NOTHING) | dq_artefact_types_populate.sql | Accepted | Fixed in 31fe486be — added an idempotent backfill INSERT that corrects any existing row still carrying the old marketdata_* values; verified against a simulated pre-fix row. |
| 5 | Two remaining "basket of the commodity prices" rows inconsistent with sibling FX/Equity fixes (non-blocking) | refdata_instrument_codes_populate.sql | Accepted | Fixed in c91d981ae. |
| 6 | Dead IrCurveTemplateEntryHistoryDialog left in place instead of deleted (controller already uses generic HistoryDialog, matching PR #1571's consolidation) | IrCurveTemplateEntryHistoryDialog.{hpp,cpp,ui} | Accepted | Fixed in 3ca572a7a — deleted, following the same pattern PR #1571 applied to sibling entities. |
| 7 | Checks table separator row a couple characters wider than the header (cosmetic) | ores.synthetic.ir_curve_template_entry.org | Accepted | Fixed in 3ca572a7a. |
Result
Delivered ores::synthetic::domain::validate_curve_template in
ores.synthetic.api (curve_template_validation.hpp=/.cpp=): a pure,
never-throwing function taking a Curve Template's entries plus the
tenor catalog/convention/resolution rows and a horizon/spot date pair,
returning an ok/error-message result. Covered by 9 test cases (13
assertions) including the two acceptance-criteria scenarios (two
overlapping periods; a point instrument's maturity falling strictly
inside an interval instrument's active period) plus the two properties
that make the model correct (point instruments never collide with
each other; touching endpoints are not a collision).
Required two prerequisite changes, both completed in this task:
ir_curve_template_entryremodelled withstart_tenor_code=/ =end_tenor_code(was a singletenor_code), so a genuine[start, end)period is representable at all — see* Planfor the design discussion and rejected alternatives.ores.synthetic.servicewired with thepostgres_event_source=/ =event_buslive-eventing pipeline it was entirely missing, across all five entities in the component.
Local build (linux-clang-debug-make) clean; full ctest suite
73/73 passed after a db recreate (schema-only change, no
provisioning re-run needed for this PR). Live wiring into a save
handler is deferred (see * Plan) as a follow-up once a combined
Curve Template editor UI exists to call it from.