build 039: complete Quotes Feed migration foundation
This commit is contained in:
@@ -1 +1,3 @@
|
||||
"""Package marker."""
|
||||
# app/src/telegram/handlers/__init__.py
|
||||
|
||||
"""Package marker."""
|
||||
@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
|
||||
from src.telegram.ui.common import mode_line
|
||||
from src.trading.auto.service import AutoTradeService
|
||||
@@ -40,10 +41,10 @@ def build_auto_notification_text() -> str:
|
||||
|
||||
|
||||
def _build_signal_notification_text(state, signal: str) -> str:
|
||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
||||
quote = _market_quote(getattr(state, "symbol", None))
|
||||
|
||||
bid_price = _price_from_snapshot(snapshot, "bid_price")
|
||||
ask_price = _price_from_snapshot(snapshot, "ask_price")
|
||||
bid_price = _price_from_quote(quote, "bid_price")
|
||||
ask_price = _price_from_quote(quote, "ask_price")
|
||||
|
||||
side = "Long" if signal == "BUY" else "Short"
|
||||
side_icon = _signal_icon(signal)
|
||||
@@ -85,28 +86,28 @@ def _build_signal_notification_text(state, signal: str) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _price_from_snapshot(
|
||||
snapshot: dict[str, object] | None,
|
||||
def _price_from_quote(
|
||||
quote: Quote | None,
|
||||
key: str,
|
||||
) -> float | None:
|
||||
if snapshot is None:
|
||||
if quote is None:
|
||||
return None
|
||||
|
||||
return safe_float(snapshot.get(key))
|
||||
return safe_float(getattr(quote, key, None))
|
||||
|
||||
|
||||
def _position_current_price(state) -> float | None:
|
||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
||||
quote = _market_quote(getattr(state, "symbol", None))
|
||||
|
||||
if snapshot is not None:
|
||||
if quote is not None:
|
||||
side = str(getattr(state, "position_side", "") or "").upper()
|
||||
|
||||
if side == "LONG":
|
||||
price = snapshot.get("bid_price") or snapshot.get("last_price")
|
||||
price = quote.bid_price or quote.last_price
|
||||
elif side == "SHORT":
|
||||
price = snapshot.get("ask_price") or snapshot.get("last_price")
|
||||
price = quote.ask_price or quote.last_price
|
||||
else:
|
||||
price = snapshot.get("last_price")
|
||||
price = quote.last_price
|
||||
|
||||
parsed = safe_float(price)
|
||||
if parsed is not None:
|
||||
@@ -720,12 +721,15 @@ def _max_reserved_line(state, price: float | None = None) -> str:
|
||||
return f"Маржа · {_format_usd_compact(own_funds_usd)}"
|
||||
|
||||
|
||||
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
|
||||
def _market_quote(symbol: str | None) -> Quote | None:
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
try:
|
||||
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
|
||||
return ExchangeService().get_quote(
|
||||
symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -907,10 +911,10 @@ def _commission_lines_for_position(
|
||||
|
||||
|
||||
def _current_price(symbol: str | None) -> float | None:
|
||||
snapshot = _market_snapshot(symbol)
|
||||
quote = _market_quote(symbol)
|
||||
|
||||
if snapshot is not None:
|
||||
price = snapshot.get("last_price")
|
||||
if quote is not None:
|
||||
price = quote.last_price
|
||||
if price is not None:
|
||||
try:
|
||||
parsed = safe_float(price)
|
||||
@@ -922,25 +926,25 @@ def _current_price(symbol: str | None) -> float | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(ExchangeService().get_price(symbol).price)
|
||||
return float(ExchangeService().get_quote(symbol).last_price)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _signal_entry_price(state) -> float | None:
|
||||
snapshot = _market_snapshot(state.symbol)
|
||||
quote = _market_quote(state.symbol)
|
||||
|
||||
if snapshot is None:
|
||||
if quote is None:
|
||||
return _current_price(state.symbol)
|
||||
|
||||
signal = (state.last_signal or "HOLD").upper()
|
||||
|
||||
if signal == "BUY":
|
||||
price = snapshot.get("ask_price")
|
||||
price = quote.ask_price
|
||||
elif signal == "SELL":
|
||||
price = snapshot.get("bid_price")
|
||||
price = quote.bid_price
|
||||
else:
|
||||
price = snapshot.get("last_price")
|
||||
price = quote.last_price
|
||||
|
||||
if price is None:
|
||||
return None
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.debug.service import DebugTradeService
|
||||
|
||||
@@ -113,6 +118,23 @@ def _format_updated_at(value: object) -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, datetime):
|
||||
current = value
|
||||
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
|
||||
current = current.astimezone(
|
||||
ZoneInfo(settings.tz),
|
||||
)
|
||||
except Exception:
|
||||
current = current.astimezone()
|
||||
|
||||
return current.strftime("%H:%M:%S")
|
||||
|
||||
text = str(value)
|
||||
|
||||
if " " in text:
|
||||
@@ -121,6 +143,23 @@ def _format_updated_at(value: object) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _quote_age_seconds(quote: object) -> float | None:
|
||||
received_at = getattr(quote, "received_at", None)
|
||||
if not isinstance(received_at, datetime):
|
||||
return None
|
||||
|
||||
if received_at.tzinfo is None:
|
||||
received_at = received_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
return max(
|
||||
0.0,
|
||||
(
|
||||
datetime.now(timezone.utc)
|
||||
- received_at.astimezone(timezone.utc)
|
||||
).total_seconds(),
|
||||
)
|
||||
|
||||
|
||||
def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
if not symbol:
|
||||
return [
|
||||
@@ -141,7 +180,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
error = None
|
||||
|
||||
try:
|
||||
market = ExchangeService().get_market_snapshot(
|
||||
market = ExchangeService().get_quote(
|
||||
symbol,
|
||||
runtime_key="debug_auto",
|
||||
)
|
||||
@@ -167,11 +206,11 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
f"Error · {error or 'unknown'}",
|
||||
]
|
||||
|
||||
last_price = market.get("last_price") if market else getattr(execution, "last_price", None)
|
||||
bid_price = market.get("bid_price") if market else getattr(execution, "bid_price", None)
|
||||
ask_price = market.get("ask_price") if market else getattr(execution, "ask_price", None)
|
||||
market_source = market.get("source") if market else "—"
|
||||
market_age = market.get("age_seconds") if market else None
|
||||
last_price = market.last_price if market else getattr(execution, "last_price", None)
|
||||
bid_price = market.bid_price if market else getattr(execution, "bid_price", None)
|
||||
ask_price = market.ask_price if market else getattr(execution, "ask_price", None)
|
||||
market_source = market.source if market else "—"
|
||||
market_age = _quote_age_seconds(market) if market else None
|
||||
|
||||
execution_source = getattr(execution, "source", "—") if execution else "—"
|
||||
execution_age = getattr(execution, "age_seconds", None) if execution else None
|
||||
@@ -184,7 +223,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
f"Ask · {_format_usd_or_dash(ask_price)}",
|
||||
f"Source · {market_source or '—'}",
|
||||
f"Quote age · {_format_age(market_age)}",
|
||||
f"Exchange time · {_format_updated_at(market.get('updated_at') if market else None)}",
|
||||
f"Exchange time · {_format_updated_at(market.exchange_timestamp if market else None)}",
|
||||
"",
|
||||
"<b>Execution</b>",
|
||||
f"Source · {execution_source or '—'}",
|
||||
@@ -274,7 +313,9 @@ def _format_crypto_size(value: float | int | None) -> str:
|
||||
return f"{float(value):.5f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_money_compact(value: float | int | None) -> str:
|
||||
def _format_money_compact(
|
||||
value: float | int | Decimal | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -286,21 +327,25 @@ def _format_money_compact(value: float | int | None) -> str:
|
||||
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_usd_or_dash(value: float | int | None) -> str:
|
||||
def _format_usd_or_dash(
|
||||
value: float | int | Decimal | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
return f"$ {_format_money_compact(value)}"
|
||||
|
||||
|
||||
def _format_usd_or_off(value: float | int | None) -> str:
|
||||
def _format_usd_or_off(
|
||||
value: float | int | Decimal | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "off"
|
||||
return "Выкл."
|
||||
|
||||
return f"$ {_format_money_compact(value)}"
|
||||
|
||||
|
||||
def _format_signed_usd(value: float | int | None) -> str:
|
||||
def _format_signed_usd(value: float | int | Decimal | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -315,7 +360,7 @@ def _format_signed_usd(value: float | int | None) -> str:
|
||||
return "$ 0"
|
||||
|
||||
|
||||
def _format_age(value: object) -> str:
|
||||
def _format_age(value: NumericLike | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
# app/src/telegram/handlers/market.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InaccessibleMessage,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.exceptions import ExchangeError
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
classify_exchange_error,
|
||||
)
|
||||
from src.telegram.live.active_screen import ActiveScreenManager
|
||||
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
|
||||
from src.telegram.ui.common import mode_line, now_line
|
||||
from src.telegram.ui.currency_ui import format_usd_amount
|
||||
from src.telegram.ui.exchange_error import (
|
||||
show_callback_exchange_error,
|
||||
show_message_exchange_error,
|
||||
)
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="market")
|
||||
|
||||
_last_market_prices: dict[str, float] = {}
|
||||
_last_market_directions: dict[str, str] = {}
|
||||
|
||||
|
||||
def _require_message(callback: CallbackQuery) -> Message | None:
|
||||
message = callback.message
|
||||
|
||||
if message is None or isinstance(message, InaccessibleMessage):
|
||||
return None
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def _market_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
# собрать текст, когда рынок/биржа недоступны через unified status layer
|
||||
def _build_market_status_text(status: ExchangeRuntimeStatus) -> str:
|
||||
icon = "⏸️" if status.code == ExchangeStatusCode.BREAK else "⛔️"
|
||||
|
||||
return (
|
||||
"<b>📈 Рынок</b>\n"
|
||||
f"{mode_line()}"
|
||||
f"{icon} {status.title}\n\n"
|
||||
f"{status.message}\n\n"
|
||||
f"{now_line()}"
|
||||
)
|
||||
|
||||
|
||||
def _build_market_text(
|
||||
*,
|
||||
ticker_price: NumericLike,
|
||||
name: str,
|
||||
market_type: str,
|
||||
base_asset: str,
|
||||
quote_asset: str,
|
||||
) -> str:
|
||||
price = safe_float(ticker_price)
|
||||
|
||||
if price is None:
|
||||
price = 0.0
|
||||
|
||||
previous_price = _last_market_prices.get(name)
|
||||
price_direction = _last_market_directions.get(name, "▲")
|
||||
|
||||
if previous_price is not None:
|
||||
if price > previous_price:
|
||||
price_direction = "🔺"
|
||||
elif price < previous_price:
|
||||
price_direction = "🔻"
|
||||
|
||||
_last_market_prices[name] = price
|
||||
_last_market_directions[name] = price_direction
|
||||
|
||||
type_map = {
|
||||
"LEVERAGE": "leverage",
|
||||
"SPOT": "spot",
|
||||
}
|
||||
market_type_ru = type_map.get(market_type.upper(), market_type.lower())
|
||||
|
||||
return (
|
||||
"<b>📈 Рынок</b>\n"
|
||||
f"{mode_line()}"
|
||||
"\n"
|
||||
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
|
||||
f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
|
||||
f"{now_line()}"
|
||||
)
|
||||
|
||||
|
||||
# live-render должен сам уметь показать ошибку, иначе runner просто потеряет экран
|
||||
def _build_market_live_text() -> str:
|
||||
service = ExchangeService()
|
||||
requested_symbol = service.settings.default_symbol
|
||||
|
||||
try:
|
||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
except Exception as exc:
|
||||
return _build_market_status_text(build_exchange_error_status(exc))
|
||||
|
||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
||||
return _build_market_status_text(runtime_status)
|
||||
|
||||
symbol = runtime_status.symbol or requested_symbol
|
||||
|
||||
validation = service.validate_symbol(symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
return _build_market_status_text(
|
||||
service.get_symbol_runtime_status(requested_symbol)
|
||||
)
|
||||
|
||||
ticker = service.get_price(validation.normalized_symbol)
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
||||
base_asset = (
|
||||
symbol_info.base_asset
|
||||
if symbol_info and symbol_info.base_asset
|
||||
else "n/a"
|
||||
)
|
||||
quote_asset = (
|
||||
symbol_info.quote_asset
|
||||
if symbol_info and symbol_info.quote_asset
|
||||
else "n/a"
|
||||
)
|
||||
name = (
|
||||
symbol_info.name
|
||||
if symbol_info and symbol_info.name
|
||||
else ticker.symbol
|
||||
)
|
||||
|
||||
return _build_market_text(
|
||||
ticker_price=ticker.price,
|
||||
name=name,
|
||||
market_type=market_type,
|
||||
base_asset=base_asset,
|
||||
quote_asset=quote_asset,
|
||||
)
|
||||
|
||||
|
||||
def _register_market_live_screen(message: Message) -> None:
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
LiveScreenRunner.unregister_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
ScreenRegistry.unregister_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
LiveScreenRunner.register_screen(
|
||||
LiveScreen(
|
||||
screen="market",
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
render_text=_build_market_live_text,
|
||||
render_markup=_market_keyboard,
|
||||
interval_seconds=5,
|
||||
)
|
||||
)
|
||||
|
||||
LiveScreenRunner.start("market")
|
||||
|
||||
|
||||
async def _prepare_market_from_message(message: Message) -> bool:
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
return False
|
||||
|
||||
await ActiveScreenManager.prepare_new_screen(
|
||||
screen="market",
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _prepare_market_from_callback(callback: CallbackQuery) -> bool:
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return False
|
||||
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
await callback.answer("Bot недоступен", show_alert=True)
|
||||
return False
|
||||
|
||||
await ActiveScreenManager.prepare_new_screen(
|
||||
screen="market",
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
keep_message_id=message.message_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _send_or_edit_market_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
text: str,
|
||||
edit_mode: bool,
|
||||
) -> None:
|
||||
if edit_mode:
|
||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
||||
_register_market_live_screen(target_message)
|
||||
ActiveScreenManager.register(screen="market", message=target_message)
|
||||
return
|
||||
|
||||
sent_message = await target_message.answer(
|
||||
text,
|
||||
reply_markup=_market_keyboard(),
|
||||
)
|
||||
_register_market_live_screen(sent_message)
|
||||
ActiveScreenManager.register(screen="market", message=sent_message)
|
||||
|
||||
|
||||
async def _render_market_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
user_id: int | None,
|
||||
chat_id: int | None,
|
||||
edit_mode: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
service = ExchangeService()
|
||||
journal = JournalService()
|
||||
requested_symbol = service.settings.default_symbol
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="market_open_requested",
|
||||
message="Запрошено открытие экрана рынка.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={"symbol": requested_symbol},
|
||||
)
|
||||
|
||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
|
||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
||||
journal.log_ui_warning(
|
||||
event_type="market_status_blocked",
|
||||
message=runtime_status.message,
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=runtime_status.as_dict(),
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=_build_market_status_text(runtime_status),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
return
|
||||
|
||||
symbol = runtime_status.symbol or requested_symbol
|
||||
validation = service.validate_symbol(symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
invalid_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
|
||||
journal.log_ui_warning(
|
||||
event_type="market_symbol_invalid",
|
||||
message=invalid_status.message,
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=invalid_status.as_dict(),
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=_build_market_status_text(invalid_status),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
return
|
||||
|
||||
ticker = service.get_price(validation.normalized_symbol)
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
||||
base_asset = (
|
||||
symbol_info.base_asset
|
||||
if symbol_info and symbol_info.base_asset
|
||||
else "n/a"
|
||||
)
|
||||
quote_asset = (
|
||||
symbol_info.quote_asset
|
||||
if symbol_info and symbol_info.quote_asset
|
||||
else "n/a"
|
||||
)
|
||||
name = (
|
||||
symbol_info.name
|
||||
if symbol_info and symbol_info.name
|
||||
else ticker.symbol
|
||||
)
|
||||
|
||||
text = _build_market_text(
|
||||
ticker_price=ticker.price,
|
||||
name=name,
|
||||
market_type=market_type,
|
||||
base_asset=base_asset,
|
||||
quote_asset=quote_asset,
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="market_open_success",
|
||||
message="Экран рынка загружен.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"symbol": ticker.symbol,
|
||||
"price": safe_float(ticker.price),
|
||||
"runtime_status": runtime_status.as_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=text,
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text == "📈 Рынок")
|
||||
async def open_market(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
|
||||
if not await _prepare_market_from_message(message):
|
||||
return
|
||||
|
||||
user_id = message.from_user.id if message.from_user else None
|
||||
chat_id = message.chat.id if message.chat else None
|
||||
|
||||
try:
|
||||
await _render_market_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=False,
|
||||
action="open",
|
||||
)
|
||||
except ExchangeError as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_open_error",
|
||||
message="Не удалось загрузить экран рынка.",
|
||||
screen="market",
|
||||
action="open",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_message_exchange_error(
|
||||
message,
|
||||
title="<b>📈 Рынок</b>",
|
||||
exc=exc,
|
||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
||||
retry_callback_data="market:retry",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "monitoring:market")
|
||||
async def open_market_from_monitoring(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
if not await _prepare_market_from_callback(callback):
|
||||
return
|
||||
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = message.chat.id
|
||||
|
||||
try:
|
||||
await _render_market_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="open_from_monitoring",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except ExchangeError as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_open_error",
|
||||
message="Не удалось загрузить экран рынка из мониторинга.",
|
||||
screen="market",
|
||||
action="open_from_monitoring",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📈 Рынок</b>",
|
||||
exc=exc,
|
||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
||||
retry_callback_data="market:retry",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "market:retry")
|
||||
async def retry_market(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
if not await _prepare_market_from_callback(callback):
|
||||
return
|
||||
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = message.chat.id
|
||||
|
||||
try:
|
||||
await _render_market_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="retry",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except ExchangeError as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_retry_error",
|
||||
message="Не удалось обновить экран рынка.",
|
||||
screen="market",
|
||||
action="retry",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📈 Рынок</b>",
|
||||
exc=exc,
|
||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
||||
retry_callback_data="market:retry",
|
||||
)
|
||||
@@ -3,8 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.exceptions import ExchangeError
|
||||
from src.integrations.exchange.models import BalanceSummary, ExchangeSymbol
|
||||
from src.integrations.exchange.models import BalanceSummary
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
|
||||
|
||||
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
|
||||
@@ -31,7 +32,10 @@ def is_fiat_currency(currency: str) -> bool:
|
||||
|
||||
|
||||
def get_currency_icon(currency: str) -> str:
|
||||
return CURRENCY_ICONS.get(currency.upper(), currency.upper())
|
||||
return CURRENCY_ICONS.get(
|
||||
currency.upper(),
|
||||
currency.upper(),
|
||||
)
|
||||
|
||||
|
||||
def get_currency_label(currency: str) -> str:
|
||||
@@ -45,6 +49,7 @@ def render_currency_title(currency: str) -> str:
|
||||
def format_amount(currency: str, value: float) -> str:
|
||||
if is_fiat_currency(currency):
|
||||
return f"{value:,.2f}".replace(",", " ")
|
||||
|
||||
return f"{value:,.8f}".replace(",", " ")
|
||||
|
||||
|
||||
@@ -52,7 +57,9 @@ def format_usd_amount(value: float) -> str:
|
||||
return f"{value:,.2f}".replace(",", " ")
|
||||
|
||||
|
||||
def format_usd_price(value: float | int | str | None) -> str:
|
||||
def format_usd_price(
|
||||
value: float | int | str | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -62,7 +69,9 @@ def format_usd_price(value: float | int | str | None) -> str:
|
||||
return "—"
|
||||
|
||||
|
||||
def format_usd_pnl(value: float | int | str | None) -> str:
|
||||
def format_usd_pnl(
|
||||
value: float | int | str | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -87,7 +96,10 @@ def render_currency_line(
|
||||
show_code: bool = True,
|
||||
) -> str:
|
||||
icon = get_currency_icon(currency)
|
||||
amount = format_amount(currency, value)
|
||||
amount = format_amount(
|
||||
currency,
|
||||
value,
|
||||
)
|
||||
|
||||
if show_code:
|
||||
return f"{icon} {currency.upper()} · {amount}"
|
||||
@@ -100,61 +112,79 @@ def balance_total(item: BalanceSummary) -> float:
|
||||
|
||||
|
||||
def is_zero_balance(item: BalanceSummary) -> bool:
|
||||
return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12
|
||||
return (
|
||||
abs(item.available) < 1e-12
|
||||
and abs(item.locked) < 1e-12
|
||||
)
|
||||
|
||||
|
||||
def _quote_priority(quote_asset: str) -> int:
|
||||
value = (quote_asset or "").upper()
|
||||
|
||||
if value == "USD":
|
||||
return 3
|
||||
|
||||
if value == "USDT":
|
||||
return 2
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _status_priority(status: str) -> int:
|
||||
value = (status or "").upper()
|
||||
|
||||
if value == "TRADING":
|
||||
return 2
|
||||
|
||||
if value in {"HALT", "BREAK"}:
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _market_type_priority(market_type: str) -> int:
|
||||
value = (market_type or "").upper()
|
||||
|
||||
if value == "SPOT":
|
||||
return 3
|
||||
|
||||
if value == "LEVERAGE":
|
||||
return 2
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _symbol_priority(symbol_info: ExchangeSymbol) -> tuple[int, int, int, str]:
|
||||
def _instrument_priority(
|
||||
instrument: Instrument,
|
||||
) -> tuple[int, int, int, str]:
|
||||
return (
|
||||
_quote_priority(symbol_info.quote_asset),
|
||||
_status_priority(symbol_info.status),
|
||||
_market_type_priority(symbol_info.market_type),
|
||||
symbol_info.symbol.upper(),
|
||||
_quote_priority(instrument.quote_asset),
|
||||
_status_priority(instrument.status),
|
||||
_market_type_priority(instrument.market_type),
|
||||
instrument.symbol.upper(),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_asset_quote_symbol(
|
||||
def _resolve_asset_quote_instrument(
|
||||
exchange_service: ExchangeService,
|
||||
asset: str,
|
||||
) -> ExchangeSymbol | None:
|
||||
) -> Instrument | None:
|
||||
asset_upper = asset.upper()
|
||||
|
||||
try:
|
||||
symbols = exchange_service.get_exchange_symbols()
|
||||
instruments = exchange_service.get_instruments()
|
||||
except ExchangeError:
|
||||
return None
|
||||
|
||||
candidates: list[ExchangeSymbol] = []
|
||||
candidates: list[Instrument] = []
|
||||
|
||||
for symbol_info in symbols:
|
||||
base_asset = (symbol_info.base_asset or "").upper()
|
||||
quote_asset = (symbol_info.quote_asset or "").upper()
|
||||
for instrument in instruments:
|
||||
base_asset = (
|
||||
instrument.base_asset or ""
|
||||
).upper()
|
||||
quote_asset = (
|
||||
instrument.quote_asset or ""
|
||||
).upper()
|
||||
|
||||
if base_asset != asset_upper:
|
||||
continue
|
||||
@@ -162,12 +192,16 @@ def _resolve_asset_quote_symbol(
|
||||
if quote_asset not in {"USD", "USDT"}:
|
||||
continue
|
||||
|
||||
candidates.append(symbol_info)
|
||||
candidates.append(instrument)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=_symbol_priority, reverse=True)
|
||||
candidates.sort(
|
||||
key=_instrument_priority,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return candidates[0]
|
||||
|
||||
|
||||
@@ -184,18 +218,26 @@ def get_asset_usd_rate(
|
||||
if asset in price_cache:
|
||||
return price_cache[asset]
|
||||
|
||||
symbol_info = _resolve_asset_quote_symbol(exchange_service, asset)
|
||||
if symbol_info is None:
|
||||
instrument = _resolve_asset_quote_instrument(
|
||||
exchange_service,
|
||||
asset,
|
||||
)
|
||||
|
||||
if instrument is None:
|
||||
price_cache[asset] = None
|
||||
return None
|
||||
|
||||
try:
|
||||
ticker = exchange_service.get_price(symbol_info.symbol)
|
||||
rate = float(ticker.price)
|
||||
quote = exchange_service.get_quote(
|
||||
instrument.symbol
|
||||
)
|
||||
rate = float(quote.last_price)
|
||||
|
||||
# Пока считаем USDT ~= USD
|
||||
# Пока считаем USDT ~= USD.
|
||||
price_cache[asset] = rate
|
||||
|
||||
return rate
|
||||
|
||||
except ExchangeError:
|
||||
price_cache[asset] = None
|
||||
return None
|
||||
@@ -207,10 +249,16 @@ def estimate_balance_usd(
|
||||
price_cache: dict[str, float | None],
|
||||
) -> float | None:
|
||||
total = balance_total(item)
|
||||
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
rate = get_asset_usd_rate(exchange_service, item.currency, price_cache)
|
||||
rate = get_asset_usd_rate(
|
||||
exchange_service,
|
||||
item.currency,
|
||||
price_cache,
|
||||
)
|
||||
|
||||
if rate is None:
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user