Stage 03.5 - private account balance and portfolio UI
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
67
app/src/integrations/exchange/balance_parser.py
Normal file
67
app/src/integrations/exchange/balance_parser.py
Normal 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
|
||||
@@ -45,3 +45,9 @@ class SymbolValidationResult:
|
||||
is_valid: bool
|
||||
message: str
|
||||
symbol_info: ExchangeSymbol | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PrivateAuthHealth:
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
86
docs/decisions/0008-private-account-first.md
Normal file
86
docs/decisions/0008-private-account-first.md
Normal 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
|
||||
151
docs/stages/stage-03-5-account-balance.md
Normal file
151
docs/stages/stage-03-5-account-balance.md
Normal 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
|
||||
Reference in New Issue
Block a user