Compare commits

...

1 Commits

Author SHA1 Message Date
00ba553ca9 Stage 03.3 - exchangeInfo and symbol validation 2026-04-14 06:23:18 +03:00
11 changed files with 359 additions and 19 deletions

View File

@@ -3,11 +3,12 @@ BOT_PARSE_MODE=HTML
APP_ENV=dev APP_ENV=dev
LOG_LEVEL=INFO LOG_LEVEL=INFO
TZ=Europe/Minsk TZ=Europe/Minsk
EXCHANGE_ENABLED=false
EXCHANGE_ENABLED=true
EXCHANGE_NAME=dzengi EXCHANGE_NAME=dzengi
EXCHANGE_BASE_URL= EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
EXCHANGE_API_KEY= EXCHANGE_API_KEY=
EXCHANGE_API_SECRET= EXCHANGE_API_SECRET=
EXCHANGE_TIMEOUT_SEC=10 EXCHANGE_TIMEOUT_SEC=10
EXCHANGE_TESTNET=false EXCHANGE_TESTNET=true
DEFAULT_SYMBOL=BTCUSDT DEFAULT_SYMBOL=BTC/USD_LEVERAGE

View File

@@ -48,5 +48,5 @@ def load_settings() -> Settings:
exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(),
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), 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",
) )

View File

@@ -25,20 +25,33 @@ class SystemSnapshot:
timezone_name: str timezone_name: str
exchange_enabled: bool exchange_enabled: bool
exchange_name: str exchange_name: str
default_symbol: str
symbol_validation_message: str
components: list[ComponentStatus] components: list[ComponentStatus]
def get_system_snapshot() -> SystemSnapshot: def get_system_snapshot() -> SystemSnapshot:
settings = load_settings() settings = load_settings()
exchange_service = ExchangeService() 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() exchange_health = exchange_service.get_health()
if exchange_health.ok and exchange_health.mode == "mock": if exchange_health.ok and exchange_health.mode == "mock":
exchange_state = "🟡 mock mode" exchange_state = "🟡 mock mode"
elif exchange_health.ok: elif exchange_health.ok:
exchange_state = "🟢 OK" exchange_state = "🟢 API OK"
else: else:
exchange_state = "🔴 attention" exchange_state = "🔴 ошибка"
symbol_state = "🟢 OK" if symbol_validation and symbol_validation.is_valid else "🔴 ошибка"
components = [ components = [
ComponentStatus( ComponentStatus(
@@ -56,6 +69,11 @@ def get_system_snapshot() -> SystemSnapshot:
state=exchange_state, state=exchange_state,
details=exchange_health.message, details=exchange_health.message,
), ),
ComponentStatus(
name="Символ",
state=symbol_state,
details=symbol_validation_message,
),
ComponentStatus( ComponentStatus(
name="База данных", name="База данных",
state="🟡 не подключена", state="🟡 не подключена",
@@ -72,6 +90,8 @@ def get_system_snapshot() -> SystemSnapshot:
timezone_name=settings.tz, timezone_name=settings.tz,
exchange_enabled=settings.exchange_enabled, exchange_enabled=settings.exchange_enabled,
exchange_name=settings.exchange_name, exchange_name=settings.exchange_name,
default_symbol=settings.default_symbol,
symbol_validation_message=symbol_validation_message,
components=components, components=components,
) )
@@ -99,7 +119,8 @@ def build_system_text() -> str:
f"- os: {snapshot.os_name}\n" f"- os: {snapshot.os_name}\n"
f"- timezone: {snapshot.timezone_name}\n" f"- timezone: {snapshot.timezone_name}\n"
f"- exchange_enabled: {snapshot.exchange_enabled}\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"
"<b>Справка</b>\n" "<b>Справка</b>\n"
"/start — стартовый экран\n" "/start — стартовый экран\n"
"/menu — показать меню\n" "/menu — показать меню\n"

View File

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

View File

@@ -24,3 +24,24 @@ class BalanceSummary:
available: float available: float
locked: float locked: float
source: str 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

View File

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

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.core.config import load_settings from src.core.config import load_settings
from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.exceptions import ExchangeError
@@ -11,20 +10,41 @@ from src.integrations.exchange.mock_data import (
mock_exchange_health, mock_exchange_health,
mock_ticker_price, 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.rest_client import ExchangeRestClient
from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates
class ExchangeService: class ExchangeService:
def __init__(self) -> None: def __init__(self) -> None:
self.settings = load_settings() self.settings = load_settings()
# =========================
# PUBLIC API
# =========================
def get_health(self) -> ExchangeHealth: def get_health(self) -> ExchangeHealth:
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
return mock_exchange_health() return mock_exchange_health()
try: 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: except ExchangeError as exc:
return ExchangeHealth( return ExchangeHealth(
ok=False, ok=False,
@@ -35,7 +55,7 @@ class ExchangeService:
return ExchangeHealth( return ExchangeHealth(
ok=True, ok=True,
mode="real_public_api", 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: def get_price(self, symbol: str | None = None) -> TickerPrice:
@@ -44,13 +64,148 @@ class ExchangeService:
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
return mock_ticker_price(symbol_to_use) 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]: def get_balance_summary(self) -> list[BalanceSummary]:
return mock_balance_summary() 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: def _get_real_price(self, symbol: str) -> TickerPrice:
client = ExchangeRestClient() client = ExchangeRestClient()
payload = client.get_json( payload = client.get_json(
"/api/v2/ticker/24hr", "/api/v2/ticker/24hr",
params={"symbol": symbol}, params={"symbol": symbol},
@@ -62,12 +217,10 @@ class ExchangeService:
close_time = payload.get("closeTime") or payload.get("eventTime") or "" close_time = payload.get("closeTime") or payload.get("eventTime") or ""
settings = load_settings()
if close_time: if close_time:
dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC")) dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC"))
dt_local = dt_utc.astimezone(ZoneInfo(settings.tz)) dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz))
updated_at = dt_local.strftime("%d/%m/%Y %H:%M:%S") updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S")
else: else:
updated_at = "n/a" updated_at = "n/a"
@@ -76,4 +229,4 @@ class ExchangeService:
price=float(price_raw), price=float(price_raw),
source="dzengi-demo-api", source="dzengi-demo-api",
updated_at=updated_at, updated_at=updated_at,
) )

View File

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

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from aiogram import F, Router from aiogram import F, Router
from aiogram.types import Message from aiogram.types import Message
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
@@ -12,12 +13,41 @@ router = Router(name="market")
@router.message(F.text == "📈 Рынок") @router.message(F.text == "📈 Рынок")
async def open_market(message: Message) -> None: async def open_market(message: Message) -> None:
service = ExchangeService() service = ExchangeService()
ticker = service.get_price()
try:
validation = service.validate_symbol(service.settings.default_symbol)
if not validation.is_valid:
await message.answer(
"<b>📈 Рынок</b>\n\n"
f"Ошибка символа: {validation.message}"
)
return
ticker = service.get_price(validation.normalized_symbol)
except ExchangeError as exc:
await message.answer(
"<b>📈 Рынок</b>\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 = ( text = (
"<b>📈 Рынок</b>\n\n" "<b>📈 Рынок</b>\n\n"
f"Символ: <b>{ticker.symbol}</b>\n" f"Символ: <b>{ticker.symbol}</b>\n"
f"Название: {name}\n"
f"Цена: <b>{ticker.price:.2f}</b>\n" f"Цена: <b>{ticker.price:.2f}</b>\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.source}\n"
f"Обновлено: {ticker.updated_at}" f"Обновлено: {ticker.updated_at}"
) )

View File

@@ -0,0 +1,15 @@
# 0007 — Symbol Validation via exchangeInfo
## Решение
Не использовать захардкоженный символ как гарантированно валидный.
Проверять его по `exchangeInfo` перед использованием в market screen и system health.
## Причины
- формат символов у биржи специфичен
- это уменьшает риск скрытых ошибок конфигурации
- это упрощает дальнейший переход к real private endpoints
## Последствия
- system screen начинает показывать отдельный статус символа
- market screen получает метаданные рынка
- exchange service становится источником правды по рынкам

View File

@@ -0,0 +1 @@
docs_stage-03-3-exchange-info