Build 060.26: complete Integration and Regression

This commit is contained in:
2026-07-31 14:14:30 +03:00
parent 60bec1eaf9
commit cb8acfe5fe
24 changed files with 4732 additions and 45 deletions

View File

@@ -0,0 +1 @@
"""Общие вспомогательные компоненты тестов Dzentra."""

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
Predicate = Callable[[], bool]
async def wait_until(
predicate: Predicate,
*,
timeout_seconds: float = 3.0,
) -> None:
"""Дождаться наблюдаемого условия без фиксированной длинной паузы."""
async with asyncio.timeout(timeout_seconds):
while not predicate():
await asyncio.sleep(0.005)

View File

@@ -0,0 +1,222 @@
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

View File

@@ -0,0 +1,314 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
from src.bootstrap.trade_stream_runtime import (
build_trade_stream_production_runtime,
)
from src.core.config import Settings, TradeStreamSettings
from src.market_data.acquisition.consistency.trade_stream_state_store import (
TradeStreamStateStore,
)
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
TradeStreamProductionRuntime,
TradeStreamProductionRuntimeState,
)
from tests.support.async_wait import wait_until
SYMBOL = "BTC/USD_LEVERAGE"
RUNTIME_CLEANUP_TIMEOUT_SECONDS = 5.0
OWNED_TASK_NAMES = frozenset(
{
"trade-stream-receive",
"trade-stream-runtime",
"trade-stream-runtime-recovery",
"trade-stream-scheduler",
"trade-stream-startup",
}
)
class ManagedRuntimeProtocol(Protocol):
@property
def state(self) -> TradeStreamProductionRuntimeState:
...
async def run(self) -> None:
...
async def stop(self) -> None:
...
def run_scenario(
scenario: Awaitable[None],
*,
timeout_seconds: float = 30.0,
) -> None:
asyncio.run(
asyncio.wait_for(
scenario,
timeout=timeout_seconds,
)
)
def make_settings(
*,
websocket_url: str,
rest_base_url: str,
open_timeout_seconds: float = 0.5,
probe_timeout_seconds: float = 0.2,
close_timeout_seconds: float = 0.2,
heartbeat_timeout_seconds: float = 60.0,
scheduler_interval_seconds: float = 60.0,
) -> Settings:
return Settings(
bot_token="integration-test-token",
bot_parse_mode="HTML",
app_env="test",
log_level="INFO",
tz="UTC",
exchange_enabled=True,
exchange_name="dzengi",
exchange_base_url=rest_base_url,
exchange_ws_url="",
exchange_api_key="",
exchange_api_secret="",
exchange_timeout_sec=2,
exchange_testnet=True,
default_symbol=SYMBOL,
trade_stream=TradeStreamSettings(
enabled=True,
websocket_url=websocket_url,
symbols=(SYMBOL,),
open_timeout_seconds=open_timeout_seconds,
probe_timeout_seconds=probe_timeout_seconds,
close_timeout_seconds=close_timeout_seconds,
heartbeat_timeout_seconds=heartbeat_timeout_seconds,
scheduler_interval_seconds=scheduler_interval_seconds,
recovery_window_ms=3_599_999,
),
db_host="localhost",
db_port=5432,
db_name="integration",
db_user="integration",
db_password="",
debug_enabled=False,
journal_debug_enabled=False,
)
def build_runtime(
*,
websocket_url: str,
rest_base_url: str,
probe_timeout_seconds: float = 0.2,
close_timeout_seconds: float = 0.2,
heartbeat_timeout_seconds: float = 60.0,
scheduler_interval_seconds: float = 60.0,
) -> TradeStreamProductionRuntime:
runtime = build_trade_stream_production_runtime(
make_settings(
websocket_url=websocket_url,
rest_base_url=rest_base_url,
probe_timeout_seconds=probe_timeout_seconds,
close_timeout_seconds=close_timeout_seconds,
heartbeat_timeout_seconds=heartbeat_timeout_seconds,
scheduler_interval_seconds=scheduler_interval_seconds,
)
)
assert runtime is not None
return runtime
def state_store_from(
runtime: TradeStreamProductionRuntime,
) -> TradeStreamStateStore:
runtime_graph: Any = runtime
return (
runtime_graph
._reconnect_recovery_coordinator
._recovery_coordinator
._state_store
)
def reconnect_coordinator_from(
runtime: TradeStreamProductionRuntime,
) -> Any:
runtime_graph: Any = runtime
return runtime_graph._reconnect_recovery_coordinator
def active_owned_task_names() -> tuple[str, ...]:
current_task = asyncio.current_task()
return tuple(
sorted(
task.get_name()
for task in asyncio.all_tasks()
if task is not current_task
and not task.done()
and task.get_name() in OWNED_TASK_NAMES
)
)
async def start_runtime(
runtime: ManagedRuntimeProtocol,
*,
timeout_seconds: float = 3.0,
) -> asyncio.Task[None]:
task = asyncio.create_task(
runtime.run(),
name="trade-stream-runtime",
)
try:
await wait_until(
lambda: runtime.state
in {
TradeStreamProductionRuntimeState.RUNNING,
TradeStreamProductionRuntimeState.FAILED,
},
timeout_seconds=timeout_seconds,
)
if (
runtime.state
is TradeStreamProductionRuntimeState.FAILED
):
await task
except BaseException as error:
try:
await stop_runtime(runtime, task)
except BaseException as cleanup_error:
error.add_note(
"Runtime startup cleanup also failed: "
f"{type(cleanup_error).__name__}."
)
raise
return task
async def wait_until_or_runtime_exit(
predicate: Callable[[], bool],
*,
runtime_task: asyncio.Task[None],
timeout_seconds: float,
) -> None:
"""
Дождаться условия, немедленно распространяя завершение Runtime.
Runtime task не отменяется: helper владеет только внутренней задачей
ожидания условия. Если Runtime завершился без ошибки до выполнения
условия, это считается ошибкой проверочного сценария.
"""
condition_task = asyncio.create_task(
wait_until(
predicate,
timeout_seconds=timeout_seconds,
)
)
try:
await asyncio.wait(
(
condition_task,
runtime_task,
),
return_when=asyncio.FIRST_COMPLETED,
)
if runtime_task.done():
condition_task.cancel()
try:
await condition_task
except asyncio.CancelledError:
pass
await runtime_task
raise RuntimeError(
"Trade Stream Runtime exited before the expected "
"live condition was reached."
)
await condition_task
finally:
if not condition_task.done():
condition_task.cancel()
try:
await condition_task
except asyncio.CancelledError:
pass
async def stop_runtime(
runtime: ManagedRuntimeProtocol,
task: asyncio.Task[None],
*,
timeout_seconds: float = RUNTIME_CLEANUP_TIMEOUT_SECONDS,
) -> None:
primary_error: BaseException | None = None
if not task.done():
try:
await asyncio.wait_for(
runtime.stop(),
timeout=timeout_seconds,
)
except BaseException as error:
primary_error = error
if not task.done() and primary_error is not None:
task.cancel()
try:
await asyncio.wait_for(
asyncio.shield(task),
timeout=timeout_seconds,
)
except BaseException as error:
if isinstance(error, TimeoutError):
task.cancel()
try:
await asyncio.wait_for(
task,
timeout=timeout_seconds,
)
except asyncio.CancelledError:
pass
except BaseException as cancellation_error:
error.add_note(
"Runtime task cancellation also failed: "
f"{type(cancellation_error).__name__}."
)
if primary_error is None:
primary_error = error
else:
primary_error.add_note(
"Runtime task cleanup also failed: "
f"{type(error).__name__}."
)
if primary_error is not None:
raise primary_error
async def assert_no_owned_tasks() -> None:
await asyncio.sleep(0)
assert active_owned_task_names() == ()