feat(live): add live screens for market and portfolio
This commit is contained in:
@@ -9,19 +9,21 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
|
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
from src.integrations.exchange.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.telegram.ui.common import mode_line
|
from src.telegram.live.runner import LiveScreen, LiveScreenRunner
|
||||||
|
from src.telegram.ui.common import mode_line, now_line
|
||||||
from src.telegram.ui.exchange_error import (
|
from src.telegram.ui.exchange_error import (
|
||||||
classify_exchange_error,
|
classify_exchange_error,
|
||||||
show_callback_exchange_error,
|
show_callback_exchange_error,
|
||||||
show_message_exchange_error,
|
show_message_exchange_error,
|
||||||
)
|
)
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
from src.trading.auto.runner import AutoTradeRunner
|
from src.trading.auto.runner import AutoTradeRunner
|
||||||
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="market")
|
router = Router(name="market")
|
||||||
|
|
||||||
|
|
||||||
|
# клавиатура экрана рынка
|
||||||
def _market_keyboard() -> InlineKeyboardMarkup:
|
def _market_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
@@ -29,6 +31,7 @@ def _market_keyboard() -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
# собрать текст рынка по готовым данным
|
||||||
def _build_market_text(
|
def _build_market_text(
|
||||||
*,
|
*,
|
||||||
ticker_price: float,
|
ticker_price: float,
|
||||||
@@ -39,6 +42,8 @@ def _build_market_text(
|
|||||||
quote_asset: str,
|
quote_asset: str,
|
||||||
tick_size: str,
|
tick_size: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
from src.telegram.ui.common import now_line
|
||||||
|
|
||||||
status_map = {
|
status_map = {
|
||||||
"TRADING": "доступен для торговли",
|
"TRADING": "доступен для торговли",
|
||||||
"HALT": "торги остановлены",
|
"HALT": "торги остановлены",
|
||||||
@@ -61,10 +66,68 @@ def _build_market_text(
|
|||||||
f"Тип инструмента: {market_type_ru}\n"
|
f"Тип инструмента: {market_type_ru}\n"
|
||||||
f"Базовый актив: {base_asset}\n"
|
f"Базовый актив: {base_asset}\n"
|
||||||
f"Валюта котировки: {quote_asset}\n"
|
f"Валюта котировки: {quote_asset}\n"
|
||||||
f"Шаг цены: {tick_size} {quote_asset}"
|
f"Шаг цены: {tick_size} {quote_asset}\n\n"
|
||||||
|
f"{now_line()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# собрать актуальный live-текст рынка
|
||||||
|
def _build_market_live_text() -> str:
|
||||||
|
service = ExchangeService()
|
||||||
|
requested_symbol = service.settings.default_symbol
|
||||||
|
|
||||||
|
validation = service.validate_symbol(requested_symbol)
|
||||||
|
|
||||||
|
if not validation.is_valid:
|
||||||
|
return (
|
||||||
|
"<b>📈 Рынок</b>\n"
|
||||||
|
f"{mode_line()}"
|
||||||
|
"⚠️ Ошибка инструмента\n\n"
|
||||||
|
"Инструмент недоступен."
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker = service.get_price(validation.normalized_symbol)
|
||||||
|
|
||||||
|
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"
|
||||||
|
tick_size = (
|
||||||
|
f"{symbol_info.tick_size}"
|
||||||
|
if symbol_info and symbol_info.tick_size is not None
|
||||||
|
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,
|
||||||
|
symbol_status=symbol_status,
|
||||||
|
market_type=market_type,
|
||||||
|
base_asset=base_asset,
|
||||||
|
quote_asset=quote_asset,
|
||||||
|
tick_size=tick_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# зарегистрировать сообщение как live-экран рынка
|
||||||
|
def _register_market_live_screen(message: Message) -> None:
|
||||||
|
LiveScreenRunner.register_screen(
|
||||||
|
LiveScreen(
|
||||||
|
screen="market",
|
||||||
|
bot=message.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 _render_market_screen(
|
async def _render_market_screen(
|
||||||
target_message: Message,
|
target_message: Message,
|
||||||
*,
|
*,
|
||||||
@@ -74,7 +137,8 @@ async def _render_market_screen(
|
|||||||
action: str,
|
action: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
AutoTradeRunner.set_current_screen("market")
|
AutoTradeRunner.set_current_screen("market")
|
||||||
|
LiveScreenRunner.set_current_screen("market")
|
||||||
|
|
||||||
service = ExchangeService()
|
service = ExchangeService()
|
||||||
journal = JournalService()
|
journal = JournalService()
|
||||||
requested_symbol = service.settings.default_symbol
|
requested_symbol = service.settings.default_symbol
|
||||||
@@ -114,8 +178,11 @@ async def _render_market_screen(
|
|||||||
|
|
||||||
if edit_mode:
|
if edit_mode:
|
||||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
||||||
|
_register_market_live_screen(target_message)
|
||||||
else:
|
else:
|
||||||
await target_message.answer(text, reply_markup=_market_keyboard())
|
sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
|
||||||
|
_register_market_live_screen(sent_message)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ticker = service.get_price(validation.normalized_symbol)
|
ticker = service.get_price(validation.normalized_symbol)
|
||||||
@@ -157,14 +224,23 @@ async def _render_market_screen(
|
|||||||
|
|
||||||
if edit_mode:
|
if edit_mode:
|
||||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
||||||
|
_register_market_live_screen(target_message)
|
||||||
else:
|
else:
|
||||||
await target_message.answer(text, reply_markup=_market_keyboard())
|
sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
|
||||||
|
_register_market_live_screen(sent_message)
|
||||||
|
|
||||||
|
|
||||||
|
# открыть рынок из главного меню
|
||||||
@router.message(F.text == "📈 Рынок")
|
@router.message(F.text == "📈 Рынок")
|
||||||
async def open_market(message: Message, state: FSMContext) -> None:
|
async def open_market(message: Message, state: FSMContext) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
await LiveScreenRunner.delete_screen(
|
||||||
|
screen="market",
|
||||||
|
bot=message.bot,
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
user_id = message.from_user.id if message.from_user else None
|
user_id = message.from_user.id if message.from_user else None
|
||||||
chat_id = message.chat.id if message.chat else None
|
chat_id = message.chat.id if message.chat else None
|
||||||
|
|
||||||
@@ -198,6 +274,7 @@ async def open_market(message: Message, state: FSMContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# обновить рынок вручную
|
||||||
@router.callback_query(F.data == "market:retry")
|
@router.callback_query(F.data == "market:retry")
|
||||||
async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
|
async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
@@ -217,7 +294,7 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
|
|||||||
edit_mode=True,
|
edit_mode=True,
|
||||||
action="retry",
|
action="retry",
|
||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
except ExchangeError as exc:
|
except ExchangeError as exc:
|
||||||
JournalService().log_ui_error(
|
JournalService().log_ui_error(
|
||||||
event_type="market_retry_error",
|
event_type="market_retry_error",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|||||||
from src.integrations.exchange.exceptions import ExchangeError
|
from src.integrations.exchange.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.models import BalanceSummary
|
from src.integrations.exchange.models import BalanceSummary
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.telegram.live.runner import LiveScreen, LiveScreenRunner
|
||||||
from src.telegram.ui.common import mode_line, now_line
|
from src.telegram.ui.common import mode_line, now_line
|
||||||
from src.telegram.ui.currency_ui import (
|
from src.telegram.ui.currency_ui import (
|
||||||
balance_total,
|
balance_total,
|
||||||
@@ -24,8 +25,8 @@ from src.telegram.ui.exchange_error import (
|
|||||||
show_message_exchange_error,
|
show_message_exchange_error,
|
||||||
)
|
)
|
||||||
from src.trading.accounts.service import AccountsService
|
from src.trading.accounts.service import AccountsService
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
from src.trading.auto.runner import AutoTradeRunner
|
from src.trading.auto.runner import AutoTradeRunner
|
||||||
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
router = Router(name="portfolio")
|
router = Router(name="portfolio")
|
||||||
@@ -39,6 +40,7 @@ PINNED_ORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# клавиатура портфеля
|
||||||
def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||||
@@ -46,6 +48,7 @@ def _portfolio_keyboard() -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
# клавиатура портфеля при частичной загрузке
|
||||||
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
|
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
|
||||||
@@ -54,6 +57,7 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
# сортировка активов в портфеле
|
||||||
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
||||||
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
||||||
currency = item.currency.upper()
|
currency = item.currency.upper()
|
||||||
@@ -63,78 +67,33 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
|||||||
return sorted(items, key=sort_key)
|
return sorted(items, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
async def _render_portfolio_screen(
|
# собрать актуальный live-текст портфеля
|
||||||
target_message: Message,
|
def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
|
||||||
*,
|
|
||||||
user_id: int | None,
|
|
||||||
chat_id: int | None,
|
|
||||||
edit_mode: bool,
|
|
||||||
action: str,
|
|
||||||
) -> None:
|
|
||||||
AutoTradeRunner.set_current_screen("portfolio")
|
|
||||||
|
|
||||||
service = AccountsService()
|
service = AccountsService()
|
||||||
exchange_service = ExchangeService()
|
exchange_service = ExchangeService()
|
||||||
journal = JournalService()
|
|
||||||
|
|
||||||
journal.log_ui_info(
|
|
||||||
event_type="portfolio_open_requested",
|
|
||||||
message="Запрошено открытие экрана портфеля.",
|
|
||||||
screen="portfolio",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
balances = service.get_live_balance_summary()
|
balances = service.get_live_balance_summary()
|
||||||
|
|
||||||
if not balances:
|
if not balances:
|
||||||
journal.log_ui_warning(
|
|
||||||
event_type="portfolio_empty",
|
|
||||||
message="Нет данных по балансу.",
|
|
||||||
screen="portfolio",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"<b>💼 Портфель</b>\n"
|
"<b>💼 Портфель</b>\n"
|
||||||
f"{mode_line()}"
|
f"{mode_line()}"
|
||||||
"Нет данных по балансу."
|
"Нет данных по балансу.\n\n"
|
||||||
|
f"{now_line()}"
|
||||||
)
|
)
|
||||||
|
return text, _portfolio_keyboard()
|
||||||
if edit_mode:
|
|
||||||
await target_message.edit_text(text, reply_markup=_portfolio_keyboard())
|
|
||||||
else:
|
|
||||||
await target_message.answer(text, reply_markup=_portfolio_keyboard())
|
|
||||||
return
|
|
||||||
|
|
||||||
visible_balances = [item for item in balances if not is_zero_balance(item)]
|
visible_balances = [item for item in balances if not is_zero_balance(item)]
|
||||||
visible_balances = sort_balances(visible_balances)
|
visible_balances = sort_balances(visible_balances)
|
||||||
|
|
||||||
if not visible_balances:
|
if not visible_balances:
|
||||||
journal.log_ui_warning(
|
|
||||||
event_type="portfolio_zero_balances",
|
|
||||||
message="Нет активов с балансом.",
|
|
||||||
screen="portfolio",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={"assets_count": len(balances)},
|
|
||||||
)
|
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"<b>💼 Портфель</b>\n"
|
"<b>💼 Портфель</b>\n"
|
||||||
f"{mode_line()}"
|
f"{mode_line()}"
|
||||||
"Нет активов с балансом."
|
"Нет активов с балансом.\n\n"
|
||||||
|
f"{now_line()}"
|
||||||
)
|
)
|
||||||
|
return text, _portfolio_keyboard()
|
||||||
if edit_mode:
|
|
||||||
await target_message.edit_text(text, reply_markup=_portfolio_keyboard())
|
|
||||||
else:
|
|
||||||
await target_message.answer(text, reply_markup=_portfolio_keyboard())
|
|
||||||
return
|
|
||||||
|
|
||||||
price_cache: dict[str, float | None] = {}
|
price_cache: dict[str, float | None] = {}
|
||||||
total_estimated_usd = 0.0
|
total_estimated_usd = 0.0
|
||||||
@@ -180,7 +139,7 @@ async def _render_portfolio_screen(
|
|||||||
lines.append("🟡 <b>Данные загружены частично</b>")
|
lines.append("🟡 <b>Данные загружены частично</b>")
|
||||||
|
|
||||||
if has_any_estimate:
|
if has_any_estimate:
|
||||||
lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}")
|
lines.append(f"Оценка: <b>~${format_usd_amount(total_estimated_usd)}</b>")
|
||||||
|
|
||||||
if missing_estimate_assets:
|
if missing_estimate_assets:
|
||||||
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
|
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
|
||||||
@@ -188,6 +147,70 @@ async def _render_portfolio_screen(
|
|||||||
for block in asset_blocks:
|
for block in asset_blocks:
|
||||||
lines.extend(block)
|
lines.extend(block)
|
||||||
|
|
||||||
|
lines.extend(["", now_line()])
|
||||||
|
|
||||||
|
reply_markup = (
|
||||||
|
_portfolio_warning_keyboard()
|
||||||
|
if has_partial_data
|
||||||
|
else _portfolio_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines).rstrip(), reply_markup
|
||||||
|
|
||||||
|
|
||||||
|
# текст live-экрана портфеля
|
||||||
|
def _portfolio_live_text() -> str:
|
||||||
|
text, _ = _build_portfolio_live_text()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# клавиатура live-экрана портфеля
|
||||||
|
def _portfolio_live_markup() -> InlineKeyboardMarkup:
|
||||||
|
_, markup = _build_portfolio_live_text()
|
||||||
|
return markup
|
||||||
|
|
||||||
|
|
||||||
|
# зарегистрировать сообщение как live-экран портфеля
|
||||||
|
def _register_portfolio_live_screen(message: Message) -> None:
|
||||||
|
LiveScreenRunner.register_screen(
|
||||||
|
LiveScreen(
|
||||||
|
screen="portfolio",
|
||||||
|
bot=message.bot,
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
render_text=_portfolio_live_text,
|
||||||
|
render_markup=_portfolio_live_markup,
|
||||||
|
interval_seconds=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LiveScreenRunner.start("portfolio")
|
||||||
|
|
||||||
|
|
||||||
|
# отрисовать экран портфеля
|
||||||
|
async def _render_portfolio_screen(
|
||||||
|
target_message: Message,
|
||||||
|
*,
|
||||||
|
user_id: int | None,
|
||||||
|
chat_id: int | None,
|
||||||
|
edit_mode: bool,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
AutoTradeRunner.set_current_screen("portfolio")
|
||||||
|
LiveScreenRunner.set_current_screen("portfolio")
|
||||||
|
|
||||||
|
journal = JournalService()
|
||||||
|
|
||||||
|
journal.log_ui_info(
|
||||||
|
event_type="portfolio_open_requested",
|
||||||
|
message="Запрошено открытие экрана портфеля.",
|
||||||
|
screen="portfolio",
|
||||||
|
action=action,
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
text, reply_markup = _build_portfolio_live_text()
|
||||||
|
|
||||||
journal.log_ui_info(
|
journal.log_ui_info(
|
||||||
event_type="portfolio_open_success",
|
event_type="portfolio_open_success",
|
||||||
message="Портфель загружен.",
|
message="Портфель загружен.",
|
||||||
@@ -195,49 +218,27 @@ async def _render_portfolio_screen(
|
|||||||
action=action,
|
action=action,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
payload={
|
|
||||||
"assets_count": len(visible_balances),
|
|
||||||
"estimated_usd": round(total_estimated_usd, 2) if has_any_estimate else None,
|
|
||||||
"missing_estimate_assets": missing_estimate_assets,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if missing_estimate_assets:
|
|
||||||
journal.log_ui_warning(
|
|
||||||
event_type="portfolio_partial_estimate",
|
|
||||||
message="Портфель загружен частично.",
|
|
||||||
screen="portfolio",
|
|
||||||
action=action,
|
|
||||||
user_id=user_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
payload={
|
|
||||||
"assets_count": len(visible_balances),
|
|
||||||
"estimated_assets": len(visible_balances) - len(missing_estimate_assets),
|
|
||||||
"failed_assets": missing_estimate_assets,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_partial_data:
|
|
||||||
lines.extend(["", now_line()])
|
|
||||||
|
|
||||||
text = "\n".join(lines).rstrip()
|
|
||||||
|
|
||||||
reply_markup = (
|
|
||||||
_portfolio_warning_keyboard()
|
|
||||||
if has_partial_data
|
|
||||||
else _portfolio_keyboard()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if edit_mode:
|
if edit_mode:
|
||||||
await target_message.edit_text(text, reply_markup=reply_markup)
|
await target_message.edit_text(text, reply_markup=reply_markup)
|
||||||
|
_register_portfolio_live_screen(target_message)
|
||||||
else:
|
else:
|
||||||
await target_message.answer(text, reply_markup=reply_markup)
|
sent_message = await target_message.answer(text, reply_markup=reply_markup)
|
||||||
|
_register_portfolio_live_screen(sent_message)
|
||||||
|
|
||||||
|
|
||||||
|
# открыть портфель из меню
|
||||||
@router.message(F.text == "💼 Портфель")
|
@router.message(F.text == "💼 Портфель")
|
||||||
async def open_portfolio(message: Message, state: FSMContext) -> None:
|
async def open_portfolio(message: Message, state: FSMContext) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
await LiveScreenRunner.delete_screen(
|
||||||
|
screen="portfolio",
|
||||||
|
bot=message.bot,
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
user_id = message.from_user.id if message.from_user else None
|
user_id = message.from_user.id if message.from_user else None
|
||||||
chat_id = message.chat.id if message.chat else None
|
chat_id = message.chat.id if message.chat else None
|
||||||
|
|
||||||
@@ -271,6 +272,7 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# обновить портфель вручную
|
||||||
@router.callback_query(F.data == "portfolio:retry")
|
@router.callback_query(F.data == "portfolio:retry")
|
||||||
async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
|
async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|||||||
133
app/src/telegram/live/runner.py
Normal file
133
app/src/telegram/live/runner.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# app/src/telegram/live/runner.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LiveScreen:
|
||||||
|
# имя live-экрана: market / portfolio / journal
|
||||||
|
screen: str
|
||||||
|
|
||||||
|
# Telegram bot instance
|
||||||
|
bot: Bot
|
||||||
|
|
||||||
|
# чат, где находится live-экран
|
||||||
|
chat_id: int
|
||||||
|
|
||||||
|
# сообщение, которое нужно автообновлять
|
||||||
|
message_id: int
|
||||||
|
|
||||||
|
# функция сборки текста экрана
|
||||||
|
render_text: Callable[[], str]
|
||||||
|
|
||||||
|
# функция сборки клавиатуры экрана
|
||||||
|
render_markup: Callable[[], object]
|
||||||
|
|
||||||
|
# интервал обновления в секундах
|
||||||
|
interval_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class LiveScreenRunner:
|
||||||
|
_screens: dict[str, LiveScreen] = {}
|
||||||
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
_current_screen: str | None = None
|
||||||
|
|
||||||
|
# переключить активный экран
|
||||||
|
@classmethod
|
||||||
|
def set_current_screen(cls, screen: str) -> None:
|
||||||
|
cls._current_screen = screen
|
||||||
|
|
||||||
|
# зарегистрировать live-экран
|
||||||
|
@classmethod
|
||||||
|
def register_screen(cls, live_screen: LiveScreen) -> None:
|
||||||
|
cls._screens[live_screen.screen] = live_screen
|
||||||
|
|
||||||
|
# удалить старый live-экран из Telegram
|
||||||
|
@classmethod
|
||||||
|
async def delete_screen(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
screen: str,
|
||||||
|
bot: Bot,
|
||||||
|
chat_id: int,
|
||||||
|
) -> None:
|
||||||
|
live_screen = cls._screens.get(screen)
|
||||||
|
|
||||||
|
if live_screen is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if live_screen.chat_id != chat_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await bot.delete_message(
|
||||||
|
chat_id=live_screen.chat_id,
|
||||||
|
message_id=live_screen.message_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cls._screens.pop(screen, None)
|
||||||
|
|
||||||
|
# запустить автообновление экрана
|
||||||
|
@classmethod
|
||||||
|
def start(cls, screen: str) -> None:
|
||||||
|
task = cls._tasks.get(screen)
|
||||||
|
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return
|
||||||
|
|
||||||
|
cls._tasks[screen] = asyncio.create_task(cls._worker(screen))
|
||||||
|
|
||||||
|
# остановить автообновление экрана
|
||||||
|
@classmethod
|
||||||
|
def stop(cls, screen: str) -> None:
|
||||||
|
task = cls._tasks.get(screen)
|
||||||
|
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
task.cancel()
|
||||||
|
cls._tasks.pop(screen, None)
|
||||||
|
|
||||||
|
# фоновый цикл обновления одного экрана
|
||||||
|
@classmethod
|
||||||
|
async def _worker(cls, screen: str) -> None:
|
||||||
|
while True:
|
||||||
|
await cls._refresh_screen(screen)
|
||||||
|
await asyncio.sleep(cls._screen_interval(screen))
|
||||||
|
|
||||||
|
# получить интервал обновления экрана
|
||||||
|
@classmethod
|
||||||
|
def _screen_interval(cls, screen: str) -> int:
|
||||||
|
live_screen = cls._screens.get(screen)
|
||||||
|
if live_screen is None:
|
||||||
|
return 5
|
||||||
|
|
||||||
|
return live_screen.interval_seconds
|
||||||
|
|
||||||
|
# обновить Telegram-сообщение live-экрана
|
||||||
|
@classmethod
|
||||||
|
async def _refresh_screen(cls, screen: str) -> None:
|
||||||
|
if cls._current_screen != screen:
|
||||||
|
return
|
||||||
|
|
||||||
|
live_screen = cls._screens.get(screen)
|
||||||
|
if live_screen is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await live_screen.bot.edit_message_text(
|
||||||
|
chat_id=live_screen.chat_id,
|
||||||
|
message_id=live_screen.message_id,
|
||||||
|
text=live_screen.render_text(),
|
||||||
|
reply_markup=live_screen.render_markup(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user