From a88abc5d42852351d90429efbf8947048a98f67f Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 13:42:38 +0300 Subject: [PATCH 1/7] feat(intrade): add observed tick history --- CMakeLists.txt | 1 + guides/api-and-header-contracts.md | 26 ++ guides/market-data-router.md | 22 +- guides/market-data-router.ru.md | 23 +- guides/platform-api-guide.md | 16 +- guides/refactor-backlog.md | 17 +- .../platforms/IntradeBarPlatform.hpp | 20 +- .../ObservedTickHistory.hpp | 255 ++++++++++++++++++ .../IntradeBarPlatform/PriceManager.hpp | 15 +- tests/intrade_observed_tick_history_test.cpp | 143 ++++++++++ 10 files changed, 511 insertions(+), 27 deletions(-) create mode 100644 include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp create mode 100644 tests/intrade_observed_tick_history_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cbbedfb7..1e76dd0c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1110,6 +1110,7 @@ if(OPTIONX_BUILD_TESTS) telegram_worker_source_test trading_view_bridge_test market_data_tick_history_contract_test + intrade_observed_tick_history_test ) if(OPTIONX_LIGHTWEIGHT_BRIDGE_SMOKE_TESTS) list(APPEND OPTIONX_LIGHTWEIGHT_TESTS diff --git a/guides/api-and-header-contracts.md b/guides/api-and-header-contracts.md index ef7f5973..f36b3f03 100644 --- a/guides/api-and-header-contracts.md +++ b/guides/api-and-header-contracts.md @@ -268,6 +268,32 @@ Contract rules: - `MarketDataContinuityService` is the thin helper for routing recovered history into the same bar batch pipeline. It marks payload bars as `HISTORICAL` and, for gap recovery, `BACKFILL`. +- Historical ticks use `TickHistoryRequest` and `TickHistoryResult` with an + inclusive millisecond range. `range_complete` is an explicit completeness + assertion; successful observations with `false` must not be used as proof of + continuity. Equal timestamps are valid, and exact duplicate removal remains + provider/consumer policy. + +### Intrade observed tick history + +`platforms::IntradeBarPlatform::fetch_tick_history()` reads a bounded in-memory +archive populated by the platform's `/price_now` polling path. These are +observed quote snapshots, not a historical tick endpoint supplied by the +broker: + +- `Tick::time_ms` keeps the broker timestamp, whose effective granularity is + one second. +- `Tick::received_ms` keeps local receipt time when the polling response was + parsed. +- The archive is per symbol, session-scoped, non-persistent, and bounded by + item count and lookback duration. +- Identical market observations are deduplicated; distinct observations with + the same second remain separate. +- `range_complete=true` is returned only for aligned requests where every + expected one-second sample is retained. Missing, unaligned, or evicted + samples leave it false while the returned observations remain usable. +- `trade_check2.php` is a trade settlement/result check keyed by deal ID. It + is not the implementation of `fetch_tick_history()`. - `BarSubscriptionRequest::continuity` enables Router-owned bar prefill and optional timestamp-gap recovery. Router buffers live batches until the corresponding history operation completes and reports route-scoped progress diff --git a/guides/market-data-router.md b/guides/market-data-router.md index 03e2e207..66d8be59 100644 --- a/guides/market-data-router.md +++ b/guides/market-data-router.md @@ -634,11 +634,23 @@ service.request_tick_history_batch( true); // require_complete_range ``` -`MarketDataRouter` still integrates continuity only for bars. No current -provider in this repository exposes authoritative tick history, so the base -operation returns `false` until a provider-specific endpoint and semantics are -implemented. This is intentional: a current-price snapshot cannot be reused as -historical tick data. +`MarketDataRouter` still has its mature continuity state machine on bars. The +Intrade Bar provider now also implements `fetch_tick_history()` as a bounded, +session-scoped archive of observed `/price_now` snapshots. This is useful for +short reconnect windows, but it is not an authoritative broker tick archive: + +- broker timestamps have one-second granularity; +- the archive starts empty for a new authenticated session and evicts old data; +- `range_complete=true` means every expected observed second is retained, not + that every broker micro-event was captured; +- distinct snapshots sharing one second are preserved, while exact duplicate + snapshots are removed; +- `trade_check2.php` remains a settlement/trade-result API and is not used for + range history. + +Router tick continuity is the next layer. Until that integration is enabled, +callers can use the provider operation directly and must treat +`range_complete=false` as an observation result rather than continuity proof. ## Owner Loop And Bot Threads diff --git a/guides/market-data-router.ru.md b/guides/market-data-router.ru.md index cf111b2c..fe03e148 100644 --- a/guides/market-data-router.ru.md +++ b/guides/market-data-router.ru.md @@ -791,8 +791,21 @@ service.request_tick_history_batch( true); // require_complete_range ``` -`MarketDataRouter` пока интегрирует continuity только для bars. Ни один -текущий provider в этом репозитории не предоставляет authoritative tick -history, поэтому базовая операция возвращает `false`, пока не появятся -конкретный endpoint и его семантика. Это намеренно: текущий price snapshot -нельзя выдавать за исторические ticks. +`MarketDataRouter` уже имеет полноценную state machine continuity для bars. +Кроме того, Intrade Bar теперь реализует `fetch_tick_history(...)` через +ограниченный архив наблюдаемых snapshots из `/price_now`. Это полезно для +короткого восстановления после reconnect, но не является authoritative +broker tick archive: + +- broker timestamps имеют гранулярность в одну секунду; +- архив пуст для новой authenticated session и вытесняет старые данные; +- `range_complete=true` означает наличие каждого ожидаемого наблюдаемого + second, а не получение каждого micro-event брокера; +- разные snapshots с одним timestamp сохраняются, а полностью одинаковые + snapshots удаляются; +- `trade_check2.php` остаётся settlement/trade-result API и не используется + для range history. + +Интеграция tick continuity в Router выполняется следующим слоем. До неё +вызывающий код может использовать provider operation напрямую и обязан +считать `range_complete=false` observations, а не доказательством continuity. diff --git a/guides/platform-api-guide.md b/guides/platform-api-guide.md index c8f4df0f..e118a894 100644 --- a/guides/platform-api-guide.md +++ b/guides/platform-api-guide.md @@ -91,6 +91,7 @@ long-lived condition hub does not retain a stale tradable snapshot. | `apply_subscriptions(batch, callback)` | Атомарно применить набор subscribe/unsubscribe изменений | Одиночные helpers являются wrappers над batch | | `unsubscribe(handle, callback)` | Остановить live stream | Handle должен принадлежать этому provider instance | | `fetch_bar_history(request, callback)` | Запросить исторические бары | Возвращает `BarHistoryResult`, а не пустой массив при ошибке | +| `fetch_tick_history(request, callback)` | Получить наблюдаемые или исторические ticks | Inclusive millisecond range; `range_complete` отделяет доказанную полноту от observations | Subscription rules: @@ -128,6 +129,9 @@ Subscription rules: timer-based final snapshot to be delivered. - `MarketDataContinuityService` routes recovered historical bars into the same `BarDataBatch` pipeline and marks them as `HISTORICAL`/`BACKFILL`. +- `TickHistoryRequest` and `TickHistoryResult` use inclusive millisecond ranges. + A successful result may be useful even when `range_complete=false`; that + flag is the provider's proof that the requested range is fully accounted for. - `BarSubscriptionRequest::continuity` lets `MarketDataRouter` request initial bar history before live delivery and optionally recover timestamp gaps. Live batches are buffered while history is in flight. Route-scoped progress is @@ -146,11 +150,13 @@ Subscription rules: `READY`, while a plain or completed `PREFILL` route does not gain outage recovery. Tick routes do not have this guarantee because the provider contract still lacks generic tick-history. -- Router continuity is currently bar-only. Providers have a separate - `fetch_tick_history()` contract with inclusive millisecond ranges and an - explicit `range_complete` result, but no current provider implements - authoritative tick history yet. See the complete EN/RU Router guides and - `market_data_continuity_example.cpp`. +- Router continuity is currently bar-first. Intrade additionally exposes a + bounded, session-scoped observed-tick archive populated by `/price_now`. + Its timestamps have one-second broker granularity, and `range_complete=true` + means every expected observed second is present in the retained archive, not + that every broker micro-event was captured. The archive is non-persistent and + starts empty for a new authenticated session. `trade_check2.php` remains a + settlement/trade-result endpoint and is not used for tick history. - `BaseMarketDataProvider` is non-copyable and non-movable so provider identity cannot be duplicated after handles were issued. - Public subscriptions describe consumer routing. Internal platform polling or diff --git a/guides/refactor-backlog.md b/guides/refactor-backlog.md index bce0901b..b664f150 100644 --- a/guides/refactor-backlog.md +++ b/guides/refactor-backlog.md @@ -14,17 +14,18 @@ series. Keep it short and remove items once they are handled. monotonic stale/degraded durations. - The generic tick-history foundation now has typed timestamp-range request and result DTOs, a provider hook, ordering/range validation, explicit - completeness semantics, and a batch adapter. No provider-specific endpoint - or Router tick continuity is implied yet. + completeness semantics, and a batch adapter. +- Intrade Bar now exposes a bounded, session-scoped observed-tick archive fed by + `/price_now`. It preserves distinct same-second snapshots and reports + `range_complete` only for proven one-second coverage; it is not a persistent + or authoritative broker tick archive. ## Next PR Candidates -- Add a provider-specific tick-history implementation only where the provider - can supply authoritative historical ticks, then integrate it with Router - continuity in a separate change. -- Integrate tick continuity into Router only after a provider-specific history - endpoint defines sequence/timestamp deduplication, retry, and history-to-live - boundary semantics. +- Integrate Intrade's observed-tick archive with Router continuity while keeping + incomplete ranges fail-closed and documenting the session/retention limit. +- Add an authoritative provider tick-history implementation only if a broker + later exposes one; do not treat `trade_check2.php` as a range-history API. - Replace the dense-bar assumption with an explicit provider completeness capability or `range_complete` history result for session-based markets. - Allow reconnect candle boundaries to use a broker-aligned clock instead of diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp index a0922846..c1a9a75e 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp @@ -14,6 +14,7 @@ #include "IntradeBarPlatform/AuthData.hpp" #include "IntradeBarPlatform/AccountInfoData.hpp" #include "IntradeBarPlatform/ApiResponses.hpp" +#include "IntradeBarPlatform/ObservedTickHistory.hpp" #include "IntradeBarPlatform/http_utils.hpp" #include "IntradeBarPlatform/http_parsers.hpp" #include "IntradeBarPlatform/HttpClientComponent.hpp" @@ -46,7 +47,8 @@ namespace optionx::platforms { /// /// Initializes all required components, including HTTP communication, authentication, /// balance tracking, trade execution, and price updates. - IntradeBarPlatform() + explicit IntradeBarPlatform( + intrade_bar::IntradeObservedTickHistoryOptions tick_history_options = {}) : BaseTradingPlatform(std::make_shared()), m_http_client(*this), m_request_manager(*this, m_http_client), @@ -55,7 +57,8 @@ namespace optionx::platforms { m_balance_manager(*this, m_request_manager, m_account_info), m_active_trades_sync_manager(*this, m_request_manager, m_account_info), m_trading_condition_manager(*this, m_account_info), - m_price_manager(*this, m_request_manager), + m_tick_history(std::move(tick_history_options)), + m_price_manager(*this, m_request_manager, m_tick_history), m_btc_price_manager(*this), m_fx_price_websocket_manager(*this), m_market_data_subscriptions( @@ -153,6 +156,18 @@ namespace optionx::platforms { return true; } + /// \brief Returns observed Intrade polling ticks from the in-memory archive. + /// \param request Inclusive broker-time range in milliseconds. + /// \param callback Callback receiving observations or a typed failure. + /// \return True when the request was accepted for local processing. + bool fetch_tick_history( + const TickHistoryRequest& request, + market_data::BaseMarketDataProvider::tick_history_callback_t callback) override { + if (!callback || !request.valid()) return false; + callback(m_tick_history.fetch(request)); + return true; + } + /// \brief Returns the live bar data callback. market_data::BaseMarketDataProvider::bars_callback_t& on_bar_data() override { return m_bar_data_callback; @@ -236,6 +251,7 @@ namespace optionx::platforms { intrade_bar::BalanceManager m_balance_manager; ///< Tracks account balance. intrade_bar::ActiveTradesSyncManager m_active_trades_sync_manager; ///< Syncs broker active trade snapshots. intrade_bar::TradingConditionManager m_trading_condition_manager; ///< Publishes current trading conditions. + intrade_bar::IntradeObservedTickHistory m_tick_history; ///< Bounded polling tick archive. intrade_bar::PriceManager m_price_manager; ///< Retrieves and updates price data. intrade_bar::BtcPriceManager m_btc_price_manager;///< Retrieves BTC/USDT quotes from the websocket stream. intrade_bar::FxPriceWebSocketManager m_fx_price_websocket_manager; ///< Retrieves FX quotes from websocket streams. diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp new file mode 100644 index 00000000..5c970f24 --- /dev/null +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -0,0 +1,255 @@ +#pragma once +#ifndef OPTIONX_HEADER_PLATFORMS_INTRADE_BAR_PLATFORM_OBSERVED_TICK_HISTORY_HPP_INCLUDED +#define OPTIONX_HEADER_PLATFORMS_INTRADE_BAR_PLATFORM_OBSERVED_TICK_HISTORY_HPP_INCLUDED + +/// \file ObservedTickHistory.hpp +/// \brief Defines the bounded in-memory history of Intrade polling observations. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../utils/fixed_point.hpp" +#include "../../utils/pubsub.hpp" +#include "../../data/ticks.hpp" +#include "../../data/events/PriceUpdateEvent.hpp" + +namespace optionx::platforms::intrade_bar { + + /// \struct IntradeObservedTickHistoryOptions + /// \brief Bounds the in-memory Intrade polling snapshot archive. + struct IntradeObservedTickHistoryOptions { + /// Maximum number of distinct observations retained per symbol. + std::size_t max_items_per_symbol = 10000; + + /// Maximum broker-time span accepted by one history query. + /// Zero disables this query-size limit. + std::uint64_t max_lookback_ms = 7ULL * 24ULL * 60ULL * 60ULL * 1000ULL; + + /// Expected granularity of Intrade polling timestamps. + /// It is used only to make a conservative completeness assertion. + std::uint64_t sampling_interval_ms = 1000; + + /// \brief Returns true when the archive can be used safely. + [[nodiscard]] bool valid() const noexcept { + return max_items_per_symbol > 0 && sampling_interval_ms > 0; + } + }; + + /// \class IntradeObservedTickHistory + /// \brief Stores bounded one-second Intrade polling observations. + /// + /// `/price_now` is a current-price snapshot rather than a broker history + /// endpoint. This archive makes observations available to consumers after + /// they have been collected during the current authenticated session. It + /// is intentionally non-persistent and never fabricates samples that were + /// not observed by the polling path. + class IntradeObservedTickHistory final { + public: + /// \brief Constructs an archive with bounded retention. + explicit IntradeObservedTickHistory( + IntradeObservedTickHistoryOptions options = {}) + : m_options(std::move(options)) {} + + /// \brief Records polling batches, preserving distinct same-second ticks. + /// \param batches Tick batches produced by the Intrade polling request. + void record(const std::vector& batches) { + std::lock_guard lock(m_mutex); + for (const auto& batch : batches) { + if (batch.symbol.empty()) continue; + + auto& history = m_symbols[batch.symbol]; + history.provider = batch.provider.empty() + ? "INTRADE_BAR" + : batch.provider; + history.price_digits = batch.price_digits; + history.volume_digits = batch.volume_digits; + + for (const auto& tick : batch.items) { + if (tick.time_ms == 0 || contains_observation(history, tick)) { + continue; + } + history.items.push_back(StoredTick{tick}); + } + prune(history); + } + } + + /// \brief Returns observations in an inclusive broker-time range. + /// \details `range_complete` is true only when the request is aligned + /// to the configured sampling interval and every expected + /// interval has an archived observation. An ordinary archive + /// miss therefore remains a successful but incomplete result. + [[nodiscard]] TickHistoryResult fetch( + const TickHistoryRequest& request) const { + if (!request.valid()) { + return TickHistoryResult::fail( + "Invalid Intrade observed tick history request."); + } + if (m_options.max_lookback_ms > 0 && + request.to_time_ms - request.from_time_ms > m_options.max_lookback_ms) { + return TickHistoryResult::fail( + "Intrade observed tick history request exceeds the configured lookback."); + } + + std::lock_guard lock(m_mutex); + TickSequence sequence; + sequence.symbol = request.symbol; + + const auto symbol_it = m_symbols.find(request.symbol); + if (symbol_it == m_symbols.end()) { + return TickHistoryResult::ok(std::move(sequence), false); + } + + const auto& history = symbol_it->second; + sequence.provider = history.provider; + sequence.price_digits = history.price_digits; + sequence.volume_digits = history.volume_digits; + sequence.ticks.reserve(history.items.size()); + for (const auto& stored : history.items) { + if (stored.tick.time_ms >= request.from_time_ms && + stored.tick.time_ms <= request.to_time_ms) { + sequence.ticks.push_back(stored.tick); + } + } + + std::stable_sort( + sequence.ticks.begin(), + sequence.ticks.end(), + [](const Tick& lhs, const Tick& rhs) { + return lhs.time_ms < rhs.time_ms; + }); + + const bool complete = has_complete_coverage( + history, + request.from_time_ms, + request.to_time_ms); + return TickHistoryResult::ok(std::move(sequence), complete); + } + + /// \brief Removes all observations collected by this archive. + void clear() noexcept { + std::lock_guard lock(m_mutex); + m_symbols.clear(); + } + + /// \brief Returns the number of retained observations for a symbol. + [[nodiscard]] std::size_t size(const std::string& symbol) const { + std::lock_guard lock(m_mutex); + const auto it = m_symbols.find(symbol); + return it == m_symbols.end() ? 0U : it->second.items.size(); + } + + /// \brief Returns the immutable archive options. + [[nodiscard]] const IntradeObservedTickHistoryOptions& options() const noexcept { + return m_options; + } + + private: + struct StoredTick { + Tick tick; + }; + + struct SymbolHistory { + std::deque items; + std::string provider; + std::uint32_t price_digits = 0; + std::uint32_t volume_digits = 0; + }; + + static bool same_market_observation( + const Tick& lhs, + const Tick& rhs) noexcept { + return lhs.ask == rhs.ask && + lhs.bid == rhs.bid && + lhs.last == rhs.last && + lhs.volume == rhs.volume && + lhs.time_ms == rhs.time_ms; + } + + static bool contains_observation( + const SymbolHistory& history, + const Tick& tick) noexcept { + return std::any_of( + history.items.begin(), + history.items.end(), + [&tick](const StoredTick& stored) { + return same_market_observation(stored.tick, tick); + }); + } + + void prune(SymbolHistory& history) { + const auto max_items = m_options.max_items_per_symbol; + while (history.items.size() > max_items) { + const auto oldest = std::min_element( + history.items.begin(), + history.items.end(), + [](const StoredTick& lhs, const StoredTick& rhs) { + return lhs.tick.time_ms < rhs.tick.time_ms; + }); + history.items.erase(oldest); + } + + if (m_options.max_lookback_ms == 0 || history.items.empty()) return; + + const auto newest = std::max_element( + history.items.begin(), + history.items.end(), + [](const StoredTick& lhs, const StoredTick& rhs) { + return lhs.tick.time_ms < rhs.tick.time_ms; + })->tick.time_ms; + const auto cutoff = newest > m_options.max_lookback_ms + ? newest - m_options.max_lookback_ms + : 0U; + history.items.erase( + std::remove_if( + history.items.begin(), + history.items.end(), + [cutoff](const StoredTick& stored) { + return stored.tick.time_ms < cutoff; + }), + history.items.end()); + } + + bool has_complete_coverage( + const SymbolHistory& history, + std::uint64_t from_time_ms, + std::uint64_t to_time_ms) const noexcept { + const auto interval = m_options.sampling_interval_ms; + if (interval == 0 || from_time_ms % interval != 0 || + to_time_ms % interval != 0) { + return false; + } + + std::uint64_t expected = from_time_ms; + while (true) { + const bool found = std::any_of( + history.items.begin(), + history.items.end(), + [expected](const StoredTick& stored) { + return stored.tick.time_ms == expected; + }); + if (!found) return false; + if (expected == to_time_ms) return true; + if (expected > std::numeric_limits::max() - interval) { + return false; + } + expected += interval; + } + } + + IntradeObservedTickHistoryOptions m_options; + mutable std::mutex m_mutex; + std::unordered_map m_symbols; + }; + +} // namespace optionx::platforms::intrade_bar + +#endif // OPTIONX_HEADER_PLATFORMS_INTRADE_BAR_PLATFORM_OBSERVED_TICK_HISTORY_HPP_INCLUDED diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/PriceManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/PriceManager.hpp index e2a21a42..e84e2ba5 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/PriceManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/PriceManager.hpp @@ -5,6 +5,8 @@ /// \file PriceManager.hpp /// \brief Defines the PriceManager class responsible for handling price updates and related events. +#include "ObservedTickHistory.hpp" + namespace optionx::platforms::intrade_bar { /// \class PriceManager @@ -21,8 +23,11 @@ namespace optionx::platforms::intrade_bar { /// \param request_manager Reference to the request manager for making HTTP requests. explicit PriceManager( BaseTradingPlatform& platform, - RequestManager& request_manager) - : BaseComponent(platform.event_bus()), m_request_manager(request_manager) { + RequestManager& request_manager, + IntradeObservedTickHistory& tick_history) + : BaseComponent(platform.event_bus()), + m_request_manager(request_manager), + m_tick_history(tick_history) { subscribe(); subscribe(); subscribe(); @@ -44,6 +49,7 @@ namespace optionx::platforms::intrade_bar { private: RequestManager& m_request_manager; ///< Reference to the request manager. + IntradeObservedTickHistory& m_tick_history; ///< Bounded polling observation archive. utils::TaskManager m_task_manager; ///< Task manager for handling asynchronous tasks. std::unordered_map m_ticks; ///< Latest tick payload by symbol. bool m_has_price_update = false; ///< Flag indicating whether a price update is in progress. @@ -84,12 +90,14 @@ namespace optionx::platforms::intrade_bar { LOGIT_0TRACE(); m_task_manager.shutdown(); m_ticks.clear(); + m_tick_history.clear(); } inline void PriceManager::handle_event(const events::DisconnectRequestEvent& event) { LOGIT_0TRACE(); m_task_manager.shutdown(); m_ticks.clear(); + m_tick_history.clear(); } inline void PriceManager::handle_event(const events::AccountInfoUpdateEvent& event) { @@ -112,6 +120,7 @@ namespace optionx::platforms::intrade_bar { LOGIT_0TRACE(); m_task_manager.shutdown(); m_ticks.clear(); + m_tick_history.clear(); } } @@ -144,6 +153,8 @@ namespace optionx::platforms::intrade_bar { task->set_period(time_shield::MS_PER_SEC); LOGIT_DEBUG("Intrade Bar price: snapshot received. batches=", batches.size()); + m_tick_history.record(batches); + for (auto& batch : batches) { for (auto& tick : batch.items) { auto it = m_ticks.find(batch.symbol); diff --git a/tests/intrade_observed_tick_history_test.cpp b/tests/intrade_observed_tick_history_test.cpp new file mode 100644 index 00000000..f6e4ce8d --- /dev/null +++ b/tests/intrade_observed_tick_history_test.cpp @@ -0,0 +1,143 @@ +#include + +#include +#include +#include +#include +#include + +#include + +using namespace optionx; +using namespace optionx::events; +using namespace optionx::platforms::intrade_bar; + +namespace { + +Tick make_tick( + double bid, + double ask, + std::uint64_t time_ms, + std::uint64_t received_ms = 0) { + return Tick( + ask, + bid, + 0.0, + 0.0, + time_ms, + received_ms, + 0); +} + +TickUpdateBatch make_batch( + std::string symbol, + std::initializer_list ticks) { + TickUpdateBatch batch; + batch.symbol = std::move(symbol); + batch.provider = "INTRADE_BAR"; + batch.price_digits = 5; + batch.volume_digits = 0; + batch.items.assign(ticks.begin(), ticks.end()); + return batch; +} + +} // namespace + +TEST(IntradeObservedTickHistory, ReturnsSortedInclusiveObservations) { + IntradeObservedTickHistory archive; + archive.record({make_batch( + "EURUSD", + {make_tick(1.1002, 1.1004, 3000), + make_tick(1.1000, 1.1002, 1000), + make_tick(1.1001, 1.1003, 2000)})}); + + const auto result = archive.fetch(TickHistoryRequest("EURUSD", 1000, 3000)); + + ASSERT_TRUE(result); + EXPECT_TRUE(result.range_complete); + ASSERT_EQ(result.sequence.ticks.size(), 3U); + EXPECT_EQ(result.sequence.ticks[0].time_ms, 1000U); + EXPECT_EQ(result.sequence.ticks[1].time_ms, 2000U); + EXPECT_EQ(result.sequence.ticks[2].time_ms, 3000U); + EXPECT_EQ(result.sequence.symbol, "EURUSD"); + EXPECT_EQ(result.sequence.provider, "INTRADE_BAR"); +} + +TEST(IntradeObservedTickHistory, DeduplicatesOnlyIdenticalMarketObservations) { + IntradeObservedTickHistory archive; + archive.record({make_batch( + "EURUSD", + {make_tick(1.1000, 1.1002, 1000, 10), + make_tick(1.1000, 1.1002, 1000, 20), + make_tick(1.1001, 1.1003, 1000, 30)})}); + + const auto result = archive.fetch(TickHistoryRequest("EURUSD", 1000, 1000)); + + ASSERT_TRUE(result); + ASSERT_EQ(result.sequence.ticks.size(), 2U); + EXPECT_DOUBLE_EQ(result.sequence.ticks[0].bid, 1.1000); + EXPECT_DOUBLE_EQ(result.sequence.ticks[1].bid, 1.1001); +} + +TEST(IntradeObservedTickHistory, CompletenessFailsClosedForMissingOrUnalignedSamples) { + IntradeObservedTickHistory archive; + archive.record({make_batch( + "EURUSD", + {make_tick(1.1000, 1.1002, 1000), + make_tick(1.1002, 1.1004, 3000)})}); + + const auto missing = archive.fetch(TickHistoryRequest("EURUSD", 1000, 3000)); + const auto unaligned = archive.fetch(TickHistoryRequest("EURUSD", 1001, 3000)); + + ASSERT_TRUE(missing); + EXPECT_FALSE(missing.range_complete); + ASSERT_TRUE(unaligned); + EXPECT_FALSE(unaligned.range_complete); +} + +TEST(IntradeObservedTickHistory, EnforcesItemAndLookbackBounds) { + IntradeObservedTickHistoryOptions options; + options.max_items_per_symbol = 2; + options.max_lookback_ms = 3000; + IntradeObservedTickHistory archive(options); + archive.record({make_batch( + "EURUSD", + {make_tick(1.1000, 1.1002, 1000), + make_tick(1.1001, 1.1003, 2000), + make_tick(1.1002, 1.1004, 3000)})}); + + EXPECT_EQ(archive.size("EURUSD"), 2U); + const auto retained = archive.fetch(TickHistoryRequest("EURUSD", 2000, 3000)); + const auto too_wide = archive.fetch(TickHistoryRequest("EURUSD", 1000, 5001)); + + ASSERT_TRUE(retained); + ASSERT_EQ(retained.sequence.ticks.size(), 2U); + EXPECT_TRUE(retained.range_complete); + EXPECT_FALSE(too_wide); +} + +TEST(IntradeObservedTickHistory, UnknownSymbolsRemainSuccessfulButIncomplete) { + IntradeObservedTickHistory archive; + + const auto result = archive.fetch(TickHistoryRequest("EURUSD", 1000, 1000)); + + ASSERT_TRUE(result); + EXPECT_FALSE(result.range_complete); + EXPECT_TRUE(result.sequence.ticks.empty()); + EXPECT_EQ(result.sequence.symbol, "EURUSD"); +} + +TEST(IntradeObservedTickHistory, ClearDropsSessionObservations) { + IntradeObservedTickHistory archive; + archive.record({make_batch("EURUSD", {make_tick(1.1000, 1.1002, 1000)})}); + ASSERT_EQ(archive.size("EURUSD"), 1U); + + archive.clear(); + + EXPECT_EQ(archive.size("EURUSD"), 0U); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From e2887431e8c2f0d7946ca9df6c8e2c781b63f24c Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 15:21:22 +0300 Subject: [PATCH 2/7] fix(intrade): use aggregate data include --- .../platforms/IntradeBarPlatform/ObservedTickHistory.hpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp index 5c970f24..62858c2d 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -16,10 +16,7 @@ #include #include -#include "../../utils/fixed_point.hpp" -#include "../../utils/pubsub.hpp" -#include "../../data/ticks.hpp" -#include "../../data/events/PriceUpdateEvent.hpp" +#include namespace optionx::platforms::intrade_bar { From cdd59d104f05171aa0fbbe0044e82b6ecdc136e0 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 15:25:14 +0300 Subject: [PATCH 3/7] fix(intrade): narrow observed history includes --- .../platforms/IntradeBarPlatform/ObservedTickHistory.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp index 62858c2d..b0fb9dff 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -16,7 +16,9 @@ #include #include -#include +#include +#include +#include namespace optionx::platforms::intrade_bar { From 3ac8f5c8484de852eb4a014b0571f0cb2181eedb Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 15:27:50 +0300 Subject: [PATCH 4/7] fix(intrade): include tick precision dependency --- .../platforms/IntradeBarPlatform/ObservedTickHistory.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp index b0fb9dff..3838d3b8 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include From e575aafafc34fe46fe91d8b243a5291db6a7595b Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 17:11:47 +0300 Subject: [PATCH 5/7] fix(intrade): keep observed history dependencies in platform umbrella --- .../platforms/IntradeBarPlatform/ObservedTickHistory.hpp | 5 ----- tests/intrade_observed_tick_history_test.cpp | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp index 3838d3b8..2a8ae9af 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -16,11 +16,6 @@ #include #include -#include -#include -#include -#include - namespace optionx::platforms::intrade_bar { /// \struct IntradeObservedTickHistoryOptions diff --git a/tests/intrade_observed_tick_history_test.cpp b/tests/intrade_observed_tick_history_test.cpp index f6e4ce8d..e0065d71 100644 --- a/tests/intrade_observed_tick_history_test.cpp +++ b/tests/intrade_observed_tick_history_test.cpp @@ -6,7 +6,7 @@ #include #include -#include +#include using namespace optionx; using namespace optionx::events; From e14772111a429b6a3100f9c3d224bb67c2267588 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 17:54:24 +0300 Subject: [PATCH 6/7] docs: clarify aggregate include ownership --- AGENTS.md | 27 +++++++++++++++++++++++++++ guides/api-and-header-contracts.md | 22 ++++++++++++++++++++++ guides/build-and-test.md | 3 +-- guides/coding-style.md | 4 ++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23aa8339..a16860ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,33 @@ - [Commit conventions](guides/commit-conventions.md) - формат коммитов, если пользователь просит создать commit. +## Header Ownership And Include Context + +Before editing a header under `include/optionx_cpp`, classify it as a +supported public entry point or an internal leaf: + +- Supported public entry points are the aggregate/facade headers listed in + `guides/api-and-header-contracts.md`. Headers under paths such as + `platforms//`, `market_data/`, and `data/*` are internal leaves + unless the documentation explicitly promotes them. +- An internal leaf is not a standalone include target. Do not add a project + cross-domain include, a `../` path, or a broad aggregate merely to make the + leaf compile in isolation. A leaf may use standard-library, third-party, + and same-family dependencies supplied by its owning domain. +- The nearest owning aggregate/facade owns the complete cross-domain include + closure and its order. Add prerequisites there, before including the leaf. + For example, `platforms.hpp` prepares `utils.hpp`, `data.hpp`, + `components.hpp`, and the platform contracts before including + `platforms/IntradeBarPlatform.hpp`; that context transitively supplies + `platforms/IntradeBarPlatform/ObservedTickHistory.hpp`. +- Tests and examples that verify the include contract must include the same + supported aggregate used by consumers. Do not use a direct leaf include as + an aggregate/include-policy test. A direct leaf test is valid only when the + leaf is intentionally documented and tested as self-contained. +- When the ownership is unclear, inspect the owning aggregate and its include + order first, then verify the chosen public path with an aggregate consumer + compile before changing a leaf include. + ## Critical Defaults - Перед правками проверь `git status --short` и не перетирай чужие изменения. diff --git a/guides/api-and-header-contracts.md b/guides/api-and-header-contracts.md index f36b3f03..dc42f79f 100644 --- a/guides/api-and-header-contracts.md +++ b/guides/api-and-header-contracts.md @@ -70,6 +70,28 @@ `include`. Он совпадает с consumer contract `` и не маскирует неверные cross-domain quoted includes во вложенных headers. +### Internal Leaf Include Context + +Classify a header before adding a project include. A supported public +aggregate/facade owns the include closure for its internal leaves; a leaf is +not automatically a standalone include target. + +- Headers under `platforms//`, `market_data/`, and `data/*` are + internal leaves unless this document explicitly lists them as public entry + points. +- Do not add a cross-domain project include, `../` path, or broad aggregate to + an internal leaf solely to make a direct include compile. Put the prerequisite + in the owning aggregate, in the intended order, using the installed + `` spelling for cross-domain edges. +- For example, `platforms.hpp` prepares shared `utils`, `data`, `components`, + and platform prerequisites before including `platforms/IntradeBarPlatform.hpp`. + The latter then includes `platforms/IntradeBarPlatform/ObservedTickHistory.hpp` + through the prepared context. +- Include-contract tests and examples must include the supported aggregate + (`` in this case), not the internal leaf. A direct + leaf test is appropriate only when standalone leaf compilation is an + intentional documented contract. + ## Header-Only Ownership `optionx_cpp` - header-only C++17 библиотека. Большая часть публичной diff --git a/guides/build-and-test.md b/guides/build-and-test.md index 3943972c..9da59fdd 100644 --- a/guides/build-and-test.md +++ b/guides/build-and-test.md @@ -191,8 +191,7 @@ destination `OptionX` folders. обновляй тест, который подключает intended public entry point: ```cpp -#include -#include +#include ``` Direct leaf includes допустимы для white-box tests only when that domain diff --git a/guides/coding-style.md b/guides/coding-style.md index f9267e6e..e8259fe0 100644 --- a/guides/coding-style.md +++ b/guides/coding-style.md @@ -26,6 +26,10 @@ prefix, for example `` or ``. Do not use quoted `"optionx_cpp/..."` paths and do not rely on `include/optionx_cpp` as an additional include root. +- This cross-domain rule applies to supported aggregates and facades. Internal + leaves receive that context from their owner and must not add a project + cross-domain include, `../` path, or broad aggregate solely to compile in + isolation. - Prefer the nearest aggregate header for public-domain and cross-domain dependencies; do not rebuild aggregate include order inside leaf DTO headers. - Do not use `../` in `#include` directives. From 6d2f8978eabcc773de0ca769b85b0c1f15b97419 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Mon, 7 Sep 2026 19:01:03 +0300 Subject: [PATCH 7/7] perf(intrade): scan observed history coverage once --- .../ObservedTickHistory.hpp | 20 ++++++++++--------- tests/intrade_observed_tick_history_test.cpp | 6 ++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp index 2a8ae9af..ea5326b7 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -123,7 +123,7 @@ namespace optionx::platforms::intrade_bar { }); const bool complete = has_complete_coverage( - history, + sequence.ticks, request.from_time_ms, request.to_time_ms); return TickHistoryResult::ok(std::move(sequence), complete); @@ -214,7 +214,7 @@ namespace optionx::platforms::intrade_bar { } bool has_complete_coverage( - const SymbolHistory& history, + const std::vector& ticks, std::uint64_t from_time_ms, std::uint64_t to_time_ms) const noexcept { const auto interval = m_options.sampling_interval_ms; @@ -223,15 +223,17 @@ namespace optionx::platforms::intrade_bar { return false; } + std::size_t tick_index = 0; std::uint64_t expected = from_time_ms; while (true) { - const bool found = std::any_of( - history.items.begin(), - history.items.end(), - [expected](const StoredTick& stored) { - return stored.tick.time_ms == expected; - }); - if (!found) return false; + while (tick_index < ticks.size() && + ticks[tick_index].time_ms < expected) { + ++tick_index; + } + if (tick_index == ticks.size() || + ticks[tick_index].time_ms != expected) { + return false; + } if (expected == to_time_ms) return true; if (expected > std::numeric_limits::max() - interval) { return false; diff --git a/tests/intrade_observed_tick_history_test.cpp b/tests/intrade_observed_tick_history_test.cpp index e0065d71..fc8170fd 100644 --- a/tests/intrade_observed_tick_history_test.cpp +++ b/tests/intrade_observed_tick_history_test.cpp @@ -48,6 +48,7 @@ TEST(IntradeObservedTickHistory, ReturnsSortedInclusiveObservations) { archive.record({make_batch( "EURUSD", {make_tick(1.1002, 1.1004, 3000), + make_tick(1.1003, 1.1005, 2000), make_tick(1.1000, 1.1002, 1000), make_tick(1.1001, 1.1003, 2000)})}); @@ -55,10 +56,11 @@ TEST(IntradeObservedTickHistory, ReturnsSortedInclusiveObservations) { ASSERT_TRUE(result); EXPECT_TRUE(result.range_complete); - ASSERT_EQ(result.sequence.ticks.size(), 3U); + ASSERT_EQ(result.sequence.ticks.size(), 4U); EXPECT_EQ(result.sequence.ticks[0].time_ms, 1000U); EXPECT_EQ(result.sequence.ticks[1].time_ms, 2000U); - EXPECT_EQ(result.sequence.ticks[2].time_ms, 3000U); + EXPECT_EQ(result.sequence.ticks[2].time_ms, 2000U); + EXPECT_EQ(result.sequence.ticks[3].time_ms, 3000U); EXPECT_EQ(result.sequence.symbol, "EURUSD"); EXPECT_EQ(result.sequence.provider, "INTRADE_BAR"); }