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