diff --git a/app/src/telegram/handlers/auto.py b/app/src/telegram/handlers/auto.py index fc29c0e..5e520dc 100644 --- a/app/src/telegram/handlers/auto.py +++ b/app/src/telegram/handlers/auto.py @@ -3,12 +3,13 @@ from __future__ import annotations from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from aiogram.exceptions import TelegramBadRequest from src.telegram.ui.common import mode_line +from src.trading.auto.runner import AutoTradeRunner from src.trading.auto.service import AutoTradeService @@ -49,12 +50,9 @@ def _signal_label(signal: str | None) -> str: def _auto_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - # 1 ряд builder.button(text="▶️ Start", callback_data="auto:start") builder.button(text="👀 Watch", callback_data="auto:observe") builder.button(text="🛑 Stop", callback_data="auto:stop") - - # 2 ряд builder.button(text="🛠️ Настройки", callback_data="settings:auto") builder.adjust(3, 1) @@ -64,10 +62,6 @@ def _auto_keyboard() -> InlineKeyboardMarkup: # собрать текст экрана def _build_auto_text() -> str: service = AutoTradeService() - - # выполнить один цикл анализа - service.run_cycle() - state = service.get_state() strategy = _strategy_label(state.strategy) @@ -86,7 +80,7 @@ def _build_auto_text() -> str: ) -# отрисовать экран +# отрисовать live-экран автоторговли async def _render_auto_screen( target_message: Message, *, @@ -98,21 +92,47 @@ async def _render_auto_screen( try: await target_message.edit_text(text, reply_markup=_auto_keyboard()) except TelegramBadRequest as exc: - if "message is not modified" in str(exc).lower(): - return - raise - else: - await target_message.answer(text, reply_markup=_auto_keyboard()) + if "message is not modified" not in str(exc).lower(): + raise + + AutoTradeRunner.register_screen( + bot=target_message.bot, + chat_id=target_message.chat.id, + message_id=target_message.message_id, + render_text=_build_auto_text, + render_markup=_auto_keyboard, + ) + return + + sent_message = await target_message.answer(text, reply_markup=_auto_keyboard()) + + AutoTradeRunner.register_screen( + bot=sent_message.bot, + chat_id=sent_message.chat.id, + message_id=sent_message.message_id, + render_text=_build_auto_text, + render_markup=_auto_keyboard, + ) -# открыть экран из меню +# открыть экран из главного меню @router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"})) async def open_auto(message: Message, state: FSMContext) -> None: await state.clear() + + AutoTradeRunner.set_current_screen("auto") + + current_state = AutoTradeService().get_state() + if current_state.status in {"RUNNING", "OBSERVING"}: + await AutoTradeRunner.delete_registered_screen( + bot=message.bot, + chat_id=message.chat.id, + ) + await _render_auto_screen(message, edit_mode=False) -# открыть экран callback +# открыть экран через callback @router.callback_query(F.data == "auto:home") async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> None: await state.clear() @@ -121,6 +141,7 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> await callback.answer("Сообщение не найдено", show_alert=True) return + AutoTradeRunner.set_current_screen("auto") await _render_auto_screen(callback.message, edit_mode=True) await callback.answer() @@ -131,6 +152,9 @@ async def auto_start(callback: CallbackQuery) -> None: service = AutoTradeService() _, message = service.start() + AutoTradeRunner.set_current_screen("auto") + AutoTradeRunner.start() + if callback.message is not None: await _render_auto_screen(callback.message, edit_mode=True) @@ -143,6 +167,9 @@ async def auto_observe(callback: CallbackQuery) -> None: service = AutoTradeService() _, message = service.observe() + AutoTradeRunner.set_current_screen("auto") + AutoTradeRunner.start() + if callback.message is not None: await _render_auto_screen(callback.message, edit_mode=True) @@ -155,6 +182,8 @@ async def auto_stop(callback: CallbackQuery) -> None: service = AutoTradeService() _, message = service.stop() + AutoTradeRunner.stop() + if callback.message is not None: await _render_auto_screen(callback.message, edit_mode=True) diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index 3c506aa..c7b8078 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -16,6 +16,7 @@ from src.telegram.handlers.journal_ui import ( render_actions, ) from src.trading.journal.service import JournalService +from src.trading.auto.runner import AutoTradeRunner router = Router(name="journal") @@ -45,6 +46,7 @@ async def _show_journal_page( page: int, edit_mode: bool, ) -> None: + AutoTradeRunner.set_current_screen("journal") service = JournalService() total = service.get_total_count() @@ -64,6 +66,7 @@ async def _show_journal_page( @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 @@ -178,6 +181,7 @@ 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 diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py index 41f0149..948ccf5 100644 --- a/app/src/telegram/handlers/market.py +++ b/app/src/telegram/handlers/market.py @@ -16,6 +16,7 @@ from src.telegram.ui.exchange_error import ( show_message_exchange_error, ) from src.trading.journal.service import JournalService +from src.trading.auto.runner import AutoTradeRunner router = Router(name="market") @@ -72,6 +73,8 @@ 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 diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index f23d5aa..40b3f34 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -25,6 +25,7 @@ from src.telegram.ui.exchange_error import ( ) from src.trading.accounts.service import AccountsService from src.trading.journal.service import JournalService +from src.trading.auto.runner import AutoTradeRunner router = Router(name="portfolio") @@ -70,6 +71,8 @@ async def _render_portfolio_screen( edit_mode: bool, action: str, ) -> None: + AutoTradeRunner.set_current_screen("portfolio") + service = AccountsService() exchange_service = ExchangeService() journal = JournalService() diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 706aa5b..18b560b 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -12,6 +12,7 @@ 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.trading.auto.service import AutoTradeService +from src.trading.auto.runner import AutoTradeRunner router = Router(name="system") @@ -44,6 +45,8 @@ async def _render_system_screen( chat_id: int | None, action: str, ) -> None: + AutoTradeRunner.set_current_screen("system") + journal = JournalService() journal.log_ui_info( @@ -166,6 +169,7 @@ async def open_system_management(callback: CallbackQuery) -> None: @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) return @@ -203,6 +207,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None: @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) return @@ -232,11 +237,13 @@ async def set_auto_strategy(callback: CallbackQuery) -> None: if callback.message is not None: await open_auto_settings(callback) + AutoTradeRunner.set_current_screen("settings_auto") 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) return @@ -268,11 +275,13 @@ async def set_auto_symbol(callback: CallbackQuery) -> None: if callback.message is not None: await open_auto_settings(callback) + AutoTradeRunner.set_current_screen("settings_auto") 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) return @@ -302,6 +311,7 @@ async def set_auto_risk(callback: CallbackQuery) -> None: if callback.message is not None: await open_auto_settings(callback) + AutoTradeRunner.set_current_screen("settings_auto") await callback.answer("Риск обновлён") diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py index eacb78c..c5dd444 100644 --- a/app/src/telegram/handlers/trade/main.py +++ b/app/src/telegram/handlers/trade/main.py @@ -12,6 +12,7 @@ from src.telegram.handlers.trade.new_order import ( show_recent_drafts, start_new_order_draft, ) +from src.trading.auto.runner import AutoTradeRunner router = Router(name="trade_main") @@ -96,6 +97,8 @@ def _trade_settings_text() -> str: @router.message(F.text.in_({"📊 Торговля", "⚡ Торговля", "Торговля"})) async def open_trade(message: Message) -> None: + AutoTradeRunner.set_current_screen("trade") + await message.answer( _trade_home_text(), reply_markup=_trade_home_keyboard(), @@ -107,6 +110,8 @@ async def open_trade_home_callback( callback: CallbackQuery, state: FSMContext, ) -> None: + AutoTradeRunner.set_current_screen("trade") + await state.clear() await callback.answer() @@ -137,6 +142,8 @@ async def open_new_order_from_trade( @router.callback_query(F.data == "trade:orders") async def open_orders_from_trade(callback: CallbackQuery) -> None: + AutoTradeRunner.set_current_screen("trade") + await callback.answer() if callback.message is not None: await callback.message.edit_text( @@ -158,6 +165,8 @@ async def open_drafts_from_orders(callback: CallbackQuery) -> None: @router.callback_query(F.data == "trade:history") async def open_trade_history(callback: CallbackQuery) -> None: + AutoTradeRunner.set_current_screen("trade") + await callback.answer() if callback.message is not None: await callback.message.edit_text( @@ -196,6 +205,8 @@ async def open_canceled_history(callback: CallbackQuery) -> None: @router.callback_query(F.data == "trade:settings") async def open_trade_settings(callback: CallbackQuery) -> None: + AutoTradeRunner.set_current_screen("trade") + await callback.answer() if callback.message is not None: await callback.message.edit_text( diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py new file mode 100644 index 0000000..56575e3 --- /dev/null +++ b/app/src/trading/auto/runner.py @@ -0,0 +1,130 @@ +# app/src/trading/auto/runner.py + +from __future__ import annotations + +import asyncio +from typing import Callable + +from aiogram import Bot + +from src.trading.auto.service import AutoTradeService + + +class AutoTradeRunner: + _task: asyncio.Task | None = None + _bot: Bot | None = None + _chat_id: int | None = None + _message_id: int | None = None + _render_text: Callable[[], str] | None = None + _render_markup: Callable[[], object] | None = None + _current_screen: str | None = None + _interval_seconds = 5 + + # зарегистрировать live-экран для автообновления + @classmethod + def register_screen( + cls, + *, + bot: Bot, + chat_id: int, + message_id: int, + render_text: Callable[[], str], + render_markup: Callable[[], object], + ) -> None: + cls._bot = bot + cls._chat_id = chat_id + cls._message_id = message_id + cls._render_text = render_text + cls._render_markup = render_markup + + # удалить ранее зарегистрированный live-экран + @classmethod + async def delete_registered_screen( + cls, + *, + bot: Bot, + chat_id: int, + ) -> None: + if cls._chat_id is None or cls._message_id is None: + return + + if cls._chat_id != chat_id: + return + + try: + await bot.delete_message( + chat_id=cls._chat_id, + message_id=cls._message_id, + ) + except Exception: + pass + + cls._message_id = None + cls._render_text = None + cls._render_markup = None + + # переключить активный экран + @classmethod + def set_current_screen(cls, screen: str) -> None: + cls._current_screen = screen + + # запустить background runner + @classmethod + def start(cls) -> None: + if cls._task is not None and not cls._task.done(): + return + + cls._task = asyncio.create_task(cls._worker()) + + # остановить background runner + @classmethod + def stop(cls) -> None: + if cls._task is None: + return + + cls._task.cancel() + cls._task = None + + # background loop автоторговли + @classmethod + async def _worker(cls) -> None: + service = AutoTradeService() + + while True: + state = service.get_state() + + if state.status == "OFF": + cls._task = None + break + + service.run_cycle() + + await cls._refresh_screen() + await asyncio.sleep(cls._interval_seconds) + + # обновить live-экран Telegram + @classmethod + async def _refresh_screen(cls) -> None: + if cls._current_screen != "auto": + return + + if not all( + [ + cls._bot, + cls._chat_id, + cls._message_id, + cls._render_text, + cls._render_markup, + ] + ): + return + + try: + await cls._bot.edit_message_text( + chat_id=cls._chat_id, + message_id=cls._message_id, + text=cls._render_text(), + reply_markup=cls._render_markup(), + ) + except Exception: + pass \ No newline at end of file diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index e020e9d..7af26f5 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import random from datetime import datetime @@ -11,6 +12,8 @@ from src.trading.auto.state import AutoTradeState class AutoTradeService: _state = AutoTradeState() + _loop_task: asyncio.Task | None = None + _loop_interval_seconds = 5 # получить текущее состояние автоторговли def get_state(self) -> AutoTradeState: @@ -18,18 +21,51 @@ class AutoTradeService: self._state.symbol = load_settings().default_symbol return self._state + # проверить, запущен ли background loop + def is_loop_running(self) -> bool: + return self._loop_task is not None and not self._loop_task.done() + + # запустить background loop, если он ещё не запущен + def start_loop(self) -> None: + if self.is_loop_running(): + return + + self._loop_task = asyncio.create_task(self._loop_worker()) + + # остановить background loop + def stop_loop(self) -> None: + if self._loop_task is None: + return + + self._loop_task.cancel() + self._loop_task = None + + # рабочий цикл автоторговли + async def _loop_worker(self) -> None: + while True: + state = self.get_state() + + if state.status == "OFF": + break + + self.run_cycle() + await asyncio.sleep(self._loop_interval_seconds) + # запустить активную торговлю def start(self) -> tuple[AutoTradeState, str]: state = self.get_state() if state.status == "RUNNING": + self.start_loop() return state, "Автоторговля уже активна." if state.status == "OBSERVING": state.status = "RUNNING" + self.start_loop() return state, "Автоторговля активирована." state.status = "RUNNING" + self.start_loop() return state, "Автоторговля запущена." # включить режим наблюдения @@ -38,9 +74,11 @@ class AutoTradeService: previous_status = state.status if previous_status == "OBSERVING": + self.start_loop() return state, "Режим наблюдения уже включён." state.status = "OBSERVING" + self.start_loop() if previous_status == "OFF": return state, "Включён режим наблюдения." @@ -52,9 +90,11 @@ class AutoTradeService: state = self.get_state() if state.status == "OFF": + self.stop_loop() return state, "Автоторговля уже выключена." state.status = "OFF" + self.stop_loop() return state, "Автоторговля выключена." # установить инструмент diff --git a/docs/decisions/0018-single-live-auto-screen.md b/docs/decisions/0018-single-live-auto-screen.md new file mode 100644 index 0000000..ce51f14 --- /dev/null +++ b/docs/decisions/0018-single-live-auto-screen.md @@ -0,0 +1,22 @@ +# 0018 — Single Live Auto Trading Screen + +## Решение + +Для автоторговли используется только один активный live-экран. + +## Причины + +Telegram не умеет перемещать старое сообщение вниз. + +Чтобы имитировать такое поведение: + +- старый live-экран удаляется; +- новый создаётся внизу; +- runner регистрирует новый message_id. + +## Последствия + +- нет дублей live-экранов; +- автообновление всегда идёт в актуальный экран; +- снижается риск Telegram rate limit; +- UX выглядит как единый живой dashboard. \ No newline at end of file diff --git a/docs/roadmap/stage-07-roadmap.md b/docs/roadmap/stage-07-roadmap.md index 1984da9..44fbb63 100644 --- a/docs/roadmap/stage-07-roadmap.md +++ b/docs/roadmap/stage-07-roadmap.md @@ -33,10 +33,20 @@ --- -## 07.3.1 — Background loop +## 07.3.1 — Background Runner -⏳ asyncio loop -⏳ live cycle +✔ asyncio runner +✔ auto-refresh screen +✔ single live auto screen +✔ active screen guard + +--- + +## 07.3.2 — Live Screens + +⏳ live market screen +⏳ live portfolio screen +⏳ live journal screen --- diff --git a/docs/stages/stage-07_3_1-auto-trading-background-runner.md b/docs/stages/stage-07_3_1-auto-trading-background-runner.md new file mode 100644 index 0000000..17d41aa --- /dev/null +++ b/docs/stages/stage-07_3_1-auto-trading-background-runner.md @@ -0,0 +1,87 @@ +# Stage 07.3.1 — Auto Trading Background Runner + +## Что сделано + +Реализован live-runner для автоторговли. + +## 1. Background runner + +Добавлен файл: + +```text +src/trading/auto/runner.py +``` + +Runner отвечает за: + +- background loop; +- автообновление Telegram-экрана; +- хранение live message; +- удаление старого live-экрана; +- контроль активного экрана. + +## 2. Live auto screen + +Экран 🤖 Автоторговля стал live-экраном. + +При активном режиме: + +- RUNNING +- OBSERVING + +экран обновляется каждые 5 секунд. + +## 3. Auto-refresh UI + +На экране автоматически обновляются: + +- последний анализ; +- сигнал стратегии; +- статус; +- PnL. + +## 4. Single live screen behavior + +Реализовано поведение одного активного live-экрана: + +- если автоторговля уже активна; +- пользователь снова открывает 🤖 Автоторговля; +- старый live-экран удаляется; +- новый экран появляется внизу; +- автообновление переключается на новый экран. + +## 5. Active screen guard + +Добавлен контроль активного экрана: + +```text +_current_screen +``` + +Runner обновляет Telegram только если активен экран: + +```text +auto +``` + +Это предотвращает прыжки обратно на экран автоторговли при переходе в другие разделы. + +## 6. Подготовка к LiveScreenRunner + +Текущая реализация подготовила базу для будущего универсального live-runner: + +- 📈 Рынок +- 💼 Портфель +- 📒 Журнал + +## Commit + +```bash +git add . +git commit -m "Stage 07.3.1 - auto trading background runner and live screen" +git push +``` + +## Следующий этап + +Stage 07.3.2 — Live screens for Market, Portfolio and Journal \ No newline at end of file