07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Package marker."""
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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
@@ -8,10 +8,9 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="🤖 Автоторговля"),
|
||||
KeyboardButton(text="💹 Торговля"),
|
||||
KeyboardButton(text="💼 Портфель"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📊 Мониторинг"),
|
||||
KeyboardButton(text="🖥️ Система"),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
0
app/src/telegram/ui/runtime_status.py
Normal file
0
app/src/telegram/ui/runtime_status.py
Normal file
Reference in New Issue
Block a user