07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics

This commit is contained in:
2026-05-28 10:30:54 +03:00
parent f9a25e7671
commit d9e6392e28
75 changed files with 9934 additions and 10508 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View 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

View 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,
}

View File

@@ -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)