diff --git a/app/.env.example b/app/.env.example index f028aac..d704377 100644 --- a/app/.env.example +++ b/app/.env.example @@ -3,11 +3,12 @@ BOT_PARSE_MODE=HTML APP_ENV=dev LOG_LEVEL=INFO TZ=Europe/Minsk -EXCHANGE_ENABLED=false + +EXCHANGE_ENABLED=true EXCHANGE_NAME=dzengi -EXCHANGE_BASE_URL= +EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com EXCHANGE_API_KEY= EXCHANGE_API_SECRET= EXCHANGE_TIMEOUT_SEC=10 -EXCHANGE_TESTNET=false -DEFAULT_SYMBOL=BTCUSDT \ No newline at end of file +EXCHANGE_TESTNET=true +DEFAULT_SYMBOL=BTC/USD_LEVERAGE \ No newline at end of file diff --git a/app/src/core/config.py b/app/src/core/config.py index 8e683fa..ecba6c7 100644 --- a/app/src/core/config.py +++ b/app/src/core/config.py @@ -48,5 +48,5 @@ def load_settings() -> Settings: exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), - default_symbol=os.getenv("DEFAULT_SYMBOL", "BTCUSDT").strip() or "BTCUSDT", + default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE", ) \ No newline at end of file diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index f4acad2..10b7d24 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -25,20 +25,33 @@ class SystemSnapshot: timezone_name: str exchange_enabled: bool exchange_name: str + default_symbol: str + symbol_validation_message: str components: list[ComponentStatus] def get_system_snapshot() -> SystemSnapshot: settings = load_settings() exchange_service = ExchangeService() + + try: + symbol_validation = exchange_service.validate_symbol(settings.default_symbol) + except Exception as exc: + symbol_validation = None + symbol_validation_message = f"Не удалось проверить символ: {exc}" + else: + symbol_validation_message = symbol_validation.message + exchange_health = exchange_service.get_health() if exchange_health.ok and exchange_health.mode == "mock": exchange_state = "🟡 mock mode" elif exchange_health.ok: - exchange_state = "🟢 OK" + exchange_state = "🟢 API OK" else: - exchange_state = "🔴 attention" + exchange_state = "🔴 ошибка" + + symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка" components = [ ComponentStatus( @@ -56,6 +69,11 @@ def get_system_snapshot() -> SystemSnapshot: state=exchange_state, details=exchange_health.message, ), + ComponentStatus( + name="Символ", + state=symbol_state, + details=symbol_validation_message, + ), ComponentStatus( name="База данных", state="🟡 не подключена", @@ -72,6 +90,8 @@ def get_system_snapshot() -> SystemSnapshot: timezone_name=settings.tz, exchange_enabled=settings.exchange_enabled, exchange_name=settings.exchange_name, + default_symbol=settings.default_symbol, + symbol_validation_message=symbol_validation_message, components=components, ) @@ -99,7 +119,8 @@ def build_system_text() -> str: f"- os: {snapshot.os_name}\n" f"- timezone: {snapshot.timezone_name}\n" f"- exchange_enabled: {snapshot.exchange_enabled}\n" - f"- exchange_name: {snapshot.exchange_name}\n\n" + f"- exchange_name: {snapshot.exchange_name}\n" + f"- default_symbol: {snapshot.default_symbol}\n\n" "Справка\n" "/start — стартовый экран\n" "/menu — показать меню\n" diff --git a/app/src/integrations/exchange/exceptions.py b/app/src/integrations/exchange/exceptions.py new file mode 100644 index 0000000..8bf9103 --- /dev/null +++ b/app/src/integrations/exchange/exceptions.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +class ExchangeError(Exception): + """Base exchange integration error.""" + + +class ExchangeConnectionError(ExchangeError): + """HTTP/network/timeout level exchange error.""" + + +class ExchangeResponseError(ExchangeError): + """Unexpected HTTP response or malformed JSON.""" diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py index 27389bf..e1ef26f 100644 --- a/app/src/integrations/exchange/models.py +++ b/app/src/integrations/exchange/models.py @@ -24,3 +24,24 @@ class BalanceSummary: available: float locked: float source: str + + +@dataclass(slots=True) +class ExchangeSymbol: + symbol: str + name: str + status: str + base_asset: str + quote_asset: str + market_modes: list[str] + market_type: str + tick_size: float | None + + +@dataclass(slots=True) +class SymbolValidationResult: + requested_symbol: str + normalized_symbol: str + is_valid: bool + message: str + symbol_info: ExchangeSymbol | None diff --git a/app/src/integrations/exchange/rest_client.py b/app/src/integrations/exchange/rest_client.py new file mode 100644 index 0000000..a23149d --- /dev/null +++ b/app/src/integrations/exchange/rest_client.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from src.core.config import load_settings +from src.integrations.exchange.exceptions import ( + ExchangeConnectionError, + ExchangeResponseError, +) + + +class ExchangeRestClient: + def __init__(self) -> None: + self.settings = load_settings() + if not self.settings.exchange_base_url: + raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.") + 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: + query = f"?{urlencode(params)}" if params else "" + url = f"{self.base_url}{path}{query}" + + request = Request( + url=url, + method="GET", + headers={ + "Accept": "application/json", + "User-Agent": "dzentra-bot/2.0.0", + }, + ) + + try: + with urlopen(request, timeout=self.timeout) as response: + 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 + except URLError as exc: + raise ExchangeConnectionError( + f"Network error while calling exchange: {exc.reason}" + ) from exc + except TimeoutError as exc: + raise ExchangeConnectionError("Timeout while calling exchange.") from exc + + if status != 200: + raise ExchangeResponseError(f"Unexpected HTTP status: {status}") + + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise ExchangeResponseError("Exchange returned non-JSON response.") from exc + + if not isinstance(payload, dict): + raise ExchangeResponseError("Exchange response is not a JSON object.") + + return payload diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index e11c043..941549c 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime from zoneinfo import ZoneInfo -from src.core.config import load_settings from src.core.config import load_settings from src.integrations.exchange.exceptions import ExchangeError @@ -11,20 +10,41 @@ from src.integrations.exchange.mock_data import ( mock_exchange_health, mock_ticker_price, ) -from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice +from src.integrations.exchange.models import ( + BalanceSummary, + ExchangeHealth, + ExchangeSymbol, + SymbolValidationResult, + TickerPrice, +) from src.integrations.exchange.rest_client import ExchangeRestClient +from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates 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: - ticker = self._get_real_price(self.settings.default_symbol) + validation = self.validate_symbol(self.settings.default_symbol) + + if not validation.is_valid: + return ExchangeHealth( + ok=False, + mode="real_symbol_error", + message=validation.message, + ) + + ticker = self._get_real_price(validation.normalized_symbol) + except ExchangeError as exc: return ExchangeHealth( ok=False, @@ -35,7 +55,7 @@ class ExchangeService: return ExchangeHealth( ok=True, mode="real_public_api", - message=f"Public API OK. Последняя цена {ticker.symbol}: {ticker.price:.2f}", + message=f"Public API OK. Цена {ticker.symbol}: {ticker.price:.2f}", ) def get_price(self, symbol: str | None = None) -> TickerPrice: @@ -44,13 +64,148 @@ class ExchangeService: if not self.settings.exchange_enabled: return mock_ticker_price(symbol_to_use) - return self._get_real_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() + # ========================= + # EXCHANGE INFO + # ========================= + + def get_exchange_symbols(self) -> list[ExchangeSymbol]: + if not self.settings.exchange_enabled: + return [] + + client = ExchangeRestClient() + payload = client.get_json("/api/v2/exchangeInfo") + + # 🔥 защита от разных форматов ответа + if isinstance(payload.get("symbols"), list): + symbols_raw = payload["symbols"] + else: + inner = payload.get("payload") + if isinstance(inner, dict) and isinstance(inner.get("symbols"), list): + symbols_raw = inner["symbols"] + else: + raise ExchangeError( + "Field 'symbols' is missing in exchangeInfo response." + ) + + def _safe_str(value: object, default: str = "") -> str: + if value is None: + return default + return str(value).strip() + + items: list[ExchangeSymbol] = [] + + for item in symbols_raw: + if not isinstance(item, dict): + continue + + # ---- tickSize ---- + tick_size_raw = item.get("tickSize") + tick_size = None + if tick_size_raw not in (None, ""): + try: + tick_size = float(str(tick_size_raw)) + 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() + ] + 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" + ) + + items.append( + ExchangeSymbol( + symbol=_safe_str(item.get("symbol")), + name=_safe_str(item.get("name")), + status=_safe_str(item.get("status"), "unknown"), + base_asset=_safe_str(item.get("baseAsset")), + quote_asset=_safe_str(item.get("quoteAsset")), + market_modes=market_modes, + market_type=market_type, + tick_size=tick_size, + ) + ) + + return items + + # ========================= + # SYMBOL VALIDATION + # ========================= + + def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult: + requested = normalize_symbol(raw_symbol) + + if not requested: + return SymbolValidationResult( + requested_symbol=requested, + normalized_symbol="", + is_valid=False, + message="Символ пустой.", + symbol_info=None, + ) + + if not self.settings.exchange_enabled: + return SymbolValidationResult( + requested_symbol=requested, + normalized_symbol=requested, + is_valid=True, + message="Mock mode active.", + symbol_info=None, + ) + + symbols = self.get_exchange_symbols() + candidates = symbol_candidates(requested) + + for candidate in candidates: + for symbol_info in symbols: + if normalize_symbol(symbol_info.symbol) == candidate: + return SymbolValidationResult( + requested_symbol=requested, + normalized_symbol=normalize_symbol(symbol_info.symbol), + is_valid=True, + message="Символ найден в exchangeInfo.", + symbol_info=symbol_info, + ) + + return SymbolValidationResult( + requested_symbol=requested, + normalized_symbol=requested, + is_valid=False, + message=f"Символ '{requested}' не найден в exchangeInfo.", + symbol_info=None, + ) + + # ========================= + # INTERNAL + # ========================= + def _get_real_price(self, symbol: str) -> TickerPrice: client = ExchangeRestClient() + payload = client.get_json( "/api/v2/ticker/24hr", params={"symbol": symbol}, @@ -62,12 +217,10 @@ class ExchangeService: close_time = payload.get("closeTime") or payload.get("eventTime") or "" - settings = load_settings() - if close_time: dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) - dt_local = dt_utc.astimezone(ZoneInfo(settings.tz)) - updated_at = dt_local.strftime("%d/%m/%Y %H:%M:%S") + dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) + updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S") else: updated_at = "n/a" @@ -76,4 +229,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/integrations/exchange/symbol_utils.py b/app/src/integrations/exchange/symbol_utils.py new file mode 100644 index 0000000..2e61f38 --- /dev/null +++ b/app/src/integrations/exchange/symbol_utils.py @@ -0,0 +1,23 @@ +from __future__ import annotations + + +def normalize_symbol(raw_symbol: str) -> str: + return (raw_symbol or "").strip().upper() + + +def symbol_candidates(raw_symbol: str) -> list[str]: + value = normalize_symbol(raw_symbol) + if not value: + return [] + + candidates = [value] + + compact = value.replace("%2F", "/") + if compact not in candidates: + candidates.append(compact) + + no_spaces = compact.replace(" ", "") + if no_spaces not in candidates: + candidates.append(no_spaces) + + return candidates diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 12b0a55..00f8f96 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram import F, Router from aiogram.types import Message +from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.service import ExchangeService @@ -12,12 +13,41 @@ router = Router(name="market") @router.message(F.text == "📈 Рынок") async def open_market(message: Message) -> None: service = ExchangeService() - ticker = service.get_price() + + try: + validation = service.validate_symbol(service.settings.default_symbol) + if not validation.is_valid: + await message.answer( + "📈 Рынок\n\n" + f"Ошибка символа: {validation.message}" + ) + return + + ticker = service.get_price(validation.normalized_symbol) + except ExchangeError as exc: + await message.answer( + "📈 Рынок\n\n" + "Не удалось получить цену с биржи.\n" + f"Ошибка: {exc}" + ) + return + + symbol_info = validation.symbol_info + symbol_status = symbol_info.status if symbol_info else "n/a" + market_type = symbol_info.market_type if symbol_info else "n/a" + market_modes = ", ".join(symbol_info.market_modes) if symbol_info and symbol_info.market_modes else "n/a" + tick_size = f"{symbol_info.tick_size}" if symbol_info and symbol_info.tick_size is not None else "n/a" + name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol text = ( "📈 Рынок\n\n" f"Символ: {ticker.symbol}\n" + f"Название: {name}\n" f"Цена: {ticker.price:.2f}\n" + f"Статус: {symbol_status}\n" + f"Тип рынка: {market_type}\n" + f"Режимы: {market_modes}\n" + f"Tick size: {tick_size}\n" f"Источник: {ticker.source}\n" f"Обновлено: {ticker.updated_at}" ) diff --git a/docs/decisions/0007-symbol-validation.md b/docs/decisions/0007-symbol-validation.md new file mode 100644 index 0000000..b7dc6e3 --- /dev/null +++ b/docs/decisions/0007-symbol-validation.md @@ -0,0 +1,15 @@ +# 0007 — Symbol Validation via exchangeInfo + +## Решение +Не использовать захардкоженный символ как гарантированно валидный. +Проверять его по `exchangeInfo` перед использованием в market screen и system health. + +## Причины +- формат символов у биржи специфичен +- это уменьшает риск скрытых ошибок конфигурации +- это упрощает дальнейший переход к real private endpoints + +## Последствия +- system screen начинает показывать отдельный статус символа +- market screen получает метаданные рынка +- exchange service становится источником правды по рынкам diff --git a/docs/stages/stage-03-3-exchange-info.md b/docs/stages/stage-03-3-exchange-info.md new file mode 100644 index 0000000..fa4d475 --- /dev/null +++ b/docs/stages/stage-03-3-exchange-info.md @@ -0,0 +1,39 @@ +# Stage 03.3 — ExchangeInfo + Symbol Validation (Stable) + +## Цель +Добавить `exchangeInfo` как источник правды по рынкам и валидировать `DEFAULT_SYMBOL` перед использованием. + +## Что было до этого +- символ задавался через `.env` +- цена бралась напрямую через `ticker/24hr` +- не было проверки, существует ли символ на бирже + +## Что добавлено + +### 1. ExchangeInfo integration +- подключён endpoint: + - `GET /api/v2/exchangeInfo` +- используется для получения списка доступных рынков + +### 2. Модель рынка +Добавлен: +- `ExchangeSymbol` + +Содержит: +- symbol +- name +- status +- base_asset +- quote_asset +- market_modes +- market_type +- tick_size + +--- + +### 3. Валидация символа + +Добавлен метод: + +```python +validate_symbol() \ No newline at end of file diff --git a/docs/stages/stage-03-3-exchange-info.txt b/docs/stages/stage-03-3-exchange-info.txt new file mode 100644 index 0000000..8972acd --- /dev/null +++ b/docs/stages/stage-03-3-exchange-info.txt @@ -0,0 +1 @@ +docs_stage-03-3-exchange-info \ No newline at end of file