diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py index 54cf8d7..1ef40ea 100644 --- a/app/src/telegram/handlers/auto/main.py +++ b/app/src/telegram/handlers/auto/main.py @@ -13,6 +13,7 @@ from src.telegram.handlers.auto.ui import ( is_auto_configured, ) from src.telegram.handlers.system import open_auto_settings +from src.telegram.live.active_screen import ActiveScreenManager from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.service import AutoTradeService @@ -41,6 +42,11 @@ async def render_auto_screen( render_text=build_auto_text, render_markup=auto_keyboard, ) + + ActiveScreenManager.register( + screen="auto", + message=target_message, + ) return sent_message = await target_message.answer(text, reply_markup=auto_keyboard()) @@ -53,17 +59,40 @@ async def render_auto_screen( render_markup=auto_keyboard, ) + ActiveScreenManager.register( + screen="auto", + message=sent_message, + ) + + +async def _prepare_auto_from_message(message: Message) -> None: + await ActiveScreenManager.prepare_new_screen( + screen="auto", + bot=message.bot, + chat_id=message.chat.id, + ) + + +async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool: + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return False + + await ActiveScreenManager.prepare_new_screen( + screen="auto", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + + return True + @router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"})) async def open_auto(message: Message, state: FSMContext) -> None: await state.clear() - AutoTradeRunner.set_current_screen("auto") - - await AutoTradeRunner.delete_registered_screen( - bot=message.bot, - chat_id=message.chat.id, - ) + await _prepare_auto_from_message(message) await render_auto_screen(message, edit_mode=False) @@ -72,11 +101,9 @@ async def open_auto(message: Message, state: FSMContext) -> None: async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_auto_from_callback(callback): return - AutoTradeRunner.set_current_screen("auto") await render_auto_screen(callback.message, edit_mode=True) await callback.answer() @@ -99,10 +126,10 @@ async def auto_start(callback: CallbackQuery) -> None: _, message = service.start() - AutoTradeRunner.set_current_screen("auto") AutoTradeRunner.start() if callback.message is not None: + await _prepare_auto_from_callback(callback) await render_auto_screen(callback.message, edit_mode=True) await callback.answer(message) @@ -126,10 +153,10 @@ async def auto_observe(callback: CallbackQuery) -> None: _, message = service.observe() - AutoTradeRunner.set_current_screen("auto") AutoTradeRunner.start() if callback.message is not None: + await _prepare_auto_from_callback(callback) await render_auto_screen(callback.message, edit_mode=True) await callback.answer(message) @@ -143,6 +170,7 @@ async def auto_stop(callback: CallbackQuery) -> None: AutoTradeRunner.stop() if callback.message is not None: + await _prepare_auto_from_callback(callback) await render_auto_screen(callback.message, edit_mode=True) await callback.answer(message) \ No newline at end of file diff --git a/app/src/telegram/handlers/debug_auto/main.py b/app/src/telegram/handlers/debug_auto/main.py index e5bacea..b926858 100644 --- a/app/src/telegram/handlers/debug_auto/main.py +++ b/app/src/telegram/handlers/debug_auto/main.py @@ -13,6 +13,7 @@ from src.telegram.handlers.debug_auto.ui import ( build_debug_auto_text, debug_auto_keyboard, ) +from src.telegram.live.active_screen import ActiveScreenManager from src.trading.debug.runner import DebugTradeRunner from src.trading.debug.service import DebugTradeService @@ -49,6 +50,11 @@ async def render_debug_auto_screen( render_text=build_debug_auto_text, render_markup=debug_auto_keyboard, ) + + ActiveScreenManager.register( + screen="debug_auto", + message=target_message, + ) return sent_message = await target_message.answer( @@ -64,17 +70,42 @@ async def render_debug_auto_screen( render_markup=debug_auto_keyboard, ) + ActiveScreenManager.register( + screen="debug_auto", + message=sent_message, + ) + + +async def _prepare_debug_auto_from_message(message: Message) -> None: + await ActiveScreenManager.prepare_new_screen( + screen="debug_auto", + bot=message.bot, + chat_id=message.chat.id, + ) + + +async def _prepare_debug_auto_from_callback(callback: CallbackQuery) -> bool: + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return False + + await ActiveScreenManager.prepare_new_screen( + screen="debug_auto", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + + return True + @router.message(F.text.in_({"🧪 Debug Auto", "Debug Auto", "/debug_auto_screen"})) async def open_debug_auto(message: Message, state: FSMContext) -> None: await state.clear() - DebugTradeRunner.set_current_screen("debug_auto") + await _prepare_debug_auto_from_message(message) - await DebugTradeRunner.delete_registered_screen( - bot=message.bot, - chat_id=message.chat.id, - ) + DebugTradeRunner.set_current_screen("debug_auto") await render_debug_auto_screen(message, edit_mode=False) @@ -90,6 +121,7 @@ async def debug_auto_start(callback: CallbackQuery) -> None: DebugTradeRunner.start() if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer("[DEBUG] Мониторинг запущен.") @@ -102,6 +134,7 @@ async def debug_auto_stop(callback: CallbackQuery) -> None: DebugTradeService().stop() if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer("[DEBUG] Мониторинг остановлен.") @@ -116,6 +149,7 @@ async def debug_auto_long(callback: CallbackQuery) -> None: DebugTradeRunner.start() if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer(result.reason, show_alert=False) @@ -130,6 +164,7 @@ async def debug_auto_short(callback: CallbackQuery) -> None: DebugTradeRunner.start() if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer(result.reason, show_alert=False) @@ -144,6 +179,7 @@ async def debug_auto_flip(callback: CallbackQuery) -> None: DebugTradeRunner.start() if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer(result.reason, show_alert=False) @@ -157,6 +193,7 @@ async def debug_auto_close(callback: CallbackQuery) -> None: DebugTradeRunner.set_current_screen("debug_auto") if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer(result.reason, show_alert=False) @@ -169,6 +206,7 @@ async def debug_auto_reset(callback: CallbackQuery) -> None: _ensure_signal_started_at(state) if callback.message is not None: + await _prepare_debug_auto_from_callback(callback) await render_debug_auto_screen(callback.message, edit_mode=True) await callback.answer("[DEBUG] Runtime reset.") \ No newline at end of file diff --git a/app/src/telegram/handlers/home.py b/app/src/telegram/handlers/home.py index 07cf7ff..32e2105 100644 --- a/app/src/telegram/handlers/home.py +++ b/app/src/telegram/handlers/home.py @@ -4,6 +4,7 @@ from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import Message +from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.menus import HOME_TEXT @@ -12,7 +13,17 @@ router = Router(name="home") @router.message(F.text == "🏠 Главная") async def open_home(message: Message, state: FSMContext) -> None: - # Глобальный экран: всегда выходим из текущего FSM-сценария. await state.clear() - await message.answer(HOME_TEXT) \ No newline at end of file + await ActiveScreenManager.prepare_new_screen( + screen="home", + bot=message.bot, + chat_id=message.chat.id, + ) + + sent_message = await message.answer(HOME_TEXT) + + ActiveScreenManager.register( + screen="home", + message=sent_message, + ) \ No newline at end of file diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index f280952..1b4f5a8 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -8,16 +8,16 @@ from aiogram.types import BufferedInputFile, CallbackQuery, Message from src.telegram.handlers.journal_ui import ( PAGE_SIZE, + build_actions_keyboard, build_clear_confirm_keyboard, build_keyboard, render, - render_clear_confirm, - build_actions_keyboard, render_actions, + render_clear_confirm, ) +from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen from src.trading.journal.service import JournalService -from src.trading.auto.runner import AutoTradeRunner router = Router(name="journal") @@ -38,16 +38,41 @@ def _user_id_from_callback(callback: CallbackQuery) -> int | None: def _chat_id_from_callback(callback: CallbackQuery) -> int | None: if callback.message and callback.message.chat: return callback.message.chat.id + return None +def _register_journal_screen(message: Message) -> None: + 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, + ) + + ScreenRegistry.register_screen( + StaticScreen( + screen="journal", + bot=message.bot, + chat_id=message.chat.id, + message_id=message.message_id, + ) + ) + + ActiveScreenManager.register( + screen="journal", + message=message, + ) + + async def _show_journal_page( target_message: Message, *, page: int, edit_mode: bool, ) -> None: - AutoTradeRunner.set_current_screen("journal") service = JournalService() total = service.get_total_count() @@ -61,65 +86,41 @@ async def _show_journal_page( if edit_mode: await target_message.edit_text(text, reply_markup=kb) + _register_journal_screen(target_message) + return - LiveScreenRunner.unregister_message( - chat_id=target_message.chat.id, - message_id=target_message.message_id, - ) - ScreenRegistry.unregister_message( - chat_id=target_message.chat.id, - message_id=target_message.message_id, - ) - - ScreenRegistry.register_screen( - StaticScreen( - screen="journal", - bot=target_message.bot, - chat_id=target_message.chat.id, - message_id=target_message.message_id, - ) - ) - else: - sent_message = await target_message.answer(text, reply_markup=kb) - - LiveScreenRunner.unregister_message( - chat_id=sent_message.chat.id, - message_id=sent_message.message_id, - ) - ScreenRegistry.unregister_message( - chat_id=sent_message.chat.id, - message_id=sent_message.message_id, - ) - - ScreenRegistry.register_screen( - StaticScreen( - screen="journal", - bot=sent_message.bot, - chat_id=sent_message.chat.id, - message_id=sent_message.message_id, - ) - ) + sent_message = await target_message.answer(text, reply_markup=kb) + _register_journal_screen(sent_message) @router.callback_query(F.data == "journal:actions") async def journal_actions(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("journal") if callback.message is None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="journal", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + await callback.message.edit_text( render_actions(), reply_markup=build_actions_keyboard(), ) + + _register_journal_screen(callback.message) + await callback.answer() - + @router.message(F.text == "📒 Журнал") async def open_journal(message: Message, state: FSMContext) -> None: await state.clear() - await ScreenRegistry.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="journal", bot=message.bot, chat_id=message.chat.id, @@ -149,10 +150,11 @@ async def open_journal_from_monitoring(callback: CallbackQuery, state: FSMContex await callback.answer("Сообщение не найдено", show_alert=True) return - await ScreenRegistry.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="journal", bot=callback.message.bot, chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, ) JournalService().log_ui_info( @@ -169,6 +171,7 @@ async def open_journal_from_monitoring(callback: CallbackQuery, state: FSMContex page=1, edit_mode=True, ) + await callback.answer() @@ -255,11 +258,17 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None: @router.callback_query(F.data == "journal:clear_confirm") async def clear_journal_confirm(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("journal") if callback.message is None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="journal", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + service = JournalService() total_count = service.get_total_count() @@ -267,6 +276,9 @@ async def clear_journal_confirm(callback: CallbackQuery) -> None: render_clear_confirm(total_count=total_count), reply_markup=build_clear_confirm_keyboard(), ) + + _register_journal_screen(callback.message) + await callback.answer() @@ -276,6 +288,13 @@ async def clear_journal(callback: CallbackQuery) -> None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="journal", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + service = JournalService() deleted_count = service.clear_all() total_count = service.get_total_count() @@ -287,6 +306,9 @@ async def clear_journal(callback: CallbackQuery) -> None: ), reply_markup=build_clear_confirm_keyboard(), ) + + _register_journal_screen(callback.message) + await callback.answer(f"Удалено: {deleted_count}") @@ -296,6 +318,13 @@ async def clear_journal_older_90(callback: CallbackQuery) -> None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="journal", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + service = JournalService() deleted_count = service.clear_older_than_days(90) total_count = service.get_total_count() @@ -308,6 +337,9 @@ async def clear_journal_older_90(callback: CallbackQuery) -> None: ), reply_markup=build_clear_confirm_keyboard(), ) + + _register_journal_screen(callback.message) + await callback.answer(f"Удалено: {deleted_count}") @@ -325,9 +357,17 @@ async def paginate(callback: CallbackQuery) -> None: await callback.answer("Неизвестное действие", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="journal", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + await _show_journal_page( callback.message, page=page, edit_mode=True, ) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index e2c2679..ffda925 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -9,6 +9,7 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.service import ExchangeService +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 @@ -17,7 +18,6 @@ from src.telegram.ui.exchange_error import ( show_callback_exchange_error, show_message_exchange_error, ) -from src.trading.auto.runner import AutoTradeRunner from src.trading.journal.service import JournalService @@ -26,7 +26,6 @@ _last_market_prices: dict[str, float] = {} _last_market_directions: dict[str, str] = {} -# клавиатура экрана рынка def _market_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="📊 К мониторингу", callback_data="monitoring:home") @@ -34,7 +33,6 @@ def _market_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -# собрать текст рынка по готовым данным def _build_market_text( *, ticker_price: float, @@ -71,7 +69,6 @@ def _build_market_text( ) -# собрать актуальный live-текст рынка def _build_market_live_text() -> str: service = ExchangeService() requested_symbol = service.settings.default_symbol @@ -103,7 +100,6 @@ def _build_market_live_text() -> str: ) -# зарегистрировать сообщение как live-экран рынка def _register_market_live_screen(message: Message) -> None: LiveScreenRunner.unregister_message( chat_id=message.chat.id, @@ -128,7 +124,6 @@ def _register_market_live_screen(message: Message) -> None: LiveScreenRunner.start("market") -# отрисовать экран рынка async def _render_market_screen( target_message: Message, *, @@ -137,8 +132,6 @@ async def _render_market_screen( edit_mode: bool, action: str, ) -> None: - AutoTradeRunner.set_current_screen("market") - service = ExchangeService() journal = JournalService() requested_symbol = service.settings.default_symbol @@ -179,9 +172,11 @@ async def _render_market_screen( 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) else: sent_message = await target_message.answer(text, reply_markup=_market_keyboard()) _register_market_live_screen(sent_message) + ActiveScreenManager.register(screen="market", message=sent_message) return @@ -217,17 +212,18 @@ async def _render_market_screen( 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) else: sent_message = await target_message.answer(text, reply_markup=_market_keyboard()) _register_market_live_screen(sent_message) + ActiveScreenManager.register(screen="market", message=sent_message) -# открыть рынок из главного меню @router.message(F.text == "📈 Рынок") async def open_market(message: Message, state: FSMContext) -> None: await state.clear() - await LiveScreenRunner.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="market", bot=message.bot, chat_id=message.chat.id, @@ -266,7 +262,6 @@ async def open_market(message: Message, state: FSMContext) -> None: ) -# открыть рынок из экрана мониторинга @router.callback_query(F.data == "monitoring:market") async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() @@ -275,10 +270,11 @@ async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext await callback.answer("Сообщение не найдено", show_alert=True) return - await LiveScreenRunner.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="market", bot=callback.message.bot, chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, ) user_id = callback.from_user.id if callback.from_user else None @@ -315,7 +311,6 @@ async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext ) -# обновить рынок вручную @router.callback_query(F.data == "market:retry") async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() @@ -324,6 +319,13 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="market", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + user_id = callback.from_user.id if callback.from_user else None chat_id = callback.message.chat.id if callback.message.chat else None diff --git a/app/src/telegram/handlers/monitoring.py b/app/src/telegram/handlers/monitoring.py index df835f8..60919d1 100644 --- a/app/src/telegram/handlers/monitoring.py +++ b/app/src/telegram/handlers/monitoring.py @@ -7,14 +7,13 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder +from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen -from src.trading.auto.runner import AutoTradeRunner router = Router(name="monitoring") -# клавиатура экрана мониторинга def _monitoring_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="💼 Портфель", callback_data="monitoring:portfolio") @@ -24,7 +23,6 @@ def _monitoring_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -# текст экрана мониторинга def _monitoring_text() -> str: return ( "📊 Мониторинг\n\n" @@ -32,7 +30,6 @@ def _monitoring_text() -> str: ) -# зарегистрировать сообщение как статичный экран мониторинга def _register_monitoring_screen(message: Message) -> None: LiveScreenRunner.unregister_message( chat_id=message.chat.id, @@ -53,13 +50,11 @@ def _register_monitoring_screen(message: Message) -> None: ) -# открыть мониторинг из главного меню @router.message(F.text == "📊 Мониторинг") async def open_monitoring(message: Message, state: FSMContext) -> None: await state.clear() - AutoTradeRunner.set_current_screen("monitoring") - await ScreenRegistry.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="monitoring", bot=message.bot, chat_id=message.chat.id, @@ -72,21 +67,37 @@ async def open_monitoring(message: Message, state: FSMContext) -> None: _register_monitoring_screen(sent_message) + ActiveScreenManager.register( + screen="monitoring", + message=sent_message, + ) + -# вернуться на экран мониторинга из callback @router.callback_query(F.data == "monitoring:home") async def open_monitoring_callback(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() - AutoTradeRunner.set_current_screen("monitoring") if callback.message is None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="monitoring", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + await callback.message.edit_text( _monitoring_text(), reply_markup=_monitoring_keyboard(), ) _register_monitoring_screen(callback.message) + + ActiveScreenManager.register( + screen="monitoring", + message=callback.message, + ) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index a2a7fd2..d2c81e4 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -10,13 +10,12 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.service import ExchangeService +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.currency_ui import ( balance_total, estimate_balance_usd, - format_amount, format_usd_amount, is_zero_balance, ) @@ -26,7 +25,6 @@ from src.telegram.ui.exchange_error import ( show_message_exchange_error, ) from src.trading.accounts.service import AccountsService -from src.trading.auto.runner import AutoTradeRunner from src.trading.journal.service import JournalService @@ -40,7 +38,7 @@ PINNED_ORDER = { "ETH": 4, } -# компактное форматирование количества + def _compact_amount(currency: str, value: float) -> str: currency = currency.upper() @@ -65,7 +63,7 @@ def _compact_amount(currency: str, value: float) -> str: return f"{int(text):,}".replace(",", " ") -# клавиатура портфеля + def _portfolio_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="📊 К мониторингу", callback_data="monitoring:home") @@ -73,7 +71,6 @@ def _portfolio_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -# клавиатура портфеля при частичной загрузке def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🔁 Обновить", callback_data="portfolio:retry") @@ -82,7 +79,6 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -# сортировка активов в портфеле def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: def sort_key(item: BalanceSummary) -> tuple[int, str]: currency = item.currency.upper() @@ -92,7 +88,6 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: return sorted(items, key=sort_key) -# собрать актуальный live-текст портфеля def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: service = AccountsService() exchange_service = ExchangeService() @@ -131,8 +126,6 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: "", ] - asset_blocks: list[list[str]] = [] - for item in visible_balances: currency = item.currency.upper() total = balance_total(item) @@ -149,7 +142,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: if item.locked > 0: line += f" · locked {_compact_amount(currency, item.locked)}" - if estimated_usd is not None and currency not in {'USD', 'USDT'}: + if estimated_usd is not None and currency not in {"USD", "USDT"}: line += f" ≈ $ {format_usd_amount(estimated_usd)}" if currency == "BTC" and any("USD:" in x or "USDT:" in x for x in lines): @@ -169,9 +162,6 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: if missing_estimate_assets: lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}") - for block in asset_blocks: - lines.extend(block) - lines.extend(["", now_line()]) reply_markup = ( @@ -183,19 +173,16 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: 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.unregister_message( chat_id=message.chat.id, @@ -220,7 +207,6 @@ def _register_portfolio_live_screen(message: Message) -> None: LiveScreenRunner.start("portfolio") -# отрисовать экран портфеля async def _render_portfolio_screen( target_message: Message, *, @@ -229,8 +215,6 @@ async def _render_portfolio_screen( edit_mode: bool, action: str, ) -> None: - AutoTradeRunner.set_current_screen("portfolio") - journal = JournalService() journal.log_ui_info( @@ -256,17 +240,18 @@ async def _render_portfolio_screen( if edit_mode: await target_message.edit_text(text, reply_markup=reply_markup) _register_portfolio_live_screen(target_message) + ActiveScreenManager.register(screen="portfolio", message=target_message) else: sent_message = await target_message.answer(text, reply_markup=reply_markup) _register_portfolio_live_screen(sent_message) + ActiveScreenManager.register(screen="portfolio", message=sent_message) -# открыть портфель из меню @router.message(F.text == "💼 Портфель") async def open_portfolio(message: Message, state: FSMContext) -> None: await state.clear() - await LiveScreenRunner.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="portfolio", bot=message.bot, chat_id=message.chat.id, @@ -305,7 +290,6 @@ async def open_portfolio(message: Message, state: FSMContext) -> None: ) -# открыть портфель из экрана мониторинга @router.callback_query(F.data == "monitoring:portfolio") async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() @@ -314,10 +298,11 @@ async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMCont await callback.answer("Сообщение не найдено", show_alert=True) return - await LiveScreenRunner.delete_screen( + await ActiveScreenManager.prepare_new_screen( screen="portfolio", bot=callback.message.bot, chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, ) user_id = callback.from_user.id if callback.from_user else None @@ -354,7 +339,6 @@ async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMCont ) -# обновить портфель вручную @router.callback_query(F.data == "portfolio:retry") async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() @@ -363,6 +347,13 @@ async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None: await callback.answer("Сообщение не найдено", show_alert=True) return + await ActiveScreenManager.prepare_new_screen( + screen="portfolio", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + user_id = callback.from_user.id if callback.from_user else None chat_id = callback.message.chat.id if callback.message.chat else None diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 8c757f4..f69848b 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -7,12 +7,13 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts from src.core.config import load_settings from src.core.constants import APP_NAME, APP_VERSION -from src.trading.journal.service import JournalService +from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts +from src.telegram.live.active_screen import ActiveScreenManager +from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen from src.trading.auto.service import AutoTradeService -from src.trading.auto.runner import AutoTradeRunner +from src.trading.journal.service import JournalService router = Router(name="system") @@ -35,6 +36,57 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() +def _register_system_screen(message: Message, screen: str = "system") -> None: + 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, + ) + + ScreenRegistry.register_screen( + StaticScreen( + screen=screen, + bot=message.bot, + chat_id=message.chat.id, + message_id=message.message_id, + ) + ) + + ActiveScreenManager.register( + screen=screen, + message=message, + ) + + +async def _prepare_system_from_message(message: Message, screen: str = "system") -> None: + await ActiveScreenManager.prepare_new_screen( + screen=screen, + bot=message.bot, + chat_id=message.chat.id, + ) + + +async def _prepare_system_from_callback( + callback: CallbackQuery, + screen: str = "system", +) -> bool: + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return False + + await ActiveScreenManager.prepare_new_screen( + screen=screen, + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + + return True + + async def _render_system_screen( target_message: Message, *, @@ -43,8 +95,6 @@ async def _render_system_screen( chat_id: int | None, action: str, ) -> None: - AutoTradeRunner.set_current_screen("system") - journal = JournalService() journal.log_ui_info( @@ -96,14 +146,19 @@ async def _render_system_screen( if edit_mode: await target_message.edit_text(text, reply_markup=reply_markup) - else: - await target_message.answer(text, reply_markup=reply_markup) + _register_system_screen(target_message, screen="system") + return + + sent_message = await target_message.answer(text, reply_markup=reply_markup) + _register_system_screen(sent_message, screen="system") @router.message(F.text.in_({"🖥️ Система"})) async def open_system(message: Message, state: FSMContext) -> None: await state.clear() + await _prepare_system_from_message(message, screen="system") + user_id = message.from_user.id if message.from_user else None chat_id = message.chat.id if message.chat else None @@ -120,8 +175,7 @@ async def open_system(message: Message, state: FSMContext) -> None: async def retry_system(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="system"): return user_id = callback.from_user.id if callback.from_user else None @@ -139,8 +193,7 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None: @router.callback_query(F.data == "system:management") async def open_system_management(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="system"): return text = ( @@ -155,29 +208,18 @@ async def open_system_management(callback: CallbackQuery) -> None: builder.button(text="🌍 Общие", callback_data="settings:general") builder.button(text="📒 Журнал", callback_data="settings:journal") builder.button(text="⬅️ Назад", callback_data="system:back") - builder.adjust(2, 2, 1) - await callback.message.edit_text( - text, - reply_markup=builder.as_markup(), - ) + await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="system") await callback.answer() @router.callback_query(F.data == "settings:auto") async def open_auto_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("settings_auto") - - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_auto"): return - AutoTradeRunner.unregister_screen( - chat_id=callback.message.chat.id, - message_id=callback.message.message_id, - ) - state = AutoTradeService().get_state() strategy_map = { @@ -192,10 +234,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None: leverage_ready = state.leverage is not None is_trend_strategy = (state.strategy or "").upper() == "TREND" - sl_ready = ( - state.stop_loss_percent is not None - and state.stop_loss_percent > 0 - ) + sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0 is_configured = ( strategy_ready @@ -206,7 +245,6 @@ async def open_auto_settings(callback: CallbackQuery) -> None: ) strategy = strategy_map.get(state.strategy or "", "—") - symbol = "—" if state.symbol: @@ -219,9 +257,8 @@ async def open_auto_settings(callback: CallbackQuery) -> None: if base.endswith(suffix) and len(base) > len(suffix): base = base[: -len(suffix)] break - symbol = base - + risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—" leverage = f"x{state.leverage:g}" if state.leverage is not None else "—" max_reserved = ( @@ -229,23 +266,9 @@ async def open_auto_settings(callback: CallbackQuery) -> None: if state.max_reserved_balance_percent is not None else "off" ) - sl = ( - f"{state.stop_loss_percent:g}%" - if state.stop_loss_percent is not None - else "off" - ) - - tp = ( - f"{state.take_profit_percent:g}%" - if state.take_profit_percent is not None - else "off" - ) - - ml = ( - f"{state.max_loss_usd:g} USD" - if state.max_loss_usd is not None - else "off" - ) + sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off" + tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off" + ml = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off" strategy_icon = "✅" if strategy_ready else "⚠️" symbol_icon = "✅" if symbol_ready else "⚠️" @@ -268,11 +291,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None: f"✅ Max Loss · {ml}" ) - config_status = ( - "✅ Все параметры настроены" - if is_configured - else "⚠️ Настрой все параметры" - ) + config_status = "✅ Все параметры настроены" if is_configured else "⚠️ Настрой все параметры" text = ( "🤖 Автоторговля\n\n" @@ -298,14 +317,13 @@ async def open_auto_settings(callback: CallbackQuery) -> None: builder.adjust(2, 2, 2, 2) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_auto") await callback.answer() @router.callback_query(F.data == "settings:auto_strategy") async def open_auto_strategy_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("settings_auto") - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_auto"): return text = ( @@ -322,6 +340,7 @@ async def open_auto_strategy_settings(callback: CallbackQuery) -> None: builder.adjust(3, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_auto") await callback.answer() @@ -330,22 +349,15 @@ async def set_auto_strategy(callback: CallbackQuery) -> None: strategy = callback.data.split(":", 2)[2] AutoTradeService().set_strategy(strategy.upper()) - if callback.message is not None: - await open_auto_settings(callback) - - AutoTradeRunner.set_current_screen("settings_auto") + await open_auto_settings(callback) await callback.answer("Стратегия обновлена") @router.callback_query(F.data == "settings:auto_symbol") async def open_auto_symbol_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("settings_auto") - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_auto"): return - settings = load_settings() - text = ( "💱 Актив\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" @@ -353,32 +365,15 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None: ) builder = InlineKeyboardBuilder() - - builder.button( - text="BTC", - callback_data="settings:auto_symbol:BTC/USD_LEVERAGE", - ) - - builder.button( - text="ETH", - callback_data="settings:auto_symbol:ETH/USD_LEVERAGE", - ) - - builder.button( - text="LTC", - callback_data="settings:auto_symbol:LTC/USD_LEVERAGE", - ) - - builder.button( - text="XRP", - callback_data="settings:auto_symbol:XRP/USD_LEVERAGE", - ) - + builder.button(text="BTC", callback_data="settings:auto_symbol:BTC/USD_LEVERAGE") + builder.button(text="ETH", callback_data="settings:auto_symbol:ETH/USD_LEVERAGE") + builder.button(text="LTC", callback_data="settings:auto_symbol:LTC/USD_LEVERAGE") + builder.button(text="XRP", callback_data="settings:auto_symbol:XRP/USD_LEVERAGE") builder.button(text="⬅️ Назад", callback_data="settings:auto") - builder.adjust(2, 2, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_auto") await callback.answer() @@ -387,18 +382,13 @@ async def set_auto_symbol(callback: CallbackQuery) -> None: symbol = callback.data.split(":", 2)[2] AutoTradeService().set_symbol(symbol) - if callback.message is not None: - await open_auto_settings(callback) - - AutoTradeRunner.set_current_screen("settings_auto") + await open_auto_settings(callback) await callback.answer("Актив обновлён") @router.callback_query(F.data == "settings:auto_risk") async def open_auto_risk_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("settings_auto") - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_auto"): return text = ( @@ -415,6 +405,7 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None: builder.adjust(3, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_auto") await callback.answer() @@ -423,19 +414,13 @@ async def set_auto_risk(callback: CallbackQuery) -> None: risk = float(callback.data.split(":", 2)[2]) AutoTradeService().set_risk_percent(risk) - if callback.message is not None: - await open_auto_settings(callback) - - AutoTradeRunner.set_current_screen("settings_auto") + await open_auto_settings(callback) await callback.answer("Риск обновлён") @router.callback_query(F.data == "settings:auto_leverage") async def open_auto_leverage_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("settings_auto") - - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_auto"): return text = ( @@ -455,6 +440,7 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None: builder.adjust(3, 3, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_auto") await callback.answer() @@ -463,17 +449,13 @@ async def set_auto_leverage(callback: CallbackQuery) -> None: leverage = float(callback.data.split(":", 2)[2]) AutoTradeService().set_leverage(leverage) - if callback.message is not None: - await open_auto_settings(callback) - - AutoTradeRunner.set_current_screen("settings_auto") + await open_auto_settings(callback) await callback.answer("Плечо обновлено") @router.callback_query(F.data == "settings:trade") async def open_trade_settings(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_trade"): return text = ( @@ -491,13 +473,13 @@ async def open_trade_settings(callback: CallbackQuery) -> None: builder.adjust(2) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_trade") await callback.answer() @router.callback_query(F.data == "settings:general") async def open_general_settings(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_general"): return text = ( @@ -514,13 +496,13 @@ async def open_general_settings(callback: CallbackQuery) -> None: builder.adjust(1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_general") await callback.answer() @router.callback_query(F.data == "settings:journal") async def open_journal_settings(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_journal"): return service = JournalService() @@ -544,17 +526,14 @@ async def open_journal_settings(callback: CallbackQuery) -> None: builder.button(text="📒 Журнал", callback_data="journal:1") builder.adjust(2, 2, 2) - await callback.message.edit_text( - text, - reply_markup=builder.as_markup(), - ) + await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_journal") await callback.answer() @router.callback_query(F.data == "settings:journal_archive") async def open_journal_archive_settings(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_journal"): return text = ( @@ -572,13 +551,13 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None: builder.adjust(2) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_journal") await callback.answer() @router.callback_query(F.data == "settings:journal_limit") async def open_journal_limit_settings(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_journal"): return text = ( @@ -597,13 +576,13 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None: builder.adjust(2, 2, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_journal") await callback.answer() @router.callback_query(F.data == "settings:journal_retention") async def open_journal_retention_settings(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_journal"): return text = ( @@ -622,6 +601,7 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None: builder.adjust(2, 2, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_journal") await callback.answer() @@ -635,8 +615,7 @@ async def journal_settings_stub(callback: CallbackQuery) -> None: @router.callback_query(F.data == "system:back") async def back_to_system(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="system"): return user_id = callback.from_user.id if callback.from_user else None @@ -654,8 +633,7 @@ async def back_to_system(callback: CallbackQuery) -> None: @router.callback_query(F.data == "system:about") async def open_system_about(callback: CallbackQuery) -> None: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="system_about"): return settings = load_settings() @@ -685,19 +663,14 @@ async def open_system_about(callback: CallbackQuery) -> None: builder.button(text="⬅️ Назад", callback_data="system:back") builder.adjust(1) - await callback.message.edit_text( - text, - reply_markup=builder.as_markup(), - ) + await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="system_about") await callback.answer() @router.callback_query(F.data == "settings:auto_max_reserved") async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("settings_auto") - - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) + if not await _prepare_system_from_callback(callback, screen="settings_auto"): return text = ( @@ -716,6 +689,7 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None: builder.adjust(2, 2, 1, 1) await callback.message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(callback.message, screen="settings_auto") await callback.answer() @@ -726,8 +700,5 @@ async def set_auto_max_reserved(callback: CallbackQuery) -> None: value = None if raw_value == "off" else float(raw_value) AutoTradeService().set_max_reserved_balance_percent(value) - if callback.message is not None: - await open_auto_settings(callback) - - AutoTradeRunner.set_current_screen("settings_auto") + await open_auto_settings(callback) await callback.answer("Max Reserved обновлён") \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py index 016b67a..5130a5f 100644 --- a/app/src/telegram/handlers/trade/main.py +++ b/app/src/telegram/handlers/trade/main.py @@ -7,12 +7,14 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from src.telegram.ui.common import mode_line from src.telegram.handlers.trade.new_order import ( show_recent_drafts, start_new_order_draft, ) -from src.trading.auto.runner import AutoTradeRunner +from src.telegram.live.active_screen import ActiveScreenManager +from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen +from src.telegram.ui.common import mode_line + router = Router(name="trade_main") @@ -25,10 +27,6 @@ def _trade_screen(title: str) -> str: ) -# ========================= -# KEYBOARDS -# ========================= - def _trade_home_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="📝 Ордер", callback_data="trade:new_order") @@ -72,13 +70,10 @@ def _settings_menu_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -# ========================= -# TEXTS -# ========================= - def _trade_home_text() -> str: return _trade_screen("Основной экран") + def _trade_orders_text() -> str: return _trade_screen("Ордера") @@ -91,161 +86,226 @@ def _trade_settings_text() -> str: return _trade_screen("Настройки") -# ========================= -# ENTRY -# ========================= +def _register_trade_screen(message: Message) -> None: + 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, + ) + + ScreenRegistry.register_screen( + StaticScreen( + screen="trade", + bot=message.bot, + chat_id=message.chat.id, + message_id=message.message_id, + ) + ) + + ActiveScreenManager.register( + screen="trade", + message=message, + ) + + +async def _prepare_trade_from_message(message: Message) -> None: + await ActiveScreenManager.prepare_new_screen( + screen="trade", + bot=message.bot, + chat_id=message.chat.id, + ) + + +async def _prepare_trade_from_callback(callback: CallbackQuery) -> bool: + if callback.message is None: + await callback.answer("Сообщение не найдено", show_alert=True) + return False + + await ActiveScreenManager.prepare_new_screen( + screen="trade", + bot=callback.message.bot, + chat_id=callback.message.chat.id, + keep_message_id=callback.message.message_id, + ) + + return True + @router.message(F.text.in_({"💹 Торговля"})) -async def open_trade(message: Message) -> None: - AutoTradeRunner.set_current_screen("trade") +async def open_trade(message: Message, state: FSMContext) -> None: + await state.clear() + await _prepare_trade_from_message(message) - await message.answer( + sent_message = await message.answer( _trade_home_text(), reply_markup=_trade_home_keyboard(), ) + _register_trade_screen(sent_message) + @router.callback_query(F.data == "trade:home") async def open_trade_home_callback( callback: CallbackQuery, state: FSMContext, ) -> None: - AutoTradeRunner.set_current_screen("trade") - await state.clear() + + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + _trade_home_text(), + reply_markup=_trade_home_keyboard(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - _trade_home_text(), - reply_markup=_trade_home_keyboard(), - ) - - -# ========================= -# NEW ORDER -# ========================= @router.callback_query(F.data == "trade:new_order") async def open_new_order_from_trade( callback: CallbackQuery, state: FSMContext, ) -> None: + if not await _prepare_trade_from_callback(callback): + return + + await start_new_order_draft(callback.message, state, edit_mode=True) + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await start_new_order_draft(callback.message, state, edit_mode=True) -# ========================= -# ORDERS -# ========================= - @router.callback_query(F.data == "trade:orders") async def open_orders_from_trade(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("trade") - + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + _trade_orders_text(), + reply_markup=_orders_menu_keyboard(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - _trade_orders_text(), - reply_markup=_orders_menu_keyboard(), - ) @router.callback_query(F.data == "trade:orders:drafts") async def open_drafts_from_orders(callback: CallbackQuery) -> None: + if not await _prepare_trade_from_callback(callback): + return + + await show_recent_drafts(callback.message, edit_mode=True, page=1) + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await show_recent_drafts(callback.message, edit_mode=True, page=1) -# ========================= -# HISTORY -# ========================= - @router.callback_query(F.data == "trade:history") async def open_trade_history(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("trade") + if not await _prepare_trade_from_callback(callback): + return + await callback.message.edit_text( + _trade_history_text(), + reply_markup=_history_menu_keyboard(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - _trade_history_text(), - reply_markup=_history_menu_keyboard(), - ) @router.callback_query(F.data == "trade:history:filled") async def open_filled_history(callback: CallbackQuery) -> None: + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + "💹 Торговля — История\n\n" + "Шаг 1/1: Исполненные\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - "💹 Торговля — История\n\n" - "Шаг 1/1: Исполненные\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) @router.callback_query(F.data == "trade:history:canceled") async def open_canceled_history(callback: CallbackQuery) -> None: + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + "💹 Торговля — История\n\n" + "Шаг 1/1: Отменённые\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - "💹 Торговля — История\n\n" - "Шаг 1/1: Отменённые\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) -# ========================= -# SETTINGS -# ========================= - @router.callback_query(F.data == "trade:settings") async def open_trade_settings(callback: CallbackQuery) -> None: - AutoTradeRunner.set_current_screen("trade") + if not await _prepare_trade_from_callback(callback): + return + await callback.message.edit_text( + _trade_settings_text(), + reply_markup=_settings_menu_keyboard(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - _trade_settings_text(), - reply_markup=_settings_menu_keyboard(), - ) @router.callback_query(F.data == "trade:settings:params") async def open_trade_settings_params(callback: CallbackQuery) -> None: + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + "💹 Торговля — Настройки\n\n" + "Шаг 1/1: Параметры ордера\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - "💹 Торговля — Настройки\n\n" - "Шаг 1/1: Параметры ордера\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) @router.callback_query(F.data == "trade:settings:mode") async def open_trade_settings_mode(callback: CallbackQuery) -> None: + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + "💹 Торговля — Настройки\n\n" + "Шаг 1/1: Режим работы\n" + "Текущий режим: demo", + reply_markup=_trade_home_button(), + ) + + _register_trade_screen(callback.message) await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - "💹 Торговля — Настройки\n\n" - "Шаг 1/1: Режим работы\n" - "Текущий режим: demo", - reply_markup=_trade_home_button(), - ) @router.callback_query(F.data == "trade:settings:help") async def open_trade_settings_help(callback: CallbackQuery) -> None: - await callback.answer() - if callback.message is not None: - await callback.message.edit_text( - "💹 Торговля — Справка\n\n" - "Шаг 1/1: Информация\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) \ No newline at end of file + if not await _prepare_trade_from_callback(callback): + return + + await callback.message.edit_text( + "💹 Торговля — Справка\n\n" + "Шаг 1/1: Информация\n" + "Раздел в разработке.", + reply_markup=_trade_home_button(), + ) + + _register_trade_screen(callback.message) + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/live/active_screen.py b/app/src/telegram/live/active_screen.py new file mode 100644 index 0000000..aac46f0 --- /dev/null +++ b/app/src/telegram/live/active_screen.py @@ -0,0 +1,100 @@ +# app/src/telegram/live/active_screen.py + +from __future__ import annotations + +from dataclasses import dataclass + +from aiogram import Bot +from aiogram.types import Message + +from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry +from src.trading.auto.runner import AutoTradeRunner +from src.trading.debug.runner import DebugTradeRunner + + +@dataclass(slots=True) +class ActiveScreen: + screen: str + chat_id: int + message_id: int + + +class ActiveScreenManager: + _screens: dict[int, ActiveScreen] = {} + + @classmethod + async def prepare_new_screen( + cls, + *, + screen: str, + bot: Bot, + chat_id: int, + keep_message_id: int | None = None, + ) -> None: + previous = cls._screens.get(chat_id) + + await AutoTradeRunner.detach_screen( + delete_message=True, + bot=bot, + chat_id=chat_id, + keep_message_id=keep_message_id, + ) + + await DebugTradeRunner.detach_screen( + delete_message=True, + bot=bot, + chat_id=chat_id, + keep_message_id=keep_message_id, + ) + + await LiveScreenRunner.delete_chat_screens( + bot=bot, + chat_id=chat_id, + keep_message_id=keep_message_id, + ) + + await ScreenRegistry.delete_chat_screens( + bot=bot, + chat_id=chat_id, + keep_message_id=keep_message_id, + ) + + if previous is not None: + if keep_message_id is None or previous.message_id != keep_message_id: + try: + await bot.delete_message( + chat_id=previous.chat_id, + message_id=previous.message_id, + ) + except Exception: + pass + + cls._screens.pop(chat_id, None) + + @classmethod + def register( + cls, + *, + screen: str, + message: Message, + ) -> None: + cls._screens[message.chat.id] = ActiveScreen( + screen=screen, + chat_id=message.chat.id, + message_id=message.message_id, + ) + + @classmethod + def unregister( + cls, + *, + chat_id: int, + message_id: int, + ) -> None: + current = cls._screens.get(chat_id) + + if current is None: + return + + if current.message_id == message_id: + cls._screens.pop(chat_id, None) \ No newline at end of file diff --git a/app/src/telegram/live/runner.py b/app/src/telegram/live/runner.py index 97ff485..d28905c 100644 --- a/app/src/telegram/live/runner.py +++ b/app/src/telegram/live/runner.py @@ -12,51 +12,29 @@ from aiogram.exceptions import TelegramBadRequest @dataclass(slots=True) class LiveScreen: - # имя live-экрана: market / portfolio 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 @dataclass(slots=True) class StaticScreen: - # имя статичного экрана: journal / monitoring / etc screen: str - - # Telegram bot instance bot: Bot - - # чат, где находится экран chat_id: int - - # сообщение экрана message_id: int class ScreenRegistry: _screens: dict[str, list[StaticScreen]] = {} - # зарегистрировать статичный экран @classmethod def register_screen(cls, static_screen: StaticScreen) -> None: screens = cls._screens.setdefault(static_screen.screen, []) - screens[:] = [ item for item in screens @@ -65,27 +43,17 @@ class ScreenRegistry: and item.message_id == static_screen.message_id ) ] - screens.append(static_screen) - - # удалить конкретное сообщение из всех статичных экранов без удаления из Telegram + @classmethod - def unregister_message( - cls, - *, - chat_id: int, - message_id: int, - ) -> None: + def unregister_message(cls, *, chat_id: int, message_id: int) -> None: empty_screens: list[str] = [] for screen, screens in cls._screens.items(): screens[:] = [ item for item in screens - if not ( - item.chat_id == chat_id - and item.message_id == message_id - ) + if not (item.chat_id == chat_id and item.message_id == message_id) ] if not screens: @@ -94,15 +62,8 @@ class ScreenRegistry: for screen in empty_screens: cls._screens.pop(screen, None) - # удалить старые статичные экраны указанного типа @classmethod - async def delete_screen( - cls, - *, - screen: str, - bot: Bot, - chat_id: int, - ) -> None: + async def delete_screen(cls, *, screen: str, bot: Bot, chat_id: int) -> None: screens = cls._screens.get(screen, []) remaining: list[StaticScreen] = [] @@ -124,16 +85,52 @@ class ScreenRegistry: else: cls._screens.pop(screen, None) + @classmethod + async def delete_chat_screens( + cls, + *, + bot: Bot, + chat_id: int, + keep_message_id: int | None = None, + ) -> None: + empty_screens: list[str] = [] + + for screen, screens in cls._screens.items(): + remaining: list[StaticScreen] = [] + + for static_screen in screens: + if static_screen.chat_id != chat_id: + remaining.append(static_screen) + continue + + if keep_message_id is not None and static_screen.message_id == keep_message_id: + remaining.append(static_screen) + continue + + try: + await bot.delete_message( + chat_id=static_screen.chat_id, + message_id=static_screen.message_id, + ) + except Exception: + pass + + cls._screens[screen] = remaining + + if not remaining: + empty_screens.append(screen) + + for screen in empty_screens: + cls._screens.pop(screen, None) + class LiveScreenRunner: _screens: dict[str, list[LiveScreen]] = {} _tasks: dict[str, asyncio.Task] = {} - # зарегистрировать live-экран @classmethod def register_screen(cls, live_screen: LiveScreen) -> None: screens = cls._screens.setdefault(live_screen.screen, []) - screens[:] = [ item for item in screens @@ -142,27 +139,17 @@ class LiveScreenRunner: and item.message_id == live_screen.message_id ) ] - screens.append(live_screen) - - # удалить конкретное сообщение из всех live-экранов без удаления из Telegram + @classmethod - def unregister_message( - cls, - *, - chat_id: int, - message_id: int, - ) -> None: + def unregister_message(cls, *, chat_id: int, message_id: int) -> None: empty_screens: list[str] = [] for screen, screens in cls._screens.items(): screens[:] = [ item for item in screens - if not ( - item.chat_id == chat_id - and item.message_id == message_id - ) + if not (item.chat_id == chat_id and item.message_id == message_id) ] if not screens: @@ -170,16 +157,10 @@ class LiveScreenRunner: for screen in empty_screens: cls._screens.pop(screen, None) + cls.stop(screen) - # удалить все live-экраны указанного типа из Telegram @classmethod - async def delete_screen( - cls, - *, - screen: str, - bot: Bot, - chat_id: int, - ) -> None: + async def delete_screen(cls, *, screen: str, bot: Bot, chat_id: int) -> None: screens = cls._screens.get(screen, []) remaining: list[LiveScreen] = [] @@ -200,8 +181,47 @@ class LiveScreenRunner: cls._screens[screen] = remaining else: cls._screens.pop(screen, None) + cls.stop(screen) + + @classmethod + async def delete_chat_screens( + cls, + *, + bot: Bot, + chat_id: int, + keep_message_id: int | None = None, + ) -> None: + empty_screens: list[str] = [] + + for screen, screens in cls._screens.items(): + remaining: list[LiveScreen] = [] + + for live_screen in screens: + if live_screen.chat_id != chat_id: + remaining.append(live_screen) + continue + + if keep_message_id is not None and live_screen.message_id == keep_message_id: + remaining.append(live_screen) + continue + + try: + await bot.delete_message( + chat_id=live_screen.chat_id, + message_id=live_screen.message_id, + ) + except Exception: + pass + + cls._screens[screen] = remaining + + if not remaining: + empty_screens.append(screen) + + for screen in empty_screens: + cls._screens.pop(screen, None) + cls.stop(screen) - # запустить автообновление группы экранов @classmethod def start(cls, screen: str) -> None: task = cls._tasks.get(screen) @@ -211,7 +231,6 @@ class LiveScreenRunner: cls._tasks[screen] = asyncio.create_task(cls._worker(screen)) - # остановить автообновление группы экранов @classmethod def stop(cls, screen: str) -> None: task = cls._tasks.get(screen) @@ -222,14 +241,16 @@ class LiveScreenRunner: task.cancel() cls._tasks.pop(screen, None) - # фоновый цикл обновления группы экранов @classmethod async def _worker(cls, screen: str) -> None: while True: + if screen not in cls._screens: + cls._tasks.pop(screen, None) + return + await cls._refresh_screen(screen) await asyncio.sleep(cls._screen_interval(screen)) - # получить интервал обновления группы экранов @classmethod def _screen_interval(cls, screen: str) -> int: screens = cls._screens.get(screen, []) @@ -238,11 +259,11 @@ class LiveScreenRunner: return screens[0].interval_seconds - # обновить все Telegram-сообщения live-экрана @classmethod async def _refresh_screen(cls, screen: str) -> None: screens = cls._screens.get(screen, []) if not screens: + cls._screens.pop(screen, None) return alive_screens: list[LiveScreen] = [] diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 633675d..8cb657f 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -97,6 +97,39 @@ class AutoTradeRunner: cls._render_markup = None cls._last_text = None + @classmethod + async def detach_screen( + cls, + *, + delete_message: bool = False, + bot: Bot | None = None, + chat_id: int | None = None, + keep_message_id: int | None = None, + ) -> None: + if ( + delete_message + and bot is not None + and cls._chat_id is not None + and cls._message_id is not None + and cls._chat_id == chat_id + and cls._message_id != keep_message_id + ): + try: + await bot.delete_message( + chat_id=cls._chat_id, + message_id=cls._message_id, + ) + except Exception: + pass + + cls._bot = None + cls._chat_id = None + cls._message_id = None + cls._render_text = None + cls._render_markup = None + cls._current_screen = None + cls._last_text = None + @classmethod def set_current_screen(cls, screen: str) -> None: cls._current_screen = screen @@ -594,10 +627,6 @@ class AutoTradeRunner: @classmethod async def _refresh_screen(cls, *, force: bool = False) -> None: - if cls._current_screen != "auto": - cls._log_refresh_skip("current_screen_not_auto") - return - now = time.monotonic() if now < cls._retry_after_until: diff --git a/app/src/trading/debug/runner.py b/app/src/trading/debug/runner.py index 6e2f958..d3f5ca9 100644 --- a/app/src/trading/debug/runner.py +++ b/app/src/trading/debug/runner.py @@ -74,6 +74,39 @@ class DebugTradeRunner: cls._render_markup = None cls._last_text = None + @classmethod + async def detach_screen( + cls, + *, + delete_message: bool = False, + bot: Bot | None = None, + chat_id: int | None = None, + keep_message_id: int | None = None, + ) -> None: + if ( + delete_message + and bot is not None + and cls._chat_id is not None + and cls._message_id is not None + and cls._chat_id == chat_id + and cls._message_id != keep_message_id + ): + try: + await bot.delete_message( + chat_id=cls._chat_id, + message_id=cls._message_id, + ) + except Exception: + pass + + cls._bot = None + cls._chat_id = None + cls._message_id = None + cls._render_text = None + cls._render_markup = None + cls._current_screen = None + cls._last_text = None + @classmethod def set_current_screen(cls, screen: str) -> None: cls._current_screen = screen diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index bf5dad4..6f15499 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -302,6 +302,19 @@ - removed shared market cache collisions - stabilized AUTO/DEBUG UI market rendering +#### 07.4.3.17 — Unified Active Screen Lifecycle + +- внедрён единый lifecycle основных экранов +- реализовано автоматическое закрытие предыдущего экрана +- устранено накопление Telegram UI-экранов +- унифицировано поведение всех основных экранов +- разделены UI lifecycle и background runtime +- сохранена фоновая работа AutoTradeRunner +- сохранена фоновая работа DebugTradeRunner +- стабилизирована работа live-экранов +- подготовлена архитектура для Telegram push-уведомлений +- подготовлена база для runtime event notifications + ### 07.4.4 ⏳ Grid Strategy diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index 73c8840..495ede4 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -287,6 +287,18 @@ - removed shared market cache collisions - stabilized AUTO/DEBUG UI market rendering +#### 07.4.3.17 — Unified Active Screen Lifecycle +- внедрён единый lifecycle основных экранов +- реализовано автоматическое закрытие предыдущего экрана +- устранено накопление Telegram UI-экранов +- унифицировано поведение всех основных экранов +- разделены UI lifecycle и background runtime +- сохранена фоновая работа AutoTradeRunner +- сохранена фоновая работа DebugTradeRunner +- стабилизирована работа live-экранов +- подготовлена архитектура для Telegram push-уведомлений +- подготовлена база для runtime event notifications + --- ### 07.4.4 diff --git a/docs/stages/ROADMAP_UPDATE_07.4.3.17.md b/docs/stages/ROADMAP_UPDATE_07.4.3.17.md new file mode 100644 index 0000000..d115ff5 --- /dev/null +++ b/docs/stages/ROADMAP_UPDATE_07.4.3.17.md @@ -0,0 +1,84 @@ +# ROADMAP UPDATE — Этап 07.4.3.17 + +## Завершено + +### Active Screen Lifecycle +- внедрён единый lifecycle основных экранов; +- реализовано автоматическое закрытие предыдущего экрана; +- устранено накопление Telegram UI-сообщений; +- унифицировано поведение всех основных экранов. + +--- + +## Обновлённая архитектура + +### ActiveScreenManager +Теперь отвечает за: +- регистрацию активного экрана; +- удаление предыдущего экрана; +- переключение UI. + +### LiveScreenRunner +Теперь отвечает только за: +- автообновление live-данных; +- worker loop; +- refresh Telegram message. + +### Trading Runtime +Теперь полностью независим от UI: +- AutoTradeRunner; +- DebugTradeRunner; +- future push-events. + +--- + +## Исправленные разделы + +### Основные экраны +- Главная +- Мониторинг +- Рынок +- Портфель +- Журнал +- Торговля +- Система +- Автоторговля +- Debug Auto + +--- + +## Новое поведение системы + +### UI +- всегда существует только один основной экран; +- при открытии нового старый удаляется автоматически. + +### Background Runtime +- продолжает работать независимо от UI; +- готов к Telegram push-событиям. + +--- + +# Следующий этап + +## Planned + +### Telegram Event Push Layer +Планируется внедрение: +- уведомлений об открытии позиции; +- уведомлений о закрытии позиции; +- уведомлений о сигналах; +- runtime alerts; +- system alerts; +- execution events. + +--- + +## Архитектурная готовность + +Система теперь готова к: +- background runtime; +- multi-event notifications; +- persistent runtime state; +- restart-safe workflows; +- production lifecycle management. diff --git a/docs/stages/stage-07_4_3_17-active_screen_lifecycle_ui_refresh_policy.md b/docs/stages/stage-07_4_3_17-active_screen_lifecycle_ui_refresh_policy.md new file mode 100644 index 0000000..3a05ac9 --- /dev/null +++ b/docs/stages/stage-07_4_3_17-active_screen_lifecycle_ui_refresh_policy.md @@ -0,0 +1,164 @@ +# Stage 07.4.3.17 - Active Screen Lifecycle & UI Refresh Policy + +### Цель этапа + +Привести Telegram UI к единой архитектуре экранов: + +- в чате существует только один основной экран; +- при открытии нового экрана предыдущий автоматически удаляется; +- live-экраны продолжают работать независимо от UI; +- фоновые процессы не зависят от существования Telegram-сообщения. + +--- + +# Проблема до исправления + +## Поведение экранов было разным + +### Автоторговля +- открытие другого экрана удаляло экран автоторговли. + +### Мониторинг +- повторное открытие удаляло старую копию; +- но другие экраны не закрывали мониторинг. + +### Торговля / Система +- можно было открыть бесконечное количество копий; +- старые экраны не очищались. + +--- + +# Целевая архитектура + +## Новая политика UI + +При открытии любого основного экрана: + +1. предыдущий основной экран удаляется; +2. новый экран становится активным; +3. live-обновления продолжают работать; +4. фоновые процессы не останавливаются. + +--- + +# Введён ActiveScreenManager + +## Ответственность + +`ActiveScreenManager` теперь управляет: + +- текущим активным экраном; +- удалением предыдущего экрана; +- переключением между основными UI-разделами. + +--- + +# Разделение ответственности + +## ActiveScreenManager + +Отвечает только за lifecycle UI: + +- какой экран сейчас активен; +- какой экран удалить; +- какой экран зарегистрировать. + +--- + +## LiveScreenRunner + +Отвечает только за: + +- автообновление live-экранов; +- refresh Telegram message; +- worker loop. + +Больше НЕ управляет жизненным циклом экранов. + +--- + +## AutoTradeRunner / DebugTradeRunner + +Отвечают только за: + +- фоновые процессы; +- runtime state; +- сигналы; +- торговую логику. + +Больше НЕ зависят от существования Telegram UI. + +--- + +# Исправленные обработчики + +Обновлены: + +- home.py +- monitoring.py +- market.py +- portfolio.py +- journal.py +- trade/main.py +- system.py +- auto/main.py +- debug_auto/main.py + +--- + +# Итоговое поведение + +## Теперь работает правильно + +### Любой основной экран: +- закрывает предыдущий экран; +- оставляет только один UI-экран в чате. + +### Live-экраны: +- продолжают обновляться; +- не плодят копии сообщений. + +### Автоторговля: +- продолжает работать после закрытия UI; +- готова к будущим Telegram push-уведомлениям. + +--- + +# Архитектурная схема + +```text +UI Screen Lifecycle + ↓ +ActiveScreenManager + ↓ +удаляет предыдущий основной экран + ↓ +рендер нового экрана + ↓ +Runner обновляет только live-data +``` + +--- + +# Результат этапа + +## Система стала: + +- предсказуемой; +- масштабируемой; +- пригодной для push-событий; +- пригодной для background runtime; +- устойчивой к накоплению Telegram-сообщений. + +--- + +# Следующие этапы + +Дальше можно безопасно внедрять: + +- push-уведомления; +- сигналы; +- алерты; +- события открытия/закрытия позиций; +- восстановление состояния после рестарта; +- runtime monitoring. \ No newline at end of file