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