Task: Design: the market data data model on oresmd
Table of Contents
- 1. Goal
- 2. Status
- 3. Acceptance
- 4. Plan
- 4.1. What is already settled
- 4.2. The asset class collision, and how it resolves
- 4.3. Identity stays one column
- 4.4. The point is already oresmd notation
- 4.5. The vocabularies become reference tables, with one exception
- 4.6. Surrogates
- 4.7. The curve layer
- 4.8. Values come into scope
- 4.9. Consequences for the existing tasks
- 4.10. Open questions
- 5. Notes
- 6. Test Scenarios
- 7. PRs
- 8. Review
- 9. Result
This page documents a task in the Adopt oresmd as the native market data identity across simulator, UIs, and shell story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
Settle the target data model for market data, so the story's remaining implementation tasks are built against an agreed shape. The model covers the asset class taxonomy, the identity of a series, the point on an observation, the controlled vocabularies, the curve layer, and the key form.
Two documents already settle most of it. This task carries those decisions forward unchanged, and resolves the parts they leave open. Where it departs from a settled position it says so.
Three requirements were added after the first draft and they change the scope of the parent story. They are section 4.8. The model must carry the values, not only the identity.
This task produces the design and its diagram. It changes no schema and no code.
2. Status
| Field | Value |
|---|---|
| State | ABANDONED |
| Parent story | Adopt oresmd as the native market data identity across simulator, UIs, and shell |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-09-23 |
3. Acceptance
- The asset class taxonomy matches Asset Classes: seven
classes, with
ratesrenamedinterest_ratesandcross_assetremoved. - Every decision already taken by the analysis task (Analysis: market data identity on oresmd) and the inverse-projection task (Inverse projection) is either carried forward unchanged or changed with the reason stated in writing. A silent reversal is the failure this acceptance exists to catch.
- The three-way vocabulary collision is resolved with one authority named, and the resolution says what each loser becomes.
- The observation point is shown to be the serialised oresmd point, with the arity evidence recorded and the canonical form taken from the implemented parser rather than re-invented.
- The controlled vocabularies are specified as generated reference tables treated as data in code, with the cost of that choice stated and the values that should stay code named.
- The surrogate key form is chosen, with the trade-off stated.
- The curve layer is placed in the model, covering the curves the bootstrap already produces.
- The generated market data is shown to be representable, naming the tables that carry it.
- The ORE sample round trip is specified, with the fidelity limits measured against the real files rather than assumed.
- A PlantUML class diagram carries the model, with every class and its attributes.
- No schema or code change is made.
4. Plan
4.1. What is already settled
Two pieces of prior art settle most of this model. Neither is re-opened here.
4.1.1. The asset class taxonomy
Asset Classes analyses seven published classifications and proposes one taxonomy. Seven classes, all at the same level:
| Code | Name |
|---|---|
interest_rates |
Interest Rate |
inflation |
Inflation |
fx |
Foreign Exchange |
equity |
Equity |
commodity |
Commodity |
credit |
Credit |
bond |
Bond |
Two changes come with it. cross_asset is removed, because a
correlation between two classes is a relation between them rather
than a third kind of thing. rates is renamed interest_rates,
because with inflation beside it the bare word no longer reads as
the interest rate class on its own.
4.1.2. The identity decisions
The analysis task decided the following, and they stand:
| Decision | Detail |
|---|---|
| Identity storage | One oresmd_uri column per identity-bearing entity. No component fields. |
| The hybrid | Rejected everywhere. Two writable copies of one identity drift, and no constraint can detect it. |
| Classification | asset_class, series_subclass and is_scalar are derived, not identity. Generated columns only where an index needs them. |
| Surrogate FKs | Kept. They are relationships, not identity copies. Joins, RLS and the hypertable depend on them. |
| Validation | Moves out of SQL check constraints and into the oresmd layer. |
| Point | point_id keeps its column and carries the serialised oresmd point. Scalar series have a null point, and the "SPOT" sentinel dies. |
| Boundary | ORE text and CSV stay boundary formats. ORE keys enter only at import. Nothing ORE-shaped is stored. |
4.1.3. The canonical form is implemented, not proposed
The inverse-projection task shipped in PR #1963. The canonical form is therefore a fact about the code, not a design question:
oresmd_projections::from_ore_keymaps an ORE key string to amarket_data_identifier, seeded by theseries_key_registry.oresmd_parser::to_uri(identifier, canonical_values)emits the one canonical string per identifier: fixed parameter order per asset class, lower-cased codes, absent fields skipped.- The builder matches tenor and point against a supplied
canonical_valuescontainer and rejects unknown spellings. - Round trips are pinned by tests:
parse(to_uri(id)) =id= andto_uri(parse(uri)) =uri=. - The stored string is the percent-encoded canonical form, and matching uses the same form.
4.2. The asset class collision, and how it resolves
Three vocabularies named the same axis and disagreed.
| Source | Values |
|---|---|
| oresmd URI authorities | fx, ir, equity, credit, commodity, inflation, correlation |
domain::asset_class |
fx, rates, credit, equity, commodity, inflation, bond, cross_asset |
| The taxonomy | interest_rates, inflation, fx, equity, commodity, credit, bond |
The taxonomy is the authority. Three consequences follow.
irbecomesinterest_rates. The C++ enum and therefdata_asset_class_codestable carry the taxonomy's spelling once its rename lands, so the URI authority is the odd one out and moves.correlationstops being an authority. The two surveyed example keys of that shape,CORRELATION/RATE/CCY-EUR-USD, name a pair of currencies rather than a class, which matches the taxonomy's reading. A correlation identifier references two classes.bondis added. The example quote file carriesBOND/YIELD_SPREAD/...rows, andfrom_ore_keyrejects them today because no oresmd authority exists. Under the taxonomybondis a legitimate root node, so the rejection is a coverage gap rather than a correct verdict.
The identifier variant gains the bond struct and keeps
correlation, whose asset_class authority becomes a pair of class
references rather than a single one. The variant is then eight
alternatives where it holds seven today.
Settled. Nesting inflation under rates was the one open item in
this section. The taxonomy holds it at the same level as the other six
classes, so a consumer that reports inflation separately selects the
class and one that does not leaves it out. SIMM's placement of
inflation inside the interest rate curve is recorded as a projection
that scheme applies, not as a parent-child relation.
Asset Classes carries the rule and the correspondence that states
it.
4.3. Identity stays one column
market_series carries one oresmd_uri column. The natural key is
(party_id, oresmd_uri). series_type, metric, qualifier,
asset_class, series_subclass and is_scalar are dropped or
become generated where an index needs them.
This is unchanged from the analysis. It is restated because the
tempting alternative is worth naming: a column per oresmd query key
(ccy, index, tenor, role, quote, metric, model,
point) is an untyped union in SQL, since no single asset class
populates all of them. The parser already enforces the per-class
shape, so the columns would add nothing the validated URI does not
carry.
4.4. The point is already oresmd notation
The question was whether the observation coordinate can be oresmd
notation without the oresmd:// prefix. It can, and that is what the
code already does. The point is a fragment of the same grammar, not a
whole identifier, so it carries no scheme.
from_ore_key builds it as a lower-cased comma-joined composite. For
a swaption it is three parts, as in 10y,2y,atm. A typed
volatility_surface_point struct exists alongside it for the vol
families. The canonical_values container holds the composite as one
literal, so the spelling is validated like any other.
4.4.1. Why three parts, with the measured evidence
A swaption is an option on a swap, so it needs two periods. The
example quote file prices a full grid, every expiry from 1M to 30Y
crossed with every underlying swap tenor from 1Y to 50Y. So
10y,2y,atm is a ten-year option into a two-year swap, at the money,
and a two-part point could not name a cell in that grid. The third
part is not always the literal ATM: the same grid carries
Smile/-0.01 and Smile/0.02.
The point is not always three parts. Counting the shapes actually
present in market_20160205.txt, by segments after the TYPE
segment:
| Key shape | Segments | Example | The point is |
|---|---|---|---|
FX/RATE |
3 | FX/RATE/EUR/USD |
absent, the pair identifies it |
CORRELATION/RATE |
2 | CORRELATION/RATE/CCY-EUR-USD |
absent, the factor pair carries it |
BOND/YIELD_SPREAD |
2 | BOND/YIELD_SPREAD/ISIN:... |
absent, the security is the entity |
CAPFLOOR/SHIFT |
3 | CAPFLOOR/SHIFT/EUR/6M |
absent, a surface-level shift |
MM/RATE |
4 | MM/RATE/CHF/0D/1D |
one tenor |
CDS/CREDIT_SPREAD |
5 | CDS/CREDIT_SPREAD/BANK/SR/USD/1Y |
seniority and tenor |
SEASONALITY/RATE |
4 | SEASONALITY/RATE/MULT/UKRPI/JAN |
a month |
SWAPTION/RATE_LNVOL |
5 | .../EUR/10Y/2Y/ATM |
expiry, swap tenor, strike |
SWAPTION/RATE_LNVOL |
6 | .../EUR/10Y/2Y/Smile/-0.01 |
the same, strike carries a kind |
CAPFLOOR/RATE_LNVOL |
7 | .../CHF/20Y/6M/0/0/0.025 |
expiry, index tenor, three-part strike |
The arity varies by (quote type, metric), from absent to three parts. One text column holding the validated composite is the right shape for that. A column per axis would be a union for the same reason as section 4.3, and would have to be revisited every time ORE adds a quote family.
Unconfirmed. The three-part tail on CAPFLOOR/RATE_LNVOL, as in
.../20Y/6M/0/0/0.025, is 3721 of 3726 capfloor rows in the sample.
Its exact semantics are not documented anywhere in the vendored tree,
which carries examples and schemas but not the pricing sources.
from_ore_key rejects the capfloor and option families today, which
is the safe behaviour until the key is confirmed against ORE's
key-building code. The model does not depend on the answer, but the
coverage gap does, and section 4.8.5 makes closing it a requirement.
4.5. The vocabularies become reference tables, with one exception
oresmd_enums.hpp is generated from a template with its values
hardcoded in the template text. Twelve vocabularies live that way.
Some of them duplicate a SQL CHECK constraint, which means adding a
value needs a template edit and a migration that must agree.
The proposal is a codegen lookup_entity model per vocabulary,
generating the table and the C++ accessor, with the values loaded as
data. The machinery exists and has no authored instances yet.
The gains are real but they are not the ones first assumed. The identity decision in section 4.3 removes the foreign keys: with one URI column and no component columns, no market data table has a column to point at a vocabulary table. The gains that survive are these.
- One source of truth. The 22
index_familyvalues exist today as an enum, and as a CHECK constraint onores_synthetic_ir_curve_generation_configs_tbl. A reference table collapses the two. - No rebuild to add a value. A new benchmark family or quote type becomes a row, not a template edit plus a migration.
- The validator already wants data.
canonical_valuesis supplied to the URI builder at run time. A table is where it comes from.
4.5.1. The exception
Two of the twelve are not business vocabularies. instrument_type
(fixing, curve, quote, vol) and curve_role (discount, projection,
self_discounting) are structural discriminators. The parser and the
projections branch on them by name, as in
id.type ! instrument_type::quote=. Nobody adds a fifth instrument
type without a code change, so making them data buys nothing and
costs compile-time exhaustiveness.
They stay as code constants. The remaining ten become reference tables.
4.5.2. The cost, stated plainly
A vocabulary held as data cannot be switched on exhaustively. The compiler stops proving that every quote type is handled. What replaces it is a run-time check against the loaded table at the validation boundary, which is where the analysis already moved validation. The trade is deliberate: a missing handler becomes a rejected identifier at import rather than a compile error.
4.6. Surrogates
The observation table is the only place where the surrogate key is on
a hot path. It is a hypertable holding tick data, and its foreign key
to market_series is indexed.
| Form | Width | Note |
|---|---|---|
uuid (today) |
16 bytes | Random, so index locality is poor on an append-only table. Globally unique, no coordination. |
uuidv7 |
16 bytes | Time-ordered, so locality is good. Keeps global uniqueness and the existing codegen assumptions. |
bigint identity |
8 bytes | Best locality and half the width. Sequential, so it needs coordination across environments. |
The recommendation is uuidv7 for the series surrogate, or bigint
if a measurement shows the FK index dominating. Two things argue
against changing the type on principle alone.
- The surrogate is not the dominant cost of a tick row, which also
carries a
timestamptz, three text columns and the point composite. Halving an 8-byte-to-16-byte index entry is a small share of that. - The codegen stack assumes UUID primary keys across registrars,
repositories and the Qt layer. A
bigintseries key is a cross-cutting change, and it should be paid for by a measurement rather than by a preference.
A measurement settles it. The loading exercise at 1k, 10k and 20k series used for the bond family is the shape to repeat.
4.7. The curve layer
A curve is not observed. The bootstrap computes it from quotes by a build procedure, so its identity and its values are different kinds of thing. The synthetic component already does this: it builds IR curves from generation configs and publishes them as ticks.
Curve identity is already modelled. It is a series with
instrument_type = curve, which projects to the ORE curve key
Yield/<ccy>/<curve_id> and carries the role query key. Nothing
new is needed for identity.
Curve values need two tables, and they are the same shape as the observation tables rather than a new concept:
| Table | Key | Carries |
|---|---|---|
curve_build |
series, reference date, build kind | which curve was built, for which reference date, by which procedure |
curve_point |
build, point | one node of the built curve: the point composite from section 4.4, plus the value |
The point composite is reused unchanged, so one coordinate grammar
covers observed and derived values. A zero curve node at the five
year point is the same literal, 5y, whether it was read from a
vendor file or solved by the bootstrap.
A curve_build is one observation of a curve entity, which is why
its key is (series, reference date). That is deliberately the same
shape as (series, observation_datetime) on the observation table. A
consumer that walks either table does the same thing.
4.8. Values come into scope
The three requirements added after the first draft are these. The first is the largest.
4.8.1. The ORE sample round trip
Requirement. Import all the market data in the ORE samples, store it, confirm it reads correctly in the database, export it back to ORE format, and have the result identical.
This contradicts the parent story. Its out-of-scope section reads: "The market data values themselves (quotes, fixings, curves): only identity is in scope." The requirement cannot be met under that boundary, so the boundary moves. The story's out-of-scope line needs amending, and this is a ruling for the owner rather than a decision this task can take.
4.8.2. What the sample files actually look like
This is measured against external/ore/examples/Input/, not assumed.
20160205 BMA_SWAP/RATIO/USD/3M/3M 0.8 20160205 BMA_SWAP/RATIO/USD/3M/1Y 0.8 ... #### Fake #### #artificial 20160205 ZERO/RATE/USD/2D/1D/1M 0.0043
Four properties matter for a round trip, and three of them are hostile to it.
- A uniform data row.
YYYYMMDD KEY VALUE, space separated. 8149 rows inmarket_20160205.txt. This part is easy, and the key is exactly whatfrom_ore_keyconsumes. - Hand-written section comments. The file carries
#### Fake ####,###fake###,#####,#artificial,# artificial,# Normal volatility cap surfaces,# YY Inflation Volatilititesand more. The styles are inconsistent, and one carries a spelling mistake. - Blank lines as separators, in places that follow the sections rather than the data.
- A hand-chosen row order that is not a function of the keys. The
groups run
BMA_SWAP, CC_BASIS_SWAP, BASIS_SWAP, MM, IR_SWAP, FRA, FX, FXFWD, ..., which is neither alphabetical nor by segment count. Within a group the order is by tenor as a duration, where = {3M, 1Y, 2Y, …, 10Y, 12Y, 15Y, 20Y, 25Y, 30Y}= is the file's order and a string sort would put10Ybefore2Y.
So the file is not a pure function of its data. Comments, blank lines and group order are all editorial choices made when the file was written.
4.8.3. The fidelity question this forces
An exporter that emits rows in a canonical sort order cannot reproduce these files byte for byte. Three positions are available, and the design does not pick between them because the answer is a product call about what identical means.
| Position | What is stored | Round trip is identical | Cost |
|---|---|---|---|
| Replay | The observations, plus the source line order and the comment and blank lines | Byte for byte | Stores presentation in the database, and the stored text is useless for anything except replay |
| Canonical | The observations only | After normalising both sides | Cannot reproduce the files as they stand |
| Both | The observations, plus an optional layout record | Byte for byte where a layout exists, canonical where it does not | The two must be kept consistent, which is the drift the analysis rejected elsewhere |
The recommendation is canonical plus a normalising comparison, with replay available later if byte identity turns out to be required for a specific delivery. The reasoning is that the round trip exists to prove no data is lost, and a normalising comparison answers that question directly. Byte identity also answers it, but it additionally asserts that our exporter reproduces somebody else's whitespace, which is a different and much larger claim.
What "normalising" has to mean. Parse both files into a multiset of (date, key, value) triples and compare those. This catches every real loss, including a dropped row, a changed value, a changed date and a re-keyed series. It deliberately ignores order, comments and blank lines, which is exactly the set of things section 4.8.2 showed to be editorial.
4.8.4. The date format differs between the two file kinds
Measured, not assumed:
| File | Row shape | Example |
|---|---|---|
market_*.txt |
YYYYMMDD KEY VALUE |
20160205 BMA_SWAP/RATIO/USD/3M/3M 0.8 |
fixings*.txt |
YYYY-MM-DD CODE VALUE |
2016-01-28 EQ-SP5 2244.2 |
The same exporter must emit both, so the date format is a property of the file kind and not a global setting.
The fixings file groups by index code and sorts by date ascending within a code, which is a different editorial rule again.
4.8.5. Keys with no oresmd mapping
The round trip imports every key in the samples, and the samples
contain ORE families that from_ore_key rejects today: BOND, the
option and capfloor families, and the volatility sub-families. Under
the taxonomy resolution in section 4.2 some of these gain a mapping,
and the rest do not.
Two ways out, and they are not equivalent.
- Map everything. Extend oresmd until every sampled key has an identifier. Complete, and it forces the capfloor grammar question in section 4.4 to be answered rather than deferred.
- Pass through unmapped keys. Store the raw ORE key for anything the parser rejects, flagged as unmapped. Gets the round trip green sooner and leaves a permanent second identity form in the table, which is the drift the analysis ruled against.
The recommendation is to map everything. It costs the capfloor answer, and it keeps one identity form, which is the whole point of the story.
4.8.6. Curves the bootstrap produces
Requirement. We already bootstrap curves, so the model must represent the curves we create.
Section 4.7 covers it. What is worth stating here is that the bootstrap
is the first producer of curve_build rows, so the round trip in
4.8.1 applies to them too: a curve we build must export to ORE format
as well as a curve we read. That is a stronger requirement than
storing it, and it is the reason the point composite is reused rather
than a second coordinate form being invented for curves.
4.8.7. Generated market data
Requirement. We generate market data, so the model must represent what we generate.
This is already covered and needs no new tables, which is worth recording because it is easy to assume otherwise. Generated data lands in the same two tables as vendor data:
- A generated observation is a
market_observationrow whosesourcecolumn names the producer channel, as insynthetic.v1.tick.fx_spot.eur-usd. - Its provenance is an
observation_lineagerow: thederivation_config_idandderivation_config_versionthat produced it, thesource_as_ofit was built from, and thesource_series_idsit consumed.
So the model already distinguishes the three provenances in one
place: a vendor observation has a source and no lineage, a derived
observation has both, and a bootstrapped curve has a curve_build.
4.9. Consequences for the existing tasks
If this design is agreed, three of the story's tasks change and none is invalidated.
| Task | Effect |
|---|---|
| Identity core | Unchanged, plus the vocabulary tables land here or just before. |
| Observation identity | Clarified: the point is the implemented comma-joined composite, not a new serialisation. |
| Generation configs | Unchanged. |
| The NATS wire | Unchanged. |
| ores.shell | Unchanged. |
| Delete legacy representations | Grows: the hardcoded enum values that become tables are deleted from the template. |
| Inverse projection | Reopened in one respect only: bond and correlation coverage, per section 4.2. |
Two of the story's tasks are out of scope: the work in section 4.8 is new and is not covered by any of them. The scaffolded tasks are unchanged by this design, so a further workstream lands alongside them.
4.10. Open questions
- What identical means. Section 4.8.3. This is the one that blocks the round-trip workstream, and it is a product call rather than an engineering one.
- The story's out-of-scope line. Section 4.8.1. The requirement cannot be met without amending it.
- The capfloor grammar. Section 4.4. Deferred twice now. Mapping every key in section 4.8.5 forces it.
- The taxonomy's own open item. Whether
SecuritiesFinancingearns a root. It belongs to Asset Classes, not here. - How a correlation references two classes. oresmd has no grammar
for it today, since
correlationwas a single authority. - The surrogate measurement. Section 4.6 recommends measuring before changing the key type.
- Whether
market_fixingstays its own table. The analysis keeps it, on the grounds that a fixing has no point dimension. It is otherwise identical to an observation, down to sharing thesoft_update_deletetrigger. Not reopened here, and the round trip in 4.8.1 will exercise both, so the duplication becomes visible. - A conflict between two documents. The inventory document states
that fixings have no bitemporal correction and are immutable once
published. The generated SQL gives
market_fixingthe same soft-update trigger as observations. One of the two is wrong, and vendors do restate fixings, so the document is the likelier candidate.
5. Notes
The model is drawn as a PlantUML class diagram at
ores.marketdata.puml, carrying every class and its attributes. The diagram is the reference for the shape; this document is the reference for why.
Green boxes are persisted tables. Amber boxes are added by this design. Purple boxes are vocabulary that becomes a reference table. Grey boxes are vocabulary that stays a code constant.
- This task was written in response to a design review that produced
an HTML mock-up of the model. That mock-up is superseded by this
document and is not kept: it proposed typed component columns for
market_seriesand for the observation coordinate, which section 4.1.2's hybrid ruling had already rejected. Recording it here so the same shape is not proposed a third time. - The design deliberately does not restate the full entity-by-entity inventory. That lives in Analysis: market data identity on oresmd and is unchanged.
- The existing tests for this model are the three oresmd suites in
projects/ores.marketdata/core/tests/(parser, projections, resolver), the entity domain suites inapi/tests/, the repository suites and the import service suite incore/tests, and the curve republish resolver suites inservice/tests. They cover identity and projection. Nothing covers values, which is the gap section 4.8 opens.
6. 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 |
|---|---|---|
7. PRs
| PR | Title |
|---|---|
8. Review
| Comment summary | File | Decision | Notes |
|---|---|---|---|
9. Result
Abandoned at sprint 25 close, as part of the reset to one approach for sprint 26. The work is not in the sprint 26 plan. The done tasks stay as the record of what shipped.