Files
dzentra_bot/app/tests/support/live_trade_stream.py

233 lines
6.2 KiB
Python

from __future__ import annotations
import math
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
if TYPE_CHECKING:
from src.core.config import Settings
RUN_LIVE_TESTS_ENV = "DZENTRA_RUN_LIVE_TESTS"
LIVE_REST_URL_ENV = "DZENTRA_LIVE_REST_URL"
LIVE_WEBSOCKET_URL_ENV = "DZENTRA_LIVE_WS_URL"
LIVE_SYMBOLS_ENV = "DZENTRA_LIVE_SYMBOLS"
LIVE_TRADE_TIMEOUT_ENV = "DZENTRA_LIVE_TRADE_TIMEOUT_SECONDS"
DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS = 600.0
LIVE_RUNTIME_CLEANUP_TIMEOUT_SECONDS = 30.0
class LiveTestConfigurationError(ValueError):
"""Ошибка явной конфигурации opt-in live verification."""
@dataclass(frozen=True, slots=True)
class LiveTradeStreamTestConfig:
rest_url: str
websocket_url: str
symbol: str
trade_timeout_seconds: float
@property
def scenario_timeout_seconds(self) -> float:
return (
self.trade_timeout_seconds * 2
+ LIVE_RUNTIME_CLEANUP_TIMEOUT_SECONDS * 2
)
def load_live_trade_stream_test_config(
environment: Mapping[str, str] | None = None,
) -> LiveTradeStreamTestConfig | None:
values = environment if environment is not None else os.environ
opt_in = values.get(RUN_LIVE_TESTS_ENV, "").strip()
if opt_in in {"", "0"}:
return None
if opt_in != "1":
raise LiveTestConfigurationError(
f"{RUN_LIVE_TESTS_ENV} must be exactly 1 when enabled."
)
rest_url = _require_environment_value(
values,
LIVE_REST_URL_ENV,
).rstrip("/")
websocket_url = _require_environment_value(
values,
LIVE_WEBSOCKET_URL_ENV,
)
symbols_value = _require_environment_value(
values,
LIVE_SYMBOLS_ENV,
)
symbols = tuple(
symbol.strip()
for symbol in symbols_value.split(",")
)
if any(not symbol for symbol in symbols):
raise LiveTestConfigurationError(
f"{LIVE_SYMBOLS_ENV} must not contain empty symbols."
)
if len(symbols) != 1:
raise LiveTestConfigurationError(
f"{LIVE_SYMBOLS_ENV} must contain exactly one symbol "
"for bounded live verification."
)
_validate_secure_url(
rest_url,
name=LIVE_REST_URL_ENV,
expected_scheme="https",
required_path=None,
)
_validate_secure_url(
websocket_url,
name=LIVE_WEBSOCKET_URL_ENV,
expected_scheme="wss",
required_path="/connect",
)
timeout_seconds = _parse_positive_timeout(
values.get(
LIVE_TRADE_TIMEOUT_ENV,
str(DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS),
),
)
return LiveTradeStreamTestConfig(
rest_url=rest_url,
websocket_url=websocket_url,
symbol=symbols[0],
trade_timeout_seconds=timeout_seconds,
)
def build_live_trade_stream_settings(
config: LiveTradeStreamTestConfig,
) -> Settings:
from src.core.config import (
MarketDataStorageSettings,
Settings,
TradeStreamSettings,
)
return Settings(
bot_token="live-verification-does-not-use-telegram",
bot_parse_mode="HTML",
app_env="live-verification",
log_level="INFO",
tz="UTC",
exchange_enabled=True,
exchange_name="dzengi",
exchange_base_url=config.rest_url,
exchange_ws_url="",
exchange_api_key="",
exchange_api_secret="",
exchange_timeout_sec=20,
exchange_testnet=False,
default_symbol=config.symbol,
trade_stream=TradeStreamSettings(
enabled=True,
websocket_url=config.websocket_url,
symbols=(config.symbol,),
open_timeout_seconds=10.0,
probe_timeout_seconds=20.0,
close_timeout_seconds=10.0,
heartbeat_timeout_seconds=20.0,
scheduler_interval_seconds=5.0,
recovery_window_ms=3_599_999,
),
db_host="localhost",
db_port=5432,
db_name="live-verification",
db_user="live-verification",
db_password="",
market_data_storage=MarketDataStorageSettings(
enabled=False,
pool_min_size=1,
pool_max_size=4,
pool_timeout_seconds=10.0,
),
debug_enabled=False,
journal_debug_enabled=False,
)
def _require_environment_value(
environment: Mapping[str, str],
name: str,
) -> str:
value = environment.get(name, "").strip()
if not value:
raise LiveTestConfigurationError(
f"{name} is required when {RUN_LIVE_TESTS_ENV}=1."
)
return value
def _validate_secure_url(
raw_url: str,
*,
name: str,
expected_scheme: str,
required_path: str | None,
) -> None:
parsed = urlsplit(raw_url)
if parsed.scheme.lower() != expected_scheme or not parsed.netloc:
raise LiveTestConfigurationError(
f"{name} must be an absolute {expected_scheme} URL."
)
if parsed.username is not None or parsed.password is not None:
raise LiveTestConfigurationError(
f"{name} must not contain embedded credentials."
)
if parsed.query or parsed.fragment:
raise LiveTestConfigurationError(
f"{name} must not contain a query or fragment."
)
if required_path is None:
if parsed.path not in {"", "/"}:
raise LiveTestConfigurationError(
f"{name} must be a base URL without an endpoint path."
)
return
if parsed.path.rstrip("/") != required_path:
raise LiveTestConfigurationError(
f"{name} must end with {required_path}."
)
def _parse_positive_timeout(raw_value: str) -> float:
try:
timeout_seconds = float(raw_value.strip())
except ValueError as error:
raise LiveTestConfigurationError(
f"{LIVE_TRADE_TIMEOUT_ENV} must be a number."
) from error
if (
not math.isfinite(timeout_seconds)
or timeout_seconds <= 0
):
raise LiveTestConfigurationError(
f"{LIVE_TRADE_TIMEOUT_ENV} must be positive and finite."
)
return timeout_seconds