Stage 03.3 - exchangeInfo and symbol validation
This commit is contained in:
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from src.core.config import load_settings
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.integrations.exchange.exceptions import ExchangeError
|
||||
@@ -11,20 +10,41 @@ from src.integrations.exchange.mock_data import (
|
||||
mock_exchange_health,
|
||||
mock_ticker_price,
|
||||
)
|
||||
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice
|
||||
from src.integrations.exchange.models import (
|
||||
BalanceSummary,
|
||||
ExchangeHealth,
|
||||
ExchangeSymbol,
|
||||
SymbolValidationResult,
|
||||
TickerPrice,
|
||||
)
|
||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||
from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates
|
||||
|
||||
|
||||
class ExchangeService:
|
||||
def __init__(self) -> None:
|
||||
self.settings = load_settings()
|
||||
|
||||
# =========================
|
||||
# PUBLIC API
|
||||
# =========================
|
||||
|
||||
def get_health(self) -> ExchangeHealth:
|
||||
if not self.settings.exchange_enabled:
|
||||
return mock_exchange_health()
|
||||
|
||||
try:
|
||||
ticker = self._get_real_price(self.settings.default_symbol)
|
||||
validation = self.validate_symbol(self.settings.default_symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
return ExchangeHealth(
|
||||
ok=False,
|
||||
mode="real_symbol_error",
|
||||
message=validation.message,
|
||||
)
|
||||
|
||||
ticker = self._get_real_price(validation.normalized_symbol)
|
||||
|
||||
except ExchangeError as exc:
|
||||
return ExchangeHealth(
|
||||
ok=False,
|
||||
@@ -35,7 +55,7 @@ class ExchangeService:
|
||||
return ExchangeHealth(
|
||||
ok=True,
|
||||
mode="real_public_api",
|
||||
message=f"Public API OK. Последняя цена {ticker.symbol}: {ticker.price:.2f}",
|
||||
message=f"Public API OK. Цена {ticker.symbol}: {ticker.price:.2f}",
|
||||
)
|
||||
|
||||
def get_price(self, symbol: str | None = None) -> TickerPrice:
|
||||
@@ -44,13 +64,148 @@ class ExchangeService:
|
||||
if not self.settings.exchange_enabled:
|
||||
return mock_ticker_price(symbol_to_use)
|
||||
|
||||
return self._get_real_price(symbol_to_use)
|
||||
validation = self.validate_symbol(symbol_to_use)
|
||||
|
||||
if not validation.is_valid:
|
||||
raise ExchangeError(validation.message)
|
||||
|
||||
return self._get_real_price(validation.normalized_symbol)
|
||||
|
||||
def get_balance_summary(self) -> list[BalanceSummary]:
|
||||
return mock_balance_summary()
|
||||
|
||||
# =========================
|
||||
# EXCHANGE INFO
|
||||
# =========================
|
||||
|
||||
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
|
||||
if not self.settings.exchange_enabled:
|
||||
return []
|
||||
|
||||
client = ExchangeRestClient()
|
||||
payload = client.get_json("/api/v2/exchangeInfo")
|
||||
|
||||
# 🔥 защита от разных форматов ответа
|
||||
if isinstance(payload.get("symbols"), list):
|
||||
symbols_raw = payload["symbols"]
|
||||
else:
|
||||
inner = payload.get("payload")
|
||||
if isinstance(inner, dict) and isinstance(inner.get("symbols"), list):
|
||||
symbols_raw = inner["symbols"]
|
||||
else:
|
||||
raise ExchangeError(
|
||||
"Field 'symbols' is missing in exchangeInfo response."
|
||||
)
|
||||
|
||||
def _safe_str(value: object, default: str = "") -> str:
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).strip()
|
||||
|
||||
items: list[ExchangeSymbol] = []
|
||||
|
||||
for item in symbols_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
# ---- tickSize ----
|
||||
tick_size_raw = item.get("tickSize")
|
||||
tick_size = None
|
||||
if tick_size_raw not in (None, ""):
|
||||
try:
|
||||
tick_size = float(str(tick_size_raw))
|
||||
except (TypeError, ValueError):
|
||||
tick_size = None
|
||||
|
||||
# ---- marketModes ----
|
||||
market_modes_raw = item.get("marketModes")
|
||||
|
||||
if isinstance(market_modes_raw, list):
|
||||
market_modes = [
|
||||
str(x).strip() for x in market_modes_raw if str(x).strip()
|
||||
]
|
||||
elif isinstance(market_modes_raw, str) and market_modes_raw.strip():
|
||||
market_modes = [market_modes_raw.strip()]
|
||||
else:
|
||||
market_modes = []
|
||||
|
||||
# ---- marketType ----
|
||||
market_type_raw = item.get("marketType")
|
||||
market_type = (
|
||||
str(market_type_raw).strip()
|
||||
if market_type_raw is not None
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
items.append(
|
||||
ExchangeSymbol(
|
||||
symbol=_safe_str(item.get("symbol")),
|
||||
name=_safe_str(item.get("name")),
|
||||
status=_safe_str(item.get("status"), "unknown"),
|
||||
base_asset=_safe_str(item.get("baseAsset")),
|
||||
quote_asset=_safe_str(item.get("quoteAsset")),
|
||||
market_modes=market_modes,
|
||||
market_type=market_type,
|
||||
tick_size=tick_size,
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
# =========================
|
||||
# SYMBOL VALIDATION
|
||||
# =========================
|
||||
|
||||
def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
|
||||
requested = normalize_symbol(raw_symbol)
|
||||
|
||||
if not requested:
|
||||
return SymbolValidationResult(
|
||||
requested_symbol=requested,
|
||||
normalized_symbol="",
|
||||
is_valid=False,
|
||||
message="Символ пустой.",
|
||||
symbol_info=None,
|
||||
)
|
||||
|
||||
if not self.settings.exchange_enabled:
|
||||
return SymbolValidationResult(
|
||||
requested_symbol=requested,
|
||||
normalized_symbol=requested,
|
||||
is_valid=True,
|
||||
message="Mock mode active.",
|
||||
symbol_info=None,
|
||||
)
|
||||
|
||||
symbols = self.get_exchange_symbols()
|
||||
candidates = symbol_candidates(requested)
|
||||
|
||||
for candidate in candidates:
|
||||
for symbol_info in symbols:
|
||||
if normalize_symbol(symbol_info.symbol) == candidate:
|
||||
return SymbolValidationResult(
|
||||
requested_symbol=requested,
|
||||
normalized_symbol=normalize_symbol(symbol_info.symbol),
|
||||
is_valid=True,
|
||||
message="Символ найден в exchangeInfo.",
|
||||
symbol_info=symbol_info,
|
||||
)
|
||||
|
||||
return SymbolValidationResult(
|
||||
requested_symbol=requested,
|
||||
normalized_symbol=requested,
|
||||
is_valid=False,
|
||||
message=f"Символ '{requested}' не найден в exchangeInfo.",
|
||||
symbol_info=None,
|
||||
)
|
||||
|
||||
# =========================
|
||||
# INTERNAL
|
||||
# =========================
|
||||
|
||||
def _get_real_price(self, symbol: str) -> TickerPrice:
|
||||
client = ExchangeRestClient()
|
||||
|
||||
payload = client.get_json(
|
||||
"/api/v2/ticker/24hr",
|
||||
params={"symbol": symbol},
|
||||
@@ -62,12 +217,10 @@ class ExchangeService:
|
||||
|
||||
close_time = payload.get("closeTime") or payload.get("eventTime") or ""
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
if close_time:
|
||||
dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC"))
|
||||
dt_local = dt_utc.astimezone(ZoneInfo(settings.tz))
|
||||
updated_at = dt_local.strftime("%d/%m/%Y %H:%M:%S")
|
||||
dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz))
|
||||
updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
updated_at = "n/a"
|
||||
|
||||
@@ -76,4 +229,4 @@ class ExchangeService:
|
||||
price=float(price_raw),
|
||||
source="dzengi-demo-api",
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user