diff --git a/app/src/core/numbers.py b/app/src/core/numbers.py
new file mode 100644
index 0000000..09dcb2e
--- /dev/null
+++ b/app/src/core/numbers.py
@@ -0,0 +1,23 @@
+# app/src/core/numbers.py
+
+# src/core/numbers.py
+
+from __future__ import annotations
+
+from src.core.types import NumericLike
+
+
+def safe_float(
+ value: object,
+ default: float | None = None,
+) -> float | None:
+ if value is None:
+ return default
+
+ if isinstance(value, bool):
+ return default
+
+ try:
+ return float(str(value).strip())
+ except (TypeError, ValueError):
+ return default
\ No newline at end of file
diff --git a/app/src/core/telegram_errors.py b/app/src/core/telegram_errors.py
new file mode 100644
index 0000000..408532a
--- /dev/null
+++ b/app/src/core/telegram_errors.py
@@ -0,0 +1,18 @@
+# app/src/core/telegram_errors.py
+
+from __future__ import annotations
+
+from aiogram.exceptions import TelegramBadRequest
+
+
+def is_message_not_modified(exc: TelegramBadRequest) -> bool:
+ return "message is not modified" in str(exc).lower()
+
+
+def is_message_to_edit_not_found(exc: TelegramBadRequest) -> bool:
+ text = str(exc).lower()
+
+ return (
+ "message to edit not found" in text
+ or "message_id_invalid" in text
+ )
\ No newline at end of file
diff --git a/app/src/core/types.py b/app/src/core/types.py
new file mode 100644
index 0000000..6ba5efa
--- /dev/null
+++ b/app/src/core/types.py
@@ -0,0 +1,12 @@
+# app/src/core/types.py
+
+from __future__ import annotations
+
+from typing import Any, TypeAlias
+
+
+NumericLike: TypeAlias = float | int | str
+
+JsonDict: TypeAlias = dict[str, Any]
+
+JsonList: TypeAlias = list[Any]
\ No newline at end of file
diff --git a/app/src/integrations/exchange/market_data_runner.py b/app/src/integrations/exchange/market_data_runner.py
index 7c59f18..40acb9c 100644
--- a/app/src/integrations/exchange/market_data_runner.py
+++ b/app/src/integrations/exchange/market_data_runner.py
@@ -22,6 +22,7 @@ class MarketRuntimeContext:
screen: str | None
action: str
runtime_label: str | None
+ last_market_status: str | None = None
class MarketDataRunner:
@@ -116,18 +117,29 @@ class MarketDataRunner:
):
MarketPriceCache.clear(cache_symbol)
- #if previous_symbol is not None:
- # cls._log_info(
- # context,
- # "market_symbol_changed",
- # f"Инструмент автоторговли изменён: {cache_symbol}.",
- # {
- # "previous_symbol": previous_symbol,
- # "symbol": symbol,
- # "cache_symbol": cache_symbol,
- # "ws_symbol": ws_symbol,
- # },
- # )
+ market_status = ExchangeService().get_symbol_market_status(symbol)
+ status_key = str(market_status.get("status") or "UNKNOWN")
+
+ if not bool(market_status.get("is_open")):
+ if context.last_market_status != status_key:
+ context.last_market_status = status_key
+
+ cls._log_warning(
+ context,
+ "market_closed",
+ "Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
+ {
+ "symbol": symbol,
+ "market_status": status_key,
+ "message": market_status.get("message"),
+ },
+ )
+
+ await asyncio.sleep(context.interval_seconds)
+ continue
+
+ context.last_market_status = status_key
+
try:
await cls._run_websocket(context, symbol)
except asyncio.CancelledError:
diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py
index 3dd8429..a378e5a 100644
--- a/app/src/integrations/exchange/service.py
+++ b/app/src/integrations/exchange/service.py
@@ -36,6 +36,73 @@ class ExchangeService:
_execution_cache_max_age_seconds = 2.0
_default_runtime_key = "auto"
+ def get_symbol_market_status(self, symbol: str | None = None) -> dict[str, object]:
+ symbol_to_use = symbol or self.settings.default_symbol
+
+ if not self.settings.exchange_enabled:
+ return {
+ "symbol": symbol_to_use,
+ "is_open": True,
+ "status": "OPEN",
+ "message": "Mock market is open.",
+ }
+
+ validation = self.validate_symbol(symbol_to_use)
+
+ if not validation.is_valid:
+ return {
+ "symbol": symbol_to_use,
+ "is_open": False,
+ "status": "INVALID_SYMBOL",
+ "message": validation.message,
+ }
+
+ symbol_info = validation.symbol_info
+ raw_status = str(getattr(symbol_info, "status", "") or "").upper()
+
+ open_statuses = {
+ "TRADING",
+ "OPEN",
+ "ACTIVE",
+ "ENABLED",
+ "ONLINE",
+ }
+
+ closed_statuses = {
+ "BREAK",
+ "CLOSED",
+ "HALT",
+ "HALTED",
+ "PAUSED",
+ "SUSPENDED",
+ "DISABLED",
+ "SETTLING",
+ "POST_ONLY",
+ }
+
+ if raw_status in open_statuses:
+ return {
+ "symbol": validation.normalized_symbol,
+ "is_open": True,
+ "status": raw_status,
+ "message": "Рынок открыт.",
+ }
+
+ if raw_status in closed_statuses:
+ return {
+ "symbol": validation.normalized_symbol,
+ "is_open": False,
+ "status": raw_status,
+ "message": "Рынок закрыт или на паузе.",
+ }
+
+ return {
+ "symbol": validation.normalized_symbol,
+ "is_open": False,
+ "status": raw_status or "UNKNOWN",
+ "message": "Статус рынка не определён.",
+ }
+
def __init__(self) -> None:
self.settings = load_settings()
self.journal = JournalService()
diff --git a/app/src/notifications/templates/execution.py b/app/src/notifications/templates/execution.py
index 82ab96c..5a896b2 100644
--- a/app/src/notifications/templates/execution.py
+++ b/app/src/notifications/templates/execution.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
+from src.core.numbers import safe_float
def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None:
@@ -27,8 +28,9 @@ def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
- strategy = str(payload.get("strategy") or "—")
- side = str(payload.get("side") or "—").upper()
+ strategy = str(payload.get("strategy") or "—").title()
+ side_raw = str(payload.get("side") or "—").upper()
+ side = side_raw.title()
leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
size = _format_size(payload.get("size"))
@@ -39,13 +41,13 @@ def _build_position_opened(event: RuntimeEvent) -> NotificationMessage:
)
semantic_lines = payload.get("semantic_lines") or []
- side_icon = "🟢" if side == "LONG" else "🔴"
+ side_icon = "🟢" if side_raw == "LONG" else "🔴"
lines = [
"🧾 Позиция открыта",
"",
f"{side_icon} {symbol} · {strategy} · {side} {leverage}",
- f"Вход: {entry_price}",
+ f"Вход: ${entry_price}",
f"Размер: {size}",
f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}",
"",
@@ -71,7 +73,7 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
payload = event.payload
symbol = _format_symbol(payload.get("symbol"))
- side = str(payload.get("side") or "—").upper()
+ side = str(payload.get("side") or "—").title()
leverage = _format_leverage(payload.get("leverage"))
entry_price = _format_price(payload.get("entry_price"))
@@ -91,8 +93,8 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage:
f"{pnl_icon} {pnl_label} · {pnl_text}",
"",
f"{symbol} · {side} {leverage}",
- f"Вход: $ {entry_price}",
- f"Выход: $ {exit_price}",
+ f"Вход: ${entry_price}",
+ f"Выход: ${exit_price}",
f"Размер: {size}",
]
@@ -138,8 +140,13 @@ def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
symbol = _format_symbol(payload.get("symbol"))
strategy = str(payload.get("strategy") or "—").title()
- old_side = str(payload.get("old_side") or "—").upper()
- new_side = str(payload.get("new_side") or payload.get("side") or "—").upper()
+ old_side_raw = str(payload.get("old_side") or "—").upper()
+ new_side_raw = str(
+ payload.get("new_side") or payload.get("side") or "—"
+ ).upper()
+
+ old_side = old_side_raw.title()
+ new_side = new_side_raw.title()
old_leverage = _format_leverage(
payload.get("old_leverage")
@@ -161,8 +168,8 @@ def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
pnl_icon = "🟢" if pnl_value >= 0 else "🔴"
pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток"
- old_icon = "🟢" if old_side == "LONG" else "🔴"
- new_icon = "🟢" if new_side == "LONG" else "🔴"
+ old_icon = "🟢" if old_side_raw == "LONG" else "🔴"
+ new_icon = "🟢" if new_side_raw == "LONG" else "🔴"
confidence = float(payload.get("confidence") or 0.0)
repeat_count = int(payload.get("repeat_count") or 0)
@@ -178,12 +185,12 @@ def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage:
f"{symbol} · {strategy} {old_icon} {old_side} → {new_icon} {new_side}",
"",
f"Закрыта {old_side} {old_leverage}",
- f"Вход: $ {entry_price}",
- f"Выход: $ {exit_price}",
+ f"Вход: ${entry_price}",
+ f"Выход: ${exit_price}",
f"Размер: {old_size}",
"",
f"Открыта {new_side} {new_leverage}",
- f"Вход: $ {new_entry_price}",
+ f"Вход: ${new_entry_price}",
f"Размер: {new_size}",
(
"Объём: "
@@ -215,9 +222,9 @@ def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage:
signal = str(payload.get("signal") or "").upper()
confidence = float(payload.get("confidence") or 0.0)
reason = str(payload.get("reason") or "Flip заблокирован")
- position_side = str(payload.get("position_side") or "—").upper()
+ position_side = str(payload.get("position_side") or "—").title()
- target_side = "LONG" if signal == "BUY" else "SHORT" if signal == "SELL" else "—"
+ target_side = "Long" if signal == "BUY" else "Short" if signal == "SELL" else "—"
icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else ""
text = (
@@ -247,43 +254,30 @@ def _format_symbol(value: object) -> str:
def _format_leverage(value: object) -> str:
- try:
- return f"x{float(value):g}"
- except (TypeError, ValueError):
+ number = safe_float(value)
+
+ if number is None:
return "—"
+ return f"x{number:g}"
+
def _format_price(value: object) -> str:
- try:
- number = float(value)
- except (TypeError, ValueError):
+ number = safe_float(value)
+
+ if number is None:
return "—"
return f"{number:,.2f}".replace(",", " ")
def _format_size(value: object) -> str:
- try:
- return f"{float(value):.8f}".rstrip("0").rstrip(".")
- except (TypeError, ValueError):
+ number = safe_float(value)
+
+ if number is None:
return "—"
-
-def _format_pnl(value: object) -> str:
- try:
- number = float(value)
- except (TypeError, ValueError):
- return "—"
-
- amount = f"$ {abs(number):,.2f}".replace(",", " ").rstrip("0").rstrip(".")
-
- if number > 0:
- return f"🟢 +{amount}"
-
- if number < 0:
- return f"🔴 −{amount}"
-
- return "$ 0"
+ return f"{number:.8f}".rstrip("0").rstrip(".")
def _alert_priority(*, confidence: float, repeat_count: int) -> str:
@@ -319,9 +313,12 @@ def _format_notional(
entry_price: object,
size: object,
) -> str:
- try:
- value = float(entry_price) * float(size)
- except (TypeError, ValueError):
+ entry = safe_float(entry_price)
+ amount = safe_float(size)
+
+ if entry is None or amount is None:
return "—"
+ value = entry * amount
+
return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
\ No newline at end of file
diff --git a/app/src/notifications/templates/signal.py b/app/src/notifications/templates/signal.py
index 8872474..37807b1 100644
--- a/app/src/notifications/templates/signal.py
+++ b/app/src/notifications/templates/signal.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, JsonList, NumericLike
from src.notifications.models import NotificationMessage
from src.runtime_events.event_types import RuntimeEventType
from src.runtime_events.models import RuntimeEvent
@@ -11,34 +13,49 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
if event.event_type != RuntimeEventType.AUTO_SIGNAL_READY:
return None
- payload = event.payload
+ payload: JsonDict = event.payload
signal = str(payload.get("signal") or "—").upper()
symbol = _format_symbol(str(payload.get("symbol") or "—"))
- confidence = float(payload.get("confidence") or 0.0)
- position_context = str(payload.get("position_context") or "NONE").upper()
- semantic_lines = payload.get("semantic_lines") or []
+ confidence = safe_float(payload.get("confidence")) or 0.0
+ repeat_count = int(safe_float(payload.get("repeat_count")) or 0)
- priority = str(event.priority or _alert_priority(
- confidence=confidence,
- repeat_count=int(payload.get("repeat_count") or 0),
- )).upper()
+ position_context = str(payload.get("position_context") or "NONE").upper()
+ semantic_lines = _as_json_list(payload.get("semantic_lines"))
+
+ bid_price = payload.get("bid_price")
+ ask_price = payload.get("ask_price")
+
+ priority = str(
+ event.priority
+ or _alert_priority(
+ confidence=confidence,
+ repeat_count=repeat_count,
+ )
+ ).upper()
direction = _signal_direction(signal)
+ direction_key = direction.upper()
icon = _direction_icon(direction)
strength = _strength_label(priority)
strength_bar = _strength_bar(priority)
+ market_price_line = _market_price_line(
+ direction=direction_key,
+ bid_price=bid_price,
+ ask_price=ask_price,
+ )
+
lines = [
f"Сигнал {icon} {symbol} · {direction}",
"",
]
- if position_context not in {"NONE", "—", ""} and position_context != direction:
- lines.extend([
- "⚠️ ПРОТИВ ПОЗИЦИИ",
- "",
- ])
+ if market_price_line:
+ lines.extend([market_price_line, ""])
+
+ if position_context not in {"NONE", "—", ""} and position_context != direction_key:
+ lines.extend(["⚠️ ПРОТИВ ПОЗИЦИИ", ""])
lines.append(f"{strength_bar} {strength} · {confidence:.2f}")
@@ -87,19 +104,21 @@ def _strength_bar(priority: str) -> str:
def _signal_direction(signal: str) -> str:
if signal == "BUY":
- return "LONG"
+ return "Long"
if signal == "SELL":
- return "SHORT"
+ return "Short"
return "—"
def _direction_icon(direction: str) -> str:
- if direction == "LONG":
+ normalized = direction.upper()
+
+ if normalized == "LONG":
return "🟢"
- if direction == "SHORT":
+ if normalized == "SHORT":
return "🔴"
return ""
@@ -112,14 +131,39 @@ def _format_symbol(symbol: str) -> str:
return symbol.split("_", 1)[0].split("/", 1)[0].upper()
-def _format_leverage(leverage: object) -> str:
- if isinstance(leverage, (int, float)):
- return f"x{leverage:g}"
+def _market_price_line(
+ *,
+ direction: str,
+ bid_price: NumericLike | None,
+ ask_price: NumericLike | None,
+) -> str:
+ bid = _format_price(bid_price)
+ ask = _format_price(ask_price)
- return "—"
+ if bid == "—" and ask == "—":
+ return ""
+
+ if direction == "LONG":
+ return f"Цена входа: Ask ${ask} / Bid ${bid}"
+
+ if direction == "SHORT":
+ return f"Цена входа: Bid ${bid} / Ask ${ask}"
+
+ return f"Цена рынка: Bid ${bid} / Ask ${ask}"
-def _dedupe_key(payload: dict) -> str:
+def _format_price(value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
+ return "—"
+
+ return f"{number:,.2f}".replace(",", " ")
+
+
+def _dedupe_key(payload: JsonDict) -> str:
+ confidence = safe_float(payload.get("confidence")) or 0.0
+
return (
f"auto_signal_ready:"
f"{payload.get('position_context')}:"
@@ -127,7 +171,14 @@ def _dedupe_key(payload: dict) -> str:
f"{payload.get('strategy')}:"
f"{payload.get('signal')}:"
f"{payload.get('repeat_count')}:"
- f"{float(payload.get('confidence') or 0.0):.2f}:"
+ f"{confidence:.2f}:"
f"{payload.get('decision_status')}:"
f"{payload.get('reason')}"
- )
\ No newline at end of file
+ )
+
+
+def _as_json_list(value: object) -> JsonList:
+ if isinstance(value, list):
+ return value
+
+ return []
\ No newline at end of file
diff --git a/app/src/telegram/handlers/_auto.py b/app/src/telegram/handlers/_auto.py
deleted file mode 100644
index dffbecb..0000000
--- a/app/src/telegram/handlers/_auto.py
+++ /dev/null
@@ -1,364 +0,0 @@
-# app/src/telegram/handlers/auto.py
-
-from __future__ import annotations
-
-from aiogram import F, Router
-from aiogram.exceptions import TelegramBadRequest
-from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
-from aiogram.utils.keyboard import InlineKeyboardBuilder
-
-from src.telegram.ui.common import mode_line
-from src.trading.auto.runner import AutoTradeRunner
-from src.trading.auto.service import AutoTradeService
-from src.telegram.handlers.system import open_auto_settings
-from src.integrations.exchange.service import ExchangeService
-from src.telegram.ui.currency_ui import format_usd_amount
-
-
-router = Router(name="auto")
-
-
-# красивое отображение стратегии
-def _strategy_label(strategy: str | None) -> str:
- mapping = {
- "TREND": "📈 Trend Following",
- "GRID": "🧩 Grid Trading",
- "SCALP": "⚡ Scalping",
- }
- return mapping.get(strategy or "", "—")
-
-
-# красивое отображение статуса
-def _status_label(status: str) -> str:
- mapping = {
- "OFF": "⚪ Выключена",
- "OBSERVING": "👀 Наблюдение",
- "RUNNING": "🟢 Активна",
- }
- return mapping.get(status, status)
-
-
-# красивое отображение сигнала
-def _signal_label(signal: str | None) -> str:
- mapping = {
- "BUY": "🟢 BUY",
- "SELL": "🔴 SELL",
- "HOLD": "🟡 HOLD",
- }
- return mapping.get(signal or "", "—")
-
-
-# красивое отображение решения
-def _decision_label(status: str) -> str:
- mapping = {
- "WAITING": "🟡 Ожидание",
- "CONFIRMING": "🟠 Подтверждение",
- "READY": "🟢 Готово к входу",
- "BLOCKED": "🔴 Заблокировано",
- }
- return mapping.get(status, status)
-
-
-# компактное значение или заглушка
-def _value_or_dash(value: object) -> str:
- if value is None:
- return "—"
- return str(value)
-
-
-# формат цены
-def _price_or_dash(value: float | None) -> str:
- if value is None:
- return "—"
- return f"{value:.2f}"
-
-
-# текущая цена инструмента
-def _market_price_or_dash(symbol: str | None) -> str:
- if not symbol:
- return "—"
-
- try:
- ticker = ExchangeService().get_price(symbol)
- return f"$ {format_usd_amount(ticker.price)}"
- except Exception:
- return "—"
-
-
-# формат USD
-def _usd_or_dash(value: float | None) -> str:
- if value is None:
- return "—"
- return f"{value:.2f} USD"
-
-
-# формат размера позиции
-def _size_or_dash(value: float | None) -> str:
- if value is None:
- return "—"
- return f"{value:.8f}".rstrip("0").rstrip(".")
-
-
-# формат плеча
-def _leverage_or_dash(value: float | None) -> str:
- if value is None:
- return "—"
- return f"{value:.1f}x"
-
-
-# формат торгового инструмента для UI
-def _format_symbol(symbol: str | None) -> str:
- if not symbol:
- return "—"
-
- base_symbol = symbol.split("_", 1)[0]
- parts = base_symbol.split("/", 1)
-
- if len(parts) == 2:
- return f"{parts[0]} / {parts[1]}"
-
- return base_symbol
-
-
-# стратегия для компактного UI
-def _compact_strategy(strategy: str | None) -> str:
- if not strategy:
- return "—"
- return strategy.upper()
-
-
-# плечо для компактного UI
-def _compact_leverage(value: float | None) -> str:
- if value is None:
- return "—"
- return f"x{value:g}"
-
-
-# проверка, настроена ли автоторговля минимально
-def _is_auto_configured(state) -> bool:
- return bool(
- state.symbol
- and state.strategy
- and state.risk_percent is not None
- )
-
-
-# строка инструмента / стратегии / плеча
-def _context_line(state) -> str:
- symbol = _format_symbol(state.symbol)
- strategy = _compact_strategy(state.strategy)
- leverage = _compact_leverage(state.leverage)
-
- if leverage == "—":
- return f"{symbol} · {strategy}"
-
- return f"{symbol} · {strategy} · {leverage}"
-
-
-# клавиатура автоторговли
-def _auto_keyboard() -> InlineKeyboardMarkup:
- builder = InlineKeyboardBuilder()
-
- builder.button(text="▶️ Start", callback_data="auto:start")
- builder.button(text="👀 Watch", callback_data="auto:observe")
- builder.button(text="🛑 Stop", callback_data="auto:stop")
- builder.button(text="🛠️ Настройки", callback_data="settings:auto")
-
- builder.adjust(3, 1)
- return builder.as_markup()
-
-
-# собрать текст экрана
-def _build_auto_text() -> str:
- service = AutoTradeService()
- state = service.get_state()
-
- account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
- risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—"
- configured = _is_auto_configured(state)
- price = _market_price_or_dash(state.symbol)
-
- status_line = {
- "OFF": "⚪ Off",
- "OBSERVING": "👀 Watch",
- "RUNNING": "🟢 On",
- }.get(state.status, state.status)
-
- header = (
- f"🤖 Автоторговля · {status_line}\n"
- f"🔸 {account_mode} аккаунт\n\n"
- )
-
- if state.status == "OFF":
- if not configured:
- return (
- f"{header}"
- "⚠️ Не настроена\n"
- "Настрой параметры"
- )
-
- return (
- f"{header}"
- f"{_context_line(state)}\n"
- f"Price: {price}\n"
- f"Risk: {risk}"
- )
-
- position_line = (
- f"Pos: {_value_or_dash(state.position_side)} | "
- f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
- )
-
- if state.position_side != "NONE" and state.entry_price is not None:
- position_line = (
- f"Pos: {_value_or_dash(state.position_side)} | "
- f"Entry: $ {_price_or_dash(state.entry_price)} | "
- f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}"
- )
-
- return (
- f"{header}"
- f"{_context_line(state)}\n"
- f"Price: {price}\n\n"
- f"{_signal_label(state.last_signal)} ×{state.last_signal_repeat_count} "
- f"· {state.decision_status}\n\n"
- f"{position_line}\n"
- f"Risk: {risk}"
- )
-
-
-# отрисовать live-экран автоторговли
-async def _render_auto_screen(
- target_message: Message,
- *,
- edit_mode: bool,
-) -> None:
- text = _build_auto_text()
-
- if edit_mode:
- try:
- await target_message.edit_text(text, reply_markup=_auto_keyboard())
- except TelegramBadRequest as exc:
- if "message is not modified" not in str(exc).lower():
- raise
-
- AutoTradeRunner.register_screen(
- bot=target_message.bot,
- chat_id=target_message.chat.id,
- message_id=target_message.message_id,
- render_text=_build_auto_text,
- render_markup=_auto_keyboard,
- )
- return
-
- sent_message = await target_message.answer(text, reply_markup=_auto_keyboard())
-
- AutoTradeRunner.register_screen(
- bot=sent_message.bot,
- chat_id=sent_message.chat.id,
- message_id=sent_message.message_id,
- render_text=_build_auto_text,
- render_markup=_auto_keyboard,
- )
-
-
-# открыть экран из главного меню
-@router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"}))
-async def open_auto(message: Message, state: FSMContext) -> None:
- await state.clear()
-
- AutoTradeRunner.set_current_screen("auto")
-
- current_state = AutoTradeService().get_state()
- if current_state.status in {"RUNNING", "OBSERVING"}:
- await AutoTradeRunner.delete_registered_screen(
- bot=message.bot,
- chat_id=message.chat.id,
- )
-
- await _render_auto_screen(message, edit_mode=False)
-
-
-# открыть экран через callback
-@router.callback_query(F.data == "auto:home")
-async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> None:
- await state.clear()
-
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
- return
-
- AutoTradeRunner.set_current_screen("auto")
- await _render_auto_screen(callback.message, edit_mode=True)
- await callback.answer()
-
-
-# включить активную торговлю
-@router.callback_query(F.data == "auto:start")
-async def auto_start(callback: CallbackQuery) -> None:
- service = AutoTradeService()
- state = service.get_state()
-
- if not _is_auto_configured(state):
- await callback.answer(
- "Сначала настрой параметры автоторговли",
- show_alert=True,
- )
-
- if callback.message is not None:
- await open_auto_settings(callback)
-
- return
-
- _, message = service.start()
-
- AutoTradeRunner.set_current_screen("auto")
- AutoTradeRunner.start()
-
- if callback.message is not None:
- await _render_auto_screen(callback.message, edit_mode=True)
-
- await callback.answer(message)
-
-
-# включить наблюдение
-@router.callback_query(F.data == "auto:observe")
-async def auto_observe(callback: CallbackQuery) -> None:
- service = AutoTradeService()
- state = service.get_state()
-
- if not _is_auto_configured(state):
- await callback.answer(
- "Сначала настрой параметры автоторговли",
- show_alert=True,
- )
-
- if callback.message is not None:
- await open_auto_settings(callback)
-
- return
-
- _, message = service.observe()
-
- AutoTradeRunner.set_current_screen("auto")
- AutoTradeRunner.start()
-
- if callback.message is not None:
- await _render_auto_screen(callback.message, edit_mode=True)
-
- await callback.answer(message)
-
-
-# выключить автоторговлю
-@router.callback_query(F.data == "auto:stop")
-async def auto_stop(callback: CallbackQuery) -> None:
- service = AutoTradeService()
- _, message = service.stop()
-
- AutoTradeRunner.stop()
-
- if callback.message is not None:
- await _render_auto_screen(callback.message, edit_mode=True)
-
- await callback.answer(message)
\ No newline at end of file
diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py
index d20ed4f..7bcdf9f 100644
--- a/app/src/telegram/handlers/auto/main.py
+++ b/app/src/telegram/handlers/auto/main.py
@@ -1,11 +1,11 @@
# app/src/telegram/handlers/auto/main.py
-
+
from __future__ import annotations
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, Message
+from aiogram.types import CallbackQuery, InaccessibleMessage, Message
from src.telegram.handlers.auto.ui import (
auto_diagnostics_keyboard,
@@ -24,6 +24,20 @@ from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
router = Router(name="auto")
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
async def render_auto_screen(
target_message: Message,
*,
@@ -38,8 +52,13 @@ async def render_auto_screen(
if "message is not modified" not in str(exc).lower():
raise
+ bot = target_message.bot
+
+ if bot is None:
+ return
+
AutoTradeRunner.register_screen(
- bot=target_message.bot,
+ bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_text,
@@ -53,9 +72,13 @@ async def render_auto_screen(
return
sent_message = await target_message.answer(text, reply_markup=auto_keyboard())
+ bot = sent_message.bot
+
+ if bot is None:
+ return
AutoTradeRunner.register_screen(
- bot=sent_message.bot,
+ bot=bot,
chat_id=sent_message.chat.id,
message_id=sent_message.message_id,
render_text=build_auto_text,
@@ -68,29 +91,43 @@ async def render_auto_screen(
)
-async def _prepare_auto_from_message(message: Message) -> None:
- await ActiveScreenManager.prepare_new_screen(
- screen="auto",
- bot=message.bot,
- chat_id=message.chat.id,
- )
+async def _prepare_auto_from_message(message: Message) -> bool:
+ bot = message.bot
-
-async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if bot is None:
return False
await ActiveScreenManager.prepare_new_screen(
screen="auto",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
+ bot=bot,
+ chat_id=message.chat.id,
)
return True
+async def _prepare_auto_from_callback(callback: CallbackQuery) -> bool:
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return False
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer("Bot недоступен", show_alert=True)
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="auto",
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
+ )
+
+ return True
+
def build_auto_diagnostics_text() -> str:
service = AutoTradeService()
@@ -118,10 +155,28 @@ async def render_auto_diagnostics_screen(
error_text = str(exc).lower()
if "message to edit not found" in error_text:
- await target_message.answer(
+ sent_message = await target_message.answer(
text,
reply_markup=auto_diagnostics_keyboard(),
)
+
+ bot = sent_message.bot
+
+ if bot is None:
+ return
+
+ AutoTradeRunner.register_screen(
+ bot=bot,
+ chat_id=sent_message.chat.id,
+ message_id=sent_message.message_id,
+ render_text=build_auto_diagnostics_text,
+ render_markup=auto_diagnostics_keyboard,
+ )
+
+ ActiveScreenManager.register(
+ screen="auto_diagnostics",
+ message=sent_message,
+ )
return
if "message is not modified" in error_text:
@@ -129,8 +184,13 @@ async def render_auto_diagnostics_screen(
raise
+ bot = target_message.bot
+
+ if bot is None:
+ return
+
AutoTradeRunner.register_screen(
- bot=target_message.bot,
+ bot=bot,
chat_id=target_message.chat.id,
message_id=target_message.message_id,
render_text=build_auto_diagnostics_text,
@@ -147,7 +207,8 @@ async def render_auto_diagnostics_screen(
async def open_auto(message: Message, state: FSMContext) -> None:
await state.clear()
- await _prepare_auto_from_message(message)
+ if not await _prepare_auto_from_message(message):
+ return
await render_auto_screen(message, edit_mode=False)
@@ -159,7 +220,13 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) ->
if not await _prepare_auto_from_callback(callback):
return
- await render_auto_screen(callback.message, edit_mode=True)
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ await render_auto_screen(message, edit_mode=True)
await callback.answer()
@@ -174,20 +241,22 @@ async def auto_start(callback: CallbackQuery) -> None:
show_alert=True,
)
- if callback.message is not None:
+ if _require_message(callback) is not None:
await open_auto_settings(callback)
return
- _, message = service.start()
+ _, message_text = service.start()
- if callback.message is not None:
- await _prepare_auto_from_callback(callback)
- await render_auto_screen(callback.message, edit_mode=True)
+ if await _prepare_auto_from_callback(callback):
+ message = _require_message(callback)
+
+ if message is not None:
+ await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
- await callback.answer(message)
+ await callback.answer(message_text)
@router.callback_query(F.data == "auto:observe")
@@ -201,48 +270,60 @@ async def auto_observe(callback: CallbackQuery) -> None:
show_alert=True,
)
- if callback.message is not None:
+ if _require_message(callback) is not None:
await open_auto_settings(callback)
return
- _, message = service.observe()
+ _, message_text = service.observe()
- if callback.message is not None:
- await _prepare_auto_from_callback(callback)
- await render_auto_screen(callback.message, edit_mode=True)
+ if await _prepare_auto_from_callback(callback):
+ message = _require_message(callback)
+
+ if message is not None:
+ await render_auto_screen(message, edit_mode=True)
AutoTradeRunner.start()
- await callback.answer(message)
+ await callback.answer(message_text)
@router.callback_query(F.data == "auto:stop")
async def auto_stop(callback: CallbackQuery) -> None:
service = AutoTradeService()
- _, message = service.stop()
+ _, message_text = service.stop()
AutoTradeRunner.stop()
- if callback.message is not None:
- await _prepare_auto_from_callback(callback)
- await render_auto_screen(callback.message, edit_mode=True)
+ if await _prepare_auto_from_callback(callback):
+ message = _require_message(callback)
- await callback.answer(message)
+ if message is not None:
+ await render_auto_screen(message, edit_mode=True)
+
+ await callback.answer(message_text)
@router.callback_query(F.data == "auto:diagnostics")
async def open_auto_diagnostics(callback: CallbackQuery) -> None:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer("Bot недоступен", show_alert=True)
return
await ActiveScreenManager.prepare_new_screen(
screen="auto_diagnostics",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
)
- await render_auto_diagnostics_screen(callback.message)
+ await render_auto_diagnostics_screen(message)
await callback.answer()
\ No newline at end of file
diff --git a/app/src/telegram/handlers/auto/risk.py b/app/src/telegram/handlers/auto/risk.py
index e31ed3a..2352bdd 100644
--- a/app/src/telegram/handlers/auto/risk.py
+++ b/app/src/telegram/handlers/auto/risk.py
@@ -7,9 +7,16 @@ import asyncio
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
-from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.types import (
+ CallbackQuery,
+ InlineKeyboardMarkup,
+ Message,
+ InaccessibleMessage,
+)
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
from src.trading.auto.runner import AutoTradeRunner
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
@@ -18,17 +25,31 @@ from src.trading.journal.service import JournalService
router = Router(name="auto_risk")
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
class AutoRiskStates(StatesGroup):
waiting_stop_loss = State()
waiting_take_profit = State()
waiting_max_loss = State()
-def _format_number(value: float | int | None) -> str:
- if value is None:
- return "—"
+def _format_number(value: NumericLike | None) -> str:
+ number = safe_float(value)
- number = float(value)
+ if number is None:
+ return "—"
if abs(number - round(number)) < 1e-9:
return f"{int(round(number))}"
@@ -36,20 +57,26 @@ def _format_number(value: float | int | None) -> str:
return f"{number:.2f}".rstrip("0").rstrip(".")
-def _format_percent(value: float | None) -> str:
- if value is None:
+def _format_percent(value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "off"
- return f"{_format_number(value)}%"
+
+ return f"{_format_number(number)}%"
-def _format_usd(value: float | None) -> str:
- if value is None:
+def _format_usd(value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "off"
- return f"{_format_number(value)} USD"
+
+ return f"{_format_number(number)} USD"
-def _rule_icon(value: float | None) -> str:
- return "✅" if value is not None else "⚠️"
+def _rule_icon(value: NumericLike | None) -> str:
+ return "✅" if safe_float(value) is not None else "⚠️"
def _risk_keyboard() -> InlineKeyboardMarkup:
@@ -96,19 +123,27 @@ def _risk_text(status_message: str | None = None) -> str:
return text
-async def _render_risk_screen(callback: CallbackQuery) -> None:
+async def _render_risk_screen(
+ callback: CallbackQuery,
+) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
return
_unregister_auto_screen_message(callback)
- await callback.message.edit_text(
+ await message.edit_text(
_risk_text(),
reply_markup=_risk_keyboard(),
)
+
await callback.answer()
@@ -121,18 +156,34 @@ async def _render_risk_screen_by_message(
) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
- data = await state.get_data()
- chat_id = data.get("risk_chat_id")
- message_id = data.get("risk_message_id")
+ bot = message.bot
- if chat_id is None or message_id is None:
+ if bot is None:
+ return
+
+ data: JsonDict = await state.get_data()
+
+ raw_chat_id = data.get("risk_chat_id")
+ raw_message_id = data.get("risk_message_id")
+
+ if not isinstance(raw_chat_id, int):
await message.answer(
_risk_text(status_message=status_message),
reply_markup=_risk_keyboard(),
)
return
- await message.bot.edit_message_text(
+ if not isinstance(raw_message_id, int):
+ await message.answer(
+ _risk_text(status_message=status_message),
+ reply_markup=_risk_keyboard(),
+ )
+ return
+
+ chat_id = raw_chat_id
+ message_id = raw_message_id
+
+ await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=_risk_text(status_message=status_message),
@@ -146,7 +197,7 @@ async def _render_risk_screen_by_message(
return
try:
- await message.bot.edit_message_text(
+ await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=_risk_text(),
@@ -156,33 +207,56 @@ async def _render_risk_screen_by_message(
pass
-async def _remember_risk_screen(callback: CallbackQuery, state: FSMContext) -> None:
- if callback.message is None:
+async def _remember_risk_screen(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
+ message = _require_message(callback)
+
+ if message is None:
return
await state.update_data(
- risk_chat_id=callback.message.chat.id,
- risk_message_id=callback.message.message_id,
+ risk_chat_id=message.chat.id,
+ risk_message_id=message.message_id,
)
-def _unregister_auto_screen_message(callback: CallbackQuery) -> None:
- if callback.message is None:
+def _unregister_auto_screen_message(
+ callback: CallbackQuery,
+) -> None:
+ message = _require_message(callback)
+
+ if message is None:
return
AutoTradeRunner.unregister_screen(
- chat_id=callback.message.chat.id,
- message_id=callback.message.message_id,
+ chat_id=message.chat.id,
+ message_id=message.message_id,
)
-def _parse_positive_or_none(raw_text: str | None) -> float | None:
+def _risk_payload(**values: object) -> JsonDict:
+ return dict(values)
+
+
+def _parse_positive_or_none(
+ raw_text: str | None,
+) -> float | None:
value_text = (raw_text or "").strip().replace(",", ".")
- if value_text.lower() in {"0", "0.0", "off", "-"}:
+ if value_text.lower() in {
+ "0",
+ "0.0",
+ "off",
+ "-",
+ }:
return None
- value = float(value_text)
+ value = safe_float(value_text)
+
+ if value is None:
+ raise ValueError
if value <= 0:
return None
@@ -190,16 +264,26 @@ def _parse_positive_or_none(raw_text: str | None) -> float | None:
return value
-def _validate_percent(value: float | None) -> bool:
- if value is None:
+def _validate_percent(
+ value: NumericLike | None,
+) -> bool:
+ number = safe_float(value)
+
+ if number is None:
return True
- return 0 < value <= 100
+
+ return 0 < number <= 100
-def _validate_max_loss(value: float | None) -> bool:
- if value is None:
+def _validate_max_loss(
+ value: NumericLike | None,
+) -> bool:
+ number = safe_float(value)
+
+ if number is None:
return True
- return 0 < value <= 10000
+
+ return 0 < number <= 10000
def _log_risk_updated(action: str) -> None:
@@ -216,11 +300,11 @@ def _log_risk_updated(action: str) -> None:
),
screen="auto",
action=action,
- payload={
- "stop_loss_percent": state.stop_loss_percent,
- "take_profit_percent": state.take_profit_percent,
- "max_loss_usd": state.max_loss_usd,
- },
+ payload=_risk_payload(
+ stop_loss_percent=state.stop_loss_percent,
+ take_profit_percent=state.take_profit_percent,
+ max_loss_usd=state.max_loss_usd,
+ ),
)
except Exception:
pass
@@ -242,17 +326,26 @@ async def open_auto_risk_from_settings(callback: CallbackQuery, state: FSMContex
async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
+
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return
+
await state.set_state(AutoRiskStates.waiting_stop_loss)
await _remember_risk_screen(callback, state)
- if callback.message is not None:
- await callback.message.edit_text(
- "Stop Loss\n\n"
- "СИСТЕМА · Настройки · Автоторговля\n\n"
- "Введите Stop Loss в процентах.\n"
- "Например: 1, 0.5, 0,5\n\n"
- "отключить параметр - 0"
- )
+ await message.edit_text(
+ "Stop Loss\n\n"
+ "СИСТЕМА · Настройки · Автоторговля\n\n"
+ "Введите Stop Loss в процентах.\n"
+ "Например: 1, 0.5, 0,5\n\n"
+ "отключить параметр - 0"
+ )
await callback.answer()
@@ -261,17 +354,26 @@ async def ask_stop_loss(callback: CallbackQuery, state: FSMContext) -> None:
async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
+
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return
+
await state.set_state(AutoRiskStates.waiting_take_profit)
await _remember_risk_screen(callback, state)
- if callback.message is not None:
- await callback.message.edit_text(
- "Take Profit\n\n"
- "СИСТЕМА · Настройки · Автоторговля\n\n"
- "Введите Take Profit в процентах.\n"
- "Например: 2, 1.5, 1,5\n\n"
- "отключить параметр - 0"
- )
+ await message.edit_text(
+ "Take Profit\n\n"
+ "СИСТЕМА · Настройки · Автоторговля\n\n"
+ "Введите Take Profit в процентах.\n"
+ "Например: 2, 1.5, 1,5\n\n"
+ "отключить параметр - 0"
+ )
await callback.answer()
@@ -280,17 +382,26 @@ async def ask_take_profit(callback: CallbackQuery, state: FSMContext) -> None:
async def ask_max_loss(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
+
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return
+
await state.set_state(AutoRiskStates.waiting_max_loss)
await _remember_risk_screen(callback, state)
- if callback.message is not None:
- await callback.message.edit_text(
- "Maximum Loss\n\n"
- "СИСТЕМА · Настройки · Автоторговля\n\n"
- "Введите максимальный paper-убыток в USD.\n"
- "Например: 100, 50.5, 50,5\n\n"
- "отключить параметр - 0"
- )
+ await message.edit_text(
+ "Maximum Loss\n\n"
+ "СИСТЕМА · Настройки · Автоторговля\n\n"
+ "Введите максимальный paper-убыток в USD.\n"
+ "Например: 100, 50.5, 50,5\n\n"
+ "отключить параметр - 0"
+ )
await callback.answer()
@@ -301,6 +412,12 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
AutoTradeRunner.set_current_screen("auto_risk")
_unregister_auto_screen_message(callback)
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
service = AutoTradeService()
service.set_stop_loss_percent(None)
service.set_take_profit_percent(None)
@@ -308,29 +425,26 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None:
_log_risk_updated("risk_reset")
- if callback.message is not None:
- await callback.message.edit_text(
- _risk_text(status_message="✅ Risk Controls сброшены"),
- reply_markup=_risk_keyboard(),
- )
- await callback.answer()
-
- await asyncio.sleep(2.5)
-
- if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
- return
-
- try:
- await callback.message.edit_text(
- _risk_text(),
- reply_markup=_risk_keyboard(),
- )
- except Exception:
- pass
- return
+ await message.edit_text(
+ _risk_text(status_message="✅ Risk Controls сброшены"),
+ reply_markup=_risk_keyboard(),
+ )
await callback.answer()
+ await asyncio.sleep(2.5)
+
+ if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk":
+ return
+
+ try:
+ await message.edit_text(
+ _risk_text(),
+ reply_markup=_risk_keyboard(),
+ )
+ except Exception:
+ pass
+
@router.message(AutoRiskStates.waiting_stop_loss)
async def set_stop_loss(message: Message, state: FSMContext) -> None:
diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py
index 2f7ca5e..af2df4c 100644
--- a/app/src/telegram/handlers/auto/ui.py
+++ b/app/src/telegram/handlers/auto/ui.py
@@ -12,6 +12,25 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.service import ExchangeService
from src.telegram.ui.common import mode_line
from src.trading.auto.service import AutoTradeService
+from src.core.numbers import safe_float
+
+
+def build_auto_notification_text() -> str:
+ state = AutoTradeService().get_state()
+
+ 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)
+
+ parts = [
+ "Автоторговля",
+ _cycle_number_text(state),
+ ]
+
+ if cycle_trades > 0:
+ parts.append(f"{cycle_trades} {_trade_word(cycle_trades)}")
+ parts.append(_format_pnl_line(cycle_pnl))
+
+ return " · ".join(parts)
def auto_keyboard() -> InlineKeyboardMarkup:
@@ -142,132 +161,41 @@ def _build_not_configured_text(state) -> str:
def _build_stopped_without_position_text(state) -> str:
- price = _current_price(state.symbol)
-
available = _allocated_balance(state) + _realized_pnl(state)
- estimated_size = _estimated_size(state, price)
-
- rr_line = _risk_reward_line(state)
- risk_line = _risk_summary_line(
- state,
- estimated_size,
- entry_price_override=price,
- )
parts = [
- f"🤖 Автоторговля {_status_text(state.status)}",
+ "⚪️ Автоторговля остановлена",
_account_mode_line(),
"",
f"Доступно 💰 {_format_money_compact(available)}",
"",
- "Подготовка ордера 🧾",
- _order_header_line(state),
- f"Цена · {_format_plain_or_dash(price)}",
- _estimated_size_text(state, price),
- _max_reserved_line(state, price),
- _effective_risk_line(state),
+ "Настройки 🛠️",
+ f"Стратегия · {_strategy_short(state.strategy)}",
+ f"Актив · {_asset_symbol(state.symbol)}",
+ f"Плечо · {_leverage_text(state.leverage)}",
+ (
+ f"Лимит · "
+ f"{_format_percent(state.max_reserved_balance_percent)}"
+ ),
+ f"Риск · {_format_percent(state.risk_percent)}",
+ "",
+ _settings_risk_percent_line(state),
]
- if rr_line or risk_line:
- parts.append("")
-
- if rr_line:
- parts.append(rr_line)
-
- if risk_line:
- parts.append(risk_line)
-
return "\n".join(parts)
-def _execution_semantic_line(state) -> str:
- message = getattr(state, "execution_semantic_message", None)
+def _settings_risk_percent_line(state) -> str:
+ sl = _format_percent(state.stop_loss_percent)
+ tp = _format_percent(state.take_profit_percent)
- if not message:
- return ""
+ ml = (
+ f"-{_format_money_compact(abs(state.max_loss_usd))}"
+ if state.max_loss_usd is not None
+ else "off"
+ )
- return str(message)
-
-
-def _execution_confidence_line(state) -> str:
- signal = (state.last_signal or "HOLD").upper()
-
- if signal not in {"BUY", "SELL"}:
- return ""
-
- score = getattr(state, "execution_confidence_score", None)
- level = getattr(state, "execution_confidence_level", None)
-
- if score is None:
- return ""
-
- percent = int(round(float(score) * 100))
-
- if level == "HIGH":
- return f"🧠 Уверенность входа · {percent}% высокая"
-
- if level == "NORMAL":
- return f"🧠 Уверенность входа · {percent}% нормальная"
-
- return f"🧠 Уверенность входа · {percent}% низкая"
-
-
-def _momentum_semantic_line(state) -> str:
- momentum_state = getattr(state, "momentum_state", None)
- momentum_direction = getattr(state, "momentum_direction", None)
- momentum_strength = getattr(state, "momentum_strength", None)
-
- if momentum_state in {None, "NONE", "UNKNOWN"}:
- return ""
-
- strength_text = ""
-
- if momentum_strength is not None:
- strength_text = f" · x{float(momentum_strength):.1f}"
-
- if momentum_state == "BREAKOUT_UP":
- return f"🚀 Импульс · пробой вверх{strength_text}"
-
- if momentum_state == "BREAKOUT_DOWN":
- return f"💥 Импульс · пробой вниз{strength_text}"
-
- if momentum_state == "MOMENTUM_UP":
- return f"⚡️ Импульс · вверх{strength_text}"
-
- if momentum_state == "MOMENTUM_DOWN":
- return f"⚡️ Импульс · вниз{strength_text}"
-
- if momentum_direction == "UP":
- return f"⚡️ Импульс · вверх{strength_text}"
-
- if momentum_direction == "DOWN":
- return f"⚡️ Импульс · вниз{strength_text}"
-
- return ""
-
-
-def _adaptive_size_line(state) -> str:
- multiplier = getattr(state, "adaptive_size_multiplier", None)
-
- if multiplier is None:
- return ""
-
- percent = int(round(float(multiplier) * 100))
- reason = getattr(state, "adaptive_size_reason", None)
-
- if multiplier <= 0:
- return "🧮 Размер · вход заблокирован"
-
- if multiplier < 1:
- return f"🧮 Размер · {percent}% адаптивно уменьшен"
-
- if multiplier > 1:
- return f"🧮 Размер · {percent}% адаптивно увеличен"
-
- if multiplier == 1:
- return "🧮 Размер · без корректировки"
-
- return ""
+ return f"SL {sl} · TP {tp} · ML {ml}"
def _build_waiting_text(state) -> str:
@@ -276,57 +204,78 @@ def _build_waiting_text(state) -> str:
available = _allocated_balance(state) + _realized_pnl(state)
estimated_size = _estimated_size(state, price)
- rr_line = _risk_reward_line(state)
- risk_line = _risk_summary_line(
- state,
- estimated_size,
- entry_price_override=price,
+ 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
)
- signal_lines = [
- _signal_line(state),
- _signal_confirmation_line(state),
- _execution_confidence_line(state),
- _market_semantic_line(state),
- _momentum_semantic_line(state),
- _entry_block_line(state),
- _execution_semantic_line(state),
- *_signal_confidence_lines(state),
- ]
-
- signal_lines = [line for line in signal_lines if line]
-
parts = [
- f"🤖 Автоторговля {_status_text(state.status)}",
+ (
+ f"{_status_text(state)}"
+ ),
_account_mode_line(),
"",
f"Доступно 💰 {_format_money_compact(available)}",
]
- if signal_lines:
- parts.extend(["", *signal_lines])
+ if cycle_trades > 0:
+ parts.extend([
+ "",
+ (
+ f"🔄 {_cycle_number_text(state)} · "
+ f"{cycle_trades} {_trade_word(cycle_trades)}"
+ ),
+ _format_pnl_line(cycle_pnl),
+ ])
- order_lines = [
- "Подготовка ордера 🧾",
+ winrate_line = _cycle_winrate_line(state, cycle_pnl, cycle_trades)
+ if winrate_line:
+ parts.append(winrate_line)
+
+ parts.extend([
+ "",
+ _signal_line(state),
+ "",
+ ])
+
+ block_title = (
+ "Прогноз сделки 🔮"
+ if state.status == "OBSERVING"
+ else "Подготовка ордера 🧾"
+ )
+
+ parts.extend([
+ block_title,
_order_header_line(state),
- f"{_price_label_for_signal(state)} · {_format_plain_or_dash(price)}",
+ f"Цена · {_format_plain_or_dash(price)}",
_estimated_size_text(state, price),
_max_reserved_line(state, price),
_effective_risk_line(state),
- ]
+ _risk_summary_line(
+ state,
+ estimated_size,
+ entry_price_override=price,
+ ),
+ ])
- order_lines = [line for line in order_lines if line]
+ adjustment_visible = _adaptive_adjustment_visible(state)
- parts.extend(["", *order_lines])
+ if adjustment_visible:
+ reason = getattr(state, "adaptive_size_reason", "") or ""
- if rr_line or risk_line:
- parts.append("")
+ multiplier = float(
+ getattr(state, "adaptive_size_multiplier", 1.0) or 1.0
+ )
- if rr_line:
- parts.append(rr_line)
-
- if risk_line:
- parts.append(risk_line)
+ parts.extend([
+ "",
+ (
+ f"Коррекция ⚠️ "
+ f"x{multiplier:.2f} "
+ f"{_short_adaptive_reason(reason, multiplier)}"
+ ),
+ ])
return "\n".join(parts)
@@ -337,162 +286,133 @@ def _build_active_position_text(state) -> str:
size = state.position_size or 0.0
notional = size * price_for_calc
+
reserved = _position_reserved_usd(state, current_price)
- available = _allocated_balance(state) + _realized_pnl(state) - reserved
+
+ available = (
+ _allocated_balance(state)
+ + _realized_pnl(state)
+ - reserved
+ )
+
pnl = state.unrealized_pnl_usd or 0.0
- rr_line = _risk_reward_line(state)
- risk_line = _risk_summary_line(state, size)
+ cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0)
- side_icon = "🟢" if state.position_side == "LONG" else "🔴"
+ cycle_pnl = float(
+ getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0
+ )
- market_lines = [
- _market_semantic_line(state),
- _momentum_semantic_line(state),
- _execution_semantic_line(state),
- ]
- market_lines = [line for line in market_lines if line]
+ side_icon = (
+ "🟢"
+ if state.position_side == "LONG"
+ else "🔴"
+ )
+
+ adaptive_warning = (
+ " ⚠️"
+ if _adaptive_size_active(state) and not _show_adaptive_banner(state)
+ else ""
+ )
parts = [
- f"🤖 Автоторговля {_status_text(state.status)}",
+ _status_text(state),
_account_mode_line(),
"",
f"Доступно 💰 {_format_money_compact(available)}",
- f"Маржа · {_format_money_compact(reserved)}",
- f"P&L {_format_signed_plain_with_direction(pnl)}",
+ f"Маржа · {_format_usd_compact(reserved)}",
]
- if market_lines:
- parts.extend(["", *market_lines])
+ 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)
+
+ separator = " " if adaptive_warning else " · "
parts.extend([
"",
(
- f"{side_icon} {_asset_symbol(state.symbol)} · "
+ f"{side_icon} "
+ f"{_asset_symbol(state.symbol)} · "
f"{_strategy_short(state.strategy)} · "
- f"{state.position_side} {_leverage_text(state.leverage)}"
+ f"{_position_side_text(state.position_side)} "
+ f"{_leverage_text(state.leverage)}"
),
- "",
- f"Размер · {_format_crypto_size(size)}",
- f"Позиция · {_format_money_compact(notional)}",
f"Вход · {_format_plain_or_dash(state.entry_price)}",
f"Цена · {_format_plain_or_dash(price_for_calc)}",
- "",
- "⚠️ Комиссии не учтены",
+ (
+ f"Размер{adaptive_warning}{separator}"
+ f"{_format_crypto_size(size)}"
+ ),
+ f"Объём · {_format_usd_compact(notional)}",
+ _format_pnl_line(pnl),
+ _risk_summary_line(state, size),
])
- if rr_line or risk_line:
- parts.append("")
+ if pnl < 0:
+ reason_block = _position_warning_reason(state)
- if rr_line:
- parts.append(rr_line)
+ if reason_block:
+ parts.extend([
+ "",
+ reason_block,
+ ])
- if risk_line:
- parts.append(risk_line)
+ if _show_flip_banner(state):
+ old_side = state.last_flip_old_side or "SHORT"
+ new_side = state.last_flip_new_side or "LONG"
+
+ old_icon = "🟢" if old_side == "LONG" else "🔴"
+ new_icon = "🟢" if new_side == "LONG" else "🔴"
+
+ parts.extend([
+ "",
+ "Позиция развернута 🔄",
+ f"{old_icon} {old_side} → {new_icon} {new_side}",
+ ])
+
+ if _adaptive_adjustment_visible(state):
+ reason = getattr(state, "adaptive_size_reason", "") or ""
+
+ multiplier = float(
+ getattr(state, "adaptive_size_multiplier", 1.0) or 1.0
+ )
+
+ parts.extend([
+ "",
+ f"Коррекция ⚠️ x{multiplier:.2f}",
+ _short_adaptive_reason(reason, multiplier),
+ ])
return "\n".join(parts)
-def _market_semantic_line(state) -> str:
- market_state = getattr(state, "market_state", None)
- trend = getattr(state, "market_trend", None)
- strength = getattr(state, "market_trend_strength", None)
- quality = getattr(state, "market_trend_quality", None)
- phase = getattr(state, "market_phase", None)
- phase_direction = getattr(state, "market_phase_direction", None)
-
- if market_state in {None, "UNKNOWN"}:
- return "⏳ Рынок · анализ"
-
- if market_state == "HIGH_VOLATILITY":
- return "⚠️ Рынок · перегрев"
-
- if market_state == "LOW_VOLATILITY" or phase == "SQUEEZE":
- return "🟦 Рынок · сжатие"
-
- if market_state == "RANGE" or phase == "RANGE":
- return "🟰 Рынок · флэт"
-
- if phase == "PULLBACK":
- if trend == "UP" and phase_direction == "DOWN":
- return "↘️ Рынок · коррекция"
-
- if trend == "DOWN" and phase_direction == "UP":
- return "↗️ Рынок · откат вверх"
-
- if trend == "UP":
- return "📈 Рынок · рост"
-
- if trend == "DOWN":
- return "📉 Рынок · снижение"
-
- return "↔️ Рынок · откат"
-
- if quality == "NOISY":
- if trend == "UP":
- return "⚠️ Рынок · шумный рост"
-
- if trend == "DOWN":
- return "⚠️ Рынок · шумное снижение"
-
- return "⚠️ Рынок · шум"
-
- if strength == "WEAK":
- if trend == "UP":
- return "🟡 Рынок · слабый рост"
-
- if trend == "DOWN":
- return "🟡 Рынок · слабое снижение"
-
- return "🟡 Рынок · слабое движение"
-
- if phase == "IMPULSE":
- if trend == "UP" and strength == "STRONG":
- return "⚡️ Рынок · сильный рост"
-
- if trend == "DOWN" and strength == "STRONG":
- return "⚡️ Рынок · сильное снижение"
-
- if trend == "UP":
- return "📈 Рынок · рост"
-
- if trend == "DOWN":
- return "📉 Рынок · снижение"
-
- if trend == "UP":
- return "📈 Рынок · рост"
-
- if trend == "DOWN":
- return "📉 Рынок · снижение"
-
- return "⏳ Рынок · анализ"
-
-
def _compact_entry_block_message(message: str) -> str:
normalized = message.strip().lower()
mapping = {
- "рынок сейчас не подходит для входа": "слабый импульс",
- "слабый импульс вверх": "слабый импульс",
- "слабый импульс вниз": "слабый импульс",
- "недостаточно live-данных": "мало данных",
- "мало live-данных": "мало данных",
- "высокая волатильность": "волатильность",
- "низкая активность": "низкая активность",
+ "trend есть, но движение шумное": "Шумное движение по тренду",
+ "рынок сейчас не подходит для входа": "Слабый импульс",
+ "слабый импульс вверх": "Слабый импульс вверх",
+ "слабый импульс вниз": "Слабый импульс вниз",
+ "недостаточно live-данных": "Мало данных",
+ "мало live-данных": "Мало данных",
+ "высокая волатильность": "Высокая волатильность",
+ "низкая активность": "Низкая активность",
}
- return mapping.get(normalized, message)
-
-
-def _entry_block_line(state) -> str:
- message = getattr(state, "entry_block_message", None)
-
- if not message:
- return ""
-
- compact_message = _compact_entry_block_message(str(message))
-
- return f"🧩 Фильтр · {compact_message}"
+ result = mapping.get(normalized, message)
+ return result.strip().rstrip(".")
def _allocated_balance(state) -> float:
@@ -533,7 +453,7 @@ def _max_reserved_line(state, price: float | None = None) -> str:
position_size_usd = size * price
own_funds_usd = position_size_usd / leverage
- return f"Маржа · {_format_money_compact(own_funds_usd)}"
+ return f"Маржа · {_format_usd_compact(own_funds_usd)}"
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
@@ -552,7 +472,11 @@ def _current_price(symbol: str | None) -> float | None:
if snapshot is not None:
price = snapshot.get("last_price")
if price is not None:
- return float(price)
+ try:
+ parsed = safe_float(price)
+ return parsed
+ except (TypeError, ValueError):
+ return None
if not symbol:
return None
@@ -581,7 +505,11 @@ def _signal_entry_price(state) -> float | None:
if price is None:
return None
- return float(price)
+ try:
+ parsed = safe_float(price)
+ return parsed
+ except (TypeError, ValueError):
+ return None
def _target_risk_usd(state) -> float:
@@ -595,9 +523,9 @@ def _effective_risk_line(state) -> str:
effective_risk_usd = getattr(state, "effective_target_risk_usd", None)
if effective_risk_usd is not None:
- return f"Риск · {_format_money_compact(effective_risk_usd)}"
+ return f"Риск · {_format_usd_compact(effective_risk_usd)}"
- return f"Риск · {_format_money_compact(_target_risk_usd(state))}"
+ return f"Риск · {_format_usd_compact(_target_risk_usd(state))}"
def _estimated_size(state, price: float | None) -> float | None:
@@ -642,14 +570,22 @@ def _estimated_size(state, price: float | None) -> float | None:
def _estimated_size_text(state, price: float | None) -> str:
size = _estimated_size(state, price)
+
if size is None or price is None:
- return "Размер · —\nПозиция · —"
+ return "Размер · —\nОбъём · —"
notional = size * price
+ size_label = "Размер"
+
+ if _adaptive_size_active(state) and not _show_adaptive_banner(state):
+ size_label = "Размер ⚠️"
+
+ separator = " " if "⚠️" in size_label else " · "
+
return (
- f"Размер · {_format_crypto_size(size)}\n"
- f"Позиция · {_format_money_compact(notional)}"
+ f"{size_label}{separator}{_format_crypto_size(size)}\n"
+ f"Объём · {_format_usd_compact(notional)}"
)
@@ -661,84 +597,118 @@ def _risk_summary_line(
) -> str:
entry_price = entry_price_override or state.entry_price
- sl = _risk_loss_text(
- percent=state.stop_loss_percent,
- fixed_loss=None,
- size=size,
- entry_price=entry_price,
+ effective_risk = getattr(
+ state,
+ "effective_target_risk_usd",
+ None,
)
- tp = _risk_profit_text(
+
+ sl_value = (
+ abs(float(effective_risk))
+ if effective_risk is not None and effective_risk > 0
+ else _risk_loss_value(
+ percent=state.stop_loss_percent,
+ fixed_loss=None,
+ size=size,
+ entry_price=entry_price,
+ )
+ )
+
+ tp_value = _risk_profit_value(
percent=state.take_profit_percent,
size=size,
entry_price=entry_price,
)
- ml = _risk_loss_text(
+
+ ml_value = _risk_loss_value(
percent=None,
fixed_loss=state.max_loss_usd,
size=size,
entry_price=entry_price,
)
- items = [
- f"SL {sl}" if sl else "SL off",
- f"TP {tp}" if tp else "TP off",
- f"ML {ml}" if ml else "ML off",
- ]
+ enabled: list[tuple[str, float]] = []
- return " | ".join(items)
+ if sl_value is not None:
+ enabled.append(("SL", sl_value))
+
+ if tp_value is not None:
+ enabled.append(("TP", tp_value))
+
+ if ml_value is not None:
+ enabled.append(("ML", ml_value))
+
+ if len(enabled) == 1:
+ key, value = enabled[0]
+
+ if key == "SL":
+ return f"Stop Loss -{_format_usd_compact(value)}"
+
+ if key == "TP":
+ return f"Take Profit +{_format_usd_compact(value)}"
+
+ if key == "ML":
+ return f"Max Loss -{_format_usd_compact(value)}"
+
+ items: list[str] = []
+
+ if sl_value is not None:
+ items.append(f"SL -{_format_usd_compact(sl_value)}")
+
+ if tp_value is not None:
+ items.append(f"TP +{_format_usd_compact(tp_value)}")
+
+ if ml_value is not None:
+ items.append(f"ML -{_format_usd_compact(ml_value)}")
+
+ if not items:
+ return "SL off · TP off · ML off"
+
+ return " · ".join(items)
-def _risk_loss_text(
+def _risk_loss_value(
*,
percent: float | None,
fixed_loss: float | None,
size: float | None,
entry_price: float | None,
-) -> str:
+) -> float | None:
if fixed_loss is not None:
- return f"-{_format_money_compact(abs(fixed_loss))}"
+ return abs(float(fixed_loss))
if percent is None:
- return ""
+ return None
- if size is None or size <= 0 or entry_price is None or entry_price <= 0:
- return ""
+ if size is None or size <= 0:
+ return None
+
+ if entry_price is None or entry_price <= 0:
+ return None
move = entry_price * (percent / 100)
- loss = move * size
- return f"-{_format_money_compact(loss)}"
+ return abs(move * size)
-def _risk_profit_text(
+def _risk_profit_value(
*,
percent: float | None,
size: float | None,
entry_price: float | None,
-) -> str:
+) -> float | None:
if percent is None:
- return ""
+ return None
- if size is None or size <= 0 or entry_price is None or entry_price <= 0:
- return ""
+ if size is None or size <= 0:
+ return None
+
+ if entry_price is None or entry_price <= 0:
+ return None
move = entry_price * (percent / 100)
- profit = move * size
- return f"+{_format_money_compact(profit)}"
-
-
-def _risk_reward_line(state) -> str:
- if (
- state.stop_loss_percent is None
- or state.stop_loss_percent <= 0
- or state.take_profit_percent is None
- or state.take_profit_percent <= 0
- ):
- return ""
-
- ratio = state.take_profit_percent / state.stop_loss_percent
- return f"R:R = 1 : {_format_ratio_value(ratio)}"
+ return abs(move * size)
def _order_header_line(state) -> str:
@@ -748,14 +718,14 @@ def _order_header_line(state) -> str:
return (
f"🟢 {_asset_symbol(state.symbol)} · "
f"{_strategy_short(state.strategy)} · "
- f"LONG {_leverage_text(state.leverage)}"
+ f"Long {_leverage_text(state.leverage)}"
)
if signal == "SELL":
return (
f"🔴 {_asset_symbol(state.symbol)} · "
f"{_strategy_short(state.strategy)} · "
- f"SHORT {_leverage_text(state.leverage)}"
+ f"Short {_leverage_text(state.leverage)}"
)
return (
@@ -765,19 +735,23 @@ def _order_header_line(state) -> str:
)
-def _price_label_for_signal(state) -> str:
- signal = (state.last_signal or "").upper()
+def _signal_text(signal: str) -> str:
+ mapping = {
+ "BUY": "Long",
+ "SELL": "Short",
+ "HOLD": "Hold",
+ }
- if signal in {"BUY", "SELL"}:
- return "Вход"
-
- return "Цена"
+ return mapping.get(signal.upper(), signal.title())
def _signal_line(state) -> str:
signal = (state.last_signal or "HOLD").upper()
- signal_text = f"Сигнал {_signal_icon(signal)} {signal}"
+ signal_text = (
+ f"Сигнал {_signal_icon(signal)} "
+ f"{_signal_text(signal)}"
+ )
if signal in {"BUY", "SELL"} and (
state.decision_status == "READY"
@@ -790,39 +764,63 @@ def _signal_line(state) -> str:
return f"{signal_text} · {duration}"
-def _signal_confirmation_line(state) -> str:
+def _position_warning_reason(state) -> str:
signal = (state.last_signal or "HOLD").upper()
- if signal not in {"BUY", "SELL"}:
- return ""
-
- status = getattr(state, "decision_status", None)
-
- seconds = int(getattr(state, "signal_confirmation_seconds", 0) or 0)
- required_seconds = int(
- getattr(state, "signal_confirmation_required_seconds", 10) or 10
+ confidence = float(
+ getattr(state, "last_signal_confidence", 0.0) or 0.0
)
- repeats = int(getattr(state, "last_signal_repeat_count", 0) or 0)
- missing_repeats = int(
- getattr(state, "signal_confirmation_missing_repeats", 0) or 0
- )
- required_repeats = repeats + missing_repeats
+ reason = str(
+ getattr(state, "last_signal_reason", "") or ""
+ ).strip()
- if status == "READY" or getattr(state, "is_signal_ready", False):
- return "✅ Подтверждение · готово"
+ lines: list[str] = []
- if status == "BLOCKED":
- return "⛔ Подтверждение · заблокировано"
+ if signal == "BUY":
+ lines.append("Сигнал 🟢 Long")
- if status == "CONFIRMING":
- return (
- f"⏳ Подтверждение · "
- f"{repeats}/{required_repeats} · "
- f"{seconds}/{required_seconds}с"
+ elif signal == "SELL":
+ lines.append("Сигнал 🔴 Short")
+
+ if signal in {"BUY", "SELL"}:
+ filled = min(3, max(0, round(confidence * 3)))
+
+ bar = (
+ "●" * filled
+ + "○" * (3 - filled)
)
- return ""
+ if confidence >= 0.8:
+ level = "Сильный"
+ elif confidence >= 0.5:
+ level = "Средний"
+ else:
+ level = "Слабый"
+
+ lines.append(
+ f"{bar} {level} · {confidence:.2f}"
+ )
+
+ reason_upper = reason.upper()
+ compact_reason = _compact_entry_block_message(reason)
+
+ if "BREAKOUT_UP" in reason_upper:
+ lines.append("Пробой вверх")
+
+ elif "BREAKOUT_DOWN" in reason_upper:
+ lines.append("Пробой вниз")
+
+ elif "TREND_UP" in reason_upper:
+ lines.append("Рынок растёт")
+
+ elif "TREND_DOWN" in reason_upper:
+ lines.append("Рынок снижается")
+
+ elif compact_reason:
+ lines.append(compact_reason)
+
+ return "\n".join(lines)
def _signal_duration_text(state) -> str:
@@ -847,20 +845,75 @@ def _signal_duration_text(state) -> str:
return f"{seconds}с"
-def _format_ratio_value(value: float) -> str:
- if abs(value - round(value)) < 1e-9:
- return str(int(round(value)))
+def _status_text(state) -> str:
+ runtime = _cycle_runtime_text(state)
- return f"{value:.2f}".rstrip("0").rstrip(".")
+ status = (state.status or "").upper()
+
+ if status == "RUNNING":
+ return f"🟢 Автоторговля ⏳ {runtime}"
+
+ if status == "OBSERVING":
+ return f"👀 Автоторговля ⏳ {runtime}"
+
+ return "⚪️ Автоторговля остановлена"
-def _status_text(status: str) -> str:
- mapping = {
- "OFF": "⚪ Остановлена",
- "OBSERVING": "👀 Наблюдение",
- "RUNNING": "🟢 Работает",
- }
- return mapping.get(status, status)
+def _cycle_number_text(state) -> str:
+ cycle_number = int(getattr(state, "cycle_number", 0) or 0)
+
+ if cycle_number <= 0:
+ cycle_number = 1
+
+ return f"Цикл {cycle_number}"
+
+
+def _cycle_runtime_text(state) -> str:
+ started_at = getattr(state, "cycle_started_at", None)
+
+ if started_at is None:
+ return "0м"
+
+ seconds = max(0, int(time.monotonic() - float(started_at)))
+
+ days = seconds // 86400
+ hours = (seconds % 86400) // 3600
+ minutes = (seconds % 3600) // 60
+
+ if days > 0:
+ return f"{days}д {hours}ч"
+
+ if hours > 0:
+ return f"{hours}ч {minutes}м"
+
+ return f"{minutes}м"
+
+
+def _show_flip_banner(state) -> bool:
+ ts = getattr(state, "last_flip_monotonic_at", None)
+
+ if ts is None:
+ return False
+
+ return (time.monotonic() - float(ts)) <= 600
+
+
+def _show_adaptive_banner(state) -> bool:
+ ts = getattr(state, "adaptive_size_changed_at", None)
+
+ if ts is None:
+ return False
+
+ return (time.monotonic() - float(ts)) <= 600
+
+
+def _adaptive_size_active(state) -> bool:
+ multiplier = getattr(state, "adaptive_size_multiplier", None)
+
+ if multiplier is None:
+ return False
+
+ return abs(float(multiplier) - 1.0) > 0.001
def _account_mode_line() -> str:
@@ -916,17 +969,6 @@ def _required_value(value: str) -> str:
return value
-def _signal_confidence_lines(state) -> list[str]:
- signal = (state.last_signal or "HOLD").upper()
-
- if signal == "HOLD":
- return []
-
- return [
- f"Уверенность · {(state.last_signal_confidence or 0.0):.2f}"
- ]
-
-
def _signal_icon(signal: str | None) -> str:
mapping = {
"BUY": "🟢",
@@ -937,6 +979,18 @@ def _signal_icon(signal: str | None) -> str:
return mapping.get(signal or "", "")
+def _position_side_text(value: str | None) -> str:
+ text = (value or "").upper()
+
+ if text == "LONG":
+ return "Long"
+
+ if text == "SHORT":
+ return "Short"
+
+ return text.title()
+
+
def _round_size(value: float | int | None) -> float | None:
if value is None:
return None
@@ -980,23 +1034,94 @@ def _format_money_compact(value: float | int | None) -> str:
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
+def _format_usd_compact(value: float | int | None) -> str:
+ if value is None:
+ return "$ —"
+
+ return f"${_format_money_compact(value)}"
+
+
def _format_plain_or_dash(value: float | int | None) -> str:
if value is None:
return "—"
- return _format_money_compact(value)
+ return _format_usd_compact(value)
-def _format_signed_plain_with_direction(value: float | int | None) -> str:
- if value is None:
- return "—"
+def _trade_word(value: int) -> str:
+ value = abs(int(value))
- amount = float(value)
+ if value % 10 == 1 and value % 100 != 11:
+ return "сделка"
+
+ if value % 10 in {2, 3, 4} and value % 100 not in {12, 13, 14}:
+ return "сделки"
+
+ return "сделок"
+
+
+def _cycle_winrate_line(state, cycle_pnl: float, cycle_trades: int) -> str:
+ if cycle_trades <= 0 or cycle_pnl <= 0:
+ return ""
+
+ wins = int(getattr(state, "cycle_winning_trades", 0) or 0)
+ winrate = round((wins / cycle_trades) * 100)
+
+ return f"Успешных · {winrate}%"
+
+def _format_pnl_line(value: float | int | None) -> str:
+ amount = float(value or 0.0)
if amount > 0:
- return f"🟢 +{_format_money_compact(amount)}"
+ return f"Прибыль 🟢 +{_format_usd_compact(amount)}"
if amount < 0:
- return f"🔴 −{_format_money_compact(abs(amount))}"
+ return f"Убыток 🔴 −{_format_usd_compact(abs(amount))}"
- return "0"
\ No newline at end of file
+ return "Результат · $0"
+
+
+def _adaptive_adjustment_visible(state) -> bool:
+ return _adaptive_size_active(state) and _show_adaptive_banner(state)
+
+
+def _short_adaptive_reason(reason: str, multiplier: float | None = None) -> str:
+ if multiplier is not None:
+ try:
+ value = float(multiplier)
+ except (TypeError, ValueError):
+ value = 1.0
+
+ if value < 0.15:
+ return "Вход почти заблокирован"
+
+ if value < 0.40:
+ return "Размер сильно уменьшен"
+
+ if value < 0.75:
+ return "Размер уменьшен"
+
+ if value < 1.0:
+ return "Небольшая коррекция"
+
+ if value > 1.05:
+ return "Размер увеличен"
+
+ text = str(reason or "").lower()
+
+ if "margin" in text:
+ return "Лимит маржи"
+
+ if "spread" in text:
+ return "Высокий spread"
+
+ if "шум" in text or "noisy" in text:
+ return "Шумный рынок"
+
+ if "низк" in text or "low" in text:
+ return "Низкая уверенность"
+
+ if not reason:
+ return "Размер изменён"
+
+ return reason[:1].upper() + reason[1:]
\ No newline at end of file
diff --git a/app/src/telegram/handlers/debug.py b/app/src/telegram/handlers/debug.py
index 8f0a6ff..de17678 100644
--- a/app/src/telegram/handlers/debug.py
+++ b/app/src/telegram/handlers/debug.py
@@ -3,11 +3,14 @@
from __future__ import annotations
import math
+import time
from aiogram import F, Router
from aiogram.types import Message
from src.core.config import load_settings
+from src.core.numbers import safe_float
+from src.core.types import JsonList, NumericLike
from src.trading.debug.execution import DebugExecutionEngine
from src.trading.debug.service import DebugTradeService
from src.trading.debug.state import DebugTradeState
@@ -18,7 +21,7 @@ router = Router(name="debug")
def _debug_enabled() -> bool:
- return load_settings().debug_enabled
+ return bool(load_settings().debug_enabled)
def _debug_help_text() -> str:
@@ -80,6 +83,7 @@ async def debug_auto(message: Message) -> None:
if command == "reset":
state = service.reset()
+
await message.answer(
"✅ [DEBUG] Runtime reset\n\n"
f"{_debug_state_text(state)}"
@@ -88,6 +92,7 @@ async def debug_auto(message: Message) -> None:
if command == "off":
state = service.stop()
+
await message.answer(
"✅ [DEBUG] Runtime stopped\n\n"
f"{_debug_state_text(state)}"
@@ -97,17 +102,20 @@ async def debug_auto(message: Message) -> None:
if command == "state":
state = service.get_state()
service.update_market()
+
await message.answer(_debug_state_text(state))
return
if command == "hold":
seconds = _parse_int(parts, index=2, default=335)
+
state = service.set_signal_duration(
signal="HOLD",
seconds=seconds,
confidence=0.0,
force_ready=False,
)
+
await message.answer(
f"✅ [DEBUG] HOLD {seconds}s\n\n"
f"{_debug_state_text(state)}"
@@ -182,15 +190,31 @@ async def debug_auto(message: Message) -> None:
if command == "long":
state, result = service.open_long()
- await message.answer(_execution_result_text("OPEN LONG", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "OPEN LONG",
+ state,
+ result,
+ )
+ )
return
if command == "short":
state, result = service.open_short()
- await message.answer(_execution_result_text("OPEN SHORT", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "OPEN SHORT",
+ state,
+ result,
+ )
+ )
return
- await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
+ await message.answer(
+ f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
+ )
@router.message(F.text.startswith("/debug_exec"))
@@ -211,43 +235,82 @@ async def debug_exec(message: Message) -> None:
if command == "state":
state = service.get_state()
service.update_market()
+
await message.answer(_debug_state_text(state))
return
if command == "buy":
state, result = service.open_long()
- await message.answer(_execution_result_text("EXEC BUY / LONG", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "EXEC BUY / LONG",
+ state,
+ result,
+ )
+ )
return
if command == "sell":
state, result = service.open_short()
- await message.answer(_execution_result_text("EXEC SELL / SHORT", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "EXEC SELL / SHORT",
+ state,
+ result,
+ )
+ )
return
if command == "flip":
state, result = service.flip()
- await message.answer(_execution_result_text("EXEC AUTO FLIP", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "EXEC AUTO FLIP",
+ state,
+ result,
+ )
+ )
return
if command == "close":
state, result = service.close(reason="DEBUG_CLOSE")
- await message.answer(_execution_result_text("EXEC CLOSE", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "EXEC CLOSE",
+ state,
+ result,
+ )
+ )
return
if command == "process":
state, result = service.process()
- await message.answer(_execution_result_text("EXEC PROCESS", state, result))
+
+ await message.answer(
+ _execution_result_text(
+ "EXEC PROCESS",
+ state,
+ result,
+ )
+ )
return
if command == "update":
state = service.update_market()
+
await message.answer(
"✅ [DEBUG] Market update\n\n"
f"{_debug_state_text(state)}"
)
return
- await message.answer(f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}")
+ await message.answer(
+ f"⛔️ Неизвестная команда: {command}\n\n{_debug_help_text()}"
+ )
@router.message(F.text.startswith("/debug_live"))
@@ -264,8 +327,9 @@ async def debug_live(message: Message) -> None:
"/debug_exec sell\n"
"/debug_exec flip\n"
"/debug_exec close\n\n"
- "Live-мониторинг для изолированного debug будет добавлен в следующем пакете "
- "через отдельный DebugTradeRunner и отдельный Debug Auto экран."
+ "Live-мониторинг для изолированного debug будет добавлен "
+ "в следующем пакете через отдельный DebugTradeRunner "
+ "и отдельный Debug Auto экран."
)
@@ -275,19 +339,30 @@ async def debug_signal(message: Message) -> None:
await message.answer("Debug mode выключен.")
return
- signal, confidence, repeat_count, error = _parse_debug_signal_args(message.text)
+ signal, confidence, repeat_count, error = _parse_debug_signal_args(
+ message.text,
+ )
if error is not None:
- await message.answer(f"⛔️ {error}\n\n{_debug_help_text()}")
+ await message.answer(
+ f"⛔️ {error}\n\n{_debug_help_text()}"
+ )
return
service = DebugTradeService()
+
state = service.set_signal(
signal=signal,
confidence=confidence,
repeat_count=repeat_count,
- reason=f"[DEBUG] LEGACY FORCE {signal} {confidence:.2f} ×{repeat_count}",
- force_ready=signal in {"BUY", "SELL"} and repeat_count >= 2,
+ reason=(
+ f"[DEBUG] LEGACY FORCE "
+ f"{signal} {confidence:.2f} ×{repeat_count}"
+ ),
+ force_ready=(
+ signal in {"BUY", "SELL"}
+ and repeat_count >= 2
+ ),
)
await message.answer(
@@ -303,6 +378,7 @@ async def debug_ready(message: Message) -> None:
return
service = DebugTradeService()
+
state = service.set_signal_duration(
signal="BUY",
seconds=15,
@@ -323,13 +399,16 @@ async def debug_state(message: Message) -> None:
return
service = DebugTradeService()
+
state = service.get_state()
service.update_market()
await message.answer(_debug_state_text(state))
-def _debug_state_text(state: DebugTradeState) -> str:
+def _debug_state_text(
+ state: DebugTradeState,
+) -> str:
position = state.position
duration = _signal_duration_text(state)
@@ -348,7 +427,7 @@ def _debug_state_text(state: DebugTradeState) -> str:
f"Signal: {_signal_icon(state.last_signal)} {state.last_signal}\n"
f"Duration: {duration}\n"
f"Repeats: {state.last_signal_repeat_count}\n"
- f"Confidence: {state.last_signal_confidence:.2f}\n"
+ f"Confidence: {safe_float(state.last_signal_confidence) or 0.0:.2f}\n"
f"Decision: {state.decision_status}\n"
f"Ready: {state.is_signal_ready}\n"
f"Reason: {state.last_signal_reason or '—'}\n\n"
@@ -385,53 +464,95 @@ def _execution_result_text(
)
-def _parse_debug_signal_args(raw_text: str | None) -> tuple[str, float, int, str | None]:
+def _parse_debug_signal_args(
+ raw_text: str | None,
+) -> tuple[str, float, int, str | None]:
parts = (raw_text or "").split()
signal = parts[1].upper() if len(parts) > 1 else "BUY"
- if signal not in {"BUY", "SELL", "HOLD"}:
- return "BUY", 0.9, 2, "SIGNAL должен быть BUY, SELL или HOLD."
- try:
- confidence = float(parts[2]) if len(parts) > 2 else 0.9
- except ValueError:
- return "BUY", 0.9, 2, "CONFIDENCE должен быть числом от 0.00 до 1.00."
+ if signal not in {"BUY", "SELL", "HOLD"}:
+ return (
+ "BUY",
+ 0.9,
+ 2,
+ "SIGNAL должен быть BUY, SELL или HOLD.",
+ )
+
+ confidence = _parse_float(parts, index=2, default=0.9)
if confidence < 0 or confidence > 1:
- return "BUY", 0.9, 2, "CONFIDENCE должен быть от 0.00 до 1.00."
+ return (
+ "BUY",
+ 0.9,
+ 2,
+ "CONFIDENCE должен быть от 0.00 до 1.00.",
+ )
- try:
- repeat_count = int(parts[3]) if len(parts) > 3 else 2
- except ValueError:
- return "BUY", 0.9, 2, "REPEATS должен быть целым числом."
+ repeat_count = _parse_int(parts, index=3, default=2)
if repeat_count < 1:
- return "BUY", 0.9, 2, "REPEATS должен быть больше или равен 1."
+ return (
+ "BUY",
+ 0.9,
+ 2,
+ "REPEATS должен быть больше или равен 1.",
+ )
return signal, confidence, repeat_count, None
-def _parse_int(parts: list[str], *, index: int, default: int) -> int:
+def _parse_int(
+ parts: JsonList,
+ *,
+ index: int,
+ default: int,
+) -> int:
try:
- return int(parts[index])
- except (IndexError, TypeError, ValueError):
+ value = parts[index]
+ except (IndexError, TypeError):
return default
+ number = safe_float(value)
-def _parse_float(parts: list[str], *, index: int, default: float) -> float:
- try:
- return float(parts[index])
- except (IndexError, TypeError, ValueError):
+ if number is None:
return default
+ return int(number)
-def _signal_duration_text(state: DebugTradeState) -> str:
- started_at = state.signal_started_at
+
+def _parse_float(
+ parts: JsonList,
+ *,
+ index: int,
+ default: float,
+) -> float:
+ try:
+ value = parts[index]
+ except (IndexError, TypeError):
+ return default
+
+ number = safe_float(value)
+
+ if number is None:
+ return default
+
+ return number
+
+
+def _signal_duration_text(
+ state: DebugTradeState,
+) -> str:
+ started_at = safe_float(state.signal_started_at)
if started_at is not None:
- total_seconds = max(0, int(__import__("time").monotonic() - float(started_at)))
+ total_seconds = max(
+ 0,
+ int(time.monotonic() - started_at),
+ )
else:
- total_seconds = max(0, (state.last_signal_repeat_count or 0) * 5)
+ repeats = state.last_signal_repeat_count or 0
+ total_seconds = max(0, int(repeats) * 5)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
@@ -446,73 +567,98 @@ def _signal_duration_text(state: DebugTradeState) -> str:
return f"{seconds}с"
-def _signal_icon(signal: str | None) -> str:
+def _signal_icon(
+ signal: str | None,
+) -> str:
mapping = {
"BUY": "🟢",
"SELL": "🔴",
"HOLD": "🟡",
}
+
return mapping.get(signal or "", "⚪")
-def _format_leverage(value: float | int | None) -> str:
- if value is None:
+def _format_leverage(
+ value: NumericLike | None,
+) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "x—"
- return f"x{float(value):g}"
+ return f"x{number:g}"
-def _format_crypto_size(value: float | int | None) -> str:
- if value is None:
+def _format_crypto_size(
+ value: NumericLike | None,
+) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "—"
- number = float(value)
return f"{number:.5f}".rstrip("0").rstrip(".")
-def _format_percent(value: float | int | None) -> str:
- if value is None:
+def _format_percent(
+ value: NumericLike | None,
+) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "off"
- number = float(value)
-
- if abs(number - round(number)) < 1e-9:
+ if math.isclose(number, round(number), abs_tol=1e-9):
return f"{int(round(number))}%"
return f"{number:.2f}".rstrip("0").rstrip(".") + "%"
-def _format_money_compact(value: float | int | None) -> str:
- if value is None:
+def _format_money_compact(
+ value: NumericLike | None,
+) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "—"
- number = float(value)
-
- if abs(number - round(number)) < 1e-9:
+ if math.isclose(number, round(number), abs_tol=1e-9):
return f"{number:,.0f}".replace(",", " ")
- return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
+ return (
+ f"{number:,.2f}"
+ .replace(",", " ")
+ .rstrip("0")
+ .rstrip(".")
+ )
-def _format_usd_or_dash(value: float | int | None) -> str:
- if value is None:
+def _format_usd_or_dash(
+ value: NumericLike | None,
+) -> str:
+ if safe_float(value) is None:
return "—"
return f"$ {_format_money_compact(value)}"
-def _format_usd_or_off(value: float | int | None) -> str:
- if value is None:
+def _format_usd_or_off(
+ value: NumericLike | None,
+) -> str:
+ if safe_float(value) is None:
return "off"
return f"$ {_format_money_compact(value)}"
-def _format_signed_usd(value: float | int | None) -> str:
- if value is None:
- return "—"
+def _format_signed_usd(
+ value: NumericLike | None,
+) -> str:
+ amount = safe_float(value)
- amount = float(value)
+ if amount is None:
+ return "—"
if amount > 0:
return f"🟢 +$ {_format_money_compact(amount)}"
diff --git a/app/src/telegram/handlers/home.py b/app/src/telegram/handlers/home.py
index 32e2105..b8e4ced 100644
--- a/app/src/telegram/handlers/home.py
+++ b/app/src/telegram/handlers/home.py
@@ -1,5 +1,7 @@
# app/src/telegram/handlers/home.py
+from __future__ import annotations
+
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
@@ -11,16 +13,33 @@ from src.telegram.menus import HOME_TEXT
router = Router(name="home")
-@router.message(F.text == "🏠 Главная")
-async def open_home(message: Message, state: FSMContext) -> None:
- await state.clear()
+async def _prepare_home_from_message(
+ message: Message,
+) -> bool:
+ bot = message.bot
+
+ if bot is None:
+ return False
await ActiveScreenManager.prepare_new_screen(
screen="home",
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
)
+ return True
+
+
+@router.message(F.text == "🏠 Главная")
+async def open_home(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ await state.clear()
+
+ if not await _prepare_home_from_message(message):
+ return
+
sent_message = await message.answer(HOME_TEXT)
ActiveScreenManager.register(
diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py
index 639f684..8d2637f 100644
--- a/app/src/telegram/handlers/journal.py
+++ b/app/src/telegram/handlers/journal.py
@@ -1,11 +1,18 @@
# app/src/telegram/handlers/journal.py
-
+
from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
-from aiogram.types import BufferedInputFile, CallbackQuery, Message
+from aiogram.types import (
+ BufferedInputFile,
+ CallbackQuery,
+ InaccessibleMessage,
+ Message,
+)
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
from src.telegram.handlers.journal_ui import (
PAGE_SIZE,
build_actions_keyboard,
@@ -23,12 +30,26 @@ from src.trading.journal.service import JournalService
router = Router(name="journal")
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
def _user_id_from_message(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
-def _chat_id_from_message(message: Message) -> int | None:
- return message.chat.id if message.chat else None
+def _chat_id_from_message(message: Message) -> int:
+ return message.chat.id
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
@@ -36,17 +57,38 @@ def _user_id_from_callback(callback: CallbackQuery) -> int | None:
def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
- if callback.message and callback.message.chat:
- return callback.message.chat.id
+ message = _require_message(callback)
- return None
+ if message is None:
+ return None
+
+ return message.chat.id
+
+
+def _parse_page(value: NumericLike | None) -> int | None:
+ number = safe_float(value)
+
+ if number is None:
+ return None
+
+ return int(number)
+
+
+def _journal_payload(**values: object) -> JsonDict:
+ return dict(values)
def _register_journal_screen(message: Message) -> None:
+ bot = message.bot
+
+ if bot is None:
+ return
+
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
+
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -55,7 +97,7 @@ def _register_journal_screen(message: Message) -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen="journal",
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
@@ -67,6 +109,54 @@ def _register_journal_screen(message: Message) -> None:
)
+async def _prepare_journal_from_message(
+ message: Message,
+) -> bool:
+ bot = message.bot
+
+ if bot is None:
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="journal",
+ bot=bot,
+ chat_id=message.chat.id,
+ )
+
+ return True
+
+
+async def _prepare_journal_from_callback(
+ callback: CallbackQuery,
+) -> bool:
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return False
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer(
+ "Bot недоступен",
+ show_alert=True,
+ )
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="journal",
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
+ )
+
+ return True
+
+
async def _show_journal_page(
target_message: Message,
*,
@@ -85,33 +175,37 @@ async def _show_journal_page(
kb = build_keyboard(page, total_pages)
if edit_mode:
- await target_message.edit_text(text, reply_markup=kb)
+ await target_message.edit_text(
+ text,
+ reply_markup=kb,
+ )
_register_journal_screen(target_message)
return
- sent_message = await target_message.answer(text, reply_markup=kb)
+ sent_message = await target_message.answer(
+ text,
+ reply_markup=kb,
+ )
_register_journal_screen(sent_message)
@router.callback_query(F.data == "journal:actions")
async def journal_actions(callback: CallbackQuery) -> None:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_journal_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
- await callback.message.edit_text(
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ await message.edit_text(
render_actions(),
reply_markup=build_actions_keyboard(),
)
- _register_journal_screen(callback.message)
+ _register_journal_screen(message)
await callback.answer()
@@ -120,11 +214,8 @@ async def journal_actions(callback: CallbackQuery) -> None:
async def open_journal(message: Message, state: FSMContext) -> None:
await state.clear()
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=message.bot,
- chat_id=message.chat.id,
- )
+ if not await _prepare_journal_from_message(message):
+ return
await _show_journal_page(
message,
@@ -134,22 +225,23 @@ async def open_journal(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:journal")
-async def open_journal_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None:
+async def open_journal_from_monitoring(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
await state.clear()
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_journal_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
await _show_journal_page(
- callback.message,
+ message,
page=1,
edit_mode=True,
)
@@ -165,6 +257,7 @@ async def journal_noop(callback: CallbackQuery) -> None:
@router.callback_query(F.data == "journal:export_csv")
async def export_journal_csv(callback: CallbackQuery) -> None:
service = JournalService()
+ message = _require_message(callback)
try:
data = service.export_csv()
@@ -173,8 +266,8 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
filename=service.build_export_filename("csv"),
)
- if callback.message is not None:
- await callback.message.answer_document(document=document)
+ if message is not None:
+ await message.answer_document(document=document)
service.log_ui_info(
event_type="journal_exported",
@@ -183,10 +276,11 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
- payload={"format": "csv"},
+ payload=_journal_payload(format="csv"),
)
await callback.answer("CSV экспортирован")
+
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
@@ -195,15 +289,20 @@ async def export_journal_csv(callback: CallbackQuery) -> None:
action="export_csv",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
- payload={"format": "csv"},
+ payload=_journal_payload(format="csv"),
raw_error=str(exc),
)
- await callback.answer("Не удалось экспортировать CSV", show_alert=True)
+
+ await callback.answer(
+ "Не удалось экспортировать CSV",
+ show_alert=True,
+ )
@router.callback_query(F.data == "journal:export_xlsx")
async def export_journal_xlsx(callback: CallbackQuery) -> None:
service = JournalService()
+ message = _require_message(callback)
try:
data = service.export_xlsx()
@@ -212,8 +311,8 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
filename=service.build_export_filename("xlsx"),
)
- if callback.message is not None:
- await callback.message.answer_document(document=document)
+ if message is not None:
+ await message.answer_document(document=document)
service.log_ui_info(
event_type="journal_exported",
@@ -222,10 +321,11 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
- payload={"format": "xlsx"},
+ payload=_journal_payload(format="xlsx"),
)
await callback.answer("Excel экспортирован")
+
except Exception as exc:
service.log_ui_error(
event_type="journal_export_error",
@@ -234,56 +334,56 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None:
action="export_xlsx",
user_id=_user_id_from_callback(callback),
chat_id=_chat_id_from_callback(callback),
- payload={"format": "xlsx"},
+ payload=_journal_payload(format="xlsx"),
raw_error=str(exc),
)
- await callback.answer("Не удалось экспортировать Excel", show_alert=True)
+
+ await callback.answer(
+ "Не удалось экспортировать Excel",
+ show_alert=True,
+ )
@router.callback_query(F.data == "journal:clear_confirm")
async def clear_journal_confirm(callback: CallbackQuery) -> None:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_journal_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
service = JournalService()
total_count = service.get_total_count()
- await callback.message.edit_text(
+ await message.edit_text(
render_clear_confirm(total_count=total_count),
reply_markup=build_clear_confirm_keyboard(),
)
- _register_journal_screen(callback.message)
+ _register_journal_screen(message)
await callback.answer()
@router.callback_query(F.data == "journal:clear")
async def clear_journal(callback: CallbackQuery) -> None:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_journal_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
service = JournalService()
deleted_count = service.clear_all()
total_count = service.get_total_count()
- await callback.message.edit_text(
+ await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count,
@@ -291,29 +391,27 @@ async def clear_journal(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(),
)
- _register_journal_screen(callback.message)
+ _register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data == "journal:clear_older:90")
async def clear_journal_older_90(callback: CallbackQuery) -> None:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_journal_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
service = JournalService()
deleted_count = service.clear_older_than_days(90)
total_count = service.get_total_count()
- await callback.message.edit_text(
+ await message.edit_text(
render_clear_confirm(
total_count=total_count,
deleted_count=deleted_count if deleted_count > 0 else None,
@@ -322,34 +420,37 @@ async def clear_journal_older_90(callback: CallbackQuery) -> None:
reply_markup=build_clear_confirm_keyboard(),
)
- _register_journal_screen(callback.message)
+ _register_journal_screen(message)
await callback.answer(f"Удалено: {deleted_count}")
@router.callback_query(F.data.startswith("journal:"))
async def paginate(callback: CallbackQuery) -> None:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_journal_from_callback(callback):
return
- page_raw = callback.data.split(":", 1)[1]
+ message = _require_message(callback)
- try:
- page = int(page_raw)
- except ValueError:
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ data = callback.data or ""
+ parts = data.split(":", 1)
+
+ if len(parts) < 2:
await callback.answer("Неизвестное действие", show_alert=True)
return
- await ActiveScreenManager.prepare_new_screen(
- screen="journal",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ page = _parse_page(parts[1])
+
+ if page is None:
+ await callback.answer("Неизвестное действие", show_alert=True)
+ return
await _show_journal_page(
- callback.message,
+ message,
page=page,
edit_mode=True,
)
diff --git a/app/src/telegram/handlers/journal_ui.py b/app/src/telegram/handlers/journal_ui.py
index 8eaf2d3..e468faf 100644
--- a/app/src/telegram/handlers/journal_ui.py
+++ b/app/src/telegram/handlers/journal_ui.py
@@ -10,6 +10,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.core.config import load_settings
from src.core.event_titles import event_title
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, JsonList, NumericLike
PAGE_SIZE = 5
@@ -30,26 +32,34 @@ TECH_TO_HUMAN_MESSAGES = {
}
-def build_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
+def build_keyboard(
+ page: int,
+ total_pages: int,
+) -> InlineKeyboardMarkup:
+ current_page = max(1, int(page))
+ pages_count = max(1, int(total_pages))
+
kb = InlineKeyboardBuilder()
- if page > 1:
+ if current_page > 1:
kb.button(text="⏮️", callback_data="journal:1")
- kb.button(text="⬅️", callback_data=f"journal:{page - 1}")
+ kb.button(text="⬅️", callback_data=f"journal:{current_page - 1}")
- kb.button(text=f"{page}/{total_pages}", callback_data="journal:noop")
+ kb.button(text=f"{current_page}/{pages_count}", callback_data="journal:noop")
- if page < total_pages:
- kb.button(text="➡️", callback_data=f"journal:{page + 1}")
+ if current_page < pages_count:
+ kb.button(text="➡️", callback_data=f"journal:{current_page + 1}")
kb.button(text="📤 Экспорт", callback_data="journal:actions")
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
kb.button(text="📊 К мониторингу", callback_data="monitoring:home")
nav_count = 1
- if page > 1:
+
+ if current_page > 1:
nav_count += 2
- if page < total_pages:
+
+ if current_page < pages_count:
nav_count += 1
kb.adjust(nav_count, 2, 1)
@@ -83,33 +93,51 @@ def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
return kb.as_markup()
+def _format_int(
+ value: NumericLike | None,
+ *,
+ default: int = 0,
+) -> int:
+ number = safe_float(value)
+
+ if number is None:
+ return default
+
+ return int(number)
+
+
def render_clear_confirm(
*,
- total_count: int,
- deleted_count: int | None = None,
- no_old_records_days: int | None = None,
+ total_count: NumericLike,
+ deleted_count: NumericLike | None = None,
+ no_old_records_days: NumericLike | None = None,
) -> str:
+ total = _format_int(total_count)
+
lines = [
"⚠️ Очистить журнал",
"",
"СИСТЕМА · Настройки · Журнал",
"",
- f"📄 Записей: {total_count}",
+ f"📄 Записей: {total}",
]
if deleted_count is not None:
- lines.append(f"🧹 Удалено записей: {deleted_count}")
+ lines.append(f"🧹 Удалено записей: {_format_int(deleted_count)}")
if no_old_records_days is not None:
- lines.append(f"📭 Нет записей старше {no_old_records_days} дней")
+ lines.append(
+ f"📭 Нет записей старше {_format_int(no_old_records_days)} дней"
+ )
return "\n".join(lines)
-def _parse_local_datetime(value: str) -> datetime | None:
+def _parse_local_datetime(value: object) -> datetime | None:
try:
settings = load_settings()
- dt = datetime.fromisoformat(value)
+ raw_value = str(value or "")
+ dt = datetime.fromisoformat(raw_value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
@@ -135,39 +163,52 @@ def _date_group_label(dt: datetime | None) -> str:
return dt.strftime("%Y-%m-%d")
-def _time_label(dt: datetime | None, raw_value: str) -> str:
+def _time_label(
+ dt: datetime | None,
+ raw_value: object,
+) -> str:
if dt is None:
- return raw_value
+ return str(raw_value or "")
return dt.strftime("%H:%M:%S")
-def _event_title(event_type: str) -> str:
- return event_title(event_type)
+def _event_title(event_type: object) -> str:
+ return event_title(str(event_type or ""))
-def _humanize_message(message: str) -> str:
- lower = message.lower()
- for k, v in TECH_TO_HUMAN_MESSAGES.items():
- if k in lower:
- return v
- return message
+def _humanize_message(message: object) -> str:
+ text = str(message or "")
+ lower = text.lower()
+
+ for technical_text, human_text in TECH_TO_HUMAN_MESSAGES.items():
+ if technical_text in lower:
+ return human_text
+
+ return text
-def _payload(event: dict) -> dict:
+def _payload(event: JsonDict) -> JsonDict:
payload = event.get("payload")
- return payload if isinstance(payload, dict) else {}
+
+ if isinstance(payload, dict):
+ return payload
+
+ return {}
-def _render_auto_signal(event: dict, created_time: str) -> list[str]:
- level = str(event.get("level", "INFO")).upper()
+def _render_auto_signal(
+ event: JsonDict,
+ created_time: str,
+) -> list[str]:
+ level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "•")
- title = _event_title(str(event.get("event_type", "")))
- message = _humanize_message(str(event.get("message", "")))
+ title = _event_title(event.get("event_type"))
+ message = _humanize_message(event.get("message"))
lines = [
f"{icon} {level} · {title}",
- f"{created_time}",
+ created_time,
]
if message:
@@ -176,15 +217,18 @@ def _render_auto_signal(event: dict, created_time: str) -> list[str]:
return lines
-def _render_default_event(event: dict, created_time: str) -> list[str]:
- level = str(event.get("level", "INFO")).upper()
+def _render_default_event(
+ event: JsonDict,
+ created_time: str,
+) -> list[str]:
+ level = str(event.get("level") or "INFO").upper()
icon = LEVEL_ICONS.get(level, "•")
- title = _event_title(str(event.get("event_type", "")))
- message = _humanize_message(str(event.get("message", "")))
+ title = _event_title(event.get("event_type"))
+ message = _humanize_message(event.get("message"))
lines = [
f"{icon} {level} · {title}",
- f"{created_time}",
+ created_time,
]
if message:
@@ -193,7 +237,11 @@ def _render_default_event(event: dict, created_time: str) -> list[str]:
return lines
-def render(events, page, total_pages):
+def render(
+ events: JsonList,
+ page: NumericLike,
+ total_pages: NumericLike,
+) -> str:
lines = [
"📒 Журнал",
"",
@@ -205,10 +253,15 @@ def render(events, page, total_pages):
lines.append("Событий пока нет.")
return "\n".join(lines)
- current_group = None
+ current_group: str | None = None
- for event in events:
- raw_created_at = str(event.get("created_at", ""))
+ for raw_event in events:
+ if not isinstance(raw_event, dict):
+ continue
+
+ event: JsonDict = raw_event
+
+ raw_created_at = event.get("created_at") or ""
dt = _parse_local_datetime(raw_created_at)
group_label = _date_group_label(dt)
created_time = _time_label(dt, raw_created_at)
@@ -218,7 +271,7 @@ def render(events, page, total_pages):
lines.append(f"{group_label}")
lines.append("")
- event_type = str(event.get("event_type", ""))
+ event_type = str(event.get("event_type") or "")
if event_type in {"signal_summary", "signal_ready"}:
lines.extend(_render_auto_signal(event, created_time))
diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py
index ffda925..7e0f157 100644
--- a/app/src/telegram/handlers/market.py
+++ b/app/src/telegram/handlers/market.py
@@ -4,9 +4,16 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.types import (
+ CallbackQuery,
+ InlineKeyboardMarkup,
+ Message,
+ InaccessibleMessage,
+)
+from src.core.numbers import safe_float
+from src.core.types import NumericLike
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.service import ExchangeService
from src.telegram.live.active_screen import ActiveScreenManager
@@ -26,6 +33,20 @@ _last_market_prices: dict[str, float] = {}
_last_market_directions: dict[str, str] = {}
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
def _market_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
@@ -35,22 +56,27 @@ def _market_keyboard() -> InlineKeyboardMarkup:
def _build_market_text(
*,
- ticker_price: float,
+ ticker_price: NumericLike,
name: str,
market_type: str,
base_asset: str,
quote_asset: str,
) -> str:
+ price = safe_float(ticker_price)
+
+ if price is None:
+ price = 0.0
+
previous_price = _last_market_prices.get(name)
price_direction = _last_market_directions.get(name, "▲")
if previous_price is not None:
- if ticker_price > previous_price:
+ if price > previous_price:
price_direction = "🔺"
- elif ticker_price < previous_price:
+ elif price < previous_price:
price_direction = "🔻"
- _last_market_prices[name] = ticker_price
+ _last_market_prices[name] = price
_last_market_directions[name] = price_direction
type_map = {
@@ -64,7 +90,7 @@ def _build_market_text(
f"{mode_line()}"
"\n"
f"{base_asset} / {quote_asset} ({market_type_ru})\n\n"
- f"$ {format_usd_amount(ticker_price)} {price_direction}\n\n"
+ f"$ {format_usd_amount(price)} {price_direction}\n\n"
f"{now_line()}"
)
@@ -87,9 +113,21 @@ def _build_market_live_text() -> str:
symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a"
- base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
- quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
- name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
+ base_asset = (
+ symbol_info.base_asset
+ if symbol_info and symbol_info.base_asset
+ else "n/a"
+ )
+ quote_asset = (
+ symbol_info.quote_asset
+ if symbol_info and symbol_info.quote_asset
+ else "n/a"
+ )
+ name = (
+ symbol_info.name
+ if symbol_info and symbol_info.name
+ else ticker.symbol
+ )
return _build_market_text(
ticker_price=ticker.price,
@@ -101,10 +139,16 @@ def _build_market_live_text() -> str:
def _register_market_live_screen(message: Message) -> None:
+ bot = message.bot
+
+ if bot is None:
+ return
+
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
+
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -113,7 +157,7 @@ def _register_market_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen(
LiveScreen(
screen="market",
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_build_market_live_text,
@@ -121,9 +165,58 @@ def _register_market_live_screen(message: Message) -> None:
interval_seconds=5,
)
)
+
LiveScreenRunner.start("market")
+async def _prepare_market_from_message(
+ message: Message,
+) -> bool:
+ bot = message.bot
+
+ if bot is None:
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="market",
+ bot=bot,
+ chat_id=message.chat.id,
+ )
+
+ return True
+
+
+async def _prepare_market_from_callback(
+ callback: CallbackQuery,
+) -> bool:
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return False
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer(
+ "Bot недоступен",
+ show_alert=True,
+ )
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="market",
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
+ )
+
+ return True
+
+
async def _render_market_screen(
target_message: Message,
*,
@@ -184,9 +277,21 @@ async def _render_market_screen(
symbol_info = validation.symbol_info
market_type = symbol_info.market_type if symbol_info else "n/a"
- base_asset = symbol_info.base_asset if symbol_info and symbol_info.base_asset else "n/a"
- quote_asset = symbol_info.quote_asset if symbol_info and symbol_info.quote_asset else "n/a"
- name = symbol_info.name if symbol_info and symbol_info.name else ticker.symbol
+ base_asset = (
+ symbol_info.base_asset
+ if symbol_info and symbol_info.base_asset
+ else "n/a"
+ )
+ quote_asset = (
+ symbol_info.quote_asset
+ if symbol_info and symbol_info.quote_asset
+ else "n/a"
+ )
+ name = (
+ symbol_info.name
+ if symbol_info and symbol_info.name
+ else ticker.symbol
+ )
text = _build_market_text(
ticker_price=ticker.price,
@@ -205,7 +310,7 @@ async def _render_market_screen(
chat_id=chat_id,
payload={
"symbol": ticker.symbol,
- "price": ticker.price,
+ "price": safe_float(ticker.price),
},
)
@@ -223,11 +328,8 @@ async def _render_market_screen(
async def open_market(message: Message, state: FSMContext) -> None:
await state.clear()
- await ActiveScreenManager.prepare_new_screen(
- screen="market",
- bot=message.bot,
- chat_id=message.chat.id,
- )
+ if not await _prepare_market_from_message(message):
+ return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
@@ -263,32 +365,38 @@ async def open_market(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:market")
-async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None:
+async def open_market_from_monitoring(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
await state.clear()
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_market_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="market",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return
user_id = callback.from_user.id if callback.from_user else None
- chat_id = callback.message.chat.id if callback.message.chat else None
+ chat_id = message.chat.id
try:
await _render_market_screen(
- callback.message,
+ message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="open_from_monitoring",
)
+
await callback.answer()
+
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_open_error",
@@ -312,32 +420,38 @@ async def open_market_from_monitoring(callback: CallbackQuery, state: FSMContext
@router.callback_query(F.data == "market:retry")
-async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
+async def retry_market(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
await state.clear()
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_market_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="market",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer(
+ "Сообщение недоступно",
+ show_alert=True,
+ )
+ return
user_id = callback.from_user.id if callback.from_user else None
- chat_id = callback.message.chat.id if callback.message.chat else None
+ chat_id = message.chat.id
try:
await _render_market_screen(
- callback.message,
+ message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
+
await callback.answer()
+
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="market_retry_error",
diff --git a/app/src/telegram/handlers/monitoring.py b/app/src/telegram/handlers/monitoring.py
index 60919d1..476c31a 100644
--- a/app/src/telegram/handlers/monitoring.py
+++ b/app/src/telegram/handlers/monitoring.py
@@ -4,7 +4,12 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
+from aiogram.types import (
+ CallbackQuery,
+ InaccessibleMessage,
+ InlineKeyboardMarkup,
+ Message,
+)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from src.telegram.live.active_screen import ActiveScreenManager
@@ -14,6 +19,20 @@ from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScr
router = Router(name="monitoring")
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
def _monitoring_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="💼 Портфель", callback_data="monitoring:portfolio")
@@ -31,10 +50,16 @@ def _monitoring_text() -> str:
def _register_monitoring_screen(message: Message) -> None:
+ bot = message.bot
+
+ if bot is None:
+ return
+
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
+
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -43,23 +68,65 @@ def _register_monitoring_screen(message: Message) -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen="monitoring",
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
)
-@router.message(F.text == "📊 Мониторинг")
-async def open_monitoring(message: Message, state: FSMContext) -> None:
- await state.clear()
+async def _prepare_monitoring_from_message(
+ message: Message,
+) -> bool:
+ bot = message.bot
+
+ if bot is None:
+ return False
await ActiveScreenManager.prepare_new_screen(
screen="monitoring",
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
)
+ return True
+
+
+async def _prepare_monitoring_from_callback(
+ callback: CallbackQuery,
+) -> bool:
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return False
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer("Bot недоступен", show_alert=True)
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="monitoring",
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
+ )
+
+ return True
+
+
+@router.message(F.text == "📊 Мониторинг")
+async def open_monitoring(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ await state.clear()
+
+ if not await _prepare_monitoring_from_message(message):
+ return
+
sent_message = await message.answer(
_monitoring_text(),
reply_markup=_monitoring_keyboard(),
@@ -74,30 +141,31 @@ async def open_monitoring(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:home")
-async def open_monitoring_callback(callback: CallbackQuery, state: FSMContext) -> None:
+async def open_monitoring_callback(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
await state.clear()
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_monitoring_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="monitoring",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
- await callback.message.edit_text(
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ await message.edit_text(
_monitoring_text(),
reply_markup=_monitoring_keyboard(),
)
- _register_monitoring_screen(callback.message)
+ _register_monitoring_screen(message)
ActiveScreenManager.register(
screen="monitoring",
- message=callback.message,
+ message=message,
)
await callback.answer()
\ No newline at end of file
diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py
index d2c81e4..8a39dc5 100644
--- a/app/src/telegram/handlers/portfolio.py
+++ b/app/src/telegram/handlers/portfolio.py
@@ -4,9 +4,16 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
+from aiogram.types import (
+ CallbackQuery,
+ InaccessibleMessage,
+ InlineKeyboardMarkup,
+ Message,
+)
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.exceptions import ExchangeError
from src.integrations.exchange.models import BalanceSummary
from src.integrations.exchange.service import ExchangeService
@@ -39,13 +46,32 @@ PINNED_ORDER = {
}
-def _compact_amount(currency: str, value: float) -> str:
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
+def _payload(**values: object) -> JsonDict:
+ return dict(values)
+
+
+def _compact_amount(currency: str, value: NumericLike) -> str:
+ number = safe_float(value) or 0.0
currency = currency.upper()
if currency in {"USD", "USDT", "EUR"}:
- return format_usd_amount(value)
+ return format_usd_amount(number)
- text = f"{value:.8f}"
+ text = f"{number:.8f}"
if "." in text:
integer, frac = text.split(".")
@@ -103,7 +129,11 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
)
return text, _portfolio_keyboard()
- visible_balances = [item for item in balances if not is_zero_balance(item)]
+ visible_balances = [
+ item
+ for item in balances
+ if not is_zero_balance(item)
+ ]
visible_balances = sort_balances(visible_balances)
if not visible_balances:
@@ -128,8 +158,13 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
for item in visible_balances:
currency = item.currency.upper()
- total = balance_total(item)
- estimated_usd = estimate_balance_usd(item, exchange_service, price_cache)
+ total = safe_float(balance_total(item)) or 0.0
+ locked = safe_float(item.locked) or 0.0
+ estimated_usd = estimate_balance_usd(
+ item,
+ exchange_service,
+ price_cache,
+ )
if estimated_usd is not None:
total_estimated_usd += estimated_usd
@@ -139,8 +174,8 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
line = f"{currency}: {_compact_amount(currency, total)}"
- if item.locked > 0:
- line += f" · locked {_compact_amount(currency, item.locked)}"
+ if locked > 0:
+ line += f" · locked {_compact_amount(currency, locked)}"
if estimated_usd is not None and currency not in {"USD", "USDT"}:
line += f" ≈ $ {format_usd_amount(estimated_usd)}"
@@ -157,7 +192,10 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]:
if has_any_estimate:
lines.insert(3, "")
- lines.insert(3, f"Оценка: ≈ $ {format_usd_amount(total_estimated_usd)}")
+ lines.insert(
+ 3,
+ f"Оценка: ≈ $ {format_usd_amount(total_estimated_usd)}",
+ )
if missing_estimate_assets:
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
@@ -184,10 +222,16 @@ def _portfolio_live_markup() -> InlineKeyboardMarkup:
def _register_portfolio_live_screen(message: Message) -> None:
+ bot = message.bot
+
+ if bot is None:
+ return
+
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
)
+
ScreenRegistry.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -196,7 +240,7 @@ def _register_portfolio_live_screen(message: Message) -> None:
LiveScreenRunner.register_screen(
LiveScreen(
screen="portfolio",
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
render_text=_portfolio_live_text,
@@ -204,9 +248,52 @@ def _register_portfolio_live_screen(message: Message) -> None:
interval_seconds=10,
)
)
+
LiveScreenRunner.start("portfolio")
+async def _prepare_portfolio_from_message(
+ message: Message,
+) -> bool:
+ bot = message.bot
+
+ if bot is None:
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="portfolio",
+ bot=bot,
+ chat_id=message.chat.id,
+ )
+
+ return True
+
+
+async def _prepare_portfolio_from_callback(
+ callback: CallbackQuery,
+) -> bool:
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return False
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer("Bot недоступен", show_alert=True)
+ return False
+
+ await ActiveScreenManager.prepare_new_screen(
+ screen="portfolio",
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
+ )
+
+ return True
+
+
async def _render_portfolio_screen(
target_message: Message,
*,
@@ -241,24 +328,25 @@ async def _render_portfolio_screen(
await target_message.edit_text(text, reply_markup=reply_markup)
_register_portfolio_live_screen(target_message)
ActiveScreenManager.register(screen="portfolio", message=target_message)
- else:
- sent_message = await target_message.answer(text, reply_markup=reply_markup)
- _register_portfolio_live_screen(sent_message)
- ActiveScreenManager.register(screen="portfolio", message=sent_message)
+ return
+
+ sent_message = await target_message.answer(text, reply_markup=reply_markup)
+ _register_portfolio_live_screen(sent_message)
+ ActiveScreenManager.register(screen="portfolio", message=sent_message)
@router.message(F.text == "💼 Портфель")
-async def open_portfolio(message: Message, state: FSMContext) -> None:
+async def open_portfolio(
+ message: Message,
+ state: FSMContext,
+) -> None:
await state.clear()
- await ActiveScreenManager.prepare_new_screen(
- screen="portfolio",
- bot=message.bot,
- chat_id=message.chat.id,
- )
+ if not await _prepare_portfolio_from_message(message):
+ return
user_id = message.from_user.id if message.from_user else None
- chat_id = message.chat.id if message.chat else None
+ chat_id = message.chat.id
try:
await _render_portfolio_screen(
@@ -291,32 +379,34 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data == "monitoring:portfolio")
-async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMContext) -> None:
+async def open_portfolio_from_monitoring(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
await state.clear()
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_portfolio_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="portfolio",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
user_id = callback.from_user.id if callback.from_user else None
- chat_id = callback.message.chat.id if callback.message.chat else None
+ chat_id = message.chat.id
try:
await _render_portfolio_screen(
- callback.message,
+ message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="open_from_monitoring",
)
await callback.answer()
+
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="portfolio_open_error",
@@ -340,32 +430,34 @@ async def open_portfolio_from_monitoring(callback: CallbackQuery, state: FSMCont
@router.callback_query(F.data == "portfolio:retry")
-async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
+async def retry_portfolio(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
await state.clear()
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ if not await _prepare_portfolio_from_callback(callback):
return
- await ActiveScreenManager.prepare_new_screen(
- screen="portfolio",
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
- )
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
user_id = callback.from_user.id if callback.from_user else None
- chat_id = callback.message.chat.id if callback.message.chat else None
+ chat_id = message.chat.id
try:
await _render_portfolio_screen(
- callback.message,
+ message,
user_id=user_id,
chat_id=chat_id,
edit_mode=True,
action="retry",
)
await callback.answer()
+
except ExchangeError as exc:
JournalService().log_ui_error(
event_type="portfolio_retry_error",
diff --git a/app/src/telegram/handlers/start.py b/app/src/telegram/handlers/start.py
index 66bdda5..865b656 100644
--- a/app/src/telegram/handlers/start.py
+++ b/app/src/telegram/handlers/start.py
@@ -15,30 +15,42 @@ from src.telegram.menus import MAIN_MENU_TEXT
router = Router(name="start")
-@router.message(Command("start"))
-async def cmd_start(message: Message, state: FSMContext) -> None:
- # Глобальный экран: всегда выходим из текущего FSM-сценария.
- await state.clear()
+async def _show_main_menu(
+ message: Message,
+) -> None:
await message.answer(
MAIN_MENU_TEXT,
reply_markup=build_main_menu_keyboard(),
)
+@router.message(Command("start"))
+async def cmd_start(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ await state.clear()
+
+ await _show_main_menu(message)
+
+
@router.message(Command("menu"))
-async def cmd_menu(message: Message, state: FSMContext) -> None:
- # Глобальный экран: всегда выходим из текущего FSM-сценария.
+async def cmd_menu(
+ message: Message,
+ state: FSMContext,
+) -> None:
await state.clear()
- await message.answer(
- MAIN_MENU_TEXT,
- reply_markup=build_main_menu_keyboard(),
- )
+
+ await _show_main_menu(message)
@router.message(Command("help"))
-async def cmd_help(message: Message, state: FSMContext) -> None:
- # Глобальный экран: всегда выходим из текущего FSM-сценария.
+async def cmd_help(
+ message: Message,
+ state: FSMContext,
+) -> None:
await state.clear()
+
await message.answer(
build_system_text(),
reply_markup=build_main_menu_keyboard(),
@@ -46,10 +58,10 @@ async def cmd_help(message: Message, state: FSMContext) -> None:
@router.message(F.text == "Меню")
-async def menu_shortcut(message: Message, state: FSMContext) -> None:
- # Глобальный экран: всегда выходим из текущего FSM-сценария.
+async def menu_shortcut(
+ message: Message,
+ state: FSMContext,
+) -> None:
await state.clear()
- await message.answer(
- MAIN_MENU_TEXT,
- reply_markup=build_main_menu_keyboard(),
- )
\ No newline at end of file
+
+ await _show_main_menu(message)
\ No newline at end of file
diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py
index 344fe02..46384af 100644
--- a/app/src/telegram/handlers/system.py
+++ b/app/src/telegram/handlers/system.py
@@ -4,9 +4,11 @@ from __future__ import annotations
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
+from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
@@ -19,6 +21,20 @@ from src.trading.journal.service import JournalService
router = Router(name="system")
+def _require_message(
+ callback: CallbackQuery,
+) -> Message | None:
+ message = callback.message
+
+ if (
+ message is None
+ or isinstance(message, InaccessibleMessage)
+ ):
+ return None
+
+ return message
+
+
def _system_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🛠️ Настройки", callback_data="system:management")
@@ -37,6 +53,11 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup:
def _register_system_screen(message: Message, screen: str = "system") -> None:
+ bot = message.bot
+
+ if bot is None:
+ return
+
LiveScreenRunner.unregister_message(
chat_id=message.chat.id,
message_id=message.message_id,
@@ -49,7 +70,7 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
ScreenRegistry.register_screen(
StaticScreen(
screen=screen,
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
message_id=message.message_id,
)
@@ -61,27 +82,42 @@ def _register_system_screen(message: Message, screen: str = "system") -> None:
)
-async def _prepare_system_from_message(message: Message, screen: str = "system") -> None:
+async def _prepare_system_from_message(message: Message, screen: str = "system") -> bool:
+ bot = message.bot
+
+ if bot is None:
+ return False
+
await ActiveScreenManager.prepare_new_screen(
screen=screen,
- bot=message.bot,
+ bot=bot,
chat_id=message.chat.id,
)
+ return True
+
async def _prepare_system_from_callback(
callback: CallbackQuery,
screen: str = "system",
) -> bool:
- if callback.message is None:
- await callback.answer("Сообщение не найдено", show_alert=True)
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return False
+
+ bot = message.bot
+
+ if bot is None:
+ await callback.answer("Bot недоступен", show_alert=True)
return False
await ActiveScreenManager.prepare_new_screen(
screen=screen,
- bot=callback.message.bot,
- chat_id=callback.message.chat.id,
- keep_message_id=callback.message.message_id,
+ bot=bot,
+ chat_id=message.chat.id,
+ keep_message_id=message.message_id,
)
return True
@@ -148,7 +184,8 @@ async def _render_system_screen(
async def open_system(message: Message, state: FSMContext) -> None:
await state.clear()
- await _prepare_system_from_message(message, screen="system")
+ if not await _prepare_system_from_message(message, screen="system"):
+ return
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
@@ -169,16 +206,23 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
if not await _prepare_system_from_callback(callback, screen="system"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
user_id = callback.from_user.id if callback.from_user else None
- chat_id = callback.message.chat.id if callback.message.chat else None
+ chat_id = message.chat.id
await _render_system_screen(
- callback.message,
+ message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
action="retry",
)
+
await callback.answer()
@@ -201,8 +245,14 @@ async def open_system_management(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(2, 2, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="system")
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="system")
await callback.answer()
@@ -225,7 +275,11 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
leverage_ready = state.leverage is not None
is_trend_strategy = (state.strategy or "").upper() == "TREND"
- sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0
+
+ sl_ready = (
+ state.stop_loss_percent is not None
+ and state.stop_loss_percent > 0
+ )
is_configured = (
strategy_ready
@@ -248,67 +302,136 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
if base.endswith(suffix) and len(base) > len(suffix):
base = base[: -len(suffix)]
break
+
symbol = base
- risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else "—"
- leverage = f"x{state.leverage:g}" if state.leverage is not None else "—"
- max_reserved = (
- f"{state.max_reserved_balance_percent:g}%"
- if state.max_reserved_balance_percent is not None
- else "off"
- )
- sl = f"{state.stop_loss_percent:g}%" if state.stop_loss_percent is not None else "off"
- tp = f"{state.take_profit_percent:g}%" if state.take_profit_percent is not None else "off"
- ml = f"{state.max_loss_usd:g} USD" if state.max_loss_usd is not None else "off"
+ risk = _format_number(state.risk_percent, suffix="%", default="—")
+
+ leverage_value = safe_float(state.leverage)
+ leverage = f"x{leverage_value:g}" if leverage_value is not None else "—"
+
+ max_reserved = _format_percent_setting(state.max_reserved_balance_percent)
+
+ sl = _format_percent_setting(state.stop_loss_percent)
+
+ tp = _format_percent_setting(state.take_profit_percent)
+
+ ml_value = safe_float(state.max_loss_usd)
+ ml = f"{ml_value:g} USD" if ml_value is not None else "off"
strategy_icon = "✅" if strategy_ready else "⚠️"
symbol_icon = "✅" if symbol_ready else "⚠️"
risk_icon = "✅" if risk_ready else "⚠️"
leverage_icon = "✅" if leverage_ready else "⚠️"
- sl_icon = "✅" if sl_ready else "⚠️"
- if is_trend_strategy:
- risk_controls_block = (
- "Защита позиции:\n"
- f"{sl_icon} Stop Loss · {'required' if not sl_ready else sl}\n"
- f"✅ Take Profit · {tp}\n"
- f"✅ Max Loss · {ml}"
- )
+ if is_trend_strategy and not sl_ready:
+ sl_icon = "⛔️"
else:
- risk_controls_block = (
- "Защита позиции:\n"
- f"✅ Stop Loss · {sl}\n"
- f"✅ Take Profit · {tp}\n"
- f"✅ Max Loss · {ml}"
+ sl_icon = (
+ "✅"
+ if state.stop_loss_percent is not None
+ else "⚠️"
)
- config_status = "✅ Все параметры настроены" if is_configured else "⚠️ Настрой все параметры"
+ tp_icon = (
+ "✅"
+ if state.take_profit_percent is not None
+ else "⚠️"
+ )
+
+ ml_icon = (
+ "✅"
+ if state.max_loss_usd is not None
+ else "⚠️"
+ )
+
+ risk_controls_block = (
+ "Защита позиции:\n"
+ f"{sl_icon} Stop Loss · {sl}\n"
+ f"{tp_icon} Take Profit · {tp}\n"
+ f"{ml_icon} Max Loss · {ml}"
+ )
+
+ settings_status_icon = "✅" if is_configured else "⛔️"
+
+ config_status = (
+ ""
+ if is_configured
+ else "\n\nНастрой все параметры"
+ )
text = (
"🤖 Автоторговля\n\n"
- "СИСТЕМА · Настройки\n\n"
+ f"СИСТЕМА · Настройки {settings_status_icon}\n\n"
f"{strategy_icon} Стратегия: {strategy}\n"
f"{symbol_icon} Актив: {symbol}\n"
f"{risk_icon} Риск на сделку: {risk}\n"
f"{leverage_icon} Плечо: {leverage}\n\n"
f"✅ Лимит на сделку: {max_reserved}\n\n"
- f"{risk_controls_block}\n\n"
+ f"{risk_controls_block}"
f"{config_status}"
)
builder = InlineKeyboardBuilder()
- builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
- builder.button(text="💱 Актив", callback_data="settings:auto_symbol")
- builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
- builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved")
- builder.button(text="🛡️ Риск", callback_data="settings:auto_risk")
- builder.button(text="🧯 Защита", callback_data="auto:risk")
- builder.button(text="🤖 Автоторговля", callback_data="auto:home")
- builder.button(text="⬅️ Назад", callback_data="system:management")
+
+ builder.button(
+ text="🧠 Стратегия",
+ callback_data="settings:auto_strategy",
+ )
+
+ builder.button(
+ text="💱 Актив",
+ callback_data="settings:auto_symbol",
+ )
+
+ builder.button(
+ text="⚙️ Плечо",
+ callback_data="settings:auto_leverage",
+ )
+
+ builder.button(
+ text="🏦 Лимит",
+ callback_data="settings:auto_max_reserved",
+ )
+
+ builder.button(
+ text="🛡️ Риск",
+ callback_data="settings:auto_risk",
+ )
+
+ builder.button(
+ text="🧯 Защита",
+ callback_data="auto:risk",
+ )
+
+ builder.button(
+ text="🤖 Автоторговля",
+ callback_data="auto:home",
+ )
+
+ builder.button(
+ text="⬅️ Назад",
+ callback_data="system:management",
+ )
+
builder.adjust(2, 2, 2, 2)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_auto")
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
+ await message.edit_text(
+ text,
+ reply_markup=builder.as_markup(),
+ )
+
+ _register_system_screen(
+ message,
+ screen="settings_auto",
+ )
+
await callback.answer()
@@ -316,6 +439,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
+
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
text = (
"🧠 Стратегия\n\n"
@@ -330,8 +459,8 @@ async def open_auto_strategy_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_auto")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_auto")
await callback.answer()
@@ -340,7 +469,7 @@ def _log_auto_setting_updated(
event_type: str = "auto_settings_updated",
message: str,
action: str,
- payload: dict,
+ payload: JsonDict,
) -> None:
try:
JournalService().log_ui_info(
@@ -370,9 +499,38 @@ def _human_symbol(symbol: str | None) -> str:
return base
+def _format_number(
+ value: NumericLike | None,
+ *,
+ suffix: str = "",
+ default: str = "—",
+) -> str:
+ number = safe_float(value)
+
+ if number is None:
+ return default
+
+ return f"{number:g}{suffix}"
+
+
+def _format_percent_setting(value: NumericLike | None) -> str:
+ return _format_number(value, suffix="%", default="off")
+
+
+def _parse_callback_float(value: object) -> float | None:
+ return safe_float(value)
+
+
@router.callback_query(F.data.startswith("settings:auto_strategy:"))
async def set_auto_strategy(callback: CallbackQuery) -> None:
- strategy = callback.data.split(":", 2)[2].upper()
+ data = callback.data or ""
+ parts = data.split(":", 2)
+
+ if len(parts) < 3:
+ await callback.answer("Некорректное значение стратегии", show_alert=True)
+ return
+
+ strategy = parts[2].upper()
service = AutoTradeService()
state = service.get_state()
@@ -399,6 +557,12 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"💱 Актив\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
@@ -413,14 +577,21 @@ async def open_auto_symbol_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(2, 2, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_auto")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_symbol:"))
async def set_auto_symbol(callback: CallbackQuery) -> None:
- symbol = callback.data.split(":", 2)[2]
+ data = callback.data or ""
+ parts = data.split(":", 2)
+
+ if len(parts) < 3:
+ await callback.answer("Некорректное значение актива", show_alert=True)
+ return
+
+ symbol = parts[2]
service = AutoTradeService()
state = service.get_state()
@@ -447,6 +618,12 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"🛡️ Риск на сделку\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
@@ -460,14 +637,25 @@ async def open_auto_risk_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_auto")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_risk:"))
async def set_auto_risk(callback: CallbackQuery) -> None:
- risk = float(callback.data.split(":", 2)[2])
+ data = callback.data or ""
+ parts = data.split(":", 2)
+
+ if len(parts) < 3:
+ await callback.answer("Некорректное значение риска", show_alert=True)
+ return
+
+ risk = _parse_callback_float(parts[2])
+
+ if risk is None:
+ await callback.answer("Некорректное значение риска", show_alert=True)
+ return
service = AutoTradeService()
state = service.get_state()
@@ -494,6 +682,12 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"⚙️ Плечо\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
@@ -510,14 +704,25 @@ async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 3, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_auto")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_leverage:"))
async def set_auto_leverage(callback: CallbackQuery) -> None:
- leverage = float(callback.data.split(":", 2)[2])
+ data = callback.data or ""
+ parts = data.split(":", 2)
+
+ if len(parts) < 3:
+ await callback.answer("Некорректное значение плеча", show_alert=True)
+ return
+
+ leverage = _parse_callback_float(parts[2])
+
+ if leverage is None:
+ await callback.answer("Некорректное значение плеча", show_alert=True)
+ return
service = AutoTradeService()
state = service.get_state()
@@ -544,6 +749,12 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_auto"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"🏦 Лимит на сделку\n\n"
"СИСТЕМА · Настройки · Автоторговля\n\n"
@@ -559,15 +770,26 @@ async def open_auto_max_reserved_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(2, 2, 1, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_auto")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_auto")
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_max_reserved:"))
async def set_auto_max_reserved(callback: CallbackQuery) -> None:
- raw_value = callback.data.split(":", 2)[2]
- value = None if raw_value == "off" else float(raw_value)
+ data = callback.data or ""
+ parts = data.split(":", 2)
+
+ if len(parts) < 3:
+ await callback.answer("Некорректные данные кнопки", show_alert=True)
+ return
+
+ raw_value = parts[2]
+ value = None if raw_value == "off" else _parse_callback_float(raw_value)
+
+ if raw_value != "off" and value is None:
+ await callback.answer("Некорректное значение лимита", show_alert=True)
+ return
service = AutoTradeService()
state = service.get_state()
@@ -596,6 +818,12 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_trade"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"💹 Торговля\n\n"
"СИСТЕМА · Настройки\n\n"
@@ -610,8 +838,8 @@ async def open_trade_settings(callback: CallbackQuery) -> None:
builder.button(text="💹 Торговля", callback_data="trade:home")
builder.adjust(2)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_trade")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_trade")
await callback.answer()
@@ -620,6 +848,12 @@ async def open_general_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_general"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"🌍 Общие\n\n"
"СИСТЕМА · Настройки\n\n"
@@ -633,8 +867,8 @@ async def open_general_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.adjust(1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_general")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_general")
await callback.answer()
@@ -643,6 +877,12 @@ async def open_journal_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
service = JournalService()
total = service.get_total_count()
@@ -664,8 +904,8 @@ async def open_journal_settings(callback: CallbackQuery) -> None:
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2, 2, 2)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_journal")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -674,6 +914,12 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"🗄 Архив\n\n"
"СИСТЕМА · Настройки · Журнал\n\n"
@@ -688,8 +934,8 @@ async def open_journal_archive_settings(callback: CallbackQuery) -> None:
builder.button(text="📒 Журнал", callback_data="journal:1")
builder.adjust(2)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_journal")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -698,6 +944,12 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"📦 Лимит\n\n"
"СИСТЕМА · Настройки · Журнал\n\n"
@@ -713,8 +965,8 @@ async def open_journal_limit_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.adjust(2, 2, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_journal")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -723,6 +975,12 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="settings_journal"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
text = (
"⏳ Хранение\n\n"
"СИСТЕМА · Настройки · Журнал\n\n"
@@ -738,8 +996,8 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="settings:journal")
builder.adjust(2, 2, 1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="settings_journal")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="settings_journal")
await callback.answer()
@@ -756,11 +1014,17 @@ async def back_to_system(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
user_id = callback.from_user.id if callback.from_user else None
- chat_id = callback.message.chat.id if callback.message.chat else None
+ chat_id = message.chat.id
await _render_system_screen(
- callback.message,
+ message,
edit_mode=True,
user_id=user_id,
chat_id=chat_id,
@@ -774,6 +1038,12 @@ async def open_system_about(callback: CallbackQuery) -> None:
if not await _prepare_system_from_callback(callback, screen="system_about"):
return
+ message = _require_message(callback)
+
+ if message is None:
+ await callback.answer("Сообщение недоступно", show_alert=True)
+ return
+
settings = load_settings()
journal = JournalService()
@@ -783,7 +1053,7 @@ async def open_system_about(callback: CallbackQuery) -> None:
screen="system",
action="about",
user_id=callback.from_user.id if callback.from_user else None,
- chat_id=callback.message.chat.id if callback.message.chat else None,
+ chat_id=message.chat.id,
)
text = (
@@ -801,6 +1071,6 @@ async def open_system_about(callback: CallbackQuery) -> None:
builder.button(text="⬅️ Назад", callback_data="system:back")
builder.adjust(1)
- await callback.message.edit_text(text, reply_markup=builder.as_markup())
- _register_system_screen(callback.message, screen="system_about")
+ await message.edit_text(text, reply_markup=builder.as_markup())
+ _register_system_screen(message, screen="system_about")
await callback.answer()
\ No newline at end of file
diff --git a/app/src/telegram/live/runner.py b/app/src/telegram/live/runner.py
index d28905c..1ff4139 100644
--- a/app/src/telegram/live/runner.py
+++ b/app/src/telegram/live/runner.py
@@ -4,9 +4,10 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
-from typing import Callable
+from collections.abc import Callable
from aiogram import Bot
+from aiogram.types import InlineKeyboardMarkup
from aiogram.exceptions import TelegramBadRequest
@@ -17,7 +18,7 @@ class LiveScreen:
chat_id: int
message_id: int
render_text: Callable[[], str]
- render_markup: Callable[[], object]
+ render_markup: Callable[[], InlineKeyboardMarkup | None]
interval_seconds: int = 5
diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py
index 01dc0ba..46fc68b 100644
--- a/app/src/trading/auto/runner.py
+++ b/app/src/trading/auto/runner.py
@@ -4,12 +4,16 @@ from __future__ import annotations
import asyncio
import time
-from typing import Callable
+
+from collections.abc import Callable
+from typing import ClassVar
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.core.event_bus import EventBus
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.notifications.targets import NotificationTargetRegistry
from src.runtime_events.event_types import RuntimeEventType
@@ -17,30 +21,27 @@ from src.runtime_events.models import RuntimeEvent
from src.runtime_events.publisher import RuntimeEventPublisher
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
-from src.telegram.handlers.auto.ui import build_auto_semantic_text
+from src.telegram.handlers.auto.ui import build_auto_notification_text
from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner:
- _task: asyncio.Task | None = None
- _bot: Bot | None = None
- _chat_id: int | None = None
- _message_id: int | None = None
- _render_text: Callable[[], str] | None = None
- _render_markup: Callable[[], object] | None = None
- _current_screen: str | None = None
-
+ _task: ClassVar[asyncio.Task | None] = None
+ _bot: ClassVar[Bot | None] = None
+ _chat_id: ClassVar[int | None] = None
+ _message_id: ClassVar[int | None] = None
+ _render_text: ClassVar[staticmethod | None] = None
+ _render_markup: ClassVar[staticmethod | None] = None
+ _current_screen: ClassVar[str | None] = None
_analysis_interval_seconds = 5
_ui_interval_seconds = 30
-
- _last_text: str | None = None
- _last_semantic_text: str | None = None
- _last_ui_refresh_at: float = 0.0
- _last_event_version: int = 0
- _retry_after_until: float = 0.0
- _last_screen_state_key: str | None = None
-
+ _last_text: ClassVar[str | None] = None
+ _last_semantic_text: ClassVar[str | None] = None
+ _last_ui_refresh_at: ClassVar[float] = 0.0
+ _last_event_version: ClassVar[int] = 0
+ _retry_after_until: ClassVar[float] = 0.0
+ _last_screen_state_key: ClassVar[str | None] = None
_position_aligned_signal_log_interval_seconds = 900
_last_position_aligned_signal_log_at_by_key: dict[str, float] = {}
@@ -57,8 +58,8 @@ class AutoTradeRunner:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
- cls._render_text = render_text
- cls._render_markup = render_markup
+ cls._render_text = staticmethod(render_text)
+ cls._render_markup = staticmethod(render_markup)
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@@ -260,8 +261,13 @@ class AutoTradeRunner:
await cls._handle_important_event(state)
@classmethod
- async def _handle_important_event(cls, state) -> None:
+ async def _handle_important_event(
+ cls,
+ state,
+ ) -> None:
event_type, payload = EventBus.last_event()
+ if not isinstance(payload, dict):
+ payload = {}
if event_type == "auto_decision_changed":
if payload.get("decision_status") != "READY":
@@ -298,7 +304,10 @@ class AutoTradeRunner:
return
@classmethod
- def _notification_reason_lines(cls, state) -> list[str]:
+ def _notification_reason_lines(
+ cls,
+ state,
+ ) -> list[str]:
snapshot = SemanticDiagnosticSnapshotBuilder().build(
state,
is_configured=True,
@@ -326,14 +335,29 @@ class AutoTradeRunner:
cls,
*,
state,
- payload: dict,
+ payload: JsonDict,
signal: str,
) -> None:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
symbol = str(payload.get("symbol") or state.symbol or "—")
strategy = str(payload.get("strategy") or state.strategy or "—")
- confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0)
- repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 0)
+ confidence = safe_float(
+ payload.get("confidence")
+ )
+
+ if confidence is None:
+ confidence = safe_float(state.last_signal_confidence)
+
+ if confidence is None:
+ confidence = 0.0
+
+ repeat_count_value = (
+ payload.get("repeat_count")
+ if payload.get("repeat_count") is not None
+ else state.last_signal_repeat_count
+ )
+
+ repeat_count = int(safe_float(repeat_count_value) or 0)
log_key = (
f"{position_side}:"
@@ -377,12 +401,31 @@ class AutoTradeRunner:
pass
@classmethod
- def _publish_strong_signal_event(cls, *, state, payload: dict) -> None:
+ def _publish_strong_signal_event(
+ cls,
+ *,
+ state,
+ payload: JsonDict,
+ ) -> None:
signal = str(payload.get("signal", "")).upper()
symbol = str(payload.get("symbol") or state.symbol or "—")
strategy = str(payload.get("strategy") or state.strategy or "—")
- repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 0)
- confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0)
+ repeat_count_value = (
+ payload.get("repeat_count")
+ if payload.get("repeat_count") is not None
+ else state.last_signal_repeat_count
+ )
+
+ repeat_count = int(safe_float(repeat_count_value) or 0)
+ confidence = safe_float(
+ payload.get("confidence")
+ )
+
+ if confidence is None:
+ confidence = safe_float(state.last_signal_confidence)
+
+ if confidence is None:
+ confidence = 0.0
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
reason = str(payload.get("reason") or state.last_signal_reason or "—")
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
@@ -409,6 +452,9 @@ class AutoTradeRunner:
"decision_status": state.decision_status,
"semantic_lines": cls._notification_reason_lines(state),
"position_side": position_context,
+ "bid_price": payload.get("bid_price"),
+ "ask_price": payload.get("ask_price"),
+ "last_price": payload.get("last_price"),
},
priority=priority.lower(),
dedupe_key=(
@@ -431,7 +477,7 @@ class AutoTradeRunner:
*,
state,
event_type: str,
- payload: dict,
+ payload: JsonDict,
) -> None:
runtime_event_type = cls._runtime_execution_event_type(event_type)
if runtime_event_type is None:
@@ -450,13 +496,17 @@ class AutoTradeRunner:
source="auto_trade_runner",
title=cls._execution_event_title(runtime_event_type),
payload={
+ **payload,
"source_event_type": event_type,
"symbol": symbol,
"side": side,
"old_side": old_side,
"new_side": new_side,
- "leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage,
- **payload,
+ "leverage": (
+ payload.get("leverage")
+ if payload.get("leverage") is not None
+ else state.leverage
+ ),
"strategy": state.strategy,
"semantic_lines": semantic_lines,
},
@@ -493,7 +543,7 @@ class AutoTradeRunner:
cls,
*,
runtime_event_type: RuntimeEventType,
- payload: dict,
+ payload: JsonDict,
) -> str:
return (
f"{runtime_event_type.value}:"
@@ -516,27 +566,40 @@ class AutoTradeRunner:
def _alert_priority(
cls,
*,
- confidence: float,
+ confidence: NumericLike,
repeat_count: int,
) -> str:
- if confidence >= 0.8 and repeat_count >= 3:
+ confidence_value = safe_float(confidence) or 0.0
+
+ if confidence_value >= 0.8 and repeat_count >= 3:
return "HIGH"
- if confidence >= 0.6 or repeat_count >= 2:
+ if confidence_value >= 0.6 or repeat_count >= 2:
return "MEDIUM"
return "LOW"
@classmethod
- def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None:
+ def _log_refresh_skip(
+ cls,
+ reason: str,
+ payload: JsonDict | None = None,
+ ) -> None:
return
@classmethod
- def _log_refresh_success(cls, payload: dict | None = None) -> None:
+ def _log_refresh_success(
+ cls,
+ payload: JsonDict | None = None,
+ ) -> None:
return
@classmethod
- def _log_refresh_error(cls, reason: str, payload: dict | None = None) -> None:
+ def _log_refresh_error(
+ cls,
+ reason: str,
+ payload: JsonDict | None = None,
+ ) -> None:
try:
JournalService().log_error(
"auto_screen_refresh_error",
@@ -547,7 +610,10 @@ class AutoTradeRunner:
pass
@classmethod
- def _screen_state_key(cls, state) -> str:
+ def _screen_state_key(
+ cls,
+ state,
+ ) -> str:
return "|".join(
str(value)
for value in [
@@ -581,6 +647,9 @@ class AutoTradeRunner:
getattr(state, "position_size", None),
#getattr(state, "unrealized_pnl_usd", None),
getattr(state, "realized_pnl_usd", None),
+ getattr(state, "cycle_closed_trades", None),
+ getattr(state, "cycle_realized_pnl_usd", None),
+ getattr(state, "cycle_winning_trades", None),
getattr(state, "last_execution_action", None),
getattr(state, "last_execution_reason", None),
]
@@ -628,19 +697,30 @@ class AutoTradeRunner:
)
return
- text = cls._render_text()
- semantic_text = build_auto_semantic_text()
+ render_text = cls._render_text
+ render_markup = cls._render_markup
+ bot = cls._bot
+
+ if (
+ render_text is None
+ or render_markup is None
+ or bot is None
+ ):
+ return
+
+ text = render_text()
+ semantic_text = build_auto_notification_text()
if semantic_text == cls._last_semantic_text:
cls._log_refresh_skip("text_not_changed")
return
try:
- await cls._bot.edit_message_text(
+ await bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
text=text,
- reply_markup=cls._render_markup(),
+ reply_markup=render_markup(),
)
cls._last_text = text
cls._last_semantic_text = semantic_text
diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py
index 7bc1cda..40bb48e 100644
--- a/app/src/trading/auto/service.py
+++ b/app/src/trading/auto/service.py
@@ -8,6 +8,8 @@ from datetime import datetime
from src.core.config import load_settings
from src.core.event_bus import EventBus
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
from src.trading.auto.state import AutoTradeState
from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService
@@ -40,7 +42,7 @@ class AutoTradeService:
_last_signal_value: str | None = None
_last_signal_reason: str = ""
_last_signal_confidence: float = 0.0
- _last_signal_payload: dict | None = None
+ _last_signal_payload: JsonDict | None = None
_last_signal_started_at: float | None = None
_last_logged_market_state: str | None = None
_last_logged_market_trend: str | None = None
@@ -50,46 +52,145 @@ class AutoTradeService:
_max_snapshot_age_seconds = 5.0
_warning_snapshot_age_seconds = 2.0
- _spread_warning_enter_percent = 0.08
- _spread_warning_exit_percent = 0.06
- _spread_block_enter_percent = 0.15
- _spread_block_exit_percent = 0.12
+ _spread_thresholds_by_asset: dict[str, dict[str, float]] = {
+ "BTC": {
+ "warning_enter": 0.08,
+ "warning_exit": 0.06,
+ "block_enter": 0.15,
+ "block_exit": 0.12,
+ },
+ "ETH": {
+ "warning_enter": 0.10,
+ "warning_exit": 0.08,
+ "block_enter": 0.18,
+ "block_exit": 0.15,
+ },
+ "LTC": {
+ "warning_enter": 0.18,
+ "warning_exit": 0.14,
+ "block_enter": 0.35,
+ "block_exit": 0.28,
+ },
+ "XRP": {
+ "warning_enter": 0.20,
+ "warning_exit": 0.16,
+ "block_enter": 0.40,
+ "block_exit": 0.32,
+ },
+ }
+
+ _default_spread_thresholds: dict[str, float] = {
+ "warning_enter": 0.12,
+ "warning_exit": 0.09,
+ "block_enter": 0.25,
+ "block_exit": 0.20,
+ }
_last_logged_execution_quality_key: str | None = None
+ def _asset_symbol(self, symbol: str | None) -> str:
+ if not symbol:
+ return ""
+
+ base = str(symbol).split("_", 1)[0].upper()
+
+ if "/" in base:
+ return base.split("/", 1)[0]
+
+ for suffix in ("USDT", "USD", "EUR", "BTC"):
+ if base.endswith(suffix) and len(base) > len(suffix):
+ return base[: -len(suffix)]
+
+ return base
+
+ def _spread_thresholds(self, symbol: str | None) -> dict[str, float]:
+ asset = self._asset_symbol(symbol)
+
+ return self._spread_thresholds_by_asset.get(
+ asset,
+ self._default_spread_thresholds,
+ )
+
+ def _sync_market_availability_state(self, state: AutoTradeState) -> bool:
+ status = ExchangeService().get_symbol_market_status(state.symbol)
+
+ is_open = bool(status.get("is_open"))
+ market_status = str(status.get("status") or "UNKNOWN")
+ message = str(status.get("message") or "")
+
+ state.market_is_open = is_open
+ state.market_status = market_status
+ state.market_status_message = message
+ state.market_status_updated_at = time.monotonic()
+
+ if is_open:
+ if state.execution_quality_reason == "MARKET_CLOSED":
+ state.execution_quality = None
+ state.execution_quality_reason = None
+ state.execution_quality_message = None
+ state.execution_block_reason = None
+ state.market_runtime_degraded = False
+
+ return True
+
+ state.execution_quality = "BLOCKED"
+ state.execution_quality_reason = "MARKET_CLOSED"
+ state.execution_quality_message = "рынок закрыт"
+ state.execution_block_reason = "рынок закрыт"
+ state.market_runtime_degraded = True
+
+ state.entry_block_reason = "MARKET_CLOSED"
+ state.entry_block_message = "рынок закрыт"
+
+ state.decision_status = "WAITING"
+ state.decision_reason = message or "Рынок закрыт."
+ state.is_signal_confirmed = False
+ state.is_signal_ready = False
+
+ return False
+
def _spread_execution_quality(
self,
*,
state: AutoTradeState,
- spread_percent: float | None,
+ spread_percent: NumericLike | None,
) -> tuple[str | None, str | None, str | None, bool]:
- if spread_percent is None:
+ spread = safe_float(spread_percent)
+
+ if spread is None:
return None, None, None, False
+ thresholds = self._spread_thresholds(state.symbol)
+
+ warning_enter = thresholds["warning_enter"]
+ warning_exit = thresholds["warning_exit"]
+ block_enter = thresholds["block_enter"]
+ block_exit = thresholds["block_exit"]
+
previous_quality = state.execution_quality
previous_reason = state.execution_quality_reason
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
- if spread_percent > self._spread_block_exit_percent:
+ if spread > block_exit:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
- if spread_percent > self._spread_warning_exit_percent:
+ if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
- if spread_percent >= self._spread_block_enter_percent:
+ if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
- if spread_percent > self._spread_warning_exit_percent:
+ if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
- if spread_percent >= self._spread_block_enter_percent:
+ if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
- if spread_percent >= self._spread_warning_enter_percent:
+ if spread >= warning_enter:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
@@ -99,11 +200,12 @@ class AutoTradeService:
self,
*,
signal: str,
- confidence: float = 0.9,
+ confidence: NumericLike = 0.9,
repeat_count: int = 2,
reason: str = "DEBUG SIGNAL",
) -> AutoTradeState:
state = self.get_state()
+ confidence_value = safe_float(confidence) or 0.0
normalized_signal = signal.strip().upper()
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
@@ -117,7 +219,7 @@ class AutoTradeService:
state.last_signal = normalized_signal
state.last_signal_repeat_count = repeat_count
- state.last_signal_confidence = confidence
+ state.last_signal_confidence = confidence_value
state.last_signal_reason = reason
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
@@ -162,13 +264,15 @@ class AutoTradeService:
return state
# установить капитал, выделенный под автоторговлю
- def set_allocated_balance_usd(self, value: float) -> AutoTradeState:
+ def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
state = self.get_state()
- if value <= 0:
- value = 1000.0
+ numeric_value = safe_float(value)
- state.allocated_balance_usd = value
+ if numeric_value is None or numeric_value <= 0:
+ numeric_value = 1000.0
+
+ state.allocated_balance_usd = numeric_value
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
return state
@@ -231,6 +335,10 @@ class AutoTradeService:
state.status = "RUNNING"
self._reset_signal_tracking()
state.cycle_realized_pnl_usd = 0.0
+ state.cycle_closed_trades = 0
+ state.cycle_winning_trades = 0
+ state.cycle_started_at = time.monotonic()
+ state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -268,6 +376,9 @@ class AutoTradeService:
if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0
+ state.cycle_closed_trades = 0
+ state.cycle_winning_trades = 0
+ state.cycle_started_at = time.monotonic()
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -288,6 +399,10 @@ class AutoTradeService:
state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0
+ state.cycle_closed_trades = 0
+ state.cycle_winning_trades = 0
+ state.cycle_started_at = None
+ state.adaptive_size_changed_at = None
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -333,39 +448,39 @@ class AutoTradeService:
return state
# установить риск
- def set_risk_percent(self, risk_percent: float) -> AutoTradeState:
+ def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState:
state = self.get_state()
- state.risk_percent = risk_percent
+ state.risk_percent = safe_float(risk_percent)
return state
# установить плечо
- def set_leverage(self, leverage: float) -> AutoTradeState:
+ def set_leverage(self, leverage: NumericLike) -> AutoTradeState:
state = self.get_state()
- state.leverage = leverage
+ state.leverage = safe_float(leverage)
return state
# установить stop loss в %
- def set_stop_loss_percent(self, value: float | None) -> AutoTradeState:
+ def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
- state.stop_loss_percent = value
+ state.stop_loss_percent = safe_float(value)
return state
# установить take profit в %
- def set_take_profit_percent(self, value: float | None) -> AutoTradeState:
+ def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
- state.take_profit_percent = value
+ state.take_profit_percent = safe_float(value)
return state
# установить max loss в USD
- def set_max_loss_usd(self, value: float | None) -> AutoTradeState:
+ def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
- state.max_loss_usd = value
+ state.max_loss_usd = safe_float(value)
return state
# установить максимальное использование баланса под маржу
- def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState:
+ def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
- state.max_reserved_balance_percent = value
+ state.max_reserved_balance_percent = safe_float(value)
state.execution_block_reason = None
return state
@@ -380,6 +495,7 @@ class AutoTradeService:
self._same_signal_count = 0
state = self.get_state()
+
state.adaptive_size_base = None
state.adaptive_size_final = None
state.adaptive_size_multiplier = None
@@ -387,6 +503,7 @@ class AutoTradeService:
state.adaptive_size_factors = None
state.effective_risk_percent = None
state.effective_target_risk_usd = None
+
state.last_signal_repeat_count = 0
state.last_signal_confidence = 0.0
state.last_signal_reason = None
@@ -399,6 +516,9 @@ class AutoTradeService:
state.signal_confirmation_missing_repeats = self._confirm_repeats
state.signal_confirmation_progress = 0.0
state.signal_confirmation_reason = None
+ state.signal_started_at = None
+ state.signal_updated_at = None
+
state.execution_block_reason = None
state.execution_semantic_status = None
state.execution_semantic_message = None
@@ -411,8 +531,7 @@ class AutoTradeService:
state.execution_confidence_required_score = self._execution_confidence_required_score
state.execution_confidence_reason = None
state.execution_confidence_factors = None
- state.signal_started_at = None
- state.signal_updated_at = None
+
state.market_state = None
state.market_trend = None
state.market_volatility = None
@@ -424,8 +543,29 @@ class AutoTradeService:
state.market_trend_quality = None
state.market_phase = None
state.market_phase_direction = None
+
+ state.market_trend_gap_percent = None
+ state.market_trend_consistency = None
+ state.market_trend_efficiency = None
+ state.trend_quality_score = None
+ state.ema_distance_atr_ratio = None
+ state.ema_distance_state = None
+ state.entry_timing_state = None
+ state.entry_timing_reason = None
+ state.ema_fast_slope_percent = None
+ state.ema_slow_slope_percent = None
+ state.candle_noise_score = None
+ state.price_position_score = None
+
+ state.htf_interval = None
+ state.htf_atr_percent = None
+ state.htf_atr_percent_baseline = None
+ state.htf_volatility_ratio = None
+ state.htf_volatility = None
+
state.entry_block_reason = None
state.entry_block_message = None
+
state.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
@@ -433,6 +573,7 @@ class AutoTradeService:
state.breakout_level = None
state.breakout_distance_percent = None
state.breakout_reason = None
+
state.runtime_expired_reason = None
state.runtime_expired_message = None
state.snapshot_age_seconds = None
@@ -508,7 +649,12 @@ class AutoTradeService:
if state.signal_started_at is None:
signal_age_seconds = 0
else:
- signal_age_seconds = max(0, int(now - float(state.signal_started_at)))
+ signal_started = safe_float(state.signal_started_at)
+ signal_age_seconds = (
+ max(0, int(now - signal_started))
+ if signal_started is not None
+ else 0
+ )
missing_repeats = max(0, self._confirm_repeats - self._same_signal_count)
missing_seconds = max(
@@ -589,7 +735,7 @@ class AutoTradeService:
signal: str,
reason: str,
confidence: float,
- payload: dict | None,
+ payload: JsonDict | None,
) -> None:
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
previous_signal = self._last_signal_value
@@ -757,7 +903,7 @@ class AutoTradeService:
signal: str,
reason: str,
confidence: float,
- payload: dict | None,
+ payload: JsonDict | None,
) -> None:
return
@@ -772,7 +918,7 @@ class AutoTradeService:
next_signal: str,
reason: str,
confidence: float,
- payload: dict | None,
+ payload: JsonDict | None,
duration_seconds: int,
) -> None:
if previous_signal != "HOLD":
@@ -822,6 +968,11 @@ class AutoTradeService:
if normalized_signal not in {"BUY", "SELL"}:
return
+ snapshot = ExchangeService().get_market_snapshot(
+ state.symbol,
+ runtime_key="auto",
+ )
+
try:
JournalService().log_ui_info(
event_type="signal_ready",
@@ -846,6 +997,9 @@ class AutoTradeService:
"confirmation_seconds": state.signal_confirmation_seconds,
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
"confirmation_progress": state.signal_confirmation_progress,
+ "bid_price": snapshot.get("bid_price"),
+ "ask_price": snapshot.get("ask_price"),
+ "last_price": snapshot.get("last_price"),
},
)
except Exception:
@@ -855,7 +1009,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
- payload: dict | None,
+ payload: JsonDict | None,
) -> None:
if not isinstance(payload, dict):
return
@@ -864,25 +1018,42 @@ class AutoTradeService:
previous_market_trend = state.market_trend
previous_market_volatility = state.market_volatility
- state.market_state = payload.get("market_state")
- state.market_trend = payload.get("market_trend")
- state.market_volatility = payload.get("market_volatility")
- state.market_trend_strength = payload.get("market_trend_strength")
- state.market_trend_quality = payload.get("market_trend_quality")
- state.market_phase = payload.get("market_phase")
- state.market_phase_direction = payload.get("market_phase_direction")
- state.market_analysis_interval = payload.get("market_analysis_interval")
- state.market_analysis_reason = payload.get("market_analysis_reason")
- state.momentum_state = payload.get("momentum_state")
- state.momentum_direction = payload.get("momentum_direction")
- state.momentum_change_percent = payload.get("momentum_change_percent")
- state.momentum_strength = payload.get("momentum_strength")
- state.breakout_level = payload.get("breakout_level")
- state.breakout_distance_percent = payload.get("breakout_distance_percent")
- state.breakout_reason = payload.get("breakout_reason")
+ state.market_state = str(payload.get("market_state") or "")
+ state.market_trend = str(payload.get("trend") or payload.get("market_trend") or "")
+ state.market_volatility = str(payload.get("volatility") or payload.get("market_volatility") or "")
+ state.market_trend_strength = str(payload.get("market_trend_strength") or "")
+ state.market_trend_quality = str(payload.get("market_trend_quality") or "")
+ state.market_phase = str(payload.get("market_phase") or "")
+ state.market_phase_direction = str(payload.get("market_phase_direction") or "")
+ state.market_trend_gap_percent = safe_float(payload.get("market_trend_gap_percent"))
+ state.market_trend_consistency = safe_float(payload.get("market_trend_consistency"))
+ state.market_trend_efficiency = safe_float(payload.get("market_trend_efficiency"))
+ state.trend_quality_score = safe_float(payload.get("trend_quality_score"))
+ state.ema_distance_atr_ratio = safe_float(payload.get("ema_distance_atr_ratio"))
+ state.ema_distance_state = str(payload.get("ema_distance_state") or "")
+ state.entry_timing_state = str(payload.get("entry_timing_state") or "")
+ state.entry_timing_reason = str(payload.get("entry_timing_reason") or "")
+ state.ema_fast_slope_percent = safe_float(payload.get("ema_fast_slope_percent"))
+ state.ema_slow_slope_percent = safe_float(payload.get("ema_slow_slope_percent"))
+ state.candle_noise_score = safe_float(payload.get("candle_noise_score"))
+ state.price_position_score = safe_float(payload.get("price_position_score"))
+ state.htf_interval = str(payload.get("htf_interval") or "")
+ state.htf_atr_percent = safe_float(payload.get("htf_atr_percent"))
+ state.htf_atr_percent_baseline = safe_float(payload.get("htf_atr_percent_baseline"))
+ state.htf_volatility_ratio = safe_float(payload.get("htf_volatility_ratio"))
+ state.htf_volatility = str(payload.get("htf_volatility") or "")
+ state.market_analysis_interval = str(payload.get("interval") or payload.get("market_analysis_interval") or "")
+ state.market_analysis_reason = str(payload.get("reason") or payload.get("market_analysis_reason") or "")
+ state.momentum_state = str(payload.get("momentum_state") or "")
+ state.momentum_direction = str(payload.get("momentum_direction") or "")
+ state.momentum_change_percent = safe_float(payload.get("momentum_change_percent"))
+ state.momentum_strength = safe_float(payload.get("momentum_strength"))
+ state.breakout_level = safe_float(payload.get("breakout_level"))
+ state.breakout_distance_percent = safe_float(payload.get("breakout_distance_percent"))
+ state.breakout_reason = str(payload.get("breakout_reason") or "")
state.market_analysis_updated_at = time.monotonic()
- state.entry_block_reason = payload.get("entry_block_reason")
- state.entry_block_message = payload.get("entry_block_message")
+ state.entry_block_reason = str(payload.get("entry_block_reason") or "")
+ state.entry_block_message = str(payload.get("entry_block_message") or "")
self._log_market_state_if_changed(
state=state,
@@ -901,7 +1072,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
- payload: dict,
+ payload: JsonDict,
) -> None:
reason = state.entry_block_reason
message = state.entry_block_message
@@ -938,7 +1109,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
- payload: dict,
+ payload: JsonDict,
previous_market_state: str | None,
previous_market_trend: str | None,
previous_market_volatility: str | None,
@@ -1003,7 +1174,7 @@ class AutoTradeService:
event_type: str,
market_state: str,
message: str,
- payload: dict,
+ payload: JsonDict,
) -> None:
level = self._market_journal_level(market_state)
@@ -1034,7 +1205,7 @@ class AutoTradeService:
return messages.get(str(market_volatility or ""), "Волатильность не определена.")
- def _market_journal_level(self, market_state: str) -> str:
+ def _market_journal_level(self, market_state: str | None) -> str:
if market_state == "HIGH_VOLATILITY":
return "WARNING"
@@ -1056,8 +1227,11 @@ class AutoTradeService:
signal_updated_at = getattr(state, "signal_updated_at", None)
if signal_updated_at is not None:
- signal_age = now - float(signal_updated_at)
+ signal_updated = safe_float(signal_updated_at)
+ if signal_updated is None:
+ return
+ signal_age = now - signal_updated
if signal_age > self._signal_ttl_seconds:
previous_signal = state.last_signal
@@ -1081,7 +1255,12 @@ class AutoTradeService:
market_updated_at = getattr(state, "market_analysis_updated_at", None)
if market_updated_at is not None:
- market_age = now - float(market_updated_at)
+ market_updated = safe_float(market_updated_at)
+
+ if market_updated is None:
+ return
+
+ market_age = now - market_updated
if market_age > self._market_analysis_ttl_seconds:
state.market_state = None
@@ -1096,7 +1275,23 @@ class AutoTradeService:
state.market_trend_quality = None
state.market_phase = None
state.market_phase_direction = None
-
+ state.market_trend_gap_percent = None
+ state.market_trend_consistency = None
+ state.market_trend_efficiency = None
+ state.trend_quality_score = None
+ state.ema_distance_atr_ratio = None
+ state.ema_distance_state = None
+ state.entry_timing_state = None
+ state.entry_timing_reason = None
+ state.ema_fast_slope_percent = None
+ state.ema_slow_slope_percent = None
+ state.candle_noise_score = None
+ state.price_position_score = None
+ state.htf_interval = None
+ state.htf_atr_percent = None
+ state.htf_atr_percent_baseline = None
+ state.htf_volatility_ratio = None
+ state.htf_volatility = None
state.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
@@ -1123,7 +1318,7 @@ class AutoTradeService:
state: AutoTradeState,
reason: str,
message: str,
- payload: dict,
+ payload: JsonDict,
) -> None:
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
@@ -1159,7 +1354,7 @@ class AutoTradeService:
fallback_price = None
try:
- fallback_price = float(
+ fallback_price = safe_float(
ExchangeService().get_price(
state.symbol,
runtime_key="auto",
@@ -1192,10 +1387,10 @@ class AutoTradeService:
)
return
- bid_price = self._safe_float(snapshot.get("bid_price"))
- ask_price = self._safe_float(snapshot.get("ask_price"))
- last_price = self._safe_float(snapshot.get("last_price"))
- age_seconds = self._safe_float(snapshot.get("age_seconds"))
+ bid_price = safe_float(snapshot.get("bid_price"))
+ ask_price = safe_float(snapshot.get("ask_price"))
+ last_price = safe_float(snapshot.get("last_price"))
+ age_seconds = safe_float(snapshot.get("age_seconds"))
is_fresh = bool(snapshot.get("is_fresh", False))
source = str(snapshot.get("source") or "")
@@ -1240,6 +1435,8 @@ class AutoTradeService:
elif state.execution_block_reason == state.execution_quality_message:
state.execution_block_reason = None
+ spread_thresholds = self._spread_thresholds(state.symbol)
+
self._log_execution_quality_if_changed(
state=state,
payload={
@@ -1258,49 +1455,46 @@ class AutoTradeService:
"market_runtime_degraded": state.market_runtime_degraded,
"max_snapshot_age_seconds": self._max_snapshot_age_seconds,
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
- "spread_warning_enter_percent": self._spread_warning_enter_percent,
- "spread_warning_exit_percent": self._spread_warning_exit_percent,
- "spread_block_enter_percent": self._spread_block_enter_percent,
- "spread_block_exit_percent": self._spread_block_exit_percent,
+ "spread_asset": self._asset_symbol(state.symbol),
+ "spread_warning_enter_percent": spread_thresholds["warning_enter"],
+ "spread_warning_exit_percent": spread_thresholds["warning_exit"],
+ "spread_block_enter_percent": spread_thresholds["block_enter"],
+ "spread_block_exit_percent": spread_thresholds["block_exit"],
},
)
def _spread_percent(
self,
*,
- bid_price: float | None,
- ask_price: float | None,
+ bid_price: NumericLike | None,
+ ask_price: NumericLike | None,
) -> float | None:
- if bid_price is None or ask_price is None:
+ bid = safe_float(bid_price)
+ ask = safe_float(ask_price)
+
+ if bid is None or ask is None:
return None
- if bid_price <= 0 or ask_price <= 0:
+ if bid <= 0 or ask <= 0:
return None
- mid_price = (bid_price + ask_price) / 2
+ mid_price = (bid + ask) / 2
+
if mid_price <= 0:
return None
- spread = ask_price - bid_price
+ spread = ask - bid
+
if spread < 0:
return None
return round((spread / mid_price) * 100, 5)
- def _safe_float(self, value: object) -> float | None:
- if value is None:
- return None
-
- try:
- return float(value)
- except (TypeError, ValueError):
- return None
-
def _log_execution_quality_if_changed(
self,
*,
state: AutoTradeState,
- payload: dict,
+ payload: JsonDict,
) -> None:
quality = state.execution_quality
reason = state.execution_quality_reason
@@ -1408,8 +1602,18 @@ class AutoTradeService:
strength = state.market_trend_strength
quality = state.market_trend_quality
phase = state.market_phase
+ ema_distance_state = state.ema_distance_state
+ entry_timing_state = state.entry_timing_state
+ trend_quality_score = safe_float(state.trend_quality_score)
- if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE", "UNKNOWN", None}:
+ if market_state in {
+ "HIGH_VOLATILITY",
+ "LOW_VOLATILITY",
+ "RANGE",
+ "UNKNOWN",
+ None,
+ "",
+ }:
return 0.25
score = 0.65
@@ -1422,7 +1626,9 @@ class AutoTradeService:
score -= 0.25
if quality == "CLEAN":
- score += 0.1
+ score += 0.12
+ elif quality == "NORMAL":
+ score += 0.04
elif quality == "NOISY":
score -= 0.25
@@ -1433,6 +1639,30 @@ class AutoTradeService:
elif phase in {"RANGE", "SQUEEZE"}:
score -= 0.3
+ if ema_distance_state == "HEALTHY":
+ score += 0.08
+ elif ema_distance_state == "EXTENDED":
+ score -= 0.08
+ elif ema_distance_state == "COMPRESSED":
+ score -= 0.18
+ elif ema_distance_state == "OVEREXTENDED":
+ score -= 0.35
+
+ if entry_timing_state == "NORMAL":
+ score += 0.08
+ elif entry_timing_state == "EARLY":
+ score -= 0.05
+ elif entry_timing_state == "LATE":
+ score -= 0.2
+ elif entry_timing_state == "CHASING":
+ score -= 0.35
+
+ if trend_quality_score is not None:
+ if trend_quality_score >= 0.7:
+ score += 0.08
+ elif trend_quality_score < 0.45:
+ score -= 0.15
+
return self._clamp_score(score)
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
@@ -1482,11 +1712,16 @@ class AutoTradeService:
return "достаточная совокупная уверенность входа"
- def _clamp_score(self, value: float | int | None) -> float:
+ def _clamp_score(self, value: NumericLike | None) -> float:
if value is None:
return 0.0
- return max(0.0, min(1.0, float(value)))
+ numeric = safe_float(value)
+
+ if numeric is None:
+ return 0.0
+
+ return max(0.0, min(1.0, numeric))
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
if state.execution_quality == "BLOCKED":
@@ -1541,6 +1776,9 @@ class AutoTradeService:
def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
reason = state.execution_quality_reason
+ if reason == "MARKET_CLOSED":
+ return "⏸️ Исполнение · рынок закрыт"
+
if reason == "STALE_SNAPSHOT":
return "⛔ Исполнение · рынок неактуален"
@@ -1561,6 +1799,11 @@ class AutoTradeService:
if state.status == "OFF":
return state
+ if not self._sync_market_availability_state(state):
+ state.last_check_at = datetime.now().strftime("%H:%M:%S")
+ self._sync_execution_semantic_state(state)
+ return state
+
self._expire_runtime_if_needed(state)
strategy = self._get_strategy()
diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py
index 1dbe6cc..ea1030b 100644
--- a/app/src/trading/auto/state.py
+++ b/app/src/trading/auto/state.py
@@ -97,6 +97,18 @@ class AutoTradeState:
# cumulative realized pnl за текущий цикл автоторговли
cycle_realized_pnl_usd: float = 0.0
+ # количество закрытых сделок в текущем цикле
+ cycle_closed_trades: int = 0
+
+ # количество прибыльных закрытых сделок
+ cycle_winning_trades: int = 0
+
+ # время запуска текущего цикла
+ cycle_started_at: float | None = None
+
+ # время последней adaptive size корректировки
+ adaptive_size_changed_at: float | None = None
+
# данные последнего flip
last_flip_old_side: str | None = None
last_flip_new_side: str | None = None
@@ -130,7 +142,7 @@ class AutoTradeState:
# сила тренда: WEAK / NORMAL / STRONG / UNKNOWN
market_trend_strength: str | None = None
- # качество тренда: CLEAN / NOISY / UNKNOWN
+ # качество тренда: CLEAN / NORMAL / NOISY / UNKNOWN
market_trend_quality: str | None = None
# фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN
@@ -139,6 +151,29 @@ class AutoTradeState:
# направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN
market_phase_direction: str | None = None
+ # advanced trend quality metrics
+ market_trend_gap_percent: float | None = None
+ market_trend_consistency: float | None = None
+ market_trend_efficiency: float | None = None
+ ema_distance_atr_ratio: float | None = None
+ ema_fast_slope_percent: float | None = None
+ ema_slow_slope_percent: float | None = None
+ candle_noise_score: float | None = None
+ price_position_score: float | None = None
+
+ # advanced trend quality semantic states
+ trend_quality_score: float | None = None
+ ema_distance_state: str | None = None
+ entry_timing_state: str | None = None
+ entry_timing_reason: str | None = None
+
+ # higher timeframe volatility context
+ htf_interval: str | None = None
+ htf_atr_percent: float | None = None
+ htf_atr_percent_baseline: float | None = None
+ htf_volatility_ratio: float | None = None
+ htf_volatility: str | None = None
+
# состояние momentum/breakout semantic engine
# NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN
momentum_state: str | None = None
@@ -262,4 +297,13 @@ class AutoTradeState:
adaptive_size_reason: str | None = None
# факторы adaptive sizing для логов / отладки
- adaptive_size_factors: dict | None = None
\ No newline at end of file
+ adaptive_size_factors: dict | None = None
+
+ # статус торговой сессии инструмента
+ market_is_open: bool | None = None
+ market_status: str | None = None
+ market_status_message: str | None = None
+ market_status_updated_at: float | None = None
+
+ # номер текущего цикла автоторговли, для которого была зафиксирована статистика
+ cycle_number: int = 0
\ No newline at end of file
diff --git a/app/src/trading/debug/runner.py b/app/src/trading/debug/runner.py
index 737508b..59c7949 100644
--- a/app/src/trading/debug/runner.py
+++ b/app/src/trading/debug/runner.py
@@ -4,33 +4,49 @@ from __future__ import annotations
import asyncio
import time
-from typing import Callable
+from collections.abc import Callable
+from typing import ClassVar, Protocol
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
+from aiogram.types import InlineKeyboardMarkup
+from src.core.telegram_errors import (
+ is_message_not_modified,
+ is_message_to_edit_not_found,
+)
from src.integrations.exchange.market_data_runner import MarketDataRunner
-from src.trading.debug.service import DebugTradeService
from src.notifications.targets import NotificationTargetRegistry
+from src.trading.debug.service import DebugTradeService
+
+
+class RenderText(Protocol):
+ def __call__(self) -> str: ...
+
+
+class RenderMarkup(Protocol):
+ def __call__(self) -> InlineKeyboardMarkup | None: ...
+
class DebugTradeRunner:
- _task: asyncio.Task | None = None
+ _task: ClassVar[asyncio.Task[None] | None] = None
- _bot: Bot | None = None
- _chat_id: int | None = None
- _message_id: int | None = None
- _render_text: Callable[[], str] | None = None
- _render_markup: Callable[[], object] | None = None
+ _bot: ClassVar[Bot | None] = None
+ _chat_id: ClassVar[int | None] = None
+ _message_id: ClassVar[int | None] = None
- _current_screen: str | None = None
+ _text_renderer: ClassVar[RenderText | None] = None
+ _markup_renderer: ClassVar[RenderMarkup | None] = None
- _interval_seconds = 5
- _market_interval_seconds = 1
+ _current_screen: ClassVar[str | None] = None
- _last_text: str | None = None
- _last_refresh_at: float = 0.0
- _retry_after_until: float = 0.0
+ _interval_seconds: ClassVar[int] = 5
+ _market_interval_seconds: ClassVar[int] = 1
+
+ _last_text: ClassVar[str | None] = None
+ _last_refresh_at: ClassVar[float] = 0.0
+ _retry_after_until: ClassVar[float] = 0.0
@classmethod
def register_screen(
@@ -39,14 +55,14 @@ class DebugTradeRunner:
bot: Bot,
chat_id: int,
message_id: int,
- render_text: Callable[[], str],
- render_markup: Callable[[], object],
+ render_text: RenderText,
+ render_markup: RenderMarkup,
) -> None:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
- cls._render_text = render_text
- cls._render_markup = render_markup
+ cls._text_renderer = render_text
+ cls._markup_renderer = render_markup
cls._last_text = None
NotificationTargetRegistry.set_default_chat(
@@ -54,6 +70,30 @@ class DebugTradeRunner:
chat_id=chat_id,
)
+ @classmethod
+ def _reset_screen(cls) -> None:
+ cls._message_id = None
+ cls._text_renderer = None
+ cls._markup_renderer = None
+ cls._last_text = None
+
+ @classmethod
+ def _reset_runtime(cls) -> None:
+ cls._bot = None
+ cls._chat_id = None
+ cls._current_screen = None
+ cls._reset_screen()
+
+ @classmethod
+ def _is_screen_ready(cls) -> bool:
+ return (
+ cls._bot is not None
+ and cls._chat_id is not None
+ and cls._message_id is not None
+ and cls._text_renderer is not None
+ and cls._markup_renderer is not None
+ )
+
@classmethod
async def delete_registered_screen(
cls,
@@ -75,10 +115,7 @@ class DebugTradeRunner:
except Exception:
pass
- cls._message_id = None
- cls._render_text = None
- cls._render_markup = None
- cls._last_text = None
+ cls._reset_screen()
@classmethod
async def detach_screen(
@@ -105,13 +142,7 @@ class DebugTradeRunner:
except Exception:
pass
- cls._bot = None
- cls._chat_id = None
- cls._message_id = None
- cls._render_text = None
- cls._render_markup = None
- cls._current_screen = None
- cls._last_text = None
+ cls._reset_runtime()
@classmethod
def set_current_screen(cls, screen: str) -> None:
@@ -121,6 +152,7 @@ class DebugTradeRunner:
def start(cls) -> None:
service = DebugTradeService()
state = service.get_state()
+
state.status = "RUNNING"
MarketDataRunner.start(
@@ -167,7 +199,11 @@ class DebugTradeRunner:
await asyncio.sleep(cls._interval_seconds)
@classmethod
- async def refresh_screen(cls, *, force: bool = False) -> None:
+ async def refresh_screen(
+ cls,
+ *,
+ force: bool = False,
+ ) -> None:
if cls._current_screen != "debug_auto":
return
@@ -176,32 +212,43 @@ class DebugTradeRunner:
if now < cls._retry_after_until:
return
- if not force and now - cls._last_refresh_at < cls._interval_seconds:
- return
-
- if not all(
- [
- cls._bot,
- cls._chat_id,
- cls._message_id,
- cls._render_text,
- cls._render_markup,
- ]
+ if (
+ not force
+ and now - cls._last_refresh_at < cls._interval_seconds
):
return
- text = cls._render_text()
+ if not cls._is_screen_ready():
+ return
+
+ bot = cls._bot
+ chat_id = cls._chat_id
+ message_id = cls._message_id
+ text_renderer = cls._text_renderer
+ markup_renderer = cls._markup_renderer
+
+ if (
+ bot is None
+ or chat_id is None
+ or message_id is None
+ or text_renderer is None
+ or markup_renderer is None
+ ):
+ return
+
+ text = text_renderer()
if text == cls._last_text:
return
try:
- await cls._bot.edit_message_text(
- chat_id=cls._chat_id,
- message_id=cls._message_id,
+ await bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
text=text,
- reply_markup=cls._render_markup(),
+ reply_markup=markup_renderer(),
)
+
cls._last_text = text
cls._last_refresh_at = now
@@ -209,18 +256,13 @@ class DebugTradeRunner:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
except TelegramBadRequest as exc:
- error_text = str(exc).lower()
-
- if "message is not modified" in error_text:
+ if is_message_not_modified(exc):
cls._last_text = text
cls._last_refresh_at = now
return
- if "message to edit not found" in error_text:
- cls._message_id = None
- cls._render_text = None
- cls._render_markup = None
- cls._last_text = None
+ if is_message_to_edit_not_found(exc):
+ cls._reset_screen()
return
except Exception:
diff --git a/app/src/trading/diagnostics/formatter.py b/app/src/trading/diagnostics/formatter.py
index 56dc6ad..0b839a3 100644
--- a/app/src/trading/diagnostics/formatter.py
+++ b/app/src/trading/diagnostics/formatter.py
@@ -4,9 +4,12 @@ from __future__ import annotations
from typing import Any
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
+
class SemanticDiagnosticFormatter:
- def format(self, snapshot: dict[str, Any]) -> str:
+ def format(self, snapshot: JsonDict) -> str:
status = snapshot.get("status", {})
signal = snapshot.get("signal", {})
market = snapshot.get("market", {})
@@ -37,7 +40,13 @@ class SemanticDiagnosticFormatter:
).strip()
sections = [
- self._headline_block(summary, status),
+ self._headline_block(
+ summary,
+ status,
+ position,
+ market=market,
+ momentum=momentum,
+ ),
self._execution_block(execution),
self._signal_block(signal),
self._market_block(market),
@@ -60,16 +69,11 @@ class SemanticDiagnosticFormatter:
if section and section.strip()
).strip()
- def _diagnostics_title(self, status: dict[str, Any]) -> str:
+ def _diagnostics_title(self, status: JsonDict) -> str:
symbol = self._asset_symbol(status.get("symbol"))
return f"🔬 Диагностика · {symbol}"
- def build_notification_reason_lines(
- self,
- snapshot: dict[str, Any],
- *,
- limit: int = 2,
- ) -> list[str]:
+ def build_notification_reason_lines(self, snapshot: JsonDict, *, limit: int = 2) -> list[str]:
signal = snapshot.get("signal", {})
market = snapshot.get("market", {})
summary = snapshot.get("summary", {})
@@ -101,8 +105,12 @@ class SemanticDiagnosticFormatter:
def _headline_block(
self,
- data: dict[str, Any],
- status: dict[str, Any],
+ data: JsonDict,
+ status: JsonDict,
+ position: JsonDict | None = None,
+ *,
+ market: JsonDict | None = None,
+ momentum: JsonDict | None = None,
) -> str:
severity = data.get("severity")
assessment = data.get("assessment") or self._human(severity)
@@ -115,8 +123,7 @@ class SemanticDiagnosticFormatter:
if headline_mode == "POSITION":
return self._position_headline(
data=data,
- severity_icon=severity_icon,
- assessment=assessment,
+ position=position or {},
)
return self._entry_headline(
@@ -124,15 +131,19 @@ class SemanticDiagnosticFormatter:
symbol=symbol,
severity_icon=severity_icon,
assessment=assessment,
+ market=market or {},
+ momentum=momentum or {},
)
def _entry_headline(
self,
*,
- data: dict[str, Any],
+ data: JsonDict,
symbol: str,
severity_icon: str,
assessment: str,
+ market: JsonDict | None = None,
+ momentum: JsonDict | None = None,
) -> str:
blockers = data.get("blockers") or []
@@ -142,10 +153,16 @@ class SemanticDiagnosticFormatter:
self._headline_status_line(
severity=data.get("severity"),
assessment=assessment,
+ data=data,
),
]
- reasons = self._headline_reasons(blockers)
+ reasons = self._headline_reasons(
+ blockers=blockers,
+ summary=data,
+ market=market or {},
+ momentum=momentum or {},
+ )
if not reasons:
reasons = ["Ограничений нет"]
@@ -159,35 +176,181 @@ class SemanticDiagnosticFormatter:
*,
severity: object,
assessment: str,
+ data: JsonDict | None = None,
) -> str:
text = str(severity or "")
+ data = data or {}
if text == "RED":
+ blockers_text = self._blockers_text(data)
+
+ if (
+ "бирж" in blockers_text
+ or "перерыв" in blockers_text
+ or "рынок закрыт" in blockers_text
+ or "торги временно" in blockers_text
+ ):
+ return "⛔️ Вход заблокирован"
+
return "⛔️ Вход заблокирован"
if text == "WAITING":
- return "⚪️ Ожидание"
+ return self._waiting_headline_status(data)
if text == "YELLOW":
+ execution = str(data.get("execution") or "")
+
+ if execution == "WAITING_SIGNAL":
+ return "🟡 Сигнал проверяется"
+
return "🟡 Осторожно"
if text == "GREEN":
return "🟢 Готово"
return f"{self._severity_icon(severity)} {assessment.capitalize()}"
+
+ def _waiting_headline_status(self, data: JsonDict) -> str:
+ execution = str(data.get("execution") or "")
+ blockers_text = self._blockers_text(data)
- def _headline_reasons(self, blockers: list[object]) -> list[str]:
+ if execution == "WAITING_SIGNAL":
+ return "🟡 Сигнал проверяется"
+
+ if (
+ "подтвержд" in blockers_text
+ or "сигнал проверяется" in blockers_text
+ or "идёт подтверждение" in blockers_text
+ ):
+ return "🟡 Сигнал проверяется"
+
+ if (
+ "бирж" in blockers_text
+ or "перерыв" in blockers_text
+ or "рынок закрыт" in blockers_text
+ or "торги временно" in blockers_text
+ ):
+ return "⛔️ Вход заблокирован"
+
+ return "🟡 Ожидание сигнала"
+
+ def _blockers_text(self, data: JsonDict) -> str:
+ blockers = data.get("blockers") or []
+
+ return " ".join(
+ str(item).strip().lower()
+ for item in blockers
+ if str(item).strip()
+ )
+
+ def _headline_reasons(
+ self,
+ *,
+ blockers: list[object],
+ summary: JsonDict,
+ market: JsonDict,
+ momentum: JsonDict,
+ ) -> list[str]:
reasons: list[str] = []
def add(text: str) -> None:
- normalized = text.strip()
+ normalized = text.strip().rstrip(".")
if normalized and normalized not in reasons:
reasons.append(normalized)
- for blocker in blockers:
- text = self._short_reason(str(blocker)).strip()
+ market_state = str(market.get("state") or summary.get("market") or "").upper()
+ market_phase = str(market.get("phase") or summary.get("phase") or "").upper()
+ market_quality = str(market.get("trend_quality") or "").upper()
+ market_strength = str(market.get("trend_strength") or "").upper()
+ market_volatility = str(market.get("volatility") or "").upper()
+ market_open = market.get("market_is_open")
- if not text:
+ momentum_state = str(momentum.get("state") or summary.get("momentum") or "").upper()
+ momentum_direction = str(momentum.get("direction") or "").upper()
+ momentum_strength = safe_float(momentum.get("strength"))
+
+ execution = str(
+ summary.get("execution")
+ or summary.get("semantic_status")
+ or ""
+ ).upper()
+
+ if market_open is False:
+ add(str(market.get("market_status_message") or "Рынок закрыт"))
+ return reasons[:2]
+
+ if execution == "READY":
+ add("Условия входа готовы")
+
+ if market_state == "HIGH_VOLATILITY" or market_volatility == "HIGH_VOLATILITY":
+ add("Рынок перегрет")
+
+ elif market_state == "LOW_VOLATILITY" or market_volatility == "LOW_VOLATILITY":
+ add("Движения мало")
+
+ elif market_state == "RANGE" or market_phase == "RANGE":
+ add("Рынок во флэте")
+
+ elif market_phase == "PULLBACK":
+ add("Рынок в откате")
+
+ elif market_phase == "SQUEEZE":
+ add("Рынок в сжатии")
+
+ elif market_quality == "NOISY":
+ add("Рынок шумный")
+
+ elif market_strength == "WEAK":
+ add("Тренд слабый")
+
+ elif market_state == "TREND_UP":
+ add("Рынок растёт")
+
+ elif market_state == "TREND_DOWN":
+ add("Рынок снижается")
+
+ if (
+ momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}
+ and market_state not in {"RANGE"}
+ and market_phase not in {"RANGE", "PULLBACK", "SQUEEZE"}
+ and market_quality != "NOISY"
+ ):
+ add("Пробой вверх" if momentum_state == "BREAKOUT_UP" else "Пробой вниз")
+
+ elif (
+ momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}
+ and market_strength != "WEAK"
+ and market_quality != "NOISY"
+ and market_phase not in {"RANGE", "SQUEEZE"}
+ ):
+ add("Импульс вверх" if momentum_state == "MOMENTUM_UP" else "Импульс вниз")
+
+ elif (
+ momentum_state in {"NONE", "NO_SIGNIFICANT_MOMENTUM"}
+ or momentum_direction in {"FLAT", "РОВНО"}
+ ):
+ if momentum_strength is not None and momentum_strength >= 1.0:
+ add("Импульс затухает")
+ else:
+ add("Сильного импульса нет")
+
+ generic_blockers = {
+ "market_filter_blocked",
+ "рынок не готов",
+ "рынок не подходит",
+ "рынок не подходит для входа",
+ }
+
+ for blocker in blockers:
+ raw = str(blocker).strip()
+ normalized = raw.lower()
+
+ if not raw or normalized in generic_blockers:
+ continue
+
+ text = self._short_reason(raw).strip()
+
+ if not text or text.lower() in generic_blockers:
continue
add(text[:1].upper() + text[1:])
@@ -195,46 +358,22 @@ class SemanticDiagnosticFormatter:
if len(reasons) >= 2:
break
- return reasons
+ return reasons[:2]
- def _position_headline(
- self,
- *,
- data: dict[str, Any],
- severity_icon: str,
- assessment: str,
- ) -> str:
- side = self._human(data.get("position"))
-
+ def _position_headline(self, *, data: JsonDict, position: JsonDict) -> str:
symbol = self._asset_symbol(data.get("symbol"))
+ pnl_value = safe_float(position.get("unrealized_pnl_usd")) or 0.0
- lines = [
- f"🔬 Диагностика · {symbol}",
- ]
+ icon = "🟢" if pnl_value >= 0 else "🔴"
+ sign = "+" if pnl_value >= 0 else "−"
- if assessment == "стабильно":
- lines.append(f"{severity_icon} Позиция открыта")
- lines.append(f"• {side.capitalize()}")
- lines.append("• Сопровождение активно")
+ return "\n".join([
+ f"🔬 Диагностика · {symbol}",
+ "",
+ f"Позиция {icon} {sign}$ {self._money(abs(pnl_value))}",
+ ])
- elif assessment == "осторожно":
- lines.append(f"{severity_icon} Позиция под риском")
- lines.append(f"• {side.capitalize()}")
- lines.append("• Требуется контроль")
-
- else:
- lines.append(f"{severity_icon} Риск позиции")
- lines.append(f"• {side.capitalize()}")
- lines.append("• Возможен выход")
-
- return "\n".join(lines)
-
- def _analytics_block(
- self,
- summary: dict[str, Any],
- runtime: dict[str, Any],
- execution: dict[str, Any],
- ) -> str:
+ def _analytics_block(self, summary: JsonDict, runtime: JsonDict, execution: JsonDict) -> str:
market_age = runtime.get("market_age_seconds")
signal_age = runtime.get("signal_age_seconds")
@@ -263,7 +402,7 @@ class SemanticDiagnosticFormatter:
return "\n".join(lines)
- def _execution_block(self, data: dict[str, Any]) -> str:
+ def _execution_block(self, data: JsonDict) -> str:
quality = str(data.get("quality") or "")
reason = str(
data.get("quality_reason")
@@ -274,6 +413,7 @@ class SemanticDiagnosticFormatter:
title = self._entry_conditions_title(
quality=quality,
semantic_status=data.get("semantic_status"),
+ reason=reason,
)
lines = [title]
@@ -284,7 +424,6 @@ class SemanticDiagnosticFormatter:
)
spread_line = self._spread_status_line(
- quality=quality,
reason=reason,
spread_percent=data.get("spread_percent"),
)
@@ -304,13 +443,25 @@ class SemanticDiagnosticFormatter:
*,
quality: str,
semantic_status: str | None = None,
+ reason: str | None = None,
) -> str:
+ reason_text = str(reason or "").upper()
+
if semantic_status == "POSITION_OPEN":
return "🟢 Вход · выполнен"
if semantic_status == "READY":
return "🟢 Условия входа · готовы"
+ if reason_text == "MARKET_CLOSED":
+ return "⛔️ Биржа · перерыв"
+
+ if semantic_status in {
+ "IDLE",
+ "WAITING_SIGNAL",
+ }:
+ return "🟡 Условия входа · ожидание"
+
if quality == "BLOCKED":
return "⛔️ Условия входа · заблокированы"
@@ -320,14 +471,13 @@ class SemanticDiagnosticFormatter:
if quality == "GOOD":
return "🟢 Условия входа · нормальные"
- return "⚪ Условия входа · не готовы"
+ return "🟡 Условия входа · ожидание"
def _spread_status_line(
self,
*,
- quality: str,
reason: str,
- spread_percent: object,
+ spread_percent: NumericLike | None,
) -> str:
if reason == "HIGH_SPREAD":
return "• Спред: блокирует вход"
@@ -335,9 +485,12 @@ class SemanticDiagnosticFormatter:
if reason == "WIDE_SPREAD":
return "• Спред: повышен"
- try:
- spread = float(spread_percent)
- except Exception:
+ if spread_percent is None:
+ return ""
+
+ spread = safe_float(spread_percent)
+
+ if spread is None:
return ""
if spread < 0.1:
@@ -345,14 +498,13 @@ class SemanticDiagnosticFormatter:
return f"• Спред: {spread:.3f}%"
- def _execution_data_state(self, value: object) -> str:
- if value is None:
+ def _execution_data_state(self, value: NumericLike | None) -> str:
+ seconds_float = safe_float(value)
+
+ if seconds_float is None:
return "нет данных"
- try:
- seconds = int(float(value))
- except Exception:
- return "неясно"
+ seconds = int(seconds_float)
if seconds <= 2:
return "live"
@@ -362,7 +514,7 @@ class SemanticDiagnosticFormatter:
return "устарели"
- def _signal_block(self, data: dict[str, Any]) -> str:
+ def _signal_block(self, data: JsonDict) -> str:
signal = str(data.get("signal") or "").upper()
try:
@@ -372,30 +524,22 @@ class SemanticDiagnosticFormatter:
except Exception:
progress = 0.0
+ title = self._signal_status_title(
+ signal=signal,
+ progress=progress,
+ )
+
lines = [
- (
- f"{self._signal_icon(signal, progress)} "
- f"Сигнал · "
- f"{self._signal_title(signal, progress)}"
- ),
+ title,
(
f"• Длительность: "
f"{self._duration(data.get('age_seconds'))}"
),
]
- # HOLD
- if signal == "HOLD":
- if progress > 0:
- lines.append(
- f"• Формирование: "
- f"{self._percent(progress)}"
- )
-
- # BUY / SELL
- elif signal in {"BUY", "SELL"}:
+ if signal in {"BUY", "SELL"}:
lines.append(
- f"• Готовность: "
+ f"• Подтверждение: "
f"{self._percent(progress)}"
)
@@ -405,33 +549,71 @@ class SemanticDiagnosticFormatter:
lines.append(explanation)
return "\n".join(lines)
-
- def _signal_title(
- self,
- value: object,
- progress: object = None,
- ) -> str:
- text = str(value or "").upper()
-
- try:
- progress_value = float(progress or 0.0)
- except Exception:
- progress_value = 0.0
-
- if text == "BUY":
- return "покупка"
-
- if text == "SELL":
- return "продажа"
-
- if text == "HOLD":
- if progress_value > 0:
- return "формируется"
- return "ожидание"
-
- return "нет"
- def _signal_explanation(self, data: dict[str, Any]) -> str:
+ def _signal_status_title(
+ self,
+ *,
+ signal: str,
+ progress: float,
+ ) -> str:
+ if signal == "BUY":
+ if progress >= 1.0:
+ return "🟢 Сигнал · Long"
+
+ return "🟡 Подтверждение сигнала"
+
+ if signal == "SELL":
+ if progress >= 1.0:
+ return "🔴 Сигнал · Short"
+
+ return "🟡 Подтверждение сигнала"
+
+ return "🟡 Ожидание сигнала"
+
+ def _momentum_title(
+ self,
+ *,
+ momentum_state: str,
+ strength: NumericLike | None = None,
+ ) -> str:
+ try:
+ strength_value = float(strength or 0.0)
+ except Exception:
+ strength_value = 0.0
+
+ if momentum_state == "BREAKOUT_UP":
+ return "🚀 Пробой вверх"
+
+ if momentum_state == "BREAKOUT_DOWN":
+ return "🚀 Пробой вниз"
+
+ if momentum_state == "MOMENTUM_UP":
+ return "⚡ Импульс вверх"
+
+ if momentum_state == "MOMENTUM_DOWN":
+ return "⚡ Импульс вниз"
+
+ if momentum_state == "EXHAUSTED":
+ return "⚠️ Импульс затухает"
+
+ if momentum_state in {"NONE", "NO_SIGNIFICANT_MOMENTUM"}:
+ if strength_value >= 1.0:
+ return "⚠️ Импульс затухает"
+
+ return "🟡 Импульса нет"
+
+ return "🟡 Импульс анализируется"
+
+ def _position_side_title(self, side: str) -> str:
+ if side == "LONG":
+ return "Long"
+
+ if side == "SHORT":
+ return "Short"
+
+ return side
+
+ def _signal_explanation(self, data: JsonDict) -> str:
signal = str(data.get("signal") or "").upper()
reason = str(data.get("reason") or "").strip()
reason_upper = reason.upper()
@@ -558,7 +740,7 @@ class SemanticDiagnosticFormatter:
return "\n".join(reasons[:2])
- def _market_block(self, data: dict[str, Any]) -> str:
+ def _market_block(self, data: JsonDict) -> str:
state = data.get("state")
trend = data.get("trend")
strength = data.get("trend_strength")
@@ -566,6 +748,7 @@ class SemanticDiagnosticFormatter:
phase_direction = data.get("phase_direction")
quality = data.get("trend_quality")
volatility = data.get("volatility")
+ market_closed = data.get("market_is_open") is False
lines = [
(
@@ -579,6 +762,16 @@ class SemanticDiagnosticFormatter:
),
]
+ if market_closed:
+ lines.append("• Биржа: перерыв")
+ lines.append(
+ str(
+ data.get("market_status_message")
+ or "Торги временно остановлены"
+ )
+ )
+ return "\n".join(lines)
+
if state == "RANGE" or phase == "RANGE":
lines.append("• Вход: ожидание")
@@ -606,26 +799,54 @@ class SemanticDiagnosticFormatter:
if quality_line:
lines.append(quality_line)
+ advanced_line = self._advanced_trend_quality_line(data)
+ if advanced_line:
+ lines.append(advanced_line)
+
explanation = self._market_explanation(data)
if explanation:
lines.append(explanation)
return "\n".join(lines)
- def _market_title(self, data: dict[str, Any]) -> str:
+ def _market_title(self, data: JsonDict) -> str:
+
+ if data.get("market_is_open") is False:
+ return "перерыв"
+
state = str(data.get("state") or "")
phase = str(data.get("phase") or "")
trend = str(data.get("trend") or "")
+ quality = str(data.get("trend_quality") or "")
+ strength = str(data.get("trend_strength") or "")
- # флэт важнее всего
+ # флэт
if state == "RANGE" or phase == "RANGE":
return "флэт"
- # откат важнее тренда
+ # откат
if phase == "PULLBACK":
return "откат"
- # импульс — это текущая фаза, но заголовок рынка оставляем по общему тренду
+ # шумный рынок
+ if quality == "NOISY":
+ if trend == "UP":
+ return "шумный рост"
+
+ if trend == "DOWN":
+ return "шумное снижение"
+
+ return "шум"
+
+ # слабый тренд
+ if strength == "WEAK":
+ if trend == "UP":
+ return "слабый рост"
+
+ if trend == "DOWN":
+ return "слабое снижение"
+
+ # импульс
if phase == "IMPULSE":
if trend == "UP":
return "рост"
@@ -703,69 +924,108 @@ class SemanticDiagnosticFormatter:
if text == "NOISY":
return "• Качество: шум"
+ if text == "NORMAL":
+ return "• Качество: нормальное"
+
if text == "CLEAN":
- return "• Качество: чистый"
+ return "• Качество: чистое"
return ""
- def _momentum_block(self, data: dict[str, Any]) -> str:
- momentum_state = data.get("state")
+ def _momentum_block(self, data: JsonDict) -> str:
+ momentum_state = str(data.get("state") or "")
+ direction = self._human(data.get("direction"))
+
+ has_live_data = data.get("has_live_data")
+ change_percent = data.get("change_percent")
+ strength = data.get("strength")
+
+ if (
+ has_live_data is False
+ or change_percent is None
+ or strength is None
+ ):
+ lines = [
+ "⚪️ Импульс · нет данных",
+ "• Недостаточно live-данных",
+ ]
+
+ reason = data.get("reason") or data.get("message")
+
+ if reason:
+ lines.append(
+ f"• {self._short_reason(str(reason))}"
+ )
+
+ return "\n".join(lines)
lines = [
- (
- f"{self._momentum_icon(momentum_state)} "
- f"Импульс · "
- f"{self._human(momentum_state)}"
+ self._momentum_title(
+ momentum_state=momentum_state,
+ strength=strength,
),
- f"• Направление: {self._human(data.get('direction'))}",
+ f"• Направление: {direction}",
f"• Пробой: {self._bool(data.get('is_breakout'))}",
- f"• Сила: {self._momentum_strength(data.get('strength'))}",
- f"• Движение: {self._percent_value(data.get('change_percent'))}",
]
- breakout_level = self._float(data.get("breakout_level"))
+ breakout_level = self._float(
+ data.get("breakout_level")
+ )
if breakout_level != "—":
- lines.insert(3, f"• Уровень: {breakout_level}")
+ formatted_breakout_level = self._price_value(
+ breakout_level,
+ is_usd=True,
+ )
+
+ lines.append(
+ f"• Цель пробоя: {formatted_breakout_level}"
+ )
breakout_distance = self._percent_value(
data.get("breakout_distance_percent")
)
if breakout_distance != "—":
- lines.insert(4, f"• Дистанция: {breakout_distance}")
+ lines.append(
+ f"• Дистанция: {breakout_distance}"
+ )
+
+ lines.extend([
+ f"• Сила: {self._momentum_strength(strength, momentum_state=momentum_state, direction=data.get('direction'))}",
+ f"• Движение: {self._percent_value(change_percent)}",
+ ])
return "\n".join(lines)
- def _has_position(self, data: dict[str, Any]) -> bool:
+ def _has_position(self, data: JsonDict) -> bool:
return str(data.get("side") or "NONE").upper() != "NONE"
- def _position_block(self, data: dict[str, Any]) -> str:
- import time
-
+ def _position_block(self, data: JsonDict) -> str:
side = str(data.get("side") or "NONE").upper()
entry_price = data.get("entry_price")
+ current_price = data.get("current_price") or entry_price
size = data.get("size")
unrealized_pnl = data.get("unrealized_pnl_usd")
-
leverage = data.get("leverage") or 2
- flip_old_side = data.get("last_flip_old_side")
- flip_new_side = data.get("last_flip_new_side")
- flip_pnl = data.get("last_flip_pnl_usd")
- flip_reason = data.get("last_flip_reason")
- flip_at = data.get("last_flip_monotonic_at")
+ adaptive_multiplier = data.get("adaptive_size_multiplier")
+ adaptive_warning = ""
- cycle_pnl = float(data.get("cycle_realized_pnl_usd") or 0.0)
+ adaptive_value = safe_float(adaptive_multiplier)
+
+ if adaptive_value is not None and abs(adaptive_value - 1.0) > 0.001:
+ adaptive_warning = " ⚠️"
lines = [
(
f"{self._position_icon(side)} "
- f"Позиция · {side} x{leverage}"
+ f"Позиция · {self._position_side_title(side)} x{leverage}"
),
- f"• {self._pnl_line(unrealized_pnl)}",
- f"• Вход: $ {self._money(entry_price)}",
- f"• Размер: {self._size_value(size)}",
- f"• Объём: {self._position_notional(entry_price, size)}",
+ self._pnl_line(unrealized_pnl),
+ f"Вход · $ {self._money(entry_price)}",
+ f"Цена · $ {self._money(current_price)}",
+ f"Размер{adaptive_warning} {self._size_value(size)}",
+ f"Объём · {self._position_notional(current_price, size)}",
]
risk_line = self._position_risk_line(data)
@@ -773,75 +1033,52 @@ class SemanticDiagnosticFormatter:
if risk_line:
lines.append(risk_line)
- is_recent_flip = False
-
- if flip_at is not None:
- try:
- is_recent_flip = (time.monotonic() - float(flip_at)) <= 600
- except Exception:
- pass
-
- if (
- is_recent_flip
- and flip_old_side
- and flip_new_side
- and flip_old_side != flip_new_side
- ):
- old_icon = self._position_icon(flip_old_side)
- new_icon = self._position_icon(flip_new_side)
-
- lines.append(
- f"Разворот {old_icon} {flip_old_side} → "
- f"{new_icon} {flip_new_side}"
- )
-
- if self._has_closed_pnl(flip_pnl):
- lines.append(
- f"• {self._pnl_line(flip_pnl)}"
- )
-
- if flip_reason:
- lines.append(
- self._short_reason(str(flip_reason))
- )
-
- else:
- reason = str(data.get("last_execution_reason") or "").strip()
-
- if reason:
- lines.append(
- self._short_reason(reason)
- )
-
- if self._has_closed_pnl(cycle_pnl):
- pnl_text = self._pnl_line(cycle_pnl)
-
- if cycle_pnl > 0:
- lines.append(
- f"Предыдущая прибыль {pnl_text.replace('Прибыль ', '')}"
- )
- else:
- lines.append(
- f"Предыдущий убыток {pnl_text.replace('Убыток ', '')}"
- )
-
return "\n".join(lines).strip()
- def _has_closed_pnl(value: Any) -> bool:
- try:
- return abs(float(value)) > 0.0001
- except Exception:
+
+ def _position_risk_line(self, data: JsonDict) -> str:
+ items: list[str] = []
+
+ sl = data.get("stop_loss_usd")
+ tp = data.get("take_profit_usd")
+ ml = data.get("max_loss_usd")
+
+ if sl is not None:
+ items.append(f"SL -$ {self._money(sl)}")
+
+ if tp is not None:
+ items.append(f"TP +$ {self._money(tp)}")
+
+ if ml is not None:
+ should_show_ml = True
+
+ if sl is not None:
+ try:
+ should_show_ml = abs(float(ml) - float(sl)) > 1.0
+ except Exception:
+ should_show_ml = True
+
+ if should_show_ml:
+ items.append(f"ML -$ {self._money(ml)}")
+
+ if not items:
+ return ""
+
+ return " · ".join(items)
+
+ def _has_closed_pnl(self, value: NumericLike | None) -> bool:
+ number = safe_float(value)
+
+ if number is None:
return False
+
+ return abs(number) > 0.0001
- def _has_adaptive_size(self, data: dict[str, Any]) -> bool:
+ def _has_adaptive_size(self, data: JsonDict) -> bool:
multiplier_raw = data.get("multiplier")
+ value = safe_float(multiplier_raw)
- if multiplier_raw is None:
- return False
-
- try:
- value = float(multiplier_raw)
- except (TypeError, ValueError):
+ if value is None:
return False
if value < 0.95:
@@ -854,15 +1091,10 @@ class SemanticDiagnosticFormatter:
return "margin" in reason
- def _adaptive_block(self, data: dict[str, Any]) -> str:
- multiplier_raw = data.get("multiplier")
+ def _adaptive_block(self, data: JsonDict) -> str:
+ multiplier = safe_float(data.get("multiplier"))
- if multiplier_raw is None:
- return ""
-
- try:
- multiplier = float(multiplier_raw)
- except (TypeError, ValueError):
+ if multiplier is None:
return ""
return (
@@ -874,7 +1106,7 @@ class SemanticDiagnosticFormatter:
f"{self._adaptive_reason_line(data)}"
).strip()
- def _status_block(self, data: dict[str, Any]) -> str:
+ def _status_block(self, data: JsonDict) -> str:
status = str(data.get("status") or "")
if status == "RUNNING":
@@ -897,7 +1129,7 @@ class SemanticDiagnosticFormatter:
f"• Настроено: {self._bool(data.get('is_configured'))}"
)
- def _execution_explanation(self, data: dict[str, Any]) -> str:
+ def _execution_explanation(self, data: JsonDict) -> str:
quality = str(data.get("quality") or "")
reason = str(
data.get("quality_reason")
@@ -906,10 +1138,18 @@ class SemanticDiagnosticFormatter:
)
semantic_status = str(data.get("semantic_status") or "")
- # нормальные состояния — молчим
+ if semantic_status in {
+ "IDLE",
+ "WAITING_SIGNAL",
+ }:
+ return "Ожидание подходящего входа."
+
if semantic_status in {"POSITION_OPEN", "READY"}:
return ""
+ if reason == "MARKET_CLOSED":
+ return "Биржа временно недоступна для торговли."
+
if quality == "GOOD":
return ""
@@ -939,7 +1179,7 @@ class SemanticDiagnosticFormatter:
return "Условия входа ещё не сформированы."
- def _market_explanation(self, data: dict[str, Any]) -> str:
+ def _market_explanation(self, data: JsonDict) -> str:
state = str(data.get("state") or "")
trend = str(data.get("trend") or "")
strength = str(data.get("trend_strength") or "")
@@ -1086,7 +1326,7 @@ class SemanticDiagnosticFormatter:
return "\n".join(reasons[:2])
- def _human(self, value: object) -> str:
+ def _human(self, value: Any) -> str:
if value is None:
return "—"
@@ -1137,6 +1377,19 @@ class SemanticDiagnosticFormatter:
"TREND_UP": "рост",
"UNKNOWN": "неясно",
"WEAK_MARKET_TREND": "слабый тренд",
+ "CHASING": "chasing",
+ "COMPRESSED": "EMA сжаты",
+ "EARLY": "ранний вход",
+ "EMA_COMPRESSED": "EMA сжаты",
+ "EMA_OVEREXTENDED": "EMA перерастянуты",
+ "EXTENDED": "расширен",
+ "HEALTHY": "здоровая дистанция",
+ "LATE": "поздний вход",
+ "OVEREXTENDED": "перерастянут",
+ "BREAKOUT_ALREADY_EXTENDED": "пробой уже растянут",
+ "HEALTHY_TREND_DISTANCE": "здоровая EMA-дистанция",
+ "PULLBACK_ENTRY_ZONE": "зона входа на откате",
+ "ENTRY_TIMING_UNKNOWN": "тайминг неясен",
# === MOMENTUM ===
"DOWN": "вниз",
@@ -1177,43 +1430,90 @@ class SemanticDiagnosticFormatter:
"IDLE": "пауза",
# === POSITION ===
- "LONG": "лонг",
+ "LONG": "Long",
"NONE": "нет",
"POSITION_OPEN": "позиция открыта",
- "SHORT": "шорт",
+ "SHORT": "Short",
}
return mapping.get(text, text)
def _bool(self, value: object) -> str:
+ if isinstance(value, bool):
+ return "да" if value else "нет"
+
+ text = str(value or "").strip().lower()
+
+ if text in {"true", "1", "yes", "да"}:
+ return "да"
+
+ if text in {"false", "0", "no", "нет", "", "none", "null"}:
+ return "нет"
+
return "да" if bool(value) else "нет"
- def _float(self, value: object) -> str:
- if value is None:
+ def _float(self, value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "—"
- try:
- return f"{float(value):.4f}"
- except Exception:
- return str(value)
+ return f"{number:.4f}"
- def _percent(self, value: object) -> str:
- if value is None:
+
+ def _percent(self, value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "—"
- try:
- return f"{float(value) * 100:.0f}%"
- except Exception:
- return str(value)
+ return f"{number * 100:.0f}%"
- def _percent_value(self, value: object) -> str:
- if value is None:
+
+ def _percent_value(self, value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
return "—"
- try:
- return f"{float(value):.3f}%"
- except Exception:
- return str(value)
+ return f"{number:.3f}%"
+
+
+ def _money(self, value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
+ return "—"
+
+ return f"{number:.2f}"
+
+
+ def _price_value(
+ self,
+ value: NumericLike | None,
+ *,
+ is_usd: bool = False,
+ ) -> str:
+ number = safe_float(value)
+
+ if number is None:
+ return "—"
+
+ formatted = f"{number:,.2f}".replace(",", " ")
+
+ if is_usd:
+ return f"${formatted}"
+
+ return formatted
+
+
+ def _size_value(self, value: NumericLike | None) -> str:
+ number = safe_float(value)
+
+ if number is None:
+ return "—"
+
+ return f"{number:.5f}".rstrip("0").rstrip(".")
def _severity_icon(self, value: object) -> str:
text = str(value or "")
@@ -1229,18 +1529,9 @@ class SemanticDiagnosticFormatter:
return "⚪"
- def _signal_icon(
- self,
- value: object,
- progress: object = None,
- ) -> str:
+ def _signal_icon(self, value: object) -> str:
text = str(value or "").upper()
- try:
- progress_value = float(progress or 0.0)
- except Exception:
- progress_value = 0.0
-
if text == "BUY":
return "🟢"
@@ -1248,9 +1539,7 @@ class SemanticDiagnosticFormatter:
return "🔴"
if text == "HOLD":
- if progress_value > 0:
- return "🟡"
- return "⚪️"
+ return "🟡"
return "⚪️"
@@ -1281,61 +1570,24 @@ class SemanticDiagnosticFormatter:
return "⚪"
return "⚪"
-
- def _money(self, value: object) -> str:
- if value is None:
- return "—"
- try:
- return f"{float(value):.2f}"
- except Exception:
- return str(value)
+ def _position_notional(
+ self,
+ entry_price: NumericLike | None,
+ size: NumericLike | None,
+ ) -> str:
+ price = safe_float(entry_price)
+ amount = safe_float(size)
-
- def _size_value(self, value: object) -> str:
- if value is None:
- return "—"
-
- try:
- return f"{float(value):.5f}".rstrip("0").rstrip(".")
- except Exception:
- return str(value)
-
-
- def _position_notional(self, entry_price: object, size: object) -> str:
- try:
- value = float(entry_price) * float(size)
- except Exception:
+ if price is None or amount is None:
return "$ —"
- return f"$ {value:.1f}"
-
- def _position_risk_line(self, data: dict[str, Any]) -> str:
- items: list[str] = []
+ return f"$ {price * amount:.1f}"
- sl = data.get("stop_loss_usd")
- tp = data.get("take_profit_usd")
- ml = data.get("max_loss_usd")
+ def _pnl_value(self, value: NumericLike | None) -> str:
+ pnl = safe_float(value)
- if sl is not None:
- items.append(f"SL −$ {self._money(sl)}")
-
- if tp is not None:
- items.append(f"TP +$ {self._money(tp)}")
-
- if ml is not None:
- items.append(f"ML −$ {self._money(ml)}")
-
- if not items:
- return ""
-
- return "• " + " · ".join(items)
-
-
- def _pnl_value(self, value: object) -> str:
- try:
- pnl = float(value or 0.0)
- except Exception:
+ if pnl is None:
return "—"
if pnl > 0:
@@ -1346,11 +1598,10 @@ class SemanticDiagnosticFormatter:
return "$ 0.00"
+ def _pnl_line(self, value: NumericLike | None) -> str:
+ pnl = safe_float(value)
- def _pnl_line(self, value: object) -> str:
- try:
- pnl = float(value or 0.0)
- except Exception:
+ if pnl is None:
return "PnL: —"
if pnl > 0:
@@ -1361,7 +1612,6 @@ class SemanticDiagnosticFormatter:
return "PnL: $ 0.00"
-
def _position_action_line(self, action: str) -> str:
if action in {"OPEN_LONG", "OPEN_SHORT"}:
return "Новая позиция открыта"
@@ -1374,11 +1624,10 @@ class SemanticDiagnosticFormatter:
return ""
+ def _adaptive_icon(self, multiplier: NumericLike | None) -> str:
+ value = safe_float(multiplier)
- def _adaptive_icon(self, multiplier: object) -> str:
- try:
- value = float(multiplier)
- except Exception:
+ if value is None:
return "⚠️"
if value == 1:
@@ -1386,11 +1635,10 @@ class SemanticDiagnosticFormatter:
return "⚠️"
+ def _adaptive_title(self, multiplier: NumericLike | None) -> str:
+ value = safe_float(multiplier)
- def _adaptive_title(self, multiplier: object) -> str:
- try:
- value = float(multiplier)
- except Exception:
+ if value is None:
return "неясен"
if value < 1:
@@ -1401,8 +1649,7 @@ class SemanticDiagnosticFormatter:
return "по настройкам"
-
- def _adaptive_reason_line(self, data: dict[str, Any]) -> str:
+ def _adaptive_reason_line(self, data: JsonDict) -> str:
reason = self._human(data.get("reason"))
if reason in {"—", "нет"}:
@@ -1410,12 +1657,18 @@ class SemanticDiagnosticFormatter:
return reason[:1].upper() + reason[1:]
- def _market_icon(self, data: dict[str, Any]) -> str:
+ def _market_icon(self, data: JsonDict) -> str:
+ if data.get("market_is_open") is False:
+ return "⛔️"
+
state = str(data.get("state") or "")
strength = str(data.get("trend_strength") or "")
quality = str(data.get("trend_quality") or "")
phase = str(data.get("phase") or "")
volatility = str(data.get("volatility") or "")
+
+ consistency = safe_float(data.get("trend_consistency"))
+
entry_block_reason = str(data.get("entry_block_reason") or "")
entry_block_message = str(data.get("entry_block_message") or "")
@@ -1425,64 +1678,70 @@ class SemanticDiagnosticFormatter:
.lower()
)
- # Флэт сам по себе — ожидание, не блокировка.
- if state == "RANGE" or phase == "RANGE":
- return "⚪️"
-
- # Жёсткая блокировка входа рынком.
- if "market_filter_blocked" in entry_block_text:
+ if state == "HIGH_VOLATILITY" or volatility == "HIGH_VOLATILITY":
return "⛔️"
if (
- "блок" in entry_block_text
+ "market_filter_blocked" in entry_block_text
+ or "блок" in entry_block_text
or "не подходит" in entry_block_text
or "высок" in entry_block_text
or "перегрев" in entry_block_text
):
return "⛔️"
- if state == "HIGH_VOLATILITY" or volatility == "HIGH_VOLATILITY":
- return "⛔️"
-
if state == "UNKNOWN":
return "⚪️"
- if phase == "SQUEEZE":
- return "⚪️"
-
- if entry_block_text:
+ if state == "RANGE" or phase == "RANGE":
return "🟡"
- if phase == "PULLBACK" or strength == "WEAK" or quality == "NOISY":
+ if phase == "SQUEEZE":
+ return "🟡"
+
+ if phase == "PULLBACK":
+ return "🟡"
+
+ if strength == "WEAK":
+ return "🟡"
+
+ if quality == "NOISY":
+ return "🟡"
+
+ if consistency is not None and consistency < 0.4:
+ return "🟡"
+
+ if entry_block_text:
return "🟡"
if state in {"TREND_UP", "TREND_DOWN"}:
return "🟢"
- return "⚪️"
+ return "🟡"
- def _momentum_icon(self, value: object) -> str:
- text = str(value or "")
+ def _momentum_strength(
+ self,
+ value: NumericLike | None,
+ *,
+ momentum_state: object | None = None,
+ direction: object | None = None,
+ ) -> str:
+ strength = safe_float(value)
- if text in {"BREAKOUT_UP", "BREAKOUT_DOWN"}:
- return "🚀"
-
- if text in {"MOMENTUM_UP", "MOMENTUM_DOWN"}:
- return "⚡️"
-
- if text == "EXHAUSTED":
- return "⛔️"
-
- return "⚪"
-
- def _momentum_strength(self, value: object) -> str:
- if value is None:
+ if strength is None:
return "—"
- try:
- strength = float(value)
- except Exception:
- return str(value)
+ state_text = str(momentum_state or "").upper()
+ direction_text = str(direction or "").upper()
+
+ if (
+ state_text in {"NONE", "NO_SIGNIFICANT_MOMENTUM"}
+ or direction_text in {"FLAT", "РОВНО"}
+ ):
+ if strength >= 1.0:
+ return "затухание"
+
+ return "слабая"
if strength < 0.3:
label = "слабая"
@@ -1533,14 +1792,13 @@ class SemanticDiagnosticFormatter:
return text
- def _market_live_state(self, value: object) -> str:
- if value is None:
+ def _market_live_state(self, value: NumericLike | None) -> str:
+ seconds_float = safe_float(value)
+
+ if seconds_float is None:
return "нет данных"
- try:
- seconds = int(float(value))
- except Exception:
- return str(value)
+ seconds = int(seconds_float)
if seconds <= 10:
return "live"
@@ -1550,14 +1808,13 @@ class SemanticDiagnosticFormatter:
return "устарели"
- def _duration(self, value: object) -> str:
- if value is None:
+ def _duration(self, value: NumericLike | None) -> str:
+ seconds_float = safe_float(value)
+
+ if seconds_float is None:
return "—"
- try:
- total_seconds = max(0, int(float(value)))
- except Exception:
- return str(value)
+ total_seconds = max(0, int(seconds_float))
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
@@ -1592,4 +1849,38 @@ class SemanticDiagnosticFormatter:
base, quote = text.split("/", 1)
return f"{base} / {quote}"
- return text
\ No newline at end of file
+ return text
+
+ def _advanced_trend_quality_line(self, data: JsonDict) -> str:
+ efficiency = safe_float(data.get("trend_efficiency"))
+ ema_distance = safe_float(data.get("ema_distance_atr_ratio"))
+ consistency = safe_float(data.get("trend_consistency"))
+ quality_score = safe_float(data.get("trend_quality_score"))
+
+ ema_distance_state = str(data.get("ema_distance_state") or "")
+ entry_timing_state = str(data.get("entry_timing_state") or "")
+
+ items: list[str] = []
+
+ if quality_score is not None:
+ items.append(f"score {quality_score:.2f}")
+
+ if consistency is not None:
+ items.append(f"стабильность {consistency:.2f}")
+
+ if efficiency is not None:
+ items.append(f"эффективность {efficiency:.2f}")
+
+ if ema_distance is not None:
+ items.append(f"EMA/ATR {ema_distance:.2f}")
+
+ if ema_distance_state:
+ items.append(self._human(ema_distance_state))
+
+ if entry_timing_state:
+ items.append(self._human(entry_timing_state))
+
+ if not items:
+ return ""
+
+ return "• Структура: " + " · ".join(items[:4])
\ No newline at end of file
diff --git a/app/src/trading/diagnostics/snapshot.py b/app/src/trading/diagnostics/snapshot.py
index b9e4634..c93344c 100644
--- a/app/src/trading/diagnostics/snapshot.py
+++ b/app/src/trading/diagnostics/snapshot.py
@@ -6,6 +6,7 @@ import time
from typing import Any
from src.trading.auto.state import AutoTradeState
+from src.core.numbers import safe_float
class SemanticDiagnosticSnapshotBuilder:
@@ -30,6 +31,8 @@ class SemanticDiagnosticSnapshotBuilder:
blockers=blockers,
)
+ position_current_price = self._position_current_price(state)
+
return {
"status": {
"status": state.status,
@@ -59,6 +62,27 @@ class SemanticDiagnosticSnapshotBuilder:
"entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message,
"age_seconds": market_age_seconds,
+ "market_is_open": state.market_is_open,
+ "market_status": state.market_status,
+ "market_status_message": state.market_status_message,
+ "market_status_updated_at": state.market_status_updated_at,
+ "trend_gap_percent": state.market_trend_gap_percent,
+ "trend_consistency": state.market_trend_consistency,
+ "trend_efficiency": state.market_trend_efficiency,
+ "trend_quality_score": state.trend_quality_score,
+ "ema_distance_atr_ratio": state.ema_distance_atr_ratio,
+ "ema_distance_state": state.ema_distance_state,
+ "entry_timing_state": state.entry_timing_state,
+ "entry_timing_reason": state.entry_timing_reason,
+ "ema_fast_slope_percent": state.ema_fast_slope_percent,
+ "ema_slow_slope_percent": state.ema_slow_slope_percent,
+ "candle_noise_score": state.candle_noise_score,
+ "price_position_score": state.price_position_score,
+ "htf_interval": state.htf_interval,
+ "htf_atr_percent": state.htf_atr_percent,
+ "htf_atr_percent_baseline": state.htf_atr_percent_baseline,
+ "htf_volatility_ratio": state.htf_volatility_ratio,
+ "htf_volatility": state.htf_volatility,
},
"momentum": {
"state": getattr(state, "momentum_state", None),
@@ -113,6 +137,11 @@ class SemanticDiagnosticSnapshotBuilder:
"last_flip_pnl_usd": state.last_flip_pnl_usd,
"last_flip_reason": state.last_flip_reason,
"last_flip_monotonic_at": state.last_flip_monotonic_at,
+ "current_price": position_current_price,
+ "adaptive_size_multiplier": state.adaptive_size_multiplier,
+ "stop_loss_usd": state.effective_target_risk_usd,
+ "take_profit_usd": self._take_profit_usd(state),
+ "max_loss_usd": state.max_loss_usd,
},
"runtime_health": {
"health_score": health_score,
@@ -151,19 +180,52 @@ class SemanticDiagnosticSnapshotBuilder:
"is_ready": state.is_signal_ready,
"is_blocked": bool(blockers),
"blockers": blockers,
+ "symbol": state.symbol,
},
}
+ def _position_current_price(
+ self,
+ state: AutoTradeState,
+ ) -> float | None:
+ if state.position_side == "NONE":
+ return None
+
+ try:
+ from src.integrations.exchange.service import ExchangeService
+
+ snapshot = ExchangeService().get_market_snapshot(
+ state.symbol,
+ runtime_key="auto",
+ )
+
+ side = str(state.position_side or "").upper()
+
+ price = snapshot.get("last_price")
+
+ if side == "LONG":
+ price = snapshot.get("bid_price") or price
+
+ elif side == "SHORT":
+ price = snapshot.get("ask_price") or price
+
+ return safe_float(price)
+
+ except Exception:
+ return None
+
def _age_seconds(
self,
*,
now: float,
started_at: float | None,
) -> int | None:
- if started_at is None:
+ started = safe_float(started_at)
+
+ if started is None:
return None
- return max(0, int(now - float(started_at)))
+ return max(0, int(now - started))
def _is_runtime_degraded(self, state: AutoTradeState) -> bool:
return bool(
@@ -203,6 +265,28 @@ class SemanticDiagnosticSnapshotBuilder:
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
score -= 10
+ if state.ema_distance_state == "COMPRESSED":
+ score -= 10
+
+ if state.ema_distance_state == "EXTENDED":
+ score -= 8
+
+ if state.ema_distance_state == "OVEREXTENDED":
+ score -= 25
+
+ if state.entry_timing_state == "LATE":
+ score -= 18
+
+ if state.entry_timing_state == "CHASING":
+ score -= 30
+
+ trend_quality_score = safe_float(state.trend_quality_score)
+ if trend_quality_score is not None:
+ if trend_quality_score < 0.45:
+ score -= 12
+ elif trend_quality_score >= 0.7:
+ score += 5
+
if state.market_runtime_degraded:
score -= 15
@@ -225,6 +309,9 @@ class SemanticDiagnosticSnapshotBuilder:
has_ready_signal = bool(state.is_signal_ready)
has_position = state.position_side != "NONE"
+ if state.market_is_open is False:
+ return "RED"
+
has_waiting_data_blocker = any(
str(item).strip().lower()
in {
@@ -258,6 +345,12 @@ class SemanticDiagnosticSnapshotBuilder:
return "WAITING"
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
+ if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
+ return "RED"
+
+ if state.market_trend_quality == "NOISY":
+ return "RED"
+
return "YELLOW"
if health_score < 45:
@@ -301,6 +394,9 @@ class SemanticDiagnosticSnapshotBuilder:
state: AutoTradeState,
blockers: list[str],
) -> str:
+ if state.market_is_open is False:
+ return state.market_status_message or "Биржа временно недоступна для торговли."
+
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
return "Ожидание: рынок без направления."
@@ -341,6 +437,13 @@ class SemanticDiagnosticSnapshotBuilder:
def _blockers(self, state: AutoTradeState) -> list[str]:
blockers: list[str] = []
+ if state.market_is_open is False:
+ blockers.append(
+ state.market_status_message
+ or "рынок закрыт"
+ )
+ return blockers
+
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
blockers.append("рынок без направления")
@@ -349,7 +452,17 @@ class SemanticDiagnosticSnapshotBuilder:
else:
blockers.append("рынок не подходит")
- return blockers
+ if state.ema_distance_state == "COMPRESSED":
+ blockers.append("EMA слишком сжаты")
+
+ if state.ema_distance_state == "OVEREXTENDED":
+ blockers.append("тренд перерастянут")
+
+ if state.entry_timing_state == "LATE":
+ blockers.append("поздний вход")
+
+ if state.entry_timing_state == "CHASING":
+ blockers.append("вход запрещён: chasing move")
if state.entry_block_message:
blockers.append(str(state.entry_block_message))
@@ -363,4 +476,26 @@ class SemanticDiagnosticSnapshotBuilder:
if state.runtime_expired_message:
blockers.append(str(state.runtime_expired_message))
- return blockers
\ No newline at end of file
+ result: list[str] = []
+ for item in blockers:
+ if item and item not in result:
+ result.append(item)
+
+ return result
+
+ def _take_profit_usd(self, state: AutoTradeState) -> float | None:
+ take_profit_percent = safe_float(state.take_profit_percent)
+ position_size = safe_float(state.position_size)
+ entry_price = safe_float(state.entry_price)
+
+ if (
+ take_profit_percent is None
+ or position_size is None
+ or position_size <= 0
+ or entry_price is None
+ or entry_price <= 0
+ ):
+ return None
+
+ move = entry_price * (take_profit_percent / 100)
+ return move * position_size
\ No newline at end of file
diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py
index 04f9d1a..0e91d2d 100644
--- a/app/src/trading/execution/engine.py
+++ b/app/src/trading/execution/engine.py
@@ -13,6 +13,8 @@ from src.trading.auto.state import AutoTradeState
from src.trading.execution.models import ExecutionDecision
from src.trading.journal.service import JournalService
from src.trading.position.state import PositionState
+from src.core.numbers import safe_float
+from src.core.types import JsonDict, NumericLike
@dataclass(slots=True)
@@ -108,7 +110,7 @@ class ExecutionEngine:
final_size=size,
)
- size = self._round_order_size(size)
+ size = self._round_size(size)
if size <= 0:
return ExecutionDecision(
@@ -134,7 +136,7 @@ class ExecutionEngine:
state.last_execution_action = action
state.last_execution_reason = f"Позиция {side} открыта."
- payload = {
+ payload: JsonDict = {
"execution_type": "ENTRY",
"action": action,
"symbol": state.symbol,
@@ -218,7 +220,7 @@ class ExecutionEngine:
final_size=new_size,
)
- new_size = self._round_order_size(new_size)
+ new_size = self._round_size(new_size)
if new_size <= 0:
return ExecutionDecision(
@@ -230,11 +232,10 @@ class ExecutionEngine:
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
- state.last_flip_old_side = old_side
- state.last_flip_new_side = new_side
- state.last_flip_pnl_usd = pnl
- state.last_flip_reason = state.last_signal_reason
- state.last_flip_monotonic_at = time.monotonic()
+ state.cycle_closed_trades += 1
+
+ if pnl > 0:
+ state.cycle_winning_trades += 1
old_side = position.side
old_entry_price = position.entry_price
@@ -242,6 +243,12 @@ class ExecutionEngine:
old_leverage = position.leverage
old_opened_at = position.opened_at
+ state.last_flip_old_side = old_side
+ state.last_flip_new_side = new_side
+ state.last_flip_pnl_usd = pnl
+ state.last_flip_reason = state.last_signal_reason
+ state.last_flip_monotonic_at = time.monotonic()
+
type(self)._position = PositionState(
side=new_side,
symbol=state.symbol,
@@ -261,7 +268,7 @@ class ExecutionEngine:
state.last_flip_at = now
type(self)._last_flip_block_key = None
- payload = {
+ payload: JsonDict = {
"execution_type": "FLIP",
"action": f"FLIP_{old_side}_TO_{new_side}",
"symbol": state.symbol,
@@ -326,8 +333,8 @@ class ExecutionEngine:
state: AutoTradeState,
*,
forced_reason: str | None = None,
- forced_exit_price: float | None = None,
- forced_pnl: float | None = None,
+ forced_exit_price: NumericLike | None = None,
+ forced_pnl: NumericLike | None = None,
forced_price_meta: _ExecutionPrice | None = None,
) -> ExecutionDecision:
position = type(self)._position
@@ -337,7 +344,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.")
if forced_exit_price is not None:
- exit_price = forced_exit_price
+ exit_price = safe_float(forced_exit_price) or 0.0
exit_execution = forced_price_meta
else:
try:
@@ -346,14 +353,26 @@ class ExecutionEngine:
except Exception as exc:
return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}")
- pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price)
+ pnl = (
+ safe_float(forced_pnl)
+ if forced_pnl is not None
+ else self._calculate_pnl(exit_price)
+ )
+
+ if pnl is None:
+ pnl = 0.0
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
+ state.cycle_closed_trades += 1
+
+ if pnl > 0:
+ state.cycle_winning_trades += 1
+
now = self._now_time()
- payload = {
+ payload: JsonDict = {
"execution_type": "EXIT",
"action": "CLOSE",
"symbol": state.symbol,
@@ -413,7 +432,7 @@ class ExecutionEngine:
f"Позиция закрыта по правилу защиты: {forced_reason}.",
)
-
+
return ExecutionDecision("CLOSE", True, "Позиция закрыта.")
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
@@ -475,18 +494,24 @@ class ExecutionEngine:
return False
return unrealized_pnl <= -abs(state.max_loss_usd)
- def _calculate_price_move_percent(self, current_price: float) -> float:
+ def _calculate_price_move_percent(
+ self,
+ current_price: NumericLike | None,
+ ) -> float:
position = type(self)._position
- entry = position.entry_price or 0.0
+ price = safe_float(current_price) or 0.0
+
+ entry = safe_float(position.entry_price) or 0.0
+
if entry <= 0:
return 0.0
if position.side == "LONG":
- return round(((current_price - entry) / entry) * 100, 4)
+ return round(((price - entry) / entry) * 100, 4)
if position.side == "SHORT":
- return round(((entry - current_price) / entry) * 100, 4)
+ return round(((entry - price) / entry) * 100, 4)
return 0.0
@@ -507,9 +532,9 @@ class ExecutionEngine:
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
position = type(self)._position
- confidence = float(state.last_signal_confidence or 0.0)
- repeat_count = int(state.last_signal_repeat_count or 0)
- unrealized_pnl = float(state.unrealized_pnl_usd or 0.0)
+ confidence = safe_float(state.last_signal_confidence) or 0.0
+ repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
+ unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
hold_seconds = self._position_hold_seconds(position)
momentum_direction = getattr(state, "momentum_direction", None)
momentum_state = getattr(state, "momentum_state", None)
@@ -560,7 +585,7 @@ class ExecutionEngine:
reason: str,
) -> ExecutionDecision:
position = type(self)._position
- confidence = float(state.last_signal_confidence or 0.0)
+ confidence = safe_float(state.last_signal_confidence) or 0.0
state.execution_block_reason = reason
state.last_flip_block_reason = reason
@@ -578,7 +603,7 @@ class ExecutionEngine:
if block_key != type(self)._last_flip_block_key:
type(self)._last_flip_block_key = block_key
- payload = {
+ payload: JsonDict = {
"execution_type": "FLIP_BLOCKED",
"symbol": state.symbol,
"position_side": position.side,
@@ -700,8 +725,10 @@ class ExecutionEngine:
multiplier = 1.0
execution_confidence_score = getattr(state, "execution_confidence_score", None)
- if execution_confidence_score is not None:
- score = max(0.0, min(1.0, float(execution_confidence_score)))
+ score_raw = safe_float(execution_confidence_score)
+
+ if score_raw is not None:
+ score = max(0.0, min(1.0, score_raw))
if score < 0.55:
multiplier *= 0.0
@@ -750,17 +777,14 @@ class ExecutionEngine:
multiplier *= 1.05
if momentum_strength is not None:
- try:
- strength = float(momentum_strength)
+ strength = safe_float(momentum_strength)
+ if strength is not None:
if strength >= 1.5:
multiplier *= 1.1
elif strength <= 0.7:
multiplier *= 0.8
- except Exception:
- pass
-
if signal == "BUY":
if momentum_direction == "DOWN":
multiplier *= 0.75
@@ -800,7 +824,10 @@ class ExecutionEngine:
state.adaptive_size_final = self._round_size(final_size)
state.adaptive_size_multiplier = multiplier
- base_risk_percent = float(state.risk_percent or 0.0)
+ if multiplier != 1:
+ state.adaptive_size_changed_at = time.monotonic()
+
+ base_risk_percent = safe_float(state.risk_percent) or 0.0
state.effective_risk_percent = round(
base_risk_percent * multiplier,
@@ -847,7 +874,7 @@ class ExecutionEngine:
base_size: float,
final_size: float,
) -> None:
- adaptive_final = float(state.adaptive_size_final or 0.0)
+ adaptive_final = safe_float(state.adaptive_size_final) or 0.0
if adaptive_final <= 0:
state.effective_risk_percent = 0.0
@@ -859,7 +886,7 @@ class ExecutionEngine:
min(1.0, final_size / adaptive_final),
)
- current_effective_risk = float(state.effective_risk_percent or 0.0)
+ current_effective_risk = safe_float(state.effective_risk_percent) or 0.0
state.effective_risk_percent = round(
current_effective_risk * margin_ratio,
@@ -917,7 +944,7 @@ class ExecutionEngine:
limited_size = self._round_size(max_size)
- adaptive_final = float(state.adaptive_size_final or 0.0)
+ adaptive_final = safe_float(state.adaptive_size_final) or 0.0
if adaptive_final > 0:
effective_multiplier = limited_size / adaptive_final
@@ -1011,32 +1038,55 @@ class ExecutionEngine:
pricing_role="MARKET_LAST",
)
- def _snapshot_price(self, raw_price: object, name: str) -> float:
+ def _snapshot_price(
+ self,
+ raw_price: NumericLike | None,
+ name: str,
+ ) -> float:
if raw_price is None:
- raise ValueError(f"Execution snapshot price '{name}' is missing.")
+ raise ValueError(
+ f"Execution snapshot price '{name}' is missing."
+ )
- price = float(raw_price)
+ price = safe_float(raw_price)
+
+ if price is None:
+ raise ValueError(
+ f"Execution snapshot price '{name}' is invalid."
+ )
if price <= 0:
- raise ValueError(f"Execution snapshot price '{name}' is invalid: {price}")
+ raise ValueError(
+ f"Execution snapshot price '{name}' is invalid: {price}"
+ )
return price
- def _round_size(self, size: float) -> float:
- factor = 10 ** self._size_precision
- return math.floor(float(size) * factor) / factor
+ def _round_size(self, size: NumericLike | None) -> float:
+ value = safe_float(size)
- def _calculate_pnl(self, current_price: float) -> float:
+ if value is None:
+ return 0.0
+
+ factor = 10 ** self._size_precision
+ return math.floor(value * factor) / factor
+
+ def _calculate_pnl(
+ self,
+ current_price: NumericLike | None,
+ ) -> float:
position = type(self)._position
- entry = position.entry_price or 0.0
- size = position.size or 0.0
+ price = safe_float(current_price) or 0.0
+
+ entry = safe_float(position.entry_price) or 0.0
+ size = safe_float(position.size) or 0.0
if position.side == "LONG":
- return round((current_price - entry) * size, 4)
+ return round((price - entry) * size, 4)
if position.side == "SHORT":
- return round((entry - current_price) * size, 4)
+ return round((entry - price) * size, 4)
return 0.0
@@ -1048,9 +1098,5 @@ class ExecutionEngine:
state.position_size = position.size
state.unrealized_pnl_usd = position.unrealized_pnl_usd
- def _round_order_size(self, value: float) -> float:
- factor = 10 ** self._size_precision
- return math.floor(float(value) * factor) / factor
-
def _now_time(self) -> str:
return datetime.now().strftime("%H:%M:%S")
\ No newline at end of file
diff --git a/app/src/trading/journal/exporter.py b/app/src/trading/journal/exporter.py
index 358fbaa..4e6924b 100644
--- a/app/src/trading/journal/exporter.py
+++ b/app/src/trading/journal/exporter.py
@@ -1,17 +1,16 @@
# app/src/trading/journal/exporter.py
-
+
from __future__ import annotations
import csv
import json
import re
+import zipfile
from datetime import datetime
from io import BytesIO, StringIO
+from xml.sax.saxutils import escape
from zoneinfo import ZoneInfo
-from openpyxl import Workbook
-from openpyxl.styles import Font
-
from src.core.config import load_settings
from src.core.event_titles import event_title
@@ -61,12 +60,12 @@ def _event_title(event_type: object) -> str:
return event_title(event_type)
-def _payload(row: dict) -> dict:
+def _payload(row: dict[str, object]) -> dict[str, object]:
payload = row.get("payload")
return payload if isinstance(payload, dict) else {}
-def _payload_json(payload: dict) -> str:
+def _payload_json(payload: dict[str, object]) -> str:
if not payload:
return ""
@@ -74,7 +73,7 @@ def _payload_json(payload: dict) -> str:
return _strip_emoji(text)
-def _export_row(row: dict) -> list[str]:
+def _export_row(row: dict[str, object]) -> list[str]:
payload = _payload(row)
return [
@@ -108,15 +107,19 @@ def _headers() -> list[str]:
]
-def _levels_summary(rows: list[dict]) -> str:
+def _levels_summary(rows: list[dict[str, object]]) -> str:
levels = sorted(
{str(row.get("level") or "").upper() for row in rows if row.get("level")}
)
return ", ".join(levels) if levels else "—"
-def _period_summary(rows: list[dict]) -> str:
- dates = [_format_datetime(row.get("created_at")) for row in rows if row.get("created_at")]
+def _period_summary(rows: list[dict[str, object]]) -> str:
+ dates = [
+ _format_datetime(row.get("created_at"))
+ for row in rows
+ if row.get("created_at")
+ ]
dates = [value for value in dates if value]
if not dates:
@@ -127,7 +130,7 @@ def _period_summary(rows: list[dict]) -> str:
def _metadata_rows(
*,
- rows: list[dict],
+ rows: list[dict[str, object]],
total_count: int,
export_limit: int,
account_mode: str,
@@ -152,7 +155,7 @@ def _metadata_rows(
def build_csv(
- rows: list[dict],
+ rows: list[dict[str, object]],
*,
total_count: int,
export_limit: int,
@@ -185,42 +188,199 @@ def build_csv(
def build_xlsx(
- rows: list[dict],
+ rows: list[dict[str, object]],
*,
total_count: int,
export_limit: int,
account_mode: str,
journal_level: str,
) -> bytes:
- wb = Workbook()
- ws = wb.active
- ws.title = "Journal"
+ sheet_rows: list[list[str]] = []
- for metadata_row in _metadata_rows(
- rows=rows,
- total_count=total_count,
- export_limit=export_limit,
- account_mode=account_mode,
- journal_level=journal_level,
- ):
- ws.append(metadata_row)
+ sheet_rows.extend(
+ _metadata_rows(
+ rows=rows,
+ total_count=total_count,
+ export_limit=export_limit,
+ account_mode=account_mode,
+ journal_level=journal_level,
+ )
+ )
- header_row_index = ws.max_row + 1
- ws.append(_headers())
-
- for cell in ws[1]:
- cell.font = Font(bold=True)
-
- for cell in ws[header_row_index]:
- cell.font = Font(bold=True)
+ sheet_rows.append(_headers())
for row in rows:
- ws.append(_export_row(row))
+ sheet_rows.append(_export_row(row))
- for column_cells in ws.columns:
- max_length = max(len(str(cell.value or "")) for cell in column_cells)
- ws.column_dimensions[column_cells[0].column_letter].width = min(max_length + 2, 60)
+ return _build_xlsx_bytes(
+ sheet_name="Journal",
+ rows=sheet_rows,
+ )
+
+def _build_xlsx_bytes(
+ *,
+ sheet_name: str,
+ rows: list[list[str]],
+) -> bytes:
stream = BytesIO()
- wb.save(stream)
- return stream.getvalue()
\ No newline at end of file
+
+ with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("[Content_Types].xml", _content_types_xml())
+ archive.writestr("_rels/.rels", _root_rels_xml())
+ archive.writestr("xl/workbook.xml", _workbook_xml(sheet_name))
+ archive.writestr("xl/_rels/workbook.xml.rels", _workbook_rels_xml())
+ archive.writestr("xl/styles.xml", _styles_xml())
+ archive.writestr("xl/worksheets/sheet1.xml", _worksheet_xml(rows))
+
+ return stream.getvalue()
+
+
+def _worksheet_xml(rows: list[list[str]]) -> str:
+ header_row_index = _header_row_index(rows)
+ column_widths = _column_widths(rows)
+
+ xml_rows: list[str] = []
+
+ for row_index, row in enumerate(rows, start=1):
+ cells: list[str] = []
+
+ for column_index, value in enumerate(row, start=1):
+ cell_ref = f"{_column_letter(column_index)}{row_index}"
+ style = ' s="1"' if row_index in {1, header_row_index} else ""
+
+ cells.append(
+ f'