from __future__ import annotations from typing import Any import pytest import src.bootstrap.trade_stream_runtime as runtime_factory from src.bootstrap.market_data_storage import build_market_data_storage from src.bootstrap.trade_stream_runtime import ( build_trade_stream_production_runtime, ) from src.core.config import ( MarketDataStorageSettings, Settings, TradeStreamSettings, ) from src.market_data.acquisition.runtime.trade_stream_production_runtime import ( TradeStreamProductionRuntime, TradeStreamProductionRuntimeState, ) from src.market_data.acquisition.models.trade import Trade class RecordingTradeObservationSink: def __init__(self) -> None: self.observations: list[Trade] = [] def persist_accepted( self, trade: Trade, *, expected_trade: Trade | None, ) -> None: del expected_trade self.observations.append(trade) def persist_duplicate(self, trade: Trade) -> None: self.observations.append(trade) def make_settings( *, enabled: bool = True, api_key: str = "api-key", storage_enabled: bool = False, subscription_ack_timeout_seconds: float = 12.5, startup_market_buffer_capacity: int = 1_234, ) -> Settings: return Settings( bot_token="test-token", bot_parse_mode="HTML", app_env="test", log_level="INFO", tz="UTC", exchange_enabled=False, exchange_name="dzengi", exchange_base_url="https://rest.example.test/", exchange_ws_url="wss://legacy.example.test", exchange_api_key=api_key, exchange_api_secret="secret", exchange_timeout_sec=17, exchange_testnet=True, default_symbol="LEGACY", trade_stream=TradeStreamSettings( enabled=enabled, websocket_url="wss://stream.example.test/root", symbols=( "ETH/USD_LEVERAGE", "BTC/USD_LEVERAGE", ), open_timeout_seconds=11.0, probe_timeout_seconds=21.0, close_timeout_seconds=9.0, heartbeat_timeout_seconds=31.0, scheduler_interval_seconds=6.0, recovery_window_ms=123_456, subscription_ack_timeout_seconds=( subscription_ack_timeout_seconds ), startup_market_buffer_capacity=( startup_market_buffer_capacity ), ), db_host="localhost", db_port=5432, db_name="test", db_user="test", db_password="test", market_data_storage=MarketDataStorageSettings( enabled=storage_enabled, pool_min_size=1, pool_max_size=4, pool_timeout_seconds=10.0, ), debug_enabled=False, journal_debug_enabled=False, ) def test_disabled_feature_does_not_build_runtime() -> None: settings = make_settings(enabled=False) assert build_trade_stream_production_runtime(settings) is None def test_enabled_storage_requires_enabled_trade_stream() -> None: settings = make_settings( enabled=False, storage_enabled=True, ) with pytest.raises(RuntimeError, match="Trade Stream"): build_trade_stream_production_runtime(settings) def test_builds_runtime_without_starting_lifecycle() -> None: runtime = build_trade_stream_production_runtime( make_settings(), ) assert isinstance(runtime, TradeStreamProductionRuntime) assert runtime.state is TradeStreamProductionRuntimeState.STOPPED assert runtime.running is False assert runtime._startup_task is None assert runtime._receive_task is None assert runtime._scheduler_task is None def test_uses_one_shared_stateful_dependency_graph() -> None: runtime = build_trade_stream_production_runtime( make_settings(), ) assert isinstance(runtime, TradeStreamProductionRuntime) runtime_graph: Any = runtime transport = runtime_graph._transport service = runtime_graph._trade_stream_service reconnect_recovery = runtime_graph._reconnect_recovery_coordinator recovery = reconnect_recovery._recovery_coordinator assert runtime_graph._session._transport is transport assert runtime_graph._subscription_manager._transport is transport assert runtime_graph._runtime_scheduler.liveness_probe is transport assert ( service._consistency_controller is recovery._recovery_controller._consistency_controller ) assert runtime_graph._live_processing_gate is ( reconnect_recovery.live_processing_gate ) assert runtime_graph._runtime_scheduler.runtime_supervisor is ( runtime_graph._runtime_supervisor ) assert runtime_graph._startup_recovery_coordinator is None def test_persistent_runtime_reuses_one_storage_graph_without_io() -> None: settings = make_settings(storage_enabled=True) storage = build_market_data_storage(settings) assert storage is not None runtime = build_trade_stream_production_runtime( settings, trade_observation_sink=storage.trade_observation_sink, checkpoint_storage=storage.trade_repository, ) assert isinstance(runtime, TradeStreamProductionRuntime) runtime_graph: Any = runtime startup_recovery = runtime_graph._startup_recovery_coordinator live_controller = ( runtime_graph._trade_stream_service._consistency_controller ) assert startup_recovery is not None assert ( startup_recovery._state_hydrator._checkpoint_storage is storage.trade_repository ) assert ( storage.trade_observation_sink._trade_storage is storage.trade_repository ) assert ( storage.trade_observation_sink._checkpoint_storage is storage.trade_repository ) assert live_controller._trade_observation_sink is ( storage.trade_observation_sink ) assert startup_recovery._state_hydrator._state_store is ( live_controller._state_store ) assert startup_recovery._state_hydrator._venue == "dzengi" assert storage.trade_observation_sink._venue == "dzengi" assert storage.connection_pool.is_open is False assert storage.lifecycle.started is False assert startup_recovery._hydration_task is None assert startup_recovery._recovery_task is None assert runtime_graph._startup_task is None assert runtime_graph._receive_task is None assert runtime_graph._scheduler_task is None @pytest.mark.parametrize( ("include_sink", "include_checkpoint"), ( (False, False), (True, False), (False, True), ), ) def test_enabled_storage_rejects_incomplete_runtime_graph_before_transport( monkeypatch: pytest.MonkeyPatch, include_sink: bool, include_checkpoint: bool, ) -> None: settings = make_settings(storage_enabled=True) storage = build_market_data_storage(settings) assert storage is not None def unexpected_transport(**kwargs: object) -> None: del kwargs raise AssertionError("Transport graph must not be created.") monkeypatch.setattr( runtime_factory, "DzengiWebSocketTransport", unexpected_transport, ) with pytest.raises(RuntimeError, match="requires both"): build_trade_stream_production_runtime( settings, trade_observation_sink=( storage.trade_observation_sink if include_sink else None ), checkpoint_storage=( storage.trade_repository if include_checkpoint else None ), ) assert storage.connection_pool.is_open is False def test_disabled_storage_rejects_checkpoint_dependency() -> None: settings = make_settings() persistent_settings = make_settings(storage_enabled=True) storage = build_market_data_storage(persistent_settings) assert storage is not None with pytest.raises(RuntimeError, match="requires enabled"): build_trade_stream_production_runtime( settings, checkpoint_storage=storage.trade_repository, ) def test_optional_storage_sink_is_shared_by_live_and_recovery() -> None: sink = RecordingTradeObservationSink() runtime = build_trade_stream_production_runtime( make_settings(), trade_observation_sink=sink, ) assert isinstance(runtime, TradeStreamProductionRuntime) runtime_graph: Any = runtime live_controller = ( runtime_graph._trade_stream_service._consistency_controller ) recovery_controller = ( runtime_graph ._reconnect_recovery_coordinator ._recovery_coordinator ._recovery_controller ._consistency_controller ) assert live_controller is recovery_controller assert live_controller._trade_observation_sink is sink def test_applies_explicit_transport_and_runtime_settings() -> None: settings = make_settings() runtime = build_trade_stream_production_runtime(settings) assert isinstance(runtime, TradeStreamProductionRuntime) runtime_graph: Any = runtime transport = runtime_graph._transport assert transport._url == "wss://stream.example.test/root/connect" assert transport._headers == { "Origin": "https://rest.example.test", "Content-Type": "application/json", "X-MBX-APIKEY": "api-key", } assert transport._open_timeout == 11.0 assert transport._probe_timeout == 21.0 assert transport._close_timeout == 9.0 assert transport._ping_interval is None assert transport._ping_timeout is None assert runtime_graph._symbols == ( "BTC/USD_LEVERAGE", "ETH/USD_LEVERAGE", ) assert runtime_graph._runtime_scheduler.interval_seconds == 6.0 assert runtime_graph._subscription_ack_timeout_seconds == 12.5 assert runtime_graph._startup_market_buffer_capacity == 1_234 assert ( runtime_graph._runtime_supervisor._heartbeat_monitor.timeout_seconds == 31.0 ) assert ( runtime_graph._reconnect_recovery_coordinator ._recovery_coordinator ._window_planner .max_window_ms == 123_456 ) def test_recovery_rest_client_reuses_settings_snapshot() -> None: settings = make_settings(api_key="") runtime = build_trade_stream_production_runtime(settings) assert isinstance(runtime, TradeStreamProductionRuntime) runtime_graph: Any = runtime document_source = ( runtime_graph._reconnect_recovery_coordinator ._recovery_coordinator ._recovery_controller ._document_source ) rest_client = document_source._client assert rest_client.settings is settings assert rest_client.base_url == "https://rest.example.test" assert rest_client.timeout == 17 assert "X-MBX-APIKEY" not in runtime_graph._transport._headers