07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer

This commit is contained in:
2026-05-20 21:15:00 +03:00
parent 2c75f95b46
commit 06ea376cb5
36 changed files with 6260 additions and 2092 deletions

View File

@@ -1,364 +0,0 @@
# app/src/telegram/handlers/auto.py
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 src.telegram.ui.common import mode_line
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.telegram.handlers.system import open_auto_settings
from src.integrations.exchange.service import ExchangeService
from src.telegram.ui.currency_ui import format_usd_amount
router = Router(name="auto")
# красивое отображение стратегии
def _strategy_label(strategy: str | None) -> str:
mapping = {
"TREND": "📈 Trend Following",
"GRID": "🧩 Grid Trading",
"SCALP": "⚡ Scalping",
}
return mapping.get(strategy or "", "")
# красивое отображение статуса
def _status_label(status: str) -> str:
mapping = {
"OFF": "⚪ Выключена",
"OBSERVING": "👀 Наблюдение",
"RUNNING": "🟢 Активна",
}
return mapping.get(status, status)
# красивое отображение сигнала
def _signal_label(signal: str | None) -> str:
mapping = {
"BUY": "🟢 BUY",
"SELL": "🔴 SELL",
"HOLD": "🟡 HOLD",
}
return mapping.get(signal or "", "")
# красивое отображение решения
def _decision_label(status: str) -> str:
mapping = {
"WAITING": "🟡 Ожидание",
"CONFIRMING": "🟠 Подтверждение",
"READY": "🟢 Готово к входу",
"BLOCKED": "🔴 Заблокировано",
}
return mapping.get(status, status)
# компактное значение или заглушка
def _value_or_dash(value: object) -> str:
if value is None:
return ""
return str(value)
# формат цены
def _price_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.2f}"
# текущая цена инструмента
def _market_price_or_dash(symbol: str | None) -> str:
if not symbol:
return ""
try:
ticker = ExchangeService().get_price(symbol)
return f"$ {format_usd_amount(ticker.price)}"
except Exception:
return ""
# формат USD
def _usd_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.2f} USD"
# формат размера позиции
def _size_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.8f}".rstrip("0").rstrip(".")
# формат плеча
def _leverage_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.1f}x"
# формат торгового инструмента для UI
def _format_symbol(symbol: str | None) -> str:
if not symbol:
return ""
base_symbol = symbol.split("_", 1)[0]
parts = base_symbol.split("/", 1)
if len(parts) == 2:
return f"{parts[0]} / {parts[1]}"
return base_symbol
# стратегия для компактного UI
def _compact_strategy(strategy: str | None) -> str:
if not strategy:
return ""
return strategy.upper()
# плечо для компактного UI
def _compact_leverage(value: float | None) -> str:
if value is None:
return ""
return f"x{value:g}"
# проверка, настроена ли автоторговля минимально
def _is_auto_configured(state) -> bool:
return bool(
state.symbol
and state.strategy
and state.risk_percent is not None
)
# строка инструмента / стратегии / плеча
def _context_line(state) -> str:
symbol = _format_symbol(state.symbol)
strategy = _compact_strategy(state.strategy)
leverage = _compact_leverage(state.leverage)
if leverage == "":
return f"{symbol} · {strategy}"
return f"{symbol} · {strategy} · {leverage}"
# клавиатура автоторговли
def _auto_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="▶️ Start", callback_data="auto:start")
builder.button(text="👀 Watch", callback_data="auto:observe")
builder.button(text="🛑 Stop", callback_data="auto:stop")
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
builder.adjust(3, 1)
return builder.as_markup()
# собрать текст экрана
def _build_auto_text() -> str:
service = AutoTradeService()
state = service.get_state()
account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else ""
configured = _is_auto_configured(state)
price = _market_price_or_dash(state.symbol)
status_line = {
"OFF": "⚪ Off",
"OBSERVING": "👀 Watch",
"RUNNING": "🟢 On",
}.get(state.status, state.status)
header = (
f"<b>🤖 Автоторговля · {status_line}</b>\n"
f"🔸 {account_mode} аккаунт\n\n"
)
if state.status == "OFF":
if not configured:
return (
f"{header}"
"⚠️ Не настроена\n"
"Настрой параметры"
)
return (
f"{header}"
f"{_context_line(state)}\n"
f"Price: {price}\n"
f"Risk: {risk}"
)
position_line = (
f"Pos: {_value_or_dash(state.position_side)} | "
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
)
if state.position_side != "NONE" and state.entry_price is not None:
position_line = (
f"Pos: {_value_or_dash(state.position_side)} | "
f"Entry: $ {_price_or_dash(state.entry_price)} | "
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
)
return (
f"{header}"
f"{_context_line(state)}\n"
f"Price: {price}\n\n"
f"{_signal_label(state.last_signal)} ×{state.last_signal_repeat_count} "
f"· {state.decision_status}\n\n"
f"{position_line}\n"
f"Risk: {risk}"
)
# отрисовать live-экран автоторговли
async def _render_auto_screen(
target_message: Message,
*,
edit_mode: bool,
) -> None:
text = _build_auto_text()
if edit_mode:
try:
await target_message.edit_text(text, reply_markup=_auto_keyboard())
except TelegramBadRequest as exc:
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
@router.callback_query(F.data == "auto:home")
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)
return
AutoTradeRunner.set_current_screen("auto")
await _render_auto_screen(callback.message, edit_mode=True)
await callback.answer()
# включить активную торговлю
@router.callback_query(F.data == "auto:start")
async def auto_start(callback: CallbackQuery) -> None:
service = AutoTradeService()
state = service.get_state()
if not _is_auto_configured(state):
await callback.answer(
"Сначала настрой параметры автоторговли",
show_alert=True,
)
if callback.message is not None:
await open_auto_settings(callback)
return
_, 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)
await callback.answer(message)
# включить наблюдение
@router.callback_query(F.data == "auto:observe")
async def auto_observe(callback: CallbackQuery) -> None:
service = AutoTradeService()
state = service.get_state()
if not _is_auto_configured(state):
await callback.answer(
"Сначала настрой параметры автоторговли",
show_alert=True,
)
if callback.message is not None:
await open_auto_settings(callback)
return
_, 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)
await callback.answer(message)
# выключить автоторговлю
@router.callback_query(F.data == "auto:stop")
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)
await callback.answer(message)

View File

@@ -1,11 +1,11 @@
# app/src/telegram/handlers/auto/main.py
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, Message
from aiogram.types import CallbackQuery, InaccessibleMessage, Message
from src.telegram.handlers.auto.ui import (
auto_diagnostics_keyboard,
@@ -24,6 +24,20 @@ from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
router = Router(name="auto")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
async def render_auto_screen(
target_message: Message,
*,
@@ -38,8 +52,13 @@ async def render_auto_screen(
if "message is not modified" not in str(exc).lower():
raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=target_message.bot,
bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_text,
@@ -53,9 +72,13 @@ async def render_auto_screen(
return
sent_message = await target_message.answer(text, reply_markup=auto_keyboard())
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=sent_message.bot,
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_text,
@@ -68,29 +91,43 @@ async def render_auto_screen(
)
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_message(message: Message) -> bool:
bot = message.bot
async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if bot is None:
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,
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
def build_auto_diagnostics_text() -> str:
service = AutoTradeService()
@@ -118,10 +155,28 @@ async def render_auto_diagnostics_screen(
error_text = str(exc).lower()
if "message to edit not found" in error_text:
await target_message.answer(
sent_message = await target_message.answer(
text,
reply_markup=auto_diagnostics_keyboard(),
)
bot = sent_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_diagnostics_text,
render_markup=auto_diagnostics_keyboard,
)
ActiveScreenManager.register(
screen="auto_diagnostics",
message=sent_message,
)
return
if "message is not modified" in error_text:
@@ -129,8 +184,13 @@ async def render_auto_diagnostics_screen(
raise
bot = target_message.bot
if bot is None:
return
AutoTradeRunner.register_screen(
bot=target_message.bot,
bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_diagnostics_text,
@@ -147,7 +207,8 @@ async def render_auto_diagnostics_screen(
async def open_auto(message: Message, state: FSMContext) -> None:
await state.clear()
await _prepare_auto_from_message(message)
if not await _prepare_auto_from_message(message):
return
await render_auto_screen(message, edit_mode=False)
@@ -159,7 +220,13 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
if not await _prepare_auto_from_callback(callback):
return
await render_auto_screen(callback.message, edit_mode=True)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await render_auto_screen(message, edit_mode=True)
await callback.answer()
@@ -174,20 +241,22 @@ async def auto_start(callback: CallbackQuery) -> None:
show_alert=True,
)
if callback.message is not None:
if _require_message(callback) is not None:
await open_auto_settings(callback)
return
_, message = service.start()
_, message_text = service.start()
if callback.message is not None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
await callback.answer(message)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:observe")
@@ -201,48 +270,60 @@ async def auto_observe(callback: CallbackQuery) -> None:
show_alert=True,
)
if callback.message is not None:
if _require_message(callback) is not None:
await open_auto_settings(callback)
return
_, message = service.observe()
_, message_text = service.observe()
if callback.message is not None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
if message is not None:
await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
await callback.answer(message)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:stop")
async def auto_stop(callback: CallbackQuery) -> None:
service = AutoTradeService()
_, message = service.stop()
_, message_text = service.stop()
AutoTradeRunner.stop()
if callback.message is not None:
await _prepare_auto_from_callback(callback)
await render_auto_screen(callback.message, edit_mode=True)
if await _prepare_auto_from_callback(callback):
message = _require_message(callback)
await callback.answer(message)
if message is not None:
await render_auto_screen(message, edit_mode=True)
await callback.answer(message_text)
@router.callback_query(F.data == "auto:diagnostics")
async def open_auto_diagnostics(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return
await ActiveScreenManager.prepare_new_screen(
screen="auto_diagnostics",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
await render_auto_diagnostics_screen(callback.message)
await render_auto_diagnostics_screen(message)
await callback.answer()

View File

@@ -7,9 +7,16 @@ import asyncio
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import (
CallbackQuery,
InlineKeyboardMarkup,
Message,
InaccessibleMessage,
)
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
@@ -18,17 +25,31 @@ from src.trading.journal.service import JournalService
router = Router(name="auto_risk")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
class AutoRiskStates(StatesGroup):
waiting_stop_loss = State()
waiting_take_profit = State()
waiting_max_loss = State()
def _format_number(value: float | int | None) -> str:
if value is None:
return ""
def _format_number(value: NumericLike | None) -> str:
number = safe_float(value)
number = float(value)
if number is None:
return ""
if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}"
@@ -36,20 +57,26 @@ def _format_number(value: float | int | None) -> str:
return f"{number:.2f}".rstrip("0").rstrip(".")
def _format_percent(value: float | None) -> str:
if value is None:
def _format_percent(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return "off"
return f"{_format_number(value)}%"
return f"{_format_number(number)}%"
def _format_usd(value: float | None) -> str:
if value is None:
def _format_usd(value: NumericLike | None) -> str:
number = safe_float(value)
if number is None:
return "off"
return f"{_format_number(value)} USD"
return f"{_format_number(number)} USD"
def _rule_icon(value: float | None) -> str:
return "" if value is not None else "⚠️"
def _rule_icon(value: NumericLike | None) -> str:
return "" if safe_float(value) is not None else "⚠️"
def _risk_keyboard() -> InlineKeyboardMarkup:
@@ -96,19 +123,27 @@ def _risk_text(status_message: str | None = None) -> str:
return text
async def _render_risk_screen(callback: CallbackQuery) -> None:
async def _render_risk_screen(
callback: CallbackQuery,
) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
_unregister_auto_screen_message(callback)
await callback.message.edit_text(
await message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
await callback.answer()
@@ -121,18 +156,34 @@ async def _render_risk_screen_by_message(
) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
data = await state.get_data()
chat_id = data.get("risk_chat_id")
message_id = data.get("risk_message_id")
bot = message.bot
if chat_id is None or message_id is None:
if bot is None:
return
data: JsonDict = await state.get_data()
raw_chat_id = data.get("risk_chat_id")
raw_message_id = data.get("risk_message_id")
if not isinstance(raw_chat_id, int):
await message.answer(
_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
return
await message.bot.edit_message_text(
if not isinstance(raw_message_id, int):
await message.answer(
_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
return
chat_id = raw_chat_id
message_id = raw_message_id
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=_risk_text(status_message=status_message),
@@ -146,7 +197,7 @@ async def _render_risk_screen_by_message(
return
try:
await message.bot.edit_message_text(
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=_risk_text(),
@@ -156,33 +207,56 @@ async def _render_risk_screen_by_message(
pass
async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> None:
if callback.message is None:
async def _remember_risk_screen(
callback: CallbackQuery,
state: FSMContext,
) -> None:
message = _require_message(callback)
if message is None:
return
await state.update_data(
risk_chat_id=callback.message.chat.id,
risk_message_id=callback.message.message_id,
risk_chat_id=message.chat.id,
risk_message_id=message.message_id,
)
def _unregister_auto_screen_message(callback: CallbackQuery) -> None:
if callback.message is None:
def _unregister_auto_screen_message(
callback: CallbackQuery,
) -> None:
message = _require_message(callback)
if message is None:
return
AutoTradeRunner.unregister_screen(
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
chat_id=message.chat.id,
message_id=message.message_id,
)
def _parse_positive_or_none(raw_text: str | None) -> float | None:
def _risk_payload(**values: object) -> JsonDict:
return dict(values)
def _parse_positive_or_none(
raw_text: str | None,
) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".")
if value_text.lower() in {"0", "0.0", "off", "-"}:
if value_text.lower() in {
"0",
"0.0",
"off",
"-",
}:
return None
value = float(value_text)
value = safe_float(value_text)
if value is None:
raise ValueError
if value <= 0:
return None
@@ -190,16 +264,26 @@ def _parse_positive_or_none(raw_text: str | None) -> float | None:
return value
def _validate_percent(value: float | None) -> bool:
if value is None:
def _validate_percent(
value: NumericLike | None,
) -> bool:
number = safe_float(value)
if number is None:
return True
return 0 < value <= 100
return 0 < number <= 100
def _validate_max_loss(value: float | None) -> bool:
if value is None:
def _validate_max_loss(
value: NumericLike | None,
) -> bool:
number = safe_float(value)
if number is None:
return True
return 0 < value <= 10000
return 0 < number <= 10000
def _log_risk_updated(action: str) -> None:
@@ -216,11 +300,11 @@ def _log_risk_updated(action: str) -> None:
),
screen="auto",
action=action,
payload={
"stop_loss_percent": state.stop_loss_percent,
"take_profit_percent": state.take_profit_percent,
"max_loss_usd": state.max_loss_usd,
},
payload=_risk_payload(
stop_loss_percent=state.stop_loss_percent,
take_profit_percent=state.take_profit_percent,
max_loss_usd=state.max_loss_usd,
),
)
except Exception:
pass
@@ -242,17 +326,26 @@ async def open_auto_risk_from_settings(callback: CallbackQuery, state: FSMContex
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_stop_loss)
await _remember_risk_screen(callback, state)
if callback.message is not None:
await callback.message.edit_text(
"<b>Stop Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Stop Loss в процентах.\n"
"Например: <code>1</code>, <code>0.5</code>, <code>0,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await message.edit_text(
"<b>Stop Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Stop Loss в процентах.\n"
"Например: <code>1</code>, <code>0.5</code>, <code>0,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await callback.answer()
@@ -261,17 +354,26 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_take_profit)
await _remember_risk_screen(callback, state)
if callback.message is not None:
await callback.message.edit_text(
"<b>Take Profit</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Take Profit в процентах.\n"
"Например: <code>2</code>, <code>1.5</code>, <code>1,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await message.edit_text(
"<b>Take Profit</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите Take Profit в процентах.\n"
"Например: <code>2</code>, <code>1.5</code>, <code>1,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await callback.answer()
@@ -280,17 +382,26 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
await state.set_state(AutoRiskStates.waiting_max_loss)
await _remember_risk_screen(callback, state)
if callback.message is not None:
await callback.message.edit_text(
"<b>Maximum Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите максимальный paper-убыток в USD.\n"
"Например: <code>100</code>, <code>50.5</code>, <code>50,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await message.edit_text(
"<b>Maximum Loss</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Введите максимальный paper-убыток в USD.\n"
"Например: <code>100</code>, <code>50.5</code>, <code>50,5</code>\n\n"
"отключить параметр - <code>0</code>"
)
await callback.answer()
@@ -301,6 +412,12 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = AutoTradeService()
service.set_stop_loss_percent(None)
service.set_take_profit_percent(None)
@@ -308,29 +425,26 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
_log_risk_updated("risk_reset")
if callback.message is not None:
await callback.message.edit_text(
_risk_text(status_message="✅ Risk Controls сброшены"),
reply_markup=_risk_keyboard(),
)
await callback.answer()
await asyncio.sleep(2.5)
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
return
try:
await callback.message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
except Exception:
pass
return
await message.edit_text(
_risk_text(status_message="✅ Risk Controls сброшены"),
reply_markup=_risk_keyboard(),
)
await callback.answer()
await asyncio.sleep(2.5)
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
return
try:
await message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
except Exception:
pass
@router.message(AutoRiskStates.waiting_stop_loss)
async def set_stop_loss(message: Message, state: FSMContext) -> None:

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,14 @@
from __future__ import annotations
import math
import time
from aiogram import F, Router
from aiogram.types import Message
from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import JsonList, NumericLike
from src.trading.debug.execution import DebugExecutionEngine
from src.trading.debug.service import DebugTradeService
from src.trading.debug.state import DebugTradeState
@@ -18,7 +21,7 @@ router = Router(name="debug")
def _debug_enabled() -> bool:
return load_settings().debug_enabled
return bool(load_settings().debug_enabled)
def _debug_help_text() -> str:
@@ -80,6 +83,7 @@ async def debug_auto(message: Message) -> None:
if command == "reset":
state = service.reset()
await message.answer(
"✅ [DEBUG] Runtime reset\n\n"
f"{_debug_state_text(state)}"
@@ -88,6 +92,7 @@ async def debug_auto(message: Message) -> None:
if command == "off":
state = service.stop()
await message.answer(
"✅ [DEBUG] Runtime stopped\n\n"
f"{_debug_state_text(state)}"
@@ -97,17 +102,20 @@ async def debug_auto(message: Message) -> None:
if command == "state":
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
return
if command == "hold":
seconds = _parse_int(parts, index=2, default=335)
state = service.set_signal_duration(
signal="HOLD",
seconds=seconds,
confidence=0.0,
force_ready=False,
)
await message.answer(
f"✅ [DEBUG] HOLD {seconds}s\n\n"
f"{_debug_state_text(state)}"
@@ -182,15 +190,31 @@ async def debug_auto(message: Message) -> None:
if command == "long":
state, result = service.open_long()
await message.answer(_execution_result_text("OPEN LONG", state, result))
await message.answer(
_execution_result_text(
"OPEN LONG",
state,
result,
)
)
return
if command == "short":
state, result = service.open_short()
await message.answer(_execution_result_text("OPEN SHORT", state, result))
await message.answer(
_execution_result_text(
"OPEN SHORT",
state,
result,
)
)
return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
await message.answer(
f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
)
@router.message(F.text.startswith("/debug_exec"))
@@ -211,43 +235,82 @@ async def debug_exec(message: Message) -> None:
if command == "state":
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
return
if command == "buy":
state, result = service.open_long()
await message.answer(_execution_result_text("EXEC BUY / LONG", state, result))
await message.answer(
_execution_result_text(
"EXEC BUY / LONG",
state,
result,
)
)
return
if command == "sell":
state, result = service.open_short()
await message.answer(_execution_result_text("EXEC SELL / SHORT", state, result))
await message.answer(
_execution_result_text(
"EXEC SELL / SHORT",
state,
result,
)
)
return
if command == "flip":
state, result = service.flip()
await message.answer(_execution_result_text("EXEC AUTO FLIP", state, result))
await message.answer(
_execution_result_text(
"EXEC AUTO FLIP",
state,
result,
)
)
return
if command == "close":
state, result = service.close(reason="DEBUG_CLOSE")
await message.answer(_execution_result_text("EXEC CLOSE", state, result))
await message.answer(
_execution_result_text(
"EXEC CLOSE",
state,
result,
)
)
return
if command == "process":
state, result = service.process()
await message.answer(_execution_result_text("EXEC PROCESS", state, result))
await message.answer(
_execution_result_text(
"EXEC PROCESS",
state,
result,
)
)
return
if command == "update":
state = service.update_market()
await message.answer(
"✅ [DEBUG] Market update\n\n"
f"{_debug_state_text(state)}"
)
return
await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
await message.answer(
f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
)
@router.message(F.text.startswith("/debug_live"))
@@ -264,8 +327,9 @@ async def debug_live(message: Message) -> None:
"/debug_exec sell\n"
"/debug_exec flip\n"
"/debug_exec close\n\n"
"Live-мониторинг для изолированного debug будет добавлен в следующем пакете "
"через отдельный DebugTradeRunner и отдельный Debug Auto экран."
"Live-мониторинг для изолированного debug будет добавлен "
"в следующем пакете через отдельный DebugTradeRunner "
"и отдельный Debug Auto экран."
)
@@ -275,19 +339,30 @@ async def debug_signal(message: Message) -> None:
await message.answer("Debug mode выключен.")
return
signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text)
signal, confidence, repeat_count, error = _parse_debug_signal_args(
message.text,
)
if error is not None:
await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}")
await message.answer(
f"⛔️ {error}\n\n{_debug_help_text()}"
)
return
service = DebugTradeService()
state = service.set_signal(
signal=signal,
confidence=confidence,
repeat_count=repeat_count,
reason=f"[DEBUG] LEGACY FORCE {signal} {confidence:.2f} ×{repeat_count}",
force_ready=signal in {"BUY", "SELL"} and repeat_count >= 2,
reason=(
f"[DEBUG] LEGACY FORCE "
f"{signal} {confidence:.2f} ×{repeat_count}"
),
force_ready=(
signal in {"BUY", "SELL"}
and repeat_count >= 2
),
)
await message.answer(
@@ -303,6 +378,7 @@ async def debug_ready(message: Message) -> None:
return
service = DebugTradeService()
state = service.set_signal_duration(
signal="BUY",
seconds=15,
@@ -323,13 +399,16 @@ async def debug_state(message: Message) -> None:
return
service = DebugTradeService()
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
def _debug_state_text(state: DebugTradeState) -> str:
def _debug_state_text(
state: DebugTradeState,
) -> str:
position = state.position
duration = _signal_duration_text(state)
@@ -348,7 +427,7 @@ def _debug_state_text(state: DebugTradeState) -> str:
f"Signal: {_signal_icon(state.last_signal)} {state.last_signal}\n"
f"Duration: {duration}\n"
f"Repeats: {state.last_signal_repeat_count}\n"
f"Confidence: {state.last_signal_confidence:.2f}\n"
f"Confidence: {safe_float(state.last_signal_confidence) or 0.0:.2f}\n"
f"Decision: {state.decision_status}\n"
f"Ready: {state.is_signal_ready}\n"
f"Reason: {state.last_signal_reason or ''}\n\n"
@@ -385,53 +464,95 @@ def _execution_result_text(
)
def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]:
def _parse_debug_signal_args(
raw_text: str | None,
) -> tuple[str, float, int, str | None]:
parts = (raw_text or "").split()
signal = parts[1].upper() if len(parts) > 1 else "BUY"
if signal not in {"BUY", "SELL", "HOLD"}:
return "BUY", 0.9, 2, "SIGNAL должен быть BUY, SELL или HOLD."
try:
confidence = float(parts[2]) if len(parts) > 2 else 0.9
except ValueError:
return "BUY", 0.9, 2, "CONFIDENCE должен быть числом от 0.00 до 1.00."
if signal not in {"BUY", "SELL", "HOLD"}:
return (
"BUY",
0.9,
2,
"SIGNAL должен быть BUY, SELL или HOLD.",
)
confidence = _parse_float(parts, index=2, default=0.9)
if confidence < 0 or confidence > 1:
return "BUY", 0.9, 2, "CONFIDENCE должен быть от 0.00 до 1.00."
return (
"BUY",
0.9,
2,
"CONFIDENCE должен быть от 0.00 до 1.00.",
)
try:
repeat_count = int(parts[3]) if len(parts) > 3 else 2
except ValueError:
return "BUY", 0.9, 2, "REPEATS должен быть целым числом."
repeat_count = _parse_int(parts, index=3, default=2)
if repeat_count < 1:
return "BUY", 0.9, 2, "REPEATS должен быть больше или равен 1."
return (
"BUY",
0.9,
2,
"REPEATS должен быть больше или равен 1.",
)
return signal, confidence, repeat_count, None
def _parse_int(parts: list[str], *, index: int, default: int) -> int:
def _parse_int(
parts: JsonList,
*,
index: int,
default: int,
) -> int:
try:
return int(parts[index])
except (IndexError, TypeError, ValueError):
value = parts[index]
except (IndexError, TypeError):
return default
number = safe_float(value)
def _parse_float(parts: list[str], *, index: int, default: float) -> float:
try:
return float(parts[index])
except (IndexError, TypeError, ValueError):
if number is None:
return default
return int(number)
def _signal_duration_text(state: DebugTradeState) -> str:
started_at = state.signal_started_at
def _parse_float(
parts: JsonList,
*,
index: int,
default: float,
) -> float:
try:
value = parts[index]
except (IndexError, TypeError):
return default
number = safe_float(value)
if number is None:
return default
return number
def _signal_duration_text(
state: DebugTradeState,
) -> str:
started_at = safe_float(state.signal_started_at)
if started_at is not None:
total_seconds = max(0, int(__import__("time").monotonic() - float(started_at)))
total_seconds = max(
0,
int(time.monotonic() - started_at),
)
else:
total_seconds = max(0, (state.last_signal_repeat_count or 0) * 5)
repeats = state.last_signal_repeat_count or 0
total_seconds = max(0, int(repeats) * 5)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
@@ -446,73 +567,98 @@ def _signal_duration_text(state: DebugTradeState) -> str:
return f"{seconds}с"
def _signal_icon(signal: str | None) -> str:
def _signal_icon(
signal: str | None,
) -> str:
mapping = {
"BUY": "🟢",
"SELL": "🔴",
"HOLD": "🟡",
}
return mapping.get(signal or "", "")
def _format_leverage(value: float | int | None) -> str:
if value is None:
def _format_leverage(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "x—"
return f"x{float(value):g}"
return f"x{number:g}"
def _format_crypto_size(value: float | int | None) -> str:
if value is None:
def _format_crypto_size(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return ""
number = float(value)
return f"{number:.5f}".rstrip("0").rstrip(".")
def _format_percent(value: float | int | None) -> str:
if value is None:
def _format_percent(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return "off"
number = float(value)
if abs(number - round(number)) < 1e-9:
if math.isclose(number, round(number), abs_tol=1e-9):
return f"{int(round(number))}%"
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
def _format_money_compact(value: float | int | None) -> str:
if value is None:
def _format_money_compact(
value: NumericLike | None,
) -> str:
number = safe_float(value)
if number is None:
return ""
number = float(value)
if abs(number - round(number)) < 1e-9:
if math.isclose(number, round(number), abs_tol=1e-9):
return f"{number:,.0f}".replace(",", " ")
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
return (
f"{number:,.2f}"
.replace(",", " ")
.rstrip("0")
.rstrip(".")
)
def _format_usd_or_dash(value: float | int | None) -> str:
if value is None:
def _format_usd_or_dash(
value: NumericLike | None,
) -> str:
if safe_float(value) is None:
return ""
return f"$ {_format_money_compact(value)}"
def _format_usd_or_off(value: float | int | None) -> str:
if value is None:
def _format_usd_or_off(
value: NumericLike | None,
) -> str:
if safe_float(value) is None:
return "off"
return f"$ {_format_money_compact(value)}"
def _format_signed_usd(value: float | int | None) -> str:
if value is None:
return ""
def _format_signed_usd(
value: NumericLike | None,
) -> str:
amount = safe_float(value)
amount = float(value)
if amount is None:
return ""
if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}"

View File

@@ -1,5 +1,7 @@
# app/src/telegram/handlers/home.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
@@ -11,16 +13,33 @@ from src.telegram.menus import HOME_TEXT
router = Router(name="home")
@router.message(F.text == "🏠 Главная")
async def open_home(message: Message, state: FSMContext) -> None:
await state.clear()
async def _prepare_home_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="home",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
)
return True
@router.message(F.text == "🏠 Главная")
async def open_home(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_home_from_message(message):
return
sent_message = await message.answer(HOME_TEXT)
ActiveScreenManager.register(

View File

@@ -1,11 +1,18 @@
# app/src/telegram/handlers/journal.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram.types import (
BufferedInputFile,
CallbackQuery,
InaccessibleMessage,
Message,
)
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.telegram.handlers.journal_ui import (
PAGE_SIZE,
build_actions_keyboard,
@@ -23,12 +30,26 @@ from src.trading.journal.service import JournalService
router = Router(name="journal")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _user_id_from_message(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
def _chat_id_from_message(message: Message) -> int | None:
return message.chat.id if message.chat else None
def _chat_id_from_message(message: Message) -> int:
return message.chat.id
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
@@ -36,17 +57,38 @@ 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
message = _require_message(callback)
return None
if message is None:
return None
return message.chat.id
def _parse_page(value: NumericLike | None) -> int | None:
number = safe_float(value)
if number is None:
return None
return int(number)
def _journal_payload(**values: object) -> JsonDict:
return dict(values)
def _register_journal_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
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,
@@ -55,7 +97,7 @@ def _register_journal_screen(message: Message) -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen="journal",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
@@ -67,6 +109,54 @@ def _register_journal_screen(message: Message) -> None:
)
async def _prepare_journal_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_journal_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return False
bot = message.bot
if bot is None:
await callback.answer(
"Bot недоступен",
show_alert=True,
)
return False
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _show_journal_page(
target_message: Message,
*,
@@ -85,33 +175,37 @@ async def _show_journal_page(
kb = build_keyboard(page, total_pages)
if edit_mode:
await target_message.edit_text(text, reply_markup=kb)
await target_message.edit_text(
text,
reply_markup=kb,
)
_register_journal_screen(target_message)
return
sent_message = await target_message.answer(text, reply_markup=kb)
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:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
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,
)
message = _require_message(callback)
await callback.message.edit_text(
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer()
@@ -120,11 +214,8 @@ async def journal_actions(callback: CallbackQuery) -> None:
async def open_journal(message: Message, state: FSMContext) -> None:
await state.clear()
await ActiveScreenManager.prepare_new_screen(
screen="journal",
bot=message.bot,
chat_id=message.chat.id,
)
if not await _prepare_journal_from_message(message):
return
await _show_journal_page(
message,
@@ -134,22 +225,23 @@ async def open_journal(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:journal")
async def open_journal_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None:
async def open_journal_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await _show_journal_page(
callback.message,
message,
page=1,
edit_mode=True,
)
@@ -165,6 +257,7 @@ async def journal_noop(callback: CallbackQuery) -> None:
@router.callback_query(F.data == "journal:export_csv")
async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
try:
data = service.export_csv()
@@ -173,8 +266,8 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
filename=service.build_export_filename("csv"),
)
if callback.message is not None:
await callback.message.answer_document(document=document)
if message is not None:
await message.answer_document(document=document)
service.log_ui_info(
event_type="journal_exported",
@@ -183,10 +276,11 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"},
payload=_journal_payload(format="csv"),
)
await callback.answer("CSV экспортирован")
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
@@ -195,15 +289,20 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "csv"},
payload=_journal_payload(format="csv"),
raw_error=str(exc),
)
await callback.answer("Не удалось экспортировать CSV", show_alert=True)
await callback.answer(
"Не удалось экспортировать CSV",
show_alert=True,
)
@router.callback_query(F.data == "journal:export_xlsx")
async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
try:
data = service.export_xlsx()
@@ -212,8 +311,8 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
filename=service.build_export_filename("xlsx"),
)
if callback.message is not None:
await callback.message.answer_document(document=document)
if message is not None:
await message.answer_document(document=document)
service.log_ui_info(
event_type="journal_exported",
@@ -222,10 +321,11 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"},
payload=_journal_payload(format="xlsx"),
)
await callback.answer("Excel экспортирован")
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
@@ -234,56 +334,56 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload={"format": "xlsx"},
payload=_journal_payload(format="xlsx"),
raw_error=str(exc),
)
await callback.answer("Не удалось экспортировать Excel", show_alert=True)
await callback.answer(
"Не удалось экспортировать Excel",
show_alert=True,
)
@router.callback_query(F.data == "journal:clear_confirm")
async def clear_journal_confirm(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
total_count = service.get_total_count()
await callback.message.edit_text(
await message.edit_text(
render_clear_confirm(total_count=total_count),
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data == "journal:clear")
async def clear_journal(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_all()
total_count = service.get_total_count()
await callback.message.edit_text(
await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count,
@@ -291,29 +391,27 @@ async def clear_journal(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data == "journal:clear_older:90")
async def clear_journal_older_90(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
deleted_count = service.clear_older_than_days(90)
total_count = service.get_total_count()
await callback.message.edit_text(
await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count if deleted_count > 0 else None,
@@ -322,34 +420,37 @@ async def clear_journal_older_90(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(),
)
_register_journal_screen(callback.message)
_register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery) -> None:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_journal_from_callback(callback):
return
page_raw = callback.data.split(":", 1)[1]
message = _require_message(callback)
try:
page = int(page_raw)
except ValueError:
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
data = callback.data or ""
parts = data.split(":", 1)
if len(parts) < 2:
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,
)
page = _parse_page(parts[1])
if page is None:
await callback.answer("Неизвестное действие", show_alert=True)
return
await _show_journal_page(
callback.message,
message,
page=page,
edit_mode=True,
)

View File

@@ -10,6 +10,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.config import load_settings
from src.core.event_titles import event_title
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
PAGE_SIZE = 5
@@ -30,26 +32,34 @@ TECH_TO_HUMAN_MESSAGES = {
}
def build_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
def build_keyboard(
page: int,
total_pages: int,
) -> InlineKeyboardMarkup:
current_page = max(1, int(page))
pages_count = max(1, int(total_pages))
kb = InlineKeyboardBuilder()
if page > 1:
if current_page > 1:
kb.button(text="⏮️", callback_data="journal:1")
kb.button(text="⬅️", callback_data=f"journal:{page - 1}")
kb.button(text="⬅️", callback_data=f"journal:{current_page - 1}")
kb.button(text=f"{page}/{total_pages}", callback_data="journal:noop")
kb.button(text=f"{current_page}/{pages_count}", callback_data="journal:noop")
if page < total_pages:
kb.button(text="➡️", callback_data=f"journal:{page + 1}")
if current_page < pages_count:
kb.button(text="➡️", callback_data=f"journal:{current_page + 1}")
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
nav_count = 1
if page > 1:
if current_page > 1:
nav_count += 2
if page < total_pages:
if current_page < pages_count:
nav_count += 1
kb.adjust(nav_count, 2, 1)
@@ -83,33 +93,51 @@ def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
return kb.as_markup()
def _format_int(
value: NumericLike | None,
*,
default: int = 0,
) -> int:
number = safe_float(value)
if number is None:
return default
return int(number)
def render_clear_confirm(
*,
total_count: int,
deleted_count: int | None = None,
no_old_records_days: int | None = None,
total_count: NumericLike,
deleted_count: NumericLike | None = None,
no_old_records_days: NumericLike | None = None,
) -> str:
total = _format_int(total_count)
lines = [
"<b>⚠️ Очистить журнал</b>",
"",
"<b>СИСТЕМА</b> · Настройки · Журнал",
"",
f"📄 Записей: {total_count}",
f"📄 Записей: {total}",
]
if deleted_count is not None:
lines.append(f"🧹 Удалено записей: {deleted_count}")
lines.append(f"🧹 Удалено записей: {_format_int(deleted_count)}")
if no_old_records_days is not None:
lines.append(f"📭 Нет записей старше {no_old_records_days} дней")
lines.append(
f"📭 Нет записей старше {_format_int(no_old_records_days)} дней"
)
return "\n".join(lines)
def _parse_local_datetime(value: str) -> datetime | None:
def _parse_local_datetime(value: object) -> datetime | None:
try:
settings = load_settings()
dt = datetime.fromisoformat(value)
raw_value = str(value or "")
dt = datetime.fromisoformat(raw_value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
@@ -135,39 +163,52 @@ def _date_group_label(dt: datetime | None) -> str:
return dt.strftime("%Y-%m-%d")
def _time_label(dt: datetime | None, raw_value: str) -> str:
def _time_label(
dt: datetime | None,
raw_value: object,
) -> str:
if dt is None:
return raw_value
return str(raw_value or "")
return dt.strftime("%H:%M:%S")
def _event_title(event_type: str) -> str:
return event_title(event_type)
def _event_title(event_type: object) -> str:
return event_title(str(event_type or ""))
def _humanize_message(message: str) -> str:
lower = message.lower()
for k, v in TECH_TO_HUMAN_MESSAGES.items():
if k in lower:
return v
return message
def _humanize_message(message: object) -> str:
text = str(message or "")
lower = text.lower()
for technical_text, human_text in TECH_TO_HUMAN_MESSAGES.items():
if technical_text in lower:
return human_text
return text
def _payload(event: dict) -> dict:
def _payload(event: JsonDict) -> JsonDict:
payload = event.get("payload")
return payload if isinstance(payload, dict) else {}
if isinstance(payload, dict):
return payload
return {}
def _render_auto_signal(event: dict, created_time: str) -> list[str]:
level = str(event.get("level", "INFO")).upper()
def _render_auto_signal(
event: JsonDict,
created_time: str,
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", "")))
message = _humanize_message(str(event.get("message", "")))
title = _event_title(event.get("event_type"))
message = _humanize_message(event.get("message"))
lines = [
f"{icon} <b>{level}</b> · {title}",
f"{created_time}",
created_time,
]
if message:
@@ -176,15 +217,18 @@ def _render_auto_signal(event: dict, created_time: str) -> list[str]:
return lines
def _render_default_event(event: dict, created_time: str) -> list[str]:
level = str(event.get("level", "INFO")).upper()
def _render_default_event(
event: JsonDict,
created_time: str,
) -> list[str]:
level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "")
title = _event_title(str(event.get("event_type", "")))
message = _humanize_message(str(event.get("message", "")))
title = _event_title(event.get("event_type"))
message = _humanize_message(event.get("message"))
lines = [
f"{icon} <b>{level}</b> · {title}",
f"{created_time}",
created_time,
]
if message:
@@ -193,7 +237,11 @@ def _render_default_event(event: dict, created_time: str) -> list[str]:
return lines
def render(events, page, total_pages):
def render(
events: JsonList,
page: NumericLike,
total_pages: NumericLike,
) -> str:
lines = [
"<b>📒 Журнал</b>",
"",
@@ -205,10 +253,15 @@ def render(events, page, total_pages):
lines.append("Событий пока нет.")
return "\n".join(lines)
current_group = None
current_group: str | None = None
for event in events:
raw_created_at = str(event.get("created_at", ""))
for raw_event in events:
if not isinstance(raw_event, dict):
continue
event: JsonDict = raw_event
raw_created_at = event.get("created_at") or ""
dt = _parse_local_datetime(raw_created_at)
group_label = _date_group_label(dt)
created_time = _time_label(dt, raw_created_at)
@@ -218,7 +271,7 @@ def render(events, page, total_pages):
lines.append(f"<b>{group_label}</b>")
lines.append("")
event_type = str(event.get("event_type", ""))
event_type = str(event.get("event_type") or "")
if event_type in {"signal_summary", "signal_ready"}:
lines.extend(_render_auto_signal(event, created_time))

View File

@@ -4,9 +4,16 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import (
CallbackQuery,
InlineKeyboardMarkup,
Message,
InaccessibleMessage,
)
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.telegram.live.active_screen import ActiveScreenManager
@@ -26,6 +33,20 @@ _last_market_prices: dict[str, float] = {}
_last_market_directions: dict[str, str] = {}
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
@@ -35,22 +56,27 @@ def _market_keyboard() -> InlineKeyboardMarkup:
def _build_market_text(
*,
ticker_price: float,
ticker_price: NumericLike,
name: str,
market_type: str,
base_asset: str,
quote_asset: str,
) -> str:
price = safe_float(ticker_price)
if price is None:
price = 0.0
previous_price = _last_market_prices.get(name)
price_direction = _last_market_directions.get(name, "")
if previous_price is not None:
if ticker_price > previous_price:
if price > previous_price:
price_direction = "🔺"
elif ticker_price < previous_price:
elif price < previous_price:
price_direction = "🔻"
_last_market_prices[name] = ticker_price
_last_market_prices[name] = price
_last_market_directions[name] = price_direction
type_map = {
@@ -64,7 +90,7 @@ def _build_market_text(
f"{mode_line()}"
"\n"
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
f"<b>$ {format_usd_amount(ticker_price)}</b> {price_direction}\n\n"
f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
f"{now_line()}"
)
@@ -87,9 +113,21 @@ def _build_market_live_text() -> str:
symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a"
base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
base_asset = (
symbol_info.base_asset
if symbol_info and symbol_info.base_asset
else "n/a"
)
quote_asset = (
symbol_info.quote_asset
if symbol_info and symbol_info.quote_asset
else "n/a"
)
name = (
symbol_info.name
if symbol_info and symbol_info.name
else ticker.symbol
)
return _build_market_text(
ticker_price=ticker.price,
@@ -101,10 +139,16 @@ def _build_market_live_text() -> str:
def _register_market_live_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
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,
@@ -113,7 +157,7 @@ def _register_market_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen(
LiveScreen(
screen="market",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_build_market_live_text,
@@ -121,9 +165,58 @@ def _register_market_live_screen(message: Message) -> None:
interval_seconds=5,
)
)
LiveScreenRunner.start("market")
async def _prepare_market_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_market_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return False
bot = message.bot
if bot is None:
await callback.answer(
"Bot недоступен",
show_alert=True,
)
return False
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _render_market_screen(
target_message: Message,
*,
@@ -184,9 +277,21 @@ async def _render_market_screen(
symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a"
base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
base_asset = (
symbol_info.base_asset
if symbol_info and symbol_info.base_asset
else "n/a"
)
quote_asset = (
symbol_info.quote_asset
if symbol_info and symbol_info.quote_asset
else "n/a"
)
name = (
symbol_info.name
if symbol_info and symbol_info.name
else ticker.symbol
)
text = _build_market_text(
ticker_price=ticker.price,
@@ -205,7 +310,7 @@ async def _render_market_screen(
chat_id=chat_id,
payload={
"symbol": ticker.symbol,
"price": ticker.price,
"price": safe_float(ticker.price),
},
)
@@ -223,11 +328,8 @@ async def _render_market_screen(
async def open_market(message: Message, state: FSMContext) -> None:
await state.clear()
await ActiveScreenManager.prepare_new_screen(
screen="market",
bot=message.bot,
chat_id=message.chat.id,
)
if not await _prepare_market_from_message(message):
return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
@@ -263,32 +365,38 @@ 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:
async def open_market_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_market_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
try:
await _render_market_screen(
callback.message,
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="open_from_monitoring",
)
await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_open_error",
@@ -312,32 +420,38 @@ 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:
async def retry_market(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_market_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer(
"Сообщение недоступно",
show_alert=True,
)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
try:
await _render_market_screen(
callback.message,
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_retry_error",

View File

@@ -4,7 +4,12 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.live.active_screen import ActiveScreenManager
@@ -14,6 +19,20 @@ from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScr
router = Router(name="monitoring")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _monitoring_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="💼 Портфель", callback_data="monitoring:portfolio")
@@ -31,10 +50,16 @@ def _monitoring_text() -> str:
def _register_monitoring_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
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,
@@ -43,23 +68,65 @@ def _register_monitoring_screen(message: Message) -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen="monitoring",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
)
@router.message(F.text == "📊 Мониторинг")
async def open_monitoring(message: Message, state: FSMContext) -> None:
await state.clear()
async def _prepare_monitoring_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="monitoring",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_monitoring_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="monitoring",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
@router.message(F.text == "📊 Мониторинг")
async def open_monitoring(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_monitoring_from_message(message):
return
sent_message = await message.answer(
_monitoring_text(),
reply_markup=_monitoring_keyboard(),
@@ -74,30 +141,31 @@ async def open_monitoring(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:home")
async def open_monitoring_callback(callback: CallbackQuery, state: FSMContext) -> None:
async def open_monitoring_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_monitoring_from_callback(callback):
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,
)
message = _require_message(callback)
await callback.message.edit_text(
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
_monitoring_text(),
reply_markup=_monitoring_keyboard(),
)
_register_monitoring_screen(callback.message)
_register_monitoring_screen(message)
ActiveScreenManager.register(
screen="monitoring",
message=callback.message,
message=message,
)
await callback.answer()

View File

@@ -4,9 +4,16 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
@@ -39,13 +46,32 @@ PINNED_ORDER = {
}
def _compact_amount(currency: str, value: float) -> str:
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _payload(**values: object) -> JsonDict:
return dict(values)
def _compact_amount(currency: str, value: NumericLike) -> str:
number = safe_float(value) or 0.0
currency = currency.upper()
if currency in {"USD", "USDT", "EUR"}:
return format_usd_amount(value)
return format_usd_amount(number)
text = f"{value:.8f}"
text = f"{number:.8f}"
if "." in text:
integer, frac = text.split(".")
@@ -103,7 +129,11 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
)
return text, _portfolio_keyboard()
visible_balances = [item for item in balances if not is_zero_balance(item)]
visible_balances = [
item
for item in balances
if not is_zero_balance(item)
]
visible_balances = sort_balances(visible_balances)
if not visible_balances:
@@ -128,8 +158,13 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
for item in visible_balances:
currency = item.currency.upper()
total = balance_total(item)
estimated_usd = estimate_balance_usd(item, exchange_service, price_cache)
total = safe_float(balance_total(item)) or 0.0
locked = safe_float(item.locked) or 0.0
estimated_usd = estimate_balance_usd(
item,
exchange_service,
price_cache,
)
if estimated_usd is not None:
total_estimated_usd += estimated_usd
@@ -139,8 +174,8 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
line = f"{currency}: {_compact_amount(currency, total)}"
if item.locked > 0:
line += f" · locked {_compact_amount(currency, item.locked)}"
if locked > 0:
line += f" · locked {_compact_amount(currency, locked)}"
if estimated_usd is not None and currency not in {"USD", "USDT"}:
line += f" ≈ $ {format_usd_amount(estimated_usd)}"
@@ -157,7 +192,10 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
if has_any_estimate:
lines.insert(3, "")
lines.insert(3, f"Оценка: <b>≈ $ {format_usd_amount(total_estimated_usd)}</b>")
lines.insert(
3,
f"Оценка: <b>≈ $ {format_usd_amount(total_estimated_usd)}</b>",
)
if missing_estimate_assets:
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
@@ -184,10 +222,16 @@ def _portfolio_live_markup() -> InlineKeyboardMarkup:
def _register_portfolio_live_screen(message: Message) -> None:
bot = message.bot
if bot is None:
return
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,
@@ -196,7 +240,7 @@ def _register_portfolio_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen(
LiveScreen(
screen="portfolio",
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_portfolio_live_text,
@@ -204,9 +248,52 @@ def _register_portfolio_live_screen(message: Message) -> None:
interval_seconds=10,
)
)
LiveScreenRunner.start("portfolio")
async def _prepare_portfolio_from_message(
message: Message,
) -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="portfolio",
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_portfolio_from_callback(
callback: CallbackQuery,
) -> bool:
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="portfolio",
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
async def _render_portfolio_screen(
target_message: Message,
*,
@@ -241,24 +328,25 @@ async def _render_portfolio_screen(
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)
return
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:
async def open_portfolio(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await ActiveScreenManager.prepare_new_screen(
screen="portfolio",
bot=message.bot,
chat_id=message.chat.id,
)
if not await _prepare_portfolio_from_message(message):
return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
chat_id = message.chat.id
try:
await _render_portfolio_screen(
@@ -291,32 +379,34 @@ 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:
async def open_portfolio_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_portfolio_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
try:
await _render_portfolio_screen(
callback.message,
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="open_from_monitoring",
)
await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="portfolio_open_error",
@@ -340,32 +430,34 @@ 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:
async def retry_portfolio(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
if not await _prepare_portfolio_from_callback(callback):
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,
)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
try:
await _render_portfolio_screen(
callback.message,
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
await callback.answer()
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="portfolio_retry_error",

View File

@@ -15,30 +15,42 @@ from src.telegram.menus import MAIN_MENU_TEXT
router = Router(name="start")
@router.message(Command("start"))
async def cmd_start(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
await state.clear()
async def _show_main_menu(
message: Message,
) -> None:
await message.answer(
MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(),
)
@router.message(Command("start"))
async def cmd_start(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await _show_main_menu(message)
@router.message(Command("menu"))
async def cmd_menu(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
async def cmd_menu(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await message.answer(
MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(),
)
await _show_main_menu(message)
@router.message(Command("help"))
async def cmd_help(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
async def cmd_help(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await message.answer(
build_system_text(),
reply_markup=build_main_menu_keyboard(),
@@ -46,10 +58,10 @@ async def cmd_help(message: Message, state: FSMContext) -> None:
@router.message(F.text == "Меню")
async def menu_shortcut(message: Message, state: FSMContext) -> None:
# Глобальный экран: всегда выходим из текущего FSM-сценария.
async def menu_shortcut(
message: Message,
state: FSMContext,
) -> None:
await state.clear()
await message.answer(
MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(),
)
await _show_main_menu(message)

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
@@ -19,6 +21,20 @@ from src.trading.journal.service import JournalService
router = Router(name="system")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
return None
return message
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🛠️ Настройки", callback_data="system:management")
@@ -37,6 +53,11 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup:
def _register_system_screen(message: Message, screen: str = "system") -> None:
bot = message.bot
if bot is None:
return
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -49,7 +70,7 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen=screen,
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
@@ -61,27 +82,42 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
)
async def _prepare_system_from_message(message: Message, screen: str = "system") -> None:
async def _prepare_system_from_message(message: Message, screen: str = "system") -> bool:
bot = message.bot
if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen=screen,
bot=message.bot,
bot=bot,
chat_id=message.chat.id,
)
return True
async def _prepare_system_from_callback(
callback: CallbackQuery,
screen: str = "system",
) -> bool:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return False
bot = message.bot
if bot is None:
await callback.answer("Bot недоступен", 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,
bot=bot,
chat_id=message.chat.id,
keep_message_id=message.message_id,
)
return True
@@ -148,7 +184,8 @@ async def _render_system_screen(
async def open_system(message: Message, state: FSMContext) -> None:
await state.clear()
await _prepare_system_from_message(message, screen="system")
if not await _prepare_system_from_message(message, screen="system"):
return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
@@ -169,16 +206,23 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
if not await _prepare_system_from_callback(callback, screen="system"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
await _render_system_screen(
callback.message,
message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
action="retry",
)
await callback.answer()
@@ -201,8 +245,14 @@ async def open_system_management(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(2, 2, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="system")
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="system")
await callback.answer()
@@ -225,7 +275,11 @@ 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
@@ -248,67 +302,136 @@ 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 = (
f"{state.max_reserved_balance_percent:g}%"
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"
risk = _format_number(state.risk_percent, suffix="%", default="")
leverage_value = safe_float(state.leverage)
leverage = f"x{leverage_value:g}" if leverage_value is not None else ""
max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
sl = _format_percent_setting(state.stop_loss_percent)
tp = _format_percent_setting(state.take_profit_percent)
ml_value = safe_float(state.max_loss_usd)
ml = f"{ml_value:g} USD" if ml_value is not None else "off"
strategy_icon = "" if strategy_ready else "⚠️"
symbol_icon = "" if symbol_ready else "⚠️"
risk_icon = "" if risk_ready else "⚠️"
leverage_icon = "" if leverage_ready else "⚠️"
sl_icon = "" if sl_ready else "⚠️"
if is_trend_strategy:
risk_controls_block = (
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · <b>{'required' if not sl_ready else sl}</b>\n"
f"✅ Take Profit · {tp}\n"
f"✅ Max Loss · {ml}"
)
if is_trend_strategy and not sl_ready:
sl_icon = "⛔️"
else:
risk_controls_block = (
"<b>Защита позиции:</b>\n"
f"✅ Stop Loss · {sl}\n"
f"✅ Take Profit · {tp}\n"
f"✅ Max Loss · {ml}"
sl_icon = (
""
if state.stop_loss_percent is not None
else "⚠️"
)
config_status = "Все параметры настроены" if is_configured else "⚠️ Настрой все параметры"
tp_icon = (
""
if state.take_profit_percent is not None
else "⚠️"
)
ml_icon = (
""
if state.max_loss_usd is not None
else "⚠️"
)
risk_controls_block = (
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · {sl}\n"
f"{tp_icon} Take Profit · {tp}\n"
f"{ml_icon} Max Loss · {ml}"
)
settings_status_icon = "" if is_configured else "⛔️"
config_status = (
""
if is_configured
else "\n\nНастрой все параметры"
)
text = (
"<b>🤖 Автоторговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
f"<b>СИСТЕМА</b> · Настройки {settings_status_icon}\n\n"
f"{strategy_icon} Стратегия: <b>{strategy}</b>\n"
f"{symbol_icon} Актив: <b>{symbol}</b>\n"
f"{risk_icon} Риск на сделку: <b>{risk}</b>\n"
f"{leverage_icon} Плечо: <b>{leverage}</b>\n\n"
f"✅ Лимит на сделку: <b>{max_reserved}</b>\n\n"
f"{risk_controls_block}\n\n"
f"{risk_controls_block}"
f"{config_status}"
)
builder = InlineKeyboardBuilder()
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved")
builder.button(text="🛡️ Риск", callback_data="settings:auto_risk")
builder.button(text="🧯 Защита", callback_data="auto:risk")
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.button(
text="🧠 Стратегия",
callback_data="settings:auto_strategy",
)
builder.button(
text="💱 Актив",
callback_data="settings:auto_symbol",
)
builder.button(
text="⚙️ Плечо",
callback_data="settings:auto_leverage",
)
builder.button(
text="🏦 Лимит",
callback_data="settings:auto_max_reserved",
)
builder.button(
text="🛡️ Риск",
callback_data="settings:auto_risk",
)
builder.button(
text="🧯 Защита",
callback_data="auto:risk",
)
builder.button(
text="🤖 Автоторговля",
callback_data="auto:home",
)
builder.button(
text="⬅️ Назад",
callback_data="system:management",
)
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")
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
text,
reply_markup=builder.as_markup(),
)
_register_system_screen(
message,
screen="settings_auto",
)
await callback.answer()
@@ -316,6 +439,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🧠 Стратегия</b>\n\n"
@@ -330,8 +459,8 @@ async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@@ -340,7 +469,7 @@ def _log_auto_setting_updated(
event_type: str = "auto_settings_updated",
message: str,
action: str,
payload: dict,
payload: JsonDict,
) -> None:
try:
JournalService().log_ui_info(
@@ -370,9 +499,38 @@ def _human_symbol(symbol: str | None) -> str:
return base
def _format_number(
value: NumericLike | None,
*,
suffix: str = "",
default: str = "",
) -> str:
number = safe_float(value)
if number is None:
return default
return f"{number:g}{suffix}"
def _format_percent_setting(value: NumericLike | None) -> str:
return _format_number(value, suffix="%", default="off")
def _parse_callback_float(value: object) -> float | None:
return safe_float(value)
@router.callback_query(F.data.startswith("settings:auto_strategy:"))
async def set_auto_strategy(callback: CallbackQuery) -> None:
strategy = callback.data.split(":", 2)[2].upper()
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение стратегии", show_alert=True)
return
strategy = parts[2].upper()
service = AutoTradeService()
state = service.get_state()
@@ -399,6 +557,12 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>💱 Актив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -413,14 +577,21 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
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 message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_symbol:"))
async def set_auto_symbol(callback: CallbackQuery) -> None:
symbol = callback.data.split(":", 2)[2]
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение актива", show_alert=True)
return
symbol = parts[2]
service = AutoTradeService()
state = service.get_state()
@@ -447,6 +618,12 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🛡️ Риск на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -460,14 +637,25 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_auto")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_risk:"))
async def set_auto_risk(callback: CallbackQuery) -> None:
risk = float(callback.data.split(":", 2)[2])
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение риска", show_alert=True)
return
risk = _parse_callback_float(parts[2])
if risk is None:
await callback.answer("Некорректное значение риска", show_alert=True)
return
service = AutoTradeService()
state = service.get_state()
@@ -494,6 +682,12 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>⚙️ Плечо</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -510,14 +704,25 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
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 message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_leverage:"))
async def set_auto_leverage(callback: CallbackQuery) -> None:
leverage = float(callback.data.split(":", 2)[2])
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректное значение плеча", show_alert=True)
return
leverage = _parse_callback_float(parts[2])
if leverage is None:
await callback.answer("Некорректное значение плеча", show_alert=True)
return
service = AutoTradeService()
state = service.get_state()
@@ -544,6 +749,12 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🏦 Лимит на сделку</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
@@ -559,15 +770,26 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
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 message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_max_reserved:"))
async def set_auto_max_reserved(callback: CallbackQuery) -> None:
raw_value = callback.data.split(":", 2)[2]
value = None if raw_value == "off" else float(raw_value)
data = callback.data or ""
parts = data.split(":", 2)
if len(parts) < 3:
await callback.answer("Некорректные данные кнопки", show_alert=True)
return
raw_value = parts[2]
value = None if raw_value == "off" else _parse_callback_float(raw_value)
if raw_value != "off" and value is None:
await callback.answer("Некорректное значение лимита", show_alert=True)
return
service = AutoTradeService()
state = service.get_state()
@@ -596,6 +818,12 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_trade"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>💹 Торговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
@@ -610,8 +838,8 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
builder.button(text="💹 Торговля", callback_data="trade:home")
builder.adjust(2)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_trade")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_trade")
await callback.answer()
@@ -620,6 +848,12 @@ async def open_general_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_general"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🌍 Общие</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
@@ -633,8 +867,8 @@ async def open_general_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(1)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_general")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_general")
await callback.answer()
@@ -643,6 +877,12 @@ async def open_journal_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
service = JournalService()
total = service.get_total_count()
@@ -664,8 +904,8 @@ 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())
_register_system_screen(callback.message, screen="settings_journal")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -674,6 +914,12 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>🗄 Архив</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
@@ -688,8 +934,8 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None:
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(callback.message, screen="settings_journal")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -698,6 +944,12 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>📦 Лимит</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
@@ -713,8 +965,8 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:journal")
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 message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -723,6 +975,12 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
text = (
"<b>⏳ Хранение</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Журнал\n\n"
@@ -738,8 +996,8 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:journal")
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 message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -756,11 +1014,17 @@ async def back_to_system(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
user_id = callback.from_user.id if callback.from_user else None
chat_id = callback.message.chat.id if callback.message.chat else None
chat_id = message.chat.id
await _render_system_screen(
callback.message,
message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
@@ -774,6 +1038,12 @@ async def open_system_about(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system_about"):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
settings = load_settings()
journal = JournalService()
@@ -783,7 +1053,7 @@ async def open_system_about(callback: CallbackQuery) -> None:
screen="system",
action="about",
user_id=callback.from_user.id if callback.from_user else None,
chat_id=callback.message.chat.id if callback.message.chat else None,
chat_id=message.chat.id,
)
text = (
@@ -801,6 +1071,6 @@ 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())
_register_system_screen(callback.message, screen="system_about")
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="system_about")
await callback.answer()

View File

@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Callable
from collections.abc import Callable
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from aiogram.exceptions import TelegramBadRequest
@@ -17,7 +18,7 @@ class LiveScreen:
chat_id: int
message_id: int
render_text: Callable[[], str]
render_markup: Callable[[], object]
render_markup: Callable[[], InlineKeyboardMarkup | None]
interval_seconds: int = 5