Stage 07.4.4.1.14 — Execution refactoring and runtime semantics
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.exceptions import (
|
||||
TelegramBadRequest,
|
||||
TelegramNetworkError,
|
||||
TelegramRetryAfter,
|
||||
)
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InaccessibleMessage, Message
|
||||
|
||||
@@ -39,6 +43,50 @@ def _require_message(
|
||||
return message
|
||||
|
||||
|
||||
async def _safe_edit_text(
|
||||
message: Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup,
|
||||
) -> bool:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
return True
|
||||
|
||||
except TelegramBadRequest as exc:
|
||||
if "message is not modified" in str(exc).lower():
|
||||
return True
|
||||
raise
|
||||
|
||||
except TelegramRetryAfter:
|
||||
return False
|
||||
|
||||
except TelegramNetworkError:
|
||||
return False
|
||||
|
||||
|
||||
async def _safe_answer(
|
||||
message: Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup,
|
||||
) -> Message | None:
|
||||
try:
|
||||
return await message.answer(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
except TelegramRetryAfter:
|
||||
return None
|
||||
|
||||
except TelegramNetworkError:
|
||||
return None
|
||||
|
||||
|
||||
async def render_auto_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
@@ -47,32 +95,42 @@ async def render_auto_screen(
|
||||
text = build_auto_text()
|
||||
|
||||
if edit_mode:
|
||||
try:
|
||||
await target_message.edit_text(text, reply_markup=auto_keyboard())
|
||||
except TelegramBadRequest as exc:
|
||||
if "message is not modified" not in str(exc).lower():
|
||||
raise
|
||||
async with AutoTradeRunner.edit_lock():
|
||||
bot = target_message.bot
|
||||
|
||||
bot = target_message.bot
|
||||
if bot is not None:
|
||||
AutoTradeRunner.register_screen(
|
||||
bot=bot,
|
||||
chat_id=target_message.chat.id,
|
||||
message_id=target_message.message_id,
|
||||
render_text=build_auto_text,
|
||||
render_markup=auto_keyboard,
|
||||
)
|
||||
|
||||
if bot is None:
|
||||
return
|
||||
ActiveScreenManager.register(
|
||||
screen="auto",
|
||||
message=target_message,
|
||||
)
|
||||
|
||||
AutoTradeRunner.register_screen(
|
||||
bot=bot,
|
||||
chat_id=target_message.chat.id,
|
||||
message_id=target_message.message_id,
|
||||
render_text=build_auto_text,
|
||||
render_markup=auto_keyboard,
|
||||
)
|
||||
updated = await _safe_edit_text(
|
||||
target_message,
|
||||
text,
|
||||
reply_markup=auto_keyboard(),
|
||||
)
|
||||
|
||||
if not updated:
|
||||
return
|
||||
|
||||
ActiveScreenManager.register(
|
||||
screen="auto",
|
||||
message=target_message,
|
||||
)
|
||||
return
|
||||
|
||||
sent_message = await target_message.answer(text, reply_markup=auto_keyboard())
|
||||
sent_message = await _safe_answer(
|
||||
target_message,
|
||||
text,
|
||||
reply_markup=auto_keyboard(),
|
||||
)
|
||||
|
||||
if sent_message is None:
|
||||
return
|
||||
bot = sent_message.bot
|
||||
|
||||
if bot is None:
|
||||
@@ -147,61 +205,64 @@ async def render_auto_diagnostics_screen(
|
||||
) -> None:
|
||||
text = build_auto_diagnostics_text()
|
||||
|
||||
try:
|
||||
await target_message.edit_text(
|
||||
text,
|
||||
reply_markup=auto_diagnostics_keyboard(),
|
||||
)
|
||||
except TelegramBadRequest as exc:
|
||||
error_text = str(exc).lower()
|
||||
|
||||
if "message to edit not found" in error_text:
|
||||
sent_message = await target_message.answer(
|
||||
text,
|
||||
reply_markup=auto_diagnostics_keyboard(),
|
||||
)
|
||||
|
||||
bot = sent_message.bot
|
||||
|
||||
if bot is None:
|
||||
return
|
||||
async with AutoTradeRunner.edit_lock():
|
||||
bot = target_message.bot
|
||||
|
||||
if bot is not None:
|
||||
AutoTradeRunner.register_screen(
|
||||
bot=bot,
|
||||
chat_id=sent_message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
chat_id=target_message.chat.id,
|
||||
message_id=target_message.message_id,
|
||||
render_text=build_auto_diagnostics_text,
|
||||
render_markup=auto_diagnostics_keyboard,
|
||||
)
|
||||
|
||||
ActiveScreenManager.register(
|
||||
screen="auto_diagnostics",
|
||||
message=sent_message,
|
||||
message=target_message,
|
||||
)
|
||||
return
|
||||
|
||||
if "message is not modified" in error_text:
|
||||
return
|
||||
try:
|
||||
await target_message.edit_text(
|
||||
text,
|
||||
reply_markup=auto_diagnostics_keyboard(),
|
||||
)
|
||||
except TelegramBadRequest as exc:
|
||||
error_text = str(exc).lower()
|
||||
|
||||
raise
|
||||
if "message to edit not found" in error_text:
|
||||
sent_message = await _safe_answer(
|
||||
target_message,
|
||||
text,
|
||||
reply_markup=auto_diagnostics_keyboard(),
|
||||
)
|
||||
|
||||
bot = target_message.bot
|
||||
if sent_message is None:
|
||||
return
|
||||
|
||||
if bot is None:
|
||||
return
|
||||
bot = sent_message.bot
|
||||
|
||||
AutoTradeRunner.register_screen(
|
||||
bot=bot,
|
||||
chat_id=target_message.chat.id,
|
||||
message_id=target_message.message_id,
|
||||
render_text=build_auto_diagnostics_text,
|
||||
render_markup=auto_diagnostics_keyboard,
|
||||
)
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
ActiveScreenManager.register(
|
||||
screen="auto_diagnostics",
|
||||
message=target_message,
|
||||
)
|
||||
AutoTradeRunner.register_screen(
|
||||
bot=bot,
|
||||
chat_id=sent_message.chat.id,
|
||||
message_id=sent_message.message_id,
|
||||
render_text=build_auto_diagnostics_text,
|
||||
render_markup=auto_diagnostics_keyboard,
|
||||
)
|
||||
|
||||
ActiveScreenManager.register(
|
||||
screen="auto_diagnostics",
|
||||
message=sent_message,
|
||||
)
|
||||
return
|
||||
|
||||
if "message is not modified" in error_text:
|
||||
return
|
||||
|
||||
raise
|
||||
|
||||
|
||||
@router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"}))
|
||||
@@ -333,6 +394,16 @@ async def auto_stop(callback: CallbackQuery) -> None:
|
||||
|
||||
@router.callback_query(F.data == "auto:diagnostics")
|
||||
async def open_auto_diagnostics(callback: CallbackQuery) -> None:
|
||||
service = AutoTradeService()
|
||||
state = service.get_state()
|
||||
|
||||
if str(state.status or "").upper() not in {"RUNNING", "OBSERVING"}:
|
||||
await callback.answer(
|
||||
"Диагностика доступна только после запуска или в режиме наблюдения",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
@@ -93,7 +91,7 @@ def _risk_keyboard() -> InlineKeyboardMarkup:
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def _risk_text(status_message: str | None = None) -> str:
|
||||
def _risk_text() -> str:
|
||||
state = AutoTradeService().get_state()
|
||||
|
||||
active_count = sum(
|
||||
@@ -107,7 +105,7 @@ def _risk_text(status_message: str | None = None) -> str:
|
||||
|
||||
status = "🟢 Активна" if active_count else "⚪ Выключена"
|
||||
|
||||
text = (
|
||||
return (
|
||||
"<b>🧯 Защита позиции</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
|
||||
f"Статус защиты: {status}\n"
|
||||
@@ -117,11 +115,6 @@ def _risk_text(status_message: str | None = None) -> str:
|
||||
f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n"
|
||||
)
|
||||
|
||||
if status_message:
|
||||
text += f"\n\n{status_message}"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
async def _render_risk_screen(
|
||||
callback: CallbackQuery,
|
||||
@@ -151,8 +144,6 @@ async def _render_risk_screen_by_message(
|
||||
message: Message,
|
||||
*,
|
||||
state: FSMContext,
|
||||
status_message: str | None = None,
|
||||
auto_clear: bool = False,
|
||||
) -> None:
|
||||
AutoTradeRunner.set_current_screen("auto_risk")
|
||||
|
||||
@@ -166,46 +157,20 @@ async def _render_risk_screen_by_message(
|
||||
raw_chat_id = data.get("risk_chat_id")
|
||||
raw_message_id = data.get("risk_message_id")
|
||||
|
||||
if not isinstance(raw_chat_id, int):
|
||||
if not isinstance(raw_chat_id, int) or not isinstance(raw_message_id, int):
|
||||
await message.answer(
|
||||
_risk_text(status_message=status_message),
|
||||
_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
return
|
||||
|
||||
if not isinstance(raw_message_id, int):
|
||||
await message.answer(
|
||||
_risk_text(status_message=status_message),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
return
|
||||
|
||||
chat_id = raw_chat_id
|
||||
message_id = raw_message_id
|
||||
|
||||
await bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=_risk_text(status_message=status_message),
|
||||
chat_id=raw_chat_id,
|
||||
message_id=raw_message_id,
|
||||
text=_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
|
||||
if status_message and auto_clear:
|
||||
await asyncio.sleep(2.5)
|
||||
|
||||
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _remember_risk_screen(
|
||||
callback: CallbackQuery,
|
||||
@@ -426,24 +391,11 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
_log_risk_updated("risk_reset")
|
||||
|
||||
await message.edit_text(
|
||||
_risk_text(status_message="✅ Risk Controls сброшены"),
|
||||
_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await asyncio.sleep(2.5)
|
||||
|
||||
if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
|
||||
return
|
||||
|
||||
try:
|
||||
await message.edit_text(
|
||||
_risk_text(),
|
||||
reply_markup=_risk_keyboard(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer("Risk Controls сброшены")
|
||||
|
||||
|
||||
@router.message(AutoRiskStates.waiting_stop_loss)
|
||||
@@ -464,8 +416,6 @@ async def set_stop_loss(message: Message, state: FSMContext) -> None:
|
||||
await _render_risk_screen_by_message(
|
||||
message,
|
||||
state=state,
|
||||
status_message=f"✅ Stop Loss обновлён: {_format_percent(value)}",
|
||||
auto_clear=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -488,8 +438,6 @@ async def set_take_profit(message: Message, state: FSMContext) -> None:
|
||||
await _render_risk_screen_by_message(
|
||||
message,
|
||||
state=state,
|
||||
status_message=f"✅ Take Profit обновлён: {_format_percent(value)}",
|
||||
auto_clear=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@@ -512,7 +460,5 @@ async def set_max_loss(message: Message, state: FSMContext) -> None:
|
||||
await _render_risk_screen_by_message(
|
||||
message,
|
||||
state=state,
|
||||
status_message=f"✅ Max Loss обновлён: {_format_usd(value)}",
|
||||
auto_clear=True,
|
||||
)
|
||||
await state.clear()
|
||||
@@ -72,6 +72,12 @@ def _build_signal_notification_text(state, signal: str) -> str:
|
||||
_signal_strength_line(confidence),
|
||||
]
|
||||
|
||||
# Общая оценка рынка на момент сигнала.
|
||||
# Это не факт входа, а качество рыночного контекста 0..100.
|
||||
market_score_line = _market_score_notification_line(state)
|
||||
if market_score_line:
|
||||
lines.append(market_score_line)
|
||||
|
||||
compact_reason = _notification_signal_reason(reason)
|
||||
if compact_reason:
|
||||
lines.append(compact_reason)
|
||||
@@ -89,6 +95,26 @@ def _price_from_snapshot(
|
||||
return safe_float(snapshot.get(key))
|
||||
|
||||
|
||||
def _position_current_price(state) -> float | None:
|
||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
||||
|
||||
if snapshot is not None:
|
||||
side = str(getattr(state, "position_side", "") or "").upper()
|
||||
|
||||
if side == "LONG":
|
||||
price = snapshot.get("bid_price") or snapshot.get("last_price")
|
||||
elif side == "SHORT":
|
||||
price = snapshot.get("ask_price") or snapshot.get("last_price")
|
||||
else:
|
||||
price = snapshot.get("last_price")
|
||||
|
||||
parsed = safe_float(price)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
return _current_price(getattr(state, "symbol", None))
|
||||
|
||||
|
||||
def _signal_strength_line(confidence: float) -> str:
|
||||
filled = min(3, max(0, round(confidence * 3)))
|
||||
bar = "●" * filled + "○" * (3 - filled)
|
||||
@@ -128,6 +154,7 @@ def auto_keyboard() -> InlineKeyboardMarkup:
|
||||
|
||||
status = (state.status or "").upper()
|
||||
block_reason = _auto_block_reason()
|
||||
diagnostics_available = status in {"RUNNING", "OBSERVING"}
|
||||
|
||||
if status == "OFF":
|
||||
if block_reason:
|
||||
@@ -156,9 +183,14 @@ def auto_keyboard() -> InlineKeyboardMarkup:
|
||||
|
||||
builder.button(text="🛠️ Настройки", callback_data="settings:auto")
|
||||
builder.button(text="🧯 Защита", callback_data="auto:risk")
|
||||
builder.button(text="🔬 Диагностика", callback_data="auto:diagnostics")
|
||||
|
||||
builder.adjust(2, 2, 1)
|
||||
if diagnostics_available:
|
||||
builder.button(text="📊 Анализ рынка", callback_data="auto:diagnostics")
|
||||
builder.adjust(2, 2, 1)
|
||||
elif status in {"OFF", "RUNNING", "OBSERVING"}:
|
||||
builder.adjust(2, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 1)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -347,21 +379,19 @@ def _build_waiting_text(state) -> str:
|
||||
|
||||
_append_auto_block_reason(parts, state)
|
||||
|
||||
execution_block_lines = _execution_block_lines(state)
|
||||
if execution_block_lines:
|
||||
parts.extend(["", *execution_block_lines])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
f"Доступно 💰 {_format_money_compact(available)}",
|
||||
])
|
||||
|
||||
if cycle_trades > 0:
|
||||
parts.extend([
|
||||
"",
|
||||
f"🔄 {_cycle_number_text(state)} · {cycle_trades} {_trade_word(cycle_trades)}",
|
||||
_format_pnl_line(cycle_pnl),
|
||||
])
|
||||
|
||||
winrate_line = _cycle_winrate_line(state, cycle_pnl, cycle_trades)
|
||||
if winrate_line:
|
||||
parts.append(winrate_line)
|
||||
parts.extend([
|
||||
"",
|
||||
*_cycle_summary_lines(state),
|
||||
])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
@@ -381,6 +411,14 @@ def _build_waiting_text(state) -> str:
|
||||
else "Подготовка ордера 🧾"
|
||||
)
|
||||
|
||||
notional = (
|
||||
estimated_size * price
|
||||
if estimated_size is not None and price is not None and price > 0
|
||||
else None
|
||||
)
|
||||
|
||||
commission_lines = _commission_lines_for_order(state, notional)
|
||||
|
||||
order_lines = [
|
||||
"",
|
||||
block_title,
|
||||
@@ -388,9 +426,19 @@ def _build_waiting_text(state) -> str:
|
||||
f"Цена · {_format_plain_or_dash(price)}",
|
||||
_estimated_size_text(state, price),
|
||||
_max_reserved_line(state, price),
|
||||
_effective_risk_line(state),
|
||||
]
|
||||
|
||||
if commission_lines:
|
||||
order_lines.extend([
|
||||
"",
|
||||
*commission_lines,
|
||||
"",
|
||||
])
|
||||
|
||||
order_lines.extend([
|
||||
_effective_risk_line(state),
|
||||
])
|
||||
|
||||
execution_confidence_line = _execution_confidence_line(state)
|
||||
if execution_confidence_line:
|
||||
order_lines.append(execution_confidence_line)
|
||||
@@ -432,43 +480,52 @@ def _execution_runtime_line(state) -> str:
|
||||
getattr(state, "execution_quality_reason", "") or ""
|
||||
).upper()
|
||||
|
||||
freshness = _execution_freshness_text(state)
|
||||
market_status_message = str(
|
||||
getattr(state, "market_status_message", "") or ""
|
||||
).strip()
|
||||
|
||||
if quality == "GOOD":
|
||||
return ""
|
||||
|
||||
if quality == "WARNING":
|
||||
if reason == "WIDE_SPREAD":
|
||||
return f"Исполнение ⚠️ Повышенный spread · {freshness}"
|
||||
if reason in {
|
||||
"MARKET_BREAK",
|
||||
"MARKET_CLOSED",
|
||||
"EXCHANGE_UNAVAILABLE",
|
||||
"AUTH_ERROR",
|
||||
"TIME_ERROR",
|
||||
"INVALID_SYMBOL",
|
||||
}:
|
||||
return market_status_message or "⏸️ Перерыв в торгах"
|
||||
|
||||
if reason == "MARKET_STATUS_UNKNOWN":
|
||||
return market_status_message or "⚠️ Статус торгов неизвестен"
|
||||
|
||||
if quality == "WARNING":
|
||||
if reason == "AGING_SNAPSHOT":
|
||||
return f"Исполнение ⚠️ Snapshot стареет · {freshness}"
|
||||
return "⚠️ Котировки обновляются с задержкой"
|
||||
|
||||
if reason == "WIDE_SPREAD":
|
||||
return "⚠️ Повышенный spread"
|
||||
|
||||
if reason == "SNAPSHOT_UNAVAILABLE":
|
||||
return f"Исполнение ⚠️ Нет стакана · {freshness}"
|
||||
return "⚠️ Нет данных стакана"
|
||||
|
||||
return f"Исполнение ⚠️ Предупреждение · {freshness}"
|
||||
return "⚠️ Предупреждение"
|
||||
|
||||
if quality == "BLOCKED":
|
||||
if reason == "MARKET_CLOSED":
|
||||
return "Исполнение ⏸️ Рынок закрыт"
|
||||
|
||||
if reason == "STALE_SNAPSHOT":
|
||||
return f"Исполнение 🔴 Snapshot устарел · {freshness}"
|
||||
if reason in {"STALE_SNAPSHOT", "SNAPSHOT_ERROR"}:
|
||||
return "⛔️ Нет актуальных котировок"
|
||||
|
||||
if reason == "HIGH_SPREAD":
|
||||
return f"Исполнение 🔴 Высокий spread · {freshness}"
|
||||
return "⛔️ Высокий spread"
|
||||
|
||||
if reason == "SNAPSHOT_ERROR":
|
||||
return "Исполнение 🔴 Нет данных рынка"
|
||||
|
||||
return f"Исполнение 🔴 Заблокировано · {freshness}"
|
||||
return "⛔️ Вход заблокирован"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _build_active_position_text(state) -> str:
|
||||
current_price = _current_price(state.symbol)
|
||||
current_price = _position_current_price(state)
|
||||
price_for_calc = current_price or state.entry_price or 0.0
|
||||
|
||||
size = state.position_size or 0.0
|
||||
@@ -509,25 +566,20 @@ def _build_active_position_text(state) -> str:
|
||||
|
||||
_append_auto_block_reason(parts, state)
|
||||
|
||||
execution_block_lines = _execution_block_lines(state)
|
||||
if execution_block_lines:
|
||||
parts.extend(["", *execution_block_lines])
|
||||
|
||||
parts.extend([
|
||||
"",
|
||||
f"Доступно 💰 {_format_money_compact(available)}",
|
||||
f"Маржа · {_format_usd_compact(reserved)}",
|
||||
])
|
||||
|
||||
if cycle_trades > 0:
|
||||
parts.extend([
|
||||
"",
|
||||
(
|
||||
f"🔄 {_cycle_number_text(state)} · "
|
||||
f"{cycle_trades} {_trade_word(cycle_trades)}"
|
||||
),
|
||||
_format_pnl_line(cycle_pnl),
|
||||
])
|
||||
|
||||
winrate_line = _cycle_winrate_line(state, cycle_pnl, cycle_trades)
|
||||
if winrate_line:
|
||||
parts.append(winrate_line)
|
||||
parts.extend([
|
||||
"",
|
||||
*_cycle_summary_lines(state),
|
||||
])
|
||||
|
||||
separator = " " if adaptive_warning else " · "
|
||||
|
||||
@@ -548,8 +600,18 @@ def _build_active_position_text(state) -> str:
|
||||
),
|
||||
f"Объём · {_format_usd_compact(notional)}",
|
||||
_format_pnl_line(pnl),
|
||||
|
||||
])
|
||||
|
||||
commission_lines = _commission_lines_for_position(state, notional)
|
||||
|
||||
if commission_lines:
|
||||
parts.extend([
|
||||
"",
|
||||
*commission_lines,
|
||||
"",
|
||||
])
|
||||
|
||||
execution_runtime_line = _execution_runtime_line(state)
|
||||
if execution_runtime_line:
|
||||
parts.append(execution_runtime_line)
|
||||
@@ -606,6 +668,11 @@ def _compact_entry_block_message(message: str) -> str:
|
||||
"мало live-данных": "Мало данных",
|
||||
"высокая волатильность": "Высокая волатильность",
|
||||
"низкая активность": "Низкая активность",
|
||||
"market_structure_conflict": "Структура против входа",
|
||||
"market_structure_mixed": "Структура не подтверждает вход",
|
||||
"структура рынка против входа": "Структура против входа",
|
||||
"структура рынка не подтверждает вход": "Структура не подтверждает вход",
|
||||
"counter_trend_breakout": "Пробой против тренда",
|
||||
}
|
||||
|
||||
result = mapping.get(normalized, message)
|
||||
@@ -661,6 +728,182 @@ def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
|
||||
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _trading_fee(symbol: str | None):
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
try:
|
||||
return ExchangeService().get_trading_fee(symbol)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _trade_fee_rt_usd(symbol: str | None, notional: float | None) -> float | None:
|
||||
if notional is None or notional <= 0:
|
||||
return None
|
||||
|
||||
fee = _trading_fee(symbol)
|
||||
if fee is None or fee.fee_percent is None:
|
||||
return None
|
||||
|
||||
return abs(notional * (fee.fee_percent / 100) * 2)
|
||||
|
||||
|
||||
def _overnight_period_seconds(symbol: str | None) -> int:
|
||||
normalized = str(symbol or "").upper()
|
||||
|
||||
if normalized.startswith("BTC/") or normalized.startswith("ETH/"):
|
||||
return 8 * 60 * 60
|
||||
|
||||
return 24 * 60 * 60
|
||||
|
||||
|
||||
def _overnight_rate_for_side(fee, side: str | None) -> float | None:
|
||||
normalized_side = str(side or "").upper()
|
||||
|
||||
if normalized_side in {"LONG", "BUY"}:
|
||||
return safe_float(fee.overnight_long_rate)
|
||||
|
||||
if normalized_side in {"SHORT", "SELL"}:
|
||||
return safe_float(fee.overnight_short_rate)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _signed_usd_compact(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "$ —"
|
||||
|
||||
if value > 0:
|
||||
return f"+{_format_usd_compact(value)}"
|
||||
|
||||
if value < 0:
|
||||
return f"-{_format_usd_compact(abs(value))}"
|
||||
|
||||
return _format_usd_compact(0)
|
||||
|
||||
|
||||
def _predicted_overnight_fee_both_sides_lines(
|
||||
symbol: str | None,
|
||||
notional: float | None,
|
||||
) -> list[str]:
|
||||
if notional is None or notional <= 0:
|
||||
return []
|
||||
|
||||
fee = _trading_fee(symbol)
|
||||
if fee is None:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
long_rate = safe_float(fee.overnight_long_rate)
|
||||
short_rate = safe_float(fee.overnight_short_rate)
|
||||
|
||||
if long_rate is not None:
|
||||
long_value = notional * (long_rate / 100)
|
||||
lines.append(f" · Левередж Long · {_signed_usd_compact(long_value)}")
|
||||
|
||||
if short_rate is not None:
|
||||
short_value = notional * (short_rate / 100)
|
||||
lines.append(f" · Левередж Short · {_signed_usd_compact(short_value)}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _position_overnight_fee_usd(
|
||||
state,
|
||||
notional: float | None,
|
||||
) -> tuple[float | None, int]:
|
||||
if notional is None or notional <= 0:
|
||||
return None, 0
|
||||
|
||||
fee = _trading_fee(state.symbol)
|
||||
if fee is None:
|
||||
return None, 0
|
||||
|
||||
rate = _overnight_rate_for_side(fee, state.position_side)
|
||||
if rate is None:
|
||||
return None, 0
|
||||
|
||||
hold_seconds = safe_float(getattr(state, "position_hold_seconds", None))
|
||||
|
||||
if hold_seconds is None:
|
||||
opened_at = safe_float(getattr(state, "position_opened_monotonic_at", None))
|
||||
if opened_at is not None:
|
||||
hold_seconds = max(0, time.monotonic() - opened_at)
|
||||
|
||||
if hold_seconds is None:
|
||||
return 0.0, 0
|
||||
|
||||
period_seconds = _overnight_period_seconds(state.symbol)
|
||||
overnight_count = int(hold_seconds // period_seconds)
|
||||
|
||||
return notional * (rate / 100) * overnight_count, overnight_count
|
||||
|
||||
|
||||
def _commission_lines_for_order(
|
||||
state,
|
||||
notional: float | None,
|
||||
) -> list[str]:
|
||||
trade_fee = _trade_fee_rt_usd(state.symbol, notional)
|
||||
|
||||
leverage = safe_float(getattr(state, "leverage", None)) or 1.0
|
||||
|
||||
if leverage <= 1:
|
||||
leverage_fees: list[str] = []
|
||||
else:
|
||||
leverage_fees = _predicted_overnight_fee_both_sides_lines(
|
||||
state.symbol,
|
||||
notional,
|
||||
)
|
||||
|
||||
if trade_fee is None and not leverage_fees:
|
||||
return []
|
||||
|
||||
lines = ["Комиссии:"]
|
||||
|
||||
if trade_fee is not None:
|
||||
lines.append(f" · Сделка (RT) · {_format_usd_compact(trade_fee)}")
|
||||
|
||||
lines.extend(leverage_fees)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _commission_lines_for_position(
|
||||
state,
|
||||
notional: float | None,
|
||||
) -> list[str]:
|
||||
trade_fee = _trade_fee_rt_usd(state.symbol, notional)
|
||||
|
||||
leverage = safe_float(getattr(state, "leverage", None)) or 1.0
|
||||
|
||||
if leverage <= 1:
|
||||
leverage_fee = None
|
||||
overnight_count = 0
|
||||
else:
|
||||
leverage_fee, overnight_count = _position_overnight_fee_usd(state, notional)
|
||||
|
||||
if trade_fee is None and leverage_fee is None:
|
||||
return []
|
||||
|
||||
lines = ["Комиссии:"]
|
||||
|
||||
if trade_fee is not None:
|
||||
lines.append(f" · Сделка (RT) · {_format_usd_compact(trade_fee)}")
|
||||
|
||||
# Показываем комиссию за левередж только после первого фактического списания.
|
||||
# До этого строка "$0 / 0 спис." не несёт пользы и визуально засоряет UI.
|
||||
if leverage_fee is not None and overnight_count > 0:
|
||||
side = _position_side_text(getattr(state, "position_side", None))
|
||||
lines.append(
|
||||
f" · Левередж {side} · {_signed_usd_compact(leverage_fee)} / "
|
||||
f"{overnight_count} спис."
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _current_price(symbol: str | None) -> float | None:
|
||||
@@ -1076,21 +1319,6 @@ def _signal_duration_text(state) -> str:
|
||||
return f"{seconds}с"
|
||||
|
||||
|
||||
def _execution_freshness_text(state) -> str:
|
||||
freshness = str(
|
||||
getattr(state, "execution_price_freshness", "") or ""
|
||||
).upper()
|
||||
|
||||
mapping = {
|
||||
"FRESH": "данные свежие",
|
||||
"AGING": "данные стареют",
|
||||
"STALE": "данные устарели",
|
||||
"UNKNOWN": "нет данных",
|
||||
}
|
||||
|
||||
return mapping.get(freshness, "нет данных")
|
||||
|
||||
|
||||
def _status_text(state) -> str:
|
||||
runtime = _cycle_runtime_text(state)
|
||||
|
||||
@@ -1306,25 +1534,46 @@ def _trade_word(value: int) -> str:
|
||||
return "сделок"
|
||||
|
||||
|
||||
def _cycle_winrate_line(state, cycle_pnl: float, cycle_trades: int) -> str:
|
||||
if cycle_trades <= 0 or cycle_pnl <= 0:
|
||||
return ""
|
||||
def _cycle_summary_lines(state) -> list[str]:
|
||||
# Единый блок статистики текущего цикла.
|
||||
# Показываем номер цикла всегда, даже если закрытых сделок ещё нет.
|
||||
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)
|
||||
|
||||
wins = int(getattr(state, "cycle_winning_trades", 0) or 0)
|
||||
winrate = round((wins / cycle_trades) * 100)
|
||||
losses = int(getattr(state, "cycle_losing_trades", 0) or 0)
|
||||
|
||||
if cycle_trades <= 0:
|
||||
return [f"🔄 {_cycle_number_text(state)}"]
|
||||
|
||||
lines = [
|
||||
(
|
||||
f"🔄 {_cycle_number_text(state)} · "
|
||||
f"{cycle_trades} {_trade_word(cycle_trades)} · "
|
||||
f"🟢 {wins} 🔴 {losses}"
|
||||
),
|
||||
*_cycle_trade_block_lines(state),
|
||||
"",
|
||||
_format_pnl_line(cycle_pnl),
|
||||
*_cycle_commission_lines(state),
|
||||
]
|
||||
|
||||
return lines
|
||||
|
||||
return f"Успешных · {winrate}%"
|
||||
|
||||
def _format_pnl_line(value: float | int | None) -> str:
|
||||
# Показываем именно итог цикла/позиции.
|
||||
# Комиссии уже включены в net PnL, а ниже отдельным блоком показываем,
|
||||
# какая часть результата пришлась на комиссии.
|
||||
amount = float(value or 0.0)
|
||||
|
||||
if amount > 0:
|
||||
return f"Прибыль 🟢 +{_format_usd_compact(amount)}"
|
||||
return f"🟢 Итог · +{_format_usd_compact(amount)}"
|
||||
|
||||
if amount < 0:
|
||||
return f"Убыток 🔴 −{_format_usd_compact(abs(amount))}"
|
||||
return f"🔴 Итог · −{_format_usd_compact(abs(amount))}"
|
||||
|
||||
return "Результат · $0"
|
||||
return "⚪ Итог · $0"
|
||||
|
||||
|
||||
def _adaptive_adjustment_visible(state) -> bool:
|
||||
@@ -1367,4 +1616,98 @@ def _short_adaptive_reason(
|
||||
if not reason:
|
||||
return "Размер скорректирован"
|
||||
|
||||
return reason[:1].upper() + reason[1:]
|
||||
return reason[:1].upper() + reason[1:]
|
||||
|
||||
|
||||
def _execution_block_lines(state) -> list[str]:
|
||||
title = str(getattr(state, "execution_block_title", "") or "").strip()
|
||||
message = str(getattr(state, "execution_block_message", "") or "").strip()
|
||||
action = str(getattr(state, "execution_block_action", "") or "").strip()
|
||||
|
||||
if not title or not message:
|
||||
return []
|
||||
|
||||
lines = [
|
||||
f"⛔ {title}",
|
||||
message,
|
||||
]
|
||||
|
||||
if action:
|
||||
lines.append(action)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _cycle_commission_lines(state) -> list[str]:
|
||||
trade_fees = safe_float(getattr(state, "cycle_trade_fees_usd", None)) or 0.0
|
||||
overnight_fees = safe_float(getattr(state, "cycle_overnight_fees_usd", None)) or 0.0
|
||||
|
||||
if abs(trade_fees) < 0.0001 and abs(overnight_fees) < 0.0001:
|
||||
return []
|
||||
|
||||
lines = ["Включая комиссии:"]
|
||||
|
||||
if abs(trade_fees) >= 0.0001:
|
||||
lines.append(f"· сделки (RT) · {_format_usd_compact(abs(trade_fees))}")
|
||||
|
||||
if abs(overnight_fees) >= 0.0001:
|
||||
lines.append(f"· левередж · {_signed_usd_compact(overnight_fees)}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _cycle_trade_block_lines(state) -> list[str]:
|
||||
# Этот блок показываем только при реальной блокировке по серии убытков.
|
||||
# Обычная пауза/cooldown после одной сделки сюда не попадает.
|
||||
if not bool(getattr(state, "loss_cooldown_active", False)):
|
||||
return []
|
||||
|
||||
consecutive_losses = int(
|
||||
getattr(state, "cycle_consecutive_losses", 0) or 0
|
||||
)
|
||||
|
||||
if consecutive_losses <= 0:
|
||||
return []
|
||||
|
||||
return [
|
||||
"",
|
||||
"⛔️ Блокировка сделок",
|
||||
f"· {consecutive_losses} убыточных сделок подряд",
|
||||
"· перезапусти цикл",
|
||||
]
|
||||
|
||||
|
||||
def _market_score_notification_line(state) -> str:
|
||||
score = safe_float(getattr(state, "market_score", None))
|
||||
|
||||
if score is None:
|
||||
return ""
|
||||
|
||||
label = str(getattr(state, "market_score_label", "") or "").strip()
|
||||
|
||||
if not label:
|
||||
label = _market_score_label(score)
|
||||
|
||||
return f"Рынок · {label.lower()} · {score:.0f}%"
|
||||
|
||||
|
||||
def _market_score_label(score: float) -> str:
|
||||
# Единая шкала общей оценки рынка:
|
||||
# 90-100 — отличный рынок
|
||||
# 75-89 — благоприятный
|
||||
# 55-74 — нейтральный
|
||||
# 35-54 — сложный
|
||||
# 0-34 — неблагоприятный
|
||||
if score >= 90:
|
||||
return "Отличный"
|
||||
|
||||
if score >= 75:
|
||||
return "Благоприятный"
|
||||
|
||||
if score >= 55:
|
||||
return "Нейтральный"
|
||||
|
||||
if score >= 35:
|
||||
return "Сложный"
|
||||
|
||||
return "Неблагоприятный"
|
||||
505
app/src/telegram/handlers/market.py
Normal file
505
app/src/telegram/handlers/market.py
Normal file
@@ -0,0 +1,505 @@
|
||||
# app/src/telegram/handlers/market.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.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.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
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
|
||||
from src.telegram.ui.currency_ui import format_usd_amount
|
||||
from src.telegram.ui.exchange_error import (
|
||||
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()
|
||||
|
||||
|
||||
# собрать текст, когда рынок/биржа недоступны через unified status layer
|
||||
def _build_market_status_text(status: ExchangeRuntimeStatus) -> str:
|
||||
icon = "⏸️" if status.code == ExchangeStatusCode.BREAK else "⛔️"
|
||||
|
||||
return (
|
||||
"<b>📈 Рынок</b>\n"
|
||||
f"{mode_line()}"
|
||||
f"{icon} {status.title}\n\n"
|
||||
f"{status.message}\n\n"
|
||||
f"{now_line()}"
|
||||
)
|
||||
|
||||
|
||||
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()}"
|
||||
)
|
||||
|
||||
|
||||
# live-render должен сам уметь показать ошибку, иначе runner просто потеряет экран
|
||||
def _build_market_live_text() -> str:
|
||||
service = ExchangeService()
|
||||
requested_symbol = service.settings.default_symbol
|
||||
|
||||
try:
|
||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
except Exception as exc:
|
||||
return _build_market_status_text(build_exchange_error_status(exc))
|
||||
|
||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
||||
return _build_market_status_text(runtime_status)
|
||||
|
||||
symbol = runtime_status.symbol or requested_symbol
|
||||
|
||||
validation = service.validate_symbol(symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
return _build_market_status_text(
|
||||
service.get_symbol_runtime_status(requested_symbol)
|
||||
)
|
||||
|
||||
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 _send_or_edit_market_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
text: str,
|
||||
edit_mode: bool,
|
||||
) -> None:
|
||||
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)
|
||||
return
|
||||
|
||||
sent_message = await target_message.answer(
|
||||
text,
|
||||
reply_markup=_market_keyboard(),
|
||||
)
|
||||
_register_market_live_screen(sent_message)
|
||||
ActiveScreenManager.register(screen="market", message=sent_message)
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
|
||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
|
||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
||||
journal.log_ui_warning(
|
||||
event_type="market_status_blocked",
|
||||
message=runtime_status.message,
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=runtime_status.as_dict(),
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=_build_market_status_text(runtime_status),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
return
|
||||
|
||||
symbol = runtime_status.symbol or requested_symbol
|
||||
validation = service.validate_symbol(symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
invalid_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
|
||||
journal.log_ui_warning(
|
||||
event_type="market_symbol_invalid",
|
||||
message=invalid_status.message,
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=invalid_status.as_dict(),
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=_build_market_status_text(invalid_status),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
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),
|
||||
"runtime_status": runtime_status.as_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=text,
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
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.core.config import load_settings
|
||||
from src.core.config import ENV_FILE, 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
|
||||
@@ -753,6 +755,40 @@ async def open_general_settings(callback: CallbackQuery) -> None:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _journal_debug_enabled() -> bool:
|
||||
return bool(load_settings().journal_debug_enabled)
|
||||
|
||||
|
||||
def _set_env_value(key: str, value: str) -> None:
|
||||
lines: list[str] = []
|
||||
|
||||
if ENV_FILE.exists():
|
||||
lines = ENV_FILE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
updated = False
|
||||
result: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
result.append(f"{key}={value}")
|
||||
updated = True
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
if not updated:
|
||||
result.append(f"{key}={value}")
|
||||
|
||||
ENV_FILE.write_text("\n".join(result) + "\n", encoding="utf-8")
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _journal_debug_status_line() -> str:
|
||||
if _journal_debug_enabled():
|
||||
return "🐞 Debug лог: <b>ВКЛ</b>"
|
||||
|
||||
return "🐞 Debug лог: <b>ВЫКЛ</b>"
|
||||
|
||||
|
||||
@router.callback_query(F.data == "settings:journal")
|
||||
async def open_journal_settings(callback: CallbackQuery) -> None:
|
||||
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
|
||||
@@ -771,25 +807,64 @@ async def open_journal_settings(callback: CallbackQuery) -> None:
|
||||
"<b>📒 Журнал</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки\n\n"
|
||||
f"📄 Записей: {total}\n"
|
||||
f"{_journal_debug_status_line()}\n"
|
||||
"📦 Лимит: —\n"
|
||||
"⏳ Хранение: —\n"
|
||||
"🗄 Архив: —\n\n"
|
||||
)
|
||||
|
||||
debug_button_text = (
|
||||
"🟢 Debug логирование"
|
||||
if _journal_debug_enabled()
|
||||
else "⚪️ Debug логирование"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=debug_button_text, callback_data="settings:journal_debug_toggle")
|
||||
builder.button(text="🗑 Очистка", callback_data="journal:clear_confirm")
|
||||
builder.button(text="🗄 Архив", callback_data="settings:journal_archive")
|
||||
builder.button(text="📦 Лимит", callback_data="settings:journal_limit")
|
||||
builder.button(text="⏳ Хранение", callback_data="settings:journal_retention")
|
||||
builder.button(text="⬅️ Назад", callback_data="system:management")
|
||||
builder.button(text="📒 Журнал", callback_data="journal:1")
|
||||
builder.adjust(2, 2, 2)
|
||||
builder.adjust(1, 2, 2, 2)
|
||||
|
||||
await message.edit_text(text, reply_markup=builder.as_markup())
|
||||
_register_system_screen(message, screen="settings_journal")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "settings:journal_debug_toggle")
|
||||
async def toggle_journal_debug(callback: CallbackQuery) -> None:
|
||||
enabled = _journal_debug_enabled()
|
||||
new_value = "false" if enabled else "true"
|
||||
|
||||
_set_env_value("JOURNAL_DEBUG_ENABLED", new_value)
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
event_type="journal_debug_changed",
|
||||
message=(
|
||||
"Debug логирование журнала выключено."
|
||||
if enabled
|
||||
else "Debug логирование журнала включено."
|
||||
),
|
||||
screen="settings_journal",
|
||||
action="toggle_debug",
|
||||
payload={
|
||||
"journal_debug_enabled": not enabled,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await open_journal_settings(callback)
|
||||
|
||||
await callback.answer(
|
||||
"Debug логирование выключено" if enabled else "Debug логирование включено"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "settings:journal_archive")
|
||||
async def open_journal_archive_settings(callback: CallbackQuery) -> None:
|
||||
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
|
||||
|
||||
Reference in New Issue
Block a user