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

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

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

View File

@@ -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"<b>{get_currency_label(item.currency)}</b>",
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(
"<b>💼 Портфель</b>\n\n"
"Не удалось получить баланс с private API.\n"
f"Ошибка: {exc}"
)
return
if not balances:
await message.answer(
"<b>💼 Портфель</b>\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(
"<b>💼 Портфель</b>\n\n"
"Все балансы нулевые."
)
return
major_balances, other_balances = split_balances(visible_balances)
lines: list[str] = ["<b>💼 Портфель</b>", "", "<b>Баланс аккаунта</b>", ""]
if major_balances:
lines.append("<b>Основные активы</b>")
lines.append("")
for item in major_balances:
lines.extend(render_balance_block(item))
if other_balances:
lines.append("<b>Прочие активы</b>")
lines.append("")
for item in other_balances:
lines.extend(render_balance_block(item))
lines.extend(
[
"<b>Итого</b>",
f"• активов с ненулевым балансом: {len(visible_balances)}",
]
)
text = "\n".join(lines).rstrip()
await message.answer(text)

View File

@@ -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

View File

@@ -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