Build 060.30: finalize Market Data Acquisition documentation

This commit is contained in:
2026-08-03 23:30:41 +03:00
parent 8c98de9acc
commit 64a5bdd04c
61 changed files with 13980 additions and 1873 deletions

View File

@@ -0,0 +1,585 @@
# Trades Feed (Time & Sales) — текущая архитектура
**Статус:** Current; accepted in Build 060.30.1
**Область:** Production vertical slice Canonical Trades
**Версия документа:** 1.0
---
## Связанные документы
- [Архитектура Build 060.30](../migrations/build_060_30_architecture.md)
- [Эксплуатация Trade Stream Runtime](../operations/trades_feed_runtime.md)
- [Build 060.27 — Persistent Market Data Storage](../migrations/build_060_27.md)
- [Build 060.28 — Persistent Checkpoint](../migrations/build_060_28.md)
- [Build 060.29 — Market Data Access and Replay](../migrations/build_060_29.md)
---
## 1. Назначение и фактический scope
Trades Feed получает сделки Dzengi, преобразует их в Canonical `Trade`,
проверяет порядок и дубликаты, восстанавливает разрывы через REST и при
включённом Storage атомарно сохраняет сделку вместе с persistent
checkpoint.
После перезапуска Runtime восстанавливает Consistency state из
PostgreSQL, выполняет Startup Recovery и только затем пропускает
накопленные live-сообщения. Сохранённые Canonical Market Data доступны
через отдельные Historical Access и deterministic Replay API.
Краткая итоговая цепочка:
```text
Dzengi WebSocket → Transport → validation/mapping → Canonical Trade ─┐
├→ Consistency
Dzengi REST Recovery → validation/mapping → Canonical Trade ─────────┘
optional PostgreSQL Trade + checkpoint
├→ next persistent startup
│ → Hydration
│ → Consistency state
│ → Startup Recovery
├→ explicit caller
│ → Historical Access
└→ explicit caller
→ Replay Plan
→ Replay Session → consumer
```
Production ingestion и persistence в этой цепочке реализованы только
для Trades. Наличие Quote/Candle моделей и repositories не означает,
что Quotes и Candles уже подключены как Production feeds.
---
## 2. Матрица готовности Market Data
| Возможность | Trades | Quotes | Candle revisions |
|---|---:|---:|---:|
| Canonical model и Dzengi adapters | Да | Да | Да |
| Production WebSocket Runtime consumer | Да | Нет | Нет |
| Live/Recovery Consistency | Да | Нет | Нет |
| Production persistence wiring | Да | Нет | Нет |
| PostgreSQL repository и schema | Да | Да | Да |
| Persistent operational checkpoint | Да | Нет | Нет |
| Historical reader | Да | Да | Да |
| Bounded deterministic Replay | Да | Да | Да |
Другие acquisition feeds проекта не входят в завершённую production
вертикаль Trades Feed.
---
## 3. Режимы по feature flags
| Trade Stream | Market Data Storage | Фактическое поведение |
|---|---|---|
| выключен | выключен | Trade Runtime и Market Data pool не создаются |
| включён | выключен | Live, in-memory Consistency и reconnect Recovery без restart persistence |
| включён | включён | Durable Trades, checkpoint, Hydration и Startup Recovery |
| выключен | включён | Недопустимая конфигурация; settings завершаются ошибкой |
Оба флага по умолчанию выключены. При включённом Trade Stream требуются
явные WebSocket URL, REST base URL и symbols. Скрытых URL или symbol
fallback нет.
Без Storage первоначальный startup подписывается без persistent
Hydration, ожидания startup ACK и Startup REST Recovery. Reconnect
Recovery во время уже работающего процесса остаётся доступным.
---
## 4. Карта компонентов
| Граница | Ответственность | Основные компоненты |
|---|---|---|
| Transport | WebSocket lifecycle, send/receive, Ping/Pong probe | `DzengiWebSocketTransport`, `WebSocketSession` |
| Protocol | Команды, события, subscription и control routing | `AcquisitionRuntimeService`, `WebSocketSubscriptionManager` |
| Adapter | JSON/schema/value validation и Canonical mapping | `DzengiUnifiedWebSocketAdapter`, REST adapter |
| Runtime | Startup, receive loop, buffering, tasks и shutdown | `TradeStreamProductionRuntime` |
| Consistency | Порядок, deduplication и per-symbol state | `TradeStreamConsistencyController`, `TradeStreamStateStore` |
| Recovery | REST windows и повторная подача в Consistency | `RuntimeRecoveryCoordinator`, `TradeRecoveryController` |
| Persistence | Canonical write, provenance и checkpoint | `TradeStorageObservationSink`, `PostgresTradeRepository` |
| Startup | Hydration durable tail и Startup Recovery | `TradeStreamStateHydrator`, `RuntimeStartupRecoveryCoordinator` |
| Read-side | Historical pages | `MarketDataHistoricalAccess`, PostgreSQL readers |
| Replay | Snapshot, virtual clock и one-shot playback | `PostgresReplayPlanBuilder`, `ReplaySessionFactory`, `ReplaySession` |
| Composition | Concrete dependency graph и root lifecycle | `bootstrap`, `ApplicationComposition` |
Transport не знает Canonical models, Recovery или PostgreSQL. Bootstrap
является внешним composition root и создаёт concrete adapters.
---
## 5. Canonical Trade и Consistency
Один `TradeStreamState` принадлежит одному нормализованному symbol.
Состояние хранит:
- последнюю принятую сделку;
- in-memory checkpoint;
- ограниченное окно Trade ID для deduplication;
- Canonical payload уже наблюдавшихся сделок.
Размер deduplication tail по умолчанию равен 10 000 Trades.
Trade ID соответствует signed 32-bit контракту. Сравнение учитывает
rollover:
- `INT32_MAX → INT32_MIN` является продвижением;
- `-1 → 0` является продвижением;
- расстояние ровно в половину 32-битного цикла неоднозначно и
отклоняется.
Две доставки считаются одним биржевым фактом, если совпадают symbol,
Trade ID, цена, количество, execution time и aggressor side. Поле
`source` описывает путь доставки WebSocket/REST и не участвует в этом
сравнении. При этом `source` сохраняется как часть Canonical payload и
provenance.
Конфликтующий дубликат и нарушение rollover-aware порядка являются
ошибками, а не молча отбрасываемыми данными.
Исходники:
- [TradeStreamState](../../app/src/market_data/acquisition/consistency/trade_stream_state.py)
- [Consistency Controller](../../app/src/market_data/acquisition/consistency/trade_stream_consistency_controller.py)
- [Trade ID sequence](../../app/src/market_data/acquisition/trade_id_sequence.py)
---
## 6. Live processing
Точная последовательность одного live-документа:
```text
WebSocket receive
→ зафиксировать transport activity
→ опубликовать Runtime Event
→ JSON decode
→ войти в LiveProcessingGate
→ разделить control и market document
→ Dzengi adapter
→ Canonical Trade
→ общий Consistency Controller
→ optional durable sink
→ продвинуть in-memory state
```
Blocking adapter/Consistency/Persistence path выполняется через
`asyncio.to_thread`. Runtime владеет worker-задачей и при cancellation
дожидается уже начатой обработки, чтобы не оставить неизвестный
результат записи.
При выключенном Storage Consistency продвигает только память процесса.
При включённом Storage durable callback завершается до изменения
in-memory checkpoint.
Runtime events доставляются in-process последовательно. Publisher не
имеет фоновой очереди или отдельной root task; production composition
регистрирует logging consumer.
Исходники:
- [Production Runtime](../../app/src/market_data/acquisition/runtime/trade_stream_production_runtime.py)
- [Acquisition Service](../../app/src/market_data/acquisition/trade_stream_acquisition_service.py)
- [Runtime Event Publisher](../../app/src/market_data/acquisition/runtime/acquisition_runtime_event_publisher.py)
---
## 7. Durable write и persistent checkpoint
Для новой принятой сделки гарантия имеет следующий порядок:
```text
BEGIN
→ insert либо validate identical Trade
→ compare-and-set persistent checkpoint
→ COMMIT
→ advance in-memory state
```
Trade и checkpoint записываются одним `PostgresTradeRepository` и одной
транзакцией. Ошибка вставки, payload conflict или checkpoint conflict
откатывает операцию и не продвигает in-memory state.
Полный дубликат обрабатывается отдельно:
```text
validate stored Trade
→ optional provenance update
→ persistent checkpoint не двигается
→ in-memory checkpoint не двигается
```
Идентичность строки Trade:
```text
venue + symbol + trade_id + executed_at
```
Persistent checkpoint имеет один ключ `venue + symbol` и ссылается на
точную durable Trade identity через `DEFERRABLE INITIALLY DEFERRED`
foreign key с `NO ACTION`.
Parent table Trades partitioned по диапазону `executed_at`; Quotes — по
`received_at`, Candle revisions — по `open_time`. Миграции всегда
создают default partition. Конкретная месячная UTC-partition появляется
только после явного вызова Partition Manager. Symbol и venue являются
identity/query scope, но не отдельными физическими уровнями
partitioning.
Исходники:
- [Trade Storage Sink](../../app/src/market_data/storage/trade_storage_observation_sink.py)
- [PostgreSQL Trade Repository](../../app/src/market_data/storage/postgres_trade_repository.py)
- [Storage migrations](../../app/src/storage/migrations.py)
---
## 8. Startup с persistent state
Application сначала открывает Market Data pool и применяет migrations.
Только после успешного Storage startup запускаются Telegram polling и
Trade Runtime tasks.
Persistent Trade Runtime выполняет:
```text
load checkpoint и bounded durable tail
→ либо атомарно принять последнюю durable Trade при первом запуске
→ опубликовать восстановленный StateStore целиком
→ WebSocket connect
→ отправить subscriptions
→ дождаться соответствующего ACK
→ сохранить ранние market documents в bounded FIFO
→ REST Recovery до одной зафиксированной границы времени
→ обработать FIFO в исходном порядке
→ запустить Supervisor, receive loop и Scheduler
```
Если для symbol нет checkpoint и durable Trades, state создаётся пустым;
исторический backfill с биржи до начала доступного REST-окна не
выполняется.
Если durable Trades есть, а checkpoint отсутствует, Hydrator может
атомарно принять последнюю durable Trade как начальную точку. Corrupt,
orphan или несовместимый checkpoint является фатальной ошибкой. Тихого
сброса checkpoint нет.
Startup buffer по умолчанию ограничен 10 000 documents. Negative ACK,
ACK timeout, overflow, Hydration error и Recovery error не открывают
Live gate.
Исходники:
- [State Hydrator](../../app/src/market_data/acquisition/checkpoint/trade_stream_state_hydrator.py)
- [Startup Recovery Coordinator](../../app/src/market_data/acquisition/runtime/runtime_startup_recovery_coordinator.py)
- [Application lifecycle](../../app/src/bootstrap/application.py)
---
## 9. Reconnect и Recovery
Transport receive error и Heartbeat timeout используют одну
generation-aware single-flight операцию:
```text
закрыть общий Live gate
→ Disconnect
→ Connect
→ переотправить desired subscriptions
→ зафиксировать recovery_end_time
→ последовательно выполнить REST Recovery для symbols
→ тот же Consistency Controller и durable sink
→ открыть Live gate
```
Вторая причина reconnect для того же connection generation
присоединяется к уже выполняющейся операции. Старое transport error не
запускает новый reconnect после смены generation.
Важные границы:
- Reconnect выполняет одну попытку; retry loop и backoff отсутствуют.
- Restore subscriptions означает успешную повторную отправку; отдельное
ожидание ACK перед reconnect Recovery не реализовано.
- Recovery использует REST `/api/v1/aggTrades` через настроенный base URL.
- Окна ограничены `TRADE_STREAM_RECOVERY_WINDOW_MS`, по умолчанию
3 599 999 ms.
- Возможный повтор на границе окон удаляет общий Consistency layer.
- Если state/checkpoint отсутствует, Recovery не придумывает начальную
историю и возвращает пустой результат.
- Ошибка reconnect или Recovery переводит gate в terminal failure и
распространяется в Application.
Recovery гарантирует упорядоченную обработку фактически полученных REST
данных, но не может гарантировать полноту данных, которых нет в ответе
биржи.
Исходники:
- [Reconnect Coordinator](../../app/src/market_data/acquisition/runtime/reconnect.py)
- [Reconnect Recovery Coordinator](../../app/src/market_data/acquisition/runtime/runtime_reconnect_recovery_coordinator.py)
- [Runtime Recovery Coordinator](../../app/src/market_data/acquisition/runtime/runtime_recovery_coordinator.py)
- [Trade Recovery Controller](../../app/src/market_data/acquisition/recovery/trade_recovery_controller.py)
---
## 10. Liveness, Heartbeat и Scheduler
Встроенный WebSocket keepalive библиотеки отключён. Источником
transport liveness является явный Ping/Pong probe.
Scheduler периодически:
1. вызывает transport probe;
2. при положительном ответе обновляет Heartbeat activity;
3. проверяет Heartbeat timeout;
4. передаёт подтверждённый timeout Supervisor.
Любое успешно полученное WebSocket-сообщение также считается activity.
Отрицательный probe сам по себе не запускает reconnect немедленно:
решение принимается по Heartbeat timeout. Supervisor не допускает
параллельный второй timeout reconnect.
Scheduler не владеет своей asyncio task. Task принадлежит Production
Runtime, который предварительно `claim()`-ит Scheduler и освобождает его
после остановки.
Исходники:
- [Heartbeat](../../app/src/market_data/acquisition/runtime/heartbeat.py)
- [Scheduler](../../app/src/market_data/acquisition/runtime/scheduler.py)
- [Supervisor](../../app/src/market_data/acquisition/runtime/supervisor.py)
---
## 11. Historical Access
Historical Access является отдельной synchronous read-side границей и
не расширяет write-only Storage facade.
Поддерживаются:
- Trade History;
- Quote History;
- Candle Revision History.
Общие свойства:
- timezone-aware полуоткрытый диапазон `[start_time, end_time)`;
- forward-only keyset pagination;
- строгий cursor scope;
- Canonical validation прочитанных PostgreSQL rows;
- limit по умолчанию 500, максимум 1 000.
Порядок страниц:
| Тип | Порядок |
|---|---|
| Trade | `executed_at`, затем `replay_sequence` |
| Quote | `received_at`, затем `replay_sequence` |
| Candle revision | `open_time`, затем `replay_sequence` |
Каждая страница выполняет отдельный запрос. Между страницами не
удерживается одна `REPEATABLE READ` транзакция, поэтому concurrent writes
или Retention могут изменить dataset следующего запроса.
Исходники:
- [Historical Access facade](../../app/src/market_data/access/market_data_historical_access.py)
- [Historical models](../../app/src/market_data/access/models.py)
---
## 12. Deterministic Replay
Replay отделён от live Runtime:
```text
caller
→ ReplaySessionFactory.prepare_session()
→ PostgresReplayPlanBuilder
→ READ ONLY REPEATABLE READ snapshot
→ bounded immutable ReplayPlan
→ закрыть transaction и connection
→ fresh Clock + fresh consumer + fresh ReplaySession
→ caller await session.run()
```
ReplayPlan ограничен максимум 100 000 events. Превышение limit приводит
к явной ошибке без частичного плана.
Глобальный порядок задаётся парой:
```text
replay_at + replay_sequence
```
| Тип | `replay_at` |
|---|---|
| Trade | `executed_at` |
| Quote | `received_at` |
| Candle revision | `observed_at` |
`replay_sequence` выдаётся одним PostgreSQL sequence для всех трёх
семейств и остаётся неизменяемым.
Clock начинается в `request.time_range.start_time`, разрешает равное
время и запрещает движение назад. Session является one-shot:
```text
CREATED → RUNNING → COMPLETED | FAILED | CANCELLED
```
Повторный `run()` запрещён. Отдельного `ReplayEngine`, default consumer,
background task, automatic startup и wall-clock pacing нет. Вызвавший
код предоставляет `ReplayConsumerFactoryProtocol`. Затем
`ReplaySessionFactory.prepare_session()` создаёт fresh consumer, Clock и
Session; caller владеет полученной Session и её `run()`.
Исходники:
- [Replay Plan Builder](../../app/src/market_data/replay/postgres_replay_plan_builder.py)
- [Replay Session Factory](../../app/src/market_data/replay/replay_session_factory.py)
- [Replay Session](../../app/src/market_data/replay/replay_session.py)
---
## 13. Ownership и lifecycle
| Ресурс | Владелец |
|---|---|
| PostgreSQL pool и migrations | Application через `MarketDataStorageLifecycle` |
| Telegram polling и root Runtime task | `run_application()` |
| startup, receive, scheduler и market-processing tasks | `TradeStreamProductionRuntime` |
| Heartbeat state | `RuntimeSupervisor` |
| reconnect/recovery single-flight | `RuntimeReconnectRecoveryCoordinator` |
| Hydration/Startup Recovery worker | Runtime через `RuntimeStartupRecoveryCoordinator` |
| WebSocket connection | `DzengiWebSocketTransport` через Session |
| Runtime Event delivery | caller `publish()`; фонового owner нет |
| Historical connection/cursor | один repository call |
| Replay snapshot transaction | `PostgresReplayPlanBuilder.create_plan()` |
| Replay execution | caller `session.run()` |
Application shutdown:
```text
отменить Telegram polling
→ runtime.stop()
→ завершить startup worker, если он выполняется
→ остановить и дождаться Scheduler
→ остановить Supervisor
→ отменить и дождаться receive loop
→ остановить WebSocket Session
→ очистить subscriptions
→ дождаться root Runtime task
→ закрыть PostgreSQL pool
→ закрыть bot HTTP session
```
Ошибка включённого Runtime или persistent write считается фатальной для
всего приложения. Degraded fallback к in-memory режиму не выполняется.
---
## 14. Направление зависимостей
```text
bootstrap
concrete Dzengi adapters + Runtime composition + PostgreSQL adapters
acquisition → Canonical models + own Protocol boundaries
→ narrow Storage checkpoint contracts/exceptions
Dzengi REST adapter → integrations.exchange REST client
storage → Canonical models
access → Canonical models + read contracts
replay → Canonical models + access row materialization
```
Подтверждённые границы:
- `market_data` не импортирует Telegram, Trading или Bootstrap;
- Bootstrap импортирует concrete adapters как composition root;
- Acquisition не зависит от Historical Access или Replay;
- Production Runtime не импортирует concrete PostgreSQL repository;
- Replay не зависит от Live Runtime и не вызывает Consistency;
- Dzengi-specific код преимущественно находится в
`acquisition/adapters/dzengi`, но provider subscription document и
часть validation/handlers физически размещены в соседних acquisition
пакетах.
Canonical models сейчас физически находятся в
`market_data/acquisition/models`. Это фактическое расположение, а не
утверждение, что общая Domain package уже выделена.
---
## 15. Failure policy
| Ошибка | Поведение |
|---|---|
| Неверные settings | Fail fast до запуска Runtime |
| Storage pool или migration | Application startup завершается ошибкой |
| Corrupt/orphan checkpoint | Startup завершается ошибкой |
| Subscription ACK timeout/negative ACK при persistent startup | Live gate остаётся закрытым, Runtime завершается |
| Startup buffer overflow | Runtime завершается |
| WebSocket receive failure | Одна generation-aware reconnect/recovery попытка |
| Reconnect или REST Recovery failure | Terminal gate failure и завершение Application |
| Canonical conflict/order error | Не принимается и распространяется как ошибка |
| Persistent write/checkpoint conflict | Transaction rollback, in-memory state не двигается |
| Replay consumer failure | Session переходит в `FAILED` |
| Replay cancellation | Session переходит в `CANCELLED` |
Ошибки не превращаются в молчаливое продолжение с потенциально
несогласованным состоянием.
---
## 16. Доступный период и ограничения
Доступная история начинается не с момента существования инструмента на
бирже, а с наиболее ранней Canonical записи, которая фактически
находится в PostgreSQL. Обычно это момент успешного включения Storage,
но история может также включать ранее импортированные durable rows.
Граница может сдвигаться из-за явно применённой Retention Policy.
Система не предоставляет:
- initial exchange historical backfill;
- completeness metadata и доказательство полной биржевой истории;
- storage raw exchange documents;
- automatic monthly partition creation;
- automatic Retention Scheduler;
- Production persistence Quotes/Candles;
- общий snapshot между Historical pages;
- восстановление исходного порядка сетевых пакетов;
- Replay HTTP/CLI/UI endpoint;
- automatic Replay startup;
- infinite reconnect retry/backoff;
- HA, leader election, distributed lease или multi-instance ownership;
- автоматические backup, metrics и alerting.
Default PostgreSQL partitions принимают строки без заранее созданной
месячной partition. Явный Partition Manager позднее может атомарно
перенести соответствующие строки.
Migration 9 выполняет блокирующий backfill `replay_sequence`. Для
крупной базы нужны backup, измерение на сопоставимом объёме и отдельное
maintenance window.
---
## 17. Безопасная итоговая формулировка
Trades Feed реализует единый Canonical Trade pipeline с live-получением,
in-process consistency, generation-aware reconnect, REST gap recovery,
опциональной durable-записью и persistent startup recovery. Historical
Access и deterministic Replay работают над фактически сохранёнными
Canonical Market Data. Полнота истории ограничена моментом включения
persistence, доступностью REST Recovery и применяемой Retention Policy.