Build 060.24: implement Runtime Recovery Architecture
This commit is contained in:
@@ -5,11 +5,11 @@ from __future__ import annotations
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
||||
TradeConsistencyError,
|
||||
TradeOrderingError,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
|
||||
DEFAULT_DEDUPLICATION_WINDOW_SIZE = 10_000
|
||||
|
||||
@@ -25,6 +25,7 @@ class TradeStreamState:
|
||||
deduplication_window_size: int = DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
||||
|
||||
last_trade_id: int | None = None
|
||||
last_trade: Trade | None = None
|
||||
|
||||
_trade_window: deque[int] = field(init=False, repr=False)
|
||||
_trades: dict[int, Trade] = field(init=False, repr=False)
|
||||
@@ -88,6 +89,7 @@ class TradeStreamState:
|
||||
self._append(trade)
|
||||
|
||||
self.last_trade_id = trade_id
|
||||
self.last_trade = trade
|
||||
|
||||
return trade
|
||||
|
||||
@@ -100,4 +102,4 @@ class TradeStreamState:
|
||||
self._trades.pop(oldest_trade_id, None)
|
||||
|
||||
self._trade_window.append(trade.trade_id)
|
||||
self._trades[trade.trade_id] = trade
|
||||
self._trades[trade.trade_id] = trade
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# app/src/market_data/acquisition/recovery/trade_recovery_window.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradeRecoveryWindow:
|
||||
"""
|
||||
Неизменяемое описание одного временного диапазона
|
||||
восстановления Trade Stream.
|
||||
|
||||
Модель не выполняет Recovery и не содержит ограничений
|
||||
конкретного REST API. Допустимый максимальный размер окна
|
||||
контролируется TradeRecoveryWindowPlanner.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
start_time: int
|
||||
end_time: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.symbol, str):
|
||||
raise TypeError("symbol must be a string")
|
||||
|
||||
if not self.symbol.strip():
|
||||
raise ValueError("symbol must not be empty")
|
||||
|
||||
if not isinstance(self.start_time, int) or isinstance(
|
||||
self.start_time,
|
||||
bool,
|
||||
):
|
||||
raise TypeError("start_time must be an integer")
|
||||
|
||||
if not isinstance(self.end_time, int) or isinstance(
|
||||
self.end_time,
|
||||
bool,
|
||||
):
|
||||
raise TypeError("end_time must be an integer")
|
||||
|
||||
if self.start_time < 0:
|
||||
raise ValueError("start_time must not be negative")
|
||||
|
||||
if self.end_time < 0:
|
||||
raise ValueError("end_time must not be negative")
|
||||
|
||||
if self.start_time > self.end_time:
|
||||
raise ValueError(
|
||||
"start_time must not be greater than end_time"
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
# app/src/market_data/acquisition/recovery/trade_recovery_window_planner.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.recovery.trade_recovery_window import (
|
||||
TradeRecoveryWindow,
|
||||
)
|
||||
|
||||
|
||||
MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS = 3_599_999
|
||||
DEFAULT_TRADE_RECOVERY_WINDOW_MS = (
|
||||
MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS
|
||||
)
|
||||
|
||||
|
||||
class TradeRecoveryWindowPlanner:
|
||||
"""
|
||||
Stateless-планировщик временных окон восстановления Trade Stream.
|
||||
|
||||
Planner разбивает один непрерывный временной диапазон на
|
||||
последовательность окон, каждое из которых совместимо с ограничением
|
||||
TradeRecoveryRequest:
|
||||
|
||||
end_time - start_time < 3_600_000 ms
|
||||
|
||||
Planner не выполняет REST-запросы и не создаёт TradeRecoveryRequest.
|
||||
"""
|
||||
|
||||
__slots__ = ("_max_window_ms",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_window_ms: int = DEFAULT_TRADE_RECOVERY_WINDOW_MS,
|
||||
) -> None:
|
||||
if not isinstance(max_window_ms, int) or isinstance(
|
||||
max_window_ms,
|
||||
bool,
|
||||
):
|
||||
raise TypeError("max_window_ms must be an integer")
|
||||
|
||||
if max_window_ms <= 0:
|
||||
raise ValueError("max_window_ms must be positive")
|
||||
|
||||
if max_window_ms > MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS:
|
||||
raise ValueError(
|
||||
"max_window_ms must not exceed "
|
||||
f"{MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS}"
|
||||
)
|
||||
|
||||
self._max_window_ms = max_window_ms
|
||||
|
||||
@property
|
||||
def max_window_ms(self) -> int:
|
||||
"""
|
||||
Максимальная длительность одного создаваемого окна.
|
||||
"""
|
||||
return self._max_window_ms
|
||||
|
||||
def build_windows(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
) -> tuple[TradeRecoveryWindow, ...]:
|
||||
"""
|
||||
Построить последовательность Recovery Window.
|
||||
|
||||
Если start_time равен end_time, восстановление не требуется
|
||||
и возвращается пустой tuple.
|
||||
|
||||
Соседние окна имеют общую границу:
|
||||
|
||||
previous.end_time == next.start_time
|
||||
|
||||
Возможный повтор Trade на границе должен быть устранён
|
||||
существующим Consistency Layer.
|
||||
"""
|
||||
self._validate_range(
|
||||
symbol=symbol,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
if start_time == end_time:
|
||||
return ()
|
||||
|
||||
windows: list[TradeRecoveryWindow] = []
|
||||
cursor = start_time
|
||||
|
||||
while cursor < end_time:
|
||||
window_end = min(
|
||||
cursor + self._max_window_ms,
|
||||
end_time,
|
||||
)
|
||||
|
||||
windows.append(
|
||||
TradeRecoveryWindow(
|
||||
symbol=symbol,
|
||||
start_time=cursor,
|
||||
end_time=window_end,
|
||||
)
|
||||
)
|
||||
|
||||
cursor = window_end
|
||||
|
||||
return tuple(windows)
|
||||
|
||||
@staticmethod
|
||||
def _validate_range(
|
||||
*,
|
||||
symbol: str,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
) -> None:
|
||||
"""
|
||||
Проверить исходный диапазон до начала разбиения.
|
||||
"""
|
||||
if not isinstance(symbol, str):
|
||||
raise TypeError("symbol must be a string")
|
||||
|
||||
if not symbol.strip():
|
||||
raise ValueError("symbol must not be empty")
|
||||
|
||||
if not isinstance(start_time, int) or isinstance(
|
||||
start_time,
|
||||
bool,
|
||||
):
|
||||
raise TypeError("start_time must be an integer")
|
||||
|
||||
if not isinstance(end_time, int) or isinstance(
|
||||
end_time,
|
||||
bool,
|
||||
):
|
||||
raise TypeError("end_time must be an integer")
|
||||
|
||||
if start_time < 0:
|
||||
raise ValueError("start_time must not be negative")
|
||||
|
||||
if end_time < 0:
|
||||
raise ValueError("end_time must not be negative")
|
||||
|
||||
if start_time > end_time:
|
||||
raise ValueError(
|
||||
"start_time must not be greater than end_time"
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
# app/src/market_data/acquisition/runtime/heartbeat.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Callable, Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.runtime.runtime_events import (
|
||||
HeartbeatTimeoutEvent,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeEventPublisherProtocol,
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatState(str, Enum):
|
||||
"""
|
||||
Текущее состояние Heartbeat Monitor.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
MONITORING = "monitoring"
|
||||
TIMED_OUT = "timed_out"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HeartbeatMonitorProtocol(Protocol):
|
||||
"""
|
||||
Контракт пассивного контроля активности Acquisition Runtime.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> HeartbeatState:
|
||||
"""Вернуть текущее состояние Heartbeat Monitor."""
|
||||
...
|
||||
|
||||
@property
|
||||
def timeout_seconds(self) -> float:
|
||||
"""Вернуть настроенный интервал timeout."""
|
||||
...
|
||||
|
||||
@property
|
||||
def last_activity_at(self) -> float | None:
|
||||
"""Вернуть monotonic-время последней активности."""
|
||||
...
|
||||
|
||||
def start(self) -> None:
|
||||
"""Начать мониторинг активности."""
|
||||
...
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Остановить мониторинг и очистить текущее состояние."""
|
||||
...
|
||||
|
||||
def record_activity(self) -> None:
|
||||
"""Зафиксировать текущий момент как последнюю активность."""
|
||||
...
|
||||
|
||||
async def check_timeout(self) -> bool:
|
||||
"""
|
||||
Проверить наступление timeout.
|
||||
|
||||
Вернуть True только при первом обнаружении timeout.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class HeartbeatMonitor:
|
||||
"""
|
||||
Пассивный монитор активности Acquisition Runtime.
|
||||
|
||||
Monitor:
|
||||
|
||||
- хранит monotonic-время последней активности;
|
||||
- детерминированно проверяет превышение timeout;
|
||||
- однократно публикует HeartbeatTimeoutEvent;
|
||||
- не запускает собственные фоновые задачи;
|
||||
- не выполняет reconnect;
|
||||
- не управляет WebSocket lifecycle.
|
||||
|
||||
Периодический вызов check_timeout() выполняет Runtime Scheduler.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_event_publisher",
|
||||
"_timeout_seconds",
|
||||
"_clock",
|
||||
"_state",
|
||||
"_last_activity_at",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
if not isinstance(timeout_seconds, (int, float)) or isinstance(
|
||||
timeout_seconds,
|
||||
bool,
|
||||
):
|
||||
raise TypeError(
|
||||
"timeout_seconds must be an integer or float"
|
||||
)
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError(
|
||||
"timeout_seconds must be positive"
|
||||
)
|
||||
|
||||
if not callable(clock):
|
||||
raise TypeError("clock must be callable")
|
||||
|
||||
self._event_publisher = event_publisher
|
||||
self._timeout_seconds = float(timeout_seconds)
|
||||
self._clock = clock
|
||||
|
||||
self._state = HeartbeatState.IDLE
|
||||
self._last_activity_at: float | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> HeartbeatState:
|
||||
"""Вернуть текущее состояние Heartbeat Monitor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def timeout_seconds(self) -> float:
|
||||
"""Вернуть настроенный интервал timeout."""
|
||||
return self._timeout_seconds
|
||||
|
||||
@property
|
||||
def last_activity_at(self) -> float | None:
|
||||
"""Вернуть monotonic-время последней активности."""
|
||||
return self._last_activity_at
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Начать мониторинг с текущего monotonic-времени.
|
||||
"""
|
||||
self._last_activity_at = self._clock()
|
||||
self._state = HeartbeatState.MONITORING
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Остановить мониторинг и удалить runtime-состояние Heartbeat.
|
||||
"""
|
||||
self._last_activity_at = None
|
||||
self._state = HeartbeatState.IDLE
|
||||
|
||||
def record_activity(self) -> None:
|
||||
"""
|
||||
Зафиксировать текущий момент как последнюю активность.
|
||||
|
||||
Получение новой активности также переводит Monitor из состояния
|
||||
TIMED_OUT обратно в MONITORING.
|
||||
"""
|
||||
self._last_activity_at = self._clock()
|
||||
self._state = HeartbeatState.MONITORING
|
||||
|
||||
async def check_timeout(self) -> bool:
|
||||
"""
|
||||
Проверить, истёк ли настроенный интервал активности.
|
||||
|
||||
HeartbeatTimeoutEvent публикуется только один раз для каждого
|
||||
периода отсутствия активности.
|
||||
|
||||
После новой активности Monitor снова может зафиксировать timeout.
|
||||
"""
|
||||
if self._state is not HeartbeatState.MONITORING:
|
||||
return False
|
||||
|
||||
last_activity_at = self._last_activity_at
|
||||
|
||||
if last_activity_at is None:
|
||||
return False
|
||||
|
||||
elapsed_seconds = self._clock() - last_activity_at
|
||||
|
||||
if elapsed_seconds < self._timeout_seconds:
|
||||
return False
|
||||
|
||||
self._state = HeartbeatState.TIMED_OUT
|
||||
|
||||
await self._event_publisher.publish(
|
||||
HeartbeatTimeoutEvent(
|
||||
timeout_seconds=self._timeout_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1 +1,186 @@
|
||||
# app/src/market_data/acquisition/runtime/reconnect.py
|
||||
# app/src/market_data/acquisition/runtime/reconnect.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||
ConnectCommand,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_events import (
|
||||
ReconnectCompletedEvent,
|
||||
ReconnectFailedEvent,
|
||||
ReconnectStartedEvent,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeCommandDispatcherProtocol,
|
||||
AcquisitionRuntimeEventPublisherProtocol,
|
||||
WebSocketSubscriptionManagerProtocol,
|
||||
)
|
||||
|
||||
|
||||
class ReconnectState(str, Enum):
|
||||
"""
|
||||
Текущее состояние ReconnectCoordinator.
|
||||
|
||||
Состояния описывают только lifecycle одной инфраструктурной
|
||||
операции reconnect.
|
||||
|
||||
Состояния Heartbeat, Supervisor и Trade Recovery принадлежат
|
||||
соответствующим специализированным компонентам.
|
||||
"""
|
||||
|
||||
DISCONNECTED = "disconnected"
|
||||
CONNECTING = "connecting"
|
||||
RESTORING_SUBSCRIPTIONS = "restoring_subscriptions"
|
||||
CONNECTED = "connected"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReconnectCoordinatorProtocol(Protocol):
|
||||
"""
|
||||
Контракт координатора повторного подключения Acquisition Runtime.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> ReconnectState:
|
||||
"""
|
||||
Вернуть текущее состояние reconnect lifecycle.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
"""
|
||||
Вернуть номер последней начатой попытки reconnect.
|
||||
"""
|
||||
...
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
"""
|
||||
Выполнить одну попытку повторного подключения.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class ReconnectCoordinator:
|
||||
"""
|
||||
Координатор одной попытки повторного подключения.
|
||||
|
||||
Coordinator:
|
||||
|
||||
- увеличивает номер попытки;
|
||||
- публикует ReconnectStartedEvent;
|
||||
- передаёт ConnectCommand в Runtime Dispatcher;
|
||||
- восстанавливает зарегистрированные подписки;
|
||||
- публикует ReconnectCompletedEvent;
|
||||
- при ошибке публикует ReconnectFailedEvent;
|
||||
- сохраняет текущее состояние lifecycle.
|
||||
|
||||
Coordinator не выполняет:
|
||||
|
||||
- retry loop;
|
||||
- backoff;
|
||||
- scheduling;
|
||||
- heartbeat monitoring;
|
||||
- Trade Recovery;
|
||||
- обработку рыночных сообщений.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_command_dispatcher",
|
||||
"_subscription_manager",
|
||||
"_event_publisher",
|
||||
"_state",
|
||||
"_attempt",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command_dispatcher: AcquisitionRuntimeCommandDispatcherProtocol,
|
||||
subscription_manager: WebSocketSubscriptionManagerProtocol,
|
||||
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||
) -> None:
|
||||
self._command_dispatcher = command_dispatcher
|
||||
self._subscription_manager = subscription_manager
|
||||
self._event_publisher = event_publisher
|
||||
|
||||
self._state = ReconnectState.DISCONNECTED
|
||||
self._attempt = 0
|
||||
|
||||
@property
|
||||
def state(self) -> ReconnectState:
|
||||
"""
|
||||
Вернуть текущее состояние reconnect lifecycle.
|
||||
"""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
"""
|
||||
Вернуть номер последней начатой попытки reconnect.
|
||||
"""
|
||||
return self._attempt
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
"""
|
||||
Выполнить одну попытку повторного подключения.
|
||||
|
||||
Последовательность:
|
||||
|
||||
ReconnectStartedEvent
|
||||
↓
|
||||
ConnectCommand
|
||||
↓
|
||||
restore_subscriptions()
|
||||
↓
|
||||
ReconnectCompletedEvent
|
||||
|
||||
Raises:
|
||||
Exception:
|
||||
Любая ошибка Runtime Dispatcher или Subscription Manager
|
||||
распространяется вызывающему компоненту без обёртки.
|
||||
"""
|
||||
self._attempt += 1
|
||||
current_attempt = self._attempt
|
||||
|
||||
self._state = ReconnectState.CONNECTING
|
||||
|
||||
await self._event_publisher.publish(
|
||||
ReconnectStartedEvent(
|
||||
attempt=current_attempt,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await self._command_dispatcher.dispatch(
|
||||
ConnectCommand()
|
||||
)
|
||||
|
||||
self._state = (
|
||||
ReconnectState.RESTORING_SUBSCRIPTIONS
|
||||
)
|
||||
|
||||
await self._subscription_manager.restore_subscriptions()
|
||||
|
||||
except Exception as error:
|
||||
self._state = ReconnectState.FAILED
|
||||
|
||||
await self._event_publisher.publish(
|
||||
ReconnectFailedEvent(
|
||||
attempt=current_attempt,
|
||||
reason=str(error),
|
||||
)
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
self._state = ReconnectState.CONNECTED
|
||||
|
||||
await self._event_publisher.publish(
|
||||
ReconnectCompletedEvent(
|
||||
attempt=current_attempt,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# app/src/market_data/acquisition/runtime/
|
||||
# runtime_recovery_coordinator.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
||||
TradeStreamStateNotFoundError,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
||||
TradeStreamStateStoreProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.models.trade import Trade
|
||||
from src.market_data.acquisition.recovery.trade_recovery_protocol import (
|
||||
TradeRecoveryProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_request import (
|
||||
TradeRecoveryRequest,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||
TradeRecoveryResult,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_window_planner import (
|
||||
TradeRecoveryWindowPlanner,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeRecoveryCoordinator:
|
||||
"""
|
||||
Runtime-координатор восстановления Trade Stream.
|
||||
|
||||
Coordinator связывает существующие компоненты:
|
||||
|
||||
- TradeStreamStateStoreProtocol;
|
||||
- checkpoint в TradeStreamState.last_trade;
|
||||
- TradeRecoveryWindowPlanner;
|
||||
- TradeRecoveryProtocol.
|
||||
|
||||
Последовательность восстановления:
|
||||
|
||||
state_store.get(symbol)
|
||||
↓
|
||||
TradeStreamState.last_trade
|
||||
↓
|
||||
executed_at → Unix milliseconds
|
||||
↓
|
||||
TradeRecoveryWindowPlanner.build_windows()
|
||||
↓
|
||||
TradeRecoveryRequest для каждого окна
|
||||
↓
|
||||
TradeRecoveryProtocol.recover()
|
||||
↓
|
||||
объединённый TradeRecoveryResult
|
||||
|
||||
Coordinator не выполняет:
|
||||
|
||||
- WebSocket connect или disconnect;
|
||||
- reconnect;
|
||||
- восстановление подписок;
|
||||
- heartbeat monitoring;
|
||||
- scheduling;
|
||||
- retry или backoff;
|
||||
- публикацию Runtime Events;
|
||||
- создание нового TradeStreamState;
|
||||
- обработку и проверку согласованности Trade.
|
||||
|
||||
Отсутствие состояния или последней принятой сделки является
|
||||
штатной ситуацией: восстановление пропускается и возвращается
|
||||
пустой TradeRecoveryResult.
|
||||
|
||||
Все остальные ошибки State Store, Window Planner и Trade Recovery
|
||||
распространяются вызывающему коду без обёртки.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_state_store",
|
||||
"_window_planner",
|
||||
"_recovery_controller",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
state_store: TradeStreamStateStoreProtocol,
|
||||
window_planner: TradeRecoveryWindowPlanner,
|
||||
recovery_controller: TradeRecoveryProtocol,
|
||||
) -> None:
|
||||
"""
|
||||
Создать Runtime Recovery Coordinator.
|
||||
|
||||
Args:
|
||||
state_store:
|
||||
Общее хранилище состояния Trade Stream Consistency.
|
||||
|
||||
Должен передаваться тот же экземпляр хранилища,
|
||||
который используется основным WebSocket-потоком.
|
||||
|
||||
window_planner:
|
||||
Планировщик допустимых временных окон восстановления.
|
||||
|
||||
recovery_controller:
|
||||
Существующий stateless-контроллер Trade Recovery.
|
||||
"""
|
||||
self._state_store = state_store
|
||||
self._window_planner = window_planner
|
||||
self._recovery_controller = recovery_controller
|
||||
|
||||
def recover(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
recovery_end_time: int,
|
||||
) -> TradeRecoveryResult:
|
||||
"""
|
||||
Восстановить пропущенные сделки одного инструмента.
|
||||
|
||||
Начальная граница определяется по времени последней сделки,
|
||||
принятой общим Trade Stream Consistency Layer.
|
||||
|
||||
Конечная граница передаётся вызывающим Runtime-компонентом.
|
||||
Coordinator не получает системное время самостоятельно.
|
||||
|
||||
Возможный повтор последней принятой сделки на левой границе
|
||||
безопасно устраняется существующим Consistency Layer.
|
||||
|
||||
Args:
|
||||
symbol:
|
||||
Символ торгового инструмента.
|
||||
|
||||
recovery_end_time:
|
||||
Конечная граница восстановления в миллисекундах
|
||||
Unix time.
|
||||
|
||||
Returns:
|
||||
Объединённый TradeRecoveryResult для полного диапазона.
|
||||
|
||||
Если состояние или checkpoint отсутствуют, возвращается
|
||||
пустой результат с границами, равными recovery_end_time.
|
||||
|
||||
Raises:
|
||||
TypeError:
|
||||
Если symbol или recovery_end_time имеют неверный тип.
|
||||
|
||||
ValueError:
|
||||
Если symbol пустой, recovery_end_time отрицательный,
|
||||
checkpoint не содержит timezone-aware datetime либо
|
||||
конечная граница расположена раньше checkpoint.
|
||||
|
||||
Exception:
|
||||
Ошибки Window Planner и Trade Recovery Controller
|
||||
распространяются без изменения.
|
||||
"""
|
||||
self._validate_input(
|
||||
symbol=symbol,
|
||||
recovery_end_time=recovery_end_time,
|
||||
)
|
||||
|
||||
try:
|
||||
state = self._state_store.get(symbol)
|
||||
except TradeStreamStateNotFoundError:
|
||||
return self._build_empty_result(
|
||||
symbol=symbol,
|
||||
boundary_time=recovery_end_time,
|
||||
)
|
||||
|
||||
checkpoint_trade = state.last_trade
|
||||
|
||||
if checkpoint_trade is None:
|
||||
return self._build_empty_result(
|
||||
symbol=symbol,
|
||||
boundary_time=recovery_end_time,
|
||||
)
|
||||
|
||||
recovery_start_time = self._datetime_to_unix_ms(
|
||||
checkpoint_trade.executed_at,
|
||||
)
|
||||
|
||||
windows = self._window_planner.build_windows(
|
||||
symbol=symbol,
|
||||
start_time=recovery_start_time,
|
||||
end_time=recovery_end_time,
|
||||
)
|
||||
|
||||
recovered_trades: list[Trade] = []
|
||||
|
||||
for window in windows:
|
||||
window_result = self._recovery_controller.recover(
|
||||
TradeRecoveryRequest(
|
||||
symbol=window.symbol,
|
||||
start_time=window.start_time,
|
||||
end_time=window.end_time,
|
||||
)
|
||||
)
|
||||
|
||||
recovered_trades.extend(
|
||||
window_result.recovered_trades,
|
||||
)
|
||||
|
||||
return TradeRecoveryResult(
|
||||
symbol=symbol,
|
||||
requested_start_time=recovery_start_time,
|
||||
requested_end_time=recovery_end_time,
|
||||
recovered_trades=tuple(recovered_trades),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_input(
|
||||
*,
|
||||
symbol: str,
|
||||
recovery_end_time: int,
|
||||
) -> None:
|
||||
"""
|
||||
Проверить входные параметры до обращения к State Store.
|
||||
"""
|
||||
if not isinstance(symbol, str):
|
||||
raise TypeError(
|
||||
"symbol должен иметь тип str."
|
||||
)
|
||||
|
||||
if not symbol.strip():
|
||||
raise ValueError(
|
||||
"symbol не должен быть пустым."
|
||||
)
|
||||
|
||||
if isinstance(recovery_end_time, bool) or not isinstance(
|
||||
recovery_end_time,
|
||||
int,
|
||||
):
|
||||
raise TypeError(
|
||||
"recovery_end_time должен иметь тип int."
|
||||
)
|
||||
|
||||
if recovery_end_time < 0:
|
||||
raise ValueError(
|
||||
"recovery_end_time не должен быть отрицательным."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _datetime_to_unix_ms(
|
||||
value: datetime,
|
||||
) -> int:
|
||||
"""
|
||||
Преобразовать timezone-aware datetime в Unix milliseconds.
|
||||
|
||||
Расчёт выполняется относительно UTC epoch без использования
|
||||
float, чтобы не вносить погрешность преобразования timestamp.
|
||||
"""
|
||||
if not isinstance(value, datetime):
|
||||
raise TypeError(
|
||||
"checkpoint executed_at должен иметь тип datetime."
|
||||
)
|
||||
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(
|
||||
"checkpoint executed_at должен содержать timezone."
|
||||
)
|
||||
|
||||
utc_value = value.astimezone(
|
||||
timezone.utc,
|
||||
)
|
||||
|
||||
epoch = datetime(
|
||||
1970,
|
||||
1,
|
||||
1,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
|
||||
delta = utc_value - epoch
|
||||
|
||||
return (
|
||||
delta.days * 86_400_000
|
||||
+ delta.seconds * 1_000
|
||||
+ delta.microseconds // 1_000
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_empty_result(
|
||||
*,
|
||||
symbol: str,
|
||||
boundary_time: int,
|
||||
) -> TradeRecoveryResult:
|
||||
"""
|
||||
Создать пустой результат при отсутствии Recovery Checkpoint.
|
||||
|
||||
Равные границы явно обозначают отсутствие диапазона,
|
||||
который можно восстановить на основе существующего состояния.
|
||||
"""
|
||||
return TradeRecoveryResult(
|
||||
symbol=symbol,
|
||||
requested_start_time=boundary_time,
|
||||
requested_end_time=boundary_time,
|
||||
recovered_trades=(),
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
# app/src/market_data/acquisition/runtime/runtime_recovery_protocol.py
|
||||
|
||||
"""
|
||||
Публичный контракт Runtime Recovery Coordinator.
|
||||
|
||||
Runtime Recovery Coordinator связывает Runtime Layer с существующим
|
||||
Trade Recovery Layer, но не выполняет reconnect, WebSocket-операции,
|
||||
heartbeat, scheduling или восстановление подписок.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||
TradeRecoveryResult,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeRecoveryProtocol(Protocol):
|
||||
"""
|
||||
Контракт Runtime-координатора восстановления Trade Stream.
|
||||
|
||||
Реализация должна:
|
||||
|
||||
- получить существующий checkpoint торгового инструмента;
|
||||
- определить полный временной диапазон восстановления;
|
||||
- разбить диапазон на допустимые Recovery Window;
|
||||
- последовательно выполнить Trade Recovery для каждого окна;
|
||||
- вернуть объединённый TradeRecoveryResult.
|
||||
|
||||
Реализация не должна:
|
||||
|
||||
- создавать новое состояние Trade Stream;
|
||||
- выполнять WebSocket connect или disconnect;
|
||||
- восстанавливать WebSocket-подписки;
|
||||
- управлять Heartbeat Monitor;
|
||||
- управлять Runtime Supervisor;
|
||||
- запускать Scheduler;
|
||||
- выполнять retry или backoff;
|
||||
- скрывать ошибки нижележащих компонентов.
|
||||
"""
|
||||
|
||||
def recover(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
recovery_end_time: int,
|
||||
) -> TradeRecoveryResult:
|
||||
"""
|
||||
Восстановить пропущенные сделки одного торгового инструмента.
|
||||
|
||||
Args:
|
||||
symbol:
|
||||
Символ торгового инструмента.
|
||||
|
||||
recovery_end_time:
|
||||
Правая граница восстанавливаемого диапазона
|
||||
в миллисекундах Unix time.
|
||||
|
||||
Значение определяется вызывающим Runtime-компонентом.
|
||||
Coordinator самостоятельно текущее время не вычисляет.
|
||||
|
||||
Returns:
|
||||
Объединённый результат восстановления всего диапазона.
|
||||
|
||||
Если существующее состояние или checkpoint отсутствуют,
|
||||
реализация возвращает пустой TradeRecoveryResult и не создаёт
|
||||
новое состояние Trade Stream.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,199 @@
|
||||
# app/src/market_data/acquisition/runtime/scheduler.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.runtime.heartbeat import (
|
||||
HeartbeatMonitorProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.supervisor import (
|
||||
RuntimeSupervisorProtocol,
|
||||
)
|
||||
|
||||
|
||||
RuntimeSleep = Callable[[float], Awaitable[None]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeSchedulerProtocol(Protocol):
|
||||
"""
|
||||
Контракт периодического планировщика Acquisition Runtime.
|
||||
"""
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""
|
||||
Вернуть признак активного scheduler loop.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def interval_seconds(self) -> float:
|
||||
"""
|
||||
Вернуть интервал между периодическими проверками.
|
||||
"""
|
||||
...
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
Запустить scheduler loop.
|
||||
|
||||
Метод выполняется до вызова stop() либо возникновения ошибки.
|
||||
"""
|
||||
...
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Запросить остановку scheduler loop.
|
||||
"""
|
||||
...
|
||||
|
||||
async def run_once(self) -> bool:
|
||||
"""
|
||||
Выполнить одну проверку Heartbeat.
|
||||
|
||||
Вернуть True, если был обнаружен timeout.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class RuntimeScheduler:
|
||||
"""
|
||||
Периодический планировщик Acquisition Runtime.
|
||||
|
||||
Scheduler отвечает только за время выполнения проверок:
|
||||
|
||||
HeartbeatMonitor.check_timeout()
|
||||
│
|
||||
├── False
|
||||
│ └── ожидание следующего интервала
|
||||
│
|
||||
└── True
|
||||
└── RuntimeSupervisor
|
||||
.handle_heartbeat_timeout()
|
||||
|
||||
Scheduler не выполняет:
|
||||
|
||||
- reconnect;
|
||||
- Heartbeat calculations;
|
||||
- управление WebSocket;
|
||||
- Trade Recovery;
|
||||
- backoff;
|
||||
- retry policy;
|
||||
- публикацию Runtime Events.
|
||||
|
||||
Ошибки Heartbeat Monitor, Runtime Supervisor и sleep-функции
|
||||
распространяются вызывающему коду без обёртки.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_heartbeat_monitor",
|
||||
"_runtime_supervisor",
|
||||
"_interval_seconds",
|
||||
"_sleep",
|
||||
"_running",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
heartbeat_monitor: HeartbeatMonitorProtocol,
|
||||
runtime_supervisor: RuntimeSupervisorProtocol,
|
||||
*,
|
||||
interval_seconds: float,
|
||||
sleep: RuntimeSleep = asyncio.sleep,
|
||||
) -> None:
|
||||
if not isinstance(interval_seconds, (int, float)) or isinstance(
|
||||
interval_seconds,
|
||||
bool,
|
||||
):
|
||||
raise TypeError(
|
||||
"interval_seconds must be an integer or float"
|
||||
)
|
||||
|
||||
if interval_seconds <= 0:
|
||||
raise ValueError(
|
||||
"interval_seconds must be positive"
|
||||
)
|
||||
|
||||
if not callable(sleep):
|
||||
raise TypeError("sleep must be callable")
|
||||
|
||||
self._heartbeat_monitor = heartbeat_monitor
|
||||
self._runtime_supervisor = runtime_supervisor
|
||||
self._interval_seconds = float(interval_seconds)
|
||||
self._sleep = sleep
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""
|
||||
Вернуть признак активного scheduler loop.
|
||||
"""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def interval_seconds(self) -> float:
|
||||
"""
|
||||
Вернуть интервал между проверками.
|
||||
"""
|
||||
return self._interval_seconds
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
Запустить периодический scheduler loop.
|
||||
|
||||
Если Scheduler уже запущен, повторный вызов немедленно
|
||||
завершается и не создаёт второй параллельный цикл.
|
||||
|
||||
Scheduler всегда сбрасывает running в False:
|
||||
|
||||
- после stop();
|
||||
- после ошибки;
|
||||
- после отмены внешней asyncio-задачи.
|
||||
"""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
await self.run_once()
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
await self._sleep(
|
||||
self._interval_seconds,
|
||||
)
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Запросить завершение scheduler loop.
|
||||
|
||||
Метод идемпотентен. Он не отменяет внешнюю asyncio-задачу,
|
||||
а останавливает цикл на ближайшей управляемой границе.
|
||||
"""
|
||||
self._running = False
|
||||
|
||||
async def run_once(self) -> bool:
|
||||
"""
|
||||
Выполнить одну проверку Heartbeat.
|
||||
|
||||
Если Heartbeat Monitor подтверждает timeout, Scheduler
|
||||
передаёт его Runtime Supervisor.
|
||||
|
||||
Возвращаемое значение отражает только результат проверки
|
||||
Heartbeat Monitor.
|
||||
"""
|
||||
timed_out = await self._heartbeat_monitor.check_timeout()
|
||||
|
||||
if timed_out:
|
||||
await self._runtime_supervisor.handle_heartbeat_timeout()
|
||||
|
||||
return timed_out
|
||||
|
||||
@@ -1 +1,203 @@
|
||||
# app/src/market_data/acquisition/runtime/supervisor.py
|
||||
# app/src/market_data/acquisition/runtime/supervisor.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.runtime.heartbeat import (
|
||||
HeartbeatMonitorProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.reconnect import (
|
||||
ReconnectCoordinatorProtocol,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSupervisorState(str, Enum):
|
||||
"""
|
||||
Текущее состояние Runtime Supervisor.
|
||||
"""
|
||||
|
||||
STOPPED = "stopped"
|
||||
RUNNING = "running"
|
||||
RECONNECTING = "reconnecting"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RuntimeSupervisorProtocol(Protocol):
|
||||
"""
|
||||
Контракт координатора lifecycle Acquisition Runtime.
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> RuntimeSupervisorState:
|
||||
"""
|
||||
Вернуть текущее состояние Runtime Supervisor.
|
||||
"""
|
||||
...
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Запустить supervision и Heartbeat Monitor.
|
||||
"""
|
||||
...
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Остановить supervision и Heartbeat Monitor.
|
||||
"""
|
||||
...
|
||||
|
||||
def notify_activity(self) -> None:
|
||||
"""
|
||||
Передать Heartbeat Monitor информацию о Runtime activity.
|
||||
"""
|
||||
...
|
||||
|
||||
async def handle_heartbeat_timeout(self) -> bool:
|
||||
"""
|
||||
Обработать подтверждённый Heartbeat timeout.
|
||||
|
||||
Вернуть True, если была выполнена попытка reconnect.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class RuntimeSupervisor:
|
||||
"""
|
||||
Координатор lifecycle Acquisition Runtime.
|
||||
|
||||
Supervisor объединяет:
|
||||
|
||||
- HeartbeatMonitor;
|
||||
- ReconnectCoordinator;
|
||||
- общее состояние Runtime Session.
|
||||
|
||||
Supervisor:
|
||||
|
||||
- запускает и останавливает Heartbeat Monitor;
|
||||
- принимает уведомления о Runtime activity;
|
||||
- запускает одну попытку reconnect после Heartbeat timeout;
|
||||
- после успешного reconnect начинает новый период Heartbeat;
|
||||
- фиксирует состояния STOPPED, RUNNING, RECONNECTING и FAILED;
|
||||
- предотвращает параллельный reconnect.
|
||||
|
||||
Supervisor не выполняет:
|
||||
|
||||
- периодические проверки Heartbeat;
|
||||
- retry loop;
|
||||
- backoff;
|
||||
- scheduling;
|
||||
- Trade Recovery;
|
||||
- управление WebSocket Transport;
|
||||
- публикацию Runtime Events.
|
||||
|
||||
Периодический вызов Heartbeat и передача timeout в Supervisor
|
||||
будут ответственностью Runtime Scheduler.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_heartbeat_monitor",
|
||||
"_reconnect_coordinator",
|
||||
"_state",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
heartbeat_monitor: HeartbeatMonitorProtocol,
|
||||
reconnect_coordinator: ReconnectCoordinatorProtocol,
|
||||
) -> None:
|
||||
self._heartbeat_monitor = heartbeat_monitor
|
||||
self._reconnect_coordinator = reconnect_coordinator
|
||||
self._state = RuntimeSupervisorState.STOPPED
|
||||
|
||||
@property
|
||||
def state(self) -> RuntimeSupervisorState:
|
||||
"""
|
||||
Вернуть текущее состояние Runtime Supervisor.
|
||||
"""
|
||||
return self._state
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Запустить supervision.
|
||||
|
||||
Повторный вызов start() начинает новый Heartbeat monitoring
|
||||
period и сохраняет Supervisor в состоянии RUNNING.
|
||||
"""
|
||||
self._heartbeat_monitor.start()
|
||||
self._state = RuntimeSupervisorState.RUNNING
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Остановить supervision.
|
||||
|
||||
Повторный вызов stop() безопасен и сохраняет состояние STOPPED.
|
||||
"""
|
||||
self._heartbeat_monitor.stop()
|
||||
self._state = RuntimeSupervisorState.STOPPED
|
||||
|
||||
def notify_activity(self) -> None:
|
||||
"""
|
||||
Передать информацию о Runtime activity в Heartbeat Monitor.
|
||||
|
||||
Активность учитывается только во время RUNNING lifecycle.
|
||||
События, полученные после stop() либо во время reconnect,
|
||||
не должны неявно запускать Heartbeat Monitor.
|
||||
"""
|
||||
if self._state is not RuntimeSupervisorState.RUNNING:
|
||||
return
|
||||
|
||||
self._heartbeat_monitor.record_activity()
|
||||
|
||||
async def handle_heartbeat_timeout(self) -> bool:
|
||||
"""
|
||||
Обработать подтверждённый Heartbeat timeout.
|
||||
|
||||
Последовательность:
|
||||
|
||||
RUNNING
|
||||
↓
|
||||
stop Heartbeat
|
||||
↓
|
||||
RECONNECTING
|
||||
↓
|
||||
ReconnectCoordinator.reconnect()
|
||||
├── success:
|
||||
│ start Heartbeat
|
||||
│ RUNNING
|
||||
│
|
||||
└── failure:
|
||||
FAILED
|
||||
exception propagates
|
||||
|
||||
Если Supervisor не находится в состоянии RUNNING,
|
||||
новая попытка reconnect не запускается.
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
True, если reconnect был выполнен успешно.
|
||||
False, если Supervisor не находился в RUNNING.
|
||||
|
||||
Raises:
|
||||
Exception:
|
||||
Исходная ошибка ReconnectCoordinator распространяется
|
||||
вызывающему компоненту без обёртки.
|
||||
"""
|
||||
if self._state is not RuntimeSupervisorState.RUNNING:
|
||||
return False
|
||||
|
||||
self._heartbeat_monitor.stop()
|
||||
self._state = RuntimeSupervisorState.RECONNECTING
|
||||
|
||||
try:
|
||||
await self._reconnect_coordinator.reconnect()
|
||||
except Exception:
|
||||
self._state = RuntimeSupervisorState.FAILED
|
||||
raise
|
||||
|
||||
self._heartbeat_monitor.start()
|
||||
self._state = RuntimeSupervisorState.RUNNING
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# app/src/market_data/acquisition/
|
||||
# trade_stream_runtime_composition.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||
DzengiTradesDocumentSource,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_consistency_controller import (
|
||||
TradeStreamConsistencyController,
|
||||
)
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
TradeStreamStateStore,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_controller import (
|
||||
TradeRecoveryController,
|
||||
)
|
||||
from src.market_data.acquisition.recovery.trade_recovery_window_planner import (
|
||||
DEFAULT_TRADE_RECOVERY_WINDOW_MS,
|
||||
TradeRecoveryWindowPlanner,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.acquisition_runtime_service import (
|
||||
AcquisitionRuntimeService,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.heartbeat import (
|
||||
HeartbeatMonitor,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.reconnect import (
|
||||
ReconnectCoordinator,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.runtime_recovery_coordinator import (
|
||||
RuntimeRecoveryCoordinator,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.scheduler import (
|
||||
RuntimeScheduler,
|
||||
RuntimeSleep,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.supervisor import (
|
||||
RuntimeSupervisor,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||
AcquisitionRuntimeEventPublisherProtocol,
|
||||
WebSocketSessionProtocol,
|
||||
WebSocketSubscriptionManagerProtocol,
|
||||
WebSocketTransportProtocol,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_acquisition_service import (
|
||||
TradeStreamAcquisitionService,
|
||||
)
|
||||
from src.market_data.acquisition.trade_stream_message_adapter_protocol import (
|
||||
TradeStreamMessageAdapterProtocol,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradeStreamRuntimeComposition:
|
||||
"""
|
||||
Неизменяемый результат композиции Trade Stream Runtime.
|
||||
|
||||
Объект предоставляет явно построенный граф зависимостей и не является
|
||||
Registry либо Service Locator. Все компоненты создаются factory-функцией
|
||||
ровно один раз и повторно используют общие stateful-зависимости.
|
||||
|
||||
Создание Composition не запускает WebSocket, Heartbeat, Supervisor
|
||||
или Scheduler и не выполняет Recovery.
|
||||
"""
|
||||
|
||||
state_store: TradeStreamStateStore
|
||||
consistency_controller: TradeStreamConsistencyController
|
||||
recovery_controller: TradeRecoveryController
|
||||
recovery_window_planner: TradeRecoveryWindowPlanner
|
||||
runtime_recovery_coordinator: RuntimeRecoveryCoordinator
|
||||
|
||||
acquisition_runtime_service: AcquisitionRuntimeService
|
||||
trade_stream_acquisition_service: TradeStreamAcquisitionService
|
||||
|
||||
reconnect_coordinator: ReconnectCoordinator
|
||||
heartbeat_monitor: HeartbeatMonitor
|
||||
runtime_supervisor: RuntimeSupervisor
|
||||
runtime_scheduler: RuntimeScheduler
|
||||
|
||||
|
||||
def build_trade_stream_runtime_composition(
|
||||
*,
|
||||
session: WebSocketSessionProtocol,
|
||||
transport: WebSocketTransportProtocol,
|
||||
subscription_manager: WebSocketSubscriptionManagerProtocol,
|
||||
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||
message_adapter: TradeStreamMessageAdapterProtocol,
|
||||
recovery_document_source: DzengiTradesDocumentSource,
|
||||
heartbeat_timeout_seconds: float,
|
||||
scheduler_interval_seconds: float,
|
||||
max_recovery_window_ms: int = DEFAULT_TRADE_RECOVERY_WINDOW_MS,
|
||||
heartbeat_clock: Callable[[], float] = time.monotonic,
|
||||
scheduler_sleep: RuntimeSleep = asyncio.sleep,
|
||||
) -> TradeStreamRuntimeComposition:
|
||||
"""
|
||||
Построить изолированный граф зависимостей Trade Stream Runtime.
|
||||
|
||||
Factory создаёт единые экземпляры TradeStreamStateStore и
|
||||
TradeStreamConsistencyController. Благодаря этому Live Stream
|
||||
и Recovery используют один checkpoint и одинаковые правила
|
||||
согласованности сделок.
|
||||
|
||||
Внешние WebSocket-зависимости передаются через Protocol-контракты.
|
||||
Factory не создаёт production transport, не читает Settings,
|
||||
не запускает lifecycle и не создаёт фоновые asyncio-задачи.
|
||||
"""
|
||||
state_store = TradeStreamStateStore()
|
||||
|
||||
consistency_controller = TradeStreamConsistencyController(
|
||||
state_store,
|
||||
)
|
||||
|
||||
recovery_controller = TradeRecoveryController(
|
||||
document_source=recovery_document_source,
|
||||
consistency_controller=consistency_controller,
|
||||
)
|
||||
|
||||
recovery_window_planner = TradeRecoveryWindowPlanner(
|
||||
max_window_ms=max_recovery_window_ms,
|
||||
)
|
||||
|
||||
runtime_recovery_coordinator = RuntimeRecoveryCoordinator(
|
||||
state_store=state_store,
|
||||
window_planner=recovery_window_planner,
|
||||
recovery_controller=recovery_controller,
|
||||
)
|
||||
|
||||
acquisition_runtime_service = AcquisitionRuntimeService(
|
||||
session=session,
|
||||
transport=transport,
|
||||
subscription_manager=subscription_manager,
|
||||
event_publisher=event_publisher,
|
||||
)
|
||||
|
||||
trade_stream_acquisition_service = TradeStreamAcquisitionService(
|
||||
runtime_service=acquisition_runtime_service,
|
||||
adapter=message_adapter,
|
||||
consistency_controller=consistency_controller,
|
||||
)
|
||||
|
||||
reconnect_coordinator = ReconnectCoordinator(
|
||||
command_dispatcher=acquisition_runtime_service,
|
||||
subscription_manager=subscription_manager,
|
||||
event_publisher=event_publisher,
|
||||
)
|
||||
|
||||
heartbeat_monitor = HeartbeatMonitor(
|
||||
event_publisher=event_publisher,
|
||||
timeout_seconds=heartbeat_timeout_seconds,
|
||||
clock=heartbeat_clock,
|
||||
)
|
||||
|
||||
runtime_supervisor = RuntimeSupervisor(
|
||||
heartbeat_monitor=heartbeat_monitor,
|
||||
reconnect_coordinator=reconnect_coordinator,
|
||||
)
|
||||
|
||||
runtime_scheduler = RuntimeScheduler(
|
||||
heartbeat_monitor=heartbeat_monitor,
|
||||
runtime_supervisor=runtime_supervisor,
|
||||
interval_seconds=scheduler_interval_seconds,
|
||||
sleep=scheduler_sleep,
|
||||
)
|
||||
|
||||
return TradeStreamRuntimeComposition(
|
||||
state_store=state_store,
|
||||
consistency_controller=consistency_controller,
|
||||
recovery_controller=recovery_controller,
|
||||
recovery_window_planner=recovery_window_planner,
|
||||
runtime_recovery_coordinator=runtime_recovery_coordinator,
|
||||
acquisition_runtime_service=acquisition_runtime_service,
|
||||
trade_stream_acquisition_service=trade_stream_acquisition_service,
|
||||
reconnect_coordinator=reconnect_coordinator,
|
||||
heartbeat_monitor=heartbeat_monitor,
|
||||
runtime_supervisor=runtime_supervisor,
|
||||
runtime_scheduler=runtime_scheduler,
|
||||
)
|
||||
Reference in New Issue
Block a user