Stage 07.4.4.1.14 — Execution refactoring and runtime semantics

This commit is contained in:
2026-07-02 16:25:18 +03:00
parent d9e6392e28
commit e08c604ff5
72 changed files with 13626 additions and 4233 deletions

View File

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

View File

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

View File

@@ -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 "Неблагоприятный"

View 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",
)

View File

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