Stage 07.4.4.1.14 — Execution refactoring and runtime semantics
This commit is contained in:
@@ -34,6 +34,8 @@ class MarketRuntimeContext:
|
||||
last_rest_state: str | None = None
|
||||
last_rest_error_key: str | None = None
|
||||
|
||||
last_ws_debug_logged_at: float = 0.0
|
||||
|
||||
|
||||
class MarketDataRunner:
|
||||
_runtimes: dict[str, MarketRuntimeContext] = {}
|
||||
@@ -45,6 +47,23 @@ class MarketDataRunner:
|
||||
# Состояние в UI может меняться чаще, но журнал не должен разрастаться.
|
||||
_runtime_log_cooldown_seconds = 300
|
||||
|
||||
@classmethod
|
||||
def get_runtime_state(cls, runtime_key: str = "default") -> dict[str, object]:
|
||||
context = cls._runtimes.get(runtime_key)
|
||||
|
||||
if context is None:
|
||||
return {
|
||||
"stream_state": None,
|
||||
"stream_error": None,
|
||||
"rest_state": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"stream_state": context.last_stream_state,
|
||||
"stream_error": context.last_stream_error_key,
|
||||
"rest_state": context.last_rest_state,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _can_log_runtime_event(
|
||||
cls,
|
||||
@@ -276,13 +295,33 @@ class MarketDataRunner:
|
||||
cache_symbol = cls._cache_symbol(symbol)
|
||||
ws_symbol = cls._ws_symbol(symbol)
|
||||
|
||||
payload_count = 0
|
||||
valid_payload_count = 0
|
||||
invalid_payload_count = 0
|
||||
|
||||
async for payload in ExchangeWebSocketClient().stream_depth(
|
||||
ws_symbol,
|
||||
interval_seconds=context.interval_seconds,
|
||||
):
|
||||
if payload_count == 0:
|
||||
current_symbol = context.symbol_provider()
|
||||
if current_symbol and current_symbol != symbol:
|
||||
break
|
||||
|
||||
best_bid = cls._extract_best_price(payload, "bids")
|
||||
best_ask = cls._extract_best_price(payload, "asks")
|
||||
|
||||
if best_bid is None or best_ask is None:
|
||||
invalid_payload_count += 1
|
||||
|
||||
if invalid_payload_count >= 5:
|
||||
raise RuntimeError(
|
||||
"WebSocket depth stream does not contain valid bids/asks."
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
invalid_payload_count = 0
|
||||
|
||||
if valid_payload_count == 0:
|
||||
should_log_connected = (
|
||||
context.last_stream_state != "CONNECTED"
|
||||
and cls._can_log_runtime_event(
|
||||
@@ -296,7 +335,6 @@ class MarketDataRunner:
|
||||
context.last_rest_error_key = None
|
||||
|
||||
if should_log_connected:
|
||||
|
||||
cls._log_info(
|
||||
context,
|
||||
"market_stream_connected",
|
||||
@@ -305,22 +343,16 @@ class MarketDataRunner:
|
||||
"requested_symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"bid_price": best_bid,
|
||||
"ask_price": best_ask,
|
||||
"payload_keys": list(payload.keys()),
|
||||
"payload_preview": cls._safe_payload_preview(payload),
|
||||
"payload_preview": cls._safe_payload_preview(
|
||||
cls._extract_depth_payload(payload)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
payload_count += 1
|
||||
|
||||
current_symbol = context.symbol_provider()
|
||||
if current_symbol and current_symbol != symbol:
|
||||
break
|
||||
|
||||
best_bid = cls._extract_best_price(payload, "bids")
|
||||
best_ask = cls._extract_best_price(payload, "asks")
|
||||
|
||||
if best_bid is None or best_ask is None:
|
||||
continue
|
||||
valid_payload_count += 1
|
||||
|
||||
MarketPriceCache.set_price(
|
||||
symbol=cache_symbol,
|
||||
@@ -331,6 +363,16 @@ class MarketDataRunner:
|
||||
runtime_key=context.runtime_key,
|
||||
)
|
||||
|
||||
cls._log_ws_depth_debug(
|
||||
context=context,
|
||||
symbol=symbol,
|
||||
cache_symbol=cache_symbol,
|
||||
ws_symbol=ws_symbol,
|
||||
best_bid=best_bid,
|
||||
best_ask=best_ask,
|
||||
valid_payload_count=valid_payload_count,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _rest_fallback_once(
|
||||
cls,
|
||||
@@ -440,11 +482,7 @@ class MarketDataRunner:
|
||||
payload: JsonDict,
|
||||
side_key: str,
|
||||
) -> float | None:
|
||||
data = payload
|
||||
|
||||
inner = payload.get("payload")
|
||||
if isinstance(inner, dict):
|
||||
data = inner
|
||||
data = cls._extract_depth_payload(payload)
|
||||
|
||||
values = data.get(side_key)
|
||||
|
||||
@@ -467,6 +505,24 @@ class MarketDataRunner:
|
||||
return cls._positive_float(raw_price)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
|
||||
data: object = payload
|
||||
|
||||
for key in ("payload", "Payload"):
|
||||
if isinstance(data, dict) and isinstance(data.get(key), dict):
|
||||
data = data.get(key)
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key in ("payload", "Payload"):
|
||||
nested = data.get(key)
|
||||
if isinstance(nested, dict):
|
||||
return nested
|
||||
|
||||
return data
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _positive_float(cls, value: NumericLike | None) -> float | None:
|
||||
@@ -504,6 +560,79 @@ class MarketDataRunner:
|
||||
|
||||
return preview
|
||||
|
||||
@classmethod
|
||||
def _log_ws_depth_debug(
|
||||
cls,
|
||||
*,
|
||||
context: MarketRuntimeContext,
|
||||
symbol: str,
|
||||
cache_symbol: str,
|
||||
ws_symbol: str,
|
||||
best_bid: float,
|
||||
best_ask: float,
|
||||
valid_payload_count: int,
|
||||
) -> None:
|
||||
now = time.monotonic()
|
||||
|
||||
if now - context.last_ws_debug_logged_at < 60:
|
||||
return
|
||||
|
||||
context.last_ws_debug_logged_at = now
|
||||
|
||||
cls._log_debug(
|
||||
context,
|
||||
"ws_depth_alive",
|
||||
"WS depth поток активен.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"cache_symbol": cache_symbol,
|
||||
"ws_symbol": ws_symbol,
|
||||
"runtime_key": context.runtime_key,
|
||||
"bid_price": best_bid,
|
||||
"ask_price": best_ask,
|
||||
"spread_percent": cls._spread_percent(best_bid, best_ask),
|
||||
"valid_payload_count": valid_payload_count,
|
||||
"source": f"ws_depth:{context.runtime_key}",
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _spread_percent(cls, bid_price: float, ask_price: float) -> float | None:
|
||||
mid_price = (bid_price + ask_price) / 2
|
||||
|
||||
if mid_price <= 0:
|
||||
return None
|
||||
|
||||
return round(((ask_price - bid_price) / mid_price) * 100, 5)
|
||||
|
||||
@classmethod
|
||||
def _log_debug(
|
||||
cls,
|
||||
context: MarketRuntimeContext,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: JsonDict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if context.screen:
|
||||
JournalService().log_ui_debug(
|
||||
event_type=event_type,
|
||||
message=cls._message(context, message),
|
||||
screen=context.screen,
|
||||
action=context.action,
|
||||
payload=cls._payload(context, payload),
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_debug(
|
||||
event_type,
|
||||
cls._message(context, message),
|
||||
cls._payload(context, payload),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _message(
|
||||
cls,
|
||||
|
||||
@@ -132,4 +132,14 @@ class KlineBatch:
|
||||
symbol: str
|
||||
interval: str
|
||||
candles: list[Kline]
|
||||
source: str
|
||||
source: str
|
||||
|
||||
# Информация о торговой комиссии для инструмента.
|
||||
@dataclass(slots=True)
|
||||
class TradingFee:
|
||||
symbol: str
|
||||
name: str
|
||||
fee_percent: float | None = None
|
||||
overnight_long_rate: float | None = None
|
||||
overnight_short_rate: float | None = None
|
||||
overnight_fee_timestamp: int | None = None
|
||||
@@ -23,7 +23,7 @@ class ExchangePrivateClient:
|
||||
signed = self.auth.build_signed_params(params)
|
||||
|
||||
return self.client.get_json(
|
||||
"/api/v2/account",
|
||||
"/api/v1/account",
|
||||
params=signed,
|
||||
headers=self.auth.build_headers(),
|
||||
)
|
||||
|
||||
@@ -29,11 +29,13 @@ from src.integrations.exchange.models import (
|
||||
SymbolValidationResult,
|
||||
TickerPrice,
|
||||
TimeSyncStatus,
|
||||
TradingFee,
|
||||
)
|
||||
from src.integrations.exchange.private_client import ExchangePrivateClient
|
||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
build_market_stale_status,
|
||||
build_account_auth_status,
|
||||
build_exchange_error_status,
|
||||
build_invalid_symbol_status,
|
||||
@@ -97,11 +99,150 @@ class ExchangeService:
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
|
||||
return build_market_status_from_symbol_status(
|
||||
status = build_market_status_from_symbol_status(
|
||||
raw_status=getattr(symbol_info, "status", None),
|
||||
symbol=validation.normalized_symbol,
|
||||
)
|
||||
|
||||
if not status.is_open:
|
||||
return status
|
||||
|
||||
try:
|
||||
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
|
||||
except Exception:
|
||||
return status
|
||||
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
|
||||
if age_seconds is not None and age_seconds > 60:
|
||||
return build_market_stale_status(
|
||||
symbol=validation.normalized_symbol,
|
||||
age_seconds=age_seconds,
|
||||
updated_at=str(snapshot.get("updated_at") or ""),
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
def _exchange_timestamp_age_seconds(
|
||||
self,
|
||||
raw_timestamp: NumericLike | None,
|
||||
) -> float | None:
|
||||
timestamp = safe_float(raw_timestamp)
|
||||
|
||||
if timestamp is None or timestamp <= 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
server_time_ms = self.get_exchange_server_time_ms()
|
||||
return max(0.0, round((server_time_ms - int(timestamp)) / 1000, 3))
|
||||
except Exception:
|
||||
local_time_ms = int(datetime.now(ZoneInfo("UTC")).timestamp() * 1000)
|
||||
return max(0.0, round((local_time_ms - int(timestamp)) / 1000, 3))
|
||||
|
||||
def get_trading_fee(self, symbol: str | None = None) -> TradingFee:
|
||||
symbol_to_use = symbol or self.settings.default_symbol
|
||||
|
||||
if not self.settings.exchange_enabled:
|
||||
return TradingFee(
|
||||
symbol=symbol_to_use,
|
||||
name=symbol_to_use,
|
||||
fee_percent=0.0,
|
||||
)
|
||||
|
||||
validation = self.validate_symbol(symbol_to_use)
|
||||
if not validation.is_valid:
|
||||
raise ExchangeError(validation.message)
|
||||
|
||||
client = ExchangeRestClient()
|
||||
|
||||
try:
|
||||
payload = client.get_payload(
|
||||
"/api/v1/tradingFees",
|
||||
params={"symbol": validation.normalized_symbol},
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log_exchange_error(
|
||||
endpoint="tradingFees",
|
||||
exc=exc,
|
||||
symbol=validation.normalized_symbol,
|
||||
)
|
||||
raise ExchangeError(f"Не удалось получить комиссию: {exc}") from exc
|
||||
|
||||
fee_items = self._extract_trading_fee_items(payload)
|
||||
|
||||
for item in fee_items:
|
||||
fee = self._parse_trading_fee_item(item)
|
||||
if fee is not None and normalize_symbol(fee.symbol) == validation.normalized_symbol:
|
||||
return fee
|
||||
|
||||
raise ExchangeError(
|
||||
f"Комиссия для символа '{validation.normalized_symbol}' не найдена."
|
||||
)
|
||||
|
||||
def get_overnight_fee_countdown(
|
||||
self,
|
||||
symbol: str | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
fee = self.get_trading_fee(symbol)
|
||||
|
||||
timestamp = fee.overnight_fee_timestamp
|
||||
|
||||
if timestamp is None or timestamp <= 0:
|
||||
return None
|
||||
|
||||
now_ms = self.get_exchange_server_time_ms()
|
||||
|
||||
remaining_seconds = int(
|
||||
max(
|
||||
0,
|
||||
(timestamp - now_ms) / 1000,
|
||||
)
|
||||
)
|
||||
|
||||
hours = remaining_seconds // 3600
|
||||
minutes = (remaining_seconds % 3600) // 60
|
||||
|
||||
return f"{hours}ч {minutes:02d}м"
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_trading_fee_items(
|
||||
self,
|
||||
payload: object,
|
||||
) -> list[object]:
|
||||
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
|
||||
if isinstance(payload, dict):
|
||||
raw_payload = payload.get("payload")
|
||||
|
||||
if isinstance(raw_payload, list):
|
||||
return raw_payload
|
||||
|
||||
return []
|
||||
|
||||
def _parse_trading_fee_item(self, item: object) -> TradingFee | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
overnight_rates = item.get("overnightRates")
|
||||
if not isinstance(overnight_rates, dict):
|
||||
overnight_rates = {}
|
||||
|
||||
return TradingFee(
|
||||
symbol=self._safe_str(item.get("symbol")),
|
||||
name=self._safe_str(item.get("name")),
|
||||
fee_percent=safe_float(item.get("fee")),
|
||||
overnight_long_rate=safe_float(overnight_rates.get("longRate")),
|
||||
overnight_short_rate=safe_float(overnight_rates.get("shortRate")),
|
||||
overnight_fee_timestamp=int(safe_float(item.get("overnightFeeTimestamp")) or 0)
|
||||
if item.get("overnightFeeTimestamp") is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
# Логировать info-событие биржи без падения основного сценария.
|
||||
def _log_info(
|
||||
self,
|
||||
@@ -321,7 +462,7 @@ class ExchangeService:
|
||||
if limit > 200:
|
||||
limit = 200
|
||||
|
||||
if interval not in {"1m", "5m", "15m"}:
|
||||
if interval not in {"1m", "5m", "15m", "1h"}:
|
||||
raise ExchangeError(f"Unsupported kline interval: {interval}")
|
||||
|
||||
normalized_price_type = price_type.strip().lower()
|
||||
@@ -340,7 +481,7 @@ class ExchangeService:
|
||||
|
||||
try:
|
||||
payload = client.get_payload(
|
||||
"/api/v2/klines",
|
||||
"/api/v1/klines",
|
||||
params={
|
||||
"symbol": validation.normalized_symbol,
|
||||
"interval": interval,
|
||||
@@ -702,20 +843,27 @@ class ExchangeService:
|
||||
if cached_price is not None:
|
||||
age = cached_price.age_seconds()
|
||||
|
||||
return {
|
||||
"symbol": cached_price.symbol,
|
||||
"last_price": cached_price.price,
|
||||
"bid_price": cached_price.bid_price or cached_price.price,
|
||||
"ask_price": cached_price.ask_price or cached_price.price,
|
||||
"updated_at": cached_price.updated_at,
|
||||
"source": cached_price.source,
|
||||
"runtime_key": cached_price.runtime_key,
|
||||
"age_seconds": round(age, 3),
|
||||
"is_fresh": age <= self._execution_cache_max_age_seconds,
|
||||
}
|
||||
if age <= self._execution_cache_max_age_seconds:
|
||||
return {
|
||||
"symbol": cached_price.symbol,
|
||||
"last_price": cached_price.price,
|
||||
"bid_price": cached_price.bid_price or cached_price.price,
|
||||
"ask_price": cached_price.ask_price or cached_price.price,
|
||||
"updated_at": cached_price.updated_at,
|
||||
"source": cached_price.source,
|
||||
"runtime_key": cached_price.runtime_key,
|
||||
"age_seconds": round(age, 3),
|
||||
"is_fresh": True,
|
||||
}
|
||||
|
||||
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
|
||||
snapshot = self.refresh_market_snapshot_cache(
|
||||
validation.normalized_symbol,
|
||||
runtime_key=normalized_runtime_key,
|
||||
)
|
||||
snapshot["runtime_key"] = normalized_runtime_key
|
||||
snapshot["age_seconds"] = 0.0
|
||||
snapshot["is_fresh"] = True
|
||||
|
||||
return snapshot
|
||||
|
||||
# Получить snapshot, пригодный для execution layer.
|
||||
@@ -786,6 +934,8 @@ class ExchangeService:
|
||||
if last_price is None or bid_price is None or ask_price is None:
|
||||
raise ExchangeError("Market snapshot contains invalid execution prices.")
|
||||
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
|
||||
return ExecutionPriceSnapshot(
|
||||
symbol=str(snapshot["symbol"]),
|
||||
last_price=last_price,
|
||||
@@ -793,8 +943,8 @@ class ExchangeService:
|
||||
ask_price=ask_price,
|
||||
updated_at=str(snapshot["updated_at"]),
|
||||
source="rest_fallback",
|
||||
is_fresh=True,
|
||||
age_seconds=0.0,
|
||||
is_fresh=bool(snapshot.get("is_fresh")),
|
||||
age_seconds=age_seconds,
|
||||
)
|
||||
|
||||
# Получить свежий snapshot напрямую из REST API.
|
||||
@@ -822,7 +972,7 @@ class ExchangeService:
|
||||
|
||||
try:
|
||||
payload = client.get_json(
|
||||
"/api/v2/ticker/24hr",
|
||||
"/api/v1/ticker/24hr",
|
||||
params={"symbol": validation.normalized_symbol},
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -848,6 +998,9 @@ class ExchangeService:
|
||||
ask_price = safe_float(payload.get("askPrice")) or last_price
|
||||
close_time = payload.get("closeTime") or payload.get("eventTime")
|
||||
|
||||
age_seconds = self._exchange_timestamp_age_seconds(close_time)
|
||||
is_fresh = age_seconds is not None and age_seconds <= 60
|
||||
|
||||
return {
|
||||
"symbol": validation.normalized_symbol,
|
||||
"last_price": last_price,
|
||||
@@ -855,8 +1008,8 @@ class ExchangeService:
|
||||
"ask_price": ask_price,
|
||||
"updated_at": self._format_exchange_time(close_time),
|
||||
"source": "fresh_rest",
|
||||
"age_seconds": 0.0,
|
||||
"is_fresh": True,
|
||||
"age_seconds": age_seconds,
|
||||
"is_fresh": is_fresh,
|
||||
}
|
||||
|
||||
# Получить live-балансы аккаунта.
|
||||
@@ -916,7 +1069,7 @@ class ExchangeService:
|
||||
client = ExchangeRestClient()
|
||||
|
||||
try:
|
||||
payload = client.get_json("/api/v2/exchangeInfo")
|
||||
payload = client.get_json("/api/v1/exchangeInfo")
|
||||
except Exception as exc:
|
||||
self._log_exchange_error(
|
||||
endpoint="exchangeInfo",
|
||||
@@ -1007,7 +1160,7 @@ class ExchangeService:
|
||||
return ExchangeSymbol(
|
||||
symbol=self._safe_str(item.get("symbol")),
|
||||
name=self._safe_str(item.get("name")),
|
||||
status=self._safe_str(item.get("status"), "unknown"),
|
||||
status=self._parse_exchange_symbol_status(item),
|
||||
base_asset=self._safe_str(item.get("baseAsset")),
|
||||
quote_asset=self._safe_str(item.get("quoteAsset")),
|
||||
market_modes=self._parse_market_modes(item.get("marketModes")),
|
||||
@@ -1025,6 +1178,49 @@ class ExchangeService:
|
||||
|
||||
return str(value).strip()
|
||||
|
||||
def _parse_exchange_symbol_status(self, item: dict[object, object]) -> str:
|
||||
status = self._safe_str(item.get("status"), "unknown")
|
||||
|
||||
false_flags = {
|
||||
"isTradingAllowed",
|
||||
"tradingAllowed",
|
||||
"availableForTrading",
|
||||
"isTradable",
|
||||
"tradable",
|
||||
"isMarketOpen",
|
||||
"marketOpen",
|
||||
"isOpen",
|
||||
"enabled",
|
||||
}
|
||||
|
||||
for key in false_flags:
|
||||
if key not in item:
|
||||
continue
|
||||
|
||||
value = item.get(key)
|
||||
|
||||
if isinstance(value, bool) and not value:
|
||||
return "NOT_TRADABLE"
|
||||
|
||||
if str(value).strip().lower() in {"false", "0", "no", "disabled"}:
|
||||
return "NOT_TRADABLE"
|
||||
|
||||
for key in ("tradingMode", "tradeMode", "mode", "state"):
|
||||
value = str(item.get(key) or "").strip().upper()
|
||||
|
||||
if value in {
|
||||
"NOT_TRADABLE",
|
||||
"TRADING_DISABLED",
|
||||
"MARKET_DISABLED",
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
"CLOSE_ONLY",
|
||||
"REDUCE_ONLY",
|
||||
"VIEW_ONLY",
|
||||
}:
|
||||
return value
|
||||
|
||||
return status
|
||||
|
||||
# Привести marketModes к list[str].
|
||||
def _parse_market_modes(self, value: object) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
@@ -1127,7 +1323,7 @@ class ExchangeService:
|
||||
)
|
||||
|
||||
def get_exchange_server_time_ms(self) -> int:
|
||||
payload = ExchangeRestClient().get_json("/api/v2/time")
|
||||
payload = ExchangeRestClient().get_json("/api/v1/time")
|
||||
|
||||
inner = payload.get("payload")
|
||||
if isinstance(inner, dict):
|
||||
|
||||
@@ -21,8 +21,6 @@ class ExchangeStatusCode(StrEnum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
# app/src/integrations/exchange/status.py
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ExchangeRuntimeStatus:
|
||||
code: ExchangeStatusCode
|
||||
@@ -55,6 +53,32 @@ class ExchangeRuntimeStatus:
|
||||
}
|
||||
|
||||
|
||||
def build_market_stale_status(
|
||||
*,
|
||||
symbol: str,
|
||||
age_seconds: float | None,
|
||||
updated_at: str | None = None,
|
||||
) -> ExchangeRuntimeStatus:
|
||||
age_text = "неизвестно" if age_seconds is None else f"{age_seconds:.0f}с"
|
||||
updated_text = f" Последнее обновление: {updated_at}." if updated_at else ""
|
||||
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.BREAK,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Рынок закрыт",
|
||||
message=(
|
||||
f"Котировки по инструменту не обновляются. "
|
||||
f"Возраст данных: {age_text}.{updated_text}"
|
||||
),
|
||||
ui_line="⏸️ Рынок закрыт",
|
||||
reason="market_data_stale",
|
||||
raw_status="STALE_MARKET_DATA",
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
|
||||
# собрать статус mock-режима
|
||||
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
|
||||
return ExchangeRuntimeStatus(
|
||||
@@ -94,6 +118,13 @@ BREAK_STATUSES = {
|
||||
"DISABLED",
|
||||
"SETTLING",
|
||||
"POST_ONLY",
|
||||
"NOT_TRADABLE",
|
||||
"TRADING_DISABLED",
|
||||
"MARKET_DISABLED",
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
"CLOSE_ONLY",
|
||||
"REDUCE_ONLY",
|
||||
"VIEW_ONLY",
|
||||
}
|
||||
|
||||
|
||||
@@ -119,15 +150,37 @@ def build_market_status_from_symbol_status(
|
||||
symbol=symbol,
|
||||
)
|
||||
|
||||
if normalized_status in {
|
||||
"NOT_TRADABLE",
|
||||
"TRADING_DISABLED",
|
||||
"MARKET_DISABLED",
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
"CLOSE_ONLY",
|
||||
"REDUCE_ONLY",
|
||||
"VIEW_ONLY",
|
||||
}:
|
||||
return ExchangeRuntimeStatus(
|
||||
code=ExchangeStatusCode.BREAK,
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Рынок недоступен",
|
||||
message=f"Этот рынок недоступен для торговли: {symbol}.",
|
||||
ui_line="⛔️ Рынок недоступен для торговли",
|
||||
reason="market_not_tradable",
|
||||
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="⏸️ Перерыв на бирже",
|
||||
title="Перерыв в торгах",
|
||||
message=f"Торги по {symbol} временно остановлены.",
|
||||
ui_line="⏸️ Перерыв в торгах",
|
||||
reason="market_break",
|
||||
raw_status=normalized_status,
|
||||
symbol=symbol,
|
||||
@@ -138,9 +191,12 @@ def build_market_status_from_symbol_status(
|
||||
is_open=False,
|
||||
is_available=True,
|
||||
is_auth_ok=True,
|
||||
title="Статус рынка не определён",
|
||||
message=f"Статус инструмента {symbol} не определён.",
|
||||
ui_line="⏸️ Перерыв на бирже",
|
||||
title="Статус торгов неизвестен",
|
||||
message=(
|
||||
f"Биржа вернула неизвестный статус инструмента"
|
||||
f"{f': {normalized_status}' if normalized_status else ''}."
|
||||
),
|
||||
ui_line="⚠️ Статус торгов неизвестен",
|
||||
reason="market_status_unknown",
|
||||
raw_status=normalized_status or None,
|
||||
symbol=symbol,
|
||||
|
||||
@@ -61,7 +61,7 @@ class ExchangeWebSocketClient:
|
||||
def _depth_request(self, symbol: str) -> JsonDict:
|
||||
return {
|
||||
"correlationId": str(uuid4()),
|
||||
"destination": "/api/v2/depth",
|
||||
"destination": "/api/v1/depth",
|
||||
"payload": {
|
||||
"limit": 5,
|
||||
"symbol": symbol,
|
||||
@@ -92,17 +92,30 @@ class ExchangeWebSocketClient:
|
||||
) -> AsyncIterator[JsonDict]:
|
||||
interval = self._interval_seconds(interval_seconds)
|
||||
headers = self._headers()
|
||||
timeout_count = 0
|
||||
max_timeouts = 3
|
||||
|
||||
async with websockets.connect(
|
||||
self.base_url,
|
||||
additional_headers=headers,
|
||||
extra_headers=headers,
|
||||
subprotocols=[Subprotocol("json")],
|
||||
ping_interval=20,
|
||||
open_timeout=self.settings.exchange_timeout_sec,
|
||||
) as websocket:
|
||||
while True:
|
||||
request = self._depth_request(symbol)
|
||||
last_ping_at = 0.0
|
||||
|
||||
while True:
|
||||
now = asyncio.get_running_loop().time()
|
||||
|
||||
if now - last_ping_at >= 5.0:
|
||||
pong = await websocket.ping()
|
||||
await asyncio.wait_for(
|
||||
pong,
|
||||
timeout=self.settings.exchange_timeout_sec,
|
||||
)
|
||||
last_ping_at = now
|
||||
|
||||
request = self._depth_request(symbol)
|
||||
await websocket.send(json.dumps(request))
|
||||
|
||||
try:
|
||||
@@ -110,10 +123,19 @@ class ExchangeWebSocketClient:
|
||||
websocket.recv(),
|
||||
timeout=self.settings.exchange_timeout_sec,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
except asyncio.TimeoutError as exc:
|
||||
timeout_count += 1
|
||||
|
||||
if timeout_count >= max_timeouts:
|
||||
raise RuntimeError(
|
||||
"WebSocket depth stream timed out repeatedly."
|
||||
) from exc
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
timeout_count = 0
|
||||
|
||||
if not isinstance(raw_message, (str, bytes)):
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user