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

@@ -0,0 +1,67 @@
from __future__ import annotations
from src.integrations.exchange.models import BalanceSummary
def _safe_float(value: object, default: float = 0.0) -> float:
try:
return float(str(value))
except (TypeError, ValueError):
return default
def parse_account_balances(payload: dict) -> list[BalanceSummary]:
# expected shapes seen in real APIs vary a lot
# support:
# 1) {"balances": [...]}
# 2) {"payload": {"balances": [...]}}
# 3) {"payload": [...]}
# 4) {"assets": [...]}
balances_raw = None
if isinstance(payload.get("balances"), list):
balances_raw = payload["balances"]
else:
inner = payload.get("payload")
if isinstance(inner, dict) and isinstance(inner.get("balances"), list):
balances_raw = inner["balances"]
elif isinstance(inner, list):
balances_raw = inner
elif isinstance(payload.get("assets"), list):
balances_raw = payload["assets"]
if not isinstance(balances_raw, list):
return []
items: list[BalanceSummary] = []
for item in balances_raw:
if not isinstance(item, dict):
continue
currency = (
str(item.get("asset") or item.get("currency") or item.get("code") or "")
.strip()
.upper()
)
if not currency:
continue
available = _safe_float(
item.get("free", item.get("available", item.get("amount", 0.0)))
)
locked = _safe_float(
item.get("locked", item.get("hold", item.get("reserved", 0.0)))
)
items.append(
BalanceSummary(
currency=currency,
available=available,
locked=locked,
source="dzengi-private-api",
)
)
return items

View File

@@ -45,3 +45,9 @@ class SymbolValidationResult:
is_valid: bool
message: str
symbol_info: ExchangeSymbol | None
@dataclass(slots=True)
class PrivateAuthHealth:
ok: bool
message: str

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
from src.core.config import load_settings
from src.integrations.exchange.rest_client import ExchangeRestClient
from src.integrations.exchange.auth import ExchangeAuth
from src.integrations.exchange.rest_client import ExchangeRestClient
class ExchangePrivateClient:
def __init__(self):
def __init__(self) -> None:
settings = load_settings()
self.client = ExchangeRestClient()
self.auth = ExchangeAuth(
@@ -13,8 +14,10 @@ class ExchangePrivateClient:
api_secret=settings.exchange_api_secret,
)
def get_account_info(self):
params = {}
def get_account_info(self, show_zero_balance: bool = False) -> dict:
params = {
"showZeroBalance": str(show_zero_balance).lower(),
}
signed = self.auth.build_signed_params(params)
return self.client.get_json(

View File

@@ -20,17 +20,27 @@ class ExchangeRestClient:
self.base_url = self.settings.exchange_base_url.rstrip("/")
self.timeout = self.settings.exchange_timeout_sec
def get_json(self, path: str, params: dict[str, str] | None = None) -> dict:
def get_json(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> dict:
query = f"?{urlencode(params)}" if params else ""
url = f"{self.base_url}{path}{query}"
request_headers = {
"Accept": "application/json",
"User-Agent": "dzentra-bot/2.0.0",
}
if headers:
request_headers.update(headers)
request = Request(
url=url,
method="GET",
headers={
"Accept": "application/json",
"User-Agent": "dzentra-bot/2.0.0",
},
headers=request_headers,
)
try:
@@ -38,9 +48,17 @@ class ExchangeRestClient:
status = getattr(response, "status", 200)
body = response.read().decode("utf-8")
except HTTPError as exc:
raise ExchangeResponseError(
f"HTTP {exc.code} from exchange: {exc.reason}"
) from exc
error_body = ""
try:
error_body = exc.read().decode("utf-8")
except Exception:
pass
message = f"HTTP {exc.code} from exchange: {exc.reason}"
if error_body:
message += f" | body: {error_body}"
raise ExchangeResponseError(message) from exc
except URLError as exc:
raise ExchangeConnectionError(
f"Network error while calling exchange: {exc.reason}"
@@ -59,4 +77,4 @@ class ExchangeRestClient:
if not isinstance(payload, dict):
raise ExchangeResponseError("Exchange response is not a JSON object.")
return payload
return payload

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,
)
)