ores.marketdata.market_observation
Table of Contents
A single market data observation: the value of a series at a given
observation_datetime and oresmd_uri (the datum's coordinate).
observation_datetime is the financial valid-time (UTC); valid_from=/=valid_to
is the transaction time. Corrections replace the previous value via the
soft-update trigger.
TimescaleDB hypertable partitioned by observation_datetime with 30-day chunks;
GIST exclusion and DELETE RULEs are incompatible with hypertables — uniqueness
is enforced via partial unique index and the soft-update/soft-delete trigger pair.
No audit trail columns (version, modified_by, performed_by, change_reason_code, change_commentary) — tick-level data volumes make these impractical.
1. Flags
2. Columns
2.1. id
Surrogate UUID uniquely identifying this observation row.
2.2. party_id
Party that owns this observation.
Set server-side from the authenticated session. Enforced by RLS.
ctx.generate_uuid()
2.3. series_id
Reference to ores_marketdata_market_series_tbl(id) — identifies what was observed.
2.4. observation_datetime
Financial valid-time: when the market value was observed (UTC).
Also the hypertable partition column. A reader that draws a window of time
narrows by observation_datetime_from, the lower bound the filterable
column adds.
ctx.past_timepoint()
2.5. oresmd_uri
The datum's canonical oresmd URI: the series' URI with type=quote and the
coordinate fields of this observation, e.g.
oresmd://ir/USD?type=quote&instrument=ir_swap"e=rate&fwd_start=0D&tenor=3M&term=5Y.
It is the row's only identity. The ORE key is not stored: ore_key_codec
writes it from the datum this URI names, so a row cannot hold two identities
that disagree.
2.6. value
Serialised market value (numeric string; format is series-type-specific).
std::to_string(faker::number::decimal<double>(0.0, 100.0))
2.7. source
Source tag identifying the producer channel that published this observation
(e.g. synthetic.v1.ops.tick.fx_spot.eur-usd).
2.8. feed_id
References feed.id – the catalogue source that produced this
observation. It is information. The series key does not contain it, so a
party that switches the source of a provider keeps one history and each
observation says which source it came from.
ctx.generate_uuid()
3. Artefact columns
The import names a series by its ORE market data key and datum URI, where the store holds a series id and a party. It carries the observation date, the source's URL and the retrieval time. Publishing resolves the series and writes the party.
3.1. version
3.2. oresmd_uri
3.3. key
3.4. datum_uri
3.5. observation_date
3.6. value
3.7. source
3.8. source_url
3.9. retrieved_at
4. SQL
4.1. Flags
4.2. Checks
| expression |
|---|
| "value" <> '' |
4.3. Bitemporal natural keys
| column | nullable |
|---|---|
| series_id | false |
| observation_datetime | false |
| oresmd_uri | false |
4.4. Indexes
| name | columns | unique | current_only | where_extra |
|---|---|---|---|---|
| observations_current_uniq | tenant_id, party_id, series_id, observation_datetime, oresmd_uri | true | true | |
| observations_series_datetime | tenant_id, party_id, series_id, observation_datetime desc | false | false | |
| observations_tenant_datetime | tenant_id, party_id, observation_datetime desc | false | false | |
| observations_source | tenant_id, party_id, source, observation_datetime desc | false | false | source is not null |
| observations_series_coordinate_datetime | tenant_id, series_id, oresmd_uri, observation_datetime desc | false | false |
5. Foreign keys
5.1. series_id
Every real caller lists observations scoped to one series — generates a
dedicated, paginated list_by_series_id endpoint (protocol/repository/service/
registrar) instead of an optional filter bolted onto the generic
.list endpoint. The list returns observations: the points that people and
processes state. It never returns a tick, because the ingest writes ticks to
the tick table and a tick has no id, no source and no correction. A client that
wants ticks calls marketdata.v1.ops.get_series_ticks. Default page size is 1000
(vs the usual 100). Ordered by observation_datetime desc (not the facet's usual primary-key
order): a fully random UUID id has no correlation to time, so the
default order would silently return an arbitrary subset rather than
the most recent observations whenever a series exceeds the page limit
— exactly the "most recent N" behaviour the hand-written override this
facet replaces used to provide, and the observations_series_datetime
index below exists specifically to serve this ordering.
6. C++
6.1. Flags
6.2. Repository
6.2.1. read_latest_for_series
Reads every current observation of one series, in observation time order. The ORE export walks a tenant's series and writes each one's observations.
std::vector<domain::market_observation> read_latest_for_series(context ctx, const boost::uuids::uuid& series_id);
#include "ores.database/repository/unit_of_work.hpp" #include "ores.marketdata.api/datum/series_stream.hpp" #include "ores.marketdata.core/repository/as_of_rows.hpp" #include "ores.marketdata.core/repository/market_series_repository.hpp" #include "ores.marketdata.core/repository/observation_lineage_repository.hpp" #include "ores.marketdata.core/repository/pg_array.hpp" #include "ores.marketdata.core/repository/tick_entitlement.hpp" #include "ores.platform/time/datetime.hpp" #include <boost/uuid/nil_generator.hpp> #include <boost/uuid/random_generator.hpp> #include <boost/uuid/uuid_io.hpp> #include <format> #include <map> #include <optional> #include <set> #include <string> #include <utility> #include <vector>
std::vector<domain::market_observation> market_observation_repository::read_latest_for_series(context ctx, const boost::uuids::uuid& series_id) { BOOST_LOG_SEV(lg(), debug) << "Reading latest observations for series: " << series_id; static constexpr auto max = db_timestamp::max(); const auto tid = ctx.tenant_id().to_string(); const auto sid = boost::uuids::to_string(series_id); const auto query = query_builder::read<std::vector<market_observation_entity>> | query_builder::where("tenant_id"_c == tid && "series_id"_c == sid && "valid_to"_c == max) | query_builder::order_by("observation_datetime"_c); return query_builder::execute_read_query<market_observation_entity, domain::market_observation>( ctx, query, [](const auto& entities) { return market_observation_mapper::map(entities); }, lg(), "Reading latest market observations."); }
6.2.2. read_latest_all
Reads every current observation of the tenant, ordered by series and then by observation time. The ORE export asks here once for the whole tenant, because a read for each series pays the planner once for every chunk of the hypertable, and the export of a tenant with 306 series took 38 seconds that way.
/** * @brief Every current observation of the tenant, ordered by series and time. * * One statement for the whole tenant. A caller that needs the observations of many * series asks here, because a statement for each series pays the planner once for * every chunk of the hypertable. The rows are held in memory together, so the size * of the answer grows with the history the tenant keeps. */ std::vector<domain::market_observation> read_latest_all(context ctx);
std::vector<domain::market_observation> market_observation_repository::read_latest_all(context ctx) { BOOST_LOG_SEV(lg(), debug) << "Reading the latest observations of every series"; static constexpr auto max = db_timestamp::max(); const auto tid = ctx.tenant_id().to_string(); const auto query = query_builder::read<std::vector<market_observation_entity>> | query_builder::where("tenant_id"_c == tid && "valid_to"_c == max) | query_builder::order_by("series_id"_c, "observation_datetime"_c); return query_builder::execute_read_query<market_observation_entity, domain::market_observation>( ctx, query, [](const auto& entities) { return market_observation_mapper::map(entities); }, lg(), "Reading the latest market observations of every series."); }
6.2.3. read_as_of_records
Reconstructs a curve or grid snapshot as of a given time, with each point's
record time: the latest observation per oresmd_uri at or before
as_of_datetime, each beside the valid_from of the row it came from. It is
correct whether every point shares one observation time or the points tick
independently.
This is the read; read_as_of is its projection. A consumer that draws the
snapshot needs the observations; one that reports how stale the snapshot is
needs both facts. Keeping one read means the paging rule, the manual overlay
and the point's record time have one home, and a value and a record time can
never be read from two queries that disagree.
// Declared here and defined in as_of_rows.hpp. The header that declares this // repository needs the name only, and the definition sits beside the read that // builds it, so a caller that wants the record times includes that one. struct observation_record;
std::vector<observation_record> read_as_of_records(context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& as_of_datetime);
namespace { /* * The stream of the ticks of one series, as the ingest derives it from the provider and the * series URI. The nil id returns when the session cannot see the series, or when it names a * party that may not read the provider's ticks. No tick carries the nil id, so the read finds no * tick and still answers with the party's own observation rows. A context with no party is a * service context, which reads the store on its own authority, as every service read does. The * id is stored nowhere, so every read computes it. @p allowed is the right the read needs. */ std::string stream_of(market_observation_repository::context ctx, const boost::uuids::uuid& series_id, bool (*allowed)(const ores::database::context&, const std::string&)) { const auto series = market_series_repository{}.read_latest(ctx, boost::uuids::to_string(series_id)); if (series.empty()) return boost::uuids::to_string(boost::uuids::nil_uuid()); const auto& s = series.front(); if (ctx.party_id() && !allowed(ctx, s.provider_code)) return boost::uuids::to_string(boost::uuids::nil_uuid()); return boost::uuids::to_string(datum::stream_id(s.provider_code, s.oresmd_uri)); } /* * The stream of every series of the tenant that the session may compute from, with the * series each belongs to, in one order. read_as_of_all reads every series in one statement, and * no statement derives the id. */ std::pair<std::vector<std::string>, std::vector<std::string>> streams_of_every_series(market_observation_repository::context ctx) { const auto party = ctx.party_id(); const auto usable = party ? computable_providers(ctx) : std::set<std::string>{}; std::vector<std::string> series; std::vector<std::string> streams; for (const auto& s : market_series_repository{}.read_latest(ctx)) { if (party && !usable.contains(s.provider_code)) continue; series.push_back(boost::uuids::to_string(s.id)); streams.push_back(boost::uuids::to_string(datum::stream_id(s.provider_code, s.oresmd_uri))); } return {std::move(series), std::move(streams)}; } /* * The datums of one series' stream, with the owner the tick rows no longer carry. The stream id * is derived rather than stored, so the caller states it in @p stream. $2 is the series, and * @p max is the record time the current series row carries. */ std::string tick_coords_cte(std::string_view stream, std::string_view max) { return std::format(R"(coords AS ( SELECT c.stream_id, c.coord, c.oresmd_uri, s.id AS series_id, s.tenant_id, s.party_id FROM ores_marketdata_tick_coords_tbl c JOIN ores_marketdata_market_series_tbl s ON s.id = $2::uuid AND s.valid_to = {1}::timestamptz WHERE c.stream_id = {0}::uuid ))", stream, max); } /* * The latest tick of each coordinate at or before @p bound, as the ten observation columns and a * source rank of 1. One index probe finds each, so a long series costs a few rows. A tick has * no source, and its record time is its own time. Needs the coords CTE. */ std::string latest_tick_points(std::string_view bound, std::string_view max) { return std::format(R"( SELECT md5(c.series_id::text || t.coord::text || t.ts::text)::uuid, c.tenant_id, c.party_id, c.series_id, t.ts, c.oresmd_uri, t.value::text, NULL::text, t.feed_id, t.ts, {1}::timestamptz, 1 FROM coords c CROSS JOIN LATERAL ( SELECT * FROM ores_marketdata_ticks_tbl x WHERE x.stream_id = c.stream_id AND x.coord = c.coord AND x.ts <= {0}::timestamptz ORDER BY x.ts DESC LIMIT 1 ) t)", bound, max); } /* * The manual points that own their coordinate for reading: the observation * each current manual annex row of this series keyed, with that row's own * record time. The annex is cold and holds only hand-keyed points, so this * read is small; keeping it separate from the observation scan leaves the * tick path unchanged. */ std::vector<observation_record> read_manual_points(market_observation_repository::context ctx, const boost::uuids::uuid& series_id, ores::logging::logger_t& log) { const auto tid = ctx.tenant_id().to_string(); const auto sid = boost::uuids::to_string(series_id); static const std::string sql = R"( SELECT o.id, o.tenant_id, o.party_id, o.series_id, o.observation_datetime, o.oresmd_uri, o.value, o.source, o.feed_id, o.valid_from, o.valid_to FROM ores_marketdata_observation_lineages_tbl l JOIN ores_marketdata_market_observations_tbl o ON o.tenant_id = l.tenant_id AND o.party_id = l.party_id AND o.series_id = l.series_id AND o.oresmd_uri = l.oresmd_uri AND o.observation_datetime = l.observation_datetime AND o.valid_to = $3 WHERE l.tenant_id = $1 AND l.series_id = $2 AND l.point_source_kind = 'manual' AND l.valid_to = $3 )"; const auto rows = query_builder::execute_parameterized_multi_column_query( ctx, sql, {tid, sid, MAX_TIMESTAMP}, log, "reading manual curve points"); std::vector<observation_record> result; result.reserve(rows.size()); for (const auto& row : rows) result.push_back({market_observation_mapper::map( as_of_observation(row, 0, "read_manual_points")), as_of_recorded_at(row, 0, "read_manual_points")}); return result; } /* * The manual points of every series of the tenant: the read above without its series. */ std::vector<observation_record> read_manual_points_all(market_observation_repository::context ctx, ores::logging::logger_t& log) { const auto tid = ctx.tenant_id().to_string(); static const std::string sql = R"( SELECT o.id, o.tenant_id, o.party_id, o.series_id, o.observation_datetime, o.oresmd_uri, o.value, o.source, o.feed_id, o.valid_from, o.valid_to FROM ores_marketdata_observation_lineages_tbl l JOIN ores_marketdata_market_observations_tbl o ON o.tenant_id = l.tenant_id AND o.party_id = l.party_id AND o.series_id = l.series_id AND o.oresmd_uri = l.oresmd_uri AND o.observation_datetime = l.observation_datetime AND o.valid_to = $2 WHERE l.tenant_id = $1 AND l.point_source_kind = 'manual' AND l.valid_to = $2 )"; const auto rows = query_builder::execute_parameterized_multi_column_query( ctx, sql, {tid, MAX_TIMESTAMP}, log, "reading manual curve points of every series"); std::vector<observation_record> result; result.reserve(rows.size()); for (const auto& row : rows) result.push_back( {market_observation_mapper::map(as_of_observation(row, 0, "read_manual_points_all")), as_of_recorded_at(row, 0, "read_manual_points_all")}); return result; } } std::vector<observation_record> market_observation_repository::read_as_of_records( context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& as_of_datetime) { using ores::platform::time::datetime; BOOST_LOG_SEV(lg(), debug) << "Reading as-of snapshot for series: " << series_id << " as-of: " << datetime::to_iso8601_utc(as_of_datetime); const auto tid = ctx.tenant_id().to_string(); const auto sid = boost::uuids::to_string(series_id); const auto as_of_str = datetime::to_iso8601_utc(as_of_datetime); const auto stream = stream_of(ctx, series_id, may_compute_from_ticks); // DISTINCT ON (oresmd_uri) returns exactly one row per point -- the latest at or before // as_of_datetime -- reconstructing a curve/grid snapshot from independently-ticking rows. // Correct whether every point shares one observation_datetime or the points have // staggered timestamps. // // The points come from two tables: the observation table, and the tick table through the // coordinate dictionary. The tick branch takes the latest tick of each coordinate with one // index probe, so it reads a few rows however long the series is. On a tie the observation // row wins, because it carries the source and its own id. static const std::string sql = "WITH " + tick_coords_cte("$5", "$4") + R"(, points AS ( SELECT id, tenant_id, party_id, series_id, observation_datetime, oresmd_uri, value, source, feed_id, valid_from, valid_to, 0 AS src_rank FROM ores_marketdata_market_observations_tbl WHERE tenant_id = $1 AND series_id = $2 AND observation_datetime <= $3 AND valid_to = $4 UNION ALL)" + latest_tick_points("$3", "$4") + R"( ) SELECT DISTINCT ON (oresmd_uri) id, tenant_id, party_id, series_id, observation_datetime, oresmd_uri, value, source, feed_id, valid_from, valid_to FROM points ORDER BY oresmd_uri, observation_datetime DESC, src_rank )"; const auto rows = query_builder::execute_parameterized_multi_column_query( ctx, sql, {tid, sid, as_of_str, MAX_TIMESTAMP, stream}, lg(), "reading as-of curve snapshot"); std::vector<observation_record> result; result.reserve(rows.size()); for (const auto& row : rows) result.push_back({market_observation_mapper::map( as_of_observation(row, 0, "read_as_of_records")), as_of_recorded_at(row, 0, "read_as_of_records")}); // A current manual point owns its coordinate until cleared, overlaying the // fed value and any later automatic write. One whose instant is after the // snapshot instant is not in it; one the snapshot covers is, whenever keyed. for (const auto& manual : read_manual_points(ctx, series_id, lg())) { if (manual.observation.observation_datetime > as_of_datetime) continue; const auto it = std::ranges::find( result, manual.observation.oresmd_uri, [](const observation_record& record) { return record.observation.oresmd_uri; }); if (it != result.end()) *it = manual; else result.push_back(manual); } return result; }
6.2.4. read_as_of
Reconstructs a curve or grid snapshot for a series as of a given time: the
latest observation per oresmd_uri at or before as_of_datetime. It is correct
whether every point shares one observation time or the points tick
independently.
A current manual point owns its coordinate for reading, so the snapshot
overlays any manual point keyed at or before as_of_datetime: the fed value
it replaced, and any later automatic write to the same coordinate, do not
displace it until the manual point is cleared.
It is the observations of read_as_of_records and nothing else: the points
without their record times, for a consumer that draws the snapshot and does not
report on it.
std::vector<domain::market_observation> read_as_of(context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& as_of_datetime);
std::vector<domain::market_observation> market_observation_repository::read_as_of( context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& as_of_datetime) { const auto records = read_as_of_records(ctx, series_id, as_of_datetime); std::vector<domain::market_observation> result; result.reserve(records.size()); for (const auto& record : records) result.push_back(record.observation); return result; }
6.2.5. read_as_of_all
Reconstructs the as-of snapshot of every series of the tenant in one statement.
It gives each series what read_as_of gives for it, with the series in each
row. The statement is that of read_as_of_records without its series filter,
and the manual points of every series are read in one statement too. The tests
compare it with read_as_of for each series, so a change to one is a change to
the other.
/** * @brief The as-of snapshot of every series of the tenant, in one statement. * * Each series gives what @c read_as_of gives for it: the latest point of each datum at * or before the instant, from the observation and tick tables, with a current manual * point owning its datum. A row carries its series in @c series_id. The statement * is that of @c read_as_of_records without its series filter, and a change to one * is a change to the other. */ std::vector<domain::market_observation> read_as_of_all(context ctx, const std::chrono::system_clock::time_point& as_of_datetime);
std::vector<domain::market_observation> market_observation_repository::read_as_of_all( context ctx, const std::chrono::system_clock::time_point& as_of_datetime) { using ores::platform::time::datetime; const auto tid = ctx.tenant_id().to_string(); const auto as_of_str = datetime::to_iso8601_utc(as_of_datetime); BOOST_LOG_SEV(lg(), debug) << "Reading the as-of snapshot of every series as-of: " << as_of_str; // The streams of the tenant travel as two arrays of one order, because the tick rows name // their stream by a derived id and no statement derives it. The statement of // read_as_of_records without its series filter: the coordinates of every series, the latest // tick of each by one index probe, and the observation rows, reduced to one row for each // datum of each series. const auto [series_ids, streams] = streams_of_every_series(ctx); static const std::string sql = R"( WITH coords AS ( SELECT x.series_id, x.stream_id, s.tenant_id, s.party_id, c.coord, c.oresmd_uri FROM unnest($4::uuid[], $5::uuid[]) AS x(series_id, stream_id) JOIN ores_marketdata_market_series_tbl s ON s.id = x.series_id AND s.valid_to = $3 JOIN ores_marketdata_tick_coords_tbl c ON c.stream_id = x.stream_id ), points AS ( SELECT id, tenant_id, party_id, series_id, observation_datetime, oresmd_uri, value, source, feed_id, valid_from, valid_to, 0 AS src_rank FROM ores_marketdata_market_observations_tbl WHERE tenant_id = $1 AND observation_datetime <= $2 AND valid_to = $3 UNION ALL SELECT md5(c.series_id::text || t.coord::text || t.ts::text)::uuid, c.tenant_id, c.party_id, c.series_id, t.ts, c.oresmd_uri, t.value::text, NULL::text, t.feed_id, t.ts, $3::timestamptz, 1 FROM coords c CROSS JOIN LATERAL ( SELECT * FROM ores_marketdata_ticks_tbl x WHERE x.stream_id = c.stream_id AND x.coord = c.coord AND x.ts <= $2::timestamptz ORDER BY x.ts DESC LIMIT 1 ) t ) SELECT DISTINCT ON (series_id, oresmd_uri) id, tenant_id, party_id, series_id, observation_datetime, oresmd_uri, value, source, feed_id, valid_from, valid_to FROM points ORDER BY series_id, oresmd_uri, observation_datetime DESC, src_rank )"; const auto rows = query_builder::execute_parameterized_multi_column_query( ctx, sql, {tid, as_of_str, MAX_TIMESTAMP, pg_array(series_ids), pg_array(streams)}, lg(), "reading as-of snapshot of every series"); std::vector<observation_record> result; result.reserve(rows.size()); for (const auto& row : rows) result.push_back( {market_observation_mapper::map(as_of_observation(row, 0, "read_as_of_all")), as_of_recorded_at(row, 0, "read_as_of_all")}); // A current manual point owns its datum until cleared, as in the read for one series. std::map<std::pair<std::string, std::string>, std::size_t> position; for (std::size_t i = 0; i != result.size(); ++i) position[{boost::uuids::to_string(result[i].observation.series_id), result[i].observation.oresmd_uri}] = i; for (auto& manual : read_manual_points_all(ctx, lg())) { if (manual.observation.observation_datetime > as_of_datetime) continue; const auto key = std::pair{boost::uuids::to_string(manual.observation.series_id), manual.observation.oresmd_uri}; if (const auto it = position.find(key); it != position.end()) result[it->second] = std::move(manual); else result.push_back(std::move(manual)); } std::vector<domain::market_observation> observations; observations.reserve(result.size()); for (auto& record : result) observations.push_back(std::move(record.observation)); return observations; }
6.2.6. read_as_of_buckets
Reconstructs one as-of snapshot per bucket boundary, for a curve-evolution view,
oldest to newest. The boundaries and the per-bucket reduction run in one
statement, so the read is one round trip rather than bucket_count as-of
queries. A bucket with no observation at or before its boundary is empty.
std::vector<std::vector<domain::market_observation>> read_as_of_buckets(context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& latest_boundary, const std::chrono::seconds& bucket_size, unsigned int bucket_count);
std::vector<std::vector<domain::market_observation>> market_observation_repository::read_as_of_buckets( context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& latest_boundary, const std::chrono::seconds& bucket_size, unsigned int bucket_count) { using ores::platform::time::datetime; BOOST_LOG_SEV(lg(), debug) << "Reading " << bucket_count << " as-of bucket snapshots for series: " << series_id << " every " << bucket_size.count() << "s ending at: " << datetime::to_iso8601_utc(latest_boundary); const auto tid = ctx.tenant_id().to_string(); const auto sid = boost::uuids::to_string(series_id); const auto latest_str = datetime::to_iso8601_utc(latest_boundary); const auto bucket_seconds = std::to_string(bucket_size.count()); const auto count_str = std::to_string(bucket_count); const auto stream = stream_of(ctx, series_id, may_read_ticks); // Bucket boundaries and the per-bucket as-of reduction both happen in the database, in // one statement: generate_series() produces the (up to bucket_count) boundaries ending // at latest_boundary, and the LATERAL subquery resolves each oresmd_uri to its own latest // observation at or before that boundary -- a per-bucket DISTINCT ON (oresmd_uri), driven // by observations_series_coordinate_datetime_idx (tenant_id, series_id, oresmd_uri, // observation_datetime desc), so each point is a skip-scan straight to its latest row // per bucket rather than a sort over the whole series. bucket_ordinal lets the caller // regroup rows by bucket without relying on floating-point/timestamp equality. static const std::string sql = R"( WITH boundaries AS ( SELECT ordinality - 1 AS bucket_ordinal, boundary FROM generate_series( $3::timestamptz - ($5::int - 1) * ($4::int * interval '1 second'), $3::timestamptz, $4::int * interval '1 second' ) WITH ORDINALITY AS t(boundary, ordinality) ), )" + tick_coords_cte("$7", "$6") + R"( SELECT b.bucket_ordinal, o.id, o.tenant_id, o.party_id, o.series_id, o.observation_datetime, o.oresmd_uri, o.value, o.source, o.feed_id, o.valid_from, o.valid_to FROM boundaries b CROSS JOIN LATERAL ( SELECT DISTINCT ON (u.oresmd_uri) u.id, u.tenant_id, u.party_id, u.series_id, u.observation_datetime, u.oresmd_uri, u.value, u.source, u.feed_id, u.valid_from, u.valid_to FROM ( (SELECT DISTINCT ON (oresmd_uri) id, tenant_id, party_id, series_id, observation_datetime, oresmd_uri, value, source, feed_id, valid_from, valid_to, 0 AS src_rank FROM ores_marketdata_market_observations_tbl m WHERE m.tenant_id = $1 AND m.series_id = $2 AND m.observation_datetime <= b.boundary AND m.valid_to = $6 ORDER BY oresmd_uri, observation_datetime DESC) UNION ALL)" + latest_tick_points("b.boundary", "$6") + R"( ) u ORDER BY u.oresmd_uri, u.observation_datetime DESC, u.src_rank ) o ORDER BY b.bucket_ordinal, o.oresmd_uri )"; const auto rows = query_builder::execute_parameterized_multi_column_query( ctx, sql, {tid, sid, latest_str, bucket_seconds, count_str, MAX_TIMESTAMP, stream}, lg(), "reading as-of bucketed curve evolution"); std::vector<std::vector<domain::market_observation>> result(bucket_count); for (const auto& row : rows) { const auto ordinal = as_of_bucket_ordinal(row, result.size()); result[ordinal].push_back( market_observation_mapper::map(as_of_observation(row, 1, "read_as_of_buckets"))); } // The manual overlay, once per bucket: a manual point owns its coordinate // from the bucket whose boundary is at or after the manual point's own // instant, and the buckets before it keep the fed value they showed. The // boundaries mirror the generate_series() above, where bucket_ordinal 0 is // the oldest. for (const auto& manual : read_manual_points(ctx, series_id, lg())) { for (unsigned int i = 0; i < bucket_count; ++i) { const auto offset = std::chrono::seconds( static_cast<long long>(bucket_count - 1 - i) * bucket_size.count()); if (manual.observation.observation_datetime > latest_boundary - offset) continue; const auto it = std::ranges::find( result[i], manual.observation.oresmd_uri, &domain::market_observation::oresmd_uri); if (it != result[i].end()) *it = manual.observation; else result[i].push_back(manual.observation); } } return result; }
6.2.7. read_instants
Reads the points a series states at each instant, oldest first, for a slice or an aligned evolution view. The instants and their points come back in one statement, so the read is one round trip however many instants it covers.
An instant's points are the ones the series states at it, not the ones it
held by then. That is what lets a reader see a node a structure gained or lost:
a point a later build does not state is absent from that build, and not carried
forward from the last time it was stated. A reader that wants the object as it
stood at an instant, assembled from whatever the last quote per coordinate was,
asks read_as_of_records or read_as_of_buckets instead.
The instants are either the ones the series states between two datetimes or the
set the caller names. A stated set replaces the range, and a stated instant at
which the series states nothing yields no rows. More instants than
max_instants is refused rather than truncated, because a caller that asked for
a quarter and got a day would read the answer as complete.
// One instant of a series: the points the series states at it. struct series_instant { std::chrono::system_clock::time_point as_of; std::vector<domain::market_observation> points; };
std::vector<series_instant> read_instants(context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& from_datetime, const std::chrono::system_clock::time_point& to_datetime, const std::vector<std::chrono::system_clock::time_point>& instants, unsigned int max_instants);
std::vector<series_instant> market_observation_repository::read_instants( context ctx, const boost::uuids::uuid& series_id, const std::chrono::system_clock::time_point& from_datetime, const std::chrono::system_clock::time_point& to_datetime, const std::vector<std::chrono::system_clock::time_point>& instants, unsigned int max_instants) { using ores::platform::time::datetime; BOOST_LOG_SEV(lg(), debug) << "Reading the instants of series: " << series_id << " from " << datetime::to_iso8601_utc(from_datetime) << " to " << datetime::to_iso8601_utc(to_datetime) << ", " << instants.size() << " stated instant(s)"; const auto tid = ctx.tenant_id().to_string(); const auto sid = boost::uuids::to_string(series_id); const auto from_str = datetime::to_iso8601_utc(from_datetime); const auto to_str = datetime::to_iso8601_utc(to_datetime); const auto cap_str = std::to_string(max_instants + 1); const auto stream = stream_of(ctx, series_id, may_read_ticks); // The stated instants travel as one comma-separated string, because the // read takes text parameters. An empty one gives an empty array, which is // what the SQL reads as "no set was stated". std::string stated_str; for (const auto& instant : instants) { if (!stated_str.empty()) stated_str += ','; stated_str += datetime::to_iso8601_utc(instant); } // The instants the read covers and their points come back together: the // boundaries are the caller's stated set when there is one and otherwise // the instants the series states in the range, and the points are the rows // at those instants and no others. The index is // observations_series_coordinate_datetime_idx (tenant_id, series_id, // oresmd_uri, observation_datetime desc), so the join by instant is served // by its leading columns rather than by a scan of the hypertable. // // The instant travels as microseconds since the epoch, because it is a // grouping key before it is a timestamp, and a text rendering parsed back // would group at another precision than the rows carry. static const std::string sql = R"( WITH stated AS ( SELECT unnest(string_to_array($7, ',')::timestamptz[]) AS instant ), )" + tick_coords_cte("$8", "$6") + R"(, instants AS ( SELECT instant FROM ( SELECT instant FROM stated UNION SELECT DISTINCT observation_datetime FROM ores_marketdata_market_observations_tbl WHERE tenant_id = $1 AND series_id = $2 AND observation_datetime >= $3 AND observation_datetime <= $4 AND valid_to = $6 AND NOT EXISTS (SELECT 1 FROM stated) UNION SELECT DISTINCT ts FROM ores_marketdata_ticks_tbl WHERE stream_id = $8::uuid AND ts >= $3::timestamptz AND ts <= $4::timestamptz AND NOT EXISTS (SELECT 1 FROM stated) ) u ORDER BY instant LIMIT $5::int ), points AS ( SELECT i.instant, o.id, o.tenant_id, o.party_id, o.series_id, o.observation_datetime, o.oresmd_uri, o.value, o.source, o.feed_id, o.valid_from, o.valid_to, 0 AS src_rank FROM instants i JOIN ores_marketdata_market_observations_tbl o ON o.tenant_id = $1 AND o.series_id = $2 AND o.observation_datetime = i.instant AND o.valid_to = $6 UNION ALL SELECT i.instant, md5(c.series_id::text || t.coord::text || t.ts::text)::uuid, c.tenant_id, c.party_id, c.series_id, t.ts, c.oresmd_uri, t.value::text, NULL::text, t.feed_id, t.ts, $6::timestamptz, 1 FROM instants i JOIN ores_marketdata_ticks_tbl t ON t.stream_id = $8::uuid AND t.ts = i.instant JOIN coords c ON c.stream_id = t.stream_id AND c.coord = t.coord ) SELECT DISTINCT ON (instant, oresmd_uri) (extract(epoch FROM instant) * 1000000)::bigint::text, id, tenant_id, party_id, series_id, observation_datetime, oresmd_uri, value, source, feed_id, valid_from, valid_to FROM points ORDER BY instant, oresmd_uri, src_rank )"; const auto rows = query_builder::execute_parameterized_multi_column_query( ctx, sql, {tid, sid, from_str, to_str, cap_str, MAX_TIMESTAMP, stated_str, stream}, lg(), "reading the instants of a series"); std::vector<series_instant> result; for (const auto& row : rows) { if (row.empty() || !row[0]) throw std::runtime_error("read_instants: a row carries no instant"); const std::chrono::system_clock::time_point instant{ std::chrono::microseconds(std::stoll(*row[0]))}; if (result.empty() || result.back().as_of != instant) { if (result.size() >= max_instants) throw std::runtime_error("read_instants: the range holds more than " + std::to_string(max_instants) + " instants; ask for a narrower range"); result.push_back({instant, {}}); } result.back().points.push_back( market_observation_mapper::map(as_of_observation(row, 1, "read_instants"))); } return result; }
6.2.8. write_manual_point
Records a point an operator keyed by hand for one coordinate of a series.
The observation row the read returns and the cold annex row that names it
manual are written in one transaction, so a failure in either leaves
neither. The actor comes from the context, so the annex's auditor trail
names who keyed the point, and the reason code and commentary are the
caller's.
void write_manual_point(context ctx, const boost::uuids::uuid& series_id, const std::string& oresmd_uri, std::chrono::system_clock::time_point observation_datetime, const std::string& value, const std::string& change_reason_code, const std::string& change_commentary);
void market_observation_repository::write_manual_point( context ctx, const boost::uuids::uuid& series_id, const std::string& oresmd_uri, std::chrono::system_clock::time_point observation_datetime, const std::string& value, const std::string& change_reason_code, const std::string& change_commentary) { BOOST_LOG_SEV(lg(), debug) << "Writing manual point for series: " << series_id << " coordinate: " << oresmd_uri; const auto party_id = ctx.party_id(); if (!party_id) throw std::invalid_argument( "market_observation_repository::write_manual_point: the context names no party"); // The actor names who keyed the point. A request context carries one; a // system-initiated write falls back to the service account, exactly as the // messaging stamp helper does. const auto actor = ctx.actor().empty() ? ctx.service_account() : ctx.actor(); // One transaction writes the observation row and its annex: the read shows // a manual point only when both are present, so a failure in either leaves // neither behind. unit_of_work uow(ctx); const auto& txn_ctx = uow.ctx(); observation_lineage_repository lineage_repo; const auto current = lineage_repo.read_latest_by_observation( txn_ctx, series_id, observation_datetime, oresmd_uri); domain::market_observation obs; obs.id = boost::uuids::random_generator()(); obs.tenant_id = ctx.tenant_id(); obs.party_id = *party_id; obs.series_id = series_id; obs.observation_datetime = observation_datetime; obs.oresmd_uri = oresmd_uri; obs.value = value; obs.source = "manual.operator"; // A manual point is written past the insert's guard, which drops an automatic // write to a coordinate an operator owns, and this write is the operator's. series_shape_check::check(txn_ctx, std::vector<domain::market_observation>{obs}); query_builder::execute_write_query( txn_ctx, market_observation_mapper::map(obs), lg(), "Inserting manual market observation."); domain::observation_lineage lineage; // An annex row already at this natural key -- a derivation's or an earlier // manual point's -- keeps its own id, so the insert trigger closes it and // writes the new generation instead of colliding with the current-row key. lineage.id = current ? current->id : boost::uuids::random_generator()(); lineage.tenant_id = ctx.tenant_id(); lineage.party_id = *party_id; lineage.series_id = series_id; lineage.observation_datetime = observation_datetime; lineage.oresmd_uri = oresmd_uri; lineage.point_source_kind = "manual"; lineage.derivation_config_id = std::nullopt; lineage.source_as_of = std::nullopt; lineage.source_series_ids = "[]"; lineage.modified_by = actor; lineage.performed_by = ctx.service_account(); lineage.change_reason_code = change_reason_code; lineage.change_commentary = change_commentary; lineage_repo.write(txn_ctx, lineage); uow.commit(); }
6.2.9. clear_manual_point
Withdraws an operator's over-key of one coordinate, so the fed value applies again. The current manual annex row is closed, never deleted: the annex's history keeps it, which is what makes "the history keeps the value it replaced" true. The over-key's own observation row is closed with it, so that the value the feed last wrote shows when no later feed write has come: left in place, the keyed value would stay the newest row of the coordinate and read as a quoted one.
void clear_manual_point(context ctx, const boost::uuids::uuid& series_id, const std::string& oresmd_uri, std::chrono::system_clock::time_point observation_datetime);
void market_observation_repository::clear_manual_point( context ctx, const boost::uuids::uuid& series_id, const std::string& oresmd_uri, std::chrono::system_clock::time_point observation_datetime) { BOOST_LOG_SEV(lg(), debug) << "Clearing manual point for series: " << series_id << " coordinate: " << oresmd_uri; // The read that finds the row and the close that withdraws it share one // transaction, so a point that moved on between them is not closed twice. unit_of_work uow(ctx); const auto& txn_ctx = uow.ctx(); observation_lineage_repository lineage_repo; const auto current = lineage_repo.read_latest_by_observation( txn_ctx, series_id, observation_datetime, oresmd_uri); // Only a manual point is withdrawn: a derived point's annex is not this // operation's to close, and a coordinate with no annex is quoted. if (!current || current->point_source_kind != "manual") return; // Closing, not deleting: the delete rule keeps the row in the annex's // history, so a reader still sees what the over-key replaced. lineage_repo.remove(txn_ctx, boost::uuids::to_string(current->id)); // The over-key's observation row goes with its annex. The delete rule keeps // it in the observation history, as it does the annex. std::vector<std::string> keyed_ids; for (const auto& obs : read_latest_for_series(txn_ctx, series_id)) { if (obs.source == "manual.operator" && obs.oresmd_uri == oresmd_uri && obs.observation_datetime == observation_datetime) keyed_ids.push_back(boost::uuids::to_string(obs.id)); } remove(txn_ctx, keyed_ids); uow.commit(); }
6.2.10. clear_manual_points
Withdraws every operator over-key of one coordinate, whatever instant each was keyed at, in one transaction. Each key is stamped when it is made, so a coordinate adjusted twice holds two. The annex rows and the keyed observation rows are all closed together or not at all, and the answer is how many over-keys were withdrawn. A coordinate that holds none answers zero and writes nothing.
std::size_t clear_manual_points(context ctx, const boost::uuids::uuid& series_id, const std::string& oresmd_uri);
std::size_t market_observation_repository::clear_manual_points( context ctx, const boost::uuids::uuid& series_id, const std::string& oresmd_uri) { BOOST_LOG_SEV(lg(), debug) << "Clearing every manual point for series: " << series_id << " coordinate: " << oresmd_uri; unit_of_work uow(ctx); const auto& txn_ctx = uow.ctx(); observation_lineage_repository lineage_repo; std::vector<std::chrono::system_clock::time_point> instants; for (const auto& annex : lineage_repo.read_manual_for_series(txn_ctx, series_id)) { if (annex.oresmd_uri != oresmd_uri) continue; lineage_repo.remove(txn_ctx, boost::uuids::to_string(annex.id)); if (std::ranges::find(instants, annex.observation_datetime) == instants.end()) instants.push_back(annex.observation_datetime); } if (instants.empty()) return 0; // The keyed observation rows go with their annex rows, so the value the feed // last wrote shows. The delete rule keeps them in the observation history. std::vector<std::string> keyed_ids; for (const auto& obs : read_latest_for_series(txn_ctx, series_id)) { if (obs.source == "manual.operator" && obs.oresmd_uri == oresmd_uri && std::ranges::find(instants, obs.observation_datetime) != instants.end()) keyed_ids.push_back(boost::uuids::to_string(obs.id)); } remove(txn_ctx, keyed_ids); uow.commit(); return instants.size(); }
6.2.11. check_series_shape
Refuses an insert whose point is not a node of the shape its series declares. A composite series states the axes it varies over and the values of each, so an observation that carries a value no axis declares is a defect in the writer rather than market data. The check runs before the row is written, in both the single and the batch insert, so a refused observation never reaches the store.
The same two slots apply the manual-point precedence rule. An insert whose point
lands on a coordinate an operator owns at the same instant is dropped, because
replacing the manual row would leave the annex naming a value the row no longer
holds. manual_point_guard states the rule. The feed, the import and the
republish all write through these inserts, and only write_manual_point writes
a manual point, by a path of its own.
#include "ores.marketdata.core/repository/manual_point_guard.hpp" #include "ores.marketdata.core/repository/series_shape_check.hpp"
series_shape_check::check(ctx, std::vector<domain::market_observation>{v}); if (manual_point_guard::unshadowed(ctx, std::vector<domain::market_observation>{v}).empty()) return;
series_shape_check::check(ctx, v); if (const auto kept = manual_point_guard::unshadowed(ctx, v); kept.size() != v.size()) { if (!kept.empty()) query_builder::execute_write_query(ctx, market_observation_mapper::map(kept), lg(), "Inserting market observations into database."); return; }
6.3. Domain includes
#include <chrono> #include <boost/uuid/uuid.hpp> #include <string>
6.4. Entity includes
#include <optional> #include <string>
6.5. Conventions
7. Physical space
A tick is a fact that arrives many times each second, and a client has no reason to reload it. The change signal that most tables send, a notification for each row that a service turns into an event, serves an entity that people edit. For this table it would take a database-wide lock at every commit, and it would tell a client to reload a series that changes faster than the client can read it. The live tick stream carries each new value, so this table sends no notification and has no event.
| Address | Enabled |
|---|---|
| ores.cpp.nats-event-registrar | false |
| ores.cpp.nats-eventing | false |
| ores.cpp.nats-event-cache | false |
| ores.sql.schema.notify_trigger | false |
| ores.sql.schema.notify_trigger_drop | false |