diff --git a/app/pytest.ini b/app/pytest.ini new file mode 100644 index 0000000..d38352d --- /dev/null +++ b/app/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +minversion = 8.0 + +testpaths = + tests + +python_files = + test_*.py + +python_classes = + Test* + +python_functions = + test_* + +addopts = + -ra + --strict-markers \ No newline at end of file diff --git a/app/scripts/check_trade_backfill_api.py b/app/scripts/check_trade_backfill_api.py new file mode 100644 index 0000000..038b1f3 --- /dev/null +++ b/app/scripts/check_trade_backfill_api.py @@ -0,0 +1,1664 @@ +# app/scripts/check_trade_backfill_api.py + +from __future__ import annotations + +import argparse +import json +import time +from datetime import UTC, datetime +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from src.core.config import load_settings + + +DEFAULT_ENDPOINT_PATH = "/api/v2/aggTrades" +DEFAULT_LIMIT = 20 +DEFAULT_REQUEST_DELAY_SECONDS = 0.2 +MAX_RESPONSE_PREVIEW_ITEMS = 10 + +STATUS_PASS = "PASS" +STATUS_FAIL = "FAIL" +STATUS_INCONCLUSIVE = "INCONCLUSIVE" +STATUS_ERROR = "ERROR" +STATUS_SKIPPED = "SKIPPED" + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Исследование REST API aggTrades для проектирования " + "подсистемы Trade Recovery." + ) + ) + + parser.add_argument( + "--symbol", + help=( + "Торговый символ. По умолчанию используется DEFAULT_SYMBOL " + "из конфигурации проекта." + ), + ) + parser.add_argument( + "--limit", + type=int, + default=DEFAULT_LIMIT, + help=( + "Базовый размер выборки для диагностических запросов " + f"(по умолчанию: {DEFAULT_LIMIT})." + ), + ) + parser.add_argument( + "--delay", + type=float, + default=DEFAULT_REQUEST_DELAY_SECONDS, + help=( + "Пауза между HTTP-запросами в секундах " + f"(по умолчанию: {DEFAULT_REQUEST_DELAY_SECONDS})." + ), + ) + parser.add_argument( + "--endpoint-path", + default=DEFAULT_ENDPOINT_PATH, + help=( + "Путь REST endpoint " + f"(по умолчанию: {DEFAULT_ENDPOINT_PATH})." + ), + ) + parser.add_argument( + "--json", + action="store_true", + help="Вывести итоговый отчёт только в формате JSON.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Показывать расширенные диагностические данные.", + ) + + arguments = parser.parse_args() + + if arguments.limit <= 0: + parser.error("--limit должен быть положительным целым числом.") + + if arguments.delay < 0: + parser.error("--delay не может быть отрицательным.") + + if not str(arguments.endpoint_path).strip(): + parser.error("--endpoint-path не может быть пустым.") + + return arguments + + +def build_rest_url( + base_url: str, + endpoint_path: str, +) -> str: + normalized_base_url = base_url.strip().rstrip("/") + + if not normalized_base_url: + raise RuntimeError( + "EXCHANGE_BASE_URL не задан в конфигурации проекта." + ) + + if not normalized_base_url.startswith(("http://", "https://")): + raise RuntimeError( + "EXCHANGE_BASE_URL должен начинаться с http:// или https://." + ) + + return ( + f"{normalized_base_url}/" + f"{endpoint_path.strip().lstrip('/')}" + ) + + +def build_headers( + api_key: str, +) -> dict[str, str]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "Dzentra-Trade-Backfill-Diagnostics/1.0", + } + + normalized_api_key = api_key.strip() + + if normalized_api_key: + headers["X-MBX-APIKEY"] = normalized_api_key + + return headers + + +def utc_timestamp() -> str: + return datetime.now(UTC).isoformat() + + +def format_json(document: object) -> str: + return json.dumps( + document, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + +def print_separator( + character: str = "=", + width: int = 80, +) -> None: + print(character * width) + + +def print_section(title: str) -> None: + print() + print_separator() + print(title) + print_separator() + print() + + +def print_subsection(title: str) -> None: + print() + print_separator("-") + print(title) + print_separator("-") + print() + + +def build_result( + name: str, + status: str, + summary: str, + *, + details: list[str] | None = None, + evidence: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "name": name, + "status": status, + "summary": summary, + "details": details or [], + "evidence": evidence or {}, + } + + +def print_result( + result: dict[str, Any], + *, + verbose: bool, +) -> None: + print(f"Status: {result['status']}") + print(f"Conclusion: {result['summary']}") + + details = result.get("details") or [] + + if details: + print() + print("Details:") + + for detail in details: + print(f" - {detail}") + + evidence = result.get("evidence") or {} + + if verbose and evidence: + print() + print("Evidence:") + print(format_json(evidence)) + + +def request_json( + url: str, + headers: dict[str, str], + timeout_seconds: float, + *, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + query = urlencode( + { + key: value + for key, value in (params or {}).items() + if value is not None + } + ) + + request_url = f"{url}?{query}" if query else url + + request = Request( + request_url, + headers=headers, + method="GET", + ) + + started_at = time.monotonic() + + try: + with urlopen( + request, + timeout=timeout_seconds, + ) as response: + raw_body = response.read().decode( + "utf-8", + errors="replace", + ) + + elapsed_ms = ( + time.monotonic() - started_at + ) * 1000.0 + + try: + payload = json.loads(raw_body) + except json.JSONDecodeError as exc: + return { + "ok": False, + "url": request_url, + "status_code": response.status, + "elapsed_ms": elapsed_ms, + "payload": None, + "raw_body": raw_body, + "error_type": type(exc).__name__, + "error": "REST API вернул невалидный JSON.", + } + + return { + "ok": True, + "url": request_url, + "status_code": response.status, + "elapsed_ms": elapsed_ms, + "payload": payload, + "raw_body": raw_body, + "error_type": None, + "error": None, + } + + except HTTPError as exc: + elapsed_ms = ( + time.monotonic() - started_at + ) * 1000.0 + + raw_body = exc.read().decode( + "utf-8", + errors="replace", + ) + + try: + payload: object = json.loads(raw_body) + except json.JSONDecodeError: + payload = raw_body + + return { + "ok": False, + "url": request_url, + "status_code": exc.code, + "elapsed_ms": elapsed_ms, + "payload": payload, + "raw_body": raw_body, + "error_type": type(exc).__name__, + "error": str(exc), + } + + except URLError as exc: + elapsed_ms = ( + time.monotonic() - started_at + ) * 1000.0 + + return { + "ok": False, + "url": request_url, + "status_code": None, + "elapsed_ms": elapsed_ms, + "payload": None, + "raw_body": "", + "error_type": type(exc).__name__, + "error": str(exc.reason), + } + + except TimeoutError as exc: + elapsed_ms = ( + time.monotonic() - started_at + ) * 1000.0 + + return { + "ok": False, + "url": request_url, + "status_code": None, + "elapsed_ms": elapsed_ms, + "payload": None, + "raw_body": "", + "error_type": type(exc).__name__, + "error": str(exc), + } + + +def execute_request( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + response = request_json( + url, + headers, + timeout_seconds, + params=params, + ) + + if request_delay_seconds > 0: + time.sleep(request_delay_seconds) + + return response + + +def response_preview( + response: dict[str, Any], +) -> object: + payload = response.get("payload") + + if not isinstance(payload, list): + return payload + + if len(payload) <= MAX_RESPONSE_PREVIEW_ITEMS: + return payload + + return payload[:MAX_RESPONSE_PREVIEW_ITEMS] + + +def response_evidence( + response: dict[str, Any], +) -> dict[str, Any]: + return { + "url": response.get("url"), + "status_code": response.get("status_code"), + "elapsed_ms": round( + float(response.get("elapsed_ms") or 0.0), + 3, + ), + "payload_preview": response_preview(response), + "error_type": response.get("error_type"), + "error": response.get("error"), + } + + +def extract_trade_id( + document: dict[str, Any], +) -> int: + for field_name in ( + "a", + "id", + "tradeId", + "trade_id", + ): + value = document.get(field_name) + + if value is None or isinstance(value, bool): + continue + + try: + return int(value) + except (TypeError, ValueError): + continue + + raise ValueError( + "Не удалось определить trade_id в документе сделки: " + f"{document!r}" + ) + + +def extract_trade_timestamp_ms( + document: dict[str, Any], +) -> int | None: + for field_name in ( + "T", + "time", + "timestamp", + "executedAt", + "executed_at", + ): + value = document.get(field_name) + + if value is None or isinstance(value, bool): + continue + + try: + return int(value) + except (TypeError, ValueError): + continue + + return None + + +def normalize_trade_document( + document: object, +) -> dict[str, Any]: + if not isinstance(document, dict): + raise ValueError( + "Элемент ответа aggTrades должен быть JSON object, " + f"получено: {type(document).__name__}." + ) + + return { + "trade_id": extract_trade_id(document), + "timestamp_ms": extract_trade_timestamp_ms(document), + "raw": document, + } + + +def normalize_trade_list( + payload: object, +) -> list[dict[str, Any]]: + if not isinstance(payload, list): + raise ValueError( + "Ответ aggTrades должен быть JSON array, " + f"получено: {type(payload).__name__}." + ) + + return [ + normalize_trade_document(document) + for document in payload + ] + + +def trade_ids( + trades: list[dict[str, Any]], +) -> list[int]: + return [ + int(trade["trade_id"]) + for trade in trades + ] + + +def trade_timestamps_ms( + trades: list[dict[str, Any]], +) -> list[int | None]: + return [ + trade.get("timestamp_ms") + for trade in trades + ] + + +def describe_trade_range( + trades: list[dict[str, Any]], +) -> dict[str, Any]: + ids = trade_ids(trades) + timestamps = trade_timestamps_ms(trades) + + return { + "count": len(trades), + "first_trade_id": ids[0] if ids else None, + "last_trade_id": ids[-1] if ids else None, + "trade_ids": ids, + "first_timestamp_ms": timestamps[0] if timestamps else None, + "last_timestamp_ms": timestamps[-1] if timestamps else None, + } + + +def detect_order(values: list[int]) -> str: + if len(values) < 2: + return "UNKNOWN" + + if all( + current < following + for current, following in zip(values, values[1:]) + ): + return "ASCENDING" + + if all( + current > following + for current, following in zip(values, values[1:]) + ): + return "DESCENDING" + + return "UNORDERED" + + +def is_non_decreasing(values: list[int]) -> bool: + return all( + current <= following + for current, following in zip(values, values[1:]) + ) + + +def is_non_increasing(values: list[int]) -> bool: + return all( + current >= following + for current, following in zip(values, values[1:]) + ) + + +def response_matches_trade_ids( + trades: list[dict[str, Any]], + expected_ids: list[int], +) -> bool: + return trade_ids(trades) == expected_ids + + +def request_trades( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + from_id: int | None = None, + start_time: int | None = None, + end_time: int | None = None, + limit: int | None = None, +) -> tuple[dict[str, Any], list[dict[str, Any]] | None]: + response = execute_request( + url, + headers, + timeout_seconds, + request_delay_seconds, + params={ + "symbol": symbol, + "fromId": from_id, + "startTime": start_time, + "endTime": end_time, + "limit": limit, + }, + ) + + if not response["ok"]: + return response, None + + try: + trades = normalize_trade_list(response["payload"]) + except ValueError as exc: + response = { + **response, + "ok": False, + "error_type": type(exc).__name__, + "error": str(exc), + } + return response, None + + return response, trades + + +def test_endpoint_accessibility( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + limit: int, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + response, trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + limit=limit, + ) + + if not response["ok"] or trades is None: + return ( + build_result( + "Endpoint accessibility", + STATUS_ERROR, + "Endpoint aggTrades недоступен или вернул некорректный ответ.", + evidence=response_evidence(response), + ), + [], + ) + + if not trades: + return ( + build_result( + "Endpoint accessibility", + STATUS_INCONCLUSIVE, + "Endpoint доступен, но не вернул сделок для выбранного символа.", + details=[ + "Проверьте торговую активность и корректность символа.", + ], + evidence=response_evidence(response), + ), + [], + ) + + return ( + build_result( + "Endpoint accessibility", + STATUS_PASS, + "Endpoint aggTrades доступен и возвращает сделки.", + details=[ + f"Получено сделок: {len(trades)}.", + f"HTTP status: {response['status_code']}.", + ], + evidence={ + **response_evidence(response), + "trade_range": describe_trade_range(trades), + }, + ), + trades, + ) + + +def test_from_id_support( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + baseline_trades: list[dict[str, Any]], + limit: int, +) -> tuple[dict[str, Any], list[dict[str, Any]], int | None]: + baseline_ids = trade_ids(baseline_trades) + + candidate_indexes = sorted( + { + min(5, len(baseline_ids) - 1), + len(baseline_ids) // 2, + len(baseline_ids) - 1, + } + ) + + observations: list[dict[str, Any]] = [] + selected_trades: list[dict[str, Any]] = [] + selected_from_id: int | None = None + + for index in candidate_indexes: + requested_from_id = baseline_ids[index] + response, returned_trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + from_id=requested_from_id, + limit=limit, + ) + + returned_ids = trade_ids(returned_trades or []) + matches_baseline = returned_ids == baseline_ids + + observations.append( + { + "baseline_index": index, + "requested_from_id": requested_from_id, + "ok": response["ok"], + "status_code": response["status_code"], + "returned_count": len(returned_ids), + "first_trade_id": returned_ids[0] if returned_ids else None, + "last_trade_id": returned_ids[-1] if returned_ids else None, + "contains_requested_from_id": requested_from_id in returned_ids, + "matches_latest_page": matches_baseline, + "trade_ids": returned_ids, + "error": response.get("error"), + } + ) + + if response["ok"] and returned_trades and not matches_baseline: + selected_trades = returned_trades + selected_from_id = requested_from_id + + successful = [item for item in observations if item["ok"]] + changed = [ + item + for item in successful + if not item["matches_latest_page"] + ] + + if not successful: + return ( + build_result( + "fromId support", + STATUS_FAIL, + "Все запросы с историческими fromId завершились ошибкой.", + evidence={"observations": observations}, + ), + [], + None, + ) + + if not changed: + return ( + build_result( + "fromId support", + STATUS_INCONCLUSIVE, + "Исторические значения fromId не изменили latest page; влияние параметра не подтверждено.", + details=[ + "Сервер мог проигнорировать fromId или применить fallback к последней странице.", + ], + evidence={"observations": observations}, + ), + [], + None, + ) + + return ( + build_result( + "fromId support", + STATUS_PASS, + "Исторический fromId изменяет возвращаемый диапазон сделок.", + evidence={"observations": observations}, + ), + selected_trades, + selected_from_id, + ) + + + +def test_deep_historical_from_id( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + baseline_trades: list[dict[str, Any]], + limit: int, +) -> tuple[dict[str, Any], list[dict[str, Any]], int | None]: + baseline_ids = trade_ids(baseline_trades) + + deep_response, deep_trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + limit=1000, + ) + + if not deep_response["ok"] or not deep_trades: + return ( + build_result( + "Deep historical fromId", + STATUS_ERROR, + "Не удалось получить глубокую историческую выборку limit=1000.", + evidence=response_evidence(deep_response), + ), + [], + None, + ) + + deep_ids = trade_ids(deep_trades) + candidates = [trade_id for trade_id in reversed(deep_ids) if trade_id not in baseline_ids] + + if not candidates: + return ( + build_result( + "Deep historical fromId", + STATUS_INCONCLUSIVE, + "В выборке limit=1000 не найден trade_id вне latest page.", + evidence={ + "latest_page_count": len(baseline_ids), + "deep_page_count": len(deep_ids), + "deep_first_trade_id": deep_ids[0] if deep_ids else None, + "deep_last_trade_id": deep_ids[-1] if deep_ids else None, + }, + ), + [], + None, + ) + + requested_from_id = candidates[0] + + response, returned_trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + from_id=requested_from_id, + limit=limit, + ) + + returned_ids = trade_ids(returned_trades or []) + matches_latest_page = returned_ids == baseline_ids + contains_requested_from_id = requested_from_id in returned_ids + + evidence = { + "requested_from_id": requested_from_id, + "deep_page_count": len(deep_ids), + "deep_first_trade_id": deep_ids[0] if deep_ids else None, + "deep_last_trade_id": deep_ids[-1] if deep_ids else None, + "ok": response["ok"], + "status_code": response["status_code"], + "returned_count": len(returned_ids), + "first_trade_id": returned_ids[0] if returned_ids else None, + "last_trade_id": returned_ids[-1] if returned_ids else None, + "contains_requested_from_id": contains_requested_from_id, + "matches_latest_page": matches_latest_page, + "trade_ids": returned_ids, + "error": response.get("error"), + "payload": response.get("payload"), + } + + if not response["ok"]: + return ( + build_result( + "Deep historical fromId", + STATUS_FAIL, + "Запрос с глубоким историческим fromId завершился ошибкой.", + evidence=evidence, + ), + [], + requested_from_id, + ) + + if matches_latest_page: + return ( + build_result( + "Deep historical fromId", + STATUS_FAIL, + "Глубокий исторический fromId не изменил latest page.", + details=[ + "Практически подтверждено, что fromId игнорируется или приводит к fallback на latest page.", + ], + evidence=evidence, + ), + returned_trades or [], + requested_from_id, + ) + + return ( + build_result( + "Deep historical fromId", + STATUS_PASS, + "Глубокий исторический fromId изменяет возвращаемый диапазон сделок.", + details=[ + "Параметр fromId пригоден для дальнейшего исследования как курсор исторической навигации.", + ], + evidence=evidence, + ), + returned_trades or [], + requested_from_id, + ) + +def test_from_id_semantics( + *, + requested_from_id: int, + trades: list[dict[str, Any]], +) -> dict[str, Any]: + if not trades: + return build_result( + "fromId semantics", + STATUS_INCONCLUSIVE, + "Невозможно определить семантику fromId без сделок.", + ) + + ids = trade_ids(trades) + order = detect_order(ids) + + if requested_from_id not in ids: + return build_result( + "fromId semantics", + STATUS_INCONCLUSIVE, + "Запрошенный fromId отсутствует в ответе.", + evidence={ + "requested_from_id": requested_from_id, + "order": order, + "trade_ids": ids, + }, + ) + + position = ids.index(requested_from_id) + + return build_result( + "fromId semantics", + STATUS_PASS, + "Запрошенный fromId включён в ответ.", + details=[ + f"Позиция requested_from_id в ответе: {position}.", + f"Порядок ответа: {order}.", + ], + evidence={ + "requested_from_id": requested_from_id, + "position": position, + "order": order, + "trade_ids": ids, + }, + ) + + +def test_ordering( + trades: list[dict[str, Any]], +) -> dict[str, Any]: + ids = trade_ids(trades) + order = detect_order(ids) + + if order == "UNKNOWN": + return build_result( + "Ordering", + STATUS_INCONCLUSIVE, + "Недостаточно сделок для определения порядка.", + ) + + if order == "UNORDERED": + return build_result( + "Ordering", + STATUS_FAIL, + "REST возвращает сделки в немонотонном порядке trade_id.", + evidence={"order": order, "trade_ids": ids}, + ) + + return build_result( + "Ordering", + STATUS_PASS, + f"REST возвращает сделки в порядке {order} по trade_id.", + evidence={"order": order, "trade_ids": ids}, + ) + + +def test_trade_id_spacing( + trades: list[dict[str, Any]], +) -> dict[str, Any]: + ids = trade_ids(trades) + + if len(ids) < 2: + return build_result( + "Trade ID spacing", + STATUS_INCONCLUSIVE, + "Недостаточно сделок для анализа расстояний между trade_id.", + ) + + deltas = [ + abs(following - current) + for current, following in zip(ids, ids[1:]) + ] + + return build_result( + "Trade ID spacing", + STATUS_PASS, + "Расстояния между соседними trade_id измерены без предположения об арифметической непрерывности.", + details=[ + f"Минимальный delta: {min(deltas)}.", + f"Максимальный delta: {max(deltas)}.", + "Ненулевые gaps допустимы и не интерпретируются как потеря сделок.", + ], + evidence={ + "trade_ids": ids, + "absolute_deltas": deltas, + }, + ) + + +def test_limit_behavior( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, +) -> dict[str, Any]: + requested_limits = (1, 5, 20, 50, 100, 500, 1000, 1001) + observations: list[dict[str, Any]] = [] + + for requested_limit in requested_limits: + response, trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + limit=requested_limit, + ) + + observations.append( + { + "requested_limit": requested_limit, + "ok": response["ok"], + "status_code": response["status_code"], + "returned_count": len(trades or []), + "error": response.get("error"), + "payload": response.get("payload") if not response["ok"] else None, + } + ) + + overflow = [ + item + for item in observations + if item["ok"] + and item["returned_count"] > item["requested_limit"] + ] + + if overflow: + return build_result( + "Limit behavior", + STATUS_FAIL, + "API вернул больше сделок, чем запрошено через limit.", + evidence={"observations": observations}, + ) + + accepted = [item for item in observations if item["ok"]] + rejected = [item for item in observations if not item["ok"]] + + return build_result( + "Limit behavior", + STATUS_PASS, + "Поведение limit измерено, включая предполагаемую верхнюю границу.", + details=[ + f"Принятые значения: {[item['requested_limit'] for item in accepted]}.", + f"Отклонённые значения: {[item['requested_limit'] for item in rejected]}.", + ], + evidence={"observations": observations}, + ) + + +def test_invalid_from_id_fallback( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + baseline_trades: list[dict[str, Any]], + limit: int, +) -> dict[str, Any]: + baseline_ids = trade_ids(baseline_trades) + latest_trade_id = max(baseline_ids) + + cases = { + "negative": -1, + "future": latest_trade_id + 1_000_000, + } + observations: dict[str, Any] = {} + + for name, requested_from_id in cases.items(): + response, trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + from_id=requested_from_id, + limit=limit, + ) + + returned_ids = trade_ids(trades or []) + observations[name] = { + "requested_from_id": requested_from_id, + "ok": response["ok"], + "status_code": response["status_code"], + "matches_latest_page": returned_ids == baseline_ids, + "trade_ids": returned_ids, + "error": response.get("error"), + "payload": response.get("payload") if not response["ok"] else None, + } + + fallback_cases = [ + name + for name, observation in observations.items() + if observation["ok"] and observation["matches_latest_page"] + ] + + if len(fallback_cases) == len(cases): + return build_result( + "Invalid fromId fallback", + STATUS_PASS, + "Некорректные fromId приводят к fallback на latest page.", + evidence={"observations": observations}, + ) + + return build_result( + "Invalid fromId fallback", + STATUS_INCONCLUSIVE, + "Поведение некорректных fromId неоднородно или не совпадает с latest page.", + evidence={"observations": observations}, + ) + + +def test_repeatability( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + from_id: int | None, + limit: int, +) -> dict[str, Any]: + first_response, first_trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + from_id=from_id, + limit=limit, + ) + second_response, second_trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + from_id=from_id, + limit=limit, + ) + + if ( + not first_response["ok"] + or not second_response["ok"] + or first_trades is None + or second_trades is None + ): + return build_result( + "Repeatability", + STATUS_INCONCLUSIVE, + "Не удалось выполнить два идентичных запроса.", + evidence={ + "first": response_evidence(first_response), + "second": response_evidence(second_response), + }, + ) + + first_ids = trade_ids(first_trades) + second_ids = trade_ids(second_trades) + + if first_ids == second_ids: + return build_result( + "Repeatability", + STATUS_PASS, + "Идентичные запросы возвращают одинаковую последовательность trade_id.", + evidence={ + "from_id": from_id, + "first_trade_ids": first_ids, + "second_trade_ids": second_ids, + }, + ) + + return build_result( + "Repeatability", + STATUS_FAIL, + "Идентичные запросы возвращают разные последовательности trade_id.", + evidence={ + "from_id": from_id, + "first_trade_ids": first_ids, + "second_trade_ids": second_ids, + }, + ) + + +def test_time_filters( + url: str, + headers: dict[str, str], + timeout_seconds: float, + request_delay_seconds: float, + *, + symbol: str, + baseline_trades: list[dict[str, Any]], + limit: int, +) -> dict[str, Any]: + timestamps = [ + timestamp + for timestamp in trade_timestamps_ms(baseline_trades) + if timestamp is not None + ] + + if len(timestamps) < 3: + return build_result( + "Time filters", + STATUS_SKIPPED, + "Недостаточно временных меток для проверки фильтров.", + ) + + lower_bound = sorted(timestamps)[len(timestamps) // 3] + upper_bound = sorted(timestamps)[(len(timestamps) * 2) // 3] + + cases = { + "startTime": { + "start_time": lower_bound, + "end_time": None, + }, + "endTime": { + "start_time": None, + "end_time": upper_bound, + }, + "startTime + endTime": { + "start_time": lower_bound, + "end_time": upper_bound, + }, + } + + observations: dict[str, Any] = {} + + for name, values in cases.items(): + response, trades = request_trades( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + start_time=values["start_time"], + end_time=values["end_time"], + limit=limit, + ) + + returned_timestamps = [ + timestamp + for timestamp in trade_timestamps_ms(trades or []) + if timestamp is not None + ] + + start_respected = ( + values["start_time"] is None + or all( + timestamp >= values["start_time"] + for timestamp in returned_timestamps + ) + ) + end_respected = ( + values["end_time"] is None + or all( + timestamp <= values["end_time"] + for timestamp in returned_timestamps + ) + ) + + observations[name] = { + "ok": response["ok"], + "status_code": response["status_code"], + "start_time": values["start_time"], + "end_time": values["end_time"], + "returned_count": len(trades or []), + "first_timestamp_ms": returned_timestamps[0] if returned_timestamps else None, + "last_timestamp_ms": returned_timestamps[-1] if returned_timestamps else None, + "start_time_respected": start_respected, + "end_time_respected": end_respected, + "error": response.get("error"), + "payload": response.get("payload") if not response["ok"] else None, + } + + valid = [ + name + for name, observation in observations.items() + if observation["ok"] + and observation["start_time_respected"] + and observation["end_time_respected"] + ] + + if valid: + return build_result( + "Time filters", + STATUS_PASS, + "Как минимум один временной фильтр фактически ограничивает timestamps ответа.", + details=[f"Подтверждённые варианты: {', '.join(valid)}."], + evidence={"observations": observations}, + ) + + return build_result( + "Time filters", + STATUS_INCONCLUSIVE, + "Временные фильтры не подтверждены по содержимому ответа.", + evidence={"observations": observations}, + ) + + +def test_timestamp_monotonicity( + trades: list[dict[str, Any]], +) -> dict[str, Any]: + ids = trade_ids(trades) + timestamps = trade_timestamps_ms(trades) + + if any(timestamp is None for timestamp in timestamps): + return build_result( + "Timestamp monotonicity", + STATUS_INCONCLUSIVE, + "Не у всех сделок удалось извлечь временную метку.", + evidence={ + "timestamps_ms": timestamps, + "trade_ids": ids, + }, + ) + + normalized_timestamps: list[int] = [ + timestamp + for timestamp in timestamps + if timestamp is not None + ] + order = detect_order(ids) + + if order == "ASCENDING": + consistent = is_non_decreasing(normalized_timestamps) + elif order == "DESCENDING": + consistent = is_non_increasing(normalized_timestamps) + else: + return build_result( + "Timestamp monotonicity", + STATUS_INCONCLUSIVE, + "Невозможно сопоставить timestamps с немонотонным порядком trade_id.", + evidence={ + "order": order, + "trade_ids": ids, + "timestamps_ms": normalized_timestamps, + }, + ) + + if consistent: + return build_result( + "Timestamp monotonicity", + STATUS_PASS, + f"Временные метки согласованы с порядком {order} по trade_id.", + evidence={ + "order": order, + "trade_ids": ids, + "timestamps_ms": normalized_timestamps, + }, + ) + + return build_result( + "Timestamp monotonicity", + STATUS_FAIL, + f"Временные метки не согласованы с порядком {order} по trade_id.", + evidence={ + "order": order, + "trade_ids": ids, + "timestamps_ms": normalized_timestamps, + }, + ) + + +def build_architectural_conclusions( + results: list[dict[str, Any]], +) -> list[str]: + statuses = { + result["name"]: result["status"] + for result in results + } + + conclusions: list[str] = [] + + if statuses.get("Deep historical fromId") == STATUS_PASS: + conclusions.append( + "Глубокий исторический fromId изменяет выборку и может рассматриваться как кандидат на курсор Recovery." + ) + elif statuses.get("Deep historical fromId") == STATUS_FAIL: + conclusions.append( + "Даже глубокий исторический fromId не изменяет latest page; Recovery нельзя проектировать вокруг fromId без дополнительного подтверждения контракта биржи." + ) + elif statuses.get("fromId support") == STATUS_PASS: + conclusions.append( + "fromId влияет на историческую выборку и может рассматриваться как кандидат на курсор Recovery." + ) + else: + conclusions.append( + "Использование fromId в Recovery пока не подтверждено." + ) + + if statuses.get("Ordering") == STATUS_PASS: + conclusions.append( + "REST-выдача имеет определённый монотонный порядок, который должен быть нормализован перед передачей в consistency layer." + ) + + conclusions.append( + "Recovery не должен предполагать арифметическую непрерывность trade_id; gaps допустимы." + ) + + if statuses.get("Repeatability") == STATUS_PASS: + conclusions.append( + "Повторный запрос одного диапазона детерминирован по trade_id." + ) + + if statuses.get("Invalid fromId fallback") == STATUS_PASS: + conclusions.append( + "Некорректный fromId нельзя использовать как сигнал пустого диапазона: сервер возвращает latest page." + ) + + return conclusions + + +def print_summary( + results: list[dict[str, Any]], + architectural_conclusions: list[str], +) -> None: + print_section("Diagnostic summary") + + counters = { + STATUS_PASS: 0, + STATUS_FAIL: 0, + STATUS_INCONCLUSIVE: 0, + STATUS_ERROR: 0, + STATUS_SKIPPED: 0, + } + + for result in results: + counters[result["status"]] += 1 + + for status, count in counters.items(): + print(f"{status}: {count}") + + print() + print("Architectural conclusions:") + + for conclusion in architectural_conclusions: + print(f" - {conclusion}") + + +def run_diagnostics( + *, + symbol: str, + endpoint_path: str, + limit: int, + request_delay_seconds: float, + verbose: bool, + json_output: bool, +) -> dict[str, Any]: + settings = load_settings() + + url = build_rest_url( + settings.exchange_base_url, + endpoint_path, + ) + headers = build_headers(settings.exchange_api_key) + timeout_seconds = float(settings.exchange_timeout_sec) + + results: list[dict[str, Any]] = [] + + if not json_output: + print_section("Trade Backfill API Diagnostics") + print(f"Generated at: {utc_timestamp()}") + print(f"Exchange: {settings.exchange_name}") + print(f"REST URL: {url}") + print(f"Symbol: {symbol}") + print(f"Base limit: {limit}") + print(f"Timeout: {timeout_seconds:.1f} seconds") + print(f"Request delay: {request_delay_seconds:.3f} seconds") + + endpoint_result, baseline_trades = test_endpoint_accessibility( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + limit=max(limit, 20), + ) + results.append(endpoint_result) + + if not json_output: + print_subsection(endpoint_result["name"]) + print_result(endpoint_result, verbose=verbose) + + if not baseline_trades: + conclusions = build_architectural_conclusions(results) + report = { + "generated_at": utc_timestamp(), + "exchange": settings.exchange_name, + "url": url, + "symbol": symbol, + "results": results, + "architectural_conclusions": conclusions, + } + + if json_output: + print(format_json(report)) + else: + print_summary(results, conclusions) + + return report + + baseline_ids = trade_ids(baseline_trades) + + from_id_result, historical_trades, historical_from_id = test_from_id_support( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + baseline_trades=baseline_trades, + limit=limit, + ) + results.append(from_id_result) + + if not json_output: + print_subsection(from_id_result["name"]) + print_result(from_id_result, verbose=verbose) + + deep_from_id_result, deep_historical_trades, deep_historical_from_id = ( + test_deep_historical_from_id( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + baseline_trades=baseline_trades, + limit=limit, + ) + ) + results.append(deep_from_id_result) + + if not json_output: + print_subsection(deep_from_id_result["name"]) + print_result(deep_from_id_result, verbose=verbose) + + selected_trades = ( + deep_historical_trades + if deep_from_id_result["status"] == STATUS_PASS + else historical_trades or baseline_trades + ) + + selected_from_id = ( + deep_historical_from_id + if deep_from_id_result["status"] == STATUS_PASS + else historical_from_id + ) + + dependent_tests: list[dict[str, Any]] = [ + test_ordering(selected_trades), + test_trade_id_spacing(selected_trades), + test_timestamp_monotonicity(selected_trades), + test_limit_behavior( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + ), + test_invalid_from_id_fallback( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + baseline_trades=baseline_trades, + limit=limit, + ), + test_repeatability( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + from_id=( + baseline_ids[len(baseline_ids) // 2] + if from_id_result["status"] == STATUS_PASS + else None + ), + limit=limit, + ), + test_time_filters( + url, + headers, + timeout_seconds, + request_delay_seconds, + symbol=symbol, + baseline_trades=baseline_trades, + limit=limit, + ), + ] + + if selected_from_id is not None and selected_trades: + dependent_tests.insert( + 0, + test_from_id_semantics( + requested_from_id=selected_from_id, + trades=selected_trades, + ), + ) + else: + dependent_tests.insert( + 0, + build_result( + "fromId semantics", + STATUS_SKIPPED, + "Проверка пропущена: влияние fromId не подтверждено.", + ), + ) + + for result in dependent_tests: + results.append(result) + + if not json_output: + print_subsection(result["name"]) + print_result(result, verbose=verbose) + + conclusions = build_architectural_conclusions(results) + + report = { + "generated_at": utc_timestamp(), + "exchange": settings.exchange_name, + "url": url, + "symbol": symbol, + "results": results, + "architectural_conclusions": conclusions, + } + + if json_output: + print(format_json(report)) + else: + print_summary(results, conclusions) + + return report + + +def main() -> None: + arguments = parse_arguments() + settings = load_settings() + + symbol = ( + arguments.symbol.strip() + if arguments.symbol + else settings.default_symbol + ) + + try: + run_diagnostics( + symbol=symbol, + endpoint_path=arguments.endpoint_path, + limit=arguments.limit, + request_delay_seconds=arguments.delay, + verbose=arguments.verbose, + json_output=arguments.json, + ) + except KeyboardInterrupt: + print() + print("Stopped by user.") + except Exception as exc: + print() + print( + "Trade backfill diagnostic failed: " + f"{type(exc).__name__}: {exc}" + ) + raise + + +if __name__ == "__main__": + main() diff --git a/app/src/market_data/acquisition/adapters/dzengi/auth.py b/app/src/market_data/acquisition/adapters/dzengi/auth.py index e69de29..fe172f5 100644 --- a/app/src/market_data/acquisition/adapters/dzengi/auth.py +++ b/app/src/market_data/acquisition/adapters/dzengi/auth.py @@ -0,0 +1 @@ +# app/src/market_data/acquisition/adapters/dzengi/auth.py \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/__init__.py b/app/src/market_data/acquisition/recovery/__init__.py new file mode 100644 index 0000000..090eced --- /dev/null +++ b/app/src/market_data/acquisition/recovery/__init__.py @@ -0,0 +1 @@ +# app/src/market_data/acquisition/recovery/__init__.py \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/trade_recovery_controller.py b/app/src/market_data/acquisition/recovery/trade_recovery_controller.py new file mode 100644 index 0000000..bfd7381 --- /dev/null +++ b/app/src/market_data/acquisition/recovery/trade_recovery_controller.py @@ -0,0 +1,118 @@ +# app/src/market_data/acquisition/recovery/trade_recovery_controller.py + +from __future__ import annotations + +from src.market_data.acquisition.adapters.dzengi.rest import ( + DzengiTradesDocumentSource, +) +from src.market_data.acquisition.adapters.dzengi.rest_trade_adapter import ( + adapt_rest_agg_trades_document, +) +from src.market_data.acquisition.consistency.trade_stream_protocol import ( + TradeStreamConsistencyProtocol, +) +from src.market_data.acquisition.models.trade import Trade +from src.market_data.acquisition.recovery.trade_recovery_normalizer import ( + normalize_recovered_trades, +) +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.validation.schema import ( + validate_rest_agg_trades_schema, +) + + +class TradeRecoveryController(TradeRecoveryProtocol): + """ + Контроллер восстановления пропущенных сделок через Dzengi REST API. + + Контроллер выполняет одну stateless-операцию восстановления: + + 1. получает сырой документ aggTrades; + 2. выполняет schema validation; + 3. преобразует документ в канонические Trade; + 4. нормализует порядок сделок; + 5. пропускает сделки через общий consistency-контроллер; + 6. возвращает только принятые сделки. + + Контроллер не владеет состоянием согласованности потока. Для Recovery + должен передаваться тот же экземпляр TradeStreamConsistencyProtocol, + который используется основным потоком сделок. + """ + + def __init__( + self, + *, + document_source: DzengiTradesDocumentSource, + consistency_controller: TradeStreamConsistencyProtocol, + ) -> None: + self._document_source = document_source + self._consistency_controller = consistency_controller + + def recover( + self, + request: TradeRecoveryRequest, + ) -> TradeRecoveryResult: + """ + Выполнить одну операцию восстановления сделок. + + Идентичные дубликаты, возвращённые consistency-контроллером + как None, не включаются в результат. + + Исключения transport, schema, parsing, value validation, mapping + и consistency не перехватываются и не оборачиваются. + """ + + document = self._document_source.fetch_trades_document( + request.symbol, + start_time=request.start_time, + end_time=request.end_time, + limit=request.limit, + ) + + validated_document = validate_rest_agg_trades_schema( + document, + ) + + trades = adapt_rest_agg_trades_document( + validated_document, + symbol=request.symbol, + ) + + normalized_trades = normalize_recovered_trades( + trades, + ) + + recovered_trades = self._accept_trades( + normalized_trades, + ) + + return TradeRecoveryResult( + symbol=request.symbol, + requested_start_time=request.start_time, + requested_end_time=request.end_time, + recovered_trades=recovered_trades, + ) + + def _accept_trades( + self, + trades: tuple[Trade, ...], + ) -> tuple[Trade, ...]: + accepted_trades: list[Trade] = [] + + for trade in trades: + accepted_trade = self._consistency_controller.accept( + trade, + ) + + if accepted_trade is not None: + accepted_trades.append(accepted_trade) + + return tuple(accepted_trades) \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/trade_recovery_exceptions.py b/app/src/market_data/acquisition/recovery/trade_recovery_exceptions.py new file mode 100644 index 0000000..4fc41f8 --- /dev/null +++ b/app/src/market_data/acquisition/recovery/trade_recovery_exceptions.py @@ -0,0 +1,37 @@ +# app/src/market_data/acquisition/recovery/trade_recovery_exceptions.py + +from __future__ import annotations + +from src.market_data.acquisition.exceptions import ( + MarketDataAcquisitionError, +) + + +class TradeRecoveryError(MarketDataAcquisitionError): + """ + Базовое исключение подсистемы восстановления сделок. + """ + + +class TradeRecoveryWindowError(TradeRecoveryError): + """ + Некорректный диапазон восстановления сделок. + """ + + +class TradeRecoveryLimitError(TradeRecoveryError): + """ + Некорректное значение параметра limit. + """ + + +class TradeRecoveryNormalizationError(TradeRecoveryError): + """ + Ошибка нормализации восстановленных сделок. + """ + + +class TradeRecoveryControllerError(TradeRecoveryError): + """ + Ошибка контроллера восстановления сделок. + """ \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/trade_recovery_normalizer.py b/app/src/market_data/acquisition/recovery/trade_recovery_normalizer.py new file mode 100644 index 0000000..15c04a3 --- /dev/null +++ b/app/src/market_data/acquisition/recovery/trade_recovery_normalizer.py @@ -0,0 +1,40 @@ +# app/src/market_data/acquisition/recovery/trade_recovery_normalizer.py + +from __future__ import annotations + +from collections.abc import Iterable + +from src.market_data.acquisition.models.trade import Trade + + +def normalize_recovered_trades( + trades: Iterable[Trade], +) -> tuple[Trade, ...]: + """ + Нормализовать порядок восстановленных сделок. + + Сделки возвращаются в возрастающем порядке по ``trade_id``. + Исходная последовательность не изменяется. + + Нормализатор намеренно не выполняет дедупликацию и не проверяет + согласованность сделок. Идентичные и конфликтующие дубликаты должны + обрабатываться экземпляром ``TradeStreamConsistencyController``. + + Parameters + ---------- + trades + Последовательность канонических сделок. + + Returns + ------- + tuple[Trade, ...] + Неизменяемая последовательность сделок, отсортированная + по возрастанию ``trade_id``. + """ + + return tuple( + sorted( + trades, + key=lambda trade: trade.trade_id, + ) + ) \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/trade_recovery_protocol.py b/app/src/market_data/acquisition/recovery/trade_recovery_protocol.py new file mode 100644 index 0000000..f75a79a --- /dev/null +++ b/app/src/market_data/acquisition/recovery/trade_recovery_protocol.py @@ -0,0 +1,55 @@ +# app/src/market_data/acquisition/recovery/trade_recovery_protocol.py + +from __future__ import annotations + +from abc import abstractmethod +from typing import Protocol + +from src.market_data.acquisition.recovery.trade_recovery_request import ( + TradeRecoveryRequest, +) +from src.market_data.acquisition.recovery.trade_recovery_result import ( + TradeRecoveryResult, +) + + +class TradeRecoveryProtocol(Protocol): + """ + Контракт подсистемы восстановления сделок. + + Реализация должна: + + - получать сделки из внешнего источника; + - нормализовать их порядок; + - выполнять согласование через + TradeStreamConsistencyController; + - возвращать канонический результат восстановления. + + Реализация не должна: + + - выполнять повторные попытки; + - управлять WebSocket; + - управлять Runtime; + - выполнять кэширование; + - принимать решения о реконнекте. + """ + + @abstractmethod + def recover( + self, + request: TradeRecoveryRequest, + ) -> TradeRecoveryResult: + """ + Выполнить одну операцию восстановления сделок. + + Parameters + ---------- + request + Параметры операции восстановления. + + Returns + ------- + TradeRecoveryResult + Канонический результат восстановления. + """ + ... \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/trade_recovery_request.py b/app/src/market_data/acquisition/recovery/trade_recovery_request.py new file mode 100644 index 0000000..f1c6b0a --- /dev/null +++ b/app/src/market_data/acquisition/recovery/trade_recovery_request.py @@ -0,0 +1,78 @@ +# app/src/market_data/acquisition/recovery/trade_recovery_request.py + +from __future__ import annotations + +from dataclasses import dataclass + + +_MAX_RECOVERY_WINDOW_MS = 60 * 60 * 1000 +_MIN_RECOVERY_LIMIT = 1 +_MAX_RECOVERY_LIMIT = 1000 + + +# Неизменяемое описание одного REST-запроса восстановления сделок. +@dataclass(frozen=True, slots=True) +class TradeRecoveryRequest: + symbol: str + + start_time: int + end_time: int + + limit: int | None = None + + def __post_init__(self) -> None: + """ + Проверить локальные инварианты запроса восстановления. + + Временные границы задаются в миллисекундах Unix time и передаются + в Dzengi REST API как параметры startTime и endTime. + """ + + if not isinstance(self.symbol, str): + raise TypeError("symbol должен иметь тип str.") + + if not self.symbol.strip(): + raise ValueError("symbol не должен быть пустым.") + + if isinstance(self.start_time, bool) or not isinstance( + self.start_time, + int, + ): + raise TypeError("start_time должен иметь тип int.") + + if isinstance(self.end_time, bool) or not isinstance( + self.end_time, + int, + ): + raise TypeError("end_time должен иметь тип int.") + + if self.start_time < 0: + raise ValueError( + "start_time не должен быть отрицательным." + ) + + if self.end_time < 0: + raise ValueError( + "end_time не должен быть отрицательным." + ) + + if self.start_time > self.end_time: + raise ValueError( + "start_time не должен быть больше end_time." + ) + + if self.end_time - self.start_time >= _MAX_RECOVERY_WINDOW_MS: + raise ValueError( + "Диапазон восстановления должен быть меньше одного часа." + ) + + if self.limit is None: + return + + if isinstance(self.limit, bool) or not isinstance(self.limit, int): + raise TypeError("limit должен иметь тип int или None.") + + if not _MIN_RECOVERY_LIMIT <= self.limit <= _MAX_RECOVERY_LIMIT: + raise ValueError( + "limit должен находиться в диапазоне от 1 до 1000." + ) \ No newline at end of file diff --git a/app/src/market_data/acquisition/recovery/trade_recovery_result.py b/app/src/market_data/acquisition/recovery/trade_recovery_result.py new file mode 100644 index 0000000..21324aa --- /dev/null +++ b/app/src/market_data/acquisition/recovery/trade_recovery_result.py @@ -0,0 +1,56 @@ +# app/src/market_data/acquisition/recovery/trade_recovery_result.py + +from __future__ import annotations + +from dataclasses import dataclass + +from src.market_data.acquisition.models.trade import Trade + + +# Неизменяемый результат одной операции восстановления сделок. +@dataclass(frozen=True, slots=True) +class TradeRecoveryResult: + symbol: str + + requested_start_time: int + requested_end_time: int + + recovered_trades: tuple[Trade, ...] + + @property + def recovered_count(self) -> int: + """ + Количество успешно восстановленных сделок. + """ + + return len(self.recovered_trades) + + @property + def is_empty(self) -> bool: + """ + Признак отсутствия восстановленных сделок. + """ + + return not self.recovered_trades + + @property + def first_trade(self) -> Trade | None: + """ + Первая сделка после нормализации. + """ + + if not self.recovered_trades: + return None + + return self.recovered_trades[0] + + @property + def last_trade(self) -> Trade | None: + """ + Последняя сделка после нормализации. + """ + + if not self.recovered_trades: + return None + + return self.recovered_trades[-1] \ No newline at end of file diff --git a/app/src/market_data/acquisition/runtime/reconnect.py b/app/src/market_data/acquisition/runtime/reconnect.py index e69de29..fccc002 100644 --- a/app/src/market_data/acquisition/runtime/reconnect.py +++ b/app/src/market_data/acquisition/runtime/reconnect.py @@ -0,0 +1 @@ +# app/src/market_data/acquisition/runtime/reconnect.py \ No newline at end of file diff --git a/app/src/market_data/acquisition/runtime/supervisor.py b/app/src/market_data/acquisition/runtime/supervisor.py index e69de29..7fe2905 100644 --- a/app/src/market_data/acquisition/runtime/supervisor.py +++ b/app/src/market_data/acquisition/runtime/supervisor.py @@ -0,0 +1 @@ +# app/src/market_data/acquisition/runtime/supervisor.py \ No newline at end of file diff --git a/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_controller.py b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_controller.py new file mode 100644 index 0000000..a1f9491 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_controller.py @@ -0,0 +1,575 @@ +# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_controller.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +import pytest + +from src.market_data.acquisition.adapters.dzengi.rest import ( + DzengiTradesDocumentSource, +) +from src.market_data.acquisition.consistency.trade_stream_exceptions import ( + TradeConsistencyError, +) +from src.market_data.acquisition.models.trade import ( + Trade, + TradeAggressorSide, +) +from src.market_data.acquisition.recovery.trade_recovery_controller import ( + TradeRecoveryController, +) +from src.market_data.acquisition.recovery.trade_recovery_request import ( + TradeRecoveryRequest, +) + + +class StubTradesDocumentSource( + DzengiTradesDocumentSource, +): + def __init__( + self, + document: object, + ) -> None: + super().__init__() + + self.document = document + + self.calls: list[ + tuple[ + str, + int | None, + int | None, + int | None, + ] + ] = [] + + def fetch_trades_document( + self, + symbol: str, + *, + start_time: int | None = None, + end_time: int | None = None, + limit: int | None = None, + ) -> object: + self.calls.append( + ( + symbol, + start_time, + end_time, + limit, + ) + ) + + return self.document + + +class StubConsistencyController: + def __init__(self) -> None: + self.received_trades: list[Trade] = [] + + def accept( + self, + trade: Trade, + ) -> Trade | None: + self.received_trades.append(trade) + + return trade + + +def _request( + *, + symbol: str = "BTCUSD", + start_time: int = 1_700_000_000_000, + end_time: int = 1_700_000_001_000, + limit: int | None = 500, +) -> TradeRecoveryRequest: + return TradeRecoveryRequest( + symbol=symbol, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + + +def _raw_trade( + *, + trade_id: int, + price: str = "50000.00", + quantity: str = "0.25", + timestamp: int = 1_700_000_000_000, + buyer_is_maker: bool = False, +) -> dict[str, object]: + return { + "a": trade_id, + "p": price, + "q": quantity, + "T": timestamp, + "m": buyer_is_maker, + } + + +def test_requests_rest_document_with_recovery_parameters() -> None: + source = StubTradesDocumentSource(document=[]) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + controller.recover( + _request( + symbol="BTCUSD", + start_time=100, + end_time=200, + limit=250, + ) + ) + + assert source.calls == [ + ( + "BTCUSD", + 100, + 200, + 250, + ) + ] + + +def test_returns_empty_result_for_empty_document() -> None: + source = StubTradesDocumentSource(document=[]) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + result = controller.recover( + _request(), + ) + + assert result.symbol == "BTCUSD" + assert result.requested_start_time == 1_700_000_000_000 + assert result.requested_end_time == 1_700_000_001_000 + assert result.recovered_trades == () + assert result.recovered_count == 0 + assert result.is_empty is True + + +def test_converts_rest_document_to_canonical_trades() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade( + trade_id=100, + price="50000.50", + quantity="0.125", + timestamp=1_700_000_000_123, + buyer_is_maker=False, + ), + ] + ) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + result = controller.recover( + _request(), + ) + + assert result.recovered_count == 1 + + trade = result.recovered_trades[0] + + assert trade.symbol == "BTCUSD" + assert trade.trade_id == 100 + assert trade.price == Decimal("50000.50") + assert trade.quantity == Decimal("0.125") + assert trade.aggressor_side is TradeAggressorSide.BUY + assert trade.source == "dzengi" + assert trade.executed_at.tzinfo is not None + + +def test_maps_buyer_is_maker_to_sell_aggressor_side() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade( + trade_id=100, + buyer_is_maker=True, + ), + ] + ) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + result = controller.recover( + _request(), + ) + + assert ( + result.recovered_trades[0].aggressor_side + is TradeAggressorSide.SELL + ) + + +def test_normalizes_trades_before_consistency_check() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=103), + _raw_trade(trade_id=100), + _raw_trade(trade_id=102), + _raw_trade(trade_id=101), + ] + ) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + controller.recover( + _request(), + ) + + assert [ + trade.trade_id + for trade in consistency_controller.received_trades + ] == [ + 100, + 101, + 102, + 103, + ] + + +def test_returns_trades_in_normalized_order() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=102), + _raw_trade(trade_id=100), + _raw_trade(trade_id=101), + ] + ) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + result = controller.recover( + _request(), + ) + + assert tuple( + trade.trade_id + for trade in result.recovered_trades + ) == ( + 100, + 101, + 102, + ) + + +def test_excludes_duplicate_rejected_by_consistency_controller() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=100), + _raw_trade(trade_id=101), + _raw_trade(trade_id=102), + ] + ) + + class DuplicateRejectingConsistencyController: + def __init__(self) -> None: + self.received_trade_ids: list[int] = [] + + def accept( + self, + trade: Trade, + ) -> Trade | None: + self.received_trade_ids.append(trade.trade_id) + + if trade.trade_id == 101: + return None + + return trade + + consistency_controller = DuplicateRejectingConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + result = controller.recover( + _request(), + ) + + assert consistency_controller.received_trade_ids == [ + 100, + 101, + 102, + ] + + assert tuple( + trade.trade_id + for trade in result.recovered_trades + ) == ( + 100, + 102, + ) + + +def test_preserves_consistency_controller_returned_instance() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=100), + ] + ) + + replacement_trade = Trade( + symbol="BTCUSD", + trade_id=100, + price=Decimal("60000.00"), + quantity=Decimal("1.00"), + executed_at=datetime( + 2026, + 1, + 1, + 12, + 0, + tzinfo=timezone.utc, + ), + aggressor_side=TradeAggressorSide.SELL, + source="test", + ) + + class ReplacingConsistencyController: + def accept( + self, + trade: Trade, + ) -> Trade | None: + return replacement_trade + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=ReplacingConsistencyController(), + ) + + result = controller.recover( + _request(), + ) + + assert result.recovered_trades == (replacement_trade,) + assert result.recovered_trades[0] is replacement_trade + + +def test_propagates_source_error() -> None: + expected_error = RuntimeError("source error") + + class FailingSource( + DzengiTradesDocumentSource, + ): + def fetch_trades_document( + self, + symbol: str, + *, + start_time: int | None = None, + end_time: int | None = None, + limit: int | None = None, + ) -> object: + raise expected_error + + controller = TradeRecoveryController( + document_source=FailingSource(), + consistency_controller=StubConsistencyController(), + ) + + with pytest.raises(RuntimeError) as exc_info: + controller.recover( + _request(), + ) + + assert exc_info.value is expected_error + + +def test_propagates_schema_validation_error() -> None: + controller = TradeRecoveryController( + document_source=StubTradesDocumentSource( + document={ + "unexpected": "document", + } + ), + consistency_controller=StubConsistencyController(), + ) + + with pytest.raises(Exception): + controller.recover( + _request(), + ) + + +def test_propagates_consistency_error() -> None: + expected_error = TradeConsistencyError() + + class FailingConsistencyController: + def accept( + self, + trade: Trade, + ) -> Trade | None: + raise expected_error + + controller = TradeRecoveryController( + document_source=StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=100), + ] + ), + consistency_controller=FailingConsistencyController(), + ) + + with pytest.raises(TradeConsistencyError) as exc_info: + controller.recover( + _request(), + ) + + assert exc_info.value is expected_error + + +def test_does_not_call_consistency_controller_for_empty_document() -> None: + source = StubTradesDocumentSource(document=[]) + + class FailingIfCalledConsistencyController: + def accept( + self, + trade: Trade, + ) -> Trade | None: + raise AssertionError( + "Consistency controller must not be called." + ) + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=FailingIfCalledConsistencyController(), + ) + + result = controller.recover( + _request(), + ) + + assert result.recovered_trades == () + + +def test_uses_injected_consistency_controller_instance() -> None: + source = StubTradesDocumentSource( + document=[], + ) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + assert ( + controller._consistency_controller # type: ignore[attr-defined] + is consistency_controller + ) + + +def test_uses_same_consistency_controller_for_all_trades() -> None: + source = StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=102), + _raw_trade(trade_id=100), + _raw_trade(trade_id=101), + ] + ) + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=source, + consistency_controller=consistency_controller, + ) + + result = controller.recover( + _request(), + ) + + assert [ + trade.trade_id + for trade in consistency_controller.received_trades + ] == [ + 100, + 101, + 102, + ] + + assert tuple( + trade.trade_id + for trade in result.recovered_trades + ) == ( + 100, + 101, + 102, + ) + + +def test_returns_recovered_trades_as_tuple() -> None: + controller = TradeRecoveryController( + document_source=StubTradesDocumentSource( + document=[ + _raw_trade(trade_id=100), + ] + ), + consistency_controller=StubConsistencyController(), + ) + + result = controller.recover( + _request(), + ) + + assert isinstance(result.recovered_trades, tuple) + + +def test_empty_recovery_returns_empty_tuple() -> None: + controller = TradeRecoveryController( + document_source=StubTradesDocumentSource( + document=[], + ), + consistency_controller=StubConsistencyController(), + ) + + result = controller.recover( + _request(), + ) + + assert result.recovered_trades == () + assert isinstance(result.recovered_trades, tuple) + + +def test_controller_does_not_create_additional_consistency_state() -> None: + consistency_controller = StubConsistencyController() + + controller = TradeRecoveryController( + document_source=StubTradesDocumentSource( + document=[], + ), + consistency_controller=consistency_controller, + ) + + assert controller.__dict__ == { + "_document_source": controller._document_source, + "_consistency_controller": consistency_controller, + } \ No newline at end of file diff --git a/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_normalizer.py b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_normalizer.py new file mode 100644 index 0000000..610b1b3 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_normalizer.py @@ -0,0 +1,224 @@ +# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_normalizer.py + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from src.market_data.acquisition.models.trade import ( + Trade, + TradeAggressorSide, +) +from src.market_data.acquisition.recovery.trade_recovery_normalizer import ( + normalize_recovered_trades, +) + + +def _trade( + *, + trade_id: int, + price: Decimal = Decimal("50000.00"), +) -> Trade: + return Trade( + symbol="BTCUSD", + trade_id=trade_id, + price=price, + quantity=Decimal("0.25"), + executed_at=datetime( + 2026, + 1, + 1, + 12, + 0, + tzinfo=timezone.utc, + ), + aggressor_side=TradeAggressorSide.BUY, + source="dzengi", + ) + + +def test_returns_empty_tuple_for_empty_input() -> None: + result = normalize_recovered_trades(()) + + assert result == () + assert isinstance(result, tuple) + + +def test_keeps_already_sorted_trades() -> None: + first_trade = _trade(trade_id=100) + second_trade = _trade(trade_id=101) + third_trade = _trade(trade_id=102) + + result = normalize_recovered_trades( + ( + first_trade, + second_trade, + third_trade, + ) + ) + + assert result == ( + first_trade, + second_trade, + third_trade, + ) + + +def test_sorts_reverse_order_by_trade_id() -> None: + first_trade = _trade(trade_id=100) + second_trade = _trade(trade_id=101) + third_trade = _trade(trade_id=102) + + result = normalize_recovered_trades( + ( + third_trade, + second_trade, + first_trade, + ) + ) + + assert result == ( + first_trade, + second_trade, + third_trade, + ) + + +def test_sorts_arbitrary_order_by_trade_id() -> None: + trade_100 = _trade(trade_id=100) + trade_101 = _trade(trade_id=101) + trade_102 = _trade(trade_id=102) + trade_103 = _trade(trade_id=103) + + result = normalize_recovered_trades( + ( + trade_102, + trade_100, + trade_103, + trade_101, + ) + ) + + assert result == ( + trade_100, + trade_101, + trade_102, + trade_103, + ) + + +def test_preserves_stable_order_for_equal_trade_ids() -> None: + first_duplicate = _trade( + trade_id=100, + price=Decimal("50000.00"), + ) + second_duplicate = _trade( + trade_id=100, + price=Decimal("50001.00"), + ) + + result = normalize_recovered_trades( + ( + first_duplicate, + second_duplicate, + ) + ) + + assert result == ( + first_duplicate, + second_duplicate, + ) + + +def test_does_not_remove_identical_duplicates() -> None: + trade = _trade(trade_id=100) + + result = normalize_recovered_trades( + ( + trade, + trade, + ) + ) + + assert result == ( + trade, + trade, + ) + + +def test_accepts_list_input() -> None: + first_trade = _trade(trade_id=100) + second_trade = _trade(trade_id=101) + + result = normalize_recovered_trades( + [ + second_trade, + first_trade, + ] + ) + + assert result == ( + first_trade, + second_trade, + ) + + +def test_accepts_generator_input() -> None: + trades = ( + _trade(trade_id=trade_id) + for trade_id in ( + 102, + 100, + 101, + ) + ) + + result = normalize_recovered_trades(trades) + + assert tuple( + trade.trade_id + for trade in result + ) == ( + 100, + 101, + 102, + ) + + +def test_does_not_modify_source_list() -> None: + first_trade = _trade(trade_id=100) + second_trade = _trade(trade_id=101) + + source = [ + second_trade, + first_trade, + ] + + normalize_recovered_trades(source) + + assert source == [ + second_trade, + first_trade, + ] + + +def test_returns_tuple_for_non_tuple_input() -> None: + result = normalize_recovered_trades( + [ + _trade(trade_id=100), + ] + ) + + assert isinstance(result, tuple) + + +def test_returns_new_tuple_for_tuple_input() -> None: + source = ( + _trade(trade_id=100), + _trade(trade_id=101), + ) + + result = normalize_recovered_trades(source) + + assert result == source + assert result is not source \ No newline at end of file diff --git a/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_request.py b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_request.py new file mode 100644 index 0000000..0ef778a --- /dev/null +++ b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_request.py @@ -0,0 +1,277 @@ +# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_request.py + +from __future__ import annotations + +import pytest + +from src.market_data.acquisition.recovery.trade_recovery_request import ( + TradeRecoveryRequest, +) + + +def test_creates_valid_request() -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + limit=500, + ) + + assert request.symbol == "BTCUSD" + assert request.start_time == 1_700_000_000_000 + assert request.end_time == 1_700_000_001_000 + assert request.limit == 500 + + +def test_allows_equal_start_and_end_time() -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_000_000, + ) + + assert request.start_time == request.end_time + + +def test_allows_none_limit() -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + limit=None, + ) + + assert request.limit is None + + +@pytest.mark.parametrize( + "limit", + [ + 1, + 1000, + ], +) +def test_allows_limit_boundaries( + limit: int, +) -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + limit=limit, + ) + + assert request.limit == limit + + +def test_rejects_non_string_symbol() -> None: + with pytest.raises( + TypeError, + match="symbol должен иметь тип str", + ): + TradeRecoveryRequest( + symbol=123, # type: ignore[arg-type] + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + ) + + +@pytest.mark.parametrize( + "symbol", + [ + "", + " ", + ], +) +def test_rejects_empty_symbol( + symbol: str, +) -> None: + with pytest.raises( + ValueError, + match="symbol не должен быть пустым", + ): + TradeRecoveryRequest( + symbol=symbol, + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + ) + + +@pytest.mark.parametrize( + "start_time", + [ + 1.5, + "1700000000000", + None, + True, + ], +) +def test_rejects_invalid_start_time_type( + start_time: object, +) -> None: + with pytest.raises( + TypeError, + match="start_time должен иметь тип int", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=start_time, # type: ignore[arg-type] + end_time=1_700_000_001_000, + ) + + +@pytest.mark.parametrize( + "end_time", + [ + 1.5, + "1700000001000", + None, + True, + ], +) +def test_rejects_invalid_end_time_type( + end_time: object, +) -> None: + with pytest.raises( + TypeError, + match="end_time должен иметь тип int", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=end_time, # type: ignore[arg-type] + ) + + +def test_rejects_negative_start_time() -> None: + with pytest.raises( + ValueError, + match="start_time не должен быть отрицательным", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=-1, + end_time=1_000, + ) + + +def test_rejects_negative_end_time() -> None: + with pytest.raises( + ValueError, + match="end_time не должен быть отрицательным", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=0, + end_time=-1, + ) + + +def test_rejects_start_time_greater_than_end_time() -> None: + with pytest.raises( + ValueError, + match="start_time не должен быть больше end_time", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=2_000, + end_time=1_000, + ) + + +def test_allows_window_shorter_than_one_hour() -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=0, + end_time=3_599_999, + ) + + assert request.end_time - request.start_time == 3_599_999 + + +@pytest.mark.parametrize( + "end_time", + [ + 3_600_000, + 3_600_001, + ], +) +def test_rejects_window_of_one_hour_or_more( + end_time: int, +) -> None: + with pytest.raises( + ValueError, + match="Диапазон восстановления должен быть меньше одного часа", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=0, + end_time=end_time, + ) + + +@pytest.mark.parametrize( + "limit", + [ + 1.5, + "100", + True, + ], +) +def test_rejects_invalid_limit_type( + limit: object, +) -> None: + with pytest.raises( + TypeError, + match="limit должен иметь тип int или None", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + limit=limit, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "limit", + [ + 0, + -1, + 1001, + ], +) +def test_rejects_limit_outside_allowed_range( + limit: int, +) -> None: + with pytest.raises( + ValueError, + match="limit должен находиться в диапазоне от 1 до 1000", + ): + TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + limit=limit, + ) + + +def test_request_is_immutable() -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + ) + + with pytest.raises(AttributeError): + request.symbol = "ETHUSD" # type: ignore[misc] + + +def test_request_uses_slots() -> None: + request = TradeRecoveryRequest( + symbol="BTCUSD", + start_time=1_700_000_000_000, + end_time=1_700_000_001_000, + ) + + assert not hasattr(request, "__dict__") \ No newline at end of file diff --git a/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_result.py b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_result.py new file mode 100644 index 0000000..01c7671 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_result.py @@ -0,0 +1,199 @@ +# app/tests/unit/market_data/acquisition/recovery/test_trade_recovery_result.py + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from datetime import datetime, timezone +from decimal import Decimal + +import pytest + +from src.market_data.acquisition.models.trade import ( + Trade, + TradeAggressorSide, +) +from src.market_data.acquisition.recovery.trade_recovery_result import ( + TradeRecoveryResult, +) + + +def _trade( + *, + trade_id: int, + symbol: str = "BTCUSD", +) -> Trade: + return Trade( + symbol=symbol, + trade_id=trade_id, + price=Decimal("50000.00"), + quantity=Decimal("0.25"), + executed_at=datetime( + 2026, + 1, + 1, + 12, + 0, + tzinfo=timezone.utc, + ), + aggressor_side=TradeAggressorSide.BUY, + source="dzengi", + ) + + +def test_creates_empty_result() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + assert result.symbol == "BTCUSD" + assert result.requested_start_time == 100 + assert result.requested_end_time == 200 + assert result.recovered_trades == () + + +def test_empty_result_has_zero_recovered_count() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + assert result.recovered_count == 0 + + +def test_empty_result_is_empty() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + assert result.is_empty is True + + +def test_empty_result_has_no_first_trade() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + assert result.first_trade is None + + +def test_empty_result_has_no_last_trade() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + assert result.last_trade is None + + +def test_non_empty_result_reports_recovered_count() -> None: + trades = ( + _trade(trade_id=100), + _trade(trade_id=101), + _trade(trade_id=102), + ) + + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=trades, + ) + + assert result.recovered_count == 3 + + +def test_non_empty_result_is_not_empty() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=( + _trade(trade_id=100), + ), + ) + + assert result.is_empty is False + + +def test_returns_first_recovered_trade() -> None: + first_trade = _trade(trade_id=100) + second_trade = _trade(trade_id=101) + + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=( + first_trade, + second_trade, + ), + ) + + assert result.first_trade is first_trade + + +def test_returns_last_recovered_trade() -> None: + first_trade = _trade(trade_id=100) + second_trade = _trade(trade_id=101) + + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=( + first_trade, + second_trade, + ), + ) + + assert result.last_trade is second_trade + + +def test_single_trade_is_both_first_and_last() -> None: + trade = _trade(trade_id=100) + + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(trade,), + ) + + assert result.first_trade is trade + assert result.last_trade is trade + + +def test_result_is_frozen() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + with pytest.raises(FrozenInstanceError): + result.symbol = "ETHUSD" # type: ignore[misc] + + +def test_result_uses_slots() -> None: + result = TradeRecoveryResult( + symbol="BTCUSD", + requested_start_time=100, + requested_end_time=200, + recovered_trades=(), + ) + + assert not hasattr(result, "__dict__") \ No newline at end of file diff --git a/docs/migrations/build_060_19.md b/docs/migrations/build_060_19.md new file mode 100644 index 0000000..10c6f25 --- /dev/null +++ b/docs/migrations/build_060_19.md @@ -0,0 +1,1620 @@ +# Build 060.19 — Trade Recovery + +**Engineering Migration Report** + +--- + +# Контроль документа + +| Свойство | Значение | +|----------|----------| +| Build | 060.19 | +| Название | Trade Recovery | +| Статус | Completed | +| Проект | Dzentra | +| Подсистема | Market Data Acquisition | +| Компонент | Trade Recovery | +| Версия | 1.0 | + +--- + +# Связанные документы + +- build_060_19_architecture.md — архитектурная спецификация Build. +- build_060_18.md — Engineering Migration Report предыдущего Build. + +--- + +# Цель Build + +Build 060.18 завершил построение самостоятельной подсистемы **Trade Stream Consistency**, которая стала единственной точкой формирования канонического потока сделок внутри подсистемы **Market Data Acquisition**. + +После завершения предыдущего этапа система уже обеспечивала: + +- построение канонической модели `Trade`; +- проверку порядка поступления сделок; +- обнаружение повторной доставки сообщений; +- обнаружение конфликтующих дубликатов; +- формирование единственного согласованного потока сделок. + +Тем самым была решена задача обеспечения внутренней согласованности непрерывного потока данных. + +Однако даже наличие механизма проверки согласованности не решает проблему восстановления истории. + +В реальных условиях эксплуатации поток рыночных данных может быть нарушен по множеству причин. + +Например: + +- кратковременный разрыв WebSocket-соединения; +- задержка доставки сообщений транспортным уровнем; +- временная недоступность биржи; +- повторная подписка после реконнекта; +- восстановление работы процесса после перезапуска. + +Во всех подобных ситуациях система должна иметь возможность повторно получить отсутствующие сделки через REST API и безопасно встроить их в уже существующий поток. + +При этом процесс восстановления не должен: + +- дублировать уже существующие сделки; +- нарушать порядок последовательности; +- обходить существующие проверки согласованности; +- использовать отдельные правила проверки. + +Главной задачей настоящего Build становится построение специализированной подсистемы **Trade Recovery**, обеспечивающей безопасное восстановление последовательности сделок с использованием уже существующей архитектуры Acquisition Layer. + +После завершения Build система получает: + +- специализированный `TradeRecoveryProtocol`; +- специализированный `TradeRecoveryController`; +- специализированную модель `TradeRecoveryRequest`; +- специализированную модель `TradeRecoveryResult`; +- компонент нормализации восстановленных сделок; +- специализированные исключения Recovery; +- полноценное unit-тестирование новой подсистемы. + +При этом Build принципиально не затрагивает: + +- каноническую модель `Trade`; +- Parser; +- Mapper; +- Value Validation; +- REST Adapter; +- Trade Stream Consistency; +- WebSocket Runtime; +- Trades Feed; +- Runtime Orchestration; +- Gap Detection; +- Recovery Registry. + +Все перечисленные задачи относятся к следующим этапам развития подсистемы Trades Feed. + +--- + +# Предпосылки + +К началу Build архитектура Acquisition Layer уже обеспечивала полный цикл построения канонической модели сделки и формирования согласованного потока. + +Конвейер обработки выглядел следующим образом. + +```text +Transport Message + │ + ▼ +Schema Validation + │ + ▼ +Parser + │ + ▼ +Value Validation + │ + ▼ +Mapper + │ + ▼ +Trade + │ + ▼ +Trade Stream Consistency + │ + ▼ +Canonical Trade Stream +``` + +Каждый уровень обладал собственной строго определённой областью ответственности. + +Schema Validation отвечала исключительно за корректность транспортного документа. + +Parser извлекал необходимые поля транспортной модели. + +Value Validation проверяла допустимость отдельных значений. + +Mapper строил каноническую модель предметной области. + +Trade Stream Consistency обеспечивал корректность последовательности сделок. + +Таким образом к началу настоящего Build система уже умела гарантировать корректность потока при условии, что все сделки были успешно получены транспортным уровнем. + +Однако отсутствовал механизм восстановления уже пропущенных сделок. + +Например, если во время обработки непрерывного потока несколько сообщений были потеряны, существующая архитектура не содержала специализированного компонента, способного безопасно получить отсутствующие сделки посредством REST API и встроить их в уже существующую последовательность. + +Попытка реализовать подобную функциональность непосредственно внутри WebSocket Feed или Runtime привела бы к нарушению принципа единственной ответственности. + +Восстановление истории представляет собой самостоятельную архитектурную задачу. + +Именно её решает Build 060.19. + +--- + +# Результаты архитектурного аудита + +Перед началом реализации Build был выполнен полный аудит существующей подсистемы **Market Data Acquisition**. + +Целью аудита являлась проверка соответствия фактической реализации архитектурной модели, утверждённой в `build_060_19_architecture.md`, а также определение оптимальных точек интеграции новой подсистемы **Trade Recovery**. + +Особое внимание уделялось уже существующей инфраструктуре получения исторических сделок через REST API. + +Первоначально предполагалось, что для реализации Recovery потребуется собственный конвейер обработки транспортных данных. + +Однако проведённый аудит показал, что подобное решение привело бы к дублированию уже существующей бизнес-логики Acquisition Layer. + +В результате архитектура Recovery была существенно упрощена. + +--- + +## Анализ существующего REST Pipeline + +В ходе проверки был полностью проанализирован существующий REST-конвейер получения агрегированных сделок. + +Аудит подтвердил наличие уже завершённой цепочки обработки. + +```text +REST API + │ + ▼ +DzengiTradesDocumentSource + │ + ▼ +Schema Validation + │ + ▼ +REST Trade Adapter + │ + ▼ +Parser + │ + ▼ +Value Validation + │ + ▼ +Mapper + │ + ▼ +Trade +``` + +Каждый уровень уже обладал собственной областью ответственности. + +`DzengiTradesDocumentSource` отвечал исключительно за получение транспортного документа. + +Schema Validation подтверждала соответствие документа транспортной схеме. + +REST Adapter преобразовывал транспортную модель к внутреннему представлению. + +Parser извлекал необходимые значения. + +Value Validation выполняла проверку корректности отдельных полей. + +Mapper строил канонический объект предметной области. + +Получаемый объект `Trade` уже полностью соответствовал требованиям всей последующей архитектуры системы. + +Таким образом Build 060.17 фактически завершил создание универсального REST-конвейера получения сделок. + +--- + +## Отказ от дублирования REST Pipeline + +После завершения аудита рассматривались два возможных варианта реализации Recovery. + +Первый вариант предполагал создание собственного конвейера обработки REST-документов. + +В подобной архитектуре Recovery самостоятельно выполнял бы: + +- разбор транспортного документа; +- проверку схемы; +- извлечение полей; +- проверку значений; +- построение модели `Trade`. + +Подобный подход был признан ошибочным. + +Он приводил сразу к нескольким серьёзным недостаткам. + +Во-первых, происходило полное дублирование уже существующего REST Pipeline. + +Во-вторых, возникал риск расхождения поведения между обычным REST Feed и Recovery. + +В-третьих, любое изменение транспортной схемы пришлось бы синхронно реализовывать сразу в двух различных подсистемах. + +Подобное решение противоречило базовым архитектурным принципам Dzentra. + +Поэтому было принято решение полностью отказаться от собственного конвейера обработки. + +Recovery использует уже существующую инфраструктуру без каких-либо изменений её внутренней логики. + +--- + +## Анализ подсистемы Trade Stream Consistency + +Отдельной задачей архитектурного аудита стала проверка взаимодействия Recovery с новой подсистемой **Trade Stream Consistency**, реализованной в Build 060.18. + +Первоначально рассматривалась возможность создания отдельного экземпляра контроллера согласованности исключительно для процесса восстановления истории. + +Подобный вариант казался естественным, поскольку Recovery представляет собой самостоятельный сценарий получения данных. + +Однако детальный анализ показал, что такое решение нарушает фундаментальные инварианты всей Acquisition Layer. + +Главная задача Trade Stream Consistency заключается в формировании единственного канонического потока сделок. + +Если Recovery использовал бы собственный экземпляр контроллера согласованности, в системе одновременно существовали бы два независимых состояния одного и того же торгового символа. + +Это неизбежно привело бы к расхождению истории обработки и нарушению единственности канонического потока. + +--- + +## Использование общего состояния потока + +По результатам аудита было принято окончательное архитектурное решение. + +Recovery не создаёт собственный экземпляр `TradeStreamConsistencyController`. + +Вместо этого контроллер согласованности передаётся в Recovery извне. + +Таким образом обе подсистемы используют единое состояние обработки сделок. + +Архитектура взаимодействия принимает следующий вид. + +```text + Trade Stream + │ + ▼ + TradeStreamConsistencyController + ▲ ▲ + │ │ + │ │ + WebSocket Feed Trade Recovery +``` + +Подобное решение обеспечивает несколько важных преимуществ. + +Во-первых, независимо от источника получения сделки используются абсолютно одинаковые правила проверки последовательности. + +Во-вторых, отсутствует необходимость синхронизации нескольких внутренних состояний. + +В-третьих, Recovery становится полностью независимым от внутренней реализации механизма согласованности. + +Он работает исключительно через публичный контракт `TradeStreamConsistencyProtocol`. + +Это полностью соответствует принципу **Dependency Inversion**, принятому в архитектуре Dzentra. + +--- + +## Анализ порядка восстановления + +Следующим результатом архитектурного аудита стало исследование порядка поступления исторических сделок через REST API. + +Проверка показала, что транспортный уровень не должен рассматриваться как источник архитектурных гарантий. + +Даже если конкретная реализация биржи в настоящий момент возвращает сделки в возрастающем порядке, подобное поведение не должно использоваться в качестве архитектурного инварианта системы. + +Recovery обязан самостоятельно формировать каноническую последовательность перед передачей сделок в Trade Stream Consistency. + +Поэтому было принято решение добавить специализированный уровень нормализации восстановленных данных. + +--- + +## Recovery Normalizer + +В рамках настоящего Build появляется отдельный компонент + +```text +Trade Recovery Normalizer +``` + +Его задача предельно проста. + +Компонент принимает последовательность уже построенных канонических объектов `Trade` и возвращает новую неизменяемую последовательность, отсортированную по `trade_id`. + +Нормализатор сознательно не выполняет: + +- дедупликацию; +- проверку порядка; +- проверку корректности данных; +- обнаружение конфликтующих повторов. + +Все перечисленные задачи полностью принадлежат Trade Stream Consistency. + +Подобное разделение обязанностей исключает дублирование бизнес-логики между двумя подсистемами. + +--- + +# Архитектурное решение + +По результатам проведённого аудита было принято решение реализовать подсистему **Trade Recovery** как самостоятельный orchestration-слой, использующий уже существующие компоненты Acquisition Layer. + +Recovery не содержит собственного Parser. + +Recovery не содержит собственного Mapper. + +Recovery не содержит собственного механизма проверки согласованности. + +Все перечисленные обязанности делегируются уже существующим специализированным компонентам системы. + +После завершения Build архитектура принимает следующий вид. + +```text +TradeRecoveryRequest + │ + ▼ +REST Document Source + │ + ▼ +Schema Validation + │ + ▼ +REST Trade Adapter + │ + ▼ +Recovery Normalizer + │ + ▼ +TradeStreamConsistencyController + │ + ▼ +TradeRecoveryResult +``` + +Таким образом Build 060.19 не создаёт новую независимую цепочку обработки данных. + +Он объединяет уже реализованные архитектурные уровни в специализированный сценарий восстановления истории сделок. + +Подобное решение обеспечивает слабую связанность компонентов, повторное использование существующей инфраструктуры и полностью соответствует принципам модульной архитектуры Dzentra. + +--- + +# Почему Recovery не интегрируется непосредственно в WebSocket + +Во время архитектурного проектирования отдельно анализировался вопрос о месте расположения новой подсистемы Recovery. + +На первый взгляд естественным выглядело решение встроить восстановление истории непосредственно в компонент WebSocket Trades Feed. + +В подобной архитектуре именно Feed отвечал бы за обнаружение необходимости восстановления, выполнение REST-запросов и публикацию восстановленных сделок. + +Однако детальный анализ показал, что подобный подход нарушает сразу несколько фундаментальных принципов архитектуры Dzentra. + +Во-первых, WebSocket Feed отвечает исключительно за получение непрерывного потока транспортных сообщений. + +Во-вторых, Recovery представляет собой самостоятельный сценарий получения исторических данных посредством REST API. + +Несмотря на то что оба компонента работают со сделками, они решают различные задачи. + +Попытка объединить их внутри одного класса привела бы к смешению различных уровней ответственности. + +Поэтому было принято решение полностью разделить эти подсистемы. + +WebSocket Feed остаётся источником непрерывного потока. + +Trade Recovery становится отдельным сервисом восстановления истории. + +Их совместная работа будет организована позднее посредством Runtime Orchestration. + +--- + +# Новая подсистема Trade Recovery + +Главным результатом настоящего Build становится появление в составе **Market Data Acquisition** нового архитектурного уровня — **Trade Recovery**. + +До начала Build Acquisition Layer уже обеспечивал получение сделок и проверку их согласованности. + +Однако отсутствовал специализированный механизм восстановления исторических данных. + +После завершения Build между REST API и каноническим потоком появляется отдельный уровень восстановления. + +Архитектура обработки принимает следующий вид. + +```text +REST API + │ + ▼ +Trade Recovery + │ + ▼ +Trade Stream Consistency + │ + ▼ +Canonical Trade Stream +``` + +Появление данного уровня является принципиальным расширением возможностей Acquisition Layer. + +Теперь система способна безопасно повторно получать ранее пропущенные сделки, не нарушая уже сформированные архитектурные инварианты. + +--- + +# Архитектурное решение + +Во время проектирования рассматривались несколько вариантов организации Recovery Pipeline. + +Первый вариант предполагал реализацию всей логики восстановления внутри одного большого компонента. + +Подобный компонент самостоятельно выполнял бы: + +- получение данных; +- обработку транспортного документа; +- сортировку сделок; +- проверку согласованности; +- формирование результата. + +После анализа архитектуры данный подход был отклонён. + +Он нарушал принцип единственной ответственности и неизбежно приводил к дублированию уже существующих компонентов Acquisition Layer. + +Поэтому было принято другое решение. + +Trade Recovery становится исключительно координационным уровнем. + +Он объединяет уже реализованные сервисы, не заменяя их. + +Каждый существующий компонент продолжает выполнять только собственную специализированную задачу. + +--- + +# Архитектура новой подсистемы + +В рамках Build реализованы шесть новых компонентов. + +```text +TradeRecoveryProtocol + +TradeRecoveryController + +TradeRecoveryRequest + +TradeRecoveryResult + +TradeRecoveryNormalizer + +Trade Recovery Exceptions +``` + +Каждый компонент обладает собственной строго определённой областью ответственности. + +Ни один из компонентов не дублирует обязанности другого. + +Подобное разделение полностью соответствует принципу **Single Responsibility**, принятому в архитектуре Dzentra. + +--- + +# TradeRecoveryProtocol + +Одной из целей настоящего Build являлось формирование полноценного контрактного уровня новой подсистемы. + +До начала Build соответствующий Protocol отсутствовал. + +В рамках реализации был добавлен новый контракт. + +```text +TradeRecoveryProtocol +``` + +Protocol определяет единственную публичную операцию. + +```text +recover(request) + │ + ▼ +TradeRecoveryResult +``` + +Контракт сознательно не определяет: + +- источник получения данных; +- используемый REST API; +- механизм нормализации; +- алгоритмы проверки согласованности; +- внутреннее состояние Recovery. + +Все перечисленные детали относятся исключительно к реализации Controller. + +Благодаря подобному разделению любые последующие компоненты смогут зависеть только от публичного контракта, а не от конкретной реализации Recovery. + +Это полностью соответствует принципу **Dependency Inversion**. + +--- + +# Почему выбран минимальный Protocol + +Во время проектирования анализировались различные варианты публичного интерфейса Recovery. + +В частности рассматривались варианты: + +- передачи отдельных параметров вместо объекта запроса; +- возврата списка сделок; +- возврата генератора; +- публикации событий вместо возврата результата. + +После анализа было принято решение использовать специализированные модели запроса и результата. + +Подобный контракт оказался наиболее устойчивым к дальнейшему развитию системы. + +Он позволяет расширять Recovery без изменения публичного API. + +Кроме того, наличие отдельных моделей запроса и результата значительно упрощает unit-тестирование и обеспечивает более высокую читаемость кода. + +В результате публичный интерфейс Recovery остаётся минимальным, но при этом полностью готовым к дальнейшему развитию. + +--- + +# TradeRecoveryRequest + +Следующим результатом Build становится появление специализированной модели запроса восстановления. + +До настоящего Build параметры восстановления передавались бы в виде набора независимых аргументов. + +Подобный подход быстро привёл бы к росту сложности публичного интерфейса. + +Поэтому было принято решение инкапсулировать все параметры восстановления в отдельную неизменяемую модель. + +`TradeRecoveryRequest` содержит: + +- торговый символ; +- начало временного интервала; +- конец временного интервала; +- необязательное ограничение количества сделок. + +Корректность параметров проверяется непосредственно при создании объекта. + +Таким образом Recovery никогда не начинает выполнение с некорректными входными данными. + +--- + +# Принципы валидации запроса + +При проектировании модели запроса отдельно анализировался вопрос распределения ответственности между Request и Controller. + +Было принято решение максимально рано проверять все архитектурные инварианты. + +Поэтому `TradeRecoveryRequest` самостоятельно валидирует: + +- непустой символ; +- типы входных значений; +- корректность временного диапазона; +- отсутствие отрицательных временных меток; +- максимально допустимый размер окна восстановления; +- допустимый диапазон значения `limit`. + +Благодаря этому Controller получает гарантированно корректный объект запроса и может сосредоточиться исключительно на координации процесса восстановления. + +--- + +# TradeRecoveryResult + +Следующим новым компонентом подсистемы становится специализированная модель результата восстановления. + +Во время проектирования рассматривались различные варианты возвращаемого значения метода `recover()`. + +В частности анализировались следующие подходы: + +- возврат обычного списка сделок; +- возврат кортежа сделок; +- возврат генератора; +- возврат только количества восстановленных сделок. + +После анализа было принято решение использовать отдельную доменную модель результата. + +Такое решение оказалось наиболее гибким. + +Оно позволяет в дальнейшем расширять результат Recovery без изменения публичного контракта Protocol. + +В рамках настоящего Build `TradeRecoveryResult` содержит: + +- торговый символ; +- запрошенную нижнюю границу временного диапазона; +- запрошенную верхнюю границу временного диапазона; +- неизменяемую последовательность восстановленных сделок. + +Дополнительно модель предоставляет вычисляемые свойства. + +Например: + +- количество восстановленных сделок; +- признак пустого результата; +- первую восстановленную сделку; +- последнюю восстановленную сделку. + +Подобные свойства позволяют вызывающему коду получать наиболее часто используемую информацию без необходимости повторной обработки коллекции. + +--- + +# Почему Recovery использует immutable-модели + +Во время проектирования отдельно рассматривался вопрос изменяемости моделей Recovery. + +На первый взгляд использование обычных изменяемых структур данных могло показаться более простым. + +Однако подобный подход создавал бы несколько потенциальных проблем. + +Во-первых, восстановленные сделки могли бы случайно изменяться после завершения Recovery Pipeline. + +Во-вторых, различные компоненты системы могли бы совместно использовать одну и ту же коллекцию. + +В-третьих, unit-тестирование существенно усложнялось бы из-за появления побочных эффектов. + +Поэтому было принято решение использовать неизменяемые модели. + +И `TradeRecoveryRequest`, и `TradeRecoveryResult` являются immutable-объектами. + +Последовательность восстановленных сделок также хранится в виде `tuple`. + +Это полностью соответствует архитектурным принципам Dzentra, согласно которым канонические данные не должны изменяться после публикации. + +--- + +# TradeRecoveryNormalizer + +Следующим результатом Build становится появление специализированного компонента + +```text +TradeRecoveryNormalizer +``` + +Его назначение принципиально отличается от задач Trade Stream Consistency. + +Normalizer не занимается проверкой корректности данных. + +Он не анализирует содержимое сделок. + +Он не принимает решений относительно повторов. + +Он не выполняет дедупликацию. + +Единственной обязанностью компонента является формирование детерминированной последовательности сделок перед передачей их в механизм проверки согласованности. + +--- + +## Ответственность Normalizer + +Во время проектирования отдельно анализировалось распределение обязанностей между Recovery и Trade Stream Consistency. + +Было принято решение максимально разделить эти два уровня. + +Normalizer отвечает исключительно за сортировку последовательности по `trade_id`. + +После завершения нормализации все сделки располагаются в возрастающем порядке. + +Именно такая последовательность передаётся далее в `TradeStreamConsistencyController`. + +Любые дополнительные проверки сознательно отсутствуют. + +Подобное решение позволяет избежать дублирования бизнес-логики между двумя независимыми подсистемами. + +--- + +## Почему Normalizer не выполняет дедупликацию + +Во время проектирования отдельно рассматривалась возможность удаления повторов непосредственно на этапе нормализации. + +На первый взгляд подобное решение могло показаться логичным. + +Однако оно нарушало бы один из фундаментальных принципов архитектуры Acquisition Layer. + +Единственным компонентом системы, имеющим право принимать решения относительно повторной доставки сделок, является Trade Stream Consistency. + +Если бы Normalizer самостоятельно удалял повторы, часть бизнес-логики проверки согласованности оказалась бы распределена между двумя различными подсистемами. + +Это неизбежно привело бы к расхождению поведения Recovery и WebSocket Pipeline. + +Поэтому было принято окончательное решение. + +Normalizer выполняет исключительно сортировку. + +Все остальные решения принимает только `TradeStreamConsistencyController`. + +--- + +# TradeRecoveryController + +Центральным компонентом настоящего Build становится + +```text +TradeRecoveryController +``` + +Именно он завершает построение новой подсистемы Trade Recovery. + +Следует подчеркнуть, что Controller не является компонентом обработки данных. + +Он представляет собой исключительно orchestration-службу. + +Controller не анализирует транспортные документы. + +Не выполняет проверку схемы. + +Не преобразует транспортные модели. + +Не реализует проверку согласованности. + +Все перечисленные задачи уже принадлежат специализированным компонентам Acquisition Layer. + +Controller лишь организует их совместную работу. + +--- + +## Архитектура Controller + +Конструкция Controller намеренно сделана максимально компактной. + +Он использует всего две основные зависимости. + +```text +DzengiTradesDocumentSource + +TradeStreamConsistencyProtocol +``` + +Первая отвечает за получение транспортных документов. + +Вторая обеспечивает формирование единственного канонического потока сделок. + +Обе зависимости передаются извне. + +Таким образом Recovery не создаёт собственные экземпляры сервисов и полностью соответствует принципу внедрения зависимостей. + +--- + +## Ответственность Controller + +Во время проектирования особое внимание уделялось разделению ответственности между Recovery и остальными компонентами Acquisition Layer. + +В результате Controller получил исключительно координационные обязанности. + +Он отвечает за: + +- выполнение запроса восстановления; +- получение транспортного документа; +- запуск существующего REST-конвейера; +- вызов Recovery Normalizer; +- последовательную передачу сделок в `TradeStreamConsistencyController`; +- формирование итогового `TradeRecoveryResult`. + +При этом Controller сознательно не реализует: + +- REST Parser; +- REST Mapper; +- проверку схемы; +- проверку значений; +- дедупликацию; +- проверку порядка; +- хранение внутреннего состояния. + +Подобное разделение существенно упрощает сопровождение Recovery и делает его независимым от внутренней реализации остальных подсистем. + +--- + +# Stateless-архитектура Recovery + +Одним из важнейших архитектурных решений настоящего Build становится полный отказ от собственного состояния внутри Recovery. + +После завершения выполнения метода `recover()` Controller не сохраняет никакой информации о предыдущем вызове. + +Не сохраняются: + +- последний диапазон восстановления; +- последняя обработанная сделка; +- внутренние кэши; +- история восстановления. + +Все необходимые сведения уже содержатся внутри Trade Stream Consistency. + +Именно эта подсистема является единственным владельцем состояния потока. + +Recovery остаётся полностью stateless-сервисом. + +Подобное решение существенно упрощает последующую интеграцию с Runtime и позволяет безопасно использовать один экземпляр Recovery в течение всего жизненного цикла приложения. + +--- + +# Алгоритм восстановления сделок + +После завершения реализации Build поведение подсистемы Recovery становится полностью детерминированным. + +Каждый запрос восстановления проходит одну и ту же последовательность этапов. + +Ни один шаг не зависит от конкретной реализации биржи или транспортного уровня. + +Полный алгоритм обработки выглядит следующим образом. + +```text +TradeRecoveryRequest + │ + ▼ +Получение REST-документа + │ + ▼ +Schema Validation + │ + ▼ +REST Trade Adapter + │ + ▼ +TradeRecoveryNormalizer + │ + ▼ +TradeStreamConsistencyController + │ + ▼ +Формирование TradeRecoveryResult +``` + +Каждый этап выполняет строго одну задачу. + +Ни один уровень не повторяет обязанности другого. + +Подобная последовательность полностью соответствует принципу конвейерной обработки данных, принятому в архитектуре Dzentra. + +--- + +# Использование существующего REST Pipeline + +Одним из главных архитектурных результатов настоящего Build стало повторное использование уже реализованного REST-конвейера. + +Recovery не содержит собственной реализации получения сделок. + +После получения запроса Controller передаёт управление существующей инфраструктуре. + +Используется следующая последовательность. + +```text +DzengiTradesDocumentSource + │ + ▼ +validate_rest_agg_trades_schema() + │ + ▼ +adapt_rest_agg_trades_document() + │ + ▼ +Parser + │ + ▼ +Value Validation + │ + ▼ +Mapper + │ + ▼ +Trade +``` + +Таким образом Build 060.19 не создаёт нового способа построения канонической модели. + +Любая сделка независимо от сценария её получения проходит абсолютно одинаковую обработку. + +Это гарантирует идентичное поведение всей Acquisition Layer. + +--- + +# Нормализация восстановленных сделок + +После завершения REST Pipeline Recovery получает последовательность уже построенных канонических объектов `Trade`. + +Следующим этапом становится нормализация этой последовательности. + +Единственной задачей Normalizer является сортировка сделок по `trade_id`. + +После завершения нормализации Recovery получает детерминированный порядок обработки. + +```text +Trade #501 + +Trade #503 + +Trade #502 +``` + +преобразуется в + +```text +Trade #501 + +Trade #502 + +Trade #503 +``` + +Никакие другие изменения последовательности не выполняются. + +Normalizer не удаляет элементы. + +Не объединяет записи. + +Не изменяет содержимое сделок. + +Благодаря этому Recovery остаётся полностью независимым от правил проверки согласованности. + +--- + +# Передача сделок в Trade Stream Consistency + +После завершения нормализации каждая сделка последовательно передаётся в существующий экземпляр + +```text +TradeStreamConsistencyController +``` + +Для каждой сделки выполняется стандартная операция. + +```text +accept(trade) + │ + ▼ +Trade | None +``` + +Recovery сознательно не интерпретирует внутреннюю логику Controller. + +Если сделка публикуется впервые, она включается в итоговый результат восстановления. + +Если обнаружен идентичный повтор, Controller возвращает `None`. + +Если обнаруживается нарушение архитектурных инвариантов, генерируется соответствующее специализированное исключение. + +Recovery не подавляет подобные ошибки. + +Они передаются вызывающему компоненту без изменения. + +Такое решение гарантирует единообразное поведение независимо от источника получения сделок. + +--- + +# Формирование результата восстановления + +После завершения обработки всей последовательности Controller формирует итоговый объект + +```text +TradeRecoveryResult +``` + +В результирующую коллекцию попадают только сделки, успешно принятые механизмом Trade Stream Consistency. + +Идентичные дубликаты автоматически исключаются из результата. + +Конфликтующие повторы не публикуются вовсе. + +Если восстановленный диапазон не содержит новых сделок, Recovery возвращает корректный пустой результат. + +Подобное поведение рассматривается как полностью штатная ситуация. + +Отсутствие новых сделок не является ошибкой восстановления. + +--- + +# Новые доменные исключения + +Следующим результатом Build становится появление специализированной иерархии исключений подсистемы Trade Recovery. + +До настоящего Build подобные ошибки отсутствовали. + +В рамках реализации добавлены следующие классы. + +```text +TradeRecoveryError + +TradeRecoveryWindowError + +TradeRecoveryLimitError + +TradeRecoveryNormalizationError + +TradeRecoveryControllerError +``` + +Каждый класс отражает отдельный тип архитектурной ошибки Recovery Pipeline. + +Разделение исключений позволяет вызывающему компоненту принимать различные решения в зависимости от характера возникшей проблемы. + +Например, неверный диапазон восстановления и ошибка внутренней координации имеют различную природу и требуют различной реакции. + +Поэтому использование одного общего исключения было признано нецелесообразным. + +Все новые классы наследуются от общей иерархии исключений подсистемы Acquisition Layer и полностью соответствуют принятой архитектуре проекта. + +--- + +# Атомарность операций + +Одним из фундаментальных требований настоящего Build являлось обеспечение атомарности процесса восстановления. + +Каждый вызов метода `recover()` представляет собой законченную независимую операцию. + +Если в процессе восстановления возникает исключение, Recovery немедленно прекращает выполнение. + +При этом Controller Recovery не содержит собственного изменяемого состояния. + +Следовательно, после возникновения ошибки внутри Recovery отсутствует необходимость выполнять откат внутренних структур данных. + +Единственным компонентом, изменяющим состояние потока, остаётся Trade Stream Consistency. + +Он уже обеспечивает собственную атомарность, реализованную в Build 060.18. + +Таким образом Build 060.19 полностью наследует гарантии согласованности предыдущего этапа, не усложняя архитектуру дополнительными механизмами отката. + +--- + +# Производительность + +Во время проектирования новой подсистемы одним из обязательных требований являлось сохранение линейной сложности процесса восстановления. + +Recovery не выполняет ресурсоёмких операций над уже полученной последовательностью сделок. + +Основные этапы обработки имеют следующую вычислительную сложность. + +| Операция | Средняя сложность | +|----------|-------------------| +| Получение REST-документа | зависит от транспортного уровня | +| Schema Validation | O(n) | +| Построение канонических Trade | O(n) | +| Сортировка последовательности | O(n log n) | +| Передача в Trade Stream Consistency | O(n) | +| Формирование результата | O(n) | + +Единственной операцией, сложность которой превышает линейную, является сортировка восстановленных сделок. + +Однако она выполняется один раз для каждого запроса восстановления и обеспечивает детерминированное поведение всей последующей обработки. + +Подобный компромисс был признан полностью оправданным. + +--- + +# Изменённые файлы + +В рамках Build были добавлены шесть новых компонентов подсистемы **Trade Recovery**. + +Все изменения были сознательно локализованы внутри нового каталога + +```text +src/market_data/acquisition/recovery/ +``` + +Подобное решение позволило полностью изолировать новую функциональность от уже существующих компонентов Acquisition Layer. + +Ни один ранее реализованный Parser, Mapper, Adapter, Validator или Controller не потребовал изменения собственной бизнес-логики. + +Новая подсистема была встроена посредством повторного использования уже существующей архитектуры. + +--- + +## Trade Recovery Protocol + +```text +src/market_data/acquisition/recovery/trade_recovery_protocol.py +``` + +Добавлен новый Protocol. + +```text +TradeRecoveryProtocol +``` + +Protocol определяет единственный публичный контракт новой подсистемы. + +```python +recover(request: TradeRecoveryRequest) -> TradeRecoveryResult +``` + +Благодаря этому все последующие компоненты Runtime смогут зависеть исключительно от абстракции Recovery, а не от конкретной реализации Controller. + +--- + +## Trade Recovery Request + +```text +src/market_data/acquisition/recovery/trade_recovery_request.py +``` + +Добавлена специализированная immutable-модель запроса восстановления. + +Модель инкапсулирует все параметры Recovery. + +Выполняется встроенная проверка: + +- торгового символа; +- диапазона времени; +- максимального размера окна; +- ограничения `limit`; +- корректности типов входных данных. + +Таким образом Controller никогда не начинает выполнение с некорректным запросом. + +--- + +## Trade Recovery Result + +```text +src/market_data/acquisition/recovery/trade_recovery_result.py +``` + +Добавлена immutable-модель результата восстановления. + +Result содержит: + +- symbol; +- requested_start_time; +- requested_end_time; +- tuple восстановленных сделок. + +Также реализованы вычисляемые свойства: + +```text +recovered_count + +is_empty + +first_trade + +last_trade +``` + +Подобный подход позволяет избежать повторной обработки коллекции вызывающим кодом. + +--- + +## Trade Recovery Exceptions + +```text +src/market_data/acquisition/recovery/trade_recovery_exceptions.py +``` + +Добавлена специализированная иерархия доменных исключений Recovery. + +Реализованы классы: + +```text +TradeRecoveryError + +TradeRecoveryWindowError + +TradeRecoveryLimitError + +TradeRecoveryNormalizationError + +TradeRecoveryControllerError +``` + +Разделение ошибок позволяет более точно диагностировать причины возникновения исключительных ситуаций без использования универсального класса ошибок. + +--- + +## Trade Recovery Normalizer + +```text +src/market_data/acquisition/recovery/trade_recovery_normalizer.py +``` + +Добавлен специализированный компонент нормализации. + +Normalizer отвечает исключительно за сортировку восстановленной последовательности по `trade_id`. + +Компонент сознательно не выполняет: + +- дедупликацию; +- проверку порядка; +- изменение объектов `Trade`; +- фильтрацию сделок. + +Подобное разделение обязанностей полностью соответствует архитектуре Acquisition Layer. + +--- + +## Trade Recovery Controller + +```text +src/market_data/acquisition/recovery/trade_recovery_controller.py +``` + +Реализован центральный компонент новой подсистемы. + +Controller отвечает исключительно за координацию процесса восстановления. + +В частности он выполняет: + +- получение транспортного документа; +- запуск существующего REST Pipeline; +- нормализацию восстановленных сделок; +- последовательную передачу сделок в `TradeStreamConsistencyController`; +- формирование объекта `TradeRecoveryResult`. + +Внутри Controller отсутствует собственная бизнес-логика обработки транспортных данных. + +Вся обработка делегируется уже существующим специализированным компонентам. + +--- + +# Добавленные unit-тесты + +Настоящий Build сопровождается полноценным покрытием новой подсистемы unit-тестами. + +Все тесты написаны исключительно через публичные интерфейсы компонентов. + +Ни один тест не зависит от внутренней реализации Recovery. + +Подобный подход позволяет свободно изменять внутреннее устройство подсистемы без изменения существующего набора тестов. + +--- + +## TradeRecoveryRequest + +Добавлен новый файл. + +```text +tests/unit/market_data/acquisition/recovery/test_trade_recovery_request.py +``` + +Проверяются следующие сценарии. + +--- + +### Создание корректного запроса + +Подтверждается успешное создание объекта с допустимыми параметрами. + +--- + +### Проверка symbol + +Подтверждается невозможность создания запроса с пустым символом. + +--- + +### Проверка диапазона времени + +Проверяется невозможность задания диапазона, в котором начало превышает окончание. + +--- + +### Проверка отрицательных временных меток + +Подтверждается генерация исключения при использовании отрицательных значений. + +--- + +### Проверка максимального окна восстановления + +Подтверждается невозможность создания запроса с временным диапазоном, превышающим допустимый предел. + +--- + +### Проверка limit + +Проверяются допустимые и недопустимые значения ограничения количества сделок. + +--- + +## TradeRecoveryResult + +Добавлен новый файл. + +```text +tests/unit/market_data/acquisition/recovery/test_trade_recovery_result.py +``` + +Проверяются: + +- корректное формирование результата; +- вычисляемое количество сделок; +- пустой результат; +- первая сделка; +- последняя сделка; +- неизменяемость модели. + +--- + +## TradeRecoveryNormalizer + +Добавлен новый файл. + +```text +tests/unit/market_data/acquisition/recovery/test_trade_recovery_normalizer.py +``` + +Проверяются следующие сценарии. + +--- + +### Сортировка последовательности + +Подтверждается корректная сортировка сделок по `trade_id`. + +--- + +### Работа с пустой коллекцией + +Подтверждается корректное поведение при отсутствии сделок. + +--- + +### Работа с уже отсортированной последовательностью + +Подтверждается отсутствие побочных эффектов. + +--- + +### Возврат immutable-последовательности + +Подтверждается, что результат всегда представлен в виде `tuple`. + +--- + +## TradeRecoveryController + +Добавлен новый файл. + +```text +tests/unit/market_data/acquisition/recovery/test_trade_recovery_controller.py +``` + +Проверяются следующие сценарии. + +--- + +### Использование существующего TradeStreamConsistencyController + +Подтверждается, что Controller использует переданный экземпляр механизма согласованности, не создавая собственный. + +--- + +### Последовательная обработка восстановленных сделок + +Подтверждается передача всех сделок в порядке возрастания `trade_id`. + +--- + +### Исключение идентичных повторов + +Подтверждается корректная обработка ситуации, когда `accept()` возвращает `None`. + +--- + +### Формирование TradeRecoveryResult + +Проверяется корректное формирование итогового результата восстановления. + +--- + +### Работа с пустым диапазоном + +Подтверждается корректное формирование пустого результата. + +--- + +### Передача исключений + +Проверяется, что Recovery не подавляет исключения, возникающие внутри Trade Stream Consistency. + +--- + +# Результаты тестирования + +После завершения реализации был выполнен запуск полного набора unit-тестов новой подсистемы Recovery. + +Сначала была выполнена проверка моделей и вспомогательных компонентов. + +Использовались команды. + +```bash +python -m pytest -q \ +tests/unit/market_data/acquisition/recovery/ +``` + +Результат. + +```text +70 passed +``` + +Все предусмотренные сценарии успешно пройдены. + +Тестирование подтвердило: + +- корректность валидации `TradeRecoveryRequest`; +- корректность формирования `TradeRecoveryResult`; +- детерминированную работу Recovery Normalizer; +- корректную координацию Recovery Controller; +- корректную интеграцию с Trade Stream Consistency. + +После этого был выполнен запуск полного набора тестов подсистемы **Market Data Acquisition**. + +Использовалась команда. + +```bash +python -m pytest -q tests/unit/market_data/acquisition +``` + +Результат. + +```text +1039 passed +``` + +Ни одного регрессионного отклонения обнаружено не было. + +Все ранее реализованные компоненты Acquisition Layer продолжают работать без изменений. + +--- + +## Полная регрессионная проверка проекта + +Заключительным этапом Build стал запуск полного набора unit-тестов всего проекта. + +Использовалась команда. + +```bash +python -m pytest -q +``` + +Получен следующий результат. + +```text +1360 passed +``` + +Полная регрессионная проверка подтвердила: + +- отсутствие нарушения обратной совместимости; +- отсутствие конфликтов между новой подсистемой Recovery и существующими компонентами; +- корректную интеграцию Recovery в архитектуру Acquisition Layer; +- сохранение стабильности всего проекта после завершения Build. + +Настоящий Build считается полностью завершённым. + +--- + +# Итоги Build + +Build 060.19 полностью завершает построение самостоятельной подсистемы **Trade Recovery** внутри Market Data Acquisition. + +До начала настоящего Build система обеспечивала получение сделок и контроль их согласованности. + +После завершения Build система дополнительно получила возможность безопасного восстановления исторических сделок через REST API. + +Таким образом ответственность Acquisition Layer была расширена. + +Теперь подсистема обеспечивает: + +- получение исторических сделок через REST API; +- повторное использование существующего REST Pipeline; +- детерминированную нормализацию восстановленных данных; +- безопасную интеграцию восстановленных сделок в существующий поток; +- повторное использование Trade Stream Consistency; +- формирование специализированного результата восстановления. + +При этом все существующие компоненты системы продолжают работать без каких-либо изменений. + +Build не нарушил обратную совместимость и не потребовал модификации Parser, Mapper, Adapter, Validator или Trade Stream Consistency. + +--- + +# Архитектурный результат + +Главным архитектурным результатом настоящего Build становится появление нового самостоятельного уровня Acquisition Layer. + +Теперь общая архитектура обработки исторических сделок принимает следующий вид. + +```text +TradeRecoveryRequest + │ + ▼ +REST Document Source + │ + ▼ +Schema Validation + │ + ▼ +REST Trade Adapter + │ + ▼ +Trade Recovery Normalizer + │ + ▼ +Trade Stream Consistency + │ + ▼ +TradeRecoveryResult +``` + +Новая подсистема полностью изолирована от транспортной реализации. + +Recovery работает исключительно с канонической моделью `Trade` и публичным контрактом Trade Stream Consistency. + +Это означает, что независимо от конкретной реализации REST API механизм восстановления использует единые архитектурные правила обработки сделок. + +Подобное решение исключает дублирование бизнес-логики и гарантирует единообразное поведение всей системы. + +--- + +# Что сознательно НЕ реализовано + +В соответствии с утверждёнными границами Build 060.19 ряд задач был сознательно оставлен за пределами реализации. + +Настоящий Build **не включает**: + +- автоматическое обнаружение пропусков последовательности (`Gap Detection`); +- автоматический запуск Recovery; +- интеграцию Recovery в Runtime; +- централизованный Trade Recovery Registry; +- планирование и координацию восстановления; +- повторную синхронизацию WebSocket после Recovery; +- механизм управления жизненным циклом Recovery; +- публикацию событий после завершения восстановления. + +Все перечисленные возможности относятся к последующим этапам развития подсистемы Trades Feed и будут использовать уже реализованный слой Trade Recovery в качестве готового архитектурного фундамента. + +--- + +# Влияние на последующие Build + +Реализация настоящего Build существенно упрощает дальнейшее развитие подсистемы Trades Feed. + +Следующие этапы смогут использовать уже готовый механизм восстановления истории и сосредоточиться исключительно на его интеграции в Runtime. + +В частности Recovery станет обязательной частью общего конвейера обработки сделок. + +```text +WebSocket Feed + │ + ▼ +Gap Detection + │ + ▼ +Trade Recovery + │ + ▼ +Trade Stream Consistency + │ + ▼ +Canonical Trade Stream +``` + +При этом сама подсистема Recovery останется неизменной. + +Последующие Build будут заниматься исключительно организацией взаимодействия уже реализованных компонентов. + +Это полностью соответствует принципу построения системы из независимых архитектурных модулей. + +--- + +# Заключение + +Build 060.19 успешно достиг всех поставленных целей. + +В рамках реализации: + +- сформирована самостоятельная подсистема **Trade Recovery**; +- реализован контракт `TradeRecoveryProtocol`; +- реализован координирующий `TradeRecoveryController`; +- реализованы immutable-модели `TradeRecoveryRequest` и `TradeRecoveryResult`; +- реализован специализированный `TradeRecoveryNormalizer`; +- введена отдельная иерархия доменных исключений Recovery; +- выполнено полное покрытие новой подсистемы unit-тестами; +- подтверждено соответствие реализации утверждённой архитектурной спецификации; +- выполнена успешная регрессионная проверка всего проекта (`1360 passed`). + +Полученные результаты формируют завершённый слой восстановления исторических сделок и создают прочную основу для следующего этапа развития Acquisition Layer — централизованного управления экземплярами Recovery через единый **Trade Recovery Registry**. + +--- + +# Следующий Build + +**Build 060.20 — Trade Recovery Registry** \ No newline at end of file diff --git a/docs/migrations/build_060_19_architecture.md b/docs/migrations/build_060_19_architecture.md new file mode 100644 index 0000000..e25eeef --- /dev/null +++ b/docs/migrations/build_060_19_architecture.md @@ -0,0 +1,6375 @@ +# Build 060.19 — Trade Recovery Architecture + +**Статус:** Architecture Specification +**Build:** 060.19 +**Ветка:** Trades Feed (Time & Sales) +**Документ:** `build_060_19_architecture.md` +**Связанные документы:** `build_060_19.md` — план реализации данного Build. +**Предыдущий Build:** Build 060.18 — Trade Stream Consistency Controller. + +--- + +# Назначение документа + +Настоящий документ является официальной архитектурной спецификацией Build 060.19 и определяет построение подсистемы восстановления потока сделок (**Trade Recovery**). + +Документ фиксирует все архитектурные решения, принятые до начала реализации, и является единственным источником истины (Single Source of Truth) при разработке данного Build. + +Все решения, описанные ниже, считаются утверждёнными до начала реализации и не должны изменяться в процессе написания кода без подготовки нового ADR. + +Настоящий документ подготовлен на основании: + +- архитектуры предыдущих Build серии 060; +- экспериментального исследования REST API биржи; +- полного аудита существующей реализации Acquisition Pipeline; +- анализа существующих транспортных адаптеров, Feed, Handler, Runtime и Consistency Layer. + +Никакие положения настоящего документа не основаны на предположениях о поведении биржи. Все архитектурные решения принимаются исключительно на основании подтверждённого поведения существующей системы и экспериментально проверенного контракта REST API. + +--- + +# Статус Build + +Build 060.19 является непосредственным продолжением Build 060.18. + +К моменту начала данного Build в системе уже существуют: + +- Canonical Trade Model; +- Parser; +- Mapper; +- Value Validation; +- REST Adapter; +- WebSocket Adapter; +- Trades Feed; +- Trades Handler; +- Trade Stream Consistency Controller; +- Canonical Trade Stream. + +Таким образом, система уже умеет: + +- получать сделки через REST; +- получать сделки через WebSocket; +- преобразовывать транспортные структуры в Canonical Trade; +- формировать согласованный поток сделок. + +Однако система ещё не умеет восстанавливать пропущенный участок истории после временной потери WebSocket-потока. + +Именно эту задачу решает Build 060.19. + +--- + +# Контекст + +После завершения Build 060.18 система гарантирует согласованность каждой опубликованной сделки. + +Однако согласованность ещё не означает полноту потока. + +Рассмотрим пример. + +```text +Trade #100 + +Trade #101 + +(WebSocket отключился) + +Trade #110 +``` + +С точки зрения Build 060.18: + +- Trade #110 является корректной; +- порядок не нарушен; +- конфликтующих повторов нет. + +Следовательно, сделка будет успешно опубликована. + +Однако фактически поток потерял сделки: + +```text +102 + +103 + +104 + +105 + +106 + +107 + +108 + +109 +``` + +Следовательно, после появления Canonical Trade Stream возникает новая архитектурная задача. + +Необходимо восстановить отсутствующий участок истории до продолжения нормальной обработки WebSocket. + +Именно этот механизм вводится Build 060.19. + +--- + +# Предпосылки + +Настоящий Build опирается на архитектурные решения предыдущих Build серии 060. + +## Build 057 + +Определены фундаментальные принципы Acquisition Layer. + +Разделены транспортный и доменный уровни. + +--- + +## Build 060.1 + +Построена Canonical Trade Model. + +Все транспортные источники приводятся к единому объекту `Trade`. + +--- + +## Build 060.4 + +Завершено разделение Parser и Mapper. + +Parser отвечает исключительно за транспортный уровень. + +Mapper отвечает исключительно за построение Domain Model. + +--- + +## Build 060.9 + +Построена система Value Validation. + +Корректность отдельных значений гарантируется до создания объекта `Trade`. + +--- + +## Build 060.11 + +Определены транспортные адаптеры REST и WebSocket. + +--- + +## Build 060.17 + +Построен Trades Feed. + +Получение сделок полностью функционирует. + +--- + +## Build 060.18 + +Построен Canonical Trade Stream. + +Введён `TradeStreamConsistencyController`, обеспечивающий: + +- дедупликацию; +- контроль порядка; +- обнаружение конфликтующих дублей; +- формирование единственного согласованного потока сделок. + +Build 060.19 обязан использовать данный механизм без изменения его архитектуры. + +Recovery не имеет права реализовывать: + +- собственную дедупликацию; +- собственную проверку порядка; +- собственную модель Trade; +- собственный поток сделок. + +--- + +# Проблема + +После потери WebSocket-соединения поток сделок становится неполным. + +При этом существующий Consistency Controller не способен самостоятельно восстановить отсутствующий участок истории. + +Это не является недостатком Build 060.18. + +Напротив, Build 060.18 сознательно ограничивает собственную ответственность исключительно проверкой согласованности уже поступающих сделок. + +Следовательно, необходим отдельный компонент, который сможет: + +- получить исторические сделки через REST; +- преобразовать их в Canonical Trade; +- безопасно объединить их с текущим потоком; +- использовать уже существующий механизм проверки согласованности. + +Такой компонент должен быть полностью независимым от Runtime, WebSocket Transport и транспортного уровня биржи. + +Именно таким компонентом становится Trade Recovery. + +--- + +# Основная идея Build + +Главная идея Build заключается в строгом разделении двух независимых задач. + +Первая задача: + +```text +Получение исторических сделок. +``` + +Вторая задача: + +```text +Проверка согласованности потока. +``` + +Получение истории не должно знать, каким образом выполняется дедупликация. + +Контроллер согласованности, в свою очередь, не должен знать, каким способом были получены сделки. + +Следовательно, Recovery становится исключительно связующим звеном между существующим REST Pipeline и существующим Stream Consistency Layer. + +Он не изменяет их архитектуру и не дублирует их функциональность. + +--- + +# Цель Build + +Build обязан обеспечить безопасное восстановление отсутствующего участка истории торгов без нарушения уже существующих архитектурных инвариантов. + +После завершения Build система должна обладать следующими свойствами. + +- Recovery использует существующий REST Pipeline. +- Recovery использует существующий TradeStreamConsistencyController. +- Recovery не создаёт собственную модель Trade. +- Recovery не создаёт собственную модель потока. +- Recovery не выполняет собственную дедупликацию. +- Recovery не реализует собственные правила Ordering. +- Recovery не зависит от Runtime. +- Recovery не зависит от WebSocket Transport. +- Recovery не зависит от механизма Reconnect. + +Таким образом Recovery становится полностью автономным компонентом Acquisition Layer. + +--- + +# Что НЕ входит в Scope Build + +Настоящий Build сознательно НЕ реализует: + +- автоматическое обнаружение потери WebSocket; +- обнаружение Gap; +- принятие решения о запуске Recovery; +- управление переподключением; +- Runtime Integration; +- Composition Root; +- сохранение состояния между перезапусками; +- журналирование Recovery; +- Retry Policy; +- стратегию многократного Backfill; +- телеметрию; +- метрики. + +Все перечисленные задачи относятся к следующим Build серии 060. + +Build 060.19 реализует исключительно механизм безопасного восстановления исторических сделок по уже подготовленному диапазону времени. + +--- + +# Архитектурные принципы + +При реализации Build 060.19 используются следующие фундаментальные принципы. + +--- + +## 1. Canonical First + +Recovery никогда не работает с транспортными структурами данных. + +Любая информация, полученная через REST API, должна пройти существующий Pipeline: + +```text +REST Document + +↓ + +Parser + +↓ + +Value Validation + +↓ + +Mapper + +↓ + +Canonical Trade +``` + +Только после появления объекта `Trade` начинается работа Recovery. + +Recovery никогда не анализирует JSON-документ REST напрямую. + +--- + +## 2. Existing Pipeline Reuse + +Build 060.19 не создаёт собственный REST Pipeline. + +Recovery полностью переиспользует существующие компоненты: + +- REST Transport; +- REST Parser; +- REST Value Validation; +- REST Mapper; +- REST Adapter. + +Во время проектирования была рассмотрена возможность реализации отдельного Recovery Adapter. + +Данный вариант отклонён. + +Причина: + +это привело бы к появлению двух различных способов преобразования одного и того же REST-документа в Canonical Trade, что нарушило бы принцип единственной модели преобразования данных. + +--- + +## 3. Single Domain Model + +Во всей системе продолжает существовать единственная доменная модель сделки: + +```python +Trade +``` + +Recovery не имеет собственной модели сделки. + +Recovery не создаёт дополнительных DTO доменного уровня. + +Все операции выполняются исключительно над существующим объектом `Trade`. + +--- + +## 4. Existing Consistency First + +Recovery никогда самостоятельно не принимает решения о корректности сделки. + +После нормализации последовательности каждая сделка обязательно передаётся в существующий: + +```text +TradeStreamConsistencyController +``` + +Именно данный компонент остаётся единственным владельцем правил: + +- Ordering; +- Deduplication; +- Conflict Detection. + +Recovery не имеет права дублировать эти проверки. + +--- + +## 5. Stateless Recovery + +Recovery не хранит собственного состояния потока. + +Он не знает: + +- последний обработанный trade_id; +- последний опубликованный Trade; +- состояние дедупликации; +- историю уже обработанных сделок. + +Recovery является stateless-компонентом. + +Всё состояние системы по-прежнему принадлежит только Build 060.18. + +--- + +## 6. Runtime Independence + +Recovery не зависит от Runtime. + +Он не знает: + +- произошло ли переподключение; +- был ли разрыв WebSocket; +- почему потребовалось восстановление; +- сколько времени отсутствовало соединение. + +Recovery получает уже сформированный запрос восстановления и выполняет только его. + +--- + +## 7. Time-Based Recovery + +Recovery использует исключительно временные диапазоны. + +Во время проектирования были рассмотрены различные варианты построения курсора восстановления. + +Использование `trade_id` было отклонено после экспериментального исследования REST API. + +Официальным механизмом навигации Build 060.19 являются: + +```text +start_time + +↓ + +end_time +``` + +Других типов курсоров Build не предусматривает. + +--- + +## 8. One Recovery Direction + +REST API возвращает сделки в порядке: + +```text +DESCENDING +``` + +Однако Canonical Trade Stream существует исключительно в порядке: + +```text +ASCENDING +``` + +Следовательно Build 060.19 вводит обязательный этап нормализации порядка. + +Никакая сделка не может быть передана в Consistency Layer до завершения данной нормализации. + +--- + +## 9. Separation Of Responsibilities + +Recovery отвечает исключительно за: + +- получение истории; +- нормализацию порядка; +- передачу сделок в Consistency Layer. + +Recovery сознательно не отвечает за: + +- принятие решения о запуске; +- вычисление диапазона восстановления; +- повторные попытки; +- работу WebSocket; +- Runtime Integration. + +Все перечисленные обязанности относятся к следующим Build серии 060. + +--- + +## 10. Unique File Naming + +Во всём проекте Dzentra продолжает действовать правило уникальности имён файлов. + +Новые файлы Build 060.19 обязаны иметь уникальные имена во всём репозитории. + +Допускается единственное исключение: + +```text +__init__.py +``` + +Использование общих имён файлов запрещается. + +Например, не допускаются: + +```text +controller.py + +request.py + +normalizer.py + +exceptions.py +``` + +Файлы должны отражать своё назначение. + +Например: + +```text +trade_recovery_controller.py + +trade_recovery_request.py + +trade_recovery_normalizer.py + +trade_recovery_protocol.py + +trade_recovery_exceptions.py +``` + +--- + +# Архитектурный фундамент Recovery + +После завершения Build 060.18 система получила понятие: + +```text +Canonical Trade Stream +``` + +Build 060.19 не изменяет данную модель. + +Вместо этого появляется новая независимая архитектурная сущность. + +```text +Trade Recovery +``` + +Recovery не становится частью Stream Consistency. + +Recovery располагается перед ним. + +Архитектурно система принимает следующий вид. + +```text +REST + +↓ + +Trade Recovery + +↓ + +Trade Stream Consistency + +↓ + +Canonical Trade Stream +``` + +Таким образом Build 060.19 не расширяет обязанности Consistency Controller. + +Он вводит новый независимый уровень Acquisition Pipeline. + +--- + +# Место Recovery в Acquisition Pipeline + +До Build 060.19 существовала следующая архитектурная схема. + +```text +REST / WebSocket + +↓ + +Parser + +↓ + +Value Validation + +↓ + +Mapper + +↓ + +Trade + +↓ + +TradeStreamConsistencyController + +↓ + +Canonical Trade Stream +``` + +После завершения Build 060.19 появляется дополнительный путь обработки исторических сделок. + +```text +REST + +↓ + +Parser + +↓ + +Value Validation + +↓ + +Mapper + +↓ + +Trade Recovery + +↓ + +TradeStreamConsistencyController + +↓ + +Canonical Trade Stream +``` + +При этом путь обработки WebSocket-сделок остаётся неизменным. + +Recovery является дополнительной веткой Acquisition Pipeline и не изменяет существующую архитектуру получения потоковых сделок. + +--- + +# Экспериментальное исследование REST API + +Перед проектированием Recovery было выполнено отдельное инженерное исследование поведения REST API биржи. + +Целью исследования являлось подтверждение фактического поведения endpoint получения исторических сделок и исключение архитектурных решений, основанных исключительно на документации биржи. + +Исследование выполнялось специализированным диагностическим инструментом: + +```text +check_trade_backfill_api_final_test.py +``` + +Все архитектурные решения настоящего Build принимаются исключительно на основании подтверждённого поведения API. + +--- + +# Подтверждённый контракт REST API + +Исследование подтвердило следующие свойства endpoint: + +```text +GET /api/v2/aggTrades +``` + +Все перечисленные ниже свойства считаются частью архитектурного контракта Build 060.19. + +--- + +# Endpoint + +Экспериментально подтверждено: + +- endpoint доступен; +- endpoint стабилен; +- endpoint детерминирован; +- повторные запросы возвращают предсказуемый результат. + +Recovery полностью опирается на данный контракт. + +--- + +# Порядок выдачи + +REST API возвращает сделки в порядке: + +```text +DESCENDING +``` + +то есть + +```text +newest + +↓ + +oldest +``` + +Это фундаментальное свойство Build. + +Recovery никогда не имеет права передавать данный поток непосредственно в Stream Consistency. + +Перед публикацией последовательность обязательно нормализуется. + +```text +REST + +DESCENDING + +↓ + +Recovery Normalizer + +ASCENDING + +↓ + +TradeStreamConsistencyController +``` + +--- + +# Timestamp + +Экспериментально подтверждено: + +при уменьшении `trade_id` + +уменьшается + +```text +executed_at +``` + +Следовательно: + +- timestamp согласован с порядком выдачи; +- дополнительная сортировка по времени не требуется. + +--- + +# Limit + +Подтверждены следующие ограничения. + +Корректно работают значения: + +```text +1 + +... + +1000 +``` + +Запрос + +```text +limit = 1001 +``` + +возвращает + +```text +HTTP 400 +``` + +Следовательно Build фиксирует официальный диапазон. + +```text +1 <= limit <= 1000 +``` + +Recovery не имеет права нарушать данный инвариант. + +--- + +# Time Filters + +Экспериментально подтверждена корректная работа параметров: + +```text +startTime +``` + +```text +endTime +``` + +а также их совместного использования. + +Recovery использует исключительно временные диапазоны. + +Поддержка навигации по времени считается официальной частью архитектурного контракта. + +--- + +# Ограничение временного диапазона + +При одновременном использовании: + +```text +startTime + ++ + +endTime +``` + +биржа требует, чтобы длина диапазона была меньше одного часа. + +Следовательно Recovery принимает следующий инвариант. + +```text +end_time > start_time +``` + +и + +```text +end_time - start_time < 1 hour +``` + +Нарушение данного условия считается ошибкой формирования Recovery Request. + +--- + +# fromId + +Во время исследования была проведена серия диагностических тестов параметра: + +```text +fromId +``` + +Исследовались: + +- исторические значения; +- глубокие исторические значения; +- отрицательные значения; +- будущие значения. + +Во всех случаях подтверждена одинаковая картина. + +REST продолжает возвращать последнюю страницу сделок. + +Следовательно использование `fromId` как курсора восстановления экспериментально не подтверждено. + +Build 060.19 полностью исключает любую зависимость от данного параметра. + +--- + +# Trade IDs + +Экспериментально подтверждено: + +`trade_id` + +не образует арифметически непрерывную последовательность. + +Например: + +```text +100 + +101 + +118 + +141 + +205 +``` + +является полностью корректной последовательностью. + +Следовательно Recovery никогда не делает предположений вида: + +```text +next_trade_id = previous_trade_id + 1 +``` + +Пропуски идентификаторов сами по себе не являются ошибкой. + +--- + +# Repeatability + +Повторный запрос одного и того же диапазона времени возвращает одинаковую последовательность `trade_id`. + +Это означает: + +- результаты детерминированы; +- повторное получение диапазона безопасно; +- Recovery может повторно использовать один и тот же диапазон без риска появления новых сделок внутри уже завершённого исторического окна. + +--- + +# Выводы исследования + +На основании проведённого исследования Build принимает следующие архитектурные решения. + +Recovery: + +- не использует `fromId`; +- использует только временные диапазоны; +- никогда не предполагает непрерывность `trade_id`; +- всегда нормализует порядок выдачи; +- полностью доверяет существующему REST Pipeline; +- не выполняет дополнительную сортировку по времени. + +Все перечисленные положения считаются официальными архитектурными инвариантами Build 060.19. + +--- + +# Аудит существующей реализации + +После завершения исследования REST API был выполнен полный аудит существующей реализации Acquisition Layer. + +Цель аудита состояла в определении минимального объёма изменений, необходимых для реализации Recovery без нарушения уже существующей архитектуры. + +В ходе аудита были исследованы: + +- REST Source; +- REST Adapter; +- Parser; +- Mapper; +- Value Validation; +- Trades Feed; +- Trades Handler; +- Trade Stream Consistency; +- Runtime; +- Subscription Layer; +- WebSocket Protocol. + +Результаты аудита определяют архитектурные границы настоящего Build. + +--- + +# Вывод №1 + +REST Pipeline уже полностью реализован. + +Существующий транспортный источник поддерживает получение исторических сделок по временным диапазонам. + +Build 060.19 не создаёт нового REST клиента. + +--- + +# Вывод №2 + +REST Adapter уже полностью реализован. + +Pipeline: + +```text +REST Document + +↓ + +Parser + +↓ + +Value Validation + +↓ + +Mapper + +↓ + +Trade +``` + +уже существует. + +Recovery полностью переиспользует данный Pipeline. + +Создание второго Adapter запрещается. + +--- + +# Вывод №3 + +Canonical Trade полностью соответствует требованиям Recovery. + +Build не изменяет: + +- модель Trade; +- Value Validation; +- Parser; +- Mapper. + +Recovery использует существующий доменный объект без каких-либо изменений. + +--- + +# Вывод №4 + +TradeStreamConsistencyController полностью готов к интеграции. + +Recovery обязан использовать существующий публичный контракт: + +```python +accept(trade: Trade) -> Trade | None +``` + +Никаких дополнительных методов Controller Build 060.19 не требует. + +--- + +# Вывод №5 + +Trades Feed не требует изменений архитектуры. + +Recovery не интегрируется непосредственно в Feed. + +Интеграция с Runtime будет реализована отдельным Build. + +Следовательно существующая архитектура Feed остаётся неизменной. + +--- + +# Вывод №6 + +Trades Handler не требует изменения ответственности. + +Во время аудита подтверждено, что Handler уже выполняет исключительно транспортные обязанности: + +- проверку структуры входящего документа; +- вызов существующего Adapter; +- получение канонических объектов `Trade`. + +Recovery не переносит в Handler никакой дополнительной логики. + +Handler остаётся полностью stateless. + +--- + +# Вывод №7 + +Runtime уже содержит архитектурные сущности, необходимые для последующей интеграции Recovery. + +Во время аудита обнаружены отдельные Runtime-компоненты: + +- Runtime Events; +- Runtime Commands; +- Subscription Layer; +- Runtime Protocol. + +Однако Build 060.19 сознательно не использует их напрямую. + +Recovery остаётся полностью независимым компонентом. + +Интеграция с Runtime переносится на последующие Build серии 060. + +--- + +# Вывод №8 + +Recovery не отвечает за управление подписками. + +Во время аудита подтверждено, что существующий Subscription Layer уже определяет архитектуру подписок WebSocket. + +Следовательно Recovery не: + +- создаёт подписки; +- восстанавливает подписки; +- отслеживает подписки; +- управляет жизненным циклом подписок. + +Данные обязанности принадлежат Runtime Layer. + +--- + +# Вывод №9 + +Recovery не зависит от WebSocket Transport. + +Во время аудита подтверждено, что существующий WebSocket Protocol определяет исключительно транспортные контракты. + +Recovery никогда не работает с: + +- WebSocket Message; +- Subscription Event; +- Transport DTO. + +Recovery получает уже готовый запрос восстановления. + +Таким образом между Recovery и WebSocket отсутствует прямая зависимость. + +--- + +# Вывод №10 + +Существующий Runtime ещё не содержит реализации Recovery. + +Во время аудита подтверждено, что Build 060.19 может быть реализован как полностью автономная подсистема. + +Это позволяет: + +- реализовать Recovery; +- полностью протестировать его; +- завершить Build; + +до появления Runtime Integration. + +Данное решение существенно уменьшает связанность системы. + +--- + +# Архитектура Recovery + +После завершения аудита необходимо определить архитектурную модель Recovery. + +Build вводит новую внутреннюю подсистему Acquisition Layer. + +Она состоит из нескольких независимых компонентов. + +```text +TradeRecoveryController + + │ + + ▼ + +TradeRecoveryNormalizer + + │ + + ▼ + +TradeStreamConsistencyController +``` + +Все перечисленные компоненты относятся исключительно к Build 060.19. + +Никакие существующие подсистемы не изменяют собственную ответственность. + +--- + +# Общая архитектурная схема + +Полный путь восстановления исторических сделок выглядит следующим образом. + +```text +TradeRecoveryRequest + + │ + + ▼ + +DzengiTradesDocumentSource + + │ + + ▼ + +REST Document + + │ + + ▼ + +REST Parser + + │ + + ▼ + +Value Validation + + │ + + ▼ + +REST Mapper + + │ + + ▼ + +tuple[Trade] + + │ + + ▼ + +TradeRecoveryNormalizer + + │ + + ▼ + +ASCENDING Trade Stream + + │ + + ▼ + +TradeStreamConsistencyController + + │ + + ▼ + +TradeRecoveryResult +``` + +Данная схема становится официальной архитектурой Recovery Pipeline. + +--- + +# Граница Recovery + +Recovery располагается между существующим REST Adapter и существующим Stream Consistency. + +До Recovery существует только набор независимых объектов Trade. + +```text +REST + +↓ + +Parser + +↓ + +Validation + +↓ + +Mapper + +↓ + +Trade +``` + +После Recovery появляется уже согласованный поток. + +```text +Trade + +↓ + +Recovery + +↓ + +TradeStreamConsistencyController + +↓ + +Canonical Trade Stream +``` + +Recovery никогда не публикует сделки самостоятельно. + +Публикация возможна только после успешного прохождения Stream Consistency. + +--- + +# TradeRecoveryController + +## Назначение + +TradeRecoveryController является центральным компонентом Build 060.19. + +Именно он управляет полным процессом восстановления исторического диапазона. + +Контроллер отвечает исключительно за оркестрацию существующих компонентов. + +Он не реализует: + +- транспорт; +- парсинг; +- дедупликацию; +- Ordering; +- Runtime. + +--- + +# Ответственность Controller + +TradeRecoveryController отвечает за: + +- получение Recovery Request; +- вызов существующего REST Source; +- вызов существующего REST Adapter; +- передачу полученного потока в Recovery Normalizer; +- последовательную передачу сделок в Stream Consistency; +- формирование итогового результата восстановления. + +--- + +# Controller НЕ отвечает + +TradeRecoveryController сознательно не отвечает за: + +- вычисление диапазона времени; +- выбор момента запуска; +- Retry Policy; +- обнаружение Gap; +- работу WebSocket; +- управление подписками; +- Runtime Integration; +- хранение состояния. + +Все перечисленные обязанности относятся к следующим Build. + +--- + +# Главный принцип Controller + +TradeRecoveryController не принимает доменных решений. + +Все решения уже принадлежат существующим компонентам системы. + +Контроллер лишь организует их совместную работу. + +Именно поэтому RecoveryController рассматривается как orchestration component, а не как Domain Service. + +--- + +# TradeRecoveryRequest + +## Назначение + +Recovery выполняется исключительно по заранее подготовленному запросу. + +Build сознательно разделяет: + +- вычисление диапазона; +- выполнение восстановления. + +Следовательно Recovery получает готовый объект запроса. + +--- + +# Содержимое Recovery Request + +Минимальная модель запроса включает: + +```text +symbol + +start_time + +end_time + +limit +``` + +Этого достаточно для выполнения одного Recovery Pipeline. + +Никакие дополнительные поля Build 060.19 не требует. + +--- + +# Почему отсутствует last_processed_timestamp + +Во время проектирования рассматривался вариант хранения внутри Recovery собственного курсора: + +```text +last_processed_timestamp +``` + +Данный вариант отклонён. + +Причины: + +- появление внутреннего состояния; +- зависимость от Runtime; +- нарушение принципа Stateless Recovery; +- смешение ответственности Runtime и Recovery. + +Recovery никогда самостоятельно не вычисляет диапазон восстановления. + +Он лишь выполняет уже подготовленный запрос. + +--- + +# TradeRecoveryRequest + +## Инварианты + +Каждый экземпляр `TradeRecoveryRequest` обязан удовлетворять следующим требованиям. + +--- + +### Инвариант №1 + +Запрос относится ровно к одному торговому инструменту. + +Например: + +```text +BTCUSDT +``` + +Recovery никогда не выполняет восстановление нескольких символов одновременно. + +--- + +### Инвариант №2 + +Временной диапазон обязан быть корректным. + +Всегда должно выполняться условие: + +```text +start_time < end_time +``` + +Нарушение данного условия считается ошибкой формирования Recovery Request. + +--- + +### Инвариант №3 + +Продолжительность диапазона не должна превышать ограничение REST API. + +```text +end_time - start_time < 1 hour +``` + +Recovery не выполняет автоматическое разбиение диапазона. + +Данная задача относится к следующим Build. + +--- + +### Инвариант №4 + +Поле `limit`, если оно указано, обязано удовлетворять диапазону: + +```text +1 <= limit <= 1000 +``` + +Recovery не корректирует ошибочные значения автоматически. + +--- + +### Инвариант №5 + +Recovery Request является неизменяемым объектом. + +После создания его содержимое никогда не изменяется. + +--- + +# Почему используется готовый Request + +Во время проектирования рассматривались несколько вариантов. + +--- + +## Вариант 1 + +Передавать только: + +```text +symbol +``` + +Отклонён. + +Recovery пришлось бы самостоятельно вычислять диапазон восстановления. + +Это нарушает разделение ответственности. + +--- + +## Вариант 2 + +Передавать: + +```text +last_trade_id +``` + +Отклонён. + +Экспериментально подтверждено, что Recovery не должен строиться вокруг `trade_id`. + +Кроме того, существующий REST API не предоставляет надёжного механизма навигации через `fromId`. + +--- + +## Вариант 3 + +Передавать: + +```text +start_time + +end_time + +limit +``` + +Принят. + +Recovery полностью независим от Runtime и выполняет исключительно уже сформированный запрос. + +--- + +# TradeRecoveryNormalizer + +## Назначение + +TradeRecoveryNormalizer является вторым компонентом Recovery Pipeline. + +Его единственная задача — + +привести последовательность сделок, полученную от REST API, к каноническому порядку публикации. + +RecoveryNormalizer не анализирует содержимое сделок. + +Он работает исключительно с их последовательностью. + +--- + +# Почему необходим отдельный компонент + +Во время проектирования рассматривалась возможность выполнения нормализации непосредственно внутри Controller. + +Данный вариант был отклонён. + +Причины: + +- смешение обязанностей; +- ухудшение тестируемости; +- усложнение Controller; +- невозможность повторного использования алгоритма. + +Разделение Controller и Normalizer соответствует общим архитектурным принципам серии Build 060. + +--- + +# Ответственность Normalizer + +TradeRecoveryNormalizer отвечает исключительно за: + +- анализ направления последовательности; +- нормализацию порядка сделок; +- проверку корректности направления потока. + +Никаких других обязанностей компонент не имеет. + +--- + +# Normalizer НЕ отвечает + +TradeRecoveryNormalizer сознательно не отвечает за: + +- REST; +- Parser; +- Mapper; +- Value Validation; +- Deduplication; +- Ordering; +- Runtime; +- Recovery Request. + +--- + +# Контракт Normalizer + +Normalizer получает: + +```text +tuple[Trade] +``` + +и возвращает: + +```text +tuple[Trade] +``` + +Количество сделок никогда не изменяется. + +Normalizer никогда: + +- не удаляет сделки; +- не добавляет сделки; +- не модифицирует сделки. + +Изменяется исключительно порядок их следования. + +--- + +# Модель нормализации + +Во время исследования REST API было подтверждено следующее поведение. + +REST API возвращает сделки в порядке: + +```text +DESCENDING +``` + +Следовательно Recovery обязан преобразовать поток в: + +```text +ASCENDING +``` + +Только после этого сделки могут быть переданы в Stream Consistency. + +--- + +# Допустимые варианты последовательности + +Build 060.19 формально определяет допустимые варианты входной последовательности. + +--- + +## Вариант №1 + +Последовательность уже является возрастающей. + +Например: + +```text +100 + +105 + +121 + +140 +``` + +В этом случае RecoveryNormalizer возвращает её без изменений. + +--- + +## Вариант №2 + +Последовательность является убывающей. + +Например: + +```text +140 + +121 + +105 + +100 +``` + +В этом случае выполняется нормализация. + +Результат: + +```text +100 + +105 + +121 + +140 +``` + +--- + +## Вариант №3 + +Последовательность имеет смешанный порядок. + +Например: + +```text +100 + +150 + +120 + +180 +``` + +Такой поток считается архитектурно недопустимым. + +Normalizer обязан завершить обработку ошибкой. + +Никакие сделки не передаются в Stream Consistency. + +--- + +# Почему запрещается смешанный порядок + +Смешанная последовательность означает нарушение фундаментального контракта транспортного уровня. + +Recovery не должен самостоятельно исправлять подобные ошибки. + +Подобная ситуация рассматривается как нарушение архитектурных инвариантов Acquisition Layer. + +Следовательно единственно допустимым поведением является генерация доменного исключения. + +--- + +# Пустая последовательность + +Если REST Pipeline возвращает: + +```text +() +``` + +Normalizer возвращает ту же пустую последовательность. + +Ошибки не возникает. + +Это означает отсутствие сделок внутри указанного диапазона времени. + +--- + +# Последовательность из одной сделки + +Если получена единственная сделка: + +```text +Trade +``` + +никакая нормализация не требуется. + +Trade передаётся в Stream Consistency без изменений. + +--- + +# Неизменяемость Trade + +Во время нормализации запрещается изменять объект Trade. + +Допускается изменение исключительно порядка следования элементов внутри коллекции. + +Canonical Trade остаётся полностью immutable. + +--- + +# Передача сделок в Stream Consistency + +После завершения нормализации Recovery начинает публикацию сделок в существующий `TradeStreamConsistencyController`. + +Передача выполняется строго последовательно. + +Для каждой сделки вызывается существующий публичный контракт: + +```python +accept(trade: Trade) -> Trade | None +``` + +Recovery никогда не обходит данный интерфейс. + +--- + +# Последовательность обработки + +После получения нормализованной последовательности обработка всегда выполняется по одной и той же схеме. + +```text +Trade #1 + +↓ + +accept() + +↓ + +Trade #2 + +↓ + +accept() + +↓ + +Trade #3 + +↓ + +accept() + +↓ + +... +``` + +Recovery никогда не передаёт контроллеру сразу всю коллекцию. + +Контроллер продолжает работать исключительно со входящим потоком отдельных сделок. + +Это сохраняет единый механизм обработки как для REST, так и для WebSocket. + +--- + +# Почему используется последовательная обработка + +Во время проектирования рассматривалась возможность добавить новый метод вида: + +```python +accept_many(...) +``` + +Данный вариант был отклонён. + +Причины: + +- появление второго публичного API; +- дублирование логики; +- нарушение единой модели обработки потока; +- увеличение сложности тестирования. + +Build 060.19 сохраняет существующий контракт без изменений. + +--- + +# Роль TradeStreamConsistencyController + +Recovery не принимает решений относительно результата обработки сделки. + +Все решения принадлежат исключительно существующему Controller. + +Для каждой сделки возможны только три сценария. + +--- + +## Сценарий №1 + +Controller возвращает: + +```python +Trade +``` + +Это означает успешное прохождение проверки согласованности. + +Recovery включает сделку в итоговый результат восстановления. + +--- + +## Сценарий №2 + +Controller возвращает: + +```python +None +``` + +Это означает обнаружение корректного дубликата. + +Recovery не считает подобную ситуацию ошибкой. + +Такая сделка просто не включается в итоговый поток восстановления. + +--- + +## Сценарий №3 + +Controller генерирует исключение. + +Например: + +```text +TradeConsistencyError +``` + +или + +```text +TradeOrderingError +``` + +Recovery немедленно прекращает выполнение. + +Итог восстановления считается неуспешным. + +--- + +# Почему Recovery не перехватывает ошибки Consistency + +Во время проектирования рассматривался вариант автоматического продолжения восстановления после ошибок согласованности. + +Данный вариант отклонён. + +Причины: + +- нарушение архитектурных инвариантов; +- сокрытие ошибок потока; +- появление недостоверного результата восстановления. + +Recovery никогда не скрывает ошибки Controller. + +Исключения распространяются вверх без изменения их смысла. + +--- + +# Формирование результата Recovery + +После обработки всех сделок Recovery формирует единый результат выполнения. + +Build 060.19 вводит отдельную доменную модель результата. + +```text +TradeRecoveryResult +``` + +Данный объект не относится к транспортному уровню. + +Он описывает исключительно итог работы Recovery. + +--- + +# Почему вводится отдельный Result + +Во время проектирования рассматривались несколько вариантов. + +--- + +## Вариант №1 + +Возвращать: + +```python +tuple[Trade] +``` + +Отклонён. + +По коллекции невозможно определить: + +- была ли выполнена обработка полностью; +- сколько сделок было отброшено как дубликаты; +- завершилось ли восстановление успешно. + +--- + +## Вариант №2 + +Возвращать: + +```python +list[Trade] +``` + +Отклонён по тем же причинам. + +Кроме того, Canonical Pipeline использует неизменяемые коллекции. + +--- + +## Вариант №3 + +Использовать специализированный объект результата. + +Принят. + +Именно он становится официальным контрактом Recovery. + +--- + +# Назначение TradeRecoveryResult + +TradeRecoveryResult описывает завершённую операцию восстановления. + +Он не является журналом выполнения. + +Он не содержит внутреннего состояния Recovery. + +Он лишь фиксирует итог уже завершённой операции. + +--- + +# Минимальный состав Result + +Build 060.19 определяет следующий минимальный набор информации. + +```text +Recovered Trades + +Skipped Duplicates +``` + +Этого достаточно для оценки результата работы Recovery. + +Расширенные диагностические поля будут добавлены в следующих Build. + +--- + +# Почему Result не содержит ошибки + +Во время проектирования рассматривался вариант хранения исключения внутри объекта результата. + +Например: + +```text +error +``` + +или + +```text +exception +``` + +Данный вариант отклонён. + +Recovery использует стандартную модель обработки ошибок Python. + +При возникновении исключения объект результата не создаётся. + +--- + +# Обработка пустого восстановления + +Если REST не возвращает ни одной сделки, + +Recovery успешно завершается. + +Результат содержит: + +```text +Recovered Trades = 0 + +Skipped Duplicates = 0 +``` + +Подобная ситуация считается полностью корректной. + +--- + +# Обработка полного дублирования + +Если все полученные сделки уже присутствуют в Canonical Stream, + +Controller вернёт: + +```python +None +``` + +для каждой сделки. + +Recovery завершится успешно. + +Результат будет иметь вид: + +```text +Recovered Trades = 0 + +Skipped Duplicates = N +``` + +Ошибки не возникает. + +--- + +# Обработка частичного восстановления + +Наиболее типичный сценарий. + +Например: + +REST вернул: + +```text +100 + +105 + +121 + +140 +``` + +Controller определил: + +```text +100 -> duplicate + +105 -> accepted + +121 -> accepted + +140 -> accepted +``` + +Итог Recovery: + +```text +Recovered Trades = 3 + +Skipped Duplicates = 1 +``` + +Именно подобный сценарий считается основной моделью работы Recovery. + +--- + +# Завершение Recovery + +Recovery считается успешно завершённым только после выполнения всех условий: + +- REST Pipeline завершился без ошибок; +- Normalizer успешно обработал последовательность; +- все сделки переданы в Stream Consistency; +- Controller не сгенерировал исключений; +- сформирован TradeRecoveryResult. + +Только после этого операция восстановления считается завершённой. + +--- + +# Исключения Recovery + +Build 060.19 вводит собственный набор доменных исключений Recovery. + +Они описывают ошибки исключительно уровня восстановления истории и не заменяют существующие исключения других компонентов системы. + +Recovery никогда не создаёт новые исключения для задач: + +- Parser; +- Mapper; +- Value Validation; +- Trade Stream Consistency. + +Каждый уровень системы продолжает использовать собственную модель ошибок. + +--- + +# Принцип распространения исключений + +Recovery придерживается принципа Fail Fast. + +Если любой нижележащий компонент завершает работу исключением, + +Recovery немедленно прекращает выполнение. + +Никакие ошибки не: + +- скрываются; +- преобразуются; +- игнорируются; +- журналируются внутри Recovery. + +Ответственность Recovery ограничивается только распространением ошибки вызывающему компоненту. + +--- + +# Собственные ошибки Recovery + +Build 060.19 предусматривает появление специализированных исключений Recovery. + +Например: + +```text +TradeRecoveryError +``` + +базовое исключение Recovery. + +От него могут наследоваться специализированные ошибки. + +Например: + +```text +TradeRecoveryNormalizationError +``` + +Ошибка нормализации последовательности. + +--- + +```text +TradeRecoveryRequestError +``` + +Некорректный Recovery Request. + +--- + +```text +TradeRecoveryConfigurationError +``` + +Некорректная конфигурация Recovery. + +--- + +Данный перечень может быть расширен в последующих Build без изменения публичной архитектуры Recovery. + +--- + +# Ошибки, которые Recovery не создаёт + +Recovery никогда не создаёт: + +```text +TradeConsistencyError +``` + +или + +```text +TradeOrderingError +``` + +Данные исключения принадлежат исключительно Build 060.18. + +Recovery лишь распространяет их без изменения. + +--- + +# Обработка транспортных ошибок + +Если REST Source завершает работу исключением, + +например: + +```text +HTTP Error + +Network Error + +Timeout +``` + +Recovery не пытается выполнить повторный запрос. + +Retry Policy не входит в Scope Build 060.19. + +Исключение распространяется вызывающему компоненту. + +--- + +# Обработка ошибок Parser + +Если Parser обнаруживает нарушение транспортного контракта, + +Recovery не вмешивается. + +Исключение считается критическим. + +Восстановление прекращается. + +--- + +# Обработка ошибок Value Validation + +Если хотя бы одна сделка не проходит существующую систему Validation, + +Recovery завершается ошибкой. + +Продолжение восстановления после подобных ошибок запрещается. + +--- + +# Обработка ошибок Mapper + +Если Mapper не способен сформировать Canonical Trade, + +Recovery немедленно прекращает выполнение. + +Никакие частично обработанные результаты не публикуются. + +--- + +# Обработка ошибок Normalizer + +Если Normalizer обнаруживает смешанный порядок последовательности, + +генерируется: + +```text +TradeRecoveryNormalizationError +``` + +После возникновения данной ошибки: + +- ни одна сделка не передаётся в Stream Consistency; +- результат Recovery не создаётся; +- выполнение прекращается. + +--- + +# Обработка ошибок Stream Consistency + +Если TradeStreamConsistencyController обнаруживает нарушение инвариантов потока, + +Recovery не выполняет никаких дополнительных действий. + +Исключение распространяется вверх без изменения. + +Таким образом единственным владельцем логики проверки согласованности остаётся Build 060.18. + +--- + +# Тестируемость Recovery + +Recovery проектируется как полностью детерминированная подсистема. + +При одинаковых входных данных Recovery всегда обязан выдавать одинаковый результат. + +Никакие внешние факторы не должны влиять на результат выполнения. + +--- + +# Принцип детерминированности + +При фиксированных: + +- Recovery Request; +- ответе REST; +- состоянии Stream Consistency; + +результат Recovery всегда обязан быть идентичным. + +Это значительно упрощает автоматическое тестирование. + +--- + +# Изоляция компонентов + +Каждый компонент Recovery должен тестироваться независимо. + +Например: + +TradeRecoveryNormalizer может быть протестирован без: + +- REST; +- Runtime; +- Stream Consistency. + +TradeRecoveryController может быть протестирован с использованием Mock Source и Mock ConsistencyController. + +Подобное разделение является обязательным архитектурным требованием. + +--- + +# Unit-тестирование + +Минимальный набор Unit-тестов должен покрывать: + +## Recovery Request + +- корректное создание; +- нарушение временного диапазона; +- нарушение ограничения limit; +- неизменяемость объекта. + +--- + +## Recovery Normalizer + +- пустая последовательность; +- одна сделка; +- ASCENDING; +- DESCENDING; +- смешанный порядок; +- отсутствие изменения объектов Trade. + +--- + +## Recovery Controller + +- успешное восстановление; +- пустое восстановление; +- полное дублирование; +- частичное восстановление; +- ошибка REST; +- ошибка Parser; +- ошибка Mapper; +- ошибка Validation; +- ошибка Consistency Controller. + +--- + +# Интеграционные тесты + +После завершения Unit-тестирования Build предусматривает отдельный набор интеграционных сценариев. + +Минимально должны быть проверены: + +- получение истории через существующий REST Pipeline; +- передача результата в Recovery; +- нормализация последовательности; +- взаимодействие с существующим TradeStreamConsistencyController; +- формирование итогового TradeRecoveryResult. + +--- + +# Архитектурные инварианты Build + +После завершения Build 060.19 система обязана удовлетворять следующим инвариантам. + +--- + +## Инвариант №1 + +Recovery никогда не работает с транспортными структурами. + +--- + +## Инвариант №2 + +Recovery использует исключительно существующий REST Pipeline. + +--- + +## Инвариант №3 + +Recovery использует исключительно существующий TradeStreamConsistencyController. + +--- + +## Инвариант №4 + +Recovery никогда не реализует собственную дедупликацию. + +--- + +## Инвариант №5 + +Recovery никогда не реализует собственную проверку порядка сделок. + +--- + +## Инвариант №6 + +Recovery никогда не изменяет объект Trade. + +--- + +## Инвариант №7 + +Recovery никогда не вычисляет диапазон восстановления самостоятельно. + +--- + +## Инвариант №8 + +Recovery полностью независим от Runtime. + +--- + +## Инвариант №9 + +Recovery полностью независим от WebSocket Transport. + +--- + +## Инвариант №10 + +Recovery не изменяет архитектуру существующих Build серии 060. + +Он исключительно дополняет Acquisition Layer новой автономной подсистемой восстановления истории. + +--- + +# Итоги Build 060.19 + +После реализации Build 060.19 система впервые получает полноценный механизм безопасного восстановления исторических сделок. + +При этом сохраняются все архитектурные принципы серии Build 060: + +- единая Canonical Trade Model; +- единый REST Pipeline; +- единая система Validation; +- единый Mapper; +- единый Trade Stream Consistency Controller; +- единый Canonical Trade Stream. + +Recovery не создаёт альтернативную архитектуру обработки сделок. + +Напротив, он органично встраивается в уже существующий Acquisition Pipeline и использует ранее построенные компоненты без дублирования их ответственности. + +Именно этот подход обеспечивает минимальную связанность подсистем, высокую тестируемость, предсказуемость поведения и возможность дальнейшего развития Runtime, Reconnect и Backfill-механизмов в последующих Build без изменения фундаментальной архитектуры Recovery. + +--- + +# Приложение A. Architecture Decision Records (ADR) + +--- + +# Назначение приложения + +Настоящее приложение фиксирует все ключевые архитектурные решения, принятые при проектировании Build 060.19. + +Основной документ описывает итоговую архитектуру системы. + +ADR, в свою очередь, отвечает на другой вопрос: + +> **Почему архитектура выглядит именно так?** + +Каждый ADR фиксирует: + +- исходную проблему; +- рассмотренные варианты; +- принятое решение; +- причины выбора; +- последствия данного решения. + +После утверждения ADR считается частью архитектурного контракта системы. + +Изменение любого ADR требует подготовки нового архитектурного решения и не допускается в процессе реализации Build. + +--- + +# ADR-060.19-001 + +# Recovery использует существующий TradeStreamConsistencyController + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +После появления механизма восстановления истории необходимо определить, каким образом проверяется корректность восстановленного потока. + +К моменту начала Build уже существует полноценный компонент: + +```text +TradeStreamConsistencyController +``` + +который гарантирует: + +- дедупликацию; +- контроль порядка; +- обнаружение конфликтующих дублей; +- защиту Canonical Trade Stream. + +Возникает вопрос: + +должен ли Recovery реализовывать аналогичную функциональность самостоятельно? + +--- + +## Рассмотренные варианты + +### Вариант 1 + +Recovery реализует собственную дедупликацию. + +Преимущества: + +- независимость. + +Недостатки: + +- дублирование логики; +- появление второго источника истины; +- риск расхождения алгоритмов; +- двойное сопровождение. + +--- + +### Вариант 2 + +Recovery реализует собственную проверку порядка. + +Преимущества: + +локальная автономность. + +Недостатки: + +- две различные реализации Ordering; +- вероятность различного поведения REST и WebSocket; +- нарушение принципа единственного владельца бизнес-правил. + +--- + +### Вариант 3 + +Recovery полностью использует существующий TradeStreamConsistencyController. + +Преимущества: + +- единая логика проверки; +- единая дедупликация; +- единая модель Ordering; +- отсутствие дублирования; +- минимальная связанность. + +Недостатков не обнаружено. + +--- + +## Принятое решение + +Build 060.19 использует исключительно существующий публичный контракт: + +```python +accept(trade: Trade) -> Trade | None +``` + +Recovery никогда не реализует собственную проверку согласованности. + +--- + +## Последствия + +Во всей системе существует только один компонент, отвечающий за: + +- Ordering; +- Deduplication; +- Conflict Detection. + +Таким компонентом является: + +```text +TradeStreamConsistencyController +``` + +Recovery остаётся исключительно оркестратором. + +--- + +# ADR-060.19-002 + +# Recovery использует временные диапазоны вместо trade_id + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +Необходимо определить способ получения исторических сделок. + +Первоначально рассматривались два варианта: + +- восстановление по trade_id; +- восстановление по времени. + +--- + +## Исследование + +Перед принятием решения было выполнено экспериментальное исследование REST API. + +Подтверждено: + +- `startTime` работает корректно; +- `endTime` работает корректно; +- совместное использование поддерживается; +- результаты детерминированы. + +Одновременно было установлено: + +использование: + +```text +fromId +``` + +не обеспечивает надёжного позиционирования внутри истории. + +Во многих случаях REST возвращает последнюю страницу сделок независимо от указанного значения. + +Следовательно построение архитектуры Recovery вокруг `trade_id` признано небезопасным. + +--- + +## Рассмотренные варианты + +### Вариант 1 + +Использовать: + +```text +fromId +``` + +Отклонён. + +Причина: + +контракт REST API не подтверждён экспериментально. + +--- + +### Вариант 2 + +Использовать: + +```text +trade_id +``` + +как внутренний курсор Runtime. + +Отклонён. + +Причины: + +- привязка Recovery к внутреннему состоянию; +- невозможность гарантировать корректное восстановление; +- зависимость от неподтверждённого поведения биржи. + +--- + +### Вариант 3 + +Использовать исключительно временной диапазон. + +Принят. + +--- + +## Принятое решение + +Recovery использует только: + +```text +start_time + +↓ + +end_time +``` + +Все остальные механизмы навигации исключены из архитектуры Build. + +--- + +## Последствия + +Recovery становится полностью независимым от внутренней структуры идентификаторов сделок. + +Даже если биржа изменит механизм формирования `trade_id`, архитектура Recovery останется корректной. + +--- + +# ADR-060.19-003 + +# Recovery не хранит собственного состояния + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +Во время проектирования возник вопрос: + +должен ли Recovery хранить информацию о последнем успешно восстановленном диапазоне? + +Например: + +```text +last_trade_id + +или + +last_timestamp +``` + +--- + +## Рассмотренные варианты + +### Stateful Recovery + +Recovery самостоятельно сохраняет: + +- последний trade_id; +- последний timestamp; +- информацию о последнем запуске. + +Преимущества: + +локальная автономность. + +Недостатки: + +- необходимость хранения состояния; +- усложнение тестирования; +- зависимость от Runtime; +- необходимость восстановления собственного состояния после перезапуска. + +--- + +### Stateless Recovery + +Recovery ничего не хранит. + +Все необходимые параметры приходят внутри Recovery Request. + +Преимущества: + +- простая архитектура; +- отсутствие собственного состояния; +- высокая тестируемость; +- независимость от Runtime; +- отсутствие необходимости синхронизации. + +Недостатков не выявлено. + +--- + +## Принятое решение + +Recovery является полностью Stateless-компонентом. + +Любая информация, необходимая для восстановления, передаётся исключительно через: + +```text +TradeRecoveryRequest +``` + +--- + +## Последствия + +Recovery становится полностью детерминированным. + +При одинаковом запросе он всегда выполняет одинаковые действия независимо от предыдущих запусков. + +--- + +# ADR-060.19-004 + +# Recovery полностью независим от Runtime + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +После появления Recovery возник вопрос: + +должен ли Recovery самостоятельно взаимодействовать с Runtime? + +Например: + +- получать Runtime Events; +- определять момент восстановления; +- инициировать переподключение; +- принимать решение о завершении Recovery. + +--- + +## Рассмотренные варианты + +### Вариант 1 + +Recovery становится частью Runtime. + +Преимущества: + +- тесная интеграция; +- меньше промежуточных компонентов. + +Недостатки: + +- высокая связанность; +- невозможность автономного тестирования; +- сложность повторного использования; +- нарушение принципа разделения ответственности. + +--- + +### Вариант 2 + +Recovery представляет собой независимый сервис. + +Runtime лишь вызывает его. + +Преимущества: + +- слабая связанность; +- простое тестирование; +- возможность автономного использования; +- отсутствие циклических зависимостей. + +Недостатков не обнаружено. + +--- + +## Принятое решение + +Recovery ничего не знает о Runtime. + +Recovery не знает: + +- почему произошло восстановление; +- почему был потерян WebSocket; +- сколько времени отсутствовало соединение; +- требуется ли последующее переподключение. + +Recovery выполняет только одну операцию: + +```text +TradeRecoveryRequest + +↓ + +TradeRecoveryResult +``` + +--- + +## Последствия + +Runtime становится владельцем жизненного цикла Recovery. + +Recovery остаётся полностью независимым компонентом Acquisition Layer. + +Интеграция между ними выполняется исключительно через публичный API. + +--- + +# ADR-060.19-005 + +# Controller и Normalizer разделены + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +Во время проектирования Recovery возник вопрос: + +следует ли выполнять нормализацию последовательности непосредственно внутри Controller? + +--- + +## Рассмотренные варианты + +### Вариант 1 + +Controller самостоятельно выполняет: + +- получение истории; +- нормализацию; +- передачу в Consistency. + +Преимущество: + +меньшее количество компонентов. + +Недостатки: + +- смешение обязанностей; +- увеличение размера Controller; +- снижение тестируемости; +- невозможность повторного использования алгоритма нормализации. + +--- + +### Вариант 2 + +Выделить отдельный компонент: + +```text +TradeRecoveryNormalizer +``` + +Преимущества: + +- Single Responsibility; +- независимое тестирование; +- простая модификация алгоритма; +- повторное использование. + +Недостатков не выявлено. + +--- + +## Принятое решение + +Recovery состоит минимум из двух независимых компонентов. + +```text +TradeRecoveryController + +↓ + +TradeRecoveryNormalizer +``` + +Controller отвечает исключительно за оркестрацию. + +Normalizer отвечает исключительно за порядок сделок. + +--- + +## Последствия + +Каждый компонент имеет одну ответственность. + +Изменение алгоритма нормализации не требует изменения Controller. + +--- + +# ADR-060.19-006 + +# RecoveryResult является отдельной Domain Model + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +После завершения Recovery необходимо определить способ возврата результата. + +Рассматривались различные модели. + +--- + +## Рассмотренные варианты + +### Вариант 1 + +Вернуть: + +```python +tuple[Trade] +``` + +Недостатки: + +- отсутствует информация о количестве пропущенных дублей; +- невозможно отличить пустой диапазон от полного дублирования; +- отсутствует описание завершённой операции. + +--- + +### Вариант 2 + +Вернуть: + +```python +list[Trade] +``` + +Недостатки аналогичны. + +Кроме того, нарушается использование immutable-коллекций. + +--- + +### Вариант 3 + +Создать отдельную доменную модель результата. + +Преимущества: + +- расширяемость; +- единый контракт; +- возможность добавления диагностической информации; +- отсутствие изменения публичного API в будущем. + +--- + +## Принятое решение + +Recovery возвращает исключительно: + +```text +TradeRecoveryResult +``` + +Этот объект описывает уже завершённую операцию восстановления. + +--- + +## Последствия + +Публичный API Recovery остаётся стабильным. + +В следующих Build возможно расширение модели результата без изменения сигнатур Controller. + +--- + +# ADR-060.19-007 + +# Recovery не реализует Retry Policy + +## Статус + +```text +Accepted +``` + +--- + +## Контекст + +Во время проектирования возник вопрос: + +следует ли Recovery автоматически повторять REST-запросы при временных ошибках сети? + +--- + +## Рассмотренные варианты + +### Вариант 1 + +Автоматический Retry внутри Recovery. + +Преимущества: + +- уменьшение количества временных ошибок. + +Недостатки: + +- появление внутреннего состояния; +- усложнение Recovery; +- необходимость настройки стратегий ожидания; +- смешение обязанностей; +- невозможность централизованного управления повторными попытками. + +--- + +### Вариант 2 + +Retry полностью принадлежит Runtime. + +Recovery выполняет единственную попытку. + +При ошибке возвращает управление вызывающему компоненту. + +Преимущества: + +- простая архитектура; +- единая стратегия повторных попыток; +- отсутствие дублирования Retry между компонентами; +- соответствие принципу Single Responsibility. + +Недостатков не выявлено. + +--- + +## Принятое решение + +Build 060.19 не реализует Retry Policy. + +Recovery выполняет одну попытку получения исторических данных. + +Любая транспортная ошибка немедленно распространяется вызывающему компоненту. + +--- + +## Последствия + +Recovery остаётся простым и детерминированным. + +Все стратегии: + +- Retry; +- Exponential Backoff; +- Circuit Breaker; +- ограничение количества попыток; +- таймеры ожидания; + +будут реализованы на уровне Runtime в последующих Build. + +--- + +# Итоги приложения A + +Настоящие ADR фиксируют фундаментальные архитектурные решения Build 060.19. + +Они определяют не только текущее устройство Recovery, но и границы его дальнейшего развития. + +Любая будущая модификация Recovery должна проверяться на соответствие данным решениям. + +Если новое архитектурное решение противоречит одному из настоящих ADR, оно не может быть реализовано без подготовки нового Architecture Decision Record и пересмотра архитектурной спецификации Build. + +--- + +# Приложение B. Диаграммы последовательностей + +--- + +# Назначение приложения + +Настоящее приложение фиксирует последовательности взаимодействия компонентов Recovery. + +В отличие от основной части документа, описывающей архитектурные сущности и их ответственность, настоящее приложение показывает: + +- порядок вызова компонентов; +- направление передачи данных; +- точки возникновения исключений; +- завершение успешных и неуспешных сценариев. + +Все диаграммы являются частью архитектурного контракта Build 060.19. + +--- + +# Обозначения + +Во всех диаграммах используются одинаковые обозначения. + +```text +↓ + +Синхронный вызов +``` + +--- + +```text +← + +Возврат результата +``` + +--- + +```text +X + +Возникновение исключения +``` + +--- + +```text +✓ + +Успешное завершение этапа +``` + +--- + +# Диаграмма 1 + +# Полный успешный сценарий Recovery + +```text + TradeRecoveryController + + │ + + ▼ + + DzengiTradesDocumentSource + + │ + + REST Request (start/end) + + │ + + ▼ + + REST Response (Document) + + │ + + ▼ + + REST Parser + + │ + + ▼ + + Value Validation + + │ + + ▼ + + REST Mapper + + │ + + ▼ + + tuple[Trade] + + │ + + ▼ + + TradeRecoveryNormalizer + + │ + + ▼ + + ASCENDING tuple + + │ + + ▼ + + TradeStreamConsistencyController + + │ + + accept(trade #1) + + │ + + ▼ + + accepted + + │ + + accept(trade #2) + + │ + + ▼ + + accepted + + │ + + ... + + │ + + ▼ + + TradeRecoveryResult + + │ + + ▼ + + Recovery Done +``` + +--- + +# Основные свойства сценария + +В данном сценарии: + +- REST завершился успешно; +- Parser завершился успешно; +- Validation завершилась успешно; +- Mapper завершился успешно; +- Normalizer завершился успешно; +- все сделки прошли Stream Consistency. + +Recovery завершается формированием результата. + +--- + +# Диаграмма 2 + +# Восстановление пустого диапазона + +```text +TradeRecoveryController + + │ + + ▼ + +REST Source + + │ + + ▼ + +REST Response + + │ + + ▼ + +() + + │ + + ▼ + +TradeRecoveryNormalizer + + │ + + ▼ + +() + + │ + + ▼ + +TradeRecoveryResult + +Recovered Trades = 0 + +Skipped Duplicates = 0 +``` + +--- + +# Основные свойства сценария + +Пустой диапазон не считается ошибкой. + +Recovery завершается успешно. + +TradeStreamConsistencyController не вызывается. + +--- + +# Диаграмма 3 + +# Полное дублирование + +```text +REST + +↓ + +tuple[Trade] + +↓ + +Normalizer + +↓ + +Trade #1 + +↓ + +Consistency + +↓ + +None + +↓ + +Trade #2 + +↓ + +Consistency + +↓ + +None + +↓ + +Trade #3 + +↓ + +Consistency + +↓ + +None + +↓ + +TradeRecoveryResult + +Recovered = 0 + +Duplicates = 3 +``` + +--- + +# Основные свойства сценария + +Все сделки уже присутствуют в Canonical Trade Stream. + +Recovery: + +- не генерирует ошибку; +- не публикует сделки; +- успешно завершается. + +--- + +# Диаграмма 4 + +# Частичное восстановление + +```text +REST + +↓ + +Trade #100 + +↓ + +Consistency + +↓ + +None + +──────────── + +Trade #105 + +↓ + +Consistency + +↓ + +Trade + +──────────── + +Trade #121 + +↓ + +Consistency + +↓ + +Trade + +──────────── + +Trade #140 + +↓ + +Consistency + +↓ + +Trade + +──────────── + +TradeRecoveryResult + +Recovered = 3 + +Duplicates = 1 +``` + +--- + +# Основные свойства сценария + +Это основной рабочий сценарий Recovery. + +Часть сделок уже существует. + +Остальные успешно публикуются. + +Recovery завершается успешно. + +--- + +# Диаграмма 5 + +# REST возвращает DESCENDING последовательность + +```text +REST + +↓ + +140 + +↓ + +121 + +↓ + +105 + +↓ + +100 + +↓ + +TradeRecoveryNormalizer + +↓ + +100 + +↓ + +105 + +↓ + +121 + +↓ + +140 + +↓ + +TradeStreamConsistencyController +``` + +--- + +# Основные свойства сценария + +Нормализация выполняется полностью внутри RecoveryNormalizer. + +TradeStreamConsistencyController получает уже канонический поток. + +Recovery никогда не передаёт Controller последовательность в обратном порядке. + +--- + +# Диаграмма 6 + +# REST возвращает ASCENDING последовательность + +```text +REST + +↓ + +100 + +↓ + +105 + +↓ + +121 + +↓ + +140 + +↓ + +TradeRecoveryNormalizer + +↓ + +100 + +↓ + +105 + +↓ + +121 + +↓ + +140 + +↓ + +TradeStreamConsistencyController +``` + +--- + +# Основные свойства сценария + +Несмотря на то, что текущий контракт REST API предусматривает выдачу сделок в порядке DESCENDING, RecoveryNormalizer проектируется универсальным. + +Если в будущем REST API начнёт возвращать последовательность уже в каноническом порядке, Recovery не потребует изменений архитектуры. + +Normalizer обнаружит, что последовательность уже соответствует Canonical Trade Stream, и вернёт её без изменений. + +--- + +# Диаграмма 7 + +# REST возвращает смешанный порядок + +```text +REST + +↓ + +100 + +↓ + +140 + +↓ + +121 + +↓ + +180 + +↓ + +TradeRecoveryNormalizer + +↓ + +X + +TradeRecoveryNormalizationError +``` + +--- + +# Основные свойства сценария + +Смешанный порядок рассматривается как нарушение транспортного контракта. + +Recovery не предпринимает попыток: + +- отсортировать последовательность; +- определить правильный порядок; +- восстановить повреждённые данные. + +Работа немедленно прекращается. + +Ни одна сделка не передаётся в TradeStreamConsistencyController. + +--- + +# Диаграмма 8 + +# Ошибка Parser + +```text +TradeRecoveryController + +↓ + +REST Source + +↓ + +REST Parser + +↓ + +X + +ParserError +``` + +--- + +# Основные свойства сценария + +Recovery не вмешивается в работу Parser. + +Parser остаётся единственным владельцем транспортной валидации структуры документа. + +Recovery лишь распространяет возникшее исключение вызывающему компоненту. + +--- + +# Диаграмма 9 + +# Ошибка Value Validation + +```text +REST + +↓ + +Parser + +↓ + +Value Validation + +↓ + +X + +ValidationError +``` + +--- + +# Основные свойства сценария + +Если хотя бы одна сделка не проходит существующую систему проверки значений, + +Recovery немедленно прекращает выполнение. + +TradeRecoveryResult не создаётся. + +--- + +# Диаграмма 10 + +# Ошибка Mapper + +```text +REST + +↓ + +Parser + +↓ + +Validation + +↓ + +Mapper + +↓ + +X + +MappingError +``` + +--- + +# Основные свойства сценария + +Recovery никогда не работает с частично сформированными объектами. + +Если Mapper не смог построить корректный объект Trade, + +дальнейшая обработка невозможна. + +--- + +# Диаграмма 11 + +# Ошибка Stream Consistency + +```text +REST + +↓ + +Parser + +↓ + +Validation + +↓ + +Mapper + +↓ + +TradeRecoveryNormalizer + +↓ + +TradeStreamConsistencyController + +↓ + +Trade #100 + +↓ + +accepted + +↓ + +Trade #105 + +↓ + +X + +TradeOrderingError +``` + +--- + +# Основные свойства сценария + +Recovery не перехватывает исключения Consistency Layer. + +После возникновения ошибки: + +- дальнейшая обработка прекращается; +- TradeRecoveryResult не создаётся; +- исключение распространяется вверх. + +Таким образом владельцем логики проверки потока остаётся исключительно Build 060.18. + +--- + +# Диаграмма 12 + +# Полный жизненный цикл Recovery + +```text +TradeRecoveryRequest + + │ + + ▼ + +TradeRecoveryController + + │ + + ▼ + +DzengiTradesDocumentSource + + │ + + ▼ + +REST Document + + │ + + ▼ + +Parser + + │ + + ▼ + +Value Validation + + │ + + ▼ + +Mapper + + │ + + ▼ + +tuple[Trade] + + │ + + ▼ + +TradeRecoveryNormalizer + + │ + + ▼ + +Normalized tuple[Trade] + + │ + + ▼ + +TradeStreamConsistencyController + + │ + + ▼ + +TradeRecoveryResult + + │ + + ▼ + +Caller +``` + +--- + +# Архитектурные выводы + +Все приведённые диаграммы подтверждают несколько фундаментальных принципов Build 060.19. + +--- + +## Принцип №1 + +Recovery никогда не работает с транспортными структурами после завершения этапа Mapping. + +Начиная с момента формирования объекта `Trade`, Recovery использует исключительно каноническую доменную модель. + +--- + +## Принцип №2 + +Recovery не изменяет существующий Acquisition Pipeline. + +Он расширяет его, добавляя дополнительный этап между REST Adapter и TradeStreamConsistencyController. + +--- + +## Принцип №3 + +Recovery не принимает доменных решений. + +Все решения относительно: + +- корректности сделки; +- порядка сделок; +- дубликатов; +- конфликтов; + +по-прежнему принадлежат TradeStreamConsistencyController. + +--- + +## Принцип №4 + +Recovery является полностью линейным Pipeline. + +Каждый компонент вызывается строго один раз. + +Обратные переходы отсутствуют. + +Циклические зависимости отсутствуют. + +--- + +## Принцип №5 + +Любое исключение немедленно завершает выполнение Recovery. + +Частично завершённое восстановление не считается успешным. + +TradeRecoveryResult формируется только после успешного прохождения всех этапов Pipeline. + +--- + +# Итоги приложения B + +Последовательности взаимодействия, приведённые в настоящем приложении, являются нормативным описанием поведения Recovery. + +Любая реализация Build 060.19 должна соответствовать данным диаграммам. + +Изменение порядка взаимодействия компонентов, появление дополнительных зависимостей или перенос ответственности между компонентами допускаются только после подготовки нового архитектурного решения (ADR) и внесения соответствующих изменений в архитектурную спецификацию. + +--- + +# Приложение C. Контракты публичного API + +--- + +# Назначение приложения + +Настоящее приложение фиксирует официальный публичный API подсистемы Trade Recovery. + +Основной документ описывает архитектуру Recovery. + +Настоящее приложение определяет: + +- публичные классы; +- публичные методы; +- входные параметры; +- возвращаемые значения; +- предусловия; +- постусловия; +- архитектурные инварианты. + +Любой внешний компонент системы имеет право взаимодействовать с Recovery исключительно через описанные здесь контракты. + +Все остальные классы Recovery считаются внутренней реализацией Build 060.19. + +--- + +# Общие принципы публичного API + +Recovery строится на следующих принципах. + +--- + +## Минимальность + +Публичный API должен содержать только действительно необходимые точки входа. + +Recovery не предоставляет вспомогательных методов. + +--- + +## Детерминированность + +При одинаковых входных данных любой публичный метод обязан возвращать одинаковый результат. + +--- + +## Отсутствие побочных эффектов + +Ни один публичный метод Recovery не изменяет: + +- состояние Runtime; +- состояние WebSocket; +- состояние подписок. + +Recovery воздействует исключительно на Canonical Trade Stream через существующий TradeStreamConsistencyController. + +--- + +## Иммутабельность + +Все входные модели Recovery являются неизменяемыми. + +Recovery никогда не модифицирует переданные объекты. + +--- + +# Публичный класс + +# TradeRecoveryController + +--- + +## Назначение + +TradeRecoveryController является единственной точкой входа в Recovery Pipeline. + +Никакой другой компонент Recovery не должен вызываться напрямую внешними подсистемами. + +--- + +## Ответственность + +Контроллер отвечает исключительно за выполнение полного цикла восстановления. + +Он: + +- получает Recovery Request; +- инициирует получение исторических сделок; +- выполняет нормализацию; +- передаёт сделки в Stream Consistency; +- формирует итоговый результат. + +--- + +## Публичный контракт + +```python +recover( + request: TradeRecoveryRequest, +) -> TradeRecoveryResult +``` + +--- + +## Входные параметры + +```text +TradeRecoveryRequest +``` + +Запрос восстановления. + +--- + +## Возвращаемое значение + +```text +TradeRecoveryResult +``` + +Описание завершённой операции восстановления. + +--- + +## Возможные исключения + +Контроллер может распространять: + +```text +TradeRecoveryRequestError +``` + +--- + +```text +TradeRecoveryNormalizationError +``` + +--- + +```text +TradeConsistencyError +``` + +--- + +```text +TradeOrderingError +``` + +--- + +а также исключения существующих компонентов: + +- REST; +- Parser; +- Mapper; +- Value Validation. + +--- + +## Предусловия + +Перед вызовом метода должны выполняться следующие условия. + +- Recovery Request успешно создан. +- Все обязательные поля заполнены. +- Диапазон времени корректен. +- RecoveryController полностью сконфигурирован. + +--- + +## Постусловия + +При успешном завершении гарантируется: + +- все сделки обработаны; +- все сделки прошли через TradeStreamConsistencyController; +- сформирован TradeRecoveryResult. + +--- + +## Побочные эффекты + +Единственным допустимым побочным эффектом является публикация новых сделок в существующий Canonical Trade Stream. + +Других изменений состояния системы Controller не выполняет. + +--- + +# Публичный класс + +# TradeRecoveryRequest + +--- + +## Назначение + +TradeRecoveryRequest описывает один запрос восстановления. + +После создания объект никогда не изменяется. + +--- + +## Обязательные поля + +```text +symbol +``` + +--- + +```text +start_time +``` + +--- + +```text +end_time +``` + +--- + +## Необязательные поля + +```text +limit +``` + +--- + +## Инварианты + +Всегда выполняются условия. + +```text +start_time < end_time +``` + +--- + +```text +limit ∈ [1;1000] +``` + +если limit указан. + +--- + +Запрос относится только к одному символу. + +--- + +Объект является immutable. + +--- + +## Предусловия создания + +Все поля проходят базовую проверку корректности. + +--- + +## Постусловия создания + +После создания Recovery Request считается валидным и может использоваться RecoveryController. + +--- + +# Публичный класс + +# TradeRecoveryResult + +--- + +## Назначение + +TradeRecoveryResult описывает итог выполнения Recovery. + +Он создаётся исключительно после успешного завершения Recovery Pipeline. + +--- + +## Основные поля + +```text +Recovered Trades +``` + +Количество новых опубликованных сделок. + +--- + +```text +Skipped Duplicates +``` + +Количество корректных дублей. + +--- + +## Инварианты + +Количество восстановленных сделок всегда больше либо равно нулю. + +Количество пропущенных дублей всегда больше либо равно нулю. + +Все значения являются согласованными относительно завершённого Recovery Pipeline. + +--- + +## Предусловия создания + +Recovery успешно завершён. + +--- + +## Постусловия + +Result полностью описывает завершённую операцию. + +После создания объект не изменяется. + +--- + +# Внутренний публичный компонент + +# TradeRecoveryNormalizer + +--- + +## Назначение + +Несмотря на то, что Normalizer используется только Controller, его контракт фиксируется отдельно. + +Это позволяет независимо тестировать данный компонент. + +--- + +## Публичный контракт + +```python +normalize( + trades: tuple[Trade, ...], +) -> tuple[Trade, ...] +``` + +--- + +## Входные параметры + +Последовательность Canonical Trade. + +--- + +## Возвращаемое значение + +Та же последовательность, + +но гарантированно приведённая к каноническому порядку. + +--- + +## Возможные исключения + +```text +TradeRecoveryNormalizationError +``` + +--- + +## Предусловия + +Все элементы коллекции являются корректными объектами Trade. + +--- + +## Постусловия + +Количество элементов сохраняется. + +Ни один объект Trade не изменяется. + +Допускается изменение исключительно порядка следования элементов. + +--- + +# Контракт взаимодействия компонентов + +Настоящий раздел определяет допустимые направления вызовов между компонентами Recovery. + +--- + +# Допустимые зависимости + +TradeRecoveryController имеет право зависеть от: + +```text +TradeRecoveryRequest +``` + +--- + +```text +DzengiTradesDocumentSource +``` + +--- + +```text +REST Adapter +``` + +--- + +```text +TradeRecoveryNormalizer +``` + +--- + +```text +TradeStreamConsistencyController +``` + +--- + +```text +TradeRecoveryResult +``` + +Других зависимостей Controller иметь не должен. + +--- + +# Недопустимые зависимости + +TradeRecoveryController не должен зависеть от: + +- Runtime; +- Reconnect; +- Subscription Manager; +- WebSocket Transport; +- Event Bus; +- Scheduler; +- Timer; +- Retry Engine. + +Появление подобных зависимостей рассматривается как нарушение архитектуры Build 060.19. + +--- + +# Контракт TradeRecoveryNormalizer + +Normalizer представляет собой полностью детерминированную функцию. + +Он зависит исключительно от: + +```text +tuple[Trade] +``` + +Никакие другие объекты системы ему не требуются. + +--- + +## Запрещённые зависимости Normalizer + +Normalizer никогда не взаимодействует с: + +- REST; +- Runtime; +- WebSocket; +- Controller; +- Repository; +- Cache; +- Configuration; +- Logger. + +Это обеспечивает возможность полностью изолированного тестирования. + +--- + +# Контракт взаимодействия с REST Pipeline + +Recovery не обращается к REST API напрямую. + +Единственная допустимая точка взаимодействия — + +существующий транспортный источник. + +Архитектурная схема выглядит следующим образом. + +```text +TradeRecoveryController + +↓ + +DzengiTradesDocumentSource + +↓ + +REST API +``` + +Recovery не формирует HTTP-запросы самостоятельно. + +Recovery не знает формат REST-документа. + +Recovery не знает структуру JSON. + +--- + +# Контракт взаимодействия с Parser + +Recovery никогда не вызывает Parser напрямую. + +Вызов Parser осуществляется исключительно существующим REST Adapter. + +Таким образом сохраняется единый Pipeline преобразования транспортных данных. + +--- + +# Контракт взаимодействия с Mapper + +Recovery никогда не создаёт объект Trade самостоятельно. + +Появление Canonical Trade возможно исключительно после успешного завершения Mapper. + +Recovery использует только уже готовые доменные объекты. + +--- + +# Контракт взаимодействия со Stream Consistency + +TradeRecoveryController взаимодействует с TradeStreamConsistencyController исключительно через его публичный API. + +Recovery запрещается: + +- изменять внутреннее состояние Controller; +- обращаться к внутренним коллекциям; +- использовать приватные методы; +- обходить механизм `accept()`. + +Таким образом сохраняется строгая инкапсуляция Consistency Layer. + +--- + +# Контракт взаимодействия с Runtime + +В Build 060.19 Runtime не является участником Recovery Pipeline. + +Единственная допустимая форма взаимодействия: + +```text +Runtime + +↓ + +TradeRecoveryController + +↓ + +TradeRecoveryResult +``` + +Runtime рассматривается исключительно как вызывающая сторона. + +Recovery не знает о его внутреннем устройстве. + +--- + +# Контракт расширяемости + +Recovery проектируется с учётом последующего развития системы. + +Следующие компоненты могут быть добавлены без изменения существующего публичного API. + +Например: + +```text +Retry Policy +``` + +--- + +```text +Metrics +``` + +--- + +```text +Tracing +``` + +--- + +```text +Telemetry +``` + +--- + +```text +Performance Monitor +``` + +--- + +```text +Recovery Statistics +``` + +Все перечисленные расширения должны использовать композицию. + +Изменение публичного API Recovery не допускается. + +--- + +# Контракт потокобезопасности + +Build 060.19 не накладывает требований на многопоточную обработку. + +Recovery выполняет один запрос восстановления за один вызов. + +Параллельное выполнение нескольких Recovery Pipeline не входит в Scope настоящего Build. + +Вопрос конкурентного восстановления нескольких символов рассматривается в последующих Build. + +--- + +# Контракт детерминированности + +Recovery обязан удовлетворять следующему свойству. + +При одинаковых: + +- Recovery Request; +- ответе REST; +- состоянии TradeStreamConsistencyController; + +результат выполнения обязан быть идентичным. + +Данное свойство считается обязательным архитектурным инвариантом публичного API. + +--- + +# Контракт обратной совместимости + +После утверждения настоящего документа публичный API Recovery считается стабильным. + +Изменение следующих сущностей требует подготовки нового архитектурного решения (ADR): + +- сигнатуры `recover()`; +- структуры `TradeRecoveryRequest`; +- структуры `TradeRecoveryResult`; +- публичного контракта `TradeRecoveryNormalizer`. + +Допускается только расширение функциональности без нарушения существующих контрактов. + +--- + +# Матрица ответственности публичных компонентов + +| Компонент | Основная ответственность | Не отвечает за | +|-----------|--------------------------|----------------| +| TradeRecoveryController | Оркестрация полного процесса Recovery | Runtime, Retry, Deduplication, Ordering | +| TradeRecoveryRequest | Описание параметров восстановления | Вычисление диапазона восстановления | +| TradeRecoveryNormalizer | Нормализация порядка сделок | REST, Validation, Deduplication | +| TradeRecoveryResult | Представление результата Recovery | Хранение внутреннего состояния Recovery | + +--- + +# Матрица владения бизнес-правилами + +| Бизнес-правило | Владелец | +|----------------|----------| +| Получение исторических данных | DzengiTradesDocumentSource | +| Разбор REST-документа | REST Parser | +| Проверка корректности значений | Value Validation | +| Построение Canonical Trade | REST Mapper | +| Нормализация порядка | TradeRecoveryNormalizer | +| Проверка порядка сделок | TradeStreamConsistencyController | +| Дедупликация | TradeStreamConsistencyController | +| Формирование результата Recovery | TradeRecoveryController | + +--- + +# Итоги приложения C + +Настоящее приложение фиксирует публичный API Build 060.19 и определяет архитектурные границы взаимодействия Recovery с остальными подсистемами Dzentra. + +После утверждения настоящего приложения любые новые компоненты должны интегрироваться с Recovery исключительно через описанные контракты. + +Это обеспечивает: + +- минимальную связанность подсистем; +- стабильность публичного API; +- возможность безопасного расширения функциональности в последующих Build без нарушения существующей архитектуры. + +--- + +# Приложение D. Сценарии выполнения Recovery + +--- + +# Назначение приложения + +Настоящее приложение содержит нормативные сценарии выполнения Build 060.19. + +Если приложение B описывает последовательности взаимодействия компонентов, то настоящее приложение описывает ожидаемое поведение Recovery при различных входных данных. + +Каждый сценарий фиксирует: + +- исходное состояние системы; +- последовательность действий; +- ожидаемый результат; +- архитектурные инварианты. + +Данные сценарии являются эталоном для разработки интеграционных тестов Build 060.19. + +--- + +# Scenario 1 + +# Полное успешное восстановление + +## Исходное состояние + +В системе существует активный Canonical Trade Stream. + +Recovery получает корректный диапазон времени. + +REST API возвращает четыре сделки. + +```text +140 + +121 + +105 + +100 +``` + +--- + +## Последовательность выполнения + +1. RecoveryController получает TradeRecoveryRequest. + +2. Выполняется REST-запрос. + +3. REST Adapter строит четыре объекта Trade. + +4. TradeRecoveryNormalizer определяет обратный порядок. + +5. Последовательность нормализуется. + +```text +100 + +105 + +121 + +140 +``` + +6. Каждая сделка последовательно передаётся в TradeStreamConsistencyController. + +7. Все сделки успешно принимаются. + +8. Формируется TradeRecoveryResult. + +--- + +## Ожидаемый результат + +```text +Recovered Trades = 4 + +Skipped Duplicates = 0 +``` + +Recovery завершается успешно. + +--- + +## Проверяемые инварианты + +- нормализация выполнена; +- все сделки прошли через Stream Consistency; +- ни одна сделка не была изменена; +- результат сформирован. + +--- + +# Scenario 2 + +# В указанном диапазоне отсутствуют сделки + +## Исходное состояние + +REST возвращает пустую последовательность. + +```text +() +``` + +--- + +## Последовательность выполнения + +1. RecoveryController выполняет REST-запрос. + +2. Parser успешно обрабатывает ответ. + +3. Mapper формирует пустую коллекцию. + +4. Normalizer получает пустую последовательность. + +5. Stream Consistency не вызывается. + +6. Формируется TradeRecoveryResult. + +--- + +## Ожидаемый результат + +```text +Recovered Trades = 0 + +Skipped Duplicates = 0 +``` + +Recovery завершается успешно. + +--- + +## Проверяемые инварианты + +Пустой диапазон не считается ошибкой. + +--- + +# Scenario 3 + +# Все сделки являются корректными дубликатами + +## Исходное состояние + +REST возвращает: + +```text +100 + +105 + +121 +``` + +Все три сделки уже присутствуют в Canonical Trade Stream. + +--- + +## Последовательность выполнения + +Каждая сделка последовательно передаётся Controller. + +Controller возвращает: + +```python +None +``` + +для каждой сделки. + +После завершения обработки создаётся TradeRecoveryResult. + +--- + +## Ожидаемый результат + +```text +Recovered Trades = 0 + +Skipped Duplicates = 3 +``` + +--- + +## Проверяемые инварианты + +Recovery не считает корректные дубликаты ошибкой. + +--- + +# Scenario 4 + +# Частичное восстановление диапазона + +## Исходное состояние + +REST возвращает: + +```text +140 + +121 + +105 + +100 +``` + +Controller определяет: + +```text +100 -> duplicate + +105 -> accepted + +121 -> accepted + +140 -> accepted +``` + +--- + +## Последовательность выполнения + +Recovery нормализует поток. + +Каждая сделка проходит через Controller. + +Дубликат исключается. + +Три новые сделки публикуются. + +--- + +## Ожидаемый результат + +```text +Recovered Trades = 3 + +Skipped Duplicates = 1 +``` + +--- + +## Проверяемые инварианты + +Recovery не прекращает выполнение при обнаружении корректного дубликата. + +--- + +# Scenario 5 + +# REST возвращает ASCENDING последовательность + +## Исходное состояние + +REST возвращает: + +```text +100 + +105 + +121 + +140 +``` + +--- + +## Последовательность выполнения + +Normalizer анализирует направление. + +Последовательность уже соответствует Canonical Trade Stream. + +Изменения не выполняются. + +--- + +## Ожидаемый результат + +Все сделки передаются Controller в первоначальном порядке. + +--- + +## Проверяемые инварианты + +Normalizer не выполняет лишних преобразований. + +Последовательность сохраняется без изменений. + +--- + +# Scenario 6 + +# REST возвращает DESCENDING последовательность + +## Исходное состояние + +REST возвращает: + +```text +140 + +121 + +105 + +100 +``` + +--- + +## Последовательность выполнения + +Normalizer определяет обратное направление. + +Выполняется нормализация. + +Полученный поток передаётся Controller. + +--- + +## Ожидаемый результат + +Controller получает: + +```text +100 + +105 + +121 + +140 +``` + +--- + +## Проверяемые инварианты + +TradeStreamConsistencyController никогда не получает последовательность в обратном порядке. + +--- + +# Scenario 7 + +# REST возвращает смешанный порядок + +## Исходное состояние + +REST возвращает последовательность: + +```text +100 + +150 + +121 + +180 +``` + +Последовательность не является ни возрастающей, ни убывающей. + +--- + +## Последовательность выполнения + +1. RecoveryController получает результат REST Pipeline. + +2. Последовательность передаётся в TradeRecoveryNormalizer. + +3. Normalizer анализирует направление последовательности. + +4. Обнаруживается нарушение архитектурного инварианта. + +5. Генерируется: + +```text +TradeRecoveryNormalizationError +``` + +6. Выполнение Recovery прекращается. + +--- + +## Ожидаемый результат + +Recovery завершается ошибкой. + +TradeStreamConsistencyController не вызывается. + +TradeRecoveryResult не создаётся. + +--- + +## Проверяемые инварианты + +- смешанная последовательность не допускается; +- Recovery не пытается исправить поток; +- никакие сделки не публикуются. + +--- + +# Scenario 8 + +# Ошибка Parser + +## Исходное состояние + +REST возвращает документ с нарушением транспортного контракта. + +Parser обнаруживает ошибку структуры. + +--- + +## Последовательность выполнения + +1. RecoveryController инициирует получение истории. + +2. REST Adapter вызывает Parser. + +3. Parser генерирует исключение. + +4. Recovery прекращает выполнение. + +--- + +## Ожидаемый результат + +TradeRecoveryResult отсутствует. + +Исключение распространяется вызывающему компоненту. + +--- + +## Проверяемые инварианты + +Recovery не вмешивается в транспортную обработку данных. + +Parser остаётся единственным владельцем логики разбора REST-документа. + +--- + +# Scenario 9 + +# Ошибка Value Validation + +## Исходное состояние + +Parser успешно завершил обработку. + +Во время проверки значений обнаружено нарушение одного из инвариантов Canonical Trade. + +--- + +## Последовательность выполнения + +1. Parser завершает работу. + +2. Запускается Value Validation. + +3. Validation обнаруживает некорректное значение. + +4. Генерируется исключение Validation. + +5. Recovery прекращает выполнение. + +--- + +## Ожидаемый результат + +TradeRecoveryResult отсутствует. + +Никакие сделки не публикуются. + +--- + +## Проверяемые инварианты + +Recovery никогда не работает с объектами, не прошедшими Validation. + +--- + +# Scenario 10 + +# Ошибка Mapper + +## Исходное состояние + +Parser и Validation успешно завершены. + +Mapper не способен сформировать объект Canonical Trade. + +--- + +## Последовательность выполнения + +1. Recovery получает транспортные данные. + +2. Mapper генерирует исключение. + +3. Recovery немедленно завершает выполнение. + +--- + +## Ожидаемый результат + +TradeRecoveryResult отсутствует. + +TradeRecoveryNormalizer не вызывается. + +--- + +## Проверяемые инварианты + +Recovery никогда не получает частично сформированные объекты Trade. + +--- + +# Scenario 11 + +# Ошибка TradeStreamConsistencyController + +## Исходное состояние + +REST Pipeline завершился успешно. + +Normalizer успешно выполнил нормализацию. + +Во время обработки одной из сделок Controller обнаруживает нарушение инвариантов потока. + +--- + +## Последовательность выполнения + +1. Recovery начинает последовательную передачу сделок. + +2. Первые сделки успешно принимаются. + +3. Для очередной сделки Controller генерирует: + +```text +TradeOrderingError +``` + +или + +```text +TradeConsistencyError +``` + +4. Recovery прекращает выполнение. + +--- + +## Ожидаемый результат + +TradeRecoveryResult отсутствует. + +Исключение распространяется вызывающему компоненту. + +--- + +## Проверяемые инварианты + +Recovery не скрывает ошибки Stream Consistency. + +Recovery не пытается продолжить обработку. + +--- + +# Scenario 12 + +# Ошибка получения исторических данных + +## Исходное состояние + +Во время обращения к REST API возникает транспортная ошибка. + +Например: + +- Timeout; +- Network Error; +- HTTP Error. + +--- + +## Последовательность выполнения + +1. RecoveryController вызывает DzengiTradesDocumentSource. + +2. Источник генерирует исключение. + +3. Recovery немедленно завершает выполнение. + +--- + +## Ожидаемый результат + +TradeRecoveryResult отсутствует. + +Повторный запрос не выполняется. + +--- + +## Проверяемые инварианты + +Retry Policy отсутствует. + +Recovery выполняет только одну попытку получения истории. + +--- + +# Матрица покрытия сценариев + +| Сценарий | REST | Normalizer | Stream Consistency | Result | +|-----------|------|------------|--------------------|--------| +| Полное восстановление | ✓ | ✓ | ✓ | ✓ | +| Пустой диапазон | ✓ | ✓ | — | ✓ | +| Полные дубликаты | ✓ | ✓ | ✓ | ✓ | +| Частичное восстановление | ✓ | ✓ | ✓ | ✓ | +| ASCENDING | ✓ | ✓ | ✓ | ✓ | +| DESCENDING | ✓ | ✓ | ✓ | ✓ | +| Смешанный порядок | ✓ | ✗ | — | ✗ | +| Ошибка Parser | ✗ | — | — | ✗ | +| Ошибка Validation | ✗ | — | — | ✗ | +| Ошибка Mapper | ✗ | — | — | ✗ | +| Ошибка Consistency | ✓ | ✓ | ✗ | ✗ | +| Ошибка REST | ✗ | — | — | ✗ | + +--- + +# Использование сценариев + +Настоящие сценарии являются нормативной моделью поведения Recovery. + +Они используются как основа для: + +- Unit-тестирования; +- Integration-тестирования; +- проверки архитектурных инвариантов; +- регрессионного тестирования последующих Build серии 060. + +Каждый новый сценарий, добавляемый в Recovery, должен сопровождаться соответствующим дополнением настоящего приложения. + +--- + +# Итоги приложения D + +Настоящее приложение описывает эталонное поведение подсистемы Recovery в наиболее важных эксплуатационных ситуациях. + +Любая реализация Build 060.19 должна демонстрировать поведение, полностью соответствующее данным сценариям. + +Отклонение от описанных сценариев допускается только после внесения изменений в архитектурную спецификацию и утверждения нового Architecture Decision Record (ADR). + +--- + +# Приложение E. План реализации Build 060.19 + +--- + +# Назначение приложения + +Настоящее приложение определяет официальный план реализации Build 060.19. + +Основной документ фиксирует архитектуру Recovery. + +Настоящее приложение определяет последовательность разработки, которая обеспечивает: + +- минимальные изменения существующего кода; +- отсутствие нарушения архитектуры предыдущих Build; +- возможность тестирования каждого этапа отдельно; +- безопасную интеграцию Recovery в существующую систему. + +Все этапы должны выполняться строго последовательно. + +Переход к следующему этапу допускается только после полного завершения предыдущего. + +--- + +# Общая последовательность реализации + +```text +Этап 1 + +Recovery Domain Models + + │ + + ▼ + +Этап 2 + +Recovery Normalizer + + │ + + ▼ + +Этап 3 + +Recovery Controller + + │ + + ▼ + +Этап 4 + +Unit Tests + + │ + + ▼ + +Этап 5 + +Integration Tests + + │ + + ▼ + +Build Complete +``` + +--- + +# Этап 1 + +# Recovery Domain Models + +--- + +## Цель + +Создать все доменные модели Build 060.19. + +На данном этапе не реализуется никакая бизнес-логика. + +Создаются исключительно структуры данных. + +--- + +## Новые сущности + +Минимальный состав моделей: + +```text +TradeRecoveryRequest +``` + +--- + +```text +TradeRecoveryResult +``` + +--- + +```text +TradeRecoveryError +``` + +--- + +```text +TradeRecoveryRequestError +``` + +--- + +```text +TradeRecoveryNormalizationError +``` + +--- + +## Требования + +Все модели должны быть: + +- immutable; +- полностью типизированными; +- независимыми от Runtime; +- независимыми от REST. + +--- + +## Не допускается + +На данном этапе запрещается: + +- выполнять Recovery; +- обращаться к REST; +- создавать Controller; +- изменять существующий Pipeline. + +--- + +## Критерии завершения + +Этап считается завершённым, если: + +- все модели реализованы; +- модели проходят статическую проверку типов; +- модели покрыты Unit-тестами. + +--- + +# Этап 2 + +# TradeRecoveryNormalizer + +--- + +## Цель + +Реализовать алгоритм нормализации порядка сделок. + +--- + +## Функциональность + +Normalizer обязан: + +- принимать tuple[Trade]; +- определять направление последовательности; +- возвращать ASCENDING поток; +- обнаруживать смешанный порядок; +- генерировать TradeRecoveryNormalizationError. + +--- + +## Не допускается + +Normalizer не должен: + +- обращаться к REST; +- использовать Runtime; +- использовать Controller; +- выполнять дедупликацию; +- изменять Trade. + +--- + +## Минимальный набор тестов + +Проверяются сценарии: + +- пустой поток; +- одна сделка; +- ASCENDING; +- DESCENDING; +- смешанный порядок. + +--- + +## Критерии завершения + +Этап считается завершённым после успешного прохождения всех Unit-тестов Normalizer. + +--- + +# Этап 3 + +# TradeRecoveryController + +--- + +## Цель + +Реализовать центральный компонент Recovery. + +--- + +## Функциональность + +Controller обязан: + +- принять Recovery Request; +- вызвать существующий REST Pipeline; +- вызвать Normalizer; +- передать сделки в TradeStreamConsistencyController; +- сформировать TradeRecoveryResult. + +--- + +## Используемые компоненты + +Controller использует только существующие компоненты системы. + +Никаких новых транспортных механизмов не создаётся. + +--- + +## Не допускается + +Controller не должен: + +- вычислять диапазон времени; +- выполнять Retry; +- работать с Runtime; +- работать с WebSocket; +- выполнять дедупликацию. + +--- + +## Критерии завершения + +Controller успешно проходит все Unit-тесты. + +--- + +# Этап 4 + +# Unit Testing + +--- + +## Цель + +Проверить корректность каждого компонента Recovery изолированно. + +--- + +## Проверяемые компоненты + +```text +TradeRecoveryRequest +``` + +--- + +```text +TradeRecoveryResult +``` + +--- + +```text +TradeRecoveryNormalizer +``` + +--- + +```text +TradeRecoveryController +``` + +--- + +## Требования + +Unit-тесты не должны обращаться: + +- к REST API; +- к Runtime; +- к WebSocket. + +Все внешние зависимости заменяются Mock-объектами. + +--- + +## Критерии завершения + +Все Unit-тесты успешно проходят. + +Покрываются все архитектурные сценарии, определённые приложением D. + +--- + +# Этап 5 + +# Integration Testing + +--- + +## Цель + +Проверить корректность работы Recovery как единой подсистемы Acquisition Layer. + +На данном этапе тестируются взаимодействия между уже существующими компонентами системы. + +Проверяется не отдельная логика компонентов, а корректность всей цепочки обработки исторических сделок. + +--- + +## Интегрируемые компоненты + +В интеграционных тестах участвуют: + +```text +DzengiTradesDocumentSource +``` + +↓ + +```text +REST Adapter +``` + +↓ + +```text +TradeRecoveryNormalizer +``` + +↓ + +```text +TradeStreamConsistencyController +``` + +↓ + +```text +TradeRecoveryResult +``` + +--- + +## Проверяемые сценарии + +Минимальный набор интеграционных тестов включает: + +### Сценарий 1 + +Полное успешное восстановление. + +--- + +### Сценарий 2 + +Пустой диапазон. + +--- + +### Сценарий 3 + +Полное дублирование. + +--- + +### Сценарий 4 + +Частичное восстановление. + +--- + +### Сценарий 5 + +REST возвращает DESCENDING поток. + +--- + +### Сценарий 6 + +REST возвращает ASCENDING поток. + +--- + +### Сценарий 7 + +REST возвращает смешанную последовательность. + +--- + +### Сценарий 8 + +TradeStreamConsistencyController обнаруживает ошибку порядка. + +--- + +### Сценарий 9 + +REST Pipeline завершается исключением. + +--- + +## Критерии завершения + +Этап считается завершённым при выполнении следующих условий. + +- Все интеграционные тесты успешно проходят. +- Все архитектурные инварианты подтверждены. +- Ни один существующий Build не требует изменения своей архитектуры. +- Recovery полностью совместим с существующим Canonical Trade Stream. + +--- + +# Проверка соответствия архитектуре + +После завершения реализации выполняется архитектурная проверка Build. + +Проверяются следующие требования. + +--- + +## Проверка №1 + +Recovery использует существующий REST Pipeline. + +--- + +## Проверка №2 + +Recovery использует существующий TradeStreamConsistencyController. + +--- + +## Проверка №3 + +Recovery не содержит собственной реализации Deduplication. + +--- + +## Проверка №4 + +Recovery не содержит собственной реализации Ordering. + +--- + +## Проверка №5 + +Recovery не зависит от Runtime. + +--- + +## Проверка №6 + +Recovery не зависит от WebSocket Transport. + +--- + +## Проверка №7 + +Recovery не изменяет Canonical Trade Model. + +--- + +## Проверка №8 + +Recovery не изменяет Parser. + +--- + +## Проверка №9 + +Recovery не изменяет Mapper. + +--- + +## Проверка №10 + +Recovery не изменяет Value Validation. + +--- + +## Проверка №11 + +Recovery не нарушает архитектурные решения Build 060.18. + +--- + +# Definition of Done (DoD) + +Build 060.19 считается завершённым только при выполнении всех перечисленных условий. + +--- + +## Архитектура + +- Архитектура полностью соответствует настоящему документу. +- Все ADR соблюдены. +- Все архитектурные инварианты сохранены. + +--- + +## Код + +- Реализованы все модели Recovery. +- Реализован TradeRecoveryNormalizer. +- Реализован TradeRecoveryController. +- Не изменены существующие публичные контракты предыдущих Build без отдельного ADR. + +--- + +## Качество + +- Код проходит статическую проверку типов. +- Код соответствует принятому стилю проекта. +- Новые сущности имеют уникальные имена файлов. +- Не допущено дублирование существующей функциональности. + +--- + +## Тестирование + +- Все Unit-тесты проходят успешно. +- Все Integration-тесты проходят успешно. +- Покрыты все сценарии, описанные в Приложении D. + +--- + +## Документация + +Подготовлены и согласованы: + +- `build_060_19_architecture.md`; +- `build_060_19.md` (Engineering Report); +- комментарии в коде (при необходимости); +- обновлены внутренние ссылки на связанные Build. + +--- + +# Зависимости от последующих Build + +Build 060.19 сознательно ограничивает собственную область ответственности. + +Следующие задачи не входят в его Scope и будут реализованы позже. + +--- + +## Build 060.20 + +Trade Recovery Registry. + +Хранение и управление состоянием восстановления для нескольких символов. + +--- + +## Build 060.21 + +Recovery Acquisition Protocol. + +Определение протокола взаимодействия Runtime и Recovery. + +--- + +## Build 060.22 + +Recovery Acquisition Service. + +Высокоуровневый сервис восстановления, объединяющий Registry и Controller. + +--- + +## Build 060.23 + +Runtime Integration. + +Интеграция Recovery с жизненным циклом Runtime. + +--- + +## Build 060.24 + +Reconnect Integration. + +Автоматический запуск Recovery после восстановления WebSocket-соединения. + +--- + +## Build 060.25 + +Полная интеграция Trades Feed. + +Объединение потоковых и исторических сделок в единый производственный Pipeline. + +--- + +## Build 060.26 + +Финальная документация, регрессионный аудит и подтверждение соответствия всей серии Build 060. + +--- + +# Заключение + +Build 060.19 завершает следующий важный этап развития подсистемы Trades Feed. + +После его реализации система впервые получает архитектурно корректный механизм восстановления исторических сделок, построенный на уже существующих компонентах без нарушения их ответственности. + +Recovery не создаёт альтернативную модель обработки данных и не дублирует ранее реализованную функциональность. Вместо этого он объединяет: + +- существующий REST Pipeline; +- существующую Canonical Trade Model; +- существующий TradeStreamConsistencyController; + +в единый механизм восстановления истории. + +Это решение обеспечивает: + +- единый источник истины для проверки согласованности потока; +- минимальную связанность подсистем; +- высокую тестируемость; +- возможность безопасного масштабирования архитектуры в следующих Build. + +Настоящий документ завершает архитектурное проектирование Build 060.19 и является нормативной спецификацией, на основании которой должна выполняться реализация. + +--- + +# Приложение F. Архитектурная совместимость и дальнейшее развитие Build 060.19 + +--- + +# Назначение приложения + +Настоящее приложение определяет место Build 060.19 в архитектуре Dzentra серии Build 060. + +Оно фиксирует: + +- архитектурные зависимости; +- совместимость с предыдущими Build; +- влияние на существующие подсистемы; +- направления дальнейшего развития Recovery; +- гарантии обратной совместимости. + +Данное приложение служит контрольной точкой эволюции архитектуры Trades Feed. + +--- + +# Место Build 060.19 в серии Build 060 + +Build 060.19 является первым Build серии, реализующим механизм восстановления исторических сделок (Trade Recovery). + +Recovery не создаёт новую модель обработки данных. + +Recovery использует существующие архитектурные компоненты и объединяет их в единый процесс восстановления истории. + +Таким образом Build 060.19 является интеграционным Build, а не Build, изменяющим фундаментальную архитектуру Trades Feed. + +--- + +# Архитектурные зависимости + +Build 060.19 непосредственно зависит только от следующих компонентов. + +```text +Canonical Trade Model +``` + +↓ + +```text +REST Trade Pipeline +``` + +↓ + +```text +TradeStreamConsistencyController +``` + +Других обязательных архитектурных зависимостей Recovery не имеет. + +--- + +# Совместимость с предыдущими Build + +Build 060.19 полностью совместим со всеми ранее утверждёнными Build серии 060. + +| Build | Назначение | Совместимость | +|--------|------------|---------------| +| Build 060.1 | Canonical Trade Model | Полная | +| Build 060.2–060.8 | Базовые доменные модели и инфраструктура | Полная | +| Build 060.9 | REST Trade Pipeline | Полная | +| Build 060.10–060.16 | Парсинг, Validation, Mapping | Полная | +| Build 060.17 | Stream Consistency | Полная | +| Build 060.18 | REST Pipeline Integration | Полная | + +Ни один из перечисленных Build не требует модификации для внедрения Recovery. + +--- + +# Build, которые Recovery не изменяет + +Build 060.19 сознательно не изменяет архитектуру следующих компонентов. + +- Canonical Trade; +- REST Parser; +- Value Validation; +- REST Mapper; +- REST Adapter; +- TradeStreamConsistencyController; +- Transport Layer; +- Exchange Integration; +- Runtime. + +Все перечисленные подсистемы используются без изменения их ответственности. + +--- + +# Build, использующие Recovery + +После завершения Build 060.19 механизм Recovery становится частью архитектурного фундамента следующих Build. + +| Build | Использование Recovery | +|--------|------------------------| +| Build 060.20 | Registry хранения состояния Recovery | +| Build 060.21 | Acquisition Protocol | +| Build 060.22 | Acquisition Service | +| Build 060.23 | Runtime Integration | +| Build 060.24 | Reconnect Integration | +| Build 060.25 | Полная интеграция Trades Feed | +| Build 060.26 | Финальная документация и аудит | + +Таким образом Recovery становится базовым компонентом всей последующей серии Build. + +--- + +# Архитектурные гарантии + +Настоящий Build гарантирует следующие свойства. + +## Единственный источник проверки согласованности + +Проверка порядка сделок выполняется исключительно через: + +```text +TradeStreamConsistencyController +``` + +Recovery не реализует собственную проверку согласованности. + +--- + +## Единственная Canonical Model + +Во всей системе существует только одна модель сделки: + +```text +Trade +``` + +Recovery не создаёт альтернативных представлений Trade. + +--- + +## Единственный REST Pipeline + +Recovery использует исключительно существующий REST Pipeline. + +Создание второго Pipeline запрещается. + +--- + +## Единственный механизм нормализации + +Нормализация порядка выполняется только в пределах Recovery и исключительно перед передачей сделок в Stream Consistency. + +После прохождения данного этапа все последующие компоненты работают только с Canonical ASCENDING-потоком. + +--- + +# Архитектурные ограничения + +Build 060.19 сознательно не решает следующие задачи. + +- автоматический запуск Recovery; +- вычисление диапазона восстановления; +- управление несколькими символами; +- повторные попытки получения истории; +- планирование Recovery; +- мониторинг Recovery; +- сбор метрик; +- журналирование Recovery; +- хранение состояния Recovery. + +Все перечисленные задачи относятся к следующим Build серии 060. + +--- + +# Гарантии обратной совместимости + +После реализации Build 060.19 гарантируется следующее. + +- Все существующие Build продолжают работать без изменений. +- Существующие публичные API остаются совместимыми. +- Existing REST Pipeline продолжает использоваться без модификации. +- Canonical Trade Model остаётся неизменной. +- TradeStreamConsistencyController остаётся единственным владельцем правил согласованности Trade Stream. + +Таким образом внедрение Recovery не нарушает существующую архитектуру проекта. + +--- + +# Правила дальнейшего развития + +Любое развитие Recovery должно соответствовать следующим принципам. + +## Не допускается + +- создание альтернативного Trade Pipeline; +- создание второго Controller согласованности; +- создание альтернативной модели Trade; +- изменение ответственности RecoveryController; +- дублирование логики TradeStreamConsistencyController. + +--- + +## Допускается + +- добавление Retry Policy; +- добавление Metrics; +- добавление Telemetry; +- добавление Tracing; +- добавление Performance Monitoring; +- расширение RecoveryResult; +- добавление новых диагностических возможностей. + +Расширение допускается только при сохранении существующего публичного API. + +--- + +# Матрица архитектурной эволюции + +| Build | Статус | Роль | +|--------|--------|------| +| 060.1 | ✅ Завершён | Canonical Trade Model | +| 060.2–060.8 | ✅ Завершены | Базовая инфраструктура | +| 060.9 | ✅ Завершён | REST Trade Pipeline | +| 060.10–060.16 | ✅ Завершены | Parser / Validation / Mapper | +| 060.17 | ✅ Завершён | Stream Consistency | +| 060.18 | ✅ Завершён | REST Pipeline Integration | +| **060.19** | **🟢 Текущий Build** | **Trade Recovery** | +| 060.20 | ⏳ Следующий | Recovery Registry | +| 060.21 | ⏳ Планируется | Acquisition Protocol | +| 060.22 | ⏳ Планируется | Acquisition Service | +| 060.23 | ⏳ Планируется | Runtime Integration | +| 060.24 | ⏳ Планируется | Reconnect Integration | +| 060.25 | ⏳ Планируется | Trades Feed Integration | +| 060.26 | ⏳ Планируется | Финальный архитектурный аудит | + +--- + +# Архитектурная зрелость Build + +После завершения Build 060.19 подсистема Trades Feed получает завершённый фундамент для работы с историческими сделками. + +В результате серия Build 060 впервые включает полный жизненный цикл обработки Trade. + +```text +REST + +↓ + +Parser + +↓ + +Validation + +↓ + +Mapper + +↓ + +Canonical Trade + +↓ + +Recovery Normalizer + +↓ + +TradeStreamConsistencyController + +↓ + +Canonical Trade Stream +``` + +Следующие Build будут расширять данный Pipeline, не изменяя его фундаментальную архитектуру. + +--- + +# Итоги приложения F + +Настоящее приложение фиксирует архитектурное положение Build 060.19 в общей системе Dzentra и определяет правила его дальнейшей эволюции. + +После утверждения настоящего приложения Build 060.19 рассматривается как стабильный архитектурный фундамент для всех последующих работ по подсистеме Trades Feed. + +Любые изменения, затрагивающие описанные в данном приложении архитектурные гарантии, должны сопровождаться новым Architecture Decision Record (ADR) и обновлением архитектурной спецификации. \ No newline at end of file