07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
This commit is contained in:
@@ -3,10 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
||||
@@ -24,10 +27,42 @@ class MarketRuntimeContext:
|
||||
runtime_label: str | None
|
||||
last_market_status: str | None = None
|
||||
|
||||
# Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками.
|
||||
last_status_error_key: str | None = None
|
||||
last_stream_state: str | None = None
|
||||
last_stream_error_key: str | None = None
|
||||
last_rest_state: str | None = None
|
||||
last_rest_error_key: str | None = None
|
||||
|
||||
|
||||
class MarketDataRunner:
|
||||
_runtimes: dict[str, MarketRuntimeContext] = {}
|
||||
|
||||
# Global dedupe runtime-событий между start/stop runtime.
|
||||
_global_runtime_event_timestamps: dict[str, float] = {}
|
||||
|
||||
# Минимальный интервал повторного логирования одного runtime-состояния.
|
||||
# Состояние в UI может меняться чаще, но журнал не должен разрастаться.
|
||||
_runtime_log_cooldown_seconds = 300
|
||||
|
||||
@classmethod
|
||||
def _can_log_runtime_event(
|
||||
cls,
|
||||
event_key: str,
|
||||
) -> bool:
|
||||
now = time.monotonic()
|
||||
|
||||
last_logged_at = cls._global_runtime_event_timestamps.get(event_key)
|
||||
|
||||
if last_logged_at is not None:
|
||||
if (
|
||||
now - last_logged_at
|
||||
) < cls._runtime_log_cooldown_seconds:
|
||||
return False
|
||||
|
||||
cls._global_runtime_event_timestamps[event_key] = now
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def start(
|
||||
cls,
|
||||
@@ -41,7 +76,11 @@ class MarketDataRunner:
|
||||
) -> None:
|
||||
existing = cls._runtimes.get(runtime_key)
|
||||
|
||||
if existing is not None and existing.task is not None and not existing.task.done():
|
||||
if (
|
||||
existing is not None
|
||||
and existing.task is not None
|
||||
and not existing.task.done()
|
||||
):
|
||||
existing.symbol_provider = symbol_provider
|
||||
existing.interval_seconds = interval_seconds
|
||||
existing.screen = screen
|
||||
@@ -97,73 +136,143 @@ class MarketDataRunner:
|
||||
async def _worker(cls, context: MarketRuntimeContext) -> None:
|
||||
last_symbol: str | None = None
|
||||
|
||||
while True:
|
||||
symbol = context.symbol_provider()
|
||||
try:
|
||||
while True:
|
||||
symbol = context.symbol_provider()
|
||||
|
||||
if not symbol:
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
continue
|
||||
if not symbol:
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
continue
|
||||
|
||||
cache_symbol = cls._cache_symbol(symbol)
|
||||
ws_symbol = cls._ws_symbol(symbol)
|
||||
cache_symbol = cls._cache_symbol(symbol)
|
||||
ws_symbol = cls._ws_symbol(symbol)
|
||||
|
||||
if symbol != last_symbol:
|
||||
previous_symbol = last_symbol
|
||||
last_symbol = symbol
|
||||
if symbol != last_symbol:
|
||||
last_symbol = symbol
|
||||
|
||||
if not cls._is_cache_symbol_used_by_other_runtime(
|
||||
runtime_key=context.runtime_key,
|
||||
cache_symbol=cache_symbol,
|
||||
):
|
||||
MarketPriceCache.clear(cache_symbol)
|
||||
if not cls._is_cache_symbol_used_by_other_runtime(
|
||||
runtime_key=context.runtime_key,
|
||||
cache_symbol=cache_symbol,
|
||||
):
|
||||
MarketPriceCache.clear(cache_symbol)
|
||||
|
||||
market_status = ExchangeService().get_symbol_market_status(symbol)
|
||||
status_key = str(market_status.get("status") or "UNKNOWN")
|
||||
try:
|
||||
market_status = ExchangeService().get_symbol_market_status(symbol)
|
||||
|
||||
if not bool(market_status.get("is_open")):
|
||||
if context.last_market_status != status_key:
|
||||
context.last_market_status = status_key
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
cls._log_warning(
|
||||
except Exception as exc:
|
||||
error_key = f"{type(exc).__name__}:{str(exc)}"
|
||||
|
||||
if context.last_status_error_key != error_key:
|
||||
context.last_status_error_key = error_key
|
||||
|
||||
cls._log_warning(
|
||||
context,
|
||||
"market_status_unavailable",
|
||||
"Статус рынка временно недоступен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(limit=5),
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
continue
|
||||
|
||||
if context.last_status_error_key is not None:
|
||||
context.last_status_error_key = None
|
||||
|
||||
cls._log_info(
|
||||
context,
|
||||
"market_closed",
|
||||
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
|
||||
"market_status_restored",
|
||||
"Статус рынка снова доступен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"market_status": status_key,
|
||||
"message": market_status.get("message"),
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
continue
|
||||
status_key = str(market_status.get("status") or "UNKNOWN")
|
||||
|
||||
context.last_market_status = status_key
|
||||
if not bool(market_status.get("is_open")):
|
||||
if context.last_market_status != status_key:
|
||||
context.last_market_status = status_key
|
||||
|
||||
try:
|
||||
await cls._run_websocket(context, symbol)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
cls._log_warning(
|
||||
context,
|
||||
"market_stream_disconnected",
|
||||
"Поток рыночных данных отключён. Используется резервный REST-режим.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(limit=5),
|
||||
},
|
||||
)
|
||||
cls._log_warning(
|
||||
context,
|
||||
"market_closed",
|
||||
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"market_status": status_key,
|
||||
"message": market_status.get("message"),
|
||||
},
|
||||
)
|
||||
|
||||
await cls._rest_fallback_once(context, symbol)
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
continue
|
||||
|
||||
context.last_market_status = status_key
|
||||
|
||||
try:
|
||||
await cls._run_websocket(context, symbol)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
|
||||
|
||||
should_log_disconnected = (
|
||||
(
|
||||
context.last_stream_state != "DISCONNECTED"
|
||||
or context.last_stream_error_key != error_key
|
||||
)
|
||||
and cls._can_log_runtime_event(
|
||||
f"market_stream_disconnected:{symbol}:{error_key}"
|
||||
)
|
||||
)
|
||||
|
||||
context.last_stream_state = "DISCONNECTED"
|
||||
context.last_stream_error_key = error_key
|
||||
|
||||
if should_log_disconnected:
|
||||
|
||||
cls._log_warning(
|
||||
context,
|
||||
"market_stream_disconnected",
|
||||
"Live-поток рыночных данных отключён. Используется REST-режим.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(limit=5),
|
||||
},
|
||||
)
|
||||
|
||||
await cls._rest_fallback_once(context, symbol)
|
||||
await asyncio.sleep(context.interval_seconds)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# stop() уже пишет market_monitor_stopped.
|
||||
# Здесь не логируем, чтобы в журнале не было дубля остановки.
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def _run_websocket(cls, context: MarketRuntimeContext, symbol: str) -> None:
|
||||
async def _run_websocket(
|
||||
cls,
|
||||
context: MarketRuntimeContext,
|
||||
symbol: str,
|
||||
) -> None:
|
||||
cache_symbol = cls._cache_symbol(symbol)
|
||||
ws_symbol = cls._ws_symbol(symbol)
|
||||
|
||||
@@ -174,19 +283,33 @@ class MarketDataRunner:
|
||||
interval_seconds=context.interval_seconds,
|
||||
):
|
||||
if payload_count == 0:
|
||||
cls._log_info(
|
||||
context,
|
||||
"market_stream_connected",
|
||||
"Поток рыночных данных подключён.",
|
||||
{
|
||||
"requested_symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"payload_keys": list(payload.keys()),
|
||||
"payload_preview": cls._safe_payload_preview(payload),
|
||||
},
|
||||
should_log_connected = (
|
||||
context.last_stream_state != "CONNECTED"
|
||||
and cls._can_log_runtime_event(
|
||||
f"market_stream_connected:{symbol}"
|
||||
)
|
||||
)
|
||||
|
||||
context.last_stream_state = "CONNECTED"
|
||||
context.last_stream_error_key = None
|
||||
context.last_rest_state = None
|
||||
context.last_rest_error_key = None
|
||||
|
||||
if should_log_connected:
|
||||
|
||||
cls._log_info(
|
||||
context,
|
||||
"market_stream_connected",
|
||||
"Live-поток рыночных данных подключён.",
|
||||
{
|
||||
"requested_symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"payload_keys": list(payload.keys()),
|
||||
"payload_preview": cls._safe_payload_preview(payload),
|
||||
},
|
||||
)
|
||||
|
||||
payload_count += 1
|
||||
|
||||
current_symbol = context.symbol_provider()
|
||||
@@ -209,28 +332,78 @@ class MarketDataRunner:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _rest_fallback_once(cls, context: MarketRuntimeContext, symbol: str) -> None:
|
||||
async def _rest_fallback_once(
|
||||
cls,
|
||||
context: MarketRuntimeContext,
|
||||
symbol: str,
|
||||
) -> None:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
ExchangeService().refresh_market_snapshot_cache,
|
||||
symbol,
|
||||
runtime_key=context.runtime_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
cls._log_error(
|
||||
context,
|
||||
"market_stream_disconnected",
|
||||
"Поток рыночных данных отключён. Резервный REST-режим недоступен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(limit=5),
|
||||
},
|
||||
|
||||
should_log_rest_available = (
|
||||
context.last_rest_state != "AVAILABLE"
|
||||
and cls._can_log_runtime_event(
|
||||
f"market_rest_fallback_available:{symbol}"
|
||||
)
|
||||
)
|
||||
|
||||
context.last_rest_state = "AVAILABLE"
|
||||
context.last_rest_error_key = None
|
||||
|
||||
if should_log_rest_available:
|
||||
|
||||
cls._log_info(
|
||||
context,
|
||||
"market_rest_fallback_available",
|
||||
"REST-режим рыночных данных доступен. Live-поток пока недоступен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"cache_symbol": cls._cache_symbol(symbol),
|
||||
"ws_symbol": cls._ws_symbol(symbol),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
|
||||
|
||||
should_log_rest_unavailable = (
|
||||
(
|
||||
context.last_rest_state != "UNAVAILABLE"
|
||||
or context.last_rest_error_key != error_key
|
||||
)
|
||||
and cls._can_log_runtime_event(
|
||||
f"market_rest_fallback_unavailable:{symbol}:{error_key}"
|
||||
)
|
||||
)
|
||||
|
||||
context.last_rest_state = "UNAVAILABLE"
|
||||
context.last_rest_error_key = error_key
|
||||
|
||||
if should_log_rest_unavailable:
|
||||
|
||||
cls._log_error(
|
||||
context,
|
||||
"market_rest_fallback_unavailable",
|
||||
"Live-поток отключён. REST-режим рыночных данных недоступен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(limit=5),
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_cache_symbol_used_by_other_runtime(cls, *, runtime_key: str, cache_symbol: str) -> bool:
|
||||
def _is_cache_symbol_used_by_other_runtime(
|
||||
cls,
|
||||
*,
|
||||
runtime_key: str,
|
||||
cache_symbol: str,
|
||||
) -> bool:
|
||||
for key, context in cls._runtimes.items():
|
||||
if key == runtime_key:
|
||||
continue
|
||||
@@ -251,6 +424,7 @@ class MarketDataRunner:
|
||||
validation = ExchangeService().validate_symbol(symbol)
|
||||
if validation.is_valid:
|
||||
return validation.normalized_symbol
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -261,7 +435,11 @@ class MarketDataRunner:
|
||||
return cls._cache_symbol(symbol)
|
||||
|
||||
@classmethod
|
||||
def _extract_best_price(cls, payload: dict, side_key: str) -> float | None:
|
||||
def _extract_best_price(
|
||||
cls,
|
||||
payload: JsonDict,
|
||||
side_key: str,
|
||||
) -> float | None:
|
||||
data = payload
|
||||
|
||||
inner = payload.get("payload")
|
||||
@@ -276,53 +454,71 @@ class MarketDataRunner:
|
||||
first = values[0]
|
||||
|
||||
if isinstance(first, list) and first:
|
||||
return cls._safe_float(first[0])
|
||||
return cls._positive_float(first[0])
|
||||
|
||||
if isinstance(first, dict):
|
||||
return cls._safe_float(
|
||||
raw_price = (
|
||||
first.get("price")
|
||||
or first.get("p")
|
||||
or first.get("bidPrice")
|
||||
or first.get("askPrice")
|
||||
)
|
||||
|
||||
return cls._positive_float(raw_price)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _safe_float(cls, value: object) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
def _positive_float(cls, value: NumericLike | None) -> float | None:
|
||||
number = safe_float(value)
|
||||
|
||||
if number is None or number <= 0:
|
||||
return None
|
||||
|
||||
return number if number > 0 else None
|
||||
return number
|
||||
|
||||
@classmethod
|
||||
def _safe_payload_preview(cls, payload: dict) -> dict:
|
||||
preview: dict = {}
|
||||
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
|
||||
preview: JsonDict = {}
|
||||
|
||||
for key, value in payload.items():
|
||||
if key in {"bids", "asks"} and isinstance(value, list):
|
||||
preview[key] = value[:2]
|
||||
|
||||
elif key == "payload" and isinstance(value, dict):
|
||||
preview[key] = {
|
||||
inner_key: inner_value[:2]
|
||||
if inner_key in {"bids", "asks"} and isinstance(inner_value, list)
|
||||
else inner_value
|
||||
for inner_key, inner_value in value.items()
|
||||
}
|
||||
inner_preview: JsonDict = {}
|
||||
|
||||
for inner_key, inner_value in value.items():
|
||||
if (
|
||||
inner_key in {"bids", "asks"}
|
||||
and isinstance(inner_value, list)
|
||||
):
|
||||
inner_preview[inner_key] = inner_value[:2]
|
||||
else:
|
||||
inner_preview[inner_key] = inner_value
|
||||
|
||||
preview[key] = inner_preview
|
||||
|
||||
else:
|
||||
preview[key] = value
|
||||
|
||||
return preview
|
||||
|
||||
@classmethod
|
||||
def _message(cls, context: MarketRuntimeContext, message: str) -> str:
|
||||
def _message(
|
||||
cls,
|
||||
context: MarketRuntimeContext,
|
||||
message: str,
|
||||
) -> str:
|
||||
return message
|
||||
|
||||
@classmethod
|
||||
def _payload(cls, context: MarketRuntimeContext, payload: dict | None = None) -> dict:
|
||||
result = dict(payload or {})
|
||||
def _payload(
|
||||
cls,
|
||||
context: MarketRuntimeContext,
|
||||
payload: JsonDict | None = None,
|
||||
) -> JsonDict:
|
||||
result: JsonDict = dict(payload or {})
|
||||
result.setdefault("runtime_key", context.runtime_key)
|
||||
|
||||
if context.screen:
|
||||
@@ -339,7 +535,7 @@ class MarketDataRunner:
|
||||
context: MarketRuntimeContext,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
payload: JsonDict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if context.screen:
|
||||
@@ -352,7 +548,12 @@ class MarketDataRunner:
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_info(event_type, cls._message(context, message), cls._payload(context, payload))
|
||||
JournalService().log_info(
|
||||
event_type,
|
||||
cls._message(context, message),
|
||||
cls._payload(context, payload),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -362,7 +563,7 @@ class MarketDataRunner:
|
||||
context: MarketRuntimeContext,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
payload: JsonDict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if context.screen:
|
||||
@@ -375,7 +576,12 @@ class MarketDataRunner:
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_warning(event_type, cls._message(context, message), cls._payload(context, payload))
|
||||
JournalService().log_warning(
|
||||
event_type,
|
||||
cls._message(context, message),
|
||||
cls._payload(context, payload),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -385,9 +591,16 @@ class MarketDataRunner:
|
||||
context: MarketRuntimeContext,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
payload: JsonDict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
error_type = None
|
||||
raw_error = None
|
||||
|
||||
if payload:
|
||||
error_type = payload.get("error_type")
|
||||
raw_error = payload.get("error")
|
||||
|
||||
if context.screen:
|
||||
JournalService().log_ui_error(
|
||||
event_type=event_type,
|
||||
@@ -395,11 +608,16 @@ class MarketDataRunner:
|
||||
screen=context.screen,
|
||||
action=context.action,
|
||||
payload=cls._payload(context, payload),
|
||||
error_type=(payload or {}).get("error_type"),
|
||||
raw_error=(payload or {}).get("error"),
|
||||
error_type=str(error_type) if error_type is not None else None,
|
||||
raw_error=str(raw_error) if raw_error is not None else None,
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_error(event_type, cls._message(context, message), cls._payload(context, payload))
|
||||
JournalService().log_error(
|
||||
event_type,
|
||||
cls._message(context, message),
|
||||
cls._payload(context, payload),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
@@ -7,25 +7,39 @@ from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
def _format_timestamp(raw_timestamp: object) -> str | None:
|
||||
if raw_timestamp is None:
|
||||
# безопасно форматирует timestamp биржи в локальное время
|
||||
def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None:
|
||||
timestamp = safe_float(raw_timestamp)
|
||||
|
||||
if timestamp is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
dt_utc = datetime.fromtimestamp(int(raw_timestamp) / 1000, tz=ZoneInfo("UTC"))
|
||||
return dt_utc.astimezone(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
dt_utc = datetime.fromtimestamp(
|
||||
int(timestamp) / 1000,
|
||||
tz=ZoneInfo("UTC"),
|
||||
)
|
||||
|
||||
return dt_utc.astimezone(
|
||||
ZoneInfo(settings.tz),
|
||||
).strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_market_event(payload: dict) -> dict | None:
|
||||
# достаёт внутренний payload из websocket-сообщения
|
||||
def _payload_from_message(payload: JsonDict) -> JsonDict | None:
|
||||
event = payload.get("Payload") or payload.get("payload")
|
||||
|
||||
if isinstance(event, dict) and "Payload" in event:
|
||||
@@ -34,16 +48,73 @@ def _extract_market_event(payload: dict) -> dict | None:
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
|
||||
symbol = event.get("symbolName") or event.get("symbol")
|
||||
bid = event.get("bid")
|
||||
ask = event.get("ofr") or event.get("ask")
|
||||
timestamp = event.get("timestamp")
|
||||
return dict(event)
|
||||
|
||||
if symbol is None or bid is None or ask is None:
|
||||
|
||||
# извлекает best bid / best ask из формата depth
|
||||
def _extract_depth_prices(event: JsonDict) -> tuple[float | None, float | None]:
|
||||
bids = event.get("bids")
|
||||
asks = event.get("asks")
|
||||
|
||||
bid_price = _extract_first_price(bids)
|
||||
ask_price = _extract_first_price(asks)
|
||||
|
||||
return bid_price, ask_price
|
||||
|
||||
|
||||
# извлекает первую цену из списка стакана
|
||||
def _extract_first_price(value: object) -> float | None:
|
||||
if not isinstance(value, list) or not value:
|
||||
return None
|
||||
|
||||
first = value[0]
|
||||
|
||||
if isinstance(first, list) and first:
|
||||
return _positive_float(first[0])
|
||||
|
||||
if isinstance(first, dict):
|
||||
return _positive_float(
|
||||
first.get("price")
|
||||
or first.get("p")
|
||||
or first.get("bidPrice")
|
||||
or first.get("askPrice")
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# безопасно приводит число к float и отсекает нулевые/отрицательные цены
|
||||
def _positive_float(value: NumericLike | None) -> float | None:
|
||||
number = safe_float(value)
|
||||
|
||||
if number is None or number <= 0:
|
||||
return None
|
||||
|
||||
return number
|
||||
|
||||
|
||||
# нормализует websocket-сообщение рынка в единый формат для MarketPriceCache
|
||||
def _extract_market_event(payload: JsonDict) -> JsonDict | None:
|
||||
event = _payload_from_message(payload)
|
||||
|
||||
if event is None:
|
||||
return None
|
||||
|
||||
symbol = (
|
||||
event.get("symbolName")
|
||||
or event.get("symbol")
|
||||
or payload.get("symbol")
|
||||
)
|
||||
|
||||
bid_price = _positive_float(event.get("bid"))
|
||||
ask_price = _positive_float(event.get("ofr") or event.get("ask"))
|
||||
|
||||
if bid_price is None or ask_price is None:
|
||||
bid_price, ask_price = _extract_depth_prices(event)
|
||||
|
||||
if symbol is None or bid_price is None or ask_price is None:
|
||||
return None
|
||||
|
||||
bid_price = float(bid)
|
||||
ask_price = float(ask)
|
||||
price = (bid_price + ask_price) / 2
|
||||
|
||||
return {
|
||||
@@ -51,10 +122,11 @@ def _extract_market_event(payload: dict) -> dict | None:
|
||||
"price": price,
|
||||
"bid_price": bid_price,
|
||||
"ask_price": ask_price,
|
||||
"updated_at": _format_timestamp(timestamp),
|
||||
"updated_at": _format_timestamp(event.get("timestamp")),
|
||||
}
|
||||
|
||||
|
||||
# запускает постоянный websocket-поток рынка и обновляет MarketPriceCache
|
||||
async def start_market_stream() -> None:
|
||||
settings = load_settings()
|
||||
journal = JournalService()
|
||||
@@ -86,16 +158,30 @@ async def start_market_stream() -> None:
|
||||
if event is None:
|
||||
continue
|
||||
|
||||
price = safe_float(event.get("price"))
|
||||
bid_price = safe_float(event.get("bid_price"))
|
||||
ask_price = safe_float(event.get("ask_price"))
|
||||
|
||||
if price is None or bid_price is None or ask_price is None:
|
||||
continue
|
||||
|
||||
MarketPriceCache.set_price(
|
||||
symbol=symbol,
|
||||
price=event["price"],
|
||||
bid_price=event["bid_price"],
|
||||
ask_price=event["ask_price"],
|
||||
updated_at=event["updated_at"],
|
||||
price=price,
|
||||
bid_price=bid_price,
|
||||
ask_price=ask_price,
|
||||
updated_at=(
|
||||
str(event.get("updated_at"))
|
||||
if event.get("updated_at") is not None
|
||||
else None
|
||||
),
|
||||
source="ws_market_stream",
|
||||
runtime_key="default",
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
try:
|
||||
journal.log_warning(
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# Состояние публичного API биржи.
|
||||
@dataclass(slots=True)
|
||||
class ExchangeHealth:
|
||||
ok: bool
|
||||
@@ -12,6 +13,19 @@ class ExchangeHealth:
|
||||
message: str
|
||||
|
||||
|
||||
# Состояние синхронизации времени сервера и биржи.
|
||||
@dataclass(slots=True)
|
||||
class TimeSyncStatus:
|
||||
ok: bool
|
||||
local_time: str
|
||||
exchange_time: str | None
|
||||
drift_seconds: float | None
|
||||
hostname: str
|
||||
local_ip: str | None
|
||||
message: str
|
||||
|
||||
|
||||
# Текущая рыночная цена инструмента.
|
||||
@dataclass(slots=True)
|
||||
class TickerPrice:
|
||||
symbol: str
|
||||
@@ -20,18 +34,26 @@ class TickerPrice:
|
||||
updated_at: str
|
||||
|
||||
|
||||
# Snapshot цен для execution layer.
|
||||
@dataclass(slots=True)
|
||||
class ExecutionPriceSnapshot:
|
||||
symbol: str
|
||||
|
||||
last_price: float
|
||||
bid_price: float
|
||||
ask_price: float
|
||||
|
||||
updated_at: str
|
||||
source: str
|
||||
|
||||
is_fresh: bool
|
||||
|
||||
age_seconds: float | None = None
|
||||
freshness_status: str = "UNKNOWN"
|
||||
spread_percent: float | None = None
|
||||
|
||||
|
||||
# Баланс актива аккаунта.
|
||||
@dataclass(slots=True)
|
||||
class BalanceSummary:
|
||||
currency: str
|
||||
@@ -40,41 +62,54 @@ class BalanceSummary:
|
||||
source: str
|
||||
|
||||
|
||||
# Информация о торговом инструменте биржи.
|
||||
@dataclass(slots=True)
|
||||
class ExchangeSymbol:
|
||||
symbol: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
base_asset: str
|
||||
quote_asset: str
|
||||
|
||||
market_modes: list[str]
|
||||
market_type: str
|
||||
|
||||
tick_size: float | None
|
||||
step_size: float | None
|
||||
min_qty: float | None
|
||||
min_notional: float | None
|
||||
|
||||
|
||||
# Результат проверки символа.
|
||||
@dataclass(slots=True)
|
||||
class SymbolValidationResult:
|
||||
requested_symbol: str
|
||||
normalized_symbol: str
|
||||
|
||||
is_valid: bool
|
||||
message: str
|
||||
|
||||
symbol_info: ExchangeSymbol | None
|
||||
|
||||
|
||||
# Состояние приватного API аккаунта.
|
||||
@dataclass(slots=True)
|
||||
class PrivateAuthHealth:
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
# =========================================================
|
||||
# MARKET ANALYSIS / KLINES
|
||||
# =========================================================
|
||||
# Runtime-статус рынка инструмента.
|
||||
@dataclass(slots=True)
|
||||
class ExchangeMarketStatus:
|
||||
symbol: str
|
||||
is_open: bool
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
# Одна свеча OHLCV.
|
||||
@dataclass(slots=True)
|
||||
class Kline:
|
||||
symbol: str
|
||||
@@ -86,12 +121,12 @@ class Kline:
|
||||
high_price: float
|
||||
low_price: float
|
||||
close_price: float
|
||||
|
||||
volume: float
|
||||
|
||||
source: str
|
||||
|
||||
|
||||
# Пакет свечей.
|
||||
@dataclass(slots=True)
|
||||
class KlineBatch:
|
||||
symbol: str
|
||||
|
||||
296
app/src/integrations/exchange/runtime_ui.py
Normal file
296
app/src/integrations/exchange/runtime_ui.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# app/src/integrations/exchange/runtime_ui.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.integrations.exchange.models import TimeSyncStatus
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
)
|
||||
|
||||
|
||||
def format_drift_seconds(value: float | int | None) -> str:
|
||||
number = safe_float(value)
|
||||
|
||||
if number is None:
|
||||
return "—"
|
||||
|
||||
sign = "-" if number < 0 else "+"
|
||||
total_seconds = abs(int(round(number)))
|
||||
|
||||
minutes = total_seconds // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
if minutes > 0:
|
||||
return f"{sign} {minutes} мин. {seconds} сек."
|
||||
|
||||
return f"{sign} {seconds} сек."
|
||||
|
||||
|
||||
def build_time_sync_details(sync: TimeSyncStatus) -> str:
|
||||
lines = [
|
||||
"Проверь настройки времени на:",
|
||||
f"Сервер: {sync.hostname}",
|
||||
]
|
||||
|
||||
if sync.local_ip:
|
||||
lines.append(f"IP: {sync.local_ip}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Время сервера: {sync.local_time}")
|
||||
|
||||
if sync.exchange_time:
|
||||
lines.append(f"Время биржи: {sync.exchange_time}")
|
||||
|
||||
lines.append(f"Расхождение: {format_drift_seconds(sync.drift_seconds)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_time_sync_status_from_service() -> TimeSyncStatus:
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
|
||||
return ExchangeService().get_time_sync_status()
|
||||
|
||||
|
||||
def build_time_sync_details_from_service() -> str:
|
||||
return build_time_sync_details(get_time_sync_status_from_service())
|
||||
|
||||
|
||||
def build_exchange_error_ui_parts(
|
||||
exc: Exception,
|
||||
) -> tuple[ExchangeRuntimeStatus, str, str]:
|
||||
status = build_exchange_error_status(exc)
|
||||
|
||||
if status.code == ExchangeStatusCode.AUTH_ERROR:
|
||||
return (
|
||||
status,
|
||||
"⛔️ Ошибка доступа к аккаунту",
|
||||
"Проверь API-ключ, Secret Key, IP whitelist и права доступа.",
|
||||
)
|
||||
|
||||
if status.code == ExchangeStatusCode.TIME_ERROR:
|
||||
return (
|
||||
status,
|
||||
"⛔️ Ошибка времени биржи",
|
||||
build_time_sync_details_from_service(),
|
||||
)
|
||||
|
||||
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
|
||||
return status, "⛔️ Биржа недоступна", ""
|
||||
|
||||
return status, status.ui_line, status.message
|
||||
|
||||
|
||||
def build_runtime_exchange_status(exc: Exception) -> dict[str, object]:
|
||||
status = build_exchange_error_status(exc)
|
||||
|
||||
if status.code == ExchangeStatusCode.TIME_ERROR:
|
||||
sync = get_time_sync_status_from_service()
|
||||
|
||||
return {
|
||||
"code": status.code.value,
|
||||
"title": "Ошибка времени биржи",
|
||||
"ui_line": "⛔️ Ошибка времени биржи",
|
||||
"details": {
|
||||
"hostname": sync.hostname,
|
||||
"local_ip": sync.local_ip,
|
||||
"local_time": sync.local_time,
|
||||
"exchange_time": sync.exchange_time,
|
||||
"drift_seconds": sync.drift_seconds,
|
||||
},
|
||||
"reason": status.reason,
|
||||
"raw_error": status.raw_error,
|
||||
}
|
||||
|
||||
_, title, details = build_exchange_error_ui_parts(exc)
|
||||
|
||||
return {
|
||||
"code": status.code.value,
|
||||
"title": title.replace("⛔️ ", "").strip(),
|
||||
"ui_line": title,
|
||||
"details": details,
|
||||
"reason": status.reason,
|
||||
"raw_error": status.raw_error,
|
||||
}
|
||||
|
||||
|
||||
def build_runtime_exchange_alerts(
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
exc: Exception | None = None,
|
||||
include_exchange_unavailable: bool = True,
|
||||
) -> list[dict[str, object]]:
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
|
||||
alerts: list[dict[str, object]] = []
|
||||
service = ExchangeService()
|
||||
|
||||
def add_alert(alert: dict[str, object] | None) -> None:
|
||||
if not alert:
|
||||
return
|
||||
|
||||
code = str(alert.get("code") or "")
|
||||
reason = str(alert.get("reason") or "")
|
||||
|
||||
for existing in alerts:
|
||||
if (
|
||||
str(existing.get("code") or "") == code
|
||||
and str(existing.get("reason") or "") == reason
|
||||
):
|
||||
return
|
||||
|
||||
alerts.append(alert)
|
||||
|
||||
if exc is not None:
|
||||
add_alert(build_runtime_exchange_status(exc))
|
||||
|
||||
try:
|
||||
runtime_status = service.get_symbol_runtime_status(symbol)
|
||||
except Exception as status_exc:
|
||||
if include_exchange_unavailable:
|
||||
add_alert(build_runtime_exchange_status(status_exc))
|
||||
else:
|
||||
if include_exchange_unavailable and not runtime_status.is_available:
|
||||
add_alert(
|
||||
build_runtime_exchange_status(
|
||||
Exception(runtime_status.raw_error or runtime_status.message)
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
time_sync = service.get_time_sync_status()
|
||||
except Exception as time_exc:
|
||||
add_alert(build_runtime_exchange_status(time_exc))
|
||||
else:
|
||||
if not time_sync.ok:
|
||||
add_alert(
|
||||
{
|
||||
"code": ExchangeStatusCode.TIME_ERROR.value,
|
||||
"title": "Ошибка времени биржи",
|
||||
"ui_line": "⛔️ Ошибка времени биржи",
|
||||
"details": {
|
||||
"hostname": time_sync.hostname,
|
||||
"local_ip": time_sync.local_ip,
|
||||
"local_time": time_sync.local_time,
|
||||
"exchange_time": time_sync.exchange_time,
|
||||
"drift_seconds": time_sync.drift_seconds,
|
||||
},
|
||||
"reason": "time_error",
|
||||
"raw_error": time_sync.message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
private_auth_health = service.get_private_auth_health()
|
||||
except Exception as auth_exc:
|
||||
add_alert(build_runtime_exchange_status(auth_exc))
|
||||
else:
|
||||
if not private_auth_health.ok:
|
||||
add_alert(
|
||||
build_runtime_exchange_status(
|
||||
Exception(private_auth_health.message)
|
||||
)
|
||||
)
|
||||
|
||||
priority = {
|
||||
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value: 10,
|
||||
ExchangeStatusCode.TIME_ERROR.value: 20,
|
||||
ExchangeStatusCode.AUTH_ERROR.value: 30,
|
||||
}
|
||||
|
||||
alerts.sort(
|
||||
key=lambda alert: priority.get(
|
||||
str(alert.get("code") or ""),
|
||||
999,
|
||||
)
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def format_runtime_exchange_alert(alert: dict[str, object]) -> str:
|
||||
title = str(
|
||||
alert.get("ui_line")
|
||||
or alert.get("title")
|
||||
or "⛔️ Ошибка биржи"
|
||||
).strip()
|
||||
|
||||
details = alert.get("details")
|
||||
code = str(alert.get("code") or "")
|
||||
|
||||
lines = [title]
|
||||
|
||||
if isinstance(details, dict):
|
||||
lines.append("Проверь настройки времени на:")
|
||||
|
||||
hostname = details.get("hostname")
|
||||
local_ip = details.get("local_ip")
|
||||
local_time = details.get("local_time")
|
||||
exchange_time = details.get("exchange_time")
|
||||
drift_seconds = details.get("drift_seconds")
|
||||
|
||||
if hostname:
|
||||
lines.append(f"• Сервер: {hostname}")
|
||||
|
||||
if local_ip:
|
||||
lines.append(f"• IP: {local_ip}")
|
||||
|
||||
if local_time:
|
||||
lines.append(f"• Время сервера: {local_time}")
|
||||
|
||||
if exchange_time:
|
||||
lines.append(f"• Время биржи: {exchange_time}")
|
||||
|
||||
lines.append(f"• Расхождение: {format_drift_seconds(drift_seconds)}")
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
if code == ExchangeStatusCode.AUTH_ERROR.value:
|
||||
lines.extend([
|
||||
"Проверь:",
|
||||
"• API-ключ, Secret Key",
|
||||
"• IP whitelist и права доступа",
|
||||
])
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
details_text = str(details or "").strip()
|
||||
|
||||
if details_text:
|
||||
lines.append(details_text)
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def format_runtime_exchange_alerts(alerts: list[dict[str, object]]) -> str:
|
||||
return "\n\n".join(
|
||||
block
|
||||
for block in (
|
||||
format_runtime_exchange_alert(alert)
|
||||
for alert in alerts
|
||||
)
|
||||
if block.strip()
|
||||
).strip()
|
||||
|
||||
|
||||
def build_runtime_exchange_alert_lines(
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
include_exchange_unavailable: bool = True,
|
||||
) -> list[str]:
|
||||
alerts = build_runtime_exchange_alerts(
|
||||
symbol=symbol,
|
||||
include_exchange_unavailable=include_exchange_unavailable,
|
||||
)
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
for alert in alerts:
|
||||
line = str(alert.get("ui_line") or alert.get("title") or "").strip()
|
||||
|
||||
if line and line not in lines:
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
File diff suppressed because it is too large
Load Diff
288
app/src/integrations/exchange/status.py
Normal file
288
app/src/integrations/exchange/status.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# app/src/integrations/exchange/status.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from src.integrations.exchange.exceptions import (
|
||||
ExchangeConnectionError,
|
||||
ExchangeResponseError,
|
||||
)
|
||||
|
||||
|
||||
class ExchangeStatusCode(StrEnum):
|
||||
OPEN = "OPEN"
|
||||
BREAK = "BREAK"
|
||||
EXCHANGE_UNAVAILABLE = "EXCHANGE_UNAVAILABLE"
|
||||
AUTH_ERROR = "AUTH_ERROR"
|
||||
TIME_ERROR = "TIME_ERROR"
|
||||
INVALID_SYMBOL = "INVALID_SYMBOL"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
# app/src/integrations/exchange/status.py
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ExchangeRuntimeStatus:
|
||||
code: ExchangeStatusCode
|
||||
is_open: bool
|
||||
is_available: bool
|
||||
is_auth_ok: bool
|
||||
title: str
|
||||
message: str
|
||||
ui_line: str
|
||||
reason: str
|
||||
symbol: str | None = None
|
||||
raw_status: str | None = None
|
||||
raw_error: str | None = None
|
||||
|
||||
# вернуть статус в dict для старого UI-кода на время миграции
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"code": self.code.value,
|
||||
"status": self.code.value,
|
||||
"symbol": self.symbol,
|
||||
"is_open": self.is_open,
|
||||
"is_available": self.is_available,
|
||||
"is_auth_ok": self.is_auth_ok,
|
||||
"title": self.title,
|
||||
"message": self.message,
|
||||
"ui_line": self.ui_line,
|
||||
"reason": self.reason,
|
||||
"raw_status": self.raw_status,
|
||||
"raw_error": self.raw_error,
|
||||
}
|
||||
|
||||
|
||||
# собрать статус mock-режима
|
||||
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.OPEN,
|
||||
is_open=True,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Mock exchange",
|
||||
message="Mock market is open.",
|
||||
ui_line="🟢 Mock биржа",
|
||||
reason="mock_exchange",
|
||||
symbol=symbol,
|
||||
raw_status="OPEN",
|
||||
)
|
||||
|
||||
|
||||
# собрать статус ошибки авторизации аккаунта
|
||||
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
|
||||
return build_exchange_error_status(exc)
|
||||
|
||||
|
||||
OPEN_STATUSES = {
|
||||
"TRADING",
|
||||
"OPEN",
|
||||
"ACTIVE",
|
||||
"ENABLED",
|
||||
"ONLINE",
|
||||
}
|
||||
|
||||
BREAK_STATUSES = {
|
||||
"BREAK",
|
||||
"CLOSED",
|
||||
"HALT",
|
||||
"HALTED",
|
||||
"PAUSED",
|
||||
"SUSPENDED",
|
||||
"DISABLED",
|
||||
"SETTLING",
|
||||
"POST_ONLY",
|
||||
}
|
||||
|
||||
|
||||
# определить единый runtime-статус по статусу инструмента биржи
|
||||
def build_market_status_from_symbol_status(
|
||||
*,
|
||||
raw_status: str | None,
|
||||
symbol: str,
|
||||
) -> ExchangeRuntimeStatus:
|
||||
normalized_status = str(raw_status or "").strip().upper()
|
||||
|
||||
if normalized_status in OPEN_STATUSES:
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.OPEN,
|
||||
is_open=True,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Биржа доступна",
|
||||
message="Рынок открыт.",
|
||||
ui_line="🟢 Биржа доступна",
|
||||
reason="market_open",
|
||||
raw_status=normalized_status,
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
if normalized_status in BREAK_STATUSES:
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.BREAK,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Перерыв на бирже",
|
||||
message="Торги по инструменту временно остановлены.",
|
||||
ui_line="⏸️ Перерыв на бирже",
|
||||
reason="market_break",
|
||||
raw_status=normalized_status,
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.UNKNOWN,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Статус рынка не определён",
|
||||
message=f"Статус инструмента {symbol} не определён.",
|
||||
ui_line="⏸️ Перерыв на бирже",
|
||||
reason="market_status_unknown",
|
||||
raw_status=normalized_status or None,
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
|
||||
# собрать единый статус для неверного торгового инструмента
|
||||
def build_invalid_symbol_status(
|
||||
*,
|
||||
symbol: str,
|
||||
message: str,
|
||||
) -> ExchangeRuntimeStatus:
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.INVALID_SYMBOL,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Инструмент недоступен",
|
||||
message=message or f"Инструмент {symbol} недоступен.",
|
||||
ui_line="⛔️ Инструмент недоступен",
|
||||
reason="invalid_symbol",
|
||||
raw_status="INVALID_SYMBOL",
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
|
||||
# собрать единый статус по ошибке exchange/API
|
||||
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
|
||||
error_type = classify_exchange_error(exc)
|
||||
raw_error = str(exc)
|
||||
|
||||
if error_type == "auth":
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.AUTH_ERROR,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=False,
|
||||
title="Ошибка доступа к аккаунту",
|
||||
message="Ошибка доступа к аккаунту.",
|
||||
ui_line="⛔️ Ошибка доступа к аккаунту",
|
||||
reason="auth_error",
|
||||
raw_status="AUTH_ERROR",
|
||||
raw_error=raw_error,
|
||||
)
|
||||
|
||||
if error_type == "time":
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.TIME_ERROR,
|
||||
is_open=False,
|
||||
is_available=False,
|
||||
is_auth_ok=True,
|
||||
title="Ошибка времени",
|
||||
message="Проверь синхронизацию времени.",
|
||||
ui_line="⛔️ Ошибка времени биржи",
|
||||
reason="time_error",
|
||||
raw_status="TIME_ERROR",
|
||||
raw_error=raw_error,
|
||||
)
|
||||
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.EXCHANGE_UNAVAILABLE,
|
||||
is_open=False,
|
||||
is_available=False,
|
||||
is_auth_ok=True,
|
||||
title="Биржа недоступна",
|
||||
message="Не удалось получить данные с биржи.",
|
||||
ui_line="⛔️ Биржа недоступна",
|
||||
reason="exchange_unavailable",
|
||||
raw_status="EXCHANGE_UNAVAILABLE",
|
||||
raw_error=raw_error,
|
||||
)
|
||||
|
||||
|
||||
# классифицировать ошибку биржи для единого UI и логов
|
||||
def classify_exchange_error(exc: Exception) -> str:
|
||||
text = str(exc).lower()
|
||||
|
||||
if any(
|
||||
marker in text
|
||||
for marker in [
|
||||
"invalid api key",
|
||||
"invalid api-key",
|
||||
"api key",
|
||||
"api-key",
|
||||
"signature",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"permissions",
|
||||
"expired",
|
||||
]
|
||||
):
|
||||
return "auth"
|
||||
|
||||
if any(
|
||||
marker in text
|
||||
for marker in [
|
||||
"-1021",
|
||||
"server time",
|
||||
"doesn't match server time",
|
||||
"рассинхрон",
|
||||
]
|
||||
):
|
||||
return "time"
|
||||
|
||||
if isinstance(exc, ExchangeConnectionError):
|
||||
return "network"
|
||||
|
||||
if isinstance(exc, ExchangeResponseError):
|
||||
if "404" in text:
|
||||
return "network"
|
||||
|
||||
if any(
|
||||
marker in text
|
||||
for marker in [
|
||||
"404",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"connection error",
|
||||
"network error",
|
||||
"name or service not known",
|
||||
"nodename nor servname",
|
||||
"temporary failure",
|
||||
]
|
||||
):
|
||||
return "network"
|
||||
|
||||
return "generic"
|
||||
|
||||
|
||||
# проверить, относится ли reason к unified exchange status layer
|
||||
def is_exchange_status_reason(reason: str | None) -> bool:
|
||||
if not reason:
|
||||
return False
|
||||
|
||||
normalized = str(reason).strip().upper()
|
||||
|
||||
return normalized in {
|
||||
ExchangeStatusCode.OPEN.value,
|
||||
ExchangeStatusCode.BREAK.value,
|
||||
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value,
|
||||
ExchangeStatusCode.AUTH_ERROR.value,
|
||||
ExchangeStatusCode.TIME_ERROR.value,
|
||||
ExchangeStatusCode.INVALID_SYMBOL.value,
|
||||
ExchangeStatusCode.UNKNOWN.value,
|
||||
}
|
||||
@@ -4,12 +4,15 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
from typing import AsyncIterator, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import websockets
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, JsonList, NumericLike
|
||||
|
||||
|
||||
class ExchangeWebSocketClient:
|
||||
@@ -17,6 +20,7 @@ class ExchangeWebSocketClient:
|
||||
self.settings = load_settings()
|
||||
self.base_url = self._build_ws_base_url()
|
||||
|
||||
# собрать корректный websocket URL из настроек
|
||||
def _build_ws_base_url(self) -> str:
|
||||
raw_url = self.settings.exchange_ws_url or self.settings.exchange_base_url
|
||||
|
||||
@@ -32,12 +36,17 @@ class ExchangeWebSocketClient:
|
||||
|
||||
return f"{raw_url}/connect"
|
||||
|
||||
async def stream_depth(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval_seconds: float = 1.0,
|
||||
) -> AsyncIterator[dict]:
|
||||
# безопасно нормализовать паузу между websocket-запросами
|
||||
def _interval_seconds(self, value: NumericLike | None) -> float:
|
||||
interval = safe_float(value)
|
||||
|
||||
if interval is None or interval <= 0:
|
||||
return 1.0
|
||||
|
||||
return interval
|
||||
|
||||
# собрать headers для подключения к websocket
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {
|
||||
"Origin": self.settings.exchange_base_url.rstrip("/"),
|
||||
"Content-Type": "application/json",
|
||||
@@ -46,22 +55,53 @@ class ExchangeWebSocketClient:
|
||||
if self.settings.exchange_api_key:
|
||||
headers["X-MBX-APIKEY"] = self.settings.exchange_api_key
|
||||
|
||||
return headers
|
||||
|
||||
# собрать payload запроса стакана
|
||||
def _depth_request(self, symbol: str) -> JsonDict:
|
||||
return {
|
||||
"correlationId": str(uuid4()),
|
||||
"destination": "/api/v2/depth",
|
||||
"payload": {
|
||||
"limit": 5,
|
||||
"symbol": symbol,
|
||||
},
|
||||
}
|
||||
|
||||
# безопасно разобрать JSON от websocket
|
||||
def _loads_json(self, raw_message: str | bytes) -> JsonDict | JsonList | None:
|
||||
try:
|
||||
payload = json.loads(raw_message)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
return cast(JsonDict, payload)
|
||||
|
||||
if isinstance(payload, list):
|
||||
return cast(JsonList, payload)
|
||||
|
||||
return None
|
||||
|
||||
# поток данных стакана по websocket
|
||||
async def stream_depth(
|
||||
self,
|
||||
symbol: str,
|
||||
*,
|
||||
interval_seconds: NumericLike = 1.0,
|
||||
) -> AsyncIterator[JsonDict]:
|
||||
interval = self._interval_seconds(interval_seconds)
|
||||
headers = self._headers()
|
||||
|
||||
async with websockets.connect(
|
||||
self.base_url,
|
||||
extra_headers=headers,
|
||||
subprotocols=["json"],
|
||||
additional_headers=headers,
|
||||
subprotocols=[Subprotocol("json")],
|
||||
ping_interval=20,
|
||||
open_timeout=self.settings.exchange_timeout_sec,
|
||||
) as websocket:
|
||||
while True:
|
||||
request = {
|
||||
"correlationId": str(uuid4()),
|
||||
"destination": "/api/v2/depth",
|
||||
"payload": {
|
||||
"limit": 5,
|
||||
"symbol": symbol,
|
||||
},
|
||||
}
|
||||
request = self._depth_request(symbol)
|
||||
|
||||
await websocket.send(json.dumps(request))
|
||||
|
||||
@@ -71,16 +111,16 @@ class ExchangeWebSocketClient:
|
||||
timeout=self.settings.exchange_timeout_sec,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_message)
|
||||
except json.JSONDecodeError:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
if not isinstance(raw_message, (str, bytes)):
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
payload = self._loads_json(raw_message)
|
||||
|
||||
if isinstance(payload, dict):
|
||||
yield payload
|
||||
|
||||
await asyncio.sleep(interval_seconds)
|
||||
await asyncio.sleep(interval)
|
||||
Reference in New Issue
Block a user