Stage 03.5 - private account balance and portfolio UI

This commit is contained in:
2026-04-14 18:12:41 +03:00
parent 96998ee998
commit 1deb676585
9 changed files with 532 additions and 48 deletions

View File

@@ -4,6 +4,7 @@ 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,
@@ -14,9 +15,11 @@ 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
@@ -25,17 +28,12 @@ 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:
validation = self.validate_symbol(self.settings.default_symbol)
if not validation.is_valid:
return ExchangeHealth(
ok=False,
@@ -44,7 +42,6 @@ class ExchangeService:
)
ticker = self._get_real_price(validation.normalized_symbol)
except ExchangeError as exc:
return ExchangeHealth(
ok=False,
@@ -58,6 +55,33 @@ class ExchangeService:
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
@@ -65,18 +89,26 @@ class ExchangeService:
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_balance_summary(self) -> list[BalanceSummary]:
return mock_balance_summary()
if not self.settings.exchange_enabled:
return mock_balance_summary()
# =========================
# EXCHANGE INFO
# =========================
auth_health = self.get_private_auth_health()
if not auth_health.ok:
raise ExchangeError(auth_health.message)
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
balances = parse_account_balances(payload)
if not balances:
raise ExchangeError("Баланс получен, но список активов пуст или не распознан.")
return balances
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
if not self.settings.exchange_enabled:
@@ -85,7 +117,6 @@ class ExchangeService:
client = ExchangeRestClient()
payload = client.get_json("/api/v2/exchangeInfo")
# 🔥 защита от разных форматов ответа
if isinstance(payload.get("symbols"), list):
symbols_raw = payload["symbols"]
else:
@@ -108,7 +139,6 @@ class ExchangeService:
if not isinstance(item, dict):
continue
# ---- tickSize ----
tick_size_raw = item.get("tickSize")
tick_size = None
if tick_size_raw not in (None, ""):
@@ -117,25 +147,16 @@ class ExchangeService:
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()
]
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"
)
market_type = str(market_type_raw).strip() if market_type_raw is not None else "unknown"
items.append(
ExchangeSymbol(
@@ -152,10 +173,6 @@ class ExchangeService:
return items
# =========================
# SYMBOL VALIDATION
# =========================
def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
requested = normalize_symbol(raw_symbol)
@@ -199,10 +216,6 @@ class ExchangeService:
symbol_info=None,
)
# =========================
# INTERNAL
# =========================
def _get_real_price(self, symbol: str) -> TickerPrice:
client = ExchangeRestClient()
@@ -229,4 +242,4 @@ class ExchangeService:
price=float(price_raw),
source="dzengi-demo-api",
updated_at=updated_at,
)
)