223 lines
5.9 KiB
Python
223 lines
5.9 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 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="",
|
|
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
|