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