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
+```