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'' + f"{_xml_text(value)}" + f"" + ) + + xml_rows.append(f'{"".join(cells)}') + + cols_xml = "".join( + ( + f'' + ) + for index, width in enumerate(column_widths, start=1) + ) + + return ( + '' + '' + f"{cols_xml}" + "" + f"{''.join(xml_rows)}" + "" + "" + ) + + +def _header_row_index(rows: list[list[str]]) -> int: + headers = _headers() + + for index, row in enumerate(rows, start=1): + if row == headers: + return index + + return 1 + + +def _column_widths(rows: list[list[str]]) -> list[int]: + max_columns = max((len(row) for row in rows), default=1) + widths: list[int] = [] + + for column_index in range(max_columns): + max_length = 0 + + for row in rows: + if column_index < len(row): + max_length = max(max_length, len(str(row[column_index] or ""))) + + widths.append(min(max_length + 2, 60)) + + return widths + + +def _column_letter(index: int) -> str: + result = "" + + while index > 0: + index, remainder = divmod(index - 1, 26) + result = chr(65 + remainder) + result + + return result + + +def _xml_text(value: object) -> str: + text = str(value or "") + return escape(text, {'"': """, "'": "'"}) + + +def _content_types_xml() -> str: + return ( + '' + '' + '' + '' + '' + '' + '' + "" + ) + + +def _root_rels_xml() -> str: + return ( + '' + '' + '' + "" + ) + + +def _workbook_xml(sheet_name: str) -> str: + return ( + '' + '' + "" + f'' + "" + "" + ) + + +def _workbook_rels_xml() -> str: + return ( + '' + '' + '' + '' + "" + ) + + +def _styles_xml() -> str: + return ( + '' + '' + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ) \ No newline at end of file diff --git a/app/src/trading/market_analysis/models.py b/app/src/trading/market_analysis/models.py index 3ceef5d..c9f5403 100644 --- a/app/src/trading/market_analysis/models.py +++ b/app/src/trading/market_analysis/models.py @@ -5,6 +5,8 @@ from __future__ import annotations from dataclasses import dataclass from enum import StrEnum +from src.core.types import JsonDict + class MarketState(StrEnum): TREND_UP = "TREND_UP" @@ -46,12 +48,6 @@ class TrendStrength(StrEnum): UNKNOWN = "UNKNOWN" -class TrendQuality(StrEnum): - CLEAN = "CLEAN" - NOISY = "NOISY" - UNKNOWN = "UNKNOWN" - - class MarketPhase(StrEnum): IMPULSE = "IMPULSE" PULLBACK = "PULLBACK" @@ -60,6 +56,29 @@ class MarketPhase(StrEnum): UNKNOWN = "UNKNOWN" +class TrendQuality(StrEnum): + CLEAN = "CLEAN" + NORMAL = "NORMAL" + NOISY = "NOISY" + UNKNOWN = "UNKNOWN" + + +class EmaDistanceState(StrEnum): + COMPRESSED = "COMPRESSED" + HEALTHY = "HEALTHY" + EXTENDED = "EXTENDED" + OVEREXTENDED = "OVEREXTENDED" + UNKNOWN = "UNKNOWN" + + +class EntryTimingState(StrEnum): + EARLY = "EARLY" + NORMAL = "NORMAL" + LATE = "LATE" + CHASING = "CHASING" + UNKNOWN = "UNKNOWN" + + @dataclass(slots=True) class MarketAnalysisResult: symbol: str @@ -80,23 +99,42 @@ class MarketAnalysisResult: reason: str is_trade_allowed: bool - payload: dict + payload: JsonDict trend_strength: TrendStrength trend_quality: TrendQuality market_phase: MarketPhase + trend_gap_percent: float | None trend_consistency: float | None + trend_efficiency: float | None + ema_distance_atr_ratio: float | None phase_direction: TrendDirection phase_change_percent: float | None phase_reason: str | None + + ema_fast_slope_percent: float | None = None + ema_slow_slope_percent: float | None = None + phase_direction_consistency: float | None = None momentum_state: MomentumState | None = None momentum_direction: TrendDirection | None = None momentum_change_percent: float | None = None momentum_strength: float | None = None + breakout_level: float | None = None breakout_distance_percent: float | None = None - breakout_reason: str | None = None \ No newline at end of file + breakout_reason: str | None = None + + 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: VolatilityState | None = None + + trend_quality_score: float | None = None + ema_distance_state: EmaDistanceState | None = None + entry_timing_state: EntryTimingState | None = None + entry_timing_reason: str | None = None \ No newline at end of file diff --git a/app/src/trading/market_analysis/service.py b/app/src/trading/market_analysis/service.py index 1d2f32c..c2f3758 100644 --- a/app/src/trading/market_analysis/service.py +++ b/app/src/trading/market_analysis/service.py @@ -2,38 +2,51 @@ from __future__ import annotations +from collections.abc import Sequence + +from src.core.numbers import safe_float +from src.core.types import JsonDict, NumericLike +from src.integrations.exchange.models import Kline from src.integrations.exchange.service import ExchangeService from src.trading.market_analysis.indicators import atr, ema, rsi from src.trading.market_analysis.models import ( + EntryTimingState, + EmaDistanceState, MarketAnalysisResult, MarketPhase, MarketState, + MomentumState, TrendDirection, TrendQuality, TrendStrength, VolatilityState, - MomentumState, ) class MarketAnalysisService: _fast_ema_period = 20 _slow_ema_period = 50 + _atr_baseline_window = 50 _atr_period = 14 _rsi_period = 14 _min_candles = 60 _low_volatility_atr_percent = 0.05 _high_volatility_atr_percent = 1.8 - _trend_gap_percent = 0.03 + _ema_fast_slope_window = 3 + _ema_slow_slope_window = 5 _trend_consistency_window = 20 + _candle_noise_window = 12 + _min_clean_body_ratio = 0.45 + _min_clean_candle_score = 0.55 + _price_position_window = 5 + _min_price_position_score = 0.6 _phase_window = 8 - _phase_direction_threshold_percent = 0.08 - _pullback_min_change_percent = 0.18 _pullback_min_direction_consistency = 0.6 _momentum_window = 3 - _momentum_change_threshold_percent = 0.35 + _momentum_decay_window = 2 _breakout_lookback = 20 - _breakout_distance_threshold_percent = 0.08 + _htf_interval = "15m" + _htf_limit = 200 def analyze( self, @@ -87,28 +100,179 @@ class MarketAnalysisService: ) atr_percent = (atr_value / close_price) * 100 + + atr_percent_baseline = self._atr_percent_baseline( + candles=candles, + close_price=close_price, + ) + + volatility_ratio = ( + atr_percent / atr_percent_baseline + if atr_percent_baseline is not None and atr_percent_baseline > 0 + else None + ) + + htf_context = self._htf_volatility_context( + symbol=batch.symbol, + base_interval=interval, + ) + + htf_volatility_ratio = safe_float( + htf_context.get("htf_volatility_ratio") + ) + + momentum_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.7, + minimum=0.08, + ) + + momentum_decay_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.12, + minimum=0.03, + ) + + breakout_distance_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.25, + minimum=0.04, + ) + + phase_direction_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.18, + minimum=0.04, + ) + + pullback_min_change_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.45, + minimum=0.08, + ) + + fast_slope_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.08, + minimum=0.01, + ) + + slow_slope_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.03, + minimum=0.005, + ) + + weak_trend_gap_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.18, + minimum=0.05, + ) + + strong_trend_gap_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.55, + minimum=0.18, + ) + + trend_direction_gap_threshold_percent = self._adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.12, + minimum=0.025, + ) + trend_gap_percent = self._trend_gap_percent_value( ema_fast=ema_fast, ema_slow=ema_slow, ) - volatility = self._classify_volatility(atr_percent) + ema_fast_slope_percent = self._ema_slope_percent( + closes=closes, + period=self._fast_ema_period, + window=self._ema_fast_slope_window, + ) + + ema_slow_slope_percent = self._ema_slope_percent( + closes=closes, + period=self._slow_ema_period, + window=self._ema_slow_slope_window, + ) + + volatility = self._classify_volatility( + atr_percent=atr_percent, + volatility_ratio=volatility_ratio, + htf_volatility_ratio=htf_volatility_ratio, + ) + trend = self._classify_trend( ema_fast=ema_fast, ema_slow=ema_slow, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_slow_slope_percent=ema_slow_slope_percent, + fast_slope_threshold_percent=fast_slope_threshold_percent, + slow_slope_threshold_percent=slow_slope_threshold_percent, + trend_direction_gap_threshold_percent=trend_direction_gap_threshold_percent, ) - trend_strength = self._classify_trend_strength(trend_gap_percent) + + trend_strength = self._classify_trend_strength( + trend_gap_percent=trend_gap_percent, + weak_threshold_percent=weak_trend_gap_threshold_percent, + strong_threshold_percent=strong_trend_gap_threshold_percent, + ) + trend_consistency = self._trend_consistency( closes=closes, trend=trend, ) - trend_quality = self._classify_trend_quality(trend_consistency) + + trend_efficiency = self._trend_efficiency( + closes=closes, + ) + + ema_distance_atr_ratio = self._ema_distance_atr_ratio( + ema_fast=ema_fast, + ema_slow=ema_slow, + atr_value=atr_value, + ) + + candle_noise_score = self._candle_noise_score(candles) + + price_position_score = self._price_position_score( + closes=closes, + ema_fast=ema_fast, + trend=trend, + ) + + trend_quality_score = self._trend_quality_score( + trend_consistency=trend_consistency, + trend_efficiency=trend_efficiency, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, + ) + + ema_distance_state = self._classify_ema_distance_state( + ema_distance_atr_ratio=ema_distance_atr_ratio, + ) + + trend_quality = self._classify_trend_quality( + trend_consistency=trend_consistency, + trend_efficiency=trend_efficiency, + ema_distance_atr_ratio=ema_distance_atr_ratio, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, + trend_strength=trend_strength, + ) phase_change_percent = self._recent_change_percent( closes=closes, window=self._phase_window, ) - phase_direction = self._classify_phase_direction(phase_change_percent) + + phase_direction = self._classify_phase_direction( + phase_change_percent, + threshold_percent=phase_direction_threshold_percent, + ) + phase_direction_consistency = self._phase_direction_consistency( closes=closes, phase_direction=phase_direction, @@ -122,7 +286,12 @@ class MarketAnalysisService: breakout_level, breakout_distance_percent, breakout_reason, - ) = self._momentum_breakout_state(closes=closes) + ) = self._momentum_breakout_state( + closes=closes, + momentum_change_threshold_percent=momentum_threshold_percent, + momentum_decay_threshold_percent=momentum_decay_threshold_percent, + breakout_distance_threshold_percent=breakout_distance_threshold_percent, + ) market_phase, phase_reason = self._classify_market_phase( trend=trend, @@ -133,24 +302,62 @@ class MarketAnalysisService: phase_direction=phase_direction, phase_change_percent=phase_change_percent, phase_direction_consistency=phase_direction_consistency, + pullback_min_change_percent=pullback_min_change_percent, + ) + + entry_timing_state, entry_timing_reason = self._classify_entry_timing( + ema_distance_state=ema_distance_state, + momentum_state=momentum_state, + momentum_strength=momentum_strength, + market_phase=market_phase, ) state = self._classify_market_state( trend=trend, volatility=volatility, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + momentum_state=momentum_state, + momentum_direction=momentum_direction, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_slow_slope_percent=ema_slow_slope_percent, + fast_slope_threshold_percent=fast_slope_threshold_percent, + slow_slope_threshold_percent=slow_slope_threshold_percent, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, ) - is_trade_allowed = state in { - MarketState.TREND_UP, - MarketState.TREND_DOWN, - } - - reason = self._reason( + is_trade_allowed = self._is_trade_allowed( state=state, trend=trend, volatility=volatility, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + momentum_state=momentum_state, + momentum_direction=momentum_direction, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_distance_state=ema_distance_state, + entry_timing_state=entry_timing_state, + fast_slope_threshold_percent=fast_slope_threshold_percent, + ) + + reason = self._reason( + state=state, + volatility=volatility, atr_percent=atr_percent, rsi_value=rsi_value, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + momentum_state=momentum_state, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, + ema_distance_state=ema_distance_state, + entry_timing_state=entry_timing_state, ) return MarketAnalysisResult( @@ -201,9 +408,33 @@ class MarketAnalysisService: "market_trend_gap_percent": round(trend_gap_percent, 5) if trend_gap_percent is not None else None, + "ema_fast_slope_percent": round(ema_fast_slope_percent, 5) + if ema_fast_slope_percent is not None + else None, + "ema_slow_slope_percent": round(ema_slow_slope_percent, 5) + if ema_slow_slope_percent is not None + else None, "market_trend_consistency": round(trend_consistency, 3) if trend_consistency is not None else None, + "market_trend_efficiency": round(trend_efficiency, 3) + if trend_efficiency is not None + else None, + "trend_quality_score": round(trend_quality_score, 3) + if trend_quality_score is not None + else None, + "ema_distance_atr_ratio": round(ema_distance_atr_ratio, 3) + if ema_distance_atr_ratio is not None + else None, + "ema_distance_state": ema_distance_state.value, + "entry_timing_state": entry_timing_state.value, + "entry_timing_reason": entry_timing_reason, + "candle_noise_score": round(candle_noise_score, 3) + if candle_noise_score is not None + else None, + "price_position_score": round(price_position_score, 3) + if price_position_score is not None + else None, "close_price": close_price, "ema_fast_period": self._fast_ema_period, "ema_slow_period": self._slow_ema_period, @@ -212,19 +443,35 @@ class MarketAnalysisService: "atr_period": self._atr_period, "atr": round(atr_value, 8), "atr_percent": round(atr_percent, 4), + "atr_percent_baseline": round(atr_percent_baseline, 4) + if atr_percent_baseline is not None + else None, + "volatility_ratio": round(volatility_ratio, 4) + if volatility_ratio is not None + else None, "rsi_period": self._rsi_period, "rsi": round(rsi_value, 2) if rsi_value is not None else None, "candles_count": len(candles), "is_trade_allowed": is_trade_allowed, + "htf_interval": htf_context.get("htf_interval"), + "htf_atr_percent": htf_context.get("htf_atr_percent"), + "htf_atr_percent_baseline": htf_context.get("htf_atr_percent_baseline"), + "htf_volatility_ratio": htf_context.get("htf_volatility_ratio"), + "htf_volatility": htf_context.get("htf_volatility"), + "htf_reason": htf_context.get("htf_reason"), }, trend_strength=trend_strength, trend_quality=trend_quality, market_phase=market_phase, trend_gap_percent=trend_gap_percent, trend_consistency=trend_consistency, + trend_efficiency=trend_efficiency, + ema_distance_atr_ratio=ema_distance_atr_ratio, phase_direction=phase_direction, phase_change_percent=phase_change_percent, phase_reason=phase_reason, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_slow_slope_percent=ema_slow_slope_percent, phase_direction_consistency=phase_direction_consistency, momentum_state=momentum_state, momentum_direction=momentum_direction, @@ -233,12 +480,159 @@ class MarketAnalysisService: breakout_level=breakout_level, breakout_distance_percent=breakout_distance_percent, breakout_reason=breakout_reason, + htf_interval=str(htf_context.get("htf_interval") or self._htf_interval), + htf_atr_percent=safe_float(htf_context.get("htf_atr_percent")), + htf_atr_percent_baseline=safe_float( + htf_context.get("htf_atr_percent_baseline") + ), + htf_volatility_ratio=safe_float(htf_context.get("htf_volatility_ratio")), + htf_volatility=( + VolatilityState(str(htf_context["htf_volatility"])) + if htf_context.get("htf_volatility") + else None + ), + trend_quality_score=trend_quality_score, + ema_distance_state=ema_distance_state, + entry_timing_state=entry_timing_state, + entry_timing_reason=entry_timing_reason, ) + def _htf_volatility_context( + self, + *, + symbol: str, + base_interval: str, + ) -> JsonDict: + if base_interval == self._htf_interval: + return { + "htf_interval": self._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": "HTF_SKIPPED_SAME_INTERVAL", + } + + try: + batch = ExchangeService().get_klines( + symbol=symbol, + interval=self._htf_interval, + limit=self._htf_limit, + ) + except Exception as exc: + return { + "htf_interval": self._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": f"HTF_KLINES_ERROR: {exc}", + } + + candles = batch.candles + closes = [item.close_price for item in candles] + + if len(candles) < self._min_candles or not closes: + return { + "htf_interval": self._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": "HTF_NOT_ENOUGH_CANDLES", + } + + close_price = safe_float(closes[-1]) + atr_value = atr(candles, self._atr_period) + + if close_price is None or close_price <= 0 or atr_value is None: + return { + "htf_interval": self._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": "HTF_ATR_UNAVAILABLE", + } + + htf_atr_percent = (atr_value / close_price) * 100 + htf_baseline = self._atr_percent_baseline( + candles=candles, + close_price=close_price, + ) + + htf_ratio = ( + htf_atr_percent / htf_baseline + if htf_baseline is not None and htf_baseline > 0 + else None + ) + + htf_volatility = self._classify_volatility( + atr_percent=htf_atr_percent, + volatility_ratio=htf_ratio, + htf_volatility_ratio=None, + ) + + return { + "htf_interval": self._htf_interval, + "htf_atr_percent": round(htf_atr_percent, 4), + "htf_atr_percent_baseline": round(htf_baseline, 4) + if htf_baseline is not None + else None, + "htf_volatility_ratio": round(htf_ratio, 4) + if htf_ratio is not None + else None, + "htf_volatility": htf_volatility.value, + "htf_reason": "HTF_OK", + } + + def _atr_percent_baseline( + self, + *, + candles: Sequence[Kline], + close_price: float, + ) -> float | None: + if close_price <= 0: + return None + + values: list[float] = [] + + window: list[Kline] = list( + candles[-self._atr_baseline_window:] + ) + + for index in range(self._atr_period, len(window) + 1): + part: list[Kline] = window[:index] + atr_value = atr(part, self._atr_period) + + if atr_value is None: + continue + + close = getattr(part[-1], "close_price", None) + + if close is None or close <= 0: + continue + + values.append((atr_value / close) * 100) + + if not values: + return None + + values.sort() + middle = len(values) // 2 + + if len(values) % 2 == 1: + return values[middle] + + return (values[middle - 1] + values[middle]) / 2 + def _momentum_breakout_state( self, *, closes: list[float], + momentum_change_threshold_percent: float, + momentum_decay_threshold_percent: float, + breakout_distance_threshold_percent: float, ) -> tuple[ MomentumState, TrendDirection, @@ -276,14 +670,47 @@ class MarketAnalysisService: momentum_change_percent = ((last_price - first_price) / first_price) * 100 abs_change = abs(momentum_change_percent) - if momentum_change_percent >= self._momentum_change_threshold_percent: + recent_change_percent = self._recent_change_percent( + closes=closes, + window=self._momentum_decay_window, + ) + + recent_abs_change = ( + abs(recent_change_percent) + if recent_change_percent is not None + else None + ) + + if ( + momentum_change_percent >= momentum_change_threshold_percent + and recent_change_percent is not None + and recent_change_percent > momentum_decay_threshold_percent + ): momentum_direction = TrendDirection.UP - elif momentum_change_percent <= -self._momentum_change_threshold_percent: + + elif ( + momentum_change_percent <= -momentum_change_threshold_percent + and recent_change_percent is not None + and recent_change_percent < -momentum_decay_threshold_percent + ): momentum_direction = TrendDirection.DOWN + else: momentum_direction = TrendDirection.FLAT - momentum_strength = abs_change / self._momentum_change_threshold_percent + if momentum_direction == TrendDirection.FLAT: + if recent_abs_change is not None: + momentum_strength = min( + recent_abs_change / momentum_decay_threshold_percent, + 3.0, + ) + else: + momentum_strength = 0.0 + else: + momentum_strength = min( + abs_change / momentum_change_threshold_percent, + 3.0, + ) lookback_window = closes[-(self._breakout_lookback + 1):-1] previous_high = max(lookback_window) @@ -303,7 +730,7 @@ class MarketAnalysisService: if last_price > previous_high: breakout_distance_percent = ((last_price - previous_high) / previous_high) * 100 - if breakout_distance_percent >= self._breakout_distance_threshold_percent: + if breakout_distance_percent >= breakout_distance_threshold_percent: return ( MomentumState.BREAKOUT_UP, TrendDirection.UP, @@ -317,7 +744,7 @@ class MarketAnalysisService: if last_price < previous_low: breakout_distance_percent = ((previous_low - last_price) / previous_low) * 100 - if breakout_distance_percent >= self._breakout_distance_threshold_percent: + if breakout_distance_percent >= breakout_distance_threshold_percent: return ( MomentumState.BREAKOUT_DOWN, TrendDirection.DOWN, @@ -334,7 +761,7 @@ class MarketAnalysisService: TrendDirection.UP, momentum_change_percent, momentum_strength, - previous_high, + None, None, "FAST_UP_MOVE", ) @@ -345,7 +772,7 @@ class MarketAnalysisService: TrendDirection.DOWN, momentum_change_percent, momentum_strength, - previous_low, + None, None, "FAST_DOWN_MOVE", ) @@ -371,11 +798,63 @@ class MarketAnalysisService: return ((ema_fast - ema_slow) / ema_slow) * 100 + def _adaptive_threshold( + self, + *, + atr_percent: NumericLike | None, + multiplier: NumericLike, + minimum: NumericLike, + ) -> float: + atr_value = safe_float(atr_percent) + multiplier_value = safe_float(multiplier) + minimum_value = safe_float(minimum) or 0.0 + + if atr_value is None or atr_value <= 0 or multiplier_value is None: + return minimum_value + + return max(minimum_value, atr_value * multiplier_value) + + def _ema_slope_percent( + self, + *, + closes: list[float], + period: int, + window: int, + ) -> float | None: + required = period + window + 5 + + if len(closes) < required: + return None + + current_ema = ema(closes, period) + + previous_ema = ema( + closes[:-window], + period, + ) + + if ( + current_ema is None + or previous_ema is None + or previous_ema <= 0 + ): + return None + + return ( + (current_ema - previous_ema) + / previous_ema + ) * 100 + def _classify_trend( self, *, ema_fast: float, ema_slow: float, + ema_fast_slope_percent: float | None = None, + ema_slow_slope_percent: float | None = None, + fast_slope_threshold_percent: float, + slow_slope_threshold_percent: float, + trend_direction_gap_threshold_percent: float, ) -> TrendDirection: gap_percent = self._trend_gap_percent_value( ema_fast=ema_fast, @@ -385,27 +864,49 @@ class MarketAnalysisService: if gap_percent is None: return TrendDirection.UNKNOWN - if gap_percent >= self._trend_gap_percent: + fast_slope = ema_fast_slope_percent or 0.0 + slow_slope = ema_slow_slope_percent or 0.0 + + fast_up = fast_slope >= fast_slope_threshold_percent + fast_down = fast_slope <= -fast_slope_threshold_percent + + slow_up = slow_slope >= slow_slope_threshold_percent + slow_down = slow_slope <= -slow_slope_threshold_percent + + if gap_percent >= trend_direction_gap_threshold_percent: + if fast_down and slow_down: + return TrendDirection.FLAT return TrendDirection.UP - if gap_percent <= -self._trend_gap_percent: + if gap_percent <= -trend_direction_gap_threshold_percent: + if fast_up and slow_up: + return TrendDirection.FLAT + return TrendDirection.DOWN + + if fast_up and slow_up: + return TrendDirection.UP + + if fast_down and slow_down: return TrendDirection.DOWN return TrendDirection.FLAT def _classify_trend_strength( self, + *, trend_gap_percent: float | None, + weak_threshold_percent: float, + strong_threshold_percent: float, ) -> TrendStrength: if trend_gap_percent is None: return TrendStrength.UNKNOWN gap = abs(trend_gap_percent) - if gap < 0.08: + if gap < weak_threshold_percent: return TrendStrength.WEAK - if gap < 0.25: + if gap < strong_threshold_percent: return TrendStrength.NORMAL return TrendStrength.STRONG @@ -442,16 +943,86 @@ class MarketAnalysisService: return None + def _trend_efficiency( + self, + *, + closes: list[float], + ) -> float | None: + window = closes[-self._trend_consistency_window :] + + if len(window) < 2: + return None + + net_move = abs(window[-1] - window[0]) + + total_move = 0.0 + + for previous_price, current_price in zip(window, window[1:]): + total_move += abs(current_price - previous_price) + + if total_move <= 0: + return None + + return net_move / total_move + + def _ema_distance_atr_ratio( + self, + *, + ema_fast: float, + ema_slow: float, + atr_value: float, + ) -> float | None: + if atr_value <= 0: + return None + + return abs(ema_fast - ema_slow) / atr_value + def _classify_trend_quality( self, + *, trend_consistency: float | None, + trend_efficiency: float | None, + ema_distance_atr_ratio: float | None, + candle_noise_score: float | None, + price_position_score: float | None, + trend_strength: TrendStrength, ) -> TrendQuality: if trend_consistency is None: return TrendQuality.UNKNOWN - if trend_consistency >= 0.6: + if trend_strength == TrendStrength.WEAK: + return TrendQuality.NOISY + + if ( + candle_noise_score is not None + and candle_noise_score < self._min_clean_candle_score + ): + return TrendQuality.NOISY + + if ( + price_position_score is not None + and price_position_score < self._min_price_position_score + ): + return TrendQuality.NOISY + + if ( + trend_efficiency is not None + and trend_efficiency < 0.28 + ): + return TrendQuality.NOISY + + if ( + ema_distance_atr_ratio is not None + and ema_distance_atr_ratio < 0.45 + ): + return TrendQuality.NOISY + + if trend_consistency >= 0.68: return TrendQuality.CLEAN + if trend_consistency >= 0.55: + return TrendQuality.NORMAL + return TrendQuality.NOISY def _recent_change_percent( @@ -474,14 +1045,16 @@ class MarketAnalysisService: def _classify_phase_direction( self, change_percent: float | None, + *, + threshold_percent: float, ) -> TrendDirection: if change_percent is None: return TrendDirection.UNKNOWN - if change_percent >= self._phase_direction_threshold_percent: + if change_percent >= threshold_percent: return TrendDirection.UP - if change_percent <= -self._phase_direction_threshold_percent: + if change_percent <= -threshold_percent: return TrendDirection.DOWN return TrendDirection.FLAT @@ -541,6 +1114,7 @@ class MarketAnalysisService: phase_direction: TrendDirection, phase_change_percent: float | None, phase_direction_consistency: float | None, + pullback_min_change_percent: float, ) -> tuple[MarketPhase, str]: if volatility == VolatilityState.LOW: return MarketPhase.SQUEEZE, "LOW_VOLATILITY_SQUEEZE" @@ -560,13 +1134,13 @@ class MarketAnalysisService: ): if ( phase_change_percent is not None - and abs(phase_change_percent) >= self._pullback_min_change_percent + and abs(phase_change_percent) >= pullback_min_change_percent and phase_direction_consistency is not None and phase_direction_consistency >= self._pullback_min_direction_consistency ): return MarketPhase.PULLBACK, "COUNTER_TREND_MOVE_CONFIRMED" - return MarketPhase.IMPULSE, "COUNTER_TREND_MOVE_TOO_WEAK" + return MarketPhase.RANGE, "COUNTER_TREND_MOVE_TOO_WEAK" if ( trend == TrendDirection.UP @@ -574,7 +1148,7 @@ class MarketAnalysisService: and rsi_value < 45 and phase_direction == TrendDirection.DOWN and phase_change_percent is not None - and abs(phase_change_percent) >= self._pullback_min_change_percent + and abs(phase_change_percent) >= pullback_min_change_percent and phase_direction_consistency is not None and phase_direction_consistency >= self._pullback_min_direction_consistency ): @@ -586,7 +1160,7 @@ class MarketAnalysisService: and rsi_value > 55 and phase_direction == TrendDirection.UP and phase_change_percent is not None - and abs(phase_change_percent) >= self._pullback_min_change_percent + and abs(phase_change_percent) >= pullback_min_change_percent and phase_direction_consistency is not None and phase_direction_consistency >= self._pullback_min_direction_consistency ): @@ -594,68 +1168,403 @@ class MarketAnalysisService: return MarketPhase.IMPULSE, "WITH_TREND_OR_NEUTRAL_MOVE" - def _classify_volatility(self, atr_percent: float) -> VolatilityState: - if atr_percent <= 0: + def _classify_volatility( + self, + *, + atr_percent: NumericLike, + volatility_ratio: NumericLike | None, + htf_volatility_ratio: NumericLike | None = None, + ) -> VolatilityState: + atr_value = safe_float(atr_percent) + + if atr_value is None or atr_value <= 0: return VolatilityState.UNKNOWN - if atr_percent < self._low_volatility_atr_percent: + local_ratio = safe_float(volatility_ratio) + htf_ratio = safe_float(htf_volatility_ratio) + + if htf_ratio is not None: + if htf_ratio > 1.8 and (local_ratio is None or local_ratio > 1.1): + return VolatilityState.HIGH + + if htf_ratio < 0.55 and (local_ratio is None or local_ratio < 0.85): + return VolatilityState.LOW + + if local_ratio is None: + if atr_value < self._low_volatility_atr_percent: + return VolatilityState.LOW + + if atr_value > self._high_volatility_atr_percent: + return VolatilityState.HIGH + + return VolatilityState.NORMAL + + if local_ratio < 0.55: return VolatilityState.LOW - if atr_percent > self._high_volatility_atr_percent: + if local_ratio > 1.8: return VolatilityState.HIGH return VolatilityState.NORMAL + def _candle_noise_score( + self, + candles: Sequence[Kline], + ) -> float | None: + window = candles[-self._candle_noise_window :] + + if not window: + return None + + clean_count = 0 + total_count = 0 + + for candle in window: + high = getattr(candle, "high_price", None) + low = getattr(candle, "low_price", None) + open_price = getattr(candle, "open_price", None) + close_price = getattr(candle, "close_price", None) + + if ( + high is None + or low is None + or open_price is None + or close_price is None + or high <= low + ): + continue + + candle_range = high - low + body = abs(close_price - open_price) + body_ratio = body / candle_range + + total_count += 1 + + if body_ratio >= self._min_clean_body_ratio: + clean_count += 1 + + if total_count == 0: + return None + + return clean_count / total_count + + + def _price_position_score( + self, + *, + closes: list[float], + ema_fast: float, + trend: TrendDirection, + ) -> float | None: + window = closes[-self._price_position_window :] + + if not window: + return None + + valid_count = 0 + + for close_price in window: + if trend == TrendDirection.UP: + if close_price > ema_fast: + valid_count += 1 + + elif trend == TrendDirection.DOWN: + if close_price < ema_fast: + valid_count += 1 + + else: + return None + + return valid_count / len(window) + def _classify_market_state( self, *, trend: TrendDirection, volatility: VolatilityState, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + momentum_state: MomentumState, + momentum_direction: TrendDirection, + ema_fast_slope_percent: float | None, + ema_slow_slope_percent: float | None, + fast_slope_threshold_percent: float, + slow_slope_threshold_percent: float, + candle_noise_score: float | None, + price_position_score: float | None, ) -> MarketState: + fast_slope = ema_fast_slope_percent or 0.0 + range_slope_threshold_percent = max( + fast_slope_threshold_percent, + slow_slope_threshold_percent * 2, + ) + if volatility == VolatilityState.HIGH: return MarketState.HIGH_VOLATILITY if volatility == VolatilityState.LOW: return MarketState.LOW_VOLATILITY + if ( + momentum_state in {MomentumState.BREAKOUT_UP, MomentumState.MOMENTUM_UP} + and momentum_direction == TrendDirection.UP + and fast_slope > 0 + ): + return MarketState.TREND_UP + + if ( + momentum_state in {MomentumState.BREAKOUT_DOWN, MomentumState.MOMENTUM_DOWN} + and momentum_direction == TrendDirection.DOWN + and fast_slope < 0 + ): + return MarketState.TREND_DOWN + + if market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}: + return MarketState.RANGE + + if trend_strength == TrendStrength.WEAK: + return MarketState.RANGE + + if ( + trend_quality == TrendQuality.NOISY + and trend_strength != TrendStrength.STRONG + ): + return MarketState.RANGE + + if ( + candle_noise_score is not None + and candle_noise_score < self._min_clean_candle_score + and abs(fast_slope) < range_slope_threshold_percent + ): + return MarketState.RANGE + + if ( + price_position_score is not None + and price_position_score < self._min_price_position_score + and abs(fast_slope) < range_slope_threshold_percent + ): + return MarketState.RANGE + if trend == TrendDirection.UP: return MarketState.TREND_UP if trend == TrendDirection.DOWN: return MarketState.TREND_DOWN - if trend == TrendDirection.FLAT: - return MarketState.RANGE - - return MarketState.UNKNOWN - - def _reason( + return MarketState.RANGE + + def _is_trade_allowed( self, *, state: MarketState, trend: TrendDirection, volatility: VolatilityState, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + momentum_state: MomentumState, + momentum_direction: TrendDirection, + candle_noise_score: float | None, + price_position_score: float | None, + ema_fast_slope_percent: float | None, + ema_distance_state: EmaDistanceState, + entry_timing_state: EntryTimingState, + fast_slope_threshold_percent: float, + ) -> bool: + if state not in { + MarketState.TREND_UP, + MarketState.TREND_DOWN, + }: + return False + + if volatility != VolatilityState.NORMAL: + return False + + if trend_strength == TrendStrength.WEAK: + return False + + if ( + trend_quality == TrendQuality.NOISY + and trend_strength != TrendStrength.STRONG + ): + return False + + if market_phase in { + MarketPhase.RANGE, + MarketPhase.SQUEEZE, + MarketPhase.PULLBACK, + }: + return False + + if trend == TrendDirection.UP: + if momentum_direction == TrendDirection.DOWN: + return False + + if momentum_state == MomentumState.BREAKOUT_DOWN: + return False + + if trend == TrendDirection.DOWN: + if momentum_direction == TrendDirection.UP: + return False + + if momentum_state == MomentumState.BREAKOUT_UP: + return False + + fast_slope = ema_fast_slope_percent or 0.0 + counter_slope_threshold_percent = fast_slope_threshold_percent + + if ( + trend == TrendDirection.UP + and fast_slope < -counter_slope_threshold_percent + ): + return False + + if ( + trend == TrendDirection.DOWN + and fast_slope > counter_slope_threshold_percent + ): + return False + + if ( + candle_noise_score is not None + and candle_noise_score < self._min_clean_candle_score + ): + return False + + if ( + price_position_score is not None + and price_position_score < self._min_price_position_score + ): + return False + + if ema_distance_state in { + EmaDistanceState.COMPRESSED, + EmaDistanceState.OVEREXTENDED, + }: + return False + + if entry_timing_state in { + EntryTimingState.LATE, + EntryTimingState.CHASING, + }: + return False + + return True + + def _reason( + self, + *, + state: MarketState, + volatility: VolatilityState, atr_percent: float, rsi_value: float | None, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + momentum_state: MomentumState, + candle_noise_score: float | None, + price_position_score: float | None, + ema_distance_state: EmaDistanceState, + entry_timing_state: EntryTimingState, ) -> str: - rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else "" + reasons: list[str] = [] if state == MarketState.TREND_UP: - return f"Рынок перешёл в рост. ATR={atr_percent:.2f}%{rsi_text}." + reasons.append("Рынок растёт") + elif state == MarketState.TREND_DOWN: + reasons.append("Рынок снижается") + elif state == MarketState.RANGE: + reasons.append("Рынок во флэте") + elif state == MarketState.HIGH_VOLATILITY: + reasons.append("Рынок слишком волатилен") + elif state == MarketState.LOW_VOLATILITY: + reasons.append("Рынок малоподвижен") + else: + reasons.append("Состояние рынка не определено") - if state == MarketState.TREND_DOWN: - return f"Рынок перешёл в снижение. ATR={atr_percent:.2f}%{rsi_text}." + if trend_strength == TrendStrength.STRONG: + reasons.append("Сильный тренд") + elif trend_strength == TrendStrength.NORMAL: + reasons.append("Нормальный тренд") + elif trend_strength == TrendStrength.WEAK: + reasons.append("Слабый тренд") - if state == MarketState.RANGE: - return f"На рынке нет выраженного направления. ATR={atr_percent:.2f}%{rsi_text}." + if trend_quality == TrendQuality.CLEAN: + reasons.append("Движение чистое") + elif trend_quality == TrendQuality.NORMAL: + reasons.append("Нормальное качество тренда") + elif trend_quality == TrendQuality.NOISY: + reasons.append("Движение шумное") - if state == MarketState.HIGH_VOLATILITY: - return f"Рынок стал слишком волатильным. ATR={atr_percent:.2f}%{rsi_text}." + if market_phase == MarketPhase.IMPULSE: + reasons.append("Фаза импульса") + elif market_phase == MarketPhase.PULLBACK: + reasons.append("Фаза отката") + elif market_phase == MarketPhase.RANGE: + reasons.append("Фаза флэта") + elif market_phase == MarketPhase.SQUEEZE: + reasons.append("Фаза сжатия") - if state == MarketState.LOW_VOLATILITY: - return f"Рынок почти не движется. ATR={atr_percent:.2f}%{rsi_text}." + if momentum_state == MomentumState.BREAKOUT_UP: + reasons.append("Пробой вверх") + elif momentum_state == MomentumState.BREAKOUT_DOWN: + reasons.append("Пробой вниз") + elif momentum_state == MomentumState.MOMENTUM_UP: + reasons.append("Импульс вверх") + elif momentum_state == MomentumState.MOMENTUM_DOWN: + reasons.append("Импульс вниз") + elif momentum_state == MomentumState.NONE: + reasons.append("Сильного импульса нет") - return f"Состояние рынка не определено. Trend={trend}, volatility={volatility}." + if ema_distance_state == EmaDistanceState.COMPRESSED: + reasons.append("EMA сильно сжаты") + elif ema_distance_state == EmaDistanceState.HEALTHY: + reasons.append("EMA-дистанция здоровая") + elif ema_distance_state == EmaDistanceState.EXTENDED: + reasons.append("Тренд расширен") + elif ema_distance_state == EmaDistanceState.OVEREXTENDED: + reasons.append("Тренд перерастянут") + + if entry_timing_state == EntryTimingState.EARLY: + reasons.append("Ранняя зона входа") + elif entry_timing_state == EntryTimingState.NORMAL: + reasons.append("Тайминг входа нормальный") + elif entry_timing_state == EntryTimingState.LATE: + reasons.append("Поздний вход") + elif entry_timing_state == EntryTimingState.CHASING: + reasons.append("Вход запрещён: chasing move") + + if ( + candle_noise_score is not None + and candle_noise_score < self._min_clean_candle_score + ): + reasons.append("Свечи шумные") + + if ( + price_position_score is not None + and price_position_score >= self._min_price_position_score + ): + reasons.append("Цена держится по тренду") + elif ( + price_position_score is not None + and price_position_score < self._min_price_position_score + ): + reasons.append("Цена плохо держится по тренду") + + if volatility == VolatilityState.HIGH: + reasons.append("Высокая волатильность") + elif volatility == VolatilityState.LOW: + reasons.append("Низкая волатильность") + elif volatility == VolatilityState.NORMAL: + reasons.append("Нормальная волатильность") + + rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else "" + + return ( + f"{'. '.join(reasons)}. " + f"ATR={atr_percent:.2f}%{rsi_text}." + ) def _unknown( self, @@ -701,7 +1610,21 @@ class MarketAnalysisService: "breakout_distance_percent": None, "breakout_reason": reason, "market_trend_gap_percent": None, + "ema_fast_slope_percent": None, + "ema_slow_slope_percent": None, "market_trend_consistency": None, + "market_trend_efficiency": None, + "trend_quality_score": None, + "ema_distance_atr_ratio": None, + "ema_distance_state": EmaDistanceState.UNKNOWN.value, + "entry_timing_state": EntryTimingState.UNKNOWN.value, + "entry_timing_reason": reason, + "htf_interval": self._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": reason, "candles_count": candles_count, "is_trade_allowed": False, "reason": reason, @@ -711,9 +1634,13 @@ class MarketAnalysisService: market_phase=MarketPhase.UNKNOWN, trend_gap_percent=None, trend_consistency=None, + trend_efficiency=None, + ema_distance_atr_ratio=None, phase_direction=TrendDirection.UNKNOWN, phase_change_percent=None, phase_reason=reason, + ema_fast_slope_percent=None, + ema_slow_slope_percent=None, phase_direction_consistency=None, momentum_state=MomentumState.UNKNOWN, momentum_direction=TrendDirection.UNKNOWN, @@ -722,4 +1649,92 @@ class MarketAnalysisService: breakout_level=None, breakout_distance_percent=None, breakout_reason=reason, - ) \ No newline at end of file + htf_interval=self._htf_interval, + htf_atr_percent=None, + htf_atr_percent_baseline=None, + htf_volatility_ratio=None, + htf_volatility=None, + trend_quality_score=None, + ema_distance_state=EmaDistanceState.UNKNOWN, + entry_timing_state=EntryTimingState.UNKNOWN, + entry_timing_reason=reason, + ) + + def _trend_quality_score( + self, + *, + trend_consistency: float | None, + trend_efficiency: float | None, + candle_noise_score: float | None, + price_position_score: float | None, + ) -> float | None: + values: list[float] = [] + + if trend_consistency is not None: + values.append(trend_consistency) + + if trend_efficiency is not None: + values.append(trend_efficiency) + + if candle_noise_score is not None: + values.append(candle_noise_score) + + if price_position_score is not None: + values.append(price_position_score) + + if not values: + return None + + return sum(values) / len(values) + + def _classify_ema_distance_state( + self, + ema_distance_atr_ratio: float | None, + ) -> EmaDistanceState: + if ema_distance_atr_ratio is None: + return EmaDistanceState.UNKNOWN + + if ema_distance_atr_ratio < 0.45: + return EmaDistanceState.COMPRESSED + + if ema_distance_atr_ratio < 1.8: + return EmaDistanceState.HEALTHY + + if ema_distance_atr_ratio < 2.8: + return EmaDistanceState.EXTENDED + + return EmaDistanceState.OVEREXTENDED + + def _classify_entry_timing( + self, + *, + ema_distance_state: EmaDistanceState, + momentum_state: MomentumState, + momentum_strength: float | None, + market_phase: MarketPhase, + ) -> tuple[EntryTimingState, str]: + strength = momentum_strength or 0.0 + + if ema_distance_state == EmaDistanceState.OVEREXTENDED: + return EntryTimingState.CHASING, "EMA_OVEREXTENDED" + + if ( + ema_distance_state == EmaDistanceState.EXTENDED + and momentum_state in { + MomentumState.BREAKOUT_UP, + MomentumState.BREAKOUT_DOWN, + } + and strength >= 1.5 + ): + return EntryTimingState.LATE, "BREAKOUT_ALREADY_EXTENDED" + + if market_phase == MarketPhase.PULLBACK: + return EntryTimingState.EARLY, "PULLBACK_ENTRY_ZONE" + + if ema_distance_state == EmaDistanceState.HEALTHY: + return EntryTimingState.NORMAL, "HEALTHY_TREND_DISTANCE" + + if ema_distance_state == EmaDistanceState.COMPRESSED: + return EntryTimingState.UNKNOWN, "EMA_COMPRESSED" + + return EntryTimingState.UNKNOWN, "ENTRY_TIMING_UNKNOWN" \ No newline at end of file diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py index 15e0eed..b6ec495 100644 --- a/app/src/trading/strategies/trend.py +++ b/app/src/trading/strategies/trend.py @@ -4,6 +4,8 @@ from __future__ import annotations import time +from typing import Any + from src.integrations.exchange.service import ExchangeService from src.trading.market_analysis.models import ( MarketPhase, @@ -133,8 +135,16 @@ class TrendStrategy: "market_phase_change_percent": market.phase_change_percent, "market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"), "market_phase_reason": market.phase_reason, - "momentum_state": market.momentum_state.value, - "momentum_direction": market.momentum_direction.value, + "momentum_state": ( + market.momentum_state.value + if market.momentum_state is not None + else "UNKNOWN" + ), + "momentum_direction": ( + market.momentum_direction.value + if market.momentum_direction is not None + else "UNKNOWN" + ), "momentum_change_percent": market.momentum_change_percent, "momentum_strength": market.momentum_strength, "breakout_level": market.breakout_level, @@ -381,8 +391,12 @@ class TrendStrategy: confidence = 0.55 + (strength_score * 0.35) return round(min(0.95, confidence), 2) - - def _analysis_price(self, snapshot: dict[str, object]) -> float: + + + def _analysis_price( + self, + snapshot: dict[str, Any], + ) -> float: bid = self._safe_float(snapshot.get("bid_price")) ask = self._safe_float(snapshot.get("ask_price")) @@ -395,7 +409,10 @@ class TrendStrategy: return 0.0 - def _safe_float(self, value: object) -> float | None: + def _safe_float( + self, + value: float | int | str | None, + ) -> float | None: if value is None: return None diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 6d5ab04..2921209 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -1257,6 +1257,135 @@ - diagnostics layer подготовлен к semantic trade analytics - diagnostics layer подготовлен к auto-refresh runtime dashboard +#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer +- реализована унификация Telegram handler architecture +- реализована единая callback message validation layer +- реализован _require_message() runtime safety layer +- устранены unsafe callback.message обращения +- реализована защита Telegram handlers от InaccessibleMessage +- реализована стандартизация Telegram screen lifecycle +- реализована стандартизация ActiveScreenManager integration +- реализована стандартизация LiveScreenRunner integration +- реализована стандартизация StaticScreen registration +- реализована стандартизация render/edit Telegram flow +- реализована стандартизация edit_mode architecture +- реализована стандартизация Telegram runtime navigation +- реализована стандартизация retry flow architecture +- реализована стандартизация monitoring navigation flow +- реализована стандартизация portfolio/market/journal runtime screens +- реализована стандартизация auto runtime screen architecture +- реализована стандартизация auto diagnostics rendering +- реализована стандартизация risk controls runtime flow +- реализована стандартизация journal runtime rendering +- реализована стандартизация monitoring runtime rendering +- реализована стандартизация Telegram callback lifecycle +- реализована стандартизация runtime logging payloads +- реализована стандартизация JournalService logging payload structure +- реализована стандартизация Telegram runtime formatting +- реализована стандартизация keyboard rendering layer +- реализована стандартизация runtime message rendering +- реализована стандартизация safe runtime editing +- реализована стандартизация Telegram exception handling +- реализована стандартизация runtime error protection +- реализована стандартизация runtime fallback rendering +- реализована стандартизация runtime state cleanup +- реализована стандартизация runtime unregister flow +- реализована стандартизация runtime screen switching +- реализована стандартизация FSM cleanup flow +- реализована стандартизация risk runtime update flow +- реализована стандартизация diagnostics runtime refresh +- реализована стандартизация portfolio runtime refresh +- реализована стандартизация market runtime refresh +- реализована стандартизация journal pagination flow +- реализована стандартизация journal export flow +- реализована стандартизация journal cleanup flow +- реализована стандартизация runtime callback alerts +- реализована стандартизация runtime user notifications +- реализована стандартизация runtime retry notifications +- реализована стандартизация Telegram screen auto-refresh architecture +- реализована стандартизация runtime render callbacks +- реализована стандартизация auto runtime protection layer +- реализована стандартизация runtime semantic rendering +- реализована стандартизация runtime formatting utilities +- реализована стандартизация numeric formatting layer +- реализована стандартизация safe numeric parsing +- реализована стандартизация safe float conversion layer +- реализован global NumericLike typing layer +- реализован global JsonDict typing layer +- реализован global JsonList typing layer +- реализован centralized safe_float() conversion layer +- устранены raw float() conversions в Telegram runtime +- устранены unsafe numeric casts +- устранены implicit runtime numeric conversions +- устранены raw dict payload usages +- устранены runtime typing inconsistencies +- устранены Telegram runtime nullable access risks +- устранены duplicated callback validation patterns +- устранены duplicated Telegram screen preparation patterns +- устранены fragmented runtime formatting implementations +- устранены inconsistent runtime payload structures +- устранены inconsistent Telegram render flows +- устранены fragmented diagnostics rendering patterns +- устранены fragmented journal rendering patterns +- устранены fragmented risk controls rendering patterns +- реализована подготовка centralized runtime typing layer +- реализована подготовка advanced runtime telemetry +- реализована подготовка persistent Telegram runtime analytics +- реализована подготовка advanced runtime observability +- реализована подготовка unified runtime safety architecture +- реализована подготовка strict typing migration +- реализована подготовка runtime-safe analytics layer +- реализована подготовка advanced Telegram runtime dashboard +- реализована подготовка unified runtime infrastructure + +### 07.4.4.1.11 ✅ Advanced Trend Quality & EMA Distance Layer +- реализован advanced trend efficiency layer +- реализован trend consistency analysis +- реализован trend quality score engine +- реализован EMA distance semantic layer +- реализован ATR-normalized EMA distance analysis +- реализован EMA compression detection +- реализован EMA overextension detection +- реализован healthy EMA structure detection +- реализован advanced trend quality classification +- реализован noisy trend semantic analysis +- реализован weak trend semantic analysis +- реализован clean trend semantic analysis +- реализован semantic market structure analysis +- реализован semantic breakout analysis +- реализован breakout vs trend reasoning +- реализован counter-trend breakout protection +- реализован late entry detection layer +- реализован overextended entry detection +- реализован chasing movement protection +- реализован pullback entry zone detection +- реализован semantic entry timing classification +- реализован runtime trend structure rendering +- реализован runtime EMA diagnostics rendering +- реализован semantic market explanation engine +- реализован semantic momentum explanation layer +- реализован semantic diagnostics rendering +- реализован semantic runtime reasoning +- реализован semantic blockers compression +- реализован advanced signal explanation layer +- реализован runtime semantic payload propagation +- реализован runtime trend quality propagation +- реализован runtime EMA state propagation +- реализован runtime timing state propagation +- реализован advanced breakout runtime handling +- реализован semantic HOLD reasoning +- реализован advanced _human() semantic mapping layer +- реализована стандартизация semantic diagnostics rendering +- реализована стандартизация trend structure runtime formatting +- реализована стандартизация EMA diagnostics runtime formatting +- реализована стандартизация breakout semantic rendering +- реализована стандартизация runtime trend explanations +- реализована подготовка position-aware diagnostics +- реализована подготовка runtime position pressure analysis +- реализована подготовка semantic position health layer +- реализована подготовка runtime risk reasoning layer + + --- ### 07.4.5 diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index 9b9b38f..f9bade2 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -1183,6 +1183,87 @@ - diagnostics layer подготовлен к semantic trade analytics - diagnostics layer подготовлен к auto-refresh runtime dashboard +#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer +- реализована унификация Telegram handler architecture +- реализована единая callback message validation layer +- реализован _require_message() runtime safety layer +- устранены unsafe callback.message обращения +- реализована защита Telegram handlers от InaccessibleMessage +- реализована стандартизация Telegram screen lifecycle +- реализована стандартизация ActiveScreenManager integration +- реализована стандартизация LiveScreenRunner integration +- реализована стандартизация StaticScreen registration +- реализована стандартизация render/edit Telegram flow +- реализована стандартизация edit_mode architecture +- реализована стандартизация Telegram runtime navigation +- реализована стандартизация retry flow architecture +- реализована стандартизация monitoring navigation flow +- реализована стандартизация portfolio/market/journal runtime screens +- реализована стандартизация auto runtime screen architecture +- реализована стандартизация auto diagnostics rendering +- реализована стандартизация risk controls runtime flow +- реализована стандартизация journal runtime rendering +- реализована стандартизация monitoring runtime rendering +- реализована стандартизация Telegram callback lifecycle +- реализована стандартизация runtime logging payloads +- реализована стандартизация JournalService logging payload structure +- реализована стандартизация Telegram runtime formatting +- реализована стандартизация keyboard rendering layer +- реализована стандартизация runtime message rendering +- реализована стандартизация safe runtime editing +- реализована стандартизация Telegram exception handling +- реализована стандартизация runtime error protection +- реализована стандартизация runtime fallback rendering +- реализована стандартизация runtime state cleanup +- реализована стандартизация runtime unregister flow +- реализована стандартизация runtime screen switching +- реализована стандартизация FSM cleanup flow +- реализована стандартизация risk runtime update flow +- реализована стандартизация diagnostics runtime refresh +- реализована стандартизация portfolio runtime refresh +- реализована стандартизация market runtime refresh +- реализована стандартизация journal pagination flow +- реализована стандартизация journal export flow +- реализована стандартизация journal cleanup flow +- реализована стандартизация runtime callback alerts +- реализована стандартизация runtime user notifications +- реализована стандартизация runtime retry notifications +- реализована стандартизация Telegram screen auto-refresh architecture +- реализована стандартизация runtime render callbacks +- реализована стандартизация auto runtime protection layer +- реализована стандартизация runtime semantic rendering +- реализована стандартизация runtime formatting utilities +- реализована стандартизация numeric formatting layer +- реализована стандартизация safe numeric parsing +- реализована стандартизация safe float conversion layer +- реализован global NumericLike typing layer +- реализован global JsonDict typing layer +- реализован global JsonList typing layer +- реализован centralized safe_float() conversion layer +- устранены raw float() conversions в Telegram runtime +- устранены unsafe numeric casts +- устранены implicit runtime numeric conversions +- устранены raw dict payload usages +- устранены runtime typing inconsistencies +- устранены Telegram runtime nullable access risks +- устранены duplicated callback validation patterns +- устранены duplicated Telegram screen preparation patterns +- устранены fragmented runtime formatting implementations +- устранены inconsistent runtime payload structures +- устранены inconsistent Telegram render flows +- устранены fragmented diagnostics rendering patterns +- устранены fragmented journal rendering patterns +- устранены fragmented risk controls rendering patterns +- реализована подготовка centralized runtime typing layer +- реализована подготовка advanced runtime telemetry +- реализована подготовка persistent Telegram runtime analytics +- реализована подготовка advanced runtime observability +- реализована подготовка unified runtime safety architecture +- реализована подготовка strict typing migration +- реализована подготовка runtime-safe analytics layer +- реализована подготовка advanced Telegram runtime dashboard +- реализована подготовка unified runtime infrastructure + --- ### 07.4.4.1.10 Semantic Runtime Diagnostics & Observability @@ -1233,6 +1314,187 @@ - diagnostic layer подготовлен к Diagnostic Journal Layer - diagnostic layer подготовлен к Auto-refresh Diagnostic UI + +#### 07.4.4.1.10.3 ✅ Telegram Diagnostic Screen +- реализирован полноценный Telegram Diagnostic Screen +- реализован отдельный diagnostics Telegram UI layer +- реализован auto-refresh diagnostics screen +- реализована интеграция diagnostics screen с AutoTradeRunner +- реализована интеграция diagnostics screen с ActiveScreenManager +- реализован отдельный diagnostic navigation flow +- реализована отдельная diagnostics keyboard +- реализовано безопасное обновление diagnostic messages +- реализована защита Telegram diagnostics UI от TelegramBadRequest +- реализован explainable runtime diagnostic screen +- реализован explainable semantic diagnostic UI +- реализован explainable market diagnostics UI +- реализован explainable momentum diagnostics UI +- реализован explainable breakout diagnostics UI +- реализован explainable execution diagnostics UI +- реализован explainable adaptive sizing diagnostics UI +- реализован explainable runtime health diagnostics UI +- реализован explainable position diagnostics UI +- реализован explainable severity system +- реализована semantic severity hierarchy +- реализовано разделение WAITING / YELLOW / RED runtime states +- реализована логика semantic waiting state +- реализована логика runtime freshness interpretation +- реализована логика execution readiness interpretation +- реализована логика signal confirmation interpretation +- реализована логика market noise interpretation +- реализована логика market phase interpretation +- реализована логика breakout explanation +- реализована логика execution quality explanation +- реализована логика adaptive sizing explanation +- реализована логика runtime degradation explanation +- исключены ложные warning состояния при HOLD signal +- исключены ложные yellow состояния без momentum +- реализован semantic OFF diagnostics mode +- реализован lightweight diagnostics режим для OFF состояния +- реализована корректная diagnostics логика без RUNNING state +- реализована подготовка cycle pnl diagnostics +- реализована подготовка flip diagnostics +- реализована подготовка cumulative realized pnl diagnostics +- реализована подготовка old/new side flip diagnostics +- реализована подготовка flip pnl diagnostics +- реализована подготовка position cycle analytics +- semantic analytics layer стал explainable +- semantic analytics layer стал user-readable +- semantic analytics layer стал Telegram-ready +- diagnostics layer подготовлен к Diagnostic Journal Layer +- diagnostics layer подготовлен к persistent runtime analytics +- diagnostics layer подготовлен к advanced cycle analytics +- diagnostics layer подготовлен к semantic trade analytics +- diagnostics layer подготовлен к auto-refresh runtime dashboard + +#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer +- реализована унификация Telegram handler architecture +- реализована единая callback message validation layer +- реализован _require_message() runtime safety layer +- устранены unsafe callback.message обращения +- реализована защита Telegram handlers от InaccessibleMessage +- реализована стандартизация Telegram screen lifecycle +- реализована стандартизация ActiveScreenManager integration +- реализована стандартизация LiveScreenRunner integration +- реализована стандартизация StaticScreen registration +- реализована стандартизация render/edit Telegram flow +- реализована стандартизация edit_mode architecture +- реализована стандартизация Telegram runtime navigation +- реализована стандартизация retry flow architecture +- реализована стандартизация monitoring navigation flow +- реализована стандартизация portfolio/market/journal runtime screens +- реализована стандартизация auto runtime screen architecture +- реализована стандартизация auto diagnostics rendering +- реализована стандартизация risk controls runtime flow +- реализована стандартизация journal runtime rendering +- реализована стандартизация monitoring runtime rendering +- реализована стандартизация Telegram callback lifecycle +- реализована стандартизация runtime logging payloads +- реализована стандартизация JournalService logging payload structure +- реализована стандартизация Telegram runtime formatting +- реализована стандартизация keyboard rendering layer +- реализована стандартизация runtime message rendering +- реализована стандартизация safe runtime editing +- реализована стандартизация Telegram exception handling +- реализована стандартизация runtime error protection +- реализована стандартизация runtime fallback rendering +- реализована стандартизация runtime state cleanup +- реализована стандартизация runtime unregister flow +- реализована стандартизация runtime screen switching +- реализована стандартизация FSM cleanup flow +- реализована стандартизация risk runtime update flow +- реализована стандартизация diagnostics runtime refresh +- реализована стандартизация portfolio runtime refresh +- реализована стандартизация market runtime refresh +- реализована стандартизация journal pagination flow +- реализована стандартизация journal export flow +- реализована стандартизация journal cleanup flow +- реализована стандартизация runtime callback alerts +- реализована стандартизация runtime user notifications +- реализована стандартизация runtime retry notifications +- реализована стандартизация Telegram screen auto-refresh architecture +- реализована стандартизация runtime render callbacks +- реализована стандартизация auto runtime protection layer +- реализована стандартизация runtime semantic rendering +- реализована стандартизация runtime formatting utilities +- реализована стандартизация numeric formatting layer +- реализована стандартизация safe numeric parsing +- реализована стандартизация safe float conversion layer +- реализован global NumericLike typing layer +- реализован global JsonDict typing layer +- реализован global JsonList typing layer +- реализован centralized safe_float() conversion layer +- устранены raw float() conversions в Telegram runtime +- устранены unsafe numeric casts +- устранены implicit runtime numeric conversions +- устранены raw dict payload usages +- устранены runtime typing inconsistencies +- устранены Telegram runtime nullable access risks +- устранены duplicated callback validation patterns +- устранены duplicated Telegram screen preparation patterns +- устранены fragmented runtime formatting implementations +- устранены inconsistent runtime payload structures +- устранены inconsistent Telegram render flows +- устранены fragmented diagnostics rendering patterns +- устранены fragmented journal rendering patterns +- устранены fragmented risk controls rendering patterns +- реализована подготовка centralized runtime typing layer +- реализована подготовка advanced runtime telemetry +- реализована подготовка persistent Telegram runtime analytics +- реализована подготовка advanced runtime observability +- реализована подготовка unified runtime safety architecture +- реализована подготовка strict typing migration +- реализована подготовка runtime-safe analytics layer +- реализована подготовка advanced Telegram runtime dashboard +- реализована подготовка unified runtime infrastructure + +### 07.4.4.1.11 ✅ Advanced Trend Quality & EMA Distance Layer +- реализован advanced trend efficiency layer +- реализован trend consistency analysis +- реализован trend quality score engine +- реализован EMA distance semantic layer +- реализован ATR-normalized EMA distance analysis +- реализован EMA compression detection +- реализован EMA overextension detection +- реализован healthy EMA structure detection +- реализован advanced trend quality classification +- реализован noisy trend semantic analysis +- реализован weak trend semantic analysis +- реализован clean trend semantic analysis +- реализован semantic market structure analysis +- реализован semantic breakout analysis +- реализован breakout vs trend reasoning +- реализован counter-trend breakout protection +- реализован late entry detection layer +- реализован overextended entry detection +- реализован chasing movement protection +- реализован pullback entry zone detection +- реализован semantic entry timing classification +- реализован runtime trend structure rendering +- реализован runtime EMA diagnostics rendering +- реализован semantic market explanation engine +- реализован semantic momentum explanation layer +- реализован semantic diagnostics rendering +- реализован semantic runtime reasoning +- реализован semantic blockers compression +- реализован advanced signal explanation layer +- реализован runtime semantic payload propagation +- реализован runtime trend quality propagation +- реализован runtime EMA state propagation +- реализован runtime timing state propagation +- реализован advanced breakout runtime handling +- реализован semantic HOLD reasoning +- реализован advanced _human() semantic mapping layer +- реализована стандартизация semantic diagnostics rendering +- реализована стандартизация trend structure runtime formatting +- реализована стандартизация EMA diagnostics runtime formatting +- реализована стандартизация breakout semantic rendering +- реализована стандартизация runtime trend explanations +- реализована подготовка position-aware diagnostics +- реализована подготовка runtime position pressure analysis +- реализована подготовка semantic position health layer +- реализована подготовка runtime risk reasoning layer + --- ### 07.4.5 diff --git a/docs/stages/stage-07_4_4_1_10_4-telegram_runtime_stabilization_type_safety_layer.md b/docs/stages/stage-07_4_4_1_10_4-telegram_runtime_stabilization_type_safety_layer.md new file mode 100644 index 0000000..7035df5 --- /dev/null +++ b/docs/stages/stage-07_4_4_1_10_4-telegram_runtime_stabilization_type_safety_layer.md @@ -0,0 +1,457 @@ +# 07.4.4.1.10.4 — Telegram Runtime Stabilization & Type Safety Layer + +## Статус + +✅ Реализовано + +Рекомендуемый commit message: + +```bash +git commit -m "07.4.4.1.10.4 — Telegram Runtime Stabilization & Type Safety Layer" +``` + +--- + +# Краткое описание этапа + +Этап `07.4.4.1.10.4` посвящён масштабной стабилизации Telegram runtime слоя после внедрения Semantic Diagnostics и Telegram Diagnostic Screen. + +Главная цель этапа: + +- привести Telegram handlers к единому архитектурному стилю; +- внедрить строгий runtime-safe typing; +- унифицировать lifecycle экранов; +- устранить unsafe callback/message usage; +- внедрить централизованный numeric parsing; +- подготовить проект к advanced runtime analytics и observability. + +Этап затронул: + +- Telegram handlers; +- Auto runtime; +- Diagnostics runtime; +- Portfolio / Market / Journal screens; +- Risk Controls; +- System settings; +- Debug runtime; +- Execution notifications; +- Market analysis layer; +- Journal export layer; +- Exchange runtime helpers. + +--- + +# Основные реализованные изменения + +## 1. Runtime Type Safety Layer + +Добавлены новые core-слои: + +```text +src/core/numbers.py +src/core/types.py +src/core/telegram_errors.py +``` + +Реализованы новые runtime-типы: + +- `NumericLike` +- `JsonDict` +- `JsonList` + +Также внедрён централизованный helper: + +```python +safe_float(value) +``` + +--- + +## 2. Telegram Callback Safety Layer + +Во всех Telegram handlers реализован единый runtime-safe pattern: + +```python +def _require_message( + callback: CallbackQuery, +) -> Message | None: +``` + +Теперь все callback handlers защищены от: + +- `InaccessibleMessage` +- `None message` +- unsafe callback.message access + +--- + +## 3. Unified Telegram Screen Lifecycle + +Унифицирован lifecycle Telegram screens: + +- ActiveScreenManager +- LiveScreenRunner +- ScreenRegistry +- StaticScreen +- LiveScreen + +Реализован единый flow: + +- prepare screen +- unregister old screen +- register live/static screen +- register active screen + +--- + +## 4. Auto Runtime Stabilization + +Стабилизирован: + +```text +src/trading/auto/runner.py +``` + +Реализовано: + +- ClassVar runtime fields +- staticmethod render callbacks +- safe payload handling +- safe EventBus integration +- NumericLike migration +- safe_float migration +- runtime-safe notifications + +--- + +## 5. Telegram Live Runner Stabilization + +Стабилизирован: + +```text +src/telegram/live/runner.py +``` + +Реализовано: + +- safer live refresh +- safer callback storage +- unified runtime architecture +- observability preparation + +--- + +## 6. Auto UI Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/auto/ui.py +``` + +Реализовано: + +- новый runtime header +- отдельный cycle block +- cycle pnl fields +- cumulative pnl fields +- flip analytics +- cleaner semantic rendering + +--- + +## 7. Auto Main Handler Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/auto/main.py +``` + +Реализовано: + +- unified render flow +- safe callback handling +- diagnostics runtime integration +- TelegramBadRequest protection +- unified screen preparation + +--- + +## 8. Auto Risk Controls Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/auto/risk.py +``` + +Реализовано: + +- NumericLike migration +- safe_float migration +- JsonDict FSM data +- safe callback handling +- safe risk screen restore +- safe reset flow +- unified payload builder + +--- + +## 9. System Handler Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/system.py +``` + +Реализовано: + +- safe callback parsing +- NumericLike migration +- safe_float migration +- unified callback validation +- safer settings parsing + +--- + +## 10. Market Screen Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/market.py +``` + +Реализовано: + +- safe ticker parsing +- NumericLike migration +- safe callback handling +- live screen stabilization +- safer logging payloads + +--- + +## 11. Portfolio Screen Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/portfolio.py +``` + +Реализовано: + +- safer live refresh +- safer retry flow +- unified monitoring navigation +- unified screen lifecycle + +--- + +## 12. Journal Runtime Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/journal.py +``` + +Реализовано: + +- safer pagination +- safer export flow +- safer cleanup flow +- unified runtime style + +--- + +## 13. Journal UI Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/journal_ui.py +``` + +Реализовано: + +- JsonDict +- JsonList +- NumericLike +- safe_float() +- safer event parsing +- safer datetime parsing +- malformed event protection + +--- + +## 14. Monitoring / Home / Start Stabilization + +Стабилизированы: + +```text +src/telegram/handlers/monitoring.py +src/telegram/handlers/home.py +src/telegram/handlers/start.py +``` + +Реализовано: + +- unified lifecycle +- unified menu rendering +- safer screen registration +- unified runtime architecture + +--- + +## 15. Debug Runtime Stabilization + +Стабилизирован: + +```text +src/telegram/handlers/debug.py +``` + +Реализовано: + +- NumericLike migration +- safe_float migration +- safer runtime formatting +- safer pnl rendering +- safer leverage formatting +- cleaner signal duration runtime + +--- + +## 16. Execution Notification Stabilization + +Стабилизирован: + +```text +src/notifications/templates/execution.py +``` + +Исправлены: + +- Long/Short formatting +- flip notifications +- pnl formatting +- execution message readability + +--- + +## 17. Auto Service Typing Migration + +Частично переведён на новый typing layer: + +```text +src/trading/auto/service.py +``` + +Реализовано: + +- JsonDict payloads +- NumericLike migration +- safe_float migration +- safer diagnostics payloads + +--- + +## 18. Market Analysis Preparation + +Подготовлены diagnostics fields: + +```text +src/trading/market_analysis/models.py +src/trading/market_analysis/service.py +``` + +Подготовка к: + +- Trend Efficiency +- EMA Distance Diagnostics +- Breakout Quality +- HTF Volatility Context +- Advanced Semantic Diagnostics + +--- + +## 19. Diagnostics Runtime Continuation + +Продолжено развитие: + +```text +src/trading/diagnostics/formatter.py +src/trading/diagnostics/snapshot.py +``` + +Подготовлены: + +- cycle analytics +- flip analytics +- runtime freshness +- semantic health +- adaptive diagnostics + +--- + +## 20. Exchange Runtime Stabilization + +Стабилизированы: + +```text +src/integrations/exchange/market_data_runner.py +src/integrations/exchange/service.py +``` + +Реализовано: + +- safer market snapshots +- runtime-safe payloads +- diagnostics preparation +- observability preparation + +--- + +# Удаление legacy handler + +Удалён: + +```text +src/telegram/handlers/_auto.py +``` + +Проект окончательно переведён на новую структуру: + +```text +src/telegram/handlers/auto/main.py +src/telegram/handlers/auto/risk.py +src/telegram/handlers/auto/ui.py +``` + +--- + +# Итог этапа + +После этапа: + +- Telegram runtime стал стабильнее; +- live refresh стал безопаснее; +- diagnostics runtime стал устойчивее; +- unified typing layer внедрён; +- runtime-safe numeric parsing внедрён; +- callback/message safety стандартизирован; +- проект подготовлен к следующему diagnostics tier. + +--- + +# Рекомендуемый commit + +```bash +git add . +git commit -m "07.4.4.1.10.4 — Telegram Runtime Stabilization & Type Safety Layer" +git push origin main +``` diff --git a/docs/stages/stage-07_4_4_1_11-advanced_trend_quality_ema_distance_layer.md b/docs/stages/stage-07_4_4_1_11-advanced_trend_quality_ema_distance_layer.md new file mode 100644 index 0000000..a263b51 --- /dev/null +++ b/docs/stages/stage-07_4_4_1_11-advanced_trend_quality_ema_distance_layer.md @@ -0,0 +1,197 @@ +# 07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer + +## Статус + +✅ Реализовано + +Рекомендуемый commit message: + +```bash +git commit -m "07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer" +``` + +--- + +# Краткое описание этапа + +Этап `07.4.4.1.11` посвящён развитию semantic market analysis layer и внедрению advanced trend diagnostics поверх базового TREND engine. + +Главная цель этапа: + +- научить систему оценивать качество тренда, а не только его направление; +- внедрить EMA distance semantic analysis; +- реализовать advanced trend efficiency logic; +- реализовать overextension / chasing protection; +- реализовать entry timing semantic layer; +- подготовить систему к position-aware diagnostics и runtime risk analysis. + +--- + +# Основные реализованные изменения + +## 1. Advanced Trend Efficiency Layer + +Реализован новый semantic слой оценки эффективности тренда. + +Добавлены diagnostics fields: + +- trend_efficiency +- trend_consistency +- trend_quality_score + +Теперь рынок анализируется по: +- стабильности импульса; +- качеству структуры; +- последовательности движения; +- эффективности продвижения цены; +- уровню рыночного шума. + +--- + +## 2. EMA Distance Semantic Layer + +Реализована полноценная EMA-distance diagnostics layer. + +Добавлены: + +- ema_distance_atr_ratio +- ema_distance_state + +Система теперь умеет различать: + +- healthy trend distance; +- compressed EMA structure; +- overextended structure; +- stretched trend; +- dangerous chasing zones. + +--- + +## 3. Entry Timing Classification + +Реализован новый semantic timing layer. + +Добавлены состояния: + +- EARLY +- HEALTHY +- LATE +- OVEREXTENDED +- PULLBACK_ENTRY_ZONE +- ENTRY_TIMING_UNKNOWN + +Теперь semantic engine способен определять: + +- ранний вход; +- здоровую зону входа; +- поздний вход; +- перерастянутый вход; +- pullback entry zone. + +--- + +## 4. Late Entry / Overextension Filter + +Реализован отдельный слой защиты от поздних входов. + +Добавлены semantic states: + +- EMA_OVEREXTENDED +- BREAKOUT_ALREADY_EXTENDED +- CHASING +- EXTENDED + +Теперь стратегия умеет: +- блокировать поздние breakout entries; +- избегать chasing movement; +- избегать входов после сильного расширения EMA. + +--- + +## 5. Trend Quality Classification Upgrade + +Существенно расширен trend quality classification engine. + +Теперь TrendQuality учитывает: +- EMA structure; +- ATR-normalized distance; +- trend consistency; +- momentum alignment; +- directional stability; +- volatility context. + +--- + +## 6. Semantic Diagnostics Integration + +Расширен formatter.py. + +Реализовано: +- semantic trend rendering; +- EMA diagnostics rendering; +- trend structure explanation; +- semantic momentum descriptions; +- breakout semantic rendering. + +--- + +## 7. Runtime Trend Structure Rendering + +Telegram diagnostics теперь отображает: +- trend quality score; +- trend consistency; +- trend efficiency; +- EMA/ATR distance; +- semantic EMA state; +- semantic entry timing. + +--- + +## 8. Strategy Semantic Upgrade + +Существенно улучшен trend.py. + +Реализовано: +- semantic breakout handling; +- counter-trend breakout protection; +- semantic pullback blocking; +- semantic noisy trend handling; +- advanced market payload propagation. + +--- + +## 9. Human-readable Semantic Rendering + +Существенно расширен _human() mapping layer. + +Добавлены semantic mappings для: +- EMA states; +- entry timing; +- breakout states; +- trend structure; +- semantic momentum. + +--- + +# Итог этапа + +После этапа: + +- market analysis стал semantic-aware; +- система научилась оценивать качество тренда; +- реализован EMA distance reasoning; +- реализован entry timing analysis; +- реализован late-entry protection; +- diagnostics стали более "человеческими"; +- Telegram runtime стал информативнее; +- подготовлена база для position-aware diagnostics. + +--- + +# Рекомендуемый commit + +```bash +git add . +git commit -m "07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer" +git push origin main +```