From 1deb6765858382ea86e116cba13e07159ac25f9e Mon Sep 17 00:00:00 2001 From: Sergey Date: Tue, 14 Apr 2026 18:12:41 +0300 Subject: [PATCH] Stage 03.5 - private account balance and portfolio UI --- app/src/core/system_status.py | 9 ++ .../integrations/exchange/balance_parser.py | 67 ++++++++ app/src/integrations/exchange/models.py | 6 + .../integrations/exchange/private_client.py | 11 +- app/src/integrations/exchange/rest_client.py | 36 +++-- app/src/integrations/exchange/service.py | 79 +++++---- app/src/telegram/handlers/portfolio.py | 135 +++++++++++++++- docs/decisions/0008-private-account-first.md | 86 ++++++++++ docs/stages/stage-03-5-account-balance.md | 151 ++++++++++++++++++ 9 files changed, 532 insertions(+), 48 deletions(-) create mode 100644 app/src/integrations/exchange/balance_parser.py create mode 100644 docs/decisions/0008-private-account-first.md create mode 100644 docs/stages/stage-03-5-account-balance.md diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index 10b7d24..29f5785 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -27,6 +27,7 @@ class SystemSnapshot: exchange_name: str default_symbol: str symbol_validation_message: str + private_auth_message: str components: list[ComponentStatus] @@ -43,6 +44,7 @@ def get_system_snapshot() -> SystemSnapshot: symbol_validation_message = symbol_validation.message exchange_health = exchange_service.get_health() + private_auth_health = exchange_service.get_private_auth_health() if exchange_health.ok and exchange_health.mode == "mock": exchange_state = "🟡 mock mode" @@ -52,6 +54,7 @@ def get_system_snapshot() -> SystemSnapshot: exchange_state = "🔴 ошибка" symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка" + private_auth_state = "🟢 OK" if private_auth_health.ok else "🔴 ошибка" components = [ ComponentStatus( @@ -74,6 +77,11 @@ def get_system_snapshot() -> SystemSnapshot: state=symbol_state, details=symbol_validation_message, ), + ComponentStatus( + name="Авторизация", + state=private_auth_state, + details=private_auth_health.message, + ), ComponentStatus( name="База данных", state="🟡 не подключена", @@ -92,6 +100,7 @@ def get_system_snapshot() -> SystemSnapshot: exchange_name=settings.exchange_name, default_symbol=settings.default_symbol, symbol_validation_message=symbol_validation_message, + private_auth_message=private_auth_health.message, components=components, ) diff --git a/app/src/integrations/exchange/balance_parser.py b/app/src/integrations/exchange/balance_parser.py new file mode 100644 index 0000000..a630d45 --- /dev/null +++ b/app/src/integrations/exchange/balance_parser.py @@ -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 diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py index e1ef26f..b9c9923 100644 --- a/app/src/integrations/exchange/models.py +++ b/app/src/integrations/exchange/models.py @@ -45,3 +45,9 @@ class SymbolValidationResult: is_valid: bool message: str symbol_info: ExchangeSymbol | None + + +@dataclass(slots=True) +class PrivateAuthHealth: + ok: bool + message: str diff --git a/app/src/integrations/exchange/private_client.py b/app/src/integrations/exchange/private_client.py index adc7982..b67a3ea 100644 --- a/app/src/integrations/exchange/private_client.py +++ b/app/src/integrations/exchange/private_client.py @@ -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( diff --git a/app/src/integrations/exchange/rest_client.py b/app/src/integrations/exchange/rest_client.py index a23149d..bea9d9e 100644 --- a/app/src/integrations/exchange/rest_client.py +++ b/app/src/integrations/exchange/rest_client.py @@ -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 \ No newline at end of file diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 941549c..51a50db 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -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, - ) \ No newline at end of file + ) diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index c133f28..a0cb54e 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -1,12 +1,143 @@ +from __future__ import annotations + from aiogram import F, Router from aiogram.types import Message -from src.telegram.menus import PORTFOLIO_TEXT +from src.integrations.exchange.exceptions import ExchangeError +from src.integrations.exchange.models import BalanceSummary +from src.integrations.exchange.service import ExchangeService router = Router(name="portfolio") +FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"} + +CURRENCY_ICONS = { + "USD": "💵", + "USDT": "💵", + "EUR": "💶", + "RUB": "₽", + "BYN": "Br", + "BTC": "₿", + "ETH": "Ξ", + "BNB": "🟡", + "SOL": "◎", + "ADA": "🔵", + "XRP": "✕", + "DOGE": "🐶", +} + +PINNED_ORDER = { + "USD": 1, + "USDT": 2, + "BTC": 3, + "ETH": 4, +} + + +def format_amount(currency: str, value: float) -> str: + if currency.upper() in FIAT_CURRENCIES: + return f"{value:,.2f}".replace(",", " ") + return f"{value:,.8f}".replace(",", " ") + + +def get_currency_label(currency: str) -> str: + icon = CURRENCY_ICONS.get(currency.upper(), "💰") + return f"{icon} {currency.upper()}" + + +def is_zero_balance(item: BalanceSummary) -> bool: + return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12 + + +def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: + def sort_key(item: BalanceSummary) -> tuple[int, str]: + currency = item.currency.upper() + priority = PINNED_ORDER.get(currency, 999) + return (priority, currency) + + return sorted(items, key=sort_key) + + +def split_balances(items: list[BalanceSummary]) -> tuple[list[BalanceSummary], list[BalanceSummary]]: + major: list[BalanceSummary] = [] + other: list[BalanceSummary] = [] + + for item in items: + if item.currency.upper() in PINNED_ORDER: + major.append(item) + else: + other.append(item) + + return major, other + + +def render_balance_block(item: BalanceSummary) -> list[str]: + total = item.available + item.locked + + return [ + f"{get_currency_label(item.currency)}", + f"• доступно: {format_amount(item.currency, item.available)}", + f"• заблокировано: {format_amount(item.currency, item.locked)}", + f"• всего: {format_amount(item.currency, total)}", + "", + ] + + @router.message(F.text == "💼 Портфель") async def open_portfolio(message: Message) -> None: - await message.answer(PORTFOLIO_TEXT) + service = ExchangeService() + + try: + balances = service.get_balance_summary() + except ExchangeError as exc: + await message.answer( + "💼 Портфель\n\n" + "Не удалось получить баланс с private API.\n" + f"Ошибка: {exc}" + ) + return + + if not balances: + await message.answer( + "💼 Портфель\n\n" + "Баланс пуст." + ) + return + + visible_balances = [item for item in balances if not is_zero_balance(item)] + visible_balances = sort_balances(visible_balances) + + if not visible_balances: + await message.answer( + "💼 Портфель\n\n" + "Все балансы нулевые." + ) + return + + major_balances, other_balances = split_balances(visible_balances) + + lines: list[str] = ["💼 Портфель", "", "Баланс аккаунта", ""] + + if major_balances: + lines.append("Основные активы") + lines.append("") + for item in major_balances: + lines.extend(render_balance_block(item)) + + if other_balances: + lines.append("Прочие активы") + lines.append("") + for item in other_balances: + lines.extend(render_balance_block(item)) + + lines.extend( + [ + "Итого", + f"• активов с ненулевым балансом: {len(visible_balances)}", + ] + ) + + text = "\n".join(lines).rstrip() + await message.answer(text) diff --git a/docs/decisions/0008-private-account-first.md b/docs/decisions/0008-private-account-first.md new file mode 100644 index 0000000..3e9d1b7 --- /dev/null +++ b/docs/decisions/0008-private-account-first.md @@ -0,0 +1,86 @@ +# 0008 — Private API starts from Account Balance (Stable) + +## Решение +Начать интеграцию private API с получения баланса аккаунта (`/api/v2/account`), а не с ордеров. + +--- + +## Причины + +### 1. Безопасность +- чтение баланса не изменяет состояние системы +- отсутствует риск случайной торговли + +--- + +### 2. Простота отладки +Позволяет проверить: +- API ключ +- подпись (HMAC) +- headers +- timestamp + +--- + +### 3. Быстрый UX результат +Пользователь сразу видит: +- баланс +- активы + +--- + +### 4. База для следующих этапов +Баланс используется в: +- расчёте риска +- позициях +- ордерах +- журнале + +--- + +## Реализация + +Добавлено: +- ExchangePrivateClient +- balance_parser +- get_balance_summary() +- get_private_auth_health() +- экран 💼 Портфель +- статус авторизации в ⚙️ Система + +--- + +## Последствия + +### Положительные +✔ быстрый результат +✔ безопасная интеграция +✔ понятный UI +✔ устойчивая архитектура + +--- + +### Отрицательные +- не покрывает сразу торговый сценарий +- требует следующего этапа (orders) + +--- + +## Альтернативы + +### Начать с ордеров +❌ отклонено: +- риск +- сложность +- сложнее отлаживать + +--- + +## Статус +Stable + +--- + +## Следующий шаг + +➡ Stage 03.6 — Orders skeleton diff --git a/docs/stages/stage-03-5-account-balance.md b/docs/stages/stage-03-5-account-balance.md new file mode 100644 index 0000000..4485f66 --- /dev/null +++ b/docs/stages/stage-03-5-account-balance.md @@ -0,0 +1,151 @@ +# Stage 03.5 — Private Account + Portfolio UI (Stable) + +## Цель +Реализовать первый реальный private API запрос и вывести баланс пользователя в Telegram. + +--- + +## Что добавлено + +### 1. Private API endpoint +Используется: +- `GET /api/v2/account` + +Особенности: +- требуется `timestamp` +- требуется `signature` +- требуется header `X-MBX-APIKEY` + +--- + +### 2. Private client +Добавлен: +- `ExchangePrivateClient` + +Функции: +- подпись запроса (HMAC SHA256) +- формирование headers +- отправка signed-запроса + +--- + +### 3. Парсер баланса +Добавлен: +- `balance_parser.py` + +Особенности: +- поддерживает разные форматы ответа API: + - `balances` + - `payload.balances` + - `payload` + - `assets` +- безопасный парсинг чисел +- защита от "грязных" данных API + +--- + +### 4. ExchangeService + +Добавлено: + +- `get_balance_summary()` +- `get_private_auth_health()` + +Функции: +- получение баланса +- проверка private API +- централизованная обработка ошибок + +--- + +### 5. Telegram — экран 💼 Портфель +Добавлено: +- отображение баланса пользователя + +--- + +### 6. UX улучшения (Stage 03.5+) + +Экран портфеля теперь: + +- показывает значки валют (₿, 💵, Ξ и др.) +- скрывает нулевые балансы +- сортирует активы +- выделяет основные активы: + - USD + - USDT + - BTC + - ETH +- разбивает на блоки: + - основные активы + - прочие активы +- показывает итоговое количество активов + +--- + +### 7. Экран ⚙️ Система + +Добавлено: + +🟢 Авторизация +— Private API OK. Балансов получено: N + +или + +🔴 Авторизация +— ошибка подписи / ключа / доступа + +--- + +## Как работает + +### EXCHANGE_ENABLED=false +- используется mock режим +- баланс берётся из mock + +### EXCHANGE_ENABLED=true + +Flow: + +1. Проверка ключей +2. Формирование signed запроса +3. Запрос `/api/v2/account` +4. Парсинг ответа +5. Вывод в Telegram + +--- + +## Что теперь гарантируется + +✔ private API реально работает +✔ ошибки авторизации видны пользователю +✔ бот не падает при нестабильном API +✔ баланс отображается корректно +✔ UX пригоден для реального использования + +--- + +## Ограничения + +Пока НЕ реализовано: + +- ордера +- торговля +- PnL +- оценка портфеля в USD +- кэширование +- rate limit handling + +--- + +## Рекомендуемый commit + +Stage 03.5 - private account balance and portfolio UI + +--- + +## Следующий этап + +➡ Stage 03.6 — Orders skeleton +или +➡ Stage 04 — Storage / Journal