add exchangeInfo symbol validation
This commit is contained in:
@@ -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
|
||||||
@@ -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",
|
||||||
)
|
)
|
||||||
@@ -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"
|
||||||
|
|||||||
13
app/src/integrations/exchange/exceptions.py
Normal file
13
app/src/integrations/exchange/exceptions.py
Normal 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."""
|
||||||
@@ -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
|
||||||
|
|||||||
62
app/src/integrations/exchange/rest_client.py
Normal file
62
app/src/integrations/exchange/rest_client.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
23
app/src/integrations/exchange/symbol_utils.py
Normal file
23
app/src/integrations/exchange/symbol_utils.py
Normal 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
|
||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
15
docs/decisions/0007-symbol-validation.md
Normal file
15
docs/decisions/0007-symbol-validation.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 0007 — Symbol Validation via exchangeInfo
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Не использовать захардкоженный символ как гарантированно валидный.
|
||||||
|
Проверять его по `exchangeInfo` перед использованием в market screen и system health.
|
||||||
|
|
||||||
|
## Причины
|
||||||
|
- формат символов у биржи специфичен
|
||||||
|
- это уменьшает риск скрытых ошибок конфигурации
|
||||||
|
- это упрощает дальнейший переход к real private endpoints
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- system screen начинает показывать отдельный статус символа
|
||||||
|
- market screen получает метаданные рынка
|
||||||
|
- exchange service становится источником правды по рынкам
|
||||||
1
docs/stages/stage-03-3-exchange-info.txt
Normal file
1
docs/stages/stage-03-3-exchange-info.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
docs_stage-03-3-exchange-info
|
||||||
Reference in New Issue
Block a user