From 2ea2707b5c14ff34f0059a02d2d60465c947fe7d Mon Sep 17 00:00:00 2001 From: LimiNode <20008174+LimiNode@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:37:40 +0300 Subject: [PATCH 1/3] docs(intrade): record legacy quote history contract --- guides/trade-check2-research.md | 91 ++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/guides/trade-check2-research.md b/guides/trade-check2-research.md index afae107..6bc2de4 100644 --- a/guides/trade-check2-research.md +++ b/guides/trade-check2-research.md @@ -124,6 +124,61 @@ request. `TickHistoryResult::range_complete` is an explicit provider proof, not an inference from the number of returned ticks. Intrade implements this contract from the `/price_now` archive, not from `trade_check2.php`. +## Legacy `/quotes` Tick-Check Evidence + +The older `mega-connector` implementation contains a separate authenticated +trade-correction flow for the broker's quote-check page. It is not the same +request as `trade_check2.php` and it is not a generic public history API. + +The request is: + +```text +POST /quotes +Content-Type: application/x-www-form-urlencoded + +option= +date= +time1= +time2= +name_method=data_tick_load +``` + +The response is HTML. The parser walks repeated table rows using: + +```html +date and time +first quote cell +selected quote cell +``` + +It converts the row timestamp to a Unix timestamp and subtracts a hard-coded +`GMT+3` offset. The first quote cell is ignored and the second quote cell is +used as the selected value. The production caller requests a window beginning at the +stored trade open time and ending at the stored close time plus one minute. It +requires exact entries for both the open and close seconds before it compares +the broker-returned prices and may change a reported loss or standoff to a win +or standoff. + +This establishes that the old quote-check page can expose second-level rows, +but the implementation does not establish a complete history contract: + +- the request has minute-resolution boundaries and sends one date field, so a + range crossing local midnight is not represented safely; +- the client has no server-side maximum-range declaration and no cursor; +- the parser marks the request successful after parsing any usable rows and + does not prove one row per second or reject gaps; +- the fixed `GMT+3` conversion is an implementation assumption, not a + broker-declared timezone contract; +- the trade-correction caller treats missing open or close rows as failure, + but it does not expose a reusable completeness result to market-data users. + +Unauthenticated probes of the current primary and numbered legacy domains +returned an authentication/redirect response, so the server's retention limit +for `/quotes` and its behaviour for future dates remain unverified. The old +source code contains no evidence for a one- or two-day limit. These questions +require a read-only authenticated probe using a closed trade or a dedicated +quote-check page request; no new trades are needed. + ## Provenance Boundary | Data | What it can establish | What it cannot establish | @@ -180,9 +235,30 @@ requests with `limit=300` and `to` set to the oldest loaded candle. A request with `limit=5` returned six aggregates in one observation, so `limit` must be treated as a broker hint until its exact boundary semantics are established. -The SPA also exposes `/ajax/instrument/history`, which returns a mapping from -instrument ID to `[timestamp_ms, price]` pairs for asset previews. It is a -different response contract and is not a tick-history completeness proof. +The current SPA bundle constructs this request with `to` as an ISO timestamp +when paging from an epoch-second cursor. In the unauthenticated live probe on +2026-10-28, a `limit=5` request returned six aggregates. Supplying an old ISO +`to` value returned five aggregates near that historical cursor plus an extra +aggregate at the current live edge. Supplying a future `to` within roughly a +week returned current-edge aggregates, while a farther future cursor returned +an empty list. This means callers must filter every aggregate to the requested +inclusive range and must treat future-date results as non-authoritative. + +The same bundle calls the separate preview endpoint as: + +```text +GET /ajax/instrument/history + instruments=[] + wow= + period= + points= +``` + +Its response is a mapping from instrument ID to sampled +`[timestamp_ms, price]` pairs. A public probe returned ten points for ten +requested points, but the endpoint did not expose range or completeness +metadata. It is therefore not equivalent to either the legacy second-level +HTML table or the chart-history OHLC endpoint. The loaded application bundles did not contain a dedicated quote-check or trade-history chart endpoint. The chart code fetches candles by instrument and @@ -195,11 +271,14 @@ Therefore the current evidence supports this narrower conclusion: - the broker exposes a useful aggregate OHLC history API for the modern SPA; - it is not a raw tick source and does not expose a `range_complete` field; +- the legacy `/quotes` flow is the only inspected path that is shaped like a + second-level quote-check table, and its retention/future-date rules are not + yet measured; - it does not prove that a chart around a trade is a special trade-scoped quote-check request; -- exact retention, maximum range, timezone, missing-candle semantics, and - price-source semantics still require authenticated UI replay with a real - closed trade. +- exact `/quotes` retention, `/quotes` future-date semantics, timezone, + missing-candle semantics, and price-source semantics still require + authenticated UI replay with a real closed trade. No runtime provider should be implemented from this observation alone. If the authenticated replay confirms the same contract, it is a candidate for a From 13f0941a22f5e7755b190e5556d077cad1dd8d13 Mon Sep 17 00:00:00 2001 From: LimiNode <20008174+LimiNode@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:06:06 +0300 Subject: [PATCH 2/3] docs(intrade): define execution-second quote checks --- guides/trade-check2-research.md | 35 +++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/guides/trade-check2-research.md b/guides/trade-check2-research.md index 6bc2de4..dc793f6 100644 --- a/guides/trade-check2-research.md +++ b/guides/trade-check2-research.md @@ -130,6 +130,12 @@ The older `mega-connector` implementation contains a separate authenticated trade-correction flow for the broker's quote-check page. It is not the same request as `trade_check2.php` and it is not a generic public history API. +For this broker, the relevant contract is the execution-second grid: the +broker fixes trade opening and closing on whole seconds, so one broker value +per second is the authoritative granularity needed to verify a trade. This is +not a claim that the chart has no sub-second market updates; those updates are +outside the trade-settlement contract. + The request is: ```text @@ -159,8 +165,8 @@ requires exact entries for both the open and close seconds before it compares the broker-returned prices and may change a reported loss or standoff to a win or standoff. -This establishes that the old quote-check page can expose second-level rows, -but the implementation does not establish a complete history contract: +This establishes the source needed for broker trade verification. It does not +make the endpoint an unlimited raw-market-event archive: - the request has minute-resolution boundaries and sends one date field, so a range crossing local midnight is not represented safely; @@ -172,6 +178,12 @@ but the implementation does not establish a complete history contract: - the trade-correction caller treats missing open or close rows as failure, but it does not expose a reusable completeness result to market-data users. +The OptionX implementation should therefore model this as a dedicated +broker-execution quote-check source. It may provide authoritative values for +known trade open/close seconds, while the generic `TickHistory` contract must +continue to distinguish it from session-observed `/price_now` data and from +unbounded raw market micro-events. + Unauthenticated probes of the current primary and numbered legacy domains returned an authentication/redirect response, so the server's retention limit for `/quotes` and its behaviour for future dates remain unverified. The old @@ -185,6 +197,7 @@ quote-check page request; no new trades are needed. | --- | --- | --- | | Broker-observed `/price_now` tick | A symbol/price snapshot with broker time and local receipt time was observed | Complete micro-event history or observations before the session/archive window | | Local `/price_now` archive | Retained observed snapshots and, for Intrade, proven one-second archive coverage | A persistent broker archive or coverage outside retained observations | +| Authenticated `/quotes` quote-check row | The broker's execution-second value for a requested symbol/time row | Sub-second market events, unlimited retention, or coverage outside the returned rows | | `trade_check2.php` result | A price/profit response associated with one known broker trade ID | Exact price semantics, independent observation timestamp, range coverage, or absence of other ticks | | `TradeRecord` history | Closed-trade lifecycle facts and stored open/close fields | A quote stream, intermediate ticks, or dense coverage | | Authoritative historical tick range | Only a provider API that explicitly supplies timestamped observations and completeness semantics | Not provided by the currently observed `trade_check2.php` contract | @@ -193,7 +206,7 @@ quote-check page request; no new trades are needed. A read-only inspection of the current `trading.intrade.bar` SPA found a separate chart-history API. This is evidence for aggregate market history; it -does not change the `trade_check2.php` decision above. +does not replace the broker's `/quotes` execution-second contract. The main observed request is: @@ -271,19 +284,21 @@ Therefore the current evidence supports this narrower conclusion: - the broker exposes a useful aggregate OHLC history API for the modern SPA; - it is not a raw tick source and does not expose a `range_complete` field; -- the legacy `/quotes` flow is the only inspected path that is shaped like a - second-level quote-check table, and its retention/future-date rules are not - yet measured; +- the legacy `/quotes` flow is the broker-specific authoritative source for + execution-second quote checks; +- `/ajax/instrument/history` is a sampled preview endpoint and does not + provide a usable second-level trade-check history; - it does not prove that a chart around a trade is a special trade-scoped quote-check request; - exact `/quotes` retention, `/quotes` future-date semantics, timezone, missing-candle semantics, and price-source semantics still require authenticated UI replay with a real closed trade. -No runtime provider should be implemented from this observation alone. If the -authenticated replay confirms the same contract, it is a candidate for a -separate provider-aligned bar-history adapter, not for `fetch_tick_history()` -or for replacing `trade_check2.php`. +The next runtime feature should be a dedicated `/quotes` trade-check adapter +that returns broker execution-second observations with explicit provenance and +gap status. It must not be implemented as a replacement for `trade_check2.php` +or as a claim of complete sub-second market history. The modern +`chart-history` endpoint remains a separate minute-bar source. ## Hypothesis Review From c2ad14893061a82926069504384853445519fec3 Mon Sep 17 00:00:00 2001 From: LimiNode <20008174+LimiNode@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:31:09 +0300 Subject: [PATCH 3/3] feat(intrade): add broker historical tick source --- guides/api-and-header-contracts.md | 25 +- guides/market-data-router.md | 32 +- guides/market-data-router.ru.md | 33 +- guides/platform-api-guide.md | 12 +- guides/trade-check2-research.md | 134 +-- include/optionx_cpp/data/ticks/Tick.hpp | 23 +- .../data/ticks/TickHistoryRequest.hpp | 25 +- .../optionx_cpp/data/ticks/TickSequence.hpp | 6 +- .../MarketDataContinuityService.hpp | 2 +- .../market_data/detail/MarketDataRouter.ipp | 5 + .../platforms/IntradeBarPlatform.hpp | 73 +- .../IntradeBarPlatform/ApiResponses.hpp | 3 + .../ObservedTickHistory.hpp | 2 + .../IntradeBarPlatform/QuoteCheck.hpp | 99 +++ .../IntradeBarPlatform/RequestManager.hpp | 238 ++++++ .../IntradeBarPlatform/http_parsers.hpp | 170 +++- tests/intrade_auth_lifecycle_test.cpp | 40 + tests/intrade_bar_api/CLI.md | 17 + .../IntradeBarSmokeSupport.hpp | 29 + tests/intrade_bar_api/README.md | 1 + .../intrade_bar_api/intrade_bar_smoke_cli.cpp | 88 ++ tests/intrade_observed_tick_history_test.cpp | 1 + tests/intrade_quote_check_parser_test.cpp | 762 ++++++++++++++++++ tests/market_data_tick_continuity_test.cpp | 33 + ...market_data_tick_history_contract_test.cpp | 40 +- 25 files changed, 1803 insertions(+), 90 deletions(-) create mode 100644 include/optionx_cpp/platforms/IntradeBarPlatform/QuoteCheck.hpp create mode 100644 tests/intrade_quote_check_parser_test.cpp diff --git a/guides/api-and-header-contracts.md b/guides/api-and-header-contracts.md index fb0e533..cda8c39 100644 --- a/guides/api-and-header-contracts.md +++ b/guides/api-and-header-contracts.md @@ -299,19 +299,22 @@ Contract rules: 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. + provider/consumer policy. `TickHistoryRequest::source` selects one source for + the operation; providers must not silently fall back to or merge another + source. `TickSequence::source` reports the source that was actually used. - `BaseMarketDataProvider::provider_time_ms()` and `BaseMarketDataProvider::tick_history_interval_ms()` are optional metadata hooks for providers whose history backend has its own clock or sampling grid. A zero value means that Router should use its local clock or event-oriented range semantics. -### Intrade observed tick history +### Intrade tick history sources -`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: +`platforms::IntradeBarPlatform::fetch_tick_history()` supports two explicit, +isolated sources. `LOCAL_OBSERVED` is the default and reads the bounded +in-memory archive populated by `/price_now`. `BROKER_HISTORICAL` uses the +authenticated `/quotes` execution-second history. There is no fallback or +automatic merge between them. - `Tick::time_ms` keeps the broker timestamp, whose effective granularity is one second. @@ -324,6 +327,16 @@ broker: - `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. +- Broker history returns one neutral `Tick::reference` value per available + whole-second timestamp; bid, ask, and last remain unspecified. Its + `range_complete=true` means every whole-second timestamp inside the original + inclusive request is present, not that every sub-second market event is + represented. +- Broker history uses the authenticated `/quotes` GET contract, limits each + broker request to 30 minutes, splits at broker-local midnight, rejects known + future-period and login HTML responses returned with HTTP 200, and reports + `TickSequence::source=BROKER_HISTORICAL`. The public request is conservatively + limited to two broker-local dates. - `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 diff --git a/guides/market-data-router.md b/guides/market-data-router.md index 4e595e2..011cc45 100644 --- a/guides/market-data-router.md +++ b/guides/market-data-router.md @@ -634,7 +634,11 @@ live-only routes without treating them as continuity-verified. The provider contract now has a separate `fetch_tick_history(...)` operation: ```cpp -md::TickHistoryRequest request("EURUSD", 1700000000000, 1700000060000); +md::TickHistoryRequest request( + "EURUSD", + 1700000000000, + 1700000060000, + md::TickHistorySource::BROKER_HISTORICAL); provider.fetch_tick_history( request, [](md::TickHistoryResult result) { @@ -656,6 +660,12 @@ The adapter validates symbol, range, and ordering. Exact duplicate handling and any provider sequence identity remain a consumer/provider policy; the base `Tick` DTO currently has no universal sequence field. +The source is selected per request. `LOCAL_OBSERVED` is the backward-compatible +default; `BROKER_HISTORICAL` asks the provider for its broker archive. Providers +must reject an unsupported source rather than silently falling back or merging +sources. A successful `TickSequence::source` identifies the source actually +used, independently from `TickSequence::provider`. + `MarketDataContinuityService::request_tick_history_batch()` is a thin adapter for providers that implement the operation. It validates symbol, range, and ordering, converts `TickSequence` into a `TickDataBatch`, and marks items as @@ -674,11 +684,11 @@ service.request_tick_history_batch( true); // require_complete_range ``` -`MarketDataRouter` also applies the history contract to tick routes. The -Intrade Bar provider implements `fetch_tick_history()` as a bounded, -session-scoped archive of observed `/price_now` snapshots. This is useful for -short prefill and reconnect windows, but it is not an authoritative broker tick -archive: +`MarketDataRouter` also applies the history contract to tick routes. Its +generated requests use the backward-compatible `LOCAL_OBSERVED` default. The +Intrade Bar provider also accepts explicit `BROKER_HISTORICAL` requests backed +by `/quotes`; those observations are not automatically mixed with the bounded, +session-scoped `/price_now` archive: - broker timestamps have one-second granularity; - the archive starts empty for a new authenticated session and evicts old data; @@ -688,6 +698,16 @@ archive: snapshots are removed; - `trade_check2.php` remains a settlement/trade-result API and is not used for range history. +- `/quotes` supplies one broker-published value on a whole-second grid. That + value uses `Tick::reference` because bid/ask/last semantics are not proven; + complete coverage means every requested grid timestamp is present, not every + sub-second market event. The authenticated request is a GET with + `symbol/date/from/to`. The current authenticated page accepted exactly 30 + minutes (`1,801` rows) and rejected 30 minutes plus one second with its + visible maximum-period validation page and no quote rows. Longer ranges are + split by the provider at non-overlapping whole-second boundaries and local + midnight. Future periods are returned as broker validation failures, not as + empty history. Providers may expose optional history-clock metadata through `BaseMarketDataProvider::provider_time_ms()` and diff --git a/guides/market-data-router.ru.md b/guides/market-data-router.ru.md index bf477b7..ddad11d 100644 --- a/guides/market-data-router.ru.md +++ b/guides/market-data-router.ru.md @@ -785,7 +785,11 @@ if (state && state->enabled) { В provider contract появилась отдельная операция `fetch_tick_history(...)`: ```cpp -md::TickHistoryRequest request("EURUSD", 1700000000000, 1700000060000); +md::TickHistoryRequest request( + "EURUSD", + 1700000000000, + 1700000060000, + md::TickHistorySource::BROKER_HISTORICAL); provider.fetch_tick_history( request, [](md::TickHistoryResult result) { @@ -808,6 +812,13 @@ timestamps разрешены: поток тиков событийный, а н конкретного provider/consumer; у базового `Tick` сейчас нет универсального sequence field. +Источник выбирается для каждого запроса отдельно. `LOCAL_OBSERVED` является +обратно совместимым значением по умолчанию, а `BROKER_HISTORICAL` запрашивает +архив брокера. Provider обязан отклонить неподдерживаемый источник, а не делать +скрытый fallback или объединять источники. В успешном результате +`TickSequence::source` указывает фактически использованный источник независимо +от `TickSequence::provider`. + `MarketDataContinuityService::request_tick_history_batch()` — тонкий adapter для provider, который реализует операцию. Он проверяет symbol, range и order, превращает `TickSequence` в `TickDataBatch` и помечает items как `HISTORICAL` @@ -826,11 +837,11 @@ service.request_tick_history_batch( true); // require_complete_range ``` -`MarketDataRouter` теперь также применяет history contract к tick routes. -Intrade Bar реализует `fetch_tick_history(...)` через ограниченный -session-scoped архив наблюдаемых snapshots из `/price_now`. Это полезно для -короткого prefill и reconnect recovery, но не является authoritative broker -tick archive: +`MarketDataRouter` также применяет history contract к tick routes. Создаваемые +им запросы используют обратно совместимый источник `LOCAL_OBSERVED`. Intrade +Bar дополнительно принимает явный `BROKER_HISTORICAL`, основанный на `/quotes`; +эти наблюдения автоматически не объединяются с ограниченным session-scoped +архивом `/price_now`: - broker timestamps имеют гранулярность в одну секунду; - архив пуст для новой authenticated session и вытесняет старые данные; @@ -840,6 +851,16 @@ tick archive: snapshots удаляются; - `trade_check2.php` остаётся settlement/trade-result API и не используется для range history. +- `/quotes` возвращает одно опубликованное брокером значение на секундной + сетке. Оно хранится в `Tick::reference`, поскольку семантика bid/ask/last не + подтверждена; полное покрытие означает наличие каждой запрошенной секундной + отметки, а не каждого внутрисекундного события рынка. Аутентифицированный + запрос использует GET-параметры `symbol/date/from/to`. Текущая + аутентифицированная страница приняла ровно 30 минут (`1 801` строку) и + отклонила 30 минут плюс одну секунду страницей проверки максимального + периода без строк котировок. Более длинные диапазоны провайдер разбивает по + неперекрывающимся целым секундам и локальной полуночи. Будущий период + возвращается как ошибка валидации брокера, а не как пустая история. Провайдер может дополнительно реализовать метаданные времени через `BaseMarketDataProvider::provider_time_ms()` и diff --git a/guides/platform-api-guide.md b/guides/platform-api-guide.md index f2fc83d..ab99cdf 100644 --- a/guides/platform-api-guide.md +++ b/guides/platform-api-guide.md @@ -132,6 +132,9 @@ Subscription rules: - `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. + `TickHistoryRequest::source` explicitly selects one history source and + `TickSequence::source` reports the source used. Providers do not silently + fall back to or merge another source. - `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 @@ -151,8 +154,13 @@ Subscription rules: recovery. Tick routes can use `TickSubscriptionRequest::continuity` with the same history-first lifecycle when the provider implements `fetch_tick_history()`. -- Router continuity supports bars and tick routes. Intrade additionally exposes - a bounded, session-scoped observed-tick archive populated by `/price_now`. +- Router continuity supports bars and tick routes. Intrade exposes a bounded, + session-scoped `LOCAL_OBSERVED` archive populated by `/price_now` and an + explicit `BROKER_HISTORICAL` source backed by the authenticated `/quotes` GET + contract. Each broker request is limited to 30 minutes; longer ranges are + split by the provider. + The local archive remains the default, so Router-generated requests preserve + their existing behaviour. The sources are never merged automatically. 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 diff --git a/guides/trade-check2-research.md b/guides/trade-check2-research.md index dc793f6..dffbfc8 100644 --- a/guides/trade-check2-research.md +++ b/guides/trade-check2-research.md @@ -120,9 +120,11 @@ that every broker micro-event was captured. ### Generic Tick History Contract `TickHistoryRequest` is an inclusive `(symbol, from_time_ms, to_time_ms)` -request. `TickHistoryResult::range_complete` is an explicit provider proof, -not an inference from the number of returned ticks. Intrade implements this -contract from the `/price_now` archive, not from `trade_check2.php`. +request with an explicit source. `LOCAL_OBSERVED` preserves the existing +`/price_now` archive behaviour and remains the default. `BROKER_HISTORICAL` +selects the authenticated `/quotes` adapter. There is no automatic fallback or +merging between the sources. `TickHistoryResult::range_complete` is an explicit +provider proof, not an inference from the number of returned ticks. ## Legacy `/quotes` Tick-Check Evidence @@ -136,60 +138,94 @@ per second is the authoritative granularity needed to verify a trade. This is not a claim that the chart has no sub-second market updates; those updates are outside the trade-settlement contract. -The request is: +The current authenticated browser request is: ```text -POST /quotes -Content-Type: application/x-www-form-urlencoded - -option= -date= -time1= -time2= -name_method=data_tick_load +GET /quotes?symbol=EUR%2FUSD&date=2026-10-30&from=22%3A30%3A00&to=22%3A45%3A00 + +symbol= +date= +from= +to= ``` -The response is HTML. The parser walks repeated table rows using: +The response is HTML. The current page describes the supported range as one +price per second for currencies and metals, with a maximum period of 30 +minutes per request. Its table rows have the following shape: ```html -date and time -first quote cell -selected quote cell +22:30:00bidpriceask ``` -It converts the row timestamp to a Unix timestamp and subtracts a hard-coded -`GMT+3` offset. The first quote cell is ignored and the second quote cell is -used as the selected value. The production caller requests a window beginning at the -stored trade open time and ending at the stored close time plus one minute. It -requires exact entries for both the open and close seconds before it compares -the broker-returned prices and may change a reported loss or standoff to a win -or standoff. +The current response contains only the local time in the first cell; the date +comes from the request's `date` parameter. Older responses containing a full +date and time remain accepted for compatibility. OptionX uses the middle +`Price` cell as the neutral `Tick::reference` value. Timestamps are converted +from the broker's observed UTC+3 clock to Unix time. The production caller +requests a window beginning at the stored trade open time and ending at the +stored close time plus one minute. It requires exact entries for both the open +and close seconds before it compares the broker-returned prices and may change +a reported loss or standoff to a win or standoff. This establishes the source needed for broker trade verification. It does not make the endpoint an unlimited raw-market-event archive: -- the request has minute-resolution boundaries and sends one date field, so a - range crossing local midnight is not represented safely; -- the client has no server-side maximum-range declaration and no cursor; -- the parser marks the request successful after parsing any usable rows and - does not prove one row per second or reject gaps; +- one request accepts at most 30 minutes and one local date, so longer ranges + must be split into consecutive requests and ranges crossing local midnight + must use separate dates; +- future periods are rejected explicitly and must not be interpreted as empty + history; +- the page states that quotes are retained for several days, but does not + publish an exact retention boundary or a cursor; - the fixed `GMT+3` conversion is an implementation assumption, not a broker-declared timezone contract; - the trade-correction caller treats missing open or close rows as failure, but it does not expose a reusable completeness result to market-data users. -The OptionX implementation should therefore model this as a dedicated -broker-execution quote-check source. It may provide authoritative values for -known trade open/close seconds, while the generic `TickHistory` contract must -continue to distinguish it from session-observed `/price_now` data and from -unbounded raw market micro-events. - -Unauthenticated probes of the current primary and numbered legacy domains -returned an authentication/redirect response, so the server's retention limit -for `/quotes` and its behaviour for future dates remain unverified. The old -source code contains no evidence for a one- or two-day limit. These questions -require a read-only authenticated probe using a closed trade or a dedicated -quote-check page request; no new trades are needed. +OptionX keeps the broker-specific HTML and request quirks in the internal +`BrokerQuoteCheck` adapter, but exposes the observations through the common +`IntradeBarPlatform::fetch_tick_history()` operation when the request selects +`BROKER_HISTORICAL`. The adapter sends the authenticated `/quotes` GET on the +platform owner loop, limits each request to 30 minutes, splits ranges at +broker-local midnight, and merges the second-aligned rows. The public request +is conservatively limited to two broker-local dates until a larger retention +policy is explicitly needed. + +The returned `TickSequence` keeps `provider=INTRADE_BAR` and reports the actual +source separately as `BROKER_HISTORICAL`. Because the selected quote column has +not been proven to be bid, ask, or last, its value is stored in the neutral +`Tick::reference` field. For partial-second requests the adapter returns only +whole-second timestamps inside the original inclusive range; it does not +fabricate rounded output timestamps. + +For this source, `range_complete=true` means that every broker whole-second +timestamp inside the requested range was returned without malformed rows. It +does not prove that every sub-second market event is represented. A valid HTTP +200 response with no rows is successful but incomplete when the range contains +an expected second. Known login HTML, unrecognized non-empty HTML, malformed +rows, transport failures, and DDoS responses are failures rather than empty +history. No fallback to `LOCAL_OBSERVED` is attempted. This integration does +not change trade settlement or correction decisions. + +An authenticated browser capture and read-only smoke confirmed the current +contract and its visible range boundary. A completed 15-minute EUR/USD request +returned 901 rows, and a completed one-hour request returned 3,601 rows via +two GET requests. A direct request for exactly 30 minutes returned 1,801 rows. +A request for 30 minutes plus one second returned HTTP 200 HTML with the form +validation text `Period of no more than 30 minutes: each second is a separate +row` and no quote rows. This confirms the 30-minute limit at the current +authenticated `/quotes` interface; it is not an assertion about undocumented +server behavior for other endpoints. + +A future request returned HTTP 200 HTML with `This period has not ended yet. +Choose a time in the past.` A request crossing midnight returned an explicit +invalid time-order message because the form accepts only one date. These +responses are classified as broker validation failures rather than empty +history. No new trades are needed for this source. + +The adapter's two-broker-date guard is a local safety limit, not evidence that +the broker retains exactly two days. The page only states that quotes are +retained for several days; the exact retention boundary remains unverified. ## Provenance Boundary @@ -290,14 +326,16 @@ Therefore the current evidence supports this narrower conclusion: provide a usable second-level trade-check history; - it does not prove that a chart around a trade is a special trade-scoped quote-check request; -- exact `/quotes` retention, `/quotes` future-date semantics, timezone, - missing-candle semantics, and price-source semantics still require - authenticated UI replay with a real closed trade. - -The next runtime feature should be a dedicated `/quotes` trade-check adapter -that returns broker execution-second observations with explicit provenance and -gap status. It must not be implemented as a replacement for `trade_check2.php` -or as a claim of complete sub-second market history. The modern +- exact `/quotes` retention remains unspecified beyond the page's "several + days" statement; +- future periods are explicitly rejected, while missing rows in a completed + range still require conservative completeness handling; +- the current page exposes `Bid`, `Price`, and `Ask`; OptionX uses `Price` as a + neutral reference and does not infer settlement-side semantics from it. + +The runtime integration can consume this dedicated `/quotes` adapter for known +trade open/close verification. It must not replace `trade_check2.php` or claim +complete sub-second market history. The modern `chart-history` endpoint remains a separate minute-bar source. ## Hypothesis Review diff --git a/include/optionx_cpp/data/ticks/Tick.hpp b/include/optionx_cpp/data/ticks/Tick.hpp index ed2c598..f4e128d 100644 --- a/include/optionx_cpp/data/ticks/Tick.hpp +++ b/include/optionx_cpp/data/ticks/Tick.hpp @@ -10,11 +10,12 @@ namespace optionx { /// \struct Tick - /// \brief Represents a market tick with quote and trade-price fields. + /// \brief Represents a market tick with quote, trade, and reference fields. struct Tick { double ask; ///< Ask price double bid; ///< Bid price double last; ///< Last traded price, when the feed provides trade ticks. + double reference; ///< Provider price with unknown bid/ask/last semantics. double volume; ///< Trade volume (can store both whole units and high precision) std::uint64_t time_ms; ///< Tick timestamp in milliseconds. std::uint64_t received_ms; ///< Time when tick was received from the server. @@ -22,7 +23,7 @@ namespace optionx { /// \brief Default constructor that initializes all fields to zero or equivalent values Tick() - : ask(0.0), bid(0.0), last(0.0), volume(0.0), + : ask(0.0), bid(0.0), last(0.0), reference(0.0), volume(0.0), time_ms(0), received_ms(0), flags(0) {} /// \brief Constructor to initialize all fields @@ -33,8 +34,8 @@ namespace optionx { /// \param rt Time when tick was received from the server /// \param f Flags representing tick characteristics Tick(double a, double b, double v, - std::uint64_t ts, std::uint64_t rt, std::uint32_t f) - : ask(a), bid(b), last(0.0), volume(v), + std::uint64_t ts, std::uint64_t rt, std::uint32_t f) + : ask(a), bid(b), last(0.0), reference(0.0), volume(v), time_ms(ts), received_ms(rt), flags(f) {} /// \brief Constructor to initialize quote, trade, and metadata fields. @@ -46,19 +47,21 @@ namespace optionx { /// \param rt Time when tick was received from the server. /// \param f Flags representing tick characteristics. Tick(double a, double b, double l, double v, - std::uint64_t ts, std::uint64_t rt, std::uint32_t f) - : ask(a), bid(b), last(l), volume(v), + std::uint64_t ts, std::uint64_t rt, std::uint32_t f) + : ask(a), bid(b), last(l), reference(0.0), volume(v), time_ms(ts), received_ms(rt), flags(f) {} - /// \brief Calculates the average price based on bid and ask. - /// \return The mid-price for quote ticks, or last price for trade ticks. + /// \brief Selects a representative price from the available tick fields. + /// \return The quote midpoint, trade/one-sided price, or neutral reference. double mid_price() const { if (ask != 0.0 && bid != 0.0) return (ask + bid) / 2.0; if (last != 0.0) return last; - return ask != 0.0 ? ask : bid; + if (ask != 0.0) return ask; + if (bid != 0.0) return bid; + return reference; } - /// \brief Calculates the midpoint or last traded price rounded to symbol precision. + /// \brief Returns the representative price rounded to symbol precision. /// \param price_digits Number of decimal places for price rounding. /// \return The value returned by mid_price() rounded to price_digits. double mid_price(std::uint32_t price_digits) const { diff --git a/include/optionx_cpp/data/ticks/TickHistoryRequest.hpp b/include/optionx_cpp/data/ticks/TickHistoryRequest.hpp index ecf1696..27bea19 100644 --- a/include/optionx_cpp/data/ticks/TickHistoryRequest.hpp +++ b/include/optionx_cpp/data/ticks/TickHistoryRequest.hpp @@ -11,6 +11,24 @@ namespace optionx { + /// \enum TickHistorySource + /// \brief Selects one provider-owned source of historical tick observations. + enum class TickHistorySource : std::uint8_t { + LOCAL_OBSERVED = 0, ///< Observations retained from the current live session. + BROKER_HISTORICAL ///< Historical observations loaded from the broker. + }; + + /// \brief Converts a tick-history source to a stable metadata token. + inline const char* to_str(TickHistorySource source) noexcept { + switch (source) { + case TickHistorySource::LOCAL_OBSERVED: + return "LOCAL_OBSERVED"; + case TickHistorySource::BROKER_HISTORICAL: + return "BROKER_HISTORICAL"; + } + return "LOCAL_OBSERVED"; + } + /// \struct TickHistoryRequest /// \brief Requests historical ticks for an inclusive millisecond range. /// \details Tick history is timestamp-based. Unlike bar history, the @@ -21,6 +39,7 @@ namespace optionx { std::string symbol; ///< Provider symbol. std::uint64_t from_time_ms = 0; ///< Inclusive Unix start timestamp. std::uint64_t to_time_ms = 0; ///< Inclusive Unix end timestamp. + TickHistorySource source = TickHistorySource::LOCAL_OBSERVED; ///< Requested source. /// \brief Constructs an empty invalid request. TickHistoryRequest() = default; @@ -29,10 +48,12 @@ namespace optionx { TickHistoryRequest( std::string symbol, std::uint64_t from_time_ms, - std::uint64_t to_time_ms) + std::uint64_t to_time_ms, + TickHistorySource source = TickHistorySource::LOCAL_OBSERVED) : symbol(std::move(symbol)), from_time_ms(from_time_ms), - to_time_ms(to_time_ms) {} + to_time_ms(to_time_ms), + source(source) {} /// \brief Returns true when the symbol and range are usable. [[nodiscard]] bool valid() const noexcept { diff --git a/include/optionx_cpp/data/ticks/TickSequence.hpp b/include/optionx_cpp/data/ticks/TickSequence.hpp index 903f457..7579480 100644 --- a/include/optionx_cpp/data/ticks/TickSequence.hpp +++ b/include/optionx_cpp/data/ticks/TickSequence.hpp @@ -18,6 +18,7 @@ namespace optionx { std::vector ticks; ///< Sequence of tick data of the specified type. std::string symbol; ///< Symbol associated with the tick sequence. std::string provider; ///< Data provider associated with the tick sequence. + std::string source; ///< Provider-owned source used to obtain the sequence. std::uint32_t price_digits; ///< Number of decimal places for price. std::uint32_t volume_digits; ///< Number of decimal places for volume. @@ -30,15 +31,18 @@ namespace optionx { /// \param p Provider associated with the tick sequence. /// \param d Number of decimal places for price. /// \param vd Number of decimal places for volume. + /// \param src Provider-owned source used to obtain the sequence. TickSequence( std::vector ts, std::string s, std::string p, std::uint32_t d, - std::uint32_t vd) + std::uint32_t vd, + std::string src = {}) : ticks(std::move(ts)), symbol(std::move(s)), provider(std::move(p)), + source(std::move(src)), price_digits(d), volume_digits(vd) {} }; diff --git a/include/optionx_cpp/market_data/MarketDataContinuityService.hpp b/include/optionx_cpp/market_data/MarketDataContinuityService.hpp index a87d816..3fd417f 100644 --- a/include/optionx_cpp/market_data/MarketDataContinuityService.hpp +++ b/include/optionx_cpp/market_data/MarketDataContinuityService.hpp @@ -206,7 +206,7 @@ namespace optionx::market_data { } if (!result.sequence.symbol.empty() && - result.sequence.symbol != request.symbol) { + !symbols_match(result.sequence.symbol, request.symbol)) { if (error_callback) { error_callback(TickHistoryResult::fail( "Historical tick response does not match the requested symbol.", diff --git a/include/optionx_cpp/market_data/detail/MarketDataRouter.ipp b/include/optionx_cpp/market_data/detail/MarketDataRouter.ipp index c7e1014..5468e2d 100644 --- a/include/optionx_cpp/market_data/detail/MarketDataRouter.ipp +++ b/include/optionx_cpp/market_data/detail/MarketDataRouter.ipp @@ -19,6 +19,7 @@ namespace optionx::market_data { double ask = 0.0; double bid = 0.0; double last = 0.0; + double reference = 0.0; double volume = 0.0; [[nodiscard]] bool operator==( @@ -27,6 +28,7 @@ namespace optionx::market_data { ask == other.ask && bid == other.bid && last == other.last && + reference == other.reference && volume == other.volume; } }; @@ -42,6 +44,7 @@ namespace optionx::market_data { hash = combine(hash, std::hash{}(key.ask)); hash = combine(hash, std::hash{}(key.bid)); hash = combine(hash, std::hash{}(key.last)); + hash = combine(hash, std::hash{}(key.reference)); hash = combine(hash, std::hash{}(key.volume)); return hash; } @@ -2511,6 +2514,7 @@ namespace optionx::market_data { key.ask = tick.ask; key.bid = tick.bid; key.last = tick.last; + key.reference = tick.reference; break; case MarketDataTickDeduplicationMode::EXACT_OBSERVATION: case MarketDataTickDeduplicationMode::PROVIDER_DEFAULT: @@ -2518,6 +2522,7 @@ namespace optionx::market_data { key.ask = tick.ask; key.bid = tick.bid; key.last = tick.last; + key.reference = tick.reference; key.volume = tick.volume; break; } diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp index 6eea464..c285609 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp @@ -282,16 +282,79 @@ namespace optionx::platforms { }); } - /// \brief Returns observed Intrade polling ticks from the in-memory archive. - /// \param request Inclusive broker-time range in milliseconds. + /// \brief Returns Intrade tick history from the explicitly selected source. + /// \param request Inclusive broker-time range and source selection. /// \param callback Callback receiving observations or a typed failure. - /// \return True when the request was accepted for local processing. + /// \details Local observations preserve the existing synchronous archive + /// lookup. Broker history is queued onto the platform owner loop + /// and never falls back to or merges with the local archive. + /// \return True when the request was accepted for 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; + if (request.source == TickHistorySource::LOCAL_OBSERVED) { + callback(m_tick_history.fetch(request)); + return true; + } + + intrade_bar::BrokerQuoteCheckRequest broker_request; + broker_request.symbol = request.symbol; + broker_request.from_time_ms = request.from_time_ms; + broker_request.to_time_ms = request.to_time_ms; + + auto completed = std::make_shared>(false); + auto callback_holder = + std::make_shared( + std::move(callback)); + auto complete = std::make_shared>(); + *complete = [completed, callback_holder](TickHistoryResult result) mutable { + if (completed->exchange(true, std::memory_order_acq_rel)) return; + auto terminal_callback = std::move(*callback_holder); + if (terminal_callback) terminal_callback(std::move(result)); + }; + auto fail = [complete](std::string reason) mutable { + (*complete)(TickHistoryResult::fail(std::move(reason))); + }; + + return post_task( + [this, broker_request, complete, fail]() mutable { + const bool accepted = m_request_manager.request_quote_check_result( + broker_request, + [complete](intrade_bar::QuoteCheckApiResult result) mutable { + if (!result) { + (*complete)(TickHistoryResult::fail( + std::move(result.error_message), + result.status_code)); + return; + } + + TickSequence sequence; + sequence.symbol = std::move(result.value.symbol); + sequence.provider = to_str(PlatformType::INTRADE_BAR); + sequence.source = to_str(TickHistorySource::BROKER_HISTORICAL); + sequence.price_digits = intrade_bar::price_digits_for_symbol( + sequence.symbol); + sequence.volume_digits = 0; + sequence.ticks.reserve(result.value.observations.size()); + for (const auto& observation : result.value.observations) { + Tick tick; + tick.reference = observation.price; + tick.time_ms = observation.time_ms; + sequence.ticks.push_back(tick); + } + (*complete)(TickHistoryResult::ok( + std::move(sequence), + result.value.range_complete, + result.status_code)); + }); + if (!accepted) { + fail("Intrade broker tick-history request was rejected by RequestManager."); + } + }, + [fail]() mutable { + fail("Intrade broker tick-history request was cancelled during platform shutdown."); + }); } /// \brief Returns the broker-aligned Intrade market-data time estimate. diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp index 7a82178..610e7a5 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp @@ -5,6 +5,8 @@ /// \file ApiResponses.hpp /// \brief Typed response payloads for Intrade Bar HTTP workflows. +#include "QuoteCheck.hpp" + namespace optionx::platforms::intrade_bar { /// \brief Result of selecting an available Intrade Bar domain. @@ -183,6 +185,7 @@ namespace optionx::platforms::intrade_bar { using TradeOpenResult = ApiResult; using TradeCheckResult = ApiResult; using TradeHistoryApiResult = ApiResult; + using QuoteCheckApiResult = ApiResult; /// \brief Converts a settings-switch failure reason to a stable log string. /// \param reason Failure reason. diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp index 5003614..72c240e 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ObservedTickHistory.hpp @@ -106,6 +106,7 @@ namespace optionx::platforms::intrade_bar { std::lock_guard lock(m_mutex); TickSequence sequence; sequence.symbol = request.symbol; + sequence.source = to_str(TickHistorySource::LOCAL_OBSERVED); const auto symbol_it = m_symbols.find(request.symbol); if (symbol_it == m_symbols.end()) { @@ -195,6 +196,7 @@ namespace optionx::platforms::intrade_bar { return lhs.ask == rhs.ask && lhs.bid == rhs.bid && lhs.last == rhs.last && + lhs.reference == rhs.reference && lhs.volume == rhs.volume && lhs.time_ms == rhs.time_ms; } diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/QuoteCheck.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/QuoteCheck.hpp new file mode 100644 index 0000000..59a5e3a --- /dev/null +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/QuoteCheck.hpp @@ -0,0 +1,99 @@ +#pragma once +#ifndef OPTIONX_HEADER_PLATFORMS_INTRADE_BAR_PLATFORM_QUOTE_CHECK_HPP_INCLUDED +#define OPTIONX_HEADER_PLATFORMS_INTRADE_BAR_PLATFORM_QUOTE_CHECK_HPP_INCLUDED + +/// \file QuoteCheck.hpp +/// \brief Defines the broker execution-second quote-check contract. + +#include +#include +#include +#include +#include +#include + +namespace optionx::platforms::intrade_bar { + + /// \brief Stable provenance label for the broker's quote-check page. + inline constexpr const char* INTRADE_QUOTE_CHECK_SOURCE = + "INTRADE_BAR_QUOTES"; + + /// \brief Maximum broker-local dates fetched by one public history request. + inline constexpr std::size_t MAX_INTRADE_QUOTE_CHECK_DAYS = 2; + + /// \brief Maximum interval accepted by one broker `/quotes` request. + /// \details The current quote-check page accepts at most 30 minutes for + /// currencies and metals. Longer public ranges are split into + /// consecutive requests by the runtime adapter. + inline constexpr std::uint64_t MAX_INTRADE_QUOTE_CHECK_INTERVAL_SEC = + 30U * 60U; + + /// \struct BrokerQuoteCheckRequest + /// \brief Requests the broker's execution-second values for one range. + /// \details The broker endpoint accepts second-formatted boundaries and + /// one local broker date. The runtime adapter may split a request + /// at the 30-minute limit or local midnight, but each returned + /// observation remains second-aligned. + struct BrokerQuoteCheckRequest { + std::string symbol; ///< Public or normalized broker symbol. + std::uint64_t from_time_ms = 0; ///< Inclusive UTC Unix timestamp. + std::uint64_t to_time_ms = 0; ///< Inclusive UTC Unix timestamp. + std::int64_t broker_utc_offset_sec = 3 * 60 * 60; ///< Observed broker offset. + + /// \brief Returns true for a usable inclusive request. + [[nodiscard]] bool valid() const noexcept { + return !symbol.empty() && + from_time_ms > 0 && + to_time_ms >= from_time_ms && + broker_utc_offset_sec >= -24 * 60 * 60 && + broker_utc_offset_sec <= 24 * 60 * 60; + } + }; + + /// \brief Returns the first whole-second timestamp inside a request. + inline std::uint64_t first_quote_check_time_ms( + const BrokerQuoteCheckRequest& request) noexcept { + const auto remainder = request.from_time_ms % 1000U; + if (remainder == 0) return request.from_time_ms; + const auto increment = 1000U - remainder; + if (request.from_time_ms > + (std::numeric_limits::max)() - increment) { + return (std::numeric_limits::max)(); + } + return request.from_time_ms + increment; + } + + /// \brief Returns the number of whole-second timestamps inside a request. + inline std::uint64_t quote_check_expected_count( + const BrokerQuoteCheckRequest& request) noexcept { + if (!request.valid()) return 0; + const auto first = first_quote_check_time_ms(request); + const auto last = request.to_time_ms - request.to_time_ms % 1000U; + if (first > last) return 0; + return (last - first) / 1000U + 1U; + } + + /// \struct BrokerQuoteCheckObservation + /// \brief One price selected from an authenticated broker quote row. + struct BrokerQuoteCheckObservation { + std::uint64_t time_ms = 0; ///< UTC Unix timestamp of the row. + double price = 0.0; ///< Quote value selected by the broker page. + }; + + /// \struct BrokerQuoteCheck + /// \brief Parsed execution-second observations returned by `/quotes`. + /// \details `range_complete` is true only when every broker whole-second + /// timestamp inside the requested inclusive range is present and + /// no malformed row was observed. It does not claim that all + /// market events inside those seconds are represented. + struct BrokerQuoteCheck { + std::string symbol; ///< Normalized symbol associated with the rows. + std::string source = INTRADE_QUOTE_CHECK_SOURCE; ///< Provenance label. + std::vector observations; + bool range_complete = false; ///< Whether the requested second grid is dense. + std::size_t malformed_rows = 0; ///< Rows rejected by the parser. + }; + +} // namespace optionx::platforms::intrade_bar + +#endif // OPTIONX_HEADER_PLATFORMS_INTRADE_BAR_PLATFORM_QUOTE_CHECK_HPP_INCLUDED diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp index ba01a23..d850b48 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include "ApiResponses.hpp" #include "BarHistoryUtils.hpp" @@ -351,6 +353,14 @@ namespace optionx::platforms::intrade_bar { int retry_attempts, std::function callback_check); + /// \brief Requests broker execution-second quote rows from `/quotes`. + /// \param request Symbol and inclusive UTC range to verify. + /// \param callback Callback receiving parsed broker quote rows. + /// \return True when the request was accepted by the HTTP scheduler. + bool request_quote_check_result( + const BrokerQuoteCheckRequest& request, + std::function callback); + /// \brief Sends a trade execution request. /// \param request Shared pointer to the trade request. /// \param result Shared pointer to the trade result. @@ -3356,6 +3366,234 @@ namespace optionx::platforms::intrade_bar { }); } + inline bool RequestManager::request_quote_check_result( + const BrokerQuoteCheckRequest& request, + std::function callback) { + if (!callback) return false; + if (!request.valid()) { + callback(QuoteCheckApiResult::fail( + "Invalid Intrade broker quote-check request.")); + return false; + } + + const auto broker_day_index = [offset_sec = request.broker_utc_offset_sec]( + std::uint64_t time_ms) noexcept { + const auto local_sec = static_cast( + time_shield::ms_to_sec(time_ms)) + offset_sec; + constexpr std::int64_t day_seconds = 24 * 60 * 60; + return local_sec >= 0 + ? local_sec / day_seconds + : (local_sec - day_seconds + 1) / day_seconds; + }; + const auto first_day = broker_day_index(request.from_time_ms); + const auto last_day = broker_day_index(request.to_time_ms); + const auto day_count = last_day >= first_day + ? static_cast(last_day - first_day) + 1U + : 0U; + if (day_count == 0 || day_count > MAX_INTRADE_QUOTE_CHECK_DAYS) { + callback(QuoteCheckApiResult::fail( + "Intrade broker quote-check request exceeds the two-day limit.")); + return false; + } + + struct QuoteCheckState { + BrokerQuoteCheckRequest request; + std::map observations; + bool completed = false; + std::function callback; + }; + + auto state = std::make_shared(); + state->request = request; + state->callback = std::move(callback); + + auto finish = [state](QuoteCheckApiResult result) mutable { + if (state->completed) return; + state->completed = true; + auto callback = std::move(state->callback); + if (callback) callback(std::move(result)); + }; + + auto run_segment = std::make_shared< + std::function>(); + std::weak_ptr> weak_run_segment = run_segment; + + *run_segment = [this, state, finish, weak_run_segment]( + std::uint64_t segment_from_ms) mutable { + if (state->completed) return; + + const auto offset_sec = state->request.broker_utc_offset_sec; + const auto local_from_sec = + time_shield::ms_to_sec(segment_from_ms) + offset_sec; + const auto local_date = time_shield::to_date_time( + local_from_sec); + const auto local_day_start_sec = time_shield::to_timestamp( + local_date.year, + local_date.mon, + local_date.day, + 0, + 0, + 0); + const auto next_day_start_utc_ms = time_shield::sec_to_ms( + local_day_start_sec + time_shield::SEC_PER_DAY - offset_sec); + const auto segment_day_end_ms = static_cast( + next_day_start_utc_ms - 1); + const auto max_segment_interval_ms = time_shield::sec_to_ms( + MAX_INTRADE_QUOTE_CHECK_INTERVAL_SEC); + const auto max_segment_to_ms = segment_from_ms > + (std::numeric_limits::max)() - + max_segment_interval_ms + ? (std::numeric_limits::max)() + : segment_from_ms + max_segment_interval_ms; + const auto segment_to_ms = std::min( + state->request.to_time_ms, + std::min(segment_day_end_ms, max_segment_to_ms)); + + BrokerQuoteCheckRequest segment_request = state->request; + segment_request.from_time_ms = segment_from_ms; + segment_request.to_time_ms = segment_to_ms; + + const auto normalized_symbol = normalize_symbol_name(segment_request.symbol); + const auto broker_symbol = normalized_symbol.size() == 6 + ? normalized_symbol.substr(0, 3) + "/" + normalized_symbol.substr(3, 3) + : normalized_symbol; + const auto local_to_sec = + time_shield::ms_to_sec(segment_to_ms) + offset_sec; + const kurlyk::QueryParams query = { + {"symbol", broker_symbol}, + {"date", time_shield::to_string("%Y-%m-%d", local_from_sec)}, + {"from", time_shield::to_string("%hh:%mm:%ss", local_from_sec)}, + {"to", time_shield::to_string("%hh:%mm:%ss", local_to_sec)} + }; + + auto future = get_http_client().get( + "/quotes", + query, + { + {"Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}, + {"Upgrade-Insecure-Requests", "1"}, + {"Referer", m_api_host + "/quotes"}, + {"Cookie", m_cookies} + }, + get_rate_limit(RateLimitType::TRADE_RESULT)); + + auto strong_run_segment = weak_run_segment.lock(); + auto response_callback = [state, finish, segment_request, strong_run_segment]( + kurlyk::HttpResponsePtr response) mutable { + const bool valid_status = response && + ::optionx::utils::validate_status(response); + const bool valid_ddos = response && + ::optionx::utils::validate_ddos_protection(response); + if (!valid_status || !valid_ddos) { + const std::string error_message = !response + ? "No response received from Intrade broker quote-check." + : !valid_status + ? ::optionx::utils::describe_response_error( + response, + "Intrade broker quote-check HTTP response failed validation.") + : "DDoS protection detected during Intrade broker quote-check."; + finish(QuoteCheckApiResult::fail( + error_message, + response + ? response->status_code + : QuoteCheckApiResult::NO_RESPONSE_STATUS)); + return; + } + + if (is_intrade_login_page(response->content)) { + finish(QuoteCheckApiResult::fail( + "Intrade broker quote-check authentication expired.", + response->status_code)); + return; + } + + if (is_intrade_quote_check_future_period_page(response->content)) { + finish(QuoteCheckApiResult::fail( + "Intrade broker quote-check period has not ended yet.", + response->status_code)); + return; + } + + if (is_intrade_quote_check_invalid_period_page(response->content)) { + finish(QuoteCheckApiResult::fail( + "Intrade broker quote-check period is invalid: end time must be later than start time.", + response->status_code)); + return; + } + + const auto trimmed_content = utils::trim_copy(response->content); + if (!trimmed_content.empty() && + trimmed_content.find("partner_stat_table_left") == std::string::npos && + !is_intrade_quote_check_page(response->content)) { + finish(QuoteCheckApiResult::fail( + "Intrade broker quote-check response format was not recognized.", + response->status_code)); + return; + } + + auto segment_result = detail::parse_quote_check_html( + response->content, + segment_request); + if (segment_result.malformed_rows > 0) { + finish(QuoteCheckApiResult::fail( + "Intrade broker quote-check response contained malformed rows.", + response->status_code)); + return; + } + + for (const auto& observation : segment_result.observations) { + state->observations[observation.time_ms] = observation.price; + } + + if (segment_request.to_time_ms < state->request.to_time_ms) { + if (!strong_run_segment) { + finish(QuoteCheckApiResult::fail( + "Intrade broker quote-check continuation was lost.", + response->status_code)); + return; + } + const auto segment_end_second = + segment_request.to_time_ms - + segment_request.to_time_ms % 1000U; + const auto next_segment_from_ms = segment_end_second > + (std::numeric_limits::max)() - 1000U + ? (std::numeric_limits::max)() + : segment_end_second + 1000U; + (*strong_run_segment)(next_segment_from_ms); + return; + } + + BrokerQuoteCheck result; + result.symbol = normalize_symbol_name(state->request.symbol); + result.source = INTRADE_QUOTE_CHECK_SOURCE; + result.observations.reserve(state->observations.size()); + for (const auto& [time_ms, price] : state->observations) { + result.observations.push_back({time_ms, price}); + } + + const auto expected_count = quote_check_expected_count(state->request); + if (result.observations.size() == expected_count) { + result.range_complete = true; + const auto first_time_ms = first_quote_check_time_ms(state->request); + for (std::size_t index = 0; index < result.observations.size(); ++index) { + if (result.observations[index].time_ms != + first_time_ms + index * 1000) { + result.range_complete = false; + break; + } + } + } + + finish(QuoteCheckApiResult::ok(std::move(result), response->status_code)); + }; + + add_http_request_task(std::move(future), std::move(response_callback)); + }; + + (*run_segment)(request.from_time_ms); + return true; + } + inline void RequestManager::request_execute_trade_result( std::shared_ptr request, std::function result_callback) { diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp index 204f23e..4894090 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -353,6 +354,34 @@ namespace optionx::platforms::intrade_bar { content.find("__NUXT__") != std::string::npos; } + /// \brief Detects a known unauthenticated Intrade login response. + inline bool is_intrade_login_page(const std::string& content) { + return is_site_login_page(content) || + is_modern_intrade_landing_page(content) || + content.find("name=\"g-rec-res-l\"") != std::string::npos || + content.find("name='g-rec-res-l'") != std::string::npos; + } + + /// \brief Detects the current authenticated `/quotes` page scaffold. + inline bool is_intrade_quote_check_page(const std::string& content) { + return content.find("name=\"symbol\"") != std::string::npos && + content.find("name=\"date\"") != std::string::npos && + content.find("name=\"from\"") != std::string::npos && + content.find("name=\"to\"") != std::string::npos; + } + + /// \brief Detects the broker's explicit future-period validation page. + inline bool is_intrade_quote_check_future_period_page( + const std::string& content) { + return content.find("period has not ended yet") != std::string::npos; + } + + /// \brief Detects an invalid quote-check time-order response. + inline bool is_intrade_quote_check_invalid_period_page( + const std::string& content) { + return content.find("must be later than") != std::string::npos; + } + /// \brief Parses the current SPA login JSON response without logging its body. /// \param content Raw JSON response body. /// \return Parsed response, or std::nullopt for a non-JSON response. @@ -1011,7 +1040,9 @@ namespace optionx::platforms::intrade_bar { return it == months.end() ? 0 : it->second; } - inline std::optional parse_history_datetime_ms(const std::string& raw) { + inline std::optional parse_history_datetime_ms( + const std::string& raw, + const int64_t broker_offset_sec = 3 * time_shield::SEC_PER_HOUR) { std::smatch match; int hour = 0; int minute = 0; @@ -1062,11 +1093,146 @@ namespace optionx::platforms::intrade_bar { return std::nullopt; } - constexpr int64_t broker_offset_sec = 3 * time_shield::SEC_PER_HOUR; const int64_t timestamp_sec = time_shield::to_timestamp(year, month, day, hour, minute, second) - broker_offset_sec; return time_shield::sec_to_ms(timestamp_sec); } + inline std::optional parse_quote_check_datetime_ms( + const std::string& raw, + const BrokerQuoteCheckRequest& request) { + if (const auto full_timestamp = parse_history_datetime_ms( + raw, + request.broker_utc_offset_sec)) { + return full_timestamp; + } + + std::smatch match; + static const std::regex time_only( + R"(^\s*([0-9]{1,2}):([0-9]{2}):([0-9]{2})\s*$)"); + if (!std::regex_match(raw, match, time_only) || match.size() != 4) { + return std::nullopt; + } + + const auto hour = utils::parse_int_strict(match[1].str()); + const auto minute = utils::parse_int_strict(match[2].str()); + const auto second = utils::parse_int_strict(match[3].str()); + if (!hour || !minute || !second || + *hour > 23 || *minute > 59 || *second > 59) { + return std::nullopt; + } + + const auto local_from_sec = static_cast( + time_shield::ms_to_sec(request.from_time_ms)) + + request.broker_utc_offset_sec; + const auto local_date = time_shield::to_date_time( + local_from_sec); + const auto timestamp_sec = time_shield::to_timestamp( + local_date.year, + local_date.mon, + local_date.day, + *hour, + *minute, + *second) - request.broker_utc_offset_sec; + return time_shield::sec_to_ms(timestamp_sec); + } + + /// \brief Parses the selected price rows returned by `/quotes`. + /// \param content Authenticated broker HTML response. + /// \param request Request range and broker timezone offset. + /// \return Parsed observations and conservative completeness metadata. + inline BrokerQuoteCheck parse_quote_check_html( + const std::string& content, + const BrokerQuoteCheckRequest& request) { + BrokerQuoteCheck result; + result.symbol = normalize_symbol_name(request.symbol); + + if (!request.valid()) return result; + + // The current quote-check page has one time cell followed by + // Bid, Price, and Ask cells. The middle Price cell is the value + // used by the broker's trade-check flow. The legacy response has + // the same selected cell but includes a full date in the first + // cell, so both timestamp forms remain supported. + static const std::regex row_regex( + R"(]*\bclass\s*=\s*["'][^"']*\bpartner_stat_table_left\b[^"']*["'][^>]*>([\s\S]*?)\s*]*>([\s\S]*?)\s*]*>([\s\S]*?))", + std::regex_constants::icase); + static const std::regex current_row_regex( + R"(]*>\s*]*>([\s\S]*?)\s*]*>([\s\S]*?)\s*]*>([\s\S]*?)\s*]*>([\s\S]*?)\s*)", + std::regex_constants::icase); + + const std::string row_marker = "partner_stat_table_left"; + std::size_t marker_count = 0; + for (std::size_t pos = content.find(row_marker); + pos != std::string::npos; + pos = content.find(row_marker, pos + row_marker.size())) { + ++marker_count; + } + + std::map rows; + std::size_t matched_legacy_row_count = 0; + const auto collect_rows = [&]( + const std::regex& regex, + const bool legacy_rows) { + for (std::sregex_iterator it(content.begin(), content.end(), regex), end; + it != end; + ++it) { + if (legacy_rows) ++matched_legacy_row_count; + const auto& match = *it; + if (match.size() < 4) { + ++result.malformed_rows; + continue; + } + + const auto timestamp = parse_quote_check_datetime_ms( + utils::trim_copy(match[1].str()), + request); + auto price_text = utils::trim_copy(match[3].str()); + std::replace(price_text.begin(), price_text.end(), ',', '.'); + const auto price = utils::parse_double_strict(price_text); + if (!timestamp || !price || *timestamp < 0) { + ++result.malformed_rows; + continue; + } + + const auto time_ms = static_cast(*timestamp); + if (time_ms < request.from_time_ms || time_ms > request.to_time_ms) { + continue; + } + rows[time_ms] = *price; + } + }; + + collect_rows(row_regex, true); + if (marker_count == 0) { + collect_rows(current_row_regex, false); + } + + if (marker_count > matched_legacy_row_count) { + result.malformed_rows += marker_count - matched_legacy_row_count; + } + + result.observations.reserve(rows.size()); + for (const auto& [time_ms, price] : rows) { + result.observations.push_back({time_ms, price}); + } + + const auto expected_count = quote_check_expected_count(request); + if (result.malformed_rows == 0 && + expected_count == result.observations.size()) { + result.range_complete = true; + const auto first_time_ms = first_quote_check_time_ms(request); + for (std::size_t index = 0; index < result.observations.size(); ++index) { + const auto expected_time = first_time_ms + index * 1000; + if (result.observations[index].time_ms != expected_time) { + result.range_complete = false; + break; + } + } + } + + return result; + } + inline std::optional parse_history_money(std::string raw) { raw = utils::trim_copy(raw); std::replace(raw.begin(), raw.end(), ',', '.'); diff --git a/tests/intrade_auth_lifecycle_test.cpp b/tests/intrade_auth_lifecycle_test.cpp index 885d1b0..a2e82a8 100644 --- a/tests/intrade_auth_lifecycle_test.cpp +++ b/tests/intrade_auth_lifecycle_test.cpp @@ -214,9 +214,11 @@ TEST(IntradeBarPublicRequests, AcceptedRequestsCompleteWhenShutdownCancelsOwnerT int history_callbacks = 0; int result_callbacks = 0; int bar_callbacks = 0; + int tick_callbacks = 0; optionx::TradeHistoryResult history_result; std::unique_ptr trade_result; optionx::BarHistoryResult bar_result; + optionx::TickHistoryResult tick_result; optionx::TradeHistoryRequest history_request = optionx::TradeHistoryRequest::all(); @@ -251,6 +253,18 @@ TEST(IntradeBarPublicRequests, AcceptedRequestsCompleteWhenShutdownCancelsOwnerT bar_result = std::move(result); })); + const optionx::TickHistoryRequest tick_request( + "EURUSD", + 1000, + 2000, + optionx::TickHistorySource::BROKER_HISTORICAL); + ASSERT_TRUE(platform.fetch_tick_history( + tick_request, + [&tick_callbacks, &tick_result](optionx::TickHistoryResult result) { + ++tick_callbacks; + tick_result = std::move(result); + })); + platform.shutdown(); EXPECT_EQ(history_callbacks, 1); @@ -272,6 +286,32 @@ TEST(IntradeBarPublicRequests, AcceptedRequestsCompleteWhenShutdownCancelsOwnerT EXPECT_NE( bar_result.error_desc.find("cancelled during platform shutdown"), std::string::npos); + + EXPECT_EQ(tick_callbacks, 1); + EXPECT_FALSE(tick_result.success); + EXPECT_NE( + tick_result.error_desc.find("cancelled during platform shutdown"), + std::string::npos); +} + +TEST(IntradeBarPublicRequests, DefaultTickHistorySourceRemainsLocalObserved) { + optionx::platforms::IntradeBarPlatform platform; + optionx::TickHistoryResult result; + int callback_count = 0; + + ASSERT_TRUE(platform.fetch_tick_history( + optionx::TickHistoryRequest("EURUSD", 1000, 2000), + [&result, &callback_count](optionx::TickHistoryResult update) { + result = std::move(update); + ++callback_count; + })); + + EXPECT_EQ(callback_count, 1); + EXPECT_TRUE(result.success); + EXPECT_FALSE(result.range_complete); + EXPECT_TRUE(result.sequence.ticks.empty()); + EXPECT_EQ(result.sequence.source, "LOCAL_OBSERVED"); + platform.shutdown(); } int main(int argc, char** argv) { diff --git a/tests/intrade_bar_api/CLI.md b/tests/intrade_bar_api/CLI.md index 108d524..53e75fb 100644 --- a/tests/intrade_bar_api/CLI.md +++ b/tests/intrade_bar_api/CLI.md @@ -156,6 +156,23 @@ auth callback=1 success=1 elapsed_ms=3639 EURUSD bid=1.14911 ask=1.14914 mid=1.14913 ``` +Fetch broker execution-second history without opening a trade: + +```powershell +.\build-codex\intrade_bar_smoke_cli.exe tick-history --symbol=EURUSD --days-ago=0 --seconds=65 +.\build-codex\intrade_bar_smoke_cli.exe tick-history --symbol=EURUSD --days-ago=1 --seconds=65 +.\build-codex\intrade_bar_smoke_cli.exe tick-history --symbol=EURUSD --from-ms=... --to-ms=... +``` + +This command uses the explicit `BROKER_HISTORICAL` tick-history source and +the authenticated `/quotes` workflow. It prints only request/result metadata, +observation count, time bounds, and the first/last neutral reference values. +It does not open trades or change broker account settings. The broker accepts +at most 30 minutes per `/quotes` request; the adapter splits longer ranges and +local-midnight crossings. Future ranges are rejected by the broker and are +reported as failures, not as empty history. Use a completed range for a live +smoke. + Smoke live market-data streams: ```powershell diff --git a/tests/intrade_bar_api/IntradeBarSmokeSupport.hpp b/tests/intrade_bar_api/IntradeBarSmokeSupport.hpp index 400272f..7b8223c 100644 --- a/tests/intrade_bar_api/IntradeBarSmokeSupport.hpp +++ b/tests/intrade_bar_api/IntradeBarSmokeSupport.hpp @@ -357,6 +357,13 @@ struct ConnectAttempt { int64_t elapsed_ms = 0; }; +struct TickHistoryAttempt { + bool accepted = false; + bool callback_received = false; + optionx::TickHistoryResult result; + int64_t elapsed_ms = 0; +}; + struct TradeOpenAttempt { bool accepted = false; bool callback_received = false; @@ -520,6 +527,28 @@ class IntradeBarSmokeRuntime { return m_price_capture.ticks(); } + TickHistoryAttempt fetch_tick_history( + optionx::TickHistoryRequest request, + int64_t timeout_ms) { + TickHistoryAttempt attempt; + std::atomic done{false}; + const auto started_at = std::chrono::steady_clock::now(); + attempt.accepted = m_platform.fetch_tick_history( + request, + [&](optionx::TickHistoryResult result) { + attempt.result = std::move(result); + attempt.callback_received = true; + done.store(true, std::memory_order_release); + }); + pump_until( + m_platform, + [&] { return done.load(std::memory_order_acquire); }, + timeout_ms); + attempt.elapsed_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - started_at).count(); + return attempt; + } + AutoDomainSelection latest_domain_selection() const { return m_domain_capture.selection(); } diff --git a/tests/intrade_bar_api/README.md b/tests/intrade_bar_api/README.md index d586584..ca44392 100644 --- a/tests/intrade_bar_api/README.md +++ b/tests/intrade_bar_api/README.md @@ -21,6 +21,7 @@ $env:OPTIONX_INTRADE_BAR_CONFIG_FILE="tests\intrade_bar_api\intrade_bar_api.loca .\build-codex\intrade_bar_smoke_cli.exe auth-cache .\build-codex\intrade_bar_smoke_cli.exe domain-check --domain-min=-1 --domain-max=1000 .\build-codex\intrade_bar_smoke_cli.exe quotes --symbol=EURUSD +.\build-codex\intrade_bar_smoke_cli.exe tick-history --symbol=EURUSD --days-ago=0 --seconds=65 .\build-codex\intrade_bar_smoke_cli.exe market-stream --symbols=BTCUSDT --ticks --bars --transport=WEBSOCKET --seconds=60 .\build-codex\intrade_bar_smoke_cli.exe history --source=CSV --days=14 --account-type=DEMO .\build-codex\intrade_bar_smoke_cli.exe history --source=HTML_CSV --all --account-type=DEMO --comment=account-history-export diff --git a/tests/intrade_bar_api/intrade_bar_smoke_cli.cpp b/tests/intrade_bar_api/intrade_bar_smoke_cli.cpp index 1ec2dc3..4e3193e 100644 --- a/tests/intrade_bar_api/intrade_bar_smoke_cli.cpp +++ b/tests/intrade_bar_api/intrade_bar_smoke_cli.cpp @@ -36,6 +36,7 @@ void print_usage(std::ostream& out) { << " intrade_bar_smoke_cli show-account\n" << " intrade_bar_smoke_cli domain-check [--domain-min=-1] [--domain-max=1000]\n" << " intrade_bar_smoke_cli quotes [--symbol=EURUSD]\n" + << " intrade_bar_smoke_cli tick-history [--symbol=EURUSD] [--days-ago=0] [--seconds=65] [--from-ms=...] [--to-ms=...]\n" << " intrade_bar_smoke_cli market-stream [--symbols=BTCUSDT,EURUSD] [--ticks] [--bars] [--transport=WEBSOCKET|POLLING|AUTO|HYBRID] [--seconds=30] [--timeframe=60] [--backfill-minutes=30]\n" << " intrade_bar_smoke_cli history [--source=CSV|HTML|HTML_CSV] [--days=14|--all] [--time-field=CLOSE_DATE] [--comment=...]\n" << " intrade_bar_smoke_cli switch-check --confirm [--account-type=DEMO] [--currency=USD]\n" @@ -305,6 +306,91 @@ int run_quotes(smoke::IntradeBarSmokeConfig config, const CliOptions& options) { return 0; } +int run_tick_history( + smoke::IntradeBarSmokeConfig config, + const CliOptions& options) { + if (!smoke::require_live_config(config, std::cerr)) return 2; + + const std::string requested_symbol = smoke::option_value_or( + options.values, + "symbol", + config.quote_symbol); + const std::string symbol = smoke::normalize_quote_symbol(requested_symbol); + const int64_t now_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + const bool has_explicit_range = + options.values.count("from-ms") != 0 || + options.values.count("to-ms") != 0; + std::uint64_t from_ms = 0; + std::uint64_t to_ms = 0; + + if (has_explicit_range) { + const auto from_value = smoke::option_i64_or(options.values, "from-ms", 0); + const auto to_value = smoke::option_i64_or(options.values, "to-ms", 0); + if (from_value <= 0 || to_value < from_value) { + std::cerr << "Invalid inclusive --from-ms/--to-ms range.\n"; + return 2; + } + from_ms = static_cast(from_value); + to_ms = static_cast(to_value); + } else { + const int64_t days_ago = smoke::option_i64_or( + options.values, + "days-ago", + 0); + const int64_t seconds = smoke::option_i64_or( + options.values, + "seconds", + 65); + if (days_ago < -3650 || days_ago > 3650 || seconds <= 0 || seconds > 172800) { + std::cerr << "Invalid --days-ago or --seconds range.\n"; + return 2; + } + const int64_t center_sec = now_ms / 1000 - days_ago * 86400 - 5; + if (center_sec <= 0 || center_sec < seconds) { + std::cerr << "Requested tick-history range is outside timestamp bounds.\n"; + return 2; + } + to_ms = static_cast(center_sec * 1000); + from_ms = static_cast((center_sec - seconds + 1) * 1000); + } + + smoke::IntradeBarSmokeRuntime runtime(std::move(config)); + if (!smoke::connect_or_report(runtime, std::cout, std::cerr)) return 1; + + const optionx::TickHistoryRequest request( + symbol, + from_ms, + to_ms, + optionx::TickHistorySource::BROKER_HISTORICAL); + const auto attempt = runtime.fetch_tick_history(request, 90000); + const auto& result = attempt.result; + std::cout << "tick-history accepted=" << attempt.accepted + << " callback=" << attempt.callback_received + << " elapsed_ms=" << attempt.elapsed_ms + << " success=" << result.success + << " status=" << result.status_code + << " range_complete=" << result.range_complete + << " symbol=" << result.sequence.symbol + << " provider=" << result.sequence.provider + << " source=" << result.sequence.source + << " observations=" << result.sequence.ticks.size(); + if (!result.sequence.ticks.empty()) { + std::cout << " min_time_ms=" << result.sequence.ticks.front().time_ms + << " max_time_ms=" << result.sequence.ticks.back().time_ms + << " first_reference=" << std::setprecision(12) + << result.sequence.ticks.front().reference + << " last_reference=" << std::setprecision(12) + << result.sequence.ticks.back().reference; + } + std::cout << '\n'; + if (!result.success && !result.error_desc.empty()) { + std::cerr << "tick-history failed: " << result.error_desc << '\n'; + } + runtime.disconnect(); + return attempt.accepted && attempt.callback_received && result.success ? 0 : 1; +} + class CliBarAggregator { public: CliBarAggregator( @@ -1378,6 +1464,8 @@ int main(int argc, char** argv) { result = run_domain_check(std::move(config), options); } else if (options.command == "quotes") { result = run_quotes(std::move(config), options); + } else if (options.command == "tick-history") { + result = run_tick_history(std::move(config), options); } else if (options.command == "market-stream") { result = run_market_stream(std::move(config), options); } else if (options.command == "history") { diff --git a/tests/intrade_observed_tick_history_test.cpp b/tests/intrade_observed_tick_history_test.cpp index 40285b8..d57c9e1 100644 --- a/tests/intrade_observed_tick_history_test.cpp +++ b/tests/intrade_observed_tick_history_test.cpp @@ -67,6 +67,7 @@ TEST(IntradeObservedTickHistory, ReturnsSortedInclusiveObservations) { EXPECT_EQ(result.sequence.ticks[3].time_ms, 3000U); EXPECT_EQ(result.sequence.symbol, "EURUSD"); EXPECT_EQ(result.sequence.provider, "INTRADE_BAR"); + EXPECT_EQ(result.sequence.source, "LOCAL_OBSERVED"); } TEST(IntradeObservedTickHistory, DeduplicatesOnlyIdenticalMarketObservations) { diff --git a/tests/intrade_quote_check_parser_test.cpp b/tests/intrade_quote_check_parser_test.cpp new file mode 100644 index 0000000..f0b9814 --- /dev/null +++ b/tests/intrade_quote_check_parser_test.cpp @@ -0,0 +1,762 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace optionx; +using namespace optionx::platforms::intrade_bar; + +namespace { + +using QuoteCheckHttpServer = SimpleWeb::Server; + +class BoundQuoteCheckHttpServer : public QuoteCheckHttpServer { +public: + unsigned short bound_port() const noexcept { + return m_bound_port.load(); + } + +protected: + void after_bind() override { + m_bound_port.store(acceptor->local_endpoint().port()); + } + +private: + std::atomic m_bound_port{0}; +}; + +class QuoteCheckServerFixture { +public: + ~QuoteCheckServerFixture() { + stop(); + } + + bool start() { + server.config.address = "127.0.0.1"; + server.config.port = 0; + server.resource["^/health$"]["GET"] = []( + std::shared_ptr response, + std::shared_ptr) { + response->write(SimpleWeb::StatusCode::success_ok, "ok"); + }; + server.resource["^/quotes$"]["GET"] = [this]( + std::shared_ptr response, + std::shared_ptr request) { + { + std::lock_guard lock(m_mutex); + request_queries.push_back(request->query_string); + } + const auto cookie = request->header.find("Cookie"); + if (cookie != request->header.end() && + cookie->second.find("PHPSESSID=fixture") != std::string::npos) { + cookie_seen.store(true); + } + std::optional forced_response; + std::optional sequence_response; + { + std::lock_guard lock(m_mutex); + forced_response = m_forced_response; + if (!m_response_sequence.empty() && + m_response_sequence_index < m_response_sequence.size()) { + sequence_response = m_response_sequence[m_response_sequence_index++]; + } + } + if (forced_response) { + response->write(SimpleWeb::StatusCode::success_ok, *forced_response); + return; + } + if (sequence_response) { + response->write(SimpleWeb::StatusCode::success_ok, *sequence_response); + return; + } + + if (request->query_string.find("date=2026-10-28") != std::string::npos) { + response->write( + SimpleWeb::StatusCode::success_ok, + R"HTML(23:59:591.199901.200001.20010)HTML"); + return; + } + response->write( + SimpleWeb::StatusCode::success_ok, + R"HTML(00:00:001.200001.200101.20020)HTML"); + }; + + thread = std::thread([this]() { + server.start(); + }); + return wait_until_ready(); + } + + void stop() { + server.stop(); + if (thread.joinable()) thread.join(); + } + + std::string host() const { + return "http://127.0.0.1:" + std::to_string(server.bound_port()); + } + + std::vector queries() const { + std::lock_guard lock(m_mutex); + return request_queries; + } + + void set_response(std::string response) { + std::lock_guard lock(m_mutex); + m_forced_response = std::move(response); + m_response_sequence.clear(); + m_response_sequence_index = 0; + } + + void set_response_sequence(std::vector responses) { + std::lock_guard lock(m_mutex); + m_forced_response.reset(); + m_response_sequence = std::move(responses); + m_response_sequence_index = 0; + } + + std::atomic cookie_seen{false}; + +private: + bool wait_until_ready() const { + for (int attempt = 0; attempt < 100; ++attempt) { + if (server.bound_port() != 0) { + try { + kurlyk::HttpClient client(host()); + client.set_timeout(1); + client.set_connect_timeout(1); + auto future = client.get("/health", {}, {}); + if (future.wait_for(std::chrono::seconds(1)) == + std::future_status::ready) { + const auto response = future.get(); + if (response && response->status_code == 200) return true; + } + } catch (...) { + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return false; + } + + BoundQuoteCheckHttpServer server; + std::thread thread; + mutable std::mutex m_mutex; + std::vector request_queries; + std::optional m_forced_response; + std::vector m_response_sequence; + std::size_t m_response_sequence_index = 0; +}; + +class QuoteCheckTestPlatform final : public optionx::platforms::BaseTradingPlatform { +public: + QuoteCheckTestPlatform() + : optionx::platforms::BaseTradingPlatform( + std::make_shared()) {} + + optionx::PlatformType platform_type() const override { + return optionx::PlatformType::INTRADE_BAR; + } +}; + +BrokerQuoteCheckRequest make_request( + std::uint64_t from_time_ms, + std::uint64_t to_time_ms, + std::int64_t broker_offset_sec = 3 * 60 * 60) { + BrokerQuoteCheckRequest request; + request.symbol = "EUR/USD"; + request.from_time_ms = from_time_ms; + request.to_time_ms = to_time_ms; + request.broker_utc_offset_sec = broker_offset_sec; + return request; +} + +TEST(IntradeQuoteCheckParser, UsesSelectedQuoteAndBuildsDenseSecondGrid) { + const auto base_utc = time_shield::to_timestamp(2026, 10, 28, 9, 0, 1); + const auto request = make_request( + time_shield::sec_to_ms(base_utc), + time_shield::sec_to_ms(base_utc + 2)); + const std::string html = R"HTML( + 12:00:02, 28 Oct 2026 + ignored-11.23452 + 12:00:01, 28 Oct 2026 + ignored-21.23450 + 12:00:03, 28 Oct 2026 + ignored-31.23455 + )HTML"; + + const auto result = detail::parse_quote_check_html(html, request); + + EXPECT_EQ(result.symbol, "EURUSD"); + EXPECT_EQ(result.source, INTRADE_QUOTE_CHECK_SOURCE); + ASSERT_EQ(result.observations.size(), 3U); + EXPECT_DOUBLE_EQ(result.observations[0].price, 1.23450); + EXPECT_DOUBLE_EQ(result.observations[1].price, 1.23452); + EXPECT_DOUBLE_EQ(result.observations[2].price, 1.23455); + EXPECT_TRUE(result.range_complete); + EXPECT_EQ(result.malformed_rows, 0U); +} + +TEST(IntradeQuoteCheckParser, ParsesCurrentTimeOnlyQuoteRows) { + const auto base_utc = time_shield::to_timestamp(2026, 10, 28, 9, 0, 1); + const auto request = make_request( + time_shield::sec_to_ms(base_utc), + time_shield::sec_to_ms(base_utc + 2)); + const std::string html = R"HTML( + 12:00:01 + 1.234401.234501.23460 + 12:00:02 + 1.234501.234551.23460 + 12:00:03 + 1.234601.234651.23470 + )HTML"; + + const auto result = detail::parse_quote_check_html(html, request); + + ASSERT_EQ(result.observations.size(), 3U); + EXPECT_DOUBLE_EQ(result.observations[0].price, 1.23450); + EXPECT_DOUBLE_EQ(result.observations[1].price, 1.23455); + EXPECT_DOUBLE_EQ(result.observations[2].price, 1.23465); + EXPECT_TRUE(result.range_complete); + EXPECT_EQ(result.malformed_rows, 0U); +} + +TEST(IntradeQuoteCheckParser, MissingSecondFailsClosed) { + const auto base_utc = time_shield::to_timestamp(2026, 10, 28, 9, 0, 1); + const auto request = make_request( + time_shield::sec_to_ms(base_utc), + time_shield::sec_to_ms(base_utc + 2)); + const std::string html = R"HTML( + 12:00:01, 28 Oct 2026 + ignored1.23450 + 12:00:03, 28 Oct 2026 + ignored1.23455 + )HTML"; + + const auto result = detail::parse_quote_check_html(html, request); + + ASSERT_EQ(result.observations.size(), 2U); + EXPECT_FALSE(result.range_complete); +} + +TEST(IntradeQuoteCheckParser, FiltersPartialSecondBoundariesWithoutRoundingOutput) { + const auto base_utc = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + const auto request = make_request( + time_shield::sec_to_ms(base_utc) + 250, + time_shield::sec_to_ms(base_utc + 2) + 700); + const std::string html = R"HTML( + 12:00:00, 28 Oct 2026 + ignored1.23449 + 12:00:01, 28 Oct 2026 + ignored1.23450 + 12:00:02, 28 Oct 2026 + ignored1.23451 + 12:00:03, 28 Oct 2026 + ignored1.23452 + )HTML"; + + const auto result = detail::parse_quote_check_html(html, request); + + ASSERT_EQ(result.observations.size(), 2U); + EXPECT_EQ(result.observations[0].time_ms, time_shield::sec_to_ms(base_utc + 1)); + EXPECT_EQ(result.observations[1].time_ms, time_shield::sec_to_ms(base_utc + 2)); + EXPECT_TRUE(result.range_complete); +} + +TEST(IntradeQuoteCheckParser, EmptySubsecondGridIsComplete) { + const auto base_ms = time_shield::sec_to_ms( + time_shield::to_timestamp(2026, 10, 28, 9, 0, 0)); + const auto result = detail::parse_quote_check_html( + {}, + make_request(base_ms + 250, base_ms + 700)); + + EXPECT_TRUE(result.observations.empty()); + EXPECT_TRUE(result.range_complete); +} + +TEST(IntradeQuoteCheckParser, MalformedRowDoesNotBecomeAnObservation) { + const auto timestamp = time_shield::to_timestamp(2026, 10, 28, 9, 0, 1); + const auto request = make_request( + time_shield::sec_to_ms(timestamp), + time_shield::sec_to_ms(timestamp)); + const std::string html = R"HTML( + not-a-date + ignorednot-a-price + )HTML"; + + const auto result = detail::parse_quote_check_html(html, request); + + EXPECT_TRUE(result.observations.empty()); + EXPECT_FALSE(result.range_complete); + EXPECT_GT(result.malformed_rows, 0U); +} + +TEST(IntradeQuoteCheckParser, SupportsARequestCrossingLocalMidnight) { + const auto first = time_shield::to_timestamp(2026, 10, 28, 20, 59, 59); + const auto second = first + 1; + const auto request = make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(second)); + const std::string html = R"HTML( + 23:59:59, 28 Oct 2026 + ignored1.20000 + 00:00:00, 29 Oct 2026 + ignored1.20010 + )HTML"; + + const auto result = detail::parse_quote_check_html(html, request); + + ASSERT_EQ(result.observations.size(), 2U); + EXPECT_TRUE(result.range_complete); + EXPECT_EQ(result.observations.front().time_ms, time_shield::sec_to_ms(first)); + EXPECT_EQ(result.observations.back().time_ms, time_shield::sec_to_ms(second)); +} + +TEST(IntradeQuoteCheckParser, InvalidRequestReturnsNoData) { + const auto result = detail::parse_quote_check_html( + "12:00:00, 28 Oct 2026", + BrokerQuoteCheckRequest{}); + + EXPECT_TRUE(result.observations.empty()); + EXPECT_FALSE(result.range_complete); +} + +TEST(IntradeQuoteCheckParser, RequestManagerSplitsLocalMidnightAndMergesRows) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + client.get_http_client().set_origin(server.host()); + client.get_http_client().set_referer(server.host() + "/"); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto first = time_shield::to_timestamp(2026, 10, 28, 20, 59, 59); + const auto second = first + 1; + const auto request = make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(second)); + std::atomic completed{false}; + QuoteCheckApiResult result; + + ASSERT_TRUE(request_manager.request_quote_check_result( + request, + [&completed, &result](QuoteCheckApiResult value) { + result = std::move(value); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(7); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + ASSERT_TRUE(result.success); + ASSERT_EQ(result.value.observations.size(), 2U); + EXPECT_TRUE(result.value.range_complete); + EXPECT_TRUE(server.cookie_seen.load()); + const auto queries = server.queries(); + ASSERT_EQ(queries.size(), 2U); + EXPECT_NE(queries[0].find("symbol=EUR%2FUSD"), std::string::npos); + EXPECT_NE(queries[0].find("date=2026-10-28"), std::string::npos); + EXPECT_NE(queries[0].find("from=23%3A59%3A59"), std::string::npos); + EXPECT_NE(queries[0].find("to=23%3A59%3A59"), std::string::npos); + EXPECT_NE(queries[1].find("date=2026-10-29"), std::string::npos); + EXPECT_NE(queries[1].find("from=00%3A00%3A00"), std::string::npos); + EXPECT_NE(queries[1].find("to=00%3A00%3A00"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, RequestManagerSplitsLongRangeIntoThirtyMinuteQueries) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + client.get_http_client().set_origin(server.host()); + client.get_http_client().set_referer(server.host() + "/"); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto first = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + const auto request = make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(first + 60 * time_shield::SEC_PER_MIN)); + std::atomic completed{false}; + QuoteCheckApiResult result; + + ASSERT_TRUE(request_manager.request_quote_check_result( + request, + [&completed, &result](QuoteCheckApiResult value) { + result = std::move(value); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(7); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + EXPECT_TRUE(result.success); + EXPECT_FALSE(result.value.range_complete); + + const auto queries = server.queries(); + ASSERT_EQ(queries.size(), 2U); + EXPECT_NE(queries[0].find("from=12%3A00%3A00"), std::string::npos); + EXPECT_NE(queries[0].find("to=12%3A30%3A00"), std::string::npos); + EXPECT_NE(queries[1].find("from=12%3A30%3A01"), std::string::npos); + EXPECT_NE(queries[1].find("to=13%3A00%3A00"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, KeepsExactlyThirtyMinutesInOneBrokerQuery) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + client.get_http_client().set_origin(server.host()); + client.get_http_client().set_referer(server.host() + "/"); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto first = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + std::atomic completed{false}; + QuoteCheckApiResult result; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(first + MAX_INTRADE_QUOTE_CHECK_INTERVAL_SEC)), + [&completed, &result](QuoteCheckApiResult value) { + result = std::move(value); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(7); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + ASSERT_TRUE(result.success); + const auto queries = server.queries(); + ASSERT_EQ(queries.size(), 1U); + EXPECT_NE(queries.front().find("from=12%3A00%3A00"), std::string::npos); + EXPECT_NE(queries.front().find("to=12%3A30%3A00"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, SplitsThirtyMinutesPlusOneSecondWithoutOverlap) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + client.get_http_client().set_origin(server.host()); + client.get_http_client().set_referer(server.host() + "/"); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto first = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + std::atomic completed{false}; + QuoteCheckApiResult result; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(first + MAX_INTRADE_QUOTE_CHECK_INTERVAL_SEC + 1)), + [&completed, &result](QuoteCheckApiResult value) { + result = std::move(value); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(7); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + ASSERT_TRUE(result.success); + const auto queries = server.queries(); + ASSERT_EQ(queries.size(), 2U); + EXPECT_NE(queries[0].find("from=12%3A00%3A00"), std::string::npos); + EXPECT_NE(queries[0].find("to=12%3A30%3A00"), std::string::npos); + EXPECT_NE(queries[1].find("from=12%3A30%3A01"), std::string::npos); + EXPECT_NE(queries[1].find("to=12%3A30%3A01"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, FailsWhenSecondSegmentReturnsUnrecognizedHtml) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + server.set_response_sequence({ + R"HTML(12:00:001.199901.200001.20010)HTML", + "Temporarily unavailable"}); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto first = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + std::atomic completed{false}; + QuoteCheckApiResult result; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(first + 60 * time_shield::SEC_PER_MIN)), + [&completed, &result](QuoteCheckApiResult value) { + result = std::move(value); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(7); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + EXPECT_FALSE(result.success); + EXPECT_NE(result.error_message.find("format was not recognized"), std::string::npos); + EXPECT_EQ(server.queries().size(), 2U); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, PlatformReturnsBrokerHistoryThroughUnifiedApi) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + + optionx::platforms::IntradeBarPlatform platform; + auto auth = std::make_unique(); + auth->set_email_password("fixture@example.test", "fixture-password"); + auth->host = server.host(); + auth->auto_find_domain = false; + ASSERT_TRUE(platform.configure_auth(std::move(auth))); + platform.event_bus().drain(); + platform.run(false); + + const auto time_sec = time_shield::to_timestamp(2026, 10, 28, 20, 59, 59); + const TickHistoryRequest request( + "EUR/USD", + time_shield::sec_to_ms(time_sec), + time_shield::sec_to_ms(time_sec), + TickHistorySource::BROKER_HISTORICAL); + TickHistoryResult result; + std::atomic completed{false}; + ASSERT_TRUE(platform.fetch_tick_history( + request, + [&result, &completed](TickHistoryResult update) { + result = std::move(update); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + platform.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + ASSERT_TRUE(result.success) << result.error_desc; + EXPECT_TRUE(result.range_complete); + EXPECT_EQ(result.sequence.symbol, "EURUSD"); + EXPECT_EQ(result.sequence.provider, "INTRADE_BAR"); + EXPECT_EQ(result.sequence.source, "BROKER_HISTORICAL"); + EXPECT_EQ(result.sequence.price_digits, 5U); + EXPECT_EQ(result.sequence.volume_digits, 0U); + ASSERT_EQ(result.sequence.ticks.size(), 1U); + EXPECT_DOUBLE_EQ(result.sequence.ticks.front().reference, 1.20000); + EXPECT_DOUBLE_EQ(result.sequence.ticks.front().ask, 0.0); + EXPECT_DOUBLE_EQ(result.sequence.ticks.front().bid, 0.0); + EXPECT_DOUBLE_EQ(result.sequence.ticks.front().last, 0.0); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, RequestManagerRejectsHttp200LoginPage) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + server.set_response( + "Digital options, without the circus." + ""); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto time_sec = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + QuoteCheckApiResult result; + std::atomic completed{false}; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request(time_shield::sec_to_ms(time_sec), time_shield::sec_to_ms(time_sec)), + [&result, &completed](QuoteCheckApiResult update) { + result = std::move(update); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + EXPECT_FALSE(result.success); + EXPECT_NE(result.error_message.find("authentication expired"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, RequestManagerRejectsUnrecognizedHttp200Html) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + server.set_response("Temporarily unavailable"); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto time_sec = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + QuoteCheckApiResult result; + std::atomic completed{false}; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request(time_shield::sec_to_ms(time_sec), time_shield::sec_to_ms(time_sec)), + [&result, &completed](QuoteCheckApiResult update) { + result = std::move(update); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + EXPECT_FALSE(result.success); + EXPECT_NE(result.error_message.find("format was not recognized"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, RequestManagerRejectsFuturePeriodWithBrokerReason) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + server.set_response( + "
" + "
" + "

This period has not ended yet. Choose a time in the past.

"); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto time_sec = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + QuoteCheckApiResult result; + std::atomic completed{false}; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request(time_shield::sec_to_ms(time_sec), time_shield::sec_to_ms(time_sec)), + [&result, &completed](QuoteCheckApiResult update) { + result = std::move(update); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + EXPECT_FALSE(result.success); + EXPECT_NE(result.error_message.find("period has not ended"), std::string::npos); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, RequestManagerAcceptsEmptyHttp200AsIncompleteHistory) { + QuoteCheckServerFixture server; + ASSERT_TRUE(server.start()); + server.set_response({}); + + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + client.get_http_client().set_host(server.host()); + request_manager.set_auth_credentials("fixture-user", "fixture-hash", "PHPSESSID=fixture"); + + const auto time_sec = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + QuoteCheckApiResult result; + std::atomic completed{false}; + ASSERT_TRUE(request_manager.request_quote_check_result( + make_request(time_shield::sec_to_ms(time_sec), time_shield::sec_to_ms(time_sec)), + [&result, &completed](QuoteCheckApiResult update) { + result = std::move(update); + completed.store(true); + })); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (!completed.load() && std::chrono::steady_clock::now() < deadline) { + client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(completed.load()); + EXPECT_TRUE(result.success); + EXPECT_TRUE(result.value.observations.empty()); + EXPECT_FALSE(result.value.range_complete); + platform.shutdown(); +} + +TEST(IntradeQuoteCheckParser, RequestManagerRejectsMoreThanTwoBrokerDates) { + QuoteCheckTestPlatform platform; + HttpClientComponent client(platform); + RequestManager request_manager(platform, client); + const auto first = time_shield::to_timestamp(2026, 10, 28, 9, 0, 0); + QuoteCheckApiResult result; + int callback_count = 0; + + EXPECT_FALSE(request_manager.request_quote_check_result( + make_request( + time_shield::sec_to_ms(first), + time_shield::sec_to_ms(first + 2 * time_shield::SEC_PER_DAY)), + [&result, &callback_count](QuoteCheckApiResult update) { + result = std::move(update); + ++callback_count; + })); + + EXPECT_EQ(callback_count, 1); + EXPECT_FALSE(result.success); + EXPECT_NE(result.error_message.find("two-day limit"), std::string::npos); + platform.shutdown(); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/market_data_tick_continuity_test.cpp b/tests/market_data_tick_continuity_test.cpp index 0c068ce..2aba117 100644 --- a/tests/market_data_tick_continuity_test.cpp +++ b/tests/market_data_tick_continuity_test.cpp @@ -815,6 +815,39 @@ TEST(MarketDataTickContinuity, SupportsRouteTimeAndPricesIdentityOverride) { EXPECT_DOUBLE_EQ(subscriber->ticks.front().items[1].bid, 1.1); } +TEST(MarketDataTickContinuity, PreservesDistinctReferencePricesAcrossIdentityModes) { + const MarketDataTickDeduplicationMode modes[] = { + MarketDataTickDeduplicationMode::EXACT_OBSERVATION, + MarketDataTickDeduplicationMode::TIME_AND_PRICES}; + + for (const auto mode : modes) { + SCOPED_TRACE(static_cast(mode)); + ScopedTestClock clock(3000); + FakeTickHistoryProvider provider; + provider.deduplication_mode = mode; + auto subscriber = std::make_shared(); + MarketDataRouter router; + + auto route = router.subscribe_ticks( + provider, + subscriber, + continuity_request(MarketDataContinuityMode::PREFILL, 2000)); + ASSERT_TRUE(route.valid()); + + Tick first; + first.time_ms = 1000; + first.reference = 1.20000; + Tick second = first; + second.reference = 1.20010; + provider.complete_history(make_history({first, second})); + + ASSERT_EQ(subscriber->ticks.size(), 1U); + ASSERT_EQ(subscriber->ticks.front().items.size(), 2U); + EXPECT_DOUBLE_EQ(subscriber->ticks.front().items[0].reference, 1.20000); + EXPECT_DOUBLE_EQ(subscriber->ticks.front().items[1].reference, 1.20010); + } +} + TEST(MarketDataTickContinuity, UsesBoundedGapRequests) { ScopedTestClock clock(10000); FakeTickHistoryProvider provider; diff --git a/tests/market_data_tick_history_contract_test.cpp b/tests/market_data_tick_history_contract_test.cpp index 2f99b49..4457ab9 100644 --- a/tests/market_data_tick_history_contract_test.cpp +++ b/tests/market_data_tick_history_contract_test.cpp @@ -48,12 +48,27 @@ TickSequence make_ticks(std::initializer_list times) { } TEST(MarketDataTickHistoryContract, ValidatesInclusiveMillisecondRange) { - EXPECT_TRUE(TickHistoryRequest("EURUSD", 1000, 2000).valid()); + const TickHistoryRequest default_request("EURUSD", 1000, 2000); + EXPECT_TRUE(default_request.valid()); + EXPECT_EQ(default_request.source, TickHistorySource::LOCAL_OBSERVED); + EXPECT_EQ(to_str(TickHistorySource::BROKER_HISTORICAL), + std::string("BROKER_HISTORICAL")); EXPECT_FALSE(TickHistoryRequest("", 1000, 2000).valid()); EXPECT_FALSE(TickHistoryRequest("EURUSD", 0, 2000).valid()); EXPECT_FALSE(TickHistoryRequest("EURUSD", 2000, 1000).valid()); } +TEST(MarketDataTickHistoryContract, NeutralReferencePriceDoesNotClaimAQuoteSide) { + Tick tick; + tick.reference = 1.23456; + + EXPECT_DOUBLE_EQ(tick.mid_price(), 1.23456); + EXPECT_DOUBLE_EQ(tick.ask, 0.0); + EXPECT_DOUBLE_EQ(tick.bid, 0.0); + EXPECT_DOUBLE_EQ(tick.last, 0.0); + EXPECT_EQ(market_price_type(tick.flags), MarketPriceType::UNKNOWN); +} + TEST(MarketDataTickHistoryContract, ProviderHookReturnsTypedResult) { FakeTickHistoryProvider provider; provider.next_sequence = make_ticks({1000, 2000}); @@ -208,6 +223,29 @@ TEST(MarketDataTickHistoryContract, ServiceRejectsForeignSymbol) { EXPECT_NE(failure.error_desc.find("symbol"), std::string::npos); } +TEST(MarketDataTickHistoryContract, ServiceAcceptsEquivalentNormalizedSymbol) { + FakeTickHistoryProvider provider; + provider.next_sequence = make_ticks({1000, 2000}); + provider.next_sequence.symbol = "EUR/USD"; + MarketDataContinuityService service(provider); + std::unique_ptr batch; + TickHistoryResult failure; + + ASSERT_TRUE(service.request_tick_history_batch( + TickHistoryRequest("eurusd", 1000, 2000), + {}, + [&batch](std::unique_ptr update) { + batch = std::move(update); + }, + [&failure](TickHistoryResult result) { + failure = std::move(result); + })); + + ASSERT_TRUE(batch); + EXPECT_TRUE(failure.error_desc.empty()); + EXPECT_EQ(batch->symbol, "EUR/USD"); +} + TEST(MarketDataTickHistoryContract, ServiceRejectsUnorderedOrOutOfRangeTicks) { FakeTickHistoryProvider provider; provider.next_sequence = make_ticks({2000, 1000});