from __future__ import annotations from datetime import datetime from zoneinfo import ZoneInfo from src.core.config import load_settings from src.integrations.exchange.balance_parser import parse_account_balances from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.mock_data import ( mock_balance_summary, mock_exchange_health, mock_ticker_price, ) from src.integrations.exchange.models import ( BalanceSummary, ExchangeHealth, ExchangeSymbol, PrivateAuthHealth, SymbolValidationResult, TickerPrice, ) from src.integrations.exchange.private_client import ExchangePrivateClient from src.integrations.exchange.rest_client import ExchangeRestClient from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates from src.trading.journal.service import JournalService class ExchangeService: def __init__(self) -> None: self.settings = load_settings() self.journal = JournalService() def _log_info(self, event_type: str, message: str, payload: dict | None = None) -> None: try: self.journal.log_info(event_type, message, payload) except Exception: pass def _log_warning(self, event_type: str, message: str, payload: dict | None = None) -> None: try: self.journal.log_warning(event_type, message, payload) except Exception: pass def _log_error(self, event_type: str, message: str, payload: dict | None = None) -> None: try: self.journal.log_error(event_type, message, payload) except Exception: pass def get_health(self) -> ExchangeHealth: if not self.settings.exchange_enabled: return mock_exchange_health() try: 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, mode="real_error", message=f"Ошибка подключения к API: {exc}", ) return ExchangeHealth( ok=True, mode="real_public_api", message=f"Public API OK. Цена {ticker.symbol}: {ticker.price:.2f}", ) def get_private_auth_health(self) -> PrivateAuthHealth: if not self.settings.exchange_enabled: return PrivateAuthHealth( ok=False, message="Интеграция с биржей выключена.", ) if not self.settings.exchange_api_key or not self.settings.exchange_api_secret: return PrivateAuthHealth( ok=False, message="EXCHANGE_API_KEY / EXCHANGE_API_SECRET не заданы.", ) try: payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) balances = parse_account_balances(payload) except Exception as exc: return PrivateAuthHealth( ok=False, message=f"Private API error: {exc}", ) return PrivateAuthHealth( ok=True, message=f"Private API OK. Балансов получено: {len(balances)}", ) def get_price(self, symbol: str | None = None) -> TickerPrice: symbol_to_use = symbol or self.settings.default_symbol if not self.settings.exchange_enabled: return mock_ticker_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_market_snapshot(self, symbol: str | None = None) -> dict[str, object]: symbol_to_use = symbol or self.settings.default_symbol if not self.settings.exchange_enabled: ticker = mock_ticker_price(symbol_to_use) return { "symbol": ticker.symbol, "last_price": ticker.price, "bid_price": ticker.price, "ask_price": ticker.price, "updated_at": ticker.updated_at, } validation = self.validate_symbol(symbol_to_use) if not validation.is_valid: raise ExchangeError(validation.message) client = ExchangeRestClient() payload = client.get_json( "/api/v2/ticker/24hr", params={"symbol": validation.normalized_symbol}, ) last_raw = payload.get("lastPrice") if last_raw is None: raise ExchangeError("Field 'lastPrice' is missing in ticker response.") bid_raw = payload.get("bidPrice") or last_raw ask_raw = payload.get("askPrice") or last_raw close_time = payload.get("closeTime") or payload.get("eventTime") or "" if close_time: dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) 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" return { "symbol": validation.normalized_symbol, "last_price": float(last_raw), "bid_price": float(bid_raw), "ask_price": float(ask_raw), "updated_at": updated_at, } def get_balance_summary(self) -> list[BalanceSummary]: if not self.settings.exchange_enabled: return mock_balance_summary() auth_health = self.get_private_auth_health() if not auth_health.ok: self._log_error( "balance_summary_error", auth_health.message, { "exchange_name": self.settings.exchange_name, "default_symbol": self.settings.default_symbol, }, ) raise ExchangeError(auth_health.message) try: payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) balances = parse_account_balances(payload) except Exception as exc: self._log_error( "balance_summary_error", f"Не удалось получить баланс: {exc}", { "exchange_name": self.settings.exchange_name, "default_symbol": self.settings.default_symbol, }, ) raise ExchangeError(f"Не удалось получить баланс: {exc}") from exc if not balances: self._log_warning( "balance_summary_empty", "Баланс получен, но список активов пуст или не распознан.", { "exchange_name": self.settings.exchange_name, "default_symbol": self.settings.default_symbol, }, ) raise ExchangeError("Баланс получен, но список активов пуст или не распознан.") self._log_info( "balance_summary_loaded", f"Баланс успешно получен. Активов: {len(balances)}", { "exchange_name": self.settings.exchange_name, "assets_count": len(balances), }, ) return balances 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 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 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 = [] 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 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, ) def _get_real_price(self, symbol: str) -> TickerPrice: client = ExchangeRestClient() try: payload = client.get_json( "/api/v2/ticker/24hr", params={"symbol": symbol}, ) except Exception as exc: self._log_error( "market_price_error", f"Не удалось получить цену инструмента {symbol}: {exc}", { "symbol": symbol, "exchange_name": self.settings.exchange_name, }, ) raise price_raw = payload.get("lastPrice") if price_raw is None: self._log_error( "market_price_error", "Field 'lastPrice' is missing in ticker response.", { "symbol": symbol, "exchange_name": self.settings.exchange_name, }, ) raise ExchangeError("Field 'lastPrice' is missing in ticker response.") close_time = payload.get("closeTime") or payload.get("eventTime") or "" if close_time: dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) 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" source = ( "dzengi-demo-api" if "demo" in self.settings.exchange_base_url.lower() else "dzengi-api" ) return TickerPrice( symbol=symbol, price=float(price_raw), source=source, updated_at=updated_at, )