Files
dzentra_bot/app/src/integrations/exchange/service.py

373 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)