Stage 07.4.4.1.14 — Execution refactoring and runtime semantics

This commit is contained in:
2026-07-02 16:25:18 +03:00
parent d9e6392e28
commit e08c604ff5
72 changed files with 13626 additions and 4233 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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