Stage 07.3.1 - auto trading background runner and live screen

This commit is contained in:
2026-04-28 22:29:26 +03:00
parent d639137855
commit b2801d8a19
11 changed files with 368 additions and 19 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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("Риск обновлён")

View File

@@ -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(

View File

@@ -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

View File

@@ -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, "Автоторговля выключена."
# установить инструмент

View File

@@ -0,0 +1,22 @@
# 0018 — Single Live Auto Trading Screen
## Решение
Для автоторговли используется только один активный live-экран.
## Причины
Telegram не умеет перемещать старое сообщение вниз.
Чтобы имитировать такое поведение:
- старый live-экран удаляется;
- новый создаётся внизу;
- runner регистрирует новый message_id.
## Последствия
- нет дублей live-экранов;
- автообновление всегда идёт в актуальный экран;
- снижается риск Telegram rate limit;
- UX выглядит как единый живой dashboard.

View File

@@ -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
---

View File

@@ -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