Build 060.23: integrate Trade Stream Acquisition
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# app/src/market_data/acquisition/trade_stream_acquisition_protocol.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Публичный контракт интеграции Trade Stream в Acquisition Layer.
|
||||
|
||||
Build 060.23 вводит единую сервисную границу между:
|
||||
|
||||
- Trade Subscription Layer;
|
||||
- Acquisition Runtime Service;
|
||||
- Unified WebSocket Adapter;
|
||||
- Trade Stream Consistency.
|
||||
|
||||
Контракт не определяет production WebSocket transport, receive-loop,
|
||||
reconnect, recovery, хранение или публикацию канонических Trade.
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TradeStreamAcquisitionServiceProtocol(Protocol):
|
||||
"""
|
||||
Контракт сервиса интеграции Trade Stream.
|
||||
|
||||
Сервис отвечает за:
|
||||
|
||||
- передачу команды подписки в Acquisition Runtime;
|
||||
- преобразование одного входящего WebSocket-документа;
|
||||
- передачу канонического Trade в Consistency Layer;
|
||||
- возврат согласованного Trade либо None для дубликата.
|
||||
|
||||
Сервис не владеет WebSocket lifecycle и не запускает receive-loop.
|
||||
"""
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
symbols: tuple[str, ...],
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Передать в Acquisition Runtime команду подписки Trade Stream.
|
||||
|
||||
Args:
|
||||
symbols:
|
||||
Символы торговых инструментов для подписки.
|
||||
|
||||
correlation_id:
|
||||
Необязательный идентификатор транспортного запроса.
|
||||
При отсутствии значение создаётся Subscription Builder.
|
||||
"""
|
||||
...
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
document: object,
|
||||
) -> Trade | None:
|
||||
"""
|
||||
Обработать один входящий WebSocket-документ.
|
||||
|
||||
Args:
|
||||
document:
|
||||
Сырой документ, полученный из WebSocket Runtime.
|
||||
|
||||
Returns:
|
||||
Канонический Trade после проверки согласованности;
|
||||
None, если сообщение не относится к Trade либо является
|
||||
корректным дубликатом.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,111 @@
|
||||
# app/src/market_data/acquisition/trade_stream_acquisition_service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Сервис интеграции Trade Stream с инфраструктурой Acquisition Runtime.
|
||||
|
||||
Build 060.23 вводит сервисную границу между:
|
||||
|
||||
- Trade Subscription Layer;
|
||||
- Acquisition Runtime Service;
|
||||
- Unified WebSocket Adapter;
|
||||
- Trade Stream Consistency.
|
||||
|
||||
На текущем этапе реализована передача команды подписки.
|
||||
Обработка входящих сообщений будет добавлена следующим шагом Build.
|
||||
"""
|
||||
|
||||
from src.market_data.acquisition.trade_stream_message_adapter_protocol import (
|
||||
TradeStreamMessageAdapterProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_protocol import (
|
||||
TradeStreamConsistencyProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
from src.market_data.acquisition.runtime.acquisition_runtime_service_protocol import (
|
||||
AcquisitionRuntimeServiceProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.subscriptions.trades import (
|
||||
build_trade_subscribe_command,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_acquisition_protocol import (
|
||||
TradeStreamAcquisitionServiceProtocol,
|
||||
)
|
||||
|
||||
|
||||
class TradeStreamAcquisitionService(
|
||||
TradeStreamAcquisitionServiceProtocol,
|
||||
):
|
||||
"""
|
||||
Координатор инфраструктуры получения Trade Stream.
|
||||
|
||||
Сервис объединяет:
|
||||
|
||||
- Acquisition Runtime;
|
||||
- Unified WebSocket Adapter;
|
||||
- Trade Stream Consistency.
|
||||
|
||||
При этом сам сервис не реализует транспорт,
|
||||
жизненный цикл WebSocket либо Recovery.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_service: AcquisitionRuntimeServiceProtocol,
|
||||
adapter: TradeStreamMessageAdapterProtocol,
|
||||
consistency_controller: TradeStreamConsistencyProtocol,
|
||||
) -> None:
|
||||
self._runtime_service = runtime_service
|
||||
self._adapter = adapter
|
||||
self._consistency_controller = consistency_controller
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
symbols: tuple[str, ...],
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Передать в Acquisition Runtime команду подписки Trade Stream.
|
||||
|
||||
Args:
|
||||
symbols:
|
||||
Символы торговых инструментов для подписки.
|
||||
|
||||
correlation_id:
|
||||
Необязательный идентификатор транспортного запроса.
|
||||
При отсутствии значение создаётся Subscription Builder.
|
||||
"""
|
||||
command = build_trade_subscribe_command(
|
||||
symbols,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
await self._runtime_service.dispatch(command)
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
document: object,
|
||||
) -> Trade | None:
|
||||
"""
|
||||
Обработать один входящий WebSocket-документ.
|
||||
|
||||
Документ преобразуется через Unified Adapter. Только канонический
|
||||
Trade передаётся в Trade Stream Consistency.
|
||||
|
||||
Args:
|
||||
document:
|
||||
Сырой WebSocket-документ.
|
||||
|
||||
Returns:
|
||||
Trade после проверки согласованности;
|
||||
None, если сообщение не относится к Trade либо является
|
||||
корректным дубликатом.
|
||||
"""
|
||||
result = self._adapter.map_message(document)
|
||||
|
||||
if not isinstance(result, Trade):
|
||||
return None
|
||||
|
||||
return self._consistency_controller.accept(result)
|
||||
@@ -0,0 +1,40 @@
|
||||
# app/src/market_data/acquisition/trade_stream_message_adapter_protocol.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Контракт адаптера входящих сообщений Trade Stream.
|
||||
|
||||
Build 060.23 отделяет сервис координации Acquisition
|
||||
от конкретной реализации WebSocket-протокола биржи.
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.models.candle_close import (
|
||||
CandleCloseEvent,
|
||||
)
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
|
||||
|
||||
TradeStreamMappedMessage = Quote | CandleCloseEvent | Trade
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TradeStreamMessageAdapterProtocol(Protocol):
|
||||
"""
|
||||
Контракт преобразования одного входящего WebSocket-документа.
|
||||
|
||||
Реализация может быть exchange-specific, но вызывающий сервис
|
||||
зависит только от результата преобразования.
|
||||
"""
|
||||
|
||||
def map_message(
|
||||
self,
|
||||
document: object,
|
||||
) -> TradeStreamMappedMessage:
|
||||
"""
|
||||
Преобразовать один WebSocket-документ в каноническую модель.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,117 @@
|
||||
# app/tests/unit/market_data/acquisition/test_dzengi_trade_stream_acquisition_integration.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||
DzengiUnifiedWebSocketAdapter,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeCommand,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_acquisition_service import (
|
||||
TradeStreamAcquisitionService,
|
||||
)
|
||||
|
||||
|
||||
class FakeRuntimeService:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[AcquisitionRuntimeCommand] = []
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
command: AcquisitionRuntimeCommand,
|
||||
) -> None:
|
||||
self.commands.append(command)
|
||||
|
||||
|
||||
class RecordingConsistencyController:
|
||||
def __init__(self) -> None:
|
||||
self.accepted_trades: list[Trade] = []
|
||||
|
||||
def accept(
|
||||
self,
|
||||
trade: Trade,
|
||||
) -> Trade | None:
|
||||
self.accepted_trades.append(trade)
|
||||
return trade
|
||||
|
||||
|
||||
def _dzengi_trade_document() -> object:
|
||||
return {
|
||||
"status": "OK",
|
||||
"destination": "internal.trade",
|
||||
"payload": {
|
||||
"id": 2134857062,
|
||||
"price": "64497.25",
|
||||
"size": "0.005",
|
||||
"ts": 1784218066823,
|
||||
"symbol": "BTC/USD_LEVERAGE",
|
||||
"buyer": True,
|
||||
"orderId": "order-123",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_dzengi_trade_document_passes_complete_acquisition_pipeline() -> None:
|
||||
runtime = FakeRuntimeService()
|
||||
consistency = RecordingConsistencyController()
|
||||
|
||||
service = TradeStreamAcquisitionService(
|
||||
runtime_service=runtime,
|
||||
adapter=DzengiUnifiedWebSocketAdapter(),
|
||||
consistency_controller=consistency,
|
||||
)
|
||||
|
||||
result = service.handle_message(
|
||||
_dzengi_trade_document(),
|
||||
)
|
||||
|
||||
assert isinstance(result, Trade)
|
||||
assert consistency.accepted_trades == [result]
|
||||
|
||||
assert result.symbol == "BTC/USD_LEVERAGE"
|
||||
assert result.trade_id == 2134857062
|
||||
assert result.price == Decimal("64497.25")
|
||||
assert result.quantity == Decimal("0.005")
|
||||
assert result.executed_at == datetime.fromtimestamp(
|
||||
1784218066823 / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
assert result.aggressor_side is TradeAggressorSide.BUY
|
||||
assert result.source == "dzengi_websocket_trade"
|
||||
|
||||
|
||||
def test_dzengi_duplicate_trade_is_rejected_by_real_consistency_pipeline() -> None:
|
||||
from src.market_data.acquisition.consistency.trade_stream_consistency_controller import (
|
||||
TradeStreamConsistencyController,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
TradeStreamStateStore,
|
||||
)
|
||||
|
||||
runtime = FakeRuntimeService()
|
||||
|
||||
consistency = TradeStreamConsistencyController(
|
||||
state_store=TradeStreamStateStore(),
|
||||
)
|
||||
|
||||
service = TradeStreamAcquisitionService(
|
||||
runtime_service=runtime,
|
||||
adapter=DzengiUnifiedWebSocketAdapter(),
|
||||
consistency_controller=consistency,
|
||||
)
|
||||
|
||||
document = _dzengi_trade_document()
|
||||
|
||||
first_result = service.handle_message(document)
|
||||
duplicate_result = service.handle_message(document)
|
||||
|
||||
assert isinstance(first_result, Trade)
|
||||
assert duplicate_result is None
|
||||
@@ -0,0 +1,383 @@
|
||||
# app/tests/unit/market_data/acquisition/test_trade_stream_acquisition_service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.market_data.acquisition.models.candle_close import (
|
||||
CandleCloseEvent,
|
||||
)
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.models.trade import (
|
||||
Trade,
|
||||
TradeAggressorSide,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||
SubscribeCommand,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.transport_messages import (
|
||||
TransportTextMessage,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeCommand,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_acquisition_protocol import (
|
||||
TradeStreamAcquisitionServiceProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_acquisition_service import (
|
||||
TradeStreamAcquisitionService,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_message_adapter_protocol import (
|
||||
TradeStreamMappedMessage,
|
||||
)
|
||||
|
||||
|
||||
class FakeRuntimeService:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[AcquisitionRuntimeCommand] = []
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
command: AcquisitionRuntimeCommand,
|
||||
) -> None:
|
||||
self.commands.append(command)
|
||||
|
||||
|
||||
class FakeMessageAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
result: TradeStreamMappedMessage,
|
||||
*,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._result = result
|
||||
self._error = error
|
||||
self.documents: list[object] = []
|
||||
|
||||
def map_message(
|
||||
self,
|
||||
document: object,
|
||||
) -> TradeStreamMappedMessage:
|
||||
self.documents.append(document)
|
||||
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
return self._result
|
||||
|
||||
|
||||
class FakeConsistencyController:
|
||||
def __init__(
|
||||
self,
|
||||
result: Trade | None = None,
|
||||
*,
|
||||
use_input_trade: bool = True,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._result = result
|
||||
self._use_input_trade = use_input_trade
|
||||
self._error = error
|
||||
self.accepted_trades: list[Trade] = []
|
||||
|
||||
def accept(
|
||||
self,
|
||||
trade: Trade,
|
||||
) -> Trade | None:
|
||||
self.accepted_trades.append(trade)
|
||||
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
if self._use_input_trade:
|
||||
return trade
|
||||
|
||||
return self._result
|
||||
|
||||
|
||||
def _trade(
|
||||
*,
|
||||
trade_id: int = 2134857062,
|
||||
) -> Trade:
|
||||
return Trade(
|
||||
symbol="BTC/USD_LEVERAGE",
|
||||
trade_id=trade_id,
|
||||
price=Decimal("64497.25"),
|
||||
quantity=Decimal("0.005"),
|
||||
executed_at=datetime(
|
||||
2026,
|
||||
7,
|
||||
16,
|
||||
11,
|
||||
27,
|
||||
46,
|
||||
823000,
|
||||
tzinfo=timezone.utc,
|
||||
),
|
||||
aggressor_side=TradeAggressorSide.BUY,
|
||||
source="dzengi",
|
||||
)
|
||||
|
||||
|
||||
def _non_trade_quote() -> Quote:
|
||||
return cast(
|
||||
Quote,
|
||||
object.__new__(Quote),
|
||||
)
|
||||
|
||||
|
||||
def _non_trade_candle() -> CandleCloseEvent:
|
||||
return cast(
|
||||
CandleCloseEvent,
|
||||
object.__new__(CandleCloseEvent),
|
||||
)
|
||||
|
||||
|
||||
def create_service(
|
||||
*,
|
||||
adapter_result: TradeStreamMappedMessage | None = None,
|
||||
adapter_error: Exception | None = None,
|
||||
consistency_result: Trade | None = None,
|
||||
consistency_uses_input_trade: bool = True,
|
||||
consistency_error: Exception | None = None,
|
||||
) -> tuple[
|
||||
TradeStreamAcquisitionService,
|
||||
FakeRuntimeService,
|
||||
FakeMessageAdapter,
|
||||
FakeConsistencyController,
|
||||
]:
|
||||
runtime = FakeRuntimeService()
|
||||
|
||||
adapter = FakeMessageAdapter(
|
||||
adapter_result if adapter_result is not None else _trade(),
|
||||
error=adapter_error,
|
||||
)
|
||||
|
||||
consistency = FakeConsistencyController(
|
||||
consistency_result,
|
||||
use_input_trade=consistency_uses_input_trade,
|
||||
error=consistency_error,
|
||||
)
|
||||
|
||||
service = TradeStreamAcquisitionService(
|
||||
runtime_service=runtime,
|
||||
adapter=adapter,
|
||||
consistency_controller=consistency,
|
||||
)
|
||||
|
||||
return (
|
||||
service,
|
||||
runtime,
|
||||
adapter,
|
||||
consistency,
|
||||
)
|
||||
|
||||
|
||||
def test_service_implements_protocol() -> None:
|
||||
service, _, _, _ = create_service()
|
||||
|
||||
assert isinstance(
|
||||
service,
|
||||
TradeStreamAcquisitionServiceProtocol,
|
||||
)
|
||||
|
||||
|
||||
def test_subscribe_dispatches_subscribe_command() -> None:
|
||||
service, runtime, _, _ = create_service()
|
||||
|
||||
asyncio.run(
|
||||
service.subscribe(
|
||||
(
|
||||
"BTCUSDT",
|
||||
"ETHUSDT",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(runtime.commands) == 1
|
||||
|
||||
command = runtime.commands[0]
|
||||
|
||||
assert isinstance(
|
||||
command,
|
||||
SubscribeCommand,
|
||||
)
|
||||
|
||||
|
||||
def test_subscribe_preserves_symbols() -> None:
|
||||
service, runtime, _, _ = create_service()
|
||||
|
||||
asyncio.run(
|
||||
service.subscribe(
|
||||
(
|
||||
"BTCUSDT",
|
||||
"ETHUSDT",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
command = runtime.commands[0]
|
||||
|
||||
assert isinstance(
|
||||
command,
|
||||
SubscribeCommand,
|
||||
)
|
||||
|
||||
assert "BTCUSDT" in command.subscription_key
|
||||
assert "ETHUSDT" in command.subscription_key
|
||||
|
||||
|
||||
def test_subscribe_accepts_correlation_id() -> None:
|
||||
service, runtime, _, _ = create_service()
|
||||
|
||||
asyncio.run(
|
||||
service.subscribe(
|
||||
(
|
||||
"BTCUSDT",
|
||||
),
|
||||
correlation_id="corr-123",
|
||||
)
|
||||
)
|
||||
|
||||
command = runtime.commands[0]
|
||||
|
||||
assert isinstance(command, SubscribeCommand)
|
||||
assert isinstance(command.message, TransportTextMessage)
|
||||
|
||||
document = json.loads(command.message.payload)
|
||||
|
||||
assert document["correlationId"] == "corr-123"
|
||||
|
||||
|
||||
def test_handle_message_passes_trade_to_consistency() -> None:
|
||||
trade = _trade()
|
||||
document = {"destination": "internal.trade"}
|
||||
|
||||
service, _, adapter, consistency = create_service(
|
||||
adapter_result=trade,
|
||||
)
|
||||
|
||||
result = service.handle_message(document)
|
||||
|
||||
assert adapter.documents == [document]
|
||||
assert consistency.accepted_trades == [trade]
|
||||
assert result is trade
|
||||
|
||||
|
||||
def test_handle_message_preserves_consistency_result_identity() -> None:
|
||||
adapted_trade = _trade(trade_id=100)
|
||||
consistency_result = _trade(trade_id=101)
|
||||
|
||||
service, _, _, consistency = create_service(
|
||||
adapter_result=adapted_trade,
|
||||
consistency_result=consistency_result,
|
||||
consistency_uses_input_trade=False,
|
||||
)
|
||||
|
||||
result = service.handle_message(
|
||||
{"destination": "internal.trade"},
|
||||
)
|
||||
|
||||
assert consistency.accepted_trades == [adapted_trade]
|
||||
assert result is consistency_result
|
||||
|
||||
|
||||
def test_handle_message_returns_none_for_duplicate() -> None:
|
||||
trade = _trade()
|
||||
|
||||
service, _, _, consistency = create_service(
|
||||
adapter_result=trade,
|
||||
consistency_result=None,
|
||||
consistency_uses_input_trade=False,
|
||||
)
|
||||
|
||||
result = service.handle_message(
|
||||
{"destination": "internal.trade"},
|
||||
)
|
||||
|
||||
assert consistency.accepted_trades == [trade]
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_handle_message_ignores_quote() -> None:
|
||||
quote = _non_trade_quote()
|
||||
document = {"destination": "quote"}
|
||||
|
||||
service, _, adapter, consistency = create_service(
|
||||
adapter_result=quote,
|
||||
)
|
||||
|
||||
result = service.handle_message(document)
|
||||
|
||||
assert adapter.documents == [document]
|
||||
assert consistency.accepted_trades == []
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_handle_message_ignores_candle_close_event() -> None:
|
||||
candle = _non_trade_candle()
|
||||
document = {"destination": "ohlc"}
|
||||
|
||||
service, _, adapter, consistency = create_service(
|
||||
adapter_result=candle,
|
||||
)
|
||||
|
||||
result = service.handle_message(document)
|
||||
|
||||
assert adapter.documents == [document]
|
||||
assert consistency.accepted_trades == []
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_handle_message_calls_adapter_once() -> None:
|
||||
document = {"destination": "internal.trade"}
|
||||
|
||||
service, _, adapter, _ = create_service()
|
||||
|
||||
service.handle_message(document)
|
||||
|
||||
assert adapter.documents == [document]
|
||||
|
||||
|
||||
def test_handle_message_propagates_adapter_error() -> None:
|
||||
original = RuntimeError("adapter failed")
|
||||
|
||||
service, _, adapter, consistency = create_service(
|
||||
adapter_error=original,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="adapter failed") as exc_info:
|
||||
service.handle_message(
|
||||
{"destination": "internal.trade"},
|
||||
)
|
||||
|
||||
assert exc_info.value is original
|
||||
assert len(adapter.documents) == 1
|
||||
assert consistency.accepted_trades == []
|
||||
|
||||
|
||||
def test_handle_message_propagates_consistency_error() -> None:
|
||||
original = RuntimeError("consistency failed")
|
||||
trade = _trade()
|
||||
|
||||
service, _, adapter, consistency = create_service(
|
||||
adapter_result=trade,
|
||||
consistency_error=original,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="consistency failed",
|
||||
) as exc_info:
|
||||
service.handle_message(
|
||||
{"destination": "internal.trade"},
|
||||
)
|
||||
|
||||
assert exc_info.value is original
|
||||
assert len(adapter.documents) == 1
|
||||
assert consistency.accepted_trades == [trade]
|
||||
1419
docs/migrations/build_060_23.md
Normal file
1419
docs/migrations/build_060_23.md
Normal file
File diff suppressed because it is too large
Load Diff
845
docs/migrations/build_060_23_architecture.md
Normal file
845
docs/migrations/build_060_23_architecture.md
Normal file
@@ -0,0 +1,845 @@
|
||||
# Build 060.23 — Trade Stream Acquisition Integration Architecture
|
||||
|
||||
**Статус:** Architecture Approved
|
||||
|
||||
**Build:** 060.23
|
||||
|
||||
**Название:**
|
||||
Trade Stream Acquisition Integration
|
||||
|
||||
---
|
||||
|
||||
# 1. Назначение Build
|
||||
|
||||
Build 060.23 завершает построение инфраструктурного слоя Acquisition для Trades Feed.
|
||||
|
||||
После Build 060.22 Runtime уже обладает собственной сервисной моделью управления инфраструктурными командами:
|
||||
|
||||
- Runtime Commands;
|
||||
- Runtime Events;
|
||||
- Runtime Service;
|
||||
- Runtime Protocols.
|
||||
|
||||
Однако в настоящий момент Runtime полностью изолирован от существующего Acquisition Layer.
|
||||
|
||||
Trades Feed продолжает работать как синхронный REST Feed:
|
||||
|
||||
```
|
||||
REST document
|
||||
↓
|
||||
TradesHandler
|
||||
↓
|
||||
Trade
|
||||
```
|
||||
|
||||
а Runtime существует отдельно:
|
||||
|
||||
```
|
||||
Runtime Commands
|
||||
Runtime Events
|
||||
Runtime Service
|
||||
```
|
||||
|
||||
В результате отсутствует единая точка, которая соединяет:
|
||||
|
||||
- Runtime;
|
||||
- Subscription API;
|
||||
- WebSocket Adapter;
|
||||
- Consistency Layer.
|
||||
|
||||
Именно эту задачу решает Build 060.23.
|
||||
|
||||
---
|
||||
|
||||
# 2. Цели Build
|
||||
|
||||
Build вводит единый сервис интеграции Trade Stream.
|
||||
|
||||
Новый сервис становится входной точкой всей Runtime Acquisition Pipeline.
|
||||
|
||||
После завершения Build поток данных приобретает следующий вид:
|
||||
|
||||
```
|
||||
Subscribe()
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Subscription Builder
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Acquisition Runtime Service
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
WebSocket Runtime
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Unified Adapter
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Consistency Controller
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade | None
|
||||
```
|
||||
|
||||
При этом Build намеренно не реализует:
|
||||
|
||||
- production WebSocket;
|
||||
- reconnect;
|
||||
- heartbeat;
|
||||
- receive loop;
|
||||
- runtime scheduler;
|
||||
- recovery.
|
||||
|
||||
Все перечисленные компоненты остаются предметом следующих Build.
|
||||
|
||||
---
|
||||
|
||||
# 3. Архитектурная цель
|
||||
|
||||
Главная задача Build —
|
||||
|
||||
полностью отделить инфраструктуру Runtime
|
||||
от обработки Trade.
|
||||
|
||||
После Build Runtime больше не знает:
|
||||
|
||||
- что такое Trade;
|
||||
- что такое Feed;
|
||||
- что такое Consistency;
|
||||
- что такое Recovery.
|
||||
|
||||
Runtime работает исключительно с инфраструктурными командами.
|
||||
|
||||
Вся логика Trade переносится в отдельный слой Acquisition Integration.
|
||||
|
||||
---
|
||||
|
||||
# 4. Архитектурные принципы
|
||||
|
||||
Build следует тем же принципам, которые используются во всей новой архитектуре Acquisition.
|
||||
|
||||
## 4.1 Single Responsibility
|
||||
|
||||
Runtime отвечает исключительно за инфраструктуру соединения.
|
||||
|
||||
Trade Integration отвечает исключительно за обработку Trade Stream.
|
||||
|
||||
Consistency отвечает исключительно за целостность последовательности сделок.
|
||||
|
||||
Recovery отвечает исключительно за восстановление пропущенных данных.
|
||||
|
||||
Ни один слой не должен смешивать собственную ответственность с соседними.
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Dependency Direction
|
||||
|
||||
Все зависимости направлены только вниз.
|
||||
|
||||
```
|
||||
Trade Stream Acquisition Service
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Runtime Service
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Runtime Protocols
|
||||
```
|
||||
|
||||
Обратные зависимости запрещены.
|
||||
|
||||
Runtime не может импортировать Acquisition.
|
||||
|
||||
---
|
||||
|
||||
## 4.3 Exchange Independence
|
||||
|
||||
Trade Integration не знает ничего о Dzengi.
|
||||
|
||||
Exchange-specific логика уже локализована внутри:
|
||||
|
||||
```
|
||||
DzengiUnifiedWebSocketAdapter
|
||||
```
|
||||
|
||||
Build не добавляет новых exchange-specific условий.
|
||||
|
||||
---
|
||||
|
||||
## 4.4 Canonical Pipeline
|
||||
|
||||
После Build существует единственный допустимый путь обработки Trade:
|
||||
|
||||
```
|
||||
Raw Message
|
||||
|
||||
↓
|
||||
|
||||
Unified Adapter
|
||||
|
||||
↓
|
||||
|
||||
Trade
|
||||
|
||||
↓
|
||||
|
||||
Consistency
|
||||
|
||||
↓
|
||||
|
||||
Trade
|
||||
```
|
||||
|
||||
Любые альтернативные пути считаются нарушением архитектуры.
|
||||
|
||||
---
|
||||
|
||||
# 5. Новые компоненты
|
||||
|
||||
Build вводит два новых компонента.
|
||||
|
||||
```
|
||||
trade_stream_acquisition_protocol.py
|
||||
|
||||
trade_stream_acquisition_service.py
|
||||
```
|
||||
|
||||
Оба файла располагаются внутри:
|
||||
|
||||
```
|
||||
src/
|
||||
└── market_data/
|
||||
└── acquisition/
|
||||
```
|
||||
|
||||
Build намеренно не изменяет существующие Feed.
|
||||
|
||||
Feed остаются независимыми клиентами Acquisition Layer.
|
||||
|
||||
---
|
||||
|
||||
# 6. Новая ответственность Acquisition Layer
|
||||
|
||||
После Build Acquisition становится полноценным координатором Runtime Pipeline.
|
||||
|
||||
Именно Acquisition теперь отвечает за:
|
||||
|
||||
- отправку Runtime Command;
|
||||
- получение Trade;
|
||||
- передачу Trade в Consistency;
|
||||
- возврат согласованного результата.
|
||||
|
||||
Runtime при этом остаётся полностью инфраструктурным компонентом.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# 7. Архитектура зависимостей
|
||||
|
||||
После завершения Build зависимости между компонентами приобретают следующий вид.
|
||||
|
||||
```
|
||||
+------------------------------------+
|
||||
| Trade Stream Acquisition Service |
|
||||
+------------------------------------+
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
+---------------------------+ +---------------------------+
|
||||
| Acquisition Runtime | | Dzengi Unified Adapter |
|
||||
| Service | +---------------------------+
|
||||
+---------------------------+ │
|
||||
│ │
|
||||
│ ▼
|
||||
│ Quote | Trade | Candle
|
||||
│
|
||||
▼
|
||||
+---------------------------+
|
||||
| Runtime Protocols |
|
||||
+---------------------------+
|
||||
│
|
||||
▼
|
||||
Runtime Infrastructure
|
||||
|
||||
|
||||
Trade
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade Stream Consistency Controller
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade | None
|
||||
```
|
||||
|
||||
Таким образом Runtime перестаёт знать о типах рыночных данных.
|
||||
|
||||
Trade остаётся полностью внутри слоя Acquisition.
|
||||
|
||||
---
|
||||
|
||||
# 8. Обработка подписок
|
||||
|
||||
Новый сервис становится единственной точкой открытия Trade Stream.
|
||||
|
||||
Внешние компоненты больше не должны самостоятельно создавать Runtime Commands.
|
||||
|
||||
Правильная последовательность выглядит следующим образом.
|
||||
|
||||
```
|
||||
symbols
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
build_trade_subscribe_command()
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
SubscribeCommand
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
AcquisitionRuntimeService.dispatch()
|
||||
```
|
||||
|
||||
Сам сервис не сериализует сообщения.
|
||||
|
||||
Он использует уже существующий Subscription Builder.
|
||||
|
||||
Build не изменяет формат WebSocket сообщений.
|
||||
|
||||
---
|
||||
|
||||
# 9. Обработка входящих сообщений
|
||||
|
||||
Второй обязанностью нового сервиса становится обработка входящего транспортного сообщения.
|
||||
|
||||
Полная последовательность обработки выглядит следующим образом.
|
||||
|
||||
```
|
||||
Raw WebSocket Document
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
DzengiUnifiedWebSocketAdapter
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Quote
|
||||
Trade
|
||||
CandleCloseEvent
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
isinstance(result, Trade)
|
||||
|
||||
│
|
||||
|
||||
├──────────────► нет
|
||||
│
|
||||
│
|
||||
▼
|
||||
|
||||
Trade Stream Consistency Controller
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade | None
|
||||
```
|
||||
|
||||
Если адаптер возвращает:
|
||||
|
||||
```
|
||||
Quote
|
||||
```
|
||||
|
||||
или
|
||||
|
||||
```
|
||||
CandleCloseEvent
|
||||
```
|
||||
|
||||
сообщение считается неподходящим для данного сервиса.
|
||||
|
||||
Build не выполняет никаких дополнительных преобразований.
|
||||
|
||||
---
|
||||
|
||||
# 10. Взаимодействие с Consistency
|
||||
|
||||
Trade Integration не реализует собственную проверку последовательности.
|
||||
|
||||
Вся ответственность делегируется существующему компоненту.
|
||||
|
||||
```
|
||||
Trade
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
TradeStreamConsistencyController.accept()
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade | None
|
||||
```
|
||||
|
||||
Если Controller возвращает:
|
||||
|
||||
```
|
||||
None
|
||||
```
|
||||
|
||||
это означает корректный дубликат.
|
||||
|
||||
Никаких дополнительных действий сервис не выполняет.
|
||||
|
||||
Если Controller возбуждает исключение:
|
||||
|
||||
- TradeConsistencyError;
|
||||
- TradeOrderingError;
|
||||
|
||||
исключение полностью передаётся вызывающей стороне.
|
||||
|
||||
Build не изменяет модель ошибок Consistency Layer.
|
||||
|
||||
---
|
||||
|
||||
# 11. Взаимодействие с Runtime
|
||||
|
||||
Trade Stream Acquisition Service использует Runtime исключительно как инфраструктурный компонент.
|
||||
|
||||
Допустимыми являются только следующие Runtime Commands.
|
||||
|
||||
```
|
||||
ConnectCommand
|
||||
|
||||
DisconnectCommand
|
||||
|
||||
SubscribeCommand
|
||||
|
||||
UnsubscribeCommand
|
||||
|
||||
SendTextCommand
|
||||
|
||||
SendBinaryCommand
|
||||
```
|
||||
|
||||
Никакие Runtime Events в Build 060.23 не анализируются.
|
||||
|
||||
Publisher продолжает существовать исключительно как инфраструктурный контракт.
|
||||
|
||||
---
|
||||
|
||||
# 12. Использование Unified Adapter
|
||||
|
||||
Build намеренно использует существующий:
|
||||
|
||||
```
|
||||
DzengiUnifiedWebSocketAdapter
|
||||
```
|
||||
|
||||
а не специализированный Trade Adapter.
|
||||
|
||||
Причины данного решения:
|
||||
|
||||
• уже существует единая точка обработки WebSocket сообщений;
|
||||
|
||||
• отсутствует дублирование маршрутизации;
|
||||
|
||||
• Runtime остаётся полностью независимым от типа сообщения;
|
||||
|
||||
• в будущем Runtime сможет одинаково обслуживать:
|
||||
|
||||
- Quotes;
|
||||
- Trades;
|
||||
- Candles.
|
||||
|
||||
Таким образом новый сервис использует уже сформированную архитектуру, а не создаёт отдельную ветку обработки Trade.
|
||||
|
||||
---
|
||||
|
||||
# 13. Публичный API сервиса
|
||||
|
||||
Build вводит минимальный публичный интерфейс.
|
||||
|
||||
```python
|
||||
class TradeStreamAcquisitionServiceProtocol(Protocol):
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
symbols: tuple[str, ...],
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
document: object,
|
||||
) -> Trade | None:
|
||||
...
|
||||
```
|
||||
|
||||
Оба метода являются частью единой ответственности сервиса.
|
||||
|
||||
Никаких дополнительных методов Build не вводит.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# 14. Architecture Decision Records (ADR)
|
||||
|
||||
## ADR-060.23-001
|
||||
|
||||
Trade Stream Integration располагается исключительно внутри слоя Acquisition.
|
||||
|
||||
### Причина
|
||||
|
||||
Runtime является инфраструктурным компонентом и не должен знать о типах рыночных данных.
|
||||
|
||||
### Следствие
|
||||
|
||||
Runtime никогда не импортирует:
|
||||
|
||||
- Trade;
|
||||
- Trades Feed;
|
||||
- Consistency;
|
||||
- Recovery;
|
||||
- Unified Adapter.
|
||||
|
||||
---
|
||||
|
||||
## ADR-060.23-002
|
||||
|
||||
Trade обрабатывается только после прохождения Unified Adapter.
|
||||
|
||||
### Причина
|
||||
|
||||
В системе уже существует единая exchange-specific точка преобразования транспортных сообщений.
|
||||
|
||||
```
|
||||
DzengiUnifiedWebSocketAdapter
|
||||
```
|
||||
|
||||
Build не создаёт альтернативных маршрутов обработки.
|
||||
|
||||
---
|
||||
|
||||
## ADR-060.23-003
|
||||
|
||||
Trade Stream Acquisition Service является единственной точкой подписки Trade Runtime.
|
||||
|
||||
### Причина
|
||||
|
||||
Внешние компоненты не должны самостоятельно формировать Runtime Commands.
|
||||
|
||||
Все команды создаются посредством существующих Subscription Builder.
|
||||
|
||||
---
|
||||
|
||||
## ADR-060.23-004
|
||||
|
||||
Trade Stream Acquisition Service не хранит состояние Trade Stream.
|
||||
|
||||
### Причина
|
||||
|
||||
Сервис выполняет исключительно координацию.
|
||||
|
||||
Состояние распределено между специализированными компонентами:
|
||||
|
||||
- Runtime Session;
|
||||
- Subscription Manager;
|
||||
- Trade Stream Consistency Controller.
|
||||
|
||||
Build не вводит нового состояния.
|
||||
|
||||
---
|
||||
|
||||
## ADR-060.23-005
|
||||
|
||||
Trade Stream Acquisition Service не публикует Trade.
|
||||
|
||||
### Причина
|
||||
|
||||
В проекте отсутствует утверждённая инфраструктура публикации канонического Trade Stream.
|
||||
|
||||
До появления такого компонента сервис возвращает:
|
||||
|
||||
```
|
||||
Trade | None
|
||||
```
|
||||
|
||||
не принимая решений о дальнейшей маршрутизации данных.
|
||||
|
||||
---
|
||||
|
||||
# 15. Что не входит в Build
|
||||
|
||||
Настоящий Build сознательно не включает:
|
||||
|
||||
- реализацию WebSocket Transport;
|
||||
- реализацию WebSocket Session;
|
||||
- production Subscription Manager;
|
||||
- receive loop;
|
||||
- reconnect;
|
||||
- heartbeat;
|
||||
- scheduler;
|
||||
- Runtime Recovery;
|
||||
- публикацию Runtime Events;
|
||||
- публикацию Trade;
|
||||
- хранение Trade;
|
||||
- обработку Quote;
|
||||
- обработку Candle;
|
||||
- изменение существующего Trades Feed;
|
||||
- изменение MarketDataRunner;
|
||||
- изменение market_stream.py;
|
||||
- изменение ExchangeWebSocketClient.
|
||||
|
||||
Все перечисленные задачи относятся к следующим Build.
|
||||
|
||||
---
|
||||
|
||||
# 16. План реализации
|
||||
|
||||
Реализация выполняется в несколько последовательных шагов.
|
||||
|
||||
## Этап 1
|
||||
|
||||
Создание нового контракта
|
||||
|
||||
```
|
||||
trade_stream_acquisition_protocol.py
|
||||
```
|
||||
|
||||
Контракт описывает публичный API сервиса.
|
||||
|
||||
---
|
||||
|
||||
## Этап 2
|
||||
|
||||
Создание реализации
|
||||
|
||||
```
|
||||
trade_stream_acquisition_service.py
|
||||
```
|
||||
|
||||
Сервис получает через конструктор зависимости:
|
||||
|
||||
- AcquisitionRuntimeServiceProtocol;
|
||||
- DzengiUnifiedWebSocketAdapter;
|
||||
- TradeStreamConsistencyProtocol.
|
||||
|
||||
---
|
||||
|
||||
## Этап 3
|
||||
|
||||
Интеграция подписки
|
||||
|
||||
Добавляется метод:
|
||||
|
||||
```
|
||||
subscribe(...)
|
||||
```
|
||||
|
||||
который:
|
||||
|
||||
- строит SubscribeCommand;
|
||||
- передаёт его Runtime Service;
|
||||
- не содержит exchange-specific логики.
|
||||
|
||||
---
|
||||
|
||||
## Этап 4
|
||||
|
||||
Интеграция обработки сообщений
|
||||
|
||||
Добавляется метод:
|
||||
|
||||
```
|
||||
handle_message(...)
|
||||
```
|
||||
|
||||
Последовательность обработки:
|
||||
|
||||
```
|
||||
document
|
||||
|
||||
↓
|
||||
|
||||
Unified Adapter
|
||||
|
||||
↓
|
||||
|
||||
Trade
|
||||
|
||||
↓
|
||||
|
||||
Consistency
|
||||
|
||||
↓
|
||||
|
||||
Trade | None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Этап 5
|
||||
|
||||
Покрытие тестами
|
||||
|
||||
Добавляются unit-тесты:
|
||||
|
||||
- проверка соответствия Protocol;
|
||||
- проверка dispatch подписок;
|
||||
- проверка обработки Trade;
|
||||
- проверка игнорирования Quote;
|
||||
- проверка игнорирования Candle;
|
||||
- проверка передачи Trade в Consistency;
|
||||
- проверка возврата None для корректного дубликата;
|
||||
- проверка проброса исключений Consistency;
|
||||
- проверка отсутствия скрытого состояния.
|
||||
|
||||
---
|
||||
|
||||
# 17. Definition of Done
|
||||
|
||||
Build считается завершённым после выполнения следующих условий.
|
||||
|
||||
✓ создан Protocol Trade Stream Acquisition Service;
|
||||
|
||||
✓ создана реализация сервиса;
|
||||
|
||||
✓ Runtime интегрирован через AcquisitionRuntimeServiceProtocol;
|
||||
|
||||
✓ Unified Adapter используется как единственная точка преобразования сообщений;
|
||||
|
||||
✓ Consistency используется как единственная точка проверки последовательности сделок;
|
||||
|
||||
✓ сервис не содержит exchange-specific логики;
|
||||
|
||||
✓ сервис не содержит собственного состояния;
|
||||
|
||||
✓ сервис не содержит логики reconnect;
|
||||
|
||||
✓ сервис не содержит логики recovery;
|
||||
|
||||
✓ сервис не взаимодействует с legacy Runtime;
|
||||
|
||||
✓ сервис полностью покрыт unit-тестами;
|
||||
|
||||
✓ существующие тесты Runtime, Feeds, Consistency и Recovery продолжают успешно проходить без изменений.
|
||||
|
||||
---
|
||||
|
||||
# 18. Архитектурное состояние после Build
|
||||
|
||||
После завершения Build 060.23 инфраструктура Acquisition приобретает завершённую форму.
|
||||
|
||||
```
|
||||
Subscription Builder
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade Stream Acquisition Service
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Acquisition Runtime Service
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Runtime Protocols
|
||||
|
||||
────────────────────────────────────
|
||||
|
||||
Raw WebSocket Message
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
DzengiUnifiedWebSocketAdapter
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade Stream Consistency Controller
|
||||
|
||||
│
|
||||
|
||||
▼
|
||||
|
||||
Trade | None
|
||||
```
|
||||
|
||||
Таким образом завершается построение сервисного слоя интеграции Runtime и Acquisition.
|
||||
|
||||
Следующий этап — **Build 060.24 — Reconnect & Runtime Recovery**, в рамках которого будет реализовано управление жизненным циклом WebSocket-соединения, автоматическое восстановление подписок и интеграция Recovery Layer с Runtime Infrastructure.
|
||||
Reference in New Issue
Block a user