From d9e6392e2801a115a4d80223f2a0a6bf11de5ea1 Mon Sep 17 00:00:00 2001 From: Sergey Date: Thu, 28 May 2026 10:30:54 +0300 Subject: [PATCH] =?UTF-8?q?07.4.4.1.13=20=E2=80=94=20AutoTrade=20Runtime?= =?UTF-8?q?=20Journal,=20Execution=20Refactor=20&=20Trade=20Analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/core/event_titles.py | 34 +- app/src/core/system_status.py | 224 +- .../exchange/market_data_runner.py | 420 ++- .../integrations/exchange/market_stream.py | 120 +- app/src/integrations/exchange/models.py | 43 +- app/src/integrations/exchange/runtime_ui.py | 296 ++ app/src/integrations/exchange/service.py | 976 +++--- app/src/integrations/exchange/status.py | 288 ++ app/src/integrations/exchange/ws_client.py | 86 +- app/src/notifications/templates/signal.py | 118 +- app/src/storage/models.py | 16 +- app/src/storage/repositories/journal.py | 112 +- app/src/storage/repositories/order_drafts.py | 111 - app/src/storage/schema.py | 41 +- app/src/storage/session.py | 2 + app/src/telegram/handlers/auto/main.py | 27 + app/src/telegram/handlers/auto/ui.py | 197 +- app/src/telegram/handlers/journal.py | 188 +- app/src/telegram/handlers/journal_ui.py | 42 +- app/src/telegram/handlers/market.py | 474 --- app/src/telegram/handlers/monitoring.py | 171 -- app/src/telegram/handlers/portfolio.py | 181 +- app/src/telegram/handlers/start.py | 34 +- app/src/telegram/handlers/system.py | 206 +- app/src/telegram/handlers/trade/__init__.py | 1 - app/src/telegram/handlers/trade/main.py | 311 -- app/src/telegram/handlers/trade/new_order.py | 16 - .../telegram/handlers/trade/new_order_core.py | 9 - .../telegram/handlers/trade/new_order_flow.py | 1299 -------- .../handlers/trade/new_order_navigation.py | 401 --- .../telegram/handlers/trade/new_order_ui.py | 1034 ------- app/src/telegram/keyboards/reply.py | 3 +- app/src/telegram/menus.py | 51 +- app/src/telegram/routers.py | 8 - app/src/telegram/ui/exchange_error.py | 95 +- app/src/telegram/ui/runtime_status.py | 0 app/src/trading/accounts/service.py | 73 +- app/src/trading/auto/__init__.py | 2 + app/src/trading/auto/auto_lifecycle.py | 555 ++++ app/src/trading/auto/autonomous_management.py | 69 + app/src/trading/auto/execution_quality.py | 488 +++ app/src/trading/auto/execution_semantic.py | 120 + app/src/trading/auto/market_runtime.py | 274 ++ app/src/trading/auto/position_health.py | 318 ++ app/src/trading/auto/position_intelligence.py | 420 +++ app/src/trading/auto/runner.py | 91 +- app/src/trading/auto/service.py | 2702 +---------------- app/src/trading/auto/signal_runtime.py | 814 +++++ app/src/trading/auto/state.py | 11 +- app/src/trading/diagnostics/formatter.py | 49 +- app/src/trading/diagnostics/snapshot.py | 17 +- app/src/trading/execution/calculations.py | 142 + app/src/trading/execution/engine.py | 2332 +------------- app/src/trading/execution/flip.py | 446 +++ app/src/trading/execution/position_actions.py | 478 +++ .../execution/position_intelligence.py | 209 ++ .../trading/execution/position_protection.py | 398 +++ app/src/trading/execution/position_runtime.py | 317 ++ app/src/trading/execution/pricing.py | 134 + app/src/trading/execution/resets.py | 74 + app/src/trading/execution/risk_close.py | 121 + app/src/trading/execution/runtime_actions.py | 309 ++ app/src/trading/execution/sizing.py | 405 +++ app/src/trading/execution/supervisor.py | 172 ++ app/src/trading/journal/exporter.py | 6 + app/src/trading/journal/filters.py | 64 + app/src/trading/journal/service.py | 150 +- app/src/trading/orders/__init__.py | 1 - app/src/trading/orders/models.py | 37 - app/src/trading/orders/service.py | 626 ---- app/src/trading/orders/states.py | 11 - app/src/trading/position/state.py | 9 + docs/roadmap/master-roadmap.md | 19 + docs/roadmap/stage-07-auto-trading-roadmap.md | 19 + ..._execution_refactor_and_trade_analytics.md | 325 ++ 75 files changed, 9934 insertions(+), 10508 deletions(-) create mode 100644 app/src/integrations/exchange/runtime_ui.py create mode 100644 app/src/integrations/exchange/status.py delete mode 100644 app/src/storage/repositories/order_drafts.py delete mode 100644 app/src/telegram/handlers/market.py delete mode 100644 app/src/telegram/handlers/monitoring.py delete mode 100644 app/src/telegram/handlers/trade/__init__.py delete mode 100644 app/src/telegram/handlers/trade/main.py delete mode 100644 app/src/telegram/handlers/trade/new_order.py delete mode 100644 app/src/telegram/handlers/trade/new_order_core.py delete mode 100644 app/src/telegram/handlers/trade/new_order_flow.py delete mode 100644 app/src/telegram/handlers/trade/new_order_navigation.py delete mode 100644 app/src/telegram/handlers/trade/new_order_ui.py create mode 100644 app/src/telegram/ui/runtime_status.py create mode 100644 app/src/trading/auto/auto_lifecycle.py create mode 100644 app/src/trading/auto/autonomous_management.py create mode 100644 app/src/trading/auto/execution_quality.py create mode 100644 app/src/trading/auto/execution_semantic.py create mode 100644 app/src/trading/auto/market_runtime.py create mode 100644 app/src/trading/auto/position_health.py create mode 100644 app/src/trading/auto/position_intelligence.py create mode 100644 app/src/trading/auto/signal_runtime.py create mode 100644 app/src/trading/execution/calculations.py create mode 100644 app/src/trading/execution/flip.py create mode 100644 app/src/trading/execution/position_actions.py create mode 100644 app/src/trading/execution/position_intelligence.py create mode 100644 app/src/trading/execution/position_protection.py create mode 100644 app/src/trading/execution/position_runtime.py create mode 100644 app/src/trading/execution/pricing.py create mode 100644 app/src/trading/execution/resets.py create mode 100644 app/src/trading/execution/risk_close.py create mode 100644 app/src/trading/execution/runtime_actions.py create mode 100644 app/src/trading/execution/sizing.py create mode 100644 app/src/trading/execution/supervisor.py create mode 100644 app/src/trading/journal/filters.py delete mode 100644 app/src/trading/orders/__init__.py delete mode 100644 app/src/trading/orders/models.py delete mode 100644 app/src/trading/orders/service.py delete mode 100644 app/src/trading/orders/states.py create mode 100644 docs/stages/stage-07_4_4_1_13-auto_trade_runtime_journal_execution_refactor_and_trade_analytics.md diff --git a/app/src/core/event_titles.py b/app/src/core/event_titles.py index 97e8fcc..954da60 100644 --- a/app/src/core/event_titles.py +++ b/app/src/core/event_titles.py @@ -16,18 +16,19 @@ EVENT_TITLES = { # Настройки "auto_settings_updated": "Автоторговля", + "auto_status_changed": "Автоторговля", "risk_settings_updated": "Защита", - # Аналитика рынка - "market_state_changed": "Рынок", - "market_volatility_changed": "Рынок", + # Аналитика автоторговли + "market_state_changed": "Автоторговля", + "market_volatility_changed": "Автоторговля", - # Мониторинг рынка - "market_monitor_started": "Рынок", - "market_monitor_stopped": "Рынок", - "market_stream_connected": "Рынок", - "market_stream_disconnected": "Рынок", - "market_symbol_changed": "Рынок", + # Рыночные данные runtime + "market_monitor_started": "Автоторговля", + "market_monitor_stopped": "Автоторговля", + "market_stream_connected": "Автоторговля", + "market_stream_disconnected": "Автоторговля", + "market_symbol_changed": "Автоторговля", # Мониторинг позиций "entry_blocked": "Вход в позицию", @@ -61,10 +62,6 @@ EVENT_TITLES = { "system_retry": "Система", "system_about_opened": "Система", - "market_open_requested": "Рынок", - "market_open_success": "Рынок", - "market_open_error": "Рынок", - "portfolio_open_requested": "Портфель", "portfolio_open_success": "Портфель", "portfolio_open_error": "Портфель", @@ -72,10 +69,21 @@ EVENT_TITLES = { "exchange_request_error": "Биржа", + "exchange_auth_error": "Аккаунт", + "exchange_auth_restored": "Аккаунт", + "exchange_time_sync_error": "Время биржи", + "exchange_time_sync_restored": "Время биржи", + "balance_summary_loaded": "Баланс", "balance_summary_error": "Баланс", "runtime_expired": "Runtime", + + "market_status_unavailable": "Автоторговля", + "market_status_restored": "Автоторговля", + "market_closed": "Автоторговля", + "market_rest_fallback_available": "Автоторговля", + "market_rest_fallback_unavailable": "Автоторговля", } diff --git a/app/src/core/system_status.py b/app/src/core/system_status.py index 0fa6bed..5cf0753 100644 --- a/app/src/core/system_status.py +++ b/app/src/core/system_status.py @@ -9,7 +9,9 @@ from zoneinfo import ZoneInfo from src.core.config import load_settings from src.core.constants import APP_NAME, APP_VERSION +from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts from src.integrations.exchange.service import ExchangeService +from src.integrations.exchange.status import build_exchange_error_status from src.storage.session import check_database_health from src.trading.journal.service import JournalService @@ -32,6 +34,96 @@ class SystemSnapshot: components: list[ComponentStatus] +def _build_exchange_alert_components( + *, + default_symbol: str, +) -> list[ComponentStatus]: + exchange_service = ExchangeService() + + try: + runtime_status = exchange_service.get_symbol_runtime_status(default_symbol) + except Exception as exc: + runtime_status = build_exchange_error_status(exc) + + if not runtime_status.is_available: + return [ + ComponentStatus( + name="Биржа", + state=runtime_status.ui_line, + details=runtime_status.message, + ) + ] + + alerts = build_runtime_exchange_alerts(symbol=default_symbol) + + exchange_unavailable_alert = next( + ( + alert + for alert in alerts + if str(alert.get("code") or "") == "EXCHANGE_UNAVAILABLE" + ), + None, + ) + + if exchange_unavailable_alert is not None: + return [ + ComponentStatus( + name="Биржа", + state=str( + exchange_unavailable_alert.get("ui_line") + or exchange_unavailable_alert.get("title") + or "⛔️ Биржа недоступна" + ), + details=str(exchange_unavailable_alert.get("details") or ""), + ) + ] + + components: list[ComponentStatus] = [ + ComponentStatus( + name="Биржа", + state="🟢", + details=runtime_status.message, + ) + ] + + has_account_alert = False + + for alert in alerts: + code = str(alert.get("code") or "") + state = str( + alert.get("ui_line") + or alert.get("title") + or "⛔️ Ошибка биржи" + ) + + if code == "AUTH_ERROR": + has_account_alert = True + name = "Аккаунт" + elif code == "TIME_ERROR": + name = "Время биржи" + else: + name = "Биржа" + + components.append( + ComponentStatus( + name=name, + state=state, + details=str(alert.get("details") or ""), + ) + ) + + if not has_account_alert: + components.append( + ComponentStatus( + name="Аккаунт", + state="🟢", + ) + ) + + return components + + +# извлечь короткую версию PostgreSQL из строки health-check def _extract_postgres_version(raw: str) -> str: if not raw: return "PostgreSQL" @@ -43,99 +135,72 @@ def _extract_postgres_version(raw: str) -> str: return "PostgreSQL" -def _build_exchange_status( - exchange_service: ExchangeService, - default_symbol: str, -) -> ComponentStatus: - try: - symbol_validation = exchange_service.validate_symbol(default_symbol) - except Exception as exc: - return ComponentStatus( - name="Биржа", - state="🔴", - details=_humanize_error_message(str(exc)), - ) - - exchange_health = exchange_service.get_health() - - if exchange_health.ok and symbol_validation.is_valid: - return ComponentStatus(name="Биржа", state="🟢") - - if not exchange_health.ok: - return ComponentStatus( - name="Биржа", - state="🔴", - details=_humanize_error_message(exchange_health.message or ""), - ) - - return ComponentStatus( - name="Биржа", - state="🔴", - details=symbol_validation.message or "Инструмент не прошёл проверку.", - ) - - -def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus: - private_auth_health = exchange_service.get_private_auth_health() - if private_auth_health.ok: - return ComponentStatus(name="Аккаунт", state="🟢") - - return ComponentStatus( - name="Аккаунт", - state="🔴", - details=_humanize_error_message(private_auth_health.message or ""), - ) - - +# проверить подключение к БД и вернуть компонент + подпись версии def _build_database_status() -> tuple[ComponentStatus, str]: db_ok, db_message = check_database_health() db_label = _extract_postgres_version(db_message) if db_ok: - return ComponentStatus(name="База данных", state="🟢"), db_label + return ( + ComponentStatus( + name="База данных", + state="🟢", + ), + db_label, + ) return ( ComponentStatus( name="База данных", - state="🔴", - details=db_message or "Ошибка подключения к БД.", + state="🔴 База данных недоступна", ), db_label, ) +# проверить доступность журнала событий def _build_journal_status() -> ComponentStatus: - ok, message = JournalService().get_journal_health() + ok, _ = JournalService().get_journal_health() + if ok: - return ComponentStatus(name="Журнал", state="🟢") + return ComponentStatus( + name="Журнал", + state="🟢", + ) - return ComponentStatus(name="Журнал", state="🔴", details=message) + return ComponentStatus( + name="Журнал", + state="🔴 Журнал недоступен", + ) +# определить runtime-режим по base_url биржи def get_runtime_mode_key() -> str: settings = load_settings() return "demo" if "demo" in settings.exchange_base_url.lower() else "live" +# вернуть человекочитаемую подпись runtime-режима def get_runtime_mode_label() -> str: return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт" +# собрать полный snapshot системного экрана def get_system_snapshot() -> SystemSnapshot: settings = load_settings() - exchange_service = ExchangeService() database_status, db_label = _build_database_status() - exchange_status = _build_exchange_status(exchange_service, settings.default_symbol) - account_status = _build_account_status(exchange_service) journal_status = _build_journal_status() + exchange_components = _build_exchange_alert_components( + default_symbol=settings.default_symbol, + ) + components = [ ComponentStatus(name="Приложение", state="🟢"), database_status, ComponentStatus(name="Telegram", state="🟢"), - exchange_status, - account_status, + *exchange_components, journal_status, ] @@ -150,19 +215,20 @@ def get_system_snapshot() -> SystemSnapshot: ) +# определить, есть ли системные предупреждения def has_system_alerts(snapshot: SystemSnapshot) -> bool: return any(component.state != "🟢" for component in snapshot.components) +# отрендерить одну строку компонента системы def _render_component(component: ComponentStatus) -> str: - line = f"{component.state} {component.name}" + if component.state == "🟢": + return f"{component.state} {component.name}" - if component.state == "🟢" or not component.details: - return line - - return f"{line}\n— {component.details}" + return component.state +# получить текущее локальное время для подписи обновления def _now_hhmmss() -> str: settings = load_settings() tz_name = settings.tz or "UTC" @@ -175,50 +241,22 @@ def _now_hhmmss() -> str: return local_dt.strftime("%H:%M:%S") +# собрать текст экрана "Система" def build_system_text(*, include_updated_at: bool = False) -> str: snapshot = get_system_snapshot() + components_block = "\n".join( - _render_component(component) for component in snapshot.components + _render_component(component) + for component in snapshot.components ) text = ( "🖥️ Система\n" f"🔸 {snapshot.mode_label}\n\n" - # f"⏱️ {snapshot.timezone_name}\n\n" f"{components_block}" ) if include_updated_at: text += f"\n\nОбновлено: {_now_hhmmss()}" - return text - - -def _humanize_error_message(text: str) -> str: - t = text.lower() - - # сеть - if "nodename nor servname" in t or "name or service not known" in t: - return "Нет связи с биржей" - - if "timeout" in t or "timed out" in t: - return "Биржа не отвечает (таймаут)" - - if "network error" in t or "connection error" in t: - return "Ошибка сети при обращении к бирже" - - # API / доступ - if "private api error" in t: - return "Ошибка доступа к аккаунту" - - if "invalid api key" in t or "api key" in t: - return "Неверный API ключ" - - if "forbidden" in t or "unauthorized" in t: - return "Нет доступа к аккаунту" - - # время - if "-1021" in t or "doesn't match server time" in t: - return "Ошибка времени (рассинхронизация)" - - return "Не удалось получить данные с биржи" \ No newline at end of file + return text \ 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 40acb9c..ad673cc 100644 --- a/app/src/integrations/exchange/market_data_runner.py +++ b/app/src/integrations/exchange/market_data_runner.py @@ -3,10 +3,13 @@ from __future__ import annotations import asyncio +import time import traceback from dataclasses import dataclass from typing import Callable +from src.core.numbers import safe_float +from src.core.types import JsonDict, NumericLike from src.integrations.exchange.market_cache import MarketPriceCache from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.ws_client import ExchangeWebSocketClient @@ -24,10 +27,42 @@ class MarketRuntimeContext: runtime_label: str | None last_market_status: str | None = None + # Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками. + last_status_error_key: str | None = None + last_stream_state: str | None = None + last_stream_error_key: str | None = None + last_rest_state: str | None = None + last_rest_error_key: str | None = None + class MarketDataRunner: _runtimes: dict[str, MarketRuntimeContext] = {} + # Global dedupe runtime-событий между start/stop runtime. + _global_runtime_event_timestamps: dict[str, float] = {} + + # Минимальный интервал повторного логирования одного runtime-состояния. + # Состояние в UI может меняться чаще, но журнал не должен разрастаться. + _runtime_log_cooldown_seconds = 300 + + @classmethod + def _can_log_runtime_event( + cls, + event_key: str, + ) -> bool: + now = time.monotonic() + + last_logged_at = cls._global_runtime_event_timestamps.get(event_key) + + if last_logged_at is not None: + if ( + now - last_logged_at + ) < cls._runtime_log_cooldown_seconds: + return False + + cls._global_runtime_event_timestamps[event_key] = now + return True + @classmethod def start( cls, @@ -41,7 +76,11 @@ class MarketDataRunner: ) -> None: existing = cls._runtimes.get(runtime_key) - if existing is not None and existing.task is not None and not existing.task.done(): + if ( + existing is not None + and existing.task is not None + and not existing.task.done() + ): existing.symbol_provider = symbol_provider existing.interval_seconds = interval_seconds existing.screen = screen @@ -97,73 +136,143 @@ class MarketDataRunner: async def _worker(cls, context: MarketRuntimeContext) -> None: last_symbol: str | None = None - while True: - symbol = context.symbol_provider() + try: + while True: + symbol = context.symbol_provider() - if not symbol: - await asyncio.sleep(context.interval_seconds) - continue + if not symbol: + await asyncio.sleep(context.interval_seconds) + continue - cache_symbol = cls._cache_symbol(symbol) - ws_symbol = cls._ws_symbol(symbol) + cache_symbol = cls._cache_symbol(symbol) + ws_symbol = cls._ws_symbol(symbol) - if symbol != last_symbol: - previous_symbol = last_symbol - last_symbol = symbol + if symbol != last_symbol: + last_symbol = symbol - if not cls._is_cache_symbol_used_by_other_runtime( - runtime_key=context.runtime_key, - cache_symbol=cache_symbol, - ): - MarketPriceCache.clear(cache_symbol) + if not cls._is_cache_symbol_used_by_other_runtime( + runtime_key=context.runtime_key, + cache_symbol=cache_symbol, + ): + MarketPriceCache.clear(cache_symbol) - market_status = ExchangeService().get_symbol_market_status(symbol) - status_key = str(market_status.get("status") or "UNKNOWN") + try: + market_status = ExchangeService().get_symbol_market_status(symbol) - if not bool(market_status.get("is_open")): - if context.last_market_status != status_key: - context.last_market_status = status_key + except asyncio.CancelledError: + raise - cls._log_warning( + except Exception as exc: + error_key = f"{type(exc).__name__}:{str(exc)}" + + if context.last_status_error_key != error_key: + context.last_status_error_key = error_key + + cls._log_warning( + context, + "market_status_unavailable", + "Статус рынка временно недоступен.", + { + "symbol": symbol, + "cache_symbol": cache_symbol, + "ws_symbol": ws_symbol, + "error": str(exc), + "error_type": type(exc).__name__, + "traceback": traceback.format_exc(limit=5), + }, + ) + + await asyncio.sleep(context.interval_seconds) + continue + + if context.last_status_error_key is not None: + context.last_status_error_key = None + + cls._log_info( context, - "market_closed", - "Рынок закрыт. Мониторинг рыночных данных временно приостановлен.", + "market_status_restored", + "Статус рынка снова доступен.", { "symbol": symbol, - "market_status": status_key, - "message": market_status.get("message"), + "cache_symbol": cache_symbol, + "ws_symbol": ws_symbol, }, ) - await asyncio.sleep(context.interval_seconds) - continue + status_key = str(market_status.get("status") or "UNKNOWN") - context.last_market_status = status_key + if not bool(market_status.get("is_open")): + if context.last_market_status != status_key: + context.last_market_status = status_key - try: - await cls._run_websocket(context, symbol) - except asyncio.CancelledError: - raise - except Exception as exc: - cls._log_warning( - context, - "market_stream_disconnected", - "Поток рыночных данных отключён. Используется резервный REST-режим.", - { - "symbol": symbol, - "cache_symbol": cache_symbol, - "ws_symbol": ws_symbol, - "error": str(exc), - "error_type": type(exc).__name__, - "traceback": traceback.format_exc(limit=5), - }, - ) + cls._log_warning( + context, + "market_closed", + "Рынок закрыт. Мониторинг рыночных данных временно приостановлен.", + { + "symbol": symbol, + "market_status": status_key, + "message": market_status.get("message"), + }, + ) - await cls._rest_fallback_once(context, symbol) - await asyncio.sleep(context.interval_seconds) + await asyncio.sleep(context.interval_seconds) + continue + + context.last_market_status = status_key + + try: + await cls._run_websocket(context, symbol) + + except asyncio.CancelledError: + raise + + except Exception as exc: + error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}" + + should_log_disconnected = ( + ( + context.last_stream_state != "DISCONNECTED" + or context.last_stream_error_key != error_key + ) + and cls._can_log_runtime_event( + f"market_stream_disconnected:{symbol}:{error_key}" + ) + ) + + context.last_stream_state = "DISCONNECTED" + context.last_stream_error_key = error_key + + if should_log_disconnected: + + cls._log_warning( + context, + "market_stream_disconnected", + "Live-поток рыночных данных отключён. Используется REST-режим.", + { + "symbol": symbol, + "cache_symbol": cache_symbol, + "ws_symbol": ws_symbol, + "error": str(exc), + "error_type": type(exc).__name__, + "traceback": traceback.format_exc(limit=5), + }, + ) + + await cls._rest_fallback_once(context, symbol) + await asyncio.sleep(context.interval_seconds) + + except asyncio.CancelledError: + # stop() уже пишет market_monitor_stopped. + # Здесь не логируем, чтобы в журнале не было дубля остановки. + raise @classmethod - async def _run_websocket(cls, context: MarketRuntimeContext, symbol: str) -> None: + async def _run_websocket( + cls, + context: MarketRuntimeContext, + symbol: str, + ) -> None: cache_symbol = cls._cache_symbol(symbol) ws_symbol = cls._ws_symbol(symbol) @@ -174,19 +283,33 @@ class MarketDataRunner: interval_seconds=context.interval_seconds, ): if payload_count == 0: - cls._log_info( - context, - "market_stream_connected", - "Поток рыночных данных подключён.", - { - "requested_symbol": symbol, - "cache_symbol": cache_symbol, - "ws_symbol": ws_symbol, - "payload_keys": list(payload.keys()), - "payload_preview": cls._safe_payload_preview(payload), - }, + should_log_connected = ( + context.last_stream_state != "CONNECTED" + and cls._can_log_runtime_event( + f"market_stream_connected:{symbol}" + ) ) + context.last_stream_state = "CONNECTED" + context.last_stream_error_key = None + context.last_rest_state = None + context.last_rest_error_key = None + + if should_log_connected: + + cls._log_info( + context, + "market_stream_connected", + "Live-поток рыночных данных подключён.", + { + "requested_symbol": symbol, + "cache_symbol": cache_symbol, + "ws_symbol": ws_symbol, + "payload_keys": list(payload.keys()), + "payload_preview": cls._safe_payload_preview(payload), + }, + ) + payload_count += 1 current_symbol = context.symbol_provider() @@ -209,28 +332,78 @@ class MarketDataRunner: ) @classmethod - async def _rest_fallback_once(cls, context: MarketRuntimeContext, symbol: str) -> None: + async def _rest_fallback_once( + cls, + context: MarketRuntimeContext, + symbol: str, + ) -> None: try: await asyncio.to_thread( ExchangeService().refresh_market_snapshot_cache, symbol, runtime_key=context.runtime_key, ) - except Exception as exc: - cls._log_error( - context, - "market_stream_disconnected", - "Поток рыночных данных отключён. Резервный REST-режим недоступен.", - { - "symbol": symbol, - "error": str(exc), - "error_type": type(exc).__name__, - "traceback": traceback.format_exc(limit=5), - }, + + should_log_rest_available = ( + context.last_rest_state != "AVAILABLE" + and cls._can_log_runtime_event( + f"market_rest_fallback_available:{symbol}" + ) ) + context.last_rest_state = "AVAILABLE" + context.last_rest_error_key = None + + if should_log_rest_available: + + cls._log_info( + context, + "market_rest_fallback_available", + "REST-режим рыночных данных доступен. Live-поток пока недоступен.", + { + "symbol": symbol, + "cache_symbol": cls._cache_symbol(symbol), + "ws_symbol": cls._ws_symbol(symbol), + }, + ) + + except Exception as exc: + error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}" + + should_log_rest_unavailable = ( + ( + context.last_rest_state != "UNAVAILABLE" + or context.last_rest_error_key != error_key + ) + and cls._can_log_runtime_event( + f"market_rest_fallback_unavailable:{symbol}:{error_key}" + ) + ) + + context.last_rest_state = "UNAVAILABLE" + context.last_rest_error_key = error_key + + if should_log_rest_unavailable: + + cls._log_error( + context, + "market_rest_fallback_unavailable", + "Live-поток отключён. REST-режим рыночных данных недоступен.", + { + "symbol": symbol, + "error": str(exc), + "error_type": type(exc).__name__, + "traceback": traceback.format_exc(limit=5), + }, + ) + @classmethod - def _is_cache_symbol_used_by_other_runtime(cls, *, runtime_key: str, cache_symbol: str) -> bool: + def _is_cache_symbol_used_by_other_runtime( + cls, + *, + runtime_key: str, + cache_symbol: str, + ) -> bool: for key, context in cls._runtimes.items(): if key == runtime_key: continue @@ -251,6 +424,7 @@ class MarketDataRunner: validation = ExchangeService().validate_symbol(symbol) if validation.is_valid: return validation.normalized_symbol + except Exception: pass @@ -261,7 +435,11 @@ class MarketDataRunner: return cls._cache_symbol(symbol) @classmethod - def _extract_best_price(cls, payload: dict, side_key: str) -> float | None: + def _extract_best_price( + cls, + payload: JsonDict, + side_key: str, + ) -> float | None: data = payload inner = payload.get("payload") @@ -276,53 +454,71 @@ class MarketDataRunner: first = values[0] if isinstance(first, list) and first: - return cls._safe_float(first[0]) + return cls._positive_float(first[0]) if isinstance(first, dict): - return cls._safe_float( + raw_price = ( first.get("price") or first.get("p") or first.get("bidPrice") or first.get("askPrice") ) + return cls._positive_float(raw_price) + return None @classmethod - def _safe_float(cls, value: object) -> float | None: - try: - number = float(value) - except (TypeError, ValueError): + def _positive_float(cls, value: NumericLike | None) -> float | None: + number = safe_float(value) + + if number is None or number <= 0: return None - return number if number > 0 else None + return number @classmethod - def _safe_payload_preview(cls, payload: dict) -> dict: - preview: dict = {} + def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict: + preview: JsonDict = {} for key, value in payload.items(): if key in {"bids", "asks"} and isinstance(value, list): preview[key] = value[:2] + elif key == "payload" and isinstance(value, dict): - preview[key] = { - inner_key: inner_value[:2] - if inner_key in {"bids", "asks"} and isinstance(inner_value, list) - else inner_value - for inner_key, inner_value in value.items() - } + inner_preview: JsonDict = {} + + for inner_key, inner_value in value.items(): + if ( + inner_key in {"bids", "asks"} + and isinstance(inner_value, list) + ): + inner_preview[inner_key] = inner_value[:2] + else: + inner_preview[inner_key] = inner_value + + preview[key] = inner_preview + else: preview[key] = value return preview @classmethod - def _message(cls, context: MarketRuntimeContext, message: str) -> str: + def _message( + cls, + context: MarketRuntimeContext, + message: str, + ) -> str: return message @classmethod - def _payload(cls, context: MarketRuntimeContext, payload: dict | None = None) -> dict: - result = dict(payload or {}) + def _payload( + cls, + context: MarketRuntimeContext, + payload: JsonDict | None = None, + ) -> JsonDict: + result: JsonDict = dict(payload or {}) result.setdefault("runtime_key", context.runtime_key) if context.screen: @@ -339,7 +535,7 @@ class MarketDataRunner: context: MarketRuntimeContext, event_type: str, message: str, - payload: dict | None = None, + payload: JsonDict | None = None, ) -> None: try: if context.screen: @@ -352,7 +548,12 @@ class MarketDataRunner: ) return - JournalService().log_info(event_type, cls._message(context, message), cls._payload(context, payload)) + JournalService().log_info( + event_type, + cls._message(context, message), + cls._payload(context, payload), + ) + except Exception: pass @@ -362,7 +563,7 @@ class MarketDataRunner: context: MarketRuntimeContext, event_type: str, message: str, - payload: dict | None = None, + payload: JsonDict | None = None, ) -> None: try: if context.screen: @@ -375,7 +576,12 @@ class MarketDataRunner: ) return - JournalService().log_warning(event_type, cls._message(context, message), cls._payload(context, payload)) + JournalService().log_warning( + event_type, + cls._message(context, message), + cls._payload(context, payload), + ) + except Exception: pass @@ -385,9 +591,16 @@ class MarketDataRunner: context: MarketRuntimeContext, event_type: str, message: str, - payload: dict | None = None, + payload: JsonDict | None = None, ) -> None: try: + error_type = None + raw_error = None + + if payload: + error_type = payload.get("error_type") + raw_error = payload.get("error") + if context.screen: JournalService().log_ui_error( event_type=event_type, @@ -395,11 +608,16 @@ class MarketDataRunner: screen=context.screen, action=context.action, payload=cls._payload(context, payload), - error_type=(payload or {}).get("error_type"), - raw_error=(payload or {}).get("error"), + error_type=str(error_type) if error_type is not None else None, + raw_error=str(raw_error) if raw_error is not None else None, ) return - JournalService().log_error(event_type, cls._message(context, message), cls._payload(context, payload)) + JournalService().log_error( + event_type, + cls._message(context, message), + cls._payload(context, payload), + ) + except Exception: pass \ No newline at end of file diff --git a/app/src/integrations/exchange/market_stream.py b/app/src/integrations/exchange/market_stream.py index 33fdbbd..4c7aeb3 100644 --- a/app/src/integrations/exchange/market_stream.py +++ b/app/src/integrations/exchange/market_stream.py @@ -7,25 +7,39 @@ from datetime import datetime from zoneinfo import ZoneInfo from src.core.config import load_settings +from src.core.numbers import safe_float +from src.core.types import JsonDict, NumericLike from src.integrations.exchange.market_cache import MarketPriceCache from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.ws_client import ExchangeWebSocketClient from src.trading.journal.service import JournalService -def _format_timestamp(raw_timestamp: object) -> str | None: - if raw_timestamp is None: +# безопасно форматирует timestamp биржи в локальное время +def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None: + timestamp = safe_float(raw_timestamp) + + if timestamp is None: return None try: settings = load_settings() - dt_utc = datetime.fromtimestamp(int(raw_timestamp) / 1000, tz=ZoneInfo("UTC")) - return dt_utc.astimezone(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S") + + dt_utc = datetime.fromtimestamp( + int(timestamp) / 1000, + tz=ZoneInfo("UTC"), + ) + + return dt_utc.astimezone( + ZoneInfo(settings.tz), + ).strftime("%d.%m.%Y %H:%M:%S") + except Exception: return None -def _extract_market_event(payload: dict) -> dict | None: +# достаёт внутренний payload из websocket-сообщения +def _payload_from_message(payload: JsonDict) -> JsonDict | None: event = payload.get("Payload") or payload.get("payload") if isinstance(event, dict) and "Payload" in event: @@ -34,16 +48,73 @@ def _extract_market_event(payload: dict) -> dict | None: if not isinstance(event, dict): return None - symbol = event.get("symbolName") or event.get("symbol") - bid = event.get("bid") - ask = event.get("ofr") or event.get("ask") - timestamp = event.get("timestamp") + return dict(event) - if symbol is None or bid is None or ask is None: + +# извлекает best bid / best ask из формата depth +def _extract_depth_prices(event: JsonDict) -> tuple[float | None, float | None]: + bids = event.get("bids") + asks = event.get("asks") + + bid_price = _extract_first_price(bids) + ask_price = _extract_first_price(asks) + + return bid_price, ask_price + + +# извлекает первую цену из списка стакана +def _extract_first_price(value: object) -> float | None: + if not isinstance(value, list) or not value: + return None + + first = value[0] + + if isinstance(first, list) and first: + return _positive_float(first[0]) + + if isinstance(first, dict): + return _positive_float( + first.get("price") + or first.get("p") + or first.get("bidPrice") + or first.get("askPrice") + ) + + return None + + +# безопасно приводит число к float и отсекает нулевые/отрицательные цены +def _positive_float(value: NumericLike | None) -> float | None: + number = safe_float(value) + + if number is None or number <= 0: + return None + + return number + + +# нормализует websocket-сообщение рынка в единый формат для MarketPriceCache +def _extract_market_event(payload: JsonDict) -> JsonDict | None: + event = _payload_from_message(payload) + + if event is None: + return None + + symbol = ( + event.get("symbolName") + or event.get("symbol") + or payload.get("symbol") + ) + + bid_price = _positive_float(event.get("bid")) + ask_price = _positive_float(event.get("ofr") or event.get("ask")) + + if bid_price is None or ask_price is None: + bid_price, ask_price = _extract_depth_prices(event) + + if symbol is None or bid_price is None or ask_price is None: return None - bid_price = float(bid) - ask_price = float(ask) price = (bid_price + ask_price) / 2 return { @@ -51,10 +122,11 @@ def _extract_market_event(payload: dict) -> dict | None: "price": price, "bid_price": bid_price, "ask_price": ask_price, - "updated_at": _format_timestamp(timestamp), + "updated_at": _format_timestamp(event.get("timestamp")), } +# запускает постоянный websocket-поток рынка и обновляет MarketPriceCache async def start_market_stream() -> None: settings = load_settings() journal = JournalService() @@ -86,16 +158,30 @@ async def start_market_stream() -> None: if event is None: continue + price = safe_float(event.get("price")) + bid_price = safe_float(event.get("bid_price")) + ask_price = safe_float(event.get("ask_price")) + + if price is None or bid_price is None or ask_price is None: + continue + MarketPriceCache.set_price( symbol=symbol, - price=event["price"], - bid_price=event["bid_price"], - ask_price=event["ask_price"], - updated_at=event["updated_at"], + price=price, + bid_price=bid_price, + ask_price=ask_price, + updated_at=( + str(event.get("updated_at")) + if event.get("updated_at") is not None + else None + ), + source="ws_market_stream", + runtime_key="default", ) except asyncio.CancelledError: raise + except Exception as exc: try: journal.log_warning( diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py index 4197d6a..bb4c396 100644 --- a/app/src/integrations/exchange/models.py +++ b/app/src/integrations/exchange/models.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass +# Состояние публичного API биржи. @dataclass(slots=True) class ExchangeHealth: ok: bool @@ -12,6 +13,19 @@ class ExchangeHealth: message: str +# Состояние синхронизации времени сервера и биржи. +@dataclass(slots=True) +class TimeSyncStatus: + ok: bool + local_time: str + exchange_time: str | None + drift_seconds: float | None + hostname: str + local_ip: str | None + message: str + + +# Текущая рыночная цена инструмента. @dataclass(slots=True) class TickerPrice: symbol: str @@ -20,18 +34,26 @@ class TickerPrice: updated_at: str +# Snapshot цен для execution layer. @dataclass(slots=True) class ExecutionPriceSnapshot: symbol: str + last_price: float bid_price: float ask_price: float + updated_at: str source: str + is_fresh: bool + age_seconds: float | None = None + freshness_status: str = "UNKNOWN" + spread_percent: float | None = None +# Баланс актива аккаунта. @dataclass(slots=True) class BalanceSummary: currency: str @@ -40,41 +62,54 @@ class BalanceSummary: source: str +# Информация о торговом инструменте биржи. @dataclass(slots=True) class ExchangeSymbol: symbol: str name: str status: str + base_asset: str quote_asset: str + market_modes: list[str] market_type: str + tick_size: float | None step_size: float | None min_qty: float | None min_notional: float | None +# Результат проверки символа. @dataclass(slots=True) class SymbolValidationResult: requested_symbol: str normalized_symbol: str + is_valid: bool message: str + symbol_info: ExchangeSymbol | None +# Состояние приватного API аккаунта. @dataclass(slots=True) class PrivateAuthHealth: ok: bool message: str -# ========================================================= -# MARKET ANALYSIS / KLINES -# ========================================================= +# Runtime-статус рынка инструмента. +@dataclass(slots=True) +class ExchangeMarketStatus: + symbol: str + is_open: bool + status: str + message: str +# Одна свеча OHLCV. @dataclass(slots=True) class Kline: symbol: str @@ -86,12 +121,12 @@ class Kline: high_price: float low_price: float close_price: float - volume: float source: str +# Пакет свечей. @dataclass(slots=True) class KlineBatch: symbol: str diff --git a/app/src/integrations/exchange/runtime_ui.py b/app/src/integrations/exchange/runtime_ui.py new file mode 100644 index 0000000..dc040d2 --- /dev/null +++ b/app/src/integrations/exchange/runtime_ui.py @@ -0,0 +1,296 @@ +# app/src/integrations/exchange/runtime_ui.py + +from __future__ import annotations + +from src.core.numbers import safe_float +from src.integrations.exchange.models import TimeSyncStatus +from src.integrations.exchange.status import ( + ExchangeRuntimeStatus, + ExchangeStatusCode, + build_exchange_error_status, +) + + +def format_drift_seconds(value: float | int | None) -> str: + number = safe_float(value) + + if number is None: + return "—" + + sign = "-" if number < 0 else "+" + total_seconds = abs(int(round(number))) + + minutes = total_seconds // 60 + seconds = total_seconds % 60 + + if minutes > 0: + return f"{sign} {minutes} мин. {seconds} сек." + + return f"{sign} {seconds} сек." + + +def build_time_sync_details(sync: TimeSyncStatus) -> str: + lines = [ + "Проверь настройки времени на:", + f"Сервер: {sync.hostname}", + ] + + if sync.local_ip: + lines.append(f"IP: {sync.local_ip}") + + lines.append("") + lines.append(f"Время сервера: {sync.local_time}") + + if sync.exchange_time: + lines.append(f"Время биржи: {sync.exchange_time}") + + lines.append(f"Расхождение: {format_drift_seconds(sync.drift_seconds)}") + + return "\n".join(lines) + + +def get_time_sync_status_from_service() -> TimeSyncStatus: + from src.integrations.exchange.service import ExchangeService + + return ExchangeService().get_time_sync_status() + + +def build_time_sync_details_from_service() -> str: + return build_time_sync_details(get_time_sync_status_from_service()) + + +def build_exchange_error_ui_parts( + exc: Exception, +) -> tuple[ExchangeRuntimeStatus, str, str]: + status = build_exchange_error_status(exc) + + if status.code == ExchangeStatusCode.AUTH_ERROR: + return ( + status, + "⛔️ Ошибка доступа к аккаунту", + "Проверь API-ключ, Secret Key, IP whitelist и права доступа.", + ) + + if status.code == ExchangeStatusCode.TIME_ERROR: + return ( + status, + "⛔️ Ошибка времени биржи", + build_time_sync_details_from_service(), + ) + + if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE: + return status, "⛔️ Биржа недоступна", "" + + return status, status.ui_line, status.message + + +def build_runtime_exchange_status(exc: Exception) -> dict[str, object]: + status = build_exchange_error_status(exc) + + if status.code == ExchangeStatusCode.TIME_ERROR: + sync = get_time_sync_status_from_service() + + return { + "code": status.code.value, + "title": "Ошибка времени биржи", + "ui_line": "⛔️ Ошибка времени биржи", + "details": { + "hostname": sync.hostname, + "local_ip": sync.local_ip, + "local_time": sync.local_time, + "exchange_time": sync.exchange_time, + "drift_seconds": sync.drift_seconds, + }, + "reason": status.reason, + "raw_error": status.raw_error, + } + + _, title, details = build_exchange_error_ui_parts(exc) + + return { + "code": status.code.value, + "title": title.replace("⛔️ ", "").strip(), + "ui_line": title, + "details": details, + "reason": status.reason, + "raw_error": status.raw_error, + } + + +def build_runtime_exchange_alerts( + *, + symbol: str | None = None, + exc: Exception | None = None, + include_exchange_unavailable: bool = True, +) -> list[dict[str, object]]: + from src.integrations.exchange.service import ExchangeService + + alerts: list[dict[str, object]] = [] + service = ExchangeService() + + def add_alert(alert: dict[str, object] | None) -> None: + if not alert: + return + + code = str(alert.get("code") or "") + reason = str(alert.get("reason") or "") + + for existing in alerts: + if ( + str(existing.get("code") or "") == code + and str(existing.get("reason") or "") == reason + ): + return + + alerts.append(alert) + + if exc is not None: + add_alert(build_runtime_exchange_status(exc)) + + try: + runtime_status = service.get_symbol_runtime_status(symbol) + except Exception as status_exc: + if include_exchange_unavailable: + add_alert(build_runtime_exchange_status(status_exc)) + else: + if include_exchange_unavailable and not runtime_status.is_available: + add_alert( + build_runtime_exchange_status( + Exception(runtime_status.raw_error or runtime_status.message) + ) + ) + + try: + time_sync = service.get_time_sync_status() + except Exception as time_exc: + add_alert(build_runtime_exchange_status(time_exc)) + else: + if not time_sync.ok: + add_alert( + { + "code": ExchangeStatusCode.TIME_ERROR.value, + "title": "Ошибка времени биржи", + "ui_line": "⛔️ Ошибка времени биржи", + "details": { + "hostname": time_sync.hostname, + "local_ip": time_sync.local_ip, + "local_time": time_sync.local_time, + "exchange_time": time_sync.exchange_time, + "drift_seconds": time_sync.drift_seconds, + }, + "reason": "time_error", + "raw_error": time_sync.message, + } + ) + + try: + private_auth_health = service.get_private_auth_health() + except Exception as auth_exc: + add_alert(build_runtime_exchange_status(auth_exc)) + else: + if not private_auth_health.ok: + add_alert( + build_runtime_exchange_status( + Exception(private_auth_health.message) + ) + ) + + priority = { + ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value: 10, + ExchangeStatusCode.TIME_ERROR.value: 20, + ExchangeStatusCode.AUTH_ERROR.value: 30, + } + + alerts.sort( + key=lambda alert: priority.get( + str(alert.get("code") or ""), + 999, + ) + ) + + return alerts + + +def format_runtime_exchange_alert(alert: dict[str, object]) -> str: + title = str( + alert.get("ui_line") + or alert.get("title") + or "⛔️ Ошибка биржи" + ).strip() + + details = alert.get("details") + code = str(alert.get("code") or "") + + lines = [title] + + if isinstance(details, dict): + lines.append("Проверь настройки времени на:") + + hostname = details.get("hostname") + local_ip = details.get("local_ip") + local_time = details.get("local_time") + exchange_time = details.get("exchange_time") + drift_seconds = details.get("drift_seconds") + + if hostname: + lines.append(f"• Сервер: {hostname}") + + if local_ip: + lines.append(f"• IP: {local_ip}") + + if local_time: + lines.append(f"• Время сервера: {local_time}") + + if exchange_time: + lines.append(f"• Время биржи: {exchange_time}") + + lines.append(f"• Расхождение: {format_drift_seconds(drift_seconds)}") + + return "\n".join(lines).strip() + + if code == ExchangeStatusCode.AUTH_ERROR.value: + lines.extend([ + "Проверь:", + "• API-ключ, Secret Key", + "• IP whitelist и права доступа", + ]) + return "\n".join(lines).strip() + + details_text = str(details or "").strip() + + if details_text: + lines.append(details_text) + + return "\n".join(lines).strip() + + +def format_runtime_exchange_alerts(alerts: list[dict[str, object]]) -> str: + return "\n\n".join( + block + for block in ( + format_runtime_exchange_alert(alert) + for alert in alerts + ) + if block.strip() + ).strip() + + +def build_runtime_exchange_alert_lines( + *, + symbol: str | None = None, + include_exchange_unavailable: bool = True, +) -> list[str]: + alerts = build_runtime_exchange_alerts( + symbol=symbol, + include_exchange_unavailable=include_exchange_unavailable, + ) + + lines: list[str] = [] + + for alert in alerts: + line = str(alert.get("ui_line") or alert.get("title") or "").strip() + + if line and line not in lines: + lines.append(line) + + return lines \ No newline at end of file diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index a378e5a..9df4d71 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -1,11 +1,15 @@ # app/src/integrations/exchange/service.py - + from __future__ import annotations +import time +import socket from datetime import datetime from zoneinfo import ZoneInfo from src.core.config import load_settings +from src.core.numbers import safe_float +from src.core.types import NumericLike from src.integrations.exchange.balance_parser import parse_account_balances from src.integrations.exchange.exceptions import ExchangeError from src.integrations.exchange.market_cache import MarketPriceCache @@ -24,9 +28,19 @@ from src.integrations.exchange.models import ( PrivateAuthHealth, SymbolValidationResult, TickerPrice, + TimeSyncStatus, ) from src.integrations.exchange.private_client import ExchangePrivateClient from src.integrations.exchange.rest_client import ExchangeRestClient +from src.integrations.exchange.status import ( + ExchangeRuntimeStatus, + build_account_auth_status, + build_exchange_error_status, + build_invalid_symbol_status, + build_market_status_from_symbol_status, + build_mock_exchange_status, + classify_exchange_error, +) from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates from src.trading.journal.service import JournalService @@ -36,152 +50,133 @@ 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 + # Dedupe одинаковых ошибок биржи, чтобы журнал не разрастался. + _exchange_error_log_ttl_seconds = 300 + _last_exchange_error_logs: dict[str, float] = {} + _active_exchange_error_keys: set[str] = set() - 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": "Статус рынка не определён.", - } + # Dedupe account/time-sync событий. + _active_auth_error_key: str | None = None + _active_time_sync_error_key: str | None = None def __init__(self) -> None: self.settings = load_settings() self.journal = JournalService() - def _log_info(self, event_type: str, message: str, payload: dict | None = None) -> None: + # Вернуть статус торговой сессии инструмента в legacy-dict формате для текущего UI. + def get_symbol_market_status(self, symbol: str | None = None) -> dict[str, object]: + return self.get_symbol_runtime_status(symbol).as_dict() + + # Вернуть typed runtime status инструмента: открыт, перерыв, нет доступа, неверный символ. + def get_symbol_runtime_status( + self, + symbol: str | None = None, + ) -> ExchangeRuntimeStatus: + symbol_to_use = symbol or self.settings.default_symbol + + if not self.settings.exchange_enabled: + return build_mock_exchange_status(symbol=symbol_to_use) + + try: + validation = self.validate_symbol(symbol_to_use) + + except Exception as exc: + self._log_exchange_error( + endpoint="symbol_market_status", + exc=exc, + symbol=symbol_to_use, + ) + + return build_exchange_error_status(exc) + + if not validation.is_valid: + return build_invalid_symbol_status( + symbol=symbol_to_use, + message=validation.message, + ) + + symbol_info = validation.symbol_info + + return build_market_status_from_symbol_status( + raw_status=getattr(symbol_info, "status", None), + symbol=validation.normalized_symbol, + ) + + # Логировать info-событие биржи без падения основного сценария. + def _log_info( + self, + event_type: str, + message: str, + payload: dict[str, object] | None = None, + ) -> None: try: self.journal.log_info(event_type, message, payload) except Exception: pass - def _log_warning(self, event_type: str, message: str, payload: dict | None = None) -> None: + # Логировать warning-событие биржи без падения основного сценария. + def _log_warning( + self, + event_type: str, + message: str, + payload: dict[str, object] | None = None, + ) -> None: try: self.journal.log_warning(event_type, message, payload) except Exception: pass - def _log_error(self, event_type: str, message: str, payload: dict | None = None) -> None: + # Логировать error-событие биржи без падения основного сценария. + def _log_error( + self, + event_type: str, + message: str, + payload: dict[str, object] | None = None, + ) -> None: try: self.journal.log_error(event_type, message, payload) except Exception: pass - def _classify_error(self, exc: Exception) -> str: - text = str(exc).lower() - - if any( - marker in text - for marker in [ - "invalid api key", - "api key", - "api-key", - "signature", - "unauthorized", - "forbidden", - "private api error", - "expired", - ] - ): - return "auth" - - if any( - marker in text - for marker in [ - "timeout", - "timed out", - "connection error", - "network error", - "name or service not known", - "nodename nor servname", - ] - ): - return "network" - - if any( - marker in text - for marker in [ - "-1021", - "server time", - "doesn't match server time", - ] - ): - return "time" - - return "generic" - + # Логировать ошибку API с единым payload. + # Одинаковые ошибки пишем в журнал не чаще одного раза за TTL. def _log_exchange_error( self, *, endpoint: str, exc: Exception, symbol: str | None = None, - extra_payload: dict | None = None, + extra_payload: dict[str, object] | None = None, ) -> None: - payload = { + error_type = classify_exchange_error(exc) + raw_error = str(exc) + + dedupe_key = ( + f"exchange_request_error:" + f"{error_type}:" + f"{raw_error}" + ) + + now = time.monotonic() + last_logged_at = type(self)._last_exchange_error_logs.get(dedupe_key) + + if ( + last_logged_at is not None + and now - last_logged_at < type(self)._exchange_error_log_ttl_seconds + ): + return + + type(self)._last_exchange_error_logs[dedupe_key] = now + type(self)._active_exchange_error_keys.add(dedupe_key) + + payload: dict[str, object] = { "endpoint": endpoint, "symbol": symbol, "exchange_name": self.settings.exchange_name, - "error_type": self._classify_error(exc), - "raw_error": str(exc), + "error_type": error_type, + "raw_error": raw_error, + "dedupe_key": dedupe_key, + "dedupe_ttl_seconds": type(self)._exchange_error_log_ttl_seconds, } if extra_payload: @@ -189,18 +184,115 @@ class ExchangeService: self._log_error( "exchange_request_error", - str(exc), + raw_error, payload, ) - def _format_exchange_time(self, raw_timestamp: object) -> str: - if not raw_timestamp: + # Логировать ошибку доступа к аккаунту человекочитаемо и без дублей. + def _log_account_auth_error( + self, + *, + exc: Exception, + endpoint: str = "private/account_info", + ) -> None: + status = build_account_auth_status(exc) + raw_error = status.raw_error or str(exc) + error_key = f"auth:{raw_error}" + + if type(self)._active_auth_error_key == error_key: + return + + type(self)._active_auth_error_key = error_key + + self._log_error( + "exchange_auth_error", + "Ошибка доступа к аккаунту. Проверь API-ключ, Secret Key, IP whitelist и права доступа.", + { + "endpoint": endpoint, + "exchange_name": self.settings.exchange_name, + "error_type": classify_exchange_error(exc), + "raw_error": raw_error, + "reason": status.reason, + }, + ) + + # Логировать восстановление доступа к аккаунту один раз. + def _log_account_auth_restored(self) -> None: + if type(self)._active_auth_error_key is None: + return + + type(self)._active_auth_error_key = None + + self._log_info( + "exchange_auth_restored", + "Доступ к аккаунту восстановлен.", + { + "endpoint": "private/account_info", + "exchange_name": self.settings.exchange_name, + }, + ) + + # Логировать рассинхронизацию времени один раз до восстановления. + def _log_time_sync_error(self, sync: TimeSyncStatus) -> None: + drift = sync.drift_seconds + drift_key = "unknown" if drift is None else str(round(float(drift), 0)) + error_key = f"time_sync:{drift_key}" + + if type(self)._active_time_sync_error_key == error_key: + return + + type(self)._active_time_sync_error_key = error_key + + self._log_error( + "exchange_time_sync_error", + "Время сервера отличается от времени биржи.", + { + "hostname": sync.hostname, + "local_ip": sync.local_ip, + "local_time": sync.local_time, + "exchange_time": sync.exchange_time, + "drift_seconds": sync.drift_seconds, + "message": sync.message, + }, + ) + + # Логировать восстановление синхронизации времени один раз. + def _log_time_sync_restored(self, sync: TimeSyncStatus) -> None: + if type(self)._active_time_sync_error_key is None: + return + + type(self)._active_time_sync_error_key = None + + self._log_info( + "exchange_time_sync_restored", + "Время сервера снова синхронизировано с биржей.", + { + "hostname": sync.hostname, + "local_ip": sync.local_ip, + "local_time": sync.local_time, + "exchange_time": sync.exchange_time, + "drift_seconds": sync.drift_seconds, + }, + ) + + # Отформатировать timestamp биржи в локальное время приложения. + def _format_exchange_time(self, raw_timestamp: NumericLike | None) -> str: + timestamp = safe_float(raw_timestamp) + + if timestamp is None or timestamp <= 0: return "n/a" - dt_utc = datetime.fromtimestamp(int(raw_timestamp) / 1000, tz=ZoneInfo("UTC")) - dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) - return dt_local.strftime("%d.%m.%Y %H:%M:%S") + try: + dt_utc = datetime.fromtimestamp( + int(timestamp) / 1000, + tz=ZoneInfo("UTC"), + ) + dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz)) + return dt_local.strftime("%d.%m.%Y %H:%M:%S") + except Exception: + return "n/a" + # Вернуть человекочитаемое имя источника цены. def _source_name(self) -> str: return ( "dzengi-demo-api" @@ -208,9 +300,11 @@ class ExchangeService: else "dzengi-api" ) + # Нормализовать runtime_key для market cache. def _runtime_key(self, runtime_key: str | None) -> str: return (runtime_key or self._default_runtime_key).strip().lower() + # Получить свечи инструмента через REST API. def get_klines( self, symbol: str | None = None, @@ -281,6 +375,7 @@ class ExchangeService: source=f"rest_klines:{normalized_price_type}", ) + # Преобразовать сырой payload свечей в список Kline. def _parse_klines_payload( self, *, @@ -308,41 +403,33 @@ class ExchangeService: return candles - def _extract_klines_items(self, payload: object) -> list: + # Извлечь массив свечей из разных возможных форматов ответа API. + def _extract_klines_items(self, payload: object) -> list[object]: if isinstance(payload, list): return payload if not isinstance(payload, dict): return [] - if isinstance(payload.get("klines"), list): - return payload["klines"] - - if isinstance(payload.get("candles"), list): - return payload["candles"] - - if isinstance(payload.get("data"), list): - return payload["data"] + for key in ("klines", "candles", "data", "result"): + value = payload.get(key) + if isinstance(value, list): + return value inner = payload.get("payload") + if isinstance(inner, list): return inner if isinstance(inner, dict): - if isinstance(inner.get("klines"), list): - return inner["klines"] - - if isinstance(inner.get("candles"), list): - return inner["candles"] - - if isinstance(inner.get("data"), list): - return inner["data"] - - if isinstance(payload.get("result"), list): - return payload["result"] + for key in ("klines", "candles", "data"): + value = inner.get(key) + if isinstance(value, list): + return value return [] + # Преобразовать одну свечу из dict/list формата в Kline. def _parse_kline_item( self, *, @@ -351,59 +438,96 @@ class ExchangeService: interval: str, source: str, ) -> Kline | None: - try: - if isinstance(item, dict): - open_time = ( - item.get("openTime") - or item.get("open_time") - or item.get("time") - or item.get("timestamp") - ) + if isinstance(item, dict): + open_time = ( + item.get("openTime") + or item.get("open_time") + or item.get("time") + or item.get("timestamp") + ) - return Kline( - symbol=symbol, - interval=interval, - open_time=int(open_time), - open_price=float(item.get("open")), - high_price=float(item.get("high")), - low_price=float(item.get("low")), - close_price=float(item.get("close")), - volume=float(item.get("volume") or 0.0), - source=source, - ) + open_time_value = safe_float(open_time) + open_price = safe_float(item.get("open")) + high_price = safe_float(item.get("high")) + low_price = safe_float(item.get("low")) + close_price = safe_float(item.get("close")) + volume = safe_float(item.get("volume")) or 0.0 - if isinstance(item, list) and len(item) >= 6: - return Kline( - symbol=symbol, - interval=interval, - open_time=int(item[0]), - open_price=float(item[1]), - high_price=float(item[2]), - low_price=float(item[3]), - close_price=float(item[4]), - volume=float(item[5] or 0.0), - source=source, - ) + if ( + open_time_value is None + or open_price is None + or high_price is None + or low_price is None + or close_price is None + ): + return None - except Exception: - return None + return Kline( + symbol=symbol, + interval=interval, + open_time=int(open_time_value), + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=close_price, + volume=volume, + source=source, + ) + + if isinstance(item, list) and len(item) >= 6: + open_time_value = safe_float(item[0]) + open_price = safe_float(item[1]) + high_price = safe_float(item[2]) + low_price = safe_float(item[3]) + close_price = safe_float(item[4]) + volume = safe_float(item[5]) or 0.0 + + if ( + open_time_value is None + or open_price is None + or high_price is None + or low_price is None + or close_price is None + ): + return None + + return Kline( + symbol=symbol, + interval=interval, + open_time=int(open_time_value), + open_price=open_price, + high_price=high_price, + low_price=low_price, + close_price=close_price, + volume=volume, + source=source, + ) return None + # Проверить публичную доступность биржи. def get_health(self) -> ExchangeHealth: if not self.settings.exchange_enabled: return mock_exchange_health() - try: - validation = self.validate_symbol(self.settings.default_symbol) - if not validation.is_valid: - return ExchangeHealth( - ok=False, - mode="real_symbol_error", - message=validation.message, - ) + status = self.get_symbol_runtime_status(self.settings.default_symbol) - ticker = self._get_real_price(validation.normalized_symbol) + if not status.is_available: + return ExchangeHealth( + ok=False, + mode="real_exchange_unavailable", + message=status.message, + ) + + if not status.is_open: + return ExchangeHealth( + ok=False, + mode="real_market_closed", + message=status.message, + ) + + try: + ticker = self._get_real_price(str(status.symbol or self.settings.default_symbol)) except ExchangeError as exc: return ExchangeHealth( ok=False, @@ -417,37 +541,47 @@ class ExchangeService: message=f"Public API OK. Цена {ticker.symbol}: {ticker.price:.2f}", ) + # Проверить доступность приватного API и валидность ключей аккаунта. def get_private_auth_health(self) -> PrivateAuthHealth: if not self.settings.exchange_enabled: + exc = ExchangeError("Интеграция с биржей выключена.") + self._log_account_auth_error(exc=exc) + return PrivateAuthHealth( ok=False, - message="Интеграция с биржей выключена.", + message=str(exc), ) if not self.settings.exchange_api_key or not self.settings.exchange_api_secret: + exc = ExchangeError("EXCHANGE_API_KEY / EXCHANGE_API_SECRET не заданы.") + self._log_account_auth_error(exc=exc) + return PrivateAuthHealth( ok=False, - message="EXCHANGE_API_KEY / EXCHANGE_API_SECRET не заданы.", + message=str(exc), ) try: payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) balances = parse_account_balances(payload) + except Exception as exc: - self._log_exchange_error( - endpoint="private/account_info", - exc=exc, - ) + self._log_account_auth_error(exc=exc) + status = build_account_auth_status(exc) + return PrivateAuthHealth( ok=False, - message=f"Private API error: {exc}", + message=status.raw_error or status.message, ) + self._log_account_auth_restored() + return PrivateAuthHealth( ok=True, message=f"Private API OK. Балансов получено: {len(balances)}", ) + # Обновить price cache и вернуть TickerPrice. def refresh_price_cache( self, symbol: str | None = None, @@ -459,13 +593,19 @@ class ExchangeService: runtime_key=runtime_key, ) + price = safe_float(snapshot.get("last_price")) + + if price is None: + raise ExchangeError("Field 'last_price' is missing in market snapshot.") + return TickerPrice( symbol=str(snapshot["symbol"]), - price=float(snapshot["last_price"]), + price=price, source=str(snapshot.get("source") or self._source_name()), updated_at=str(snapshot["updated_at"]), ) + # Обновить market snapshot cache через свежий REST-запрос. def refresh_market_snapshot_cache( self, symbol: str | None = None, @@ -475,11 +615,18 @@ class ExchangeService: normalized_runtime_key = self._runtime_key(runtime_key) snapshot = self.get_fresh_market_snapshot(symbol) + last_price = safe_float(snapshot.get("last_price")) + bid_price = safe_float(snapshot.get("bid_price")) + ask_price = safe_float(snapshot.get("ask_price")) + + if last_price is None or bid_price is None or ask_price is None: + raise ExchangeError("Market snapshot contains invalid price fields.") + MarketPriceCache.set_price( symbol=str(snapshot["symbol"]), - price=float(snapshot["last_price"]), - bid_price=float(snapshot["bid_price"]), - ask_price=float(snapshot["ask_price"]), + price=last_price, + bid_price=bid_price, + ask_price=ask_price, updated_at=str(snapshot["updated_at"]), source=str(snapshot.get("source") or "rest_polling"), runtime_key=normalized_runtime_key, @@ -487,6 +634,7 @@ class ExchangeService: return snapshot + # Получить последнюю цену инструмента из cache или REST API. def get_price( self, symbol: str | None = None, @@ -518,6 +666,7 @@ class ExchangeService: return self._get_real_price(validation.normalized_symbol) + # Получить market snapshot: last/bid/ask/source/age/freshness. def get_market_snapshot( self, symbol: str | None = None, @@ -569,6 +718,7 @@ class ExchangeService: snapshot["runtime_key"] = normalized_runtime_key return snapshot + # Получить snapshot, пригодный для execution layer. def get_execution_snapshot( self, symbol: str | None = None, @@ -607,30 +757,47 @@ class ExchangeService: age <= self._execution_cache_max_age_seconds and cached_price.has_bid_ask() ): - return ExecutionPriceSnapshot( - symbol=cached_price.symbol, - last_price=cached_price.price, - bid_price=float(cached_price.bid_price), - ask_price=float(cached_price.ask_price), - updated_at=cached_price.updated_at, - source=f"{cached_price.source}:fresh_cache", - is_fresh=True, - age_seconds=round(age, 3), - ) + bid_price = safe_float(cached_price.bid_price) + ask_price = safe_float(cached_price.ask_price) + last_price = safe_float(cached_price.price) + + if ( + last_price is not None + and bid_price is not None + and ask_price is not None + ): + return ExecutionPriceSnapshot( + symbol=cached_price.symbol, + last_price=last_price, + bid_price=bid_price, + ask_price=ask_price, + updated_at=cached_price.updated_at, + source=f"{cached_price.source}:fresh_cache", + is_fresh=True, + age_seconds=round(age, 3), + ) snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol) + last_price = safe_float(snapshot.get("last_price")) + bid_price = safe_float(snapshot.get("bid_price")) + ask_price = safe_float(snapshot.get("ask_price")) + + if last_price is None or bid_price is None or ask_price is None: + raise ExchangeError("Market snapshot contains invalid execution prices.") + return ExecutionPriceSnapshot( symbol=str(snapshot["symbol"]), - last_price=float(snapshot["last_price"]), - bid_price=float(snapshot["bid_price"]), - ask_price=float(snapshot["ask_price"]), + last_price=last_price, + bid_price=bid_price, + ask_price=ask_price, updated_at=str(snapshot["updated_at"]), source="rest_fallback", is_fresh=True, age_seconds=0.0, ) + # Получить свежий snapshot напрямую из REST API. def get_fresh_market_snapshot(self, symbol: str | None = None) -> dict[str, object]: symbol_to_use = symbol or self.settings.default_symbol @@ -666,8 +833,9 @@ class ExchangeService: ) raise ExchangeError(str(exc)) from exc - last_raw = payload.get("lastPrice") - if last_raw is None: + last_price = safe_float(payload.get("lastPrice")) + + if last_price is None: exc = ExchangeError("Field 'lastPrice' is missing in ticker response.") self._log_exchange_error( endpoint="ticker/24hr", @@ -676,36 +844,29 @@ class ExchangeService: ) raise exc - bid_raw = payload.get("bidPrice") or last_raw - ask_raw = payload.get("askPrice") or last_raw - close_time = payload.get("closeTime") or payload.get("eventTime") or "" + bid_price = safe_float(payload.get("bidPrice")) or last_price + ask_price = safe_float(payload.get("askPrice")) or last_price + close_time = payload.get("closeTime") or payload.get("eventTime") return { "symbol": validation.normalized_symbol, - "last_price": float(last_raw), - "bid_price": float(bid_raw), - "ask_price": float(ask_raw), + "last_price": last_price, + "bid_price": bid_price, + "ask_price": ask_price, "updated_at": self._format_exchange_time(close_time), "source": "fresh_rest", "age_seconds": 0.0, "is_fresh": True, } + # Получить live-балансы аккаунта. def get_balance_summary(self) -> list[BalanceSummary]: if not self.settings.exchange_enabled: return mock_balance_summary() auth_health = self.get_private_auth_health() if not auth_health.ok: - auth_exc = ExchangeError(auth_health.message) - self._log_exchange_error( - endpoint="private/account_info", - exc=auth_exc, - extra_payload={ - "default_symbol": self.settings.default_symbol, - }, - ) - raise auth_exc + raise ExchangeError(auth_health.message) try: payload = ExchangePrivateClient().get_account_info(show_zero_balance=False) @@ -742,12 +903,15 @@ class ExchangeService: return balances + # Получить и распарсить список инструментов биржи. def get_exchange_symbols(self) -> list[ExchangeSymbol]: if not self.settings.exchange_enabled: return [] - if type(self)._exchange_symbols_cache is not None: - return type(self)._exchange_symbols_cache + cached_symbols = type(self)._exchange_symbols_cache + + if cached_symbols is not None: + return cached_symbols client = ExchangeRestClient() @@ -760,133 +924,150 @@ class ExchangeService: ) raise ExchangeError(str(exc)) from exc - if isinstance(payload.get("symbols"), list): - symbols_raw = payload["symbols"] - else: - inner = payload.get("payload") - if isinstance(inner, dict) and isinstance(inner.get("symbols"), list): - symbols_raw = inner["symbols"] - else: - exc = ExchangeError("Field 'symbols' is missing in exchangeInfo response.") - self._log_exchange_error( - endpoint="exchangeInfo", - exc=exc, - ) - raise exc - - def _safe_str(value: object, default: str = "") -> str: - if value is None: - return default - return str(value).strip() - - def _safe_float(value: object) -> float | None: - if value in (None, ""): - return None - try: - return float(str(value).strip()) - except (TypeError, ValueError): - return None - - def _extract_filter_value( - filters: object, - filter_names: list[str], - keys: list[str], - ) -> float | None: - if not isinstance(filters, list): - return None - - normalized_filter_names = {name.upper() for name in filter_names} - - for entry in filters: - if not isinstance(entry, dict): - continue - - filter_type = str(entry.get("filterType", "")).strip().upper() - if filter_type not in normalized_filter_names: - continue - - for key in keys: - value = _safe_float(entry.get(key)) - if value is not None: - return value - - return None - + symbols_raw = self._extract_exchange_symbols_raw(payload) items: list[ExchangeSymbol] = [] for item in symbols_raw: if not isinstance(item, dict): continue - filters = item.get("filters") + symbol = self._parse_exchange_symbol(item) - tick_size = _safe_float(item.get("tickSize")) - if tick_size is None: - tick_size = _extract_filter_value( - filters, - filter_names=["PRICE_FILTER"], - keys=["tickSize"], - ) - - step_size = _safe_float(item.get("stepSize")) - if step_size is None: - step_size = _extract_filter_value( - filters, - filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], - keys=["stepSize"], - ) - - min_qty = _safe_float(item.get("minQty")) - if min_qty is None: - min_qty = _extract_filter_value( - filters, - filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], - keys=["minQty"], - ) - - min_notional = _safe_float(item.get("minNotional")) - if min_notional is None: - min_notional = _extract_filter_value( - filters, - filter_names=["MIN_NOTIONAL", "NOTIONAL"], - keys=["minNotional", "notional"], - ) - - market_modes_raw = item.get("marketModes") - if isinstance(market_modes_raw, list): - market_modes = [str(x).strip() for x in market_modes_raw if str(x).strip()] - elif isinstance(market_modes_raw, str) and market_modes_raw.strip(): - market_modes = [market_modes_raw.strip()] - else: - market_modes = [] - - market_type_raw = item.get("marketType") - market_type = ( - str(market_type_raw).strip() - if market_type_raw is not None - else "unknown" - ) - - items.append( - ExchangeSymbol( - symbol=_safe_str(item.get("symbol")), - name=_safe_str(item.get("name")), - status=_safe_str(item.get("status"), "unknown"), - base_asset=_safe_str(item.get("baseAsset")), - quote_asset=_safe_str(item.get("quoteAsset")), - market_modes=market_modes, - market_type=market_type, - tick_size=tick_size, - step_size=step_size, - min_qty=min_qty, - min_notional=min_notional, - ) - ) + if symbol.symbol: + items.append(symbol) type(self)._exchange_symbols_cache = items return items + # Извлечь сырой список symbols из exchangeInfo. + def _extract_exchange_symbols_raw( + self, + payload: dict[str, object], + ) -> list[object]: + symbols = payload.get("symbols") + + if isinstance(symbols, list): + return symbols + + inner = payload.get("payload") + + if isinstance(inner, dict): + nested_symbols = inner.get("symbols") + + if isinstance(nested_symbols, list): + return nested_symbols + + exc = ExchangeError("Field 'symbols' is missing in exchangeInfo response.") + self._log_exchange_error( + endpoint="exchangeInfo", + exc=exc, + ) + raise exc + + # Преобразовать один сырой symbol item в ExchangeSymbol. + def _parse_exchange_symbol( + self, + item: dict[object, object], + ) -> ExchangeSymbol: + filters = item.get("filters") + + tick_size = safe_float(item.get("tickSize")) + if tick_size is None: + tick_size = self._extract_filter_value( + filters, + filter_names=["PRICE_FILTER"], + keys=["tickSize"], + ) + + step_size = safe_float(item.get("stepSize")) + if step_size is None: + step_size = self._extract_filter_value( + filters, + filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], + keys=["stepSize"], + ) + + min_qty = safe_float(item.get("minQty")) + if min_qty is None: + min_qty = self._extract_filter_value( + filters, + filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], + keys=["minQty"], + ) + + min_notional = safe_float(item.get("minNotional")) + if min_notional is None: + min_notional = self._extract_filter_value( + filters, + filter_names=["MIN_NOTIONAL", "NOTIONAL"], + keys=["minNotional", "notional"], + ) + + return ExchangeSymbol( + symbol=self._safe_str(item.get("symbol")), + name=self._safe_str(item.get("name")), + status=self._safe_str(item.get("status"), "unknown"), + base_asset=self._safe_str(item.get("baseAsset")), + quote_asset=self._safe_str(item.get("quoteAsset")), + market_modes=self._parse_market_modes(item.get("marketModes")), + market_type=self._safe_str(item.get("marketType"), "unknown"), + tick_size=tick_size, + step_size=step_size, + min_qty=min_qty, + min_notional=min_notional, + ) + + # Безопасно привести значение к строке. + def _safe_str(self, value: object, default: str = "") -> str: + if value is None: + return default + + return str(value).strip() + + # Привести marketModes к list[str]. + def _parse_market_modes(self, value: object) -> list[str]: + if isinstance(value, list): + return [ + str(item).strip() + for item in value + if str(item).strip() + ] + + if isinstance(value, str) and value.strip(): + return [value.strip()] + + return [] + + # Извлечь числовое значение из filters exchangeInfo. + def _extract_filter_value( + self, + filters: object, + *, + filter_names: list[str], + keys: list[str], + ) -> float | None: + if not isinstance(filters, list): + return None + + normalized_filter_names = {name.upper() for name in filter_names} + + for entry in filters: + if not isinstance(entry, dict): + continue + + filter_type = str(entry.get("filterType", "")).strip().upper() + if filter_type not in normalized_filter_names: + continue + + for key in keys: + value = safe_float(entry.get(key)) + if value is not None: + return value + + return None + + # Проверить, существует ли инструмент на бирже. def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult: requested = normalize_symbol(raw_symbol) @@ -930,12 +1111,119 @@ class ExchangeService: symbol_info=None, ) + # Получить реальную цену инструмента через свежий REST snapshot. def _get_real_price(self, symbol: str) -> TickerPrice: snapshot = self.get_fresh_market_snapshot(symbol) + price = safe_float(snapshot.get("last_price")) + + if price is None: + raise ExchangeError("Field 'last_price' is missing in market snapshot.") return TickerPrice( symbol=str(snapshot["symbol"]), - price=float(snapshot["last_price"]), + price=price, source=self._source_name(), updated_at=str(snapshot["updated_at"]), - ) \ No newline at end of file + ) + + def get_exchange_server_time_ms(self) -> int: + payload = ExchangeRestClient().get_json("/api/v2/time") + + inner = payload.get("payload") + if isinstance(inner, dict): + value = inner.get("serverTime") + else: + value = payload.get("serverTime") + + server_time = safe_float(value) + + if server_time is None: + raise ExchangeError("Field 'serverTime' is missing in time response.") + + return int(server_time) + + + def get_time_sync_status(self) -> TimeSyncStatus: + hostname = socket.gethostname() + local_ip = self._get_local_ip() + + local_dt = datetime.now(ZoneInfo("UTC")) + local_ms = int(local_dt.timestamp() * 1000) + + try: + exchange_ms = self.get_exchange_server_time_ms() + drift_seconds = round((local_ms - exchange_ms) / 1000, 3) + ok = abs(drift_seconds) <= 3 + + sync = TimeSyncStatus( + ok=ok, + local_time=self._format_exchange_time(local_ms), + exchange_time=self._format_exchange_time(exchange_ms), + drift_seconds=drift_seconds, + hostname=hostname, + local_ip=local_ip, + message=( + "Время сервера синхронизировано." + if ok + else "Время сервера отличается от времени биржи." + ), + ) + + if sync.ok: + self._log_time_sync_restored(sync) + else: + self._log_time_sync_error(sync) + + return sync + + except Exception as exc: + self._log_exchange_error( + endpoint="time", + exc=exc, + ) + + sync = TimeSyncStatus( + ok=False, + local_time=self._format_exchange_time(local_ms), + exchange_time=None, + drift_seconds=None, + hostname=hostname, + local_ip=local_ip, + message="Не удалось получить время биржи.", + ) + + self._log_time_sync_error(sync) + + return sync + + + def _get_local_ip(self) -> str | None: + try: + hostname = socket.gethostname() + + addresses = socket.gethostbyname_ex(hostname)[2] + + private_ips: list[str] = [] + + for ip in addresses: + if ( + ip.startswith("192.168.") + or ip.startswith("10.") + or ( + ip.startswith("172.") + and 16 <= int(ip.split(".")[1]) <= 31 + ) + ): + private_ips.append(ip) + + if private_ips: + return private_ips[0] + + for ip in addresses: + if not ip.startswith("127."): + return ip + + except Exception: + pass + + return None \ No newline at end of file diff --git a/app/src/integrations/exchange/status.py b/app/src/integrations/exchange/status.py new file mode 100644 index 0000000..d9e6e74 --- /dev/null +++ b/app/src/integrations/exchange/status.py @@ -0,0 +1,288 @@ +# app/src/integrations/exchange/status.py + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from src.integrations.exchange.exceptions import ( + ExchangeConnectionError, + ExchangeResponseError, +) + + +class ExchangeStatusCode(StrEnum): + OPEN = "OPEN" + BREAK = "BREAK" + EXCHANGE_UNAVAILABLE = "EXCHANGE_UNAVAILABLE" + AUTH_ERROR = "AUTH_ERROR" + TIME_ERROR = "TIME_ERROR" + INVALID_SYMBOL = "INVALID_SYMBOL" + UNKNOWN = "UNKNOWN" + + +# app/src/integrations/exchange/status.py + +@dataclass(slots=True) +class ExchangeRuntimeStatus: + code: ExchangeStatusCode + is_open: bool + is_available: bool + is_auth_ok: bool + title: str + message: str + ui_line: str + reason: str + symbol: str | None = None + raw_status: str | None = None + raw_error: str | None = None + + # вернуть статус в dict для старого UI-кода на время миграции + def as_dict(self) -> dict[str, object]: + return { + "code": self.code.value, + "status": self.code.value, + "symbol": self.symbol, + "is_open": self.is_open, + "is_available": self.is_available, + "is_auth_ok": self.is_auth_ok, + "title": self.title, + "message": self.message, + "ui_line": self.ui_line, + "reason": self.reason, + "raw_status": self.raw_status, + "raw_error": self.raw_error, + } + + +# собрать статус mock-режима +def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus: + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.OPEN, + is_open=True, + is_available=True, + is_auth_ok=True, + title="Mock exchange", + message="Mock market is open.", + ui_line="🟢 Mock биржа", + reason="mock_exchange", + symbol=symbol, + raw_status="OPEN", + ) + + +# собрать статус ошибки авторизации аккаунта +def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus: + return build_exchange_error_status(exc) + + +OPEN_STATUSES = { + "TRADING", + "OPEN", + "ACTIVE", + "ENABLED", + "ONLINE", +} + +BREAK_STATUSES = { + "BREAK", + "CLOSED", + "HALT", + "HALTED", + "PAUSED", + "SUSPENDED", + "DISABLED", + "SETTLING", + "POST_ONLY", +} + + +# определить единый runtime-статус по статусу инструмента биржи +def build_market_status_from_symbol_status( + *, + raw_status: str | None, + symbol: str, +) -> ExchangeRuntimeStatus: + normalized_status = str(raw_status or "").strip().upper() + + if normalized_status in OPEN_STATUSES: + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.OPEN, + is_open=True, + is_available=True, + is_auth_ok=True, + title="Биржа доступна", + message="Рынок открыт.", + ui_line="🟢 Биржа доступна", + reason="market_open", + raw_status=normalized_status, + symbol=symbol, + ) + + if normalized_status in BREAK_STATUSES: + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.BREAK, + is_open=False, + is_available=True, + is_auth_ok=True, + title="Перерыв на бирже", + message="Торги по инструменту временно остановлены.", + ui_line="⏸️ Перерыв на бирже", + reason="market_break", + raw_status=normalized_status, + symbol=symbol, + ) + + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.UNKNOWN, + is_open=False, + is_available=True, + is_auth_ok=True, + title="Статус рынка не определён", + message=f"Статус инструмента {symbol} не определён.", + ui_line="⏸️ Перерыв на бирже", + reason="market_status_unknown", + raw_status=normalized_status or None, + symbol=symbol, + ) + + +# собрать единый статус для неверного торгового инструмента +def build_invalid_symbol_status( + *, + symbol: str, + message: str, +) -> ExchangeRuntimeStatus: + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.INVALID_SYMBOL, + is_open=False, + is_available=True, + is_auth_ok=True, + title="Инструмент недоступен", + message=message or f"Инструмент {symbol} недоступен.", + ui_line="⛔️ Инструмент недоступен", + reason="invalid_symbol", + raw_status="INVALID_SYMBOL", + symbol=symbol, + ) + + +# собрать единый статус по ошибке exchange/API +def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus: + error_type = classify_exchange_error(exc) + raw_error = str(exc) + + if error_type == "auth": + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.AUTH_ERROR, + is_open=False, + is_available=True, + is_auth_ok=False, + title="Ошибка доступа к аккаунту", + message="Ошибка доступа к аккаунту.", + ui_line="⛔️ Ошибка доступа к аккаунту", + reason="auth_error", + raw_status="AUTH_ERROR", + raw_error=raw_error, + ) + + if error_type == "time": + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.TIME_ERROR, + is_open=False, + is_available=False, + is_auth_ok=True, + title="Ошибка времени", + message="Проверь синхронизацию времени.", + ui_line="⛔️ Ошибка времени биржи", + reason="time_error", + raw_status="TIME_ERROR", + raw_error=raw_error, + ) + + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.EXCHANGE_UNAVAILABLE, + is_open=False, + is_available=False, + is_auth_ok=True, + title="Биржа недоступна", + message="Не удалось получить данные с биржи.", + ui_line="⛔️ Биржа недоступна", + reason="exchange_unavailable", + raw_status="EXCHANGE_UNAVAILABLE", + raw_error=raw_error, + ) + + +# классифицировать ошибку биржи для единого UI и логов +def classify_exchange_error(exc: Exception) -> str: + text = str(exc).lower() + + if any( + marker in text + for marker in [ + "invalid api key", + "invalid api-key", + "api key", + "api-key", + "signature", + "unauthorized", + "forbidden", + "permissions", + "expired", + ] + ): + return "auth" + + if any( + marker in text + for marker in [ + "-1021", + "server time", + "doesn't match server time", + "рассинхрон", + ] + ): + return "time" + + if isinstance(exc, ExchangeConnectionError): + return "network" + + if isinstance(exc, ExchangeResponseError): + if "404" in text: + return "network" + + if any( + marker in text + for marker in [ + "404", + "timeout", + "timed out", + "connection error", + "network error", + "name or service not known", + "nodename nor servname", + "temporary failure", + ] + ): + return "network" + + return "generic" + + +# проверить, относится ли reason к unified exchange status layer +def is_exchange_status_reason(reason: str | None) -> bool: + if not reason: + return False + + normalized = str(reason).strip().upper() + + return normalized in { + ExchangeStatusCode.OPEN.value, + ExchangeStatusCode.BREAK.value, + ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value, + ExchangeStatusCode.AUTH_ERROR.value, + ExchangeStatusCode.TIME_ERROR.value, + ExchangeStatusCode.INVALID_SYMBOL.value, + ExchangeStatusCode.UNKNOWN.value, + } \ No newline at end of file diff --git a/app/src/integrations/exchange/ws_client.py b/app/src/integrations/exchange/ws_client.py index b3e61ac..cae5e8e 100644 --- a/app/src/integrations/exchange/ws_client.py +++ b/app/src/integrations/exchange/ws_client.py @@ -4,12 +4,15 @@ from __future__ import annotations import asyncio import json -from typing import AsyncIterator +from typing import AsyncIterator, cast from uuid import uuid4 import websockets +from websockets.typing import Subprotocol from src.core.config import load_settings +from src.core.numbers import safe_float +from src.core.types import JsonDict, JsonList, NumericLike class ExchangeWebSocketClient: @@ -17,6 +20,7 @@ class ExchangeWebSocketClient: self.settings = load_settings() self.base_url = self._build_ws_base_url() + # собрать корректный websocket URL из настроек def _build_ws_base_url(self) -> str: raw_url = self.settings.exchange_ws_url or self.settings.exchange_base_url @@ -32,12 +36,17 @@ class ExchangeWebSocketClient: return f"{raw_url}/connect" - async def stream_depth( - self, - symbol: str, - *, - interval_seconds: float = 1.0, - ) -> AsyncIterator[dict]: + # безопасно нормализовать паузу между websocket-запросами + def _interval_seconds(self, value: NumericLike | None) -> float: + interval = safe_float(value) + + if interval is None or interval <= 0: + return 1.0 + + return interval + + # собрать headers для подключения к websocket + def _headers(self) -> dict[str, str]: headers = { "Origin": self.settings.exchange_base_url.rstrip("/"), "Content-Type": "application/json", @@ -46,22 +55,53 @@ class ExchangeWebSocketClient: if self.settings.exchange_api_key: headers["X-MBX-APIKEY"] = self.settings.exchange_api_key + return headers + + # собрать payload запроса стакана + def _depth_request(self, symbol: str) -> JsonDict: + return { + "correlationId": str(uuid4()), + "destination": "/api/v2/depth", + "payload": { + "limit": 5, + "symbol": symbol, + }, + } + + # безопасно разобрать JSON от websocket + def _loads_json(self, raw_message: str | bytes) -> JsonDict | JsonList | None: + try: + payload = json.loads(raw_message) + except json.JSONDecodeError: + return None + + if isinstance(payload, dict): + return cast(JsonDict, payload) + + if isinstance(payload, list): + return cast(JsonList, payload) + + return None + + # поток данных стакана по websocket + async def stream_depth( + self, + symbol: str, + *, + interval_seconds: NumericLike = 1.0, + ) -> AsyncIterator[JsonDict]: + interval = self._interval_seconds(interval_seconds) + headers = self._headers() + async with websockets.connect( self.base_url, - extra_headers=headers, - subprotocols=["json"], + additional_headers=headers, + subprotocols=[Subprotocol("json")], ping_interval=20, open_timeout=self.settings.exchange_timeout_sec, ) as websocket: while True: - request = { - "correlationId": str(uuid4()), - "destination": "/api/v2/depth", - "payload": { - "limit": 5, - "symbol": symbol, - }, - } + request = self._depth_request(symbol) await websocket.send(json.dumps(request)) @@ -71,16 +111,16 @@ class ExchangeWebSocketClient: timeout=self.settings.exchange_timeout_sec, ) except asyncio.TimeoutError: - await asyncio.sleep(interval_seconds) + await asyncio.sleep(interval) continue - try: - payload = json.loads(raw_message) - except json.JSONDecodeError: - await asyncio.sleep(interval_seconds) + if not isinstance(raw_message, (str, bytes)): + await asyncio.sleep(interval) continue + payload = self._loads_json(raw_message) + if isinstance(payload, dict): yield payload - await asyncio.sleep(interval_seconds) \ No newline at end of file + await asyncio.sleep(interval) \ No newline at end of file diff --git a/app/src/notifications/templates/signal.py b/app/src/notifications/templates/signal.py index 37807b1..211f1f3 100644 --- a/app/src/notifications/templates/signal.py +++ b/app/src/notifications/templates/signal.py @@ -23,9 +23,6 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None 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( @@ -40,24 +37,32 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None 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 market_price_line: - lines.extend([market_price_line, ""]) + position_line = _position_context_line( + signal=signal, + position_context=position_context, + ) - if position_context not in {"NONE", "—", ""} and position_context != direction_key: - lines.extend(["⚠️ ПРОТИВ ПОЗИЦИИ", ""]) + if position_line: + lines.append(position_line) - lines.append(f"{strength_bar} {strength} · {confidence:.2f}") + price_lines = _market_price_lines( + direction=direction_key, + bid_price=payload.get("bid_price"), + ask_price=payload.get("ask_price"), + ) + + if price_lines: + lines.append("") + lines.extend(price_lines) + + lines.extend([ + "", + f"{strength_bar} {strength} · {confidence:.2f}", + ]) if semantic_lines: lines.extend( @@ -74,6 +79,68 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None ) +def _position_context_line( + *, + signal: str, + position_context: str, +) -> str: + if position_context in {"NONE", "—", ""}: + return "" + + if position_context == "LONG" and signal == "BUY": + return "ℹ️ В сторону открытой позиции" + + if position_context == "SHORT" and signal == "SELL": + return "ℹ️ В сторону открытой позиции" + + if position_context == "LONG" and signal == "SELL": + return "⚠️ Против открытой позиции" + + if position_context == "SHORT" and signal == "BUY": + return "⚠️ Против открытой позиции" + + return "" + + +def _market_price_lines( + *, + direction: str, + bid_price: NumericLike | None, + ask_price: NumericLike | None, +) -> list[str]: + bid = _format_price_usd(bid_price) + ask = _format_price_usd(ask_price) + + if bid == "—" and ask == "—": + return [] + + if direction == "LONG": + return [ + f"Цена входа Long · {ask} (Ask)", + f"Цена Bid · {bid}", + ] + + if direction == "SHORT": + return [ + f"Цена входа Short · {bid} (Bid)", + f"Цена Ask · {ask}", + ] + + return [ + f"Цена Bid · {bid}", + f"Цена Ask · {ask}", + ] + + +def _format_price_usd(value: NumericLike | None) -> str: + number = safe_float(value) + + if number is None: + return "—" + + return f"${number:,.2f}".replace(",", " ") + + def _alert_priority(*, confidence: float, repeat_count: int) -> str: if confidence >= 0.8 and repeat_count >= 3: return "HIGH" @@ -131,27 +198,6 @@ def _format_symbol(symbol: str) -> str: return symbol.split("_", 1)[0].split("/", 1)[0].upper() -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) - - 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 _format_price(value: NumericLike | None) -> str: number = safe_float(value) diff --git a/app/src/storage/models.py b/app/src/storage/models.py index c2e3cc3..035a513 100644 --- a/app/src/storage/models.py +++ b/app/src/storage/models.py @@ -1,3 +1,5 @@ +# app/src/storage/models.py + from __future__ import annotations from dataclasses import dataclass @@ -18,16 +20,4 @@ class JournalEventRecord: level: str event_type: str message: str - payload_json: str | None - - -@dataclass(slots=True) -class OrderDraftRecord: - id: int | None - created_at: str - symbol: str - side: str - order_type: str - quantity: str - status: str - payload_json: str | None + payload_json: str | None \ No newline at end of file diff --git a/app/src/storage/repositories/journal.py b/app/src/storage/repositories/journal.py index 09731aa..3bee771 100644 --- a/app/src/storage/repositories/journal.py +++ b/app/src/storage/repositories/journal.py @@ -17,7 +17,11 @@ class JournalRepository: message: str, payload: dict[str, Any] | None = None, ) -> None: - payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None + payload_json = ( + json.dumps(payload, ensure_ascii=False) + if payload is not None + else None + ) with get_connection() as connection: with connection.cursor() as cursor: @@ -66,7 +70,11 @@ class JournalRepository: return [self._row_to_dict(row) for row in rows] - def list_recent_with_offset(self, limit: int, offset: int) -> list[dict[str, Any]]: + def list_recent_with_offset( + self, + limit: int, + offset: int, + ) -> list[dict[str, Any]]: with get_connection() as connection: with connection.cursor() as cursor: cursor.execute( @@ -82,13 +90,86 @@ class JournalRepository: return [self._row_to_dict(row) for row in rows] - def list_export_rows(self, limit: int = 5000) -> list[dict[str, Any]]: + def list_export_rows( + self, + limit: int = 5000, + export_filter: str = "all", + ) -> list[dict[str, Any]]: + where_sql = "" + + if export_filter == "auto": + where_sql = """ + WHERE + COALESCE(payload_json ->> 'screen', '') = 'auto' + OR event_type LIKE 'position_%%' + OR event_type LIKE 'trade_%%' + OR event_type LIKE 'runtime_%%' + OR event_type IN ( + 'signal_summary', + 'signal_ready', + 'signal_changed', + 'signal_blocked', + 'execution_blocked', + 'execution_quality_changed' + ) + """ + + elif export_filter == "trades": + where_sql = """ + WHERE + event_type IN ( + 'position_opened', + 'position_closed', + 'position_flipped', + 'position_flip_blocked', + 'trade_opened', + 'trade_closed', + 'trade_flipped' + ) + OR event_type LIKE 'trade_%%' + """ + + elif export_filter == "errors": + where_sql = """ + WHERE + level IN ('ERROR', 'CRITICAL') + OR ( + level = 'WARNING' + AND ( + event_type LIKE '%%error%%' + OR event_type LIKE '%%blocked%%' + OR event_type LIKE '%%failed%%' + OR COALESCE(payload_json ->> 'error_type', '') <> '' + OR COALESCE(payload_json ->> 'raw_error', '') <> '' + ) + ) + """ + + elif export_filter == "not_auto": + where_sql = """ + WHERE NOT ( + COALESCE(payload_json ->> 'screen', '') = 'auto' + OR event_type LIKE 'position_%%' + OR event_type LIKE 'trade_%%' + OR event_type LIKE 'runtime_%%' + OR event_type IN ( + 'signal_summary', + 'signal_ready', + 'signal_changed', + 'signal_blocked', + 'execution_blocked', + 'execution_quality_changed' + ) + ) + """ + with get_connection() as connection: with connection.cursor() as cursor: cursor.execute( - """ + f""" SELECT id, created_at, level, event_type, message, payload_json FROM journal_events + {where_sql} ORDER BY created_at DESC, id DESC LIMIT %s """, @@ -114,17 +195,6 @@ class JournalRepository: return int(deleted_count or 0) - def _row_to_dict(self, row: tuple[Any, ...]) -> dict[str, Any]: - return { - "id": str(row[0]), - "created_at": str(row[1]), - "level": str(row[2]), - "event_type": str(row[3]), - "message": str(row[4]), - "payload": self._parse_payload(row[5]), - } - - def delete_older_than_days(self, days: int) -> int: with get_connection() as connection: with connection.cursor() as cursor: @@ -137,4 +207,14 @@ class JournalRepository: ) deleted_count = cursor.rowcount - return deleted_count \ No newline at end of file + return int(deleted_count or 0) + + def _row_to_dict(self, row: tuple[Any, ...]) -> dict[str, Any]: + return { + "id": str(row[0]), + "created_at": str(row[1]), + "level": str(row[2]), + "event_type": str(row[3]), + "message": str(row[4]), + "payload": self._parse_payload(row[5]), + } \ No newline at end of file diff --git a/app/src/storage/repositories/order_drafts.py b/app/src/storage/repositories/order_drafts.py deleted file mode 100644 index 4bd7ec9..0000000 --- a/app/src/storage/repositories/order_drafts.py +++ /dev/null @@ -1,111 +0,0 @@ -# app/src/storage/repositories/order_drafts.py - -from __future__ import annotations - -import json -from typing import Any - -from src.storage.session import get_connection - - -class OrderDraftRepository: - def add_draft( - self, - *, - symbol: str, - side: str, - order_type: str, - quantity: str, - status: str = "draft", - payload: dict[str, Any] | None = None, - ) -> None: - payload_json = json.dumps(payload, ensure_ascii=False) if payload is not None else None - - with get_connection() as connection: - with connection.cursor() as cursor: - cursor.execute( - ''' - INSERT INTO order_drafts (symbol, side, order_type, quantity, status, payload_json) - VALUES (%s, %s, %s, %s, %s, %s::jsonb) - ''', - (symbol, side, order_type, quantity, status, payload_json), - ) - - def list_recent_drafts(self, limit: int = 10) -> list[dict[str, str]]: - with get_connection() as connection: - with connection.cursor() as cursor: - cursor.execute( - ''' - SELECT id, created_at, symbol, side, order_type, quantity::text, status - FROM order_drafts - ORDER BY created_at DESC, id DESC - LIMIT %s - ''', - (limit,), - ) - rows = cursor.fetchall() - - items: list[dict[str, str]] = [] - for row in rows: - items.append( - { - "id": str(row[0]), - "created_at": str(row[1]), - "symbol": str(row[2]), - "side": str(row[3]), - "order_type": str(row[4]), - "quantity": str(row[5]), - "status": str(row[6]), - } - ) - return items - - def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None: - with get_connection() as connection: - with connection.cursor() as cursor: - cursor.execute( - ''' - SELECT id, created_at, symbol, side, order_type, quantity::text, status, payload_json - FROM order_drafts - WHERE id = %s - LIMIT 1 - ''', - (draft_id,), - ) - row = cursor.fetchone() - - if not row: - return None - - payload_raw = row[7] - payload: dict[str, Any] = {} - - if isinstance(payload_raw, dict): - payload = payload_raw - elif payload_raw: - try: - payload = json.loads(str(payload_raw)) - except Exception: - payload = {} - - price = payload.get("price") - price_text = str(price) if price not in (None, "") else "" - - return { - "id": str(row[0]), - "created_at": str(row[1]), - "symbol": str(row[2]), - "side": str(row[3]), - "order_type": str(row[4]), - "quantity": str(row[5]), - "status": str(row[6]), - "price": price_text, - } - - def count_drafts(self) -> int: - with get_connection() as connection: - with connection.cursor() as cursor: - cursor.execute("SELECT COUNT(*) FROM order_drafts") - row = cursor.fetchone() - - return int(row[0]) if row else 0 \ No newline at end of file diff --git a/app/src/storage/schema.py b/app/src/storage/schema.py index 7a3c66d..e4cfbb4 100644 --- a/app/src/storage/schema.py +++ b/app/src/storage/schema.py @@ -1,15 +1,23 @@ +# app/src/storage/schema.py + from __future__ import annotations + +from psycopg import sql + from src.storage.session import get_connection -DDL = [ - ''' + + +# SQL-команды для первичной инициализации базы данных. +DDL: list[sql.SQL] = [ + sql.SQL(""" CREATE TABLE IF NOT EXISTS balance_snapshots ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), source TEXT NOT NULL, payload_json JSONB NOT NULL ) - ''', - ''' + """), + sql.SQL(""" CREATE TABLE IF NOT EXISTS journal_events ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -18,28 +26,19 @@ DDL = [ message TEXT NOT NULL, payload_json JSONB ) - ''', - ''' + """), + sql.SQL(""" CREATE INDEX IF NOT EXISTS idx_journal_events_created_at ON journal_events (created_at DESC) - ''', - ''' + """), + sql.SQL(""" CREATE INDEX IF NOT EXISTS idx_journal_events_event_type ON journal_events (event_type) - ''', - ''' - CREATE TABLE IF NOT EXISTS order_drafts ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - symbol TEXT NOT NULL, - side TEXT NOT NULL, - order_type TEXT NOT NULL, - quantity NUMERIC(36, 18) NOT NULL, - status TEXT NOT NULL, - payload_json JSONB - ) - ''' + """), ] + + +# создаёт таблицы и индексы, если они ещё не существуют def init_schema() -> None: with get_connection() as connection: with connection.cursor() as cursor: diff --git a/app/src/storage/session.py b/app/src/storage/session.py index 937b4f5..d2413a2 100644 --- a/app/src/storage/session.py +++ b/app/src/storage/session.py @@ -1,3 +1,5 @@ +# app/src/storage/session.py + from __future__ import annotations from contextlib import contextmanager diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py index 7bcdf9f..9ee8889 100644 --- a/app/src/telegram/handlers/auto/main.py +++ b/app/src/telegram/handlers/auto/main.py @@ -12,6 +12,7 @@ from src.telegram.handlers.auto.ui import ( auto_keyboard, build_auto_text, is_auto_configured, + _auto_block_reason, ) from src.telegram.handlers.system import open_auto_settings from src.telegram.live.active_screen import ActiveScreenManager @@ -230,6 +231,16 @@ async def open_auto_from_callback(callback: CallbackQuery, state: FSMContext) -> await callback.answer() +@router.callback_query(F.data == "auto:start_blocked") +async def auto_start_blocked(callback: CallbackQuery) -> None: + reason = _auto_block_reason() or "Запуск сейчас недоступен" + + await callback.answer( + reason.replace("⛔️ ", ""), + show_alert=True, + ) + + @router.callback_query(F.data == "auto:start") async def auto_start(callback: CallbackQuery) -> None: service = AutoTradeService() @@ -246,6 +257,22 @@ async def auto_start(callback: CallbackQuery) -> None: return + block_reason = _auto_block_reason() + + if block_reason: + await callback.answer( + block_reason.replace("⛔️ ", "").replace("⛔ ", ""), + show_alert=True, + ) + + if await _prepare_auto_from_callback(callback): + message = _require_message(callback) + + if message is not None: + await render_auto_screen(message, edit_mode=True) + + return + _, message_text = service.start() if await _prepare_auto_from_callback(callback): diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py index 6b5def9..5dce4c9 100644 --- a/app/src/telegram/handlers/auto/ui.py +++ b/app/src/telegram/handlers/auto/ui.py @@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder from src.integrations.exchange.service import ExchangeService +from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines from src.telegram.ui.common import mode_line from src.trading.auto.service import AutoTradeService from src.core.numbers import safe_float @@ -18,6 +19,11 @@ from src.core.numbers import safe_float def build_auto_notification_text() -> str: state = AutoTradeService().get_state() + signal = str(getattr(state, "last_signal", "HOLD") or "HOLD").upper() + + if signal in {"BUY", "SELL"}: + return _build_signal_notification_text(state, signal) + cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0) cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0) @@ -33,15 +39,102 @@ def build_auto_notification_text() -> str: return " · ".join(parts) +def _build_signal_notification_text(state, signal: str) -> str: + snapshot = _market_snapshot(getattr(state, "symbol", None)) + + bid_price = _price_from_snapshot(snapshot, "bid_price") + ask_price = _price_from_snapshot(snapshot, "ask_price") + + side = "Long" if signal == "BUY" else "Short" + side_icon = _signal_icon(signal) + asset = _asset_symbol(getattr(state, "symbol", None)) + + confidence = safe_float(getattr(state, "last_signal_confidence", None)) or 0.0 + reason = str(getattr(state, "last_signal_reason", "") or "").strip() + + if signal == "BUY": + entry_price = ask_price + entry_source = "Ask" + second_price_label = "Bid" + second_price = bid_price + else: + entry_price = bid_price + entry_source = "Bid" + second_price_label = "Ask" + second_price = ask_price + + lines = [ + f"Сигнал {side_icon} {asset} · {side}", + "", + f"Цена входа {side} · {_format_plain_or_dash(entry_price)} ({entry_source})", + f"Цена {second_price_label} · {_format_plain_or_dash(second_price)}", + "", + _signal_strength_line(confidence), + ] + + compact_reason = _notification_signal_reason(reason) + if compact_reason: + lines.append(compact_reason) + + return "\n".join(lines) + + +def _price_from_snapshot( + snapshot: dict[str, object] | None, + key: str, +) -> float | None: + if snapshot is None: + return None + + return safe_float(snapshot.get(key)) + + +def _signal_strength_line(confidence: float) -> str: + filled = min(3, max(0, round(confidence * 3))) + bar = "●" * filled + "○" * (3 - filled) + + if confidence >= 0.8: + level = "Сильный" + elif confidence >= 0.5: + level = "Средний" + else: + level = "Слабый" + + return f"{bar} {level} · {confidence:.2f}" + + +def _notification_signal_reason(reason: str) -> str: + reason_upper = reason.upper() + + if "BREAKOUT_UP" in reason_upper: + return "Пробой вверх" + + if "BREAKOUT_DOWN" in reason_upper: + return "Пробой вниз" + + if "TREND_UP" in reason_upper: + return "Рынок растёт" + + if "TREND_DOWN" in reason_upper: + return "Рынок снижается" + + return _compact_entry_block_message(reason) + + def auto_keyboard() -> InlineKeyboardMarkup: state = AutoTradeService().get_state() builder = InlineKeyboardBuilder() status = (state.status or "").upper() + block_reason = _auto_block_reason() if status == "OFF": - builder.button(text="▶️ Запустить", callback_data="auto:start") + if block_reason: + builder.button(text="⛔ Запуск недоступен", callback_data="auto:start_blocked") + else: + builder.button(text="▶️ Запустить", callback_data="auto:start") + builder.button(text="👀 Наблюдать", callback_data="auto:observe") elif status == "RUNNING": @@ -49,7 +142,11 @@ def auto_keyboard() -> InlineKeyboardMarkup: builder.button(text="🛑 Остановить", callback_data="auto:stop") elif status == "OBSERVING": - builder.button(text="▶️ Запустить", callback_data="auto:start") + if block_reason: + builder.button(text="⛔ Запуск недоступен", callback_data="auto:start_blocked") + else: + builder.button(text="▶️ Запустить", callback_data="auto:start") + builder.button(text="🛑 Остановить", callback_data="auto:stop") else: @@ -97,30 +194,31 @@ def is_auto_configured(state) -> bool: return True -def build_auto_text() -> str: - state = AutoTradeService().get_state() +def _auto_block_reason(state: object | None = None) -> str | None: + state_to_use = state or AutoTradeService().get_state() - if not is_auto_configured(state): - return _build_not_configured_text(state) - - if state.position_side != "NONE" and state.entry_price is not None: - return _build_active_position_text(state) - - if state.status == "OFF": - return _build_stopped_without_position_text(state) - - return _build_waiting_text(state) - - -def build_auto_semantic_text() -> str: - text = build_auto_text() - - return re.sub( - r" · \d+с| · \d+м \d+с| · \d+ч \d+м", - "", - text, + lines = build_runtime_exchange_alert_lines( + symbol=getattr(state_to_use, "symbol", None), + include_exchange_unavailable=True, ) + if not lines: + return None + + return "\n".join(lines) + + +def _append_auto_block_reason(parts: list[str], state: object) -> None: + block_reason = _auto_block_reason(state) + + if block_reason: + parts.extend([ + "", + block_reason, + ]) + + + def _build_not_configured_text(state) -> str: symbol_ready = state.symbol is not None @@ -134,11 +232,16 @@ def _build_not_configured_text(state) -> str: parts = [ "🤖 Автоторговля ⚪ Не настроена", _account_mode_line(), + ] + + _append_auto_block_reason(parts, state) + + parts.extend([ "", f"{symbol_icon} Актив · {_asset_symbol(state.symbol)}", f"{strategy_icon} Стратегия · {_required_value(_strategy_short(state.strategy))}", f"{risk_icon} Риск · {_required_value(_risk_percent_text(state))}", - ] + ]) strategy = (state.strategy or "").upper() @@ -166,6 +269,11 @@ def _build_stopped_without_position_text(state) -> str: parts = [ "⚪️ Автоторговля остановлена", _account_mode_line(), + ] + + _append_auto_block_reason(parts, state) + + parts.extend([ "", f"Доступно 💰 {_format_money_compact(available)}", "", @@ -180,11 +288,36 @@ def _build_stopped_without_position_text(state) -> str: f"Риск · {_format_percent(state.risk_percent)}", "", _settings_risk_percent_line(state), - ] + ]) return "\n".join(parts) +def build_auto_text() -> str: + state = AutoTradeService().get_state() + + if not is_auto_configured(state): + return _build_not_configured_text(state) + + if state.position_side != "NONE" and state.entry_price is not None: + return _build_active_position_text(state) + + if state.status == "OFF": + return _build_stopped_without_position_text(state) + + return _build_waiting_text(state) + + +def build_auto_semantic_text() -> str: + text = build_auto_text() + + return re.sub( + r" · \d+с| · \d+м \d+с| · \d+ч \d+м", + "", + text, + ) + + def _settings_risk_percent_line(state) -> str: sl = _format_percent(state.stop_loss_percent) tp = _format_percent(state.take_profit_percent) @@ -210,9 +343,14 @@ def _build_waiting_text(state) -> str: parts = [ f"{_status_text(state)}", _account_mode_line(), + ] + + _append_auto_block_reason(parts, state) + + parts.extend([ "", f"Доступно 💰 {_format_money_compact(available)}", - ] + ]) if cycle_trades > 0: parts.extend([ @@ -367,10 +505,15 @@ def _build_active_position_text(state) -> str: parts = [ _status_text(state), _account_mode_line(), + ] + + _append_auto_block_reason(parts, state) + + parts.extend([ "", f"Доступно 💰 {_format_money_compact(available)}", f"Маржа · {_format_usd_compact(reserved)}", - ] + ]) if cycle_trades > 0: parts.extend([ diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py index 8d2637f..4cf47bb 100644 --- a/app/src/telegram/handlers/journal.py +++ b/app/src/telegram/handlers/journal.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError from aiogram.fsm.context import FSMContext from aiogram.types import ( BufferedInputFile, @@ -17,11 +18,14 @@ from src.telegram.handlers.journal_ui import ( PAGE_SIZE, build_actions_keyboard, build_clear_confirm_keyboard, + build_export_format_keyboard, build_keyboard, render, render_actions, render_clear_confirm, + render_export_format, ) +from src.trading.journal.filters import normalize_journal_export_filter from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen from src.trading.journal.service import JournalService @@ -175,10 +179,18 @@ async def _show_journal_page( kb = build_keyboard(page, total_pages) if edit_mode: - await target_message.edit_text( - text, - reply_markup=kb, - ) + try: + await target_message.edit_text( + text, + reply_markup=kb, + ) + except TelegramBadRequest as exc: + if "message is not modified" in str(exc).lower(): + _register_journal_screen(target_message) + return + + raise + _register_journal_screen(target_message) return @@ -200,10 +212,44 @@ async def journal_actions(callback: CallbackQuery) -> None: await callback.answer("Сообщение недоступно", show_alert=True) return - await message.edit_text( - render_actions(), - reply_markup=build_actions_keyboard(), - ) + try: + await message.edit_text( + render_actions(), + reply_markup=build_actions_keyboard(), + ) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise + + _register_journal_screen(message) + + await callback.answer() + + +@router.callback_query(F.data.startswith("journal:export_filter:")) +async def journal_export_filter(callback: CallbackQuery) -> None: + # Пользователь выбрал фильтр экспорта. + # Теперь показываем выбор формата: CSV или Excel. + if not await _prepare_journal_from_callback(callback): + return + + message = _require_message(callback) + + if message is None: + await callback.answer("Сообщение недоступно", show_alert=True) + return + + raw_filter = (callback.data or "").rsplit(":", 1)[-1] + export_filter = normalize_journal_export_filter(raw_filter) + + try: + await message.edit_text( + render_export_format(export_filter), + reply_markup=build_export_format_keyboard(export_filter), + ) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise _register_journal_screen(message) @@ -224,8 +270,8 @@ async def open_journal(message: Message, state: FSMContext) -> None: ) -@router.callback_query(F.data == "monitoring:journal") -async def open_journal_from_monitoring( +@router.callback_query(F.data == "system:journal") +async def open_journal_from_system( callback: CallbackQuery, state: FSMContext, ) -> None: @@ -254,20 +300,36 @@ async def journal_noop(callback: CallbackQuery) -> None: await callback.answer() -@router.callback_query(F.data == "journal:export_csv") +@router.callback_query(F.data.startswith("journal:export_csv")) async def export_journal_csv(callback: CallbackQuery) -> None: service = JournalService() message = _require_message(callback) + if message is None: + await callback.answer("Сообщение недоступно", show_alert=True) + return + + parts = (callback.data or "").split(":") + export_filter = normalize_journal_export_filter( + parts[2] if len(parts) >= 3 else "all" + ) + + await callback.answer("Готовлю CSV…") + try: - data = service.export_csv() + data = service.export_csv(export_filter=export_filter) document = BufferedInputFile( data, - filename=service.build_export_filename("csv"), + filename=service.build_export_filename( + "csv", + export_filter=export_filter, + ), ) - if message is not None: - await message.answer_document(document=document) + await message.answer_document( + document=document, + request_timeout=120, + ) service.log_ui_info( event_type="journal_exported", @@ -276,10 +338,31 @@ async def export_journal_csv(callback: CallbackQuery) -> None: action="export_csv", user_id=_user_id_from_callback(callback), chat_id=_chat_id_from_callback(callback), - payload=_journal_payload(format="csv"), + payload=_journal_payload( + format="csv", + export_filter=export_filter, + ), ) - await callback.answer("CSV экспортирован") + except TelegramNetworkError as exc: + service.log_ui_error( + event_type="journal_export_error", + message="Не удалось отправить CSV файл журнала.", + screen="journal", + action="export_csv", + user_id=_user_id_from_callback(callback), + chat_id=_chat_id_from_callback(callback), + payload=_journal_payload( + format="csv", + export_filter=export_filter, + ), + raw_error=str(exc), + ) + + await message.answer( + "⛔️ Не удалось отправить CSV файл.\n" + "Попробуй ещё раз или уменьши объём журнала." + ) except Exception as exc: service.log_ui_error( @@ -289,30 +372,46 @@ async def export_journal_csv(callback: CallbackQuery) -> None: action="export_csv", user_id=_user_id_from_callback(callback), chat_id=_chat_id_from_callback(callback), - payload=_journal_payload(format="csv"), + payload=_journal_payload( + format="csv", + export_filter=export_filter, + ), raw_error=str(exc), ) - await callback.answer( - "Не удалось экспортировать CSV", - show_alert=True, - ) + await message.answer("⛔️ Не удалось экспортировать CSV.") -@router.callback_query(F.data == "journal:export_xlsx") +@router.callback_query(F.data.startswith("journal:export_xlsx")) async def export_journal_xlsx(callback: CallbackQuery) -> None: service = JournalService() message = _require_message(callback) + if message is None: + await callback.answer("Сообщение недоступно", show_alert=True) + return + + parts = (callback.data or "").split(":") + export_filter = normalize_journal_export_filter( + parts[2] if len(parts) >= 3 else "all" + ) + + await callback.answer("Готовлю Excel…") + try: - data = service.export_xlsx() + data = service.export_xlsx(export_filter=export_filter) document = BufferedInputFile( data, - filename=service.build_export_filename("xlsx"), + filename=service.build_export_filename( + "xlsx", + export_filter=export_filter, + ), ) - if message is not None: - await message.answer_document(document=document) + await message.answer_document( + document=document, + request_timeout=120, + ) service.log_ui_info( event_type="journal_exported", @@ -321,10 +420,31 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None: action="export_xlsx", user_id=_user_id_from_callback(callback), chat_id=_chat_id_from_callback(callback), - payload=_journal_payload(format="xlsx"), + payload=_journal_payload( + format="xlsx", + export_filter=export_filter, + ), ) - await callback.answer("Excel экспортирован") + except TelegramNetworkError as exc: + service.log_ui_error( + event_type="journal_export_error", + message="Не удалось отправить XLSX файл журнала.", + screen="journal", + action="export_xlsx", + user_id=_user_id_from_callback(callback), + chat_id=_chat_id_from_callback(callback), + payload=_journal_payload( + format="xlsx", + export_filter=export_filter, + ), + raw_error=str(exc), + ) + + await message.answer( + "⛔️ Не удалось отправить Excel файл.\n" + "Попробуй ещё раз или уменьши объём журнала." + ) except Exception as exc: service.log_ui_error( @@ -334,14 +454,14 @@ async def export_journal_xlsx(callback: CallbackQuery) -> None: action="export_xlsx", user_id=_user_id_from_callback(callback), chat_id=_chat_id_from_callback(callback), - payload=_journal_payload(format="xlsx"), + payload=_journal_payload( + format="xlsx", + export_filter=export_filter, + ), raw_error=str(exc), ) - await callback.answer( - "Не удалось экспортировать Excel", - show_alert=True, - ) + await message.answer("⛔️ Не удалось экспортировать Excel.") @router.callback_query(F.data == "journal:clear_confirm") diff --git a/app/src/telegram/handlers/journal_ui.py b/app/src/telegram/handlers/journal_ui.py index e468faf..9d8c431 100644 --- a/app/src/telegram/handlers/journal_ui.py +++ b/app/src/telegram/handlers/journal_ui.py @@ -12,9 +12,10 @@ from src.core.config import load_settings from src.core.event_titles import event_title from src.core.numbers import safe_float from src.core.types import JsonDict, JsonList, NumericLike +from src.trading.journal.filters import journal_export_filter_label -PAGE_SIZE = 5 +PAGE_SIZE = 3 LEVEL_ICONS = { "INFO": "ℹ️", @@ -52,7 +53,7 @@ def build_keyboard( kb.button(text="📤 Экспорт", callback_data="journal:actions") kb.button(text="🛠️ Настройки", callback_data="settings:journal") - kb.button(text="📊 К мониторингу", callback_data="monitoring:home") + kb.button(text="⬅️ Назад", callback_data="system:back") nav_count = 1 @@ -67,10 +68,28 @@ def build_keyboard( def build_actions_keyboard() -> InlineKeyboardMarkup: + # Первый экран экспорта: выбираем, что именно экспортировать. kb = InlineKeyboardBuilder() - kb.button(text="📄 CSV", callback_data="journal:export_csv") - kb.button(text="📊 Excel", callback_data="journal:export_xlsx") + + kb.button(text="🤖 Автоторговля", callback_data="journal:export_filter:auto") + kb.button(text="📈 Сделки", callback_data="journal:export_filter:trades") + kb.button(text="⛔ Ошибки", callback_data="journal:export_filter:errors") + kb.button(text="🧾 Без авто", callback_data="journal:export_filter:not_auto") + kb.button(text="📒 Всё", callback_data="journal:export_filter:all") kb.button(text="⬅️ Назад", callback_data="journal:1") + + kb.adjust(2, 2, 1, 1) + return kb.as_markup() + + +def build_export_format_keyboard(export_filter: str) -> InlineKeyboardMarkup: + # Второй экран экспорта: после выбора фильтра выбираем формат. + kb = InlineKeyboardBuilder() + + kb.button(text="📄 CSV", callback_data=f"journal:export_csv:{export_filter}") + kb.button(text="📊 Excel", callback_data=f"journal:export_xlsx:{export_filter}") + kb.button(text="⬅️ Назад", callback_data="journal:actions") + kb.adjust(2, 1) return kb.as_markup() @@ -78,7 +97,18 @@ def build_actions_keyboard() -> InlineKeyboardMarkup: def render_actions() -> str: return ( "📤 Экспорт\n\n" - "МОНИТОРИНГ · Журнал\n\n" + "СИСТЕМА · Журнал\n\n" + "Что экспортировать?" + ) + + +def render_export_format(export_filter: str) -> str: + # Показываем выбранный фильтр перед выбором CSV/XLSX. + label = journal_export_filter_label(export_filter) + + return ( + f"📤 Экспорт · {label}\n\n" + "СИСТЕМА · Журнал\n\n" "Выберите формат:" ) @@ -245,7 +275,7 @@ def render( lines = [ "📒 Журнал", "", - "МОНИТОРИНГ", + "СИСТЕМА", "", ] diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py deleted file mode 100644 index 7e0f157..0000000 --- a/app/src/telegram/handlers/market.py +++ /dev/null @@ -1,474 +0,0 @@ -# app/src/telegram/handlers/market.py - -from __future__ import annotations - -from aiogram import F, Router -from aiogram.fsm.context import FSMContext -from aiogram.utils.keyboard import InlineKeyboardBuilder -from aiogram.types import ( - CallbackQuery, - InlineKeyboardMarkup, - Message, - InaccessibleMessage, -) - -from src.core.numbers import safe_float -from src.core.types import NumericLike -from src.integrations.exchange.exceptions import ExchangeError -from src.integrations.exchange.service import ExchangeService -from src.telegram.live.active_screen import ActiveScreenManager -from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry -from src.telegram.ui.common import mode_line, now_line -from src.telegram.ui.currency_ui import format_usd_amount -from src.telegram.ui.exchange_error import ( - classify_exchange_error, - show_callback_exchange_error, - show_message_exchange_error, -) -from src.trading.journal.service import JournalService - - -router = Router(name="market") -_last_market_prices: dict[str, float] = {} -_last_market_directions: dict[str, str] = {} - - -def _require_message( - callback: CallbackQuery, -) -> Message | None: - message = callback.message - - if ( - message is None - or isinstance(message, InaccessibleMessage) - ): - return None - - return message - - -def _market_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="📊 К мониторингу", callback_data="monitoring:home") - builder.adjust(1) - return builder.as_markup() - - -def _build_market_text( - *, - ticker_price: NumericLike, - name: str, - market_type: str, - base_asset: str, - quote_asset: str, -) -> str: - price = safe_float(ticker_price) - - if price is None: - price = 0.0 - - previous_price = _last_market_prices.get(name) - price_direction = _last_market_directions.get(name, "▲") - - if previous_price is not None: - if price > previous_price: - price_direction = "🔺" - elif price < previous_price: - price_direction = "🔻" - - _last_market_prices[name] = price - _last_market_directions[name] = price_direction - - type_map = { - "LEVERAGE": "leverage", - "SPOT": "spot", - } - market_type_ru = type_map.get(market_type.upper(), market_type.lower()) - - return ( - "📈 Рынок\n" - f"{mode_line()}" - "\n" - f"{base_asset} / {quote_asset} ({market_type_ru})\n\n" - f"$ {format_usd_amount(price)} {price_direction}\n\n" - f"{now_line()}" - ) - - -def _build_market_live_text() -> str: - service = ExchangeService() - requested_symbol = service.settings.default_symbol - - validation = service.validate_symbol(requested_symbol) - - if not validation.is_valid: - return ( - "📈 Рынок\n" - f"{mode_line()}" - "⚠️ Ошибка инструмента\n\n" - "Инструмент недоступен." - ) - - ticker = service.get_price(validation.normalized_symbol) - - symbol_info = validation.symbol_info - market_type = symbol_info.market_type if symbol_info else "n/a" - base_asset = ( - symbol_info.base_asset - if symbol_info and symbol_info.base_asset - else "n/a" - ) - quote_asset = ( - symbol_info.quote_asset - if symbol_info and symbol_info.quote_asset - else "n/a" - ) - name = ( - symbol_info.name - if symbol_info and symbol_info.name - else ticker.symbol - ) - - return _build_market_text( - ticker_price=ticker.price, - name=name, - market_type=market_type, - base_asset=base_asset, - quote_asset=quote_asset, - ) - - -def _register_market_live_screen(message: Message) -> None: - bot = message.bot - - if bot is None: - return - - LiveScreenRunner.unregister_message( - chat_id=message.chat.id, - message_id=message.message_id, - ) - - ScreenRegistry.unregister_message( - chat_id=message.chat.id, - message_id=message.message_id, - ) - - LiveScreenRunner.register_screen( - LiveScreen( - screen="market", - bot=bot, - chat_id=message.chat.id, - message_id=message.message_id, - render_text=_build_market_live_text, - render_markup=_market_keyboard, - interval_seconds=5, - ) - ) - - LiveScreenRunner.start("market") - - -async def _prepare_market_from_message( - message: Message, -) -> bool: - bot = message.bot - - if bot is None: - return False - - await ActiveScreenManager.prepare_new_screen( - screen="market", - bot=bot, - chat_id=message.chat.id, - ) - - return True - - -async def _prepare_market_from_callback( - callback: CallbackQuery, -) -> bool: - message = _require_message(callback) - - if message is None: - await callback.answer( - "Сообщение недоступно", - show_alert=True, - ) - return False - - bot = message.bot - - if bot is None: - await callback.answer( - "Bot недоступен", - show_alert=True, - ) - return False - - await ActiveScreenManager.prepare_new_screen( - screen="market", - bot=bot, - chat_id=message.chat.id, - keep_message_id=message.message_id, - ) - - return True - - -async def _render_market_screen( - target_message: Message, - *, - user_id: int | None, - chat_id: int | None, - edit_mode: bool, - action: str, -) -> None: - service = ExchangeService() - journal = JournalService() - requested_symbol = service.settings.default_symbol - - journal.log_ui_info( - event_type="market_open_requested", - message="Запрошено открытие экрана рынка.", - screen="market", - action=action, - user_id=user_id, - chat_id=chat_id, - payload={"symbol": requested_symbol}, - ) - - validation = service.validate_symbol(requested_symbol) - - if not validation.is_valid: - journal.log_ui_warning( - event_type="market_symbol_invalid", - message="Инструмент недоступен.", - screen="market", - action=action, - user_id=user_id, - chat_id=chat_id, - payload={ - "symbol": requested_symbol, - "validation_message": validation.message, - }, - ) - - text = ( - "📈 Рынок\n" - f"{mode_line()}" - "⚠️ Ошибка инструмента\n\n" - "Инструмент недоступен." - ) - - if edit_mode: - await target_message.edit_text(text, reply_markup=_market_keyboard()) - _register_market_live_screen(target_message) - ActiveScreenManager.register(screen="market", message=target_message) - else: - sent_message = await target_message.answer(text, reply_markup=_market_keyboard()) - _register_market_live_screen(sent_message) - ActiveScreenManager.register(screen="market", message=sent_message) - - return - - ticker = service.get_price(validation.normalized_symbol) - - symbol_info = validation.symbol_info - market_type = symbol_info.market_type if symbol_info else "n/a" - base_asset = ( - symbol_info.base_asset - if symbol_info and symbol_info.base_asset - else "n/a" - ) - quote_asset = ( - symbol_info.quote_asset - if symbol_info and symbol_info.quote_asset - else "n/a" - ) - name = ( - symbol_info.name - if symbol_info and symbol_info.name - else ticker.symbol - ) - - text = _build_market_text( - ticker_price=ticker.price, - name=name, - market_type=market_type, - base_asset=base_asset, - quote_asset=quote_asset, - ) - - journal.log_ui_info( - event_type="market_open_success", - message="Экран рынка загружен.", - screen="market", - action=action, - user_id=user_id, - chat_id=chat_id, - payload={ - "symbol": ticker.symbol, - "price": safe_float(ticker.price), - }, - ) - - if edit_mode: - await target_message.edit_text(text, reply_markup=_market_keyboard()) - _register_market_live_screen(target_message) - ActiveScreenManager.register(screen="market", message=target_message) - else: - sent_message = await target_message.answer(text, reply_markup=_market_keyboard()) - _register_market_live_screen(sent_message) - ActiveScreenManager.register(screen="market", message=sent_message) - - -@router.message(F.text == "📈 Рынок") -async def open_market(message: Message, state: FSMContext) -> None: - await state.clear() - - if not await _prepare_market_from_message(message): - return - - user_id = message.from_user.id if message.from_user else None - chat_id = message.chat.id if message.chat else None - - try: - await _render_market_screen( - message, - user_id=user_id, - chat_id=chat_id, - edit_mode=False, - action="open", - ) - except ExchangeError as exc: - JournalService().log_ui_error( - event_type="market_open_error", - message="Не удалось загрузить экран рынка.", - screen="market", - action="open", - user_id=user_id, - chat_id=chat_id, - error_type=classify_exchange_error(exc), - raw_error=str(exc), - ) - - await show_message_exchange_error( - message, - title="📈 Рынок", - exc=exc, - network_details="Рыночные данные недоступны.\nОбнови экран.", - auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.", - retry_callback_data="market:retry", - ) - - -@router.callback_query(F.data == "monitoring:market") -async def open_market_from_monitoring( - callback: CallbackQuery, - state: FSMContext, -) -> None: - await state.clear() - - if not await _prepare_market_from_callback(callback): - return - - message = _require_message(callback) - - if message is None: - await callback.answer( - "Сообщение недоступно", - show_alert=True, - ) - return - - user_id = callback.from_user.id if callback.from_user else None - chat_id = message.chat.id - - try: - await _render_market_screen( - message, - user_id=user_id, - chat_id=chat_id, - edit_mode=True, - action="open_from_monitoring", - ) - - await callback.answer() - - except ExchangeError as exc: - JournalService().log_ui_error( - event_type="market_open_error", - message="Не удалось загрузить экран рынка из мониторинга.", - screen="market", - action="open_from_monitoring", - user_id=user_id, - chat_id=chat_id, - error_type=classify_exchange_error(exc), - raw_error=str(exc), - ) - - await show_callback_exchange_error( - callback, - title="📈 Рынок", - exc=exc, - network_details="Рыночные данные недоступны.\nОбнови экран.", - auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.", - retry_callback_data="market:retry", - ) - - -@router.callback_query(F.data == "market:retry") -async def retry_market( - callback: CallbackQuery, - state: FSMContext, -) -> None: - await state.clear() - - if not await _prepare_market_from_callback(callback): - return - - message = _require_message(callback) - - if message is None: - await callback.answer( - "Сообщение недоступно", - show_alert=True, - ) - return - - user_id = callback.from_user.id if callback.from_user else None - chat_id = message.chat.id - - try: - await _render_market_screen( - message, - user_id=user_id, - chat_id=chat_id, - edit_mode=True, - action="retry", - ) - - await callback.answer() - - except ExchangeError as exc: - JournalService().log_ui_error( - event_type="market_retry_error", - message="Не удалось обновить экран рынка.", - screen="market", - action="retry", - user_id=user_id, - chat_id=chat_id, - error_type=classify_exchange_error(exc), - raw_error=str(exc), - ) - - await show_callback_exchange_error( - callback, - title="📈 Рынок", - exc=exc, - network_details="Рыночные данные недоступны.\nОбнови экран.", - auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.", - retry_callback_data="market:retry", - ) \ No newline at end of file diff --git a/app/src/telegram/handlers/monitoring.py b/app/src/telegram/handlers/monitoring.py deleted file mode 100644 index 476c31a..0000000 --- a/app/src/telegram/handlers/monitoring.py +++ /dev/null @@ -1,171 +0,0 @@ -# app/src/telegram/handlers/monitoring.py - -from __future__ import annotations - -from aiogram import F, Router -from aiogram.fsm.context import FSMContext -from aiogram.types import ( - CallbackQuery, - InaccessibleMessage, - InlineKeyboardMarkup, - Message, -) -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from src.telegram.live.active_screen import ActiveScreenManager -from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen - - -router = Router(name="monitoring") - - -def _require_message( - callback: CallbackQuery, -) -> Message | None: - message = callback.message - - if ( - message is None - or isinstance(message, InaccessibleMessage) - ): - return None - - return message - - -def _monitoring_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="💼 Портфель", callback_data="monitoring:portfolio") - builder.button(text="📈 Рынок", callback_data="monitoring:market") - builder.button(text="📒 Журнал", callback_data="monitoring:journal") - builder.adjust(2, 1) - return builder.as_markup() - - -def _monitoring_text() -> str: - return ( - "📊 Мониторинг\n\n" - "Выберите раздел:" - ) - - -def _register_monitoring_screen(message: Message) -> None: - bot = message.bot - - if bot is None: - return - - LiveScreenRunner.unregister_message( - chat_id=message.chat.id, - message_id=message.message_id, - ) - - ScreenRegistry.unregister_message( - chat_id=message.chat.id, - message_id=message.message_id, - ) - - ScreenRegistry.register_screen( - StaticScreen( - screen="monitoring", - bot=bot, - chat_id=message.chat.id, - message_id=message.message_id, - ) - ) - - -async def _prepare_monitoring_from_message( - message: Message, -) -> bool: - bot = message.bot - - if bot is None: - return False - - await ActiveScreenManager.prepare_new_screen( - screen="monitoring", - bot=bot, - chat_id=message.chat.id, - ) - - return True - - -async def _prepare_monitoring_from_callback( - callback: CallbackQuery, -) -> bool: - message = _require_message(callback) - - if message is None: - await callback.answer("Сообщение недоступно", show_alert=True) - return False - - bot = message.bot - - if bot is None: - await callback.answer("Bot недоступен", show_alert=True) - return False - - await ActiveScreenManager.prepare_new_screen( - screen="monitoring", - bot=bot, - chat_id=message.chat.id, - keep_message_id=message.message_id, - ) - - return True - - -@router.message(F.text == "📊 Мониторинг") -async def open_monitoring( - message: Message, - state: FSMContext, -) -> None: - await state.clear() - - if not await _prepare_monitoring_from_message(message): - return - - sent_message = await message.answer( - _monitoring_text(), - reply_markup=_monitoring_keyboard(), - ) - - _register_monitoring_screen(sent_message) - - ActiveScreenManager.register( - screen="monitoring", - message=sent_message, - ) - - -@router.callback_query(F.data == "monitoring:home") -async def open_monitoring_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - await state.clear() - - if not await _prepare_monitoring_from_callback(callback): - return - - message = _require_message(callback) - - if message is None: - await callback.answer("Сообщение недоступно", show_alert=True) - return - - await message.edit_text( - _monitoring_text(), - reply_markup=_monitoring_keyboard(), - ) - - _register_monitoring_screen(message) - - ActiveScreenManager.register( - screen="monitoring", - message=message, - ) - - await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py index 8a39dc5..03aa52f 100644 --- a/app/src/telegram/handlers/portfolio.py +++ b/app/src/telegram/handlers/portfolio.py @@ -1,5 +1,4 @@ # app/src/telegram/handlers/portfolio.py - from __future__ import annotations from aiogram import F, Router @@ -13,10 +12,14 @@ from aiogram.types import ( from aiogram.utils.keyboard import InlineKeyboardBuilder from src.core.numbers import safe_float -from src.core.types import JsonDict, NumericLike -from src.integrations.exchange.exceptions import ExchangeError +from src.core.types import NumericLike from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.service import ExchangeService +from src.integrations.exchange.runtime_ui import ( + build_runtime_exchange_alerts, + format_runtime_exchange_alerts, +) +from src.integrations.exchange.status import classify_exchange_error from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry from src.telegram.ui.common import mode_line, now_line @@ -26,11 +29,6 @@ from src.telegram.ui.currency_ui import ( format_usd_amount, is_zero_balance, ) -from src.telegram.ui.exchange_error import ( - classify_exchange_error, - show_callback_exchange_error, - show_message_exchange_error, -) from src.trading.accounts.service import AccountsService from src.trading.journal.service import JournalService @@ -46,6 +44,7 @@ PINNED_ORDER = { } +# Получить доступное Telegram-сообщение из callback. def _require_message( callback: CallbackQuery, ) -> Message | None: @@ -60,10 +59,7 @@ def _require_message( return message -def _payload(**values: object) -> JsonDict: - return dict(values) - - +# Отформатировать количество актива компактно для портфеля. def _compact_amount(currency: str, value: NumericLike) -> str: number = safe_float(value) or 0.0 currency = currency.upper() @@ -90,21 +86,15 @@ def _compact_amount(currency: str, value: NumericLike) -> str: return f"{int(text):,}".replace(",", " ") -def _portfolio_keyboard() -> InlineKeyboardMarkup: +# Клавиатура портфеля при частичных данных или ошибке. +def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button(text="📊 К мониторингу", callback_data="monitoring:home") + builder.button(text="🔁 Обновить", callback_data="portfolio:retry") builder.adjust(1) return builder.as_markup() -def _portfolio_warning_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="🔁 Обновить", callback_data="portfolio:retry") - builder.button(text="📊 К мониторингу", callback_data="monitoring:home") - builder.adjust(1, 1) - return builder.as_markup() - - +# Отсортировать балансы: сначала основные активы, потом остальные по алфавиту. def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: def sort_key(item: BalanceSummary) -> tuple[int, str]: currency = item.currency.upper() @@ -114,11 +104,41 @@ def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]: return sorted(items, key=sort_key) -def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: +# Собрать текст ошибки портфеля через единый exchange status layer. +def _build_portfolio_exchange_error_text(exc: Exception) -> str: + alerts = build_runtime_exchange_alerts(exc=exc) + body = format_runtime_exchange_alerts(alerts) + + if not body: + body = "⛔️ Биржа недоступна" + + return ( + "💼 Портфель\n" + f"{mode_line()}" + f"{body}\n\n" + f"{now_line()}" + ) + + + +# Собрать live-текст портфеля. +# raise_errors=True используется при ручном открытии экрана, +# чтобы handler смог записать ошибку в журнал и показать стандартный error UI. +def _build_portfolio_live_text( + *, + raise_errors: bool = False, +) -> tuple[str, InlineKeyboardMarkup | None]: service = AccountsService() exchange_service = ExchangeService() - balances = service.get_live_balance_summary() + try: + balances = service.get_live_balance_summary() + + except Exception as exc: + if raise_errors: + raise + + return _build_portfolio_exchange_error_text(exc), _portfolio_warning_keyboard() if not balances: text = ( @@ -127,7 +147,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: "Нет данных по балансу.\n\n" f"{now_line()}" ) - return text, _portfolio_keyboard() + return text, None visible_balances = [ item @@ -143,7 +163,7 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: "Нет активов с балансом.\n\n" f"{now_line()}" ) - return text, _portfolio_keyboard() + return text, None price_cache: dict[str, float | None] = {} total_estimated_usd = 0.0 @@ -160,11 +180,15 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: currency = item.currency.upper() total = safe_float(balance_total(item)) or 0.0 locked = safe_float(item.locked) or 0.0 - estimated_usd = estimate_balance_usd( - item, - exchange_service, - price_cache, - ) + + try: + estimated_usd = estimate_balance_usd( + item, + exchange_service, + price_cache, + ) + except Exception: + estimated_usd = None if estimated_usd is not None: total_estimated_usd += estimated_usd @@ -205,18 +229,20 @@ def _build_portfolio_live_text() -> tuple[str, InlineKeyboardMarkup]: reply_markup = ( _portfolio_warning_keyboard() if has_partial_data - else _portfolio_keyboard() + else None ) return "\n".join(lines).rstrip(), reply_markup +# Render-функция текста для live runner. def _portfolio_live_text() -> str: text, _ = _build_portfolio_live_text() return text -def _portfolio_live_markup() -> InlineKeyboardMarkup: +# Render-функция клавиатуры для live runner. +def _portfolio_live_markup() -> InlineKeyboardMarkup | None: _, markup = _build_portfolio_live_text() return markup @@ -252,6 +278,7 @@ def _register_portfolio_live_screen(message: Message) -> None: LiveScreenRunner.start("portfolio") +# Подготовить новый экран портфеля из обычного сообщения. async def _prepare_portfolio_from_message( message: Message, ) -> bool: @@ -269,6 +296,7 @@ async def _prepare_portfolio_from_message( return True +# Подготовить экран портфеля из callback. async def _prepare_portfolio_from_callback( callback: CallbackQuery, ) -> bool: @@ -294,6 +322,7 @@ async def _prepare_portfolio_from_callback( return True +# Отрисовать экран портфеля и зарегистрировать live обновления. async def _render_portfolio_screen( target_message: Message, *, @@ -313,7 +342,7 @@ async def _render_portfolio_screen( chat_id=chat_id, ) - text, reply_markup = _build_portfolio_live_text() + text, reply_markup = _build_portfolio_live_text(raise_errors=True) journal.log_ui_info( event_type="portfolio_open_success", @@ -356,7 +385,8 @@ async def open_portfolio( edit_mode=False, action="open", ) - except ExchangeError as exc: + + except Exception as exc: JournalService().log_ui_error( event_type="portfolio_open_error", message="Не удалось загрузить портфель.", @@ -368,64 +398,14 @@ async def open_portfolio( raw_error=str(exc), ) - await show_message_exchange_error( - message, - title="💼 Портфель", - exc=exc, - network_details="Не загружен баланс аккаунта.\nОбнови экран.", - auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", - retry_callback_data="portfolio:retry", + sent_message = await message.answer( + _build_portfolio_exchange_error_text(exc), + reply_markup=_portfolio_warning_keyboard(), ) - -@router.callback_query(F.data == "monitoring:portfolio") -async def open_portfolio_from_monitoring( - callback: CallbackQuery, - state: FSMContext, -) -> None: - await state.clear() - - if not await _prepare_portfolio_from_callback(callback): - return - - message = _require_message(callback) - - if message is None: - await callback.answer("Сообщение недоступно", show_alert=True) - return - - user_id = callback.from_user.id if callback.from_user else None - chat_id = message.chat.id - - try: - await _render_portfolio_screen( - message, - user_id=user_id, - chat_id=chat_id, - edit_mode=True, - action="open_from_monitoring", - ) - await callback.answer() - - except ExchangeError as exc: - JournalService().log_ui_error( - event_type="portfolio_open_error", - message="Не удалось загрузить портфель из мониторинга.", + ActiveScreenManager.register( screen="portfolio", - action="open_from_monitoring", - user_id=user_id, - chat_id=chat_id, - error_type=classify_exchange_error(exc), - raw_error=str(exc), - ) - - await show_callback_exchange_error( - callback, - title="💼 Портфель", - exc=exc, - network_details="Не загружен баланс аккаунта.\nОбнови экран.", - auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", - retry_callback_data="portfolio:retry", + message=sent_message, ) @@ -458,7 +438,7 @@ async def retry_portfolio( ) await callback.answer() - except ExchangeError as exc: + except Exception as exc: JournalService().log_ui_error( event_type="portfolio_retry_error", message="Не удалось обновить портфель.", @@ -470,11 +450,14 @@ async def retry_portfolio( raw_error=str(exc), ) - await show_callback_exchange_error( - callback, - title="💼 Портфель", - exc=exc, - network_details="Не загружен баланс аккаунта.\nОбнови экран.", - auth_details="Не загружен баланс аккаунта.\nПроверь API ключи.", - retry_callback_data="portfolio:retry", - ) \ No newline at end of file + await message.edit_text( + _build_portfolio_exchange_error_text(exc), + reply_markup=_portfolio_warning_keyboard(), + ) + + ActiveScreenManager.register( + screen="portfolio", + message=message, + ) + + await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/start.py b/app/src/telegram/handlers/start.py index 865b656..ccc76db 100644 --- a/app/src/telegram/handlers/start.py +++ b/app/src/telegram/handlers/start.py @@ -8,13 +8,16 @@ from aiogram.fsm.context import FSMContext from aiogram.types import Message from src.core.system_status import build_system_text +from src.telegram.handlers.auto.main import render_auto_screen from src.telegram.keyboards.reply import build_main_menu_keyboard +from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.menus import MAIN_MENU_TEXT router = Router(name="start") +# показать только reply-меню без открытия live-экрана async def _show_main_menu( message: Message, ) -> None: @@ -24,6 +27,35 @@ async def _show_main_menu( ) +# открыть главный рабочий экран проекта — Автоторговлю +async def _open_auto_start_screen( + message: Message, +) -> None: + bot = message.bot + + if bot is None: + return + + # сначала прикрепляем основное reply-меню, + # потому что экран Автоторговли использует inline-кнопки + await message.answer( + "Открываю Автоторговлю.", + reply_markup=build_main_menu_keyboard(), + ) + + # очищаем предыдущий активный экран перед открытием Автоторговли + await ActiveScreenManager.prepare_new_screen( + screen="auto", + bot=bot, + chat_id=message.chat.id, + ) + + await render_auto_screen( + message, + edit_mode=False, + ) + + @router.message(Command("start")) async def cmd_start( message: Message, @@ -31,7 +63,7 @@ async def cmd_start( ) -> None: await state.clear() - await _show_main_menu(message) + await _open_auto_start_screen(message) @router.message(Command("menu")) diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py index 46384af..2913843 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -4,14 +4,14 @@ from __future__ import annotations from aiogram import F, Router from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, InaccessibleMessage +from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from src.core.numbers import safe_float -from src.core.types import JsonDict, NumericLike from src.core.config import load_settings from src.core.constants import APP_NAME, APP_VERSION +from src.core.numbers import safe_float from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts +from src.core.types import JsonDict, NumericLike from src.telegram.live.active_screen import ActiveScreenManager from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen from src.trading.auto.service import AutoTradeService @@ -21,15 +21,10 @@ from src.trading.journal.service import JournalService router = Router(name="system") -def _require_message( - callback: CallbackQuery, -) -> Message | None: +def _require_message(callback: CallbackQuery) -> Message | None: message = callback.message - if ( - message is None - or isinstance(message, InaccessibleMessage) - ): + if message is None or isinstance(message, InaccessibleMessage): return None return message @@ -38,8 +33,9 @@ def _require_message( def _system_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🛠️ Настройки", callback_data="system:management") + builder.button(text="📒 Журнал", callback_data="system:journal") builder.button(text="ℹ️ Информация", callback_data="system:about") - builder.adjust(2) + builder.adjust(2, 1) return builder.as_markup() @@ -47,8 +43,9 @@ def _system_alert_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="🔁 Обновить", callback_data="system:retry") builder.button(text="🛠️ Настройки", callback_data="system:management") + builder.button(text="📒 Журнал", callback_data="system:journal") builder.button(text="ℹ️ Информация", callback_data="system:about") - builder.adjust(1, 2) + builder.adjust(1, 2, 1) return builder.as_markup() @@ -82,7 +79,10 @@ def _register_system_screen(message: Message, screen: str = "system") -> None: ) -async def _prepare_system_from_message(message: Message, screen: str = "system") -> bool: +async def _prepare_system_from_message( + message: Message, + screen: str = "system", +) -> bool: bot = message.bot if bot is None: @@ -222,7 +222,7 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None: chat_id=chat_id, action="retry", ) - + await callback.answer() @@ -239,11 +239,10 @@ async def open_system_management(callback: CallbackQuery) -> None: builder = InlineKeyboardBuilder() builder.button(text="🤖 Автоторговля", callback_data="settings:auto") - builder.button(text="💹 Торговля", callback_data="settings:trade") builder.button(text="🌍 Общие", callback_data="settings:general") builder.button(text="📒 Журнал", callback_data="settings:journal") builder.button(text="⬅️ Назад", callback_data="system:back") - builder.adjust(2, 2, 1) + builder.adjust(1, 2, 1) message = _require_message(callback) @@ -275,11 +274,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None: leverage_ready = state.leverage is not None is_trend_strategy = (state.strategy or "").upper() == "TREND" - - sl_ready = ( - state.stop_loss_percent is not None - and state.stop_loss_percent > 0 - ) + sl_ready = state.stop_loss_percent is not None and state.stop_loss_percent > 0 is_configured = ( strategy_ready @@ -290,30 +285,14 @@ async def open_auto_settings(callback: CallbackQuery) -> None: ) strategy = strategy_map.get(state.strategy or "", "—") - symbol = "—" - - if state.symbol: - base = state.symbol.split("_", 1)[0].upper() - - if "/" in base: - symbol = base.split("/", 1)[0] - else: - for suffix in ("USDT", "USD", "EUR", "BTC"): - if base.endswith(suffix) and len(base) > len(suffix): - base = base[: -len(suffix)] - break - - symbol = base - + symbol = _human_symbol(state.symbol) risk = _format_number(state.risk_percent, suffix="%", default="—") leverage_value = safe_float(state.leverage) leverage = f"x{leverage_value:g}" if leverage_value is not None else "—" max_reserved = _format_percent_setting(state.max_reserved_balance_percent) - sl = _format_percent_setting(state.stop_loss_percent) - tp = _format_percent_setting(state.take_profit_percent) ml_value = safe_float(state.max_loss_usd) @@ -323,42 +302,12 @@ async def open_auto_settings(callback: CallbackQuery) -> None: symbol_icon = "✅" if symbol_ready else "⚠️" risk_icon = "✅" if risk_ready else "⚠️" leverage_icon = "✅" if leverage_ready else "⚠️" - - if is_trend_strategy and not sl_ready: - sl_icon = "⛔️" - else: - sl_icon = ( - "✅" - if state.stop_loss_percent is not None - else "⚠️" - ) - - tp_icon = ( - "✅" - if state.take_profit_percent is not None - else "⚠️" - ) - - ml_icon = ( - "✅" - if state.max_loss_usd is not None - else "⚠️" - ) - - risk_controls_block = ( - "Защита позиции:\n" - f"{sl_icon} Stop Loss · {sl}\n" - f"{tp_icon} Take Profit · {tp}\n" - f"{ml_icon} Max Loss · {ml}" - ) + sl_icon = "⛔️" if is_trend_strategy and not sl_ready else ("✅" if state.stop_loss_percent is not None else "⚠️") + tp_icon = "✅" if state.take_profit_percent is not None else "⚠️" + ml_icon = "✅" if state.max_loss_usd is not None else "⚠️" settings_status_icon = "✅" if is_configured else "⛔️" - - config_status = ( - "" - if is_configured - else "\n\nНастрой все параметры" - ) + config_status = "" if is_configured else "\n\nНастрой все параметры" text = ( "🤖 Автоторговля\n\n" @@ -368,52 +317,22 @@ async def open_auto_settings(callback: CallbackQuery) -> None: f"{risk_icon} Риск на сделку: {risk}\n" f"{leverage_icon} Плечо: {leverage}\n\n" f"✅ Лимит на сделку: {max_reserved}\n\n" - f"{risk_controls_block}" + "Защита позиции:\n" + f"{sl_icon} Stop Loss · {sl}\n" + f"{tp_icon} Take Profit · {tp}\n" + f"{ml_icon} Max Loss · {ml}" f"{config_status}" ) builder = InlineKeyboardBuilder() - - builder.button( - text="🧠 Стратегия", - callback_data="settings:auto_strategy", - ) - - builder.button( - text="💱 Актив", - callback_data="settings:auto_symbol", - ) - - builder.button( - text="⚙️ Плечо", - callback_data="settings:auto_leverage", - ) - - builder.button( - text="🏦 Лимит", - callback_data="settings:auto_max_reserved", - ) - - builder.button( - text="🛡️ Риск", - callback_data="settings:auto_risk", - ) - - builder.button( - text="🧯 Защита", - callback_data="auto:risk", - ) - - builder.button( - text="🤖 Автоторговля", - callback_data="auto:home", - ) - - builder.button( - text="⬅️ Назад", - callback_data="system:management", - ) - + builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy") + builder.button(text="💱 Актив", callback_data="settings:auto_symbol") + builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage") + builder.button(text="🏦 Лимит", callback_data="settings:auto_max_reserved") + builder.button(text="🛡️ Риск", callback_data="settings:auto_risk") + builder.button(text="🧯 Защита", callback_data="auto:risk") + builder.button(text="🤖 Автоторговля", callback_data="auto:home") + builder.button(text="⬅️ Назад", callback_data="system:management") builder.adjust(2, 2, 2, 2) message = _require_message(callback) @@ -422,16 +341,8 @@ async def open_auto_settings(callback: CallbackQuery) -> None: await callback.answer("Сообщение недоступно", show_alert=True) return - await message.edit_text( - text, - reply_markup=builder.as_markup(), - ) - - _register_system_screen( - message, - screen="settings_auto", - ) - + await message.edit_text(text, reply_markup=builder.as_markup()) + _register_system_screen(message, screen="settings_auto") await callback.answer() @@ -439,7 +350,7 @@ async def open_auto_settings(callback: CallbackQuery) -> None: async def open_auto_strategy_settings(callback: CallbackQuery) -> None: if not await _prepare_system_from_callback(callback, screen="settings_auto"): return - + message = _require_message(callback) if message is None: @@ -813,36 +724,6 @@ async def set_auto_max_reserved(callback: CallbackQuery) -> None: await callback.answer("Лимит обновлён") -@router.callback_query(F.data == "settings:trade") -async def open_trade_settings(callback: CallbackQuery) -> None: - if not await _prepare_system_from_callback(callback, screen="settings_trade"): - return - - message = _require_message(callback) - - if message is None: - await callback.answer("Сообщение недоступно", show_alert=True) - return - - text = ( - "💹 Торговля\n\n" - "СИСТЕМА · Настройки\n\n" - "Актив: —\n" - "Тип ордера по умолчанию: —\n" - "Пресеты количества: —\n\n" - "В разработке." - ) - - builder = InlineKeyboardBuilder() - builder.button(text="⬅️ Назад", callback_data="system:management") - builder.button(text="💹 Торговля", callback_data="trade:home") - builder.adjust(2) - - await message.edit_text(text, reply_markup=builder.as_markup()) - _register_system_screen(message, screen="settings_trade") - await callback.answer() - - @router.callback_query(F.data == "settings:general") async def open_general_settings(callback: CallbackQuery) -> None: if not await _prepare_system_from_callback(callback, screen="settings_general"): @@ -1001,10 +882,14 @@ async def open_journal_retention_settings(callback: CallbackQuery) -> None: await callback.answer() -@router.callback_query(F.data.in_({ - "settings:journal_limit_stub", - "settings:journal_retention_stub", -})) +@router.callback_query( + F.data.in_( + { + "settings:journal_limit_stub", + "settings:journal_retention_stub", + } + ) +) async def journal_settings_stub(callback: CallbackQuery) -> None: await callback.answer("Настройка скоро появится", show_alert=True) @@ -1030,6 +915,7 @@ async def back_to_system(callback: CallbackQuery) -> None: chat_id=chat_id, action="back", ) + await callback.answer() @@ -1064,7 +950,7 @@ async def open_system_about(callback: CallbackQuery) -> None: f"Режим: {'DEMO' if 'demo' in settings.exchange_base_url.lower() else 'LIVE'}\n" f"Часовой пояс: {settings.tz}\n\n" "Торговый Telegram-бот для контроля рынка, портфеля, журнала событий " - "и будущей автоторговли." + "и автоторговли." ) builder = InlineKeyboardBuilder() diff --git a/app/src/telegram/handlers/trade/__init__.py b/app/src/telegram/handlers/trade/__init__.py deleted file mode 100644 index d8df7b8..0000000 --- a/app/src/telegram/handlers/trade/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package marker.""" diff --git a/app/src/telegram/handlers/trade/main.py b/app/src/telegram/handlers/trade/main.py deleted file mode 100644 index 5130a5f..0000000 --- a/app/src/telegram/handlers/trade/main.py +++ /dev/null @@ -1,311 +0,0 @@ -# app/src/telegram/handlers/trade/main.py - -from __future__ import annotations - -from aiogram import F, Router -from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from src.telegram.handlers.trade.new_order import ( - show_recent_drafts, - start_new_order_draft, -) -from src.telegram.live.active_screen import ActiveScreenManager -from src.telegram.live.runner import LiveScreenRunner, ScreenRegistry, StaticScreen -from src.telegram.ui.common import mode_line - - -router = Router(name="trade_main") - - -def _trade_screen(title: str) -> str: - return ( - f"💹 Торговля — {title}\n" - f"{mode_line()}" - "Выбери раздел" - ) - - -def _trade_home_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="📝 Ордер", callback_data="trade:new_order") - builder.button(text="📂 Ордера", callback_data="trade:orders") - builder.button(text="📜 История", callback_data="trade:history") - builder.button(text="🛠️ Настройки", callback_data="settings:trade") - builder.adjust(2, 2) - return builder.as_markup() - - -def _trade_home_button() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="💹 К торговле", callback_data="trade:home") - return builder.as_markup() - - -def _orders_menu_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="📂 Черновики", callback_data="trade:orders:drafts") - builder.button(text="💹 К торговле", callback_data="trade:home") - builder.adjust(2) - return builder.as_markup() - - -def _history_menu_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="✅ Исполненные", callback_data="trade:history:filled") - builder.button(text="🚫 Отменённые", callback_data="trade:history:canceled") - builder.button(text="💹 К торговле", callback_data="trade:home") - builder.adjust(2, 1) - return builder.as_markup() - - -def _settings_menu_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="⚙️ Параметры", callback_data="trade:settings:params") - builder.button(text="🔁 Режим", callback_data="trade:settings:mode") - builder.button(text="ℹ️ Справка", callback_data="trade:settings:help") - builder.button(text="💹 К торговле", callback_data="trade:home") - builder.adjust(2, 2) - return builder.as_markup() - - -def _trade_home_text() -> str: - return _trade_screen("Основной экран") - - -def _trade_orders_text() -> str: - return _trade_screen("Ордера") - - -def _trade_history_text() -> str: - return _trade_screen("История") - - -def _trade_settings_text() -> str: - return _trade_screen("Настройки") - - -def _register_trade_screen(message: Message) -> None: - LiveScreenRunner.unregister_message( - chat_id=message.chat.id, - message_id=message.message_id, - ) - ScreenRegistry.unregister_message( - chat_id=message.chat.id, - message_id=message.message_id, - ) - - ScreenRegistry.register_screen( - StaticScreen( - screen="trade", - bot=message.bot, - chat_id=message.chat.id, - message_id=message.message_id, - ) - ) - - ActiveScreenManager.register( - screen="trade", - message=message, - ) - - -async def _prepare_trade_from_message(message: Message) -> None: - await ActiveScreenManager.prepare_new_screen( - screen="trade", - bot=message.bot, - chat_id=message.chat.id, - ) - - -async def _prepare_trade_from_callback(callback: CallbackQuery) -> bool: - if callback.message is None: - await callback.answer("Сообщение не найдено", show_alert=True) - return False - - await ActiveScreenManager.prepare_new_screen( - screen="trade", - bot=callback.message.bot, - chat_id=callback.message.chat.id, - keep_message_id=callback.message.message_id, - ) - - return True - - -@router.message(F.text.in_({"💹 Торговля"})) -async def open_trade(message: Message, state: FSMContext) -> None: - await state.clear() - await _prepare_trade_from_message(message) - - sent_message = await message.answer( - _trade_home_text(), - reply_markup=_trade_home_keyboard(), - ) - - _register_trade_screen(sent_message) - - -@router.callback_query(F.data == "trade:home") -async def open_trade_home_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - await state.clear() - - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - _trade_home_text(), - reply_markup=_trade_home_keyboard(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:new_order") -async def open_new_order_from_trade( - callback: CallbackQuery, - state: FSMContext, -) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await start_new_order_draft(callback.message, state, edit_mode=True) - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:orders") -async def open_orders_from_trade(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - _trade_orders_text(), - reply_markup=_orders_menu_keyboard(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:orders:drafts") -async def open_drafts_from_orders(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await show_recent_drafts(callback.message, edit_mode=True, page=1) - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:history") -async def open_trade_history(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - _trade_history_text(), - reply_markup=_history_menu_keyboard(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:history:filled") -async def open_filled_history(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - "💹 Торговля — История\n\n" - "Шаг 1/1: Исполненные\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:history:canceled") -async def open_canceled_history(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - "💹 Торговля — История\n\n" - "Шаг 1/1: Отменённые\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:settings") -async def open_trade_settings(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - _trade_settings_text(), - reply_markup=_settings_menu_keyboard(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:settings:params") -async def open_trade_settings_params(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - "💹 Торговля — Настройки\n\n" - "Шаг 1/1: Параметры ордера\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:settings:mode") -async def open_trade_settings_mode(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - "💹 Торговля — Настройки\n\n" - "Шаг 1/1: Режим работы\n" - "Текущий режим: demo", - reply_markup=_trade_home_button(), - ) - - _register_trade_screen(callback.message) - await callback.answer() - - -@router.callback_query(F.data == "trade:settings:help") -async def open_trade_settings_help(callback: CallbackQuery) -> None: - if not await _prepare_trade_from_callback(callback): - return - - await callback.message.edit_text( - "💹 Торговля — Справка\n\n" - "Шаг 1/1: Информация\n" - "Раздел в разработке.", - reply_markup=_trade_home_button(), - ) - - _register_trade_screen(callback.message) - await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order.py b/app/src/telegram/handlers/trade/new_order.py deleted file mode 100644 index 0301ff2..0000000 --- a/app/src/telegram/handlers/trade/new_order.py +++ /dev/null @@ -1,16 +0,0 @@ -# app/src/telegram/handlers/trade/new_order.py -# Точка сборки всех роутеров сценария нового ордера (flow, navigation, drafts). - -from __future__ import annotations - -from src.telegram.handlers.trade.new_order_core import router -from src.telegram.handlers.trade import new_order_navigation as _new_order_navigation # noqa -from src.telegram.handlers.trade import new_order_flow as _new_order_flow # noqa -from src.telegram.handlers.trade.new_order_flow import start_new_order_draft -from src.telegram.handlers.trade.new_order_ui import show_recent_drafts - -__all__ = [ - "router", - "show_recent_drafts", - "start_new_order_draft", -] \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_core.py b/app/src/telegram/handlers/trade/new_order_core.py deleted file mode 100644 index 11a5681..0000000 --- a/app/src/telegram/handlers/trade/new_order_core.py +++ /dev/null @@ -1,9 +0,0 @@ -# app/src/telegram/handlers/trade/new_order_core.py - -from __future__ import annotations - -from aiogram import Router - -router = Router(name="trade_new_order") - -DRAFTS_PAGE_SIZE = 3 \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_flow.py b/app/src/telegram/handlers/trade/new_order_flow.py deleted file mode 100644 index 36bd03f..0000000 --- a/app/src/telegram/handlers/trade/new_order_flow.py +++ /dev/null @@ -1,1299 +0,0 @@ -# app/src/telegram/handlers/trade/new_order_flow.py - -from __future__ import annotations - -from aiogram import F -from aiogram.filters import Command -from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, Message - -from src.integrations.exchange.exceptions import ExchangeError -from src.telegram.handlers.trade.new_order_core import router -from src.telegram.handlers.trade.new_order_ui import ( - _confirm_keyboard, - _draft_detail_keyboard, - _drafts_back_keyboard, - _price_keyboard, - _price_manual_keyboard, - _quantity_keyboard, - _quantity_manual_keyboard, - _render_confirm, - _render_draft_detail, - _render_draft_summary, - _render_manual_price_screen, - _render_manual_quantity_screen, - _render_order_path, - _render_price_inline_error, - _render_price_input_help, - _render_price_step_screen, - _render_quantity_inline_error, - _render_quantity_input_help, - _render_quantity_step_screen, - _render_validation_error, - _screen_title, - _side_keyboard, - _trade_back_home_keyboard, - _type_keyboard, - mode_line, - show_recent_drafts, -) -from src.telegram.ui.exchange_error import ( - classify_exchange_error, - show_callback_exchange_error, - show_message_exchange_error, -) -from src.trading.journal.service import JournalService -from src.trading.orders.service import OrderDraftsService -from src.trading.orders.states import NewOrderDraftStates - - -MAIN_MENU_BUTTONS = { - "🏠 Главная", - "📈 Рынок", - "💼 Портфель", - "💹 Торговля", - "🤖 Авто", - "📒 Журнал", - "🖥️ Система", - "Меню", -} - - -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 _user_id_from_callback(callback: CallbackQuery) -> int | None: - return callback.from_user.id if callback.from_user else None - - -def _chat_id_from_callback(callback: CallbackQuery) -> int | None: - if callback.message and callback.message.chat: - return callback.message.chat.id - return None - - -@router.callback_query(F.data == "drafts:noop") -async def drafts_noop(callback: CallbackQuery) -> None: - await callback.answer() - - -@router.callback_query(F.data.startswith("drafts:")) -async def paginate_drafts(callback: CallbackQuery) -> None: - value = callback.data.split(":", 1)[1] - if value == "noop": - await callback.answer() - return - - page = int(value) - await callback.answer() - - if callback.message is not None: - JournalService().log_ui_info( - event_type="trade_drafts_paginate", - message="Открыта страница черновиков.", - screen="trade", - action="drafts_paginate", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"page": page}, - ) - await show_recent_drafts(callback.message, edit_mode=True, page=page) - - -@router.callback_query(F.data.startswith("draft_open:")) -async def open_draft(callback: CallbackQuery) -> None: - service = OrderDraftsService() - _, draft_id, page_raw = callback.data.split(":", 2) - page = int(page_raw) - - draft = service.get_draft_by_id(draft_id) - if not draft: - JournalService().log_ui_warning( - event_type="trade_draft_open_not_found", - message="Черновик не найден.", - screen="trade", - action="draft_open", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"draft_id": draft_id, "page": page}, - ) - await callback.answer("Черновик не найден", show_alert=True) - return - - JournalService().log_ui_info( - event_type="trade_draft_open_success", - message="Черновик открыт.", - screen="trade", - action="draft_open", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"draft_id": draft_id, "page": page}, - ) - - await callback.message.edit_text( - _render_draft_detail(draft), - reply_markup=_draft_detail_keyboard(draft_id, page), - ) - await callback.answer() - - -@router.callback_query(F.data.startswith("draft_edit:")) -async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None: - service = OrderDraftsService() - journal = JournalService() - - _, draft_id, page_raw = callback.data.split(":", 2) - page = int(page_raw) - - draft = service.get_draft_by_id(draft_id) - if not draft: - journal.log_ui_warning( - event_type="trade_draft_edit_not_found", - message="Черновик не найден.", - screen="trade", - action="draft_edit", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"draft_id": draft_id, "page": page}, - ) - await callback.answer("Черновик не найден", show_alert=True) - return - - side = str(draft["side"]).upper() - order_type = str(draft["order_type"]).upper() - quantity = str(draft["quantity"]) - price = str(draft.get("price") or "") or None - - await state.clear() - await state.update_data( - draft_edit_id=draft_id, - draft_edit_page=page, - side=side, - order_type=order_type, - quantity=quantity, - price=price, - ) - - try: - title = _screen_title(is_edit_mode=True) - context = service.get_entry_context(side=side, order_type=order_type) - - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( - title=title, - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=page, - ), - ) - - journal.log_ui_info( - event_type="trade_draft_edit_requested", - message="Запрошено редактирование черновика.", - screen="trade", - action="draft_edit", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={ - "draft_id": draft_id, - "page": page, - "side": side, - "order_type": order_type, - }, - ) - await callback.answer() - except (ExchangeError, ValueError) as exc: - journal.log_ui_error( - event_type="trade_draft_edit_error", - message="Не удалось открыть редактирование черновика.", - screen="trade", - action="draft_edit", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - error_type=classify_exchange_error(exc) if isinstance(exc, ExchangeError) else "generic", - raw_error=str(exc), - payload={"draft_id": draft_id, "page": page}, - ) - await show_callback_exchange_error( - callback, - title=_screen_title(is_edit_mode=True), - exc=exc, - retry_callback_data=callback.data, - back_callback_data=f"draft_open:{draft_id}:{page}", - drafts_page=page, - ) - - -@router.callback_query(F.data.startswith("draft_delete:")) -async def delete_draft_stub(callback: CallbackQuery) -> None: - JournalService().log_ui_info( - event_type="trade_draft_delete_requested", - message="Запрошено удаление черновика.", - screen="trade", - action="draft_delete", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"callback_data": callback.data}, - ) - await callback.answer("Удаление скоро появится") - - -@router.message(Command("cancel_order")) -async def cancel_order_builder(message: Message, state: FSMContext) -> None: - await state.clear() - - JournalService().log_ui_info( - event_type="trade_order_create_cancelled", - message="Создание черновика ордера отменено.", - screen="trade", - action="order_cancel", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - ) - - await message.answer( - "💹 Торговля — Новый ордер\n" - f"{mode_line()}" - "⛔ Создание черновика отменено", - reply_markup=_trade_back_home_keyboard(), - ) - - -@router.message(Command("new_order")) -async def start_new_order_draft( - message: Message, - state: FSMContext, - edit_mode: bool = False, -) -> None: - await state.clear() - await state.set_state(NewOrderDraftStates.waiting_side) - - service = OrderDraftsService() - journal = JournalService() - - try: - context = service.get_entry_context(side="BUY", order_type="MARKET") - - text = ( - "💹 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - "Шаг 1/4. Выбери сторону" - ) - - journal.log_ui_info( - event_type="trade_order_create_requested", - message="Запрошено создание черновика ордера.", - screen="trade", - action="order_create", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - payload={"symbol": context.symbol}, - ) - - if edit_mode: - await message.edit_text(text, reply_markup=_side_keyboard()) - else: - await message.answer(text, reply_markup=_side_keyboard()) - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_create_error", - message="Не удалось открыть создание черновика ордера.", - screen="trade", - action="order_create", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - ) - await show_message_exchange_error( - message, - title="💹 Торговля — Новый ордер", - exc=exc, - retry_callback_data="trade:new_order_retry", - ) - - -@router.callback_query( - NewOrderDraftStates.waiting_side, - F.data.startswith("order_side:"), -) -async def process_order_side_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - side = callback.data.split(":", 1)[1] - await state.update_data(side=side) - await state.set_state(NewOrderDraftStates.waiting_type) - - path = _render_order_path(side=side) - service = OrderDraftsService() - journal = JournalService() - - try: - context = service.get_entry_context(side=side, order_type="MARKET") - - text = ( - "💹 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - f"{path}\n\n" - "Шаг 2/4. Выбери тип ордера" - ) - - journal.log_ui_info( - event_type="trade_order_side_selected", - message="Выбрана сторона ордера.", - screen="trade", - action="order_side", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"side": side, "symbol": context.symbol}, - ) - - await callback.message.edit_text(text, reply_markup=_type_keyboard()) - await callback.answer() - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_side_error", - message="Не удалось обработать выбор стороны ордера.", - screen="trade", - action="order_side", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={"side": side}, - ) - await show_callback_exchange_error( - callback, - title="💹 Торговля — Новый ордер", - exc=exc, - retry_callback_data=callback.data, - ) - - -@router.message( - NewOrderDraftStates.waiting_side, - ~F.text.in_(MAIN_MENU_BUTTONS), -) -async def process_order_side_text(message: Message) -> None: - await message.answer( - "Пожалуйста, используйте кнопки для выбора стороны.", - reply_markup=_side_keyboard(), - ) - - -@router.callback_query( - NewOrderDraftStates.waiting_type, - F.data.startswith("order_type:"), -) -async def process_order_type_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - journal = JournalService() - order_type = callback.data.split(":", 1)[1] - - data = await state.get_data() - side = data.get("side", "BUY") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - await state.update_data(order_type=order_type) - await state.set_state(NewOrderDraftStates.waiting_quantity) - - try: - context = service.get_entry_context(side=side, order_type=order_type) - - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ) - - journal.log_ui_info( - event_type="trade_order_type_selected", - message="Пользователь выбрал тип ордера.", - screen="trade", - action="order_select_type", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={ - "side": side, - "order_type": order_type, - "symbol": context.symbol, - "is_edit_mode": is_edit_mode, - }, - ) - - await callback.message.edit_text( - _render_quantity_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_type_select_error", - message="Не удалось обработать выбор типа ордера.", - screen="trade", - action="order_select_type", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={ - "side": side, - "order_type": order_type, - "is_edit_mode": is_edit_mode, - }, - ) - await show_callback_exchange_error( - callback, - title=_screen_title(is_edit_mode), - exc=exc, - retry_callback_data=callback.data, - drafts_page=drafts_page, - ) - - -@router.message( - NewOrderDraftStates.waiting_type, - ~F.text.in_(MAIN_MENU_BUTTONS), -) -async def process_order_type_text(message: Message) -> None: - await message.answer( - "Пожалуйста, используйте кнопки для выбора типа ордера.", - reply_markup=_type_keyboard(), - ) - - -@router.callback_query( - NewOrderDraftStates.waiting_quantity, - F.data.startswith("order_qty:"), -) -@router.callback_query( - NewOrderDraftStates.waiting_quantity, - F.data.startswith("order_qty:"), -) -async def process_quantity_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - journal = JournalService() - value = callback.data.split(":", 1)[1] - - data = await state.get_data() - is_edit_mode = bool(data.get("draft_edit_id")) - title = _screen_title(is_edit_mode) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - side = data.get("side", "BUY") - order_type = data.get("order_type", "MARKET") - - try: - context = service.get_entry_context(side=side, order_type=order_type) - - if value == "manual": - rules = service.get_entry_rules() - quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" - - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ) - - journal.log_ui_info( - event_type="trade_order_quantity_manual_open", - message="Открыт ручной ввод количества.", - screen="trade", - action="order_quantity_manual", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={ - "side": side, - "order_type": order_type, - "is_edit_mode": is_edit_mode, - }, - ) - - await callback.message.edit_text( - _render_manual_quantity_screen( - title=title, - symbol=context.symbol, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - min_qty=rules["min_qty"], - step_size=rules["step_size"], - min_notional=rules["min_notional"], - example=quantity_example, - order_path=path, - ), - reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), - ) - await callback.answer() - return - - quantity = service.normalize_preset_quantity( - side=side, - order_type=order_type, - raw_quantity=value, - ) - if quantity is None: - await callback.answer("Некорректное значение количества.", show_alert=True) - return - - await state.update_data(quantity=quantity) - - journal.log_ui_info( - event_type="trade_order_quantity_selected", - message="Выбрано количество ордера.", - screen="trade", - action="order_quantity", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={ - "side": side, - "order_type": order_type, - "quantity": quantity, - "is_edit_mode": is_edit_mode, - }, - ) - - if order_type == "LIMIT": - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_price) - await callback.message.edit_text( - _render_price_step_screen( - title=title, - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, - ), - ) - await callback.answer() - return - - draft = service.build_draft( - side=side, - order_type=order_type, - quantity=quantity, - ) - - notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "reference_price": f"{context.reference_price:.2f}", - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - - await state.set_state(NewOrderDraftStates.waiting_confirm) - - await callback.message.edit_text( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - reference_price=f"{context.reference_price:.2f}", - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) - await callback.answer() - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_quantity_error", - message="Не удалось обработать выбор количества ордера.", - screen="trade", - action="order_quantity", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={ - "side": side, - "order_type": order_type, - "value": value, - "is_edit_mode": is_edit_mode, - }, - ) - await show_callback_exchange_error( - callback, - title=title, - exc=exc, - retry_callback_data=callback.data, - drafts_page=drafts_page, - ) - - -@router.message( - NewOrderDraftStates.waiting_quantity, - ~F.text.in_(MAIN_MENU_BUTTONS), -) -async def process_order_quantity(message: Message, state: FSMContext) -> None: - service = OrderDraftsService() - journal = JournalService() - raw_quantity = message.text or "" - - data = await state.get_data() - side = data.get("side", "BUY") - order_type = data.get("order_type", "MARKET") - is_edit_mode = bool(data.get("draft_edit_id")) - title = _screen_title(is_edit_mode) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - quantity = service.normalize_entry_quantity( - side=side, - order_type=order_type, - raw_quantity=raw_quantity, - ) - - context = service.get_entry_context(side=side, order_type=order_type) - rules = service.get_entry_rules() - quantity_example = context.quantity_presets[0] if context.quantity_presets else "0.001" - - help_text = _render_quantity_input_help( - min_qty=rules["min_qty"], - step_size=rules["step_size"], - min_notional=rules["min_notional"], - price=context.reference_price, - quote_currency=context.quote_currency, - example=quantity_example, - ) - - if quantity is None: - await message.answer( - _render_quantity_inline_error( - title=title, - symbol=context.symbol, - order_path=_render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ), - errors=["Количество должно быть числом больше нуля."], - help_text=help_text, - ), - reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), - ) - return - - quantity_errors = service.validate_entry_quantity( - side=side, - order_type=order_type, - quantity=quantity, - price=None, - ) - if quantity_errors: - await message.answer( - _render_quantity_inline_error( - title=title, - symbol=context.symbol, - order_path=_render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ), - errors=quantity_errors, - help_text=help_text, - ), - reply_markup=_quantity_manual_keyboard(drafts_page=drafts_page), - ) - return - - await state.update_data(quantity=quantity) - - journal.log_ui_info( - event_type="trade_order_quantity_manual_success", - message="Количество ордера введено вручную.", - screen="trade", - action="order_quantity_manual", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - payload={ - "side": side, - "order_type": order_type, - "quantity": quantity, - "is_edit_mode": is_edit_mode, - }, - ) - - if order_type == "LIMIT": - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_price) - await message.answer( - _render_price_step_screen( - title=title, - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, - ), - ) - return - - draft = service.build_draft( - side=side, - order_type=order_type, - quantity=quantity, - ) - notional = service.calculate_notional(quantity, f"{context.reference_price:.2f}") - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "reference_price": f"{context.reference_price:.2f}", - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - await state.set_state(NewOrderDraftStates.waiting_confirm) - - await message.answer( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - reference_price=f"{context.reference_price:.2f}", - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_quantity_manual_error", - message="Не удалось обработать ручной ввод количества.", - screen="trade", - action="order_quantity_manual", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={ - "side": side, - "order_type": order_type, - "raw_quantity": raw_quantity, - "is_edit_mode": is_edit_mode, - }, - ) - await show_message_exchange_error( - message, - title=title, - exc=exc, - drafts_page=drafts_page, - ) - - -@router.callback_query( - NewOrderDraftStates.waiting_price, - F.data.startswith("order_price:"), -) -async def process_price_callback( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - journal = JournalService() - value = callback.data.split(":", 1)[1] - - data = await state.get_data() - is_edit_mode = bool(data.get("draft_edit_id")) - title = _screen_title(is_edit_mode) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - context = service.get_entry_context( - side=data.get("side", "BUY"), - order_type=data.get("order_type", "LIMIT"), - ) - - if value == "manual": - rules = service.get_entry_rules() - price_example = f"{context.last_price:.2f}" - - path = _render_order_path( - side=data.get("side"), - order_type=data.get("order_type"), - quantity=data.get("quantity"), - base_currency=context.base_currency, - ) - - journal.log_ui_info( - event_type="trade_order_price_manual_open", - message="Открыт ручной ввод цены.", - screen="trade", - action="order_price_manual", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={"is_edit_mode": is_edit_mode}, - ) - - await callback.message.edit_text( - _render_manual_price_screen( - title=title, - symbol=context.symbol, - tick_size=rules["tick_size"], - example=price_example, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_manual_keyboard(drafts_page=drafts_page), - ) - await callback.answer() - return - - price = service.normalize_price(value) - if price is None: - await callback.answer("Некорректная цена.", show_alert=True) - return - - draft = service.build_draft( - side=data["side"], - order_type=data["order_type"], - quantity=data["quantity"], - price=price, - ) - - notional = service.calculate_notional(data["quantity"], price) - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - - await state.set_state(NewOrderDraftStates.waiting_confirm) - - journal.log_ui_info( - event_type="trade_order_price_selected", - message="Выбрана цена ордера.", - screen="trade", - action="order_price", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={ - "price": price, - "is_edit_mode": is_edit_mode, - }, - ) - - await callback.message.edit_text( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) - await callback.answer() - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_price_error", - message="Не удалось обработать выбор цены ордера.", - screen="trade", - action="order_price", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={ - "value": value, - "is_edit_mode": is_edit_mode, - }, - ) - await show_callback_exchange_error( - callback, - title=title, - exc=exc, - retry_callback_data=callback.data, - drafts_page=drafts_page, - ) - - -@router.message( - NewOrderDraftStates.waiting_price, - ~F.text.in_(MAIN_MENU_BUTTONS), -) -async def process_order_price(message: Message, state: FSMContext) -> None: - service = OrderDraftsService() - journal = JournalService() - raw_price = message.text or "" - price = service.normalize_price(raw_price) - - data = await state.get_data() - is_edit_mode = bool(data.get("draft_edit_id")) - title = _screen_title(is_edit_mode) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - rules = service.get_entry_rules() - context = service.get_entry_context( - side=data.get("side", "BUY"), - order_type=data.get("order_type", "LIMIT"), - ) - price_example = f"{context.last_price:.2f}" - help_text = _render_price_input_help( - tick_size=rules["tick_size"], - example=price_example, - quote_currency=context.quote_currency, - ) - - if price is None: - await message.answer( - _render_price_inline_error( - title=title, - symbol=context.symbol, - order_path=_render_order_path( - side=data.get("side"), - order_type=data.get("order_type"), - quantity=data.get("quantity"), - base_currency=context.base_currency, - ), - errors=["Цена должна быть числом больше нуля."], - help_text=help_text, - ), - reply_markup=_price_manual_keyboard(drafts_page=drafts_page), - ) - return - - draft = service.build_draft( - side=data["side"], - order_type=data["order_type"], - quantity=data["quantity"], - price=price, - ) - - validation = service.validate_draft(draft) - if not validation.is_valid: - await message.answer( - _render_price_inline_error( - title=title, - symbol=context.symbol, - order_path=_render_order_path( - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - base_currency=context.base_currency, - ), - errors=validation.errors, - help_text=help_text, - ), - reply_markup=_price_manual_keyboard(drafts_page=drafts_page), - ) - return - - notional = service.calculate_notional(data["quantity"], price) - - await state.update_data( - confirm_draft={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "base_currency": context.base_currency, - "quote_currency": context.quote_currency, - "notional": notional, - } - ) - await state.set_state(NewOrderDraftStates.waiting_confirm) - - journal.log_ui_info( - event_type="trade_order_price_manual_success", - message="Цена ордера введена вручную.", - screen="trade", - action="order_price_manual", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - payload={ - "price": price, - "is_edit_mode": is_edit_mode, - }, - ) - - await message.answer( - _render_confirm( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - notional=notional, - is_edit_mode=is_edit_mode, - base_currency=context.base_currency, - quote_currency=context.quote_currency, - ), - reply_markup=_confirm_keyboard(drafts_page=drafts_page), - ) - except ExchangeError as exc: - journal.log_ui_error( - event_type="trade_order_price_manual_error", - message="Не удалось обработать ручной ввод цены.", - screen="trade", - action="order_price_manual", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={ - "raw_price": raw_price, - "is_edit_mode": is_edit_mode, - }, - ) - await show_message_exchange_error( - message, - title=title, - exc=exc, - drafts_page=drafts_page, - ) - - -@router.message(Command("drafts")) -async def drafts_command(message: Message) -> None: - JournalService().log_ui_info( - event_type="trade_drafts_open_requested", - message="Запрошено открытие списка черновиков.", - screen="trade", - action="drafts_open", - user_id=_user_id_from_message(message), - chat_id=_chat_id_from_message(message), - ) - await show_recent_drafts(message, edit_mode=False, page=1) - - -@router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm") -async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None: - service = OrderDraftsService() - journal = JournalService() - data = await state.get_data() - - raw = data.get("confirm_draft") - if not raw: - await state.clear() - journal.log_ui_warning( - event_type="trade_order_confirm_state_error", - message="Состояние подтверждения черновика не найдено.", - screen="trade", - action="order_confirm", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - ) - await callback.answer("Ошибка состояния", show_alert=True) - return - - reference_price = raw.get("reference_price") - base_currency = raw.get("base_currency") - quote_currency = raw.get("quote_currency") - notional = raw.get("notional") - - draft = service.build_draft( - side=raw["side"], - order_type=raw["order_type"], - quantity=raw["quantity"], - price=raw.get("price"), - ) - - try: - service.save_draft(draft) - except ValueError as exc: - edit_page = data.get("draft_edit_page") - await state.clear() - errors = [item.strip() for item in str(exc).split(";") if item.strip()] - - journal.log_ui_warning( - event_type="trade_order_confirm_validation_error", - message="Черновик не прошёл проверку при сохранении.", - screen="trade", - action="order_confirm", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - raw_error=str(exc), - payload={ - "errors": errors, - "edit_page": edit_page, - }, - ) - - reply_markup = ( - _drafts_back_keyboard(int(edit_page)) - if edit_page - else _trade_back_home_keyboard() - ) - await callback.message.edit_text( - _render_validation_error(errors), - reply_markup=reply_markup, - ) - await callback.answer() - return - except ExchangeError as exc: - await state.clear() - - journal.log_ui_error( - event_type="trade_order_confirm_error", - message="Не удалось сохранить черновик ордера.", - screen="trade", - action="order_confirm", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - error_type=classify_exchange_error(exc), - raw_error=str(exc), - payload={"draft_edit_page": data.get("draft_edit_page")}, - ) - - await show_callback_exchange_error( - callback, - title="💹 Торговля — Подтверждение черновика", - exc=exc, - retry_callback_data=callback.data, - drafts_page=data.get("draft_edit_page"), - ) - return - - edit_page = data.get("draft_edit_page") - await state.clear() - - journal.log_ui_info( - event_type="trade_order_confirm_success", - message="Черновик ордера сохранён.", - screen="trade", - action="order_confirm", - user_id=_user_id_from_callback(callback), - chat_id=_chat_id_from_callback(callback), - payload={ - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "is_edit_mode": bool(edit_page), - }, - ) - - reply_markup = ( - _drafts_back_keyboard(int(edit_page)) - if edit_page - else _trade_back_home_keyboard() - ) - - await callback.message.edit_text( - _render_draft_summary( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - price=draft.price, - base_currency=base_currency, - quote_currency=quote_currency, - reference_price=reference_price, - notional=notional, - is_edit_mode=bool(edit_page), - ), - reply_markup=reply_markup, - ) - await callback.answer() \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_navigation.py b/app/src/telegram/handlers/trade/new_order_navigation.py deleted file mode 100644 index 1adf374..0000000 --- a/app/src/telegram/handlers/trade/new_order_navigation.py +++ /dev/null @@ -1,401 +0,0 @@ -# app/src/telegram/handlers/trade/new_order_navigation.py - -from __future__ import annotations - -from aiogram import F -from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery - -from src.integrations.exchange.exceptions import ExchangeError, format_exchange_error_for_user -from src.telegram.handlers.trade.new_order_core import router -from src.telegram.handlers.trade.new_order_ui import ( - _draft_detail_keyboard, - _price_keyboard, - _quantity_keyboard, - _render_draft_detail, - _render_exchange_error, - _render_order_path, - _render_price_step_screen, - _render_quantity_step_screen, - _screen_title, - _side_keyboard, - _trade_back_home_keyboard, - _type_keyboard, - mode_line, -) -from src.trading.orders.service import OrderDraftsService -from src.trading.orders.states import NewOrderDraftStates - - -async def _return_to_draft_detail( - callback: CallbackQuery, - *, - draft_id: str, - page: int, -) -> None: - service = OrderDraftsService() - draft = service.get_draft_by_id(draft_id) - - if not draft: - await callback.message.edit_text( - "💹 Торговля\n\n" - "Черновик не найден.", - reply_markup=_trade_back_home_keyboard(), - ) - await callback.answer() - return - - await callback.message.edit_text( - _render_draft_detail(draft), - reply_markup=_draft_detail_keyboard(draft_id, page), - ) - await callback.answer() - - -async def _show_navigation_exchange_error( - callback: CallbackQuery, - *, - title: str, - exc: Exception, - draft_page: int | None = None, -) -> None: - reply_markup = ( - _draft_detail_keyboard("", draft_page) # won't use if branch below replaces - if False - else None - ) - - if draft_page: - keyboard = _draft_detail_keyboard("noop", draft_page) - # заменим клавиатуру сразу на корректную - # edit/detail тут не нужны, нужен простой возврат к черновикам - from src.telegram.handlers.trade.new_order_ui import _drafts_back_keyboard - - reply_markup = _drafts_back_keyboard(int(draft_page)) - else: - reply_markup = _trade_back_home_keyboard() - - await callback.message.edit_text( - _render_exchange_error( - title=title, - message=format_exchange_error_for_user(exc), - ), - reply_markup=reply_markup, - ) - await callback.answer() - - -@router.callback_query(F.data == "order_back:side") -async def go_back_to_side(callback: CallbackQuery, state: FSMContext) -> None: - service = OrderDraftsService() - - try: - context = service.get_entry_context(side="BUY", order_type="MARKET") - - await state.set_state(NewOrderDraftStates.waiting_side) - text = ( - "💹 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - "Шаг 1/4. Выбери сторону" - ) - await callback.message.edit_text(text, reply_markup=_side_keyboard()) - await callback.answer() - except ExchangeError as exc: - await _show_navigation_exchange_error( - callback, - title="💹 Торговля — Новый ордер", - exc=exc, - ) - - -@router.callback_query(F.data == "order_back:type") -async def go_back_to_type(callback: CallbackQuery, state: FSMContext) -> None: - data = await state.get_data() - - draft_id = data.get("draft_edit_id") - draft_page = data.get("draft_edit_page") - - if draft_id and draft_page: - await _return_to_draft_detail( - callback, - draft_id=str(draft_id), - page=int(draft_page), - ) - return - - service = OrderDraftsService() - side = data.get("side", "BUY") - - try: - context = service.get_entry_context(side=side, order_type="MARKET") - path = _render_order_path(side=side) - - await state.set_state(NewOrderDraftStates.waiting_type) - text = ( - "💹 Торговля — Новый ордер\n" - f"{mode_line()}" - f"{context.symbol}\n\n" - f"{path}\n\n" - "Шаг 2/4. Выбери тип ордера" - ) - await callback.message.edit_text(text, reply_markup=_type_keyboard()) - await callback.answer() - except ExchangeError as exc: - await _show_navigation_exchange_error( - callback, - title="💹 Торговля — Новый ордер", - exc=exc, - ) - - -@router.callback_query(F.data == "order_back:quantity") -async def go_back_to_quantity(callback: CallbackQuery, state: FSMContext) -> None: - service = OrderDraftsService() - data = await state.get_data() - - side = data.get("side", "BUY") - order_type = data.get("order_type", "MARKET") - quantity = data.get("quantity") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - context = service.get_entry_context(side=side, order_type=order_type) - - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - if not quantity: - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() - except ExchangeError as exc: - await _show_navigation_exchange_error( - callback, - title=_screen_title(is_edit_mode), - exc=exc, - draft_page=drafts_page, - ) - - -@router.callback_query(F.data == "order_back:confirm") -async def go_back_from_confirm(callback: CallbackQuery, state: FSMContext) -> None: - service = OrderDraftsService() - data = await state.get_data() - - confirm_draft = data.get("confirm_draft") - if not confirm_draft: - await state.clear() - await callback.message.edit_text( - "💹 Торговля\n\n" - "Не удалось восстановить шаг подтверждения.", - reply_markup=_trade_back_home_keyboard(), - ) - await callback.answer() - return - - side = confirm_draft["side"] - order_type = confirm_draft["order_type"] - quantity = confirm_draft.get("quantity") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - if order_type == "LIMIT": - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_price) - await callback.message.edit_text( - _render_price_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, - ), - ) - await callback.answer() - return - - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() - except ExchangeError as exc: - await _show_navigation_exchange_error( - callback, - title=_screen_title(is_edit_mode), - exc=exc, - draft_page=drafts_page, - ) - - -@router.callback_query(F.data == "order_manual_back:quantity") -async def go_back_from_manual_quantity( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - data = await state.get_data() - - side = data.get("side", "BUY") - order_type = data.get("order_type", "MARKET") - quantity = data.get("quantity") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - if not quantity: - path = _render_order_path( - side=side, - order_type=order_type, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_quantity) - await callback.message.edit_text( - _render_quantity_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - available_balance=context.available_balance, - balance_currency=context.balance_currency, - reference_price=context.reference_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_quantity_keyboard( - context.quantity_presets, - drafts_page=drafts_page, - ), - ) - await callback.answer() - except ExchangeError as exc: - await _show_navigation_exchange_error( - callback, - title=_screen_title(is_edit_mode), - exc=exc, - draft_page=drafts_page, - ) - - -@router.callback_query(F.data == "order_manual_back:price") -async def go_back_from_manual_price( - callback: CallbackQuery, - state: FSMContext, -) -> None: - service = OrderDraftsService() - data = await state.get_data() - - side = data.get("side", "BUY") - order_type = data.get("order_type", "LIMIT") - quantity = data.get("quantity") - is_edit_mode = bool(data.get("draft_edit_id")) - draft_page = data.get("draft_edit_page") - drafts_page = int(draft_page) if draft_page else None - - try: - context = service.get_entry_context(side=side, order_type=order_type) - path = _render_order_path( - side=side, - order_type=order_type, - quantity=quantity, - base_currency=context.base_currency, - ) - - await state.set_state(NewOrderDraftStates.waiting_price) - await callback.message.edit_text( - _render_price_step_screen( - title=_screen_title(is_edit_mode), - symbol=context.symbol, - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - quote_currency=context.quote_currency, - order_path=path, - ), - reply_markup=_price_keyboard( - bid=context.bid_price, - ask=context.ask_price, - last=context.last_price, - drafts_page=drafts_page, - ), - ) - await callback.answer() - except ExchangeError as exc: - await _show_navigation_exchange_error( - callback, - title=_screen_title(is_edit_mode), - exc=exc, - draft_page=drafts_page, - ) \ No newline at end of file diff --git a/app/src/telegram/handlers/trade/new_order_ui.py b/app/src/telegram/handlers/trade/new_order_ui.py deleted file mode 100644 index 1e0e833..0000000 --- a/app/src/telegram/handlers/trade/new_order_ui.py +++ /dev/null @@ -1,1034 +0,0 @@ -# app/src/telegram/handlers/trade/new_order_ui.py - -from __future__ import annotations - -from datetime import datetime -from decimal import Decimal, InvalidOperation, ROUND_UP -from zoneinfo import ZoneInfo - -from aiogram.types import InlineKeyboardMarkup, Message -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from src.telegram.handlers.trade.new_order_core import DRAFTS_PAGE_SIZE -from src.telegram.ui.common import mode_line -from src.trading.orders.service import OrderDraftsService -from src.integrations.exchange.exceptions import ( - ExchangeConnectionError, - ExchangeError, - ExchangeResponseError, -) - - -def _clean_number(value: str | float | None, precision: int | None = None) -> str: - if value is None: - return "" - - try: - num = float(value) - except (ValueError, TypeError): - return str(value) - - if precision is not None: - text = f"{num:.{precision}f}" - return text.rstrip("0").rstrip(".") - - text = f"{num:.18f}" - return text.rstrip("0").rstrip(".") - - -def _resolve_symbol_assets(symbol: str) -> tuple[str | None, str | None]: - try: - service = OrderDraftsService() - validation = service.exchange.validate_symbol(symbol) - symbol_info = validation.symbol_info - - if symbol_info is None: - return None, None - - base_currency = ( - str(symbol_info.base_asset).upper() - if getattr(symbol_info, "base_asset", None) - else None - ) - quote_currency = ( - str(symbol_info.quote_asset).upper() - if getattr(symbol_info, "quote_asset", None) - else None - ) - - return base_currency, quote_currency - except Exception: - return None, None - - -def _to_decimal(value: str | float | int | None) -> Decimal | None: - if value is None: - return None - try: - return Decimal(str(value).strip()) - except (InvalidOperation, ValueError): - return None - - -def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal: - if step <= 0: - return value - ratio = (value / step).to_integral_value(rounding=ROUND_UP) - return ratio * step - - -def _format_decimal_text(value: Decimal) -> str: - text = f"{value:.8f}" - text = text.rstrip("0").rstrip(".") - return text or "0" - - -def _side_badge(side: str) -> str: - return "🟢 BUY" if side.upper() == "BUY" else "🔴 SELL" - - -def _describe_exchange_error(exc: Exception) -> str: - text = str(exc).strip() - - if isinstance(exc, ExchangeResponseError) and ( - "-1021" in text or "doesn't match server time" in text - ): - return ( - "Не удалось получить данные биржи: время на устройстве " - "не синхронизировано со временем биржи. " - "Проверь системное время и повтори попытку." - ) - - if isinstance(exc, ExchangeConnectionError): - return ( - "Не удалось получить данные биржи: таймаут или ошибка сети. " - "Попробуй ещё раз через несколько секунд." - ) - - if isinstance(exc, ExchangeResponseError): - return ( - "Не удалось получить данные биржи: биржа вернула некорректный ответ. " - "Попробуй ещё раз через несколько секунд." - ) - - if isinstance(exc, ExchangeError): - return text or "Не удалось получить данные биржи." - - return text or "Не удалось получить данные биржи." - - -def _render_exchange_error( - *, - title: str, - exc: Exception, -) -> str: - lines = [ - title, - mode_line().rstrip(), - "", - "⚠️ Данные биржи временно недоступны", - "", - _describe_exchange_error(exc), - ] - return "\n".join(lines) - - -# Оценивает минимально допустимое количество по правилу minNotional. -def _estimate_min_quantity_by_notional( - *, - reference_price: float | None, - min_notional: str | None, - step_size: str | None, -) -> str | None: - ref = _to_decimal(reference_price) - notional = _to_decimal(min_notional) - step = _to_decimal(step_size) - - if ref is None or ref <= 0 or notional is None or notional <= 0: - return None - - raw_qty = notional / ref - - if step is not None and step > 0: - raw_qty = _ceil_to_step(raw_qty, step) - - return _format_decimal_text(raw_qty) - - -def _side_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="🟢 BUY", callback_data="order_side:BUY") - builder.button(text="🔴 SELL", callback_data="order_side:SELL") - builder.button(text="💹 К торговле", callback_data="trade:home") - builder.adjust(2, 1) - return builder.as_markup() - - -def _type_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="⚡ MARKET", callback_data="order_type:MARKET") - builder.button(text="🎯 LIMIT", callback_data="order_type:LIMIT") - builder.button(text="⬅️ Назад", callback_data="order_back:side") - builder.button(text="💹 К торговле", callback_data="trade:home") - builder.adjust(2, 2) - return builder.as_markup() - - -def _quantity_keyboard( - presets: list[str], - drafts_page: int | None = None, -) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - - all_labels = ["1%", "5%", "10%", "25%", "50%", "100%"] - labels = all_labels[: len(presets)] - - for label, value in zip(labels, presets): - builder.button(text=label, callback_data=f"order_qty:{value}") - - builder.button(text="✍️ Ввести вручную", callback_data="order_qty:manual") - builder.button(text="⬅️ Назад", callback_data="order_back:type") - - if drafts_page is not None: - builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") - else: - builder.button(text="💹 К торговле", callback_data="trade:home") - - if len(presets) == 0: - builder.adjust(1, 2) - elif len(presets) <= 4: - builder.adjust(2, 2, 1, 2) - elif len(presets) == 5: - builder.adjust(3, 2, 1, 2) - else: - builder.adjust(3, 3, 1, 2) - - return builder.as_markup() - - -def _quantity_manual_keyboard( - drafts_page: int | None = None, -) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="⬅️ Назад", callback_data="order_manual_back:quantity") - - if drafts_page is not None: - builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") - else: - builder.button(text="💹 К торговле", callback_data="trade:home") - - builder.adjust(2) - return builder.as_markup() - - -def _price_keyboard( - bid: float, - ask: float, - last: float, - drafts_page: int | None = None, -) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text=f"Bid {bid:.2f}", callback_data=f"order_price:{bid}") - builder.button(text=f"Ask {ask:.2f}", callback_data=f"order_price:{ask}") - builder.button(text=f"Last {last:.2f}", callback_data=f"order_price:{last}") - builder.button(text="✍️ Ввести вручную", callback_data="order_price:manual") - builder.button(text="⬅️ Назад", callback_data="order_back:quantity") - - if drafts_page is not None: - builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") - else: - builder.button(text="💹 К торговле", callback_data="trade:home") - - builder.adjust(2, 2, 2) - return builder.as_markup() - - -def _price_manual_keyboard( - drafts_page: int | None = None, -) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="⬅️ Назад", callback_data="order_manual_back:price") - - if drafts_page is not None: - builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") - else: - builder.button(text="💹 К торговле", callback_data="trade:home") - - builder.adjust(2) - return builder.as_markup() - - -def _confirm_keyboard( - drafts_page: int | None = None, -) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="✅ Подтвердить", callback_data="order_confirm") - builder.button(text="⬅️ Назад", callback_data="order_back:confirm") - - if drafts_page is not None: - builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") - else: - builder.button(text="💹 К торговле", callback_data="trade:home") - - builder.adjust(1, 2) - return builder.as_markup() - - -def _trade_back_home_keyboard() -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="💹 К торговле", callback_data="trade:home") - return builder.as_markup() - - -def _drafts_back_keyboard(page: int) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="📚 К черновикам", callback_data=f"drafts:{page}") - return builder.as_markup() - - -def _drafts_pagination_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - - if page > 1: - builder.button(text="⏮️", callback_data="drafts:1") - builder.button(text="⬅️", callback_data=f"drafts:{page - 1}") - - builder.button(text=f"{page}/{total_pages}", callback_data="drafts:noop") - - if page < total_pages: - builder.button(text="➡️", callback_data=f"drafts:{page + 1}") - - first_row_count = 1 - if page > 1: - first_row_count += 2 - if page < total_pages: - first_row_count += 1 - - builder.button(text="💹 К торговле", callback_data="trade:home") - builder.adjust(first_row_count, 1) - - return builder.as_markup() - - -def _draft_detail_keyboard(draft_id: str, page: int) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - builder.button(text="✏️ Редактировать", callback_data=f"draft_edit:{draft_id}:{page}") - builder.button(text="🗑 Удалить", callback_data=f"draft_delete:{draft_id}") - builder.button(text="📚 К черновикам", callback_data=f"drafts:{page}") - builder.adjust(2, 1) - return builder.as_markup() - - -def _exchange_error_keyboard( - *, - back_callback_data: str | None = None, - drafts_page: int | None = None, -) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - - # Кнопка "Назад" (если есть куда возвращаться) - if back_callback_data: - builder.button(text="⬅️ Назад", callback_data=back_callback_data) - - # Кнопка "К черновикам" (если мы в edit flow) - if drafts_page is not None: - builder.button(text="📚 К черновикам", callback_data=f"drafts:{drafts_page}") - else: - builder.button(text="💹 К торговле", callback_data="trade:home") - - builder.adjust(2 if back_callback_data else 1) - return builder.as_markup() - - -def _format_value_with_currency( - value: str | float | None, - currency: str | None, -) -> str | None: - if value is None: - return None - - text = _clean_number(value, precision=2) - if not text: - return None - - return f"{text} {currency}" if currency else text - - -def _format_value_with_asset( - value: str | float | None, - asset: str | None, -) -> str | None: - if value is None: - return None - - text = _clean_number(value) - if not text: - return None - - return f"{text} {asset}" if asset else text - - -def _render_draft_summary( - symbol: str, - side: str, - order_type: str, - quantity: str, - price: str | None, - base_currency: str | None = None, - quote_currency: str | None = None, - reference_price: str | None = None, - notional: float | None = None, - is_edit_mode: bool = False, -) -> str: - quantity_text = _format_value_with_asset(quantity, base_currency) - side_line = _side_badge(side) - order_type_text = order_type.upper() - success_text = "✅ Черновик изменён" if is_edit_mode else "✅ Черновик создан" - - lines = [ - "💹 Торговля — Черновик ордера", - mode_line().rstrip(), - "", - f"{symbol}", - "", - f"{side_line} · {order_type_text} · {quantity_text or quantity}", - ] - - if price: - price_text = _format_value_with_currency(price, quote_currency) - lines.append(f"Цена: {price_text or price}") - elif reference_price: - reference_price_text = _format_value_with_currency(reference_price, quote_currency) - lines.append(f"Цена: {reference_price_text or reference_price}") - - if notional is not None: - notional_text = _format_value_with_currency(notional, quote_currency) - lines.append(f"Notional: {notional_text or str(notional)}") - - lines.extend( - [ - "", - "Статус: draft", - "", - success_text, - "", - "Ордер не отправлялся на биржу", - ] - ) - - return "\n".join(lines) - - -def _render_confirm( - symbol: str, - side: str, - order_type: str, - quantity: str, - price: str | None, - notional: float | None, - is_edit_mode: bool = False, - base_currency: str | None = None, - quote_currency: str | None = None, - reference_price: str | None = None, - order_path: str | None = None, -) -> str: - quantity_text = _format_value_with_asset(quantity, base_currency) - side_line = _side_badge(side) - order_type_text = order_type.upper() - - lines = [ - _screen_title(is_edit_mode), - mode_line().rstrip(), - "", - f"{symbol}", - "", - f"{side_line} · {order_type_text} · {quantity_text or quantity}", - ] - - if price: - price_text = _format_value_with_currency(price, quote_currency) - lines.append(f"Цена: {price_text or price}") - elif reference_price: - reference_price_text = _format_value_with_currency(reference_price, quote_currency) - lines.append(f"Цена: {reference_price_text or reference_price}") - - if notional is not None: - notional_text = _format_value_with_currency(notional, quote_currency) - lines.append(f"Notional: {notional_text or str(notional)}") - - lines.extend( - [ - "", - "Шаг 4/4. Подтверди черновик", - ] - ) - - return "\n".join(lines) - - -def _render_validation_error(errors: list[str]) -> str: - lines = [ - "💹 Торговля — Ошибка валидации", - mode_line().rstrip(), - "Шаг 4/4. Проверь параметры черновика", - "", - "❌ Черновик не сохранён", - "", - ] - for item in errors: - lines.append(f"• {item}") - return "\n".join(lines) - - -def _render_inline_error( - title: str, - step_text: str, - errors: list[str], - help_text: str | None = None, -) -> str: - lines = [ - title, - mode_line().rstrip(), - step_text, - "", - "⚠️ Найдены ошибки", - "", - ] - - for item in errors: - lines.append(f"• {item}") - - if help_text: - lines.extend(["", help_text]) - - return "\n".join(lines) - - -# Формирует блок правил ручного ввода количества. -def _render_quantity_input_help( - *, - min_qty: str | None, - step_size: str | None, - min_notional: str | None, - price: float | None, - quote_currency: str | None, - example: str, -) -> str: - lines = [ - "👉 Правила ввода количества", - "", - ] - - min_quantity = None - estimated_notional = None - - try: - if min_notional and price: - min_from_notional = float(min_notional) / float(price) - min_quantity = max(float(min_qty or 0), min_from_notional) - elif min_qty: - min_quantity = float(min_qty) - except Exception: - min_quantity = float(min_qty) if min_qty else None - - if min_quantity and step_size: - step = float(step_size) - min_quantity = (int(min_quantity / step + 0.9999999)) * step - - if min_quantity and price: - estimated_notional = min_quantity * price - - currency = quote_currency or "?" - - if min_quantity: - qty_text = f"{min_quantity:.6f}".rstrip("0").rstrip(".") - lines.append(f"• минимум для ввода: {qty_text}") - - if step_size: - lines.append(f"• шаг: {step_size}") - - if estimated_notional: - lines.append(f"≈ сумма: {estimated_notional:.2f} {currency}") - - lines.extend( - [ - "", - f"👉 Пример: {example}", - ] - ) - - return "\n".join(lines) - - -# Формирует блок правил ручного ввода цены. -def _render_price_input_help( - *, - tick_size: str | None, - example: str, - quote_currency: str | None = None, -) -> str: - currency = quote_currency or "?" - lines = [ - "👉 Правила ввода цены", - "", - ] - - if tick_size: - lines.append(f"• шаг цены: {tick_size} {currency}") - - lines.extend(["", f"Пример: {example} {currency}"]) - return "\n".join(lines) - - -# Рендерит экран детального просмотра черновика. -def _render_draft_detail( - draft: dict[str, str], - base_currency: str | None = None, - quote_currency: str | None = None, -) -> str: - quantity = draft["quantity"] - created_at = _format_draft_time(draft["created_at"]) - - if base_currency is None or quote_currency is None: - resolved_base, resolved_quote = _resolve_symbol_assets(str(draft["symbol"])) - base_currency = base_currency or resolved_base - quote_currency = quote_currency or resolved_quote - - quantity_text = _format_value_with_asset(quantity, base_currency) - price_text = None - if draft.get("price"): - price_text = _format_value_with_currency(draft["price"], quote_currency) - - side_line = _side_badge(str(draft["side"])) - order_type = str(draft["order_type"]).upper() - - lines = [ - "💹 Торговля — Черновик", - mode_line().rstrip(), - "", - f"{draft['symbol']}", - "", - f"{side_line} · {order_type} · {quantity_text or str(quantity)}", - ] - - if price_text: - lines.append(f"Цена: {price_text}") - - lines.extend( - [ - f"Статус: {draft['status']}", - f"Время: {created_at}", - ] - ) - - return "\n".join(lines) - - -def _format_draft_time(value: str) -> str: - try: - dt = datetime.fromisoformat(str(value)) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo("UTC")) - local_dt = dt.astimezone(ZoneInfo("Europe/Minsk")) - return local_dt.strftime("%Y-%m-%d %H:%M:%S") - except Exception: - return str(value) - - -def _format_draft_quantity(value: str) -> str: - text = str(value).rstrip("0").rstrip(".") - return text or "0" - - -def _screen_title(is_edit_mode: bool) -> str: - if is_edit_mode: - return "💹 Торговля — Редактирование черновика" - return "💹 Торговля — Новый ордер" - - -# Рендерит экран выбора количества. -def _render_quantity_step_screen( - *, - title: str, - symbol: str, - available_balance: float, - balance_currency: str, - reference_price: float, - quote_currency: str, - order_path: str | None = None, -) -> str: - lines = [ - title, - mode_line().rstrip(), - "", - f"{symbol}", - "", - ] - - if order_path: - lines.append(order_path) - lines.append("") - - lines.extend( - [ - f"Доступно: {available_balance:.8f} {balance_currency}", - f"Ориентир цены: {reference_price:.2f} {quote_currency}", - "", - "Шаг 3/4. Выбери количество", - ] - ) - - return "\n".join(lines) - - -def _render_quantity_inline_error( - *, - title: str, - symbol: str, - order_path: str, - errors: list[str], - help_text: str, -) -> str: - lines = [ - title, - mode_line().rstrip(), - "", - f"{symbol}", - "", - order_path, - "", - "⚠️ Найдены ошибки", - "", - ] - - for item in errors: - lines.append(f"• {item}") - - lines.extend( - [ - "", - help_text, - "", - "Шаг 3/4. Проверь введённое значение", - ] - ) - - return "\n".join(lines) - - -# Рендерит экран выбора цены. -def _render_price_step_screen( - *, - title: str, - bid: float, - ask: float, - last: float, - quote_currency: str, - symbol: str, - order_path: str | None = None, -) -> str: - return ( - f"{title}\n" - f"{mode_line()}" - f"{symbol}\n\n" - f"{order_path + '\n' if order_path else ''}" - f"Bid: {bid:.2f} {quote_currency}\n" - f"Ask: {ask:.2f} {quote_currency}\n" - f"Last: {last:.2f} {quote_currency}\n\n" - "Шаг 4/4. Выбери цену" - ) - - -def _render_price_inline_error( - *, - title: str, - symbol: str, - order_path: str, - errors: list[str], - help_text: str, -) -> str: - lines = [ - title, - mode_line().rstrip(), - "", - f"{symbol}", - "", - order_path, - "", - "⚠️ Найдены ошибки", - "", - ] - - for item in errors: - lines.append(f"• {item}") - - lines.extend( - [ - "", - help_text, - "", - "Шаг 4/4. Проверь введённое значение", - ] - ) - - return "\n".join(lines) - - -# Рендерит экран ручного ввода количества. -def _render_manual_quantity_screen( - *, - title: str, - symbol: str, - reference_price: float | None, - quote_currency: str | None, - min_qty: str | None, - step_size: str | None, - min_notional: str | None, - example: str, - order_path: str | None = None, -) -> str: - estimated_min_qty = _estimate_min_quantity_by_notional( - reference_price=reference_price, - min_notional=min_notional, - step_size=step_size, - ) - - estimated_notional = None - if estimated_min_qty is not None and reference_price is not None and reference_price > 0: - try: - estimated_notional = float(estimated_min_qty) * float(reference_price) - except Exception: - estimated_notional = None - - currency = quote_currency or "?" - - lines = [ - title, - mode_line().rstrip(), - symbol, - "", - ] - - if order_path: - lines.extend([order_path, ""]) - - if reference_price is not None and reference_price > 0: - lines.extend( - [ - f"Ориентир цены: {reference_price:.2f} {currency}", - "", - ] - ) - - lines.extend( - [ - "👉 Правила ввода количества", - "", - ] - ) - - if estimated_min_qty: - lines.append(f"• минимум для ввода: {estimated_min_qty}") - elif min_qty: - lines.append(f"• минимум для ввода: {min_qty}") - - if step_size: - lines.append(f"• шаг: {step_size}") - - if estimated_notional is not None: - lines.append(f"≈ сумма: {estimated_notional:.2f} {currency}") - - lines.extend( - [ - "", - f"👉 Пример: {example}", - "", - "Шаг 3/4. Введи количество", - ] - ) - - return "\n".join(lines) - - -# Рендерит экран ручного ввода цены. -def _render_manual_price_screen( - *, - title: str, - symbol: str, - tick_size: str | None, - example: str, - quote_currency: str | None, - order_path: str | None = None, -) -> str: - lines = [ - title, - mode_line().rstrip(), - f"{symbol}", - "", - ] - - if order_path: - lines.append(order_path) - lines.append("") - - lines.append( - _render_price_input_help( - tick_size=tick_size, - example=example, - quote_currency=quote_currency, - ) - ) - - lines.extend( - [ - "", - "Шаг 4/4. Введи цену", - ] - ) - - return "\n".join(lines) - - -# Показывает компактный список последних черновиков с пагинацией. -async def show_recent_drafts( - message: Message, - edit_mode: bool = False, - page: int = 1, -) -> None: - service = OrderDraftsService() - all_drafts = service.list_recent_drafts(limit=100) - - total = len(all_drafts) - total_pages = max(1, (total + DRAFTS_PAGE_SIZE - 1) // DRAFTS_PAGE_SIZE) - page = max(1, min(page, total_pages)) - - start = (page - 1) * DRAFTS_PAGE_SIZE - end = start + DRAFTS_PAGE_SIZE - drafts = all_drafts[start:end] - - if not drafts: - text = ( - "💹 Торговля — Черновики\n" - f"{mode_line()}" - "Список пуст\n\n" - "Черновиков пока нет." - ) - if edit_mode: - await message.edit_text(text, reply_markup=_trade_back_home_keyboard()) - else: - await message.answer(text, reply_markup=_trade_back_home_keyboard()) - return - - lines = [ - "💹 Торговля — Черновики", - mode_line().rstrip(), - "", - ] - - details_builder = InlineKeyboardBuilder() - - for local_idx, item in enumerate(drafts, start=1): - global_idx = start + local_idx - - quantity = _format_draft_quantity(item["quantity"]) - created_at = _format_draft_time(item["created_at"]) - - base_currency, _quote_currency = _resolve_symbol_assets(str(item["symbol"])) - - quantity_text = _format_value_with_asset(quantity, base_currency) - side_line = _side_badge(str(item["side"])) - order_type = str(item["order_type"]).upper() - - time_short = created_at[11:16] if len(created_at) >= 16 else created_at - - lines.append( - f"{global_idx}. {side_line} · {order_type} · " - f"{quantity_text or quantity} · {time_short}" - ) - - details_builder.button( - text=str(global_idx), - callback_data=f"draft_open:{item['id']}:{page}", - ) - - details_builder.adjust(3) - - pagination_markup = _drafts_pagination_keyboard(page, total_pages) - details_builder.attach(InlineKeyboardBuilder.from_markup(pagination_markup)) - - text = "\n".join(lines).rstrip() - keyboard = details_builder.as_markup() - - if edit_mode: - await message.edit_text(text, reply_markup=keyboard) - else: - await message.answer(text, reply_markup=keyboard) - - -# функция формирования “пути ордера” -def _render_order_path( - *, - side: str | None = None, - order_type: str | None = None, - quantity: str | None = None, - price: str | None = None, - base_currency: str | None = None, - quote_currency: str | None = None, -) -> str: - parts: list[str] = [] - - if side: - side_emoji = "🟢" if side.upper() == "BUY" else "🔴" - parts.append(f"{side_emoji} {side.upper()}") - - if order_type: - parts.append(order_type.upper()) - - if quantity: - quantity_text = _format_value_with_asset(quantity, base_currency) - parts.append(quantity_text or str(quantity)) - - if price: - price_text = _format_value_with_currency(price, quote_currency) - parts.append(price_text or str(price)) - - if not parts: - return "" - - return " · ".join(parts) - - -def _render_order_card( - *, - symbol: str, - side: str, - order_type: str, - quantity: str, - price: str | None, - notional: float | None, - base_currency: str | None, - quote_currency: str | None, -) -> list[str]: - side_emoji = "🟢" if side == "BUY" else "🔴" - - quantity_text = _format_value_with_asset(quantity, base_currency) - price_text = _format_value_with_currency(price, quote_currency) if price else None - notional_text = ( - _format_value_with_currency(notional, quote_currency) - if notional is not None - else None - ) - - lines = [ - f"{symbol}", - "", - f"{side_emoji} {side} · {order_type} · {quantity_text or quantity}", - ] - - if price_text: - lines.append(f"Цена: {price_text}") - - if notional_text: - lines.append(f"Notional: {notional_text}") - - return lines \ No newline at end of file diff --git a/app/src/telegram/keyboards/reply.py b/app/src/telegram/keyboards/reply.py index 837ac62..4d582e1 100644 --- a/app/src/telegram/keyboards/reply.py +++ b/app/src/telegram/keyboards/reply.py @@ -8,10 +8,9 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup: keyboard=[ [ KeyboardButton(text="🤖 Автоторговля"), - KeyboardButton(text="💹 Торговля"), + KeyboardButton(text="💼 Портфель"), ], [ - KeyboardButton(text="📊 Мониторинг"), KeyboardButton(text="🖥️ Система"), ], ], diff --git a/app/src/telegram/menus.py b/app/src/telegram/menus.py index 1220ced..251a5d4 100644 --- a/app/src/telegram/menus.py +++ b/app/src/telegram/menus.py @@ -2,31 +2,48 @@ MAIN_MENU_TEXT = ( "Dzentra Bot\n\n" - "Новый каркас проекта успешно создан.\n\n" - "Выбери раздел через меню ниже." + "Trading Runtime Terminal\n\n" + "Доступные разделы:\n" + "• Автоторговля\n" + "• Портфель\n" + "• Система" ) + HOME_TEXT = ( "🏠 Главная\n\n" - "Это главный экран бота.\n\n" - "Сейчас здесь отображается базовый статус:\n" - "- бот запущен\n" - "- меню подключено\n" - "- handlers работают\n" - "- проект на этапе Bootstrap v2\n" + "Главное меню Dzentra Bot.\n\n" + "Используй кнопки ниже для перехода в нужный раздел." ) + SYSTEM_TEXT = ( "🖥️ Система\n\n" - "Системный экран.\n\n" - "Справка\n" + "Системный runtime экран.\n\n" + "Разделы\n" + "• Настройки\n" + "• Журнал\n" + "• Информация\n\n" + "Команды\n" "/start — запуск\n" - "/menu — показать меню\n" - "/help — краткая справка\n" + "/menu — главное меню\n" + "/help — системная информация\n" ) -MARKET_TEXT = "📈 Рынок\n\nРаздел пока в разработке." -PORTFOLIO_TEXT = "💼 Портфель\n\nРаздел пока в разработке." -TRADE_TEXT = "💹 Торговля\n\nВыберите действие:\nDRAFT режим" -AUTO_TEXT = "🤖 Авто\n\nРаздел пока в разработке." -JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." \ No newline at end of file + +PORTFOLIO_TEXT = ( + "💼 Портфель\n\n" + "Просмотр активов и баланса биржи." +) + + +AUTO_TEXT = ( + "🤖 Автоторговля\n\n" + "Runtime экран автоторговли." +) + + +JOURNAL_TEXT = ( + "📒 Журнал\n\n" + "Runtime события и execution logs." +) \ No newline at end of file diff --git a/app/src/telegram/routers.py b/app/src/telegram/routers.py index f248933..6c0d7d4 100644 --- a/app/src/telegram/routers.py +++ b/app/src/telegram/routers.py @@ -7,23 +7,15 @@ from src.telegram.handlers.debug import router as debug_router from src.telegram.handlers.debug_auto.main import router as debug_auto_router from src.telegram.handlers.home import router as home_router from src.telegram.handlers.journal import router as journal_router -from src.telegram.handlers.market import router as market_router -from src.telegram.handlers.monitoring import router as monitoring_router from src.telegram.handlers.portfolio import router as portfolio_router from src.telegram.handlers.start import router as start_router from src.telegram.handlers.system import router as system_router -from src.telegram.handlers.trade.main import router as trade_main_router -from src.telegram.handlers.trade.new_order import router as trade_new_order_router def setup_routers(dispatcher: Dispatcher) -> None: dispatcher.include_router(start_router) dispatcher.include_router(home_router) - dispatcher.include_router(monitoring_router) - dispatcher.include_router(market_router) dispatcher.include_router(portfolio_router) - dispatcher.include_router(trade_main_router) - dispatcher.include_router(trade_new_order_router) dispatcher.include_router(auto_router) dispatcher.include_router(journal_router) dispatcher.include_router(debug_auto_router) diff --git a/app/src/telegram/ui/exchange_error.py b/app/src/telegram/ui/exchange_error.py index b7ceb14..a440c18 100644 --- a/app/src/telegram/ui/exchange_error.py +++ b/app/src/telegram/ui/exchange_error.py @@ -5,8 +5,15 @@ from __future__ import annotations from dataclasses import dataclass from aiogram.exceptions import TelegramBadRequest -from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message +from aiogram.types import ( + CallbackQuery, + InaccessibleMessage, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, +) +from src.integrations.exchange.runtime_ui import build_exchange_error_ui_parts from src.telegram.ui.common import mode_line, now_line @@ -17,7 +24,10 @@ class ExchangeErrorView: DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран." -DEFAULT_AUTH_DETAILS = "Не удалось выполнить запрос к аккаунту.\nПроверь API ключи." +DEFAULT_AUTH_DETAILS = ( + "Не удалось выполнить приватный запрос к аккаунту.\n" + "Проверь API-ключ, Secret Key, IP whitelist и права доступа." +) DEFAULT_TIME_DETAILS = ( "Не удалось выполнить запрос к бирже.\n" "Проверь синхронизацию времени и обнови экран." @@ -25,43 +35,7 @@ DEFAULT_TIME_DETAILS = ( DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран." -def classify_exchange_error(exc: Exception) -> str: - text = str(exc).lower() - - network_markers = [ - "nodename nor servname", - "name or service not known", - "network error", - "connection error", - "timed out", - "timeout", - ] - if any(marker in text for marker in network_markers): - return "network" - - time_markers = [ - "-1021", - "doesn't match server time", - ] - if any(marker in text for marker in time_markers): - return "time" - - auth_markers = [ - "invalid api key", - "api key", - "api-key", - "signature", - "expired", - "forbidden", - "unauthorized", - "private api error", - ] - if any(marker in text for marker in auth_markers): - return "auth" - - return "generic" - - +# Собрать UI-представление ошибки через единый exchange status layer. def _build_exchange_error_view( *, exc: Exception, @@ -70,32 +44,15 @@ def _build_exchange_error_view( time_details: str | None = None, generic_details: str | None = None, ) -> ExchangeErrorView: - error_type = classify_exchange_error(exc) - - if error_type == "network": - return ExchangeErrorView( - headline="🔴 Биржа недоступна", - details=network_details, - ) - - if error_type == "auth": - return ExchangeErrorView( - headline="🔴 Ошибка доступа к аккаунту", - details=auth_details, - ) - - if error_type == "time": - return ExchangeErrorView( - headline="🔴 Ошибка времени", - details=time_details or DEFAULT_TIME_DETAILS, - ) + _, headline, details = build_exchange_error_ui_parts(exc) return ExchangeErrorView( - headline="🔴 Ошибка биржи", - details=generic_details or DEFAULT_GENERIC_DETAILS, + headline=headline, + details=details or generic_details or DEFAULT_GENERIC_DETAILS, ) +# Отрисовать единый текст ошибки биржи для message/callback экранов. def render_exchange_error( *, title: str, @@ -122,6 +79,7 @@ def render_exchange_error( ) +# Собрать клавиатуру для экранов с ошибкой биржи. def exchange_error_keyboard( *, retry_callback_data: str | None = None, @@ -129,7 +87,6 @@ def exchange_error_keyboard( drafts_page: int | None = None, ) -> InlineKeyboardMarkup: buttons: list[list[InlineKeyboardButton]] = [] - first_row: list[InlineKeyboardButton] = [] if retry_callback_data: @@ -160,12 +117,12 @@ def exchange_error_keyboard( ) ] ) - else: + elif back_callback_data is None: buttons.append( [ InlineKeyboardButton( - text="🏠 К торговле", - callback_data="trade:home", + text="🤖 К автоторговле", + callback_data="auto:home", ) ] ) @@ -173,6 +130,7 @@ def exchange_error_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +# Показать ошибку биржи через редактирование callback-сообщения. async def show_callback_exchange_error( callback: CallbackQuery, *, @@ -186,7 +144,9 @@ async def show_callback_exchange_error( back_callback_data: str | None = None, drafts_page: int | None = None, ) -> None: - if callback.message is None: + message = callback.message + + if message is None or isinstance(message, InaccessibleMessage): await callback.answer("Сообщение не найдено", show_alert=True) return @@ -198,6 +158,7 @@ async def show_callback_exchange_error( time_details=time_details, generic_details=generic_details, ) + markup = exchange_error_keyboard( retry_callback_data=retry_callback_data or callback.data, back_callback_data=back_callback_data, @@ -205,15 +166,17 @@ async def show_callback_exchange_error( ) try: - await callback.message.edit_text(text, reply_markup=markup) + await message.edit_text(text, reply_markup=markup) await callback.answer() except TelegramBadRequest as tg_exc: if "message is not modified" in str(tg_exc).lower(): await callback.answer("Ошибка всё ещё актуальна") return + raise +# Показать ошибку биржи новым сообщением. async def show_message_exchange_error( message: Message, *, diff --git a/app/src/telegram/ui/runtime_status.py b/app/src/telegram/ui/runtime_status.py new file mode 100644 index 0000000..e69de29 diff --git a/app/src/trading/accounts/service.py b/app/src/trading/accounts/service.py index 40dc1de..0e311cc 100644 --- a/app/src/trading/accounts/service.py +++ b/app/src/trading/accounts/service.py @@ -1,8 +1,16 @@ +# app/src/trading/accounts/service.py + from __future__ import annotations from src.integrations.exchange.models import BalanceSummary from src.integrations.exchange.service import ExchangeService -from src.storage.repositories.balance_snapshots import BalanceSnapshotRepository +from src.integrations.exchange.status import ( + ExchangeStatusCode, + build_exchange_error_status, +) +from src.storage.repositories.balance_snapshots import ( + BalanceSnapshotRepository, +) from src.trading.journal.service import JournalService @@ -12,12 +20,27 @@ class AccountsService: self.snapshot_repository = BalanceSnapshotRepository() self.journal = JournalService() + # получить live balance summary через typed exchange runtime layer def get_live_balance_summary(self) -> list[BalanceSummary]: - balances = self.exchange_service.get_balance_summary() + try: + balances = self.exchange_service.get_balance_summary() + + except Exception as exc: + runtime_status = build_exchange_error_status(exc) + + self._log_balance_runtime_error(runtime_status) + + raise + self._save_snapshot(balances) + return balances - def _save_snapshot(self, balances: list[BalanceSummary]) -> None: + # сохранить snapshot баланса + def _save_snapshot( + self, + balances: list[BalanceSummary], + ) -> None: payload = { "assets": [ { @@ -35,22 +58,62 @@ class AccountsService: source="portfolio_screen", payload=payload, ) + except Exception as exc: try: self.journal.log_warning( "balance_snapshot_error", f"Не удалось сохранить snapshot баланса: {exc}", - {"assets_count": len(balances)}, + { + "assets_count": len(balances), + }, ) + except Exception: pass + return try: self.journal.log_info( "balance_snapshot_saved", f"Snapshot баланса сохранён. Активов: {len(balances)}", - {"assets_count": len(balances)}, + { + "assets_count": len(balances), + }, ) + except Exception: pass + + # записать typed runtime exchange error для balances + def _log_balance_runtime_error( + self, + runtime_status, + ) -> None: + try: + payload = { + "status_code": runtime_status.code.value, + "is_available": runtime_status.is_available, + "is_auth_ok": runtime_status.is_auth_ok, + "reason": runtime_status.reason, + "raw_status": runtime_status.raw_status, + "raw_error": runtime_status.raw_error, + } + + if runtime_status.code == ExchangeStatusCode.AUTH_ERROR: + self.journal.log_warning( + "balance_auth_error", + runtime_status.message, + payload, + ) + return + + self.journal.log_warning( + "balance_exchange_error", + runtime_status.message, + payload, + ) + + except Exception: + pass \ No newline at end of file diff --git a/app/src/trading/auto/__init__.py b/app/src/trading/auto/__init__.py index 3446c6c..c4c1e55 100644 --- a/app/src/trading/auto/__init__.py +++ b/app/src/trading/auto/__init__.py @@ -1 +1,3 @@ +# app/src/trading/auto/__init__.py + """Package marker.""" \ No newline at end of file diff --git a/app/src/trading/auto/auto_lifecycle.py b/app/src/trading/auto/auto_lifecycle.py new file mode 100644 index 0000000..49fd1c6 --- /dev/null +++ b/app/src/trading/auto/auto_lifecycle.py @@ -0,0 +1,555 @@ +# app/src/trading/auto/auto_lifecycle.py + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING +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 NumericLike +from src.trading.auto.state import AutoTradeState +from src.trading.execution.engine import ExecutionEngine +from src.trading.strategies.base import BaseStrategy, StrategyContext +from src.trading.strategies.registry import StrategyRegistry +from src.trading.auto.execution_quality import AutoExecutionQualityMixin +from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin +from src.trading.auto.market_runtime import AutoMarketRuntimeMixin +from src.trading.auto.position_intelligence import AutoPositionIntelligenceMixin +from src.trading.auto.position_health import AutoPositionHealthMixin +from src.trading.auto.execution_semantic import AutoExecutionSemanticMixin +from src.trading.auto.autonomous_management import AutoAutonomousManagementMixin +from src.trading.journal.service import JournalService + + + +if TYPE_CHECKING: + from src.trading.auto.execution_semantic import AutoExecutionSemanticMixin + from src.trading.auto.position_health import AutoPositionHealthMixin + from src.trading.auto.position_intelligence import AutoPositionIntelligenceMixin + from src.trading.auto.market_runtime import AutoMarketRuntimeMixin + from src.trading.auto.execution_quality import AutoExecutionQualityMixin + from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin + + +class AutoLifecycleMixin( + AutoSignalRuntimeMixin, + AutoExecutionQualityMixin, + AutoMarketRuntimeMixin, + AutoPositionHealthMixin, + AutoPositionIntelligenceMixin, + AutoAutonomousManagementMixin, + AutoExecutionSemanticMixin, +): + _state: AutoTradeState + _loop_task: asyncio.Task | None + _loop_interval_seconds: int + + _confirm_min_duration_seconds: int + _confirm_repeats: int + _execution_confidence_required_score: float + + + # Записать изменение режима автоторговли в журнал. + def _log_auto_status_changed( + self, + *, + previous_status: str, + new_status: str, + action: str, + message: str, + ) -> None: + state = self.get_state() + + JournalService().log_ui_info( + event_type="auto_status_changed", + message=message, + screen="auto", + action=action, + payload={ + "previous_status": previous_status, + "new_status": new_status, + "symbol": state.symbol, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + "risk_percent": state.risk_percent, + "leverage": state.leverage, + "allocated_balance_usd": state.allocated_balance_usd, + "stop_loss_percent": state.stop_loss_percent, + "take_profit_percent": state.take_profit_percent, + "max_loss_usd": state.max_loss_usd, + "max_reserved_balance_percent": state.max_reserved_balance_percent, + }, + ) + + # установить капитал, выделенный под автоторговлю + def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState: + state = self.get_state() + + numeric_value = safe_float(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 + + # получить текущее состояние автоторговли + def get_state(self) -> AutoTradeState: + if not self._state.symbol: + self._state.symbol = load_settings().default_symbol + return self._state + + # проверить, запущен ли background loop + def is_loop_running(self) -> bool: + return self._loop_task is not None and not self._loop_task.done() + + # запустить background loop, если он ещё не запущен + def start_loop(self) -> None: + if self.is_loop_running(): + return + + self._loop_task = asyncio.create_task(self._loop_worker()) + + # остановить background loop + def stop_loop(self) -> None: + if self._loop_task is None: + return + + self._loop_task.cancel() + self._loop_task = None + + # рабочий цикл автоторговли + async def _loop_worker(self) -> None: + while True: + state = self.get_state() + + if state.status == "OFF": + break + + self.run_cycle() + await asyncio.sleep(self._loop_interval_seconds) + + # запустить активную торговлю + def start(self) -> tuple[AutoTradeState, str]: + state = self.get_state() + previous_status = state.status + + if state.status == "RUNNING": + return state, "Автоторговля уже активна." + + if state.status == "OBSERVING": + state.status = "RUNNING" + + EventBus.emit( + "auto_status_changed", + { + "previous_status": previous_status, + "status": state.status, + }, + ) + + self._log_auto_status_changed( + previous_status=previous_status, + new_status=state.status, + action="start", + message="Автоторговля активирована.", + ) + + return state, "Автоторговля активирована." + + 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 + state.last_flip_reason = None + state.last_flip_monotonic_at = None + state.last_signal = "HOLD" + state.signal_started_at = time.monotonic() + + EventBus.emit( + "auto_status_changed", + { + "previous_status": previous_status, + "status": state.status, + }, + ) + + self._log_auto_status_changed( + previous_status=previous_status, + new_status=state.status, + action="start", + message="Автоторговля запущена.", + ) + + return state, "Автоторговля запущена." + + # включить режим наблюдения + def observe(self) -> tuple[AutoTradeState, str]: + state = self.get_state() + previous_status = state.status + + if previous_status == "OBSERVING": + return state, "Режим наблюдения уже включён." + + state.status = "OBSERVING" + + EventBus.emit( + "auto_status_changed", + { + "previous_status": previous_status, + "status": state.status, + }, + ) + + 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 + state.last_flip_reason = None + state.last_flip_monotonic_at = None + + self._log_auto_status_changed( + previous_status=previous_status, + new_status=state.status, + action="observe", + message="Включён режим наблюдения.", + ) + + return state, "Включён режим наблюдения." + + self._log_auto_status_changed( + previous_status=previous_status, + new_status=state.status, + action="observe", + message="Автоторговля переведена в режим наблюдения.", + ) + + return state, "Автоторговля переведена в режим наблюдения." + + # полностью выключить автоторговлю + def stop(self) -> tuple[AutoTradeState, str]: + state = self.get_state() + previous_status = state.status + + if state.status == "OFF": + self.stop_loop() + return state, "Автоторговля уже выключена." + + 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 + state.last_flip_reason = None + state.last_flip_monotonic_at = None + self.stop_loop() + + EventBus.emit( + "auto_status_changed", + { + "previous_status": previous_status, + "status": state.status, + }, + ) + + self._log_auto_status_changed( + previous_status=previous_status, + new_status=state.status, + action="stop", + message="Автоторговля выключена.", + ) + + return state, "Автоторговля выключена." + + # установить инструмент + def set_symbol(self, symbol: str) -> AutoTradeState: + state = self.get_state() + previous_symbol = state.symbol + + state.symbol = symbol + self._reset_signal_tracking() + + StrategyRegistry.reset_runtime(symbol=previous_symbol) + StrategyRegistry.reset_runtime(symbol=symbol) + + return state + + # установить стратегию + def set_strategy(self, strategy: str) -> AutoTradeState: + state = self.get_state() + previous_strategy = state.strategy + normalized_strategy = strategy.strip().upper() + + state.strategy = normalized_strategy + self._reset_signal_tracking() + + StrategyRegistry.reset_runtime(previous_strategy) + StrategyRegistry.reset_runtime(normalized_strategy) + + return state + + # установить риск + def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState: + state = self.get_state() + state.risk_percent = safe_float(risk_percent) + return state + + # установить плечо + def set_leverage(self, leverage: NumericLike) -> AutoTradeState: + state = self.get_state() + state.leverage = safe_float(leverage) + return state + + # установить stop loss в % + def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState: + state = self.get_state() + state.stop_loss_percent = safe_float(value) + return state + + # установить take profit в % + def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState: + state = self.get_state() + state.take_profit_percent = safe_float(value) + return state + + # установить max loss в USD + def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState: + state = self.get_state() + state.max_loss_usd = safe_float(value) + return state + + # установить максимальное использование баланса под маржу + def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState: + state = self.get_state() + state.max_reserved_balance_percent = safe_float(value) + state.execution_block_reason = None + return state + + # сбросить внутренний трекинг сигналов и runtime state + def _reset_signal_tracking(self) -> None: + self._last_signal_key = None + self._last_signal_value = None + self._last_signal_reason = "" + self._last_signal_confidence = 0.0 + self._last_signal_payload = None + self._last_signal_started_at = None + self._same_signal_count = 0 + + state = self.get_state() + + state.adaptive_size_base = None + state.adaptive_size_final = None + state.adaptive_size_multiplier = None + state.adaptive_size_reason = None + 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 + state.decision_status = "WAITING" + state.decision_reason = None + state.is_signal_confirmed = False + state.is_signal_ready = False + state.signal_confirmation_seconds = 0 + state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds + 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 + state.execution_semantic_reason = None + state.execution_quality = None + state.execution_quality_reason = None + state.execution_quality_message = None + state.execution_price_source = None + state.execution_price_age_seconds = None + state.execution_bid_price = None + state.execution_ask_price = None + state.execution_last_price = None + state.execution_price_freshness = None + state.execution_confidence_score = None + state.execution_confidence_level = None + state.execution_confidence_required_score = self._execution_confidence_required_score + state.execution_confidence_reason = None + state.execution_confidence_factors = None + + state.market_state = None + state.market_trend = None + state.market_volatility = None + state.market_analysis_interval = None + state.market_analysis_reason = None + state.market_analysis_updated_at = None + state.market_runtime_degraded = False + state.market_trend_strength = None + 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 + state.momentum_strength = None + 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 + state.spread_percent = None + + state.position_pnl_percent = None + state.position_hold_seconds = None + state.position_pressure = None + state.position_health_score = None + state.position_health_status = None + state.position_health_reason = None + state.position_risk_level = None + state.position_risk_reason = None + state.position_trend_alignment = None + state.position_adverse_momentum = False + state.position_exit_pressure = None + + state.position_lifecycle_stage = None + state.position_hold_quality = None + state.position_decay_state = None + state.position_exit_confidence = None + state.position_exit_signal = None + state.position_intelligence_reason = None + state.position_recommended_action = None + + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + + state.autonomous_action = None + state.autonomous_action_reason = None + state.autonomous_action_confidence = None + state.autonomous_protection_required = False + state.autonomous_reduce_required = False + state.autonomous_exit_required = False + state.autonomous_last_action = None + state.autonomous_last_action_reason = None + state.autonomous_last_action_at = None + + state.last_loss_monotonic_at = None + + # собрать контекст для стратегии + def _build_strategy_context(self) -> StrategyContext: + state = self.get_state() + + return StrategyContext( + symbol=state.symbol, + status=state.status, + risk_percent=state.risk_percent, + ) + + # получить стратегию для текущего цикла + def _get_strategy(self) -> BaseStrategy: + state = self.get_state() + return StrategyRegistry.get(state.strategy) + + # выполнить один полный runtime cycle автоторговли + def run_cycle(self) -> AutoTradeState: + state = self.get_state() + + 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() + context = self._build_strategy_context() + result = strategy.analyze(context) + + self._sync_market_analysis_state( + state=state, + payload=result.payload, + ) + + self._sync_execution_quality_state(state) + + state.last_check_at = datetime.now().strftime("%H:%M:%S") + + self._log_signal_if_changed( + strategy_name=strategy.name, + state=state, + signal=result.signal.value, + reason=result.reason, + confidence=result.confidence, + payload=result.payload, + ) + + if state.execution_quality != "BLOCKED": + ExecutionEngine().process(state) + + self._sync_position_health_state(state) + self._sync_position_intelligence_state(state) + self._sync_autonomous_trade_management(state) + + if state.execution_quality != "BLOCKED": + ExecutionEngine().process_runtime_action(state) + + self._sync_execution_semantic_state(state) + + return state \ No newline at end of file diff --git a/app/src/trading/auto/autonomous_management.py b/app/src/trading/auto/autonomous_management.py new file mode 100644 index 0000000..67b60a4 --- /dev/null +++ b/app/src/trading/auto/autonomous_management.py @@ -0,0 +1,69 @@ +# app/src/trading/auto/autonomous_management.py + +from __future__ import annotations + +from src.core.numbers import safe_float +from src.trading.auto.state import AutoTradeState + + +class AutoAutonomousManagementMixin: + # синхронизировать автономное управление открытой позицией + def _sync_autonomous_trade_management( + self, + state: AutoTradeState, + ) -> None: + if state.position_side == "NONE": + state.autonomous_action = None + state.autonomous_action_reason = None + state.autonomous_action_confidence = None + state.autonomous_protection_required = False + state.autonomous_reduce_required = False + state.autonomous_exit_required = False + return + + exit_signal = str(state.position_exit_signal or "HOLD").upper() + exit_confidence = safe_float(state.position_exit_confidence) or 0.0 + + action = "HOLD" + reason = "позиция удерживается" + + protect_required = False + reduce_required = False + exit_required = False + + if exit_signal == "WATCH": + action = "WATCH" + reason = "позиция требует наблюдения" + + elif exit_signal == "REDUCE_OR_PROTECT": + if state.position_pressure in {"HIGH_LOSS", "LOSS"}: + action = "REDUCE" + reduce_required = True + reason = "позиция должна быть уменьшена" + else: + action = "PROTECT" + protect_required = True + reason = "позиция требует защиты" + + elif exit_signal == "EXIT": + action = "EXIT" + exit_required = True + reason = "позиция требует закрытия" + + if ( + state.position_adverse_momentum + and state.position_trend_alignment == "AGAINST" + and exit_confidence >= 0.65 + ): + action = "EXIT" + exit_required = True + reduce_required = False + protect_required = False + reason = "рынок агрессивно движется против позиции" + + state.autonomous_action = action + state.autonomous_action_reason = reason + state.autonomous_action_confidence = exit_confidence + state.autonomous_protection_required = protect_required + state.autonomous_reduce_required = reduce_required + state.autonomous_exit_required = exit_required \ No newline at end of file diff --git a/app/src/trading/auto/execution_quality.py b/app/src/trading/auto/execution_quality.py new file mode 100644 index 0000000..a45f7c1 --- /dev/null +++ b/app/src/trading/auto/execution_quality.py @@ -0,0 +1,488 @@ +# app/src/trading/auto/execution_quality.py + +from __future__ import annotations + +import time + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.integrations.exchange.service import ExchangeService +from src.integrations.exchange.status import ( + ExchangeRuntimeStatus, + ExchangeStatusCode, + build_exchange_error_status, +) +from src.trading.auto.state import AutoTradeState +from src.trading.journal.service import JournalService + + +class AutoExecutionQualityMixin: + _spread_thresholds_by_asset: dict[str, dict[str, float]] + _default_spread_thresholds: dict[str, float] + + _max_snapshot_age_seconds: float + _warning_snapshot_age_seconds: float + + _last_logged_execution_quality_key: str | None + + # получить базовый asset из symbol для spread thresholds + 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 + + # получить spread thresholds для конкретного инструмента + 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, + ) + + # синхронизировать единый статус биржи/торговой сессии в AutoTradeState + def _sync_market_availability_state(self, state: AutoTradeState) -> bool: + try: + status = ExchangeService().get_symbol_runtime_status(state.symbol) + except Exception as exc: + status = build_exchange_error_status(exc) + + state.market_is_open = status.is_open + state.market_status = status.code.value + state.market_status_message = status.ui_line + state.market_status_updated_at = time.monotonic() + + if status.is_open: + self._clear_exchange_block_state(state) + return True + + self._apply_exchange_block_state( + state=state, + status=status, + ) + + return False + + # очистить старую блокировку биржи, если рынок снова доступен + def _clear_exchange_block_state(self, state: AutoTradeState) -> None: + if state.execution_quality_reason not in { + "MARKET_BREAK", + "EXCHANGE_UNAVAILABLE", + "AUTH_ERROR", + "TIME_ERROR", + "INVALID_SYMBOL", + "MARKET_CLOSED", + }: + return + + state.execution_quality = None + state.execution_quality_reason = None + state.execution_quality_message = None + state.execution_block_reason = None + state.market_runtime_degraded = False + + state.entry_block_reason = None + state.entry_block_message = None + + # применить блокировку execution по единому ExchangeRuntimeStatus + def _apply_exchange_block_state( + self, + *, + state: AutoTradeState, + status: ExchangeRuntimeStatus, + ) -> None: + reason = self._exchange_execution_reason(status) + message = status.ui_line or status.message + + state.execution_quality = "BLOCKED" + state.execution_quality_reason = reason + state.execution_quality_message = message + state.execution_block_reason = message + state.market_runtime_degraded = True + + state.entry_block_reason = reason + state.entry_block_message = message + + state.decision_status = "WAITING" + state.decision_reason = message + state.is_signal_confirmed = False + state.is_signal_ready = False + + self._log_exchange_availability_if_changed( + state=state, + status=status, + reason=reason, + ) + + # преобразовать typed exchange status в код причины execution layer + def _exchange_execution_reason(self, status: ExchangeRuntimeStatus) -> str: + if status.code == ExchangeStatusCode.BREAK: + return "MARKET_BREAK" + + if status.code == ExchangeStatusCode.AUTH_ERROR: + return "AUTH_ERROR" + + if status.code == ExchangeStatusCode.TIME_ERROR: + return "TIME_ERROR" + + if status.code == ExchangeStatusCode.INVALID_SYMBOL: + return "INVALID_SYMBOL" + + if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE: + return "EXCHANGE_UNAVAILABLE" + + return "MARKET_BREAK" + + # залогировать изменение доступности биржи/рынка + def _log_exchange_availability_if_changed( + self, + *, + state: AutoTradeState, + status: ExchangeRuntimeStatus, + reason: str, + ) -> None: + key = ( + f"{state.status}:{state.symbol}:{state.strategy}:" + f"{status.code.value}:{reason}:{status.ui_line}" + ) + + if key == type(self)._last_logged_execution_quality_key: + return + + type(self)._last_logged_execution_quality_key = key + + try: + JournalService().log_ui_warning( + event_type="exchange_availability_changed", + message=status.ui_line, + screen="auto", + action="exchange_status", + payload={ + "status": state.status, + "symbol": state.symbol, + "strategy": state.strategy, + "exchange_status_code": status.code.value, + "exchange_reason": status.reason, + "execution_reason": reason, + "is_open": status.is_open, + "is_available": status.is_available, + "is_auth_ok": status.is_auth_ok, + "message": status.message, + "raw_status": status.raw_status, + "raw_error": status.raw_error, + }, + ) + except Exception: + pass + + # рассчитать качество исполнения на основе spread + def _spread_execution_quality( + self, + *, + state: AutoTradeState, + spread_percent: NumericLike | None, + ) -> tuple[str | None, str | None, str | None, bool]: + 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 > block_exit: + return "BLOCKED", "HIGH_SPREAD", "высокий spread", False + + 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 >= block_enter: + return "BLOCKED", "HIGH_SPREAD", "высокий spread", False + + if spread > warning_exit: + return "WARNING", "WIDE_SPREAD", "spread повышен", False + + return "GOOD", "MARKET_OK", "рынок готов", False + + if spread >= block_enter: + return "BLOCKED", "HIGH_SPREAD", "высокий spread", False + + if spread >= warning_enter: + return "WARNING", "WIDE_SPREAD", "spread повышен", False + + return "GOOD", "MARKET_OK", "рынок готов", False + + # синхронизировать runtime quality исполнения + def _sync_execution_quality_state(self, state: AutoTradeState) -> None: + try: + snapshot = ExchangeService().get_market_snapshot( + state.symbol, + runtime_key="auto", + ) + except Exception as exc: + fallback_price = None + + try: + fallback_price = safe_float( + ExchangeService().get_price( + state.symbol, + runtime_key="auto", + ).price + ) + except Exception: + pass + + state.snapshot_age_seconds = None + state.spread_percent = None + + if fallback_price is not None and fallback_price > 0: + state.execution_quality = "WARNING" + state.execution_quality_reason = "SNAPSHOT_UNAVAILABLE" + state.execution_quality_message = "нет depth snapshot" + state.market_runtime_degraded = True + else: + status = build_exchange_error_status(exc) + self._apply_exchange_block_state( + state=state, + status=status, + ) + + self._log_execution_quality_if_changed( + state=state, + payload={ + "error": str(exc), + "error_type": type(exc).__name__, + "fallback_price_available": fallback_price is not None, + }, + ) + return + + 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 "") + + self._sync_execution_pricing_state( + state, + snapshot, + ) + + state.snapshot_age_seconds = age_seconds + state.spread_percent = self._spread_percent( + bid_price=bid_price, + ask_price=ask_price, + ) + + if age_seconds is not None and age_seconds > self._max_snapshot_age_seconds: + state.execution_quality = "BLOCKED" + state.execution_quality_reason = "STALE_SNAPSHOT" + state.execution_quality_message = "snapshot устарел" + state.market_runtime_degraded = True + + elif age_seconds is not None and age_seconds > self._warning_snapshot_age_seconds: + state.execution_quality = "WARNING" + state.execution_quality_reason = "AGING_SNAPSHOT" + state.execution_quality_message = "snapshot стареет" + state.market_runtime_degraded = not is_fresh + + elif state.spread_percent is not None: + ( + state.execution_quality, + state.execution_quality_reason, + state.execution_quality_message, + state.market_runtime_degraded, + ) = self._spread_execution_quality( + state=state, + spread_percent=state.spread_percent, + ) + + else: + state.execution_quality = "GOOD" + state.execution_quality_reason = "MARKET_OK" + state.execution_quality_message = "рынок готов" + state.market_runtime_degraded = False + + if state.execution_quality == "BLOCKED": + state.execution_block_reason = state.execution_quality_message + + 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={ + "symbol": state.symbol, + "strategy": state.strategy, + "bid_price": bid_price, + "ask_price": ask_price, + "last_price": last_price, + "snapshot_age_seconds": age_seconds, + "spread_percent": state.spread_percent, + "is_fresh": is_fresh, + "source": source, + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_quality_message": state.execution_quality_message, + "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_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"], + }, + ) + + # рассчитать spread между bid/ask в процентах + def _spread_percent( + self, + *, + bid_price: NumericLike | None, + ask_price: NumericLike | None, + ) -> float | None: + bid = safe_float(bid_price) + ask = safe_float(ask_price) + + if bid is None or ask is None: + return None + + if bid <= 0 or ask <= 0: + return None + + mid_price = (bid + ask) / 2 + + if mid_price <= 0: + return None + + spread = ask - bid + + if spread < 0: + return None + + return round((spread / mid_price) * 100, 5) + + # синхронизировать execution pricing данные в state + def _sync_execution_pricing_state( + self, + state: AutoTradeState, + snapshot: dict[str, object], + ) -> None: + age_seconds = safe_float(snapshot.get("age_seconds")) + + state.execution_price_source = str(snapshot.get("source") or "") + state.execution_price_age_seconds = age_seconds + state.execution_bid_price = safe_float(snapshot.get("bid_price")) + state.execution_ask_price = safe_float(snapshot.get("ask_price")) + state.execution_last_price = safe_float(snapshot.get("last_price")) + + if age_seconds is None: + state.execution_price_freshness = "UNKNOWN" + elif age_seconds <= 1: + state.execution_price_freshness = "FRESH" + elif age_seconds <= self._warning_snapshot_age_seconds: + state.execution_price_freshness = "AGING" + else: + state.execution_price_freshness = "STALE" + + # записать событие изменения execution quality + def _log_execution_quality_if_changed( + self, + *, + state: AutoTradeState, + payload: dict[str, object], + ) -> None: + quality = state.execution_quality + reason = state.execution_quality_reason + message = state.execution_quality_message + + if not quality or not reason or not message: + return + + key = f"{state.status}:{state.symbol}:{state.strategy}:{quality}:{reason}:{message}" + + if key == type(self)._last_logged_execution_quality_key: + return + + type(self)._last_logged_execution_quality_key = key + + if quality == "GOOD": + return + + try: + log_payload = { + **payload, + "status": state.status, + "symbol": state.symbol, + "strategy": state.strategy, + } + + if quality == "BLOCKED": + JournalService().log_ui_warning( + event_type="execution_quality_changed", + message=f"Качество исполнения: {message}.", + screen="auto", + action="execution_quality", + payload=log_payload, + ) + return + + JournalService().log_ui_info( + event_type="execution_quality_changed", + message=f"Качество исполнения: {message}.", + screen="auto", + action="execution_quality", + payload=log_payload, + ) + except Exception: + pass + + # рассчитать confidence execution quality для общего execution confidence + def _execution_quality_confidence_score(self, state: AutoTradeState) -> float: + quality = state.execution_quality + reason = state.execution_quality_reason + + if quality == "GOOD": + return 1.0 + + if quality == "WARNING": + if reason == "WIDE_SPREAD": + return 0.65 + + if reason == "AGING_SNAPSHOT": + return 0.6 + + if reason == "SNAPSHOT_UNAVAILABLE": + return 0.55 + + return 0.6 + + if quality == "BLOCKED": + return 0.0 + + return 0.5 \ No newline at end of file diff --git a/app/src/trading/auto/execution_semantic.py b/app/src/trading/auto/execution_semantic.py new file mode 100644 index 0000000..b8c9aaa --- /dev/null +++ b/app/src/trading/auto/execution_semantic.py @@ -0,0 +1,120 @@ +# app/src/trading/auto/execution_semantic.py + +from __future__ import annotations + +from src.integrations.exchange.status import ( + ExchangeStatusCode, + is_exchange_status_reason, +) +from src.trading.auto.state import AutoTradeState + + +class AutoExecutionSemanticMixin: + _execution_confidence_required_score: float + + # синхронизировать semantic-статус execution слоя для UI + def _sync_execution_semantic_state(self, state: AutoTradeState) -> None: + if state.execution_quality == "BLOCKED": + state.execution_semantic_status = "BLOCKED" + state.execution_semantic_message = self._execution_block_semantic_message(state) + state.execution_semantic_reason = state.execution_quality_reason + return + + if state.decision_status == "BLOCKED": + state.execution_semantic_status = "BLOCKED" + + if ( + state.execution_confidence_score is not None + and state.execution_confidence_score < self._execution_confidence_required_score + ): + state.execution_semantic_message = "⛔ Исполнение · низкая уверенность" + state.execution_semantic_reason = state.execution_confidence_reason + return + + state.execution_semantic_message = "⛔ Исполнение · сигнал заблокирован" + state.execution_semantic_reason = state.decision_reason + return + + if state.position_side != "NONE": + state.execution_semantic_status = "POSITION_OPEN" + state.execution_semantic_message = "📌 Исполнение · позиция открыта" + state.execution_semantic_reason = state.last_execution_reason + return + + if state.decision_status == "READY" and state.is_signal_ready: + state.execution_semantic_status = "READY" + state.execution_semantic_message = "✅ Исполнение · готово" + state.execution_semantic_reason = state.decision_reason + return + + if state.decision_status == "CONFIRMING": + state.execution_semantic_status = "WAITING_SIGNAL" + state.execution_semantic_message = "⏳ Исполнение · ждёт подтверждения" + state.execution_semantic_reason = state.decision_reason + return + + if state.last_signal in {"BUY", "SELL"}: + state.execution_semantic_status = "WAITING_SIGNAL" + state.execution_semantic_message = "⏳ Исполнение · сигнал проверяется" + state.execution_semantic_reason = state.decision_reason + return + + state.execution_semantic_status = "IDLE" + state.execution_semantic_message = "" + state.execution_semantic_reason = state.decision_reason + + # вернуть человекочитаемое сообщение блокировки execution слоя + def _execution_block_semantic_message(self, state: AutoTradeState) -> str: + reason = str(state.execution_quality_reason or "") + message = str(state.execution_quality_message or "") + + if self._is_exchange_unavailable(reason): + return "⛔ Исполнение · биржа недоступна" + + if self._is_exchange_break(reason): + return "⏸️ Исполнение · перерыв на бирже" + + if self._is_auth_error(reason): + return "⛔ Исполнение · неверный API Key" + + if reason == "STALE_SNAPSHOT": + return "⛔ Исполнение · рынок неактуален" + + if reason == "HIGH_SPREAD": + return "⛔ Исполнение · высокий spread" + + if reason == "SNAPSHOT_ERROR": + return "⛔ Исполнение · нет данных рынка" + + if reason == "SNAPSHOT_UNAVAILABLE": + return "⚠️ Исполнение · нет стакана" + + if message: + return f"⛔ Исполнение · {message}" + + return "⛔ Исполнение · заблокировано" + + # проверить, что блокировка пришла из единого exchange status layer + def _is_exchange_unavailable(self, reason: str) -> bool: + return ( + is_exchange_status_reason(reason) + and reason + in { + ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value, + ExchangeStatusCode.TIME_ERROR.value, + } + ) + + # проверить, что причина блокировки — торговый перерыв, а не ошибка доступа + def _is_exchange_break(self, reason: str) -> bool: + return ( + is_exchange_status_reason(reason) + and reason == ExchangeStatusCode.BREAK.value + ) + + # проверить ошибку приватного доступа / API key + def _is_auth_error(self, reason: str) -> bool: + return ( + is_exchange_status_reason(reason) + and reason == ExchangeStatusCode.AUTH_ERROR.value + ) \ No newline at end of file diff --git a/app/src/trading/auto/market_runtime.py b/app/src/trading/auto/market_runtime.py new file mode 100644 index 0000000..950351b --- /dev/null +++ b/app/src/trading/auto/market_runtime.py @@ -0,0 +1,274 @@ +# app/src/trading/auto/market_runtime.py + +from __future__ import annotations + +import time + +from src.core.numbers import safe_float +from src.core.types import JsonDict +from src.trading.auto.state import AutoTradeState +from src.trading.journal.service import JournalService + + +class AutoMarketRuntimeMixin: + _last_logged_market_state: str | None + _last_logged_market_trend: str | None + _last_logged_market_volatility: str | None + _last_logged_entry_block_reason: str | None + _last_logged_entry_block_at: float | None = None + _entry_block_log_ttl_seconds: int = 900 + + # синхронизировать market analysis payload в AutoTradeState + def _sync_market_analysis_state( + self, + *, + state: AutoTradeState, + payload: JsonDict | None, + ) -> None: + if not isinstance(payload, dict): + return + + previous_market_state = state.market_state + previous_market_trend = state.market_trend + previous_market_volatility = state.market_volatility + + 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.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, + payload=payload, + previous_market_state=previous_market_state, + previous_market_trend=previous_market_trend, + previous_market_volatility=previous_market_volatility, + ) + + self._log_entry_block_if_changed( + state=state, + payload=payload, + ) + + # записать entry-block событие, если причина изменилась или истёк TTL + def _log_entry_block_if_changed( + self, + *, + state: AutoTradeState, + payload: JsonDict, + ) -> None: + reason = state.entry_block_reason + message = state.entry_block_message + + if not reason or not message: + return + + now = time.monotonic() + + # status специально не входит в key: + # RUNNING / OBSERVING не должны создавать дубли одной и той же причины. + key = f"{state.symbol}:{state.strategy}:{reason}:{message}" + + last_logged_at = type(self)._last_logged_entry_block_at + ttl_expired = ( + last_logged_at is None + or now - last_logged_at >= type(self)._entry_block_log_ttl_seconds + ) + + if ( + key == type(self)._last_logged_entry_block_reason + and not ttl_expired + ): + return + + type(self)._last_logged_entry_block_reason = key + type(self)._last_logged_entry_block_at = now + + try: + JournalService().log_ui_info( + event_type="entry_blocked", + message=f"Вход в позицию не выполнен: {message}.", + screen="auto", + action="entry_diagnostics", + payload={ + **payload, + "entry_block_reason": reason, + "entry_block_message": message, + "entry_block_key": key, + "entry_block_ttl_seconds": type(self)._entry_block_log_ttl_seconds, + "symbol": state.symbol, + "strategy": state.strategy, + "status": state.status, + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_trend_strength": state.market_trend_strength, + "market_trend_quality": state.market_trend_quality, + "market_phase": state.market_phase, + "market_phase_direction": state.market_phase_direction, + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "momentum_change_percent": state.momentum_change_percent, + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_confidence_score": state.execution_confidence_score, + "last_signal": state.last_signal, + "last_signal_confidence": state.last_signal_confidence, + "last_signal_reason": state.last_signal_reason, + }, + ) + except Exception: + pass + + # записать market state / volatility событие, если состояние изменилось + def _log_market_state_if_changed( + self, + *, + state: AutoTradeState, + payload: JsonDict, + previous_market_state: str | None, + previous_market_trend: str | None, + previous_market_volatility: str | None, + ) -> None: + market_state = state.market_state + market_trend = state.market_trend + market_volatility = state.market_volatility + + if not market_state or market_state == "UNKNOWN": + return + + state_changed = ( + market_state != previous_market_state + and market_state != type(self)._last_logged_market_state + ) + + volatility_changed = ( + market_volatility is not None + and market_volatility != previous_market_volatility + and market_volatility != type(self)._last_logged_market_volatility + ) + + if not state_changed and not volatility_changed: + return + + journal_payload = { + **payload, + "previous_market_state": previous_market_state, + "previous_market_trend": previous_market_trend, + "previous_market_volatility": previous_market_volatility, + "current_market_state": market_state, + "current_market_trend": market_trend, + "current_market_volatility": market_volatility, + } + + try: + if state_changed: + self._write_market_journal_event( + event_type="market_state_changed", + market_state=market_state, + message=self._market_state_message(market_state), + payload=journal_payload, + ) + + if volatility_changed: + self._write_market_journal_event( + event_type="market_volatility_changed", + market_state=market_state, + message=self._market_volatility_message(market_volatility), + payload=journal_payload, + ) + except Exception: + pass + + type(self)._last_logged_market_state = market_state + type(self)._last_logged_market_trend = market_trend + type(self)._last_logged_market_volatility = market_volatility + + # записать market journal событие с нужным уровнем важности + def _write_market_journal_event( + self, + *, + event_type: str, + market_state: str, + message: str, + payload: JsonDict, + ) -> None: + level = self._market_journal_level(market_state) + + if level == "WARNING": + JournalService().log_ui_warning( + event_type=event_type, + message=message, + screen="auto", + action="market_analysis", + payload=payload, + ) + return + + JournalService().log_ui_info( + event_type=event_type, + message=message, + screen="auto", + action="market_analysis", + payload=payload, + ) + + # получить человекочитаемое сообщение по volatility + def _market_volatility_message(self, market_volatility: str | None) -> str: + messages = { + "LOW": "Волатильность изменена: низкая.", + "NORMAL": "Волатильность изменена: нормальная.", + "HIGH": "Волатильность изменена: высокая.", + } + + return messages.get(str(market_volatility or ""), "Волатильность не определена.") + + # определить уровень journal события для market state + def _market_journal_level(self, market_state: str | None) -> str: + if market_state == "HIGH_VOLATILITY": + return "WARNING" + + return "INFO" + + # получить человекочитаемое сообщение по market state + def _market_state_message(self, market_state: str) -> str: + messages = { + "TREND_UP": "Состояние рынка изменено: рост.", + "TREND_DOWN": "Состояние рынка изменено: снижение.", + "RANGE": "Состояние рынка изменено: нет выраженного направления.", + "HIGH_VOLATILITY": "Состояние рынка изменено: высокая волатильность.", + "LOW_VOLATILITY": "Состояние рынка изменено: низкая активность.", + } + + return messages.get(market_state, "Состояние рынка анализируется.") \ No newline at end of file diff --git a/app/src/trading/auto/position_health.py b/app/src/trading/auto/position_health.py new file mode 100644 index 0000000..c864547 --- /dev/null +++ b/app/src/trading/auto/position_health.py @@ -0,0 +1,318 @@ +# app/src/trading/auto/position_health.py + +from __future__ import annotations + +import time + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.trading.auto.state import AutoTradeState + + +class AutoPositionHealthMixin: + # синхронизировать runtime health/risk состояние открытой позиции + def _sync_position_health_state(self, state: AutoTradeState) -> None: + if state.position_side == "NONE" or state.entry_price is None: + state.position_pnl_percent = None + state.position_hold_seconds = None + state.position_pressure = None + state.position_health_score = None + state.position_health_status = None + state.position_health_reason = None + state.position_risk_level = None + state.position_risk_reason = None + state.position_trend_alignment = None + state.position_adverse_momentum = False + state.position_exit_pressure = None + return + + pnl_percent = self._position_pnl_percent(state) + hold_seconds = self._position_hold_seconds(state) + trend_alignment = self._position_trend_alignment(state) + adverse_momentum = self._has_adverse_position_momentum(state) + + pressure = self._position_pressure( + state=state, + pnl_percent=pnl_percent, + ) + + health_score = self._position_health_score( + state=state, + pnl_percent=pnl_percent, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + + risk_level, risk_reason = self._position_risk_level( + state=state, + pnl_percent=pnl_percent, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + + state.position_pnl_percent = pnl_percent + state.position_hold_seconds = hold_seconds + state.position_pressure = pressure + state.position_health_score = health_score + state.position_health_status = self._position_health_status(health_score) + state.position_health_reason = self._position_health_reason( + pressure=pressure, + trend_alignment=trend_alignment, + adverse_momentum=adverse_momentum, + ) + state.position_risk_level = risk_level + state.position_risk_reason = risk_reason + state.position_trend_alignment = trend_alignment + state.position_adverse_momentum = adverse_momentum + state.position_exit_pressure = self._position_exit_pressure( + state=state, + pnl_percent=pnl_percent, + risk_level=risk_level, + ) + + # рассчитать PnL позиции в процентах от notional + def _position_pnl_percent(self, state: AutoTradeState) -> float | None: + entry_price = safe_float(state.entry_price) + size = safe_float(state.position_size) + pnl = safe_float(state.unrealized_pnl_usd) + + if entry_price is None or entry_price <= 0: + return None + + if size is None or size <= 0: + return None + + if pnl is None: + return None + + notional = entry_price * size + + if notional <= 0: + return None + + return round((pnl / notional) * 100, 4) + + # рассчитать время удержания открытой позиции + def _position_hold_seconds(self, state: AutoTradeState) -> int | None: + opened_at = getattr(state, "position_opened_monotonic_at", None) + + if opened_at is None: + return None + + opened = safe_float(opened_at) + + if opened is None: + return None + + return max(0, int(time.monotonic() - opened)) + + # определить давление на позицию по PnL + def _position_pressure( + self, + *, + state: AutoTradeState, + pnl_percent: NumericLike | None, + ) -> str: + pnl = safe_float(state.unrealized_pnl_usd) or 0.0 + percent = safe_float(pnl_percent) + + if percent is None: + if pnl < 0: + return "LOSS" + + if pnl > 0: + return "PROFIT" + + return "FLAT" + + if percent <= -0.8: + return "HIGH_LOSS" + + if percent <= -0.3: + return "LOSS" + + if percent >= 0.8: + return "STRONG_PROFIT" + + if percent >= 0.3: + return "PROFIT" + + return "FLAT" + + # определить alignment позиции относительно тренда + def _position_trend_alignment(self, state: AutoTradeState) -> str: + side = str(state.position_side or "NONE").upper() + market_state = str(state.market_state or "").upper() + trend = str(state.market_trend or "").upper() + + if side == "NONE": + return "NONE" + + if side == "LONG": + if market_state == "TREND_UP" or trend == "UP": + return "ALIGNED" + + if market_state == "TREND_DOWN" or trend == "DOWN": + return "AGAINST" + + if side == "SHORT": + if market_state == "TREND_DOWN" or trend == "DOWN": + return "ALIGNED" + + if market_state == "TREND_UP" or trend == "UP": + return "AGAINST" + + return "NEUTRAL" + + # проверить, направлен ли momentum против позиции + def _has_adverse_position_momentum(self, state: AutoTradeState) -> bool: + side = str(state.position_side or "NONE").upper() + momentum_direction = str(state.momentum_direction or "").upper() + momentum_state = str(state.momentum_state or "").upper() + + if side == "LONG": + return ( + momentum_direction == "DOWN" + or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + ) + + if side == "SHORT": + return ( + momentum_direction == "UP" + or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + ) + + return False + + # рассчитать health score позиции + def _position_health_score( + self, + *, + state: AutoTradeState, + pnl_percent: NumericLike | None, + trend_alignment: str, + adverse_momentum: bool, + ) -> int: + score = 100 + percent = safe_float(pnl_percent) + + if percent is not None: + if percent <= -1.0: + score -= 35 + elif percent <= -0.5: + score -= 22 + elif percent < 0: + score -= 10 + elif percent >= 0.8: + score += 5 + + if trend_alignment == "AGAINST": + score -= 25 + elif trend_alignment == "NEUTRAL": + score -= 8 + + if adverse_momentum: + score -= 20 + + if state.execution_quality == "BLOCKED": + score -= 15 + elif state.execution_quality == "WARNING": + score -= 8 + + if state.market_runtime_degraded: + score -= 10 + + return max(0, min(100, score)) + + # классифицировать health status по score + def _position_health_status(self, score: int | None) -> str: + if score is None: + return "UNKNOWN" + + if score >= 80: + return "HEALTHY" + + if score >= 55: + return "WATCH" + + if score >= 35: + return "PRESSURE" + + return "DANGER" + + # сформировать человекочитаемую причину health состояния + def _position_health_reason( + self, + *, + pressure: str, + trend_alignment: str, + adverse_momentum: bool, + ) -> str: + if trend_alignment == "AGAINST" and adverse_momentum: + return "тренд и momentum против позиции" + + if trend_alignment == "AGAINST": + return "тренд против позиции" + + if adverse_momentum: + return "momentum против позиции" + + if pressure in {"HIGH_LOSS", "LOSS"}: + return "позиция под давлением" + + if pressure in {"PROFIT", "STRONG_PROFIT"}: + return "позиция в прибыли" + + return "позиция стабильна" + + # определить runtime risk level позиции + def _position_risk_level( + self, + *, + state: AutoTradeState, + pnl_percent: NumericLike | None, + trend_alignment: str, + adverse_momentum: bool, + ) -> tuple[str, str]: + percent = safe_float(pnl_percent) + + if state.execution_quality == "BLOCKED": + return "HIGH", "исполнение заблокировано" + + if percent is not None and percent <= -1.0: + return "HIGH", "сильная просадка позиции" + + if trend_alignment == "AGAINST" and adverse_momentum: + return "HIGH", "рынок движется против позиции" + + if percent is not None and percent < 0: + if trend_alignment == "AGAINST" or adverse_momentum: + return "ELEVATED", "убыток усиливается рыночным контекстом" + + return "MODERATE", "позиция в минусе" + + if adverse_momentum: + return "MODERATE", "momentum против позиции" + + return "LOW", "критичных рисков нет" + + # определить давление на выход из позиции + def _position_exit_pressure( + self, + *, + state: AutoTradeState, + pnl_percent: NumericLike | None, + risk_level: str, + ) -> str: + percent = safe_float(pnl_percent) + + if risk_level == "HIGH": + return "HIGH" + + if risk_level == "ELEVATED": + return "WATCH" + + if percent is not None and percent <= -0.5: + return "WATCH" + + return "LOW" \ No newline at end of file diff --git a/app/src/trading/auto/position_intelligence.py b/app/src/trading/auto/position_intelligence.py new file mode 100644 index 0000000..bf01b8e --- /dev/null +++ b/app/src/trading/auto/position_intelligence.py @@ -0,0 +1,420 @@ +# app/src/trading/auto/position_intelligence.py + +from __future__ import annotations + +from src.core.numbers import safe_float +from src.trading.auto.state import AutoTradeState + + +class AutoPositionIntelligenceMixin: + # синхронизировать intelligence-состояние открытой позиции + def _sync_position_intelligence_state(self, state: AutoTradeState) -> None: + if state.position_side == "NONE" or state.entry_price is None: + state.position_lifecycle_stage = None + state.position_hold_quality = None + state.position_decay_state = None + state.position_exit_confidence = None + state.position_exit_signal = None + state.position_intelligence_reason = None + state.position_recommended_action = None + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + return + + lifecycle_stage = self._position_lifecycle_stage(state) + hold_quality = self._position_hold_quality(state) + decay_state = self._position_decay_state(state) + + self._sync_advanced_position_analytics( + state=state, + lifecycle_stage=lifecycle_stage, + hold_quality=hold_quality, + decay_state=decay_state, + ) + + exit_confidence = self._position_exit_confidence( + state=state, + hold_quality=hold_quality, + decay_state=decay_state, + ) + + exit_signal = self._position_exit_signal(exit_confidence) + + state.position_lifecycle_stage = lifecycle_stage + state.position_hold_quality = hold_quality + state.position_decay_state = decay_state + state.position_exit_confidence = exit_confidence + state.position_exit_signal = exit_signal + state.position_intelligence_reason = self._position_intelligence_reason( + state=state, + hold_quality=hold_quality, + decay_state=decay_state, + exit_signal=exit_signal, + ) + state.position_recommended_action = self._position_recommended_action( + exit_signal + ) + + # определить lifecycle stage позиции по времени удержания + def _position_lifecycle_stage(self, state: AutoTradeState) -> str: + hold_seconds = state.position_hold_seconds + + if hold_seconds is None: + return "UNKNOWN" + + if hold_seconds < 60: + return "NEW" + + if hold_seconds < 300: + return "ACTIVE" + + if hold_seconds < 900: + return "MATURE" + + return "AGED" + + # определить качество удержания позиции + def _position_hold_quality(self, state: AutoTradeState) -> str: + health_status = str(state.position_health_status or "").upper() + pressure = str(state.position_pressure or "").upper() + trend_alignment = str(state.position_trend_alignment or "").upper() + + if health_status == "DANGER": + return "BAD" + + if pressure == "HIGH_LOSS": + return "BAD" + + if trend_alignment == "AGAINST" and state.position_adverse_momentum: + return "BAD" + + if health_status == "PRESSURE": + return "WEAK" + + if pressure == "LOSS": + return "WEAK" + + if pressure in {"PROFIT", "STRONG_PROFIT"} and trend_alignment == "ALIGNED": + return "GOOD" + + if health_status == "HEALTHY": + return "GOOD" + + return "NEUTRAL" + + # определить тип ухудшения позиции + def _position_decay_state(self, state: AutoTradeState) -> str: + pressure = str(state.position_pressure or "").upper() + trend_alignment = str(state.position_trend_alignment or "").upper() + lifecycle = str(state.position_lifecycle_stage or "").upper() + + if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum: + return "ACCELERATING_LOSS" + + if trend_alignment == "AGAINST" and state.position_adverse_momentum: + return "CONTEXT_DECAY" + + if pressure == "PROFIT" and state.position_adverse_momentum: + return "PROFIT_DECAY" + + if lifecycle == "AGED" and pressure == "FLAT": + return "TIME_DECAY" + + return "NONE" + + # рассчитать confidence для выхода из позиции + def _position_exit_confidence( + self, + *, + state: AutoTradeState, + hold_quality: str, + decay_state: str, + ) -> float: + score = 0.0 + + risk_level = str(state.position_risk_level or "").upper() + exit_pressure = str(state.position_exit_pressure or "").upper() + + if risk_level == "HIGH": + score += 0.45 + elif risk_level == "ELEVATED": + score += 0.30 + elif risk_level == "MODERATE": + score += 0.15 + + if exit_pressure == "HIGH": + score += 0.30 + elif exit_pressure == "WATCH": + score += 0.15 + + if hold_quality == "BAD": + score += 0.25 + elif hold_quality == "WEAK": + score += 0.15 + + if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: + score += 0.25 + elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: + score += 0.15 + + if state.execution_quality == "BLOCKED": + score += 0.10 + + return round(max(0.0, min(1.0, score)), 3) + + # определить semantic exit signal по confidence + def _position_exit_signal(self, exit_confidence: float | None) -> str: + if exit_confidence is None: + return "NONE" + + if exit_confidence >= 0.75: + return "EXIT" + + if exit_confidence >= 0.50: + return "REDUCE_OR_PROTECT" + + if exit_confidence >= 0.30: + return "WATCH" + + return "HOLD" + + # сформировать объяснение position intelligence + def _position_intelligence_reason( + self, + *, + state: AutoTradeState, + hold_quality: str, + decay_state: str, + exit_signal: str, + ) -> str: + if exit_signal == "EXIT": + return "позиция требует выхода" + + if exit_signal == "REDUCE_OR_PROTECT": + return "позицию нужно защитить или уменьшить" + + if decay_state != "NONE": + return "качество удержания ухудшается" + + if hold_quality == "GOOD": + return "позицию можно удерживать" + + if hold_quality == "WEAK": + return "позиция требует наблюдения" + + return "критичных признаков выхода нет" + + # определить рекомендуемое действие по exit signal + def _position_recommended_action(self, exit_signal: str | None) -> str: + if exit_signal == "EXIT": + return "CLOSE" + + if exit_signal == "REDUCE_OR_PROTECT": + return "PROTECT" + + if exit_signal == "WATCH": + return "WATCH" + + return "HOLD" + + # синхронизировать advanced analytics позиции + def _sync_advanced_position_analytics( + self, + *, + state: AutoTradeState, + lifecycle_stage: str, + hold_quality: str, + decay_state: str, + ) -> None: + pnl = safe_float(state.unrealized_pnl_usd) + pnl_percent = safe_float(state.position_pnl_percent) + + peak_pnl = safe_float(state.position_peak_pnl_usd) + peak_pnl_percent = safe_float(state.position_peak_pnl_percent) + + if pnl is not None: + if peak_pnl is None or pnl > peak_pnl: + state.position_peak_pnl_usd = pnl + + if pnl_percent is not None: + if peak_pnl_percent is None or pnl_percent > peak_pnl_percent: + state.position_peak_pnl_percent = pnl_percent + + state.position_mfe_percent = self._position_mfe_percent(state) + state.position_mae_percent = self._position_mae_percent(state) + state.position_giveback_percent = self._position_giveback_percent(state) + + fatigue_score = self._position_fatigue_score( + state=state, + lifecycle_stage=lifecycle_stage, + hold_quality=hold_quality, + decay_state=decay_state, + ) + + state.position_fatigue_score = fatigue_score + state.position_fatigue_state = self._position_fatigue_state(fatigue_score) + state.position_conviction_state = self._position_conviction_state(state) + state.position_exit_urgency = self._position_exit_urgency(state) + state.position_reversal_risk = self._position_reversal_risk(state) + + # рассчитать maximum favorable excursion позиции + def _position_mfe_percent(self, state: AutoTradeState) -> float | None: + peak = safe_float(state.position_peak_pnl_percent) + + if peak is None: + return None + + return round(max(0.0, peak), 4) + + # рассчитать maximum adverse excursion позиции + def _position_mae_percent(self, state: AutoTradeState) -> float | None: + current = safe_float(state.position_pnl_percent) + + if current is None: + return None + + return round(min(0.0, current), 4) + + # рассчитать процент отдачи прибыли от peak pnl + def _position_giveback_percent(self, state: AutoTradeState) -> float | None: + peak = safe_float(state.position_peak_pnl_percent) + current = safe_float(state.position_pnl_percent) + + if peak is None or current is None: + return None + + if peak <= 0: + return 0.0 + + giveback = peak - current + + if giveback <= 0: + return 0.0 + + return round((giveback / peak) * 100, 2) + + # рассчитать fatigue score позиции + def _position_fatigue_score( + self, + *, + state: AutoTradeState, + lifecycle_stage: str, + hold_quality: str, + decay_state: str, + ) -> float: + score = 0.0 + + giveback = safe_float(state.position_giveback_percent) or 0.0 + hold_seconds = safe_float(state.position_hold_seconds) or 0.0 + + if lifecycle_stage == "AGED": + score += 0.25 + elif lifecycle_stage == "MATURE": + score += 0.15 + + if hold_quality == "BAD": + score += 0.30 + elif hold_quality == "WEAK": + score += 0.18 + + if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: + score += 0.30 + elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: + score += 0.18 + + if giveback >= 70: + score += 0.30 + elif giveback >= 45: + score += 0.20 + elif giveback >= 25: + score += 0.10 + + if hold_seconds >= 1800: + score += 0.15 + elif hold_seconds >= 900: + score += 0.08 + + if state.position_adverse_momentum: + score += 0.15 + + return round(max(0.0, min(1.0, score)), 3) + + # определить fatigue state позиции + def _position_fatigue_state(self, score: float | None) -> str: + value = safe_float(score) + + if value is None: + return "UNKNOWN" + + if value >= 0.75: + return "EXHAUSTED" + + if value >= 0.50: + return "TIRED" + + if value >= 0.25: + return "WATCH" + + return "FRESH" + + # определить conviction state позиции + def _position_conviction_state(self, state: AutoTradeState) -> str: + health = str(state.position_health_status or "").upper() + fatigue = str(state.position_fatigue_state or "").upper() + alignment = str(state.position_trend_alignment or "").upper() + + if health == "DANGER" or fatigue == "EXHAUSTED": + return "BROKEN" + + if alignment == "AGAINST" or fatigue == "TIRED": + return "WEAKENING" + + if health == "HEALTHY" and alignment == "ALIGNED": + return "STRONG" + + return "NEUTRAL" + + # определить срочность выхода из позиции + def _position_exit_urgency(self, state: AutoTradeState) -> str: + exit_signal = str(state.position_exit_signal or "").upper() + fatigue = str(state.position_fatigue_state or "").upper() + risk = str(state.position_risk_level or "").upper() + + if exit_signal == "EXIT" or risk == "HIGH": + return "IMMEDIATE" + + if fatigue == "EXHAUSTED": + return "HIGH" + + if exit_signal == "REDUCE_OR_PROTECT" or fatigue == "TIRED": + return "MEDIUM" + + if exit_signal == "WATCH": + return "LOW" + + return "NONE" + + # определить риск разворота позиции + def _position_reversal_risk(self, state: AutoTradeState) -> str: + giveback = safe_float(state.position_giveback_percent) or 0.0 + fatigue = str(state.position_fatigue_state or "").upper() + adverse = bool(state.position_adverse_momentum) + + if adverse and giveback >= 45: + return "HIGH" + + if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25: + return "ELEVATED" + + if adverse: + return "MODERATE" + + return "LOW" \ No newline at end of file diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 46fc68b..9ef9b45 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -400,6 +400,57 @@ class AutoTradeRunner: except Exception: pass + @classmethod + def _signal_price_payload( + cls, + *, + state, + payload: JsonDict, + signal: str, + ) -> JsonDict: + bid_price = safe_float(payload.get("bid_price")) + ask_price = safe_float(payload.get("ask_price")) + last_price = safe_float(payload.get("last_price")) + + if bid_price is None: + bid_price = safe_float(getattr(state, "execution_bid_price", None)) + + if ask_price is None: + ask_price = safe_float(getattr(state, "execution_ask_price", None)) + + if last_price is None: + last_price = safe_float(getattr(state, "execution_last_price", None)) + + signal_price = None + signal_price_role = "last" + + if signal == "BUY": + signal_price = ask_price or last_price or bid_price + signal_price_role = "ask" + + elif signal == "SELL": + signal_price = bid_price or last_price or ask_price + signal_price_role = "bid" + + else: + signal_price = last_price or ask_price or bid_price + + return { + "bid_price": bid_price, + "ask_price": ask_price, + "last_price": last_price, + "signal_price": signal_price, + "signal_price_role": signal_price_role, + "signal_price_source": ( + payload.get("price_source") + or getattr(state, "execution_price_source", None) + ), + "signal_price_age_seconds": ( + payload.get("price_age_seconds") + or getattr(state, "execution_price_age_seconds", None) + ), + } + @classmethod def _publish_strong_signal_event( cls, @@ -410,6 +461,7 @@ class AutoTradeRunner: 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_value = ( payload.get("repeat_count") if payload.get("repeat_count") is not None @@ -417,18 +469,35 @@ class AutoTradeRunner: ) repeat_count = int(safe_float(repeat_count_value) or 0) - confidence = safe_float( - payload.get("confidence") - ) + + 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 + + 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") + position_context = str(getattr(state, "position_side", "NONE") or "NONE").upper() + is_aligned_signal = cls._is_position_aligned_signal( + state=state, + signal=signal, + ) + + price_payload = cls._signal_price_payload( + state=state, + payload=payload, + signal=signal, + ) + + semantic_lines = cls._notification_reason_lines(state) priority = cls._alert_priority( confidence=confidence, @@ -449,12 +518,11 @@ class AutoTradeRunner: "leverage": leverage, "reason": reason, "position_context": position_context, - "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"), + "is_position_aligned_signal": is_aligned_signal, + "decision_status": state.decision_status, + "semantic_lines": semantic_lines, + **price_payload, }, priority=priority.lower(), dedupe_key=( @@ -466,7 +534,8 @@ class AutoTradeRunner: f"{repeat_count}:" f"{confidence:.2f}:" f"{state.decision_status}:" - f"{reason}" + f"{reason}:" + f"aligned={is_aligned_signal}" ), ) ) diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index 0121e86..15e616a 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -3,55 +3,150 @@ from __future__ import annotations import asyncio -import time -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.core.types import JsonDict +from src.trading.auto.auto_lifecycle import AutoLifecycleMixin from src.trading.auto.state import AutoTradeState -from src.trading.execution.engine import ExecutionEngine -from src.trading.journal.service import JournalService -from src.trading.strategies.base import BaseStrategy, StrategyContext -from src.trading.strategies.registry import StrategyRegistry -from src.integrations.exchange.service import ExchangeService -class AutoTradeService: +class AutoTradeService(AutoLifecycleMixin): + + # ========================================================= + # GLOBAL SERVICE STATE + # ========================================================= + + # единый runtime state автоторговли + # хранит: + # - сигналы + # - market context + # - execution context + # - pnl + # - lifecycle + # - protection state _state = AutoTradeState() + + # background asyncio loop task + # нужен для continuous auto-trading worker + # None -> loop не запущен _loop_task: asyncio.Task | None = None + + # интервал между auto-trading циклами + # run_cycle() вызывается каждые N секунд _loop_interval_seconds = 5 - # минимальное количество повторов BUY / SELL для подтверждения сигнала + # ========================================================= + # SIGNAL CONFIRMATION ENGINE + # ========================================================= + + # минимальное количество одинаковых BUY/SELL подряд + # чтобы сигнал считался подтвержденным _confirm_repeats = 2 - # минимальное время удержания BUY / SELL сигнала для подтверждения + # минимальное время удержания сигнала + # перед execution _confirm_min_duration_seconds = 10 - # минимальная уверенность для готовности к будущему execution + # ========================================================= + # EXECUTION CONFIDENCE RULES + # ========================================================= + + # минимальный confidence для READY state + # ниже -> сигнал не считается готовым _ready_confidence = 0.3 - # минимальный итоговый execution confidence для допуска входа + + # минимальный execution confidence + # для реального допуска execution engine _execution_confidence_required_score = 0.55 + # ========================================================= + # RUNTIME TTL + # ========================================================= + + # время жизни signal runtime + # после ttl сигнал считается устаревшим _signal_ttl_seconds = 90 + + # время жизни market analysis runtime + # после ttl market context считается stale _market_analysis_ttl_seconds = 180 + + # последний logged runtime expiration key + # нужен чтобы не спамить одинаковыми логами _last_logged_runtime_expired_key: str | None = None + # ========================================================= + # SIGNAL MEMORY + # ========================================================= + + # уникальный ключ последнего сигнала + # используется для deduplication _last_signal_key: str | None = None + + # последнее signal значение: + # BUY / SELL / HOLD _last_signal_value: str | None = None + + # последнее объяснение сигнала _last_signal_reason: str = "" + + # confidence последнего сигнала _last_signal_confidence: float = 0.0 + + # полный payload последнего signal анализа + # содержит market metrics / indicators / runtime info _last_signal_payload: JsonDict | None = None + + # monotonic timestamp начала сигнала + # нужен для confirmation timing _last_signal_started_at: float | None = None + + # ========================================================= + # MARKET STATE LOG MEMORY + # ========================================================= + + # последние logged market states + # нужны чтобы не дублировать одинаковые runtime logs + _last_logged_market_state: str | None = None _last_logged_market_trend: str | None = None _last_logged_market_volatility: str | None = None + + # последнее logged reason блокировки входа _last_logged_entry_block_reason: str | None = None + + # количество одинаковых сигналов подряд + # используется confirmation engine _same_signal_count = 0 + # ========================================================= + # EXECUTION SNAPSHOT VALIDATION + # ========================================================= + + # максимальный допустимый возраст execution snapshot + # старше -> snapshot stale _max_snapshot_age_seconds = 5.0 + + # warning threshold snapshot age + # выше -> degraded execution quality _warning_snapshot_age_seconds = 2.0 + + # ========================================================= + # SPREAD RISK THRESHOLDS + # ========================================================= + + # asset-specific spread thresholds + # + # warning_enter: + # warning при входе + # + # warning_exit: + # warning при выходе + # + # block_enter: + # полный блок входа + # + # block_exit: + # полный блок выхода _spread_thresholds_by_asset: dict[str, dict[str, float]] = { "BTC": { "warning_enter": 0.08, @@ -79,2580 +174,15 @@ class AutoTradeService: }, } + # default spread thresholds + # используются если asset не найден _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: NumericLike | None, - ) -> tuple[str | None, str | None, str | None, bool]: - 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 > block_exit: - return "BLOCKED", "HIGH_SPREAD", "высокий spread", False - - 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 >= block_enter: - return "BLOCKED", "HIGH_SPREAD", "высокий spread", False - - if spread > warning_exit: - return "WARNING", "WIDE_SPREAD", "spread повышен", False - - return "GOOD", "MARKET_OK", "рынок готов", False - - if spread >= block_enter: - return "BLOCKED", "HIGH_SPREAD", "высокий spread", False - - if spread >= warning_enter: - return "WARNING", "WIDE_SPREAD", "spread повышен", False - - return "GOOD", "MARKET_OK", "рынок готов", False - - # debug: принудительно выставить сигнал и decision - def debug_force_signal( - self, - *, - signal: str, - 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"}: - normalized_signal = "HOLD" - - previous_signal = state.last_signal - previous_decision_status = state.decision_status - - if previous_signal != normalized_signal or state.signal_started_at is None: - state.signal_started_at = time.monotonic() - - state.last_signal = normalized_signal - state.last_signal_repeat_count = repeat_count - 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 - state.signal_confirmation_missing_repeats = 0 - state.signal_confirmation_progress = 1.0 - state.signal_confirmation_reason = "debug confirmation" - - if normalized_signal == "HOLD": - state.decision_status = "WAITING" - state.decision_reason = "Debug HOLD." - state.is_signal_confirmed = False - state.is_signal_ready = False - else: - state.decision_status = "READY" - state.decision_reason = "Debug READY signal." - state.is_signal_confirmed = True - state.is_signal_ready = True - - signal_intent = self._signal_intent( - state=state, - signal=state.last_signal, - ) - - EventBus.emit( - "auto_decision_changed", - { - "previous_signal": previous_signal, - "previous_decision_status": previous_decision_status, - "decision_status": state.decision_status, - "signal": state.last_signal, - "signal_intent": signal_intent, - "repeat_count": state.last_signal_repeat_count, - "confidence": state.last_signal_confidence, - "symbol": state.symbol, - "strategy": state.strategy, - "leverage": state.leverage, - "reason": state.last_signal_reason, - "debug": True, - }, - ) - - return state - - # установить капитал, выделенный под автоторговлю - def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState: - state = self.get_state() - - numeric_value = safe_float(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 - - # получить текущее состояние автоторговли - def get_state(self) -> AutoTradeState: - if not self._state.symbol: - self._state.symbol = load_settings().default_symbol - return self._state - - # проверить, запущен ли background loop - def is_loop_running(self) -> bool: - return self._loop_task is not None and not self._loop_task.done() - - # запустить background loop, если он ещё не запущен - def start_loop(self) -> None: - if self.is_loop_running(): - return - - self._loop_task = asyncio.create_task(self._loop_worker()) - - # остановить background loop - def stop_loop(self) -> None: - if self._loop_task is None: - return - - self._loop_task.cancel() - self._loop_task = None - - # рабочий цикл автоторговли - async def _loop_worker(self) -> None: - while True: - state = self.get_state() - - if state.status == "OFF": - break - - self.run_cycle() - await asyncio.sleep(self._loop_interval_seconds) - - # запустить активную торговлю - def start(self) -> tuple[AutoTradeState, str]: - state = self.get_state() - previous_status = state.status - - if state.status == "RUNNING": - return state, "Автоторговля уже активна." - - if state.status == "OBSERVING": - state.status = "RUNNING" - EventBus.emit( - "auto_status_changed", - { - "previous_status": previous_status, - "status": state.status, - }, - ) - return state, "Автоторговля активирована." - - 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 - state.last_flip_reason = None - state.last_flip_monotonic_at = None - state.last_signal = "HOLD" - state.signal_started_at = time.monotonic() - - EventBus.emit( - "auto_status_changed", - { - "previous_status": previous_status, - "status": state.status, - }, - ) - return state, "Автоторговля запущена." - - # включить режим наблюдения - def observe(self) -> tuple[AutoTradeState, str]: - state = self.get_state() - previous_status = state.status - - if previous_status == "OBSERVING": - return state, "Режим наблюдения уже включён." - - state.status = "OBSERVING" - - EventBus.emit( - "auto_status_changed", - { - "previous_status": previous_status, - "status": state.status, - }, - ) - - 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 - state.last_flip_reason = None - state.last_flip_monotonic_at = None - return state, "Включён режим наблюдения." - - return state, "Автоторговля переведена в режим наблюдения." - - # полностью выключить автоторговлю - def stop(self) -> tuple[AutoTradeState, str]: - state = self.get_state() - previous_status = state.status - - if state.status == "OFF": - self.stop_loop() - return state, "Автоторговля уже выключена." - - 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 - state.last_flip_reason = None - state.last_flip_monotonic_at = None - self.stop_loop() - - EventBus.emit( - "auto_status_changed", - { - "previous_status": previous_status, - "status": state.status, - }, - ) - - return state, "Автоторговля выключена." - - # установить инструмент - def set_symbol(self, symbol: str) -> AutoTradeState: - state = self.get_state() - previous_symbol = state.symbol - - state.symbol = symbol - self._reset_signal_tracking() - - StrategyRegistry.reset_runtime(symbol=previous_symbol) - StrategyRegistry.reset_runtime(symbol=symbol) - - return state - - # установить стратегию - def set_strategy(self, strategy: str) -> AutoTradeState: - state = self.get_state() - previous_strategy = state.strategy - normalized_strategy = strategy.strip().upper() - - state.strategy = normalized_strategy - self._reset_signal_tracking() - - StrategyRegistry.reset_runtime(previous_strategy) - StrategyRegistry.reset_runtime(normalized_strategy) - - return state - - # установить риск - def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState: - state = self.get_state() - state.risk_percent = safe_float(risk_percent) - return state - - # установить плечо - def set_leverage(self, leverage: NumericLike) -> AutoTradeState: - state = self.get_state() - state.leverage = safe_float(leverage) - return state - - # установить stop loss в % - def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState: - state = self.get_state() - state.stop_loss_percent = safe_float(value) - return state - - # установить take profit в % - def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState: - state = self.get_state() - state.take_profit_percent = safe_float(value) - return state - - # установить max loss в USD - def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState: - state = self.get_state() - state.max_loss_usd = safe_float(value) - return state - - # установить максимальное использование баланса под маржу - def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState: - state = self.get_state() - state.max_reserved_balance_percent = safe_float(value) - state.execution_block_reason = None - return state - - # сбросить внутренний трекинг сигналов - def _reset_signal_tracking(self) -> None: - self._last_signal_key = None - self._last_signal_value = None - self._last_signal_reason = "" - self._last_signal_confidence = 0.0 - self._last_signal_payload = None - self._last_signal_started_at = None - self._same_signal_count = 0 - - state = self.get_state() - - state.adaptive_size_base = None - state.adaptive_size_final = None - state.adaptive_size_multiplier = None - state.adaptive_size_reason = None - 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 - state.decision_status = "WAITING" - state.decision_reason = None - state.is_signal_confirmed = False - state.is_signal_ready = False - state.signal_confirmation_seconds = 0 - state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds - 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 - state.execution_semantic_reason = None - state.execution_quality = None - state.execution_quality_reason = None - state.execution_quality_message = None - state.execution_price_source = None - state.execution_price_age_seconds = None - state.execution_bid_price = None - state.execution_ask_price = None - state.execution_last_price = None - state.execution_price_freshness = None - state.execution_confidence_score = None - state.execution_confidence_level = None - state.execution_confidence_required_score = self._execution_confidence_required_score - state.execution_confidence_reason = None - state.execution_confidence_factors = None - - state.market_state = None - state.market_trend = None - state.market_volatility = None - state.market_analysis_interval = None - state.market_analysis_reason = None - state.market_analysis_updated_at = None - state.market_runtime_degraded = False - state.market_trend_strength = None - 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 - state.momentum_strength = None - 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 - state.spread_percent = None - - state.position_pnl_percent = None - state.position_hold_seconds = None - state.position_pressure = None - state.position_health_score = None - state.position_health_status = None - state.position_health_reason = None - state.position_risk_level = None - state.position_risk_reason = None - state.position_trend_alignment = None - state.position_adverse_momentum = False - state.position_exit_pressure = None - - state.position_lifecycle_stage = None - state.position_hold_quality = None - state.position_decay_state = None - state.position_exit_confidence = None - state.position_exit_signal = None - state.position_intelligence_reason = None - state.position_recommended_action = None - - state.position_peak_pnl_usd = None - state.position_peak_pnl_percent = None - state.position_mfe_percent = None - state.position_mae_percent = None - state.position_fatigue_score = None - state.position_fatigue_state = None - state.position_giveback_percent = None - state.position_conviction_state = None - state.position_exit_urgency = None - state.position_reversal_risk = None - - state.autonomous_action = None - state.autonomous_action_reason = None - state.autonomous_action_confidence = None - state.autonomous_protection_required = False - state.autonomous_reduce_required = False - state.autonomous_exit_required = False - state.autonomous_last_action = None - state.autonomous_last_action_reason = None - state.autonomous_last_action_at = None - - state.last_loss_monotonic_at = None - - # собрать контекст для стратегии - def _build_strategy_context(self) -> StrategyContext: - state = self.get_state() - - return StrategyContext( - symbol=state.symbol, - status=state.status, - risk_percent=state.risk_percent, - ) - - # получить стратегию для текущего цикла - def _get_strategy(self) -> BaseStrategy: - state = self.get_state() - return StrategyRegistry.get(state.strategy) - - # определить смысл сигнала с учетом открытой позиции - def _signal_intent(self, *, state: AutoTradeState, signal: str | None) -> str: - normalized_signal = (signal or "HOLD").upper() - position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper() - - if normalized_signal == "HOLD": - return "HOLD_MARKET" - - if normalized_signal not in {"BUY", "SELL"}: - return "NOISE" - - if position_side == "NONE": - return "ENTRY_CANDIDATE" - - if position_side == "LONG" and normalized_signal == "BUY": - return "REINFORCE_POSITION" - - if position_side == "SHORT" and normalized_signal == "SELL": - return "REINFORCE_POSITION" - - if position_side == "LONG" and normalized_signal == "SELL": - return "REVERSAL_CANDIDATE" - - if position_side == "SHORT" and normalized_signal == "BUY": - return "REVERSAL_CANDIDATE" - - return "NOISE" - - # обновить статус решения по текущему сигналу - def _update_decision_state( - self, - *, - state: AutoTradeState, - signal: str, - confidence: float, - ) -> None: - state.is_signal_confirmed = False - state.is_signal_ready = False - - state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds - - if signal == "HOLD": - state.signal_confirmation_seconds = 0 - state.signal_confirmation_missing_repeats = self._confirm_repeats - state.signal_confirmation_progress = 0.0 - state.signal_confirmation_reason = None - state.decision_status = "WAITING" - state.decision_reason = "Нет торгового направления." - return - - now = time.monotonic() - - if state.signal_started_at is None: - signal_age_seconds = 0 - else: - 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( - 0, - self._confirm_min_duration_seconds - signal_age_seconds, - ) - - repeat_progress = min( - 1.0, - self._same_signal_count / max(1, self._confirm_repeats), - ) - time_progress = min( - 1.0, - signal_age_seconds / max(1, self._confirm_min_duration_seconds), - ) - - confirmation_progress = min(repeat_progress, time_progress) - - state.signal_confirmation_seconds = signal_age_seconds - state.signal_confirmation_missing_repeats = missing_repeats - state.signal_confirmation_progress = round(confirmation_progress, 3) - - if missing_repeats > 0 or missing_seconds > 0: - state.decision_status = "CONFIRMING" - state.signal_confirmation_reason = ( - f"{self._same_signal_count}/{self._confirm_repeats} повторов, " - f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с" - ) - state.decision_reason = ( - f"Сигнал {signal} подтверждается: " - f"{self._same_signal_count}/{self._confirm_repeats} повторов, " - f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с." - ) - return - - state.is_signal_confirmed = True - state.signal_confirmation_reason = "сигнал подтверждён" - - if confidence < self._ready_confidence: - state.decision_status = "BLOCKED" - state.decision_reason = ( - f"Сигнал {signal} подтверждён, но уверенность низкая: " - f"{confidence:.2f} < {self._ready_confidence:.2f}." - ) - return - - self._sync_execution_confidence_state( - state=state, - signal=signal, - confidence=confidence, - ) - - if ( - state.execution_confidence_score is not None - and state.execution_confidence_score < self._execution_confidence_required_score - ): - state.decision_status = "BLOCKED" - state.decision_reason = ( - f"Execution confidence низкий: " - f"{state.execution_confidence_score:.2f} < " - f"{self._execution_confidence_required_score:.2f}." - ) - return - - state.is_signal_ready = True - state.signal_confirmation_progress = 1.0 - state.decision_status = "READY" - state.decision_reason = ( - f"Сигнал {signal} подтверждён по повторам и времени удержания." - ) - - # записать новый сигнал и итог предыдущей серии при смене сигнала - def _log_signal_if_changed( - self, - *, - strategy_name: str, - state: AutoTradeState, - signal: str, - reason: str, - confidence: float, - payload: JsonDict | None, - ) -> None: - signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}" - previous_signal = self._last_signal_value - previous_count = self._same_signal_count - is_same_signal = signal_key == self._last_signal_key - now = time.monotonic() - - if is_same_signal: - self._same_signal_count += 1 - self._last_signal_reason = reason - self._last_signal_confidence = confidence - self._last_signal_payload = payload - - self._update_signal_state_fields( - state=state, - signal=signal, - reason=reason, - confidence=confidence, - ) - return - - if previous_signal is not None and previous_signal != signal: - if previous_count > 1: - self._log_signal_summary( - strategy_name=strategy_name, - state=state, - previous_signal=previous_signal, - previous_count=previous_count, - next_signal=signal, - reason=self._last_signal_reason, - confidence=self._last_signal_confidence, - payload=self._last_signal_payload, - duration_seconds=self._signal_duration_seconds(now=now), - ) - else: - self._log_signal_event( - strategy_name=strategy_name, - state=state, - signal=previous_signal, - reason=f"{previous_signal} завершился без серии.", - confidence=self._last_signal_confidence, - payload={ - "previous_signal": previous_signal, - "next_signal": signal, - }, - ) - - self._last_signal_key = signal_key - self._last_signal_value = signal - self._last_signal_reason = reason - self._last_signal_confidence = confidence - self._last_signal_payload = payload - self._last_signal_started_at = now - self._same_signal_count = 1 - - self._update_signal_state_fields( - state=state, - signal=signal, - reason=reason, - confidence=confidence, - ) - - def _signal_duration_seconds(self, *, now: float) -> int: - if self._last_signal_started_at is None: - return max(0, int(self._same_signal_count * self._loop_interval_seconds)) - - return max(0, int(now - self._last_signal_started_at)) - - def _format_duration(self, total_seconds: int) -> str: - total_seconds = max(0, int(total_seconds)) - - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - seconds = total_seconds % 60 - - if hours > 0: - return f"{hours}ч {minutes:02d}м {seconds:02d}с" - - if minutes > 0: - return f"{minutes}м {seconds:02d}с" - - return f"{seconds}с" - - # обновить поля state для экрана автоторговли - def _update_signal_state_fields( - self, - *, - state: AutoTradeState, - signal: str, - reason: str, - confidence: float, - ) -> None: - previous_signal = state.last_signal - previous_decision_status = state.decision_status - - if previous_signal != signal or state.signal_started_at is None: - state.signal_started_at = time.monotonic() - - state.last_signal = signal - state.last_signal_repeat_count = self._same_signal_count - state.last_signal_confidence = confidence - state.last_signal_reason = reason - state.signal_updated_at = time.monotonic() - state.runtime_expired_reason = None - state.runtime_expired_message = None - - self._update_decision_state( - state=state, - signal=signal, - confidence=confidence, - ) - - signal_intent = self._signal_intent( - state=state, - signal=state.last_signal, - ) - - if ( - previous_decision_status != state.decision_status - and state.decision_status == "READY" - ): - self._log_ready_signal( - state=state, - signal=state.last_signal, - reason=state.last_signal_reason or reason, - confidence=state.last_signal_confidence, - signal_intent=signal_intent, - ) - - if previous_signal != state.last_signal: - EventBus.emit( - "auto_signal_changed", - { - "previous_signal": previous_signal, - "signal": state.last_signal, - "signal_intent": signal_intent, - "repeat_count": state.last_signal_repeat_count, - "confidence": state.last_signal_confidence, - }, - ) - - if previous_decision_status != state.decision_status: - EventBus.emit( - "auto_decision_changed", - { - "previous_decision_status": previous_decision_status, - "decision_status": state.decision_status, - "signal": state.last_signal, - "signal_intent": signal_intent, - "repeat_count": state.last_signal_repeat_count, - "confidence": state.last_signal_confidence, - "symbol": state.symbol, - "strategy": state.strategy, - "leverage": state.leverage, - "reason": state.last_signal_reason, - }, - ) - - # одиночные BUY / SELL больше не пишем в журнал как полезные события - def _log_signal_event( - self, - *, - strategy_name: str, - state: AutoTradeState, - signal: str, - reason: str, - confidence: float, - payload: JsonDict | None, - ) -> None: - return - - # записать итог серии одинаковых сигналов при смене сигнала - def _log_signal_summary( - self, - *, - strategy_name: str, - state: AutoTradeState, - previous_signal: str, - previous_count: int, - next_signal: str, - reason: str, - confidence: float, - payload: JsonDict | None, - duration_seconds: int, - ) -> None: - if previous_signal != "HOLD": - return - - duration_text = self._format_duration(duration_seconds) - signal_intent = "HOLD_MARKET" - - try: - JournalService().log_ui_info( - event_type="signal_summary", - message=( - f"HOLD длился {duration_text} и завершился сигналом {next_signal}." - ), - screen="auto", - action="signal_summary", - payload={ - "strategy": strategy_name, - "status": state.status, - "symbol": state.symbol, - "signal": previous_signal, - "next_signal": next_signal, - "signal_intent": signal_intent, - "repeat_count": previous_count, - "duration_seconds": duration_seconds, - "duration_text": duration_text, - "confidence": confidence, - "reason": reason, - "is_strong_signal": False, - "is_aggregated": True, - "payload": payload or {}, - }, - ) - except Exception: - pass - - def _log_ready_signal( - self, - *, - state: AutoTradeState, - signal: str | None, - reason: str, - confidence: float, - signal_intent: str, - ) -> None: - normalized_signal = (signal or "HOLD").upper() - 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", - message=( - f"Сигнал {normalized_signal} подтверждён и готов к исполнению." - ), - screen="auto", - action="signal_ready", - payload={ - "strategy": state.strategy, - "status": state.status, - "symbol": state.symbol, - "signal": normalized_signal, - "signal_intent": signal_intent, - "confidence": confidence, - "reason": reason, - "repeat_count": state.last_signal_repeat_count, - "position_side": state.position_side, - "decision_status": state.decision_status, - "is_strong_signal": confidence > self._ready_confidence, - "is_aggregated": False, - "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: - pass - - def _sync_market_analysis_state( - self, - *, - state: AutoTradeState, - payload: JsonDict | None, - ) -> None: - if not isinstance(payload, dict): - return - - previous_market_state = state.market_state - previous_market_trend = state.market_trend - previous_market_volatility = state.market_volatility - - 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 = 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, - payload=payload, - previous_market_state=previous_market_state, - previous_market_trend=previous_market_trend, - previous_market_volatility=previous_market_volatility, - ) - - self._log_entry_block_if_changed( - state=state, - payload=payload, - ) - - def _log_entry_block_if_changed( - self, - *, - state: AutoTradeState, - payload: JsonDict, - ) -> None: - reason = state.entry_block_reason - message = state.entry_block_message - - if not reason or not message: - return - - key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}:{message}" - - if key == type(self)._last_logged_entry_block_reason: - return - - type(self)._last_logged_entry_block_reason = key - - try: - JournalService().log_ui_info( - event_type="entry_blocked", - message=f"Вход в позицию не выполнен: {message}.", - screen="auto", - action="entry_diagnostics", - payload={ - **payload, - "entry_block_reason": reason, - "entry_block_message": message, - "symbol": state.symbol, - "strategy": state.strategy, - "status": state.status, - }, - ) - except Exception: - pass - - def _log_market_state_if_changed( - self, - *, - state: AutoTradeState, - payload: JsonDict, - previous_market_state: str | None, - previous_market_trend: str | None, - previous_market_volatility: str | None, - ) -> None: - market_state = state.market_state - market_trend = state.market_trend - market_volatility = state.market_volatility - - if not market_state or market_state == "UNKNOWN": - return - - state_changed = ( - market_state != previous_market_state - and market_state != type(self)._last_logged_market_state - ) - - volatility_changed = ( - market_volatility is not None - and market_volatility != previous_market_volatility - and market_volatility != type(self)._last_logged_market_volatility - ) - - if not state_changed and not volatility_changed: - return - - journal_payload = { - **payload, - "previous_market_state": previous_market_state, - "previous_market_trend": previous_market_trend, - "previous_market_volatility": previous_market_volatility, - "current_market_state": market_state, - "current_market_trend": market_trend, - "current_market_volatility": market_volatility, - } - - try: - if state_changed: - self._write_market_journal_event( - event_type="market_state_changed", - market_state=market_state, - message=self._market_state_message(market_state), - payload=journal_payload, - ) - - if volatility_changed: - self._write_market_journal_event( - event_type="market_volatility_changed", - market_state=market_state, - message=self._market_volatility_message(market_volatility), - payload=journal_payload, - ) - except Exception: - pass - - type(self)._last_logged_market_state = market_state - type(self)._last_logged_market_trend = market_trend - type(self)._last_logged_market_volatility = market_volatility - - def _write_market_journal_event( - self, - *, - event_type: str, - market_state: str, - message: str, - payload: JsonDict, - ) -> None: - level = self._market_journal_level(market_state) - - if level == "WARNING": - JournalService().log_ui_warning( - event_type=event_type, - message=message, - screen="auto", - action="market_analysis", - payload=payload, - ) - return - - JournalService().log_ui_info( - event_type=event_type, - message=message, - screen="auto", - action="market_analysis", - payload=payload, - ) - - def _market_volatility_message(self, market_volatility: str | None) -> str: - messages = { - "LOW": "Волатильность изменена: низкая.", - "NORMAL": "Волатильность изменена: нормальная.", - "HIGH": "Волатильность изменена: высокая.", - } - - return messages.get(str(market_volatility or ""), "Волатильность не определена.") - - def _market_journal_level(self, market_state: str | None) -> str: - if market_state == "HIGH_VOLATILITY": - return "WARNING" - - return "INFO" - - def _market_state_message(self, market_state: str) -> str: - messages = { - "TREND_UP": "Состояние рынка изменено: рост.", - "TREND_DOWN": "Состояние рынка изменено: снижение.", - "RANGE": "Состояние рынка изменено: нет выраженного направления.", - "HIGH_VOLATILITY": "Состояние рынка изменено: высокая волатильность.", - "LOW_VOLATILITY": "Состояние рынка изменено: низкая активность.", - } - - return messages.get(market_state, "Состояние рынка анализируется.") - - def _expire_runtime_if_needed(self, state: AutoTradeState) -> None: - now = time.monotonic() - - signal_updated_at = getattr(state, "signal_updated_at", None) - if signal_updated_at is not None: - 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 - - self._reset_signal_tracking() - - state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED" - state.runtime_expired_message = "сигнал устарел и был сброшен" - - self._log_runtime_expired_if_changed( - state=state, - reason="SIGNAL_TTL_EXPIRED", - message="Сигнал устарел и был сброшен.", - payload={ - "previous_signal": previous_signal, - "signal_age_seconds": int(signal_age), - "signal_ttl_seconds": self._signal_ttl_seconds, - }, - ) - - return - - market_updated_at = getattr(state, "market_analysis_updated_at", None) - if market_updated_at is not None: - 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 - state.market_trend = None - state.market_volatility = None - state.market_analysis_interval = None - state.market_analysis_reason = None - state.market_analysis_updated_at = None - state.entry_block_reason = None - state.entry_block_message = None - state.market_trend_strength = None - 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 - state.momentum_strength = None - state.breakout_level = None - state.breakout_distance_percent = None - state.breakout_reason = None - state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED" - state.runtime_expired_message = "анализ рынка устарел" - - self._log_runtime_expired_if_changed( - state=state, - reason="MARKET_ANALYSIS_TTL_EXPIRED", - message="Анализ рынка устарел и был сброшен.", - payload={ - "market_age_seconds": int(market_age), - "market_analysis_ttl_seconds": self._market_analysis_ttl_seconds, - }, - ) - - def _log_runtime_expired_if_changed( - self, - *, - state: AutoTradeState, - reason: str, - message: str, - payload: JsonDict, - ) -> None: - key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}" - - if key == type(self)._last_logged_runtime_expired_key: - return - - type(self)._last_logged_runtime_expired_key = key - - try: - JournalService().log_ui_warning( - event_type="runtime_expired", - message=message, - screen="auto", - action="runtime_expiration", - payload={ - **payload, - "symbol": state.symbol, - "strategy": state.strategy, - "status": state.status, - "runtime_expired_reason": reason, - }, - ) - except Exception: - pass - - def _sync_execution_quality_state(self, state: AutoTradeState) -> None: - try: - snapshot = ExchangeService().get_market_snapshot( - state.symbol, - runtime_key="auto", - ) - except Exception as exc: - fallback_price = None - - try: - fallback_price = safe_float( - ExchangeService().get_price( - state.symbol, - runtime_key="auto", - ).price - ) - except Exception: - pass - - state.snapshot_age_seconds = None - state.spread_percent = None - - if fallback_price is not None and fallback_price > 0: - state.execution_quality = "WARNING" - state.execution_quality_reason = "SNAPSHOT_UNAVAILABLE" - state.execution_quality_message = "нет depth snapshot" - state.market_runtime_degraded = True - else: - state.execution_quality = "BLOCKED" - state.execution_quality_reason = "SNAPSHOT_ERROR" - state.execution_quality_message = "нет данных рынка" - state.market_runtime_degraded = True - - self._log_execution_quality_if_changed( - state=state, - payload={ - "error": str(exc), - "error_type": type(exc).__name__, - "fallback_price_available": fallback_price is not None, - }, - ) - return - - 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 "") - - self._sync_execution_pricing_state( - state, - snapshot, - ) - - state.snapshot_age_seconds = age_seconds - state.spread_percent = self._spread_percent( - bid_price=bid_price, - ask_price=ask_price, - ) - - if age_seconds is not None and age_seconds > self._max_snapshot_age_seconds: - state.execution_quality = "BLOCKED" - state.execution_quality_reason = "STALE_SNAPSHOT" - state.execution_quality_message = "snapshot устарел" - state.market_runtime_degraded = True - - elif age_seconds is not None and age_seconds > self._warning_snapshot_age_seconds: - state.execution_quality = "WARNING" - state.execution_quality_reason = "AGING_SNAPSHOT" - state.execution_quality_message = "snapshot стареет" - state.market_runtime_degraded = not is_fresh - - elif state.spread_percent is not None: - ( - state.execution_quality, - state.execution_quality_reason, - state.execution_quality_message, - state.market_runtime_degraded, - ) = self._spread_execution_quality( - state=state, - spread_percent=state.spread_percent, - ) - - else: - state.execution_quality = "GOOD" - state.execution_quality_reason = "MARKET_OK" - state.execution_quality_message = "рынок готов" - state.market_runtime_degraded = False - - if state.execution_quality == "BLOCKED": - state.execution_block_reason = state.execution_quality_message - - 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={ - "symbol": state.symbol, - "strategy": state.strategy, - "bid_price": bid_price, - "ask_price": ask_price, - "last_price": last_price, - "snapshot_age_seconds": age_seconds, - "spread_percent": state.spread_percent, - "is_fresh": is_fresh, - "source": source, - "execution_quality": state.execution_quality, - "execution_quality_reason": state.execution_quality_reason, - "execution_quality_message": state.execution_quality_message, - "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_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: NumericLike | None, - ask_price: NumericLike | None, - ) -> float | None: - bid = safe_float(bid_price) - ask = safe_float(ask_price) - - if bid is None or ask is None: - return None - - if bid <= 0 or ask <= 0: - return None - - mid_price = (bid + ask) / 2 - - if mid_price <= 0: - return None - - spread = ask - bid - - if spread < 0: - return None - - return round((spread / mid_price) * 100, 5) - - def _sync_execution_pricing_state( - self, - state: AutoTradeState, - snapshot: JsonDict, - ) -> None: - age_seconds = safe_float(snapshot.get("age_seconds")) - - state.execution_price_source = str(snapshot.get("source") or "") - state.execution_price_age_seconds = age_seconds - state.execution_bid_price = safe_float(snapshot.get("bid_price")) - state.execution_ask_price = safe_float(snapshot.get("ask_price")) - state.execution_last_price = safe_float(snapshot.get("last_price")) - - if age_seconds is None: - state.execution_price_freshness = "UNKNOWN" - elif age_seconds <= 1: - state.execution_price_freshness = "FRESH" - elif age_seconds <= self._warning_snapshot_age_seconds: - state.execution_price_freshness = "AGING" - else: - state.execution_price_freshness = "STALE" - - def _sync_position_health_state(self, state: AutoTradeState) -> None: - if state.position_side == "NONE" or state.entry_price is None: - state.position_pnl_percent = None - state.position_hold_seconds = None - state.position_pressure = None - state.position_health_score = None - state.position_health_status = None - state.position_health_reason = None - state.position_risk_level = None - state.position_risk_reason = None - state.position_trend_alignment = None - state.position_adverse_momentum = False - state.position_exit_pressure = None - return - - pnl_percent = self._position_pnl_percent(state) - hold_seconds = self._position_hold_seconds(state) - trend_alignment = self._position_trend_alignment(state) - adverse_momentum = self._has_adverse_position_momentum(state) - - pressure = self._position_pressure( - state=state, - pnl_percent=pnl_percent, - ) - - health_score = self._position_health_score( - state=state, - pnl_percent=pnl_percent, - trend_alignment=trend_alignment, - adverse_momentum=adverse_momentum, - ) - - risk_level, risk_reason = self._position_risk_level( - state=state, - pnl_percent=pnl_percent, - trend_alignment=trend_alignment, - adverse_momentum=adverse_momentum, - ) - - state.position_pnl_percent = pnl_percent - state.position_hold_seconds = hold_seconds - state.position_pressure = pressure - state.position_health_score = health_score - state.position_health_status = self._position_health_status(health_score) - state.position_health_reason = self._position_health_reason( - pressure=pressure, - trend_alignment=trend_alignment, - adverse_momentum=adverse_momentum, - ) - state.position_risk_level = risk_level - state.position_risk_reason = risk_reason - state.position_trend_alignment = trend_alignment - state.position_adverse_momentum = adverse_momentum - state.position_exit_pressure = self._position_exit_pressure( - state=state, - pnl_percent=pnl_percent, - risk_level=risk_level, - ) - - def _position_pnl_percent(self, state: AutoTradeState) -> float | None: - entry_price = safe_float(state.entry_price) - size = safe_float(state.position_size) - pnl = safe_float(state.unrealized_pnl_usd) - - if entry_price is None or entry_price <= 0: - return None - - if size is None or size <= 0: - return None - - if pnl is None: - return None - - notional = entry_price * size - - if notional <= 0: - return None - - return round((pnl / notional) * 100, 4) - - def _position_hold_seconds(self, state: AutoTradeState) -> int | None: - opened_at = getattr(state, "position_opened_monotonic_at", None) - - if opened_at is None: - return None - - opened = safe_float(opened_at) - - if opened is None: - return None - - return max(0, int(time.monotonic() - opened)) - - def _position_pressure( - self, - *, - state: AutoTradeState, - pnl_percent: float | None, - ) -> str: - pnl = safe_float(state.unrealized_pnl_usd) or 0.0 - - if pnl_percent is None: - if pnl < 0: - return "LOSS" - if pnl > 0: - return "PROFIT" - return "FLAT" - - if pnl_percent <= -0.8: - return "HIGH_LOSS" - - if pnl_percent <= -0.3: - return "LOSS" - - if pnl_percent >= 0.8: - return "STRONG_PROFIT" - - if pnl_percent >= 0.3: - return "PROFIT" - - return "FLAT" - - def _position_trend_alignment(self, state: AutoTradeState) -> str: - side = str(state.position_side or "NONE").upper() - market_state = str(state.market_state or "").upper() - trend = str(state.market_trend or "").upper() - - if side == "NONE": - return "NONE" - - if side == "LONG": - if market_state == "TREND_UP" or trend == "UP": - return "ALIGNED" - if market_state == "TREND_DOWN" or trend == "DOWN": - return "AGAINST" - - if side == "SHORT": - if market_state == "TREND_DOWN" or trend == "DOWN": - return "ALIGNED" - if market_state == "TREND_UP" or trend == "UP": - return "AGAINST" - - return "NEUTRAL" - - def _has_adverse_position_momentum(self, state: AutoTradeState) -> bool: - side = str(state.position_side or "NONE").upper() - momentum_direction = str(state.momentum_direction or "").upper() - momentum_state = str(state.momentum_state or "").upper() - - if side == "LONG": - return ( - momentum_direction == "DOWN" - or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} - ) - - if side == "SHORT": - return ( - momentum_direction == "UP" - or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} - ) - - return False - - def _position_health_score( - self, - *, - state: AutoTradeState, - pnl_percent: float | None, - trend_alignment: str, - adverse_momentum: bool, - ) -> int: - score = 100 - - if pnl_percent is not None: - if pnl_percent <= -1.0: - score -= 35 - elif pnl_percent <= -0.5: - score -= 22 - elif pnl_percent < 0: - score -= 10 - elif pnl_percent >= 0.8: - score += 5 - - if trend_alignment == "AGAINST": - score -= 25 - elif trend_alignment == "NEUTRAL": - score -= 8 - - if adverse_momentum: - score -= 20 - - if state.execution_quality == "BLOCKED": - score -= 15 - elif state.execution_quality == "WARNING": - score -= 8 - - if state.market_runtime_degraded: - score -= 10 - - return max(0, min(100, score)) - - def _position_health_status(self, score: int | None) -> str: - if score is None: - return "UNKNOWN" - - if score >= 80: - return "HEALTHY" - - if score >= 55: - return "WATCH" - - if score >= 35: - return "PRESSURE" - - return "DANGER" - - def _position_health_reason( - self, - *, - pressure: str, - trend_alignment: str, - adverse_momentum: bool, - ) -> str: - if trend_alignment == "AGAINST" and adverse_momentum: - return "тренд и momentum против позиции" - - if trend_alignment == "AGAINST": - return "тренд против позиции" - - if adverse_momentum: - return "momentum против позиции" - - if pressure in {"HIGH_LOSS", "LOSS"}: - return "позиция под давлением" - - if pressure in {"PROFIT", "STRONG_PROFIT"}: - return "позиция в прибыли" - - return "позиция стабильна" - - def _position_risk_level( - self, - *, - state: AutoTradeState, - pnl_percent: float | None, - trend_alignment: str, - adverse_momentum: bool, - ) -> tuple[str, str]: - if state.execution_quality == "BLOCKED": - return "HIGH", "исполнение заблокировано" - - if pnl_percent is not None and pnl_percent <= -1.0: - return "HIGH", "сильная просадка позиции" - - if trend_alignment == "AGAINST" and adverse_momentum: - return "HIGH", "рынок движется против позиции" - - if pnl_percent is not None and pnl_percent < 0: - if trend_alignment == "AGAINST" or adverse_momentum: - return "ELEVATED", "убыток усиливается рыночным контекстом" - - return "MODERATE", "позиция в минусе" - - if adverse_momentum: - return "MODERATE", "momentum против позиции" - - return "LOW", "критичных рисков нет" - - def _position_exit_pressure( - self, - *, - state: AutoTradeState, - pnl_percent: float | None, - risk_level: str, - ) -> str: - if risk_level == "HIGH": - return "HIGH" - - if risk_level == "ELEVATED": - return "WATCH" - - if pnl_percent is not None and pnl_percent <= -0.5: - return "WATCH" - - return "LOW" - - def _sync_position_intelligence_state(self, state: AutoTradeState) -> None: - if state.position_side == "NONE" or state.entry_price is None: - state.position_lifecycle_stage = None - state.position_hold_quality = None - state.position_decay_state = None - state.position_exit_confidence = None - state.position_exit_signal = None - state.position_intelligence_reason = None - state.position_recommended_action = None - state.position_peak_pnl_usd = None - state.position_peak_pnl_percent = None - state.position_mfe_percent = None - state.position_mae_percent = None - state.position_fatigue_score = None - state.position_fatigue_state = None - state.position_giveback_percent = None - state.position_conviction_state = None - state.position_exit_urgency = None - state.position_reversal_risk = None - return - - lifecycle_stage = self._position_lifecycle_stage(state) - hold_quality = self._position_hold_quality(state) - decay_state = self._position_decay_state(state) - - self._sync_advanced_position_analytics( - state=state, - lifecycle_stage=lifecycle_stage, - hold_quality=hold_quality, - decay_state=decay_state, - ) - - exit_confidence = self._position_exit_confidence( - state=state, - hold_quality=hold_quality, - decay_state=decay_state, - ) - - exit_signal = self._position_exit_signal(exit_confidence) - - state.position_lifecycle_stage = lifecycle_stage - state.position_hold_quality = hold_quality - state.position_decay_state = decay_state - state.position_exit_confidence = exit_confidence - state.position_exit_signal = exit_signal - state.position_intelligence_reason = self._position_intelligence_reason( - state=state, - hold_quality=hold_quality, - decay_state=decay_state, - exit_signal=exit_signal, - ) - state.position_recommended_action = self._position_recommended_action( - exit_signal - ) - - def _position_lifecycle_stage(self, state: AutoTradeState) -> str: - hold_seconds = state.position_hold_seconds - - if hold_seconds is None: - return "UNKNOWN" - - if hold_seconds < 60: - return "NEW" - - if hold_seconds < 300: - return "ACTIVE" - - if hold_seconds < 900: - return "MATURE" - - return "AGED" - - def _position_hold_quality(self, state: AutoTradeState) -> str: - health_status = str(state.position_health_status or "").upper() - pressure = str(state.position_pressure or "").upper() - trend_alignment = str(state.position_trend_alignment or "").upper() - - if health_status == "DANGER": - return "BAD" - - if pressure == "HIGH_LOSS": - return "BAD" - - if trend_alignment == "AGAINST" and state.position_adverse_momentum: - return "BAD" - - if health_status == "PRESSURE": - return "WEAK" - - if pressure == "LOSS": - return "WEAK" - - if pressure in {"PROFIT", "STRONG_PROFIT"} and trend_alignment == "ALIGNED": - return "GOOD" - - if health_status == "HEALTHY": - return "GOOD" - - return "NEUTRAL" - - def _position_decay_state(self, state: AutoTradeState) -> str: - pressure = str(state.position_pressure or "").upper() - trend_alignment = str(state.position_trend_alignment or "").upper() - lifecycle = str(state.position_lifecycle_stage or "").upper() - - if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum: - return "ACCELERATING_LOSS" - - if trend_alignment == "AGAINST" and state.position_adverse_momentum: - return "CONTEXT_DECAY" - - if pressure == "PROFIT" and state.position_adverse_momentum: - return "PROFIT_DECAY" - - if lifecycle == "AGED" and pressure == "FLAT": - return "TIME_DECAY" - - return "NONE" - - def _position_exit_confidence( - self, - *, - state: AutoTradeState, - hold_quality: str, - decay_state: str, - ) -> float: - score = 0.0 - - risk_level = str(state.position_risk_level or "").upper() - exit_pressure = str(state.position_exit_pressure or "").upper() - - if risk_level == "HIGH": - score += 0.45 - elif risk_level == "ELEVATED": - score += 0.30 - elif risk_level == "MODERATE": - score += 0.15 - - if exit_pressure == "HIGH": - score += 0.30 - elif exit_pressure == "WATCH": - score += 0.15 - - if hold_quality == "BAD": - score += 0.25 - elif hold_quality == "WEAK": - score += 0.15 - - if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: - score += 0.25 - elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: - score += 0.15 - - if state.execution_quality == "BLOCKED": - score += 0.10 - - return round(max(0.0, min(1.0, score)), 3) - - def _position_exit_signal(self, exit_confidence: float | None) -> str: - if exit_confidence is None: - return "NONE" - - if exit_confidence >= 0.75: - return "EXIT" - - if exit_confidence >= 0.50: - return "REDUCE_OR_PROTECT" - - if exit_confidence >= 0.30: - return "WATCH" - - return "HOLD" - - def _position_intelligence_reason( - self, - *, - state: AutoTradeState, - hold_quality: str, - decay_state: str, - exit_signal: str, - ) -> str: - if exit_signal == "EXIT": - return "позиция требует выхода" - - if exit_signal == "REDUCE_OR_PROTECT": - return "позицию нужно защитить или уменьшить" - - if decay_state != "NONE": - return "качество удержания ухудшается" - - if hold_quality == "GOOD": - return "позицию можно удерживать" - - if hold_quality == "WEAK": - return "позиция требует наблюдения" - - return "критичных признаков выхода нет" - - def _position_recommended_action(self, exit_signal: str | None) -> str: - if exit_signal == "EXIT": - return "CLOSE" - - if exit_signal == "REDUCE_OR_PROTECT": - return "PROTECT" - - if exit_signal == "WATCH": - return "WATCH" - - return "HOLD" - - def _sync_advanced_position_analytics( - self, - *, - state: AutoTradeState, - lifecycle_stage: str, - hold_quality: str, - decay_state: str, - ) -> None: - pnl = safe_float(state.unrealized_pnl_usd) - pnl_percent = safe_float(state.position_pnl_percent) - - peak_pnl = safe_float(state.position_peak_pnl_usd) - peak_pnl_percent = safe_float(state.position_peak_pnl_percent) - - if pnl is not None: - if peak_pnl is None or pnl > peak_pnl: - state.position_peak_pnl_usd = pnl - - if pnl_percent is not None: - if peak_pnl_percent is None or pnl_percent > peak_pnl_percent: - state.position_peak_pnl_percent = pnl_percent - - state.position_mfe_percent = self._position_mfe_percent(state) - state.position_mae_percent = self._position_mae_percent(state) - state.position_giveback_percent = self._position_giveback_percent(state) - - fatigue_score = self._position_fatigue_score( - state=state, - lifecycle_stage=lifecycle_stage, - hold_quality=hold_quality, - decay_state=decay_state, - ) - - state.position_fatigue_score = fatigue_score - state.position_fatigue_state = self._position_fatigue_state(fatigue_score) - state.position_conviction_state = self._position_conviction_state(state) - state.position_exit_urgency = self._position_exit_urgency(state) - state.position_reversal_risk = self._position_reversal_risk(state) - - def _position_mfe_percent(self, state: AutoTradeState) -> float | None: - peak = safe_float(state.position_peak_pnl_percent) - - if peak is None: - return None - - return round(max(0.0, peak), 4) - - def _position_mae_percent(self, state: AutoTradeState) -> float | None: - current = safe_float(state.position_pnl_percent) - - if current is None: - return None - - return round(min(0.0, current), 4) - - def _position_giveback_percent(self, state: AutoTradeState) -> float | None: - peak = safe_float(state.position_peak_pnl_percent) - current = safe_float(state.position_pnl_percent) - - if peak is None or current is None: - return None - - if peak <= 0: - return 0.0 - - giveback = peak - current - - if giveback <= 0: - return 0.0 - - return round((giveback / peak) * 100, 2) - - def _position_fatigue_score( - self, - *, - state: AutoTradeState, - lifecycle_stage: str, - hold_quality: str, - decay_state: str, - ) -> float: - score = 0.0 - - giveback = safe_float(state.position_giveback_percent) or 0.0 - hold_seconds = safe_float(state.position_hold_seconds) or 0.0 - - if lifecycle_stage == "AGED": - score += 0.25 - elif lifecycle_stage == "MATURE": - score += 0.15 - - if hold_quality == "BAD": - score += 0.30 - elif hold_quality == "WEAK": - score += 0.18 - - if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}: - score += 0.30 - elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: - score += 0.18 - - if giveback >= 70: - score += 0.30 - elif giveback >= 45: - score += 0.20 - elif giveback >= 25: - score += 0.10 - - if hold_seconds >= 1800: - score += 0.15 - elif hold_seconds >= 900: - score += 0.08 - - if state.position_adverse_momentum: - score += 0.15 - - return round(max(0.0, min(1.0, score)), 3) - - def _position_fatigue_state(self, score: float | None) -> str: - value = safe_float(score) - - if value is None: - return "UNKNOWN" - - if value >= 0.75: - return "EXHAUSTED" - - if value >= 0.50: - return "TIRED" - - if value >= 0.25: - return "WATCH" - - return "FRESH" - - def _position_conviction_state(self, state: AutoTradeState) -> str: - health = str(state.position_health_status or "").upper() - fatigue = str(state.position_fatigue_state or "").upper() - alignment = str(state.position_trend_alignment or "").upper() - - if health == "DANGER" or fatigue == "EXHAUSTED": - return "BROKEN" - - if alignment == "AGAINST" or fatigue == "TIRED": - return "WEAKENING" - - if health == "HEALTHY" and alignment == "ALIGNED": - return "STRONG" - - return "NEUTRAL" - - def _position_exit_urgency(self, state: AutoTradeState) -> str: - exit_signal = str(state.position_exit_signal or "").upper() - fatigue = str(state.position_fatigue_state or "").upper() - risk = str(state.position_risk_level or "").upper() - - if exit_signal == "EXIT" or risk == "HIGH": - return "IMMEDIATE" - - if fatigue == "EXHAUSTED": - return "HIGH" - - if exit_signal == "REDUCE_OR_PROTECT" or fatigue == "TIRED": - return "MEDIUM" - - if exit_signal == "WATCH": - return "LOW" - - return "NONE" - - def _position_reversal_risk(self, state: AutoTradeState) -> str: - giveback = safe_float(state.position_giveback_percent) or 0.0 - fatigue = str(state.position_fatigue_state or "").upper() - adverse = bool(state.position_adverse_momentum) - - if adverse and giveback >= 45: - return "HIGH" - - if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25: - return "ELEVATED" - - if adverse: - return "MODERATE" - - return "LOW" - - def _sync_autonomous_trade_management( - self, - state: AutoTradeState, - ) -> None: - if state.position_side == "NONE": - state.autonomous_action = None - state.autonomous_action_reason = None - state.autonomous_action_confidence = None - state.autonomous_protection_required = False - state.autonomous_reduce_required = False - state.autonomous_exit_required = False - return - - exit_signal = str(state.position_exit_signal or "HOLD").upper() - exit_confidence = safe_float(state.position_exit_confidence) or 0.0 - - action = "HOLD" - reason = "позиция удерживается" - - protect_required = False - reduce_required = False - exit_required = False - - if exit_signal == "WATCH": - action = "WATCH" - reason = "позиция требует наблюдения" - - elif exit_signal == "REDUCE_OR_PROTECT": - if state.position_pressure in {"HIGH_LOSS", "LOSS"}: - action = "REDUCE" - reduce_required = True - reason = "позиция должна быть уменьшена" - else: - action = "PROTECT" - protect_required = True - reason = "позиция требует защиты" - - elif exit_signal == "EXIT": - action = "EXIT" - exit_required = True - reason = "позиция требует закрытия" - - if ( - state.position_adverse_momentum - and state.position_trend_alignment == "AGAINST" - and exit_confidence >= 0.65 - ): - action = "EXIT" - exit_required = True - reason = "рынок агрессивно движется против позиции" - - state.autonomous_action = action - state.autonomous_action_reason = reason - state.autonomous_action_confidence = exit_confidence - state.autonomous_protection_required = protect_required - state.autonomous_reduce_required = reduce_required - state.autonomous_exit_required = exit_required - - def _log_execution_quality_if_changed( - self, - *, - state: AutoTradeState, - payload: JsonDict, - ) -> None: - quality = state.execution_quality - reason = state.execution_quality_reason - message = state.execution_quality_message - - if not quality or not reason or not message: - return - - key = f"{state.status}:{state.symbol}:{state.strategy}:{quality}:{reason}:{message}" - - if key == type(self)._last_logged_execution_quality_key: - return - - type(self)._last_logged_execution_quality_key = key - - if quality == "GOOD": - return - - try: - log_payload = { - **payload, - "status": state.status, - "symbol": state.symbol, - "strategy": state.strategy, - } - - if quality == "BLOCKED": - JournalService().log_ui_warning( - event_type="execution_quality_changed", - message=f"Качество исполнения: {message}.", - screen="auto", - action="execution_quality", - payload=log_payload, - ) - return - - JournalService().log_ui_info( - event_type="execution_quality_changed", - message=f"Качество исполнения: {message}.", - screen="auto", - action="execution_quality", - payload=log_payload, - ) - except Exception: - pass - - def _sync_execution_confidence_state( - self, - *, - state: AutoTradeState, - signal: str, - confidence: float, - ) -> None: - if signal not in {"BUY", "SELL"}: - state.execution_confidence_score = None - state.execution_confidence_level = None - state.execution_confidence_required_score = self._execution_confidence_required_score - state.execution_confidence_reason = None - state.execution_confidence_factors = None - return - - signal_score = self._clamp_score(confidence) - confirmation_score = self._clamp_score(state.signal_confirmation_progress) - market_score = self._market_confidence_score(state) - execution_score = self._execution_quality_confidence_score(state) - - score = ( - signal_score * 0.35 - + confirmation_score * 0.20 - + market_score * 0.25 - + execution_score * 0.20 - ) - - score = round(self._clamp_score(score), 3) - - state.execution_confidence_score = score - state.execution_confidence_required_score = self._execution_confidence_required_score - state.execution_confidence_level = self._execution_confidence_level(score) - state.execution_confidence_reason = self._execution_confidence_reason(state) - state.execution_confidence_factors = { - "signal_score": round(signal_score, 3), - "confirmation_score": round(confirmation_score, 3), - "market_score": round(market_score, 3), - "execution_score": round(execution_score, 3), - "required_score": self._execution_confidence_required_score, - "market_state": state.market_state, - "market_trend": state.market_trend, - "market_trend_strength": state.market_trend_strength, - "market_trend_quality": state.market_trend_quality, - "market_phase": state.market_phase, - "execution_quality": state.execution_quality, - "execution_quality_reason": state.execution_quality_reason, - "spread_percent": state.spread_percent, - "momentum_state": getattr(state, "momentum_state", None), - "momentum_direction": getattr(state, "momentum_direction", None), - "momentum_change_percent": getattr(state, "momentum_change_percent", None), - "momentum_strength": getattr(state, "momentum_strength", None), - "breakout_level": getattr(state, "breakout_level", None), - "breakout_distance_percent": getattr(state, "breakout_distance_percent", None), - "breakout_reason": getattr(state, "breakout_reason", None), - } - - def _market_confidence_score(self, state: AutoTradeState) -> float: - market_state = state.market_state - 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, - "", - }: - return 0.25 - - score = 0.65 - - if strength == "STRONG": - score += 0.2 - elif strength == "NORMAL": - score += 0.1 - elif strength == "WEAK": - score -= 0.25 - - if quality == "CLEAN": - score += 0.12 - elif quality == "NORMAL": - score += 0.04 - elif quality == "NOISY": - score -= 0.25 - - if phase == "IMPULSE": - score += 0.1 - elif phase == "PULLBACK": - score -= 0.25 - 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: - quality = state.execution_quality - reason = state.execution_quality_reason - - if quality == "GOOD": - return 1.0 - - if quality == "WARNING": - if reason == "WIDE_SPREAD": - return 0.65 - - if reason == "AGING_SNAPSHOT": - return 0.6 - - if reason == "SNAPSHOT_UNAVAILABLE": - return 0.55 - - return 0.6 - - if quality == "BLOCKED": - return 0.0 - - return 0.5 - - def _execution_confidence_level(self, score: float) -> str: - if score >= 0.75: - return "HIGH" - - if score >= self._execution_confidence_required_score: - return "NORMAL" - - return "LOW" - - def _execution_confidence_reason(self, state: AutoTradeState) -> str: - score = state.execution_confidence_score - - if score is None: - return "execution confidence не рассчитан" - - if score < self._execution_confidence_required_score: - return "низкая совокупная уверенность входа" - - if state.execution_confidence_level == "HIGH": - return "высокая совокупная уверенность входа" - - return "достаточная совокупная уверенность входа" - - def _clamp_score(self, value: NumericLike | None) -> float: - if value is None: - return 0.0 - - 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": - state.execution_semantic_status = "BLOCKED" - state.execution_semantic_message = self._execution_block_semantic_message(state) - state.execution_semantic_reason = state.execution_quality_reason - return - - if state.decision_status == "BLOCKED": - state.execution_semantic_status = "BLOCKED" - - if ( - state.execution_confidence_score is not None - and state.execution_confidence_score < self._execution_confidence_required_score - ): - state.execution_semantic_message = "⛔ Исполнение · низкая уверенность" - state.execution_semantic_reason = state.execution_confidence_reason - return - - state.execution_semantic_message = "⛔ Исполнение · сигнал заблокирован" - state.execution_semantic_reason = state.decision_reason - return - - if state.position_side != "NONE": - state.execution_semantic_status = "POSITION_OPEN" - state.execution_semantic_message = "📌 Исполнение · позиция открыта" - state.execution_semantic_reason = state.last_execution_reason - return - - if state.decision_status == "READY" and state.is_signal_ready: - state.execution_semantic_status = "READY" - state.execution_semantic_message = "✅ Исполнение · готово" - state.execution_semantic_reason = state.decision_reason - return - - if state.decision_status == "CONFIRMING": - state.execution_semantic_status = "WAITING_SIGNAL" - state.execution_semantic_message = "⏳ Исполнение · ждёт подтверждения" - state.execution_semantic_reason = state.decision_reason - return - - if state.last_signal in {"BUY", "SELL"}: - state.execution_semantic_status = "WAITING_SIGNAL" - state.execution_semantic_message = "⏳ Исполнение · сигнал проверяется" - state.execution_semantic_reason = state.decision_reason - return - - state.execution_semantic_status = "IDLE" - state.execution_semantic_message = "" - state.execution_semantic_reason = state.decision_reason - - def _execution_block_semantic_message(self, state: AutoTradeState) -> str: - reason = state.execution_quality_reason - - if reason == "MARKET_CLOSED": - return "⏸️ Исполнение · рынок закрыт" - - if reason == "STALE_SNAPSHOT": - return "⛔ Исполнение · рынок неактуален" - - if reason == "HIGH_SPREAD": - return "⛔ Исполнение · высокий spread" - - if reason == "SNAPSHOT_ERROR": - return "⛔ Исполнение · нет данных рынка" - - if reason == "SNAPSHOT_UNAVAILABLE": - return "⚠️ Исполнение · нет стакана" - - return "⛔ Исполнение · заблокировано" - - def run_cycle(self) -> AutoTradeState: - state = self.get_state() - - 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() - context = self._build_strategy_context() - result = strategy.analyze(context) - - self._sync_market_analysis_state( - state=state, - payload=result.payload, - ) - - self._sync_execution_quality_state(state) - - state.last_check_at = datetime.now().strftime("%H:%M:%S") - - self._log_signal_if_changed( - strategy_name=strategy.name, - state=state, - signal=result.signal.value, - reason=result.reason, - confidence=result.confidence, - payload=result.payload, - ) - - if state.execution_quality != "BLOCKED": - ExecutionEngine().process(state) - - self._sync_position_health_state(state) - self._sync_position_intelligence_state(state) - self._sync_autonomous_trade_management(state) - - if state.execution_quality != "BLOCKED": - ExecutionEngine().process_runtime_action(state) - - self._sync_execution_semantic_state(state) - - return state \ No newline at end of file + # последний logged execution quality key + # нужен чтобы не спамить одинаковыми warning/block logs + _last_logged_execution_quality_key: str | None = None \ No newline at end of file diff --git a/app/src/trading/auto/signal_runtime.py b/app/src/trading/auto/signal_runtime.py new file mode 100644 index 0000000..35ac124 --- /dev/null +++ b/app/src/trading/auto/signal_runtime.py @@ -0,0 +1,814 @@ +# app/src/trading/auto/signal_runtime.py + +from __future__ import annotations + +import time +from typing import Callable, cast + +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.service import ExchangeService +from src.trading.auto.state import AutoTradeState +from src.trading.journal.service import JournalService + + +class AutoSignalRuntimeMixin: + _loop_interval_seconds: int + + _confirm_repeats: int + _confirm_min_duration_seconds: int + _ready_confidence: float + _execution_confidence_required_score: float + + _signal_ttl_seconds: int + _market_analysis_ttl_seconds: int + _last_logged_runtime_expired_key: str | None + + _last_signal_key: str | None + _last_signal_value: str | None + _last_signal_reason: str + _last_signal_confidence: float + _last_signal_payload: JsonDict | None + _last_signal_started_at: float | None + _same_signal_count: int + + # получить state из основного AutoTradeService + def get_state(self) -> AutoTradeState: + raise NotImplementedError + + # сбросить runtime tracking в основном AutoTradeService + def _reset_signal_tracking(self) -> None: + raise NotImplementedError + + # debug: принудительно выставить сигнал и decision + def debug_force_signal( + self, + *, + signal: str, + 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"}: + normalized_signal = "HOLD" + + previous_signal = state.last_signal + previous_decision_status = state.decision_status + + if previous_signal != normalized_signal or state.signal_started_at is None: + state.signal_started_at = time.monotonic() + + state.last_signal = normalized_signal + state.last_signal_repeat_count = repeat_count + 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 + state.signal_confirmation_missing_repeats = 0 + state.signal_confirmation_progress = 1.0 + state.signal_confirmation_reason = "debug confirmation" + + if normalized_signal == "HOLD": + state.decision_status = "WAITING" + state.decision_reason = "Debug HOLD." + state.is_signal_confirmed = False + state.is_signal_ready = False + else: + state.decision_status = "READY" + state.decision_reason = "Debug READY signal." + state.is_signal_confirmed = True + state.is_signal_ready = True + + signal_intent = self._signal_intent( + state=state, + signal=state.last_signal, + ) + + EventBus.emit( + "auto_decision_changed", + { + "previous_signal": previous_signal, + "previous_decision_status": previous_decision_status, + "decision_status": state.decision_status, + "signal": state.last_signal, + "signal_intent": signal_intent, + "repeat_count": state.last_signal_repeat_count, + "confidence": state.last_signal_confidence, + "symbol": state.symbol, + "strategy": state.strategy, + "leverage": state.leverage, + "reason": state.last_signal_reason, + "debug": True, + }, + ) + + return state + + # определить смысл сигнала с учетом открытой позиции + def _signal_intent(self, *, state: AutoTradeState, signal: str | None) -> str: + normalized_signal = (signal or "HOLD").upper() + position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper() + + if normalized_signal == "HOLD": + return "HOLD_MARKET" + + if normalized_signal not in {"BUY", "SELL"}: + return "NOISE" + + if position_side == "NONE": + return "ENTRY_CANDIDATE" + + if position_side == "LONG" and normalized_signal == "BUY": + return "REINFORCE_POSITION" + + if position_side == "SHORT" and normalized_signal == "SELL": + return "REINFORCE_POSITION" + + if position_side == "LONG" and normalized_signal == "SELL": + return "REVERSAL_CANDIDATE" + + if position_side == "SHORT" and normalized_signal == "BUY": + return "REVERSAL_CANDIDATE" + + return "NOISE" + + # обновить статус решения по текущему сигналу + def _update_decision_state( + self, + *, + state: AutoTradeState, + signal: str, + confidence: float, + ) -> None: + state.is_signal_confirmed = False + state.is_signal_ready = False + state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds + + if signal == "HOLD": + state.signal_confirmation_seconds = 0 + state.signal_confirmation_missing_repeats = self._confirm_repeats + state.signal_confirmation_progress = 0.0 + state.signal_confirmation_reason = None + state.decision_status = "WAITING" + state.decision_reason = "Нет торгового направления." + return + + now = time.monotonic() + + if state.signal_started_at is None: + signal_age_seconds = 0 + else: + 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( + 0, + self._confirm_min_duration_seconds - signal_age_seconds, + ) + + repeat_progress = min( + 1.0, + self._same_signal_count / max(1, self._confirm_repeats), + ) + time_progress = min( + 1.0, + signal_age_seconds / max(1, self._confirm_min_duration_seconds), + ) + + confirmation_progress = min(repeat_progress, time_progress) + + state.signal_confirmation_seconds = signal_age_seconds + state.signal_confirmation_missing_repeats = missing_repeats + state.signal_confirmation_progress = round(confirmation_progress, 3) + + if missing_repeats > 0 or missing_seconds > 0: + state.decision_status = "CONFIRMING" + state.signal_confirmation_reason = ( + f"{self._same_signal_count}/{self._confirm_repeats} повторов, " + f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с" + ) + state.decision_reason = ( + f"Сигнал {signal} подтверждается: " + f"{self._same_signal_count}/{self._confirm_repeats} повторов, " + f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с." + ) + return + + state.is_signal_confirmed = True + state.signal_confirmation_reason = "сигнал подтверждён" + + if confidence < self._ready_confidence: + state.decision_status = "BLOCKED" + state.decision_reason = ( + f"Сигнал {signal} подтверждён, но уверенность низкая: " + f"{confidence:.2f} < {self._ready_confidence:.2f}." + ) + return + + self._sync_execution_confidence_state( + state=state, + signal=signal, + confidence=confidence, + ) + + if ( + state.execution_confidence_score is not None + and state.execution_confidence_score < self._execution_confidence_required_score + ): + state.decision_status = "BLOCKED" + state.decision_reason = ( + f"Execution confidence низкий: " + f"{state.execution_confidence_score:.2f} < " + f"{self._execution_confidence_required_score:.2f}." + ) + return + + state.is_signal_ready = True + state.signal_confirmation_progress = 1.0 + state.decision_status = "READY" + state.decision_reason = ( + f"Сигнал {signal} подтверждён по повторам и времени удержания." + ) + + # записать новый сигнал и итог предыдущей серии при смене сигнала + def _log_signal_if_changed( + self, + *, + strategy_name: str, + state: AutoTradeState, + signal: str, + reason: str, + confidence: float, + payload: JsonDict | None, + ) -> None: + signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}" + previous_signal = self._last_signal_value + previous_count = self._same_signal_count + is_same_signal = signal_key == self._last_signal_key + now = time.monotonic() + + if is_same_signal: + self._same_signal_count += 1 + self._last_signal_reason = reason + self._last_signal_confidence = confidence + self._last_signal_payload = payload + + self._update_signal_state_fields( + state=state, + signal=signal, + reason=reason, + confidence=confidence, + ) + return + + if previous_signal is not None and previous_signal != signal: + if previous_count > 1: + self._log_signal_summary( + strategy_name=strategy_name, + state=state, + previous_signal=previous_signal, + previous_count=previous_count, + next_signal=signal, + reason=self._last_signal_reason, + confidence=self._last_signal_confidence, + payload=self._last_signal_payload, + duration_seconds=self._signal_duration_seconds(now=now), + ) + else: + self._log_signal_event( + strategy_name=strategy_name, + state=state, + signal=previous_signal, + reason=f"{previous_signal} завершился без серии.", + confidence=self._last_signal_confidence, + payload={ + "previous_signal": previous_signal, + "next_signal": signal, + }, + ) + + self._last_signal_key = signal_key + self._last_signal_value = signal + self._last_signal_reason = reason + self._last_signal_confidence = confidence + self._last_signal_payload = payload + self._last_signal_started_at = now + self._same_signal_count = 1 + + self._update_signal_state_fields( + state=state, + signal=signal, + reason=reason, + confidence=confidence, + ) + + # рассчитать длительность текущей серии сигналов + def _signal_duration_seconds(self, *, now: float) -> int: + if self._last_signal_started_at is None: + return max(0, int(self._same_signal_count * self._loop_interval_seconds)) + + return max(0, int(now - self._last_signal_started_at)) + + # отформатировать длительность для журнала + def _format_duration(self, total_seconds: int) -> str: + total_seconds = max(0, int(total_seconds)) + + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + if hours > 0: + return f"{hours}ч {minutes:02d}м {seconds:02d}с" + + if minutes > 0: + return f"{minutes}м {seconds:02d}с" + + return f"{seconds}с" + + # обновить поля state для экрана автоторговли + def _update_signal_state_fields( + self, + *, + state: AutoTradeState, + signal: str, + reason: str, + confidence: float, + ) -> None: + previous_signal = state.last_signal + previous_decision_status = state.decision_status + + if previous_signal != signal or state.signal_started_at is None: + state.signal_started_at = time.monotonic() + + state.last_signal = signal + state.last_signal_repeat_count = self._same_signal_count + state.last_signal_confidence = confidence + state.last_signal_reason = reason + state.signal_updated_at = time.monotonic() + state.runtime_expired_reason = None + state.runtime_expired_message = None + + self._update_decision_state( + state=state, + signal=signal, + confidence=confidence, + ) + + signal_intent = self._signal_intent( + state=state, + signal=state.last_signal, + ) + + if ( + previous_decision_status != state.decision_status + and state.decision_status == "READY" + ): + self._log_ready_signal( + state=state, + signal=state.last_signal, + reason=state.last_signal_reason or reason, + confidence=state.last_signal_confidence, + signal_intent=signal_intent, + ) + + if previous_signal != state.last_signal: + EventBus.emit( + "auto_signal_changed", + { + "previous_signal": previous_signal, + "signal": state.last_signal, + "signal_intent": signal_intent, + "repeat_count": state.last_signal_repeat_count, + "confidence": state.last_signal_confidence, + }, + ) + + if previous_decision_status != state.decision_status: + EventBus.emit( + "auto_decision_changed", + { + "previous_decision_status": previous_decision_status, + "decision_status": state.decision_status, + "signal": state.last_signal, + "signal_intent": signal_intent, + "repeat_count": state.last_signal_repeat_count, + "confidence": state.last_signal_confidence, + "symbol": state.symbol, + "strategy": state.strategy, + "leverage": state.leverage, + "reason": state.last_signal_reason, + }, + ) + + # одиночные BUY / SELL больше не пишем в журнал как полезные события + def _log_signal_event( + self, + *, + strategy_name: str, + state: AutoTradeState, + signal: str, + reason: str, + confidence: float, + payload: JsonDict | None, + ) -> None: + return + + # записать итог серии одинаковых сигналов при смене сигнала + def _log_signal_summary( + self, + *, + strategy_name: str, + state: AutoTradeState, + previous_signal: str, + previous_count: int, + next_signal: str, + reason: str, + confidence: float, + payload: JsonDict | None, + duration_seconds: int, + ) -> None: + if previous_signal != "HOLD": + return + + duration_text = self._format_duration(duration_seconds) + signal_intent = "HOLD_MARKET" + + try: + JournalService().log_ui_info( + event_type="signal_summary", + message=( + f"HOLD длился {duration_text} и завершился сигналом {next_signal}." + ), + screen="auto", + action="signal_summary", + payload={ + "strategy": strategy_name, + "status": state.status, + "symbol": state.symbol, + "signal": previous_signal, + "next_signal": next_signal, + "signal_intent": signal_intent, + "repeat_count": previous_count, + "duration_seconds": duration_seconds, + "duration_text": duration_text, + "confidence": confidence, + "reason": reason, + "is_strong_signal": False, + "is_aggregated": True, + "payload": payload or {}, + }, + ) + except Exception: + pass + + # записать событие готовности сигнала к исполнению + def _log_ready_signal( + self, + *, + state: AutoTradeState, + signal: str | None, + reason: str, + confidence: float, + signal_intent: str, + ) -> None: + normalized_signal = (signal or "HOLD").upper() + 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", + message=( + f"Сигнал {normalized_signal} подтверждён и готов к исполнению." + ), + screen="auto", + action="signal_ready", + payload={ + "strategy": state.strategy, + "status": state.status, + "symbol": state.symbol, + "signal": normalized_signal, + "signal_intent": signal_intent, + "confidence": confidence, + "reason": reason, + "repeat_count": state.last_signal_repeat_count, + "position_side": state.position_side, + "decision_status": state.decision_status, + "is_strong_signal": confidence > self._ready_confidence, + "is_aggregated": False, + "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: + pass + + # сбросить устаревшие signal / market runtime данные + def _expire_runtime_if_needed(self, state: AutoTradeState) -> None: + now = time.monotonic() + + signal_updated_at = getattr(state, "signal_updated_at", None) + if signal_updated_at is not None: + 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 + + self._reset_signal_tracking() + + state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED" + state.runtime_expired_message = "сигнал устарел и был сброшен" + + self._log_runtime_expired_if_changed( + state=state, + reason="SIGNAL_TTL_EXPIRED", + message="Сигнал устарел и был сброшен.", + payload={ + "previous_signal": previous_signal, + "signal_age_seconds": int(signal_age), + "signal_ttl_seconds": self._signal_ttl_seconds, + }, + ) + + return + + market_updated_at = getattr(state, "market_analysis_updated_at", None) + if market_updated_at is not None: + 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 + state.market_trend = None + state.market_volatility = None + state.market_analysis_interval = None + state.market_analysis_reason = None + state.market_analysis_updated_at = None + state.entry_block_reason = None + state.entry_block_message = None + state.market_trend_strength = None + 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 + state.momentum_strength = None + state.breakout_level = None + state.breakout_distance_percent = None + state.breakout_reason = None + state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED" + state.runtime_expired_message = "анализ рынка устарел" + + self._log_runtime_expired_if_changed( + state=state, + reason="MARKET_ANALYSIS_TTL_EXPIRED", + message="Анализ рынка устарел и был сброшен.", + payload={ + "market_age_seconds": int(market_age), + "market_analysis_ttl_seconds": self._market_analysis_ttl_seconds, + }, + ) + + # записать событие устаревания runtime данных + def _log_runtime_expired_if_changed( + self, + *, + state: AutoTradeState, + reason: str, + message: str, + payload: JsonDict, + ) -> None: + key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}" + + if key == type(self)._last_logged_runtime_expired_key: + return + + type(self)._last_logged_runtime_expired_key = key + + try: + JournalService().log_ui_warning( + event_type="runtime_expired", + message=message, + screen="auto", + action="runtime_expiration", + payload={ + **payload, + "symbol": state.symbol, + "strategy": state.strategy, + "status": state.status, + "runtime_expired_reason": reason, + }, + ) + except Exception: + pass + + # синхронизировать итоговый execution confidence + def _sync_execution_confidence_state( + self, + *, + state: AutoTradeState, + signal: str, + confidence: float, + ) -> None: + if signal not in {"BUY", "SELL"}: + state.execution_confidence_score = None + state.execution_confidence_level = None + state.execution_confidence_required_score = self._execution_confidence_required_score + state.execution_confidence_reason = None + state.execution_confidence_factors = None + return + + signal_score = self._clamp_score(confidence) + confirmation_score = self._clamp_score(state.signal_confirmation_progress) + market_score = self._market_confidence_score(state) + execution_quality_confidence_score = cast( + Callable[[AutoTradeState], float], + getattr(self, "_execution_quality_confidence_score"), + ) + execution_score = execution_quality_confidence_score(state) + + score = ( + signal_score * 0.35 + + confirmation_score * 0.20 + + market_score * 0.25 + + execution_score * 0.20 + ) + + score = round(self._clamp_score(score), 3) + + state.execution_confidence_score = score + state.execution_confidence_required_score = self._execution_confidence_required_score + state.execution_confidence_level = self._execution_confidence_level(score) + state.execution_confidence_reason = self._execution_confidence_reason(state) + state.execution_confidence_factors = { + "signal_score": round(signal_score, 3), + "confirmation_score": round(confirmation_score, 3), + "market_score": round(market_score, 3), + "execution_score": round(execution_score, 3), + "required_score": self._execution_confidence_required_score, + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_trend_strength": state.market_trend_strength, + "market_trend_quality": state.market_trend_quality, + "market_phase": state.market_phase, + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "spread_percent": state.spread_percent, + "momentum_state": getattr(state, "momentum_state", None), + "momentum_direction": getattr(state, "momentum_direction", None), + "momentum_change_percent": getattr(state, "momentum_change_percent", None), + "momentum_strength": getattr(state, "momentum_strength", None), + "breakout_level": getattr(state, "breakout_level", None), + "breakout_distance_percent": getattr(state, "breakout_distance_percent", None), + "breakout_reason": getattr(state, "breakout_reason", None), + } + + # рассчитать market confidence для итогового execution confidence + def _market_confidence_score(self, state: AutoTradeState) -> float: + market_state = state.market_state + 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, + "", + }: + return 0.25 + + score = 0.65 + + if strength == "STRONG": + score += 0.2 + elif strength == "NORMAL": + score += 0.1 + elif strength == "WEAK": + score -= 0.25 + + if quality == "CLEAN": + score += 0.12 + elif quality == "NORMAL": + score += 0.04 + elif quality == "NOISY": + score -= 0.25 + + if phase == "IMPULSE": + score += 0.1 + elif phase == "PULLBACK": + score -= 0.25 + 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) + + # определить уровень execution confidence + def _execution_confidence_level(self, score: float) -> str: + if score >= 0.75: + return "HIGH" + + if score >= self._execution_confidence_required_score: + return "NORMAL" + + return "LOW" + + # сформировать причину execution confidence + def _execution_confidence_reason(self, state: AutoTradeState) -> str: + score = state.execution_confidence_score + + if score is None: + return "execution confidence не рассчитан" + + if score < self._execution_confidence_required_score: + return "низкая совокупная уверенность входа" + + if state.execution_confidence_level == "HIGH": + return "высокая совокупная уверенность входа" + + return "достаточная совокупная уверенность входа" + + # ограничить score диапазоном 0.0..1.0 + def _clamp_score(self, value: NumericLike | None) -> float: + if value is None: + return 0.0 + + numeric = safe_float(value) + + if numeric is None: + return 0.0 + + return max(0.0, min(1.0, numeric)) \ No newline at end of file diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index 8c1f8db..22e7212 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -404,4 +404,13 @@ class AutoTradeState: market_status_updated_at: float | None = None # номер текущего цикла автоторговли, для которого была зафиксирована статистика - cycle_number: int = 0 \ No newline at end of file + cycle_number: int = 0 + + # уникальный номер сделки внутри runtime + trade_sequence: int = 0 + + # id текущей открытой сделки + current_trade_id: str | None = None + + # номер цикла, в котором открыта текущая сделка + current_trade_cycle_number: int | None = None \ No newline at end of file diff --git a/app/src/trading/diagnostics/formatter.py b/app/src/trading/diagnostics/formatter.py index ae451d4..73464a6 100644 --- a/app/src/trading/diagnostics/formatter.py +++ b/app/src/trading/diagnostics/formatter.py @@ -6,6 +6,7 @@ from typing import Any from src.core.numbers import safe_float from src.core.types import JsonDict, NumericLike +from src.integrations.exchange.runtime_ui import format_runtime_exchange_alert class SemanticDiagnosticFormatter: @@ -17,6 +18,11 @@ class SemanticDiagnosticFormatter: execution = snapshot.get("execution", {}) adaptive = snapshot.get("adaptive_size", {}) runtime = snapshot.get("runtime_health", {}) + exchange_statuses = runtime.get("exchange_statuses") or [] + exchange_status = runtime.get("exchange_status") + + if not exchange_statuses and exchange_status: + exchange_statuses = [exchange_status] summary = snapshot.get("summary", {}) position = snapshot.get("position", {}) @@ -33,6 +39,9 @@ class SemanticDiagnosticFormatter: self._status_block(status), ] + for item in exchange_statuses: + sections.append(self._runtime_exchange_block(item)) + return "\n\n".join( section.strip() for section in sections @@ -47,11 +56,16 @@ class SemanticDiagnosticFormatter: market=market, momentum=momentum, ), + ] + for item in exchange_statuses: + sections.append(self._runtime_exchange_block(item)) + + sections.extend([ self._execution_block(execution), self._signal_block(signal), self._market_block(market), self._momentum_block(momentum), - ] + ]) if mode != "COMPACT": if has_position: @@ -752,6 +766,7 @@ class SemanticDiagnosticFormatter: quality = data.get("trend_quality") volatility = data.get("volatility") market_closed = data.get("market_is_open") is False + market_data_state = self._market_live_state(data.get("age_seconds")) lines = [ ( @@ -759,10 +774,7 @@ class SemanticDiagnosticFormatter: f"Рынок · " f"{self._market_title(data)}" ), - ( - f"• Данные: " - f"{self._market_live_state(data.get('age_seconds'))}" - ), + f"• Данные: {market_data_state}", ] if market_closed: @@ -1215,23 +1227,23 @@ class SemanticDiagnosticFormatter: ).strip() def _status_block(self, data: JsonDict) -> str: - status = str(data.get("status") or "") + status = str(data.get("status") or "").upper() if status == "RUNNING": icon = "🟢" title = "работает" elif status == "OBSERVING": - icon = "🟡" - title = "наблюдение" + icon = "👀" + title = "под наблюдением" elif status == "OFF": - icon = "⛔️" + icon = "⚪️" title = "остановлена" else: - icon = "⚪" + icon = "⛔️" title = "не готова" return ( - f"{icon} Автоторговля · {title}\n" + f"{icon} Автоторговля {title}\n" f"• Актив: {self._format_system_symbol(data.get('symbol'))}\n" f"• Стратегия: {data.get('strategy') or '—'}\n" f"• Настроено: {self._bool(data.get('is_configured'))}" @@ -1319,7 +1331,7 @@ class SemanticDiagnosticFormatter: age_seconds = None if age_seconds is None: - add("Нет live-данных") + add("Live-поток недоступен") elif age_seconds > 60: add("Данные рынка устарели") @@ -1798,6 +1810,9 @@ class SemanticDiagnosticFormatter: ): return "⛔️" + if data.get("age_seconds") is None: + return "🟡" + if state == "UNKNOWN": return "⚪️" @@ -1904,7 +1919,7 @@ class SemanticDiagnosticFormatter: seconds_float = safe_float(value) if seconds_float is None: - return "нет данных" + return "REST" seconds = int(seconds_float) @@ -1991,4 +2006,10 @@ class SemanticDiagnosticFormatter: if not items: return "" - return "• Структура: " + " · ".join(items[:4]) \ No newline at end of file + return "• Структура: " + " · ".join(items[:4]) + + def _runtime_exchange_block( + self, + data: JsonDict, + ) -> str: + return format_runtime_exchange_alert(data) \ No newline at end of file diff --git a/app/src/trading/diagnostics/snapshot.py b/app/src/trading/diagnostics/snapshot.py index 5e27ab5..4d90d9e 100644 --- a/app/src/trading/diagnostics/snapshot.py +++ b/app/src/trading/diagnostics/snapshot.py @@ -7,6 +7,7 @@ from typing import Any from src.trading.auto.state import AutoTradeState from src.core.numbers import safe_float +from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts class SemanticDiagnosticSnapshotBuilder: @@ -38,6 +39,8 @@ class SemanticDiagnosticSnapshotBuilder: current_price=position_current_price, ) + runtime_exchange_alerts = self._runtime_exchange_alerts(state) + return { "status": { "status": state.status, @@ -161,6 +164,12 @@ class SemanticDiagnosticSnapshotBuilder: "adverse_momentum": position_health.get("adverse_momentum"), }, "runtime_health": { + "exchange_statuses": runtime_exchange_alerts, + "exchange_status": ( + runtime_exchange_alerts[0] + if runtime_exchange_alerts + else None + ), "health_score": health_score, "severity": severity, "is_runtime_degraded": self._is_runtime_degraded(state), @@ -800,4 +809,10 @@ class SemanticDiagnosticSnapshotBuilder: return None move = entry_price * (take_profit_percent / 100) - return move * position_size \ No newline at end of file + return move * position_size + + def _runtime_exchange_alerts( + self, + state: AutoTradeState, + ) -> list[dict[str, Any]]: + return build_runtime_exchange_alerts(symbol=state.symbol) \ No newline at end of file diff --git a/app/src/trading/execution/calculations.py b/app/src/trading/execution/calculations.py new file mode 100644 index 0000000..a542a48 --- /dev/null +++ b/app/src/trading/execution/calculations.py @@ -0,0 +1,142 @@ +# app/src/trading/execution/calculations.py + +from __future__ import annotations + +from datetime import datetime +from typing import Protocol + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.trading.position.state import PositionState + + +class _ExecutionCalculationsProtocol(Protocol): + """ + Protocol для доступа к shared position state. + """ + + _position: PositionState + + +class ExecutionCalculationsMixin( + _ExecutionCalculationsProtocol, +): + """ + Execution math/calculation helpers. + + Отвечает за: + - pnl calculations + - price move calculations + - shared execution math helpers + - execution timestamps + """ + + # ========================================================= + # PRICE MOVE % + # ========================================================= + + def _calculate_price_move_percent( + self, + current_price: NumericLike | None, + ) -> float: + """ + Рассчитать изменение цены относительно entry. + + LONG: + (current - entry) / entry + + SHORT: + (entry - current) / entry + """ + + position = type(self)._position + + price = safe_float(current_price) or 0.0 + + entry = safe_float( + position.entry_price + ) or 0.0 + + if entry <= 0: + return 0.0 + + # ----------------------------------------------------- + # LONG + # ----------------------------------------------------- + + if position.side == "LONG": + return round( + ((price - entry) / entry) * 100, + 4, + ) + + # ----------------------------------------------------- + # SHORT + # ----------------------------------------------------- + + if position.side == "SHORT": + return round( + ((entry - price) / entry) * 100, + 4, + ) + + return 0.0 + + # ========================================================= + # PNL + # ========================================================= + + def _calculate_pnl( + self, + current_price: NumericLike | None, + ) -> float: + """ + Рассчитать unrealized pnl позиции. + """ + + position = type(self)._position + + 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 + + # ----------------------------------------------------- + # LONG + # ----------------------------------------------------- + + if position.side == "LONG": + return round( + (price - entry) * size, + 4, + ) + + # ----------------------------------------------------- + # SHORT + # ----------------------------------------------------- + + if position.side == "SHORT": + return round( + (entry - price) * size, + 4, + ) + + return 0.0 + + # ========================================================= + # TIME + # ========================================================= + + def _now_time(self) -> str: + """ + Current execution timestamp. + """ + + return datetime.now().strftime( + "%H:%M:%S" + ) \ No newline at end of file diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index 9274c41..b3b6ce9 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -3,30 +3,46 @@ from __future__ import annotations import time -import math -from dataclasses import dataclass -from datetime import datetime +#import math +#from dataclasses import dataclass +#from datetime import datetime -from src.core.event_bus import EventBus -from src.integrations.exchange.service import ExchangeService +#from src.core.event_bus import EventBus +#from src.integrations.exchange.service import ExchangeService from src.trading.auto.state import AutoTradeState from src.trading.execution.models import ExecutionDecision -from src.trading.journal.service import JournalService +#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 +#from src.core.numbers import safe_float +#from src.core.types import NumericLike +from src.trading.execution.pricing import ExecutionPricingMixin +from src.trading.execution.position_runtime import ExecutionPositionRuntimeMixin +from src.trading.execution.position_intelligence import ExecutionPositionIntelligenceMixin +from src.trading.execution.position_protection import ExecutionPositionProtectionMixin +from src.trading.execution.supervisor import ExecutionSupervisorMixin +from src.trading.execution.sizing import ExecutionSizingMixin +from src.trading.execution.risk_close import ExecutionRiskCloseMixin +from src.trading.execution.flip import ExecutionFlipMixin +from src.trading.execution.position_actions import ExecutionPositionActionsMixin +from src.trading.execution.runtime_actions import ExecutionRuntimeActionsMixin +from src.trading.execution.calculations import ExecutionCalculationsMixin +from src.trading.execution.resets import ExecutionResetsMixin -@dataclass(slots=True) -class _ExecutionPrice: - price: float - source: str - age_seconds: float | None - updated_at: str - pricing_role: str - - -class ExecutionEngine: +class ExecutionEngine( + ExecutionCalculationsMixin, + ExecutionResetsMixin, + ExecutionPricingMixin, + ExecutionPositionRuntimeMixin, + ExecutionPositionIntelligenceMixin, + ExecutionSizingMixin, + ExecutionPositionActionsMixin, + ExecutionPositionProtectionMixin, + ExecutionSupervisorMixin, + ExecutionRiskCloseMixin, + ExecutionFlipMixin, + ExecutionRuntimeActionsMixin, +): _position = PositionState() _size_precision = 5 _min_flip_confidence = 0.75 @@ -54,9 +70,6 @@ class ExecutionEngine: _last_supervisor_block_key: str | None = None - def get_position(self) -> PositionState: - return type(self)._position - def process(self, state: AutoTradeState) -> ExecutionDecision: self._sync_state_from_position(state) @@ -80,6 +93,24 @@ class ExecutionEngine: if state.decision_status != "READY" or not state.is_signal_ready: return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.") + position = type(self)._position + + # Не пытаемся повторно открыть позицию в ту же сторону. + # Сигнал остаётся валидным для UI/Telegram, но execution не дублируется. + if position.side == "LONG" and state.last_signal == "BUY": + return ExecutionDecision( + "NONE", + False, + "Сигнал BUY совпадает с уже открытой LONG позицией.", + ) + + if position.side == "SHORT" and state.last_signal == "SELL": + return ExecutionDecision( + "NONE", + False, + "Сигнал SELL совпадает с уже открытой SHORT позицией.", + ) + if self._should_flip_position(state): flip_block_reason = self._flip_block_reason(state) @@ -94,2261 +125,4 @@ class ExecutionEngine: if state.last_signal == "SELL": return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT") - return ExecutionDecision("NONE", False, "Нет торгового действия.") - - def process_runtime_action(self, state: AutoTradeState) -> ExecutionDecision: - self._sync_state_from_position(state) - - position = type(self)._position - - if state.status != "RUNNING": - return ExecutionDecision( - "NONE", - False, - "Runtime action доступен только в режиме RUNNING.", - ) - - if position.side == "NONE": - return ExecutionDecision( - "NONE", - False, - "Нет открытой позиции для runtime action.", - ) - - action = str( - getattr(state, "autonomous_action", "") or "" - ).upper() - - confidence = safe_float( - getattr(state, "autonomous_action_confidence", None) - ) or 0.0 - - reason = str( - getattr(state, "autonomous_action_reason", "") or "" - ) - - if action in {"", "HOLD", "WATCH"}: - return ExecutionDecision( - "NONE", - False, - "Runtime action не требуется.", - ) - - if self._runtime_action_cooldown_active(state, action): - return ExecutionDecision( - "NONE", - False, - "Runtime action cooldown активен.", - ) - - if action == "PROTECT": - return self._log_runtime_action( - state=state, - action="PROTECT", - reason=reason or "позиция требует защиты", - confidence=confidence, - executed=False, - ) - - if action == "REDUCE": - return self._log_runtime_action( - state=state, - action="REDUCE", - reason=reason or "позиция требует уменьшения", - confidence=confidence, - executed=False, - ) - - if action == "EXIT": - if confidence < 0.75: - return self._log_runtime_action( - state=state, - action="EXIT_BLOCKED", - reason=( - "autonomous exit заблокирован: " - f"confidence {confidence:.2f} < 0.75" - ), - confidence=confidence, - executed=False, - ) - - decision = self._close_position( - state, - forced_reason="AUTONOMOUS_EXIT", - ) - - state.autonomous_last_action = "EXIT" - state.autonomous_last_action_reason = reason or decision.reason - state.autonomous_last_action_at = time.monotonic() - - return decision - - return ExecutionDecision( - "NONE", - False, - f"Неизвестный runtime action: {action}.", - ) - - def _runtime_action_cooldown_active( - self, - state: AutoTradeState, - action: str, - ) -> bool: - ts = safe_float( - getattr(state, "autonomous_last_action_at", None) - ) - - last_action = str( - getattr(state, "autonomous_last_action", "") or "" - ).upper() - - if ts is None: - return False - - if last_action != action: - return False - - return ( - time.monotonic() - ts - ) < self._runtime_action_cooldown_seconds - - def _log_runtime_action( - self, - *, - state: AutoTradeState, - action: str, - reason: str, - confidence: float, - executed: bool, - ) -> ExecutionDecision: - position = type(self)._position - - key = ( - f"{state.symbol}:" - f"{position.side}:" - f"{action}:" - f"{reason}:" - f"{confidence:.2f}" - ) - - if key != type(self)._last_runtime_action_key: - type(self)._last_runtime_action_key = key - - payload: JsonDict = { - "execution_type": "RUNTIME_ACTION", - "action": action, - "executed": executed, - "symbol": state.symbol, - "position_side": position.side, - "entry_price": position.entry_price, - "size": position.size, - "unrealized_pnl_usd": state.unrealized_pnl_usd, - "position_health_status": getattr( - state, - "position_health_status", - None, - ), - "position_risk_level": getattr( - state, - "position_risk_level", - None, - ), - "position_exit_signal": getattr( - state, - "position_exit_signal", - None, - ), - "position_exit_confidence": getattr( - state, - "position_exit_confidence", - None, - ), - "autonomous_action": getattr( - state, - "autonomous_action", - None, - ), - "confidence": confidence, - "reason": reason, - } - - JournalService().log_ui_warning( - event_type="runtime_position_action", - message=f"Runtime action: {action}. Причина: {reason}.", - screen="auto", - action="runtime_position_action", - payload=payload, - ) - - EventBus.emit("runtime_position_action", payload) - - state.autonomous_last_action = action - state.autonomous_last_action_reason = reason - state.autonomous_last_action_at = time.monotonic() - - return ExecutionDecision(action, executed, reason) - - def _open_position_if_empty( - self, - *, - state: AutoTradeState, - side: str, - action: str, - ) -> ExecutionDecision: - position = type(self)._position - - if position.side != "NONE": - self._sync_state_from_position(state) - return ExecutionDecision("NONE", False, "Позиция уже открыта.") - - try: - entry = self._entry_price_for_side(state.symbol, side) - entry_price = entry.price - except Exception as exc: - return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}") - - now = self._now_time() - opened_monotonic_at = time.monotonic() - size = self._calculate_position_size(state, entry_price=entry_price) - - if size <= 0: - return ExecutionDecision( - "NONE", - False, - "Позиция не открыта: невозможно рассчитать adaptive size.", - ) - - size = self._adjust_size_by_margin_limit( - state=state, - entry_price=entry_price, - size=size, - ) - - self._sync_effective_risk_after_margin_limit( - state, - base_size=state.adaptive_size_base or 0.0, - final_size=size, - ) - - size = self._round_size(size) - - if size <= 0: - return ExecutionDecision( - "NONE", - False, - "Позиция не открыта: итоговый size равен 0.", - ) - - type(self)._position = PositionState( - side=side, - symbol=state.symbol, - entry_price=entry_price, - size=size, - leverage=state.leverage, - unrealized_pnl_usd=0.0, - opened_at=now, - opened_monotonic_at=opened_monotonic_at, - updated_at=now, - ) - - self._sync_state_from_position(state) - state.execution_block_reason = None - state.last_flip_block_reason = None - state.last_execution_action = action - state.last_execution_reason = f"Позиция {side} открыта." - - payload: JsonDict = { - "execution_type": "ENTRY", - "action": action, - "symbol": state.symbol, - "side": side, - "entry_price": entry_price, - "size": size, - "leverage": state.leverage, - "signal": state.last_signal, - "confidence": state.last_signal_confidence, - "execution_confidence_score": state.execution_confidence_score, - "execution_confidence_level": state.execution_confidence_level, - "execution_confidence_reason": state.execution_confidence_reason, - "adaptive_size_multiplier": state.adaptive_size_multiplier, - "adaptive_size_reason": state.adaptive_size_reason, - "adaptive_size_factors": state.adaptive_size_factors, - "effective_risk_percent": state.effective_risk_percent, - "effective_target_risk_usd": state.effective_target_risk_usd, - "adaptive_size_base": state.adaptive_size_base, - "adaptive_size_final": state.adaptive_size_final, - "repeat_count": state.last_signal_repeat_count, - "reason": state.last_signal_reason, - "opened_at": now, - "opened_monotonic_at": opened_monotonic_at, - "pricing": "ask_for_long_bid_for_short", - "pricing_role": entry.pricing_role, - "price_source": entry.source, - "price_age_seconds": entry.age_seconds, - "price_updated_at": entry.updated_at, - } - - JournalService().log_ui_info( - event_type="position_opened", - message=f"Позиция {side} открыта: {state.symbol}.", - screen="auto", - action="paper_execution", - payload=payload, - ) - - EventBus.emit("paper_position_opened", payload) - - return ExecutionDecision(action, True, f"Позиция {side} открыта.") - - def _flip_position(self, state: AutoTradeState) -> ExecutionDecision: - position = type(self)._position - - if position.side == "NONE": - self._sync_state_from_position(state) - return ExecutionDecision("NONE", False, "Нет позиции для flip.") - - new_side = self._target_side_from_signal(state.last_signal) - if new_side is None: - return ExecutionDecision("NONE", False, "Нет направления для flip.") - - try: - exit_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) - entry_execution = self._entry_price_for_side(state.symbol, new_side) - exit_price = exit_execution.price - new_entry_price = entry_execution.price - except Exception as exc: - return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}") - - now = self._now_time() - opened_monotonic_at = time.monotonic() - pnl = self._calculate_pnl(exit_price) - new_size = self._calculate_position_size(state, entry_price=new_entry_price) - - if new_size <= 0: - return ExecutionDecision( - "NONE", - False, - "Flip отменён: невозможно рассчитать adaptive size.", - ) - - new_size = self._adjust_size_by_margin_limit( - state=state, - entry_price=new_entry_price, - size=new_size, - ) - - self._sync_effective_risk_after_margin_limit( - state, - base_size=state.adaptive_size_base or 0.0, - final_size=new_size, - ) - - new_size = self._round_size(new_size) - - if new_size <= 0: - return ExecutionDecision( - "NONE", - False, - "Flip отменён: итоговый size равен 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 - - old_side = position.side - old_entry_price = position.entry_price - old_size = position.size - 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, - entry_price=new_entry_price, - size=new_size, - leverage=state.leverage, - unrealized_pnl_usd=0.0, - opened_at=now, - opened_monotonic_at=opened_monotonic_at, - updated_at=now, - ) - - self._sync_state_from_position(state) - state.execution_block_reason = None - state.last_flip_block_reason = None - state.last_execution_action = f"FLIP_{old_side}_TO_{new_side}" - state.last_execution_reason = "Направление позиции изменено." - state.last_flip_at = now - type(self)._last_flip_block_key = None - - payload: JsonDict = { - "execution_type": "FLIP", - "action": f"FLIP_{old_side}_TO_{new_side}", - "symbol": state.symbol, - "old_side": old_side, - "new_side": new_side, - "side": new_side, - "entry_price": old_entry_price, - "exit_price": exit_price, - "new_entry_price": new_entry_price, - "old_size": old_size, - "new_size": new_size, - "size": new_size, - "old_leverage": old_leverage, - "leverage": state.leverage, - "pnl": pnl, - "signal": state.last_signal, - "confidence": state.last_signal_confidence, - "execution_confidence_score": state.execution_confidence_score, - "execution_confidence_level": state.execution_confidence_level, - "execution_confidence_reason": state.execution_confidence_reason, - "adaptive_size_multiplier": state.adaptive_size_multiplier, - "adaptive_size_reason": state.adaptive_size_reason, - "adaptive_size_factors": state.adaptive_size_factors, - "effective_risk_percent": state.effective_risk_percent, - "effective_target_risk_usd": state.effective_target_risk_usd, - "adaptive_size_base": state.adaptive_size_base, - "adaptive_size_final": state.adaptive_size_final, - "repeat_count": state.last_signal_repeat_count, - "reason": state.last_signal_reason, - "opened_at": old_opened_at, - "new_opened_monotonic_at": opened_monotonic_at, - "closed_at": now, - "new_opened_at": now, - "pricing": "exit_by_side_then_entry_by_side", - "exit_pricing_role": exit_execution.pricing_role, - "exit_price_source": exit_execution.source, - "exit_price_age_seconds": exit_execution.age_seconds, - "exit_price_updated_at": exit_execution.updated_at, - "entry_pricing_role": entry_execution.pricing_role, - "entry_price_source": entry_execution.source, - "entry_price_age_seconds": entry_execution.age_seconds, - "entry_price_updated_at": entry_execution.updated_at, - } - - JournalService().log_ui_info( - event_type="position_flipped", - message=f"Направление позиции изменено: {old_side} → {new_side}.", - screen="auto", - action="paper_execution", - payload=payload, - ) - - EventBus.emit("paper_position_flipped", payload) - - return ExecutionDecision( - f"FLIP_{old_side}_TO_{new_side}", - True, - f"Направление позиции изменено: {old_side} → {new_side}.", - ) - - def _close_position( - self, - state: AutoTradeState, - *, - forced_reason: str | None = None, - forced_exit_price: NumericLike | None = None, - forced_pnl: NumericLike | None = None, - forced_price_meta: _ExecutionPrice | None = None, - ) -> ExecutionDecision: - position = type(self)._position - - if position.side == "NONE": - self._sync_state_from_position(state) - return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.") - - if forced_exit_price is not None: - exit_price = safe_float(forced_exit_price) or 0.0 - exit_execution = forced_price_meta - else: - try: - exit_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) - exit_price = exit_execution.price - except Exception as exc: - return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") - - 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 - - if pnl < 0: - state.last_loss_monotonic_at = time.monotonic() - - now = self._now_time() - - payload: JsonDict = { - "execution_type": "EXIT", - "action": "CLOSE", - "symbol": state.symbol, - "side": position.side, - "entry_price": position.entry_price, - "exit_price": exit_price, - "size": position.size, - "leverage": position.leverage, - "pnl": pnl, - "signal": state.last_signal, - "confidence": state.last_signal_confidence, - "repeat_count": state.last_signal_repeat_count, - "reason": state.last_signal_reason, - "risk_reason": forced_reason, - "is_forced": forced_reason is not None, - "opened_at": position.opened_at, - "closed_at": now, - "pricing": "bid_for_long_exit_ask_for_short_exit", - "pricing_role": exit_execution.pricing_role if exit_execution else None, - "price_source": exit_execution.source if exit_execution else None, - "price_age_seconds": exit_execution.age_seconds if exit_execution else None, - "price_updated_at": exit_execution.updated_at if exit_execution else None, - } - - close_reason = forced_reason or "MANUAL" - - JournalService().log_ui_info( - event_type="position_closed", - message=f"Позиция {position.side} закрыта: {close_reason}.", - screen="auto", - action="paper_execution", - payload=payload, - ) - - EventBus.emit("paper_position_closed", payload) - - type(self)._position = PositionState() - self._sync_state_from_position(state) - state.position_opened_monotonic_at = None - self._reset_runtime_protection_state(state) - state.execution_block_reason = None - state.last_flip_block_reason = None - state.last_execution_action = ( - f"FORCE_CLOSE_{forced_reason}" - if forced_reason is not None - else "CLOSE" - ) - state.last_execution_reason = ( - f"Позиция закрыта по правилу защиты: {forced_reason}." - if forced_reason is not None - else "Позиция закрыта." - ) - type(self)._last_flip_block_key = None - - if forced_reason is not None: - return ExecutionDecision( - f"FORCE_CLOSE_{forced_reason}", - True, - f"Позиция закрыта по правилу защиты: {forced_reason}.", - ) - - - return ExecutionDecision("CLOSE", True, "Позиция закрыта.") - - def _process_execution_supervisor( - self, - state: AutoTradeState, - ) -> ExecutionDecision | None: - halt_reason = self._execution_halt_reason(state) - - if halt_reason is not None: - return self._block_execution( - state=state, - reason=halt_reason, - action="EXECUTION_HALTED", - ) - - cooldown_reason = self._execution_cooldown_reason(state) - - if cooldown_reason is not None: - return self._block_execution( - state=state, - reason=cooldown_reason, - action="EXECUTION_COOLDOWN", - ) - - degraded_reason = self._degraded_market_reason(state) - - if degraded_reason is not None: - return self._block_execution( - state=state, - reason=degraded_reason, - action="DEGRADED_MARKET", - ) - - stale_reason = self._stale_execution_reason(state) - - if stale_reason is not None: - return self._block_execution( - state=state, - reason=stale_reason, - action="STALE_EXECUTION", - ) - - conflict_reason = self._conflict_signal_reason(state) - - if conflict_reason is not None: - return self._block_execution( - state=state, - reason=conflict_reason, - action="SIGNAL_CONFLICT", - ) - - return None - - def _execution_halt_reason( - self, - state: AutoTradeState, - ) -> str | None: - pnl = safe_float(state.cycle_realized_pnl_usd) or 0.0 - - if pnl <= -abs(self._emergency_halt_drawdown_usd): - return ( - "execution emergency halt: " - "cycle drawdown limit exceeded" - ) - - closed = safe_float(state.cycle_closed_trades) or 0 - wins = safe_float(state.cycle_winning_trades) or 0 - - losses = max(0, int(closed - wins)) - - if losses >= self._emergency_halt_loss_streak: - return ( - "execution emergency halt: " - "loss streak exceeded" - ) - - return None - - def _execution_cooldown_reason( - self, - state: AutoTradeState, - ) -> str | None: - ts = safe_float( - getattr(state, "last_loss_monotonic_at", None) - ) - - if ts is None: - return None - - delta = ( - time.monotonic() - ts - ) - - if delta < self._execution_cooldown_after_loss_seconds: - remaining = int( - self._execution_cooldown_after_loss_seconds - delta - ) - - return ( - "execution cooldown after loss " - f"({remaining}s remaining)" - ) - - return None - - def _degraded_market_reason( - self, - state: AutoTradeState, - ) -> str | None: - market_state = getattr(state, "market_state", None) - - if market_state in self._degraded_market_block_states: - return ( - "market state blocked execution: " - f"{market_state}" - ) - - return None - - def _stale_execution_reason( - self, - state: AutoTradeState, - ) -> str | None: - age = safe_float( - getattr(state, "execution_price_age_seconds", None) - ) - - if age is None: - age = safe_float( - getattr(state, "snapshot_age_seconds", None) - ) - - if age is None: - return None - - if age > self._max_execution_snapshot_age_seconds: - return ( - "execution snapshot stale: " - f"{age:.2f}s" - ) - - return None - - def _conflict_signal_reason( - self, - state: AutoTradeState, - ) -> str | None: - if not self._conflict_execution_block: - return None - - signal = (state.last_signal or "").upper() - momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() - trend_direction = str(getattr(state, "market_trend", "") or "").upper() - - if signal == "BUY": - if momentum_direction == "DOWN": - return "BUY conflicts with momentum" - - if trend_direction == "DOWN": - return "BUY conflicts with trend" - - if signal == "SELL": - if momentum_direction == "UP": - return "SELL conflicts with momentum" - - if trend_direction == "UP": - return "SELL conflicts with trend" - - return None - - def _block_execution( - self, - *, - state: AutoTradeState, - reason: str, - action: str, - ) -> ExecutionDecision: - state.execution_block_reason = reason - state.last_execution_action = action - state.last_execution_reason = reason - - key = ( - f"{action}:" - f"{state.symbol}:" - f"{reason}" - ) - - if key != type(self)._last_supervisor_block_key: - type(self)._last_supervisor_block_key = key - - payload: JsonDict = { - "execution_type": "SUPERVISOR_BLOCK", - "action": action, - "symbol": state.symbol, - "reason": reason, - "market_state": getattr( - state, - "market_state", - None, - ), - "signal": state.last_signal, - "confidence": state.last_signal_confidence, - "unrealized_pnl_usd": state.unrealized_pnl_usd, - "cycle_realized_pnl_usd": state.cycle_realized_pnl_usd, - } - - JournalService().log_ui_warning( - event_type="execution_supervisor_block", - message=f"Execution supervisor blocked action: {reason}", - screen="auto", - action="execution_supervisor", - payload=payload, - ) - - EventBus.emit( - "execution_supervisor_block", - payload, - ) - - return ExecutionDecision( - "NONE", - False, - reason, - ) - - def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: - position = type(self)._position - - if position.side == "NONE": - return None - - try: - current_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side) - current_price = current_execution.price - except Exception: - return None - - price_move_percent = self._calculate_price_move_percent(current_price) - unrealized_pnl = self._calculate_pnl(current_price) - - if self._is_max_loss_hit(state, unrealized_pnl): - return self._close_position( - state, - forced_reason="MAX_LOSS", - forced_exit_price=current_price, - forced_pnl=unrealized_pnl, - forced_price_meta=current_execution, - ) - - if self._is_stop_loss_hit(state, price_move_percent): - return self._close_position( - state, - forced_reason="STOP_LOSS", - forced_exit_price=current_price, - forced_pnl=unrealized_pnl, - forced_price_meta=current_execution, - ) - - if self._is_take_profit_hit(state, price_move_percent): - return self._close_position( - state, - forced_reason="TAKE_PROFIT", - forced_exit_price=current_price, - forced_pnl=unrealized_pnl, - forced_price_meta=current_execution, - ) - - return None - - def _process_runtime_protection( - self, - state: AutoTradeState, - ) -> ExecutionDecision | None: - position = type(self)._position - - if position.side == "NONE": - self._reset_runtime_protection_state(state) - return None - - try: - current_execution = self._exit_price_for_side( - position.symbol or state.symbol, - position.side, - ) - current_price = current_execution.price - except Exception: - self._sync_runtime_protection_state( - state=state, - status="DEGRADED", - reason="нет актуальной цены для protection engine", - ) - return None - - self._sync_runtime_protection_state( - state=state, - status="ACTIVE", - reason="protection engine активен", - ) - - self._update_break_even_protection( - state=state, - current_price=current_price, - ) - - self._update_profit_lock_protection( - state=state, - current_price=current_price, - ) - - self._update_trailing_stop_protection( - state=state, - current_price=current_price, - ) - - close_reason = self._runtime_protection_close_reason( - state=state, - current_price=current_price, - ) - - if close_reason is None: - close_reason = self._runtime_intelligence_close_reason( - state=state, - current_price=current_price, - ) - - if close_reason is None: - return None - - pnl = self._calculate_pnl(current_price) - - return self._close_position( - state, - forced_reason=close_reason, - forced_exit_price=current_price, - forced_pnl=pnl, - forced_price_meta=current_execution, - ) - - def _reset_runtime_protection_state(self, state: AutoTradeState) -> None: - state.position_protection_status = None - state.position_protection_reason = None - state.break_even_armed = False - state.break_even_price = None - state.trailing_stop_active = False - state.trailing_stop_price = None - state.profit_lock_active = False - state.profit_lock_price = None - state.runtime_protection_action = None - state.runtime_protection_reason = None - state.runtime_protection_updated_at = None - - def _sync_runtime_protection_state( - self, - *, - state: AutoTradeState, - status: str, - reason: str, - ) -> None: - state.position_protection_status = status - state.position_protection_reason = reason - state.runtime_protection_updated_at = time.monotonic() - - def _runtime_intelligence_close_reason( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> str | None: - giveback_reason = self._giveback_close_reason( - state=state, - current_price=current_price, - ) - - if giveback_reason is not None: - return giveback_reason - - time_decay_reason = self._time_decay_close_reason( - state=state, - current_price=current_price, - ) - - if time_decay_reason is not None: - return time_decay_reason - - return None - - - def _giveback_close_reason( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> str | None: - pnl_percent = self._calculate_price_move_percent(current_price) - - peak_percent = safe_float( - getattr(state, "position_peak_pnl_percent", None) - ) - - if peak_percent is None or peak_percent <= 0: - return None - - if pnl_percent is None: - return None - - giveback = peak_percent - pnl_percent - - if giveback <= 0: - return None - - giveback_percent = round((giveback / peak_percent) * 100, 2) - - fatigue_state = str( - getattr(state, "position_fatigue_state", "") or "" - ).upper() - - reversal_risk = str( - getattr(state, "position_reversal_risk", "") or "" - ).upper() - - adverse_momentum = bool( - getattr(state, "position_adverse_momentum", False) - ) - - exit_confidence = safe_float( - getattr(state, "position_exit_confidence", None) - ) or 0.0 - - if ( - peak_percent >= 0.75 - and giveback_percent >= 55 - and pnl_percent > 0 - ): - return "GIVEBACK_PROTECTION" - - if ( - peak_percent >= 0.50 - and giveback_percent >= 40 - and adverse_momentum - ): - return "GIVEBACK_MOMENTUM_REVERSAL" - - if ( - peak_percent >= 0.50 - and giveback_percent >= 35 - and fatigue_state in {"TIRED", "EXHAUSTED"} - ): - return "GIVEBACK_FATIGUE_EXIT" - - if ( - peak_percent >= 0.50 - and giveback_percent >= 35 - and reversal_risk in {"ELEVATED", "HIGH"} - and exit_confidence >= 0.50 - ): - return "GIVEBACK_REVERSAL_RISK" - - return None - - - def _time_decay_close_reason( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> str | None: - hold_seconds = safe_float( - getattr(state, "position_hold_seconds", None) - ) - - if hold_seconds is None: - hold_seconds = safe_float( - self._position_hold_seconds(type(self)._position) - ) - - if hold_seconds is None: - return None - - pnl_percent = self._calculate_price_move_percent(current_price) - - fatigue_state = str( - getattr(state, "position_fatigue_state", "") or "" - ).upper() - - conviction_state = str( - getattr(state, "position_conviction_state", "") or "" - ).upper() - - decay_state = str( - getattr(state, "position_decay_state", "") or "" - ).upper() - - adverse_momentum = bool( - getattr(state, "position_adverse_momentum", False) - ) - - market_runtime_degraded = bool( - getattr(state, "market_runtime_degraded", False) - ) - - if pnl_percent is None: - return None - - if ( - hold_seconds >= 2400 - and -0.15 <= pnl_percent <= 0.25 - and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"} - ): - return "TIME_DECAY_EXIT" - - if ( - hold_seconds >= 1800 - and -0.20 <= pnl_percent <= 0.35 - and fatigue_state in {"TIRED", "EXHAUSTED"} - ): - return "TIME_DECAY_FATIGUE_EXIT" - - if ( - hold_seconds >= 1200 - and pnl_percent <= 0.20 - and adverse_momentum - ): - return "TIME_DECAY_ADVERSE_MOMENTUM" - - if ( - hold_seconds >= 1200 - and pnl_percent <= 0.30 - and market_runtime_degraded - ): - return "TIME_DECAY_DEGRADED_MARKET" - - if ( - hold_seconds >= 1800 - and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"} - and pnl_percent <= 0.30 - ): - return "TIME_DECAY_CONTEXT_DECAY" - - return None - - def _update_break_even_protection( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> None: - position = type(self)._position - - if state.break_even_armed: - return - - pnl_percent = self._calculate_price_move_percent(current_price) - - if pnl_percent is None: - return - - if pnl_percent < 0.35: - return - - entry_price = safe_float(position.entry_price) - - if entry_price is None or entry_price <= 0: - return - - state.break_even_armed = True - state.break_even_price = entry_price - state.runtime_protection_action = "BREAK_EVEN_ARMED" - state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован" - state.runtime_protection_updated_at = time.monotonic() - - self._log_runtime_protection_event( - state=state, - action="BREAK_EVEN_ARMED", - reason=state.runtime_protection_reason, - current_price=current_price, - ) - - def _update_profit_lock_protection( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> None: - position = type(self)._position - - pnl_percent = self._calculate_price_move_percent(current_price) - - if pnl_percent is None: - return - - if pnl_percent < 0.75: - return - - entry_price = safe_float(position.entry_price) - - if entry_price is None or entry_price <= 0: - return - - if position.side == "LONG": - lock_price = entry_price * 1.003 - - elif position.side == "SHORT": - lock_price = entry_price * 0.997 - - else: - return - - previous_price = safe_float(state.profit_lock_price) - - if previous_price is not None: - if position.side == "LONG" and lock_price <= previous_price: - return - - if position.side == "SHORT" and lock_price >= previous_price: - return - - state.profit_lock_active = True - state.profit_lock_price = round(lock_price, 8) - state.runtime_protection_action = "PROFIT_LOCK_ACTIVE" - state.runtime_protection_reason = "часть прибыли защищена profit lock" - state.runtime_protection_updated_at = time.monotonic() - - self._log_runtime_protection_event( - state=state, - action="PROFIT_LOCK_ACTIVE", - reason=state.runtime_protection_reason, - current_price=current_price, - ) - - def _update_trailing_stop_protection( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> None: - position = type(self)._position - - pnl_percent = self._calculate_price_move_percent(current_price) - - if pnl_percent is None: - return - - if pnl_percent < 1.0: - return - - trail_distance_percent = 0.35 - - if position.side == "LONG": - trail_price = current_price * (1 - trail_distance_percent / 100) - - previous_price = safe_float(state.trailing_stop_price) - - if previous_price is not None and trail_price <= previous_price: - return - - elif position.side == "SHORT": - trail_price = current_price * (1 + trail_distance_percent / 100) - - previous_price = safe_float(state.trailing_stop_price) - - if previous_price is not None and trail_price >= previous_price: - return - - else: - return - - state.trailing_stop_active = True - state.trailing_stop_price = round(trail_price, 8) - state.runtime_protection_action = "TRAILING_STOP_ACTIVE" - state.runtime_protection_reason = "trailing stop подтянут вслед за прибылью" - state.runtime_protection_updated_at = time.monotonic() - - self._log_runtime_protection_event( - state=state, - action="TRAILING_STOP_ACTIVE", - reason=state.runtime_protection_reason, - current_price=current_price, - ) - - def _runtime_protection_close_reason( - self, - *, - state: AutoTradeState, - current_price: float, - ) -> str | None: - position = type(self)._position - - fatigue_state = str(getattr(state, "position_fatigue_state", "") or "").upper() - reversal_risk = str(getattr(state, "position_reversal_risk", "") or "").upper() - exit_urgency = str(getattr(state, "position_exit_urgency", "") or "").upper() - conviction = str(getattr(state, "position_conviction_state", "") or "").upper() - risk_level = str(getattr(state, "position_risk_level", "") or "").upper() - exit_signal = str(getattr(state, "position_exit_signal", "") or "").upper() - decay_state = str(getattr(state, "position_decay_state", "") or "").upper() - - if exit_urgency == "IMMEDIATE": - return "LIFECYCLE_EXIT" - - if conviction == "BROKEN": - return "CONVICTION_BROKEN" - - if fatigue_state == "EXHAUSTED" and reversal_risk in {"ELEVATED", "HIGH"}: - return "FATIGUE_EXIT" - - if ( - state.position_adverse_momentum - and reversal_risk == "HIGH" - and risk_level in {"ELEVATED", "HIGH"} - ): - return "MOMENTUM_EXIT" - - if ( - getattr(state, "market_runtime_degraded", False) - and exit_signal in {"EXIT", "REDUCE_OR_PROTECT"} - and decay_state != "NONE" - ): - return "DEGRADATION_EXIT" - - if position.side == "LONG": - if ( - state.trailing_stop_active - and state.trailing_stop_price is not None - and current_price <= state.trailing_stop_price - ): - return "TRAILING_STOP" - - if ( - state.profit_lock_active - and state.profit_lock_price is not None - and current_price <= state.profit_lock_price - ): - return "PROFIT_LOCK" - - if ( - state.break_even_armed - and state.break_even_price is not None - and current_price <= state.break_even_price - ): - return "BREAK_EVEN" - - if position.side == "SHORT": - if ( - state.trailing_stop_active - and state.trailing_stop_price is not None - and current_price >= state.trailing_stop_price - ): - return "TRAILING_STOP" - - if ( - state.profit_lock_active - and state.profit_lock_price is not None - and current_price >= state.profit_lock_price - ): - return "PROFIT_LOCK" - - if ( - state.break_even_armed - and state.break_even_price is not None - and current_price >= state.break_even_price - ): - return "BREAK_EVEN" - - return None - - def _log_runtime_protection_event( - self, - *, - state: AutoTradeState, - action: str, - reason: str, - current_price: float, - ) -> None: - position = type(self)._position - - payload: JsonDict = { - "execution_type": "RUNTIME_PROTECTION", - "action": action, - "symbol": state.symbol, - "position_side": position.side, - "entry_price": position.entry_price, - "current_price": current_price, - "size": position.size, - "unrealized_pnl_usd": state.unrealized_pnl_usd, - "position_pnl_percent": self._calculate_price_move_percent(current_price), - "break_even_armed": state.break_even_armed, - "break_even_price": state.break_even_price, - "profit_lock_active": state.profit_lock_active, - "profit_lock_price": state.profit_lock_price, - "trailing_stop_active": state.trailing_stop_active, - "trailing_stop_price": state.trailing_stop_price, - "reason": reason, - } - - JournalService().log_ui_info( - event_type="runtime_protection_updated", - message=f"Runtime protection: {action}. {reason}.", - screen="auto", - action="runtime_protection", - payload=payload, - ) - - EventBus.emit("runtime_protection_updated", payload) - - def _is_stop_loss_hit(self, state: AutoTradeState, price_move_percent: float) -> bool: - if state.stop_loss_percent is None: - return False - return price_move_percent <= -abs(state.stop_loss_percent) - - def _is_take_profit_hit(self, state: AutoTradeState, price_move_percent: float) -> bool: - if state.take_profit_percent is None: - return False - return price_move_percent >= abs(state.take_profit_percent) - - def _is_max_loss_hit(self, state: AutoTradeState, unrealized_pnl: float) -> bool: - if state.max_loss_usd is None: - return False - return unrealized_pnl <= -abs(state.max_loss_usd) - - def _calculate_price_move_percent( - self, - current_price: NumericLike | None, - ) -> float: - position = type(self)._position - - 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(((price - entry) / entry) * 100, 4) - - if position.side == "SHORT": - return round(((entry - price) / entry) * 100, 4) - - return 0.0 - - def _should_flip_position(self, state: AutoTradeState) -> bool: - position = type(self)._position - - if position.side == "NONE": - return False - - if position.side == "LONG" and state.last_signal == "SELL": - return True - - if position.side == "SHORT" and state.last_signal == "BUY": - return True - - return False - - def _flip_block_reason(self, state: AutoTradeState) -> str | None: - position = type(self)._position - - 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) - signal = (state.last_signal or "").upper() - - if confidence < self._min_flip_confidence: - return ( - "уверенность сигнала ниже порога " - f"({confidence:.2f} < {self._min_flip_confidence:.2f})" - ) - - if repeat_count < self._min_flip_repeat_count: - return ( - "сигнал ещё не подтверждён нужным количеством повторов " - f"({repeat_count} < {self._min_flip_repeat_count})" - ) - - if hold_seconds is not None and hold_seconds < self._min_flip_hold_seconds: - return ( - "позиция открыта слишком недавно " - f"({hold_seconds}с < {self._min_flip_hold_seconds}с)" - ) - - if self._flip_cooldown_active(state): - return ( - "flip cooldown активен " - f"(< {self._flip_cooldown_seconds}с)" - ) - - if signal == "BUY" and momentum_direction == "DOWN": - return "momentum направлен против BUY сигнала" - - if signal == "SELL" and momentum_direction == "UP": - return "momentum направлен против SELL сигнала" - - if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: - if confidence < 0.85: - return ( - "flip заблокирован во время breakout impulse " - f"({confidence:.2f} < 0.85)" - ) - - if unrealized_pnl < 0 and confidence < self._loss_flip_confidence: - return ( - "позиция сейчас в минусе, а сигнал недостаточно сильный " - f"({confidence:.2f} < {self._loss_flip_confidence:.2f})" - ) - - return None - - def _block_flip( - self, - state: AutoTradeState, - reason: str, - ) -> ExecutionDecision: - position = type(self)._position - confidence = safe_float(state.last_signal_confidence) or 0.0 - - state.execution_block_reason = reason - state.last_flip_block_reason = reason - state.last_execution_action = "FLIP_BLOCKED" - state.last_execution_reason = reason - - block_key = ( - f"{position.side}:" - f"{state.last_signal}:" - f"{state.last_signal_repeat_count}:" - f"{confidence:.2f}:" - f"{reason}" - ) - - if block_key != type(self)._last_flip_block_key: - type(self)._last_flip_block_key = block_key - - payload: JsonDict = { - "execution_type": "FLIP_BLOCKED", - "symbol": state.symbol, - "position_side": position.side, - "signal": state.last_signal, - "confidence": confidence, - "repeat_count": state.last_signal_repeat_count, - "reason": reason, - "unrealized_pnl_usd": state.unrealized_pnl_usd, - "opened_at": position.opened_at, - "updated_at": position.updated_at, - } - - JournalService().log_ui_warning( - event_type="position_flip_blocked", - message=f"Смена направления позиции заблокирована: {reason}.", - screen="auto", - action="paper_execution", - payload=payload, - ) - - EventBus.emit("paper_flip_blocked", payload) - - return ExecutionDecision("NONE", False, reason) - - def _position_hold_seconds(self, position: PositionState) -> int | None: - opened_monotonic_at = safe_float( - getattr(position, "opened_monotonic_at", None) - ) - - if opened_monotonic_at is not None: - return max(0, int(time.monotonic() - opened_monotonic_at)) - - if not position.opened_at: - return None - - try: - opened_at = datetime.strptime(position.opened_at, "%H:%M:%S") - now = datetime.strptime(self._now_time(), "%H:%M:%S") - - seconds = int((now - opened_at).total_seconds()) - - if seconds < 0: - seconds += 24 * 60 * 60 - - return seconds - except Exception: - return None - - def _flip_cooldown_active( - self, - state: AutoTradeState, - ) -> bool: - ts = getattr(state, "last_flip_monotonic_at", None) - - if ts is None: - return False - - return ( - time.monotonic() - float(ts) - ) < self._flip_cooldown_seconds - - def _target_side_from_signal(self, signal: str | None) -> str | None: - if signal == "BUY": - return "LONG" - - if signal == "SELL": - return "SHORT" - - return None - - def _update_unrealized_pnl(self, state: AutoTradeState) -> None: - position = type(self)._position - - if position.side == "NONE": - self._sync_state_from_position(state) - return - - try: - current_execution = self._exit_price_for_side( - position.symbol or state.symbol, - position.side, - ) - current_price = current_execution.price - except Exception: - self._sync_state_from_position(state) - return - - pnl = self._calculate_pnl(current_price) - pnl_percent = self._calculate_price_move_percent(current_price) - - position.unrealized_pnl_usd = pnl - position.updated_at = self._now_time() - - if position.peak_unrealized_pnl_usd is None or pnl > position.peak_unrealized_pnl_usd: - position.peak_unrealized_pnl_usd = pnl - - if position.peak_pnl_percent is None or pnl_percent > position.peak_pnl_percent: - position.peak_pnl_percent = pnl_percent - - if position.max_favorable_excursion_percent is None: - position.max_favorable_excursion_percent = max(0.0, pnl_percent) - else: - position.max_favorable_excursion_percent = max( - position.max_favorable_excursion_percent, - pnl_percent, - ) - - if position.max_adverse_excursion_percent is None: - position.max_adverse_excursion_percent = min(0.0, pnl_percent) - else: - position.max_adverse_excursion_percent = min( - position.max_adverse_excursion_percent, - pnl_percent, - ) - - self._sync_position_runtime_memory( - position=position, - current_price=current_price, - pnl_percent=pnl_percent, - ) - - self._sync_state_from_position(state) - - def _sync_position_runtime_memory( - self, - *, - position: PositionState, - current_price: float, - pnl_percent: float, - ) -> None: - if position.best_price_seen is None: - position.best_price_seen = current_price - - if position.worst_price_seen is None: - position.worst_price_seen = current_price - - if position.side == "LONG": - position.best_price_seen = max(position.best_price_seen, current_price) - position.worst_price_seen = min(position.worst_price_seen, current_price) - - elif position.side == "SHORT": - position.best_price_seen = min(position.best_price_seen, current_price) - position.worst_price_seen = max(position.worst_price_seen, current_price) - - peak = safe_float(position.peak_pnl_percent) or 0.0 - - giveback_score = 0.0 - - if peak > 0: - giveback = max(0.0, peak - pnl_percent) - giveback_score = min(1.0, giveback / max(0.01, peak)) - - fatigue = 0.0 - - if giveback_score >= 0.70: - fatigue += 0.35 - elif giveback_score >= 0.45: - fatigue += 0.25 - elif giveback_score >= 0.25: - fatigue += 0.12 - - if pnl_percent < 0: - fatigue += 0.20 - - position.fatigue_score = round(max(0.0, min(1.0, fatigue)), 3) - - if position.fatigue_score >= 0.75: - position.fatigue_state = "EXHAUSTED" - elif position.fatigue_score >= 0.50: - position.fatigue_state = "TIRED" - elif position.fatigue_score >= 0.25: - position.fatigue_state = "WATCH" - else: - position.fatigue_state = "FRESH" - - def _calculate_position_size( - self, - state: AutoTradeState, - *, - entry_price: float | None = None, - ) -> float: - if state.risk_percent is None or state.risk_percent <= 0: - self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) - return 0.0 - - if state.stop_loss_percent is None or state.stop_loss_percent <= 0: - self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) - return 0.0 - - price = entry_price - - if price is None: - try: - price = self._signal_entry_price(state).price - except Exception: - self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) - return 0.0 - - if price <= 0: - self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) - return 0.0 - - balance_usd = state.allocated_balance_usd - target_risk_usd = balance_usd * (state.risk_percent / 100) - stop_loss_distance_usd = price * (state.stop_loss_percent / 100) - - if stop_loss_distance_usd <= 0: - self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0) - return 0.0 - - base_size = target_risk_usd / stop_loss_distance_usd - multiplier = self._adaptive_size_multiplier(state) - final_size = base_size * multiplier - - self._sync_adaptive_size_state( - state, - base_size=base_size, - final_size=final_size, - multiplier=multiplier, - ) - - return self._round_size(final_size) - - def _adaptive_size_multiplier(self, state: AutoTradeState) -> float: - multiplier = 1.0 - - execution_confidence_score = getattr(state, "execution_confidence_score", None) - 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 - elif score < 0.65: - multiplier *= 0.65 - elif score < 0.75: - multiplier *= 0.85 - elif score >= 0.85: - multiplier *= 1.15 - - market_state = getattr(state, "market_state", None) - market_trend_strength = getattr(state, "market_trend_strength", None) - market_trend_quality = getattr(state, "market_trend_quality", None) - market_phase = getattr(state, "market_phase", None) - - if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE", "CHAOTIC", "LIQUIDITY_VOID"}: - multiplier *= 0.65 - - if market_trend_strength == "STRONG": - multiplier *= 1.1 - elif market_trend_strength == "WEAK": - multiplier *= 0.75 - - if market_trend_quality == "CLEAN": - multiplier *= 1.05 - elif market_trend_quality == "NOISY": - multiplier *= 0.75 - - if market_phase == "IMPULSE": - multiplier *= 1.1 - elif market_phase == "PULLBACK": - multiplier *= 0.8 - elif market_phase in {"RANGE", "SQUEEZE"}: - multiplier *= 0.7 - - momentum_state = getattr(state, "momentum_state", None) - momentum_direction = getattr(state, "momentum_direction", None) - momentum_strength = getattr(state, "momentum_strength", None) - - signal = (state.last_signal or "").upper() - - if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: - multiplier *= 1.15 - elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}: - multiplier *= 1.05 - - strength = safe_float(momentum_strength) - if strength is not None: - if strength >= 1.5: - multiplier *= 1.1 - elif strength <= 0.7: - multiplier *= 0.8 - - if signal == "BUY" and momentum_direction == "DOWN": - multiplier *= 0.65 - - if signal == "SELL" and momentum_direction == "UP": - multiplier *= 0.65 - - execution_quality = getattr(state, "execution_quality", None) - execution_quality_reason = getattr(state, "execution_quality_reason", None) - - if execution_quality == "BLOCKED": - multiplier *= 0.0 - elif execution_quality == "WARNING": - if execution_quality_reason == "WIDE_SPREAD": - multiplier *= 0.75 - elif execution_quality_reason == "AGING_SNAPSHOT": - multiplier *= 0.8 - elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE": - multiplier *= 0.7 - else: - multiplier *= 0.8 - - if getattr(state, "market_runtime_degraded", False): - multiplier *= 0.75 - - return round(max(0.0, min(1.25, multiplier)), 4) - - def _sync_adaptive_size_state( - self, - state: AutoTradeState, - *, - base_size: float, - final_size: float, - multiplier: float, - ) -> None: - reason = self._adaptive_size_reason(multiplier) - - state.adaptive_size_base = self._round_size(base_size) - state.adaptive_size_final = self._round_size(final_size) - state.adaptive_size_multiplier = multiplier - - 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, - 4, - ) - - state.effective_target_risk_usd = round( - state.allocated_balance_usd - * (state.effective_risk_percent / 100), - 4, - ) - state.adaptive_size_reason = reason - state.adaptive_size_factors = { - "execution_confidence_score": getattr(state, "execution_confidence_score", None), - "execution_confidence_level": getattr(state, "execution_confidence_level", None), - "market_state": getattr(state, "market_state", None), - "market_trend_strength": getattr(state, "market_trend_strength", None), - "market_trend_quality": getattr(state, "market_trend_quality", None), - "market_phase": getattr(state, "market_phase", None), - "momentum_state": getattr(state, "momentum_state", None), - "momentum_direction": getattr(state, "momentum_direction", None), - "momentum_strength": getattr(state, "momentum_strength", None), - "execution_quality": getattr(state, "execution_quality", None), - "execution_quality_reason": getattr(state, "execution_quality_reason", None), - "spread_percent": getattr(state, "spread_percent", None), - "base_size": self._round_size(base_size), - "final_size": self._round_size(final_size), - "multiplier": multiplier, - } - - if multiplier <= 0: - state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_ZERO" - elif multiplier < 1: - state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_REDUCED" - elif multiplier > 1: - state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_INCREASED" - else: - state.execution_size_adjustment_reason = None - - def _sync_effective_risk_after_margin_limit( - self, - state: AutoTradeState, - *, - base_size: float, - final_size: float, - ) -> None: - adaptive_final = safe_float(state.adaptive_size_final) or 0.0 - - if adaptive_final <= 0: - state.effective_risk_percent = 0.0 - state.effective_target_risk_usd = 0.0 - return - - margin_ratio = max( - 0.0, - min(1.0, final_size / adaptive_final), - ) - - current_effective_risk = safe_float(state.effective_risk_percent) or 0.0 - - state.effective_risk_percent = round( - current_effective_risk * margin_ratio, - 4, - ) - - state.effective_target_risk_usd = round( - state.allocated_balance_usd - * (state.effective_risk_percent / 100), - 4, - ) - - def _adaptive_size_reason(self, multiplier: float) -> str: - if multiplier <= 0: - return "adaptive size заблокировал вход" - - if multiplier < 0.75: - return "размер позиции сильно уменьшен по risk/runtime факторам" - - if multiplier < 1: - return "размер позиции умеренно уменьшен по risk/runtime факторам" - - if multiplier > 1: - return "размер позиции увеличен при сильном execution context" - - return "размер позиции без adaptive корректировки" - - def _adjust_size_by_margin_limit( - self, - *, - state: AutoTradeState, - entry_price: float, - size: float, - ) -> float: - max_percent = state.max_reserved_balance_percent - - if max_percent is None or max_percent <= 0: - return self._round_size(size) - - leverage = state.leverage or 1.0 - if leverage <= 0 or entry_price <= 0: - state.execution_block_reason = "Invalid leverage or entry price." - return 0.0 - - balance_usd = state.allocated_balance_usd - max_reserved_usd = balance_usd * (max_percent / 100) - - max_notional_usd = max_reserved_usd * leverage - max_size = max_notional_usd / entry_price - - if size <= max_size: - return self._round_size(size) - - state.execution_size_adjustment_reason = "MARGIN_LIMIT" - - limited_size = self._round_size(max_size) - - adaptive_final = safe_float(state.adaptive_size_final) or 0.0 - - if adaptive_final > 0: - effective_multiplier = limited_size / adaptive_final - - if effective_multiplier < 0.5: - state.adaptive_size_reason = ( - "размер позиции сильно ограничен margin limit" - ) - else: - state.adaptive_size_reason = ( - "размер позиции ограничен margin limit" - ) - - return limited_size - - def _signal_entry_price(self, state: AutoTradeState) -> _ExecutionPrice: - if state.last_signal == "BUY": - return self._entry_price_for_side(state.symbol, "LONG") - - if state.last_signal == "SELL": - return self._entry_price_for_side(state.symbol, "SHORT") - - return self._market_last_price(state.symbol) - - def _entry_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice: - snapshot = ExchangeService().get_execution_snapshot(symbol) - - if ( - snapshot.age_seconds is not None - and snapshot.age_seconds > 5 - ): - raise ValueError( - "Execution snapshot is stale." - ) - - if side == "LONG": - return _ExecutionPrice( - price=self._snapshot_price(snapshot.ask_price, "ask_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="LONG_ENTRY_ASK", - ) - - if side == "SHORT": - return _ExecutionPrice( - price=self._snapshot_price(snapshot.bid_price, "bid_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="SHORT_ENTRY_BID", - ) - - return _ExecutionPrice( - price=self._snapshot_price(snapshot.last_price, "last_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="ENTRY_LAST", - ) - - def _exit_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice: - snapshot = ExchangeService().get_execution_snapshot(symbol) - - if ( - snapshot.age_seconds is not None - and snapshot.age_seconds > 5 - ): - raise ValueError( - "Execution snapshot is stale." - ) - - if side == "LONG": - return _ExecutionPrice( - price=self._snapshot_price(snapshot.bid_price, "bid_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="LONG_EXIT_BID", - ) - - if side == "SHORT": - return _ExecutionPrice( - price=self._snapshot_price(snapshot.ask_price, "ask_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="SHORT_EXIT_ASK", - ) - - return _ExecutionPrice( - price=self._snapshot_price(snapshot.last_price, "last_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="EXIT_LAST", - ) - - def _market_last_price(self, symbol: str) -> _ExecutionPrice: - snapshot = ExchangeService().get_execution_snapshot(symbol) - - return _ExecutionPrice( - price=self._snapshot_price(snapshot.last_price, "last_price"), - source=snapshot.source, - age_seconds=snapshot.age_seconds, - updated_at=snapshot.updated_at, - pricing_role="MARKET_LAST", - ) - - 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." - ) - - 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}" - ) - - return price - - def _round_size(self, size: NumericLike | None) -> float: - value = safe_float(size) - - 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 - - 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((price - entry) * size, 4) - - if position.side == "SHORT": - return round((entry - price) * size, 4) - - return 0.0 - - def _sync_state_from_position(self, state: AutoTradeState) -> None: - position = type(self)._position - - state.position_side = position.side - state.entry_price = position.entry_price - state.position_size = position.size - state.unrealized_pnl_usd = position.unrealized_pnl_usd - - if position.side == "NONE": - state.position_opened_monotonic_at = None - - state.position_peak_pnl_usd = None - state.position_peak_pnl_percent = None - state.position_mfe_percent = None - state.position_mae_percent = None - state.position_fatigue_score = None - state.position_fatigue_state = None - state.position_giveback_percent = None - state.position_conviction_state = None - state.position_exit_urgency = None - state.position_reversal_risk = None - return - - state.position_opened_monotonic_at = position.opened_monotonic_at - state.position_peak_pnl_usd = position.peak_unrealized_pnl_usd - state.position_peak_pnl_percent = position.peak_pnl_percent - state.position_mfe_percent = position.max_favorable_excursion_percent - state.position_mae_percent = position.max_adverse_excursion_percent - state.position_fatigue_score = position.fatigue_score - state.position_fatigue_state = position.fatigue_state - - def _refresh_position_runtime_metrics( - self, - *, - position: PositionState, - current_price: float, - ) -> None: - price_move_percent = self._calculate_price_move_percent(current_price) - pnl = safe_float(position.unrealized_pnl_usd) - - if pnl is not None: - peak_pnl = safe_float(position.peak_unrealized_pnl_usd) - - if peak_pnl is None or pnl > peak_pnl: - position.peak_unrealized_pnl_usd = pnl - - peak_percent = safe_float(position.peak_pnl_percent) - - if peak_percent is None or price_move_percent > peak_percent: - position.peak_pnl_percent = price_move_percent - - mfe = safe_float(position.max_favorable_excursion_percent) - mae = safe_float(position.max_adverse_excursion_percent) - - if mfe is None or price_move_percent > mfe: - position.max_favorable_excursion_percent = price_move_percent - - if mae is None or price_move_percent < mae: - position.max_adverse_excursion_percent = price_move_percent - - best_price = safe_float(position.best_price_seen) - worst_price = safe_float(position.worst_price_seen) - - if best_price is None: - position.best_price_seen = current_price - elif position.side == "LONG" and current_price > best_price: - position.best_price_seen = current_price - elif position.side == "SHORT" and current_price < best_price: - position.best_price_seen = current_price - - if worst_price is None: - position.worst_price_seen = current_price - elif position.side == "LONG" and current_price < worst_price: - position.worst_price_seen = current_price - elif position.side == "SHORT" and current_price > worst_price: - position.worst_price_seen = current_price - - fatigue_score = self._runtime_fatigue_score(position) - position.fatigue_score = fatigue_score - position.fatigue_state = self._runtime_fatigue_state(fatigue_score) - - def _runtime_fatigue_score(self, position: PositionState) -> float: - score = 0.0 - - mfe = safe_float(position.max_favorable_excursion_percent) or 0.0 - current_peak = safe_float(position.peak_pnl_percent) or 0.0 - mae = safe_float(position.max_adverse_excursion_percent) or 0.0 - - hold_seconds = 0 - - opened_at = safe_float(position.opened_monotonic_at) - if opened_at is not None: - hold_seconds = max(0, int(time.monotonic() - opened_at)) - - if hold_seconds >= 1800: - score += 0.25 - elif hold_seconds >= 900: - score += 0.15 - elif hold_seconds >= 300: - score += 0.08 - - if mfe > 0 and current_peak > 0: - giveback = max(0.0, mfe - current_peak) - - if giveback >= 0.75: - score += 0.25 - elif giveback >= 0.45: - score += 0.18 - elif giveback >= 0.25: - score += 0.10 - - if mae <= -1.0: - score += 0.25 - elif mae <= -0.5: - score += 0.15 - - return round(max(0.0, min(1.0, score)), 3) - - def _runtime_fatigue_state(self, score: float | None) -> str: - value = safe_float(score) - - if value is None: - return "UNKNOWN" - - if value >= 0.75: - return "EXHAUSTED" - - if value >= 0.50: - return "TIRED" - - if value >= 0.25: - return "WATCH" - - return "FRESH" - - def _reset_position_lifecycle_state(self, state: AutoTradeState) -> None: - state.position_peak_pnl_usd = None - state.position_peak_pnl_percent = None - state.position_mfe_percent = None - state.position_mae_percent = None - state.position_fatigue_score = None - state.position_fatigue_state = None - state.position_giveback_percent = None - state.position_conviction_state = None - state.position_exit_urgency = None - state.position_reversal_risk = None - - def _now_time(self) -> str: - return datetime.now().strftime("%H:%M:%S") \ No newline at end of file + return ExecutionDecision("NONE", False, "Нет торгового действия.") \ No newline at end of file diff --git a/app/src/trading/execution/flip.py b/app/src/trading/execution/flip.py new file mode 100644 index 0000000..57c4167 --- /dev/null +++ b/app/src/trading/execution/flip.py @@ -0,0 +1,446 @@ +# app/src/trading/execution/flip.py + +from __future__ import annotations + +import time +from typing import Protocol + +from src.core.event_bus import EventBus +from src.core.numbers import safe_float +from src.core.types import JsonDict +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.trading.execution.pricing import ExecutionPrice + + +class _ExecutionFlipProtocol(Protocol): + _position: PositionState + _min_flip_confidence: float + _min_flip_repeat_count: int + _min_flip_hold_seconds: int + _flip_cooldown_seconds: int + _loss_flip_confidence: float + _last_flip_block_key: str | None + + def _create_trade_id(self, state: AutoTradeState, side: str) -> str: ... + + # получить exit price для текущей стороны позиции + def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ... + + # получить entry price для новой стороны позиции + def _entry_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ... + + # рассчитать размер позиции + def _calculate_position_size( + self, + state: AutoTradeState, + *, + entry_price: float | None = None, + ) -> float: ... + + # ограничить размер позиции margin-limit правилом + def _adjust_size_by_margin_limit( + self, + *, + state: AutoTradeState, + entry_price: float, + size: float, + ) -> float: ... + + # пересчитать effective risk после margin-limit + def _sync_effective_risk_after_margin_limit( + self, + state: AutoTradeState, + *, + base_size: float, + final_size: float, + ) -> None: ... + + # округлить размер позиции + def _round_size(self, size) -> float: ... + + # рассчитать PnL позиции + def _calculate_pnl(self, current_price) -> float: ... + + # синхронизировать AutoTradeState с PositionState + def _sync_state_from_position(self, state: AutoTradeState) -> None: ... + + # посчитать время удержания позиции + def _position_hold_seconds(self, position: PositionState) -> int | None: ... + + # получить текущее время строкой + def _now_time(self) -> str: ... + + +class ExecutionFlipMixin(_ExecutionFlipProtocol): + # записать отказ flip execution в журнал + def _log_flip_rejected( + self, + *, + state: AutoTradeState, + reason: str, + ) -> None: + position = type(self)._position + + payload: JsonDict = { + "execution_type": "FLIP_REJECTED", + "symbol": state.symbol, + "position_side": position.side, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "reject_reason": reason, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "opened_at": position.opened_at, + "updated_at": position.updated_at, + } + + JournalService().log_ui_warning( + event_type="position_flip_rejected", + message=f"Flip позиции отклонён: {reason}", + screen="auto", + action="paper_execution", + payload=payload, + ) + + # проверить, нужен ли flip позиции по текущему сигналу + def _should_flip_position(self, state: AutoTradeState) -> bool: + position = type(self)._position + + if position.side == "NONE": + return False + + if position.side == "LONG" and state.last_signal == "SELL": + return True + + if position.side == "SHORT" and state.last_signal == "BUY": + return True + + return False + + # определить причину блокировки flip, если flip сейчас опасен + def _flip_block_reason(self, state: AutoTradeState) -> str | None: + position = type(self)._position + + 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) + signal = (state.last_signal or "").upper() + + if confidence < self._min_flip_confidence: + return ( + "уверенность сигнала ниже порога " + f"({confidence:.2f} < {self._min_flip_confidence:.2f})" + ) + + if repeat_count < self._min_flip_repeat_count: + return ( + "сигнал ещё не подтверждён нужным количеством повторов " + f"({repeat_count} < {self._min_flip_repeat_count})" + ) + + if hold_seconds is not None and hold_seconds < self._min_flip_hold_seconds: + return ( + "позиция открыта слишком недавно " + f"({hold_seconds}с < {self._min_flip_hold_seconds}с)" + ) + + if self._flip_cooldown_active(state): + return ( + "flip cooldown активен " + f"(< {self._flip_cooldown_seconds}с)" + ) + + if signal == "BUY" and momentum_direction == "DOWN": + return "momentum направлен против BUY сигнала" + + if signal == "SELL" and momentum_direction == "UP": + return "momentum направлен против SELL сигнала" + + if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: + if confidence < 0.85: + return ( + "flip заблокирован во время breakout impulse " + f"({confidence:.2f} < 0.85)" + ) + + if unrealized_pnl < 0 and confidence < self._loss_flip_confidence: + return ( + "позиция сейчас в минусе, а сигнал недостаточно сильный " + f"({confidence:.2f} < {self._loss_flip_confidence:.2f})" + ) + + return None + + # записать блокировку flip в state, journal и event bus + def _block_flip( + self, + state: AutoTradeState, + reason: str, + ) -> ExecutionDecision: + position = type(self)._position + confidence = safe_float(state.last_signal_confidence) or 0.0 + + state.execution_block_reason = reason + state.last_flip_block_reason = reason + state.last_execution_action = "FLIP_BLOCKED" + state.last_execution_reason = reason + + block_key = ( + f"{position.side}:" + f"{state.last_signal}:" + f"{state.last_signal_repeat_count}:" + f"{confidence:.2f}:" + f"{reason}" + ) + + if block_key != type(self)._last_flip_block_key: + type(self)._last_flip_block_key = block_key + + payload: JsonDict = { + "execution_type": "FLIP_BLOCKED", + "symbol": state.symbol, + "position_side": position.side, + "signal": state.last_signal, + "confidence": confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": reason, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "opened_at": position.opened_at, + "updated_at": position.updated_at, + } + + JournalService().log_ui_warning( + event_type="position_flip_blocked", + message=f"Смена направления позиции заблокирована: {reason}.", + screen="auto", + action="paper_execution", + payload=payload, + ) + + EventBus.emit("paper_flip_blocked", payload) + + return ExecutionDecision("NONE", False, reason) + + # проверить, активен ли cooldown после последнего flip + def _flip_cooldown_active( + self, + state: AutoTradeState, + ) -> bool: + ts = getattr(state, "last_flip_monotonic_at", None) + + if ts is None: + return False + + return ( + time.monotonic() - float(ts) + ) < self._flip_cooldown_seconds + + # определить сторону позиции по сигналу BUY / SELL + def _target_side_from_signal(self, signal: str | None) -> str | None: + if signal == "BUY": + return "LONG" + + if signal == "SELL": + return "SHORT" + + return None + + # закрыть текущую позицию и открыть новую в противоположную сторону + def _flip_position(self, state: AutoTradeState) -> ExecutionDecision: + position = type(self)._position + + if position.side == "NONE": + self._sync_state_from_position(state) + reason = "Нет позиции для flip." + self._log_flip_rejected(state=state, reason=reason) + return ExecutionDecision("NONE", False, reason) + + new_side = self._target_side_from_signal(state.last_signal) + + if new_side is None: + reason = "Нет направления для flip." + self._log_flip_rejected(state=state, reason=reason) + return ExecutionDecision("NONE", False, reason) + + try: + exit_execution = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + entry_execution = self._entry_price_for_side( + state.symbol, + new_side, + ) + exit_price = exit_execution.price + new_entry_price = entry_execution.price + + except Exception as exc: + reason = f"Ошибка получения цены для flip: {exc}" + self._log_flip_rejected(state=state, reason=reason) + return ExecutionDecision("NONE", False, reason) + + now = self._now_time() + opened_monotonic_at = time.monotonic() + pnl = self._calculate_pnl(exit_price) + new_size = self._calculate_position_size( + state, + entry_price=new_entry_price, + ) + + if new_size <= 0: + reason = "Flip отменён: невозможно рассчитать adaptive size." + self._log_flip_rejected(state=state, reason=reason) + return ExecutionDecision("NONE", False, reason) + + new_size = self._adjust_size_by_margin_limit( + state=state, + entry_price=new_entry_price, + size=new_size, + ) + + self._sync_effective_risk_after_margin_limit( + state, + base_size=state.adaptive_size_base or 0.0, + final_size=new_size, + ) + + new_size = self._round_size(new_size) + + if new_size <= 0: + reason = "Flip отменён: итоговый size равен 0." + self._log_flip_rejected(state=state, reason=reason) + return ExecutionDecision("NONE", False, reason) + + state.realized_pnl_usd += pnl + state.cycle_realized_pnl_usd += pnl + state.cycle_closed_trades += 1 + + if pnl > 0: + state.cycle_winning_trades += 1 + + old_side = position.side + old_entry_price = position.entry_price + old_size = position.size + 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() + + old_trade_id = position.trade_id or state.current_trade_id + old_trade_sequence = position.trade_sequence or state.trade_sequence + old_trade_cycle_number = ( + position.trade_cycle_number + or state.current_trade_cycle_number + or state.cycle_number + ) + + new_trade_id = self._create_trade_id(state, new_side) + + state.current_trade_id = new_trade_id + state.current_trade_cycle_number = state.cycle_number + + type(self)._position = PositionState( + trade_id=new_trade_id, + trade_cycle_number=state.current_trade_cycle_number, + trade_sequence=state.trade_sequence, + side=new_side, + symbol=state.symbol, + entry_price=new_entry_price, + size=new_size, + leverage=state.leverage, + unrealized_pnl_usd=0.0, + opened_at=now, + opened_monotonic_at=opened_monotonic_at, + updated_at=now, + ) + + self._sync_state_from_position(state) + + state.execution_block_reason = None + state.last_flip_block_reason = None + state.last_execution_action = f"FLIP_{old_side}_TO_{new_side}" + state.last_execution_reason = "Направление позиции изменено." + state.last_flip_at = now + + type(self)._last_flip_block_key = None + + payload: JsonDict = { + "trade_id": old_trade_id, + "closed_trade_id": old_trade_id, + "new_trade_id": new_trade_id, + "trade_sequence": old_trade_sequence, + "trade_cycle_number": old_trade_cycle_number, + "closed_trade_sequence": old_trade_sequence, + "closed_trade_cycle_number": old_trade_cycle_number, + "new_trade_sequence": state.trade_sequence, + "new_trade_cycle_number": state.current_trade_cycle_number, + "execution_type": "FLIP", + "action": f"FLIP_{old_side}_TO_{new_side}", + "symbol": state.symbol, + "old_side": old_side, + "new_side": new_side, + "side": new_side, + "entry_price": old_entry_price, + "exit_price": exit_price, + "new_entry_price": new_entry_price, + "old_size": old_size, + "new_size": new_size, + "size": new_size, + "old_leverage": old_leverage, + "leverage": state.leverage, + "pnl": pnl, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_reason": state.execution_confidence_reason, + "adaptive_size_multiplier": state.adaptive_size_multiplier, + "adaptive_size_reason": state.adaptive_size_reason, + "adaptive_size_factors": state.adaptive_size_factors, + "effective_risk_percent": state.effective_risk_percent, + "effective_target_risk_usd": state.effective_target_risk_usd, + "adaptive_size_base": state.adaptive_size_base, + "adaptive_size_final": state.adaptive_size_final, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "opened_at": old_opened_at, + "new_opened_monotonic_at": opened_monotonic_at, + "closed_at": now, + "new_opened_at": now, + "pricing": "exit_by_side_then_entry_by_side", + "exit_pricing_role": exit_execution.pricing_role, + "exit_price_source": exit_execution.source, + "exit_price_age_seconds": exit_execution.age_seconds, + "exit_price_updated_at": exit_execution.updated_at, + "entry_pricing_role": entry_execution.pricing_role, + "entry_price_source": entry_execution.source, + "entry_price_age_seconds": entry_execution.age_seconds, + "entry_price_updated_at": entry_execution.updated_at, + } + + JournalService().log_ui_info( + event_type="position_flipped", + message=f"Направление позиции изменено: {old_side} → {new_side}.", + screen="auto", + action="paper_execution", + payload=payload, + ) + + EventBus.emit("paper_position_flipped", payload) + + return ExecutionDecision( + f"FLIP_{old_side}_TO_{new_side}", + True, + f"Направление позиции изменено: {old_side} → {new_side}.", + ) \ No newline at end of file diff --git a/app/src/trading/execution/position_actions.py b/app/src/trading/execution/position_actions.py new file mode 100644 index 0000000..87729ac --- /dev/null +++ b/app/src/trading/execution/position_actions.py @@ -0,0 +1,478 @@ +# app/src/trading/execution/position_actions.py + +from __future__ import annotations + +import time +from typing import Protocol + +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.models import ExecutionDecision +from src.trading.execution.pricing import ExecutionPrice +from src.trading.journal.service import JournalService +from src.trading.position.state import PositionState + + +class _ExecutionPositionActionsProtocol(Protocol): + _position: PositionState + _last_flip_block_key: str | None + + # создать trade id + def _create_trade_id( + self, + state: AutoTradeState, + side: str, + ) -> str: ... + + # получить entry execution price + def _entry_price_for_side( + self, + symbol: str, + side: str, + ) -> ExecutionPrice: ... + + # получить exit execution price + def _exit_price_for_side( + self, + symbol: str, + side: str, + ) -> ExecutionPrice: ... + + # рассчитать adaptive size + def _calculate_position_size( + self, + state: AutoTradeState, + *, + entry_price: float | None = None, + ) -> float: ... + + # ограничить size margin limit + def _adjust_size_by_margin_limit( + self, + *, + state: AutoTradeState, + entry_price: float, + size: float, + ) -> float: ... + + # обновить effective risk после margin limit + def _sync_effective_risk_after_margin_limit( + self, + state: AutoTradeState, + *, + base_size: float, + final_size: float, + ) -> None: ... + + # округлить size + def _round_size(self, size: NumericLike | None) -> float: ... + + # синхронизировать state с position + def _sync_state_from_position( + self, + state: AutoTradeState, + ) -> None: ... + + # посчитать pnl + def _calculate_pnl( + self, + current_price: NumericLike | None, + ) -> float: ... + + # получить текущее время + def _now_time(self) -> str: ... + + # reset runtime protection state + def _reset_runtime_protection_state( + self, + state: AutoTradeState, + ) -> None: ... + + +class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): + # создать новый trade_id для связки open -> close + def _create_trade_id(self, state: AutoTradeState, side: str) -> str: + state.trade_sequence = int(state.trade_sequence or 0) + 1 + cycle_number = int(state.cycle_number or 0) + + return ( + f"trade-{cycle_number}-" + f"{state.trade_sequence}-" + f"{side.lower()}-" + f"{int(time.time())}" + ) + + # записать отказ открытия позиции в журнал + def _log_position_open_rejected( + self, + *, + state: AutoTradeState, + side: str, + action: str, + reason: str, + ) -> None: + payload: JsonDict = { + "execution_type": "ENTRY_REJECTED", + "action": action, + "symbol": state.symbol, + "side": side, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_reason": state.execution_confidence_reason, + "adaptive_size_multiplier": state.adaptive_size_multiplier, + "adaptive_size_reason": state.adaptive_size_reason, + "adaptive_size_factors": state.adaptive_size_factors, + "effective_risk_percent": state.effective_risk_percent, + "effective_target_risk_usd": state.effective_target_risk_usd, + "adaptive_size_base": state.adaptive_size_base, + "adaptive_size_final": state.adaptive_size_final, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "reject_reason": reason, + } + + JournalService().log_ui_warning( + event_type="position_open_rejected", + message=f"Открытие позиции {side} отклонено: {reason}", + screen="auto", + action="paper_execution", + payload=payload, + ) + + # открыть позицию, если сейчас позиции нет + def _open_position_if_empty( + self, + *, + state: AutoTradeState, + side: str, + action: str, + ) -> ExecutionDecision: + position = type(self)._position + + if position.side != "NONE": + self._sync_state_from_position(state) + + if position.side == side: + reason = f"Позиция {side} уже открыта." + return ExecutionDecision("NONE", False, reason) + + reason = ( + f"Позиция уже открыта в другом направлении: " + f"{position.side}, новый запрос: {side}." + ) + + self._log_position_open_rejected( + state=state, + side=side, + action=action, + reason=reason, + ) + + return ExecutionDecision("NONE", False, reason) + + try: + entry = self._entry_price_for_side(state.symbol, side) + entry_price = entry.price + + except Exception as exc: + reason = f"Не удалось получить цену для paper execution: {exc}" + + self._log_position_open_rejected( + state=state, + side=side, + action=action, + reason=reason, + ) + + return ExecutionDecision("NONE", False, reason) + + now = self._now_time() + opened_monotonic_at = time.monotonic() + + size = self._calculate_position_size( + state, + entry_price=entry_price, + ) + + if size <= 0: + reason = "Позиция не открыта: невозможно рассчитать adaptive size." + + self._log_position_open_rejected( + state=state, + side=side, + action=action, + reason=reason, + ) + + return ExecutionDecision("NONE", False, reason) + + size = self._adjust_size_by_margin_limit( + state=state, + entry_price=entry_price, + size=size, + ) + + self._sync_effective_risk_after_margin_limit( + state, + base_size=state.adaptive_size_base or 0.0, + final_size=size, + ) + + size = self._round_size(size) + + if size <= 0: + reason = "Позиция не открыта: итоговый size равен 0." + + self._log_position_open_rejected( + state=state, + side=side, + action=action, + reason=reason, + ) + + return ExecutionDecision("NONE", False, reason) + + trade_id = self._create_trade_id(state, side) + state.current_trade_id = trade_id + state.current_trade_cycle_number = state.cycle_number + + type(self)._position = PositionState( + trade_id=trade_id, + trade_cycle_number=state.current_trade_cycle_number, + trade_sequence=state.trade_sequence, + side=side, + symbol=state.symbol, + entry_price=entry_price, + size=size, + leverage=state.leverage, + unrealized_pnl_usd=0.0, + opened_at=now, + opened_monotonic_at=opened_monotonic_at, + updated_at=now, + ) + + self._sync_state_from_position(state) + + state.execution_block_reason = None + state.last_flip_block_reason = None + state.last_execution_action = action + state.last_execution_reason = f"Позиция {side} открыта." + + payload: JsonDict = { + "trade_id": trade_id, + "trade_sequence": state.trade_sequence, + "trade_cycle_number": state.current_trade_cycle_number, + "execution_type": "ENTRY", + "action": action, + "symbol": state.symbol, + "side": side, + "entry_price": entry_price, + "size": size, + "leverage": state.leverage, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_reason": state.execution_confidence_reason, + "adaptive_size_multiplier": state.adaptive_size_multiplier, + "adaptive_size_reason": state.adaptive_size_reason, + "adaptive_size_factors": state.adaptive_size_factors, + "effective_risk_percent": state.effective_risk_percent, + "effective_target_risk_usd": state.effective_target_risk_usd, + "adaptive_size_base": state.adaptive_size_base, + "adaptive_size_final": state.adaptive_size_final, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "opened_at": now, + "opened_monotonic_at": opened_monotonic_at, + "pricing": "ask_for_long_bid_for_short", + "pricing_role": entry.pricing_role, + "price_source": entry.source, + "price_age_seconds": entry.age_seconds, + "price_updated_at": entry.updated_at, + } + + JournalService().log_ui_info( + event_type="position_opened", + message=f"Позиция {side} открыта: {state.symbol}.", + screen="auto", + action="paper_execution", + payload=payload, + ) + + EventBus.emit("paper_position_opened", payload) + + return ExecutionDecision(action, True, f"Позиция {side} открыта.") + + # закрыть открытую позицию + def _close_position( + self, + state: AutoTradeState, + *, + forced_reason: str | None = None, + forced_exit_price: NumericLike | None = None, + forced_pnl: NumericLike | None = None, + forced_price_meta: ExecutionPrice | None = None, + ) -> ExecutionDecision: + position = type(self)._position + + if position.side == "NONE": + self._sync_state_from_position(state) + + return ExecutionDecision( + "NONE", + False, + "Нет открытой позиции для закрытия.", + ) + + if forced_exit_price is not None: + exit_price = safe_float(forced_exit_price) or 0.0 + exit_execution = forced_price_meta + + else: + try: + exit_execution = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + + exit_price = exit_execution.price + + except Exception as exc: + return ExecutionDecision( + "NONE", + False, + f"Ошибка получения цены для закрытия: {exc}", + ) + + 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 + + if pnl < 0: + state.last_loss_monotonic_at = time.monotonic() + + now = self._now_time() + + trade_id = ( + position.trade_id + or state.current_trade_id + ) + + payload: JsonDict = { + "trade_id": trade_id, + "trade_sequence": position.trade_sequence or state.trade_sequence, + "trade_cycle_number": ( + position.trade_cycle_number + or state.current_trade_cycle_number + ), + "execution_type": "EXIT", + "action": "CLOSE", + "symbol": state.symbol, + "side": position.side, + "entry_price": position.entry_price, + "exit_price": exit_price, + "size": position.size, + "leverage": position.leverage, + "pnl": pnl, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "risk_reason": forced_reason, + "is_forced": forced_reason is not None, + "opened_at": position.opened_at, + "closed_at": now, + "pricing": "bid_for_long_exit_ask_for_short_exit", + "pricing_role": ( + exit_execution.pricing_role + if exit_execution + else None + ), + "price_source": ( + exit_execution.source + if exit_execution + else None + ), + "price_age_seconds": ( + exit_execution.age_seconds + if exit_execution + else None + ), + "price_updated_at": ( + exit_execution.updated_at + if exit_execution + else None + ), + } + + close_reason = forced_reason or "MANUAL" + + JournalService().log_ui_info( + event_type="position_closed", + message=f"Позиция {position.side} закрыта: {close_reason}.", + screen="auto", + action="paper_execution", + payload=payload, + ) + + EventBus.emit( + "paper_position_closed", + payload, + ) + + type(self)._position = PositionState() + + self._sync_state_from_position(state) + + state.position_opened_monotonic_at = None + state.current_trade_id = None + state.current_trade_cycle_number = None + + self._reset_runtime_protection_state(state) + + state.execution_block_reason = None + state.last_flip_block_reason = None + + state.last_execution_action = ( + f"FORCE_CLOSE_{forced_reason}" + if forced_reason is not None + else "CLOSE" + ) + + state.last_execution_reason = ( + f"Позиция закрыта по правилу защиты: {forced_reason}." + if forced_reason is not None + else "Позиция закрыта." + ) + + type(self)._last_flip_block_key = None + + if forced_reason is not None: + return ExecutionDecision( + f"FORCE_CLOSE_{forced_reason}", + True, + f"Позиция закрыта по правилу защиты: {forced_reason}.", + ) + + return ExecutionDecision( + "CLOSE", + True, + "Позиция закрыта.", + ) \ No newline at end of file diff --git a/app/src/trading/execution/position_intelligence.py b/app/src/trading/execution/position_intelligence.py new file mode 100644 index 0000000..1a68655 --- /dev/null +++ b/app/src/trading/execution/position_intelligence.py @@ -0,0 +1,209 @@ +# app/src/trading/execution/position_intelligence.py + +from __future__ import annotations + +from typing import Protocol + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.trading.auto.state import AutoTradeState +from src.trading.position.state import PositionState + + +class _ExecutionPositionIntelligenceProtocol(Protocol): + _position: PositionState + + # посчитать изменение цены позиции в процентах + def _calculate_price_move_percent( + self, + current_price: NumericLike | None, + ) -> float: + ... + + # посчитать время удержания позиции в секундах + def _position_hold_seconds( + self, + position: PositionState, + ) -> int | None: + ... + + +class ExecutionPositionIntelligenceMixin(_ExecutionPositionIntelligenceProtocol): + # определить причину закрытия позиции по position intelligence + def _runtime_intelligence_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + giveback_reason = self._giveback_close_reason( + state=state, + current_price=current_price, + ) + + if giveback_reason is not None: + return giveback_reason + + time_decay_reason = self._time_decay_close_reason( + state=state, + current_price=current_price, + ) + + if time_decay_reason is not None: + return time_decay_reason + + return None + + # определить закрытие по возврату прибыли от пика + def _giveback_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + pnl_percent = self._calculate_price_move_percent(current_price) + + peak_percent = safe_float( + getattr(state, "position_peak_pnl_percent", None) + ) + + if peak_percent is None or peak_percent <= 0: + return None + + if pnl_percent is None: + return None + + giveback = peak_percent - pnl_percent + + if giveback <= 0: + return None + + giveback_percent = round((giveback / peak_percent) * 100, 2) + + fatigue_state = str( + getattr(state, "position_fatigue_state", "") or "" + ).upper() + + reversal_risk = str( + getattr(state, "position_reversal_risk", "") or "" + ).upper() + + adverse_momentum = bool( + getattr(state, "position_adverse_momentum", False) + ) + + exit_confidence = safe_float( + getattr(state, "position_exit_confidence", None) + ) or 0.0 + + if ( + peak_percent >= 0.75 + and giveback_percent >= 55 + and pnl_percent > 0 + ): + return "GIVEBACK_PROTECTION" + + if ( + peak_percent >= 0.50 + and giveback_percent >= 40 + and adverse_momentum + ): + return "GIVEBACK_MOMENTUM_REVERSAL" + + if ( + peak_percent >= 0.50 + and giveback_percent >= 35 + and fatigue_state in {"TIRED", "EXHAUSTED"} + ): + return "GIVEBACK_FATIGUE_EXIT" + + if ( + peak_percent >= 0.50 + and giveback_percent >= 35 + and reversal_risk in {"ELEVATED", "HIGH"} + and exit_confidence >= 0.50 + ): + return "GIVEBACK_REVERSAL_RISK" + + return None + + # определить закрытие по устареванию позиции во времени + def _time_decay_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + hold_seconds = safe_float( + getattr(state, "position_hold_seconds", None) + ) + + if hold_seconds is None: + hold_seconds = safe_float( + self._position_hold_seconds(type(self)._position) + ) + + if hold_seconds is None: + return None + + pnl_percent = self._calculate_price_move_percent(current_price) + + fatigue_state = str( + getattr(state, "position_fatigue_state", "") or "" + ).upper() + + conviction_state = str( + getattr(state, "position_conviction_state", "") or "" + ).upper() + + decay_state = str( + getattr(state, "position_decay_state", "") or "" + ).upper() + + adverse_momentum = bool( + getattr(state, "position_adverse_momentum", False) + ) + + market_runtime_degraded = bool( + getattr(state, "market_runtime_degraded", False) + ) + + if pnl_percent is None: + return None + + if ( + hold_seconds >= 2400 + and -0.15 <= pnl_percent <= 0.25 + and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"} + ): + return "TIME_DECAY_EXIT" + + if ( + hold_seconds >= 1800 + and -0.20 <= pnl_percent <= 0.35 + and fatigue_state in {"TIRED", "EXHAUSTED"} + ): + return "TIME_DECAY_FATIGUE_EXIT" + + if ( + hold_seconds >= 1200 + and pnl_percent <= 0.20 + and adverse_momentum + ): + return "TIME_DECAY_ADVERSE_MOMENTUM" + + if ( + hold_seconds >= 1200 + and pnl_percent <= 0.30 + and market_runtime_degraded + ): + return "TIME_DECAY_DEGRADED_MARKET" + + if ( + hold_seconds >= 1800 + and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"} + and pnl_percent <= 0.30 + ): + return "TIME_DECAY_CONTEXT_DECAY" + + return None \ No newline at end of file diff --git a/app/src/trading/execution/position_protection.py b/app/src/trading/execution/position_protection.py new file mode 100644 index 0000000..ba120e6 --- /dev/null +++ b/app/src/trading/execution/position_protection.py @@ -0,0 +1,398 @@ +# app/src/trading/execution/position_protection.py + +from __future__ import annotations + +import time +from typing import ClassVar, Protocol + +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.models import ExecutionDecision +from src.trading.execution.pricing import ExecutionPrice +from src.trading.journal.service import JournalService +from src.trading.position.state import PositionState + + +class _ExecutionPositionProtectionProtocol(Protocol): + _position: ClassVar[PositionState] + + # получить цену закрытия позиции по стороне + def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: + ... + + # посчитать PnL позиции + def _calculate_pnl(self, current_price: NumericLike | None) -> float: + ... + + # посчитать движение цены от входа в процентах + def _calculate_price_move_percent(self, current_price: NumericLike | None) -> float: + ... + + # закрыть позицию + def _close_position( + self, + state: AutoTradeState, + *, + forced_reason: str | None = None, + forced_exit_price: NumericLike | None = None, + forced_pnl: NumericLike | None = None, + forced_price_meta: ExecutionPrice | None = None, + ) -> ExecutionDecision: + ... + + # сбросить состояние runtime-защиты + def _reset_runtime_protection_state( + self, + state: AutoTradeState, + ) -> None: + ... + + # получить intelligence-причину закрытия позиции + def _runtime_intelligence_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + ... + + +class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): + # обработать runtime-защиту открытой позиции + def _process_runtime_protection( + self, + state: AutoTradeState, + ) -> ExecutionDecision | None: + position = type(self)._position + + if position.side == "NONE": + self._reset_runtime_protection_state(state) + return None + + try: + current_execution = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + current_price = current_execution.price + except Exception: + self._sync_runtime_protection_state( + state=state, + status="DEGRADED", + reason="нет актуальной цены для protection engine", + ) + return None + + self._sync_runtime_protection_state( + state=state, + status="ACTIVE", + reason="protection engine активен", + ) + + self._update_break_even_protection( + state=state, + current_price=current_price, + ) + + self._update_profit_lock_protection( + state=state, + current_price=current_price, + ) + + self._update_trailing_stop_protection( + state=state, + current_price=current_price, + ) + + close_reason = self._runtime_protection_close_reason( + state=state, + current_price=current_price, + ) + + if close_reason is None: + close_reason = self._runtime_intelligence_close_reason( + state=state, + current_price=current_price, + ) + + if close_reason is None: + return None + + pnl = self._calculate_pnl(current_price) + + return self._close_position( + state, + forced_reason=close_reason, + forced_exit_price=current_price, + forced_pnl=pnl, + forced_price_meta=current_execution, + ) + + # синхронизировать состояние protection engine + def _sync_runtime_protection_state( + self, + *, + state: AutoTradeState, + status: str, + reason: str, + ) -> None: + state.position_protection_status = status + state.position_protection_reason = reason + state.runtime_protection_updated_at = time.monotonic() + + # активировать break-even защиту + def _update_break_even_protection( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> None: + position = type(self)._position + + if state.break_even_armed: + return + + pnl_percent = self._calculate_price_move_percent(current_price) + + if pnl_percent < 0.35: + return + + entry_price = safe_float(position.entry_price) + + if entry_price is None or entry_price <= 0: + return + + state.break_even_armed = True + state.break_even_price = entry_price + state.runtime_protection_action = "BREAK_EVEN_ARMED" + state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован" + state.runtime_protection_updated_at = time.monotonic() + + self._log_runtime_protection_event( + state=state, + action="BREAK_EVEN_ARMED", + reason=state.runtime_protection_reason, + current_price=current_price, + ) + + # активировать profit lock защиту + def _update_profit_lock_protection( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> None: + position = type(self)._position + + pnl_percent = self._calculate_price_move_percent(current_price) + + if pnl_percent < 0.75: + return + + entry_price = safe_float(position.entry_price) + + if entry_price is None or entry_price <= 0: + return + + if position.side == "LONG": + lock_price = entry_price * 1.003 + elif position.side == "SHORT": + lock_price = entry_price * 0.997 + else: + return + + previous_price = safe_float(state.profit_lock_price) + + if previous_price is not None: + if position.side == "LONG" and lock_price <= previous_price: + return + + if position.side == "SHORT" and lock_price >= previous_price: + return + + state.profit_lock_active = True + state.profit_lock_price = round(lock_price, 8) + state.runtime_protection_action = "PROFIT_LOCK_ACTIVE" + state.runtime_protection_reason = "часть прибыли защищена profit lock" + state.runtime_protection_updated_at = time.monotonic() + + self._log_runtime_protection_event( + state=state, + action="PROFIT_LOCK_ACTIVE", + reason=state.runtime_protection_reason, + current_price=current_price, + ) + + # активировать trailing stop защиту + def _update_trailing_stop_protection( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> None: + position = type(self)._position + + pnl_percent = self._calculate_price_move_percent(current_price) + + if pnl_percent < 1.0: + return + + trail_distance_percent = 0.35 + + if position.side == "LONG": + trail_price = current_price * (1 - trail_distance_percent / 100) + previous_price = safe_float(state.trailing_stop_price) + + if previous_price is not None and trail_price <= previous_price: + return + + elif position.side == "SHORT": + trail_price = current_price * (1 + trail_distance_percent / 100) + previous_price = safe_float(state.trailing_stop_price) + + if previous_price is not None and trail_price >= previous_price: + return + + else: + return + + state.trailing_stop_active = True + state.trailing_stop_price = round(trail_price, 8) + state.runtime_protection_action = "TRAILING_STOP_ACTIVE" + state.runtime_protection_reason = "trailing stop подтянут вслед за прибылью" + state.runtime_protection_updated_at = time.monotonic() + + self._log_runtime_protection_event( + state=state, + action="TRAILING_STOP_ACTIVE", + reason=state.runtime_protection_reason, + current_price=current_price, + ) + + # определить причину закрытия по защите + def _runtime_protection_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + position = type(self)._position + + fatigue_state = str(getattr(state, "position_fatigue_state", "") or "").upper() + reversal_risk = str(getattr(state, "position_reversal_risk", "") or "").upper() + exit_urgency = str(getattr(state, "position_exit_urgency", "") or "").upper() + conviction = str(getattr(state, "position_conviction_state", "") or "").upper() + risk_level = str(getattr(state, "position_risk_level", "") or "").upper() + exit_signal = str(getattr(state, "position_exit_signal", "") or "").upper() + decay_state = str(getattr(state, "position_decay_state", "") or "").upper() + + if exit_urgency == "IMMEDIATE": + return "LIFECYCLE_EXIT" + + if conviction == "BROKEN": + return "CONVICTION_BROKEN" + + if fatigue_state == "EXHAUSTED" and reversal_risk in {"ELEVATED", "HIGH"}: + return "FATIGUE_EXIT" + + if ( + state.position_adverse_momentum + and reversal_risk == "HIGH" + and risk_level in {"ELEVATED", "HIGH"} + ): + return "MOMENTUM_EXIT" + + if ( + getattr(state, "market_runtime_degraded", False) + and exit_signal in {"EXIT", "REDUCE_OR_PROTECT"} + and decay_state != "NONE" + ): + return "DEGRADATION_EXIT" + + if position.side == "LONG": + if ( + state.trailing_stop_active + and state.trailing_stop_price is not None + and current_price <= state.trailing_stop_price + ): + return "TRAILING_STOP" + + if ( + state.profit_lock_active + and state.profit_lock_price is not None + and current_price <= state.profit_lock_price + ): + return "PROFIT_LOCK" + + if ( + state.break_even_armed + and state.break_even_price is not None + and current_price <= state.break_even_price + ): + return "BREAK_EVEN" + + if position.side == "SHORT": + if ( + state.trailing_stop_active + and state.trailing_stop_price is not None + and current_price >= state.trailing_stop_price + ): + return "TRAILING_STOP" + + if ( + state.profit_lock_active + and state.profit_lock_price is not None + and current_price >= state.profit_lock_price + ): + return "PROFIT_LOCK" + + if ( + state.break_even_armed + and state.break_even_price is not None + and current_price >= state.break_even_price + ): + return "BREAK_EVEN" + + return None + + # записать событие runtime-защиты в журнал + def _log_runtime_protection_event( + self, + *, + state: AutoTradeState, + action: str, + reason: str, + current_price: float, + ) -> None: + position = type(self)._position + + payload: JsonDict = { + "execution_type": "RUNTIME_PROTECTION", + "action": action, + "symbol": state.symbol, + "position_side": position.side, + "entry_price": position.entry_price, + "current_price": current_price, + "size": position.size, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "position_pnl_percent": self._calculate_price_move_percent(current_price), + "break_even_armed": state.break_even_armed, + "break_even_price": state.break_even_price, + "profit_lock_active": state.profit_lock_active, + "profit_lock_price": state.profit_lock_price, + "trailing_stop_active": state.trailing_stop_active, + "trailing_stop_price": state.trailing_stop_price, + "reason": reason, + } + + JournalService().log_ui_info( + event_type="runtime_protection_updated", + message=f"Runtime protection: {action}. {reason}.", + screen="auto", + action="runtime_protection", + payload=payload, + ) + + EventBus.emit("runtime_protection_updated", payload) \ No newline at end of file diff --git a/app/src/trading/execution/position_runtime.py b/app/src/trading/execution/position_runtime.py new file mode 100644 index 0000000..fd88e61 --- /dev/null +++ b/app/src/trading/execution/position_runtime.py @@ -0,0 +1,317 @@ +# app/src/trading/execution/position_runtime.py + +from __future__ import annotations + +import time +from datetime import datetime +from typing import TYPE_CHECKING, Protocol + +from src.core.types import NumericLike +from src.core.numbers import safe_float +from src.trading.auto.state import AutoTradeState +from src.trading.position.state import PositionState +from src.trading.execution.pricing import ExecutionPrice + + +class _ExecutionRuntimeProtocol(Protocol): + _position: PositionState + + def _calculate_pnl( + self, + current_price: NumericLike | None, + ) -> float: ... + + def _calculate_price_move_percent( + self, + current_price: NumericLike | None, + ) -> float: ... + def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ... + def _now_time(self) -> str: ... + + +class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): + # получить текущую paper-позицию + def get_position(self) -> PositionState: + return type(self)._position + + # обновить unrealized PnL и runtime-память позиции + def _update_unrealized_pnl(self, state: AutoTradeState) -> None: + position = type(self)._position + + if position.side == "NONE": + self._sync_state_from_position(state) + return + + try: + current_execution = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + current_price = current_execution.price + except Exception: + self._sync_state_from_position(state) + return + + pnl = self._calculate_pnl(current_price) + pnl_percent = self._calculate_price_move_percent(current_price) + + position.unrealized_pnl_usd = pnl + position.updated_at = self._now_time() + + if position.peak_unrealized_pnl_usd is None or pnl > position.peak_unrealized_pnl_usd: + position.peak_unrealized_pnl_usd = pnl + + if position.peak_pnl_percent is None or pnl_percent > position.peak_pnl_percent: + position.peak_pnl_percent = pnl_percent + + if position.max_favorable_excursion_percent is None: + position.max_favorable_excursion_percent = max(0.0, pnl_percent) + else: + position.max_favorable_excursion_percent = max( + position.max_favorable_excursion_percent, + pnl_percent, + ) + + if position.max_adverse_excursion_percent is None: + position.max_adverse_excursion_percent = min(0.0, pnl_percent) + else: + position.max_adverse_excursion_percent = min( + position.max_adverse_excursion_percent, + pnl_percent, + ) + + self._sync_position_runtime_memory( + position=position, + current_price=current_price, + pnl_percent=pnl_percent, + ) + + self._sync_state_from_position(state) + + # синхронизировать AutoTradeState с текущей paper-позицией + def _sync_state_from_position(self, state: AutoTradeState) -> None: + position = type(self)._position + + state.position_side = position.side + state.entry_price = position.entry_price + state.position_size = position.size + state.unrealized_pnl_usd = position.unrealized_pnl_usd + + if position.side == "NONE": + state.position_opened_monotonic_at = None + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None + return + + state.position_opened_monotonic_at = position.opened_monotonic_at + state.position_peak_pnl_usd = position.peak_unrealized_pnl_usd + state.position_peak_pnl_percent = position.peak_pnl_percent + state.position_mfe_percent = position.max_favorable_excursion_percent + state.position_mae_percent = position.max_adverse_excursion_percent + state.position_fatigue_score = position.fatigue_score + state.position_fatigue_state = position.fatigue_state + + # обновить best/worst price и fatigue state позиции + def _sync_position_runtime_memory( + self, + *, + position: PositionState, + current_price: float, + pnl_percent: float, + ) -> None: + if position.best_price_seen is None: + position.best_price_seen = current_price + + if position.worst_price_seen is None: + position.worst_price_seen = current_price + + if position.side == "LONG": + position.best_price_seen = max(position.best_price_seen, current_price) + position.worst_price_seen = min(position.worst_price_seen, current_price) + + elif position.side == "SHORT": + position.best_price_seen = min(position.best_price_seen, current_price) + position.worst_price_seen = max(position.worst_price_seen, current_price) + + peak = safe_float(position.peak_pnl_percent) or 0.0 + giveback_score = 0.0 + + if peak > 0: + giveback = max(0.0, peak - pnl_percent) + giveback_score = min(1.0, giveback / max(0.01, peak)) + + fatigue = 0.0 + + if giveback_score >= 0.70: + fatigue += 0.35 + elif giveback_score >= 0.45: + fatigue += 0.25 + elif giveback_score >= 0.25: + fatigue += 0.12 + + if pnl_percent < 0: + fatigue += 0.20 + + position.fatigue_score = round(max(0.0, min(1.0, fatigue)), 3) + + if position.fatigue_score >= 0.75: + position.fatigue_state = "EXHAUSTED" + elif position.fatigue_score >= 0.50: + position.fatigue_state = "TIRED" + elif position.fatigue_score >= 0.25: + position.fatigue_state = "WATCH" + else: + position.fatigue_state = "FRESH" + + # посчитать время удержания позиции в секундах + def _position_hold_seconds(self, position: PositionState) -> int | None: + opened_monotonic_at = safe_float( + getattr(position, "opened_monotonic_at", None) + ) + + if opened_monotonic_at is not None: + return max(0, int(time.monotonic() - opened_monotonic_at)) + + if not position.opened_at: + return None + + try: + opened_at = datetime.strptime(position.opened_at, "%H:%M:%S") + now = datetime.strptime(self._now_time(), "%H:%M:%S") + + seconds = int((now - opened_at).total_seconds()) + + if seconds < 0: + seconds += 24 * 60 * 60 + + return seconds + except Exception: + return None + + # обновить runtime-метрики позиции по текущей цене + def _refresh_position_runtime_metrics( + self, + *, + position: PositionState, + current_price: float, + ) -> None: + price_move_percent = self._calculate_price_move_percent(current_price) + pnl = safe_float(position.unrealized_pnl_usd) + + if pnl is not None: + peak_pnl = safe_float(position.peak_unrealized_pnl_usd) + + if peak_pnl is None or pnl > peak_pnl: + position.peak_unrealized_pnl_usd = pnl + + peak_percent = safe_float(position.peak_pnl_percent) + + if peak_percent is None or price_move_percent > peak_percent: + position.peak_pnl_percent = price_move_percent + + mfe = safe_float(position.max_favorable_excursion_percent) + mae = safe_float(position.max_adverse_excursion_percent) + + if mfe is None or price_move_percent > mfe: + position.max_favorable_excursion_percent = price_move_percent + + if mae is None or price_move_percent < mae: + position.max_adverse_excursion_percent = price_move_percent + + best_price = safe_float(position.best_price_seen) + worst_price = safe_float(position.worst_price_seen) + + if best_price is None: + position.best_price_seen = current_price + elif position.side == "LONG" and current_price > best_price: + position.best_price_seen = current_price + elif position.side == "SHORT" and current_price < best_price: + position.best_price_seen = current_price + + if worst_price is None: + position.worst_price_seen = current_price + elif position.side == "LONG" and current_price < worst_price: + position.worst_price_seen = current_price + elif position.side == "SHORT" and current_price > worst_price: + position.worst_price_seen = current_price + + fatigue_score = self._runtime_fatigue_score(position) + position.fatigue_score = fatigue_score + position.fatigue_state = self._runtime_fatigue_state(fatigue_score) + + # рассчитать fatigue score позиции + def _runtime_fatigue_score(self, position: PositionState) -> float: + score = 0.0 + + mfe = safe_float(position.max_favorable_excursion_percent) or 0.0 + current_peak = safe_float(position.peak_pnl_percent) or 0.0 + mae = safe_float(position.max_adverse_excursion_percent) or 0.0 + + hold_seconds = 0 + + opened_at = safe_float(position.opened_monotonic_at) + if opened_at is not None: + hold_seconds = max(0, int(time.monotonic() - opened_at)) + + if hold_seconds >= 1800: + score += 0.25 + elif hold_seconds >= 900: + score += 0.15 + elif hold_seconds >= 300: + score += 0.08 + + if mfe > 0 and current_peak > 0: + giveback = max(0.0, mfe - current_peak) + + if giveback >= 0.75: + score += 0.25 + elif giveback >= 0.45: + score += 0.18 + elif giveback >= 0.25: + score += 0.10 + + if mae <= -1.0: + score += 0.25 + elif mae <= -0.5: + score += 0.15 + + return round(max(0.0, min(1.0, score)), 3) + + # преобразовать fatigue score в semantic state + def _runtime_fatigue_state(self, score: float | None) -> str: + value = safe_float(score) + + if value is None: + return "UNKNOWN" + + if value >= 0.75: + return "EXHAUSTED" + + if value >= 0.50: + return "TIRED" + + if value >= 0.25: + return "WATCH" + + return "FRESH" + + # сбросить lifecycle-метрики позиции в AutoTradeState + def _reset_position_lifecycle_state(self, state: AutoTradeState) -> None: + state.position_peak_pnl_usd = None + state.position_peak_pnl_percent = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.position_fatigue_score = None + state.position_fatigue_state = None + state.position_giveback_percent = None + state.position_conviction_state = None + state.position_exit_urgency = None + state.position_reversal_risk = None \ No newline at end of file diff --git a/app/src/trading/execution/pricing.py b/app/src/trading/execution/pricing.py new file mode 100644 index 0000000..bd744f1 --- /dev/null +++ b/app/src/trading/execution/pricing.py @@ -0,0 +1,134 @@ +# app/src/trading/execution/pricing.py + +from __future__ import annotations + +from dataclasses import dataclass + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.integrations.exchange.service import ExchangeService +from src.trading.auto.state import AutoTradeState + + +@dataclass(slots=True) +class ExecutionPrice: + price: float + source: str + age_seconds: float | None + updated_at: str + pricing_role: str + + +class ExecutionPricingMixin: + # получить цену входа по текущему сигналу + def _signal_entry_price(self, state: AutoTradeState) -> ExecutionPrice: + if state.last_signal == "BUY": + return self._entry_price_for_side(state.symbol, "LONG") + + if state.last_signal == "SELL": + return self._entry_price_for_side(state.symbol, "SHORT") + + return self._market_last_price(state.symbol) + + # получить цену входа по стороне позиции + def _entry_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: + snapshot = ExchangeService().get_execution_snapshot(symbol) + + if snapshot.age_seconds is not None and snapshot.age_seconds > 5: + raise ValueError("Execution snapshot is stale.") + + if side == "LONG": + return ExecutionPrice( + price=self._snapshot_price(snapshot.ask_price, "ask_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="LONG_ENTRY_ASK", + ) + + if side == "SHORT": + return ExecutionPrice( + price=self._snapshot_price(snapshot.bid_price, "bid_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="SHORT_ENTRY_BID", + ) + + return ExecutionPrice( + price=self._snapshot_price(snapshot.last_price, "last_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="ENTRY_LAST", + ) + + # получить цену выхода по стороне позиции + def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: + snapshot = ExchangeService().get_execution_snapshot(symbol) + + if snapshot.age_seconds is not None and snapshot.age_seconds > 5: + raise ValueError("Execution snapshot is stale.") + + if side == "LONG": + return ExecutionPrice( + price=self._snapshot_price(snapshot.bid_price, "bid_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="LONG_EXIT_BID", + ) + + if side == "SHORT": + return ExecutionPrice( + price=self._snapshot_price(snapshot.ask_price, "ask_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="SHORT_EXIT_ASK", + ) + + return ExecutionPrice( + price=self._snapshot_price(snapshot.last_price, "last_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="EXIT_LAST", + ) + + # получить последнюю рыночную цену + def _market_last_price(self, symbol: str) -> ExecutionPrice: + snapshot = ExchangeService().get_execution_snapshot(symbol) + + return ExecutionPrice( + price=self._snapshot_price(snapshot.last_price, "last_price"), + source=snapshot.source, + age_seconds=snapshot.age_seconds, + updated_at=snapshot.updated_at, + pricing_role="MARKET_LAST", + ) + + # проверить и нормализовать цену из execution snapshot + 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." + ) + + 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}" + ) + + return price \ No newline at end of file diff --git a/app/src/trading/execution/resets.py b/app/src/trading/execution/resets.py new file mode 100644 index 0000000..54bbb7c --- /dev/null +++ b/app/src/trading/execution/resets.py @@ -0,0 +1,74 @@ +# app/src/trading/execution/resets.py + +from __future__ import annotations + +from typing import Protocol + +from src.trading.auto.state import AutoTradeState + + +class _ExecutionResetsProtocol(Protocol): + """ + Protocol для reset mixin. + + Сейчас пустой, но оставлен для единообразия архитектуры. + """ + pass + + +class ExecutionResetsMixin(_ExecutionResetsProtocol): + """ + Общие reset-функции execution слоя. + + Здесь находятся методы очистки runtime/protection/ + lifecycle состояния позиции. + + Это позволяет избежать циклических зависимостей между: + - position_actions.py + - position_protection.py + - runtime_actions.py + """ + + def _reset_runtime_protection_state( + self, + state: AutoTradeState, + ) -> None: + """ + Полный reset runtime protection состояния позиции. + Вызывается после закрытия позиции. + """ + + state.position_protection_status = None + state.position_protection_reason = None + + state.break_even_armed = False + state.break_even_price = None + + state.trailing_stop_active = False + state.trailing_stop_price = None + + state.profit_lock_active = False + state.profit_lock_price = None + + state.runtime_protection_action = None + state.runtime_protection_reason = None + state.runtime_protection_updated_at = None + + def _reset_position_lifecycle_state( + self, + state: AutoTradeState, + ) -> None: + """ + Reset lifecycle состояния позиции. + Используется после полного закрытия позиции. + """ + + state.position_opened_monotonic_at = None + + state.last_flip_old_side = None + state.last_flip_new_side = None + state.last_flip_pnl_usd = None + state.last_flip_reason = None + + state.execution_block_reason = None + state.last_flip_block_reason = None \ No newline at end of file diff --git a/app/src/trading/execution/risk_close.py b/app/src/trading/execution/risk_close.py new file mode 100644 index 0000000..6725a0e --- /dev/null +++ b/app/src/trading/execution/risk_close.py @@ -0,0 +1,121 @@ +# app/src/trading/execution/risk_close.py + +from __future__ import annotations + +from typing import Protocol + +from src.trading.auto.state import AutoTradeState +from src.trading.execution.models import ExecutionDecision +from src.trading.execution.pricing import ExecutionPrice +from src.trading.position.state import PositionState + + +class _ExecutionRiskCloseProtocol(Protocol): + _position: PositionState + + # получить цену выхода для стороны позиции + def _exit_price_for_side( + self, + symbol: str, + side: str, + ) -> ExecutionPrice: ... + + # посчитать движение цены позиции в процентах + def _calculate_price_move_percent(self, current_price) -> float: ... + + # посчитать текущий PnL позиции + def _calculate_pnl(self, current_price) -> float: ... + + # закрыть открытую позицию + def _close_position( + self, + state: AutoTradeState, + *, + forced_reason: str | None = None, + forced_exit_price=None, + forced_pnl=None, + forced_price_meta: ExecutionPrice | None = None, + ) -> ExecutionDecision: ... + + +class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol): + # проверить, нужно ли закрыть позицию по max loss / stop loss / take profit + def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: + position = type(self)._position + + if position.side == "NONE": + return None + + try: + current_execution = self._exit_price_for_side( + position.symbol or state.symbol, + position.side, + ) + current_price = current_execution.price + except Exception: + return None + + price_move_percent = self._calculate_price_move_percent(current_price) + unrealized_pnl = self._calculate_pnl(current_price) + + if self._is_max_loss_hit(state, unrealized_pnl): + return self._close_position( + state, + forced_reason="MAX_LOSS", + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + forced_price_meta=current_execution, + ) + + if self._is_stop_loss_hit(state, price_move_percent): + return self._close_position( + state, + forced_reason="STOP_LOSS", + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + forced_price_meta=current_execution, + ) + + if self._is_take_profit_hit(state, price_move_percent): + return self._close_position( + state, + forced_reason="TAKE_PROFIT", + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + forced_price_meta=current_execution, + ) + + return None + + # проверить, достигнут ли stop loss в процентах + def _is_stop_loss_hit( + self, + state: AutoTradeState, + price_move_percent: float, + ) -> bool: + if state.stop_loss_percent is None: + return False + + return price_move_percent <= -abs(state.stop_loss_percent) + + # проверить, достигнут ли take profit в процентах + def _is_take_profit_hit( + self, + state: AutoTradeState, + price_move_percent: float, + ) -> bool: + if state.take_profit_percent is None: + return False + + return price_move_percent >= abs(state.take_profit_percent) + + # проверить, достигнут ли максимальный убыток в USD + def _is_max_loss_hit( + self, + state: AutoTradeState, + unrealized_pnl: float, + ) -> bool: + if state.max_loss_usd is None: + return False + + return unrealized_pnl <= -abs(state.max_loss_usd) \ No newline at end of file diff --git a/app/src/trading/execution/runtime_actions.py b/app/src/trading/execution/runtime_actions.py new file mode 100644 index 0000000..ebb8eb2 --- /dev/null +++ b/app/src/trading/execution/runtime_actions.py @@ -0,0 +1,309 @@ +# app/src/trading/execution/runtime_actions.py + +from __future__ import annotations + +import time +from typing import Protocol + +from src.core.event_bus import EventBus +from src.core.numbers import safe_float +from src.core.types import JsonDict +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 + + +class _ExecutionRuntimeActionsProtocol(Protocol): + _position: PositionState + + def _sync_state_from_position( + self, + state: AutoTradeState, + ) -> None: ... + + def _close_position( + self, + state: AutoTradeState, + *, + forced_reason: str | None = None, + ) -> ExecutionDecision: ... + + +class ExecutionRuntimeActionsMixin( + _ExecutionRuntimeActionsProtocol +): + """ + Runtime autonomous actions subsystem. + + Отвечает за: + - runtime EXIT + - runtime REDUCE + - runtime PROTECT + - cooldown runtime действий + - runtime logging + """ + + _runtime_action_cooldown_seconds = 30 + _last_runtime_action_key: str | None = None + + # ========================================================= + # PUBLIC + # ========================================================= + + def process_runtime_action( + self, + state: AutoTradeState, + ) -> ExecutionDecision: + """ + Главный runtime action processor. + """ + + self._sync_state_from_position(state) + + position = type(self)._position + + if state.status != "RUNNING": + return ExecutionDecision( + "NONE", + False, + "Runtime action доступен только в режиме RUNNING.", + ) + + if position.side == "NONE": + return ExecutionDecision( + "NONE", + False, + "Нет открытой позиции для runtime action.", + ) + + action = str( + getattr(state, "autonomous_action", "") or "" + ).upper() + + confidence = safe_float( + getattr(state, "autonomous_action_confidence", None) + ) or 0.0 + + reason = str( + getattr(state, "autonomous_action_reason", "") or "" + ) + + # ----------------------------------------------------- + # NO ACTION + # ----------------------------------------------------- + + if action in {"", "HOLD", "WATCH"}: + return ExecutionDecision( + "NONE", + False, + "Runtime action не требуется.", + ) + + # ----------------------------------------------------- + # COOLDOWN + # ----------------------------------------------------- + + if self._runtime_action_cooldown_active(state, action): + return ExecutionDecision( + "NONE", + False, + "Runtime action cooldown активен.", + ) + + # ----------------------------------------------------- + # PROTECT + # ----------------------------------------------------- + + if action == "PROTECT": + return self._log_runtime_action( + state=state, + action="PROTECT", + reason=reason or "позиция требует защиты", + confidence=confidence, + executed=False, + ) + + # ----------------------------------------------------- + # REDUCE + # ----------------------------------------------------- + + if action == "REDUCE": + return self._log_runtime_action( + state=state, + action="REDUCE", + reason=reason or "позиция требует уменьшения", + confidence=confidence, + executed=False, + ) + + # ----------------------------------------------------- + # EXIT + # ----------------------------------------------------- + + if action == "EXIT": + + if confidence < 0.75: + return self._log_runtime_action( + state=state, + action="EXIT_BLOCKED", + reason=( + "autonomous exit заблокирован: " + f"confidence {confidence:.2f} < 0.75" + ), + confidence=confidence, + executed=False, + ) + + decision = self._close_position( + state, + forced_reason="AUTONOMOUS_EXIT", + ) + + state.autonomous_last_action = "EXIT" + state.autonomous_last_action_reason = ( + reason or decision.reason + ) + state.autonomous_last_action_at = ( + time.monotonic() + ) + + return decision + + # ----------------------------------------------------- + # UNKNOWN ACTION + # ----------------------------------------------------- + + return ExecutionDecision( + "NONE", + False, + f"Неизвестный runtime action: {action}.", + ) + + # ========================================================= + # COOLDOWN + # ========================================================= + + def _runtime_action_cooldown_active( + self, + state: AutoTradeState, + action: str, + ) -> bool: + """ + Проверка cooldown runtime action. + """ + + ts = safe_float( + getattr(state, "autonomous_last_action_at", None) + ) + + last_action = str( + getattr(state, "autonomous_last_action", "") or "" + ).upper() + + if ts is None: + return False + + if last_action != action: + return False + + return ( + time.monotonic() - ts + ) < self._runtime_action_cooldown_seconds + + # ========================================================= + # LOGGING + # ========================================================= + + def _log_runtime_action( + self, + *, + state: AutoTradeState, + action: str, + reason: str, + confidence: float, + executed: bool, + ) -> ExecutionDecision: + """ + Runtime action logging + deduplication. + """ + + position = type(self)._position + + key = ( + f"{state.symbol}:" + f"{position.side}:" + f"{action}:" + f"{reason}:" + f"{confidence:.2f}" + ) + + if key != type(self)._last_runtime_action_key: + + type(self)._last_runtime_action_key = key + + payload: JsonDict = { + "execution_type": "RUNTIME_ACTION", + "action": action, + "executed": executed, + "symbol": state.symbol, + "position_side": position.side, + "entry_price": position.entry_price, + "size": position.size, + "unrealized_pnl_usd": ( + state.unrealized_pnl_usd + ), + "position_health_status": getattr( + state, + "position_health_status", + None, + ), + "position_risk_level": getattr( + state, + "position_risk_level", + None, + ), + "position_exit_signal": getattr( + state, + "position_exit_signal", + None, + ), + "position_exit_confidence": getattr( + state, + "position_exit_confidence", + None, + ), + "autonomous_action": getattr( + state, + "autonomous_action", + None, + ), + "confidence": confidence, + "reason": reason, + } + + JournalService().log_ui_warning( + event_type="runtime_position_action", + message=( + f"Runtime action: {action}. " + f"Причина: {reason}." + ), + screen="auto", + action="runtime_position_action", + payload=payload, + ) + + EventBus.emit( + "runtime_position_action", + payload, + ) + + state.autonomous_last_action = action + state.autonomous_last_action_reason = reason + state.autonomous_last_action_at = time.monotonic() + + return ExecutionDecision( + action, + executed, + reason, + ) \ No newline at end of file diff --git a/app/src/trading/execution/sizing.py b/app/src/trading/execution/sizing.py new file mode 100644 index 0000000..f6da964 --- /dev/null +++ b/app/src/trading/execution/sizing.py @@ -0,0 +1,405 @@ +# app/src/trading/execution/sizing.py + +from __future__ import annotations + +import math +import time +from typing import Protocol + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.trading.auto.state import AutoTradeState +from src.trading.execution.pricing import ExecutionPrice + + +class _ExecutionSizingProtocol(Protocol): + _size_precision: int + + # получить цену входа по текущему сигналу + def _signal_entry_price( + self, + state: AutoTradeState, + ) -> ExecutionPrice: + ... + + # округлить размер позиции + def _round_size( + self, + size: NumericLike | None, + ) -> float: + ... + + +class ExecutionSizingMixin(_ExecutionSizingProtocol): + # рассчитать итоговый размер позиции с учётом риска и adaptive multiplier + def _calculate_position_size( + self, + state: AutoTradeState, + *, + entry_price: float | None = None, + ) -> float: + if state.risk_percent is None or state.risk_percent <= 0: + self._sync_adaptive_size_state( + state, + base_size=0.0, + final_size=0.0, + multiplier=0.0, + ) + return 0.0 + + if state.stop_loss_percent is None or state.stop_loss_percent <= 0: + self._sync_adaptive_size_state( + state, + base_size=0.0, + final_size=0.0, + multiplier=0.0, + ) + return 0.0 + + price = entry_price + + if price is None: + try: + price = self._signal_entry_price(state).price + except Exception: + self._sync_adaptive_size_state( + state, + base_size=0.0, + final_size=0.0, + multiplier=0.0, + ) + return 0.0 + + if price <= 0: + self._sync_adaptive_size_state( + state, + base_size=0.0, + final_size=0.0, + multiplier=0.0, + ) + return 0.0 + + balance_usd = state.allocated_balance_usd + target_risk_usd = balance_usd * (state.risk_percent / 100) + stop_loss_distance_usd = price * (state.stop_loss_percent / 100) + + if stop_loss_distance_usd <= 0: + self._sync_adaptive_size_state( + state, + base_size=0.0, + final_size=0.0, + multiplier=0.0, + ) + return 0.0 + + base_size = target_risk_usd / stop_loss_distance_usd + multiplier = self._adaptive_size_multiplier(state) + final_size = base_size * multiplier + + self._sync_adaptive_size_state( + state, + base_size=base_size, + final_size=final_size, + multiplier=multiplier, + ) + + return self._round_size(final_size) + + # рассчитать коэффициент изменения размера позиции по runtime/context факторам + def _adaptive_size_multiplier(self, state: AutoTradeState) -> float: + multiplier = 1.0 + + execution_confidence_score = getattr( + state, + "execution_confidence_score", + None, + ) + 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 + elif score < 0.65: + multiplier *= 0.65 + elif score < 0.75: + multiplier *= 0.85 + elif score >= 0.85: + multiplier *= 1.15 + + market_state = getattr(state, "market_state", None) + market_trend_strength = getattr(state, "market_trend_strength", None) + market_trend_quality = getattr(state, "market_trend_quality", None) + market_phase = getattr(state, "market_phase", None) + + if market_state in { + "HIGH_VOLATILITY", + "LOW_VOLATILITY", + "RANGE", + "CHAOTIC", + "LIQUIDITY_VOID", + }: + multiplier *= 0.65 + + if market_trend_strength == "STRONG": + multiplier *= 1.1 + elif market_trend_strength == "WEAK": + multiplier *= 0.75 + + if market_trend_quality == "CLEAN": + multiplier *= 1.05 + elif market_trend_quality == "NOISY": + multiplier *= 0.75 + + if market_phase == "IMPULSE": + multiplier *= 1.1 + elif market_phase == "PULLBACK": + multiplier *= 0.8 + elif market_phase in {"RANGE", "SQUEEZE"}: + multiplier *= 0.7 + + momentum_state = getattr(state, "momentum_state", None) + momentum_direction = getattr(state, "momentum_direction", None) + momentum_strength = getattr(state, "momentum_strength", None) + + signal = (state.last_signal or "").upper() + + if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: + multiplier *= 1.15 + elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}: + multiplier *= 1.05 + + strength = safe_float(momentum_strength) + + if strength is not None: + if strength >= 1.5: + multiplier *= 1.1 + elif strength <= 0.7: + multiplier *= 0.8 + + if signal == "BUY" and momentum_direction == "DOWN": + multiplier *= 0.65 + + if signal == "SELL" and momentum_direction == "UP": + multiplier *= 0.65 + + execution_quality = getattr(state, "execution_quality", None) + execution_quality_reason = getattr( + state, + "execution_quality_reason", + None, + ) + + if execution_quality == "BLOCKED": + multiplier *= 0.0 + elif execution_quality == "WARNING": + if execution_quality_reason == "WIDE_SPREAD": + multiplier *= 0.75 + elif execution_quality_reason == "AGING_SNAPSHOT": + multiplier *= 0.8 + elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE": + multiplier *= 0.7 + else: + multiplier *= 0.8 + + if getattr(state, "market_runtime_degraded", False): + multiplier *= 0.75 + + return round(max(0.0, min(1.25, multiplier)), 4) + + # синхронизировать рассчитанный adaptive size в AutoTradeState + def _sync_adaptive_size_state( + self, + state: AutoTradeState, + *, + base_size: float, + final_size: float, + multiplier: float, + ) -> None: + reason = self._adaptive_size_reason(multiplier) + + state.adaptive_size_base = self._round_size(base_size) + state.adaptive_size_final = self._round_size(final_size) + state.adaptive_size_multiplier = multiplier + + 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, + 4, + ) + + state.effective_target_risk_usd = round( + state.allocated_balance_usd + * (state.effective_risk_percent / 100), + 4, + ) + + state.adaptive_size_reason = reason + state.adaptive_size_factors = { + "execution_confidence_score": getattr( + state, + "execution_confidence_score", + None, + ), + "execution_confidence_level": getattr( + state, + "execution_confidence_level", + None, + ), + "market_state": getattr(state, "market_state", None), + "market_trend_strength": getattr( + state, + "market_trend_strength", + None, + ), + "market_trend_quality": getattr( + state, + "market_trend_quality", + None, + ), + "market_phase": getattr(state, "market_phase", None), + "momentum_state": getattr(state, "momentum_state", None), + "momentum_direction": getattr( + state, + "momentum_direction", + None, + ), + "momentum_strength": getattr( + state, + "momentum_strength", + None, + ), + "execution_quality": getattr(state, "execution_quality", None), + "execution_quality_reason": getattr( + state, + "execution_quality_reason", + None, + ), + "spread_percent": getattr(state, "spread_percent", None), + "base_size": self._round_size(base_size), + "final_size": self._round_size(final_size), + "multiplier": multiplier, + } + + if multiplier <= 0: + state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_ZERO" + elif multiplier < 1: + state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_REDUCED" + elif multiplier > 1: + state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_INCREASED" + else: + state.execution_size_adjustment_reason = None + + # пересчитать effective risk после ограничения размера по margin limit + def _sync_effective_risk_after_margin_limit( + self, + state: AutoTradeState, + *, + base_size: float, + final_size: float, + ) -> None: + adaptive_final = safe_float(state.adaptive_size_final) or 0.0 + + if adaptive_final <= 0: + state.effective_risk_percent = 0.0 + state.effective_target_risk_usd = 0.0 + return + + margin_ratio = max( + 0.0, + min(1.0, final_size / adaptive_final), + ) + + current_effective_risk = safe_float( + state.effective_risk_percent + ) or 0.0 + + state.effective_risk_percent = round( + current_effective_risk * margin_ratio, + 4, + ) + + state.effective_target_risk_usd = round( + state.allocated_balance_usd + * (state.effective_risk_percent / 100), + 4, + ) + + # вернуть текстовую причину изменения adaptive size + def _adaptive_size_reason(self, multiplier: float) -> str: + if multiplier <= 0: + return "adaptive size заблокировал вход" + + if multiplier < 0.75: + return "размер позиции сильно уменьшен по risk/runtime факторам" + + if multiplier < 1: + return "размер позиции умеренно уменьшен по risk/runtime факторам" + + if multiplier > 1: + return "размер позиции увеличен при сильном execution context" + + return "размер позиции без adaptive корректировки" + + # ограничить размер позиции по максимальному резервированию баланса + def _adjust_size_by_margin_limit( + self, + *, + state: AutoTradeState, + entry_price: float, + size: float, + ) -> float: + max_percent = state.max_reserved_balance_percent + + if max_percent is None or max_percent <= 0: + return self._round_size(size) + + leverage = state.leverage or 1.0 + + if leverage <= 0 or entry_price <= 0: + state.execution_block_reason = "Invalid leverage or entry price." + return 0.0 + + balance_usd = state.allocated_balance_usd + max_reserved_usd = balance_usd * (max_percent / 100) + + max_notional_usd = max_reserved_usd * leverage + max_size = max_notional_usd / entry_price + + if size <= max_size: + return self._round_size(size) + + state.execution_size_adjustment_reason = "MARGIN_LIMIT" + + limited_size = self._round_size(max_size) + + adaptive_final = safe_float(state.adaptive_size_final) or 0.0 + + if adaptive_final > 0: + effective_multiplier = limited_size / adaptive_final + + if effective_multiplier < 0.5: + state.adaptive_size_reason = ( + "размер позиции сильно ограничен margin limit" + ) + else: + state.adaptive_size_reason = ( + "размер позиции ограничен margin limit" + ) + + return limited_size + + # округлить размер позиции вниз до допустимой точности + def _round_size(self, size: NumericLike | None) -> float: + value = safe_float(size) + + if value is None: + return 0.0 + + factor = 10 ** self._size_precision + return math.floor(value * factor) / factor \ No newline at end of file diff --git a/app/src/trading/execution/supervisor.py b/app/src/trading/execution/supervisor.py new file mode 100644 index 0000000..537ff6d --- /dev/null +++ b/app/src/trading/execution/supervisor.py @@ -0,0 +1,172 @@ +# app/src/trading/execution/supervisor.py + +from __future__ import annotations + +import time +from typing import Protocol + +from src.core.event_bus import EventBus +from src.core.numbers import safe_float +from src.core.types import JsonDict +from src.trading.auto.state import AutoTradeState +from src.trading.execution.models import ExecutionDecision +from src.trading.journal.service import JournalService + + +class _ExecutionSupervisorProtocol(Protocol): + _emergency_halt_drawdown_usd: float + _emergency_halt_loss_streak: int + _execution_cooldown_after_loss_seconds: int + _max_execution_snapshot_age_seconds: int + _degraded_market_block_states: set[str] + _conflict_execution_block: bool + + +class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): + # проверить все supervisor-блокировки перед исполнением + def _process_execution_supervisor( + self, + state: AutoTradeState, + ) -> ExecutionDecision | None: + for reason, action in ( + (self._execution_halt_reason(state), "EXECUTION_HALTED"), + (self._execution_cooldown_reason(state), "EXECUTION_COOLDOWN"), + (self._degraded_market_reason(state), "DEGRADED_MARKET"), + (self._stale_execution_reason(state), "STALE_EXECUTION"), + (self._conflict_signal_reason(state), "SIGNAL_CONFLICT"), + ): + if reason is not None: + return self._block_execution( + state=state, + reason=reason, + action=action, + ) + + return None + + # определить, нужно ли аварийно остановить execution + def _execution_halt_reason(self, state: AutoTradeState) -> str | None: + pnl = safe_float(state.cycle_realized_pnl_usd) or 0.0 + + if pnl <= -abs(self._emergency_halt_drawdown_usd): + return "execution emergency halt: cycle drawdown limit exceeded" + + closed = safe_float(state.cycle_closed_trades) or 0 + wins = safe_float(state.cycle_winning_trades) or 0 + losses = max(0, int(closed - wins)) + + if losses >= self._emergency_halt_loss_streak: + return "execution emergency halt: loss streak exceeded" + + return None + + # определить, активен ли cooldown после убыточной сделки + def _execution_cooldown_reason(self, state: AutoTradeState) -> str | None: + ts = safe_float(getattr(state, "last_loss_monotonic_at", None)) + + if ts is None: + return None + + delta = time.monotonic() - ts + + if delta < self._execution_cooldown_after_loss_seconds: + remaining = int(self._execution_cooldown_after_loss_seconds - delta) + return f"execution cooldown after loss ({remaining}s remaining)" + + return None + + # определить, запрещает ли состояние рынка исполнение + def _degraded_market_reason(self, state: AutoTradeState) -> str | None: + market_state = getattr(state, "market_state", None) + + if market_state in self._degraded_market_block_states: + return f"market state blocked execution: {market_state}" + + return None + + # определить, устарели ли данные для исполнения + def _stale_execution_reason(self, state: AutoTradeState) -> str | None: + age = safe_float(getattr(state, "execution_price_age_seconds", None)) + + if age is None: + age = safe_float(getattr(state, "snapshot_age_seconds", None)) + + if age is None: + return None + + if age > self._max_execution_snapshot_age_seconds: + return f"execution snapshot stale: {age:.2f}s" + + return None + + # определить конфликт сигнала с momentum или трендом + def _conflict_signal_reason(self, state: AutoTradeState) -> str | None: + if not self._conflict_execution_block: + return None + + signal = (state.last_signal or "").upper() + momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() + trend_direction = str(getattr(state, "market_trend", "") or "").upper() + + if signal == "BUY": + if momentum_direction == "DOWN": + return "BUY conflicts with momentum" + + if trend_direction == "DOWN": + return "BUY conflicts with trend" + + if signal == "SELL": + if momentum_direction == "UP": + return "SELL conflicts with momentum" + + if trend_direction == "UP": + return "SELL conflicts with trend" + + return None + + # заблокировать execution и записать событие в журнал + def _block_execution( + self, + *, + state: AutoTradeState, + reason: str, + action: str, + ) -> ExecutionDecision: + state.execution_block_reason = reason + state.last_execution_action = action + state.last_execution_reason = reason + + key_reason = reason + + if action == "EXECUTION_COOLDOWN": + key_reason = "execution cooldown after loss" + + key = f"{action}:{state.symbol}:{key_reason}" + last_key = getattr(type(self), "_last_supervisor_block_key", None) + + if key != last_key: + setattr(type(self), "_last_supervisor_block_key", key) + + payload: JsonDict = { + "execution_type": "SUPERVISOR_BLOCK", + "action": action, + "symbol": state.symbol, + "reason": reason, + "market_state": getattr(state, "market_state", None), + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "cycle_realized_pnl_usd": state.cycle_realized_pnl_usd, + } + + JournalService().log_ui_warning( + event_type="execution_supervisor_block", + message=f"Execution supervisor blocked action: {reason}", + screen="auto", + action="execution_supervisor", + payload=payload, + ) + + EventBus.emit("execution_supervisor_block", payload) + + return ExecutionDecision("NONE", False, reason) \ No newline at end of file diff --git a/app/src/trading/journal/exporter.py b/app/src/trading/journal/exporter.py index 4e6924b..840970e 100644 --- a/app/src/trading/journal/exporter.py +++ b/app/src/trading/journal/exporter.py @@ -135,6 +135,7 @@ def _metadata_rows( export_limit: int, account_mode: str, journal_level: str, + export_filter_label: str = "Всё", ) -> list[list[str]]: exported_count = len(rows) is_limited = total_count > exported_count @@ -143,6 +144,7 @@ def _metadata_rows( ["Экспорт журнала"], ["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")], ["Аккаунт", account_mode.upper()], + ["Фильтр", export_filter_label], ["Уровень журнала", journal_level], ["Всего записей в журнале", str(total_count)], ["Записей в файле", str(exported_count)], @@ -161,6 +163,7 @@ def build_csv( export_limit: int, account_mode: str, journal_level: str, + export_filter_label: str = "Всё", ) -> bytes: output = StringIO() writer = csv.writer( @@ -176,6 +179,7 @@ def build_csv( export_limit=export_limit, account_mode=account_mode, journal_level=journal_level, + export_filter_label=export_filter_label, ): writer.writerow(metadata_row) @@ -194,6 +198,7 @@ def build_xlsx( export_limit: int, account_mode: str, journal_level: str, + export_filter_label: str = "Всё", ) -> bytes: sheet_rows: list[list[str]] = [] @@ -204,6 +209,7 @@ def build_xlsx( export_limit=export_limit, account_mode=account_mode, journal_level=journal_level, + export_filter_label=export_filter_label, ) ) diff --git a/app/src/trading/journal/filters.py b/app/src/trading/journal/filters.py new file mode 100644 index 0000000..5c45492 --- /dev/null +++ b/app/src/trading/journal/filters.py @@ -0,0 +1,64 @@ +# app/src/trading/journal/filters.py + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class JournalExportFilter: + # key используется в callback_data и имени файла + key: str + + # label показываем в UI и metadata экспорта + label: str + + # description можно использовать позже в UI/подсказках + description: str + + +JOURNAL_EXPORT_FILTERS: dict[str, JournalExportFilter] = { + "all": JournalExportFilter( + key="all", + label="Всё", + description="Все записи журнала.", + ), + "auto": JournalExportFilter( + key="auto", + label="Автоторговля", + description="События автоторговли, сигналов, execution и runtime.", + ), + "trades": JournalExportFilter( + key="trades", + label="Сделки", + description="Открытия, закрытия, flip и trade-события.", + ), + "errors": JournalExportFilter( + key="errors", + label="Ошибки", + description="ERROR, CRITICAL и важные WARNING.", + ), + "not_auto": JournalExportFilter( + key="not_auto", + label="Без авто", + description="Все записи, кроме автоторговли.", + ), +} + + +def normalize_journal_export_filter(value: str | None) -> str: + # Защита от неизвестных callback_data. + key = str(value or "all").strip().lower() + + if key in JOURNAL_EXPORT_FILTERS: + return key + + return "all" + + +def get_journal_export_filter(value: str | None) -> JournalExportFilter: + return JOURNAL_EXPORT_FILTERS[normalize_journal_export_filter(value)] + + +def journal_export_filter_label(value: str | None) -> str: + return get_journal_export_filter(value).label \ No newline at end of file diff --git a/app/src/trading/journal/service.py b/app/src/trading/journal/service.py index aa821ff..048296a 100644 --- a/app/src/trading/journal/service.py +++ b/app/src/trading/journal/service.py @@ -10,6 +10,11 @@ from src.core.config import load_settings from src.storage.repositories.journal import JournalRepository from src.storage.session import check_database_health from src.trading.journal.exporter import build_csv, build_xlsx +from src.trading.journal.filters import ( + journal_export_filter_label, + normalize_journal_export_filter, +) + EXPORT_LIMIT = 10000 @@ -201,8 +206,17 @@ class JournalService: def get_total_count(self) -> int: return self.repository.count_events() - def get_export_rows(self, limit: int = EXPORT_LIMIT) -> list[dict[str, Any]]: - return self.repository.list_export_rows(limit=limit) + def get_export_rows( + self, + limit: int = EXPORT_LIMIT, + export_filter: str = "all", + ) -> list[dict[str, Any]]: + filter_key = normalize_journal_export_filter(export_filter) + + return self.repository.list_export_rows( + limit=limit, + export_filter=filter_key, + ) def _journal_level(self) -> str: return "INFO+" @@ -216,20 +230,34 @@ class JournalService: return now.strftime("%Y-%m-%d_%H-%M-%S") - def build_export_filename(self, extension: str) -> str: + def build_export_filename( + self, + extension: str, + export_filter: str = "all", + ) -> str: safe_extension = extension.lower().strip().lstrip(".") safe_level = self._journal_level().lower().replace("+", "_plus") + safe_filter = normalize_journal_export_filter(export_filter) return ( f"journal_" f"{self._account_mode()}_" + f"{safe_filter}_" f"{safe_level}_" f"{self._export_timestamp()}." f"{safe_extension}" ) - def export_csv(self, limit: int = EXPORT_LIMIT) -> bytes: - rows = self.get_export_rows(limit=limit) + def export_csv( + self, + limit: int = EXPORT_LIMIT, + export_filter: str = "all", + ) -> bytes: + filter_key = normalize_journal_export_filter(export_filter) + rows = self.get_export_rows( + limit=limit, + export_filter=filter_key, + ) return build_csv( rows, @@ -237,10 +265,19 @@ class JournalService: export_limit=limit, account_mode=self._account_mode(), journal_level=self._journal_level(), + export_filter_label=journal_export_filter_label(filter_key), ) - def export_xlsx(self, limit: int = EXPORT_LIMIT) -> bytes: - rows = self.get_export_rows(limit=limit) + def export_xlsx( + self, + limit: int = EXPORT_LIMIT, + export_filter: str = "all", + ) -> bytes: + filter_key = normalize_journal_export_filter(export_filter) + rows = self.get_export_rows( + limit=limit, + export_filter=filter_key, + ) return build_xlsx( rows, @@ -248,6 +285,7 @@ class JournalService: export_limit=limit, account_mode=self._account_mode(), journal_level=self._journal_level(), + export_filter_label=journal_export_filter_label(filter_key), ) def clear_all(self) -> int: @@ -289,4 +327,100 @@ class JournalService: }, ) - return deleted_count \ No newline at end of file + return deleted_count + + def _build_trade_payload( + self, + *, + state: object, + action: str, + trade_id: str | None = None, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + # Единый payload сделки для будущего анализа стратегии. + payload: dict[str, Any] = { + "trade_id": trade_id, + "action": action, + "symbol": getattr(state, "symbol", None), + "strategy": getattr(state, "strategy", None), + "cycle_number": getattr(state, "cycle_number", None), + "status": getattr(state, "status", None), + + "position_side": getattr(state, "position_side", None), + "entry_price": getattr(state, "entry_price", None), + "position_size": getattr(state, "position_size", None), + "leverage": getattr(state, "leverage", None), + + "unrealized_pnl_usd": getattr(state, "unrealized_pnl_usd", None), + "realized_pnl_usd": getattr(state, "realized_pnl_usd", None), + "cycle_realized_pnl_usd": getattr(state, "cycle_realized_pnl_usd", None), + "cycle_closed_trades": getattr(state, "cycle_closed_trades", None), + "cycle_winning_trades": getattr(state, "cycle_winning_trades", None), + + "last_signal": getattr(state, "last_signal", None), + "last_signal_confidence": getattr(state, "last_signal_confidence", None), + "last_signal_reason": getattr(state, "last_signal_reason", None), + "decision_status": getattr(state, "decision_status", None), + "decision_reason": getattr(state, "decision_reason", None), + + "market_state": getattr(state, "market_state", None), + "market_trend": getattr(state, "market_trend", None), + "market_trend_strength": getattr(state, "market_trend_strength", None), + "market_trend_quality": getattr(state, "market_trend_quality", None), + "market_phase": getattr(state, "market_phase", None), + "market_phase_direction": getattr(state, "market_phase_direction", None), + + "momentum_state": getattr(state, "momentum_state", None), + "momentum_direction": getattr(state, "momentum_direction", None), + "momentum_strength": getattr(state, "momentum_strength", None), + "momentum_change_percent": getattr(state, "momentum_change_percent", None), + + "execution_quality": getattr(state, "execution_quality", None), + "execution_quality_reason": getattr(state, "execution_quality_reason", None), + "execution_confidence_score": getattr(state, "execution_confidence_score", None), + "execution_confidence_level": getattr(state, "execution_confidence_level", None), + + "spread_percent": getattr(state, "spread_percent", None), + "snapshot_age_seconds": getattr(state, "snapshot_age_seconds", None), + + "adaptive_size_base": getattr(state, "adaptive_size_base", None), + "adaptive_size_final": getattr(state, "adaptive_size_final", None), + "adaptive_size_multiplier": getattr(state, "adaptive_size_multiplier", None), + "adaptive_size_reason": getattr(state, "adaptive_size_reason", None), + + "position_mfe_percent": getattr(state, "position_mfe_percent", None), + "position_mae_percent": getattr(state, "position_mae_percent", None), + "position_peak_pnl_usd": getattr(state, "position_peak_pnl_usd", None), + "position_hold_seconds": getattr(state, "position_hold_seconds", None), + } + + if extra: + payload.update(extra) + + return payload + + def log_trade_event( + self, + *, + event_type: str, + message: str, + state: object, + action: str, + trade_id: str | None = None, + payload: dict[str, Any] | None = None, + ) -> None: + # Trade-события пишем в общий журнал, чтобы экспорт CSV/XLSX уже работал без новой таблицы. + self.log_info( + event_type=event_type, + message=self._build_message(message), + payload=self._build_payload( + screen="auto", + action=action, + payload=self._build_trade_payload( + state=state, + action=action, + trade_id=trade_id, + extra=payload, + ), + ), + ) \ No newline at end of file diff --git a/app/src/trading/orders/__init__.py b/app/src/trading/orders/__init__.py deleted file mode 100644 index d8df7b8..0000000 --- a/app/src/trading/orders/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package marker.""" diff --git a/app/src/trading/orders/models.py b/app/src/trading/orders/models.py deleted file mode 100644 index 8671a76..0000000 --- a/app/src/trading/orders/models.py +++ /dev/null @@ -1,37 +0,0 @@ -# app/src/trading/orders/models.py - -from __future__ import annotations - -from dataclasses import dataclass, field - - -@dataclass(slots=True) -class OrderDraft: - symbol: str - side: str - order_type: str - quantity: str - price: str | None = None - status: str = "draft" - - -@dataclass(slots=True) -class OrderEntryContext: - symbol: str - side: str - order_type: str - base_currency: str - balance_currency: str - quote_currency: str - available_balance: float - reference_price: float - last_price: float - bid_price: float - ask_price: float - quantity_presets: list[str] = field(default_factory=list) - - -@dataclass(slots=True) -class OrderValidationResult: - is_valid: bool - errors: list[str] = field(default_factory=list) \ No newline at end of file diff --git a/app/src/trading/orders/service.py b/app/src/trading/orders/service.py deleted file mode 100644 index 06119eb..0000000 --- a/app/src/trading/orders/service.py +++ /dev/null @@ -1,626 +0,0 @@ -# /app/src/trading/orders/service.py - -from __future__ import annotations - -from decimal import Decimal, InvalidOperation, ROUND_DOWN, ROUND_UP - -from src.core.config import load_settings -from src.integrations.exchange.models import ExchangeSymbol -from src.integrations.exchange.service import ExchangeService -from src.storage.repositories.order_drafts import OrderDraftRepository -from src.trading.journal.service import JournalService -from src.trading.orders.models import OrderDraft, OrderEntryContext, OrderValidationResult - - -class OrderDraftsService: - def __init__(self) -> None: - self.settings = load_settings() - self.repository = OrderDraftRepository() - self.journal = JournalService() - self.exchange = ExchangeService() - - def build_draft( - self, - *, - side: str, - order_type: str, - quantity: str, - price: str | None = None, - ) -> OrderDraft: - return OrderDraft( - symbol=self.settings.default_symbol, - side=side.upper(), - order_type=order_type.upper(), - quantity=quantity, - price=price, - status="draft", - ) - - def get_entry_rules(self) -> dict[str, str | None]: - validation = self.exchange.validate_symbol(self.settings.default_symbol) - symbol_info = validation.symbol_info - - if symbol_info is None: - return { - "min_qty": None, - "step_size": None, - "min_notional": None, - "tick_size": None, - } - - min_qty = getattr(symbol_info, "min_qty", None) - step_size = getattr(symbol_info, "step_size", None) - min_notional = getattr(symbol_info, "min_notional", None) - tick_size = getattr(symbol_info, "tick_size", None) - - return { - "min_qty": str(min_qty) if min_qty not in (None, "") else None, - "step_size": str(step_size) if step_size not in (None, "") else None, - "min_notional": str(min_notional) if min_notional not in (None, "") else None, - "tick_size": str(tick_size) if tick_size not in (None, "") else None, - } - - def save_draft(self, draft: OrderDraft) -> None: - validation = self.validate_draft(draft) - if not validation.is_valid: - try: - self.journal.log_warning( - "order_draft_validation_failed", - "Черновик ордера не прошёл валидацию.", - { - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "errors": validation.errors, - }, - ) - except Exception: - pass - raise ValueError("; ".join(validation.errors)) - - payload = { - "source": "trade_screen", - "mode": "draft_only", - "price": draft.price, - } - - self.repository.add_draft( - symbol=draft.symbol, - side=draft.side, - order_type=draft.order_type, - quantity=draft.quantity, - status=draft.status, - payload=payload, - ) - - try: - self.journal.log_info( - "order_draft_saved", - "Черновик ордера сохранён.", - { - "symbol": draft.symbol, - "side": draft.side, - "order_type": draft.order_type, - "quantity": draft.quantity, - "price": draft.price, - "status": draft.status, - }, - ) - except Exception: - pass - - def validate_draft(self, draft: OrderDraft) -> OrderValidationResult: - errors: list[str] = [] - - if draft.side not in {"BUY", "SELL"}: - errors.append("Сторона ордера должна быть BUY или SELL.") - - if draft.order_type not in {"MARKET", "LIMIT"}: - errors.append("Тип ордера должен быть MARKET или LIMIT.") - - symbol_validation = self.exchange.validate_symbol(draft.symbol) - if not symbol_validation.is_valid: - errors.append(symbol_validation.message) - - quantity = self._to_decimal(draft.quantity) - if quantity is None or quantity <= 0: - errors.append("Количество должно быть числом больше нуля.") - - symbol_info = symbol_validation.symbol_info - - if quantity is not None and quantity > 0 and symbol_info is not None: - min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None)) - if min_qty is not None and min_qty > 0 and quantity < min_qty: - errors.append( - f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}." - ) - - step_size = self._to_decimal(getattr(symbol_info, "step_size", None)) - if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size): - errors.append( - f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}." - ) - - if draft.order_type == "LIMIT": - if not draft.price: - errors.append("Для LIMIT ордера требуется цена.") - else: - price = self._to_decimal(draft.price) - if price is None or price <= 0: - errors.append("Цена должна быть числом больше нуля.") - else: - tick_size = self._to_decimal(getattr(symbol_info, "tick_size", None)) - if tick_size is not None and tick_size > 0: - if not self._fits_step(price, tick_size): - errors.append( - f"Цена должна соответствовать шагу tickSize = {getattr(symbol_info, 'tick_size', None)}." - ) - - if quantity is not None and quantity > 0 and symbol_info is not None: - reference_price = self._resolve_reference_price_for_validation(draft, symbol_info) - if reference_price is not None: - min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None)) - if min_notional is not None and min_notional > 0: - notional = quantity * reference_price - if notional < min_notional: - errors.append( - f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}." - ) - - return OrderValidationResult( - is_valid=len(errors) == 0, - errors=errors, - ) - - def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str | int]]: - return self.repository.list_recent_drafts(limit=limit) - - def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None: - return self.repository.get_draft_by_id(draft_id) - - def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext: - validation = self.exchange.validate_symbol(self.settings.default_symbol) - if not validation.is_valid or validation.symbol_info is None: - raise ValueError(validation.message) - - symbol_info = validation.symbol_info - balances = self.exchange.get_balance_summary() - market = self.exchange.get_market_snapshot(self.settings.default_symbol) - - base_asset = (symbol_info.base_asset or "").strip() - quote_asset = (symbol_info.quote_asset or "").strip() - - if not base_asset or not quote_asset: - message = ( - "Биржа не вернула base/quote валюту для инструмента. " - "Невозможно корректно рассчитать контекст ордера." - ) - try: - self.journal.log_error( - "order_entry_context_assets_missing", - message, - { - "symbol": self.settings.default_symbol, - "base_asset": base_asset or None, - "quote_asset": quote_asset or None, - }, - ) - except Exception: - pass - raise ValueError(message) - - base_currency = base_asset.upper() - quote_currency = quote_asset.upper() - - available_by_currency = { - item.currency.upper(): float(item.available) - for item in balances - } - - side_upper = side.upper() - order_type_upper = order_type.upper() - - if side_upper == "BUY": - balance_currency = quote_currency - available_balance = available_by_currency.get(balance_currency, 0.0) - reference_price = float(market["ask_price"]) - max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0 - else: - balance_currency = base_currency - available_balance = available_by_currency.get(balance_currency, 0.0) - reference_price = float(market["bid_price"]) - max_qty = available_balance - - quantity_presets = self._build_quantity_presets( - max_qty=max_qty, - reference_price=reference_price, - symbol_info=symbol_info, - ) - - return OrderEntryContext( - symbol=self.settings.default_symbol, - side=side_upper, - order_type=order_type_upper, - base_currency=base_currency, - balance_currency=balance_currency, - quote_currency=quote_currency, - available_balance=available_balance, - reference_price=reference_price, - last_price=float(market["last_price"]), - bid_price=float(market["bid_price"]), - ask_price=float(market["ask_price"]), - quantity_presets=quantity_presets, - ) - - def validate_entry_quantity( - self, - *, - side: str, - order_type: str, - quantity: str, - price: str | None = None, - ) -> list[str]: - errors: list[str] = [] - - validation = self.exchange.validate_symbol(self.settings.default_symbol) - if not validation.is_valid or validation.symbol_info is None: - errors.append(validation.message) - return errors - - symbol_info = validation.symbol_info - quantity_dec = self._to_decimal(quantity) - - if quantity_dec is None or quantity_dec <= 0: - errors.append("Количество должно быть числом больше нуля.") - return errors - - min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None)) - if min_qty is not None and min_qty > 0 and quantity_dec < min_qty: - errors.append( - f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}." - ) - - step_size = self._to_decimal(getattr(symbol_info, "step_size", None)) - if step_size is not None and step_size > 0 and not self._fits_step(quantity_dec, step_size): - errors.append( - f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}." - ) - - reference_price = self._resolve_reference_price_for_entry( - side=side, - order_type=order_type, - price=price, - ) - - if reference_price is not None: - min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None)) - if min_notional is not None and min_notional > 0: - notional = quantity_dec * reference_price - if notional < min_notional: - errors.append( - f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}." - ) - - return errors - - def normalize_preset_quantity( - self, - *, - side: str, - order_type: str, - raw_quantity: str, - price: str | None = None, - ) -> str | None: - return self._normalize_entry_quantity_with_rules( - side=side, - order_type=order_type, - raw_quantity=raw_quantity, - price=price, - raise_to_minimum=True, - ) - - def normalize_entry_quantity( - self, - *, - side: str, - order_type: str, - raw_quantity: str, - price: str | None = None, - ) -> str | None: - return self._normalize_entry_quantity_with_rules( - side=side, - order_type=order_type, - raw_quantity=raw_quantity, - price=price, - raise_to_minimum=True, - ) - - def _normalize_entry_quantity_with_rules( - self, - *, - side: str, - order_type: str, - raw_quantity: str, - price: str | None = None, - raise_to_minimum: bool, - ) -> str | None: - validation = self.exchange.validate_symbol(self.settings.default_symbol) - if not validation.is_valid or validation.symbol_info is None: - return self.normalize_quantity(raw_quantity) - - original_quantity = self._to_decimal((raw_quantity or "").strip().replace(",", ".")) - if original_quantity is None or original_quantity <= 0: - return None - - symbol_info = validation.symbol_info - step_size = self._to_decimal(getattr(symbol_info, "step_size", None)) - min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None)) - min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None)) - - minimum_allowed = min_qty if min_qty is not None and min_qty > 0 else None - - reference_price = self._resolve_reference_price_for_entry( - side=side, - order_type=order_type, - price=price, - ) - if ( - reference_price is not None - and reference_price > 0 - and min_notional is not None - and min_notional > 0 - ): - min_by_notional = min_notional / reference_price - if step_size is not None and step_size > 0: - min_by_notional = self._ceil_to_step(min_by_notional, step_size) - - if minimum_allowed is None or min_by_notional > minimum_allowed: - minimum_allowed = min_by_notional - - quantity = original_quantity - - if step_size is not None and step_size > 0: - quantity = self._floor_to_step(quantity, step_size) - - if quantity <= 0: - if raise_to_minimum and minimum_allowed is not None and minimum_allowed > 0: - quantity = minimum_allowed - if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size): - quantity = self._ceil_to_step(quantity, step_size) - else: - return None - - if raise_to_minimum and minimum_allowed is not None and quantity < minimum_allowed: - quantity = minimum_allowed - if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size): - quantity = self._ceil_to_step(quantity, step_size) - - if quantity <= 0: - return None - - return self._format_decimal(quantity) - - def _build_quantity_presets( - self, - *, - max_qty: float, - reference_price: float, - symbol_info: ExchangeSymbol, - ) -> list[str]: - percents = [0.01, 0.05, 0.10, 0.25, 0.50, 1.00] - - max_qty_dec = self._to_decimal(max_qty) - reference_price_dec = self._to_decimal(reference_price) - - if max_qty_dec is None or max_qty_dec <= 0: - return [] - - step_size = self._to_decimal(getattr(symbol_info, "step_size", None)) - min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None)) - min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None)) - - result: list[str] = [] - seen: set[str] = set() - - for percent in percents: - qty = max_qty_dec * Decimal(str(percent)) - qty = self._normalize_quantity_to_exchange_rules( - quantity=qty, - step_size=step_size, - ) - - if qty is None or qty <= 0: - continue - - if min_qty is not None and min_qty > 0 and qty < min_qty: - continue - - if reference_price_dec is not None and reference_price_dec > 0: - if min_notional is not None and min_notional > 0: - if qty * reference_price_dec < min_notional: - continue - - if qty > max_qty_dec: - continue - - text = self._format_decimal(qty) - if text == "0" or text in seen: - continue - - seen.add(text) - result.append(text) - - if result: - return result - - fallback = self._normalize_quantity_to_exchange_rules( - quantity=max_qty_dec, - step_size=step_size, - ) - if fallback is None or fallback <= 0: - return [] - - if min_qty is not None and min_qty > 0 and fallback < min_qty: - return [] - - if reference_price_dec is not None and reference_price_dec > 0: - if min_notional is not None and min_notional > 0: - if fallback * reference_price_dec < min_notional: - return [] - - return [self._format_decimal(fallback)] - - @staticmethod - def normalize_side(raw: str) -> str | None: - value = (raw or "").strip().upper() - if value in {"BUY", "SELL"}: - return value - return None - - @staticmethod - def normalize_order_type(raw: str) -> str | None: - value = (raw or "").strip().upper() - if value in {"MARKET", "LIMIT"}: - return value - return None - - @staticmethod - def normalize_quantity(raw: str) -> str | None: - value = (raw or "").strip().replace(",", ".") - if not value: - return None - try: - quantity = float(value) - except ValueError: - return None - if quantity <= 0: - return None - return value - - @staticmethod - def normalize_price(raw: str) -> str | None: - value = (raw or "").strip().replace(",", ".") - if not value: - return None - try: - price = float(value) - except ValueError: - return None - if price <= 0: - return None - return value - - @staticmethod - def _format_number(value: float) -> str: - text = f"{value:.8f}" - text = text.rstrip("0").rstrip(".") - return text or "0" - - @staticmethod - def _format_decimal(value: Decimal) -> str: - text = f"{value:.8f}" - text = text.rstrip("0").rstrip(".") - return text or "0" - - @staticmethod - def _to_decimal(value: str | float | Decimal | None) -> Decimal | None: - if value is None: - return None - try: - return Decimal(str(value).strip()) - except (InvalidOperation, ValueError): - return None - - @staticmethod - def _fits_step(value: Decimal, step: Decimal) -> bool: - if step <= 0: - return True - ratio = value / step - return ratio == ratio.to_integral_value() - - @staticmethod - def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: - if step <= 0: - return value - ratio = (value / step).to_integral_value(rounding=ROUND_DOWN) - return ratio * step - - @staticmethod - def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal: - if step <= 0: - return value - ratio = (value / step).to_integral_value(rounding=ROUND_UP) - return ratio * step - - def _normalize_quantity_to_exchange_rules( - self, - *, - quantity: Decimal, - step_size: Decimal | None, - ) -> Decimal | None: - if quantity <= 0: - return None - - if step_size is not None and step_size > 0: - quantity = self._floor_to_step(quantity, step_size) - - if quantity <= 0: - return None - - return quantity - - def _resolve_reference_price_for_validation( - self, - draft: OrderDraft, - symbol_info: ExchangeSymbol | None, - ) -> Decimal | None: - price = self._to_decimal(draft.price) - if price is not None and price > 0: - return price - - if symbol_info is None: - return None - - try: - market = self.exchange.get_market_snapshot(draft.symbol) - except Exception: - return None - - if draft.side.upper() == "BUY": - return self._to_decimal(market.get("ask_price")) - return self._to_decimal(market.get("bid_price")) - - def _resolve_reference_price_for_entry( - self, - *, - side: str, - order_type: str, - price: str | None = None, - ) -> Decimal | None: - if order_type.upper() == "LIMIT": - explicit_price = self._to_decimal(price) - if explicit_price is not None and explicit_price > 0: - return explicit_price - - try: - market = self.exchange.get_market_snapshot(self.settings.default_symbol) - except Exception: - return None - - if side.upper() == "BUY": - return self._to_decimal(market.get("ask_price")) - return self._to_decimal(market.get("bid_price")) - - def calculate_notional(self, quantity: str, price: str | None) -> float | None: - q = self._to_decimal(quantity) - p = self._to_decimal(price) if price else None - - if q is None or p is None: - return None - - try: - return float(q * p) - except Exception: - return None \ No newline at end of file diff --git a/app/src/trading/orders/states.py b/app/src/trading/orders/states.py deleted file mode 100644 index 4d647ea..0000000 --- a/app/src/trading/orders/states.py +++ /dev/null @@ -1,11 +0,0 @@ -# /app/src/trading/orders/states.py - -from aiogram.fsm.state import State, StatesGroup - - -class NewOrderDraftStates(StatesGroup): - waiting_side = State() - waiting_type = State() - waiting_quantity = State() - waiting_price = State() - waiting_confirm = State() \ No newline at end of file diff --git a/app/src/trading/position/state.py b/app/src/trading/position/state.py index 432ad12..e3f2888 100644 --- a/app/src/trading/position/state.py +++ b/app/src/trading/position/state.py @@ -13,6 +13,15 @@ class PositionState: # торговый инструмент symbol: str = "" + # id сделки, к которой относится текущая позиция + trade_id: str | None = None + + # порядковый номер сделки внутри runtime + trade_sequence: int | None = None + + # номер auto-cycle, в котором открыта сделка + trade_cycle_number: int | None = None + # цена входа entry_price: float | None = None diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 01cb282..2cb59c6 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -1432,6 +1432,25 @@ - реализована preparation for partial exit engine - реализована preparation for advanced runtime orchestration +### 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics +- разобран ExecutionEngine на отдельные mixin-модули +- добавлены position open/close/flip actions +- добавлены runtime protection, risk close, supervisor, sizing, pricing, resets +- добавлен trade_id / trade_sequence / trade_cycle_number для связки open-close-flip +- добавлено логирование trade_opened / trade_closed / trade_flipped +- добавлено логирование запуска, наблюдения и остановки автоторговли +- добавлены фильтры экспорта журнала: all, auto, trades, errors, not_auto +- исправлен экспорт CSV/XLSX с фильтрами журнала +- снижено дублирование market stream / REST fallback событий +- снижено дублирование exchange/runtime ошибок +- добавлены human-readable event titles для новых событий +- улучшены уведомления AUTO_SIGNAL_READY +- добавлена цена входа по направлению сигнала: Ask для Long, Bid для Short +- добавлен контекст сигнала относительно открытой позиции +- удалены legacy trade/order handlers и order drafts +- вынесены auto runtime слои: lifecycle, signal, market, quality, semantic, health, intelligence, autonomous management +- добавлены exchange status/runtime UI helpers + --- ### 07.4.5 diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index 2d7d2df..eca67a9 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -1542,6 +1542,25 @@ - реализована preparation for partial exit engine - реализована preparation for advanced runtime orchestration +### 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics +- разобран ExecutionEngine на отдельные mixin-модули +- добавлены position open/close/flip actions +- добавлены runtime protection, risk close, supervisor, sizing, pricing, resets +- добавлен trade_id / trade_sequence / trade_cycle_number для связки open-close-flip +- добавлено логирование trade_opened / trade_closed / trade_flipped +- добавлено логирование запуска, наблюдения и остановки автоторговли +- добавлены фильтры экспорта журнала: all, auto, trades, errors, not_auto +- исправлен экспорт CSV/XLSX с фильтрами журнала +- снижено дублирование market stream / REST fallback событий +- снижено дублирование exchange/runtime ошибок +- добавлены human-readable event titles для новых событий +- улучшены уведомления AUTO_SIGNAL_READY +- добавлена цена входа по направлению сигнала: Ask для Long, Bid для Short +- добавлен контекст сигнала относительно открытой позиции +- удалены legacy trade/order handlers и order drafts +- вынесены auto runtime слои: lifecycle, signal, market, quality, semantic, health, intelligence, autonomous management +- добавлены exchange status/runtime UI helpers + --- ### 07.4.5 diff --git a/docs/stages/stage-07_4_4_1_13-auto_trade_runtime_journal_execution_refactor_and_trade_analytics.md b/docs/stages/stage-07_4_4_1_13-auto_trade_runtime_journal_execution_refactor_and_trade_analytics.md new file mode 100644 index 0000000..6aefb5b --- /dev/null +++ b/docs/stages/stage-07_4_4_1_13-auto_trade_runtime_journal_execution_refactor_and_trade_analytics.md @@ -0,0 +1,325 @@ +# 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics + +Статус + +## ✅ Реализовано + +Рекомендуемый commit message: + +```bash +git commit -m "07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics" +``` + +--- + +## Краткое описание этапа + +Этап посвящён глубокой переработке execution architecture, runtime journal analytics и переходу execution engine к modular runtime orchestration architecture. + +Главная цель этапа: + +* декомпозировать execution engine; +* реализовать полноценный trade lifecycle tracking; +* внедрить runtime trade analytics; +* улучшить observability execution layer; +* реализовать execution supervisor protection; +* внедрить adaptive runtime execution modules; +* подготовить execution layer к institutional-grade market orchestration; +* снизить шум runtime уведомлений; +* унифицировать runtime journal/export infrastructure. + +--- + +# Основные реализованные изменения + +## 1. Execution Engine Refactor + +ExecutionEngine был разбит на отдельные runtime execution modules. + +Добавлены отдельные mixin-модули: + +* execution calculations +* execution sizing +* execution pricing +* execution supervisor +* execution position actions +* execution flip logic +* execution runtime actions +* execution risk close +* execution position protection +* execution resets +* execution runtime synchronization + +Теперь execution architecture: + +* стала модульной; +* упростила дальнейшее развитие; +* уменьшила связность execution layer; +* позволила независимо развивать runtime risk modules; +* подготовила систему к HTF market orchestration. + +--- + +## 2. Position Open / Close / Flip Runtime Architecture + +Полностью переработан lifecycle позиции. + +Добавлены: + +* unified position open logic; +* unified close logic; +* unified flip execution pipeline; +* adaptive flip protection; +* flip cooldown; +* flip block runtime layer; +* execution side-aware pricing; +* runtime pnl synchronization. + +Теперь execution layer умеет: + +* безопасно разворачивать позиции; +* блокировать dangerous flips; +* предотвращать rapid flip spam; +* учитывать momentum conflict; +* учитывать holding duration; +* учитывать execution confidence. + +--- + +## 3. Trade Lifecycle Analytics + +Реализован полноценный trade lifecycle tracking. + +Добавлены: + +* trade_id +* trade_sequence +* trade_cycle_number +* current_trade_id +* current_trade_cycle_number + +Теперь каждая сделка имеет: + +* lifecycle identity; +* связь open → flip → close; +* cycle-aware tracking; +* runtime analytics continuity. + +--- + +## 4. Runtime Trade Journal Layer + +Реализован отдельный runtime trade analytics layer. + +Добавлены runtime journal events: + +* trade_opened +* trade_closed +* trade_flipped +* trade_position_size_changed + +Теперь journal способен: + +* анализировать сделки по lifecycle; +* связывать execution actions; +* анализировать flips; +* анализировать resize events; +* отслеживать pnl структуры. + +--- + +## 5. AutoTrade Lifecycle Runtime Layer + +Реализован отдельный auto lifecycle orchestration layer. + +Добавлены runtime lifecycle modules: + +* auto_lifecycle +* signal_runtime +* market_runtime +* execution_quality +* execution_semantic +* position_health +* position_intelligence +* autonomous_management + +Теперь AutoTrade architecture: + +* разделена по responsibility layers; +* стала значительно проще для расширения; +* получила runtime semantic orchestration; +* получила execution-aware lifecycle control. + +--- + +## 6. Execution Supervisor Protection Layer + +Существенно расширен supervisor runtime engine. + +Добавлены: + +* execution emergency halt; +* cooldown after loss; +* stale execution blocking; +* degraded market blocking; +* momentum/trend conflict blocking; +* execution supervisor journal events. + +Теперь execution supervisor умеет: + +* блокировать execution после серии убытков; +* предотвращать execution в degraded market; +* блокировать stale snapshots; +* останавливать dangerous runtime execution. + +--- + +## 7. Runtime Signal Notification Layer + +Полностью переработан runtime signal notification system. + +Добавлены: + +* side-aware entry price rendering; +* LONG Ask execution rendering; +* SHORT Bid execution rendering; +* position-aware signal context; +* aligned/opposite position detection; +* semantic runtime notification rendering. + +Теперь уведомления умеют: + +* показывать реальную execution entry price; +* отображать направление позиции; +* показывать conflict against open position; +* отображать semantic market reasoning. + +--- + +## 8. Runtime Exchange Status & UI Layer + +Добавлен unified runtime exchange status architecture. + +Добавлены: + +* runtime exchange UI helpers; +* exchange runtime status rendering; +* exchange degradation alerts; +* runtime availability synchronization. + +Теперь runtime UI: + +* показывает degradation state; +* отображает execution availability; +* умеет синхронизировать exchange runtime state; +* снижает UI noise. + +--- + +## 9. Journal Export & Filtering Layer + +Существенно расширен journal export engine. + +Добавлены фильтры: + +* all +* auto +* trades +* errors +* not_auto + +Теперь экспорт: + +* умеет фильтровать execution events; +* умеет экспортировать trade analytics; +* поддерживает runtime segmentation; +* поддерживает execution observability analysis. + +--- + +## 10. Runtime Event Observability + +Существенно расширена runtime observability architecture. + +Добавлены runtime events: + +* execution_supervisor_block +* paper_position_opened +* paper_position_closed +* paper_position_flipped +* paper_flip_blocked +* auto_signal_ready +* auto_position_aligned_signal_suppressed + +Теперь runtime layer: + +* полностью observability-aware; +* поддерживает semantic runtime analytics; +* поддерживает event-driven orchestration; +* поддерживает runtime notification synchronization. + +--- + +## 11. Runtime Noise Reduction + +Существенно снижено количество runtime spam событий. + +Добавлены: + +* dedupe runtime keys; +* supervisor block deduplication; +* aligned signal suppression; +* runtime notification deduplication; +* execution event throttling. + +Теперь runtime system: + +* генерирует меньше шума; +* уменьшает notification flooding; +* уменьшает journal duplication; +* улучшает observability readability. + +--- + +## 12. Legacy Cleanup & Architecture Simplification + +Удалены legacy runtime modules: + +* legacy order drafts; +* old trade handlers; +* obsolete monitoring handlers; +* deprecated trade flow modules; +* unused order runtime structures. + +Теперь architecture: + +* стала чище; +* уменьшила technical debt; +* сократила legacy execution code; +* упростила поддержку runtime engine. + +--- + +# Итог этапа + +После этапа: + +* execution engine стал modular runtime system; +* реализован полноценный trade lifecycle tracking; +* execution layer стал position-aware; +* journal получил runtime trade analytics; +* supervisor стал execution-aware; +* runtime notifications стали semantic-aware; +* execution observability существенно улучшена; +* runtime spam значительно снижен; +* architecture подготовлена к HTF market analysis layer. + +--- + +# Рекомендуемый commit + +```bash +git add . +git commit -m "07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics" +git push origin main +```