07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics

This commit is contained in:
2026-05-28 10:30:54 +03:00
parent f9a25e7671
commit d9e6392e28
75 changed files with 9934 additions and 10508 deletions

View File

@@ -12,6 +12,7 @@ from src.telegram.handlers.auto.ui import (
auto_keyboard,
build_auto_text,
is_auto_configured,
_auto_block_reason,
)
from src.telegram.handlers.system import open_auto_settings
from src.telegram.live.active_screen import ActiveScreenManager
@@ -230,6 +231,16 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
await callback.answer()
@router.callback_query(F.data == "auto:start_blocked")
async def auto_start_blocked(callback: CallbackQuery) -> None:
reason = _auto_block_reason() or "Запуск сейчас недоступен"
await callback.answer(
reason.replace("⛔️ ", ""),
show_alert=True,
)
@router.callback_query(F.data == "auto:start")
async def auto_start(callback: CallbackQuery) -> None:
service = AutoTradeService()
@@ -246,6 +257,22 @@ async def auto_start(callback: CallbackQuery) -> None:
return
block_reason = _auto_block_reason()
if block_reason:
await callback.answer(
block_reason.replace("⛔️ ", "").replace("", ""),
show_alert=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)
return
_, message_text = service.start()
if await _prepare_auto_from_callback(callback):

View File

@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
from src.telegram.ui.common import mode_line
from src.trading.auto.service import AutoTradeService
from src.core.numbers import safe_float
@@ -18,6 +19,11 @@ from src.core.numbers import safe_float
def build_auto_notification_text() -> str:
state = AutoTradeService().get_state()
signal = str(getattr(state, "last_signal", "HOLD") or "HOLD").upper()
if signal in {"BUY", "SELL"}:
return _build_signal_notification_text(state, signal)
cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0)
cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0)
@@ -33,15 +39,102 @@ def build_auto_notification_text() -> str:
return " · ".join(parts)
def _build_signal_notification_text(state, signal: str) -> str:
snapshot = _market_snapshot(getattr(state, "symbol", None))
bid_price = _price_from_snapshot(snapshot, "bid_price")
ask_price = _price_from_snapshot(snapshot, "ask_price")
side = "Long" if signal == "BUY" else "Short"
side_icon = _signal_icon(signal)
asset = _asset_symbol(getattr(state, "symbol", None))
confidence = safe_float(getattr(state, "last_signal_confidence", None)) or 0.0
reason = str(getattr(state, "last_signal_reason", "") or "").strip()
if signal == "BUY":
entry_price = ask_price
entry_source = "Ask"
second_price_label = "Bid"
second_price = bid_price
else:
entry_price = bid_price
entry_source = "Bid"
second_price_label = "Ask"
second_price = ask_price
lines = [
f"Сигнал {side_icon} {asset} · {side}",
"",
f"Цена входа {side} · {_format_plain_or_dash(entry_price)} ({entry_source})",
f"Цена {second_price_label} · {_format_plain_or_dash(second_price)}",
"",
_signal_strength_line(confidence),
]
compact_reason = _notification_signal_reason(reason)
if compact_reason:
lines.append(compact_reason)
return "\n".join(lines)
def _price_from_snapshot(
snapshot: dict[str, object] | None,
key: str,
) -> float | None:
if snapshot is None:
return None
return safe_float(snapshot.get(key))
def _signal_strength_line(confidence: float) -> str:
filled = min(3, max(0, round(confidence * 3)))
bar = "" * filled + "" * (3 - filled)
if confidence >= 0.8:
level = "Сильный"
elif confidence >= 0.5:
level = "Средний"
else:
level = "Слабый"
return f"{bar} {level} · {confidence:.2f}"
def _notification_signal_reason(reason: str) -> str:
reason_upper = reason.upper()
if "BREAKOUT_UP" in reason_upper:
return "Пробой вверх"
if "BREAKOUT_DOWN" in reason_upper:
return "Пробой вниз"
if "TREND_UP" in reason_upper:
return "Рынок растёт"
if "TREND_DOWN" in reason_upper:
return "Рынок снижается"
return _compact_entry_block_message(reason)
def auto_keyboard() -> InlineKeyboardMarkup:
state = AutoTradeService().get_state()
builder = InlineKeyboardBuilder()
status = (state.status or "").upper()
block_reason = _auto_block_reason()
if status == "OFF":
builder.button(text="▶️ Запустить", callback_data="auto:start")
if block_reason:
builder.button(text="⛔ Запуск недоступен", callback_data="auto:start_blocked")
else:
builder.button(text="▶️ Запустить", callback_data="auto:start")
builder.button(text="👀 Наблюдать", callback_data="auto:observe")
elif status == "RUNNING":
@@ -49,7 +142,11 @@ def auto_keyboard() -> InlineKeyboardMarkup:
builder.button(text="🛑 Остановить", callback_data="auto:stop")
elif status == "OBSERVING":
builder.button(text="▶️ Запустить", callback_data="auto:start")
if block_reason:
builder.button(text="⛔ Запуск недоступен", callback_data="auto:start_blocked")
else:
builder.button(text="▶️ Запустить", callback_data="auto:start")
builder.button(text="🛑 Остановить", callback_data="auto:stop")
else:
@@ -97,30 +194,31 @@ def is_auto_configured(state) -> bool:
return True
def build_auto_text() -> str:
state = AutoTradeService().get_state()
def _auto_block_reason(state: object | None = None) -> str | None:
state_to_use = state or AutoTradeService().get_state()
if not is_auto_configured(state):
return _build_not_configured_text(state)
if state.position_side != "NONE" and state.entry_price is not None:
return _build_active_position_text(state)
if state.status == "OFF":
return _build_stopped_without_position_text(state)
return _build_waiting_text(state)
def build_auto_semantic_text() -> str:
text = build_auto_text()
return re.sub(
r" · \d+с| · \d+м \d+с| · \d+ч \d+м",
"",
text,
lines = build_runtime_exchange_alert_lines(
symbol=getattr(state_to_use, "symbol", None),
include_exchange_unavailable=True,
)
if not lines:
return None
return "\n".join(lines)
def _append_auto_block_reason(parts: list[str], state: object) -> None:
block_reason = _auto_block_reason(state)
if block_reason:
parts.extend([
"",
block_reason,
])
def _build_not_configured_text(state) -> str:
symbol_ready = state.symbol is not None
@@ -134,11 +232,16 @@ def _build_not_configured_text(state) -> str:
parts = [
"🤖 Автоторговля ⚪ Не настроена",
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}",
f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}",
f"{risk_icon} Риск · {_required_value(_risk_percent_text(state))}",
]
])
strategy = (state.strategy or "").upper()
@@ -166,6 +269,11 @@ def _build_stopped_without_position_text(state) -> str:
parts = [
"⚪️ Автоторговля остановлена",
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"Доступно 💰 {_format_money_compact(available)}",
"",
@@ -180,11 +288,36 @@ def _build_stopped_without_position_text(state) -> str:
f"Риск · {_format_percent(state.risk_percent)}",
"",
_settings_risk_percent_line(state),
]
])
return "\n".join(parts)
def build_auto_text() -> str:
state = AutoTradeService().get_state()
if not is_auto_configured(state):
return _build_not_configured_text(state)
if state.position_side != "NONE" and state.entry_price is not None:
return _build_active_position_text(state)
if state.status == "OFF":
return _build_stopped_without_position_text(state)
return _build_waiting_text(state)
def build_auto_semantic_text() -> str:
text = build_auto_text()
return re.sub(
r" · \d+с| · \d+м \d+с| · \d+ч \d+м",
"",
text,
)
def _settings_risk_percent_line(state) -> str:
sl = _format_percent(state.stop_loss_percent)
tp = _format_percent(state.take_profit_percent)
@@ -210,9 +343,14 @@ def _build_waiting_text(state) -> str:
parts = [
f"{_status_text(state)}",
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"Доступно 💰 {_format_money_compact(available)}",
]
])
if cycle_trades > 0:
parts.extend([
@@ -367,10 +505,15 @@ def _build_active_position_text(state) -> str:
parts = [
_status_text(state),
_account_mode_line(),
]
_append_auto_block_reason(parts, state)
parts.extend([
"",
f"Доступно 💰 {_format_money_compact(available)}",
f"Маржа · {_format_usd_compact(reserved)}",
]
])
if cycle_trades > 0:
parts.extend([

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
from aiogram.fsm.context import FSMContext
from aiogram.types import (
BufferedInputFile,
@@ -17,11 +18,14 @@ from src.telegram.handlers.journal_ui import (
PAGE_SIZE,
build_actions_keyboard,
build_clear_confirm_keyboard,
build_export_format_keyboard,
build_keyboard,
render,
render_actions,
render_clear_confirm,
render_export_format,
)
from src.trading.journal.filters import normalize_journal_export_filter
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.trading.journal.service import JournalService
@@ -175,10 +179,18 @@ async def _show_journal_page(
kb = build_keyboard(page, total_pages)
if edit_mode:
await target_message.edit_text(
text,
reply_markup=kb,
)
try:
await target_message.edit_text(
text,
reply_markup=kb,
)
except TelegramBadRequest as exc:
if "message is not modified" in str(exc).lower():
_register_journal_screen(target_message)
return
raise
_register_journal_screen(target_message)
return
@@ -200,10 +212,44 @@ async def journal_actions(callback: CallbackQuery) -> None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
try:
await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
_register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data.startswith("journal:export_filter:"))
async def journal_export_filter(callback: CallbackQuery) -> None:
# Пользователь выбрал фильтр экспорта.
# Теперь показываем выбор формата: CSV или Excel.
if not await _prepare_journal_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
raw_filter = (callback.data or "").rsplit(":", 1)[-1]
export_filter = normalize_journal_export_filter(raw_filter)
try:
await message.edit_text(
render_export_format(export_filter),
reply_markup=build_export_format_keyboard(export_filter),
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
_register_journal_screen(message)
@@ -224,8 +270,8 @@ async def open_journal(message: Message, state: FSMContext) -> None:
)
@router.callback_query(F.data == "monitoring:journal")
async def open_journal_from_monitoring(
@router.callback_query(F.data == "system:journal")
async def open_journal_from_system(
callback: CallbackQuery,
state: FSMContext,
) -> None:
@@ -254,20 +300,36 @@ async def journal_noop(callback: CallbackQuery) -> None:
await callback.answer()
@router.callback_query(F.data == "journal:export_csv")
@router.callback_query(F.data.startswith("journal:export_csv"))
async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
parts = (callback.data or "").split(":")
export_filter = normalize_journal_export_filter(
parts[2] if len(parts) >= 3 else "all"
)
await callback.answer("Готовлю CSV…")
try:
data = service.export_csv()
data = service.export_csv(export_filter=export_filter)
document = BufferedInputFile(
data,
filename=service.build_export_filename("csv"),
filename=service.build_export_filename(
"csv",
export_filter=export_filter,
),
)
if message is not None:
await message.answer_document(document=document)
await message.answer_document(
document=document,
request_timeout=120,
)
service.log_ui_info(
event_type="journal_exported",
@@ -276,10 +338,31 @@ 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=_journal_payload(format="csv"),
payload=_journal_payload(
format="csv",
export_filter=export_filter,
),
)
await callback.answer("CSV экспортирован")
except TelegramNetworkError as exc:
service.log_ui_error(
event_type="journal_export_error",
message="Не удалось отправить CSV файл журнала.",
screen="journal",
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="csv",
export_filter=export_filter,
),
raw_error=str(exc),
)
await message.answer(
"⛔️ Не удалось отправить CSV файл.\n"
"Попробуй ещё раз или уменьши объём журнала."
)
except Exception as exc:
service.log_ui_error(
@@ -289,30 +372,46 @@ 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=_journal_payload(format="csv"),
payload=_journal_payload(
format="csv",
export_filter=export_filter,
),
raw_error=str(exc),
)
await callback.answer(
"Не удалось экспортировать CSV",
show_alert=True,
)
await message.answer("⛔️ Не удалось экспортировать CSV.")
@router.callback_query(F.data == "journal:export_xlsx")
@router.callback_query(F.data.startswith("journal:export_xlsx"))
async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService()
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
parts = (callback.data or "").split(":")
export_filter = normalize_journal_export_filter(
parts[2] if len(parts) >= 3 else "all"
)
await callback.answer("Готовлю Excel…")
try:
data = service.export_xlsx()
data = service.export_xlsx(export_filter=export_filter)
document = BufferedInputFile(
data,
filename=service.build_export_filename("xlsx"),
filename=service.build_export_filename(
"xlsx",
export_filter=export_filter,
),
)
if message is not None:
await message.answer_document(document=document)
await message.answer_document(
document=document,
request_timeout=120,
)
service.log_ui_info(
event_type="journal_exported",
@@ -321,10 +420,31 @@ 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=_journal_payload(format="xlsx"),
payload=_journal_payload(
format="xlsx",
export_filter=export_filter,
),
)
await callback.answer("Excel экспортирован")
except TelegramNetworkError as exc:
service.log_ui_error(
event_type="journal_export_error",
message="Не удалось отправить XLSX файл журнала.",
screen="journal",
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
payload=_journal_payload(
format="xlsx",
export_filter=export_filter,
),
raw_error=str(exc),
)
await message.answer(
"⛔️ Не удалось отправить Excel файл.\n"
"Попробуй ещё раз или уменьши объём журнала."
)
except Exception as exc:
service.log_ui_error(
@@ -334,14 +454,14 @@ 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=_journal_payload(format="xlsx"),
payload=_journal_payload(
format="xlsx",
export_filter=export_filter,
),
raw_error=str(exc),
)
await callback.answer(
"Не удалось экспортировать Excel",
show_alert=True,
)
await message.answer("⛔️ Не удалось экспортировать Excel.")
@router.callback_query(F.data == "journal:clear_confirm")

View File

@@ -12,9 +12,10 @@ 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
from src.trading.journal.filters import journal_export_filter_label
PAGE_SIZE = 5
PAGE_SIZE = 3
LEVEL_ICONS = {
"INFO": "",
@@ -52,7 +53,7 @@ def build_keyboard(
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
kb.button(text="⬅️ Назад", callback_data="system:back")
nav_count = 1
@@ -67,10 +68,28 @@ def build_keyboard(
def build_actions_keyboard() -> InlineKeyboardMarkup:
# Первый экран экспорта: выбираем, что именно экспортировать.
kb = InlineKeyboardBuilder()
kb.button(text="📄 CSV", callback_data="journal:export_csv")
kb.button(text="📊 Excel", callback_data="journal:export_xlsx")
kb.button(text="🤖 Автоторговля", callback_data="journal:export_filter:auto")
kb.button(text="📈 Сделки", callback_data="journal:export_filter:trades")
kb.button(text="⛔ Ошибки", callback_data="journal:export_filter:errors")
kb.button(text="🧾 Без авто", callback_data="journal:export_filter:not_auto")
kb.button(text="📒 Всё", callback_data="journal:export_filter:all")
kb.button(text="⬅️ Назад", callback_data="journal:1")
kb.adjust(2, 2, 1, 1)
return kb.as_markup()
def build_export_format_keyboard(export_filter: str) -> InlineKeyboardMarkup:
# Второй экран экспорта: после выбора фильтра выбираем формат.
kb = InlineKeyboardBuilder()
kb.button(text="📄 CSV", callback_data=f"journal:export_csv:{export_filter}")
kb.button(text="📊 Excel", callback_data=f"journal:export_xlsx:{export_filter}")
kb.button(text="⬅️ Назад", callback_data="journal:actions")
kb.adjust(2, 1)
return kb.as_markup()
@@ -78,7 +97,18 @@ def build_actions_keyboard() -> InlineKeyboardMarkup:
def render_actions() -> str:
return (
"<b>📤 Экспорт</b>\n\n"
"<b>МОНИТОРИНГ · Журнал</b>\n\n"
"<b>СИСТЕМА · Журнал</b>\n\n"
"Что экспортировать?"
)
def render_export_format(export_filter: str) -> str:
# Показываем выбранный фильтр перед выбором CSV/XLSX.
label = journal_export_filter_label(export_filter)
return (
f"<b>📤 Экспорт · {label}</b>\n\n"
"<b>СИСТЕМА · Журнал</b>\n\n"
"Выберите формат:"
)
@@ -245,7 +275,7 @@ def render(
lines = [
"<b>📒 Журнал</b>",
"",
"<b>МОНИТОРИНГ</b>",
"<b>СИСТЕМА</b>",
"",
]

View File

@@ -1,474 +0,0 @@
# app/src/telegram/handlers/market.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
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
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
from src.telegram.ui.common import mode_line, now_line
from src.telegram.ui.currency_ui import format_usd_amount
from src.telegram.ui.exchange_error import (
classify_exchange_error,
show_callback_exchange_error,
show_message_exchange_error,
)
from src.trading.journal.service import JournalService
router = Router(name="market")
_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")
builder.adjust(1)
return builder.as_markup()
def _build_market_text(
*,
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 price > previous_price:
price_direction = "🔺"
elif price < previous_price:
price_direction = "🔻"
_last_market_prices[name] = price
_last_market_directions[name] = price_direction
type_map = {
"LEVERAGE": "leverage",
"SPOT": "spot",
}
market_type_ru = type_map.get(market_type.upper(), market_type.lower())
return (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
"\n"
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
f"{now_line()}"
)
def _build_market_live_text() -> str:
service = ExchangeService()
requested_symbol = service.settings.default_symbol
validation = service.validate_symbol(requested_symbol)
if not validation.is_valid:
return (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
"⚠️ Ошибка инструмента\n\n"
"Инструмент недоступен."
)
ticker = service.get_price(validation.normalized_symbol)
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
)
return _build_market_text(
ticker_price=ticker.price,
name=name,
market_type=market_type,
base_asset=base_asset,
quote_asset=quote_asset,
)
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,
)
LiveScreenRunner.register_screen(
LiveScreen(
screen="market",
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_build_market_live_text,
render_markup=_market_keyboard,
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,
*,
user_id: int | None,
chat_id: int | None,
edit_mode: bool,
action: str,
) -> None:
service = ExchangeService()
journal = JournalService()
requested_symbol = service.settings.default_symbol
journal.log_ui_info(
event_type="market_open_requested",
message="Запрошено открытие экрана рынка.",
screen="market",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={"symbol": requested_symbol},
)
validation = service.validate_symbol(requested_symbol)
if not validation.is_valid:
journal.log_ui_warning(
event_type="market_symbol_invalid",
message="Инструмент недоступен.",
screen="market",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"symbol": requested_symbol,
"validation_message": validation.message,
},
)
text = (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
"⚠️ Ошибка инструмента\n\n"
"Инструмент недоступен."
)
if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard())
_register_market_live_screen(target_message)
ActiveScreenManager.register(screen="market", message=target_message)
else:
sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
_register_market_live_screen(sent_message)
ActiveScreenManager.register(screen="market", message=sent_message)
return
ticker = service.get_price(validation.normalized_symbol)
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
)
text = _build_market_text(
ticker_price=ticker.price,
name=name,
market_type=market_type,
base_asset=base_asset,
quote_asset=quote_asset,
)
journal.log_ui_info(
event_type="market_open_success",
message="Экран рынка загружен.",
screen="market",
action=action,
user_id=user_id,
chat_id=chat_id,
payload={
"symbol": ticker.symbol,
"price": safe_float(ticker.price),
},
)
if edit_mode:
await target_message.edit_text(text, reply_markup=_market_keyboard())
_register_market_live_screen(target_message)
ActiveScreenManager.register(screen="market", message=target_message)
else:
sent_message = await target_message.answer(text, reply_markup=_market_keyboard())
_register_market_live_screen(sent_message)
ActiveScreenManager.register(screen="market", message=sent_message)
@router.message(F.text == "📈 Рынок")
async def open_market(message: Message, state: FSMContext) -> None:
await state.clear()
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
try:
await _render_market_screen(
message,
user_id=user_id,
chat_id=chat_id,
edit_mode=False,
action="open",
)
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_open_error",
message="Не удалось загрузить экран рынка.",
screen="market",
action="open",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_message_exchange_error(
message,
title="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)
@router.callback_query(F.data == "monitoring:market")
async def open_market_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_market_from_callback(callback):
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 = message.chat.id
try:
await _render_market_screen(
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",
message="Не удалось загрузить экран рынка из мониторинга.",
screen="market",
action="open_from_monitoring",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_callback_exchange_error(
callback,
title="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)
@router.callback_query(F.data == "market:retry")
async def retry_market(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_market_from_callback(callback):
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 = message.chat.id
try:
await _render_market_screen(
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",
message="Не удалось обновить экран рынка.",
screen="market",
action="retry",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_callback_exchange_error(
callback,
title="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)

View File

@@ -1,171 +0,0 @@
# app/src/telegram/handlers/monitoring.py
from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
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")
builder.button(text="📈 Рынок", callback_data="monitoring:market")
builder.button(text="📒 Журнал", callback_data="monitoring:journal")
builder.adjust(2, 1)
return builder.as_markup()
def _monitoring_text() -> str:
return (
"<b>📊 Мониторинг</b>\n\n"
"Выберите раздел:"
)
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,
)
ScreenRegistry.register_screen(
StaticScreen(
screen="monitoring",
bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
)
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=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(),
)
_register_monitoring_screen(sent_message)
ActiveScreenManager.register(
screen="monitoring",
message=sent_message,
)
@router.callback_query(F.data == "monitoring:home")
async def open_monitoring_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_monitoring_from_callback(callback):
return
message = _require_message(callback)
if message is None:
await callback.answer("Сообщение недоступно", show_alert=True)
return
await message.edit_text(
_monitoring_text(),
reply_markup=_monitoring_keyboard(),
)
_register_monitoring_screen(message)
ActiveScreenManager.register(
screen="monitoring",
message=message,
)
await callback.answer()

View File

@@ -1,5 +1,4 @@
# app/src/telegram/handlers/portfolio.py
from __future__ import annotations
from aiogram import F, Router
@@ -13,10 +12,14 @@ from aiogram.types import (
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.core.types import NumericLike
from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.runtime_ui import (
build_runtime_exchange_alerts,
format_runtime_exchange_alerts,
)
from src.integrations.exchange.status import classify_exchange_error
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
from src.telegram.ui.common import mode_line, now_line
@@ -26,11 +29,6 @@ from src.telegram.ui.currency_ui import (
format_usd_amount,
is_zero_balance,
)
from src.telegram.ui.exchange_error import (
classify_exchange_error,
show_callback_exchange_error,
show_message_exchange_error,
)
from src.trading.accounts.service import AccountsService
from src.trading.journal.service import JournalService
@@ -46,6 +44,7 @@ PINNED_ORDER = {
}
# Получить доступное Telegram-сообщение из callback.
def _require_message(
callback: CallbackQuery,
) -> Message | None:
@@ -60,10 +59,7 @@ def _require_message(
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()
@@ -90,21 +86,15 @@ def _compact_amount(currency: str, value: NumericLike) -> str:
return f"{int(text):,}".replace(",", " ")
def _portfolio_keyboard() -> InlineKeyboardMarkup:
# Клавиатура портфеля при частичных данных или ошибке.
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
builder.adjust(1)
return builder.as_markup()
def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="portfolio:retry")
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
builder.adjust(1, 1)
return builder.as_markup()
# Отсортировать балансы: сначала основные активы, потом остальные по алфавиту.
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
def sort_key(item: BalanceSummary) -> tuple[int, str]:
currency = item.currency.upper()
@@ -114,11 +104,41 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
return sorted(items, key=sort_key)
def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
# Собрать текст ошибки портфеля через единый exchange status layer.
def _build_portfolio_exchange_error_text(exc: Exception) -> str:
alerts = build_runtime_exchange_alerts(exc=exc)
body = format_runtime_exchange_alerts(alerts)
if not body:
body = "⛔️ Биржа недоступна"
return (
"<b>💼 Портфель</b>\n"
f"{mode_line()}"
f"{body}\n\n"
f"{now_line()}"
)
# Собрать live-текст портфеля.
# raise_errors=True используется при ручном открытии экрана,
# чтобы handler смог записать ошибку в журнал и показать стандартный error UI.
def _build_portfolio_live_text(
*,
raise_errors: bool = False,
) -> tuple[str, InlineKeyboardMarkup | None]:
service = AccountsService()
exchange_service = ExchangeService()
balances = service.get_live_balance_summary()
try:
balances = service.get_live_balance_summary()
except Exception as exc:
if raise_errors:
raise
return _build_portfolio_exchange_error_text(exc), _portfolio_warning_keyboard()
if not balances:
text = (
@@ -127,7 +147,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
"Нет данных по балансу.\n\n"
f"{now_line()}"
)
return text, _portfolio_keyboard()
return text, None
visible_balances = [
item
@@ -143,7 +163,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
"Нет активов с балансом.\n\n"
f"{now_line()}"
)
return text, _portfolio_keyboard()
return text, None
price_cache: dict[str, float | None] = {}
total_estimated_usd = 0.0
@@ -160,11 +180,15 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
currency = item.currency.upper()
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,
)
try:
estimated_usd = estimate_balance_usd(
item,
exchange_service,
price_cache,
)
except Exception:
estimated_usd = None
if estimated_usd is not None:
total_estimated_usd += estimated_usd
@@ -205,18 +229,20 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
reply_markup = (
_portfolio_warning_keyboard()
if has_partial_data
else _portfolio_keyboard()
else None
)
return "\n".join(lines).rstrip(), reply_markup
# Render-функция текста для live runner.
def _portfolio_live_text() -> str:
text, _ = _build_portfolio_live_text()
return text
def _portfolio_live_markup() -> InlineKeyboardMarkup:
# Render-функция клавиатуры для live runner.
def _portfolio_live_markup() -> InlineKeyboardMarkup | None:
_, markup = _build_portfolio_live_text()
return markup
@@ -252,6 +278,7 @@ def _register_portfolio_live_screen(message: Message) -> None:
LiveScreenRunner.start("portfolio")
# Подготовить новый экран портфеля из обычного сообщения.
async def _prepare_portfolio_from_message(
message: Message,
) -> bool:
@@ -269,6 +296,7 @@ async def _prepare_portfolio_from_message(
return True
# Подготовить экран портфеля из callback.
async def _prepare_portfolio_from_callback(
callback: CallbackQuery,
) -> bool:
@@ -294,6 +322,7 @@ async def _prepare_portfolio_from_callback(
return True
# Отрисовать экран портфеля и зарегистрировать live обновления.
async def _render_portfolio_screen(
target_message: Message,
*,
@@ -313,7 +342,7 @@ async def _render_portfolio_screen(
chat_id=chat_id,
)
text, reply_markup = _build_portfolio_live_text()
text, reply_markup = _build_portfolio_live_text(raise_errors=True)
journal.log_ui_info(
event_type="portfolio_open_success",
@@ -356,7 +385,8 @@ async def open_portfolio(
edit_mode=False,
action="open",
)
except ExchangeError as exc:
except Exception as exc:
JournalService().log_ui_error(
event_type="portfolio_open_error",
message="Не удалось загрузить портфель.",
@@ -368,64 +398,14 @@ async def open_portfolio(
raw_error=str(exc),
)
await show_message_exchange_error(
message,
title="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
sent_message = await message.answer(
_build_portfolio_exchange_error_text(exc),
reply_markup=_portfolio_warning_keyboard(),
)
@router.callback_query(F.data == "monitoring:portfolio")
async def open_portfolio_from_monitoring(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_portfolio_from_callback(callback):
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 = message.chat.id
try:
await _render_portfolio_screen(
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",
message="Не удалось загрузить портфель из мониторинга.",
ActiveScreenManager.register(
screen="portfolio",
action="open_from_monitoring",
user_id=user_id,
chat_id=chat_id,
error_type=classify_exchange_error(exc),
raw_error=str(exc),
)
await show_callback_exchange_error(
callback,
title="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
message=sent_message,
)
@@ -458,7 +438,7 @@ async def retry_portfolio(
)
await callback.answer()
except ExchangeError as exc:
except Exception as exc:
JournalService().log_ui_error(
event_type="portfolio_retry_error",
message="Не удалось обновить портфель.",
@@ -470,11 +450,14 @@ async def retry_portfolio(
raw_error=str(exc),
)
await show_callback_exchange_error(
callback,
title="<b>💼 Портфель</b>",
exc=exc,
network_details="Не загружен баланс аккаунта.\nОбнови экран.",
auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.",
retry_callback_data="portfolio:retry",
)
await message.edit_text(
_build_portfolio_exchange_error_text(exc),
reply_markup=_portfolio_warning_keyboard(),
)
ActiveScreenManager.register(
screen="portfolio",
message=message,
)
await callback.answer()

View File

@@ -8,13 +8,16 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from src.core.system_status import build_system_text
from src.telegram.handlers.auto.main import render_auto_screen
from src.telegram.keyboards.reply import build_main_menu_keyboard
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.menus import MAIN_MENU_TEXT
router = Router(name="start")
# показать только reply-меню без открытия live-экрана
async def _show_main_menu(
message: Message,
) -> None:
@@ -24,6 +27,35 @@ async def _show_main_menu(
)
# открыть главный рабочий экран проекта — Автоторговлю
async def _open_auto_start_screen(
message: Message,
) -> None:
bot = message.bot
if bot is None:
return
# сначала прикрепляем основное reply-меню,
# потому что экран Автоторговли использует inline-кнопки
await message.answer(
"Открываю Автоторговлю.",
reply_markup=build_main_menu_keyboard(),
)
# очищаем предыдущий активный экран перед открытием Автоторговли
await ActiveScreenManager.prepare_new_screen(
screen="auto",
bot=bot,
chat_id=message.chat.id,
)
await render_auto_screen(
message,
edit_mode=False,
)
@router.message(Command("start"))
async def cmd_start(
message: Message,
@@ -31,7 +63,7 @@ async def cmd_start(
) -> None:
await state.clear()
await _show_main_menu(message)
await _open_auto_start_screen(message)
@router.message(Command("menu"))

View File

@@ -4,14 +4,14 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage
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.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION
from src.core.numbers import safe_float
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
from src.core.types import JsonDict, NumericLike
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.trading.auto.service import AutoTradeService
@@ -21,15 +21,10 @@ from src.trading.journal.service import JournalService
router = Router(name="system")
def _require_message(
callback: CallbackQuery,
) -> Message | None:
def _require_message(callback: CallbackQuery) -> Message | None:
message = callback.message
if (
message is None
or isinstance(message, InaccessibleMessage)
):
if message is None or isinstance(message, InaccessibleMessage):
return None
return message
@@ -38,8 +33,9 @@ def _require_message(
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="system:journal")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(2)
builder.adjust(2, 1)
return builder.as_markup()
@@ -47,8 +43,9 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔁 Обновить", callback_data="system:retry")
builder.button(text="🛠️ Настройки", callback_data="system:management")
builder.button(text="📒 Журнал", callback_data="system:journal")
builder.button(text=" Информация", callback_data="system:about")
builder.adjust(1, 2)
builder.adjust(1, 2, 1)
return builder.as_markup()
@@ -82,7 +79,10 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
)
async def _prepare_system_from_message(message: Message, screen: str = "system") -> bool:
async def _prepare_system_from_message(
message: Message,
screen: str = "system",
) -> bool:
bot = message.bot
if bot is None:
@@ -222,7 +222,7 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
chat_id=chat_id,
action="retry",
)
await callback.answer()
@@ -239,11 +239,10 @@ async def open_system_management(callback: CallbackQuery) -> None:
builder = InlineKeyboardBuilder()
builder.button(text="🤖 Автоторговля", callback_data="settings:auto")
builder.button(text="💹 Торговля", callback_data="settings:trade")
builder.button(text="🌍 Общие", callback_data="settings:general")
builder.button(text="📒 Журнал", callback_data="settings:journal")
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(2, 2, 1)
builder.adjust(1, 2, 1)
message = _require_message(callback)
@@ -275,11 +274,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
leverage_ready = state.leverage is not None
is_trend_strategy = (state.strategy or "").upper() == "TREND"
sl_ready = (
state.stop_loss_percent is not None
and state.stop_loss_percent > 0
)
sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0
is_configured = (
strategy_ready
@@ -290,30 +285,14 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
)
strategy = strategy_map.get(state.strategy or "", "")
symbol = ""
if state.symbol:
base = state.symbol.split("_", 1)[0].upper()
if "/" in base:
symbol = base.split("/", 1)[0]
else:
for suffix in ("USDT", "USD", "EUR", "BTC"):
if base.endswith(suffix) and len(base) > len(suffix):
base = base[: -len(suffix)]
break
symbol = base
symbol = _human_symbol(state.symbol)
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)
@@ -323,42 +302,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
symbol_icon = "" if symbol_ready else "⚠️"
risk_icon = "" if risk_ready else "⚠️"
leverage_icon = "" if leverage_ready else "⚠️"
if is_trend_strategy and not sl_ready:
sl_icon = ""
else:
sl_icon = (
""
if state.stop_loss_percent is not None
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}"
)
sl_icon = "⛔️" if is_trend_strategy and not sl_ready else ("" if state.stop_loss_percent is not None else "⚠️")
tp_icon = "" if state.take_profit_percent is not None else "⚠️"
ml_icon = "" if state.max_loss_usd is not None else ""
settings_status_icon = "" if is_configured else "⛔️"
config_status = (
""
if is_configured
else "\n\nНастрой все параметры"
)
config_status = "" if is_configured else "\n\nНастрой все параметры"
text = (
"<b>🤖 Автоторговля</b>\n\n"
@@ -368,52 +317,22 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
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}"
"<b>Защита позиции:</b>\n"
f"{sl_icon} Stop Loss · {sl}\n"
f"{tp_icon} Take Profit · {tp}\n"
f"{ml_icon} Max Loss · {ml}"
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)
message = _require_message(callback)
@@ -422,16 +341,8 @@ async def open_auto_settings(callback: CallbackQuery) -> 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 message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_auto")
await callback.answer()
@@ -439,7 +350,7 @@ 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:
@@ -813,36 +724,6 @@ async def set_auto_max_reserved(callback: CallbackQuery) -> None:
await callback.answer("Лимит обновлён")
@router.callback_query(F.data == "settings:trade")
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"
"Актив: —\n"
"Тип ордера по умолчанию: —\n"
"Пресеты количества: —\n\n"
"В разработке."
)
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.button(text="💹 Торговля", callback_data="trade:home")
builder.adjust(2)
await message.edit_text(text, reply_markup=builder.as_markup())
_register_system_screen(message, screen="settings_trade")
await callback.answer()
@router.callback_query(F.data == "settings:general")
async def open_general_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_general"):
@@ -1001,10 +882,14 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
await callback.answer()
@router.callback_query(F.data.in_({
"settings:journal_limit_stub",
"settings:journal_retention_stub",
}))
@router.callback_query(
F.data.in_(
{
"settings:journal_limit_stub",
"settings:journal_retention_stub",
}
)
)
async def journal_settings_stub(callback: CallbackQuery) -> None:
await callback.answer("Настройка скоро появится", show_alert=True)
@@ -1030,6 +915,7 @@ async def back_to_system(callback: CallbackQuery) -> None:
chat_id=chat_id,
action="back",
)
await callback.answer()
@@ -1064,7 +950,7 @@ async def open_system_about(callback: CallbackQuery) -> None:
f"Режим: {'DEMO' if 'demo' in settings.exchange_base_url.lower() else 'LIVE'}\n"
f"Часовой пояс: {settings.tz}\n\n"
"Торговый Telegram-бот для контроля рынка, портфеля, журнала событий "
"и будущей автоторговли."
"и автоторговли."
)
builder = InlineKeyboardBuilder()

View File

@@ -1 +0,0 @@
"""Package marker."""

View File

@@ -1,311 +0,0 @@
# app/src/telegram/handlers/trade/main.py
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 src.telegram.handlers.trade.new_order import (
show_recent_drafts,
start_new_order_draft,
)
from src.telegram.live.active_screen import ActiveScreenManager
from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen
from src.telegram.ui.common import mode_line
router = Router(name="trade_main")
def _trade_screen(title: str) -> str:
return (
f"<b>💹 Торговля — {title}</b>\n"
f"{mode_line()}"
"Выбери раздел"
)
def _trade_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📝 Ордер", callback_data="trade:new_order")
builder.button(text="📂 Ордера", callback_data="trade:orders")
builder.button(text="📜 История", callback_data="trade:history")
builder.button(text="🛠️ Настройки", callback_data="settings:trade")
builder.adjust(2, 2)
return builder.as_markup()
def _trade_home_button() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="💹 К торговле", callback_data="trade:home")
return builder.as_markup()
def _orders_menu_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📂 Черновики", callback_data="trade:orders:drafts")
builder.button(text="💹 К торговле", callback_data="trade:home")
builder.adjust(2)
return builder.as_markup()
def _history_menu_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="✅ Исполненные", callback_data="trade:history:filled")
builder.button(text="🚫 Отменённые", callback_data="trade:history:canceled")
builder.button(text="💹 К торговле", callback_data="trade:home")
builder.adjust(2, 1)
return builder.as_markup()
def _settings_menu_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⚙️ Параметры", callback_data="trade:settings:params")
builder.button(text="🔁 Режим", callback_data="trade:settings:mode")
builder.button(text=" Справка", callback_data="trade:settings:help")
builder.button(text="💹 К торговле", callback_data="trade:home")
builder.adjust(2, 2)
return builder.as_markup()
def _trade_home_text() -> str:
return _trade_screen("Основной экран")
def _trade_orders_text() -> str:
return _trade_screen("Ордера")
def _trade_history_text() -> str:
return _trade_screen("История")
def _trade_settings_text() -> str:
return _trade_screen("Настройки")
def _register_trade_screen(message: Message) -> None:
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
ScreenRegistry.register_screen(
StaticScreen(
screen="trade",
bot=message.bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
)
ActiveScreenManager.register(
screen="trade",
message=message,
)
async def _prepare_trade_from_message(message: Message) -> None:
await ActiveScreenManager.prepare_new_screen(
screen="trade",
bot=message.bot,
chat_id=message.chat.id,
)
async def _prepare_trade_from_callback(callback: CallbackQuery) -> bool:
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen="trade",
bot=callback.message.bot,
chat_id=callback.message.chat.id,
keep_message_id=callback.message.message_id,
)
return True
@router.message(F.text.in_({"💹 Торговля"}))
async def open_trade(message: Message, state: FSMContext) -> None:
await state.clear()
await _prepare_trade_from_message(message)
sent_message = await message.answer(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
_register_trade_screen(sent_message)
@router.callback_query(F.data == "trade:home")
async def open_trade_home_callback(
callback: CallbackQuery,
state: FSMContext,
) -> None:
await state.clear()
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
_trade_home_text(),
reply_markup=_trade_home_keyboard(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:new_order")
async def open_new_order_from_trade(
callback: CallbackQuery,
state: FSMContext,
) -> None:
if not await _prepare_trade_from_callback(callback):
return
await start_new_order_draft(callback.message, state, edit_mode=True)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:orders")
async def open_orders_from_trade(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
_trade_orders_text(),
reply_markup=_orders_menu_keyboard(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:orders:drafts")
async def open_drafts_from_orders(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await show_recent_drafts(callback.message, edit_mode=True, page=1)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:history")
async def open_trade_history(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
_trade_history_text(),
reply_markup=_history_menu_keyboard(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:history:filled")
async def open_filled_history(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
"<b>💹 Торговля — История</b>\n\n"
"Шаг 1/1: Исполненные\n"
"Раздел в разработке.",
reply_markup=_trade_home_button(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:history:canceled")
async def open_canceled_history(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
"<b>💹 Торговля — История</b>\n\n"
"Шаг 1/1: Отменённые\n"
"Раздел в разработке.",
reply_markup=_trade_home_button(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:settings")
async def open_trade_settings(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
_trade_settings_text(),
reply_markup=_settings_menu_keyboard(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:settings:params")
async def open_trade_settings_params(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
"<b>💹 Торговля — Настройки</b>\n\n"
"Шаг 1/1: Параметры ордера\n"
"Раздел в разработке.",
reply_markup=_trade_home_button(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:settings:mode")
async def open_trade_settings_mode(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
"<b>💹 Торговля — Настройки</b>\n\n"
"Шаг 1/1: Режим работы\n"
"Текущий режим: <b>demo</b>",
reply_markup=_trade_home_button(),
)
_register_trade_screen(callback.message)
await callback.answer()
@router.callback_query(F.data == "trade:settings:help")
async def open_trade_settings_help(callback: CallbackQuery) -> None:
if not await _prepare_trade_from_callback(callback):
return
await callback.message.edit_text(
"<b>💹 Торговля — Справка</b>\n\n"
"Шаг 1/1: Информация\n"
"Раздел в разработке.",
reply_markup=_trade_home_button(),
)
_register_trade_screen(callback.message)
await callback.answer()

View File

@@ -1,16 +0,0 @@
# app/src/telegram/handlers/trade/new_order.py
# Точка сборки всех роутеров сценария нового ордера (flow, navigation, drafts).
from __future__ import annotations
from src.telegram.handlers.trade.new_order_core import router
from src.telegram.handlers.trade import new_order_navigation as _new_order_navigation # noqa
from src.telegram.handlers.trade import new_order_flow as _new_order_flow # noqa
from src.telegram.handlers.trade.new_order_flow import start_new_order_draft
from src.telegram.handlers.trade.new_order_ui import show_recent_drafts
__all__ = [
"router",
"show_recent_drafts",
"start_new_order_draft",
]

View File

@@ -1,9 +0,0 @@
# app/src/telegram/handlers/trade/new_order_core.py
from __future__ import annotations
from aiogram import Router
router = Router(name="trade_new_order")
DRAFTS_PAGE_SIZE = 3

File diff suppressed because it is too large Load Diff

View File

@@ -1,401 +0,0 @@
# app/src/telegram/handlers/trade/new_order_navigation.py
from __future__ import annotations
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from src.integrations.exchange.exceptions import ExchangeError, format_exchange_error_for_user
from src.telegram.handlers.trade.new_order_core import router
from src.telegram.handlers.trade.new_order_ui import (
_draft_detail_keyboard,
_price_keyboard,
_quantity_keyboard,
_render_draft_detail,
_render_exchange_error,
_render_order_path,
_render_price_step_screen,
_render_quantity_step_screen,
_screen_title,
_side_keyboard,
_trade_back_home_keyboard,
_type_keyboard,
mode_line,
)
from src.trading.orders.service import OrderDraftsService
from src.trading.orders.states import NewOrderDraftStates
async def _return_to_draft_detail(
callback: CallbackQuery,
*,
draft_id: str,
page: int,
) -> None:
service = OrderDraftsService()
draft = service.get_draft_by_id(draft_id)
if not draft:
await callback.message.edit_text(
"<b>💹 Торговля</b>\n\n"
"Черновик не найден.",
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
await callback.message.edit_text(
_render_draft_detail(draft),
reply_markup=_draft_detail_keyboard(draft_id, page),
)
await callback.answer()
async def _show_navigation_exchange_error(
callback: CallbackQuery,
*,
title: str,
exc: Exception,
draft_page: int | None = None,
) -> None:
reply_markup = (
_draft_detail_keyboard("", draft_page) # won't use if branch below replaces
if False
else None
)
if draft_page:
keyboard = _draft_detail_keyboard("noop", draft_page)
# заменим клавиатуру сразу на корректную
# edit/detail тут не нужны, нужен простой возврат к черновикам
from src.telegram.handlers.trade.new_order_ui import _drafts_back_keyboard
reply_markup = _drafts_back_keyboard(int(draft_page))
else:
reply_markup = _trade_back_home_keyboard()
await callback.message.edit_text(
_render_exchange_error(
title=title,
message=format_exchange_error_for_user(exc),
),
reply_markup=reply_markup,
)
await callback.answer()
@router.callback_query(F.data == "order_back:side")
async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
try:
context = service.get_entry_context(side="BUY", order_type="MARKET")
await state.set_state(NewOrderDraftStates.waiting_side)
text = (
"<b>💹 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
f"{context.symbol}\n\n"
"Шаг 1/4. Выбери сторону"
)
await callback.message.edit_text(text, reply_markup=_side_keyboard())
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title="<b>💹 Торговля — Новый ордер</b>",
exc=exc,
)
@router.callback_query(F.data == "order_back:type")
async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None:
data = await state.get_data()
draft_id = data.get("draft_edit_id")
draft_page = data.get("draft_edit_page")
if draft_id and draft_page:
await _return_to_draft_detail(
callback,
draft_id=str(draft_id),
page=int(draft_page),
)
return
service = OrderDraftsService()
side = data.get("side", "BUY")
try:
context = service.get_entry_context(side=side, order_type="MARKET")
path = _render_order_path(side=side)
await state.set_state(NewOrderDraftStates.waiting_type)
text = (
"<b>💹 Торговля — Новый ордер</b>\n"
f"{mode_line()}"
f"{context.symbol}\n\n"
f"{path}\n\n"
"Шаг 2/4. Выбери тип ордера"
)
await callback.message.edit_text(text, reply_markup=_type_keyboard())
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title="<b>💹 Торговля — Новый ордер</b>",
exc=exc,
)
@router.callback_query(F.data == "order_back:quantity")
async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "MARKET")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
try:
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
if not quantity:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
exc=exc,
draft_page=drafts_page,
)
@router.callback_query(F.data == "order_back:confirm")
async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> None:
service = OrderDraftsService()
data = await state.get_data()
confirm_draft = data.get("confirm_draft")
if not confirm_draft:
await state.clear()
await callback.message.edit_text(
"<b>💹 Торговля</b>\n\n"
"Не удалось восстановить шаг подтверждения.",
reply_markup=_trade_back_home_keyboard(),
)
await callback.answer()
return
side = confirm_draft["side"]
order_type = confirm_draft["order_type"]
quantity = confirm_draft.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
try:
if order_type == "LIMIT":
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
_render_price_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
drafts_page=drafts_page,
),
)
await callback.answer()
return
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
exc=exc,
draft_page=drafts_page,
)
@router.callback_query(F.data == "order_manual_back:quantity")
async def go_back_from_manual_quantity(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "MARKET")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
try:
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
if not quantity:
path = _render_order_path(
side=side,
order_type=order_type,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_quantity)
await callback.message.edit_text(
_render_quantity_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
available_balance=context.available_balance,
balance_currency=context.balance_currency,
reference_price=context.reference_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_quantity_keyboard(
context.quantity_presets,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
exc=exc,
draft_page=drafts_page,
)
@router.callback_query(F.data == "order_manual_back:price")
async def go_back_from_manual_price(
callback: CallbackQuery,
state: FSMContext,
) -> None:
service = OrderDraftsService()
data = await state.get_data()
side = data.get("side", "BUY")
order_type = data.get("order_type", "LIMIT")
quantity = data.get("quantity")
is_edit_mode = bool(data.get("draft_edit_id"))
draft_page = data.get("draft_edit_page")
drafts_page = int(draft_page) if draft_page else None
try:
context = service.get_entry_context(side=side, order_type=order_type)
path = _render_order_path(
side=side,
order_type=order_type,
quantity=quantity,
base_currency=context.base_currency,
)
await state.set_state(NewOrderDraftStates.waiting_price)
await callback.message.edit_text(
_render_price_step_screen(
title=_screen_title(is_edit_mode),
symbol=context.symbol,
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
quote_currency=context.quote_currency,
order_path=path,
),
reply_markup=_price_keyboard(
bid=context.bid_price,
ask=context.ask_price,
last=context.last_price,
drafts_page=drafts_page,
),
)
await callback.answer()
except ExchangeError as exc:
await _show_navigation_exchange_error(
callback,
title=_screen_title(is_edit_mode),
exc=exc,
draft_page=drafts_page,
)

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,9 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
keyboard=[
[
KeyboardButton(text="🤖 Автоторговля"),
KeyboardButton(text="💹 Торговля"),
KeyboardButton(text="💼 Портфель"),
],
[
KeyboardButton(text="📊 Мониторинг"),
KeyboardButton(text="🖥️ Система"),
],
],

View File

@@ -2,31 +2,48 @@
MAIN_MENU_TEXT = (
"<b>Dzentra Bot</b>\n\n"
"Новый каркас проекта успешно создан.\n\n"
"Выбери раздел через меню ниже."
"Trading Runtime Terminal\n\n"
"Доступные разделы:\n"
"• Автоторговля\n"
"• Портфель\n"
"• Система"
)
HOME_TEXT = (
"<b>🏠 Главная</b>\n\n"
"Это главный экран бота.\n\n"
"Сейчас здесь отображается базовый статус:\n"
"- бот запущен\n"
"- меню подключено\n"
"- handlers работают\n"
"- проект на этапе Bootstrap v2\n"
"Главное меню Dzentra Bot.\n\n"
"Используй кнопки ниже для перехода в нужный раздел."
)
SYSTEM_TEXT = (
"<b>🖥️ Система</b>\n\n"
"Системный экран.\n\n"
"<b>Справка</b>\n"
"Системный runtime экран.\n\n"
"<b>Разделы</b>\n"
"• Настройки\n"
"• Журнал\n"
"• Информация\n\n"
"<b>Команды</b>\n"
"/start — запуск\n"
"/menu — показать меню\n"
"/help — краткая справка\n"
"/menu — главное меню\n"
"/help — системная информация\n"
)
MARKET_TEXT = "<b>📈 Рынок</b>\n\nРаздел пока в разработке."
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\n\nРаздел пока в разработке."
TRADE_TEXT = "<b>💹 Торговля</b>\n\nВыберите действие:\n<i>DRAFT режим</i>"
AUTO_TEXT = "<b>🤖 Авто</b>\n\nРаздел пока в разработке."
JOURNAL_TEXT = "<b>📒 Журнал</b>\n\nРаздел пока в разработке."
PORTFOLIO_TEXT = (
"<b>💼 Портфель</b>\n\n"
"Просмотр активов и баланса биржи."
)
AUTO_TEXT = (
"<b>🤖 Автоторговля</b>\n\n"
"Runtime экран автоторговли."
)
JOURNAL_TEXT = (
"<b>📒 Журнал</b>\n\n"
"Runtime события и execution logs."
)

View File

@@ -7,23 +7,15 @@ from src.telegram.handlers.debug import router as debug_router
from src.telegram.handlers.debug_auto.main import router as debug_auto_router
from src.telegram.handlers.home import router as home_router
from src.telegram.handlers.journal import router as journal_router
from src.telegram.handlers.market import router as market_router
from src.telegram.handlers.monitoring import router as monitoring_router
from src.telegram.handlers.portfolio import router as portfolio_router
from src.telegram.handlers.start import router as start_router
from src.telegram.handlers.system import router as system_router
from src.telegram.handlers.trade.main import router as trade_main_router
from src.telegram.handlers.trade.new_order import router as trade_new_order_router
def setup_routers(dispatcher: Dispatcher) -> None:
dispatcher.include_router(start_router)
dispatcher.include_router(home_router)
dispatcher.include_router(monitoring_router)
dispatcher.include_router(market_router)
dispatcher.include_router(portfolio_router)
dispatcher.include_router(trade_main_router)
dispatcher.include_router(trade_new_order_router)
dispatcher.include_router(auto_router)
dispatcher.include_router(journal_router)
dispatcher.include_router(debug_auto_router)

View File

@@ -5,8 +5,15 @@ from __future__ import annotations
from dataclasses import dataclass
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from aiogram.types import (
CallbackQuery,
InaccessibleMessage,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from src.integrations.exchange.runtime_ui import build_exchange_error_ui_parts
from src.telegram.ui.common import mode_line, now_line
@@ -17,7 +24,10 @@ class ExchangeErrorView:
DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
DEFAULT_AUTH_DETAILS = "Не удалось выполнить запрос к аккаунту.\nПроверь API ключи."
DEFAULT_AUTH_DETAILS = (
"Не удалось выполнить приватный запрос к аккаунту.\n"
"Проверь API-ключ, Secret Key, IP whitelist и права доступа."
)
DEFAULT_TIME_DETAILS = (
"Не удалось выполнить запрос к бирже.\n"
"Проверь синхронизацию времени и обнови экран."
@@ -25,43 +35,7 @@ DEFAULT_TIME_DETAILS = (
DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
def classify_exchange_error(exc: Exception) -> str:
text = str(exc).lower()
network_markers = [
"nodename nor servname",
"name or service not known",
"network error",
"connection error",
"timed out",
"timeout",
]
if any(marker in text for marker in network_markers):
return "network"
time_markers = [
"-1021",
"doesn't match server time",
]
if any(marker in text for marker in time_markers):
return "time"
auth_markers = [
"invalid api key",
"api key",
"api-key",
"signature",
"expired",
"forbidden",
"unauthorized",
"private api error",
]
if any(marker in text for marker in auth_markers):
return "auth"
return "generic"
# Собрать UI-представление ошибки через единый exchange status layer.
def _build_exchange_error_view(
*,
exc: Exception,
@@ -70,32 +44,15 @@ def _build_exchange_error_view(
time_details: str | None = None,
generic_details: str | None = None,
) -> ExchangeErrorView:
error_type = classify_exchange_error(exc)
if error_type == "network":
return ExchangeErrorView(
headline="🔴 Биржа недоступна",
details=network_details,
)
if error_type == "auth":
return ExchangeErrorView(
headline="🔴 Ошибка доступа к аккаунту",
details=auth_details,
)
if error_type == "time":
return ExchangeErrorView(
headline="🔴 Ошибка времени",
details=time_details or DEFAULT_TIME_DETAILS,
)
_, headline, details = build_exchange_error_ui_parts(exc)
return ExchangeErrorView(
headline="🔴 Ошибка биржи",
details=generic_details or DEFAULT_GENERIC_DETAILS,
headline=headline,
details=details or generic_details or DEFAULT_GENERIC_DETAILS,
)
# Отрисовать единый текст ошибки биржи для message/callback экранов.
def render_exchange_error(
*,
title: str,
@@ -122,6 +79,7 @@ def render_exchange_error(
)
# Собрать клавиатуру для экранов с ошибкой биржи.
def exchange_error_keyboard(
*,
retry_callback_data: str | None = None,
@@ -129,7 +87,6 @@ def exchange_error_keyboard(
drafts_page: int | None = None,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
first_row: list[InlineKeyboardButton] = []
if retry_callback_data:
@@ -160,12 +117,12 @@ def exchange_error_keyboard(
)
]
)
else:
elif back_callback_data is None:
buttons.append(
[
InlineKeyboardButton(
text="🏠 К торговле",
callback_data="trade:home",
text="🤖 К автоторговле",
callback_data="auto:home",
)
]
)
@@ -173,6 +130,7 @@ def exchange_error_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
# Показать ошибку биржи через редактирование callback-сообщения.
async def show_callback_exchange_error(
callback: CallbackQuery,
*,
@@ -186,7 +144,9 @@ async def show_callback_exchange_error(
back_callback_data: str | None = None,
drafts_page: int | None = None,
) -> None:
if callback.message is None:
message = callback.message
if message is None or isinstance(message, InaccessibleMessage):
await callback.answer("Сообщение не найдено", show_alert=True)
return
@@ -198,6 +158,7 @@ async def show_callback_exchange_error(
time_details=time_details,
generic_details=generic_details,
)
markup = exchange_error_keyboard(
retry_callback_data=retry_callback_data or callback.data,
back_callback_data=back_callback_data,
@@ -205,15 +166,17 @@ async def show_callback_exchange_error(
)
try:
await callback.message.edit_text(text, reply_markup=markup)
await message.edit_text(text, reply_markup=markup)
await callback.answer()
except TelegramBadRequest as tg_exc:
if "message is not modified" in str(tg_exc).lower():
await callback.answer("Ошибка всё ещё актуальна")
return
raise
# Показать ошибку биржи новым сообщением.
async def show_message_exchange_error(
message: Message,
*,

View File