373 lines
13 KiB
Python
373 lines
13 KiB
Python
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,
|
||
) |