diff --git a/app/src/core/config.py b/app/src/core/config.py index c0f1a99..7bc5a21 100644 --- a/app/src/core/config.py +++ b/app/src/core/config.py @@ -48,8 +48,9 @@ class Settings: db_user: str db_password: str - # Debag helper + # Debug helpers debug_enabled: bool + journal_debug_enabled: bool # helper: demo/live mode def is_demo_mode(self) -> bool: @@ -91,6 +92,9 @@ def load_settings() -> Settings: log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk", debug_enabled=_parse_bool(os.getenv("DEBUG_ENABLED", "false")), + journal_debug_enabled=_parse_bool( + os.getenv("JOURNAL_DEBUG_ENABLED", "false") + ), # Exchange exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")), @@ -101,8 +105,8 @@ def load_settings() -> Settings: exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), - default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() - or "BTC/USD_LEVERAGE", + default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip() + or "ETH/USD_LEVERAGE", # Database db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost", diff --git a/app/src/core/event_bus.py b/app/src/core/event_bus.py index 2dcb5ea..3fcf264 100644 --- a/app/src/core/event_bus.py +++ b/app/src/core/event_bus.py @@ -9,6 +9,8 @@ class EventBus: _version: int = 0 _last_event_type: str | None = None _last_payload: dict[str, Any] = {} + _events: list[tuple[int, str, dict[str, Any]]] = [] + _max_events: int = 100 # зафиксировать важное событие системы @classmethod @@ -17,6 +19,17 @@ class EventBus: cls._last_event_type = event_type cls._last_payload = payload or {} + cls._events.append( + ( + cls._version, + event_type, + dict(cls._last_payload), + ) + ) + + if len(cls._events) > cls._max_events: + cls._events = cls._events[-cls._max_events:] + # текущая версия событий @classmethod def version(cls) -> int: @@ -25,4 +38,13 @@ class EventBus: # последнее событие @classmethod def last_event(cls) -> tuple[str | None, dict[str, Any]]: - return cls._last_event_type, dict(cls._last_payload) \ No newline at end of file + return cls._last_event_type, dict(cls._last_payload) + + # события после указанной версии + @classmethod + def events_after(cls, version: int) -> list[tuple[int, str, dict[str, Any]]]: + return [ + (event_version, event_type, dict(payload)) + for event_version, event_type, payload in cls._events + if event_version > version + ] \ No newline at end of file diff --git a/app/src/core/event_titles.py b/app/src/core/event_titles.py index 954da60..14ec6e8 100644 --- a/app/src/core/event_titles.py +++ b/app/src/core/event_titles.py @@ -55,6 +55,7 @@ EVENT_TITLES = { "journal_export_xlsx_success": "Журнал", "journal_export_xlsx_error": "Журнал", "journal_cleared_old": "Журнал", + "journal_debug_changed": "Журнал", "system_open_requested": "Система", "system_open_alert": "Система", @@ -84,6 +85,8 @@ EVENT_TITLES = { "market_closed": "Автоторговля", "market_rest_fallback_available": "Автоторговля", "market_rest_fallback_unavailable": "Автоторговля", + + "ws_depth_alive": "WebSocket debug", } diff --git a/app/src/core/numbers.py b/app/src/core/numbers.py index 09dcb2e..6c5a4fc 100644 --- a/app/src/core/numbers.py +++ b/app/src/core/numbers.py @@ -1,11 +1,7 @@ # app/src/core/numbers.py -# src/core/numbers.py - from __future__ import annotations -from src.core.types import NumericLike - def safe_float( value: object, @@ -20,4 +16,36 @@ def safe_float( try: return float(str(value).strip()) except (TypeError, ValueError): - return default \ No newline at end of file + return default + + +def safe_round( + value: object, + digits: int, +) -> float | None: + """ + Безопасное округление. + + None -> None + любое число -> round(...) + """ + number = safe_float(value) + + if number is None: + return None + + return round(number, digits) + + +def get_value(value: object) -> object | None: + """ + Возвращает значение Enum или сам объект. + + Enum -> .value + None -> None + Остальные типы -> без изменений. + """ + if value is None: + return None + + return getattr(value, "value", value) \ 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 ad673cc..d492842 100644 --- a/app/src/integrations/exchange/market_data_runner.py +++ b/app/src/integrations/exchange/market_data_runner.py @@ -34,6 +34,8 @@ class MarketRuntimeContext: last_rest_state: str | None = None last_rest_error_key: str | None = None + last_ws_debug_logged_at: float = 0.0 + class MarketDataRunner: _runtimes: dict[str, MarketRuntimeContext] = {} @@ -45,6 +47,23 @@ class MarketDataRunner: # Состояние в UI может меняться чаще, но журнал не должен разрастаться. _runtime_log_cooldown_seconds = 300 + @classmethod + def get_runtime_state(cls, runtime_key: str = "default") -> dict[str, object]: + context = cls._runtimes.get(runtime_key) + + if context is None: + return { + "stream_state": None, + "stream_error": None, + "rest_state": None, + } + + return { + "stream_state": context.last_stream_state, + "stream_error": context.last_stream_error_key, + "rest_state": context.last_rest_state, + } + @classmethod def _can_log_runtime_event( cls, @@ -276,13 +295,33 @@ class MarketDataRunner: cache_symbol = cls._cache_symbol(symbol) ws_symbol = cls._ws_symbol(symbol) - payload_count = 0 + valid_payload_count = 0 + invalid_payload_count = 0 async for payload in ExchangeWebSocketClient().stream_depth( ws_symbol, interval_seconds=context.interval_seconds, ): - if payload_count == 0: + current_symbol = context.symbol_provider() + if current_symbol and current_symbol != symbol: + break + + best_bid = cls._extract_best_price(payload, "bids") + best_ask = cls._extract_best_price(payload, "asks") + + if best_bid is None or best_ask is None: + invalid_payload_count += 1 + + if invalid_payload_count >= 5: + raise RuntimeError( + "WebSocket depth stream does not contain valid bids/asks." + ) + + continue + + invalid_payload_count = 0 + + if valid_payload_count == 0: should_log_connected = ( context.last_stream_state != "CONNECTED" and cls._can_log_runtime_event( @@ -296,7 +335,6 @@ class MarketDataRunner: context.last_rest_error_key = None if should_log_connected: - cls._log_info( context, "market_stream_connected", @@ -305,22 +343,16 @@ class MarketDataRunner: "requested_symbol": symbol, "cache_symbol": cache_symbol, "ws_symbol": ws_symbol, + "bid_price": best_bid, + "ask_price": best_ask, "payload_keys": list(payload.keys()), - "payload_preview": cls._safe_payload_preview(payload), + "payload_preview": cls._safe_payload_preview( + cls._extract_depth_payload(payload) + ), }, ) - payload_count += 1 - - current_symbol = context.symbol_provider() - if current_symbol and current_symbol != symbol: - break - - best_bid = cls._extract_best_price(payload, "bids") - best_ask = cls._extract_best_price(payload, "asks") - - if best_bid is None or best_ask is None: - continue + valid_payload_count += 1 MarketPriceCache.set_price( symbol=cache_symbol, @@ -331,6 +363,16 @@ class MarketDataRunner: runtime_key=context.runtime_key, ) + cls._log_ws_depth_debug( + context=context, + symbol=symbol, + cache_symbol=cache_symbol, + ws_symbol=ws_symbol, + best_bid=best_bid, + best_ask=best_ask, + valid_payload_count=valid_payload_count, + ) + @classmethod async def _rest_fallback_once( cls, @@ -440,11 +482,7 @@ class MarketDataRunner: payload: JsonDict, side_key: str, ) -> float | None: - data = payload - - inner = payload.get("payload") - if isinstance(inner, dict): - data = inner + data = cls._extract_depth_payload(payload) values = data.get(side_key) @@ -467,6 +505,24 @@ class MarketDataRunner: return cls._positive_float(raw_price) return None + + @classmethod + def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict: + data: object = payload + + for key in ("payload", "Payload"): + if isinstance(data, dict) and isinstance(data.get(key), dict): + data = data.get(key) + + if isinstance(data, dict): + for key in ("payload", "Payload"): + nested = data.get(key) + if isinstance(nested, dict): + return nested + + return data + + return payload @classmethod def _positive_float(cls, value: NumericLike | None) -> float | None: @@ -504,6 +560,79 @@ class MarketDataRunner: return preview + @classmethod + def _log_ws_depth_debug( + cls, + *, + context: MarketRuntimeContext, + symbol: str, + cache_symbol: str, + ws_symbol: str, + best_bid: float, + best_ask: float, + valid_payload_count: int, + ) -> None: + now = time.monotonic() + + if now - context.last_ws_debug_logged_at < 60: + return + + context.last_ws_debug_logged_at = now + + cls._log_debug( + context, + "ws_depth_alive", + "WS depth поток активен.", + { + "symbol": symbol, + "cache_symbol": cache_symbol, + "ws_symbol": ws_symbol, + "runtime_key": context.runtime_key, + "bid_price": best_bid, + "ask_price": best_ask, + "spread_percent": cls._spread_percent(best_bid, best_ask), + "valid_payload_count": valid_payload_count, + "source": f"ws_depth:{context.runtime_key}", + }, + ) + + @classmethod + def _spread_percent(cls, bid_price: float, ask_price: float) -> float | None: + mid_price = (bid_price + ask_price) / 2 + + if mid_price <= 0: + return None + + return round(((ask_price - bid_price) / mid_price) * 100, 5) + + @classmethod + def _log_debug( + cls, + context: MarketRuntimeContext, + event_type: str, + message: str, + payload: JsonDict | None = None, + ) -> None: + try: + if context.screen: + JournalService().log_ui_debug( + event_type=event_type, + message=cls._message(context, message), + screen=context.screen, + action=context.action, + payload=cls._payload(context, payload), + ) + return + + JournalService().log_debug( + event_type, + cls._message(context, message), + cls._payload(context, payload), + ) + + except Exception: + pass + @classmethod def _message( cls, diff --git a/app/src/integrations/exchange/models.py b/app/src/integrations/exchange/models.py index bb4c396..f025665 100644 --- a/app/src/integrations/exchange/models.py +++ b/app/src/integrations/exchange/models.py @@ -132,4 +132,14 @@ class KlineBatch: symbol: str interval: str candles: list[Kline] - source: str \ No newline at end of file + source: str + +# Информация о торговой комиссии для инструмента. +@dataclass(slots=True) +class TradingFee: + symbol: str + name: str + fee_percent: float | None = None + overnight_long_rate: float | None = None + overnight_short_rate: float | None = None + overnight_fee_timestamp: int | None = None \ No newline at end of file diff --git a/app/src/integrations/exchange/private_client.py b/app/src/integrations/exchange/private_client.py index 33985d7..391a134 100644 --- a/app/src/integrations/exchange/private_client.py +++ b/app/src/integrations/exchange/private_client.py @@ -23,7 +23,7 @@ class ExchangePrivateClient: signed = self.auth.build_signed_params(params) return self.client.get_json( - "/api/v2/account", + "/api/v1/account", params=signed, headers=self.auth.build_headers(), ) diff --git a/app/src/integrations/exchange/service.py b/app/src/integrations/exchange/service.py index 9df4d71..d2cbf12 100644 --- a/app/src/integrations/exchange/service.py +++ b/app/src/integrations/exchange/service.py @@ -29,11 +29,13 @@ from src.integrations.exchange.models import ( SymbolValidationResult, TickerPrice, TimeSyncStatus, + TradingFee, ) from src.integrations.exchange.private_client import ExchangePrivateClient from src.integrations.exchange.rest_client import ExchangeRestClient from src.integrations.exchange.status import ( ExchangeRuntimeStatus, + build_market_stale_status, build_account_auth_status, build_exchange_error_status, build_invalid_symbol_status, @@ -97,11 +99,150 @@ class ExchangeService: symbol_info = validation.symbol_info - return build_market_status_from_symbol_status( + status = build_market_status_from_symbol_status( raw_status=getattr(symbol_info, "status", None), symbol=validation.normalized_symbol, ) + if not status.is_open: + return status + + try: + snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol) + except Exception: + return status + + age_seconds = safe_float(snapshot.get("age_seconds")) + + if age_seconds is not None and age_seconds > 60: + return build_market_stale_status( + symbol=validation.normalized_symbol, + age_seconds=age_seconds, + updated_at=str(snapshot.get("updated_at") or ""), + ) + + return status + + def _exchange_timestamp_age_seconds( + self, + raw_timestamp: NumericLike | None, + ) -> float | None: + timestamp = safe_float(raw_timestamp) + + if timestamp is None or timestamp <= 0: + return None + + try: + server_time_ms = self.get_exchange_server_time_ms() + return max(0.0, round((server_time_ms - int(timestamp)) / 1000, 3)) + except Exception: + local_time_ms = int(datetime.now(ZoneInfo("UTC")).timestamp() * 1000) + return max(0.0, round((local_time_ms - int(timestamp)) / 1000, 3)) + + def get_trading_fee(self, symbol: str | None = None) -> TradingFee: + symbol_to_use = symbol or self.settings.default_symbol + + if not self.settings.exchange_enabled: + return TradingFee( + symbol=symbol_to_use, + name=symbol_to_use, + fee_percent=0.0, + ) + + validation = self.validate_symbol(symbol_to_use) + if not validation.is_valid: + raise ExchangeError(validation.message) + + client = ExchangeRestClient() + + try: + payload = client.get_payload( + "/api/v1/tradingFees", + params={"symbol": validation.normalized_symbol}, + ) + except Exception as exc: + self._log_exchange_error( + endpoint="tradingFees", + exc=exc, + symbol=validation.normalized_symbol, + ) + raise ExchangeError(f"Не удалось получить комиссию: {exc}") from exc + + fee_items = self._extract_trading_fee_items(payload) + + for item in fee_items: + fee = self._parse_trading_fee_item(item) + if fee is not None and normalize_symbol(fee.symbol) == validation.normalized_symbol: + return fee + + raise ExchangeError( + f"Комиссия для символа '{validation.normalized_symbol}' не найдена." + ) + + def get_overnight_fee_countdown( + self, + symbol: str | None = None, + ) -> str | None: + try: + fee = self.get_trading_fee(symbol) + + timestamp = fee.overnight_fee_timestamp + + if timestamp is None or timestamp <= 0: + return None + + now_ms = self.get_exchange_server_time_ms() + + remaining_seconds = int( + max( + 0, + (timestamp - now_ms) / 1000, + ) + ) + + hours = remaining_seconds // 3600 + minutes = (remaining_seconds % 3600) // 60 + + return f"{hours}ч {minutes:02d}м" + + except Exception: + return None + + def _extract_trading_fee_items( + self, + payload: object, + ) -> list[object]: + + if isinstance(payload, list): + return payload + + if isinstance(payload, dict): + raw_payload = payload.get("payload") + + if isinstance(raw_payload, list): + return raw_payload + + return [] + + def _parse_trading_fee_item(self, item: object) -> TradingFee | None: + if not isinstance(item, dict): + return None + + overnight_rates = item.get("overnightRates") + if not isinstance(overnight_rates, dict): + overnight_rates = {} + + return TradingFee( + symbol=self._safe_str(item.get("symbol")), + name=self._safe_str(item.get("name")), + fee_percent=safe_float(item.get("fee")), + overnight_long_rate=safe_float(overnight_rates.get("longRate")), + overnight_short_rate=safe_float(overnight_rates.get("shortRate")), + overnight_fee_timestamp=int(safe_float(item.get("overnightFeeTimestamp")) or 0) + if item.get("overnightFeeTimestamp") is not None + else None, + ) + # Логировать info-событие биржи без падения основного сценария. def _log_info( self, @@ -321,7 +462,7 @@ class ExchangeService: if limit > 200: limit = 200 - if interval not in {"1m", "5m", "15m"}: + if interval not in {"1m", "5m", "15m", "1h"}: raise ExchangeError(f"Unsupported kline interval: {interval}") normalized_price_type = price_type.strip().lower() @@ -340,7 +481,7 @@ class ExchangeService: try: payload = client.get_payload( - "/api/v2/klines", + "/api/v1/klines", params={ "symbol": validation.normalized_symbol, "interval": interval, @@ -702,20 +843,27 @@ class ExchangeService: if cached_price is not None: age = cached_price.age_seconds() - return { - "symbol": cached_price.symbol, - "last_price": cached_price.price, - "bid_price": cached_price.bid_price or cached_price.price, - "ask_price": cached_price.ask_price or cached_price.price, - "updated_at": cached_price.updated_at, - "source": cached_price.source, - "runtime_key": cached_price.runtime_key, - "age_seconds": round(age, 3), - "is_fresh": age <= self._execution_cache_max_age_seconds, - } + if age <= self._execution_cache_max_age_seconds: + return { + "symbol": cached_price.symbol, + "last_price": cached_price.price, + "bid_price": cached_price.bid_price or cached_price.price, + "ask_price": cached_price.ask_price or cached_price.price, + "updated_at": cached_price.updated_at, + "source": cached_price.source, + "runtime_key": cached_price.runtime_key, + "age_seconds": round(age, 3), + "is_fresh": True, + } - snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol) + snapshot = self.refresh_market_snapshot_cache( + validation.normalized_symbol, + runtime_key=normalized_runtime_key, + ) snapshot["runtime_key"] = normalized_runtime_key + snapshot["age_seconds"] = 0.0 + snapshot["is_fresh"] = True + return snapshot # Получить snapshot, пригодный для execution layer. @@ -786,6 +934,8 @@ class ExchangeService: if last_price is None or bid_price is None or ask_price is None: raise ExchangeError("Market snapshot contains invalid execution prices.") + age_seconds = safe_float(snapshot.get("age_seconds")) + return ExecutionPriceSnapshot( symbol=str(snapshot["symbol"]), last_price=last_price, @@ -793,8 +943,8 @@ class ExchangeService: ask_price=ask_price, updated_at=str(snapshot["updated_at"]), source="rest_fallback", - is_fresh=True, - age_seconds=0.0, + is_fresh=bool(snapshot.get("is_fresh")), + age_seconds=age_seconds, ) # Получить свежий snapshot напрямую из REST API. @@ -822,7 +972,7 @@ class ExchangeService: try: payload = client.get_json( - "/api/v2/ticker/24hr", + "/api/v1/ticker/24hr", params={"symbol": validation.normalized_symbol}, ) except Exception as exc: @@ -848,6 +998,9 @@ class ExchangeService: ask_price = safe_float(payload.get("askPrice")) or last_price close_time = payload.get("closeTime") or payload.get("eventTime") + age_seconds = self._exchange_timestamp_age_seconds(close_time) + is_fresh = age_seconds is not None and age_seconds <= 60 + return { "symbol": validation.normalized_symbol, "last_price": last_price, @@ -855,8 +1008,8 @@ class ExchangeService: "ask_price": ask_price, "updated_at": self._format_exchange_time(close_time), "source": "fresh_rest", - "age_seconds": 0.0, - "is_fresh": True, + "age_seconds": age_seconds, + "is_fresh": is_fresh, } # Получить live-балансы аккаунта. @@ -916,7 +1069,7 @@ class ExchangeService: client = ExchangeRestClient() try: - payload = client.get_json("/api/v2/exchangeInfo") + payload = client.get_json("/api/v1/exchangeInfo") except Exception as exc: self._log_exchange_error( endpoint="exchangeInfo", @@ -1007,7 +1160,7 @@ class ExchangeService: return ExchangeSymbol( symbol=self._safe_str(item.get("symbol")), name=self._safe_str(item.get("name")), - status=self._safe_str(item.get("status"), "unknown"), + status=self._parse_exchange_symbol_status(item), 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")), @@ -1025,6 +1178,49 @@ class ExchangeService: return str(value).strip() + def _parse_exchange_symbol_status(self, item: dict[object, object]) -> str: + status = self._safe_str(item.get("status"), "unknown") + + false_flags = { + "isTradingAllowed", + "tradingAllowed", + "availableForTrading", + "isTradable", + "tradable", + "isMarketOpen", + "marketOpen", + "isOpen", + "enabled", + } + + for key in false_flags: + if key not in item: + continue + + value = item.get(key) + + if isinstance(value, bool) and not value: + return "NOT_TRADABLE" + + if str(value).strip().lower() in {"false", "0", "no", "disabled"}: + return "NOT_TRADABLE" + + for key in ("tradingMode", "tradeMode", "mode", "state"): + value = str(item.get(key) or "").strip().upper() + + if value in { + "NOT_TRADABLE", + "TRADING_DISABLED", + "MARKET_DISABLED", + "UNAVAILABLE_FOR_TRADING", + "CLOSE_ONLY", + "REDUCE_ONLY", + "VIEW_ONLY", + }: + return value + + return status + # Привести marketModes к list[str]. def _parse_market_modes(self, value: object) -> list[str]: if isinstance(value, list): @@ -1127,7 +1323,7 @@ class ExchangeService: ) def get_exchange_server_time_ms(self) -> int: - payload = ExchangeRestClient().get_json("/api/v2/time") + payload = ExchangeRestClient().get_json("/api/v1/time") inner = payload.get("payload") if isinstance(inner, dict): diff --git a/app/src/integrations/exchange/status.py b/app/src/integrations/exchange/status.py index d9e6e74..741ba0c 100644 --- a/app/src/integrations/exchange/status.py +++ b/app/src/integrations/exchange/status.py @@ -21,8 +21,6 @@ class ExchangeStatusCode(StrEnum): UNKNOWN = "UNKNOWN" -# app/src/integrations/exchange/status.py - @dataclass(slots=True) class ExchangeRuntimeStatus: code: ExchangeStatusCode @@ -55,6 +53,32 @@ class ExchangeRuntimeStatus: } +def build_market_stale_status( + *, + symbol: str, + age_seconds: float | None, + updated_at: str | None = None, +) -> ExchangeRuntimeStatus: + age_text = "неизвестно" if age_seconds is None else f"{age_seconds:.0f}с" + updated_text = f" Последнее обновление: {updated_at}." if updated_at else "" + + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.BREAK, + is_open=False, + is_available=True, + is_auth_ok=True, + title="Рынок закрыт", + message=( + f"Котировки по инструменту не обновляются. " + f"Возраст данных: {age_text}.{updated_text}" + ), + ui_line="⏸️ Рынок закрыт", + reason="market_data_stale", + raw_status="STALE_MARKET_DATA", + symbol=symbol, + ) + + # собрать статус mock-режима def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus: return ExchangeRuntimeStatus( @@ -94,6 +118,13 @@ BREAK_STATUSES = { "DISABLED", "SETTLING", "POST_ONLY", + "NOT_TRADABLE", + "TRADING_DISABLED", + "MARKET_DISABLED", + "UNAVAILABLE_FOR_TRADING", + "CLOSE_ONLY", + "REDUCE_ONLY", + "VIEW_ONLY", } @@ -119,15 +150,37 @@ def build_market_status_from_symbol_status( symbol=symbol, ) + if normalized_status in { + "NOT_TRADABLE", + "TRADING_DISABLED", + "MARKET_DISABLED", + "UNAVAILABLE_FOR_TRADING", + "CLOSE_ONLY", + "REDUCE_ONLY", + "VIEW_ONLY", + }: + return ExchangeRuntimeStatus( + code=ExchangeStatusCode.BREAK, + is_open=False, + is_available=True, + is_auth_ok=True, + title="Рынок недоступен", + message=f"Этот рынок недоступен для торговли: {symbol}.", + ui_line="⛔️ Рынок недоступен для торговли", + reason="market_not_tradable", + 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="⏸️ Перерыв на бирже", + title="Перерыв в торгах", + message=f"Торги по {symbol} временно остановлены.", + ui_line="⏸️ Перерыв в торгах", reason="market_break", raw_status=normalized_status, symbol=symbol, @@ -138,9 +191,12 @@ def build_market_status_from_symbol_status( is_open=False, is_available=True, is_auth_ok=True, - title="Статус рынка не определён", - message=f"Статус инструмента {symbol} не определён.", - ui_line="⏸️ Перерыв на бирже", + title="Статус торгов неизвестен", + message=( + f"Биржа вернула неизвестный статус инструмента" + f"{f': {normalized_status}' if normalized_status else ''}." + ), + ui_line="⚠️ Статус торгов неизвестен", reason="market_status_unknown", raw_status=normalized_status or None, symbol=symbol, diff --git a/app/src/integrations/exchange/ws_client.py b/app/src/integrations/exchange/ws_client.py index cae5e8e..8f65969 100644 --- a/app/src/integrations/exchange/ws_client.py +++ b/app/src/integrations/exchange/ws_client.py @@ -61,7 +61,7 @@ class ExchangeWebSocketClient: def _depth_request(self, symbol: str) -> JsonDict: return { "correlationId": str(uuid4()), - "destination": "/api/v2/depth", + "destination": "/api/v1/depth", "payload": { "limit": 5, "symbol": symbol, @@ -92,17 +92,30 @@ class ExchangeWebSocketClient: ) -> AsyncIterator[JsonDict]: interval = self._interval_seconds(interval_seconds) headers = self._headers() + timeout_count = 0 + max_timeouts = 3 async with websockets.connect( self.base_url, - additional_headers=headers, + extra_headers=headers, subprotocols=[Subprotocol("json")], ping_interval=20, open_timeout=self.settings.exchange_timeout_sec, ) as websocket: - while True: - request = self._depth_request(symbol) + last_ping_at = 0.0 + while True: + now = asyncio.get_running_loop().time() + + if now - last_ping_at >= 5.0: + pong = await websocket.ping() + await asyncio.wait_for( + pong, + timeout=self.settings.exchange_timeout_sec, + ) + last_ping_at = now + + request = self._depth_request(symbol) await websocket.send(json.dumps(request)) try: @@ -110,10 +123,19 @@ class ExchangeWebSocketClient: websocket.recv(), timeout=self.settings.exchange_timeout_sec, ) - except asyncio.TimeoutError: + except asyncio.TimeoutError as exc: + timeout_count += 1 + + if timeout_count >= max_timeouts: + raise RuntimeError( + "WebSocket depth stream timed out repeatedly." + ) from exc + await asyncio.sleep(interval) continue + timeout_count = 0 + if not isinstance(raw_message, (str, bytes)): await asyncio.sleep(interval) continue diff --git a/app/src/main.py b/app/src/main.py index aecec1b..3c71666 100644 --- a/app/src/main.py +++ b/app/src/main.py @@ -10,7 +10,7 @@ async def main() -> None: bot, dispatcher = create_app() # WebSocket stream временно отключён. - # Причина: Dzengi Swagger содержит wss:/api/v2/* endpoints, + # Причина: Dzengi Swagger содержит wss:/api/v1/* endpoints, # но runtime probe не нашёл endpoint с WebSocket Upgrade 101. # # Когда Dzengi подтвердит рабочий WS endpoint, diff --git a/app/src/notifications/templates/execution.py b/app/src/notifications/templates/execution.py index 5a896b2..3e297ac 100644 --- a/app/src/notifications/templates/execution.py +++ b/app/src/notifications/templates/execution.py @@ -2,10 +2,10 @@ from __future__ import annotations +from src.core.numbers import safe_float from src.notifications.models import NotificationMessage from src.runtime_events.event_types import RuntimeEventType from src.runtime_events.models import RuntimeEvent -from src.core.numbers import safe_float def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | None: @@ -17,7 +17,7 @@ def build_execution_notification(event: RuntimeEvent) -> NotificationMessage | N if event.event_type == RuntimeEventType.POSITION_FLIPPED: return _build_position_flipped(event) - + if event.event_type == RuntimeEventType.POSITION_FLIP_BLOCKED: return _build_flip_blocked(event) @@ -28,39 +28,46 @@ def _build_position_opened(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) - strategy = str(payload.get("strategy") or "—").title() side_raw = str(payload.get("side") or "—").upper() side = side_raw.title() + side_icon = _side_icon(side_raw) + leverage = _format_leverage(payload.get("leverage")) entry_price = _format_price(payload.get("entry_price")) size = _format_size(payload.get("size")) - confidence = float(payload.get("confidence") or 0.0) + + signal = str(payload.get("signal") or "—").upper() + confidence = safe_float(payload.get("confidence")) or 0.0 + repeat_count = int(safe_float(payload.get("repeat_count")) or 0) + priority = _alert_priority( confidence=confidence, - repeat_count=int(payload.get("repeat_count") or 0), + repeat_count=repeat_count, ) + semantic_lines = payload.get("semantic_lines") or [] - side_icon = "🟢" if side_raw == "LONG" else "🔴" - lines = [ - "🧾 Позиция открыта", - "", - f"{side_icon} {symbol} · {strategy} · {side} {leverage}", - f"Вход: ${entry_price}", - f"Размер: {size}", - f"Объём: {_format_notional(entry_price=payload.get('entry_price'), size=payload.get('size'))}", - "", - f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}", + f"🧾 Открытие · {symbol} {side_icon} {side}", + f"{_strength_bar(priority)} {_strength_label(priority)} · {confidence:.2f}", + f"Серия {signal} · ×{repeat_count}", ] - if semantic_lines: + if isinstance(semantic_lines, list): lines.extend( str(line).strip().rstrip(".") for line in semantic_lines if str(line).strip() ) + lines.extend( + [ + f"Цена входа · ${entry_price}", + f"Размер · {size}", + f"Плечо · {leverage}", + ] + ) + return NotificationMessage( title=event.title, text="\n".join(lines), @@ -73,14 +80,14 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) - side = str(payload.get("side") or "—").title() - leverage = _format_leverage(payload.get("leverage")) + side_raw = str(payload.get("side") or "—").upper() + side = side_raw.title() + side_icon = _side_icon(side_raw) entry_price = _format_price(payload.get("entry_price")) exit_price = _format_price(payload.get("exit_price")) - size = _format_size(payload.get("size")) - pnl_value = float(payload.get("pnl") or 0.0) + pnl_value = safe_float(payload.get("pnl")) or 0.0 pnl_text = _format_pnl_amount(pnl_value) risk_reason = _human_close_reason(payload.get("risk_reason")) @@ -89,20 +96,14 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage: pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток" lines = [ - "🧾 Сделка закрыта", - f"{pnl_icon} {pnl_label} · {pnl_text}", - "", - f"{symbol} · {side} {leverage}", - f"Вход: ${entry_price}", - f"Выход: ${exit_price}", - f"Размер: {size}", + f"💰 Закрытие · {symbol} {side_icon} {side}", + f"{pnl_label} {pnl_icon} {pnl_text}", + f"Вход · ${entry_price}", + f"Выход · ${exit_price}", ] if risk_reason: - lines.extend([ - "", - f"Закрытие по {risk_reason}", - ]) + lines.append(f"Причина · {risk_reason}") return NotificationMessage( title=event.title, @@ -112,95 +113,51 @@ def _build_position_closed(event: RuntimeEvent) -> NotificationMessage: ) -def _format_pnl_amount(value: float) -> str: - amount = f"$ {abs(value):,.2f}".replace(",", " ").rstrip("0").rstrip(".") - - if value > 0: - return f"+{amount}" - - if value < 0: - return f"−{amount}" - - return "$ 0" - - -def _human_close_reason(value: object) -> str: - mapping = { - "STOP_LOSS": "Stop Loss", - "TAKE_PROFIT": "Take Profit", - "MAX_LOSS": "Max Loss", - } - - return mapping.get(str(value or ""), "") - - def _build_position_flipped(event: RuntimeEvent) -> NotificationMessage: payload = event.payload symbol = _format_symbol(payload.get("symbol")) - strategy = str(payload.get("strategy") or "—").title() old_side_raw = str(payload.get("old_side") or "—").upper() - new_side_raw = str( - payload.get("new_side") or payload.get("side") or "—" - ).upper() + new_side_raw = str(payload.get("new_side") or payload.get("side") or "—").upper() old_side = old_side_raw.title() new_side = new_side_raw.title() - old_leverage = _format_leverage( - payload.get("old_leverage") - if payload.get("old_leverage") is not None - else payload.get("leverage") - ) - new_leverage = _format_leverage(payload.get("leverage")) + old_icon = _side_icon(old_side_raw) + new_icon = _side_icon(new_side_raw) - entry_price = _format_price(payload.get("entry_price")) exit_price = _format_price(payload.get("exit_price")) new_entry_price = _format_price(payload.get("new_entry_price")) - old_size = _format_size(payload.get("old_size")) - new_size = _format_size(payload.get("new_size")) - - pnl_value = float(payload.get("pnl") or 0.0) + pnl_value = safe_float(payload.get("pnl")) or 0.0 pnl_text = _format_pnl_amount(pnl_value) pnl_icon = "🟢" if pnl_value >= 0 else "🔴" pnl_label = "Прибыль" if pnl_value >= 0 else "Убыток" - old_icon = "🟢" if old_side_raw == "LONG" else "🔴" - new_icon = "🟢" if new_side_raw == "LONG" else "🔴" + signal = str(payload.get("signal") or "—").upper() + confidence = safe_float(payload.get("confidence")) or 0.0 + repeat_count = int(safe_float(payload.get("repeat_count")) or 0) - confidence = float(payload.get("confidence") or 0.0) - repeat_count = int(payload.get("repeat_count") or 0) priority = _alert_priority( confidence=confidence, repeat_count=repeat_count, ) + semantic_lines = payload.get("semantic_lines") or [] lines = [ - "🧾 Сделка развернута", - f"{pnl_label} {pnl_icon} {pnl_text}", - f"{symbol} · {strategy} {old_icon} {old_side} → {new_icon} {new_side}", + f"🔄 Разворот · {symbol} {old_icon} {old_side} → {new_icon} {new_side}", + f"{pnl_label} {pnl_icon} {pnl_text}", + f"Закрытие · ${exit_price}", + f"Новый вход · ${new_entry_price}", "", - f"Закрыта {old_side} {old_leverage}", - f"Вход: ${entry_price}", - f"Выход: ${exit_price}", - f"Размер: {old_size}", - "", - f"Открыта {new_side} {new_leverage}", - f"Вход: ${new_entry_price}", - f"Размер: {new_size}", - ( - "Объём: " - f"{_format_notional(entry_price=payload.get('new_entry_price'), size=payload.get('new_size'))}" - ), - "", - f"{_strength_bar(priority)} Сигнал {_strength_label(priority).lower()} · {confidence:.2f}", + f"{_strength_bar(priority)} {_strength_label(priority)} · {confidence:.2f}", + f"Серия {signal} · ×{repeat_count}", ] - if semantic_lines: + if isinstance(semantic_lines, list): lines.extend( str(line).strip().rstrip(".") for line in semantic_lines @@ -220,20 +177,25 @@ def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage: symbol = _format_symbol(payload.get("symbol")) signal = str(payload.get("signal") or "").upper() - confidence = float(payload.get("confidence") or 0.0) + confidence = safe_float(payload.get("confidence")) or 0.0 reason = str(payload.get("reason") or "Flip заблокирован") position_side = str(payload.get("position_side") or "—").title() - target_side = "Long" if signal == "BUY" else "Short" if signal == "SELL" else "—" - icon = "🟢" if target_side == "LONG" else "🔴" if target_side == "SHORT" else "" + if signal == "BUY": + target_side = "Long" + icon = "🟢" + elif signal == "SELL": + target_side = "Short" + icon = "🔴" + else: + target_side = "—" + icon = "⚪️" text = ( - f"⚠️ Flip отменён\n\n" - f"{icon} {symbol} · {target_side}\n" - f"Текущая позиция: {position_side}\n\n" - f"Недостаточно условий для разворота\n" - f"{reason}\n" - f"Сила сигнала: {confidence:.2f}" + f"Flip отменён {symbol} {icon} {target_side}\n\n" + f"Текущая позиция · {position_side}\n" + f"Сила сигнала · {confidence:.2f}\n" + f"Причина · {reason}" ) return NotificationMessage( @@ -244,6 +206,58 @@ def _build_flip_blocked(event: RuntimeEvent) -> NotificationMessage: ) +def _side_icon(side: str) -> str: + normalized = str(side or "").upper() + + if normalized == "LONG": + return "🟢" + + if normalized == "SHORT": + return "🔴" + + return "⚪️" + + +def _format_pnl_amount(value: float) -> str: + amount = f"$ {abs(value):,.2f}".replace(",", " ").rstrip("0").rstrip(".") + + if value > 0: + return f"+{amount}" + + if value < 0: + return f"−{amount}" + + return "$ 0" + + +def _human_close_reason(value: object) -> str: + mapping = { + "STOP_LOSS": "Stop Loss", + "TAKE_PROFIT": "Take Profit", + "MAX_LOSS": "Max Loss", + "AUTONOMOUS_EXIT": "Autonomous Exit", + "TRAILING_STOP": "Trailing Stop", + "PROFIT_LOCK": "Profit Lock", + "BREAK_EVEN": "Break Even", + "LIFECYCLE_EXIT": "Lifecycle Exit", + "CONVICTION_BROKEN": "Conviction Broken", + "FATIGUE_EXIT": "Fatigue Exit", + "MOMENTUM_EXIT": "Momentum Exit", + "DEGRADATION_EXIT": "Degradation Exit", + "GIVEBACK_PROTECTION": "Giveback Protection", + "GIVEBACK_MOMENTUM_REVERSAL": "Giveback Momentum Reversal", + "GIVEBACK_FATIGUE_EXIT": "Giveback Fatigue Exit", + "GIVEBACK_REVERSAL_RISK": "Giveback Reversal Risk", + "TIME_DECAY_EXIT": "Time Decay", + "TIME_DECAY_FATIGUE_EXIT": "Time Decay Fatigue", + "TIME_DECAY_ADVERSE_MOMENTUM": "Time Decay Momentum", + "TIME_DECAY_DEGRADED_MARKET": "Time Decay Market", + "TIME_DECAY_CONTEXT_DECAY": "Time Decay Context", + } + + return mapping.get(str(value or ""), "") + + def _format_symbol(value: object) -> str: symbol = str(value or "—") @@ -296,6 +310,7 @@ def _strength_label(priority: str) -> str: "MEDIUM": "Средний", "LOW": "Слабый", } + return mapping.get(priority.upper(), priority) @@ -305,20 +320,5 @@ def _strength_bar(priority: str) -> str: "MEDIUM": "●●○", "LOW": "●○○", } - return mapping.get(priority.upper(), "●○○") - -def _format_notional( - *, - entry_price: object, - size: object, -) -> str: - entry = safe_float(entry_price) - amount = safe_float(size) - - if entry is None or amount is None: - return "—" - - value = entry * amount - - return f"$ {value:,.2f}".replace(",", " ").rstrip("0").rstrip(".") \ No newline at end of file + return mapping.get(priority.upper(), "●○○") \ No newline at end of file diff --git a/app/src/notifications/templates/signal.py b/app/src/notifications/templates/signal.py index 211f1f3..597041a 100644 --- a/app/src/notifications/templates/signal.py +++ b/app/src/notifications/templates/signal.py @@ -38,9 +38,27 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None strength_bar = _strength_bar(priority) lines = [ - f"Сигнал {icon} {symbol} · {direction}", + f"⚡️ Сигнал · {symbol} {icon} {direction}", + f"{strength_bar} {strength} · {confidence:.2f}", + f"Серия {signal} · ×{repeat_count}", ] + if semantic_lines: + lines.extend( + str(line).strip().rstrip(".") + for line in semantic_lines + if str(line).strip() + ) + + price_lines = _market_price_lines( + direction=direction_key, + bid_price=payload.get("bid_price"), + ask_price=payload.get("ask_price"), + ) + + if price_lines: + lines.extend(price_lines) + position_line = _position_context_line( signal=signal, position_context=position_context, @@ -49,27 +67,9 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None if position_line: lines.append(position_line) - 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( - str(line).strip().rstrip(".") - for line in semantic_lines - if str(line).strip() - ) + block_lines = _execution_block_lines(payload) + if block_lines: + lines.extend(["", *block_lines]) return NotificationMessage( title=event.title, @@ -79,6 +79,25 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None ) +def _execution_block_lines(payload: JsonDict) -> list[str]: + title = str(payload.get("execution_block_title") or "").strip() + message = str(payload.get("execution_block_message") or "").strip() + action = str(payload.get("execution_block_action") or "").strip() + + if not title or not message: + return [] + + lines = [ + f"⛔ {title}", + message, + ] + + if action: + lines.append(action) + + return lines + + def _position_context_line( *, signal: str, @@ -111,25 +130,13 @@ def _market_price_lines( bid = _format_price_usd(bid_price) ask = _format_price_usd(ask_price) - if bid == "—" and ask == "—": - return [] + if direction == "LONG" and ask != "—": + return [f"Цена входа · {ask} (Ask)"] - if direction == "LONG": - return [ - f"Цена входа Long · {ask} (Ask)", - f"Цена Bid · {bid}", - ] + if direction == "SHORT" and bid != "—": + return [f"Цена входа · {bid} (Bid)"] - if direction == "SHORT": - return [ - f"Цена входа Short · {bid} (Bid)", - f"Цена Ask · {ask}", - ] - - return [ - f"Цена Bid · {bid}", - f"Цена Ask · {ask}", - ] + return [] def _format_price_usd(value: NumericLike | None) -> str: @@ -198,17 +205,8 @@ def _format_symbol(symbol: str) -> str: return symbol.split("_", 1)[0].split("/", 1)[0].upper() -def _format_price(value: NumericLike | None) -> str: - number = safe_float(value) - - if number is None: - return "—" - - return f"{number:,.2f}".replace(",", " ") - - def _dedupe_key(payload: JsonDict) -> str: - confidence = safe_float(payload.get("confidence")) or 0.0 + is_aligned_signal = bool(payload.get("is_position_aligned_signal")) return ( f"auto_signal_ready:" @@ -216,10 +214,8 @@ def _dedupe_key(payload: JsonDict) -> str: f"{payload.get('symbol')}:" f"{payload.get('strategy')}:" f"{payload.get('signal')}:" - f"{payload.get('repeat_count')}:" - f"{confidence:.2f}:" f"{payload.get('decision_status')}:" - f"{payload.get('reason')}" + f"aligned={is_aligned_signal}" ) diff --git a/app/src/telegram/handlers/auto/main.py b/app/src/telegram/handlers/auto/main.py index 9ee8889..523db2d 100644 --- a/app/src/telegram/handlers/auto/main.py +++ b/app/src/telegram/handlers/auto/main.py @@ -3,7 +3,11 @@ from __future__ import annotations from aiogram import F, Router -from aiogram.exceptions import TelegramBadRequest +from aiogram.exceptions import ( + TelegramBadRequest, + TelegramNetworkError, + TelegramRetryAfter, +) from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InaccessibleMessage, Message @@ -39,6 +43,50 @@ def _require_message( return message +async def _safe_edit_text( + message: Message, + text: str, + *, + reply_markup, +) -> bool: + try: + await message.edit_text( + text, + reply_markup=reply_markup, + ) + return True + + except TelegramBadRequest as exc: + if "message is not modified" in str(exc).lower(): + return True + raise + + except TelegramRetryAfter: + return False + + except TelegramNetworkError: + return False + + +async def _safe_answer( + message: Message, + text: str, + *, + reply_markup, +) -> Message | None: + try: + return await message.answer( + text, + reply_markup=reply_markup, + ) + + except TelegramRetryAfter: + return None + + except TelegramNetworkError: + return None + + async def render_auto_screen( target_message: Message, *, @@ -47,32 +95,42 @@ async def render_auto_screen( text = build_auto_text() if edit_mode: - try: - await target_message.edit_text(text, reply_markup=auto_keyboard()) - except TelegramBadRequest as exc: - if "message is not modified" not in str(exc).lower(): - raise + async with AutoTradeRunner.edit_lock(): + bot = target_message.bot - bot = target_message.bot + if bot is not None: + AutoTradeRunner.register_screen( + bot=bot, + chat_id=target_message.chat.id, + message_id=target_message.message_id, + render_text=build_auto_text, + render_markup=auto_keyboard, + ) - if bot is None: - return + ActiveScreenManager.register( + screen="auto", + message=target_message, + ) - AutoTradeRunner.register_screen( - bot=bot, - chat_id=target_message.chat.id, - message_id=target_message.message_id, - render_text=build_auto_text, - render_markup=auto_keyboard, - ) + updated = await _safe_edit_text( + target_message, + text, + reply_markup=auto_keyboard(), + ) + + if not updated: + return - ActiveScreenManager.register( - screen="auto", - message=target_message, - ) return - sent_message = await target_message.answer(text, reply_markup=auto_keyboard()) + sent_message = await _safe_answer( + target_message, + text, + reply_markup=auto_keyboard(), + ) + + if sent_message is None: + return bot = sent_message.bot if bot is None: @@ -147,61 +205,64 @@ async def render_auto_diagnostics_screen( ) -> None: text = build_auto_diagnostics_text() - try: - await target_message.edit_text( - text, - reply_markup=auto_diagnostics_keyboard(), - ) - except TelegramBadRequest as exc: - error_text = str(exc).lower() - - if "message to edit not found" in error_text: - sent_message = await target_message.answer( - text, - reply_markup=auto_diagnostics_keyboard(), - ) - - bot = sent_message.bot - - if bot is None: - return + async with AutoTradeRunner.edit_lock(): + bot = target_message.bot + if bot is not None: AutoTradeRunner.register_screen( bot=bot, - chat_id=sent_message.chat.id, - message_id=sent_message.message_id, + chat_id=target_message.chat.id, + message_id=target_message.message_id, render_text=build_auto_diagnostics_text, render_markup=auto_diagnostics_keyboard, ) ActiveScreenManager.register( screen="auto_diagnostics", - message=sent_message, + message=target_message, ) - return - if "message is not modified" in error_text: - return + try: + await target_message.edit_text( + text, + reply_markup=auto_diagnostics_keyboard(), + ) + except TelegramBadRequest as exc: + error_text = str(exc).lower() - raise + if "message to edit not found" in error_text: + sent_message = await _safe_answer( + target_message, + text, + reply_markup=auto_diagnostics_keyboard(), + ) - bot = target_message.bot + if sent_message is None: + return - if bot is None: - return + bot = sent_message.bot - AutoTradeRunner.register_screen( - bot=bot, - chat_id=target_message.chat.id, - message_id=target_message.message_id, - render_text=build_auto_diagnostics_text, - render_markup=auto_diagnostics_keyboard, - ) + if bot is None: + return - ActiveScreenManager.register( - screen="auto_diagnostics", - message=target_message, - ) + AutoTradeRunner.register_screen( + bot=bot, + chat_id=sent_message.chat.id, + message_id=sent_message.message_id, + render_text=build_auto_diagnostics_text, + render_markup=auto_diagnostics_keyboard, + ) + + ActiveScreenManager.register( + screen="auto_diagnostics", + message=sent_message, + ) + return + + if "message is not modified" in error_text: + return + + raise @router.message(F.text.in_({"🤖 Автоторговля", "🤖 Авто"})) @@ -333,6 +394,16 @@ async def auto_stop(callback: CallbackQuery) -> None: @router.callback_query(F.data == "auto:diagnostics") async def open_auto_diagnostics(callback: CallbackQuery) -> None: + service = AutoTradeService() + state = service.get_state() + + if str(state.status or "").upper() not in {"RUNNING", "OBSERVING"}: + await callback.answer( + "Диагностика доступна только после запуска или в режиме наблюдения", + show_alert=True, + ) + return + message = _require_message(callback) if message is None: diff --git a/app/src/telegram/handlers/auto/risk.py b/app/src/telegram/handlers/auto/risk.py index 2352bdd..f84db6a 100644 --- a/app/src/telegram/handlers/auto/risk.py +++ b/app/src/telegram/handlers/auto/risk.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio - from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup @@ -93,7 +91,7 @@ def _risk_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -def _risk_text(status_message: str | None = None) -> str: +def _risk_text() -> str: state = AutoTradeService().get_state() active_count = sum( @@ -107,7 +105,7 @@ def _risk_text(status_message: str | None = None) -> str: status = "🟢 Активна" if active_count else "⚪ Выключена" - text = ( + return ( "🧯 Защита позиции\n\n" "СИСТЕМА · Настройки · Автоторговля\n\n" f"Статус защиты: {status}\n" @@ -117,11 +115,6 @@ def _risk_text(status_message: str | None = None) -> str: f"{_rule_icon(state.max_loss_usd)} Max Loss · {_format_usd(state.max_loss_usd)}\n" ) - if status_message: - text += f"\n\n{status_message}" - - return text - async def _render_risk_screen( callback: CallbackQuery, @@ -151,8 +144,6 @@ async def _render_risk_screen_by_message( message: Message, *, state: FSMContext, - status_message: str | None = None, - auto_clear: bool = False, ) -> None: AutoTradeRunner.set_current_screen("auto_risk") @@ -166,46 +157,20 @@ async def _render_risk_screen_by_message( raw_chat_id = data.get("risk_chat_id") raw_message_id = data.get("risk_message_id") - if not isinstance(raw_chat_id, int): + if not isinstance(raw_chat_id, int) or not isinstance(raw_message_id, int): await message.answer( - _risk_text(status_message=status_message), + _risk_text(), reply_markup=_risk_keyboard(), ) return - if not isinstance(raw_message_id, int): - await message.answer( - _risk_text(status_message=status_message), - reply_markup=_risk_keyboard(), - ) - return - - chat_id = raw_chat_id - message_id = raw_message_id - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=_risk_text(status_message=status_message), + chat_id=raw_chat_id, + message_id=raw_message_id, + text=_risk_text(), reply_markup=_risk_keyboard(), ) - if status_message and auto_clear: - await asyncio.sleep(2.5) - - if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk": - return - - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=_risk_text(), - reply_markup=_risk_keyboard(), - ) - except Exception: - pass - async def _remember_risk_screen( callback: CallbackQuery, @@ -426,24 +391,11 @@ async def reset_risk(callback: CallbackQuery, state: FSMContext) -> None: _log_risk_updated("risk_reset") await message.edit_text( - _risk_text(status_message="✅ Risk Controls сброшены"), + _risk_text(), reply_markup=_risk_keyboard(), ) - await callback.answer() - - await asyncio.sleep(2.5) - - if getattr(AutoTradeRunner, "_current_screen", None) != "auto_risk": - return - - try: - await message.edit_text( - _risk_text(), - reply_markup=_risk_keyboard(), - ) - except Exception: - pass + await callback.answer("Risk Controls сброшены") @router.message(AutoRiskStates.waiting_stop_loss) @@ -464,8 +416,6 @@ async def set_stop_loss(message: Message, state: FSMContext) -> None: await _render_risk_screen_by_message( message, state=state, - status_message=f"✅ Stop Loss обновлён: {_format_percent(value)}", - auto_clear=True, ) await state.clear() @@ -488,8 +438,6 @@ async def set_take_profit(message: Message, state: FSMContext) -> None: await _render_risk_screen_by_message( message, state=state, - status_message=f"✅ Take Profit обновлён: {_format_percent(value)}", - auto_clear=True, ) await state.clear() @@ -512,7 +460,5 @@ async def set_max_loss(message: Message, state: FSMContext) -> None: await _render_risk_screen_by_message( message, state=state, - status_message=f"✅ Max Loss обновлён: {_format_usd(value)}", - auto_clear=True, ) await state.clear() \ No newline at end of file diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py index 5dce4c9..28d322b 100644 --- a/app/src/telegram/handlers/auto/ui.py +++ b/app/src/telegram/handlers/auto/ui.py @@ -72,6 +72,12 @@ def _build_signal_notification_text(state, signal: str) -> str: _signal_strength_line(confidence), ] + # Общая оценка рынка на момент сигнала. + # Это не факт входа, а качество рыночного контекста 0..100. + market_score_line = _market_score_notification_line(state) + if market_score_line: + lines.append(market_score_line) + compact_reason = _notification_signal_reason(reason) if compact_reason: lines.append(compact_reason) @@ -89,6 +95,26 @@ def _price_from_snapshot( return safe_float(snapshot.get(key)) +def _position_current_price(state) -> float | None: + snapshot = _market_snapshot(getattr(state, "symbol", None)) + + if snapshot is not None: + side = str(getattr(state, "position_side", "") or "").upper() + + if side == "LONG": + price = snapshot.get("bid_price") or snapshot.get("last_price") + elif side == "SHORT": + price = snapshot.get("ask_price") or snapshot.get("last_price") + else: + price = snapshot.get("last_price") + + parsed = safe_float(price) + if parsed is not None: + return parsed + + return _current_price(getattr(state, "symbol", None)) + + def _signal_strength_line(confidence: float) -> str: filled = min(3, max(0, round(confidence * 3))) bar = "●" * filled + "○" * (3 - filled) @@ -128,6 +154,7 @@ def auto_keyboard() -> InlineKeyboardMarkup: status = (state.status or "").upper() block_reason = _auto_block_reason() + diagnostics_available = status in {"RUNNING", "OBSERVING"} if status == "OFF": if block_reason: @@ -156,9 +183,14 @@ def auto_keyboard() -> InlineKeyboardMarkup: builder.button(text="🛠️ Настройки", callback_data="settings:auto") builder.button(text="🧯 Защита", callback_data="auto:risk") - builder.button(text="🔬 Диагностика", callback_data="auto:diagnostics") - builder.adjust(2, 2, 1) + if diagnostics_available: + builder.button(text="📊 Анализ рынка", callback_data="auto:diagnostics") + builder.adjust(2, 2, 1) + elif status in {"OFF", "RUNNING", "OBSERVING"}: + builder.adjust(2, 2) + else: + builder.adjust(2, 2, 1) return builder.as_markup() @@ -347,21 +379,19 @@ def _build_waiting_text(state) -> str: _append_auto_block_reason(parts, state) + execution_block_lines = _execution_block_lines(state) + if execution_block_lines: + parts.extend(["", *execution_block_lines]) + parts.extend([ "", f"Доступно 💰 {_format_money_compact(available)}", ]) - if cycle_trades > 0: - parts.extend([ - "", - f"🔄 {_cycle_number_text(state)} · {cycle_trades} {_trade_word(cycle_trades)}", - _format_pnl_line(cycle_pnl), - ]) - - winrate_line = _cycle_winrate_line(state, cycle_pnl, cycle_trades) - if winrate_line: - parts.append(winrate_line) + parts.extend([ + "", + *_cycle_summary_lines(state), + ]) parts.extend([ "", @@ -381,6 +411,14 @@ def _build_waiting_text(state) -> str: else "Подготовка ордера 🧾" ) + notional = ( + estimated_size * price + if estimated_size is not None and price is not None and price > 0 + else None + ) + + commission_lines = _commission_lines_for_order(state, notional) + order_lines = [ "", block_title, @@ -388,9 +426,19 @@ def _build_waiting_text(state) -> str: f"Цена · {_format_plain_or_dash(price)}", _estimated_size_text(state, price), _max_reserved_line(state, price), - _effective_risk_line(state), ] + if commission_lines: + order_lines.extend([ + "", + *commission_lines, + "", + ]) + + order_lines.extend([ + _effective_risk_line(state), + ]) + execution_confidence_line = _execution_confidence_line(state) if execution_confidence_line: order_lines.append(execution_confidence_line) @@ -432,43 +480,52 @@ def _execution_runtime_line(state) -> str: getattr(state, "execution_quality_reason", "") or "" ).upper() - freshness = _execution_freshness_text(state) + market_status_message = str( + getattr(state, "market_status_message", "") or "" + ).strip() if quality == "GOOD": return "" - if quality == "WARNING": - if reason == "WIDE_SPREAD": - return f"Исполнение ⚠️ Повышенный spread · {freshness}" + if reason in { + "MARKET_BREAK", + "MARKET_CLOSED", + "EXCHANGE_UNAVAILABLE", + "AUTH_ERROR", + "TIME_ERROR", + "INVALID_SYMBOL", + }: + return market_status_message or "⏸️ Перерыв в торгах" + if reason == "MARKET_STATUS_UNKNOWN": + return market_status_message or "⚠️ Статус торгов неизвестен" + + if quality == "WARNING": if reason == "AGING_SNAPSHOT": - return f"Исполнение ⚠️ Snapshot стареет · {freshness}" + return "⚠️ Котировки обновляются с задержкой" + + if reason == "WIDE_SPREAD": + return "⚠️ Повышенный spread" if reason == "SNAPSHOT_UNAVAILABLE": - return f"Исполнение ⚠️ Нет стакана · {freshness}" + return "⚠️ Нет данных стакана" - return f"Исполнение ⚠️ Предупреждение · {freshness}" + return "⚠️ Предупреждение" if quality == "BLOCKED": - if reason == "MARKET_CLOSED": - return "Исполнение ⏸️ Рынок закрыт" - - if reason == "STALE_SNAPSHOT": - return f"Исполнение 🔴 Snapshot устарел · {freshness}" + if reason in {"STALE_SNAPSHOT", "SNAPSHOT_ERROR"}: + return "⛔️ Нет актуальных котировок" if reason == "HIGH_SPREAD": - return f"Исполнение 🔴 Высокий spread · {freshness}" + return "⛔️ Высокий spread" - if reason == "SNAPSHOT_ERROR": - return "Исполнение 🔴 Нет данных рынка" - - return f"Исполнение 🔴 Заблокировано · {freshness}" + return "⛔️ Вход заблокирован" return "" def _build_active_position_text(state) -> str: - current_price = _current_price(state.symbol) + current_price = _position_current_price(state) price_for_calc = current_price or state.entry_price or 0.0 size = state.position_size or 0.0 @@ -509,25 +566,20 @@ def _build_active_position_text(state) -> str: _append_auto_block_reason(parts, state) + execution_block_lines = _execution_block_lines(state) + if execution_block_lines: + parts.extend(["", *execution_block_lines]) + parts.extend([ "", f"Доступно 💰 {_format_money_compact(available)}", f"Маржа · {_format_usd_compact(reserved)}", ]) - if cycle_trades > 0: - parts.extend([ - "", - ( - f"🔄 {_cycle_number_text(state)} · " - f"{cycle_trades} {_trade_word(cycle_trades)}" - ), - _format_pnl_line(cycle_pnl), - ]) - - winrate_line = _cycle_winrate_line(state, cycle_pnl, cycle_trades) - if winrate_line: - parts.append(winrate_line) + parts.extend([ + "", + *_cycle_summary_lines(state), + ]) separator = " " if adaptive_warning else " · " @@ -548,8 +600,18 @@ def _build_active_position_text(state) -> str: ), f"Объём · {_format_usd_compact(notional)}", _format_pnl_line(pnl), + ]) + commission_lines = _commission_lines_for_position(state, notional) + + if commission_lines: + parts.extend([ + "", + *commission_lines, + "", + ]) + execution_runtime_line = _execution_runtime_line(state) if execution_runtime_line: parts.append(execution_runtime_line) @@ -606,6 +668,11 @@ def _compact_entry_block_message(message: str) -> str: "мало live-данных": "Мало данных", "высокая волатильность": "Высокая волатильность", "низкая активность": "Низкая активность", + "market_structure_conflict": "Структура против входа", + "market_structure_mixed": "Структура не подтверждает вход", + "структура рынка против входа": "Структура против входа", + "структура рынка не подтверждает вход": "Структура не подтверждает вход", + "counter_trend_breakout": "Пробой против тренда", } result = mapping.get(normalized, message) @@ -661,6 +728,182 @@ def _market_snapshot(symbol: str | None) -> dict[str, object] | None: return ExchangeService().get_market_snapshot(symbol, runtime_key="auto") except Exception: return None + + +def _trading_fee(symbol: str | None): + if not symbol: + return None + + try: + return ExchangeService().get_trading_fee(symbol) + except Exception: + return None + + +def _trade_fee_rt_usd(symbol: str | None, notional: float | None) -> float | None: + if notional is None or notional <= 0: + return None + + fee = _trading_fee(symbol) + if fee is None or fee.fee_percent is None: + return None + + return abs(notional * (fee.fee_percent / 100) * 2) + + +def _overnight_period_seconds(symbol: str | None) -> int: + normalized = str(symbol or "").upper() + + if normalized.startswith("BTC/") or normalized.startswith("ETH/"): + return 8 * 60 * 60 + + return 24 * 60 * 60 + + +def _overnight_rate_for_side(fee, side: str | None) -> float | None: + normalized_side = str(side or "").upper() + + if normalized_side in {"LONG", "BUY"}: + return safe_float(fee.overnight_long_rate) + + if normalized_side in {"SHORT", "SELL"}: + return safe_float(fee.overnight_short_rate) + + return None + + +def _signed_usd_compact(value: float | None) -> str: + if value is None: + return "$ —" + + if value > 0: + return f"+{_format_usd_compact(value)}" + + if value < 0: + return f"-{_format_usd_compact(abs(value))}" + + return _format_usd_compact(0) + + +def _predicted_overnight_fee_both_sides_lines( + symbol: str | None, + notional: float | None, +) -> list[str]: + if notional is None or notional <= 0: + return [] + + fee = _trading_fee(symbol) + if fee is None: + return [] + + lines: list[str] = [] + + long_rate = safe_float(fee.overnight_long_rate) + short_rate = safe_float(fee.overnight_short_rate) + + if long_rate is not None: + long_value = notional * (long_rate / 100) + lines.append(f" · Левередж Long · {_signed_usd_compact(long_value)}") + + if short_rate is not None: + short_value = notional * (short_rate / 100) + lines.append(f" · Левередж Short · {_signed_usd_compact(short_value)}") + + return lines + + +def _position_overnight_fee_usd( + state, + notional: float | None, +) -> tuple[float | None, int]: + if notional is None or notional <= 0: + return None, 0 + + fee = _trading_fee(state.symbol) + if fee is None: + return None, 0 + + rate = _overnight_rate_for_side(fee, state.position_side) + if rate is None: + return None, 0 + + hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) + + if hold_seconds is None: + opened_at = safe_float(getattr(state, "position_opened_monotonic_at", None)) + if opened_at is not None: + hold_seconds = max(0, time.monotonic() - opened_at) + + if hold_seconds is None: + return 0.0, 0 + + period_seconds = _overnight_period_seconds(state.symbol) + overnight_count = int(hold_seconds // period_seconds) + + return notional * (rate / 100) * overnight_count, overnight_count + + +def _commission_lines_for_order( + state, + notional: float | None, +) -> list[str]: + trade_fee = _trade_fee_rt_usd(state.symbol, notional) + + leverage = safe_float(getattr(state, "leverage", None)) or 1.0 + + if leverage <= 1: + leverage_fees: list[str] = [] + else: + leverage_fees = _predicted_overnight_fee_both_sides_lines( + state.symbol, + notional, + ) + + if trade_fee is None and not leverage_fees: + return [] + + lines = ["Комиссии:"] + + if trade_fee is not None: + lines.append(f" · Сделка (RT) · {_format_usd_compact(trade_fee)}") + + lines.extend(leverage_fees) + + return lines + + +def _commission_lines_for_position( + state, + notional: float | None, +) -> list[str]: + trade_fee = _trade_fee_rt_usd(state.symbol, notional) + + leverage = safe_float(getattr(state, "leverage", None)) or 1.0 + + if leverage <= 1: + leverage_fee = None + overnight_count = 0 + else: + leverage_fee, overnight_count = _position_overnight_fee_usd(state, notional) + + if trade_fee is None and leverage_fee is None: + return [] + + lines = ["Комиссии:"] + + if trade_fee is not None: + lines.append(f" · Сделка (RT) · {_format_usd_compact(trade_fee)}") + + # Показываем комиссию за левередж только после первого фактического списания. + # До этого строка "$0 / 0 спис." не несёт пользы и визуально засоряет UI. + if leverage_fee is not None and overnight_count > 0: + side = _position_side_text(getattr(state, "position_side", None)) + lines.append( + f" · Левередж {side} · {_signed_usd_compact(leverage_fee)} / " + f"{overnight_count} спис." + ) + + return lines def _current_price(symbol: str | None) -> float | None: @@ -1076,21 +1319,6 @@ def _signal_duration_text(state) -> str: return f"{seconds}с" -def _execution_freshness_text(state) -> str: - freshness = str( - getattr(state, "execution_price_freshness", "") or "" - ).upper() - - mapping = { - "FRESH": "данные свежие", - "AGING": "данные стареют", - "STALE": "данные устарели", - "UNKNOWN": "нет данных", - } - - return mapping.get(freshness, "нет данных") - - def _status_text(state) -> str: runtime = _cycle_runtime_text(state) @@ -1306,25 +1534,46 @@ def _trade_word(value: int) -> str: return "сделок" -def _cycle_winrate_line(state, cycle_pnl: float, cycle_trades: int) -> str: - if cycle_trades <= 0 or cycle_pnl <= 0: - return "" +def _cycle_summary_lines(state) -> list[str]: + # Единый блок статистики текущего цикла. + # Показываем номер цикла всегда, даже если закрытых сделок ещё нет. + cycle_trades = int(getattr(state, "cycle_closed_trades", 0) or 0) + cycle_pnl = float(getattr(state, "cycle_realized_pnl_usd", 0.0) or 0.0) wins = int(getattr(state, "cycle_winning_trades", 0) or 0) - winrate = round((wins / cycle_trades) * 100) + losses = int(getattr(state, "cycle_losing_trades", 0) or 0) + + if cycle_trades <= 0: + return [f"🔄 {_cycle_number_text(state)}"] + + lines = [ + ( + f"🔄 {_cycle_number_text(state)} · " + f"{cycle_trades} {_trade_word(cycle_trades)} · " + f"🟢 {wins} 🔴 {losses}" + ), + *_cycle_trade_block_lines(state), + "", + _format_pnl_line(cycle_pnl), + *_cycle_commission_lines(state), + ] + + return lines - return f"Успешных · {winrate}%" def _format_pnl_line(value: float | int | None) -> str: + # Показываем именно итог цикла/позиции. + # Комиссии уже включены в net PnL, а ниже отдельным блоком показываем, + # какая часть результата пришлась на комиссии. amount = float(value or 0.0) if amount > 0: - return f"Прибыль 🟢 +{_format_usd_compact(amount)}" + return f"🟢 Итог · +{_format_usd_compact(amount)}" if amount < 0: - return f"Убыток 🔴 −{_format_usd_compact(abs(amount))}" + return f"🔴 Итог · −{_format_usd_compact(abs(amount))}" - return "Результат · $0" + return "⚪ Итог · $0" def _adaptive_adjustment_visible(state) -> bool: @@ -1367,4 +1616,98 @@ def _short_adaptive_reason( if not reason: return "Размер скорректирован" - return reason[:1].upper() + reason[1:] \ No newline at end of file + return reason[:1].upper() + reason[1:] + + +def _execution_block_lines(state) -> list[str]: + title = str(getattr(state, "execution_block_title", "") or "").strip() + message = str(getattr(state, "execution_block_message", "") or "").strip() + action = str(getattr(state, "execution_block_action", "") or "").strip() + + if not title or not message: + return [] + + lines = [ + f"⛔ {title}", + message, + ] + + if action: + lines.append(action) + + return lines + + +def _cycle_commission_lines(state) -> list[str]: + trade_fees = safe_float(getattr(state, "cycle_trade_fees_usd", None)) or 0.0 + overnight_fees = safe_float(getattr(state, "cycle_overnight_fees_usd", None)) or 0.0 + + if abs(trade_fees) < 0.0001 and abs(overnight_fees) < 0.0001: + return [] + + lines = ["Включая комиссии:"] + + if abs(trade_fees) >= 0.0001: + lines.append(f"· сделки (RT) · {_format_usd_compact(abs(trade_fees))}") + + if abs(overnight_fees) >= 0.0001: + lines.append(f"· левередж · {_signed_usd_compact(overnight_fees)}") + + return lines + + +def _cycle_trade_block_lines(state) -> list[str]: + # Этот блок показываем только при реальной блокировке по серии убытков. + # Обычная пауза/cooldown после одной сделки сюда не попадает. + if not bool(getattr(state, "loss_cooldown_active", False)): + return [] + + consecutive_losses = int( + getattr(state, "cycle_consecutive_losses", 0) or 0 + ) + + if consecutive_losses <= 0: + return [] + + return [ + "", + "⛔️ Блокировка сделок", + f"· {consecutive_losses} убыточных сделок подряд", + "· перезапусти цикл", + ] + + +def _market_score_notification_line(state) -> str: + score = safe_float(getattr(state, "market_score", None)) + + if score is None: + return "" + + label = str(getattr(state, "market_score_label", "") or "").strip() + + if not label: + label = _market_score_label(score) + + return f"Рынок · {label.lower()} · {score:.0f}%" + + +def _market_score_label(score: float) -> str: + # Единая шкала общей оценки рынка: + # 90-100 — отличный рынок + # 75-89 — благоприятный + # 55-74 — нейтральный + # 35-54 — сложный + # 0-34 — неблагоприятный + if score >= 90: + return "Отличный" + + if score >= 75: + return "Благоприятный" + + if score >= 55: + return "Нейтральный" + + if score >= 35: + return "Сложный" + + return "Неблагоприятный" \ No newline at end of file diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py new file mode 100644 index 0000000..88818e7 --- /dev/null +++ b/app/src/telegram/handlers/market.py @@ -0,0 +1,505 @@ +# app/src/telegram/handlers/market.py + +from __future__ import annotations + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import ( + CallbackQuery, + InaccessibleMessage, + InlineKeyboardMarkup, + Message, +) +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.integrations.exchange.exceptions import ExchangeError +from src.integrations.exchange.service import ExchangeService +from src.integrations.exchange.status import ( + ExchangeRuntimeStatus, + ExchangeStatusCode, + build_exchange_error_status, + classify_exchange_error, +) +from src.telegram.live.active_screen import ActiveScreenManager +from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry +from src.telegram.ui.common import mode_line, now_line +from src.telegram.ui.currency_ui import format_usd_amount +from src.telegram.ui.exchange_error import ( + show_callback_exchange_error, + show_message_exchange_error, +) +from src.trading.journal.service import JournalService + + +router = Router(name="market") + +_last_market_prices: dict[str, float] = {} +_last_market_directions: dict[str, str] = {} + + +def _require_message(callback: CallbackQuery) -> Message | None: + message = callback.message + + if message is None or isinstance(message, InaccessibleMessage): + return None + + return message + + +def _market_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="📊 К мониторингу", callback_data="monitoring:home") + builder.adjust(1) + return builder.as_markup() + + +# собрать текст, когда рынок/биржа недоступны через unified status layer +def _build_market_status_text(status: ExchangeRuntimeStatus) -> str: + icon = "⏸️" if status.code == ExchangeStatusCode.BREAK else "⛔️" + + return ( + "📈 Рынок\n" + f"{mode_line()}" + f"{icon} {status.title}\n\n" + f"{status.message}\n\n" + f"{now_line()}" + ) + + +def _build_market_text( + *, + ticker_price: NumericLike, + name: str, + market_type: str, + base_asset: str, + quote_asset: str, +) -> str: + price = safe_float(ticker_price) + + if price is None: + price = 0.0 + + previous_price = _last_market_prices.get(name) + price_direction = _last_market_directions.get(name, "▲") + + if previous_price is not None: + if price > previous_price: + price_direction = "🔺" + elif price < previous_price: + price_direction = "🔻" + + _last_market_prices[name] = price + _last_market_directions[name] = price_direction + + type_map = { + "LEVERAGE": "leverage", + "SPOT": "spot", + } + market_type_ru = type_map.get(market_type.upper(), market_type.lower()) + + return ( + "📈 Рынок\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()}" + ) + + +# live-render должен сам уметь показать ошибку, иначе runner просто потеряет экран +def _build_market_live_text() -> str: + service = ExchangeService() + requested_symbol = service.settings.default_symbol + + try: + runtime_status = service.get_symbol_runtime_status(requested_symbol) + except Exception as exc: + return _build_market_status_text(build_exchange_error_status(exc)) + + if runtime_status.code != ExchangeStatusCode.OPEN: + return _build_market_status_text(runtime_status) + + symbol = runtime_status.symbol or requested_symbol + + validation = service.validate_symbol(symbol) + + if not validation.is_valid: + return _build_market_status_text( + service.get_symbol_runtime_status(requested_symbol) + ) + + ticker = service.get_price(validation.normalized_symbol) + + symbol_info = validation.symbol_info + market_type = symbol_info.market_type if symbol_info else "n/a" + base_asset = ( + symbol_info.base_asset + if symbol_info and symbol_info.base_asset + else "n/a" + ) + quote_asset = ( + symbol_info.quote_asset + if symbol_info and symbol_info.quote_asset + else "n/a" + ) + name = ( + symbol_info.name + if symbol_info and symbol_info.name + else ticker.symbol + ) + + return _build_market_text( + ticker_price=ticker.price, + name=name, + market_type=market_type, + base_asset=base_asset, + quote_asset=quote_asset, + ) + + +def _register_market_live_screen(message: Message) -> None: + bot = message.bot + + if bot is None: + return + + LiveScreenRunner.unregister_message( + chat_id=message.chat.id, + message_id=message.message_id, + ) + + ScreenRegistry.unregister_message( + chat_id=message.chat.id, + message_id=message.message_id, + ) + + LiveScreenRunner.register_screen( + LiveScreen( + screen="market", + bot=bot, + chat_id=message.chat.id, + message_id=message.message_id, + render_text=_build_market_live_text, + render_markup=_market_keyboard, + interval_seconds=5, + ) + ) + + LiveScreenRunner.start("market") + + +async def _prepare_market_from_message(message: Message) -> bool: + bot = message.bot + + if bot is None: + return False + + await ActiveScreenManager.prepare_new_screen( + screen="market", + bot=bot, + chat_id=message.chat.id, + ) + + return True + + +async def _prepare_market_from_callback(callback: CallbackQuery) -> bool: + message = _require_message(callback) + + if message is None: + await callback.answer("Сообщение недоступно", show_alert=True) + return False + + bot = message.bot + + if bot is None: + await callback.answer("Bot недоступен", show_alert=True) + return False + + await ActiveScreenManager.prepare_new_screen( + screen="market", + bot=bot, + chat_id=message.chat.id, + keep_message_id=message.message_id, + ) + + return True + + +async def _send_or_edit_market_screen( + target_message: Message, + *, + text: str, + edit_mode: bool, +) -> None: + if edit_mode: + await target_message.edit_text(text, reply_markup=_market_keyboard()) + _register_market_live_screen(target_message) + ActiveScreenManager.register(screen="market", message=target_message) + return + + sent_message = await target_message.answer( + text, + reply_markup=_market_keyboard(), + ) + _register_market_live_screen(sent_message) + ActiveScreenManager.register(screen="market", message=sent_message) + + +async def _render_market_screen( + target_message: Message, + *, + user_id: int | None, + chat_id: int | None, + edit_mode: bool, + action: str, +) -> None: + service = ExchangeService() + journal = JournalService() + requested_symbol = service.settings.default_symbol + + journal.log_ui_info( + event_type="market_open_requested", + message="Запрошено открытие экрана рынка.", + screen="market", + action=action, + user_id=user_id, + chat_id=chat_id, + payload={"symbol": requested_symbol}, + ) + + runtime_status = service.get_symbol_runtime_status(requested_symbol) + + if runtime_status.code != ExchangeStatusCode.OPEN: + journal.log_ui_warning( + event_type="market_status_blocked", + message=runtime_status.message, + screen="market", + action=action, + user_id=user_id, + chat_id=chat_id, + payload=runtime_status.as_dict(), + ) + + await _send_or_edit_market_screen( + target_message, + text=_build_market_status_text(runtime_status), + edit_mode=edit_mode, + ) + return + + symbol = runtime_status.symbol or requested_symbol + validation = service.validate_symbol(symbol) + + if not validation.is_valid: + invalid_status = service.get_symbol_runtime_status(requested_symbol) + + journal.log_ui_warning( + event_type="market_symbol_invalid", + message=invalid_status.message, + screen="market", + action=action, + user_id=user_id, + chat_id=chat_id, + payload=invalid_status.as_dict(), + ) + + await _send_or_edit_market_screen( + target_message, + text=_build_market_status_text(invalid_status), + edit_mode=edit_mode, + ) + return + + ticker = service.get_price(validation.normalized_symbol) + + symbol_info = validation.symbol_info + market_type = symbol_info.market_type if symbol_info else "n/a" + base_asset = ( + symbol_info.base_asset + if symbol_info and symbol_info.base_asset + else "n/a" + ) + quote_asset = ( + symbol_info.quote_asset + if symbol_info and symbol_info.quote_asset + else "n/a" + ) + name = ( + symbol_info.name + if symbol_info and symbol_info.name + else ticker.symbol + ) + + text = _build_market_text( + ticker_price=ticker.price, + name=name, + market_type=market_type, + base_asset=base_asset, + quote_asset=quote_asset, + ) + + journal.log_ui_info( + event_type="market_open_success", + message="Экран рынка загружен.", + screen="market", + action=action, + user_id=user_id, + chat_id=chat_id, + payload={ + "symbol": ticker.symbol, + "price": safe_float(ticker.price), + "runtime_status": runtime_status.as_dict(), + }, + ) + + await _send_or_edit_market_screen( + target_message, + text=text, + edit_mode=edit_mode, + ) + + +@router.message(F.text == "📈 Рынок") +async def open_market(message: Message, state: FSMContext) -> None: + await state.clear() + + if not await _prepare_market_from_message(message): + return + + user_id = message.from_user.id if message.from_user else None + chat_id = message.chat.id if message.chat else None + + try: + await _render_market_screen( + message, + user_id=user_id, + chat_id=chat_id, + edit_mode=False, + action="open", + ) + except ExchangeError as exc: + JournalService().log_ui_error( + event_type="market_open_error", + message="Не удалось загрузить экран рынка.", + screen="market", + action="open", + user_id=user_id, + chat_id=chat_id, + error_type=classify_exchange_error(exc), + raw_error=str(exc), + ) + + await show_message_exchange_error( + message, + title="📈 Рынок", + 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/system.py b/app/src/telegram/handlers/system.py index 2913843..1c208de 100644 --- a/app/src/telegram/handlers/system.py +++ b/app/src/telegram/handlers/system.py @@ -2,12 +2,14 @@ from __future__ import annotations +import os + from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InaccessibleMessage, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder -from src.core.config import load_settings +from src.core.config import ENV_FILE, load_settings from src.core.constants import APP_NAME, APP_VERSION from src.core.numbers import safe_float from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts @@ -753,6 +755,40 @@ async def open_general_settings(callback: CallbackQuery) -> None: await callback.answer() +def _journal_debug_enabled() -> bool: + return bool(load_settings().journal_debug_enabled) + + +def _set_env_value(key: str, value: str) -> None: + lines: list[str] = [] + + if ENV_FILE.exists(): + lines = ENV_FILE.read_text(encoding="utf-8").splitlines() + + updated = False + result: list[str] = [] + + for line in lines: + if line.strip().startswith(f"{key}="): + result.append(f"{key}={value}") + updated = True + else: + result.append(line) + + if not updated: + result.append(f"{key}={value}") + + ENV_FILE.write_text("\n".join(result) + "\n", encoding="utf-8") + os.environ[key] = value + + +def _journal_debug_status_line() -> str: + if _journal_debug_enabled(): + return "🐞 Debug лог: ВКЛ" + + return "🐞 Debug лог: ВЫКЛ" + + @router.callback_query(F.data == "settings:journal") async def open_journal_settings(callback: CallbackQuery) -> None: if not await _prepare_system_from_callback(callback, screen="settings_journal"): @@ -771,25 +807,64 @@ async def open_journal_settings(callback: CallbackQuery) -> None: "📒 Журнал\n\n" "СИСТЕМА · Настройки\n\n" f"📄 Записей: {total}\n" + f"{_journal_debug_status_line()}\n" "📦 Лимит: —\n" "⏳ Хранение: —\n" "🗄 Архив: —\n\n" ) + debug_button_text = ( + "🟢 Debug логирование" + if _journal_debug_enabled() + else "⚪️ Debug логирование" + ) + builder = InlineKeyboardBuilder() + builder.button(text=debug_button_text, callback_data="settings:journal_debug_toggle") builder.button(text="🗑 Очистка", callback_data="journal:clear_confirm") builder.button(text="🗄 Архив", callback_data="settings:journal_archive") builder.button(text="📦 Лимит", callback_data="settings:journal_limit") builder.button(text="⏳ Хранение", callback_data="settings:journal_retention") builder.button(text="⬅️ Назад", callback_data="system:management") builder.button(text="📒 Журнал", callback_data="journal:1") - builder.adjust(2, 2, 2) + builder.adjust(1, 2, 2, 2) await message.edit_text(text, reply_markup=builder.as_markup()) _register_system_screen(message, screen="settings_journal") await callback.answer() +@router.callback_query(F.data == "settings:journal_debug_toggle") +async def toggle_journal_debug(callback: CallbackQuery) -> None: + enabled = _journal_debug_enabled() + new_value = "false" if enabled else "true" + + _set_env_value("JOURNAL_DEBUG_ENABLED", new_value) + + try: + JournalService().log_ui_info( + event_type="journal_debug_changed", + message=( + "Debug логирование журнала выключено." + if enabled + else "Debug логирование журнала включено." + ), + screen="settings_journal", + action="toggle_debug", + payload={ + "journal_debug_enabled": not enabled, + }, + ) + except Exception: + pass + + await open_journal_settings(callback) + + await callback.answer( + "Debug логирование выключено" if enabled else "Debug логирование включено" + ) + + @router.callback_query(F.data == "settings:journal_archive") async def open_journal_archive_settings(callback: CallbackQuery) -> None: if not await _prepare_system_from_callback(callback, screen="settings_journal"): diff --git a/app/src/trading/auto/auto_lifecycle.py b/app/src/trading/auto/auto_lifecycle.py index 49fd1c6..cc65362 100644 --- a/app/src/trading/auto/auto_lifecycle.py +++ b/app/src/trading/auto/auto_lifecycle.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import time -from typing import TYPE_CHECKING from datetime import datetime from src.core.config import load_settings @@ -18,29 +17,19 @@ 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_semantics import AutoPositionSemanticsMixin 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, + AutoPositionSemanticsMixin, AutoAutonomousManagementMixin, AutoExecutionSemanticMixin, ): @@ -52,8 +41,6 @@ class AutoLifecycleMixin( _confirm_repeats: int _execution_confidence_required_score: float - - # Записать изменение режима автоторговли в журнал. def _log_auto_status_changed( self, *, @@ -85,7 +72,6 @@ class AutoLifecycleMixin( }, ) - # установить капитал, выделенный под автоторговлю def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState: state = self.get_state() @@ -99,24 +85,20 @@ class AutoLifecycleMixin( 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 @@ -124,7 +106,6 @@ class AutoLifecycleMixin( self._loop_task.cancel() self._loop_task = None - # рабочий цикл автоторговли async def _loop_worker(self) -> None: while True: state = self.get_state() @@ -135,7 +116,6 @@ class AutoLifecycleMixin( self.run_cycle() await asyncio.sleep(self._loop_interval_seconds) - # запустить активную торговлю def start(self) -> tuple[AutoTradeState, str]: state = self.get_state() previous_status = state.status @@ -145,6 +125,15 @@ class AutoLifecycleMixin( if state.status == "OBSERVING": state.status = "RUNNING" + # При ручном запуске из OBSERVING очищаем старую cooldown-блокировку, + # чтобы запуск не наследовал паузу прошлого цикла. + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + state.last_loss_monotonic_at = None + state.execution_block_title = None + state.execution_block_message = None + state.execution_block_action = None + state.execution_block_reason = None EventBus.emit( "auto_status_changed", @@ -168,6 +157,17 @@ class AutoLifecycleMixin( state.cycle_realized_pnl_usd = 0.0 state.cycle_closed_trades = 0 state.cycle_winning_trades = 0 + # Новый цикл должен начинаться без старой блокировки после убытков. + state.cycle_losing_trades = 0 + state.cycle_consecutive_losses = 0 + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + state.last_loss_monotonic_at = None + state.execution_block_title = None + state.execution_block_message = None + state.execution_block_action = None + state.cycle_trade_fees_usd = 0.0 + state.cycle_overnight_fees_usd = 0.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 @@ -195,7 +195,6 @@ class AutoLifecycleMixin( return state, "Автоторговля запущена." - # включить режим наблюдения def observe(self) -> tuple[AutoTradeState, str]: state = self.get_state() previous_status = state.status @@ -216,13 +215,28 @@ class AutoLifecycleMixin( if previous_status == "OFF": state.cycle_realized_pnl_usd = 0.0 state.cycle_closed_trades = 0 + state.cycle_losing_trades = 0 + state.cycle_consecutive_losses = 0 + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + state.last_loss_monotonic_at = None state.cycle_winning_trades = 0 + state.cycle_trade_fees_usd = 0.0 + state.cycle_overnight_fees_usd = 0.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 + state.position_stall_state = None + state.position_stall_reason = None + state.position_mfe_percent = None + state.position_mae_percent = None + state.execution_block_title = None + state.execution_block_message = None + state.execution_block_action = None + state.execution_block_reason = None self._log_auto_status_changed( previous_status=previous_status, @@ -242,7 +256,6 @@ class AutoLifecycleMixin( return state, "Автоторговля переведена в режим наблюдения." - # полностью выключить автоторговлю def stop(self) -> tuple[AutoTradeState, str]: state = self.get_state() previous_status = state.status @@ -254,7 +267,18 @@ class AutoLifecycleMixin( state.status = "OFF" state.cycle_realized_pnl_usd = 0.0 state.cycle_closed_trades = 0 + state.cycle_losing_trades = 0 + state.cycle_consecutive_losses = 0 + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + state.last_loss_monotonic_at = None + state.execution_block_title = None + state.execution_block_message = None + state.execution_block_action = None + state.execution_block_reason = None state.cycle_winning_trades = 0 + state.cycle_trade_fees_usd = 0.0 + state.cycle_overnight_fees_usd = 0.0 state.cycle_started_at = None state.adaptive_size_changed_at = None state.last_flip_old_side = None @@ -262,6 +286,10 @@ class AutoLifecycleMixin( state.last_flip_pnl_usd = None state.last_flip_reason = None state.last_flip_monotonic_at = None + state.position_stall_state = None + state.position_stall_reason = None + state.position_mfe_percent = None + state.position_mae_percent = None self.stop_loop() EventBus.emit( @@ -281,7 +309,6 @@ class AutoLifecycleMixin( return state, "Автоторговля выключена." - # установить инструмент def set_symbol(self, symbol: str) -> AutoTradeState: state = self.get_state() previous_symbol = state.symbol @@ -294,7 +321,6 @@ class AutoLifecycleMixin( return state - # установить стратегию def set_strategy(self, strategy: str) -> AutoTradeState: state = self.get_state() previous_strategy = state.strategy @@ -308,44 +334,37 @@ class AutoLifecycleMixin( 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 @@ -364,7 +383,9 @@ class AutoLifecycleMixin( state.adaptive_size_factors = None state.effective_risk_percent = None state.effective_target_risk_usd = None + state.execution_size_adjustment_reason = None + state.last_signal = "HOLD" state.last_signal_repeat_count = 0 state.last_signal_confidence = 0.0 state.last_signal_reason = None @@ -410,6 +431,18 @@ class AutoLifecycleMixin( state.market_trend_quality = None state.market_phase = None state.market_phase_direction = None + state.market_structure = None + state.market_structure_reason = None + state.market_score = None + state.market_score_label = None + state.market_long_score = None + state.market_short_score = None + + state.last_closed_candle_change_percent = None + state.last_closed_candle_direction = None + state.current_interval_change_percent = None + state.current_interval_direction = None + state.current_interval_label = None state.market_trend_gap_percent = None state.market_trend_consistency = None @@ -429,6 +462,14 @@ class AutoLifecycleMixin( state.htf_atr_percent_baseline = None state.htf_volatility_ratio = None state.htf_volatility = None + state.htf_market_state = None + state.htf_trend = None + state.htf_trend_strength = None + state.htf_trend_quality = None + state.htf_market_phase = None + state.htf_alignment = None + state.htf_confirmation_score = None + state.htf_reason = None state.entry_block_reason = None state.entry_block_message = None @@ -476,6 +517,20 @@ class AutoLifecycleMixin( state.position_conviction_state = None state.position_exit_urgency = None state.position_reversal_risk = None + state.position_stall_state = None + state.position_stall_reason = 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 state.autonomous_action = None state.autonomous_action_reason = None @@ -486,10 +541,16 @@ class AutoLifecycleMixin( state.autonomous_last_action = None state.autonomous_last_action_reason = None state.autonomous_last_action_at = None - state.last_loss_monotonic_at = None - # собрать контекст для стратегии + # Сброс именно runtime-блокировки, чтобы после нового запуска + # не оставалась старая пауза после прошлой убыточной сделки. + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + state.execution_block_title = None + state.execution_block_message = None + state.execution_block_action = None + def _build_strategy_context(self) -> StrategyContext: state = self.get_state() @@ -499,12 +560,10 @@ class AutoLifecycleMixin( 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() @@ -520,6 +579,16 @@ class AutoLifecycleMixin( strategy = self._get_strategy() context = self._build_strategy_context() + # Последовательность принятия решения: + # 1. Проверяем доступность рынка и live-данных. + # 2. Стратегия анализирует свечи 5m + HTF 1h + live snapshot. + # 3. Стратегия возвращает HOLD / BUY / SELL. + # 4. Market runtime переносит payload стратегии в общий state. + # 5. Execution quality проверяет spread, свежесть цены и стакан. + # 6. Signal runtime подтверждает BUY/SELL по повторам и времени. + # 7. ExecutionEngine открывает сделку только если сигнал READY. + # 8. Если позиция открыта — protection/semantics решают, + # удерживать, защищать или закрывать позицию. result = strategy.analyze(context) self._sync_market_analysis_state( @@ -529,6 +598,27 @@ class AutoLifecycleMixin( self._sync_execution_quality_state(state) + engine = ExecutionEngine() + + # Перед health/semantics обновляем runtime PnL позиции, + # иначе position intelligence может работать по данным прошлого цикла. + engine._update_unrealized_pnl(state) + + # ВАЖНО: + # раньше position health/intelligence обновлялись только после ExecutionEngine.process(). + # Из-за этого runtime protection внутри execution мог принимать решение + # по старому состоянию позиции. + # + # Теперь перед execution обновляем: + # - health позиции + # - semantics позиции + # - autonomous management + # + # Это уменьшает задержку реакции защиты на смену trend/momentum/market context. + self._sync_position_health_state(state) + self._sync_position_semantics_state(state) + self._sync_autonomous_trade_management(state) + state.last_check_at = datetime.now().strftime("%H:%M:%S") self._log_signal_if_changed( @@ -540,15 +630,17 @@ class AutoLifecycleMixin( payload=result.payload, ) - if state.execution_quality != "BLOCKED": - ExecutionEngine().process(state) + engine.process(state) + # Повторная синхронизация после execution: + # если позиция была открыта/закрыта/перевернута, UI и runtime state + # сразу получают актуальное состояние. self._sync_position_health_state(state) - self._sync_position_intelligence_state(state) + self._sync_position_semantics_state(state) self._sync_autonomous_trade_management(state) - if state.execution_quality != "BLOCKED": - ExecutionEngine().process_runtime_action(state) + if state.execution_quality != "BLOCKED" and engine.get_position().side != "NONE": + engine.process_runtime_action(state) self._sync_execution_semantic_state(state) diff --git a/app/src/trading/auto/autonomous_management.py b/app/src/trading/auto/autonomous_management.py index 67b60a4..eba7a09 100644 --- a/app/src/trading/auto/autonomous_management.py +++ b/app/src/trading/auto/autonomous_management.py @@ -4,6 +4,23 @@ from __future__ import annotations from src.core.numbers import safe_float from src.trading.auto.state import AutoTradeState +from src.trading.execution.constants import ( + AUTONOMOUS_ACTION_EXIT, + AUTONOMOUS_ACTION_HOLD, + AUTONOMOUS_ACTION_PROTECT, + AUTONOMOUS_ACTION_REDUCE, + AUTONOMOUS_ACTION_WATCH, + AUTONOMOUS_AGGRESSIVE_EXIT_CONFIDENCE_THRESHOLD, + AUTONOMOUS_EXIT_CONFIDENCE_THRESHOLD, + POSITION_EXIT_SIGNAL_EXIT, + POSITION_EXIT_SIGNAL_HOLD, + POSITION_EXIT_SIGNAL_REDUCE_OR_PROTECT, + POSITION_EXIT_SIGNAL_WATCH, + POSITION_PRESSURE_HIGH_LOSS, + POSITION_PRESSURE_LOSS, + POSITION_TREND_AGAINST, + POSITION_SIDE_NONE, +) class AutoAutonomousManagementMixin: @@ -12,7 +29,9 @@ class AutoAutonomousManagementMixin: self, state: AutoTradeState, ) -> None: - if state.position_side == "NONE": + # Если позиции нет или она неполная, очищаем autonomous-state, + # чтобы не осталось старого действия от прошлой позиции. + if state.position_side == POSITION_SIDE_NONE or state.entry_price is None: state.autonomous_action = None state.autonomous_action_reason = None state.autonomous_action_confidence = None @@ -21,41 +40,53 @@ class AutoAutonomousManagementMixin: state.autonomous_exit_required = False return - exit_signal = str(state.position_exit_signal or "HOLD").upper() + exit_signal = str( + state.position_exit_signal + or POSITION_EXIT_SIGNAL_HOLD + ).upper() exit_confidence = safe_float(state.position_exit_confidence) or 0.0 + position_pressure = str(state.position_pressure or "").upper() + trend_alignment = str(state.position_trend_alignment or "").upper() - action = "HOLD" + action = AUTONOMOUS_ACTION_HOLD reason = "позиция удерживается" protect_required = False reduce_required = False exit_required = False - if exit_signal == "WATCH": - action = "WATCH" + if exit_signal == POSITION_EXIT_SIGNAL_WATCH: + action = AUTONOMOUS_ACTION_WATCH reason = "позиция требует наблюдения" - elif exit_signal == "REDUCE_OR_PROTECT": - if state.position_pressure in {"HIGH_LOSS", "LOSS"}: - action = "REDUCE" + elif exit_signal == POSITION_EXIT_SIGNAL_REDUCE_OR_PROTECT: + if position_pressure in {POSITION_PRESSURE_HIGH_LOSS, POSITION_PRESSURE_LOSS}: + action = AUTONOMOUS_ACTION_REDUCE reduce_required = True reason = "позиция должна быть уменьшена" else: - action = "PROTECT" + action = AUTONOMOUS_ACTION_PROTECT protect_required = True reason = "позиция требует защиты" - elif exit_signal == "EXIT": - action = "EXIT" - exit_required = True - reason = "позиция требует закрытия" + elif exit_signal == POSITION_EXIT_SIGNAL_EXIT: + if exit_confidence >= AUTONOMOUS_EXIT_CONFIDENCE_THRESHOLD: + action = AUTONOMOUS_ACTION_EXIT + exit_required = True + reason = "позиция требует закрытия" + else: + action = AUTONOMOUS_ACTION_PROTECT + protect_required = True + reason = "позиция требует защиты перед возможным выходом" + # Жёсткая эскалация: если и тренд, и momentum против позиции, + # автономное управление должно требовать выход, а не частичную защиту. if ( state.position_adverse_momentum - and state.position_trend_alignment == "AGAINST" - and exit_confidence >= 0.65 + and trend_alignment == POSITION_TREND_AGAINST + and exit_confidence >= AUTONOMOUS_AGGRESSIVE_EXIT_CONFIDENCE_THRESHOLD ): - action = "EXIT" + action = AUTONOMOUS_ACTION_EXIT exit_required = True reduce_required = False protect_required = False diff --git a/app/src/trading/auto/execution_quality.py b/app/src/trading/auto/execution_quality.py index a45f7c1..c49e2a8 100644 --- a/app/src/trading/auto/execution_quality.py +++ b/app/src/trading/auto/execution_quality.py @@ -235,11 +235,29 @@ class AutoExecutionQualityMixin: # синхронизировать runtime quality исполнения def _sync_execution_quality_state(self, state: AutoTradeState) -> None: + if state.market_is_open is False: + return + try: snapshot = ExchangeService().get_market_snapshot( state.symbol, runtime_key="auto", ) + + age_seconds = safe_float(snapshot.get("age_seconds")) + + if ( + age_seconds is not None + and age_seconds > self._warning_snapshot_age_seconds + ): + try: + snapshot = ExchangeService().refresh_market_snapshot_cache( + state.symbol, + runtime_key="auto", + ) + except Exception: + pass + except Exception as exc: fallback_price = None @@ -253,13 +271,22 @@ class AutoExecutionQualityMixin: except Exception: pass + # Snapshot недоступен — очищаем все pricing-поля, + # чтобы UI/execution не использовали старые bid/ask/last. state.snapshot_age_seconds = None state.spread_percent = 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 = fallback_price + state.execution_price_freshness = "UNKNOWN" 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.execution_block_reason = None state.market_runtime_degraded = True else: status = build_exchange_error_status(exc) diff --git a/app/src/trading/auto/execution_semantic.py b/app/src/trading/auto/execution_semantic.py index b8c9aaa..0536dc2 100644 --- a/app/src/trading/auto/execution_semantic.py +++ b/app/src/trading/auto/execution_semantic.py @@ -2,10 +2,7 @@ from __future__ import annotations -from src.integrations.exchange.status import ( - ExchangeStatusCode, - is_exchange_status_reason, -) +from src.integrations.exchange.status import ExchangeStatusCode from src.trading.auto.state import AutoTradeState @@ -14,6 +11,16 @@ class AutoExecutionSemanticMixin: # синхронизировать semantic-статус execution слоя для UI def _sync_execution_semantic_state(self, state: AutoTradeState) -> None: + if state.execution_block_reason: + state.execution_semantic_status = "BLOCKED" + state.execution_semantic_message = ( + f"⛔ Исполнение · {state.execution_block_message}" + if state.execution_block_message + else "⛔ Исполнение · заблокировано" + ) + state.execution_semantic_reason = state.execution_block_reason + return + if state.execution_quality == "BLOCKED": state.execution_semantic_status = "BLOCKED" state.execution_semantic_message = self._execution_block_semantic_message(state) @@ -96,25 +103,29 @@ class AutoExecutionSemanticMixin: # проверить, что блокировка пришла из единого 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, - } - ) + # Поддерживаем оба формата: + # 1) внутренние execution reason: EXCHANGE_UNAVAILABLE / TIME_ERROR + # 2) значения ExchangeStatusCode, если они попадут сюда напрямую. + return reason in { + "EXCHANGE_UNAVAILABLE", + "TIME_ERROR", + 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 - ) + # AutoExecutionQualityMixin сейчас кладёт MARKET_BREAK, + # а ExchangeStatusCode может прийти как BREAK. + return reason in { + "MARKET_BREAK", + 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 + # Поддерживаем внутренний AUTH_ERROR и enum-value. + return reason in { + "AUTH_ERROR", + 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 index 950351b..3ced277 100644 --- a/app/src/trading/auto/market_runtime.py +++ b/app/src/trading/auto/market_runtime.py @@ -11,10 +11,11 @@ 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 + # Последние залогированные состояния нужны для dedupe journal-событий. + # Dedupe market-событий отдельно по symbol/strategy, + # чтобы разные инструменты не подавляли события друг друга. + _last_logged_market_key: str | None = None + _last_logged_entry_block_reason: str | None = None _last_logged_entry_block_at: float | None = None _entry_block_log_ttl_seconds: int = 900 @@ -39,6 +40,38 @@ class AutoMarketRuntimeMixin: 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 "") + + # Общая оценка рынка нужна UI, diagnostics, execution confidence + # и adaptive sizing. Это не отдельная метрика тренда, а итоговая + # оценка всего рыночного контекста. + state.market_score = safe_float(payload.get("market_score")) + state.market_score_label = str(payload.get("market_score_label") or "") + + # market_long_score / market_short_score — направленные оценки входа. + # Это не общий market_score, а оценка конкретно Long/Short. + state.market_long_score = safe_float(payload.get("market_long_score")) + state.market_short_score = safe_float(payload.get("market_short_score")) + + state.last_closed_candle_change_percent = safe_float( + payload.get("last_closed_candle_change_percent") + ) + state.last_closed_candle_direction = str( + payload.get("last_closed_candle_direction") or "" + ) + + state.current_interval_change_percent = safe_float( + payload.get("current_interval_change_percent") + ) + state.current_interval_direction = str( + payload.get("current_interval_direction") or "" + ) + state.current_interval_label = str( + payload.get("current_interval_label") or "" + ) + + state.market_structure = str(payload.get("market_structure") or "") + state.market_structure_reason = str(payload.get("market_structure_reason") 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")) @@ -51,13 +84,34 @@ class AutoMarketRuntimeMixin: 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.htf_market_state = str(payload.get("htf_market_state") or "") + state.htf_trend = str(payload.get("htf_trend") or "") + state.htf_trend_strength = str(payload.get("htf_trend_strength") or "") + state.htf_trend_quality = str(payload.get("htf_trend_quality") or "") + state.htf_market_phase = str(payload.get("htf_market_phase") or "") + state.htf_alignment = str(payload.get("htf_alignment") or "") + state.htf_confirmation_score = safe_float(payload.get("htf_confirmation_score")) + state.htf_reason = str(payload.get("htf_reason") 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.market_analysis_updated_at = time.monotonic() + 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")) @@ -65,9 +119,14 @@ class AutoMarketRuntimeMixin: 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 "") + if state.runtime_expired_reason == "MARKET_ANALYSIS_TTL_EXPIRED": + state.runtime_expired_reason = None + state.runtime_expired_message = None + self._log_market_state_if_changed( state=state, payload=payload, @@ -136,10 +195,27 @@ class AutoMarketRuntimeMixin: "market_trend_quality": state.market_trend_quality, "market_phase": state.market_phase, "market_phase_direction": state.market_phase_direction, + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, "momentum_state": state.momentum_state, "momentum_direction": state.momentum_direction, "momentum_strength": state.momentum_strength, "momentum_change_percent": state.momentum_change_percent, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, "execution_quality": state.execution_quality, "execution_quality_reason": state.execution_quality_reason, "execution_confidence_score": state.execution_confidence_score, @@ -168,17 +244,28 @@ class AutoMarketRuntimeMixin: 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 + # Один ключ на текущее рыночное состояние. + # Так journal не будет спамить одинаковыми событиями, + # но изменения по другому symbol/strategy не потеряются. + market_key = ( + f"{state.symbol}:" + f"{state.strategy}:" + f"{market_state}:" + f"{market_trend}:" + f"{market_volatility}" ) + state_changed = market_state != previous_market_state + volatility_changed = ( - market_volatility is not None + bool(market_volatility) + and market_volatility != "UNKNOWN" and market_volatility != previous_market_volatility - and market_volatility != type(self)._last_logged_market_volatility ) + if market_key == type(self)._last_logged_market_key: + return + if not state_changed and not volatility_changed: return @@ -211,9 +298,7 @@ class AutoMarketRuntimeMixin: 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 + type(self)._last_logged_market_key = market_key # записать market journal событие с нужным уровнем важности def _write_market_journal_event( diff --git a/app/src/trading/auto/position_health.py b/app/src/trading/auto/position_health.py index c864547..985326b 100644 --- a/app/src/trading/auto/position_health.py +++ b/app/src/trading/auto/position_health.py @@ -2,11 +2,35 @@ 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 +from src.trading.execution.constants import ( + EXECUTION_QUALITY_BLOCKED, + EXECUTION_QUALITY_WARNING, + MARKET_VOLATILITY_HIGH_STATES, + POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT, + POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT, + POSITION_EXIT_PRESSURE_LOSS_PERCENT, + POSITION_HEALTH_DANGER, + POSITION_HEALTH_HEALTHY, + POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT, + POSITION_HEALTH_PNL_HARD_LOSS_PERCENT, + POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT, + POSITION_HEALTH_PNL_PRESSURE_PERCENT, + POSITION_HEALTH_PRESSURE, + POSITION_HEALTH_UNKNOWN, + POSITION_HEALTH_WATCH, + POSITION_MOMENTUM_STRONG, + POSITION_RISK_ELEVATED, + POSITION_RISK_HIGH, + POSITION_RISK_LOW, + POSITION_RISK_MODERATE, + POSITION_STOP_LOSS_RATIO_CRITICAL, + POSITION_STOP_LOSS_RATIO_WARNING, + POSITION_STOP_LOSS_RATIO_WATCH, + get_position_health_thresholds, +) class AutoPositionHealthMixin: @@ -26,8 +50,11 @@ class AutoPositionHealthMixin: state.position_exit_pressure = None return - pnl_percent = self._position_pnl_percent(state) - hold_seconds = self._position_hold_seconds(state) + # PnL % и время удержания больше не считаем здесь. + # Эти значения должны приходить из единого расчёта position_metrics.py + # через execution/position_runtime.py. + pnl_percent = safe_float(state.position_pnl_percent) + hold_seconds = state.position_hold_seconds trend_alignment = self._position_trend_alignment(state) adverse_momentum = self._has_adverse_position_momentum(state) @@ -70,41 +97,8 @@ class AutoPositionHealthMixin: 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)) + def _health_thresholds(self, state: AutoTradeState) -> dict[str, float]: + return get_position_health_thresholds(state.symbol) # определить давление на позицию по PnL def _position_pressure( @@ -125,16 +119,18 @@ class AutoPositionHealthMixin: return "FLAT" - if percent <= -0.8: + thresholds = self._health_thresholds(state) + + if percent <= thresholds["high_loss"]: return "HIGH_LOSS" - if percent <= -0.3: + if percent <= thresholds["loss"]: return "LOSS" - if percent >= 0.8: + if percent >= thresholds["strong_profit"]: return "STRONG_PROFIT" - if percent >= 0.3: + if percent >= thresholds["profit"]: return "PROFIT" return "FLAT" @@ -144,12 +140,21 @@ class AutoPositionHealthMixin: side = str(state.position_side or "NONE").upper() market_state = str(state.market_state or "").upper() trend = str(state.market_trend or "").upper() + htf_trend = str(getattr(state, "htf_trend", "") or "").upper() + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() if side == "NONE": return "NONE" + # HTF AGAINST важнее локального тренда: + # если старший таймфрейм против позиции, позиция считается рискованной. + if htf_alignment == "AGAINST": + return "AGAINST" + if side == "LONG": if market_state == "TREND_UP" or trend == "UP": + if htf_trend in {"DOWN"}: + return "NEUTRAL" return "ALIGNED" if market_state == "TREND_DOWN" or trend == "DOWN": @@ -157,6 +162,8 @@ class AutoPositionHealthMixin: if side == "SHORT": if market_state == "TREND_DOWN" or trend == "DOWN": + if htf_trend in {"UP"}: + return "NEUTRAL" return "ALIGNED" if market_state == "TREND_UP" or trend == "UP": @@ -169,17 +176,45 @@ class AutoPositionHealthMixin: side = str(state.position_side or "NONE").upper() momentum_direction = str(state.momentum_direction or "").upper() momentum_state = str(state.momentum_state or "").upper() + momentum_strength = safe_float(getattr(state, "momentum_strength", None)) or 0.0 + + current_interval_direction = str( + getattr(state, "current_interval_direction", "") or "" + ).upper() + current_interval_change_percent = safe_float( + getattr(state, "current_interval_change_percent", None) + ) + + current_interval_move_abs = abs(current_interval_change_percent or 0.0) + + current_interval_against_long = ( + current_interval_direction == "DOWN" + and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT + ) + + current_interval_against_short = ( + current_interval_direction == "UP" + and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT + ) if side == "LONG": return ( - momentum_direction == "DOWN" - or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + or current_interval_against_long + or ( + momentum_direction == "DOWN" + and momentum_strength >= POSITION_MOMENTUM_STRONG + ) ) if side == "SHORT": return ( - momentum_direction == "UP" - or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + or current_interval_against_short + or ( + momentum_direction == "UP" + and momentum_strength >= POSITION_MOMENTUM_STRONG + ) ) return False @@ -195,28 +230,68 @@ class AutoPositionHealthMixin: ) -> int: score = 100 percent = safe_float(pnl_percent) + stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None)) + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() + market_structure = str(getattr(state, "market_structure", "") or "").upper() + market_phase = str(getattr(state, "market_phase", "") or "").upper() + trend_quality = str(getattr(state, "market_trend_quality", "") or "").upper() + volatility = str(getattr(state, "market_volatility", "") or "").upper() if percent is not None: - if percent <= -1.0: - score -= 35 - elif percent <= -0.5: - score -= 22 + if percent <= POSITION_HEALTH_PNL_HARD_LOSS_PERCENT: + score -= 40 + elif percent <= POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT: + score -= 30 + elif percent <= POSITION_HEALTH_PNL_PRESSURE_PERCENT: + score -= 18 elif percent < 0: - score -= 10 - elif percent >= 0.8: + score -= 8 + elif percent >= POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT: score += 5 + # Если позиция прошла большую часть stop loss — ухудшаем score заранее. + if stop_loss_percent is not None and stop_loss_percent > 0: + loss_ratio = abs(percent) / stop_loss_percent if percent < 0 else 0.0 + + if loss_ratio >= POSITION_STOP_LOSS_RATIO_CRITICAL: + score -= 25 + elif loss_ratio >= POSITION_STOP_LOSS_RATIO_WARNING: + score -= 15 + if trend_alignment == "AGAINST": - score -= 25 + score -= 30 elif trend_alignment == "NEUTRAL": - score -= 8 + score -= 10 if adverse_momentum: - score -= 20 + score -= 25 - if state.execution_quality == "BLOCKED": + if htf_alignment == "AGAINST": + score -= 20 + elif htf_alignment == "NEUTRAL": + score -= 8 + + if market_structure == "MIXED": + score -= 12 + elif market_structure == "LH_LL" and state.position_side == "LONG": + score -= 18 + elif market_structure == "HH_HL" and state.position_side == "SHORT": + score -= 18 + + if market_phase in {"RANGE", "SQUEEZE"}: + score -= 10 + elif market_phase == "PULLBACK" and trend_alignment != "ALIGNED": + score -= 12 + + if trend_quality == "NOISY": + score -= 12 + + if volatility in MARKET_VOLATILITY_HIGH_STATES: + score -= 12 + + if state.execution_quality == EXECUTION_QUALITY_BLOCKED: score -= 15 - elif state.execution_quality == "WARNING": + elif state.execution_quality == EXECUTION_QUALITY_WARNING: score -= 8 if state.market_runtime_degraded: @@ -227,18 +302,18 @@ class AutoPositionHealthMixin: # классифицировать health status по score def _position_health_status(self, score: int | None) -> str: if score is None: - return "UNKNOWN" + return POSITION_HEALTH_UNKNOWN if score >= 80: - return "HEALTHY" + return POSITION_HEALTH_HEALTHY - if score >= 55: - return "WATCH" + if score >= 60: + return POSITION_HEALTH_WATCH - if score >= 35: - return "PRESSURE" + if score >= 40: + return POSITION_HEALTH_PRESSURE - return "DANGER" + return POSITION_HEALTH_DANGER # сформировать человекочитаемую причину health состояния def _position_health_reason( @@ -275,26 +350,86 @@ class AutoPositionHealthMixin: adverse_momentum: bool, ) -> tuple[str, str]: percent = safe_float(pnl_percent) + stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None)) + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() + market_structure = str(getattr(state, "market_structure", "") or "").upper() + volatility = str(getattr(state, "market_volatility", "") or "").upper() - if state.execution_quality == "BLOCKED": - return "HIGH", "исполнение заблокировано" + current_interval_direction = str( + getattr(state, "current_interval_direction", "") or "" + ).upper() + current_interval_change_percent = safe_float( + getattr(state, "current_interval_change_percent", None) + ) + current_interval_move_abs = abs(current_interval_change_percent or 0.0) - if percent is not None and percent <= -1.0: - return "HIGH", "сильная просадка позиции" + current_interval_against_position = ( + ( + state.position_side == "LONG" + and current_interval_direction == "DOWN" + ) + or ( + state.position_side == "SHORT" + and current_interval_direction == "UP" + ) + ) + + if state.execution_quality == EXECUTION_QUALITY_BLOCKED: + return POSITION_RISK_HIGH, "исполнение заблокировано" + + if percent is not None: + if percent <= POSITION_HEALTH_PNL_HARD_LOSS_PERCENT: + return POSITION_RISK_HIGH, "сильная просадка позиции" + + if stop_loss_percent is not None and stop_loss_percent > 0 and percent < 0: + loss_ratio = abs(percent) / stop_loss_percent + + if loss_ratio >= POSITION_STOP_LOSS_RATIO_CRITICAL: + return POSITION_RISK_HIGH, "позиция близко к stop loss" + + if loss_ratio >= POSITION_STOP_LOSS_RATIO_WARNING: + return POSITION_RISK_ELEVATED, "позиция прошла больше половины stop loss" if trend_alignment == "AGAINST" and adverse_momentum: - return "HIGH", "рынок движется против позиции" + return POSITION_RISK_HIGH, "рынок движется против позиции" + + if htf_alignment == "AGAINST" and adverse_momentum: + return POSITION_RISK_HIGH, "старший таймфрейм и momentum против позиции" + + if ( + state.position_side == "LONG" + and market_structure == "LH_LL" + ): + return POSITION_RISK_ELEVATED, "структура рынка против LONG" + + if ( + state.position_side == "SHORT" + and market_structure == "HH_HL" + ): + return POSITION_RISK_ELEVATED, "структура рынка против SHORT" + + if volatility in MARKET_VOLATILITY_HIGH_STATES and percent is not None and percent < 0: + return POSITION_RISK_ELEVATED, "убыток в высокой волатильности" if percent is not None and percent < 0: if trend_alignment == "AGAINST" or adverse_momentum: - return "ELEVATED", "убыток усиливается рыночным контекстом" + return POSITION_RISK_ELEVATED, "убыток усиливается рыночным контекстом" - return "MODERATE", "позиция в минусе" + return POSITION_RISK_MODERATE, "позиция в минусе" + + if current_interval_against_position and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT: + return POSITION_RISK_ELEVATED, "текущая 5м свеча против позиции" + + if current_interval_against_position and percent is not None and percent < 0: + return POSITION_RISK_ELEVATED, "убыток усиливается текущей 5м свечой" if adverse_momentum: - return "MODERATE", "momentum против позиции" + return POSITION_RISK_MODERATE, "momentum против позиции" - return "LOW", "критичных рисков нет" + if htf_alignment == "AGAINST": + return POSITION_RISK_MODERATE, "старший таймфрейм против позиции" + + return POSITION_RISK_LOW, "критичных рисков нет" # определить давление на выход из позиции def _position_exit_pressure( @@ -305,14 +440,22 @@ class AutoPositionHealthMixin: risk_level: str, ) -> str: percent = safe_float(pnl_percent) + stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None)) - if risk_level == "HIGH": + if risk_level == POSITION_RISK_HIGH: return "HIGH" - if risk_level == "ELEVATED": + if risk_level in {POSITION_RISK_ELEVATED, POSITION_RISK_MODERATE}: return "WATCH" - if percent is not None and percent <= -0.5: - return "WATCH" + if percent is not None: + if percent <= POSITION_EXIT_PRESSURE_LOSS_PERCENT: + return "WATCH" + + if stop_loss_percent is not None and stop_loss_percent > 0 and percent < 0: + loss_ratio = abs(percent) / stop_loss_percent + + if loss_ratio >= POSITION_STOP_LOSS_RATIO_WATCH: + 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_semantics.py similarity index 55% rename from app/src/trading/auto/position_intelligence.py rename to app/src/trading/auto/position_semantics.py index bf01b8e..777f494 100644 --- a/app/src/trading/auto/position_intelligence.py +++ b/app/src/trading/auto/position_semantics.py @@ -1,14 +1,37 @@ -# app/src/trading/auto/position_intelligence.py +# app/src/trading/auto/position_semantics.py from __future__ import annotations from src.core.numbers import safe_float from src.trading.auto.state import AutoTradeState +from src.trading.execution.constants import ( + POSITION_EXIT_DAMPING_MATURE_MULTIPLIER, + POSITION_EXIT_DAMPING_MATURE_SECONDS, + POSITION_EXIT_DAMPING_NEW_MULTIPLIER, + POSITION_EXIT_DAMPING_NEW_SECONDS, + POSITION_EXIT_SIGNAL_EXIT_CONFIDENCE, + POSITION_EXIT_SIGNAL_PROTECT_CONFIDENCE, + POSITION_EXIT_SIGNAL_WATCH_CONFIDENCE, + POSITION_GIVEBACK_HIGH_PERCENT, + POSITION_GIVEBACK_LOW_PERCENT, + POSITION_GIVEBACK_MEDIUM_PERCENT, + POSITION_LIFECYCLE_ACTIVE_SECONDS, + POSITION_LIFECYCLE_MATURE_SECONDS, + POSITION_LIFECYCLE_NEW_SECONDS, + POSITION_REVERSAL_ELEVATED_GIVEBACK_PERCENT, + POSITION_REVERSAL_HIGH_GIVEBACK_PERCENT, + POSITION_STALL_ADVERSE_MAE_PERCENT, + POSITION_STALL_CONFIRMED_SECONDS, + POSITION_STALL_DEVELOPING_SECONDS, + POSITION_STALL_EARLY_SECONDS, + POSITION_STALL_LOW_PROGRESS_MFE_PERCENT, + POSITION_STALL_LOW_PROGRESS_PNL_PERCENT, +) -class AutoPositionIntelligenceMixin: - # синхронизировать intelligence-состояние открытой позиции - def _sync_position_intelligence_state(self, state: AutoTradeState) -> None: +class AutoPositionSemanticsMixin: + # синхронизировать semantics-состояние открытой позиции + def _sync_position_semantics_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 @@ -27,11 +50,26 @@ class AutoPositionIntelligenceMixin: state.position_conviction_state = None state.position_exit_urgency = None state.position_reversal_risk = None + state.position_stall_state = None + state.position_stall_reason = None return lifecycle_stage = self._position_lifecycle_stage(state) hold_quality = self._position_hold_quality(state) - decay_state = self._position_decay_state(state) + + # Передаём свежий lifecycle_stage явно, + # чтобы decay не читал старое значение из state. + decay_state = self._position_decay_state( + state=state, + lifecycle_stage=lifecycle_stage, + ) + + # Сначала записываем базовые поля. + # Advanced analytics ниже обновит MFE/MAE/giveback/fatigue, + # а уже после этого можно корректно рассчитывать stall. + state.position_lifecycle_stage = lifecycle_stage + state.position_hold_quality = hold_quality + state.position_decay_state = decay_state self._sync_advanced_position_analytics( state=state, @@ -40,6 +78,12 @@ class AutoPositionIntelligenceMixin: decay_state=decay_state, ) + # Stall считаем после обновления MFE/MAE, + # иначе он может читать устаревшее значение position_mfe_percent. + stall_state, stall_reason = self._position_stall_state(state) + state.position_stall_state = stall_state + state.position_stall_reason = stall_reason + exit_confidence = self._position_exit_confidence( state=state, hold_quality=hold_quality, @@ -48,12 +92,14 @@ class AutoPositionIntelligenceMixin: 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( + + # Срочность выхода считаем после записи свежего exit_signal, + # иначе urgency может читать сигнал прошлого цикла. + state.position_exit_urgency = self._position_exit_urgency(state) + + state.position_intelligence_reason = self._position_semantics_reason( state=state, hold_quality=hold_quality, decay_state=decay_state, @@ -70,13 +116,13 @@ class AutoPositionIntelligenceMixin: if hold_seconds is None: return "UNKNOWN" - if hold_seconds < 60: + if hold_seconds < POSITION_LIFECYCLE_NEW_SECONDS: return "NEW" - if hold_seconds < 300: + if hold_seconds < POSITION_LIFECYCLE_ACTIVE_SECONDS: return "ACTIVE" - if hold_seconds < 900: + if hold_seconds < POSITION_LIFECYCLE_MATURE_SECONDS: return "MATURE" return "AGED" @@ -111,10 +157,15 @@ class AutoPositionIntelligenceMixin: return "NEUTRAL" # определить тип ухудшения позиции - def _position_decay_state(self, state: AutoTradeState) -> str: + def _position_decay_state( + self, + *, + state: AutoTradeState, + lifecycle_stage: str, + ) -> 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() + lifecycle = str(lifecycle_stage or "").upper() if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum: return "ACCELERATING_LOSS" @@ -142,6 +193,17 @@ class AutoPositionIntelligenceMixin: risk_level = str(state.position_risk_level or "").upper() exit_pressure = str(state.position_exit_pressure or "").upper() + market_quality = str( + getattr(state, "market_trend_quality", "") or "" + ).upper() + + pnl_percent = safe_float(getattr(state, "position_pnl_percent", None)) + hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) or 0.0 + + adverse_momentum = bool(getattr(state, "position_adverse_momentum", False)) + trend_alignment = str( + getattr(state, "position_trend_alignment", "") or "" + ).upper() if risk_level == "HIGH": score += 0.45 @@ -168,6 +230,53 @@ class AutoPositionIntelligenceMixin: if state.execution_quality == "BLOCKED": score += 0.10 + stall_state = str( + getattr(state, "position_stall_state", "") or "" + ).upper() + + # Если позиция застряла, повышаем внимание к выходу. + # Особенно важно для NOISY рынка: там долгое удержание около нуля + # часто просто накапливает комиссии и даёт серию мелких убытков. + if stall_state == "ADVERSE_STALLED": + score += 0.20 + elif stall_state == "NOISY_STALLED": + score += 0.15 + elif stall_state == "STALLED": + score += 0.10 + + # NOISY рынок не запрещает торговлю полностью, + # но позицию в шуме нужно сопровождать агрессивнее: + # если после входа позиция уже в минусе или momentum против неё, + # повышаем готовность к защите/выходу. + if market_quality == "NOISY": + if pnl_percent is not None and pnl_percent < 0: + score += 0.10 + + if adverse_momentum: + score += 0.12 + + if trend_alignment == "AGAINST": + score += 0.10 + + # Новую позицию не закрываем слишком агрессивно: + # первые минуты часто дают техническую просадку из-за spread/волны. + # + # Но если есть реальное ухудшение — HIGH risk, adverse momentum + # вместе с трендом против позиции — dampening не применяем. + severe_deterioration = ( + risk_level == "HIGH" + or ( + adverse_momentum + and trend_alignment == "AGAINST" + ) + ) + + if not severe_deterioration: + if hold_seconds < POSITION_EXIT_DAMPING_NEW_SECONDS: + score *= POSITION_EXIT_DAMPING_NEW_MULTIPLIER + elif hold_seconds < POSITION_EXIT_DAMPING_MATURE_SECONDS: + score *= POSITION_EXIT_DAMPING_MATURE_MULTIPLIER + return round(max(0.0, min(1.0, score)), 3) # определить semantic exit signal по confidence @@ -175,19 +284,19 @@ class AutoPositionIntelligenceMixin: if exit_confidence is None: return "NONE" - if exit_confidence >= 0.75: + if exit_confidence >= POSITION_EXIT_SIGNAL_EXIT_CONFIDENCE: return "EXIT" - if exit_confidence >= 0.50: + if exit_confidence >= POSITION_EXIT_SIGNAL_PROTECT_CONFIDENCE: return "REDUCE_OR_PROTECT" - if exit_confidence >= 0.30: + if exit_confidence >= POSITION_EXIT_SIGNAL_WATCH_CONFIDENCE: return "WATCH" return "HOLD" - # сформировать объяснение position intelligence - def _position_intelligence_reason( + # сформировать объяснение position semantics + def _position_semantics_reason( self, *, state: AutoTradeState, @@ -262,7 +371,6 @@ class AutoPositionIntelligenceMixin: 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 позиции @@ -281,7 +389,15 @@ class AutoPositionIntelligenceMixin: if current is None: return None - return round(min(0.0, current), 4) + previous_mae = safe_float(state.position_mae_percent) + + # MAE — это максимальное неблагоприятное движение за всю жизнь позиции. + # Поэтому мы не пересчитываем его от текущего PnL, + # а сохраняем самый глубокий исторический минус. + if previous_mae is None: + return round(min(0.0, current), 4) + + return round(min(previous_mae, current, 0.0), 4) # рассчитать процент отдачи прибыли от peak pnl def _position_giveback_percent(self, state: AutoTradeState) -> float | None: @@ -330,16 +446,16 @@ class AutoPositionIntelligenceMixin: elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}: score += 0.18 - if giveback >= 70: + if giveback >= POSITION_GIVEBACK_HIGH_PERCENT: score += 0.30 - elif giveback >= 45: + elif giveback >= POSITION_GIVEBACK_MEDIUM_PERCENT: score += 0.20 - elif giveback >= 25: + elif giveback >= POSITION_GIVEBACK_LOW_PERCENT: score += 0.10 - if hold_seconds >= 1800: + if hold_seconds >= POSITION_LIFECYCLE_MATURE_SECONDS * 2: score += 0.15 - elif hold_seconds >= 900: + elif hold_seconds >= POSITION_LIFECYCLE_MATURE_SECONDS: score += 0.08 if state.position_adverse_momentum: @@ -371,7 +487,10 @@ class AutoPositionIntelligenceMixin: fatigue = str(state.position_fatigue_state or "").upper() alignment = str(state.position_trend_alignment or "").upper() - if health == "DANGER" or fatigue == "EXHAUSTED": + if health == "DANGER": + return "BROKEN" + + if fatigue == "EXHAUSTED" and alignment == "AGAINST": return "BROKEN" if alignment == "AGAINST" or fatigue == "TIRED": @@ -388,7 +507,7 @@ class AutoPositionIntelligenceMixin: fatigue = str(state.position_fatigue_state or "").upper() risk = str(state.position_risk_level or "").upper() - if exit_signal == "EXIT" or risk == "HIGH": + if exit_signal == "EXIT" and risk == "HIGH": return "IMMEDIATE" if fatigue == "EXHAUSTED": @@ -408,13 +527,72 @@ class AutoPositionIntelligenceMixin: fatigue = str(state.position_fatigue_state or "").upper() adverse = bool(state.position_adverse_momentum) - if adverse and giveback >= 45: + if adverse and giveback >= POSITION_REVERSAL_HIGH_GIVEBACK_PERCENT: return "HIGH" - if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25: + if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= POSITION_REVERSAL_ELEVATED_GIVEBACK_PERCENT: return "ELEVATED" if adverse: return "MODERATE" - return "LOW" \ No newline at end of file + return "LOW" + + # определить, застряла ли позиция без нормального движения + def _position_stall_state(self, state: AutoTradeState) -> tuple[str, str]: + hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) or 0.0 + pnl_percent = safe_float(getattr(state, "position_pnl_percent", None)) + mfe = max( + safe_float(state.position_peak_pnl_percent) or 0.0, + safe_float(state.position_pnl_percent) or 0.0, + ) + mae = safe_float(getattr(state, "position_mae_percent", None)) or 0.0 + + market_quality = str( + getattr(state, "market_trend_quality", "") or "" + ).upper() + + adverse_momentum = bool( + getattr(state, "position_adverse_momentum", False) + ) + + trend_alignment = str( + getattr(state, "position_trend_alignment", "") or "" + ).upper() + + if hold_seconds < POSITION_STALL_EARLY_SECONDS: + return "EARLY", "позиция открыта недавно" + + if pnl_percent is None: + return "NONE", "нет данных PnL" + + # low_progress = позиция не дала нормального плюса + # и сейчас находится около нуля. + # MAE используем отдельно: если был глубокий минус, + # это уже не просто "стоит", а ухудшение качества позиции. + low_progress = ( + abs(pnl_percent) <= POSITION_STALL_LOW_PROGRESS_PNL_PERCENT + and mfe <= POSITION_STALL_LOW_PROGRESS_MFE_PERCENT + ) + had_adverse_excursion = mae <= POSITION_STALL_ADVERSE_MAE_PERCENT + + if not low_progress: + return "NONE", "позиция развивается" + + # Пока прошло меньше 10 минут — + # обычный рынок ещё может "раскачаться". + if hold_seconds < POSITION_STALL_DEVELOPING_SECONDS: + return "NONE", "позиция ещё развивается" + + # После 10 минут рынок уже начинает говорить сам за себя. + if adverse_momentum or trend_alignment == "AGAINST" or had_adverse_excursion: + return "ADVERSE_STALLED", "позиция застряла, рынок против неё" + + if market_quality == "NOISY": + return "NOISY_STALLED", "позиция застряла в шумном рынке" + + # Только для обычного рынка спустя длительное время. + if hold_seconds >= POSITION_STALL_CONFIRMED_SECONDS: + return "STALLED", "позиция долго не развивается" + + return "NONE", "критичного застоя нет" \ No newline at end of file diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 9ef9b45..697b28d 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -6,7 +6,7 @@ import asyncio import time from collections.abc import Callable -from typing import ClassVar +from typing import Any, ClassVar from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter @@ -31,8 +31,8 @@ class AutoTradeRunner: _bot: ClassVar[Bot | None] = None _chat_id: ClassVar[int | None] = None _message_id: ClassVar[int | None] = None - _render_text: ClassVar[staticmethod | None] = None - _render_markup: ClassVar[staticmethod | None] = None + _render_text: ClassVar[Any] = None + _render_markup: ClassVar[Any] = None _current_screen: ClassVar[str | None] = None _analysis_interval_seconds = 5 _ui_interval_seconds = 30 @@ -44,6 +44,14 @@ class AutoTradeRunner: _last_screen_state_key: ClassVar[str | None] = None _position_aligned_signal_log_interval_seconds = 900 _last_position_aligned_signal_log_at_by_key: dict[str, float] = {} + _edit_lock: ClassVar[asyncio.Lock | None] = None + + @classmethod + def edit_lock(cls) -> asyncio.Lock: + if cls._edit_lock is None: + cls._edit_lock = asyncio.Lock() + + return cls._edit_lock @classmethod def register_screen( @@ -53,13 +61,13 @@ class AutoTradeRunner: chat_id: int, message_id: int, render_text: Callable[[], str], - render_markup: Callable[[], object], + render_markup: Callable[[], Any], ) -> None: cls._bot = bot cls._chat_id = chat_id cls._message_id = message_id - cls._render_text = staticmethod(render_text) - cls._render_markup = staticmethod(render_markup) + cls._render_text = render_text + cls._render_markup = render_markup cls._last_text = None cls._last_semantic_text = None cls._last_screen_state_key = None @@ -169,6 +177,7 @@ class AutoTradeRunner: if cls._task is not None and not cls._task.done(): return + cls._last_event_version = EventBus.version() cls._task = asyncio.create_task(cls._worker()) @classmethod @@ -209,37 +218,53 @@ class AutoTradeRunner: state = service.get_state() + previous_event_version = cls._last_event_version current_event_version = EventBus.version() - has_important_event = current_event_version != cls._last_event_version + events = EventBus.events_after(previous_event_version) + has_important_event = bool(events) screen_state_key = cls._screen_state_key(state) has_screen_state_changed = screen_state_key != cls._last_screen_state_key if has_screen_state_changed: cls._last_screen_state_key = screen_state_key + force_refresh = False if has_important_event: + for event_version, event_type, payload in events: + if ( + event_type == "auto_decision_changed" + and cls._has_position_opened_event(events) + ): + continue + + if event_type in { + "paper_position_opened", + "paper_position_closed", + "paper_position_flipped", + }: + force_refresh = True + + try: + await cls._handle_important_event( + state=state, + event_type=event_type, + payload=payload, + ) + except Exception as exc: + cls._log_refresh_error( + "auto_event_handler_error", + { + "error": str(exc), + "error_type": type(exc).__name__, + "event_type": event_type, + "event_version": event_version, + }, + ) + cls._last_event_version = current_event_version - event_type, _ = EventBus.last_event() - force_refresh = event_type in { - "paper_position_opened", - "paper_position_closed", - "paper_position_flipped", - } - - try: - await cls._handle_important_event(state) - except Exception as exc: - cls._log_refresh_error( - "auto_event_handler_error", - { - "error": str(exc), - "error_type": type(exc).__name__, - }, - ) - try: await cls._refresh_screen( force=force_refresh or has_screen_state_changed @@ -258,14 +283,47 @@ class AutoTradeRunner: @classmethod async def process_last_event_now(cls) -> None: state = AutoTradeService().get_state() - await cls._handle_important_event(state) + + previous_event_version = cls._last_event_version + current_event_version = EventBus.version() + events = EventBus.events_after(previous_event_version) + + for event_version, event_type, payload in events: + try: + await cls._handle_important_event( + state=state, + event_type=event_type, + payload=payload, + ) + except Exception as exc: + cls._log_refresh_error( + "auto_event_handler_error", + { + "error": str(exc), + "error_type": type(exc).__name__, + "event_type": event_type, + "event_version": event_version, + }, + ) + + cls._last_event_version = current_event_version + + @classmethod + def _has_position_opened_event(cls, events) -> bool: + for _, event_type, payload in events: + if event_type == "paper_position_opened": + return True + + return False @classmethod async def _handle_important_event( cls, + *, state, + event_type: str | None, + payload: JsonDict | None, ) -> None: - event_type, payload = EventBus.last_event() if not isinstance(payload, dict): payload = {} @@ -277,15 +335,15 @@ class AutoTradeRunner: if signal not in {"BUY", "SELL"}: return - # Если сигнал совпадает с открытой позицией, не публикуем событие, - # чтобы не создавать избыточные уведомления - #if cls._is_position_aligned_signal(state=state, signal=signal): - # cls._log_position_aligned_signal_suppressed( - # state=state, - # payload=payload, - # signal=signal, - # ) - # return + signal_intent = str(payload.get("signal_intent") or "").upper() + + if signal_intent == "REINFORCE_POSITION": + cls._log_position_aligned_signal_suppressed( + state=state, + payload=payload, + signal=signal, + ) + return cls._publish_strong_signal_event(state=state, payload=payload) return @@ -485,11 +543,17 @@ class AutoTradeRunner: ) reason = str(payload.get("reason") or state.last_signal_reason or "—") - position_context = str(getattr(state, "position_side", "NONE") or "NONE").upper() - is_aligned_signal = cls._is_position_aligned_signal( - state=state, - signal=signal, - ) + signal_intent = str(payload.get("signal_intent") or "").upper() + if signal_intent == "ENTRY_CANDIDATE": + position_context = "NONE" + else: + position_context = str( + payload.get("position_side") + or getattr(state, "position_side", "NONE") + or "NONE" + ).upper() + + is_aligned_signal = signal_intent == "REINFORCE_POSITION" price_payload = cls._signal_price_payload( state=state, @@ -510,9 +574,13 @@ class AutoTradeRunner: source="auto_trade_runner", title=f"Auto strong signal {signal}", payload={ + "execution_block_title": getattr(state, "execution_block_title", None), + "execution_block_message": getattr(state, "execution_block_message", None), + "execution_block_action": getattr(state, "execution_block_action", None), "symbol": symbol, "strategy": strategy, "signal": signal, + "signal_intent": signal_intent, "repeat_count": repeat_count, "confidence": confidence, "leverage": leverage, @@ -521,6 +589,13 @@ class AutoTradeRunner: "position_side": position_context, "is_position_aligned_signal": is_aligned_signal, "decision_status": state.decision_status, + + # market_score передаём в уведомления, + # чтобы позже можно было показывать “Рынок · благоприятный · 82%” + # не только в экране, но и в событиях/алертах. + "market_score": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), + "semantic_lines": semantic_lines, **price_payload, }, @@ -531,11 +606,8 @@ class AutoTradeRunner: f"{symbol}:" f"{strategy}:" f"{signal}:" - f"{repeat_count}:" - f"{confidence:.2f}:" - f"{state.decision_status}:" - f"{reason}:" - f"aligned={is_aligned_signal}" + f"{signal_intent}:" + f"{state.decision_status}" ), ) ) @@ -577,6 +649,12 @@ class AutoTradeRunner: else state.leverage ), "strategy": state.strategy, + + # Фиксируем market_score на момент открытия/закрытия/flip, + # чтобы журнал и уведомления показывали рыночный контекст сделки. + "market_score": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), + "semantic_lines": semantic_lines, }, priority="normal", @@ -701,6 +779,12 @@ class AutoTradeRunner: getattr(state, "market_trend_quality", None), getattr(state, "market_phase", None), getattr(state, "market_phase_direction", None), + + # Общая оценка рынка влияет на заголовок блока “Рынок” + # и на adaptive size, поэтому изменение score должно сразу обновлять UI. + getattr(state, "market_score", None), + getattr(state, "market_score_label", None), + getattr(state, "entry_block_reason", None), getattr(state, "entry_block_message", None), getattr(state, "execution_quality", None), @@ -721,121 +805,99 @@ class AutoTradeRunner: getattr(state, "cycle_winning_trades", None), getattr(state, "last_execution_action", None), getattr(state, "last_execution_reason", None), + getattr(state, "execution_block_title", None), + getattr(state, "execution_block_message", None), + getattr(state, "execution_block_action", None), ] ) @classmethod async def _refresh_screen(cls, *, force: bool = False) -> None: - now = time.monotonic() + async with cls.edit_lock(): + now = time.monotonic() - if now < cls._retry_after_until: - cls._log_refresh_skip( - "retry_after_active", - {"retry_after_until": cls._retry_after_until, "now": now}, - ) - return - - if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds: - cls._log_refresh_skip( - "ui_interval_not_reached", - { - "elapsed": round(now - cls._last_ui_refresh_at, 2), - "interval": cls._ui_interval_seconds, - }, - ) - return - - if not all( - [ - cls._bot, - cls._chat_id, - cls._message_id, - cls._render_text, - cls._render_markup, - ] - ): - cls._log_refresh_skip( - "screen_not_registered", - { - "has_bot": cls._bot is not None, - "chat_id": cls._chat_id, - "message_id": cls._message_id, - "has_render_text": cls._render_text is not None, - "has_render_markup": cls._render_markup is not None, - }, - ) - return - - render_text = cls._render_text - render_markup = cls._render_markup - bot = cls._bot - - if ( - render_text is None - or render_markup is None - or bot is None - ): - return - - text = render_text() - semantic_text = build_auto_notification_text() - - if semantic_text == cls._last_semantic_text: - cls._log_refresh_skip("text_not_changed") - return - - try: - await bot.edit_message_text( - chat_id=cls._chat_id, - message_id=cls._message_id, - text=text, - reply_markup=render_markup(), - ) - cls._last_text = text - cls._last_semantic_text = semantic_text - cls._last_ui_refresh_at = now - - cls._log_refresh_success( - { - "chat_id": cls._chat_id, - "message_id": cls._message_id, - "text_length": len(text), - } - ) - - except TelegramRetryAfter as exc: - cls._retry_after_until = time.monotonic() + exc.retry_after + 15 - cls._last_ui_refresh_at = time.monotonic() - return - - except TelegramBadRequest as exc: - error_text = str(exc).lower() - - if "message is not modified" in error_text: - cls._last_text = text - cls._last_semantic_text = semantic_text - cls._last_ui_refresh_at = now - cls._log_refresh_skip("telegram_message_not_modified") - return - - if "message to edit not found" in error_text: - cls._message_id = None - cls._render_text = None - cls._render_markup = None - cls._last_text = None - cls._log_refresh_error( - "telegram_message_to_edit_not_found", - {"error": str(exc)}, + if now < cls._retry_after_until: + cls._log_refresh_skip( + "retry_after_active", + {"retry_after_until": cls._retry_after_until, "now": now}, ) return - cls._log_refresh_error( - "telegram_bad_request", - {"error": str(exc)}, - ) + if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds: + cls._log_refresh_skip( + "ui_interval_not_reached", + { + "elapsed": round(now - cls._last_ui_refresh_at, 2), + "interval": cls._ui_interval_seconds, + }, + ) + return - except Exception as exc: - cls._log_refresh_error( - "unexpected_refresh_error", - {"error": str(exc)}, - ) \ No newline at end of file + bot = cls._bot + chat_id = cls._chat_id + message_id = cls._message_id + render_text = cls._render_text + render_markup = cls._render_markup + + if ( + bot is None + or chat_id is None + or message_id is None + or render_text is None + or render_markup is None + ): + cls._log_refresh_skip("screen_not_registered") + return + + text = render_text() + semantic_text = build_auto_notification_text() + markup = render_markup() + + if ( + bot is not cls._bot + or chat_id != cls._chat_id + or message_id != cls._message_id + or render_text is not cls._render_text + or render_markup is not cls._render_markup + ): + cls._log_refresh_skip("screen_changed_during_render") + return + + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=markup, + ) + + cls._last_text = text + cls._last_semantic_text = semantic_text + cls._last_ui_refresh_at = now + + except TelegramRetryAfter as exc: + cls._retry_after_until = time.monotonic() + exc.retry_after + 15 + cls._last_ui_refresh_at = time.monotonic() + return + + except TelegramBadRequest as exc: + error_text = str(exc).lower() + + if "message is not modified" in error_text: + cls._last_text = text + cls._last_semantic_text = semantic_text + cls._last_ui_refresh_at = now + return + + if "message to edit not found" in error_text: + cls._message_id = None + cls._render_text = None + cls._render_markup = None + cls._last_text = None + cls._last_semantic_text = None + return + + cls._log_refresh_error("telegram_bad_request", {"error": str(exc)}) + + except Exception as exc: + cls._log_refresh_error("unexpected_refresh_error", {"error": str(exc)}) \ No newline at end of file diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index 15e616a..8cf145b 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -11,16 +11,13 @@ from src.trading.auto.state import AutoTradeState class AutoTradeService(AutoLifecycleMixin): - # ========================================================= # GLOBAL SERVICE STATE - # ========================================================= - # единый runtime state автоторговли # хранит: # - сигналы # - market context # - execution context - # - pnl + # - PnL открытой позиции и реализованный PnL # - lifecycle # - protection state _state = AutoTradeState() @@ -32,52 +29,40 @@ class AutoTradeService(AutoLifecycleMixin): # интервал между auto-trading циклами # run_cycle() вызывается каждые N секунд - _loop_interval_seconds = 5 + _loop_interval_seconds: int = 5 - # ========================================================= # SIGNAL CONFIRMATION ENGINE - # ========================================================= - # минимальное количество одинаковых BUY/SELL подряд # чтобы сигнал считался подтвержденным - _confirm_repeats = 2 + _confirm_repeats: int = 2 # минимальное время удержания сигнала # перед execution - _confirm_min_duration_seconds = 10 + _confirm_min_duration_seconds: int = 10 - # ========================================================= # EXECUTION CONFIDENCE RULES - # ========================================================= - # минимальный confidence для READY state # ниже -> сигнал не считается готовым - _ready_confidence = 0.3 + _ready_confidence = 0.45 # минимальный execution confidence # для реального допуска execution engine - _execution_confidence_required_score = 0.55 + _execution_confidence_required_score = 0.65 - # ========================================================= # RUNTIME TTL - # ========================================================= - # время жизни signal runtime # после ttl сигнал считается устаревшим - _signal_ttl_seconds = 90 + _signal_ttl_seconds: int = 90 # время жизни market analysis runtime # после ttl market context считается stale - _market_analysis_ttl_seconds = 180 + _market_analysis_ttl_seconds: int = 180 # последний logged runtime expiration key # нужен чтобы не спамить одинаковыми логами _last_logged_runtime_expired_key: str | None = None - # ========================================================= # SIGNAL MEMORY - # ========================================================= - # уникальный ключ последнего сигнала # используется для deduplication _last_signal_key: str | None = None @@ -100,40 +85,20 @@ class AutoTradeService(AutoLifecycleMixin): # нужен для 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 + _same_signal_count: int = 0 - # ========================================================= # EXECUTION SNAPSHOT VALIDATION - # ========================================================= - # максимальный допустимый возраст execution snapshot # старше -> snapshot stale - _max_snapshot_age_seconds = 5.0 + _max_snapshot_age_seconds: float = 5.0 # warning threshold snapshot age # выше -> degraded execution quality - _warning_snapshot_age_seconds = 2.0 + _warning_snapshot_age_seconds: float = 2.0 - # ========================================================= # SPREAD RISK THRESHOLDS - # ========================================================= - # asset-specific spread thresholds # # warning_enter: diff --git a/app/src/trading/auto/signal_runtime.py b/app/src/trading/auto/signal_runtime.py index 35ac124..1514850 100644 --- a/app/src/trading/auto/signal_runtime.py +++ b/app/src/trading/auto/signal_runtime.py @@ -149,6 +149,12 @@ class AutoSignalRuntimeMixin: state.is_signal_ready = False state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds + 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 + if signal == "HOLD": state.signal_confirmation_seconds = 0 state.signal_confirmation_missing_repeats = self._confirm_repeats @@ -160,15 +166,12 @@ class AutoSignalRuntimeMixin: 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 - ) + 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( @@ -369,10 +372,17 @@ class AutoSignalRuntimeMixin: signal=state.last_signal, ) - if ( + ready_changed = ( previous_decision_status != state.decision_status and state.decision_status == "READY" - ): + ) + + ready_signal_changed = ( + previous_signal != state.last_signal + and state.decision_status == "READY" + ) + + if ready_changed or ready_signal_changed: self._log_ready_signal( state=state, signal=state.last_signal, @@ -390,13 +400,19 @@ class AutoSignalRuntimeMixin: "signal_intent": signal_intent, "repeat_count": state.last_signal_repeat_count, "confidence": state.last_signal_confidence, + "symbol": state.symbol, + "strategy": state.strategy, }, ) - if previous_decision_status != state.decision_status: + if ( + previous_decision_status != state.decision_status + or ready_signal_changed + ): EventBus.emit( "auto_decision_changed", { + "previous_signal": previous_signal, "previous_decision_status": previous_decision_status, "decision_status": state.decision_status, "signal": state.last_signal, @@ -485,10 +501,13 @@ class AutoSignalRuntimeMixin: if normalized_signal not in {"BUY", "SELL"}: return - snapshot = ExchangeService().get_market_snapshot( - state.symbol, - runtime_key="auto", - ) + try: + snapshot = ExchangeService().get_market_snapshot( + state.symbol, + runtime_key="auto", + ) + except Exception: + snapshot = {} try: JournalService().log_ui_info( @@ -499,24 +518,141 @@ class AutoSignalRuntimeMixin: screen="auto", action="signal_ready", payload={ - "strategy": state.strategy, + # ---------- Event ---------- + "event_type": "signal_ready", + "action": "signal_ready", + "is_aggregated": False, + "is_strong_signal": confidence > self._ready_confidence, + + # ---------- Runtime ---------- "status": state.status, + "strategy": state.strategy, "symbol": state.symbol, + "cycle_number": state.cycle_number, + + # ---------- Signal ---------- "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 ---------- "confirmation_seconds": state.signal_confirmation_seconds, "confirmation_required_seconds": state.signal_confirmation_required_seconds, + "confirmation_missing_repeats": state.signal_confirmation_missing_repeats, "confirmation_progress": state.signal_confirmation_progress, + "confirmation_reason": state.signal_confirmation_reason, + + # ---------- Decision ---------- + "decision_status": state.decision_status, + "decision_reason": state.decision_reason, + "is_signal_confirmed": state.is_signal_confirmed, + "is_signal_ready": state.is_signal_ready, + + # ---------- Position Context ---------- + "position_side": state.position_side, + "entry_price": state.entry_price, + "position_size": state.position_size, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "current_trade_id": state.current_trade_id, + "current_trade_cycle_number": state.current_trade_cycle_number, + + # ---------- Risk Settings ---------- + "risk_percent": state.risk_percent, + "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, + "allocated_balance_usd": state.allocated_balance_usd, + "leverage": state.leverage, + + # ---------- Execution Confidence ---------- + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_required_score": state.execution_confidence_required_score, + "execution_confidence_reason": state.execution_confidence_reason, + "execution_confidence_factors": state.execution_confidence_factors, + + # ---------- Execution Quality ---------- + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_quality_message": state.execution_quality_message, + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + + # ---------- Live Snapshot ---------- "bid_price": snapshot.get("bid_price"), "ask_price": snapshot.get("ask_price"), "last_price": snapshot.get("last_price"), + + # ---------- Market Score ---------- + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + + # ---------- Market ---------- + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_volatility": state.market_volatility, + "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, + + # ---------- Candle ---------- + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + + # ---------- Structure ---------- + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, + + # ---------- Trend Quality ---------- + "market_trend_gap_percent": state.market_trend_gap_percent, + "market_trend_consistency": state.market_trend_consistency, + "market_trend_efficiency": state.market_trend_efficiency, + "trend_quality_score": state.trend_quality_score, + "ema_distance_atr_ratio": state.ema_distance_atr_ratio, + "ema_distance_state": state.ema_distance_state, + "entry_timing_state": state.entry_timing_state, + "entry_timing_reason": state.entry_timing_reason, + + # ---------- Momentum / Breakout ---------- + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_change_percent": state.momentum_change_percent, + "momentum_strength": state.momentum_strength, + "breakout_level": state.breakout_level, + "breakout_distance_percent": state.breakout_distance_percent, + "breakout_reason": state.breakout_reason, + + # ---------- HTF ---------- + "htf_interval": state.htf_interval, + "htf_atr_percent": state.htf_atr_percent, + "htf_atr_percent_baseline": state.htf_atr_percent_baseline, + "htf_volatility_ratio": state.htf_volatility_ratio, + "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, + + # ---------- Runtime Health ---------- + "market_runtime_degraded": state.market_runtime_degraded, + "runtime_expired_reason": state.runtime_expired_reason, + "runtime_expired_message": state.runtime_expired_message, + "market_is_open": state.market_is_open, + "market_status": state.market_status, + "market_status_message": state.market_status_message, }, ) except Exception: @@ -536,7 +672,36 @@ class AutoSignalRuntimeMixin: if signal_age > self._signal_ttl_seconds: previous_signal = state.last_signal - self._reset_signal_tracking() + # Сбрасываем только signal runtime. + # Нельзя вызывать _reset_signal_tracking(), потому что она также + # очищает market/HTF/momentum context. + 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.last_signal = "HOLD" + state.last_signal_repeat_count = 0 + state.last_signal_confidence = 0.0 + state.last_signal_reason = None + state.signal_started_at = None + state.signal_updated_at = None + state.decision_status = "WAITING" + state.decision_reason = "Сигнал устарел." + state.is_signal_confirmed = False + state.is_signal_ready = False + 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.execution_confidence_score = None + state.execution_confidence_level = None + state.execution_confidence_reason = None + state.execution_confidence_factors = None + state.execution_confidence_required_score = self._execution_confidence_required_score state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED" state.runtime_expired_message = "сигнал устарел и был сброшен" @@ -576,6 +741,19 @@ class AutoSignalRuntimeMixin: state.market_trend_quality = None state.market_phase = None state.market_phase_direction = None + state.current_interval_change_percent = None + state.current_interval_direction = None + state.current_interval_label = None + state.last_closed_candle_change_percent = None + state.last_closed_candle_direction = None + # Сбрасываем общую оценку рынка вместе с market context, + # чтобы UI не показывал старый процент после истечения TTL. + state.market_score = None + state.market_score_label = None + state.market_long_score = None + state.market_short_score = None + state.market_structure = None + state.market_structure_reason = None state.market_trend_gap_percent = None state.market_trend_consistency = None state.market_trend_efficiency = None @@ -593,6 +771,15 @@ class AutoSignalRuntimeMixin: state.htf_atr_percent_baseline = None state.htf_volatility_ratio = None state.htf_volatility = None + state.htf_market_state = None + state.htf_trend = None + state.htf_trend_strength = None + state.htf_trend_quality = None + state.htf_market_phase = None + state.htf_alignment = None + state.htf_confirmation_score = None + state.htf_reason = None + state.momentum_state = None state.momentum_direction = None state.momentum_change_percent = None @@ -600,6 +787,7 @@ class AutoSignalRuntimeMixin: 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 = "анализ рынка устарел" @@ -664,7 +852,16 @@ class AutoSignalRuntimeMixin: signal_score = self._clamp_score(confidence) confirmation_score = self._clamp_score(state.signal_confirmation_progress) - market_score = self._market_confidence_score(state) + + # ВАЖНО: + # market_score теперь считается с учётом направления сигнала. + # Раньше BUY мог получить хороший market_score просто потому, + # что рынок трендовый, даже если тренд/моментум были против BUY. + market_score = self._market_confidence_score( + state=state, + signal=signal, + ) + execution_quality_confidence_score = cast( Callable[[AutoTradeState], float], getattr(self, "_execution_quality_confidence_score"), @@ -687,14 +884,27 @@ class AutoSignalRuntimeMixin: state.execution_confidence_factors = { "signal_score": round(signal_score, 3), "confirmation_score": round(confirmation_score, 3), + # market_score здесь — направленная рыночная оценка 0.0..1.0 + # именно для текущего BUY / SELL сигнала. + # state.market_score — общая оценка рынка 0..100 без привязки к сигналу. "market_score": round(market_score, 3), + "market_score_raw": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), "execution_score": round(execution_score, 3), "required_score": self._execution_confidence_required_score, + "signal": signal, "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, + "current_interval_change_percent": getattr(state, "current_interval_change_percent", None), + "current_interval_direction": getattr(state, "current_interval_direction", None), + "current_interval_label": getattr(state, "current_interval_label", None), + "market_structure": getattr(state, "market_structure", None), + "market_structure_reason": getattr(state, "market_structure_reason", None), + "htf_alignment": getattr(state, "htf_alignment", None), + "htf_confirmation_score": getattr(state, "htf_confirmation_score", None), "execution_quality": state.execution_quality, "execution_quality_reason": state.execution_quality_reason, "spread_percent": state.spread_percent, @@ -708,71 +918,215 @@ class AutoSignalRuntimeMixin: } # рассчитать 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 + def _market_confidence_score( + self, + *, + state: AutoTradeState, + signal: str, + ) -> float: + market_state = str(state.market_state or "").upper() + market_trend = str(state.market_trend or "").upper() + strength = str(state.market_trend_strength or "").upper() + quality = str(state.market_trend_quality or "").upper() + phase = str(state.market_phase or "").upper() + current_interval_direction = str( + getattr(state, "current_interval_direction", "") or "" + ).upper() + current_interval_change_percent = safe_float( + getattr(state, "current_interval_change_percent", None) + ) + ema_distance_state = str(state.ema_distance_state or "").upper() + entry_timing_state = str(state.entry_timing_state or "").upper() + momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() + momentum_state = str(getattr(state, "momentum_state", "") or "").upper() trend_quality_score = safe_float(state.trend_quality_score) + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() + htf_confirmation_score = safe_float(getattr(state, "htf_confirmation_score", None)) + market_structure = str(getattr(state, "market_structure", "") or "").upper() + normalized_signal = str(signal or "").upper() - if market_state in { - "HIGH_VOLATILITY", - "LOW_VOLATILITY", - "RANGE", - "UNKNOWN", - None, - "", - }: + early_impulse_market = ( + market_state == "RANGE" + and phase == "IMPULSE" + and htf_alignment in {"ALIGNED", "SAME_INTERVAL"} + and ( + ( + normalized_signal == "BUY" + and market_trend == "UP" + and momentum_direction == "UP" + and momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + ) + or ( + normalized_signal == "SELL" + and market_trend == "DOWN" + and momentum_direction == "DOWN" + and momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + ) + ) + ) + + current_interval_supports_signal = ( + ( + normalized_signal == "BUY" + and current_interval_direction == "UP" + ) + or ( + normalized_signal == "SELL" + and current_interval_direction == "DOWN" + ) + ) + + current_interval_against_signal = ( + ( + normalized_signal == "BUY" + and current_interval_direction == "DOWN" + ) + or ( + normalized_signal == "SELL" + and current_interval_direction == "UP" + ) + ) + + current_interval_move_abs = abs(current_interval_change_percent or 0.0) + + if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "UNKNOWN", ""}: + return 0.15 + + if market_state == "RANGE" and not early_impulse_market: + return 0.15 + + # Жёсткая защита от входа против локального тренда. + if normalized_signal == "BUY" and market_trend == "DOWN": + return 0.05 + + if normalized_signal == "SELL" and market_trend == "UP": + return 0.05 + + # Жёсткая защита от входа против momentum. + if normalized_signal == "BUY" and momentum_direction == "DOWN": + return 0.05 + + if normalized_signal == "SELL" and momentum_direction == "UP": + return 0.05 + + # После ужесточения фильтров лучше не давать высокий confidence, + # если momentum вообще не подтверждает направление входа. + if normalized_signal == "BUY" and momentum_direction != "UP": return 0.25 - score = 0.65 + if normalized_signal == "SELL" and momentum_direction != "DOWN": + return 0.25 + + # HTF против входа должен почти обнулять рыночную часть confidence. + if htf_alignment == "AGAINST": + return 0.05 + + if htf_alignment == "UNKNOWN": + return 0.25 + + if htf_confirmation_score is not None and htf_confirmation_score < 0.55: + return 0.35 + + # Структура против направления входа. + if normalized_signal == "BUY" and market_structure == "LH_LL": + return 0.10 + + if normalized_signal == "SELL" and market_structure == "HH_HL": + return 0.10 + + if market_structure == "MIXED": + score_penalty_for_structure = 0.08 + else: + score_penalty_for_structure = 0.0 + + score = 0.60 + score -= score_penalty_for_structure + + if early_impulse_market: + score += 0.10 + + # “Сейчас (5м)” — короткий подтверждающий фактор. + # Он не открывает сделку сам по себе, но усиливает или ослабляет market_score. + if current_interval_supports_signal: + score += 0.05 + + if current_interval_against_signal: + score -= 0.10 + + if current_interval_move_abs >= 0.12: + score -= 0.05 if strength == "STRONG": - score += 0.2 + score += 0.16 elif strength == "NORMAL": - score += 0.1 + score += 0.08 elif strength == "WEAK": score -= 0.25 if quality == "CLEAN": - score += 0.12 + score += 0.10 elif quality == "NORMAL": score += 0.04 elif quality == "NOISY": - score -= 0.25 + score -= 0.12 if phase == "IMPULSE": - score += 0.1 + score += 0.08 elif phase == "PULLBACK": score -= 0.25 elif phase in {"RANGE", "SQUEEZE"}: - score -= 0.3 + score -= 0.35 if ema_distance_state == "HEALTHY": score += 0.08 elif ema_distance_state == "EXTENDED": - score -= 0.08 + score -= 0.10 elif ema_distance_state == "COMPRESSED": - score -= 0.18 + score -= 0.20 elif ema_distance_state == "OVEREXTENDED": - score -= 0.35 + score -= 0.40 if entry_timing_state == "NORMAL": score += 0.08 elif entry_timing_state == "EARLY": - score -= 0.05 + score -= 0.08 elif entry_timing_state == "LATE": - score -= 0.2 + score -= 0.25 elif entry_timing_state == "CHASING": - score -= 0.35 + score -= 0.40 + + if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}: + score += 0.06 + elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}: + score += 0.04 + + current_interval_penalty = 0.0 + + if normalized_signal == "BUY" and current_interval_direction == "DOWN": + current_interval_penalty = 0.08 + + if normalized_signal == "SELL" and current_interval_direction == "UP": + current_interval_penalty = 0.08 if trend_quality_score is not None: if trend_quality_score >= 0.7: - score += 0.08 + score += 0.06 elif trend_quality_score < 0.45: - score -= 0.15 + score -= 0.18 + + if htf_alignment == "ALIGNED": + score += 0.12 + + if htf_confirmation_score is not None and htf_confirmation_score >= 0.75: + score += 0.06 + + if normalized_signal == "BUY" and market_structure == "HH_HL": + score += 0.08 + + if normalized_signal == "SELL" and market_structure == "LH_LL": + score += 0.08 + + score -= current_interval_penalty return self._clamp_score(score) diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index 22e7212..6ed2399 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -14,14 +14,11 @@ class AutoTradeState: strategy: str | None = "TREND" # торговый инструмент - symbol: str = "BTC/USD_LEVERAGE" + symbol: str = "ETH/USD_LEVERAGE" # риск на одну сделку в % risk_percent: float | None = 1.0 - # текущий PnL - pnl_usd: float = 0.0 - # время последней проверки last_check_at: str | None = None @@ -106,6 +103,15 @@ class AutoTradeState: position_exit_urgency: str | None = None position_reversal_risk: str | None = None + # stall-состояние позиции: + # NONE — позиция развивается нормально + # EARLY — ещё рано оценивать + # STALLED — позиция стоит на месте + # NOISY_STALLED — позиция застряла в шумном рынке + # ADVERSE_STALLED — позиция застряла и рынок начинает идти против неё + position_stall_state: str | None = None + position_stall_reason: str | None = None + # autonomous trade management autonomous_action: str | None = None autonomous_action_reason: str | None = None @@ -153,7 +159,7 @@ class AutoTradeState: stop_loss_percent: float | None = 1.0 # take profit по движению цены в % - take_profit_percent: float | None = None + take_profit_percent: float | None = 2.0 # максимальный допустимый paper-убыток в USD max_loss_usd: float | None = None @@ -164,6 +170,11 @@ class AutoTradeState: # последняя причина блокировки execution execution_block_reason: str | None = None + # человекочитаемая блокировка совершения сделок для UI / Telegram + execution_block_title: str | None = None + execution_block_message: str | None = None + execution_block_action: str | None = None + # причина авто-уменьшения размера позиции execution_size_adjustment_reason: str | None = None @@ -182,6 +193,24 @@ class AutoTradeState: # количество прибыльных закрытых сделок cycle_winning_trades: int = 0 + # количество убыточных сделок в текущем цикле + cycle_losing_trades: int = 0 + + # серия убыточных сделок подряд + cycle_consecutive_losses: int = 0 + + # активна ли cooldown-блокировка после серии убытков + loss_cooldown_active: bool = False + + # причина cooldown-блокировки + loss_cooldown_reason: str | None = None + + # сумма комиссий за сделки RT в текущем цикле + cycle_trade_fees_usd: float = 0.0 + + # сумма списаний/начислений за левередж в текущем цикле + cycle_overnight_fees_usd: float = 0.0 + # время запуска текущего цикла cycle_started_at: float | None = None @@ -230,6 +259,38 @@ class AutoTradeState: # направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN market_phase_direction: str | None = None + # общая оценка рынка 0..100 на основе всех market-метрик: + # HTF trend, локальный trend, фаза, структура, волатильность, качество, timing. + market_score: float | None = None + + # человекочитаемая категория market_score: + # отличный / благоприятный / нейтральный / сложный / неблагоприятный + market_score_label: str | None = None + + # направленная оценка входа 0..100. + # market_long_score — насколько хорош вход в Long. + # market_short_score — насколько хорош вход в Short. + # Это не дубль market_score: market_score = общий рынок, + # long/short score = оценка конкретного направления. + market_long_score: float | None = None + market_short_score: float | None = None + + # последняя полностью закрытая свеча. + # В Dzengi последняя candle[-1] обычно текущая формирующаяся, + # поэтому закрытая свеча берётся как candle[-2]. + last_closed_candle_change_percent: float | None = None + last_closed_candle_direction: str | None = None + + # движение внутри текущей свечи/интервала анализа. + # Используется как short-term фактор для входа/выхода и UI. + current_interval_change_percent: float | None = None + current_interval_direction: str | None = None + current_interval_label: str | None = None + + # структура рынка: HH_HL / LH_LL / MIXED / UNKNOWN + market_structure: str | None = None + market_structure_reason: str | None = None + # advanced trend quality metrics market_trend_gap_percent: float | None = None market_trend_consistency: float | None = None @@ -253,6 +314,16 @@ class AutoTradeState: htf_volatility_ratio: float | None = None htf_volatility: str | None = None + # higher timeframe trend context + htf_market_state: str | None = None + htf_trend: str | None = None + htf_trend_strength: str | None = None + htf_trend_quality: str | None = None + htf_market_phase: str | None = None + htf_alignment: str | None = None + htf_confirmation_score: float | None = None + htf_reason: str | None = None + # состояние momentum/breakout semantic engine # NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN momentum_state: str | None = None diff --git a/app/src/trading/diagnostics/formatter.py b/app/src/trading/diagnostics/formatter.py index 73464a6..06b6b7e 100644 --- a/app/src/trading/diagnostics/formatter.py +++ b/app/src/trading/diagnostics/formatter.py @@ -14,71 +14,50 @@ class SemanticDiagnosticFormatter: status = snapshot.get("status", {}) signal = snapshot.get("signal", {}) market = snapshot.get("market", {}) - momentum = snapshot.get("momentum", {}) 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", {}) - - mode = str(summary.get("mode") or "EXPANDED") - has_position = self._has_position(position) - if has_position: - mode = "EXPANDED" - if str(status.get("status") or "").upper() == "OFF": - sections = [ - self._diagnostics_title(status), - self._status_block(status), - ] + return self._diagnostics_title(status) - for item in exchange_statuses: - sections.append(self._runtime_exchange_block(item)) + header_lines = [self._diagnostics_title(status)] - return "\n\n".join( - section.strip() - for section in sections - if section and section.strip() - ).strip() + live_warning = self._live_stream_warning(runtime) + if live_warning: + header_lines.append(live_warning) + + sections = ["\n".join(header_lines)] - sections = [ - self._headline_block( - summary, - status, - position, - 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 has_position: + # При открытой позиции сначала показываем саму позицию: + # сторона, вход, текущая цена, размер, объём, SL/TP. + position_block = self._position_block(position) + if position_block: + sections.append(position_block) - if mode != "COMPACT": - if has_position: - sections.append(self._position_block(position)) - position_health_block = self._position_health_block(position) - if position_health_block: - sections.append(position_health_block) + # Затем показываем здоровье позиции. + position_health_block = self._position_health_block(position) + if position_health_block: + sections.append(position_health_block) - if self._has_adaptive_size(adaptive): - sections.append(self._adaptive_block(adaptive)) - - sections.append(self._analytics_block(summary, runtime, execution)) - sections.append(self._status_block(status)) + sections.append(self._market_block(market)) + else: + sections.extend([ + self._execution_block(execution, market=market), + self._signal_block(signal, market=market), + self._market_block(market), + ]) return "\n\n".join( section.strip() @@ -88,7 +67,12 @@ class SemanticDiagnosticFormatter: def _diagnostics_title(self, status: JsonDict) -> str: symbol = self._asset_symbol(status.get("symbol")) - return f"🔬 Диагностика · {symbol}" + strategy = self._strategy_title(status.get("strategy")) + + if strategy == "—": + return f"📊 Анализ рынка · {symbol}" + + return f"📊 Анализ рынка · {symbol} · {strategy}" def build_notification_reason_lines(self, snapshot: JsonDict, *, limit: int = 2) -> list[str]: signal = snapshot.get("signal", {}) @@ -131,22 +115,17 @@ class SemanticDiagnosticFormatter: ) -> str: severity = data.get("severity") assessment = data.get("assessment") or self._human(severity) - - symbol = self._asset_symbol(status.get("symbol")) headline_mode = str(data.get("headline_mode") or "ENTRY") - severity_icon = self._severity_icon(severity) - if headline_mode == "POSITION": return self._position_headline( - data=data, + status=status, position=position or {}, ) return self._entry_headline( data=data, - symbol=symbol, - severity_icon=severity_icon, + status=status, assessment=assessment, market=market or {}, momentum=momentum or {}, @@ -156,8 +135,7 @@ class SemanticDiagnosticFormatter: self, *, data: JsonDict, - symbol: str, - severity_icon: str, + status: JsonDict, assessment: str, market: JsonDict | None = None, momentum: JsonDict | None = None, @@ -165,8 +143,7 @@ class SemanticDiagnosticFormatter: blockers = data.get("blockers") or [] lines = [ - f"🔬 Диагностика · {symbol}", - "", + self._diagnostics_title(status), self._headline_status_line( severity=data.get("severity"), assessment=assessment, @@ -199,16 +176,6 @@ class SemanticDiagnosticFormatter: data = data or {} if text == "RED": - blockers_text = self._blockers_text(data) - - if ( - "бирж" in blockers_text - or "перерыв" in blockers_text - or "рынок закрыт" in blockers_text - or "торги временно" in blockers_text - ): - return "⛔️ Вход заблокирован" - return "⛔️ Вход заблокирован" if text == "WAITING": @@ -299,10 +266,10 @@ class SemanticDiagnosticFormatter: if execution == "READY": add("Условия входа готовы") - if market_state == "HIGH_VOLATILITY" or market_volatility == "HIGH_VOLATILITY": + if market_state == "HIGH_VOLATILITY" or market_volatility in {"HIGH", "HIGH_VOLATILITY"}: add("Рынок перегрет") - elif market_state == "LOW_VOLATILITY" or market_volatility == "LOW_VOLATILITY": + elif market_state == "LOW_VOLATILITY" or market_volatility in {"LOW", "LOW_VOLATILITY"}: add("Движения мало") elif market_state == "RANGE" or market_phase == "RANGE": @@ -377,16 +344,19 @@ class SemanticDiagnosticFormatter: return reasons[:2] - def _position_headline(self, *, data: JsonDict, position: JsonDict) -> str: - symbol = self._asset_symbol(data.get("symbol")) + def _position_headline( + self, + *, + status: JsonDict, + position: JsonDict, + ) -> str: pnl_value = safe_float(position.get("unrealized_pnl_usd")) or 0.0 icon = "🟢" if pnl_value >= 0 else "🔴" sign = "+" if pnl_value >= 0 else "−" return "\n".join([ - f"🔬 Диагностика · {symbol}", - "", + self._diagnostics_title(status), f"Позиция {icon} {sign}$ {self._money(abs(pnl_value))}", ]) @@ -419,7 +389,14 @@ class SemanticDiagnosticFormatter: return "\n".join(lines) - def _execution_block(self, data: JsonDict) -> str: + def _execution_block( + self, + data: JsonDict, + *, + market: JsonDict | None = None, + ) -> str: + market = market or {} + quality = str(data.get("quality") or "") reason = str( data.get("quality_reason") @@ -427,13 +404,13 @@ class SemanticDiagnosticFormatter: or "" ) - title = self._entry_conditions_title( - quality=quality, - semantic_status=data.get("semantic_status"), - reason=reason, - ) - - lines = [title] + lines = [ + self._entry_conditions_title( + quality=quality, + semantic_status=data.get("semantic_status"), + reason=reason, + ) + ] lines.append( "• Данные: " @@ -448,13 +425,55 @@ class SemanticDiagnosticFormatter: if spread_line: lines.append(spread_line) - explanation = self._execution_explanation(data) + timing_line = self._entry_timing_line( + execution=data, + market=market, + ) + + if timing_line: + lines.append(timing_line) - if explanation: - lines.append(explanation) - return "\n".join(lines) - + + def _entry_timing_line( + self, + *, + execution: JsonDict, + market: JsonDict, + ) -> str: + state = str( + market.get("entry_timing_state") + or market.get("entry_timing") + or "" + ).upper() + + label_map = { + "EARLY": "ранний", + "NORMAL": "нормальный", + "LATE": "поздний", + "CHASING": "погоня за движением", + "UNKNOWN": "неясный", + } + + label = label_map.get(state) + + if not label: + semantic_status = str(execution.get("semantic_status") or "").upper() + quality = str(execution.get("quality") or "").upper() + + if semantic_status == "READY": + label = "готов" + elif quality == "BLOCKED": + label = "плохой" + elif quality == "WARNING": + label = "рискованный" + elif semantic_status in {"IDLE", "WAITING_SIGNAL"}: + label = "подтверждение" + else: + label = "ожидание" + + return f"• Момент входа: {label}" + def _entry_conditions_title( self, *, @@ -465,7 +484,7 @@ class SemanticDiagnosticFormatter: reason_text = str(reason or "").upper() if semantic_status == "POSITION_OPEN": - return "🟢 Вход · выполнен" + return "🟢 Условия входа · выполнен" if semantic_status == "READY": return "🟢 Условия входа · готовы" @@ -477,7 +496,7 @@ class SemanticDiagnosticFormatter: "IDLE", "WAITING_SIGNAL", }: - return "🟡 Условия входа · ожидание" + return "🟡 Условия входа · подтверждение" if quality == "BLOCKED": return "⛔️ Условия входа · заблокированы" @@ -524,49 +543,98 @@ class SemanticDiagnosticFormatter: seconds = int(seconds_float) if seconds <= 2: - return "live" + return "Live-поток" if seconds <= 10: return f"задержка {seconds}с" return "устарели" - def _signal_block(self, data: JsonDict) -> str: + def _signal_block( + self, + data: JsonDict, + *, + market: JsonDict | None = None, + ) -> str: + market = market or {} + signal = str(data.get("signal") or "").upper() - try: - progress = float( - data.get("confirmation_progress") or 0.0 - ) - except Exception: - progress = 0.0 + progress = safe_float(data.get("confirmation_progress")) or 0.0 title = self._signal_status_title( signal=signal, progress=progress, ) - lines = [ - title, - ( - f"• Длительность: " - f"{self._duration(data.get('age_seconds'))}" - ), - ] + lines = [title] - if signal in {"BUY", "SELL"}: - lines.append( - f"• Подтверждение: " - f"{self._percent(progress)}" - ) + direction_line = self._signal_direction_line( + signal=signal, + market=market, + ) - explanation = self._signal_explanation(data) + if direction_line: + lines.append(direction_line) - if explanation: - lines.append(explanation) + readiness_line = self._signal_readiness_line(data) + + if readiness_line: + lines.append(readiness_line) + + lines.append( + f"• Длительность: {self._duration(data.get('age_seconds'))}" + ) return "\n".join(lines) - + + def _signal_direction_line( + self, + *, + signal: str, + market: JsonDict, + ) -> str: + long_score = safe_float(market.get("market_long_score")) + short_score = safe_float(market.get("market_short_score")) + + if signal == "BUY": + return "• Направление: Long" + + if signal == "SELL": + return "• Направление: Short" + + if long_score is None and short_score is None: + return "" + + if short_score is not None and (long_score is None or short_score > long_score): + return "• Потенциал: Short" + + return "• Потенциал: Long" + + def _signal_readiness_line(self, data: JsonDict) -> str: + signal = str(data.get("signal") or "").upper() + + if signal not in {"BUY", "SELL"}: + return "" + + progress = safe_float(data.get("confirmation_progress")) + + if progress is None: + return "" + + return f"• Готовность: {self._score_percent(progress)}" + + def _score_percent(self, value: NumericLike | None) -> str: + number = safe_float(value) + + if number is None: + return "" + + if 0 <= number <= 1: + number *= 100 + + return f"{number:.0f}%" + def _signal_status_title( self, *, @@ -577,15 +645,15 @@ class SemanticDiagnosticFormatter: if progress >= 1.0: return "🟢 Сигнал · Long" - return "🟡 Подтверждение сигнала" + return "🟡 Сигнал · подтверждение" if signal == "SELL": if progress >= 1.0: return "🔴 Сигнал · Short" - return "🟡 Подтверждение сигнала" + return "🟡 Сигнал · подтверждение" - return "🟡 Ожидание сигнала" + return "🟡 Сигнал · ожидание" def _momentum_title( self, @@ -667,6 +735,20 @@ class SemanticDiagnosticFormatter: if "MARKET_FILTER_BLOCKED" in reason_upper: add("Рынок не готов") + if "MARKET_STRUCTURE_CONFLICT" in reason_upper or "СТРУКТУРА РЫНКА ПРОТИВ" in reason_upper: + add("Структура против входа") + + if "MARKET_STRUCTURE_MIXED" in reason_upper or "СТРУКТУРА РЫНКА СМЕШАН" in reason_upper: + add("Структура не подтверждает вход") + + if "MOMENTUM_NOT_CONFIRMED" in reason_upper: + if signal == "BUY": + add("Нет уверенного движения вверх") + elif signal == "SELL": + add("Нет уверенного движения вниз") + else: + add("Движение не подтверждает вход") + # 2. Рыночный контекст if ( "WEAK_MARKET_TREND" in reason_upper @@ -681,7 +763,7 @@ class SemanticDiagnosticFormatter: add("Рынок в откате") if "RANGE" in reason_upper or "ФЛЭТ" in reason_upper: - add("Рынок во флэте") + add("Нет понятного направления") if "SQUEEZE" in reason_upper or "СЖАТ" in reason_upper: add("Рынок в сжатии") @@ -758,15 +840,7 @@ class SemanticDiagnosticFormatter: return "\n".join(reasons[:2]) def _market_block(self, data: JsonDict) -> str: - state = data.get("state") - trend = data.get("trend") - strength = data.get("trend_strength") - phase = data.get("phase") - phase_direction = data.get("phase_direction") - 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 = [ ( @@ -774,11 +848,14 @@ class SemanticDiagnosticFormatter: f"Рынок · " f"{self._market_title(data)}" ), - f"• Данные: {market_data_state}", ] + directional_lines = self._directional_market_score_lines(data) + if directional_lines: + lines.extend(directional_lines) + if market_closed: - lines.append("• Биржа: перерыв") + lines.append("• Биржа · перерыв") lines.append( str( data.get("market_status_message") @@ -787,63 +864,80 @@ class SemanticDiagnosticFormatter: ) return "\n".join(lines) - if state == "RANGE" or phase == "RANGE": - lines.append("• Вход: ожидание") - - if state != "RANGE" and phase != "RANGE": - trend_line = self._market_trend_line( - trend=trend, - strength=strength, - ) - if trend_line: - lines.append(trend_line) - - current_line = self._market_current_line( - state=state, - phase=phase, - phase_direction=phase_direction, - ) - if current_line: - lines.append(current_line) - - volatility_line = self._market_volatility_line(volatility) - if volatility_line: - lines.append(volatility_line) - - quality_line = self._market_quality_line(quality) - if quality_line: - lines.append(quality_line) - - advanced_line = self._advanced_trend_quality_line(data) - if advanced_line: - lines.append(advanced_line) - - explanation = self._market_explanation(data) - if explanation: - lines.append(explanation) + # Метрики рынка выводим по важности: + # 1. старший тренд; + # 2. рабочий тренд; + # 3. последняя закрытая свеча; + # 4. текущая свеча; + # 5. структура; + # 6. волатильность; + # 7. качество движения. + for line in [ + self._htf_context_line(data), + self._market_trend_line( + trend=data.get("trend"), + strength=data.get("trend_strength"), + quality=data.get("trend_quality"), + volatility=data.get("volatility"), + interval=data.get("interval") or data.get("current_interval_label") or "5m", + ), + self._last_closed_candle_line(data), + self._current_candle_line(data), + self._market_structure_line(data), + self._market_volatility_line(data.get("volatility")), + self._market_quality_line( + data.get("trend_quality") + or data.get("market_trend_quality") + ), + ]: + if line: + lines.append(line) return "\n".join(lines) - def _market_title(self, data: JsonDict) -> str: + def _market_structure_line(self, data: JsonDict) -> str: + structure = str( + data.get("market_structure") + or data.get("structure") + or "" + ) + mapping = { + "HH_HL": "• Структура: рост · HH/HL", + "LH_LL": "• Структура: снижение · LH/LL", + "MIXED": "• Структура: смешанная", + "UNKNOWN": "", + "": "", + } + + return mapping.get(structure, "") + + def _market_title(self, data: JsonDict) -> str: if data.get("market_is_open") is False: return "перерыв" + score = safe_float(data.get("market_score")) + label = str(data.get("market_score_label") or "").strip() + + if score is not None: + if not label: + label = self._market_score_label(score) + + return f"{label.lower()} · {score:.0f}%" + state = str(data.get("state") or "") phase = str(data.get("phase") or "") trend = str(data.get("trend") or "") quality = str(data.get("trend_quality") or "") strength = str(data.get("trend_strength") or "") - # флэт + # Fallback для старых snapshot или если market_score ещё не рассчитан. if state == "RANGE" or phase == "RANGE": return "флэт" - # откат if phase == "PULLBACK": return "откат" - # шумный рынок if quality == "NOISY": if trend == "UP": return "шумный рост" @@ -853,7 +947,6 @@ class SemanticDiagnosticFormatter: return "шум" - # слабый тренд if strength == "WEAK": if trend == "UP": return "слабый рост" @@ -861,7 +954,6 @@ class SemanticDiagnosticFormatter: if trend == "DOWN": return "слабое снижение" - # импульс if phase == "IMPULSE": if trend == "UP": return "рост" @@ -871,7 +963,6 @@ class SemanticDiagnosticFormatter: return "импульс" - # базовый тренд if trend == "UP": return "рост" @@ -879,58 +970,146 @@ class SemanticDiagnosticFormatter: return "снижение" return self._human(state) + + def _directional_market_score_lines(self, data: JsonDict) -> list[str]: + long_score = safe_float(data.get("market_long_score")) + short_score = safe_float(data.get("market_short_score")) + + if long_score is None and short_score is None: + return [] + + lines: list[str] = [] + + long_is_best = ( + long_score is not None + and ( + short_score is None + or long_score >= short_score + ) + ) + + short_is_best = ( + short_score is not None + and ( + long_score is None + or short_score > long_score + ) + ) + + if long_score is not None: + mark = " ✅" if long_is_best else "" + lines.append(f"• Long: {long_score:.0f}%{mark}") + + if short_score is not None: + mark = " ✅" if short_is_best else "" + lines.append(f"• Short: {short_score:.0f}%{mark}") + + return lines + + def _market_score_label(self, score: NumericLike | None) -> str: + # Общая оценка рынка: + # 90-100 — отличный рынок + # 75-89 — благоприятный + # 55-74 — нейтральный + # 35-54 — сложный + # 0-34 — неблагоприятный + value = safe_float(score) + + if value is None: + return "оценка недоступна" + + if value >= 90: + return "отличный" + + if value >= 75: + return "благоприятный" + + if value >= 55: + return "нейтральный" + + if value >= 35: + return "сложный" + + return "неблагоприятный" def _market_trend_line( self, *, trend: object, strength: object, + quality: object | None = None, + volatility: object | None = None, + interval: object | None = None, ) -> str: + interval_text = str(interval or "5m") trend_text = self._human(trend) strength_text = self._human(strength) - if trend_text in {"—", "нет", "неясно", "ровно"}: - return "" + quality_text = str(quality or "").upper() + volatility_text = str(volatility or "").upper() + + if trend_text in {"—", "нет", "неясно"}: + return f"• Рабочий тренд ({interval_text}): неясно" + + if trend_text in {"ровно"}: + return f"• Рабочий тренд ({interval_text}): флэт" + + if ( + quality_text == "NOISY" + or volatility_text in {"HIGH", "HIGH_VOLATILITY"} + ): + return f"• Рабочий тренд ({interval_text}): {trend_text}" if strength_text in {"—", "нет", "неясно"}: - return f"• Тренд: {trend_text}" + return f"• Рабочий тренд ({interval_text}): {trend_text}" - return f"• Тренд: {trend_text} · {strength_text}" + return f"• Рабочий тренд ({interval_text}): {trend_text} · {strength_text}" - def _market_current_line( - self, - *, - state: object, - phase: object, - phase_direction: object, - ) -> str: - state_text = self._human(state) - phase_text = self._human(phase) - direction_text = self._human(phase_direction) + def _last_closed_candle_line(self, data: JsonDict) -> str: + change = safe_float(data.get("last_closed_candle_change_percent")) + direction = str(data.get("last_closed_candle_direction") or "").upper() + interval_label = str(data.get("current_interval_label") or "5m").strip() - if phase_text in {"—", "нет", "неясно"}: + if change is None: return "" - if phase_text == state_text: + if direction == "UP" or change > 0: + arrow = "▲" + elif direction == "DOWN" or change < 0: + arrow = "▼" + else: + arrow = "→" + + return f"• Последняя свеча ({interval_label}): {arrow} {change:+.2f}%" + + def _current_candle_line(self, data: JsonDict) -> str: + change = safe_float(data.get("current_interval_change_percent")) + interval_label = str(data.get("current_interval_label") or "5m").strip() + + if change is None: return "" - if phase_text == "флэт": - return "" - - if direction_text in {"—", "нет", "неясно", "ровно"}: - return f"• Сейчас: {phase_text}" - - return f"• Сейчас: {phase_text} {direction_text}" + if change > 0: + arrow = "▲" + elif change < 0: + arrow = "▼" + else: + arrow = "→" + return f"• Текущая свеча ({interval_label}): {arrow} {change:+.2f}%" + def _market_volatility_line(self, value: object) -> str: text = str(value or "") - if text == "HIGH_VOLATILITY": + if text in {"HIGH", "HIGH_VOLATILITY"}: return "• Волатильность: высокая" - if text == "LOW_VOLATILITY": + if text in {"LOW", "LOW_VOLATILITY"}: return "• Волатильность: низкая" + if text == "NORMAL": + return "• Волатильность: нормальная" + return "" def _market_quality_line(self, value: object) -> str: @@ -1052,14 +1231,13 @@ class SemanticDiagnosticFormatter: def _position_health_block(self, data: JsonDict) -> str: health_state = str(data.get("health_state") or "") - health_score = safe_float(data.get("health_score")) health_message = str(data.get("health_message") or "").strip() pressure_state = str(data.get("pressure_state") or "") trend_alignment = str(data.get("trend_alignment") or "") adverse_momentum = bool(data.get("adverse_momentum")) - risk_used = safe_float(data.get("risk_used_percent")) price_move = safe_float(data.get("price_move_percent")) opened_age = data.get("opened_age_seconds") + pnl = safe_float(data.get("unrealized_pnl_usd")) if not health_state or health_state in {"NONE", "UNKNOWN"}: return "" @@ -1071,15 +1249,13 @@ class SemanticDiagnosticFormatter: ), ] - if health_score is not None: - lines.append(f"• Score: {health_score:.0f}/100") + pnl_line = self._position_health_pnl_line(pnl) + if pnl_line: + lines.append(pnl_line) if price_move is not None: lines.append(f"• Движение цены: {price_move:+.3f}%") - if risk_used is not None and risk_used > 0: - lines.append(f"• Использовано риска: {risk_used:.1f}%") - alignment_line = self._position_alignment_line(trend_alignment) if alignment_line: lines.append(alignment_line) @@ -1147,9 +1323,12 @@ class SemanticDiagnosticFormatter: text = str(value or "") mapping = { + "STRONG_PROFIT": "• Давление: нет, сильная прибыль", "PROFIT": "• Давление: нет", "PROFIT_UNDER_PRESSURE": "• Давление: прибыль под риском", + "FLAT": "• Давление: нейтральное", "LOSS": "• Давление: умеренное", + "HIGH_LOSS": "• Давление: высокое", "PRESSURE": "• Давление: повышенное", "DANGER": "• Давление: критическое", } @@ -1330,28 +1509,32 @@ class SemanticDiagnosticFormatter: except Exception: age_seconds = None - if age_seconds is None: - add("Live-поток недоступен") - elif age_seconds > 60: + if age_seconds is not None and age_seconds > 60: add("Данные рынка устарели") - if state == "HIGH_VOLATILITY" or volatility == "HIGH_VOLATILITY": + if state == "HIGH_VOLATILITY" or volatility in {"HIGH", "HIGH_VOLATILITY"}: add("Рынок перегрет") - if state == "LOW_VOLATILITY" or volatility == "LOW_VOLATILITY": + if state == "LOW_VOLATILITY" or volatility in {"LOW", "LOW_VOLATILITY"}: add("Движения мало") if "MARKET_FILTER_BLOCKED" in entry_block: - if is_range: - add("Рынок без направления") - elif is_pullback: - add("Откат блокирует вход") + if state == "HIGH_VOLATILITY" or volatility in {"HIGH", "HIGH_VOLATILITY"}: + add("Слишком резкое движение — вход рискованный") + elif state == "LOW_VOLATILITY" or volatility in {"LOW", "LOW_VOLATILITY"}: + add("Слишком слабое движение") elif quality == "NOISY": - add("Шум блокирует вход") + add("Движение шумное") elif strength == "WEAK": - add("Слабый тренд блокирует вход") + add("Тренд слабый") + elif is_pullback: + add("Рынок в откате") + elif is_squeeze: + add("Рынок сжат") + elif is_range: + add("Нет понятного направления") else: - add("Рынок блокирует вход") + add("Условия для входа ещё не совпали") elif entry_block: normalized_block = entry_block.strip().lower() @@ -1370,9 +1553,6 @@ class SemanticDiagnosticFormatter: else: short_reason = self._short_reason(entry_block) - if short_reason == "COUNTER_TREND_BREAKOUT": - short_reason = "Пробой против тренда" - if not (is_range and "тренд слаб" in short_reason.lower()): add(short_reason) @@ -1437,7 +1617,7 @@ class SemanticDiagnosticFormatter: if not reasons: if is_range: - return "Рынок без направления" + return "Нет понятного направления" if is_squeeze: return "Рынок в сжатии" if is_pullback: @@ -1484,7 +1664,15 @@ class SemanticDiagnosticFormatter: "MARKET_FILTER_BLOCKED": "рынок не готов", "MARKET_OK": "рынок готов", "MARKET_PULLBACK": "откат", - "MARKET_STATE_NOT_TREND": "рынок без направления", + "MARKET_STATE_NOT_TREND": "нет понятного направления", + "HH_HL": "структура роста", + "LH_LL": "структура снижения", + "MIXED": "структура смешанная", + "MARKET_STRUCTURE_CONFLICT": "структура против входа", + "MARKET_STRUCTURE_MIXED": "структура не подтверждает вход", + "HIGHER_HIGH_HIGHER_LOW": "выше хай и выше лой", + "LOWER_HIGH_LOWER_LOW": "ниже хай и ниже лой", + "MIXED_MARKET_STRUCTURE": "смешанная структура", "NOISY": "шум", "NOISY_MARKET_TREND": "рынок шумный", "NORMAL": "норма", @@ -1516,6 +1704,7 @@ class SemanticDiagnosticFormatter: "EXHAUSTED": "выдохся", "MOMENTUM_DOWN": "импульс вниз", "MOMENTUM_UP": "импульс вверх", + "MOMENTUM_NOT_CONFIRMED": "движение не подтверждает вход", "NO_SIGNIFICANT_MOMENTUM": "импульс слабый", "STRONG": "сильная", "UP": "вверх", @@ -1554,6 +1743,17 @@ class SemanticDiagnosticFormatter: "NONE": "нет", "POSITION_OPEN": "позиция открыта", "SHORT": "Short", + + # === HTF / GLOBAL TREND === + "HTF_TREND_AGAINST": "старший тренд против входа", + "HTF_TREND_NOT_CONFIRMED": "старший тренд не подтвердил вход", + "HTF_NOT_CONFIRMED": "старший тренд не подтвердил вход", + "HTF_ALIGNMENT_AGAINST": "старший тренд против входа", + "HTF_ALIGNMENT_UNKNOWN": "старший тренд неясен", + "AGAINST": "против входа", + "ALIGNED": "подтверждает вход", + "SAME_INTERVAL": "тот же таймфрейм", + "NEUTRAL": "нейтрально", } return mapping.get(text, text) @@ -1780,61 +1980,70 @@ class SemanticDiagnosticFormatter: def _market_icon(self, data: JsonDict) -> str: if data.get("market_is_open") is False: return "⛔️" + + score = safe_float(data.get("market_score")) - state = str(data.get("state") or "") - strength = str(data.get("trend_strength") or "") - quality = str(data.get("trend_quality") or "") - phase = str(data.get("phase") or "") - volatility = str(data.get("volatility") or "") + if score is not None: + if score >= 75: + return "🟢" - consistency = safe_float(data.get("trend_consistency")) + if score >= 35: + return "🟡" - entry_block_reason = str(data.get("entry_block_reason") or "") - entry_block_message = str(data.get("entry_block_message") or "") - - entry_block_text = ( - f"{entry_block_reason} {entry_block_message}" - .strip() - .lower() - ) - - if state == "HIGH_VOLATILITY" or volatility == "HIGH_VOLATILITY": return "⛔️" - if ( - "market_filter_blocked" in entry_block_text - or "блок" in entry_block_text - or "не подходит" in entry_block_text - or "высок" in entry_block_text - or "перегрев" in entry_block_text - ): + state = str(data.get("state") or "").upper() + strength = str(data.get("trend_strength") or "").upper() + quality = str(data.get("trend_quality") or "").upper() + phase = str(data.get("phase") or "").upper() + volatility = str(data.get("volatility") or "").upper() + + entry_block_reason = str(data.get("entry_block_reason") or "").upper() + entry_block_message = str(data.get("entry_block_message") or "").lower() + + hard_block_reasons = { + "MARKET_CLOSED", + "STALE_SNAPSHOT", + "SNAPSHOT_ERROR", + "SNAPSHOT_UNAVAILABLE", + "HIGH_SPREAD", + # Структура рынка против входа — это hard-block. + "MARKET_STRUCTURE_CONFLICT", + "MARKET_STRUCTURE_MIXED", + } + + hard_block_texts = { + "бирж", + "перерыв", + "рынок закрыт", + "торги временно", + "нет данных рынка", + "устарел", + "snapshot", + "спред", + "spread", + # Русские сообщения по structure-block. + "структура рынка против входа", + "структура рынка не подтверждает вход", + } + + if entry_block_reason in hard_block_reasons: return "⛔️" - if data.get("age_seconds") is None: - return "🟡" + if any(text in entry_block_message for text in hard_block_texts): + return "⛔️" - if state == "UNKNOWN": + if state in {"UNKNOWN", "NONE"}: return "⚪️" - if state == "RANGE" or phase == "RANGE": - return "🟡" - - if phase == "SQUEEZE": - return "🟡" - - if phase == "PULLBACK": - return "🟡" - - if strength == "WEAK": - return "🟡" - - if quality == "NOISY": - return "🟡" - - if consistency is not None and consistency < 0.4: - return "🟡" - - if entry_block_text: + if ( + entry_block_reason + or state in {"HIGH_VOLATILITY", "CHAOTIC", "LIQUIDITY_VOID", "RANGE"} + or phase in {"RANGE", "SQUEEZE", "PULLBACK"} + or volatility in {"HIGH", "HIGH_VOLATILITY", "LOW", "LOW_VOLATILITY"} + or strength == "WEAK" + or quality == "NOISY" + ): return "🟡" if state in {"TREND_UP", "TREND_DOWN"}: @@ -1894,15 +2103,25 @@ class SemanticDiagnosticFormatter: "snapshot устарел": "Данные рынка устарели", "spread повышен": "Спред повышен", "шумный тренд": "Рынок шумный", - + "market_structure_conflict": "Структура против входа", + "market_structure_mixed": "Структура не подтверждает вход", + "структура рынка против входа": "Структура против входа", + "структура рынка не подтверждает вход": "Структура не подтверждает вход", "counter_trend_breakout": "Пробой против тренда", "market_filter_blocked": "Рынок не подходит для входа", "market_pullback": "Рынок в откате", - "market_state_not_trend": "Рынок без направления", + "market_state_not_trend": "Нет понятного направления", "noisy_market_trend": "Движение шумное", "weak_down_impulse": "Импульс вниз слабый", "weak_market_trend": "Тренд слабый", "weak_up_impulse": "Импульс вверх слабый", + "momentum_not_confirmed": "Движение не подтверждает вход", + "htf_trend_against": "Старший тренд против входа", + "htf_trend_not_confirmed": "Старший тренд не подтвердил вход", + "htf_not_confirmed": "Старший тренд не подтвердил вход", + "htf_alignment_against": "Старший тренд против входа", + "htf_alignment_unknown": "Старший тренд неясен", + "against": "Старший тренд против входа", } if normalized in mapping: @@ -1919,7 +2138,7 @@ class SemanticDiagnosticFormatter: seconds_float = safe_float(value) if seconds_float is None: - return "REST" + return "—" seconds = int(seconds_float) @@ -2012,4 +2231,77 @@ class SemanticDiagnosticFormatter: self, data: JsonDict, ) -> str: - return format_runtime_exchange_alert(data) \ No newline at end of file + return format_runtime_exchange_alert(data) + + def _htf_context_line(self, data: JsonDict) -> str: + htf_trend = str(data.get("htf_trend") or "") + htf_interval = str(data.get("htf_interval") or "1h").upper() + + if not htf_trend or htf_trend in {"UNKNOWN", "NONE"}: + return "" + + trend_text = self._human(htf_trend) + + return f"• Старший тренд ({htf_interval}): {trend_text}" + + def _human_htf_alignment(self, value: object) -> str: + text = str(value or "").upper() + + mapping = { + "ALIGNED": "подтверждает", + "AGAINST": "против входа", + "NEUTRAL": "нейтрально", + "UNKNOWN": "неясно", + "SAME_INTERVAL": "тот же ТФ", + } + + return mapping.get(text, text.lower() or "—") + + def _position_pnl_icon(self, pnl: float | None) -> str: + if pnl is None: + return "⚪️" + + if pnl > 0: + return "🟢" + + if pnl < 0: + return "🔴" + + return "🟡" + + + def _position_health_pnl_line(self, pnl: float | None) -> str: + if pnl is None: + return "• PnL · —" + + if pnl > 0: + return f"• Прибыль · +$ {abs(pnl):.2f}" + + if pnl < 0: + return f"• Убыток · −$ {abs(pnl):.2f}" + + return "• PnL · $ 0.00" + + def _live_stream_warning(self, runtime: JsonDict) -> str: + market_data_runtime = runtime.get("market_data_runtime") + + if not isinstance(market_data_runtime, dict): + return "⚠️ Live-поток недоступен" + + stream_state = str(market_data_runtime.get("stream_state") or "").upper() + + if stream_state == "CONNECTED": + return "" + + return "⚠️ Live-поток недоступен" + + def _strategy_title(self, value: object) -> str: + text = str(value or "").upper() + + mapping = { + "TREND": "Trend", + "GRID": "Grid", + "SCALP": "Scalp", + } + + return mapping.get(text, text.title() if text else "—") \ No newline at end of file diff --git a/app/src/trading/diagnostics/semantic_runtime.py b/app/src/trading/diagnostics/semantic_runtime.py index e0d5c95..dff6614 100644 --- a/app/src/trading/diagnostics/semantic_runtime.py +++ b/app/src/trading/diagnostics/semantic_runtime.py @@ -89,6 +89,9 @@ class SemanticRuntimeDiagnostics: "age_seconds": market_age_seconds, "entry_block_reason": state.entry_block_reason, "entry_block_message": state.entry_block_message, + # Общая оценка рынка 0..100 для UI/диагностики. + "market_score": state.market_score, + "market_score_label": state.market_score_label, } def _momentum_section(self, state: AutoTradeState) -> dict[str, Any]: @@ -141,6 +144,10 @@ class SemanticRuntimeDiagnostics: "effective_risk_percent": state.effective_risk_percent, "effective_target_risk_usd": state.effective_target_risk_usd, "size_adjustment_reason": state.execution_size_adjustment_reason, + # Сохраняем market_score рядом с adaptive size, + # чтобы было видно, повлиял ли рынок на размер позиции. + "market_score": state.market_score, + "market_score_label": state.market_score_label, } def _position_section(self, state: AutoTradeState) -> dict[str, Any]: @@ -198,6 +205,8 @@ class SemanticRuntimeDiagnostics: "mode": state.status, "signal": state.last_signal, "market": state.market_state, + "market_score": state.market_score, + "market_score_label": state.market_score_label, "phase": state.market_phase, "momentum": state.momentum_state, "execution": state.execution_semantic_status, diff --git a/app/src/trading/diagnostics/snapshot.py b/app/src/trading/diagnostics/snapshot.py index 4d90d9e..be48414 100644 --- a/app/src/trading/diagnostics/snapshot.py +++ b/app/src/trading/diagnostics/snapshot.py @@ -8,6 +8,9 @@ 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 +from src.integrations.exchange.market_data_runner import MarketDataRunner +from src.trading.execution.position_metrics import build_position_metrics +from src.trading.position.state import PositionState class SemanticDiagnosticSnapshotBuilder: @@ -55,6 +58,8 @@ class SemanticDiagnosticSnapshotBuilder: "is_confirmed": state.is_signal_confirmed, "is_ready": state.is_signal_ready, "repeat_count": state.last_signal_repeat_count, + "required_repeats": state.signal_confirmation_missing_repeats + + state.last_signal_repeat_count, "confirmation_progress": state.signal_confirmation_progress, "age_seconds": signal_age_seconds, "reason": state.last_signal_reason, @@ -67,6 +72,17 @@ class SemanticDiagnosticSnapshotBuilder: "trend_quality": state.market_trend_quality, "phase": state.market_phase, "phase_direction": state.market_phase_direction, + # Таймфрейм локального анализа рынка. + "interval": state.market_analysis_interval, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, "entry_block_reason": state.entry_block_reason, "entry_block_message": state.entry_block_message, "age_seconds": market_age_seconds, @@ -74,6 +90,8 @@ class SemanticDiagnosticSnapshotBuilder: "market_status": state.market_status, "market_status_message": state.market_status_message, "market_status_updated_at": state.market_status_updated_at, + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, "trend_gap_percent": state.market_trend_gap_percent, "trend_consistency": state.market_trend_consistency, "trend_efficiency": state.market_trend_efficiency, @@ -91,6 +109,14 @@ class SemanticDiagnosticSnapshotBuilder: "htf_atr_percent_baseline": state.htf_atr_percent_baseline, "htf_volatility_ratio": state.htf_volatility_ratio, "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, }, "momentum": { "state": getattr(state, "momentum_state", None), @@ -129,6 +155,11 @@ class SemanticDiagnosticSnapshotBuilder: "effective_target_risk_usd": state.effective_target_risk_usd, "reason": state.adaptive_size_reason, "factors": state.adaptive_size_factors, + # Общая оценка рынка на момент расчёта размера позиции. + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, }, "position": { "side": state.position_side, @@ -164,6 +195,7 @@ class SemanticDiagnosticSnapshotBuilder: "adverse_momentum": position_health.get("adverse_momentum"), }, "runtime_health": { + "market_data_runtime": MarketDataRunner.get_runtime_state("auto"), "exchange_statuses": runtime_exchange_alerts, "exchange_status": ( runtime_exchange_alerts[0] @@ -201,6 +233,10 @@ class SemanticDiagnosticSnapshotBuilder: "main_message": self._main_message(state=state, blockers=blockers), "market": state.market_state, + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, "phase": state.market_phase, "momentum": getattr(state, "momentum_state", None), "execution": state.execution_semantic_status, @@ -271,7 +307,16 @@ class SemanticDiagnosticSnapshotBuilder: ) -> int: score = 100 - if state.status != "RUNNING": + # Если MarketAnalysisService уже дал общую оценку рынка, + # diagnostics-health лучше строить от неё, а дальше корректировать + # runtime-блокировками, execution quality и статусом автоторговли. + market_score = safe_float(getattr(state, "market_score", None)) + if market_score is not None: + score = int(max(0, min(100, market_score))) + + if state.status == "OFF": + score -= 25 + elif state.status != "RUNNING": score -= 10 if blockers: @@ -282,39 +327,51 @@ class SemanticDiagnosticSnapshotBuilder: elif state.execution_quality == "WARNING": score -= 15 - if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}: - score -= 15 + if market_score is None: + # Старый fallback: если общей оценки рынка нет, + # health_score собирается из отдельных market-метрик. + if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}: + score -= 15 - if state.market_trend_strength == "WEAK": - score -= 10 + if state.market_trend_strength == "WEAK": + score -= 10 - if state.market_trend_quality == "NOISY": - score -= 10 + if state.market_trend_quality == "NOISY": + score -= 10 - if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}: - score -= 10 + if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}: + score -= 10 - if state.ema_distance_state == "COMPRESSED": - score -= 10 + if state.market_structure == "MIXED": + score -= 15 - if state.ema_distance_state == "EXTENDED": - score -= 8 + if state.market_structure == "HH_HL" and state.market_trend == "DOWN": + score -= 20 - if state.ema_distance_state == "OVEREXTENDED": - score -= 25 + if state.market_structure == "LH_LL" and state.market_trend == "UP": + score -= 20 - if state.entry_timing_state == "LATE": - score -= 18 + if state.ema_distance_state == "COMPRESSED": + score -= 10 - if state.entry_timing_state == "CHASING": - score -= 30 + if state.ema_distance_state == "EXTENDED": + score -= 8 - trend_quality_score = safe_float(state.trend_quality_score) - if trend_quality_score is not None: - if trend_quality_score < 0.45: - score -= 12 - elif trend_quality_score >= 0.7: - score += 5 + if state.ema_distance_state == "OVEREXTENDED": + score -= 25 + + if state.entry_timing_state == "LATE": + score -= 18 + + if state.entry_timing_state == "CHASING": + score -= 30 + + trend_quality_score = safe_float(state.trend_quality_score) + if trend_quality_score is not None: + if trend_quality_score < 0.45: + score -= 12 + elif trend_quality_score >= 0.7: + score += 5 if state.market_runtime_degraded: score -= 15 @@ -340,6 +397,9 @@ class SemanticDiagnosticSnapshotBuilder: if state.market_is_open is False: return "RED" + + if state.market_score is not None and state.market_score < 25: + return "RED" has_waiting_data_blocker = any( str(item).strip().lower() @@ -354,6 +414,24 @@ class SemanticDiagnosticSnapshotBuilder: if has_waiting_data_blocker: return "WAITING" + # Структура рынка — это hard-block, а не обычное ожидание. + if state.entry_block_reason in { + "MARKET_STRUCTURE_CONFLICT", + "MARKET_STRUCTURE_MIXED", + }: + return "RED" + + # Market filter сначала оцениваем как блокировку, + # иначе HOLD преждевременно вернёт WAITING. + if state.entry_block_reason == "MARKET_FILTER_BLOCKED": + if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}: + return "RED" + + if state.market_trend_quality == "NOISY": + return "RED" + + return "YELLOW" + if ( state.execution_quality == "BLOCKED" or state.decision_status == "BLOCKED" @@ -373,15 +451,6 @@ class SemanticDiagnosticSnapshotBuilder: if signal == "HOLD" and not has_ready_signal: return "WAITING" - if state.entry_block_reason == "MARKET_FILTER_BLOCKED": - if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}: - return "RED" - - if state.market_trend_quality == "NOISY": - return "RED" - - return "YELLOW" - if health_score < 45: return "YELLOW" @@ -426,12 +495,17 @@ class SemanticDiagnosticSnapshotBuilder: if state.market_is_open is False: return state.market_status_message or "Биржа временно недоступна для торговли." - if state.entry_block_reason == "MARKET_FILTER_BLOCKED": - if state.market_state == "RANGE" or state.market_phase == "RANGE": - return "Ожидание: рынок без направления." + # Структура рынка — отдельная жёсткая причина блокировки, + # чтобы UI не показывал её как обычное ожидание рынка. + if state.entry_block_reason == "MARKET_STRUCTURE_CONFLICT": + return "Вход заблокирован: структура рынка против направления." + + if state.entry_block_reason == "MARKET_STRUCTURE_MIXED": + return "Вход заблокирован: структура рынка не подтверждает направление." + + if state.entry_block_reason == "MARKET_FILTER_BLOCKED": + return self._market_filter_message(state) - return "Осторожно: рынок не подходит." - if state.execution_quality == "BLOCKED": reason = str(state.execution_quality_reason or "") @@ -463,6 +537,36 @@ class SemanticDiagnosticSnapshotBuilder: return "Критичных ограничений нет." + def _market_filter_message(self, state: AutoTradeState) -> str: + if state.market_volatility == "HIGH": + return "Ожидание: движение слишком резкое, вход сейчас рискованный." + + if state.entry_timing_state in {"LATE", "CHASING"}: + return "Ожидание: цена уже сильно прошла, входить поздно." + + if state.ema_distance_state == "OVEREXTENDED": + return "Ожидание: цена ушла слишком далеко после импульса." + + if state.ema_distance_state == "COMPRESSED": + return "Ожидание: рынок слишком сжат, направление ещё не подтвердилось." + + if state.market_trend_quality == "NOISY": + return "Ожидание: движение есть, но оно шумное и ненадёжное." + + if state.market_structure == "MIXED": + return "Ожидание: структура рынка противоречивая." + + if state.market_phase == "PULLBACK": + return "Ожидание: рынок в откате, ждём подтверждения продолжения." + + if state.market_state == "RANGE" or state.market_phase in {"RANGE", "SQUEEZE"}: + return "Ожидание: рынок пока без понятного направления." + + if state.entry_block_message: + return f"Ожидание: {state.entry_block_message}." + + return "Ожидание: условия для входа пока не совпали." + def _blockers(self, state: AutoTradeState) -> list[str]: blockers: list[str] = [] @@ -473,9 +577,15 @@ class SemanticDiagnosticSnapshotBuilder: ) return blockers + if state.entry_block_reason in { + "MARKET_STRUCTURE_CONFLICT", + "MARKET_STRUCTURE_MIXED", + }: + blockers.append(str(state.entry_block_message or "структура рынка не подтверждает вход")) + if state.entry_block_reason == "MARKET_FILTER_BLOCKED": if state.market_state == "RANGE" or state.market_phase == "RANGE": - blockers.append("рынок без направления") + blockers.append(self._market_filter_message(state).replace("Ожидание: ", "").rstrip(".")) elif state.entry_block_message: blockers.append(str(state.entry_block_message)) else: @@ -493,8 +603,15 @@ class SemanticDiagnosticSnapshotBuilder: if state.entry_timing_state == "CHASING": blockers.append("вход запрещён: chasing move") + # Добавляем entry_block_message только если это не тот же текст, + # который уже был добавлен выше через MARKET_FILTER_BLOCKED / STRUCTURE. if state.entry_block_message: - blockers.append(str(state.entry_block_message)) + message = str(state.entry_block_message) + + normalized = message.strip() + + if normalized and normalized not in blockers: + blockers.append(normalized) if state.execution_quality == "BLOCKED": blockers.append(str(state.execution_quality_message or "исполнение заблокировано")) @@ -531,51 +648,74 @@ class SemanticDiagnosticSnapshotBuilder: } entry_price = safe_float(state.entry_price) + position_size = safe_float(state.position_size) pnl = safe_float(state.unrealized_pnl_usd) stop_loss_usd = safe_float(state.effective_target_risk_usd) max_loss_usd = safe_float(state.max_loss_usd) - price_move_percent = self._position_price_move_percent( - side=state.position_side, - entry_price=entry_price, + metrics = build_position_metrics( + PositionState( + side=state.position_side or "NONE", + symbol=state.symbol, + entry_price=entry_price, + size=position_size, + leverage=state.leverage, + unrealized_pnl_usd=pnl, + opened_monotonic_at=state.position_opened_monotonic_at, + ), current_price=current_price, ) + price_move_percent = metrics.price_move_percent + risk_used_percent = self._position_risk_used_percent( pnl=pnl, stop_loss_usd=stop_loss_usd, max_loss_usd=max_loss_usd, ) - trend_alignment = self._position_trend_alignment(state) - adverse_momentum = self._has_adverse_momentum(state) - pressure_state = self._position_pressure_state( - pnl=pnl, - risk_used_percent=risk_used_percent, - adverse_momentum=adverse_momentum, - ) + health_state = str(state.position_health_status or "") + health_score = state.position_health_score + health_message = state.position_health_reason + pressure_state = str(state.position_pressure or "") + trend_alignment = str(state.position_trend_alignment or "") + adverse_momentum = bool(state.position_adverse_momentum) - opened_age_seconds = self._age_seconds( - now=time.monotonic(), - started_at=state.position_opened_monotonic_at, - ) + if not trend_alignment: + trend_alignment = self._position_trend_alignment(state) - health_score = self._position_health_score( - pnl=pnl, - risk_used_percent=risk_used_percent, - trend_alignment=trend_alignment, - adverse_momentum=adverse_momentum, - pressure_state=pressure_state, - opened_age_seconds=opened_age_seconds, - ) + if not adverse_momentum: + adverse_momentum = self._has_adverse_momentum(state) - health_state = self._position_health_state(health_score) - health_message = self._position_health_message( - health_state=health_state, - pressure_state=pressure_state, - trend_alignment=trend_alignment, - adverse_momentum=adverse_momentum, - ) + if not pressure_state: + pressure_state = self._position_pressure_state( + pnl=pnl, + risk_used_percent=risk_used_percent, + adverse_momentum=adverse_momentum, + ) + + if not health_state: + health_score = self._position_health_score( + pnl=pnl, + risk_used_percent=risk_used_percent, + trend_alignment=trend_alignment or "NEUTRAL", + adverse_momentum=adverse_momentum, + pressure_state=pressure_state or "UNKNOWN", + opened_age_seconds=self._age_seconds( + now=time.monotonic(), + started_at=state.position_opened_monotonic_at, + ), + ) + + health_state = self._position_health_state(health_score) + + if not health_message: + health_message = self._position_health_message( + health_state=health_state, + pressure_state=pressure_state or "UNKNOWN", + trend_alignment=trend_alignment or "NEUTRAL", + adverse_momentum=adverse_momentum, + ) return { "health_state": health_state, @@ -588,29 +728,6 @@ class SemanticDiagnosticSnapshotBuilder: "adverse_momentum": adverse_momentum, } - def _position_price_move_percent( - self, - *, - side: str | None, - entry_price: float | None, - current_price: float | None, - ) -> float | None: - if entry_price is None or current_price is None: - return None - - if entry_price <= 0 or current_price <= 0: - return None - - normalized_side = str(side or "").upper() - - if normalized_side == "LONG": - return round(((current_price - entry_price) / entry_price) * 100, 4) - - if normalized_side == "SHORT": - return round(((entry_price - current_price) / entry_price) * 100, 4) - - return None - def _position_risk_used_percent( self, *, diff --git a/app/src/trading/execution/calculations.py b/app/src/trading/execution/calculations.py index a542a48..eb46d85 100644 --- a/app/src/trading/execution/calculations.py +++ b/app/src/trading/execution/calculations.py @@ -5,138 +5,94 @@ 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 +from src.trading.execution.position_metrics import build_position_metrics 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 % - # ========================================================= - +class ExecutionCalculationsMixin(_ExecutionCalculationsProtocol): + # Единая точка расчёта движения цены позиции. + # Вся логика вынесена в position_metrics.py, + # чтобы LONG/SHORT считались одинаково во всех частях execution. 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 + metrics = build_position_metrics( + position, + current_price=current_price, + ) - 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 - # ========================================================= + return metrics.price_move_percent + # Единая точка расчёта итогового PnL. + # Возвращает net PnL: + # gross PnL - комиссия вход/выход + overnight cashflow. def _calculate_pnl( self, current_price: NumericLike | None, ) -> float: - """ - Рассчитать unrealized pnl позиции. - """ - position = type(self)._position - price = safe_float(current_price) or 0.0 + metrics = build_position_metrics( + position, + current_price=current_price, + ) - entry = safe_float( - position.entry_price - ) or 0.0 + return metrics.net_pnl_usd - size = safe_float( - position.size - ) or 0.0 + # Gross PnL без комиссии и overnight. + # Оставляем метод как совместимый wrapper, + # но сам расчёт теперь берётся из position_metrics.py. + def _calculate_gross_pnl( + self, + current_price: NumericLike | None, + ) -> float: + position = type(self)._position - # ----------------------------------------------------- - # LONG - # ----------------------------------------------------- + metrics = build_position_metrics( + position, + current_price=current_price, + ) - if position.side == "LONG": - return round( - (price - entry) * size, - 4, - ) + return metrics.gross_pnl_usd - # ----------------------------------------------------- - # SHORT - # ----------------------------------------------------- + # Комиссия вход + предполагаемый выход. + # Теперь считается централизованно через position_metrics.py. + def _calculate_round_trip_commission( + self, + current_price: NumericLike | None, + ) -> float: + position = type(self)._position - if position.side == "SHORT": - return round( - (entry - price) * size, - 4, - ) + metrics = build_position_metrics( + position, + current_price=current_price, + ) - return 0.0 + return metrics.commission_usd - # ========================================================= - # TIME - # ========================================================= + # Overnight / leverage cashflow. + # Может быть отрицательным или положительным, + # зависит от ставки биржи для LONG/SHORT. + def _calculate_overnight_cashflow( + self, + current_price: NumericLike | None, + ) -> float: + position = type(self)._position + + metrics = build_position_metrics( + position, + current_price=current_price, + ) + + return metrics.overnight_cashflow_usd def _now_time(self) -> str: - """ - Current execution timestamp. - """ - - return datetime.now().strftime( - "%H:%M:%S" - ) \ No newline at end of file + return datetime.now().strftime("%H:%M:%S") \ No newline at end of file diff --git a/app/src/trading/execution/constants.py b/app/src/trading/execution/constants.py new file mode 100644 index 0000000..f5a8e79 --- /dev/null +++ b/app/src/trading/execution/constants.py @@ -0,0 +1,360 @@ +# app/src/trading/execution/constants.py + +from __future__ import annotations + + +# ----- Runtime autonomous actions ----- + +RUNTIME_ACTION_COOLDOWN_SECONDS = 30 +RUNTIME_EXIT_CONFIDENCE_THRESHOLD = 0.75 + +RUNTIME_ACTION_SKIPPED = "RUNTIME_ACTION_SKIPPED" +RUNTIME_ACTION_COOLDOWN = "RUNTIME_ACTION_COOLDOWN" +RUNTIME_ACTION_UNKNOWN = "RUNTIME_ACTION_UNKNOWN" + + +# ----- Auto / execution states ----- + +AUTO_STATUS_RUNNING = "RUNNING" + +EXECUTION_STATUS_RUNNING = "RUNNING" +EXECUTION_DECISION_READY = "READY" + + +# ----- Position sides ----- + +POSITION_SIDE_NONE = "NONE" +POSITION_SIDE_LONG = "LONG" +POSITION_SIDE_SHORT = "SHORT" + + +# ----- Signals ----- + +SIGNAL_BUY = "BUY" +SIGNAL_SELL = "SELL" + + +# ----- Execution actions ----- + +EXECUTION_ACTION_NONE = "NONE" +EXECUTION_ACTION_OPEN_LONG = "OPEN_LONG" +EXECUTION_ACTION_OPEN_SHORT = "OPEN_SHORT" +EXECUTION_ACTION_CLOSE = "CLOSE" +EXECUTION_ACTION_FLIP_BLOCKED = "FLIP_BLOCKED" + +EXECUTION_ACTION_FORCE_CLOSE_PREFIX = "FORCE_CLOSE_" + + +# -----Execution types ----- + +EXECUTION_TYPE_ENTRY = "ENTRY" +EXECUTION_TYPE_EXIT = "EXIT" +EXECUTION_TYPE_ENTRY_REJECTED = "ENTRY_REJECTED" +EXECUTION_TYPE_RUNTIME_ACTION = "RUNTIME_ACTION" + +EXECUTION_TYPE_FLIP = "FLIP" +EXECUTION_TYPE_FLIP_REJECTED = "FLIP_REJECTED" +EXECUTION_TYPE_FLIP_BLOCKED = "FLIP_BLOCKED" + + +# ----- Execution reasons ----- + +EXECUTION_REASON_MANUAL = "MANUAL" +EXECUTION_REASON_AUTONOMOUS_EXIT = "AUTONOMOUS_EXIT" + + +# ----- Pricing modes ----- + +PRICING_ENTRY_MODE = "ask_for_long_bid_for_short" +PRICING_EXIT_MODE = "bid_for_long_exit_ask_for_short_exit" +PRICING_FLIP_MODE = "exit_by_side_then_entry_by_side" + + +# ----- Execution limits ----- + +EXECUTION_MAX_CONSECUTIVE_LOSSES = 5 + +# ----- Execution quality ----- + +EXECUTION_QUALITY_BLOCKED = "BLOCKED" +EXECUTION_QUALITY_WARNING = "WARNING" + + +# ----- Autonomous management ----- + +AUTONOMOUS_ACTION_HOLD = "HOLD" +AUTONOMOUS_ACTION_WATCH = "WATCH" +AUTONOMOUS_ACTION_PROTECT = "PROTECT" +AUTONOMOUS_ACTION_REDUCE = "REDUCE" +AUTONOMOUS_ACTION_EXIT = "EXIT" +AUTONOMOUS_ACTION_EXIT_BLOCKED = "EXIT_BLOCKED" + +AUTONOMOUS_EXIT_CONFIDENCE_THRESHOLD = 0.75 +AUTONOMOUS_AGGRESSIVE_EXIT_CONFIDENCE_THRESHOLD = 0.65 + + +# ----- Position exit signals ----- + +POSITION_EXIT_SIGNAL_HOLD = "HOLD" +POSITION_EXIT_SIGNAL_WATCH = "WATCH" +POSITION_EXIT_SIGNAL_REDUCE_OR_PROTECT = "REDUCE_OR_PROTECT" +POSITION_EXIT_SIGNAL_EXIT = "EXIT" + +POSITION_EXIT_SIGNAL_EXIT_CONFIDENCE = 0.75 +POSITION_EXIT_SIGNAL_PROTECT_CONFIDENCE = 0.50 +POSITION_EXIT_SIGNAL_WATCH_CONFIDENCE = 0.30 + + +# ----- Position pressure / trend ----- + +POSITION_PRESSURE_HIGH_LOSS = "HIGH_LOSS" +POSITION_PRESSURE_LOSS = "LOSS" + +POSITION_TREND_AGAINST = "AGAINST" + + +# ----- Position risk ----- + +POSITION_RISK_HIGH = "HIGH" +POSITION_RISK_ELEVATED = "ELEVATED" +POSITION_RISK_MODERATE = "MODERATE" +POSITION_RISK_LOW = "LOW" + + +# ----- Position health ----- + +POSITION_HEALTH_HEALTHY = "HEALTHY" +POSITION_HEALTH_WATCH = "WATCH" +POSITION_HEALTH_PRESSURE = "PRESSURE" +POSITION_HEALTH_DANGER = "DANGER" +POSITION_HEALTH_UNKNOWN = "UNKNOWN" + +POSITION_HEALTH_PNL_HARD_LOSS_PERCENT = -1.0 +POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT = -0.6 +POSITION_HEALTH_PNL_PRESSURE_PERCENT = -0.25 +POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT = 0.8 + +POSITION_EXIT_PRESSURE_LOSS_PERCENT = -0.4 + + +# ----- Position stop-loss ratios ----- + +POSITION_STOP_LOSS_RATIO_WATCH = 0.50 +POSITION_STOP_LOSS_RATIO_WARNING = 0.55 +POSITION_STOP_LOSS_RATIO_CRITICAL = 0.80 + + +# ----- Position momentum / candle thresholds ----- + +POSITION_MOMENTUM_STRONG = 0.80 + +POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT = 0.04 +POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT = 0.12 + + +# ----- Position lifecycle / semantics ----- + +POSITION_LIFECYCLE_NEW_SECONDS = 60 +POSITION_LIFECYCLE_ACTIVE_SECONDS = 300 +POSITION_LIFECYCLE_MATURE_SECONDS = 900 + +POSITION_EXIT_DAMPING_NEW_SECONDS = 300 +POSITION_EXIT_DAMPING_MATURE_SECONDS = 900 +POSITION_EXIT_DAMPING_NEW_MULTIPLIER = 0.45 +POSITION_EXIT_DAMPING_MATURE_MULTIPLIER = 0.70 + +POSITION_GIVEBACK_HIGH_PERCENT = 70 +POSITION_GIVEBACK_MEDIUM_PERCENT = 45 +POSITION_GIVEBACK_LOW_PERCENT = 25 + +POSITION_REVERSAL_HIGH_GIVEBACK_PERCENT = 45 +POSITION_REVERSAL_ELEVATED_GIVEBACK_PERCENT = 25 + +POSITION_STALL_EARLY_SECONDS = 300 +POSITION_STALL_DEVELOPING_SECONDS = 600 +POSITION_STALL_CONFIRMED_SECONDS = 900 +POSITION_STALL_LOW_PROGRESS_PNL_PERCENT = 0.25 +POSITION_STALL_LOW_PROGRESS_MFE_PERCENT = 0.35 +POSITION_STALL_ADVERSE_MAE_PERCENT = -0.35 + + +# ----- Market / flip filters ----- + +MARKET_VOLATILITY_HIGH_STATES = {"HIGH", "HIGH_VOLATILITY"} + +MARKET_STATE_FLIP_BLOCKED = { + "RANGE", + "HIGH_VOLATILITY", + "LOW_VOLATILITY", + "UNKNOWN", + "", +} + +MARKET_PHASE_FLIP_BLOCKED = { + "RANGE", + "UNKNOWN", + "", +} + +FLIP_MIN_EXECUTION_CONFIDENCE = 0.70 +FLIP_MIN_HTF_CONFIRMATION_SCORE = 0.65 +FLIP_BREAKOUT_CONFIDENCE_THRESHOLD = 0.85 + +# ----- Asset-specific thresholds ----- + +DEFAULT_POSITION_THRESHOLDS = { + "health": { + "high_loss": -0.75, + "loss": -0.40, + "profit": 0.45, + "strong_profit": 1.20, + }, + "exit": { + "min_hold": 1500, + "neutral_min_hold": 1800, + "neutral_band": 0.40, + "normal_pullback": -0.45, + "hard_loss": -0.90, + "noisy_min_hold": 600, + "noisy_loss_exit": -0.30, + "noisy_profit_giveback": 35, + "clean_giveback_min_peak": 1.10, + "clean_giveback_percent": 55, + "noisy_giveback_min_peak": 0.40, + "noisy_giveback_percent": 35, + }, +} + + +POSITION_THRESHOLDS_BY_ASSET = { + "BTC": { + "health": { + "high_loss": -0.65, + "loss": -0.30, + "profit": 0.30, + "strong_profit": 0.90, + }, + "exit": { + "min_hold": 1200, + "neutral_min_hold": 1500, + "neutral_band": 0.30, + "normal_pullback": -0.35, + "hard_loss": -0.75, + "noisy_min_hold": 600, + "noisy_loss_exit": -0.25, + "noisy_profit_giveback": 35, + "clean_giveback_min_peak": 1.20, + "clean_giveback_percent": 55, + "noisy_giveback_min_peak": 0.45, + "noisy_giveback_percent": 35, + }, + }, + "ETH": { + "health": { + "high_loss": -0.85, + "loss": -0.45, + "profit": 0.40, + "strong_profit": 1.10, + }, + "exit": { + "min_hold": 1500, + "neutral_min_hold": 1800, + "neutral_band": 0.40, + "normal_pullback": -0.45, + "hard_loss": -0.85, + "noisy_min_hold": 600, + "noisy_loss_exit": -0.30, + "noisy_profit_giveback": 35, + "clean_giveback_min_peak": 1.10, + "clean_giveback_percent": 55, + "noisy_giveback_min_peak": 0.40, + "noisy_giveback_percent": 35, + }, + }, + "LTC": { + "health": { + "high_loss": -1.00, + "loss": -0.55, + "profit": 0.55, + "strong_profit": 1.35, + }, + "exit": { + "min_hold": 1800, + "neutral_min_hold": 2100, + "neutral_band": 0.45, + "normal_pullback": -0.55, + "hard_loss": -1.00, + "noisy_min_hold": 600, + "noisy_loss_exit": -0.40, + "noisy_profit_giveback": 35, + "clean_giveback_min_peak": 1.20, + "clean_giveback_percent": 55, + "noisy_giveback_min_peak": 0.45, + "noisy_giveback_percent": 35, + }, + }, + "XRP": { + "health": { + "high_loss": -1.10, + "loss": -0.60, + "profit": 0.60, + "strong_profit": 1.50, + }, + "exit": { + "min_hold": 1800, + "neutral_min_hold": 2100, + "neutral_band": 0.50, + "normal_pullback": -0.60, + "hard_loss": -1.10, + "noisy_min_hold": 600, + "noisy_loss_exit": -0.40, + "noisy_profit_giveback": 35, + "clean_giveback_min_peak": 1.20, + "clean_giveback_percent": 55, + "noisy_giveback_min_peak": 0.45, + "noisy_giveback_percent": 35, + }, + }, +} + + +# ---- Helpers ----- + +def asset_symbol(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 get_position_thresholds(symbol: str | None) -> dict[str, dict[str, float]]: + asset = asset_symbol(symbol) + + return POSITION_THRESHOLDS_BY_ASSET.get( + asset, + DEFAULT_POSITION_THRESHOLDS, + ) + + +def get_position_health_thresholds(symbol: str | None) -> dict[str, float]: + return get_position_thresholds(symbol)["health"] + + +def get_position_exit_thresholds(symbol: str | None) -> dict[str, float]: + return get_position_thresholds(symbol)["exit"] + + +def build_flip_action( + old_side: str, + new_side: str, +) -> str: + return f"FLIP_{old_side}_TO_{new_side}" \ No newline at end of file diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index b3b6ce9..a0ee958 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -2,22 +2,12 @@ from __future__ import annotations -import time -#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.trading.auto.state import AutoTradeState from src.trading.execution.models import ExecutionDecision -#from src.trading.journal.service import JournalService from src.trading.position.state import PositionState -#from src.core.numbers import safe_float -#from src.core.types import 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_exit_decision import ExecutionPositionExitDecisionMixin from src.trading.execution.position_protection import ExecutionPositionProtectionMixin from src.trading.execution.supervisor import ExecutionSupervisorMixin from src.trading.execution.sizing import ExecutionSizingMixin @@ -27,6 +17,17 @@ 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 +from src.trading.execution.constants import ( + EXECUTION_ACTION_NONE, + EXECUTION_ACTION_OPEN_LONG, + EXECUTION_ACTION_OPEN_SHORT, + EXECUTION_DECISION_READY, + EXECUTION_STATUS_RUNNING, + POSITION_SIDE_LONG, + POSITION_SIDE_SHORT, + SIGNAL_BUY, + SIGNAL_SELL, +) class ExecutionEngine( @@ -34,7 +35,7 @@ class ExecutionEngine( ExecutionResetsMixin, ExecutionPricingMixin, ExecutionPositionRuntimeMixin, - ExecutionPositionIntelligenceMixin, + ExecutionPositionExitDecisionMixin, ExecutionSizingMixin, ExecutionPositionActionsMixin, ExecutionPositionProtectionMixin, @@ -45,11 +46,11 @@ class ExecutionEngine( ): _position = PositionState() _size_precision = 5 - _min_flip_confidence = 0.75 - _min_flip_repeat_count = 3 - _min_flip_hold_seconds = 60 + _min_flip_confidence = 0.65 + _min_flip_repeat_count = 2 + _min_flip_hold_seconds = 20 _flip_cooldown_seconds = 45 - _loss_flip_confidence = 0.9 + _loss_flip_confidence = 0.75 _last_flip_block_key: str | None = None _runtime_action_cooldown_seconds = 30 _last_runtime_action_key: str | None = None @@ -61,7 +62,6 @@ class ExecutionEngine( _max_execution_snapshot_age_seconds = 5 _degraded_market_block_states = { - "HIGH_VOLATILITY", "CHAOTIC", "LIQUIDITY_VOID", } @@ -70,47 +70,81 @@ class ExecutionEngine( _last_supervisor_block_key: str | None = None + # вернуть ExecutionDecision без выполнения торгового действия + def _skip_execution( + self, + state: AutoTradeState, + reason: str, + ) -> ExecutionDecision: + state.last_execution_action = EXECUTION_ACTION_NONE + state.last_execution_reason = reason + + return ExecutionDecision( + EXECUTION_ACTION_NONE, + False, + reason, + ) + def process(self, state: AutoTradeState) -> ExecutionDecision: + # Synchronize runtime state self._sync_state_from_position(state) - if state.status != "RUNNING": - return ExecutionDecision("NONE", False, "Execution доступен только в режиме RUNNING.") + if state.status != EXECUTION_STATUS_RUNNING: + return self._skip_execution( + state, + "Execution доступен только в режиме RUNNING.", + ) self._update_unrealized_pnl(state) + # Emergency risk management risk_decision = self._risk_close_decision(state) if risk_decision is not None: return risk_decision + # Runtime position protection protection_decision = self._process_runtime_protection(state) if protection_decision is not None: return protection_decision + # Signal readiness validation + if state.decision_status != EXECUTION_DECISION_READY or not state.is_signal_ready: + reason = ( + f"Execution ожидает READY " + f"(decision={state.decision_status}, " + f"ready={state.is_signal_ready})." + ) + + return self._skip_execution( + state, + reason, + ) + + # Execution supervisor supervisor_decision = self._process_execution_supervisor(state) if supervisor_decision is not None: return supervisor_decision - if state.decision_status != "READY" or not state.is_signal_ready: - return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.") - + # Existing position validation position = type(self)._position # Не пытаемся повторно открыть позицию в ту же сторону. # Сигнал остаётся валидным для UI/Telegram, но execution не дублируется. - if position.side == "LONG" and state.last_signal == "BUY": + if position.side == POSITION_SIDE_LONG and state.last_signal == SIGNAL_BUY: return ExecutionDecision( - "NONE", + EXECUTION_ACTION_NONE, False, "Сигнал BUY совпадает с уже открытой LONG позицией.", ) - if position.side == "SHORT" and state.last_signal == "SELL": + if position.side == POSITION_SIDE_SHORT and state.last_signal == SIGNAL_SELL: return ExecutionDecision( - "NONE", + EXECUTION_ACTION_NONE, False, "Сигнал SELL совпадает с уже открытой SHORT позицией.", ) + # Position flip if self._should_flip_position(state): flip_block_reason = self._flip_block_reason(state) @@ -119,10 +153,22 @@ class ExecutionEngine( return self._flip_position(state) - if state.last_signal == "BUY": - return self._open_position_if_empty(state=state, side="LONG", action="OPEN_LONG") + # New position opening + if state.last_signal == SIGNAL_BUY: + return self._open_position_if_empty( + state=state, + side=POSITION_SIDE_LONG, + action=EXECUTION_ACTION_OPEN_LONG, + ) - if state.last_signal == "SELL": - return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT") + if state.last_signal == SIGNAL_SELL: + return self._open_position_if_empty( + state=state, + side=POSITION_SIDE_SHORT, + action=EXECUTION_ACTION_OPEN_SHORT, + ) - return ExecutionDecision("NONE", False, "Нет торгового действия.") \ No newline at end of file + return self._skip_execution( + state, + "Нет торгового действия.", + ) \ No newline at end of file diff --git a/app/src/trading/execution/flip.py b/app/src/trading/execution/flip.py index 57c4167..43bcbe2 100644 --- a/app/src/trading/execution/flip.py +++ b/app/src/trading/execution/flip.py @@ -7,12 +7,33 @@ 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.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 -from src.trading.execution.pricing import ExecutionPrice +from src.trading.execution.position_metrics import build_position_metrics +from src.trading.execution.constants import ( + EXECUTION_ACTION_FLIP_BLOCKED, + EXECUTION_ACTION_NONE, + EXECUTION_MAX_CONSECUTIVE_LOSSES, + EXECUTION_TYPE_FLIP, + EXECUTION_TYPE_FLIP_BLOCKED, + EXECUTION_TYPE_FLIP_REJECTED, + FLIP_BREAKOUT_CONFIDENCE_THRESHOLD, + FLIP_MIN_EXECUTION_CONFIDENCE, + FLIP_MIN_HTF_CONFIRMATION_SCORE, + MARKET_PHASE_FLIP_BLOCKED, + MARKET_STATE_FLIP_BLOCKED, + POSITION_SIDE_LONG, + POSITION_SIDE_NONE, + POSITION_SIDE_SHORT, + PRICING_FLIP_MODE, + SIGNAL_BUY, + SIGNAL_SELL, + build_flip_action, +) class _ExecutionFlipProtocol(Protocol): @@ -24,57 +45,280 @@ class _ExecutionFlipProtocol(Protocol): _loss_flip_confidence: float _last_flip_block_key: str | None - def _create_trade_id(self, state: AutoTradeState, side: str) -> str: ... + def _create_trade_id(self, state: AutoTradeState, side: str) -> str: + ... - # получить exit price для текущей стороны позиции - def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ... + 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 _entry_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: + ... - # рассчитать размер позиции def _calculate_position_size( self, state: AutoTradeState, *, entry_price: float | None = None, - ) -> float: ... + ) -> float: + ... - # ограничить размер позиции margin-limit правилом def _adjust_size_by_margin_limit( self, *, state: AutoTradeState, entry_price: float, size: float, - ) -> float: ... + ) -> float: + ... - # пересчитать effective risk после margin-limit def _sync_effective_risk_after_margin_limit( self, state: AutoTradeState, *, base_size: float, final_size: float, - ) -> None: ... + ) -> None: + ... - # округлить размер позиции - def _round_size(self, size) -> float: ... + def _round_size(self, size: NumericLike | None) -> float: + ... - # рассчитать PnL позиции - def _calculate_pnl(self, current_price) -> float: ... + def _sync_state_from_position(self, state: AutoTradeState) -> None: + ... - # синхронизировать AutoTradeState с PositionState - def _sync_state_from_position(self, state: AutoTradeState) -> None: ... + def _now_time(self) -> str: + ... - # посчитать время удержания позиции - def _position_hold_seconds(self, position: PositionState) -> int | None: ... + def _reset_runtime_protection_state(self, state: AutoTradeState) -> None: + ... - # получить текущее время строкой - def _now_time(self) -> str: ... + def _reset_position_lifecycle_state(self, state: AutoTradeState) -> None: + ... class ExecutionFlipMixin(_ExecutionFlipProtocol): + # ---------- Payload builders ---------- + # собрать payload отказа flip без изменения состояния + def _build_flip_rejected_payload( + self, + *, + state: AutoTradeState, + reason: str, + ) -> JsonDict: + position = type(self)._position + + return { + "execution_type": EXECUTION_TYPE_FLIP_REJECTED, + "symbol": state.symbol, + "position_side": position.side, + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "execution_confidence_score": state.execution_confidence_score, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + "reject_reason": reason, + # Общая оценка рынка на момент отказа flip. + "market_score": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_phase": state.market_phase, + "market_trend_quality": state.market_trend_quality, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "entry_timing_state": state.entry_timing_state, + "opened_at": position.opened_at, + "updated_at": position.updated_at, + } + + # собрать payload блокировки flip без изменения состояния + def _build_flip_blocked_payload( + self, + *, + state: AutoTradeState, + reason: str, + confidence: float, + ) -> JsonDict: + position = type(self)._position + + return { + "execution_type": EXECUTION_TYPE_FLIP_BLOCKED, + "symbol": state.symbol, + "position_side": position.side, + "signal": state.last_signal, + "confidence": confidence, + "execution_confidence_score": state.execution_confidence_score, + "repeat_count": state.last_signal_repeat_count, + "reason": reason, + # Общая оценка рынка на момент блокировки flip. + "market_score": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_phase": state.market_phase, + "market_structure": state.market_structure, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "opened_at": position.opened_at, + "updated_at": position.updated_at, + } + + # собрать payload выполненного flip без изменения состояния + def _build_flip_executed_payload( + self, + *, + state: AutoTradeState, + old_trade_id: str | None, + old_trade_sequence: int | None, + old_trade_cycle_number: int | None, + new_trade_id: str, + old_side: str, + new_side: str, + old_entry_price: float | None, + exit_price: float, + new_entry_price: float, + old_size: float | None, + new_size: float, + old_leverage: float | None, + pnl: float, + metrics, + flip_action: str, + now: str, + opened_monotonic_at: float, + old_opened_at: str | None, + exit_execution: ExecutionPrice, + entry_execution: ExecutionPrice, + ) -> JsonDict: + return { + "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": EXECUTION_TYPE_FLIP, + "action": flip_action, + "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, + + # ---------- PnL / Metrics ---------- + "net_pnl_usd": metrics.net_pnl_usd, + "gross_pnl_usd": metrics.gross_pnl_usd, + "commission_usd": metrics.commission_usd, + "overnight_cashflow_usd": metrics.overnight_cashflow_usd, + "pnl_percent": metrics.pnl_percent, + "price_move_percent": metrics.price_move_percent, + "entry_notional_usd": metrics.entry_notional_usd, + "current_notional_usd": metrics.current_notional_usd, + "margin_usd": metrics.margin_usd, + "hold_seconds": metrics.hold_seconds, + "overnight_count": metrics.overnight_count, + + "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, + # Общая оценка рынка на момент смены направления позиции. + # Фиксируем её вместе с adaptive size, чтобы видеть контекст flip. + "market_score": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), + "opened_at": old_opened_at, + "new_opened_monotonic_at": opened_monotonic_at, + "closed_at": now, + "new_opened_at": now, + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_phase": state.market_phase, + "market_structure": state.market_structure, + + # ---------- Position health ---------- + "position_hold_seconds": state.position_hold_seconds, + "position_health_status": state.position_health_status, + "position_health_score": state.position_health_score, + "position_health_reason": state.position_health_reason, + "position_risk_level": state.position_risk_level, + "position_risk_reason": state.position_risk_reason, + "position_trend_alignment": state.position_trend_alignment, + "position_adverse_momentum": state.position_adverse_momentum, + + # ---------- Position intelligence ---------- + "position_exit_signal": state.position_exit_signal, + "position_exit_confidence": state.position_exit_confidence, + "position_exit_urgency": state.position_exit_urgency, + "position_reversal_risk": state.position_reversal_risk, + "position_fatigue_state": state.position_fatigue_state, + "position_giveback_percent": state.position_giveback_percent, + "position_mfe_percent": state.position_mfe_percent, + "position_mae_percent": state.position_mae_percent, + "position_peak_pnl_usd": state.position_peak_pnl_usd, + "position_peak_pnl_percent": state.position_peak_pnl_percent, + + # ---------- Autonomous ---------- + "autonomous_action": state.autonomous_action, + "autonomous_action_reason": state.autonomous_action_reason, + "autonomous_action_confidence": state.autonomous_action_confidence, + "autonomous_protection_required": state.autonomous_protection_required, + "autonomous_reduce_required": state.autonomous_reduce_required, + "autonomous_exit_required": state.autonomous_exit_required, + + # ---------- Runtime protection ---------- + "position_protection_status": state.position_protection_status, + "position_protection_reason": state.position_protection_reason, + "runtime_protection_action": state.runtime_protection_action, + "runtime_protection_reason": state.runtime_protection_reason, + "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, + + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "pricing": PRICING_FLIP_MODE, + "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, + } + + # ---------- Journal helpers ---------- # записать отказ flip execution в журнал def _log_flip_rejected( self, @@ -82,21 +326,10 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): 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, - } + payload = self._build_flip_rejected_payload( + state=state, + reason=reason, + ) JournalService().log_ui_warning( event_type="position_flip_rejected", @@ -106,77 +339,16 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): 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 + # ---------- Decision helpers ---------- + # записать отказ flip и вернуть стандартное решение без исполнения + def _reject_flip( + self, + *, + state: AutoTradeState, + reason: str, + ) -> ExecutionDecision: + self._log_flip_rejected(state=state, reason=reason) + return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) # записать блокировку flip в state, journal и event bus def _block_flip( @@ -189,7 +361,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): state.execution_block_reason = reason state.last_flip_block_reason = reason - state.last_execution_action = "FLIP_BLOCKED" + state.last_execution_action = EXECUTION_ACTION_FLIP_BLOCKED state.last_execution_reason = reason block_key = ( @@ -203,18 +375,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): 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, - } + payload = self._build_flip_blocked_payload( + state=state, + reason=reason, + confidence=confidence, + ) JournalService().log_ui_warning( event_type="position_flip_blocked", @@ -226,48 +391,182 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): EventBus.emit("paper_flip_blocked", payload) - return ExecutionDecision("NONE", False, reason) + return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) + + # ---------- Flip checks ---------- + # проверить, нужен ли flip позиции по текущему сигналу + def _should_flip_position(self, state: AutoTradeState) -> bool: + position = type(self)._position + signal = str(state.last_signal or "").upper() + + if position.side == POSITION_SIDE_NONE: + return False + + if position.side == POSITION_SIDE_LONG and signal == SIGNAL_SELL: + return True + + if position.side == POSITION_SIDE_SHORT and signal == SIGNAL_BUY: + return True + + return False + + # определить причину блокировки flip, если flip сейчас опасен + def _flip_block_reason(self, state: AutoTradeState) -> str | None: + position = type(self)._position + + signal = str(state.last_signal or "").upper() + confidence = safe_float(state.last_signal_confidence) or 0.0 + execution_confidence = safe_float(state.execution_confidence_score) + repeat_count = int(safe_float(state.last_signal_repeat_count) or 0) + unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0 + metrics = build_position_metrics( + position, + current_price=position.entry_price, + ) + hold_seconds = metrics.hold_seconds + + market_state = str(getattr(state, "market_state", "") or "").upper() + market_trend = str(getattr(state, "market_trend", "") or "").upper() + market_phase = str(getattr(state, "market_phase", "") or "").upper() + market_quality = str(getattr(state, "market_trend_quality", "") or "").upper() + market_structure = str(getattr(state, "market_structure", "") or "").upper() + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() + htf_score = safe_float(getattr(state, "htf_confirmation_score", None)) + entry_timing = str(getattr(state, "entry_timing_state", "") or "").upper() + + momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() + momentum_state = str(getattr(state, "momentum_state", "") or "").upper() + + if confidence < self._min_flip_confidence: + return ( + "уверенность flip-сигнала ниже порога " + f"({confidence:.2f} < {self._min_flip_confidence:.2f})" + ) + + if ( + execution_confidence is not None + and execution_confidence < FLIP_MIN_EXECUTION_CONFIDENCE + ): + return ( + "execution confidence для flip недостаточный " + f"({execution_confidence:.2f} < {FLIP_MIN_EXECUTION_CONFIDENCE:.2f})" + ) + + if repeat_count < self._min_flip_repeat_count: + return ( + "flip-сигнал ещё не подтверждён нужным количеством повторов " + 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 f"flip cooldown активен (< {self._flip_cooldown_seconds}с)" + + if market_state in MARKET_STATE_FLIP_BLOCKED: + return f"market state не подходит для flip: {market_state or 'UNKNOWN'}" + + if market_phase in MARKET_PHASE_FLIP_BLOCKED: + return f"market phase не подходит для flip: {market_phase or 'UNKNOWN'}" + + if market_quality == "NOISY": + return "flip заблокирован: тренд шумный" + + if htf_alignment != "ALIGNED": + return f"flip заблокирован: HTF не подтверждает направление ({htf_alignment or 'UNKNOWN'})" + + if htf_score is None or htf_score < FLIP_MIN_HTF_CONFIRMATION_SCORE: + return f"flip заблокирован: слабое HTF-подтверждение ({htf_score})" + + if entry_timing in {"LATE", "CHASING"}: + return f"flip заблокирован: плохой тайминг входа ({entry_timing})" + + if signal == SIGNAL_BUY: + if market_trend == "DOWN": + return "BUY flip против основного market trend" + + if momentum_direction != "UP": + return "momentum не подтверждает BUY flip" + + if momentum_state == "BREAKOUT_DOWN": + return "BUY flip против breakout вниз" + + if market_structure == "LH_LL": + return "BUY flip против bearish market structure" + + if signal == SIGNAL_SELL: + if market_trend == "UP": + return "SELL flip против основного market trend" + + if momentum_direction != "DOWN": + return "momentum не подтверждает SELL flip" + + if momentum_state == "BREAKOUT_UP": + return "SELL flip против breakout вверх" + + if market_structure == "HH_HL": + return "SELL flip против bullish market structure" + + if market_structure == "MIXED": + return "flip заблокирован: структура рынка смешанная" + + if ( + momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"} + and confidence < FLIP_BREAKOUT_CONFIDENCE_THRESHOLD + ): + return ( + "flip заблокирован во время breakout impulse " + f"({confidence:.2f} < {FLIP_BREAKOUT_CONFIDENCE_THRESHOLD:.2f})" + ) + + if unrealized_pnl < 0 and confidence < self._loss_flip_confidence: + return ( + "позиция сейчас в минусе, а flip-сигнал недостаточно сильный " + f"({confidence:.2f} < {self._loss_flip_confidence:.2f})" + ) + + return None # проверить, активен ли cooldown после последнего flip - def _flip_cooldown_active( - self, - state: AutoTradeState, - ) -> bool: - ts = getattr(state, "last_flip_monotonic_at", None) + def _flip_cooldown_active(self, state: AutoTradeState) -> bool: + ts = safe_float(getattr(state, "last_flip_monotonic_at", None)) if ts is None: return False - return ( - time.monotonic() - float(ts) - ) < self._flip_cooldown_seconds + return (time.monotonic() - ts) < self._flip_cooldown_seconds # определить сторону позиции по сигналу BUY / SELL def _target_side_from_signal(self, signal: str | None) -> str | None: - if signal == "BUY": - return "LONG" + normalized_signal = str(signal or "").upper() - if signal == "SELL": - return "SHORT" + if normalized_signal == SIGNAL_BUY: + return POSITION_SIDE_LONG + + if normalized_signal == SIGNAL_SELL: + return POSITION_SIDE_SHORT return None + # ---------- Execution ---------- # закрыть текущую позицию и открыть новую в противоположную сторону def _flip_position(self, state: AutoTradeState) -> ExecutionDecision: position = type(self)._position - if position.side == "NONE": + if position.side == POSITION_SIDE_NONE: self._sync_state_from_position(state) reason = "Нет позиции для flip." - self._log_flip_rejected(state=state, reason=reason) - return ExecutionDecision("NONE", False, reason) + return self._reject_flip(state=state, reason=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) + return self._reject_flip(state=state, reason=reason) try: exit_execution = self._exit_price_for_side( @@ -283,12 +582,19 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): except Exception as exc: reason = f"Ошибка получения цены для flip: {exc}" - self._log_flip_rejected(state=state, reason=reason) - return ExecutionDecision("NONE", False, reason) + return self._reject_flip(state=state, reason=reason) now = self._now_time() opened_monotonic_at = time.monotonic() - pnl = self._calculate_pnl(exit_price) + metrics = build_position_metrics( + position, + current_price=exit_price, + ) + + # net_pnl_usd может быть None при неполных метриках, + # поэтому нормализуем в 0.0, чтобы статистика цикла не падала. + pnl = safe_float(metrics.net_pnl_usd) or 0.0 + new_size = self._calculate_position_size( state, entry_price=new_entry_price, @@ -296,8 +602,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): if new_size <= 0: reason = "Flip отменён: невозможно рассчитать adaptive size." - self._log_flip_rejected(state=state, reason=reason) - return ExecutionDecision("NONE", False, reason) + return self._reject_flip(state=state, reason=reason) new_size = self._adjust_size_by_margin_limit( state=state, @@ -315,21 +620,50 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): if new_size <= 0: reason = "Flip отменён: итоговый size равен 0." - self._log_flip_rejected(state=state, reason=reason) - return ExecutionDecision("NONE", False, reason) + return self._reject_flip(state=state, reason=reason) state.realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl state.cycle_closed_trades += 1 + state.cycle_trade_fees_usd += abs(safe_float(metrics.commission_usd) or 0.0) + state.cycle_overnight_fees_usd += safe_float(metrics.overnight_cashflow_usd) or 0.0 if pnl > 0: state.cycle_winning_trades += 1 + # прибыльный flip закрывает серию убытков + state.cycle_consecutive_losses = 0 + + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + + elif pnl < 0: + state.cycle_losing_trades += 1 + state.cycle_consecutive_losses += 1 + + state.last_loss_monotonic_at = time.monotonic() + + if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES: + state.loss_cooldown_active = True + state.loss_cooldown_reason = ( + f"{state.cycle_consecutive_losses} подряд убыточных сделок" + ) + old_side = position.side old_entry_price = position.entry_price old_size = position.size old_leverage = position.leverage old_opened_at = position.opened_at + flip_action = build_flip_action(old_side, new_side) + + self._reset_runtime_protection_state(state) + self._reset_position_lifecycle_state(state) + + # Flip открывает новую позицию, поэтому autonomous runtime прошлой позиции + # нельзя переносить на новую сделку. + state.autonomous_last_action = None + state.autonomous_last_action_reason = None + state.autonomous_last_action_at = None state.last_flip_old_side = old_side state.last_flip_new_side = new_side @@ -367,67 +701,39 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): self._sync_state_from_position(state) + state.position_opened_monotonic_at = opened_monotonic_at + 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_action = flip_action 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, - } + payload = self._build_flip_executed_payload( + state=state, + old_trade_id=old_trade_id, + old_trade_sequence=old_trade_sequence, + old_trade_cycle_number=old_trade_cycle_number, + new_trade_id=new_trade_id, + old_side=old_side, + new_side=new_side, + old_entry_price=old_entry_price, + exit_price=exit_price, + new_entry_price=new_entry_price, + old_size=old_size, + new_size=new_size, + old_leverage=old_leverage, + pnl=pnl, + metrics=metrics, + flip_action=flip_action, + now=now, + opened_monotonic_at=opened_monotonic_at, + old_opened_at=old_opened_at, + exit_execution=exit_execution, + entry_execution=entry_execution, + ) JournalService().log_ui_info( event_type="position_flipped", @@ -440,7 +746,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol): EventBus.emit("paper_position_flipped", payload) return ExecutionDecision( - f"FLIP_{old_side}_TO_{new_side}", + flip_action, 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 index 87729ac..9dcf0be 100644 --- a/app/src/trading/execution/position_actions.py +++ b/app/src/trading/execution/position_actions.py @@ -13,34 +13,44 @@ 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 +from src.trading.execution.position_metrics import build_position_metrics +from src.trading.execution.constants import ( + EXECUTION_ACTION_CLOSE, + EXECUTION_ACTION_FORCE_CLOSE_PREFIX, + EXECUTION_ACTION_NONE, + EXECUTION_MAX_CONSECUTIVE_LOSSES, + EXECUTION_REASON_MANUAL, + EXECUTION_TYPE_ENTRY, + EXECUTION_TYPE_ENTRY_REJECTED, + EXECUTION_TYPE_EXIT, + POSITION_SIDE_NONE, + PRICING_ENTRY_MODE, + PRICING_EXIT_MODE, +) 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, @@ -48,7 +58,6 @@ class _ExecutionPositionActionsProtocol(Protocol): entry_price: float | None = None, ) -> float: ... - # ограничить size margin limit def _adjust_size_by_margin_limit( self, *, @@ -57,7 +66,6 @@ class _ExecutionPositionActionsProtocol(Protocol): size: float, ) -> float: ... - # обновить effective risk после margin limit def _sync_effective_risk_after_margin_limit( self, state: AutoTradeState, @@ -66,32 +74,28 @@ class _ExecutionPositionActionsProtocol(Protocol): 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: ... + def _reset_position_lifecycle_state( + self, + state: AutoTradeState, + ) -> None: ... + class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): + # ---------- Trade id ---------- # создать новый trade_id для связки open -> close def _create_trade_id(self, state: AutoTradeState, side: str) -> str: state.trade_sequence = int(state.trade_sequence or 0) + 1 @@ -103,7 +107,539 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): f"{side.lower()}-" f"{int(time.time())}" ) - + + # ---------- Payload builders ---------- + # собрать payload отказа открытия позиции без изменения состояния + def _build_position_open_rejected_payload( + self, + *, + state: AutoTradeState, + side: str, + action: str, + reason: str, + ) -> JsonDict: + return { + # ---------- Event ---------- + "execution_type": EXECUTION_TYPE_ENTRY_REJECTED, + "action": action, + "reject_reason": reason, + + # ---------- Runtime ---------- + "status": state.status, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + + # ---------- Instrument ---------- + "symbol": state.symbol, + "side": side, + + # ---------- Signal ---------- + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + + # ---------- Decision ---------- + "decision_status": state.decision_status, + "decision_reason": state.decision_reason, + + # ---------- Runtime blocks ---------- + "entry_block_reason": state.entry_block_reason, + "entry_block_message": state.entry_block_message, + "execution_block_reason": state.execution_block_reason, + "execution_block_title": state.execution_block_title, + "execution_block_message": state.execution_block_message, + "execution_block_action": state.execution_block_action, + "last_flip_block_reason": state.last_flip_block_reason, + + # ---------- Execution ---------- + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_reason": state.execution_confidence_reason, + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_quality_message": state.execution_quality_message, + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + + # ---------- Execution price ---------- + "execution_price_source": state.execution_price_source, + "execution_price_age_seconds": state.execution_price_age_seconds, + "execution_bid_price": state.execution_bid_price, + "execution_ask_price": state.execution_ask_price, + "execution_last_price": state.execution_last_price, + "execution_price_freshness": state.execution_price_freshness, + + # ---------- Adaptive size ---------- + "adaptive_size_base": state.adaptive_size_base, + "adaptive_size_final": state.adaptive_size_final, + "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, + + # ---------- Risk settings ---------- + "risk_percent": state.risk_percent, + "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, + "allocated_balance_usd": state.allocated_balance_usd, + "leverage": state.leverage, + + # ---------- Market score ---------- + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + + # ---------- Market ---------- + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_volatility": state.market_volatility, + "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, + + # ---------- Candle ---------- + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + + # ---------- Structure ---------- + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, + + # ---------- Momentum ---------- + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "momentum_change_percent": state.momentum_change_percent, + "breakout_level": state.breakout_level, + "breakout_distance_percent": state.breakout_distance_percent, + "breakout_reason": state.breakout_reason, + + # ---------- HTF ---------- + "htf_interval": state.htf_interval, + "htf_atr_percent": state.htf_atr_percent, + "htf_atr_percent_baseline": state.htf_atr_percent_baseline, + "htf_volatility_ratio": state.htf_volatility_ratio, + "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, + + # ---------- Market runtime ---------- + "market_runtime_degraded": state.market_runtime_degraded, + "runtime_expired_reason": state.runtime_expired_reason, + "runtime_expired_message": state.runtime_expired_message, + "market_is_open": state.market_is_open, + "market_status": state.market_status, + "market_status_message": state.market_status_message, + } + + # собрать payload успешного открытия позиции без изменения состояния + def _build_position_opened_payload( + self, + *, + state: AutoTradeState, + side: str, + action: str, + trade_id: str, + entry_price: float, + size: float, + now: str, + opened_monotonic_at: float, + entry: ExecutionPrice, + ) -> JsonDict: + return { + # ---------- Trade ---------- + "trade_id": trade_id, + "trade_sequence": state.trade_sequence, + "trade_cycle_number": state.current_trade_cycle_number, + + "execution_type": EXECUTION_TYPE_ENTRY, + "action": action, + + # ---------- Runtime ---------- + "status": state.status, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + + # ---------- Position ---------- + "symbol": state.symbol, + "side": side, + "entry_price": entry_price, + "size": size, + "leverage": state.leverage, + + "opened_at": now, + "opened_monotonic_at": opened_monotonic_at, + + # ---------- Runtime position state ---------- + "position_pressure": state.position_pressure, + "position_health_status": state.position_health_status, + "position_health_score": state.position_health_score, + "position_risk_level": state.position_risk_level, + "position_risk_reason": state.position_risk_reason, + + # ---------- Signal ---------- + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + + # ---------- Decision ---------- + "decision_status": state.decision_status, + "decision_reason": state.decision_reason, + + # ---------- Runtime blocks ---------- + "entry_block_reason": state.entry_block_reason, + "entry_block_message": state.entry_block_message, + "execution_block_reason": state.execution_block_reason, + "execution_block_title": state.execution_block_title, + "execution_block_message": state.execution_block_message, + "execution_block_action": state.execution_block_action, + "last_flip_block_reason": state.last_flip_block_reason, + + # ---------- Execution ---------- + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_reason": state.execution_confidence_reason, + + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_quality_message": state.execution_quality_message, + + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + + # ---------- Execution price ---------- + "execution_price_source": state.execution_price_source, + "execution_price_age_seconds": state.execution_price_age_seconds, + "execution_bid_price": state.execution_bid_price, + "execution_ask_price": state.execution_ask_price, + "execution_last_price": state.execution_last_price, + "execution_price_freshness": state.execution_price_freshness, + + # ---------- Pricing ---------- + "pricing": PRICING_ENTRY_MODE, + "pricing_role": entry.pricing_role, + "price_source": entry.source, + "price_age_seconds": entry.age_seconds, + "price_updated_at": entry.updated_at, + + # ---------- Adaptive size ---------- + "adaptive_size_base": state.adaptive_size_base, + "adaptive_size_final": state.adaptive_size_final, + "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, + + # ---------- Risk settings ---------- + "risk_percent": state.risk_percent, + "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, + "allocated_balance_usd": state.allocated_balance_usd, + + # ---------- Market score ---------- + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + + # ---------- Market ---------- + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_volatility": state.market_volatility, + "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, + + # ---------- Candle ---------- + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + + # ---------- Structure ---------- + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, + + # ---------- Momentum ---------- + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "momentum_change_percent": state.momentum_change_percent, + "breakout_level": state.breakout_level, + "breakout_distance_percent": state.breakout_distance_percent, + "breakout_reason": state.breakout_reason, + + # ---------- HTF ---------- + "htf_interval": state.htf_interval, + "htf_atr_percent": state.htf_atr_percent, + "htf_atr_percent_baseline": state.htf_atr_percent_baseline, + "htf_volatility_ratio": state.htf_volatility_ratio, + "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, + + # ---------- Market runtime ---------- + "market_runtime_degraded": state.market_runtime_degraded, + "runtime_expired_reason": state.runtime_expired_reason, + "runtime_expired_message": state.runtime_expired_message, + "market_is_open": state.market_is_open, + "market_status": state.market_status, + "market_status_message": state.market_status_message, + } + + # собрать payload закрытия позиции без изменения состояния + def _build_position_closed_payload( + self, + *, + state: AutoTradeState, + position: PositionState, + trade_id: str | None, + exit_price: float, + exit_execution: ExecutionPrice | None, + metrics, + pnl: float, + price_move_percent: float | None, + close_reason: str, + forced_reason: str | None, + now: str, + ) -> JsonDict: + return { + # ---------- Trade ---------- + "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": EXECUTION_TYPE_EXIT, + "action": EXECUTION_ACTION_CLOSE, + "risk_reason": forced_reason, + "close_reason": close_reason, + "is_forced": forced_reason is not None, + + # ---------- Runtime ---------- + "status": state.status, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + + # ---------- Instrument / Position ---------- + "symbol": state.symbol, + "side": position.side, + "entry_price": position.entry_price, + "exit_price": exit_price, + "size": position.size, + "leverage": position.leverage, + + "opened_at": position.opened_at, + "closed_at": now, + + # ---------- PnL / Metrics ---------- + "pnl": pnl, + "net_pnl_usd": metrics.net_pnl_usd, + "gross_pnl_usd": metrics.gross_pnl_usd, + "commission_usd": metrics.commission_usd, + "overnight_cashflow_usd": metrics.overnight_cashflow_usd, + "pnl_percent": metrics.pnl_percent, + "price_move_percent": price_move_percent, + + "entry_notional_usd": metrics.entry_notional_usd, + "current_notional_usd": metrics.current_notional_usd, + "margin_usd": metrics.margin_usd, + "hold_seconds": metrics.hold_seconds, + "overnight_count": metrics.overnight_count, + + # ---------- Signal ---------- + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "reason": state.last_signal_reason, + + # ---------- Decision ---------- + "decision_status": state.decision_status, + "decision_reason": state.decision_reason, + + # ---------- Runtime blocks ---------- + "entry_block_reason": state.entry_block_reason, + "entry_block_message": state.entry_block_message, + "execution_block_reason": state.execution_block_reason, + "execution_block_title": state.execution_block_title, + "execution_block_message": state.execution_block_message, + "execution_block_action": state.execution_block_action, + "last_flip_block_reason": state.last_flip_block_reason, + + # ---------- Execution ---------- + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_quality_message": state.execution_quality_message, + + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_reason": state.execution_confidence_reason, + + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + + # ---------- Execution price ---------- + "execution_price_source": state.execution_price_source, + "execution_price_age_seconds": state.execution_price_age_seconds, + "execution_bid_price": state.execution_bid_price, + "execution_ask_price": state.execution_ask_price, + "execution_last_price": state.execution_last_price, + "execution_price_freshness": state.execution_price_freshness, + + # ---------- Pricing ---------- + "pricing": PRICING_EXIT_MODE, + "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, + + # ---------- Adaptive size ---------- + "adaptive_size_base": state.adaptive_size_base, + "adaptive_size_final": state.adaptive_size_final, + "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, + + # ---------- Risk Settings ---------- + "risk_percent": state.risk_percent, + "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, + "allocated_balance_usd": state.allocated_balance_usd, + + # ---------- Cycle Stats Before Close Sync ---------- + "realized_pnl_usd_before": state.realized_pnl_usd, + "cycle_realized_pnl_usd_before": state.cycle_realized_pnl_usd, + "cycle_closed_trades_before": state.cycle_closed_trades, + "cycle_winning_trades_before": state.cycle_winning_trades, + "cycle_losing_trades_before": state.cycle_losing_trades, + "cycle_consecutive_losses_before": state.cycle_consecutive_losses, + "cycle_trade_fees_usd_before": state.cycle_trade_fees_usd, + "cycle_overnight_fees_usd_before": state.cycle_overnight_fees_usd, + + # ---------- Position Health ---------- + "position_hold_seconds": state.position_hold_seconds, + "position_health_status": state.position_health_status, + "position_health_score": state.position_health_score, + "position_health_reason": state.position_health_reason, + "position_risk_level": state.position_risk_level, + "position_risk_reason": state.position_risk_reason, + "position_trend_alignment": state.position_trend_alignment, + "position_adverse_momentum": state.position_adverse_momentum, + + # ---------- Position Intelligence ---------- + "position_exit_signal": state.position_exit_signal, + "position_exit_confidence": state.position_exit_confidence, + "position_exit_urgency": state.position_exit_urgency, + "position_reversal_risk": state.position_reversal_risk, + "position_fatigue_state": state.position_fatigue_state, + "position_giveback_percent": state.position_giveback_percent, + "position_mfe_percent": state.position_mfe_percent, + "position_mae_percent": state.position_mae_percent, + "position_peak_pnl_usd": state.position_peak_pnl_usd, + "position_peak_pnl_percent": state.position_peak_pnl_percent, + + # ---------- Autonomous ---------- + "autonomous_action": state.autonomous_action, + "autonomous_action_reason": state.autonomous_action_reason, + "autonomous_action_confidence": state.autonomous_action_confidence, + "autonomous_protection_required": state.autonomous_protection_required, + "autonomous_reduce_required": state.autonomous_reduce_required, + "autonomous_exit_required": state.autonomous_exit_required, + + # ---------- Market Score ---------- + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + + # ---------- Market ---------- + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_volatility": state.market_volatility, + "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, + + # ---------- Candle ---------- + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + + # ---------- Structure ---------- + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, + + # ---------- Momentum ---------- + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "momentum_change_percent": state.momentum_change_percent, + "breakout_level": state.breakout_level, + "breakout_distance_percent": state.breakout_distance_percent, + "breakout_reason": state.breakout_reason, + + # ---------- HTF ---------- + "htf_interval": state.htf_interval, + "htf_atr_percent": state.htf_atr_percent, + "htf_atr_percent_baseline": state.htf_atr_percent_baseline, + "htf_volatility_ratio": state.htf_volatility_ratio, + "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, + + # ---------- Market runtime ---------- + "market_runtime_degraded": state.market_runtime_degraded, + "runtime_expired_reason": state.runtime_expired_reason, + "runtime_expired_message": state.runtime_expired_message, + "market_is_open": state.market_is_open, + "market_status": state.market_status, + "market_status_message": state.market_status_message, + } + + # ---------- Journal helpers ---------- # записать отказ открытия позиции в журнал def _log_position_open_rejected( self, @@ -113,27 +649,12 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): 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, - } + payload = self._build_position_open_rejected_payload( + state=state, + side=side, + action=action, + reason=reason, + ) JournalService().log_ui_warning( event_type="position_open_rejected", @@ -143,6 +664,26 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): payload=payload, ) + # ---------- Decision helpers ---------- + # записать отказ открытия позиции и вернуть стандартное решение без исполнения + def _reject_position_open( + self, + *, + state: AutoTradeState, + side: str, + action: str, + reason: str, + ) -> ExecutionDecision: + self._log_position_open_rejected( + state=state, + side=side, + action=action, + reason=reason, + ) + + return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) + + # ---------- Position actions ---------- # открыть позицию, если сейчас позиции нет def _open_position_if_empty( self, @@ -153,46 +694,57 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): ) -> ExecutionDecision: position = type(self)._position - if position.side != "NONE": + if position.side != POSITION_SIDE_NONE: self._sync_state_from_position(state) if position.side == side: reason = f"Позиция {side} уже открыта." - return ExecutionDecision("NONE", False, reason) + return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) reason = ( f"Позиция уже открыта в другом направлении: " f"{position.side}, новый запрос: {side}." ) - self._log_position_open_rejected( + return self._reject_position_open( 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 + entry_price = safe_float(entry.price) except Exception as exc: reason = f"Не удалось получить цену для paper execution: {exc}" - self._log_position_open_rejected( + return self._reject_position_open( state=state, side=side, action=action, reason=reason, ) - return ExecutionDecision("NONE", False, reason) + if entry_price is None or entry_price <= 0: + reason = "Позиция не открыта: некорректная цена входа." + + return self._reject_position_open( + state=state, + side=side, + action=action, + reason=reason, + ) now = self._now_time() opened_monotonic_at = time.monotonic() + # Перед новой позицией очищаем runtime-защиту и lifecycle, + # чтобы новая сделка не унаследовала состояние прошлой позиции. + self._reset_runtime_protection_state(state) + self._reset_position_lifecycle_state(state) + size = self._calculate_position_size( state, entry_price=entry_price, @@ -201,14 +753,14 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): if size <= 0: reason = "Позиция не открыта: невозможно рассчитать adaptive size." - self._log_position_open_rejected( + return self._reject_position_open( state=state, side=side, action=action, reason=reason, ) - return ExecutionDecision("NONE", False, reason) + base_size = size size = self._adjust_size_by_margin_limit( state=state, @@ -216,26 +768,24 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): size=size, ) + size = self._round_size(size) + self._sync_effective_risk_after_margin_limit( state, - base_size=state.adaptive_size_base or 0.0, + base_size=base_size, final_size=size, ) - size = self._round_size(size) - if size <= 0: reason = "Позиция не открыта: итоговый size равен 0." - self._log_position_open_rejected( + return self._reject_position_open( 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 @@ -257,44 +807,26 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): self._sync_state_from_position(state) + # Сразу фиксируем monotonic-время открытия в AutoTradeState, + # чтобы UI/protection/semantics не ждали следующего цикла. + state.position_opened_monotonic_at = opened_monotonic_at + 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, - } + payload = self._build_position_opened_payload( + state=state, + side=side, + action=action, + trade_id=trade_id, + entry_price=entry_price, + size=size, + now=now, + opened_monotonic_at=opened_monotonic_at, + entry=entry, + ) JournalService().log_ui_info( event_type="position_opened", @@ -320,17 +852,17 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): ) -> ExecutionDecision: position = type(self)._position - if position.side == "NONE": + if position.side == POSITION_SIDE_NONE: self._sync_state_from_position(state) return ExecutionDecision( - "NONE", + EXECUTION_ACTION_NONE, False, "Нет открытой позиции для закрытия.", ) if forced_exit_price is not None: - exit_price = safe_float(forced_exit_price) or 0.0 + exit_price = safe_float(forced_exit_price) exit_execution = forced_price_meta else: @@ -340,89 +872,85 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): position.side, ) - exit_price = exit_execution.price + exit_price = safe_float(exit_execution.price) except Exception as exc: return ExecutionDecision( - "NONE", + EXECUTION_ACTION_NONE, False, f"Ошибка получения цены для закрытия: {exc}", ) + if exit_price is None or exit_price <= 0: + return ExecutionDecision( + EXECUTION_ACTION_NONE, + False, + "Ошибка закрытия позиции: некорректная цена выхода.", + ) + + metrics = build_position_metrics( + position, + current_price=exit_price, + ) + pnl = ( safe_float(forced_pnl) if forced_pnl is not None - else self._calculate_pnl(exit_price) + else metrics.net_pnl_usd ) if pnl is None: pnl = 0.0 + price_move_percent = metrics.price_move_percent + close_reason = forced_reason or EXECUTION_REASON_MANUAL + now = self._now_time() + + trade_id = position.trade_id or state.current_trade_id + + payload = self._build_position_closed_payload( + state=state, + position=position, + trade_id=trade_id, + exit_price=exit_price, + exit_execution=exit_execution, + metrics=metrics, + pnl=pnl, + price_move_percent=price_move_percent, + close_reason=close_reason, + forced_reason=forced_reason, + now=now, + ) + state.realized_pnl_usd += pnl state.cycle_realized_pnl_usd += pnl state.cycle_closed_trades += 1 + # Комиссии закрытой сделки добавляем один раз. + # Важно: выше по функции этих начислений быть не должно, иначе UI покажет x2. + state.cycle_trade_fees_usd += abs(safe_float(metrics.commission_usd) or 0.0) + state.cycle_overnight_fees_usd += safe_float(metrics.overnight_cashflow_usd) or 0.0 + if pnl > 0: state.cycle_winning_trades += 1 - if pnl < 0: + # Прибыльная сделка сбрасывает серию подряд идущих убытков. + state.cycle_consecutive_losses = 0 + state.loss_cooldown_active = False + state.loss_cooldown_reason = None + + elif pnl < 0: + state.cycle_losing_trades += 1 + state.cycle_consecutive_losses += 1 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" + # Не включаем cooldown после первого убытка. + # Supervisor остановит торговлю только когда серия достигнет лимита. + if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES: + state.loss_cooldown_active = True + state.loss_cooldown_reason = ( + f"{state.cycle_consecutive_losses} подряд убыточных сделок" + ) JournalService().log_ui_info( event_type="position_closed", @@ -432,10 +960,7 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): payload=payload, ) - EventBus.emit( - "paper_position_closed", - payload, - ) + EventBus.emit("paper_position_closed", payload) type(self)._position = PositionState() @@ -445,15 +970,23 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): state.current_trade_id = None state.current_trade_cycle_number = None + # После закрытия очищаем autonomous cooldown/action, + # чтобы новая сделка не унаследовала runtime-действие прошлой позиции. + state.autonomous_last_action = None + state.autonomous_last_action_reason = None + state.autonomous_last_action_at = None + + # После закрытия очищаем protection и lifecycle runtime закрытой позиции. self._reset_runtime_protection_state(state) + self._reset_position_lifecycle_state(state) state.execution_block_reason = None state.last_flip_block_reason = None state.last_execution_action = ( - f"FORCE_CLOSE_{forced_reason}" + f"{EXECUTION_ACTION_FORCE_CLOSE_PREFIX}{forced_reason}" if forced_reason is not None - else "CLOSE" + else EXECUTION_ACTION_CLOSE ) state.last_execution_reason = ( @@ -466,13 +999,13 @@ class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol): if forced_reason is not None: return ExecutionDecision( - f"FORCE_CLOSE_{forced_reason}", + f"{EXECUTION_ACTION_FORCE_CLOSE_PREFIX}{forced_reason}", True, f"Позиция закрыта по правилу защиты: {forced_reason}.", ) return ExecutionDecision( - "CLOSE", + EXECUTION_ACTION_CLOSE, True, "Позиция закрыта.", ) \ No newline at end of file diff --git a/app/src/trading/execution/position_exit_decision.py b/app/src/trading/execution/position_exit_decision.py new file mode 100644 index 0000000..de276ec --- /dev/null +++ b/app/src/trading/execution/position_exit_decision.py @@ -0,0 +1,408 @@ +# app/src/trading/execution/position_exit_decision.py + +from __future__ import annotations + +import time +from typing import ClassVar, Protocol + +from src.core.numbers import safe_float +from src.trading.auto.state import AutoTradeState +from src.trading.execution.position_metrics import PositionMetrics, build_position_metrics +from src.trading.position.state import PositionState +from src.trading.execution.constants import get_position_exit_thresholds + + +class _ExecutionPositionExitDecisionProtocol(Protocol): + _position: ClassVar[PositionState] + + +class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol): + """ + Execution-слой принятия решения о runtime-закрытии позиции. + + Важно: + - этот файл НЕ рассчитывает PnL, движение цены и время удержания сам; + - все числовые метрики позиции берутся из position_metrics.py; + - здесь остаётся только логика принятия решения: закрывать позицию или нет. + """ + + def _runtime_intelligence_close_reason( + self, + *, + state: AutoTradeState, + current_price: float, + ) -> str | None: + metrics = build_position_metrics( + type(self)._position, + current_price=current_price, + ) + + # Защита от раннего выхода на обычной волне/откате. + # Если позиция открыта недавно и просадка ещё в рамках нормальной + # волатильности актива, intelligence-close не закрывает сделку. + if self._is_normal_pullback_wave(state=state, metrics=metrics): + return None + + giveback_reason = self._giveback_close_reason( + state=state, + metrics=metrics, + ) + + if giveback_reason is not None: + self._sync_intelligence_exit_state( + state=state, + reason=giveback_reason, + algorithm="GIVEBACK", + ) + return giveback_reason + + time_decay_reason = self._time_decay_close_reason( + state=state, + metrics=metrics, + ) + + if time_decay_reason is not None: + self._sync_intelligence_exit_state( + state=state, + reason=time_decay_reason, + algorithm="TIME_DECAY", + ) + return time_decay_reason + + return None + + def _sync_intelligence_exit_state( + self, + *, + state: AutoTradeState, + reason: str, + algorithm: str, + ) -> None: + # В AutoTradeState сейчас нет отдельного поля position_exit_algorithm. + # Поэтому алгоритм пишем в position_intelligence_reason — это поле уже есть + # в state и попадёт дальше в диагностику / журнал закрытия. + state.position_intelligence_reason = algorithm + + state.runtime_protection_action = "INTELLIGENCE_EXIT" + state.runtime_protection_reason = reason + state.runtime_protection_updated_at = time.monotonic() + + def _giveback_close_reason( + self, + *, + state: AutoTradeState, + metrics: PositionMetrics, + ) -> str | None: + price_move_percent = metrics.price_move_percent + + peak_percent = safe_float( + getattr(state, "position_peak_pnl_percent", None) + ) + + if peak_percent is None or peak_percent <= 0: + return None + + giveback = peak_percent - price_move_percent + + if giveback <= 0: + return None + + giveback_percent = round((giveback / peak_percent) * 100, 2) + + # Сохраняем рассчитанный giveback в state, + # чтобы журнал закрытия видел именно то значение, + # на основании которого принято решение. + state.position_giveback_percent = giveback_percent + + 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 + + thresholds = self._exit_thresholds(state) + + market_quality = str( + getattr(state, "market_trend_quality", "") or "" + ).upper() + + stall_state = str( + getattr(state, "position_stall_state", "") or "" + ).upper() + + # В CLEAN рынке даём прибыли больше пространства. + # В NOISY рынке фиксируем быстрее, потому что откаты чаще съедают прибыль. + if market_quality == "NOISY": + min_peak = thresholds["noisy_giveback_min_peak"] + giveback_limit = thresholds["noisy_giveback_percent"] + else: + min_peak = thresholds["clean_giveback_min_peak"] + giveback_limit = thresholds["clean_giveback_percent"] + + if ( + peak_percent >= min_peak + and giveback_percent >= giveback_limit + and price_move_percent > 0.10 + ): + return ( + "NOISY_GIVEBACK_EXIT" + if market_quality == "NOISY" + else "CLEAN_GIVEBACK_EXIT" + ) + + if ( + stall_state in {"NOISY_STALLED", "ADVERSE_STALLED"} + and peak_percent >= min_peak + and giveback_percent >= max(25, giveback_limit - 10) + and price_move_percent > 0 + ): + return "STALL_GIVEBACK_EXIT" + + if ( + peak_percent >= 1.50 + and giveback_percent >= 50 + and price_move_percent > 0.25 + ): + return "GIVEBACK_PROFIT_LOCK" + + if ( + peak_percent >= 1.20 + and giveback_percent >= 60 + and price_move_percent > 0.15 + ): + return "GIVEBACK_PROTECTION" + + if ( + peak_percent >= 1.00 + and giveback_percent >= 50 + and adverse_momentum + ): + return "GIVEBACK_MOMENTUM_REVERSAL" + + if ( + peak_percent >= 1.00 + and giveback_percent >= 45 + and fatigue_state in {"TIRED", "EXHAUSTED"} + ): + return "GIVEBACK_FATIGUE_EXIT" + + if ( + peak_percent >= 1.00 + and giveback_percent >= 45 + and reversal_risk in {"ELEVATED", "HIGH"} + and exit_confidence >= 0.60 + ): + return "GIVEBACK_REVERSAL_RISK" + + return None + + def _time_decay_close_reason( + self, + *, + state: AutoTradeState, + metrics: PositionMetrics, + ) -> str | None: + hold_seconds = metrics.hold_seconds + + if hold_seconds is None: + return None + + price_move_percent = metrics.price_move_percent + thresholds = self._exit_thresholds(state) + + # Hard-loss — отдельный аварийный intelligence-exit. + # Если движение цены уже глубже допустимого порога, + # не ждём fatigue / time-decay / adverse momentum. + if price_move_percent <= thresholds["hard_loss"]: + return "HARD_LOSS_EXIT" + + 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) + ) + + net_pnl_usd = safe_float(getattr(metrics, "net_pnl_usd", None)) or 0.0 + + risk_level = str( + getattr(state, "position_risk_level", "") or "" + ).upper() + + # Time-decay не должен закрывать позицию просто потому, + # что она долго стоит около нуля. + # Разрешаем time-decay закрытие только если: + # - сделка уже покрыла RT-комиссию и net PnL положительный; + # - или есть реальное ухудшение: adverse momentum / HIGH risk / BROKEN conviction. + real_deterioration = ( + adverse_momentum + or risk_level == "HIGH" + or conviction_state == "BROKEN" + ) + + market_quality = str( + getattr(state, "market_trend_quality", "") or "" + ).upper() + + peak_percent = safe_float( + getattr(state, "position_peak_pnl_percent", None) + ) or 0.0 + + giveback_percent = safe_float( + getattr(state, "position_giveback_percent", None) + ) or 0.0 + + # Специальный быстрый выход для NOISY рынка. + # В шумном рынке не ждём классический time-decay 1500-2100 секунд: + # если позиция после минимального времени уже в минусе + # или быстро отдаёт прибыль, закрываем раньше. + if market_quality == "NOISY" and hold_seconds >= thresholds["noisy_min_hold"]: + if ( + price_move_percent <= thresholds["noisy_loss_exit"] + and adverse_momentum + ): + return "NOISY_ADVERSE_EXIT" + + if ( + peak_percent > 0 + and giveback_percent >= thresholds["noisy_profit_giveback"] + and price_move_percent > 0 + ): + return "NOISY_PROFIT_GIVEBACK_EXIT" + + if net_pnl_usd <= 0 and not real_deterioration: + return None + + # Нейтральную позицию по ETH/BTC/LTC/XRP держим дольше. + # Например для ETH: если движение внутри ±0.40%, + # не закрываем её по time-decay раньше neutral_min_hold. + if ( + hold_seconds < thresholds["neutral_min_hold"] + and abs(price_move_percent) <= thresholds["neutral_band"] + and not real_deterioration + ): + return None + + if ( + hold_seconds >= thresholds["neutral_min_hold"] + and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"] + and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"} + ): + return "TIME_DECAY_EXIT" + + if ( + hold_seconds >= thresholds["min_hold"] + and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"] + and fatigue_state in {"TIRED", "EXHAUSTED"} + ): + return "TIME_DECAY_FATIGUE_EXIT" + + if ( + hold_seconds >= thresholds["min_hold"] + and price_move_percent <= thresholds["normal_pullback"] + and adverse_momentum + ): + return "TIME_DECAY_ADVERSE_MOMENTUM" + + if ( + hold_seconds >= thresholds["min_hold"] + and price_move_percent <= thresholds["normal_pullback"] + and market_runtime_degraded + ): + return "TIME_DECAY_DEGRADED_MARKET" + + if ( + hold_seconds >= thresholds["neutral_min_hold"] + and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"} + and price_move_percent <= thresholds["neutral_band"] + ): + return "TIME_DECAY_CONTEXT_DECAY" + + return None + + def _exit_thresholds(self, state: AutoTradeState) -> dict[str, float]: + return get_position_exit_thresholds( + getattr(state, "symbol", None) + ) + + def _is_normal_pullback_wave( + self, + *, + state: AutoTradeState, + metrics: PositionMetrics, + ) -> bool: + thresholds = self._exit_thresholds(state) + + hold_seconds = safe_float(metrics.hold_seconds) + price_move_percent = safe_float(metrics.price_move_percent) + + if hold_seconds is None or price_move_percent is None: + return False + + # Если убыток уже глубже hard_loss — это не обычный откат. + if price_move_percent <= thresholds["hard_loss"]: + return False + + if hold_seconds >= thresholds["min_hold"]: + return False + + if price_move_percent < thresholds["normal_pullback"]: + return False + + adverse_momentum = bool( + getattr(state, "position_adverse_momentum", False) + ) + + risk_level = str( + getattr(state, "position_risk_level", "") or "" + ).upper() + + conviction_state = str( + getattr(state, "position_conviction_state", "") or "" + ).upper() + + # Если есть реальное ухудшение, это уже не обычный откат. + # Так мы не блокируем быстрый выход в NOISY рынке, + # когда momentum/риск явно против позиции. + if adverse_momentum or risk_level == "HIGH" or conviction_state == "BROKEN": + return False + + market_phase = str(getattr(state, "market_phase", "") or "").upper() + market_quality = str(getattr(state, "market_trend_quality", "") or "").upper() + market_structure = str(getattr(state, "market_structure", "") or "").upper() + trend_alignment = str(getattr(state, "position_trend_alignment", "") or "").upper() + + # Обычный откат/шум/флэт после входа не должен сразу закрывать сделку. + if market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}: + return True + + if market_quality == "NOISY" and trend_alignment != "AGAINST": + return True + + if market_structure in {"HH_HL", "LH_LL", "MIXED"} and trend_alignment != "AGAINST": + return True + + return False \ No newline at end of file diff --git a/app/src/trading/execution/position_intelligence.py b/app/src/trading/execution/position_intelligence.py deleted file mode 100644 index 1a68655..0000000 --- a/app/src/trading/execution/position_intelligence.py +++ /dev/null @@ -1,209 +0,0 @@ -# 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_metrics.py b/app/src/trading/execution/position_metrics.py new file mode 100644 index 0000000..b8bc474 --- /dev/null +++ b/app/src/trading/execution/position_metrics.py @@ -0,0 +1,512 @@ +# app/src/trading/execution/position_metrics.py + +from __future__ import annotations + +import time +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.position.state import PositionState + + +# Единый снимок расчётов по открытой позиции. +# Все числовые показатели позиции должны считаться здесь один раз, +# а остальные части бота должны только использовать готовые значения. +@dataclass(slots=True) +class PositionMetrics: + symbol: str + side: str + + entry_price: float | None + current_price: float | None + size: float | None + leverage: float | None + + entry_notional_usd: float + current_notional_usd: float + margin_usd: float + + # - price_move_percent = движение цены от входа. + price_move_percent: float + # - gross_pnl_usd = PnL без комиссий и overnight. + gross_pnl_usd: float + # - commission_usd = комиссия вход + предполагаемый выход. + commission_usd: float + # - overnight_cashflow_usd = списание или начисление за leverage. + overnight_cashflow_usd: float + # - net_pnl_usd = итоговый PnL после комиссии и overnight. + net_pnl_usd: float + # - pnl_percent = net PnL в процентах от notional входа. + pnl_percent: float + + hold_seconds: int | None + overnight_count: int + + +@dataclass(slots=True) +class PlannedPositionMetrics: + """ + Единый расчёт планируемой позиции до открытия. + + Используется на этапе: + - подготовки ордера, + - оценки размера, + - оценки маржи, + - оценки комиссии, + - отображения в UI. + """ + + symbol: str + side: str + + entry_price: float | None + size: float | None + leverage: float | None + + notional_usd: float + margin_usd: float + commission_usd: float + + +def build_position_metrics( + position: PositionState, + *, + current_price: NumericLike | None, +) -> PositionMetrics: + """ + Главная функция расчёта метрик уже открытой позиции. + + Сюда нужно постепенно перенести все расчёты, которые сейчас разбросаны по: + - execution/calculations.py + - execution/position_runtime.py + - execution/risk_close.py + - execution/position_protection.py + - auto/position_health.py + + Последовательность: + 1. Нормализуем входные данные позиции. + 2. Считаем notional и margin. + 3. Считаем движение цены. + 4. Считаем gross PnL. + 5. Считаем комиссии. + 6. Считаем overnight cashflow. + 7. Считаем net PnL. + 8. Считаем PnL % от notional входа. + """ + + price = safe_float(current_price) + entry = safe_float(position.entry_price) + size = safe_float(position.size) + leverage = safe_float(position.leverage) or 1.0 + + entry_notional = _notional(entry, size) + current_notional = _notional(price, size) + margin = _margin(current_notional, leverage) + + price_move_percent = _price_move_percent( + side=position.side, + entry_price=entry, + current_price=price, + ) + + gross_pnl = _gross_pnl_usd( + side=position.side, + entry_price=entry, + current_price=price, + size=size, + ) + + commission = _round_trip_commission_usd( + symbol=position.symbol, + entry_price=entry, + current_price=price, + size=size, + ) + + hold_seconds = _hold_seconds(position) + + overnight_cashflow, overnight_count = _overnight_cashflow_usd( + symbol=position.symbol, + side=position.side, + current_price=price, + size=size, + leverage=leverage, + hold_seconds=hold_seconds, + ) + + net_pnl = round(gross_pnl - commission + overnight_cashflow, 4) + + pnl_percent = _pnl_percent( + pnl_usd=net_pnl, + entry_notional_usd=entry_notional, + ) + + return PositionMetrics( + symbol=position.symbol, + side=position.side, + entry_price=entry, + current_price=price, + size=size, + leverage=leverage, + entry_notional_usd=entry_notional, + current_notional_usd=current_notional, + margin_usd=margin, + price_move_percent=price_move_percent, + gross_pnl_usd=gross_pnl, + commission_usd=commission, + overnight_cashflow_usd=overnight_cashflow, + net_pnl_usd=net_pnl, + pnl_percent=pnl_percent, + hold_seconds=hold_seconds, + overnight_count=overnight_count, + ) + + +def build_planned_position_metrics( + *, + symbol: str, + side: str, + entry_price: NumericLike | None, + size: NumericLike | None, + leverage: NumericLike | None, +) -> PlannedPositionMetrics: + """ + Расчёт планируемой позиции до открытия. + + Здесь нет PnL, потому что позиции ещё нет. + Считаем только: + - объём позиции, + - маржу, + - примерную round-trip комиссию. + """ + + price = safe_float(entry_price) + parsed_size = safe_float(size) + parsed_leverage = safe_float(leverage) or 1.0 + + notional = _notional(price, parsed_size) + margin = _margin(notional, parsed_leverage) + + commission = _round_trip_commission_usd( + symbol=symbol, + entry_price=price, + current_price=price, + size=parsed_size, + ) + + return PlannedPositionMetrics( + symbol=symbol, + side=side, + entry_price=price, + size=parsed_size, + leverage=parsed_leverage, + notional_usd=notional, + margin_usd=margin, + commission_usd=commission, + ) + + +def _price_move_percent( + *, + side: str | None, + entry_price: float | None, + current_price: float | None, +) -> float: + """ + Считает движение цены от входа. + + LONG: + цена выше входа = плюс. + + SHORT: + цена ниже входа = плюс. + """ + + if entry_price is None or entry_price <= 0: + return 0.0 + + if current_price is None or current_price <= 0: + return 0.0 + + normalized_side = str(side or "").upper() + + if normalized_side == "LONG": + return round(((current_price - entry_price) / entry_price) * 100, 4) + + if normalized_side == "SHORT": + return round(((entry_price - current_price) / entry_price) * 100, 4) + + return 0.0 + + +def _gross_pnl_usd( + *, + side: str | None, + entry_price: float | None, + current_price: float | None, + size: float | None, +) -> float: + """ + Считает PnL без комиссий. + + Это “грязная” прибыль/убыток только от изменения цены. + """ + + if entry_price is None or entry_price <= 0: + return 0.0 + + if current_price is None or current_price <= 0: + return 0.0 + + if size is None or size <= 0: + return 0.0 + + normalized_side = str(side or "").upper() + + if normalized_side == "LONG": + return round((current_price - entry_price) * size, 4) + + if normalized_side == "SHORT": + return round((entry_price - current_price) * size, 4) + + return 0.0 + + +def _round_trip_commission_usd( + *, + symbol: str | None, + entry_price: float | None, + current_price: float | None, + size: float | None, +) -> float: + """ + Считает комиссию вход + выход. + + Для открытой позиции: + - вход уже был по entry_price; + - выход предполагается по current_price. + + Для планируемой позиции: + - entry_price и current_price могут быть одинаковыми. + """ + + if entry_price is None or entry_price <= 0: + return 0.0 + + if current_price is None or current_price <= 0: + return 0.0 + + if size is None or size <= 0: + return 0.0 + + fee_percent = _trading_fee_percent(symbol) + + if fee_percent <= 0: + return 0.0 + + entry_notional = entry_price * size + exit_notional = current_price * size + + return round((entry_notional + exit_notional) * (fee_percent / 100), 4) + + +def _overnight_cashflow_usd( + *, + symbol: str | None, + side: str | None, + current_price: float | None, + size: float | None, + leverage: float | None, + hold_seconds: int | None, +) -> tuple[float, int]: + """ + Считает overnight/leverage cashflow. + + Значение может быть: + - отрицательным, если биржа списывает funding/overnight; + - положительным, если ставка по стороне позиции положительная; + - нулевым, если плечо x1 или срок удержания меньше периода списания. + """ + + parsed_leverage = safe_float(leverage) or 1.0 + + if parsed_leverage <= 1: + return 0.0, 0 + + if current_price is None or current_price <= 0: + return 0.0, 0 + + if size is None or size <= 0: + return 0.0, 0 + + if hold_seconds is None or hold_seconds <= 0: + return 0.0, 0 + + rate = _overnight_rate_for_side( + symbol=symbol, + side=side, + ) + + if rate is None: + return 0.0, 0 + + period_seconds = _overnight_period_seconds(symbol) + overnight_count = int(hold_seconds // period_seconds) + + if overnight_count <= 0: + return 0.0, 0 + + notional = current_price * size + cashflow = notional * (rate / 100) * overnight_count + + return round(cashflow, 4), overnight_count + + +def _pnl_percent( + *, + pnl_usd: float, + entry_notional_usd: float, +) -> float: + """ + Считает net PnL в процентах от notional входа. + + Важно: + здесь используется net PnL, то есть уже после комиссии и overnight. + """ + + if entry_notional_usd <= 0: + return 0.0 + + return round((pnl_usd / entry_notional_usd) * 100, 4) + + +def _notional( + price: float | None, + size: float | None, +) -> float: + """ + Считает объём позиции в USD. + """ + + if price is None or price <= 0: + return 0.0 + + if size is None or size <= 0: + return 0.0 + + return round(price * size, 4) + + +def _margin( + notional_usd: float, + leverage: float | None, +) -> float: + """ + Считает занятые собственные средства. + + Пример: + notional $1000 при плече x2 = margin $500. + """ + + parsed_leverage = safe_float(leverage) or 1.0 + + if parsed_leverage <= 0: + return 0.0 + + if notional_usd <= 0: + return 0.0 + + return round(notional_usd / parsed_leverage, 4) + + +def _hold_seconds(position: PositionState) -> int | None: + """ + Считает время удержания позиции. + + Основной источник — opened_monotonic_at. + Это надёжнее, чем строковое время opened_at. + """ + + opened_at = safe_float(getattr(position, "opened_monotonic_at", None)) + + if opened_at is None: + return None + + return max(0, int(time.monotonic() - opened_at)) + + +def _overnight_period_seconds(symbol: str | None) -> int: + """ + Возвращает период списания overnight. + + Сейчас логика сохранена как в старом коде: + - BTC/ETH: каждые 8 часов; + - остальные активы: раз в 24 часа. + """ + + normalized = str(symbol or "").upper() + + if normalized.startswith("BTC/") or normalized.startswith("BTC"): + return 8 * 60 * 60 + + if normalized.startswith("ETH/") or normalized.startswith("ETH"): + return 8 * 60 * 60 + + return 24 * 60 * 60 + + +def _overnight_rate_for_side( + *, + symbol: str | None, + side: str | None, +) -> float | None: + # Берёт overnight rate для стороны позиции. + # LONG использует overnight_long_rate. + # SHORT использует overnight_short_rate. + + fee = _trading_fee(symbol) + + if fee is None: + return None + + normalized_side = str(side or "").upper() + + if normalized_side == "LONG": + return safe_float(getattr(fee, "overnight_long_rate", None)) + + if normalized_side == "SHORT": + return safe_float(getattr(fee, "overnight_short_rate", None)) + + return None + + +def _trading_fee_percent(symbol: str | None) -> float: + # Возвращает торговую комиссию в процентах. + # Если комиссию получить не удалось — возвращаем 0, + # чтобы расчёт позиции не падал. + + fee = _trading_fee(symbol) + + if fee is None: + return 0.0 + + return safe_float(getattr(fee, "fee_percent", None)) or 0.0 + + +def _trading_fee(symbol: str | None): + # Получает объект комиссии с биржи. + # В этом первом варианте кеш специально не добавлен сюда, + # чтобы не усложнять файл. Кеш уже есть в ExchangeService/старом коде. + # Если потребуется — на следующем шаге добавим cache именно здесь. + + normalized_symbol = str(symbol or "").strip() + + if not normalized_symbol: + return None + + try: + return ExchangeService().get_trading_fee(normalized_symbol) + except Exception: + 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 index ba120e6..ee9b62a 100644 --- a/app/src/trading/execution/position_protection.py +++ b/app/src/trading/execution/position_protection.py @@ -10,27 +10,63 @@ 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.position_metrics import PositionMetrics, build_position_metrics from src.trading.execution.pricing import ExecutionPrice from src.trading.journal.service import JournalService from src.trading.position.state import PositionState +PROTECTION_THRESHOLDS_BY_ASSET = { + "BTC": { + "break_even_activate": 0.45, + "break_even_buffer": 0.12, + "profit_lock_activate": 0.95, + "profit_lock_distance": 0.55, + "trailing_activate": 1.35, + "trailing_distance": 0.35, + }, + "ETH": { + "break_even_activate": 0.60, + "break_even_buffer": 0.18, + "profit_lock_activate": 1.20, + "profit_lock_distance": 0.70, + "trailing_activate": 1.60, + "trailing_distance": 0.45, + }, + "LTC": { + "break_even_activate": 0.75, + "break_even_buffer": 0.22, + "profit_lock_activate": 1.45, + "profit_lock_distance": 0.85, + "trailing_activate": 1.90, + "trailing_distance": 0.60, + }, + "XRP": { + "break_even_activate": 0.85, + "break_even_buffer": 0.25, + "profit_lock_activate": 1.60, + "profit_lock_distance": 0.95, + "trailing_activate": 2.10, + "trailing_distance": 0.70, + }, +} + +DEFAULT_PROTECTION_THRESHOLDS = { + "break_even_activate": 0.65, + "break_even_buffer": 0.20, + "profit_lock_activate": 1.30, + "profit_lock_distance": 0.75, + "trailing_activate": 1.75, + "trailing_distance": 0.50, +} + + 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, @@ -42,14 +78,12 @@ class _ExecutionPositionProtectionProtocol(Protocol): ) -> ExecutionDecision: ... - # сбросить состояние runtime-защиты def _reset_runtime_protection_state( self, state: AutoTradeState, ) -> None: ... - # получить intelligence-причину закрытия позиции def _runtime_intelligence_close_reason( self, *, @@ -60,7 +94,8 @@ class _ExecutionPositionProtectionProtocol(Protocol): class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): - # обработать runtime-защиту открытой позиции + # Главный runtime protection processor. + # Здесь один раз получаем цену выхода и один раз считаем PositionMetrics. def _process_runtime_protection( self, state: AutoTradeState, @@ -76,7 +111,12 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): position.symbol or state.symbol, position.side, ) - current_price = current_execution.price + + current_price = safe_float(current_execution.price) + + if current_price is None or current_price <= 0: + raise ValueError("invalid execution price") + except Exception: self._sync_runtime_protection_state( state=state, @@ -85,6 +125,11 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): ) return None + metrics = build_position_metrics( + position, + current_price=current_price, + ) + self._sync_runtime_protection_state( state=state, status="ACTIVE", @@ -94,16 +139,19 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): self._update_break_even_protection( state=state, current_price=current_price, + metrics=metrics, ) self._update_profit_lock_protection( state=state, current_price=current_price, + metrics=metrics, ) self._update_trailing_stop_protection( state=state, current_price=current_price, + metrics=metrics, ) close_reason = self._runtime_protection_close_reason( @@ -120,17 +168,14 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): 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_pnl=metrics.net_pnl_usd, forced_price_meta=current_execution, ) - # синхронизировать состояние protection engine def _sync_runtime_protection_state( self, *, @@ -142,30 +187,39 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): 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, + metrics: PositionMetrics, ) -> None: position = type(self)._position if state.break_even_armed: return - pnl_percent = self._calculate_price_move_percent(current_price) + price_move_percent = metrics.price_move_percent + thresholds = self._protection_thresholds(state) - if pnl_percent < 0.35: + if price_move_percent < thresholds["break_even_activate"]: 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 + + buffer_percent = thresholds["break_even_buffer"] + + if position.side == "LONG": + state.break_even_price = entry_price * (1 + buffer_percent / 100) + elif position.side == "SHORT": + state.break_even_price = entry_price * (1 - buffer_percent / 100) + else: + return + state.runtime_protection_action = "BREAK_EVEN_ARMED" state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован" state.runtime_protection_updated_at = time.monotonic() @@ -175,31 +229,38 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): action="BREAK_EVEN_ARMED", reason=state.runtime_protection_reason, current_price=current_price, + metrics=metrics, ) - # активировать profit lock защиту def _update_profit_lock_protection( self, *, state: AutoTradeState, current_price: float, + metrics: PositionMetrics, ) -> None: position = type(self)._position - pnl_percent = self._calculate_price_move_percent(current_price) + price_move_percent = metrics.price_move_percent + thresholds = self._protection_thresholds(state) - if pnl_percent < 0.75: + if price_move_percent < thresholds["profit_lock_activate"]: return entry_price = safe_float(position.entry_price) - if entry_price is None or entry_price <= 0: return + lock_distance_percent = thresholds["profit_lock_distance"] + if position.side == "LONG": - lock_price = entry_price * 1.003 + min_lock_price = entry_price * 1.001 + dynamic_lock_price = current_price * (1 - lock_distance_percent / 100) + lock_price = max(min_lock_price, dynamic_lock_price) elif position.side == "SHORT": - lock_price = entry_price * 0.997 + min_lock_price = entry_price * 0.999 + dynamic_lock_price = current_price * (1 + lock_distance_percent / 100) + lock_price = min(min_lock_price, dynamic_lock_price) else: return @@ -208,7 +269,6 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): 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 @@ -223,23 +283,25 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): action="PROFIT_LOCK_ACTIVE", reason=state.runtime_protection_reason, current_price=current_price, + metrics=metrics, ) - # активировать trailing stop защиту def _update_trailing_stop_protection( self, *, state: AutoTradeState, current_price: float, + metrics: PositionMetrics, ) -> None: position = type(self)._position - pnl_percent = self._calculate_price_move_percent(current_price) + price_move_percent = metrics.price_move_percent + thresholds = self._protection_thresholds(state) - if pnl_percent < 1.0: + if price_move_percent < thresholds["trailing_activate"]: return - trail_distance_percent = 0.35 + trail_distance_percent = thresholds["trailing_distance"] if position.side == "LONG": trail_price = current_price * (1 - trail_distance_percent / 100) @@ -269,9 +331,9 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): action="TRAILING_STOP_ACTIVE", reason=state.runtime_protection_reason, current_price=current_price, + metrics=metrics, ) - # определить причину закрытия по защите def _runtime_protection_close_reason( self, *, @@ -280,37 +342,6 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): ) -> 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 @@ -333,7 +364,7 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): ): return "BREAK_EVEN" - if position.side == "SHORT": + elif position.side == "SHORT": if ( state.trailing_stop_active and state.trailing_stop_price is not None @@ -357,7 +388,164 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): return None - # записать событие runtime-защиты в журнал + def _build_runtime_protection_payload( + self, + *, + state: AutoTradeState, + action: str, + reason: str, + current_price: float, + metrics: PositionMetrics, + ) -> JsonDict: + position = type(self)._position + + return { + # ---------- Trade ---------- + "trade_id": position.trade_id, + "trade_sequence": position.trade_sequence, + "trade_cycle_number": position.trade_cycle_number, + + # ---------- Event ---------- + "execution_type": "RUNTIME_PROTECTION", + "action": action, + "reason": reason, + + # ---------- Runtime ---------- + "status": state.status, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + + # ---------- Position ---------- + "symbol": state.symbol, + "position_side": position.side, + "entry_price": position.entry_price, + "current_price": current_price, + "size": position.size, + "leverage": position.leverage, + + "opened_at": position.opened_at, + "updated_at": position.updated_at, + + # ---------- Metrics ---------- + "position_pnl_percent": metrics.price_move_percent, + "net_pnl_usd": metrics.net_pnl_usd, + "gross_pnl_usd": metrics.gross_pnl_usd, + "commission_usd": metrics.commission_usd, + "overnight_cashflow_usd": metrics.overnight_cashflow_usd, + "margin_usd": metrics.margin_usd, + "hold_seconds": metrics.hold_seconds, + + # ---------- Runtime protection ---------- + "position_protection_status": state.position_protection_status, + "position_protection_reason": state.position_protection_reason, + "runtime_protection_action": state.runtime_protection_action, + "runtime_protection_reason": state.runtime_protection_reason, + "runtime_protection_updated_at": state.runtime_protection_updated_at, + + "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, + + # ---------- Protection thresholds ---------- + "protection_thresholds": self._protection_thresholds(state), + + # ---------- Position Intelligence ---------- + "position_health_status": state.position_health_status, + "position_health_score": state.position_health_score, + "position_health_reason": state.position_health_reason, + + "position_exit_signal": state.position_exit_signal, + "position_exit_confidence": state.position_exit_confidence, + "position_exit_urgency": state.position_exit_urgency, + + "position_risk_level": state.position_risk_level, + "position_risk_reason": state.position_risk_reason, + + "position_trend_alignment": state.position_trend_alignment, + "position_adverse_momentum": state.position_adverse_momentum, + + "position_reversal_risk": state.position_reversal_risk, + "position_fatigue_state": state.position_fatigue_state, + + "position_giveback_percent": state.position_giveback_percent, + "position_mfe_percent": state.position_mfe_percent, + "position_mae_percent": state.position_mae_percent, + + "position_peak_pnl_usd": state.position_peak_pnl_usd, + "position_peak_pnl_percent": state.position_peak_pnl_percent, + + # ---------- Execution ---------- + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + + # ---------- Execution price ---------- + "execution_price_source": state.execution_price_source, + "execution_price_age_seconds": state.execution_price_age_seconds, + "execution_bid_price": state.execution_bid_price, + "execution_ask_price": state.execution_ask_price, + "execution_last_price": state.execution_last_price, + + # ---------- Market Score ---------- + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + + # ---------- Market ---------- + "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, + + # ---------- Candle ---------- + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + + # ---------- Structure ---------- + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, + + # ---------- Momentum ---------- + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "momentum_change_percent": state.momentum_change_percent, + "breakout_level": state.breakout_level, + "breakout_distance_percent": state.breakout_distance_percent, + "breakout_reason": state.breakout_reason, + + # ---------- HTF ---------- + "htf_interval": state.htf_interval, + "htf_atr_percent": state.htf_atr_percent, + "htf_atr_percent_baseline": state.htf_atr_percent_baseline, + "htf_volatility_ratio": state.htf_volatility_ratio, + "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, + } + def _log_runtime_protection_event( self, *, @@ -365,27 +553,15 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): action: str, reason: str, current_price: float, + metrics: PositionMetrics, ) -> 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, - } + payload = self._build_runtime_protection_payload( + state=state, + action=action, + reason=reason, + current_price=current_price, + metrics=metrics, + ) JournalService().log_ui_info( event_type="runtime_protection_updated", @@ -395,4 +571,27 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol): payload=payload, ) - EventBus.emit("runtime_protection_updated", payload) \ No newline at end of file + EventBus.emit("runtime_protection_updated", payload) + + 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 _protection_thresholds(self, state: AutoTradeState) -> dict[str, float]: + asset = self._asset_symbol(state.symbol) + + return PROTECTION_THRESHOLDS_BY_ASSET.get( + asset, + DEFAULT_PROTECTION_THRESHOLDS, + ) \ No newline at end of file diff --git a/app/src/trading/execution/position_runtime.py b/app/src/trading/execution/position_runtime.py index fd88e61..779af59 100644 --- a/app/src/trading/execution/position_runtime.py +++ b/app/src/trading/execution/position_runtime.py @@ -2,30 +2,20 @@ from __future__ import annotations -import time -from datetime import datetime -from typing import TYPE_CHECKING, Protocol +from typing import 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 +from src.trading.position.state import PositionState +from src.trading.execution.position_metrics import build_position_metrics 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: ... @@ -34,7 +24,9 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): def get_position(self) -> PositionState: return type(self)._position - # обновить unrealized PnL и runtime-память позиции + # Обновить runtime-метрики открытой позиции. + # Важно: PnL, комиссия, overnight и движение цены теперь считаются + # один раз через position_metrics.py. def _update_unrealized_pnl(self, state: AutoTradeState) -> None: position = type(self)._position @@ -47,43 +39,35 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): position.symbol or state.symbol, position.side, ) - current_price = current_execution.price + current_price = safe_float(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) + if current_price is None or current_price <= 0: + self._sync_state_from_position(state) + return - position.unrealized_pnl_usd = pnl + metrics = build_position_metrics( + position, + current_price=current_price, + ) + + position.unrealized_pnl_usd = metrics.net_pnl_usd 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 + # Единые runtime-метрики позиции. + # Эти значения дальше используют health/semantics/protection, + # поэтому не пересчитываем их в других файлах. + state.position_pnl_percent = metrics.pnl_percent + state.position_hold_seconds = metrics.hold_seconds - 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( + self._refresh_position_runtime_metrics( position=position, current_price=current_price, - pnl_percent=pnl_percent, + price_move_percent=metrics.price_move_percent, + pnl_percent=metrics.pnl_percent, + hold_seconds=metrics.hold_seconds, ) self._sync_state_from_position(state) @@ -109,6 +93,17 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): state.position_conviction_state = None state.position_exit_urgency = None state.position_reversal_risk = 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 state.position_opened_monotonic_at = position.opened_monotonic_at @@ -119,91 +114,22 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): 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-метрики позиции по текущей цене + # Обновить runtime-память позиции: + # peak PnL, MFE/MAE, best/worst price, fatigue. + # Само движение цены уже рассчитано выше через position_metrics.py, + # поэтому здесь не пересчитываем его повторно. def _refresh_position_runtime_metrics( self, *, position: PositionState, current_price: float, + price_move_percent: float, + pnl_percent: float, + hold_seconds: int | None, ) -> None: - price_move_percent = self._calculate_price_move_percent(current_price) + if price_move_percent is None: + return + pnl = safe_float(position.unrealized_pnl_usd) if pnl is not None: @@ -214,8 +140,8 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): 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 + if peak_percent is None or pnl_percent > peak_percent: + position.peak_pnl_percent = pnl_percent mfe = safe_float(position.max_favorable_excursion_percent) mae = safe_float(position.max_adverse_excursion_percent) @@ -243,39 +169,49 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): elif position.side == "SHORT" and current_price > worst_price: position.worst_price_seen = current_price - fatigue_score = self._runtime_fatigue_score(position) + fatigue_score = self._runtime_fatigue_score( + position=position, + current_pnl_percent=price_move_percent, + hold_seconds=hold_seconds, + ) position.fatigue_score = fatigue_score position.fatigue_state = self._runtime_fatigue_state(fatigue_score) # рассчитать fatigue score позиции - def _runtime_fatigue_score(self, position: PositionState) -> float: + def _runtime_fatigue_score( + self, + *, + position: PositionState, + current_pnl_percent: float, + hold_seconds: int | None, + ) -> 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 + # Время удержания позиции уже рассчитано централизованно + # в position_metrics.py, здесь его не пересчитываем. + resolved_hold_seconds = hold_seconds or 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: + if resolved_hold_seconds >= 1800: score += 0.25 - elif hold_seconds >= 900: + elif resolved_hold_seconds >= 900: score += 0.15 - elif hold_seconds >= 300: + elif resolved_hold_seconds >= 300: score += 0.08 - if mfe > 0 and current_peak > 0: - giveback = max(0.0, mfe - current_peak) + if mfe > 0: + giveback_ratio = max( + 0.0, + (mfe - current_pnl_percent) / max(0.01, mfe), + ) - if giveback >= 0.75: + if giveback_ratio >= 0.75: score += 0.25 - elif giveback >= 0.45: + elif giveback_ratio >= 0.45: score += 0.18 - elif giveback >= 0.25: + elif giveback_ratio >= 0.25: score += 0.10 if mae <= -1.0: @@ -301,17 +237,4 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol): 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 + return "FRESH" \ No newline at end of file diff --git a/app/src/trading/execution/pricing.py b/app/src/trading/execution/pricing.py index bd744f1..4fed518 100644 --- a/app/src/trading/execution/pricing.py +++ b/app/src/trading/execution/pricing.py @@ -33,79 +33,86 @@ class ExecutionPricingMixin: # получить цену входа по стороне позиции 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.") + self._ensure_fresh_snapshot(snapshot.age_seconds) 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, + return self._build_execution_price( + snapshot, + raw_price=snapshot.ask_price, + price_name="ask_price", 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, + return self._build_execution_price( + snapshot, + raw_price=snapshot.bid_price, + price_name="bid_price", 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, + return self._build_execution_price( + snapshot, + raw_price=snapshot.last_price, + price_name="last_price", 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.") + self._ensure_fresh_snapshot(snapshot.age_seconds) 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, + return self._build_execution_price( + snapshot, + raw_price=snapshot.bid_price, + price_name="bid_price", 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, + return self._build_execution_price( + snapshot, + raw_price=snapshot.ask_price, + price_name="ask_price", 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, + return self._build_execution_price( + snapshot, + raw_price=snapshot.last_price, + price_name="last_price", pricing_role="EXIT_LAST", ) # получить последнюю рыночную цену def _market_last_price(self, symbol: str) -> ExecutionPrice: snapshot = ExchangeService().get_execution_snapshot(symbol) + self._ensure_fresh_snapshot(snapshot.age_seconds) + return self._build_execution_price( + snapshot, + raw_price=snapshot.last_price, + price_name="last_price", + pricing_role="MARKET_LAST", + ) + + # собрать ExecutionPrice из execution snapshot + def _build_execution_price( + self, + snapshot, + *, + raw_price: NumericLike | None, + price_name: str, + pricing_role: str, + ) -> ExecutionPrice: return ExecutionPrice( - price=self._snapshot_price(snapshot.last_price, "last_price"), + price=self._snapshot_price(raw_price, price_name), source=snapshot.source, age_seconds=snapshot.age_seconds, updated_at=snapshot.updated_at, - pricing_role="MARKET_LAST", + pricing_role=pricing_role, ) # проверить и нормализовать цену из execution snapshot @@ -115,20 +122,30 @@ class ExecutionPricingMixin: name: str, ) -> float: if raw_price is None: - raise ValueError( - f"Execution snapshot price '{name}' is missing." - ) + raise ValueError(f"Execution snapshot price '{name}' is missing.") price = safe_float(raw_price) if price is None: - raise ValueError( - f"Execution snapshot price '{name}' is invalid." - ) + 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 + return price + + # проверить свежесть execution snapshot + def _ensure_fresh_snapshot(self, age_seconds: NumericLike | None) -> None: + age = safe_float(age_seconds) + + if age is None: + return + + max_age = safe_float( + getattr(self, "_max_execution_snapshot_age_seconds", None) + ) or 5.0 + + if age > max_age: + raise ValueError(f"Execution snapshot is stale: {age:.2f}s.") \ No newline at end of file diff --git a/app/src/trading/execution/resets.py b/app/src/trading/execution/resets.py index 54bbb7c..1112313 100644 --- a/app/src/trading/execution/resets.py +++ b/app/src/trading/execution/resets.py @@ -13,6 +13,7 @@ class _ExecutionResetsProtocol(Protocol): Сейчас пустой, но оставлен для единообразия архитектуры. """ + pass @@ -22,11 +23,6 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol): Здесь находятся методы очистки runtime/protection/ lifecycle состояния позиции. - - Это позволяет избежать циклических зависимостей между: - - position_actions.py - - position_protection.py - - runtime_actions.py """ def _reset_runtime_protection_state( @@ -35,7 +31,7 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol): ) -> None: """ Полный reset runtime protection состояния позиции. - Вызывается после закрытия позиции. + Вызывается после закрытия позиции или перед flip. """ state.position_protection_status = None @@ -59,16 +55,52 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol): state: AutoTradeState, ) -> None: """ - Reset lifecycle состояния позиции. - Используется после полного закрытия позиции. + Reset lifecycle/runtime состояния закрытой позиции. + Используется после полного закрытия позиции или перед flip. """ 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.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.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 index 6725a0e..5305135 100644 --- a/app/src/trading/execution/risk_close.py +++ b/app/src/trading/execution/risk_close.py @@ -4,10 +4,13 @@ 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.execution.models import ExecutionDecision from src.trading.execution.pricing import ExecutionPrice from src.trading.position.state import PositionState +from src.trading.execution.position_metrics import build_position_metrics class _ExecutionRiskCloseProtocol(Protocol): @@ -18,13 +21,8 @@ class _ExecutionRiskCloseProtocol(Protocol): self, symbol: str, side: str, - ) -> ExecutionPrice: ... - - # посчитать движение цены позиции в процентах - def _calculate_price_move_percent(self, current_price) -> float: ... - - # посчитать текущий PnL позиции - def _calculate_pnl(self, current_price) -> float: ... + ) -> ExecutionPrice: + ... # закрыть открытую позицию def _close_position( @@ -32,13 +30,32 @@ class _ExecutionRiskCloseProtocol(Protocol): state: AutoTradeState, *, forced_reason: str | None = None, - forced_exit_price=None, - forced_pnl=None, + forced_exit_price: NumericLike | None = None, + forced_pnl: NumericLike | None = None, forced_price_meta: ExecutionPrice | None = None, - ) -> ExecutionDecision: ... + ) -> ExecutionDecision: + ... class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol): + # закрыть позицию по risk-правилу с уже рассчитанными ценой и PnL + def _close_position_by_risk( + self, + state: AutoTradeState, + *, + reason: str, + current_price: NumericLike | None, + unrealized_pnl: NumericLike | None, + current_execution: ExecutionPrice, + ) -> ExecutionDecision: + return self._close_position( + state, + forced_reason=reason, + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + forced_price_meta=current_execution, + ) + # проверить, нужно ли закрыть позицию по max loss / stop loss / take profit def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: position = type(self)._position @@ -55,34 +72,39 @@ class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol): except Exception: return None - price_move_percent = self._calculate_price_move_percent(current_price) - unrealized_pnl = self._calculate_pnl(current_price) + metrics = build_position_metrics( + position, + current_price=current_price, + ) + + price_move_percent = metrics.price_move_percent + unrealized_pnl = metrics.net_pnl_usd if self._is_max_loss_hit(state, unrealized_pnl): - return self._close_position( + return self._close_position_by_risk( state, - forced_reason="MAX_LOSS", - forced_exit_price=current_price, - forced_pnl=unrealized_pnl, - forced_price_meta=current_execution, + reason="MAX_LOSS", + current_price=current_price, + unrealized_pnl=unrealized_pnl, + current_execution=current_execution, ) if self._is_stop_loss_hit(state, price_move_percent): - return self._close_position( + return self._close_position_by_risk( state, - forced_reason="STOP_LOSS", - forced_exit_price=current_price, - forced_pnl=unrealized_pnl, - forced_price_meta=current_execution, + reason="STOP_LOSS", + current_price=current_price, + unrealized_pnl=unrealized_pnl, + current_execution=current_execution, ) - if self._is_take_profit_hit(state, price_move_percent): - return self._close_position( + if self._is_take_profit_hit(state, unrealized_pnl): + return self._close_position_by_risk( state, - forced_reason="TAKE_PROFIT", - forced_exit_price=current_price, - forced_pnl=unrealized_pnl, - forced_price_meta=current_execution, + reason="TAKE_PROFIT", + current_price=current_price, + unrealized_pnl=unrealized_pnl, + current_execution=current_execution, ) return None @@ -91,31 +113,62 @@ class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol): def _is_stop_loss_hit( self, state: AutoTradeState, - price_move_percent: float, + price_move_percent: NumericLike | None, ) -> bool: - if state.stop_loss_percent is None: + stop_loss_percent = safe_float(state.stop_loss_percent) + price_move = safe_float(price_move_percent) + + if stop_loss_percent is None or stop_loss_percent <= 0: return False - return price_move_percent <= -abs(state.stop_loss_percent) + if price_move is None: + return False + + return price_move <= -abs(stop_loss_percent) # проверить, достигнут ли take profit в процентах def _is_take_profit_hit( self, state: AutoTradeState, - price_move_percent: float, + unrealized_pnl: NumericLike | None, ) -> bool: - if state.take_profit_percent is None: + take_profit_percent = safe_float(state.take_profit_percent) + pnl = safe_float(unrealized_pnl) + + if take_profit_percent is None or take_profit_percent <= 0: return False - return price_move_percent >= abs(state.take_profit_percent) + if pnl is None: + return False + + position = type(self)._position + + entry_price = safe_float(position.entry_price) + size = safe_float(position.size) + + if entry_price is None or entry_price <= 0: + return False + + if size is None or size <= 0: + return False + + target_profit_usd = abs(entry_price * size * (take_profit_percent / 100)) + + return pnl >= target_profit_usd # проверить, достигнут ли максимальный убыток в USD def _is_max_loss_hit( self, state: AutoTradeState, - unrealized_pnl: float, + unrealized_pnl: NumericLike | None, ) -> bool: - if state.max_loss_usd is None: + max_loss_usd = safe_float(state.max_loss_usd) + pnl = safe_float(unrealized_pnl) + + if max_loss_usd is None or max_loss_usd <= 0: return False - return unrealized_pnl <= -abs(state.max_loss_usd) \ No newline at end of file + if pnl is None: + return False + + return pnl <= -abs(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 index ebb8eb2..9bf2a96 100644 --- a/app/src/trading/execution/runtime_actions.py +++ b/app/src/trading/execution/runtime_actions.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from typing import Protocol +from typing import ClassVar, Protocol from src.core.event_bus import EventBus from src.core.numbers import safe_float @@ -12,10 +12,29 @@ 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.constants import ( + AUTONOMOUS_ACTION_EXIT, + AUTONOMOUS_ACTION_EXIT_BLOCKED, + AUTONOMOUS_ACTION_HOLD, + AUTONOMOUS_ACTION_PROTECT, + AUTONOMOUS_ACTION_REDUCE, + AUTONOMOUS_ACTION_WATCH, + AUTO_STATUS_RUNNING, + EXECUTION_ACTION_NONE, + EXECUTION_REASON_AUTONOMOUS_EXIT, + EXECUTION_TYPE_RUNTIME_ACTION, + POSITION_SIDE_NONE, + RUNTIME_ACTION_COOLDOWN, + RUNTIME_ACTION_COOLDOWN_SECONDS, + RUNTIME_ACTION_SKIPPED, + RUNTIME_ACTION_UNKNOWN, + RUNTIME_EXIT_CONFIDENCE_THRESHOLD, + get_position_exit_thresholds, +) class _ExecutionRuntimeActionsProtocol(Protocol): - _position: PositionState + _position: ClassVar[PositionState] def _sync_state_from_position( self, @@ -33,49 +52,39 @@ class _ExecutionRuntimeActionsProtocol(Protocol): class ExecutionRuntimeActionsMixin( _ExecutionRuntimeActionsProtocol ): - """ - Runtime autonomous actions subsystem. + # ----- Runtime autonomous actions subsystem. + # Отвечает за: + # - runtime EXIT + # - runtime REDUCE + # - runtime PROTECT + # - cooldown runtime действий + # - runtime logging - Отвечает за: - - runtime EXIT - - runtime REDUCE - - runtime PROTECT - - cooldown runtime действий - - runtime logging - """ - - _runtime_action_cooldown_seconds = 30 + _runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS _last_runtime_action_key: str | None = None - # ========================================================= - # PUBLIC - # ========================================================= - + # ----- PUBLIC ----- def process_runtime_action( self, state: AutoTradeState, ) -> ExecutionDecision: - """ - Главный runtime action processor. - """ + # Главный 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 state.status != AUTO_STATUS_RUNNING: + reason = "Runtime action доступен только в режиме RUNNING." + state.last_execution_action = RUNTIME_ACTION_SKIPPED + state.last_execution_reason = reason + return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) - if position.side == "NONE": - return ExecutionDecision( - "NONE", - False, - "Нет открытой позиции для runtime action.", - ) + if position.side == POSITION_SIDE_NONE: + reason = "Нет открытой позиции для runtime action." + state.last_execution_action = RUNTIME_ACTION_SKIPPED + state.last_execution_reason = reason + return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason) action = str( getattr(state, "autonomous_action", "") or "" @@ -89,110 +98,106 @@ class ExecutionRuntimeActionsMixin( getattr(state, "autonomous_action_reason", "") or "" ) - # ----------------------------------------------------- - # NO ACTION - # ----------------------------------------------------- - - if action in {"", "HOLD", "WATCH"}: - return ExecutionDecision( - "NONE", - False, - "Runtime action не требуется.", - ) - - # ----------------------------------------------------- - # COOLDOWN - # ----------------------------------------------------- + if action in {"", AUTONOMOUS_ACTION_HOLD, AUTONOMOUS_ACTION_WATCH}: + skip_reason = "Runtime action не требуется." + state.last_execution_action = RUNTIME_ACTION_SKIPPED + state.last_execution_reason = skip_reason + return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason) if self._runtime_action_cooldown_active(state, action): - return ExecutionDecision( - "NONE", - False, - "Runtime action cooldown активен.", - ) + skip_reason = "Runtime action cooldown активен." + state.last_execution_action = RUNTIME_ACTION_COOLDOWN + state.last_execution_reason = skip_reason + return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason) - # ----------------------------------------------------- - # PROTECT - # ----------------------------------------------------- - - if action == "PROTECT": + if action == AUTONOMOUS_ACTION_PROTECT: return self._log_runtime_action( state=state, - action="PROTECT", + action=AUTONOMOUS_ACTION_PROTECT, reason=reason or "позиция требует защиты", confidence=confidence, executed=False, ) - # ----------------------------------------------------- - # REDUCE - # ----------------------------------------------------- - - if action == "REDUCE": + if action == AUTONOMOUS_ACTION_REDUCE: return self._log_runtime_action( state=state, - action="REDUCE", + action=AUTONOMOUS_ACTION_REDUCE, reason=reason or "позиция требует уменьшения", confidence=confidence, executed=False, ) - # ----------------------------------------------------- - # EXIT - # ----------------------------------------------------- + if action == AUTONOMOUS_ACTION_EXIT: + if self._early_exit_guard_active(state): + hold_seconds = safe_float( + getattr(state, "position_hold_seconds", None) + ) or 0.0 - if action == "EXIT": + thresholds = get_position_exit_thresholds( + getattr(state, "symbol", None) + ) + + min_hold = thresholds["min_hold"] - if confidence < 0.75: return self._log_runtime_action( state=state, - action="EXIT_BLOCKED", + action=AUTONOMOUS_ACTION_EXIT_BLOCKED, reason=( - "autonomous exit заблокирован: " - f"confidence {confidence:.2f} < 0.75" + "early exit guard: позиция ещё слишком новая для закрытия " + f"({hold_seconds:.0f}s < {min_hold:.0f}s)" ), confidence=confidence, executed=False, + cooldown_action=None, ) + if confidence < RUNTIME_EXIT_CONFIDENCE_THRESHOLD: + return self._log_runtime_action( + state=state, + action=AUTONOMOUS_ACTION_EXIT_BLOCKED, + reason=( + "autonomous exit заблокирован: " + f"confidence {confidence:.2f} < " + f"{RUNTIME_EXIT_CONFIDENCE_THRESHOLD:.2f}" + ), + confidence=confidence, + executed=False, + cooldown_action=None, + ) + + self._log_runtime_action( + state=state, + action=AUTONOMOUS_ACTION_EXIT, + reason=reason or "autonomous exit", + confidence=confidence, + executed=True, + ) + decision = self._close_position( state, - forced_reason="AUTONOMOUS_EXIT", + forced_reason=EXECUTION_REASON_AUTONOMOUS_EXIT, ) - state.autonomous_last_action = "EXIT" - state.autonomous_last_action_reason = ( - reason or decision.reason - ) - state.autonomous_last_action_at = ( - time.monotonic() - ) + state.autonomous_last_action = AUTONOMOUS_ACTION_EXIT + state.autonomous_last_action_reason = reason or decision.reason + state.autonomous_last_action_at = time.monotonic() return decision - # ----------------------------------------------------- - # UNKNOWN ACTION - # ----------------------------------------------------- + unknown_reason = f"Неизвестный runtime action: {action}." + state.last_execution_action = RUNTIME_ACTION_UNKNOWN + state.last_execution_reason = unknown_reason - return ExecutionDecision( - "NONE", - False, - f"Неизвестный runtime action: {action}.", - ) - - # ========================================================= - # COOLDOWN - # ========================================================= + return ExecutionDecision(EXECUTION_ACTION_NONE, False, unknown_reason) + # ----- COOLDOWN ----- def _runtime_action_cooldown_active( self, state: AutoTradeState, action: str, ) -> bool: - """ - Проверка cooldown runtime action. - """ - + # Проверка cooldown runtime action. ts = safe_float( getattr(state, "autonomous_last_action_at", None) ) @@ -211,10 +216,121 @@ class ExecutionRuntimeActionsMixin( time.monotonic() - ts ) < self._runtime_action_cooldown_seconds - # ========================================================= - # LOGGING - # ========================================================= + def _build_runtime_action_payload( + self, + *, + state: AutoTradeState, + position: PositionState, + trade_id: str | None, + action: str, + reason: str, + confidence: float, + executed: bool, + ) -> JsonDict: + return { + # ---------- Trade ---------- + "trade_id": trade_id, + "trade_sequence": position.trade_sequence, + "trade_cycle_number": position.trade_cycle_number, + # ---------- Event ---------- + "execution_type": EXECUTION_TYPE_RUNTIME_ACTION, + "action": action, + "executed": executed, + "reason": reason, + "confidence": confidence, + + # ---------- Runtime ---------- + "status": state.status, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + + # ---------- Instrument / Position ---------- + "symbol": state.symbol, + "position_side": position.side, + "entry_price": position.entry_price, + "size": position.size, + "leverage": position.leverage, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + "position_pnl_percent": state.position_pnl_percent, + "position_hold_seconds": state.position_hold_seconds, + + # ---------- Position health ---------- + "position_pressure": state.position_pressure, + "position_health_status": state.position_health_status, + "position_health_score": state.position_health_score, + "position_health_reason": state.position_health_reason, + "position_risk_level": state.position_risk_level, + "position_risk_reason": state.position_risk_reason, + "position_trend_alignment": state.position_trend_alignment, + "position_adverse_momentum": state.position_adverse_momentum, + "position_exit_pressure": state.position_exit_pressure, + + # ---------- Position intelligence ---------- + "position_lifecycle_stage": state.position_lifecycle_stage, + "position_hold_quality": state.position_hold_quality, + "position_decay_state": state.position_decay_state, + "position_exit_signal": state.position_exit_signal, + "position_exit_confidence": state.position_exit_confidence, + "position_exit_urgency": state.position_exit_urgency, + "position_reversal_risk": state.position_reversal_risk, + "position_intelligence_reason": state.position_intelligence_reason, + "position_recommended_action": state.position_recommended_action, + + # ---------- Advanced analytics ---------- + "position_peak_pnl_usd": state.position_peak_pnl_usd, + "position_peak_pnl_percent": state.position_peak_pnl_percent, + "position_mfe_percent": state.position_mfe_percent, + "position_mae_percent": state.position_mae_percent, + "position_fatigue_score": state.position_fatigue_score, + "position_fatigue_state": state.position_fatigue_state, + "position_giveback_percent": state.position_giveback_percent, + "position_stall_state": state.position_stall_state, + "position_stall_reason": state.position_stall_reason, + + # ---------- Autonomous management ---------- + "autonomous_action": state.autonomous_action, + "autonomous_action_reason": state.autonomous_action_reason, + "autonomous_action_confidence": state.autonomous_action_confidence, + "autonomous_protection_required": state.autonomous_protection_required, + "autonomous_reduce_required": state.autonomous_reduce_required, + "autonomous_exit_required": state.autonomous_exit_required, + "autonomous_last_action": state.autonomous_last_action, + "autonomous_last_action_reason": state.autonomous_last_action_reason, + + # ---------- Runtime protection ---------- + "position_protection_status": state.position_protection_status, + "position_protection_reason": state.position_protection_reason, + "runtime_protection_action": state.runtime_protection_action, + "runtime_protection_reason": state.runtime_protection_reason, + "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, + + # ---------- Market context ---------- + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_volatility": state.market_volatility, + "market_trend_quality": state.market_trend_quality, + "market_phase": state.market_phase, + "market_structure": state.market_structure, + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "htf_alignment": state.htf_alignment, + + # ---------- Execution context ---------- + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_confidence_score": state.execution_confidence_score, + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + } + + # ----- LOGGING ----- def _log_runtime_action( self, *, @@ -223,14 +339,14 @@ class ExecutionRuntimeActionsMixin( reason: str, confidence: float, executed: bool, + cooldown_action: str | None = None, ) -> ExecutionDecision: - """ - Runtime action logging + deduplication. - """ - + # Runtime action logging + deduplication. position = type(self)._position + trade_id = position.trade_id or state.current_trade_id key = ( + f"{trade_id}:" f"{state.symbol}:" f"{position.side}:" f"{action}:" @@ -239,48 +355,17 @@ class ExecutionRuntimeActionsMixin( ) 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, - } + payload = self._build_runtime_action_payload( + state=state, + position=position, + trade_id=trade_id, + action=action, + reason=reason, + confidence=confidence, + executed=executed, + ) JournalService().log_ui_warning( event_type="runtime_position_action", @@ -298,7 +383,9 @@ class ExecutionRuntimeActionsMixin( payload, ) - state.autonomous_last_action = action + state.last_execution_action = action + state.last_execution_reason = reason + state.autonomous_last_action = cooldown_action or action state.autonomous_last_action_reason = reason state.autonomous_last_action_at = time.monotonic() @@ -306,4 +393,27 @@ class ExecutionRuntimeActionsMixin( action, executed, reason, - ) \ No newline at end of file + ) + + def _early_exit_guard_active(self, state: AutoTradeState) -> bool: + hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) + pnl_percent = safe_float(getattr(state, "position_pnl_percent", None)) + + if hold_seconds is None or pnl_percent is None: + return False + + thresholds = get_position_exit_thresholds( + getattr(state, "symbol", None) + ) + + min_hold = thresholds["min_hold"] + hard_loss = thresholds["hard_loss"] + + if hold_seconds >= min_hold: + return False + + # Если просадка уже критическая — guard не мешает защите. + if pnl_percent <= hard_loss: + return False + + return True \ No newline at end of file diff --git a/app/src/trading/execution/sizing.py b/app/src/trading/execution/sizing.py index f6da964..11ab575 100644 --- a/app/src/trading/execution/sizing.py +++ b/app/src/trading/execution/sizing.py @@ -38,58 +38,35 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol): *, 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, - ) + risk_percent = safe_float(state.risk_percent) + stop_loss_percent = safe_float(state.stop_loss_percent) + balance_usd = safe_float(state.allocated_balance_usd) or 0.0 + + if risk_percent is None or 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, - ) + if stop_loss_percent is None or 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 + price = safe_float(entry_price) if price is None: try: - price = self._signal_entry_price(state).price + price = safe_float(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 + price = None - if price <= 0: - self._sync_adaptive_size_state( - state, - base_size=0.0, - final_size=0.0, - multiplier=0.0, - ) + if price is None or 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) + target_risk_usd = balance_usd * (risk_percent / 100) + stop_loss_distance_usd = price * (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, - ) + if target_risk_usd <= 0 or 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 @@ -105,84 +82,49 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol): return self._round_size(final_size) - # рассчитать коэффициент изменения размера позиции по runtime/context факторам + # рассчитать коэффициент изменения размера позиции по итоговым runtime/context факторам def _adaptive_size_multiplier(self, state: AutoTradeState) -> float: multiplier = 1.0 - execution_confidence_score = getattr( - state, - "execution_confidence_score", - None, + # execution_confidence_score — итоговая готовность входа: + # сигнал + подтверждение + рынок + качество исполнения. + # Если он ниже required_score, размер должен быть 0. + execution_score = safe_float( + getattr(state, "execution_confidence_score", None) + ) + required_score = ( + safe_float(getattr(state, "execution_confidence_required_score", None)) + or 0.65 ) - score_raw = safe_float(execution_confidence_score) - if score_raw is not None: - score = max(0.0, min(1.0, score_raw)) + if execution_score is not None: + execution_score = max(0.0, min(1.0, execution_score)) - if score < 0.55: + if execution_score < required_score: multiplier *= 0.0 - elif score < 0.65: + elif execution_score < 0.75: + multiplier *= 0.90 + elif execution_score < 0.85: + multiplier *= 1.00 + else: + multiplier *= 1.10 + + # market_score — новая общая оценка рынка 0..100. + # Она должна влиять на размер позиции напрямую, + # но без повторного ручного штрафования по trend/phase/momentum. + market_score = self._market_score_for_sizing(state) + + if market_score is not None: + if market_score < 35: + multiplier *= 0.0 + elif market_score < 55: multiplier *= 0.65 - elif score < 0.75: + elif market_score < 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 + elif market_score < 90: + multiplier *= 1.00 + else: + multiplier *= 1.12 execution_quality = getattr(state, "execution_quality", None) execution_quality_reason = getattr( @@ -191,23 +133,52 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol): None, ) + # Качество исполнения оставляем отдельным фактором, + # потому что оно связано не с рынком, а с возможностью нормально войти: + # spread, snapshot age, стакан, деградация live-данных. 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 + multiplier *= 0.85 elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE": - multiplier *= 0.7 + multiplier *= 0.70 else: - multiplier *= 0.8 + multiplier *= 0.85 if getattr(state, "market_runtime_degraded", False): multiplier *= 0.75 return round(max(0.0, min(1.25, multiplier)), 4) + # получить market_score для sizing. + # Основной источник — state.market_score / state.market_score_percent. + # Fallback — market_score из execution_confidence_factors, где он хранится как 0..1. + def _market_score_for_sizing(self, state: AutoTradeState) -> float | None: + direct_score = safe_float(getattr(state, "market_score", None)) + + if direct_score is None: + direct_score = safe_float(getattr(state, "market_score_percent", None)) + + if direct_score is not None: + return max(0.0, min(100.0, direct_score)) + + factors = getattr(state, "execution_confidence_factors", None) + + if isinstance(factors, dict): + factor_score = safe_float(factors.get("market_score")) + + if factor_score is not None: + # Старый market_score внутри execution_confidence_factors хранится как 0..1. + if factor_score <= 1.0: + factor_score *= 100 + + return max(0.0, min(100.0, factor_score)) + + return None + # синхронизировать рассчитанный adaptive size в AutoTradeState def _sync_adaptive_size_state( self, @@ -241,6 +212,7 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol): state.adaptive_size_reason = reason state.adaptive_size_factors = { + "market_score": self._market_score_for_sizing(state), "execution_confidence_score": getattr( state, "execution_confidence_score", @@ -310,6 +282,13 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol): state.effective_target_risk_usd = 0.0 return + # Фиксируем итоговый size после margin limit, чтобы UI и journal не показывали старое значение. + state.adaptive_size_final = self._round_size(final_size) + + if state.adaptive_size_factors is not None: + state.adaptive_size_factors["final_size"] = self._round_size(final_size) + state.adaptive_size_factors["margin_limited"] = final_size < adaptive_final + margin_ratio = max( 0.0, min(1.0, final_size / adaptive_final), @@ -354,43 +333,39 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol): entry_price: float, size: float, ) -> float: - max_percent = state.max_reserved_balance_percent + max_percent = safe_float(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 + leverage = safe_float(state.leverage) or 1.0 + price = safe_float(entry_price) + current_size = safe_float(size) or 0.0 - if leverage <= 0 or entry_price <= 0: + if leverage <= 0 or price is None or price <= 0: state.execution_block_reason = "Invalid leverage or entry price." return 0.0 - balance_usd = state.allocated_balance_usd + balance_usd = safe_float(state.allocated_balance_usd) or 0.0 max_reserved_usd = balance_usd * (max_percent / 100) - max_notional_usd = max_reserved_usd * leverage - max_size = max_notional_usd / entry_price + max_size = max_notional_usd / price - if size <= max_size: - return self._round_size(size) + if current_size <= max_size: + return self._round_size(current_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" - ) + state.adaptive_size_reason = "размер позиции сильно ограничен margin limit" else: - state.adaptive_size_reason = ( - "размер позиции ограничен margin limit" - ) + state.adaptive_size_reason = "размер позиции ограничен margin limit" return limited_size diff --git a/app/src/trading/execution/supervisor.py b/app/src/trading/execution/supervisor.py index 537ff6d..22b1aba 100644 --- a/app/src/trading/execution/supervisor.py +++ b/app/src/trading/execution/supervisor.py @@ -20,10 +20,10 @@ class _ExecutionSupervisorProtocol(Protocol): _max_execution_snapshot_age_seconds: int _degraded_market_block_states: set[str] _conflict_execution_block: bool + _last_supervisor_block_key: str | None class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): - # проверить все supervisor-блокировки перед исполнением def _process_execution_supervisor( self, state: AutoTradeState, @@ -33,6 +33,8 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): (self._execution_cooldown_reason(state), "EXECUTION_COOLDOWN"), (self._degraded_market_reason(state), "DEGRADED_MARKET"), (self._stale_execution_reason(state), "STALE_EXECUTION"), + (self._entry_block_reason(state), "ENTRY_BLOCKED"), + (self._low_execution_confidence_reason(state), "LOW_EXECUTION_CONFIDENCE"), (self._conflict_signal_reason(state), "SIGNAL_CONFLICT"), ): if reason is not None: @@ -42,26 +44,58 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): action=action, ) + self._clear_supervisor_block_state(state) return None - # определить, нужно ли аварийно остановить execution + def _clear_supervisor_block_state( + self, + state: AutoTradeState, + ) -> None: + supervisor_actions = { + "EXECUTION_HALTED", + "EXECUTION_COOLDOWN", + "DEGRADED_MARKET", + "STALE_EXECUTION", + "ENTRY_BLOCKED", + "LOW_EXECUTION_CONFIDENCE", + "SIGNAL_CONFLICT", + } + + state.execution_block_title = None + state.execution_block_message = None + state.execution_block_action = None + + if state.last_execution_action not in supervisor_actions: + return + + if state.execution_block_reason == state.last_execution_reason: + state.execution_block_reason = None + + type(self)._last_supervisor_block_key = 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)) + # Блокируем цикл только после серии подряд идущих убытков. + # Важно: не считаем все убыточные сделки цикла, потому что прибыльная сделка + # должна сбрасывать серию убытков. + losses = int(getattr(state, "cycle_consecutive_losses", 0) or 0) 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: + # Cooldown после убытка включаем только если его явно активировал execution layer. + # Сам факт last_loss_monotonic_at больше НЕ должен блокировать торговлю, + # иначе пауза появляется уже после первой убыточной сделки. + if not bool(getattr(state, "loss_cooldown_active", False)): + return None + ts = safe_float(getattr(state, "last_loss_monotonic_at", None)) if ts is None: @@ -75,16 +109,67 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): return None - # определить, запрещает ли состояние рынка исполнение + # разрешить ранний вход из RANGE, если уже есть impulse/momentum по сигналу + def _early_impulse_execution_allowed(self, state: AutoTradeState) -> bool: + signal = str(getattr(state, "last_signal", "") or "").upper() + market_state = str(getattr(state, "market_state", "") or "").upper() + market_phase = str(getattr(state, "market_phase", "") or "").upper() + market_trend = str(getattr(state, "market_trend", "") or "").upper() + momentum_state = str(getattr(state, "momentum_state", "") or "").upper() + momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() + + if signal not in {"BUY", "SELL"}: + return False + + if market_state != "RANGE": + return False + + if market_phase != "IMPULSE": + return False + + if htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}: + return False + + if signal == "BUY": + return ( + market_trend == "UP" + and momentum_direction == "UP" + and momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"} + ) + + if signal == "SELL": + return ( + market_trend == "DOWN" + and momentum_direction == "DOWN" + and momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"} + ) + + return False + def _degraded_market_reason(self, state: AutoTradeState) -> str | None: - market_state = getattr(state, "market_state", None) + market_state = str(getattr(state, "market_state", "") or "").upper() + volatility = str(getattr(state, "market_volatility", "") or "").upper() + + early_impulse_allowed = self._early_impulse_execution_allowed(state) if market_state in self._degraded_market_block_states: + if market_state == "RANGE" and early_impulse_allowed: + return None + return f"market state blocked execution: {market_state}" + if market_state in {"RANGE", "LOW_VOLATILITY", "UNKNOWN", ""}: + if market_state == "RANGE" and early_impulse_allowed: + return None + + return f"market state blocked execution: {market_state or 'UNKNOWN'}" + + if volatility in {"LOW", "UNKNOWN", ""}: + return f"market volatility blocked execution: {volatility or 'UNKNOWN'}" + return None - # определить, устарели ли данные для исполнения def _stale_execution_reason(self, state: AutoTradeState) -> str | None: age = safe_float(getattr(state, "execution_price_age_seconds", None)) @@ -99,32 +184,297 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): return None - # определить конфликт сигнала с momentum или трендом + def _entry_block_reason(self, state: AutoTradeState) -> str | None: + reason = str(getattr(state, "entry_block_reason", "") or "").strip() + message = str(getattr(state, "entry_block_message", "") or "").strip() + + if reason: + return message or f"entry blocked by market analysis: {reason}" + + return None + + def _low_execution_confidence_reason(self, state: AutoTradeState) -> str | None: + signal = str(getattr(state, "last_signal", "") or "").upper() + + if signal not in {"BUY", "SELL"}: + return None + + score = safe_float(getattr(state, "execution_confidence_score", None)) + required = safe_float( + getattr(state, "execution_confidence_required_score", None) + ) + + if required is None: + required = 0.55 + + if score is None: + return "execution confidence is not calculated" + + if score < required: + return f"execution confidence too low: {score:.2f} < {required:.2f}" + + 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() + signal = str(getattr(state, "last_signal", "") or "").upper() momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper() trend_direction = str(getattr(state, "market_trend", "") or "").upper() + market_state = str(getattr(state, "market_state", "") or "").upper() + market_structure = str(getattr(state, "market_structure", "") or "").upper() + htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper() + + if signal not in {"BUY", "SELL"}: + return None + + if htf_alignment and htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}: + return f"{signal} conflicts with HTF alignment: {htf_alignment}" if signal == "BUY": if momentum_direction == "DOWN": return "BUY conflicts with momentum" - if trend_direction == "DOWN": + if trend_direction == "DOWN" or market_state == "TREND_DOWN": return "BUY conflicts with trend" + if market_structure == "LH_LL": + return "BUY conflicts with bearish market structure" + if signal == "SELL": if momentum_direction == "UP": return "SELL conflicts with momentum" - if trend_direction == "UP": + if trend_direction == "UP" or market_state == "TREND_UP": return "SELL conflicts with trend" + if market_structure == "HH_HL": + return "SELL conflicts with bullish market structure" + return None - # заблокировать execution и записать событие в журнал + def _human_execution_block( + self, + *, + action: str, + reason: str, + state: AutoTradeState, + ) -> tuple[str, str, str]: + if action == "EXECUTION_HALTED": + # Для UI показываем именно текущую серию убытков подряд, + # а не общее количество минусовых сделок за цикл. + losses = int(getattr(state, "cycle_consecutive_losses", 0) or 0) + + if "loss streak" in reason: + return ( + "Совершение сделок заблокировано", + f"Превышен лимит убыточных сделок · {losses}", + "Перезапусти цикл автоторговли", + ) + + return ( + "Совершение сделок заблокировано", + "Превышен лимит просадки цикла", + "Перезапусти цикл автоторговли", + ) + + if action == "EXECUTION_COOLDOWN": + return ( + "Совершение сделок временно заблокировано", + "Пауза после убыточной сделки", + "Дождись окончания cooldown", + ) + + if action == "LOW_EXECUTION_CONFIDENCE": + return ( + "Сделка заблокирована", + "Низкая уверенность исполнения", + "Дождись более сильного сигнала", + ) + + if action == "ENTRY_BLOCKED": + return ( + "Сделка заблокирована", + str(getattr(state, "entry_block_message", "") or "Рынок сейчас не подходит для входа"), + "Дождись подходящих условий", + ) + + if action == "SIGNAL_CONFLICT": + return ( + "Сделка заблокирована", + "Сигнал конфликтует с рынком", + "Дождись подтверждения направления", + ) + + if action == "STALE_EXECUTION": + return ( + "Сделка заблокирована", + "Нет актуальных котировок", + "Дождись обновления данных", + ) + + if action == "DEGRADED_MARKET": + return ( + "Сделка заблокирована", + "Рыночные условия не подходят", + "Дождись нормализации рынка", + ) + + return ( + "Сделка заблокирована", + reason, + "Проверь журнал автоторговли", + ) + + def _build_supervisor_block_payload( + self, + *, + state: AutoTradeState, + action: str, + reason: str, + ) -> JsonDict: + return { + # ---------- Event ---------- + "execution_type": "SUPERVISOR_BLOCK", + "action": action, + "reason": reason, + + # ---------- Runtime ---------- + "status": state.status, + "strategy": state.strategy, + "cycle_number": state.cycle_number, + + # ---------- Instrument ---------- + "symbol": state.symbol, + + # ---------- Signal ---------- + "signal": state.last_signal, + "confidence": state.last_signal_confidence, + "repeat_count": state.last_signal_repeat_count, + "signal_reason": state.last_signal_reason, + + # ---------- Decision ---------- + "decision_status": state.decision_status, + "decision_reason": state.decision_reason, + "is_signal_confirmed": state.is_signal_confirmed, + "is_signal_ready": state.is_signal_ready, + + # ---------- Runtime blocks ---------- + "entry_block_reason": state.entry_block_reason, + "entry_block_message": state.entry_block_message, + "execution_block_reason": state.execution_block_reason, + "execution_block_title": state.execution_block_title, + "execution_block_message": state.execution_block_message, + "execution_block_action": state.execution_block_action, + "last_flip_block_reason": state.last_flip_block_reason, + + # ---------- Execution ---------- + "execution_confidence_score": state.execution_confidence_score, + "execution_confidence_level": state.execution_confidence_level, + "execution_confidence_required_score": state.execution_confidence_required_score, + "execution_confidence_reason": state.execution_confidence_reason, + "execution_confidence_factors": state.execution_confidence_factors, + + "execution_quality": state.execution_quality, + "execution_quality_reason": state.execution_quality_reason, + "execution_quality_message": state.execution_quality_message, + + "spread_percent": state.spread_percent, + "snapshot_age_seconds": state.snapshot_age_seconds, + + # ---------- Execution price ---------- + "execution_price_source": state.execution_price_source, + "execution_price_age_seconds": state.execution_price_age_seconds, + "execution_bid_price": state.execution_bid_price, + "execution_ask_price": state.execution_ask_price, + "execution_last_price": state.execution_last_price, + "execution_price_freshness": state.execution_price_freshness, + + # ---------- Risk settings ---------- + "risk_percent": state.risk_percent, + "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, + "allocated_balance_usd": state.allocated_balance_usd, + "leverage": state.leverage, + + # ---------- Position ---------- + "position_side": state.position_side, + "entry_price": state.entry_price, + "position_size": state.position_size, + "unrealized_pnl_usd": state.unrealized_pnl_usd, + + # ---------- Cycle stats ---------- + "realized_pnl_usd": state.realized_pnl_usd, + "cycle_realized_pnl_usd": state.cycle_realized_pnl_usd, + "cycle_closed_trades": state.cycle_closed_trades, + "cycle_winning_trades": state.cycle_winning_trades, + "cycle_losing_trades": state.cycle_losing_trades, + "cycle_consecutive_losses": state.cycle_consecutive_losses, + "loss_cooldown_active": state.loss_cooldown_active, + "loss_cooldown_reason": state.loss_cooldown_reason, + + # ---------- Market score ---------- + "market_score": state.market_score, + "market_score_label": state.market_score_label, + "market_long_score": state.market_long_score, + "market_short_score": state.market_short_score, + + # ---------- Market ---------- + "market_state": state.market_state, + "market_trend": state.market_trend, + "market_volatility": state.market_volatility, + "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, + + # ---------- Candle ---------- + "last_closed_candle_change_percent": state.last_closed_candle_change_percent, + "last_closed_candle_direction": state.last_closed_candle_direction, + "current_interval_change_percent": state.current_interval_change_percent, + "current_interval_direction": state.current_interval_direction, + "current_interval_label": state.current_interval_label, + + # ---------- Structure ---------- + "market_structure": state.market_structure, + "market_structure_reason": state.market_structure_reason, + + # ---------- Momentum ---------- + "momentum_state": state.momentum_state, + "momentum_direction": state.momentum_direction, + "momentum_strength": state.momentum_strength, + "momentum_change_percent": state.momentum_change_percent, + "breakout_level": state.breakout_level, + "breakout_distance_percent": state.breakout_distance_percent, + "breakout_reason": state.breakout_reason, + + # ---------- HTF ---------- + "htf_interval": state.htf_interval, + "htf_atr_percent": state.htf_atr_percent, + "htf_atr_percent_baseline": state.htf_atr_percent_baseline, + "htf_volatility_ratio": state.htf_volatility_ratio, + "htf_volatility": state.htf_volatility, + "htf_market_state": state.htf_market_state, + "htf_trend": state.htf_trend, + "htf_trend_strength": state.htf_trend_strength, + "htf_trend_quality": state.htf_trend_quality, + "htf_market_phase": state.htf_market_phase, + "htf_alignment": state.htf_alignment, + "htf_confirmation_score": state.htf_confirmation_score, + "htf_reason": state.htf_reason, + + # ---------- Market runtime ---------- + "market_runtime_degraded": state.market_runtime_degraded, + "runtime_expired_reason": state.runtime_expired_reason, + "runtime_expired_message": state.runtime_expired_message, + "market_is_open": state.market_is_open, + "market_status": state.market_status, + "market_status_message": state.market_status_message, + } + def _block_execution( self, *, @@ -136,6 +486,16 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): state.last_execution_action = action state.last_execution_reason = reason + ( + state.execution_block_title, + state.execution_block_message, + state.execution_block_action, + ) = self._human_execution_block( + action=action, + reason=reason, + state=state, + ) + key_reason = reason if action == "EXECUTION_COOLDOWN": @@ -147,17 +507,11 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol): 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, - } + payload = self._build_supervisor_block_payload( + state=state, + action=action, + reason=reason, + ) JournalService().log_ui_warning( event_type="execution_supervisor_block", diff --git a/app/src/trading/journal/service.py b/app/src/trading/journal/service.py index 048296a..3b448e5 100644 --- a/app/src/trading/journal/service.py +++ b/app/src/trading/journal/service.py @@ -119,6 +119,47 @@ class JournalService: payload=payload, ) + def log_debug( + self, + event_type: str, + message: str, + payload: dict[str, Any] | None = None, + ) -> None: + if not load_settings().journal_debug_enabled: + return + + self.log_info( + event_type=event_type, + message=f"[DEBUG] {message}", + payload=payload, + ) + + def log_ui_debug( + self, + *, + event_type: str, + message: str, + screen: str, + action: str, + user_id: int | None = None, + chat_id: int | None = None, + payload: dict[str, Any] | None = None, + ) -> None: + if not load_settings().journal_debug_enabled: + return + + self.log_info( + event_type=event_type, + message=self._build_message(f"[DEBUG] {message}"), + payload=self._build_payload( + user_id=user_id, + chat_id=chat_id, + screen=screen, + action=action, + payload=payload, + ), + ) + def log_ui_info( self, *, @@ -338,31 +379,39 @@ class JournalService: extra: dict[str, Any] | None = None, ) -> dict[str, Any]: # Единый payload сделки для будущего анализа стратегии. + # Порядок блоков важен: так экспорт журнала легче читать и сравнивать. payload: dict[str, Any] = { + # ---------- Trade identity ---------- "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), + "cycle_number": getattr(state, "cycle_number", None), + # ---------- Position at event moment ---------- "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), + # ---------- PnL / cycle statistics ---------- "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), + "cycle_losing_trades": getattr(state, "cycle_losing_trades", None), + "cycle_consecutive_losses": getattr(state, "cycle_consecutive_losses", None), + # ---------- Signal / decision ---------- "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 summary ---------- "market_state": getattr(state, "market_state", None), "market_trend": getattr(state, "market_trend", None), "market_trend_strength": getattr(state, "market_trend_strength", None), @@ -370,24 +419,44 @@ class JournalService: "market_phase": getattr(state, "market_phase", None), "market_phase_direction": getattr(state, "market_phase_direction", None), + # ---------- Market score ---------- + "market_score": getattr(state, "market_score", None), + "market_score_label": getattr(state, "market_score_label", None), + "market_long_score": getattr(state, "market_long_score", None), + "market_short_score": getattr(state, "market_short_score", None), + + # ---------- Candle / interval context ---------- + "last_closed_candle_change_percent": getattr(state, "last_closed_candle_change_percent", None), + "last_closed_candle_direction": getattr(state, "last_closed_candle_direction", None), + "current_interval_change_percent": getattr(state, "current_interval_change_percent", None), + "current_interval_direction": getattr(state, "current_interval_direction", None), + "current_interval_label": getattr(state, "current_interval_label", None), + + # ---------- Market structure ---------- + "market_structure": getattr(state, "market_structure", None), + "market_structure_reason": getattr(state, "market_structure_reason", None), + + # ---------- Momentum / breakout ---------- "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 ---------- "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 ---------- "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 analytics ---------- "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), diff --git a/app/src/trading/market_analysis/filters.py b/app/src/trading/market_analysis/filters.py new file mode 100644 index 0000000..4c735c4 --- /dev/null +++ b/app/src/trading/market_analysis/filters.py @@ -0,0 +1,196 @@ +# app/src/trading/market_analysis/filters.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, + MarketPhase, + MarketState, + MarketStructure, + MomentumState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +# Главный рыночный фильтр входа. +# +# Этот файл НЕ открывает сделки сам. +# Он только отвечает на вопрос: +# "Можно ли стратегии вообще рассматривать вход по текущему состоянию рынка?" +# +# Последовательность: +# 1. Проверяем, что рынок действительно трендовый. +# 2. Проверяем направление тренда. +# 3. Отсекаем плохую волатильность. +# 4. Проверяем старший таймфрейм. +# 5. Проверяем momentum / breakout. +# 6. Проверяем структуру рынка. +# 7. Проверяем EMA, свечи, цену и тайминг. +# +# ВАЖНО: +# Резкий breakout может появляться из COMPRESSED / шумного состояния. +# Поэтому COMPRESSED и NOISY теперь не всегда блокируют вход, +# если есть подтверждённый breakout по тренду. +def is_trade_allowed( + *, + state: MarketState, + trend: TrendDirection, + volatility: VolatilityState, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + market_structure: MarketStructure, + momentum_state: MomentumState, + momentum_direction: TrendDirection, + candle_noise_score: float | None, + price_position_score: float | None, + ema_fast_slope_percent: float | None, + ema_distance_state: EmaDistanceState, + entry_timing_state: EntryTimingState, + fast_slope_threshold_percent: float, + htf_alignment: str, + htf_confirmation_score: float | None, + min_htf_confirmation_score: float, + min_clean_candle_score: float, + min_price_position_score: float, + rsi_value: float | None, + rsi_overbought: float, + rsi_oversold: float, +) -> bool: + is_up_context = ( + state == MarketState.TREND_UP + and trend == TrendDirection.UP + ) + + is_down_context = ( + state == MarketState.TREND_DOWN + and trend == TrendDirection.DOWN + ) + + is_breakout_up = ( + is_up_context + and momentum_state == MomentumState.BREAKOUT_UP + and momentum_direction == TrendDirection.UP + ) + + is_breakout_down = ( + is_down_context + and momentum_state == MomentumState.BREAKOUT_DOWN + and momentum_direction == TrendDirection.DOWN + ) + + is_breakout_with_trend = is_breakout_up or is_breakout_down + + if not is_up_context and not is_down_context: + return False + + # Не торгуем только при низкой / неизвестной волатильности. + # HIGH не блокируем полностью: для TREND это может быть нормальный импульс. + if volatility in { + VolatilityState.LOW, + VolatilityState.UNKNOWN, + }: + return False + + if trend_strength == TrendStrength.UNKNOWN: + return False + + if trend_strength == TrendStrength.WEAK and not is_breakout_with_trend: + return False + + if trend_quality == TrendQuality.UNKNOWN: + return False + + if trend_quality == TrendQuality.NOISY and not is_breakout_with_trend: + return False + + if market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}: + if not is_breakout_with_trend: + return False + + if htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}: + return False + + if ( + htf_confirmation_score is not None + and htf_confirmation_score < min_htf_confirmation_score + and not is_breakout_with_trend + ): + return False + + if rsi_value is not None and not is_breakout_with_trend: + if trend == TrendDirection.UP and rsi_value >= rsi_overbought: + return False + + if trend == TrendDirection.DOWN and rsi_value <= rsi_oversold: + return False + + if trend == TrendDirection.UP: + if momentum_direction != TrendDirection.UP: + return False + + if momentum_state not in { + MomentumState.MOMENTUM_UP, + MomentumState.BREAKOUT_UP, + }: + return False + + if market_structure == MarketStructure.LH_LL: + return False + + if trend == TrendDirection.DOWN: + if momentum_direction != TrendDirection.DOWN: + return False + + if momentum_state not in { + MomentumState.MOMENTUM_DOWN, + MomentumState.BREAKOUT_DOWN, + }: + return False + + if market_structure == MarketStructure.HH_HL: + return False + + fast_slope = ema_fast_slope_percent or 0.0 + + if trend == TrendDirection.UP and fast_slope < fast_slope_threshold_percent: + return False + + if trend == TrendDirection.DOWN and fast_slope > -fast_slope_threshold_percent: + return False + + if candle_noise_score is None: + return False + + if candle_noise_score < min_clean_candle_score and not is_breakout_with_trend: + return False + + if price_position_score is None: + return False + + if price_position_score < min_price_position_score and not is_breakout_with_trend: + return False + + if ema_distance_state in { + EmaDistanceState.OVEREXTENDED, + EmaDistanceState.UNKNOWN, + }: + return False + + if ema_distance_state == EmaDistanceState.COMPRESSED and not is_breakout_with_trend: + return False + + if entry_timing_state in { + EntryTimingState.LATE, + EntryTimingState.CHASING, + }: + return False + + if entry_timing_state == EntryTimingState.UNKNOWN and not is_breakout_with_trend: + return False + + return True \ No newline at end of file diff --git a/app/src/trading/market_analysis/htf.py b/app/src/trading/market_analysis/htf.py new file mode 100644 index 0000000..825fce9 --- /dev/null +++ b/app/src/trading/market_analysis/htf.py @@ -0,0 +1,487 @@ +# app/src/trading/market_analysis/htf.py + +from __future__ import annotations + +from src.core.numbers import safe_float +from src.core.types import JsonDict +from src.integrations.exchange.service import ExchangeService +from src.trading.market_analysis.indicators import atr, ema +from src.trading.market_analysis.indicators.trend import ( + classify_trend, + classify_trend_quality, + classify_trend_strength, + ema_distance_atr_ratio as calculate_ema_distance_atr_ratio, + ema_slope_percent, + trend_consistency, + trend_efficiency, + trend_gap_percent_value, +) +from src.trading.market_analysis.indicators.volatility import ( + adaptive_threshold, + atr_percent_baseline, + classify_volatility, +) +from src.trading.market_analysis.models import ( + MarketPhase, + MarketState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) +from src.trading.market_analysis.quality import ( + candle_noise_score as calculate_candle_noise_score, + price_position_score as calculate_price_position_score, +) + + +def htf_volatility_context( + service, + *, + symbol: str, + base_interval: str, +) -> JsonDict: + if base_interval == service._htf_interval: + return { + "htf_interval": service._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": "HTF_SKIPPED_SAME_INTERVAL", + } + + try: + batch = ExchangeService().get_klines( + symbol=symbol, + interval=service._htf_interval, + limit=service._htf_limit, + ) + except Exception as exc: + return { + "htf_interval": service._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": f"HTF_KLINES_ERROR: {exc}", + } + + candles = batch.candles + closes = [item.close_price for item in candles] + + if len(candles) < service._min_candles or not closes: + return { + "htf_interval": service._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": "HTF_NOT_ENOUGH_CANDLES", + } + + close_price = safe_float(closes[-1]) + atr_value = atr(candles, service._atr_period) + + if close_price is None or close_price <= 0 or atr_value is None: + return { + "htf_interval": service._htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_reason": "HTF_ATR_UNAVAILABLE", + } + + htf_atr_percent = (atr_value / close_price) * 100 + htf_baseline = atr_percent_baseline( + candles=candles, + close_price=close_price, + atr_period=service._atr_period, + atr_baseline_window=service._atr_baseline_window, + ) + + htf_ratio = ( + htf_atr_percent / htf_baseline + if htf_baseline is not None and htf_baseline > 0 + else None + ) + + htf_volatility = classify_volatility( + atr_percent=htf_atr_percent, + volatility_ratio=htf_ratio, + htf_volatility_ratio=None, + low_volatility_atr_percent=service._low_volatility_atr_percent, + high_volatility_atr_percent=service._high_volatility_atr_percent, + ) + + return { + "htf_interval": service._htf_interval, + "htf_atr_percent": round(htf_atr_percent, 4), + "htf_atr_percent_baseline": round(htf_baseline, 4) + if htf_baseline is not None + else None, + "htf_volatility_ratio": round(htf_ratio, 4) + if htf_ratio is not None + else None, + "htf_volatility": htf_volatility.value, + "htf_reason": "HTF_OK", + } + + +def htf_trend_context( + service, + *, + symbol: str, + base_interval: str, + local_state: MarketState, + local_trend: TrendDirection, +) -> JsonDict: + if base_interval == service._htf_interval: + return { + "htf_market_state": local_state.value, + "htf_trend": local_trend.value, + "htf_trend_strength": TrendStrength.UNKNOWN.value, + "htf_trend_quality": TrendQuality.UNKNOWN.value, + "htf_market_phase": MarketPhase.UNKNOWN.value, + "htf_alignment": "SAME_INTERVAL", + "htf_confirmation_score": 1.0, + "htf_reason": "HTF_SKIPPED_SAME_INTERVAL", + } + + try: + batch = ExchangeService().get_klines( + symbol=symbol, + interval=service._htf_interval, + limit=service._htf_limit, + ) + except Exception as exc: + return _htf_unknown_context(f"HTF_KLINES_ERROR: {exc}") + + candles = batch.candles + closes = [item.close_price for item in candles] + + if len(candles) < service._min_candles: + return _htf_unknown_context("HTF_NOT_ENOUGH_CANDLES") + + close_price = closes[-1] if closes else None + ema_fast = ema(closes, service._fast_ema_period) + ema_slow = ema(closes, service._slow_ema_period) + atr_value = atr(candles, service._atr_period) + + if ( + close_price is None + or close_price <= 0 + or ema_fast is None + or ema_slow is None + or atr_value is None + ): + return _htf_unknown_context("HTF_INDICATORS_UNAVAILABLE") + + atr_percent = (atr_value / close_price) * 100 + + fast_slope_threshold_percent = adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.08, + minimum=0.01, + ) + + slow_slope_threshold_percent = adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.03, + minimum=0.005, + ) + + trend_direction_gap_threshold_percent = adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.12, + minimum=0.025, + ) + + weak_trend_gap_threshold_percent = adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.18, + minimum=0.05, + ) + + strong_trend_gap_threshold_percent = adaptive_threshold( + atr_percent=atr_percent, + multiplier=0.55, + minimum=0.18, + ) + + ema_fast_slope_percent = ema_slope_percent( + closes=closes, + period=service._fast_ema_period, + window=service._ema_fast_slope_window, + ) + + ema_slow_slope_percent = ema_slope_percent( + closes=closes, + period=service._slow_ema_period, + window=service._ema_slow_slope_window, + ) + + trend = classify_trend( + ema_fast=ema_fast, + ema_slow=ema_slow, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_slow_slope_percent=ema_slow_slope_percent, + fast_slope_threshold_percent=fast_slope_threshold_percent, + slow_slope_threshold_percent=slow_slope_threshold_percent, + trend_direction_gap_threshold_percent=trend_direction_gap_threshold_percent, + ) + + trend_gap_percent = trend_gap_percent_value( + ema_fast=ema_fast, + ema_slow=ema_slow, + ) + + trend_strength = classify_trend_strength( + trend_gap_percent=trend_gap_percent, + weak_threshold_percent=weak_trend_gap_threshold_percent, + strong_threshold_percent=strong_trend_gap_threshold_percent, + ) + + trend_consistency_value = trend_consistency( + closes=closes, + trend=trend, + trend_consistency_window=service._trend_consistency_window, + ) + + trend_efficiency_value = trend_efficiency( + closes=closes, + trend_consistency_window=service._trend_consistency_window, + ) + + ema_distance_atr_ratio_value = calculate_ema_distance_atr_ratio( + ema_fast=ema_fast, + ema_slow=ema_slow, + atr_value=atr_value, + ) + + candle_noise_score = calculate_candle_noise_score( + candles, + candle_noise_window=service._candle_noise_window, + min_clean_body_ratio=service._min_clean_body_ratio, + ) + + price_position_score = calculate_price_position_score( + closes=closes, + ema_fast=ema_fast, + trend=trend, + price_position_window=service._price_position_window, + ) + + trend_quality = classify_trend_quality( + trend_consistency=trend_consistency_value, + trend_efficiency=trend_efficiency_value, + ema_distance_atr_ratio=ema_distance_atr_ratio_value, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, + trend_strength=trend_strength, + min_clean_candle_score=service._min_clean_candle_score, + min_price_position_score=service._min_price_position_score, + ) + + market_phase = _htf_market_phase( + trend=trend, + trend_strength=trend_strength, + trend_quality=trend_quality, + ) + + market_state = _htf_market_state( + trend=trend, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + ) + + alignment = _htf_alignment( + local_state=local_state, + htf_state=market_state, + local_trend=local_trend, + htf_trend=trend, + ) + + confirmation_score = _htf_confirmation_score( + alignment=alignment, + trend_strength=trend_strength, + trend_quality=trend_quality, + trend_consistency=trend_consistency_value, + trend_efficiency=trend_efficiency_value, + ) + + return { + "htf_market_state": market_state.value, + "htf_trend": trend.value, + "htf_trend_strength": trend_strength.value, + "htf_trend_quality": trend_quality.value, + "htf_market_phase": market_phase.value, + "htf_alignment": alignment, + "htf_confirmation_score": round(confirmation_score, 3), + "htf_reason": ( + f"HTF_{service._htf_interval}:" + f"{market_state.value}:" + f"{trend.value}:" + f"{alignment}" + ), + } + + +def safe_market_state(value: object) -> MarketState | None: + try: + return MarketState(str(value)) + except Exception: + return None + + +def safe_trend_direction(value: object) -> TrendDirection | None: + try: + return TrendDirection(str(value)) + except Exception: + return None + + +def safe_trend_strength(value: object) -> TrendStrength | None: + try: + return TrendStrength(str(value)) + except Exception: + return None + + +def safe_trend_quality(value: object) -> TrendQuality | None: + try: + return TrendQuality(str(value)) + except Exception: + return None + + +def safe_market_phase(value: object) -> MarketPhase | None: + try: + return MarketPhase(str(value)) + except Exception: + return None + + +def safe_volatility_state(value: object) -> VolatilityState | None: + try: + return VolatilityState(str(value)) + except Exception: + return None + + +def _htf_unknown_context(reason: str) -> JsonDict: + return { + "htf_market_state": MarketState.UNKNOWN.value, + "htf_trend": TrendDirection.UNKNOWN.value, + "htf_trend_strength": TrendStrength.UNKNOWN.value, + "htf_trend_quality": TrendQuality.UNKNOWN.value, + "htf_market_phase": MarketPhase.UNKNOWN.value, + "htf_alignment": "UNKNOWN", + "htf_confirmation_score": None, + "htf_reason": reason, + } + + +def _htf_market_phase( + *, + trend: TrendDirection, + trend_strength: TrendStrength, + trend_quality: TrendQuality, +) -> MarketPhase: + if trend in {TrendDirection.UNKNOWN, TrendDirection.FLAT}: + return MarketPhase.RANGE + + if trend_strength == TrendStrength.WEAK: + return MarketPhase.RANGE + + if trend_quality == TrendQuality.NOISY: + return MarketPhase.RANGE + + return MarketPhase.IMPULSE + + +def _htf_market_state( + *, + trend: TrendDirection, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, +) -> MarketState: + if trend == TrendDirection.UP: + return MarketState.TREND_UP + + if trend == TrendDirection.DOWN: + return MarketState.TREND_DOWN + + if trend == TrendDirection.FLAT: + return MarketState.RANGE + + return MarketState.UNKNOWN + + +def _htf_alignment( + *, + local_state: MarketState, + htf_state: MarketState, + local_trend: TrendDirection, + htf_trend: TrendDirection, +) -> str: + if htf_trend == TrendDirection.UNKNOWN: + return "UNKNOWN" + + if htf_trend == TrendDirection.FLAT: + return "NEUTRAL" + + if local_trend == TrendDirection.UP: + return "ALIGNED" if htf_trend == TrendDirection.UP else "AGAINST" + + if local_trend == TrendDirection.DOWN: + return "ALIGNED" if htf_trend == TrendDirection.DOWN else "AGAINST" + + if local_state == MarketState.TREND_UP: + return "ALIGNED" if htf_trend == TrendDirection.UP else "AGAINST" + + if local_state == MarketState.TREND_DOWN: + return "ALIGNED" if htf_trend == TrendDirection.DOWN else "AGAINST" + + return "NEUTRAL" + + +def _htf_confirmation_score( + *, + alignment: str, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + trend_consistency: float | None, + trend_efficiency: float | None, +) -> float: + if alignment == "AGAINST": + return 0.0 + + if alignment == "UNKNOWN": + return 0.5 + + if alignment == "NEUTRAL": + return 0.55 + + score = 0.65 + + if trend_strength == TrendStrength.STRONG: + score += 0.15 + elif trend_strength == TrendStrength.WEAK: + score -= 0.2 + + if trend_quality == TrendQuality.CLEAN: + score += 0.1 + elif trend_quality == TrendQuality.NOISY: + score -= 0.2 + + if trend_consistency is not None: + score += (trend_consistency - 0.5) * 0.2 + + if trend_efficiency is not None: + score += (trend_efficiency - 0.3) * 0.15 + + return max(0.0, min(1.0, score)) \ No newline at end of file diff --git a/app/src/trading/market_analysis/indicators/__init__.py b/app/src/trading/market_analysis/indicators/__init__.py new file mode 100644 index 0000000..bc3b2d9 --- /dev/null +++ b/app/src/trading/market_analysis/indicators/__init__.py @@ -0,0 +1,13 @@ +# app/src/trading/market_analysis/indicators/__init__.py + +from __future__ import annotations + +from src.trading.market_analysis.indicators.trend import ema +from src.trading.market_analysis.indicators.volatility import atr +from src.trading.market_analysis.indicators.momentum import rsi + +__all__ = [ + "ema", + "atr", + "rsi", +] \ No newline at end of file diff --git a/app/src/trading/market_analysis/indicators/momentum.py b/app/src/trading/market_analysis/indicators/momentum.py new file mode 100644 index 0000000..c834965 --- /dev/null +++ b/app/src/trading/market_analysis/indicators/momentum.py @@ -0,0 +1,218 @@ +# app/src/trading/market_analysis/indicators/momentum.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + MomentumState, + TrendDirection, +) + + +def rsi(values: list[float], period: int = 14) -> float | None: + if period <= 0 or len(values) < period + 1: + return None + + gains: list[float] = [] + losses: list[float] = [] + + recent = values[-(period + 1):] + + for previous, current in zip(recent, recent[1:]): + change = current - previous + + if change > 0: + gains.append(change) + losses.append(0.0) + else: + gains.append(0.0) + losses.append(abs(change)) + + average_gain = sum(gains) / period + average_loss = sum(losses) / period + + if average_loss == 0: + return 100.0 + + rs = average_gain / average_loss + return 100 - (100 / (1 + rs)) + + +def recent_change_percent( + *, + closes: list[float], + window: int, +) -> float | None: + if window <= 0 or len(closes) < window + 1: + return None + + first_price = closes[-(window + 1)] + last_price = closes[-1] + + if first_price <= 0: + return None + + return ((last_price - first_price) / first_price) * 100 + + +def momentum_breakout_state( + *, + closes: list[float], + momentum_window: int, + momentum_decay_window: int, + breakout_lookback: int, + momentum_change_threshold_percent: float, + momentum_decay_threshold_percent: float, + breakout_distance_threshold_percent: float, +) -> tuple[ + MomentumState, + TrendDirection, + float | None, + float | None, + float | None, + float | None, + str | None, +]: + if len(closes) < max(momentum_window + 1, breakout_lookback + 1): + return ( + MomentumState.UNKNOWN, + TrendDirection.UNKNOWN, + None, + None, + None, + None, + "NOT_ENOUGH_DATA", + ) + + first_price = closes[-(momentum_window + 1)] + last_price = closes[-1] + + if first_price <= 0 or last_price <= 0: + return ( + MomentumState.UNKNOWN, + TrendDirection.UNKNOWN, + None, + None, + None, + None, + "INVALID_PRICE", + ) + + momentum_change_percent = ((last_price - first_price) / first_price) * 100 + abs_change = abs(momentum_change_percent) + + recent_change_value = recent_change_percent( + closes=closes, + window=momentum_decay_window, + ) + + recent_abs_change = ( + abs(recent_change_value) + if recent_change_value is not None + else None + ) + + if ( + momentum_change_percent >= momentum_change_threshold_percent + and recent_change_value is not None + and recent_change_value > momentum_decay_threshold_percent + ): + momentum_direction = TrendDirection.UP + + elif ( + momentum_change_percent <= -momentum_change_threshold_percent + and recent_change_value is not None + and recent_change_value < -momentum_decay_threshold_percent + ): + momentum_direction = TrendDirection.DOWN + + else: + momentum_direction = TrendDirection.FLAT + + if momentum_direction == TrendDirection.FLAT: + if recent_abs_change is not None: + momentum_strength = min( + recent_abs_change / momentum_decay_threshold_percent, + 3.0, + ) + else: + momentum_strength = 0.0 + else: + momentum_strength = min( + abs_change / momentum_change_threshold_percent, + 3.0, + ) + + lookback_window = closes[-(breakout_lookback + 1):-1] + previous_high = max(lookback_window) + previous_low = min(lookback_window) + + if previous_high <= 0 or previous_low <= 0: + return ( + MomentumState.UNKNOWN, + TrendDirection.UNKNOWN, + momentum_change_percent, + momentum_strength, + None, + None, + "INVALID_BREAKOUT_LEVEL", + ) + + if last_price > previous_high: + breakout_distance_percent = ((last_price - previous_high) / previous_high) * 100 + + if breakout_distance_percent >= breakout_distance_threshold_percent: + return ( + MomentumState.BREAKOUT_UP, + TrendDirection.UP, + momentum_change_percent, + momentum_strength, + previous_high, + breakout_distance_percent, + "PRICE_ABOVE_LOOKBACK_HIGH", + ) + + if last_price < previous_low: + breakout_distance_percent = ((previous_low - last_price) / previous_low) * 100 + + if breakout_distance_percent >= breakout_distance_threshold_percent: + return ( + MomentumState.BREAKOUT_DOWN, + TrendDirection.DOWN, + momentum_change_percent, + momentum_strength, + previous_low, + breakout_distance_percent, + "PRICE_BELOW_LOOKBACK_LOW", + ) + + if momentum_direction == TrendDirection.UP: + return ( + MomentumState.MOMENTUM_UP, + TrendDirection.UP, + momentum_change_percent, + momentum_strength, + None, + None, + "FAST_UP_MOVE", + ) + + if momentum_direction == TrendDirection.DOWN: + return ( + MomentumState.MOMENTUM_DOWN, + TrendDirection.DOWN, + momentum_change_percent, + momentum_strength, + None, + None, + "FAST_DOWN_MOVE", + ) + + return ( + MomentumState.NONE, + TrendDirection.FLAT, + momentum_change_percent, + momentum_strength, + None, + None, + "NO_SIGNIFICANT_MOMENTUM", + ) \ No newline at end of file diff --git a/app/src/trading/market_analysis/indicators/trend.py b/app/src/trading/market_analysis/indicators/trend.py new file mode 100644 index 0000000..bd27422 --- /dev/null +++ b/app/src/trading/market_analysis/indicators/trend.py @@ -0,0 +1,243 @@ +# app/src/trading/market_analysis/indicators/trend.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + TrendDirection, + TrendQuality, + TrendStrength, +) + + +def ema(values: list[float], period: int) -> float | None: + if period <= 0 or len(values) < period: + return None + + multiplier = 2 / (period + 1) + current = sum(values[:period]) / period + + for value in values[period:]: + current = (value - current) * multiplier + current + + return current + + +def trend_gap_percent_value( + *, + ema_fast: float, + ema_slow: float, +) -> float | None: + if ema_slow <= 0: + return None + + return ((ema_fast - ema_slow) / ema_slow) * 100 + + +def ema_slope_percent( + *, + closes: list[float], + period: int, + window: int, +) -> float | None: + required = period + window + 5 + + if len(closes) < required: + return None + + current_ema = ema(closes, period) + previous_ema = ema(closes[:-window], period) + + if ( + current_ema is None + or previous_ema is None + or previous_ema <= 0 + ): + return None + + return ((current_ema - previous_ema) / previous_ema) * 100 + + +def classify_trend( + *, + ema_fast: float, + ema_slow: float, + ema_fast_slope_percent: float | None = None, + ema_slow_slope_percent: float | None = None, + fast_slope_threshold_percent: float, + slow_slope_threshold_percent: float, + trend_direction_gap_threshold_percent: float, +) -> TrendDirection: + gap_percent = trend_gap_percent_value( + ema_fast=ema_fast, + ema_slow=ema_slow, + ) + + if gap_percent is None: + return TrendDirection.UNKNOWN + + fast_slope = ema_fast_slope_percent or 0.0 + slow_slope = ema_slow_slope_percent or 0.0 + + fast_up = fast_slope >= fast_slope_threshold_percent + fast_down = fast_slope <= -fast_slope_threshold_percent + + slow_up = slow_slope >= slow_slope_threshold_percent + slow_down = slow_slope <= -slow_slope_threshold_percent + + if gap_percent >= trend_direction_gap_threshold_percent: + if fast_down and slow_down: + return TrendDirection.FLAT + return TrendDirection.UP + + if gap_percent <= -trend_direction_gap_threshold_percent: + if fast_up and slow_up: + return TrendDirection.FLAT + return TrendDirection.DOWN + + if fast_up and slow_up: + return TrendDirection.UP + + if fast_down and slow_down: + return TrendDirection.DOWN + + return TrendDirection.FLAT + + +def classify_trend_strength( + *, + trend_gap_percent: float | None, + weak_threshold_percent: float, + strong_threshold_percent: float, +) -> TrendStrength: + if trend_gap_percent is None: + return TrendStrength.UNKNOWN + + gap = abs(trend_gap_percent) + + if gap < weak_threshold_percent: + return TrendStrength.WEAK + + if gap < strong_threshold_percent: + return TrendStrength.NORMAL + + return TrendStrength.STRONG + + +def trend_consistency( + *, + closes: list[float], + trend: TrendDirection, + trend_consistency_window: int, +) -> float | None: + if len(closes) < 2: + return None + + window = closes[-trend_consistency_window:] + + if len(window) < 2: + return None + + up_moves = 0 + down_moves = 0 + + for previous_price, current_price in zip(window, window[1:]): + if current_price > previous_price: + up_moves += 1 + elif current_price < previous_price: + down_moves += 1 + + total_moves = max(1, len(window) - 1) + + if trend == TrendDirection.UP: + return up_moves / total_moves + + if trend == TrendDirection.DOWN: + return down_moves / total_moves + + return None + + +def trend_efficiency( + *, + closes: list[float], + trend_consistency_window: int, +) -> float | None: + window = closes[-trend_consistency_window:] + + if len(window) < 2: + return None + + net_move = abs(window[-1] - window[0]) + total_move = 0.0 + + for previous_price, current_price in zip(window, window[1:]): + total_move += abs(current_price - previous_price) + + if total_move <= 0: + return None + + return net_move / total_move + + +def ema_distance_atr_ratio( + *, + ema_fast: float, + ema_slow: float, + atr_value: float, +) -> float | None: + if atr_value <= 0: + return None + + return abs(ema_fast - ema_slow) / atr_value + + +def classify_trend_quality( + *, + trend_consistency: float | None, + trend_efficiency: float | None, + ema_distance_atr_ratio: float | None, + candle_noise_score: float | None, + price_position_score: float | None, + trend_strength: TrendStrength, + min_clean_candle_score: float, + min_price_position_score: float, +) -> TrendQuality: + if trend_consistency is None: + return TrendQuality.UNKNOWN + + if trend_strength == TrendStrength.WEAK: + return TrendQuality.NOISY + + if ( + candle_noise_score is not None + and candle_noise_score < min_clean_candle_score + ): + return TrendQuality.NOISY + + if ( + price_position_score is not None + and price_position_score < min_price_position_score + ): + return TrendQuality.NOISY + + if trend_efficiency is not None and trend_efficiency < 0.28: + return TrendQuality.NOISY + + # Сжатые EMA сами по себе не означают шум. + # После флэта хороший вход часто начинается именно из сжатия. + # Поэтому качество тренда не понижаем только из-за EMA compression. + if ( + ema_distance_atr_ratio is not None + and ema_distance_atr_ratio < 0.25 + and trend_efficiency is not None + and trend_efficiency < 0.25 + ): + return TrendQuality.NOISY + + if trend_consistency >= 0.68: + return TrendQuality.CLEAN + + if trend_consistency >= 0.55: + return TrendQuality.NORMAL + + return TrendQuality.NOISY \ No newline at end of file diff --git a/app/src/trading/market_analysis/indicators/volatility.py b/app/src/trading/market_analysis/indicators/volatility.py new file mode 100644 index 0000000..d4c8de1 --- /dev/null +++ b/app/src/trading/market_analysis/indicators/volatility.py @@ -0,0 +1,129 @@ +# app/src/trading/market_analysis/indicators/volatility.py + +from __future__ import annotations + +from collections.abc import Sequence + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.integrations.exchange.models import Kline +from src.trading.market_analysis.models import VolatilityState + + +def atr(candles: list[Kline], period: int = 14) -> float | None: + if period <= 0 or len(candles) < period + 1: + return None + + true_ranges: list[float] = [] + + for previous, current in zip(candles, candles[1:]): + high_low = current.high_price - current.low_price + high_close = abs(current.high_price - previous.close_price) + low_close = abs(current.low_price - previous.close_price) + + true_ranges.append(max(high_low, high_close, low_close)) + + if len(true_ranges) < period: + return None + + recent = true_ranges[-period:] + return sum(recent) / period + + +def atr_percent_baseline( + *, + candles: Sequence[Kline], + close_price: float, + atr_period: int, + atr_baseline_window: int, +) -> float | None: + if close_price <= 0: + return None + + values: list[float] = [] + window: list[Kline] = list(candles[-atr_baseline_window:]) + + for index in range(atr_period, len(window) + 1): + part: list[Kline] = window[:index] + atr_value = atr(list(part), atr_period) + + if atr_value is None: + continue + + close = getattr(part[-1], "close_price", None) + + if close is None or close <= 0: + continue + + values.append((atr_value / close) * 100) + + if not values: + return None + + values.sort() + middle = len(values) // 2 + + if len(values) % 2 == 1: + return values[middle] + + return (values[middle - 1] + values[middle]) / 2 + + +def adaptive_threshold( + *, + atr_percent: NumericLike | None, + multiplier: NumericLike, + minimum: NumericLike, +) -> float: + atr_value = safe_float(atr_percent) + multiplier_value = safe_float(multiplier) + minimum_value = safe_float(minimum) or 0.0 + + if atr_value is None or atr_value <= 0 or multiplier_value is None: + return minimum_value + + return max(minimum_value, atr_value * multiplier_value) + + +def classify_volatility( + *, + atr_percent: NumericLike, + volatility_ratio: NumericLike | None, + htf_volatility_ratio: NumericLike | None = None, + low_volatility_atr_percent: NumericLike = 0.05, + high_volatility_atr_percent: NumericLike = 1.8, +) -> VolatilityState: + atr_value = safe_float(atr_percent) + + if atr_value is None or atr_value <= 0: + return VolatilityState.UNKNOWN + + local_ratio = safe_float(volatility_ratio) + htf_ratio = safe_float(htf_volatility_ratio) + + if htf_ratio is not None: + if htf_ratio > 1.8 and (local_ratio is None or local_ratio > 1.1): + return VolatilityState.HIGH + + if htf_ratio < 0.55 and (local_ratio is None or local_ratio < 0.85): + return VolatilityState.LOW + + if local_ratio is None: + low_value = safe_float(low_volatility_atr_percent) or 0.05 + high_value = safe_float(high_volatility_atr_percent) or 1.8 + + if atr_value < low_value: + return VolatilityState.LOW + + if atr_value > high_value: + return VolatilityState.HIGH + + return VolatilityState.NORMAL + + if local_ratio < 0.55: + return VolatilityState.LOW + + if local_ratio > 1.8: + return VolatilityState.HIGH + + return VolatilityState.NORMAL \ No newline at end of file diff --git a/app/src/trading/market_analysis/indicators/volume.py b/app/src/trading/market_analysis/indicators/volume.py new file mode 100644 index 0000000..91b6de6 --- /dev/null +++ b/app/src/trading/market_analysis/indicators/volume.py @@ -0,0 +1,25 @@ +# app/src/trading/market_analysis/indicators/volume.py + +from __future__ import annotations + + +def average_volume(values: list[float], period: int) -> float | None: + if period <= 0 or len(values) < period: + return None + + recent = values[-period:] + return sum(recent) / period + + +def volume_ratio( + *, + current_volume: float | None, + average_volume_value: float | None, +) -> float | None: + if current_volume is None or average_volume_value is None: + return None + + if average_volume_value <= 0: + return None + + return current_volume / average_volume_value \ No newline at end of file diff --git a/app/src/trading/market_analysis/indicators.py b/app/src/trading/market_analysis/indicators_legacy.py similarity index 100% rename from app/src/trading/market_analysis/indicators.py rename to app/src/trading/market_analysis/indicators_legacy.py diff --git a/app/src/trading/market_analysis/models.py b/app/src/trading/market_analysis/models.py index c9f5403..9f88c52 100644 --- a/app/src/trading/market_analysis/models.py +++ b/app/src/trading/market_analysis/models.py @@ -1,5 +1,5 @@ # app/src/trading/market_analysis/models.py - + from __future__ import annotations from dataclasses import dataclass @@ -17,6 +17,13 @@ class MarketState(StrEnum): UNKNOWN = "UNKNOWN" +class MarketStructure(StrEnum): + HH_HL = "HH_HL" + LH_LL = "LH_LL" + MIXED = "MIXED" + UNKNOWN = "UNKNOWN" + + class TrendDirection(StrEnum): UP = "UP" DOWN = "DOWN" @@ -79,15 +86,51 @@ class EntryTimingState(StrEnum): UNKNOWN = "UNKNOWN" +@dataclass(slots=True) +class HtfContext: + # Старший таймфрейм. + interval: str | None = None + + # Состояние старшего рынка. + market_state: MarketState | None = None + trend: TrendDirection | None = None + trend_strength: TrendStrength | None = None + trend_quality: TrendQuality | None = None + market_phase: MarketPhase | None = None + + # Волатильность старшего таймфрейма. + volatility: VolatilityState | None = None + atr_percent: float | None = None + atr_percent_baseline: float | None = None + volatility_ratio: float | None = None + + # Подтверждение локального направления старшим ТФ. + alignment: str | None = None + confirmation_score: float | None = None + reason: str | None = None + + @dataclass(slots=True) class MarketAnalysisResult: + # Основное symbol: str interval: str + candles_count: int + reason: str + is_trade_allowed: bool + payload: JsonDict + # Основное состояние рынка state: MarketState trend: TrendDirection volatility: VolatilityState + trend_strength: TrendStrength + trend_quality: TrendQuality + market_phase: MarketPhase + market_structure: MarketStructure + market_structure_reason: str + # Базовые индикаторы close_price: float | None ema_fast: float | None ema_slow: float | None @@ -95,46 +138,66 @@ class MarketAnalysisResult: atr_percent: float | None rsi: float | None - candles_count: int - reason: str - is_trade_allowed: bool - - payload: JsonDict - - trend_strength: TrendStrength - trend_quality: TrendQuality - market_phase: MarketPhase - + # Метрики тренда trend_gap_percent: float | None trend_consistency: float | None trend_efficiency: float | None + trend_quality_score: float | None ema_distance_atr_ratio: float | None + ema_fast_slope_percent: float | None + ema_slow_slope_percent: float | None + # EMA distance / entry timing + ema_distance_state: EmaDistanceState + entry_timing_state: EntryTimingState + entry_timing_reason: str | None + + # Фаза рынка phase_direction: TrendDirection phase_change_percent: float | None + phase_direction_consistency: float | None phase_reason: str | None - ema_fast_slope_percent: float | None = None - ema_slow_slope_percent: float | None = None + # Текущая свеча / интервал + current_interval_change_percent: float | None + current_interval_direction: TrendDirection + current_interval_label: str - phase_direction_consistency: float | None = None + # Momentum / Breakout + momentum_state: MomentumState + momentum_direction: TrendDirection + momentum_change_percent: float | None + momentum_strength: float | None + breakout_level: float | None + breakout_distance_percent: float | None + breakout_reason: str | None - momentum_state: MomentumState | None = None - momentum_direction: TrendDirection | None = None - momentum_change_percent: float | None = None - momentum_strength: float | None = None - - breakout_level: float | None = None - breakout_distance_percent: float | None = None - breakout_reason: str | None = None + # Старший таймфрейм. + # Новый сгруппированный объект. Пока можно использовать параллельно + # со старыми flat-полями ниже, чтобы не ломать result.py/snapshot.py сразу. + htf: HtfContext | None = None + # Старые flat HTF-поля оставлены для совместимости. + # Позже их можно удалить после перевода result.py/snapshot.py/formatter.py на htf. htf_interval: str | None = None htf_atr_percent: float | None = None htf_atr_percent_baseline: float | None = None htf_volatility_ratio: float | None = None htf_volatility: VolatilityState | None = None - trend_quality_score: float | None = None - ema_distance_state: EmaDistanceState | None = None - entry_timing_state: EntryTimingState | None = None - entry_timing_reason: str | None = None \ No newline at end of file + htf_market_state: MarketState | None = None + htf_trend: TrendDirection | None = None + htf_trend_strength: TrendStrength | None = None + htf_trend_quality: TrendQuality | None = None + htf_market_phase: MarketPhase | None = None + htf_alignment: str | None = None + htf_confirmation_score: float | None = None + htf_reason: str | None = None + + # Общая оценка рынка 0..100. + market_score: int | None = None + market_score_label: str | None = None + + # Направленные оценки входа 0..100. + market_long_score: int | None = None + market_short_score: int | None = None \ No newline at end of file diff --git a/app/src/trading/market_analysis/payload.py b/app/src/trading/market_analysis/payload.py new file mode 100644 index 0000000..7990d2b --- /dev/null +++ b/app/src/trading/market_analysis/payload.py @@ -0,0 +1,190 @@ +# app/src/trading/market_analysis/payload.py + +from __future__ import annotations + +from src.core.numbers import get_value, safe_round +from src.core.types import JsonDict +from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, + MarketPhase, + MarketState, + MarketStructure, + MomentumState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def build_market_analysis_payload( + *, + symbol: str, + interval: str, + state: MarketState, + trend: TrendDirection, + volatility: VolatilityState, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + phase_direction: TrendDirection, + phase_change_percent: float | None, + phase_direction_consistency: float | None, + current_interval_change_percent: float | None, + current_interval_direction: TrendDirection, + current_interval_label: str, + phase_reason: str | None, + market_structure: MarketStructure, + market_structure_reason: str | None, + momentum_state: MomentumState, + momentum_direction: TrendDirection, + momentum_change_percent: float | None, + momentum_strength: float | None, + breakout_level: float | None, + breakout_distance_percent: float | None, + breakout_reason: str | None, + trend_gap_percent: float | None, + ema_fast_slope_percent: float | None, + ema_slow_slope_percent: float | None, + trend_consistency: float | None, + trend_efficiency: float | None, + trend_quality_score_value: float | None, + ema_distance_atr_ratio: float | None, + ema_distance_state: EmaDistanceState, + entry_timing_state: EntryTimingState, + entry_timing_reason: str | None, + candle_noise_score: float | None, + price_position_score: float | None, + close_price: float, + ema_fast_period: int, + ema_slow_period: int, + ema_fast: float, + ema_slow: float, + atr_period: int, + atr_value: float, + atr_percent: float, + atr_percent_baseline: float | None, + volatility_ratio: float | None, + rsi_period: int, + rsi_value: float | None, + rsi_overbought: float, + rsi_oversold: float, + candles_count: int, + is_trade_allowed: bool, + htf_context: JsonDict | None, + htf_trend_context: JsonDict | None, + market_score: int | None = None, + market_score_label: str | None = None, + market_long_score: int | None = None, + market_short_score: int | None = None, + last_closed_candle_change_percent: float | None = None, + last_closed_candle_direction: TrendDirection | None = None, +) -> JsonDict: + # HTF-контексты могут быть пустыми, если старший ТФ временно недоступен. + htf_context = htf_context or {} + htf_trend_context = htf_trend_context or {} + + return { + # ---------- Base ---------- + "symbol": symbol, + "interval": interval, + "candles_count": candles_count, + "is_trade_allowed": is_trade_allowed, + + # ---------- Market ---------- + "market_state": get_value(state), + "market_score": market_score, + "market_score_label": market_score_label, + "market_long_score": market_long_score, + "market_short_score": market_short_score, + + # ---------- Trend ---------- + "trend": get_value(trend), + "market_trend_strength": get_value(trend_strength), + "market_trend_quality": get_value(trend_quality), + "market_trend_gap_percent": safe_round(trend_gap_percent, 5), + "market_trend_consistency": safe_round(trend_consistency, 3), + "market_trend_efficiency": safe_round(trend_efficiency, 3), + "trend_quality_score": safe_round(trend_quality_score_value, 3), + + # ---------- Volatility ---------- + "volatility": get_value(volatility), + "volatility_ratio": safe_round(volatility_ratio, 4), + + # ---------- Phase ---------- + "market_phase": get_value(market_phase), + "market_phase_direction": get_value(phase_direction), + "market_phase_change_percent": safe_round(phase_change_percent, 5), + "market_phase_direction_consistency": safe_round(phase_direction_consistency, 3), + "market_phase_reason": phase_reason, + + # ---------- Current / Last Candle ---------- + "current_interval_change_percent": safe_round(current_interval_change_percent, 5), + "current_interval_direction": get_value(current_interval_direction), + "current_interval_label": current_interval_label, + "last_closed_candle_change_percent": safe_round(last_closed_candle_change_percent, 5), + "last_closed_candle_direction": get_value(last_closed_candle_direction), + + # ---------- Structure ---------- + "market_structure": get_value(market_structure), + "market_structure_reason": market_structure_reason, + + # ---------- Momentum / Breakout ---------- + "momentum_state": get_value(momentum_state), + "momentum_direction": get_value(momentum_direction), + "momentum_change_percent": safe_round(momentum_change_percent, 5), + "momentum_strength": safe_round(momentum_strength, 3), + "breakout_level": breakout_level, + "breakout_distance_percent": safe_round(breakout_distance_percent, 5), + "breakout_reason": breakout_reason, + + # ---------- EMA ---------- + "ema_fast_period": ema_fast_period, + "ema_slow_period": ema_slow_period, + "ema_fast": safe_round(ema_fast, 8), + "ema_slow": safe_round(ema_slow, 8), + "ema_fast_slope_percent": safe_round(ema_fast_slope_percent, 5), + "ema_slow_slope_percent": safe_round(ema_slow_slope_percent, 5), + "ema_distance_atr_ratio": safe_round(ema_distance_atr_ratio, 3), + "ema_distance_state": get_value(ema_distance_state), + + # ---------- Entry Timing ---------- + "entry_timing_state": get_value(entry_timing_state), + "entry_timing_reason": entry_timing_reason, + + # ---------- Candle / Price Quality ---------- + "candle_noise_score": safe_round(candle_noise_score, 3), + "price_position_score": safe_round(price_position_score, 3), + "close_price": safe_round(close_price, 8), + + # ---------- ATR ---------- + "atr_period": atr_period, + "atr": safe_round(atr_value, 8), + "atr_percent": safe_round(atr_percent, 4), + "atr_percent_baseline": safe_round(atr_percent_baseline, 4), + + # ---------- RSI ---------- + "rsi_period": rsi_period, + "rsi": safe_round(rsi_value, 2), + "rsi_overbought": rsi_overbought, + "rsi_oversold": rsi_oversold, + + # ---------- HTF Volatility ---------- + "htf_interval": htf_context.get("htf_interval"), + "htf_atr_percent": htf_context.get("htf_atr_percent"), + "htf_atr_percent_baseline": htf_context.get("htf_atr_percent_baseline"), + "htf_volatility_ratio": htf_context.get("htf_volatility_ratio"), + "htf_volatility": htf_context.get("htf_volatility"), + "htf_volatility_reason": htf_context.get("htf_reason"), + + # ---------- HTF Trend ---------- + "htf_market_state": htf_trend_context.get("htf_market_state"), + "htf_trend": htf_trend_context.get("htf_trend"), + "htf_trend_strength": htf_trend_context.get("htf_trend_strength"), + "htf_trend_quality": htf_trend_context.get("htf_trend_quality"), + "htf_market_phase": htf_trend_context.get("htf_market_phase"), + "htf_alignment": htf_trend_context.get("htf_alignment"), + "htf_confirmation_score": htf_trend_context.get("htf_confirmation_score"), + "htf_reason": htf_trend_context.get("htf_reason"), + } \ No newline at end of file diff --git a/app/src/trading/market_analysis/phase.py b/app/src/trading/market_analysis/phase.py new file mode 100644 index 0000000..02b49d9 --- /dev/null +++ b/app/src/trading/market_analysis/phase.py @@ -0,0 +1,139 @@ +# app/src/trading/market_analysis/phase.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + MarketPhase, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def classify_phase_direction( + change_percent: float | None, + *, + threshold_percent: float, +) -> TrendDirection: + if change_percent is None: + return TrendDirection.UNKNOWN + + if change_percent >= threshold_percent: + return TrendDirection.UP + + if change_percent <= -threshold_percent: + return TrendDirection.DOWN + + return TrendDirection.FLAT + + +def phase_direction_consistency( + *, + closes: list[float], + phase_direction: TrendDirection, + phase_window: int, +) -> float | None: + window = closes[-(phase_window + 1):] + + if len(window) < 2: + return None + + up_moves = 0 + down_moves = 0 + + for previous_price, current_price in zip(window, window[1:]): + if current_price > previous_price: + up_moves += 1 + elif current_price < previous_price: + down_moves += 1 + + total_moves = max(1, len(window) - 1) + + if phase_direction == TrendDirection.UP: + return up_moves / total_moves + + if phase_direction == TrendDirection.DOWN: + return down_moves / total_moves + + return None + + +def is_counter_trend_move( + *, + trend: TrendDirection, + phase_direction: TrendDirection, +) -> bool: + if trend == TrendDirection.UP: + return phase_direction == TrendDirection.DOWN + + if trend == TrendDirection.DOWN: + return phase_direction == TrendDirection.UP + + return False + + +def classify_market_phase( + *, + trend: TrendDirection, + volatility: VolatilityState, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + rsi_value: float | None, + phase_direction: TrendDirection, + phase_change_percent: float | None, + phase_direction_consistency: float | None, + pullback_min_change_percent: float, + pullback_min_direction_consistency: float, +) -> tuple[MarketPhase, str]: + if volatility == VolatilityState.LOW: + return MarketPhase.SQUEEZE, "LOW_VOLATILITY_SQUEEZE" + + if trend == TrendDirection.FLAT: + return MarketPhase.RANGE, "FLAT_TREND_RANGE" + + if trend not in {TrendDirection.UP, TrendDirection.DOWN}: + return MarketPhase.UNKNOWN, "UNKNOWN_TREND" + + if trend_strength == TrendStrength.WEAK: + return MarketPhase.RANGE, "WEAK_TREND_RANGE" + + if is_counter_trend_move( + trend=trend, + phase_direction=phase_direction, + ): + if ( + phase_change_percent is not None + and abs(phase_change_percent) >= pullback_min_change_percent + and phase_direction_consistency is not None + and phase_direction_consistency >= pullback_min_direction_consistency + ): + return MarketPhase.PULLBACK, "COUNTER_TREND_MOVE_CONFIRMED" + + return MarketPhase.RANGE, "COUNTER_TREND_MOVE_TOO_WEAK" + + if ( + trend == TrendDirection.UP + and rsi_value is not None + and rsi_value < 45 + and phase_direction == TrendDirection.DOWN + and phase_change_percent is not None + and abs(phase_change_percent) >= pullback_min_change_percent + and phase_direction_consistency is not None + and phase_direction_consistency >= pullback_min_direction_consistency + ): + return MarketPhase.PULLBACK, "UPTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE" + + if ( + trend == TrendDirection.DOWN + and rsi_value is not None + and rsi_value > 55 + and phase_direction == TrendDirection.UP + and phase_change_percent is not None + and abs(phase_change_percent) >= pullback_min_change_percent + and phase_direction_consistency is not None + and phase_direction_consistency >= pullback_min_direction_consistency + ): + return MarketPhase.PULLBACK, "DOWNTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE" + + return MarketPhase.IMPULSE, "WITH_TREND_OR_NEUTRAL_MOVE" \ No newline at end of file diff --git a/app/src/trading/market_analysis/quality.py b/app/src/trading/market_analysis/quality.py new file mode 100644 index 0000000..050372d --- /dev/null +++ b/app/src/trading/market_analysis/quality.py @@ -0,0 +1,81 @@ +# app/src/trading/market_analysis/quality.py + +from __future__ import annotations + +from collections.abc import Sequence + +from src.integrations.exchange.models import Kline +from src.trading.market_analysis.models import TrendDirection + + +def candle_noise_score( + candles: Sequence[Kline], + *, + candle_noise_window: int, + min_clean_body_ratio: float, +) -> float | None: + window = candles[-candle_noise_window:] + + if not window: + return None + + clean_count = 0 + total_count = 0 + + for candle in window: + high = getattr(candle, "high_price", None) + low = getattr(candle, "low_price", None) + open_price = getattr(candle, "open_price", None) + close_price = getattr(candle, "close_price", None) + + if ( + high is None + or low is None + or open_price is None + or close_price is None + or high <= low + ): + continue + + candle_range = high - low + body = abs(close_price - open_price) + body_ratio = body / candle_range + + total_count += 1 + + if body_ratio >= min_clean_body_ratio: + clean_count += 1 + + if total_count == 0: + return None + + return clean_count / total_count + + +def price_position_score( + *, + closes: list[float], + ema_fast: float, + trend: TrendDirection, + price_position_window: int, +) -> float | None: + window = closes[-price_position_window:] + + if not window: + return None + + valid_count = 0 + + for close_price in window: + if trend == TrendDirection.UP: + if close_price > ema_fast: + valid_count += 1 + + elif trend == TrendDirection.DOWN: + if close_price < ema_fast: + valid_count += 1 + + else: + return None + + return valid_count / len(window) \ No newline at end of file diff --git a/app/src/trading/market_analysis/reason.py b/app/src/trading/market_analysis/reason.py new file mode 100644 index 0000000..18d28be --- /dev/null +++ b/app/src/trading/market_analysis/reason.py @@ -0,0 +1,135 @@ +# app/src/trading/market_analysis/reason.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, + MarketPhase, + MarketState, + MomentumState, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def build_market_reason( + *, + state: MarketState, + volatility: VolatilityState, + atr_percent: float, + rsi_value: float | None, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + momentum_state: MomentumState, + candle_noise_score: float | None, + price_position_score: float | None, + ema_distance_state: EmaDistanceState, + entry_timing_state: EntryTimingState, + min_clean_candle_score: float, + min_price_position_score: float, + rsi_overbought: float = 72.0, + rsi_oversold: float = 28.0, +) -> str: + reasons: list[str] = [] + + def add(text: str) -> None: + # Не даём одинаковым причинам дублироваться в итоговой строке. + if text and text not in reasons: + reasons.append(text) + + if state == MarketState.TREND_UP: + add("Рынок растёт") + elif state == MarketState.TREND_DOWN: + add("Рынок снижается") + elif state == MarketState.RANGE: + add("Рынок во флэте") + elif state == MarketState.HIGH_VOLATILITY: + add("Рынок слишком волатилен") + elif state == MarketState.LOW_VOLATILITY: + add("Рынок малоподвижен") + else: + add("Состояние рынка не определено") + + if trend_strength == TrendStrength.STRONG: + add("Сильный тренд") + elif trend_strength == TrendStrength.NORMAL: + add("Нормальный тренд") + elif trend_strength == TrendStrength.WEAK: + add("Слабый тренд") + + if trend_quality == TrendQuality.CLEAN: + add("Движение чистое") + elif trend_quality == TrendQuality.NORMAL: + add("Нормальное качество тренда") + elif trend_quality == TrendQuality.NOISY: + add("Движение шумное") + + if market_phase == MarketPhase.IMPULSE: + add("Фаза импульса") + elif market_phase == MarketPhase.PULLBACK: + add("Фаза отката") + elif market_phase == MarketPhase.RANGE: + add("Фаза флэта") + elif market_phase == MarketPhase.SQUEEZE: + add("Фаза сжатия") + + if momentum_state == MomentumState.BREAKOUT_UP: + add("Пробой вверх") + elif momentum_state == MomentumState.BREAKOUT_DOWN: + add("Пробой вниз") + elif momentum_state == MomentumState.MOMENTUM_UP: + add("Импульс вверх") + elif momentum_state == MomentumState.MOMENTUM_DOWN: + add("Импульс вниз") + elif momentum_state == MomentumState.NONE: + add("Сильного импульса нет") + + if ema_distance_state == EmaDistanceState.COMPRESSED: + add("EMA сильно сжаты") + elif ema_distance_state == EmaDistanceState.HEALTHY: + add("EMA-дистанция здоровая") + elif ema_distance_state == EmaDistanceState.EXTENDED: + add("Тренд расширен") + elif ema_distance_state == EmaDistanceState.OVEREXTENDED: + add("Тренд перерастянут") + + if entry_timing_state == EntryTimingState.EARLY: + add("Ранняя зона входа") + elif entry_timing_state == EntryTimingState.NORMAL: + add("Тайминг входа нормальный") + elif entry_timing_state == EntryTimingState.LATE: + add("Поздний вход") + elif entry_timing_state == EntryTimingState.CHASING: + add("Вход запрещён: chasing move") + + if rsi_value is not None: + if rsi_value >= rsi_overbought: + add("RSI в зоне перекупленности") + elif rsi_value <= rsi_oversold: + add("RSI в зоне перепроданности") + + if candle_noise_score is not None and candle_noise_score < min_clean_candle_score: + add("Свечи шумные") + + if price_position_score is not None: + if price_position_score >= min_price_position_score: + add("Цена держится по тренду") + else: + add("Цена плохо держится по тренду") + + if volatility == VolatilityState.HIGH: + add("Высокая волатильность") + elif volatility == VolatilityState.LOW: + add("Низкая волатильность") + elif volatility == VolatilityState.NORMAL: + add("Нормальная волатильность") + + if not reasons: + add("Рынок анализируется") + + rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else "" + + return f"{'. '.join(reasons)}. ATR={atr_percent:.2f}%{rsi_text}." \ No newline at end of file diff --git a/app/src/trading/market_analysis/result.py b/app/src/trading/market_analysis/result.py new file mode 100644 index 0000000..9078ea3 --- /dev/null +++ b/app/src/trading/market_analysis/result.py @@ -0,0 +1,178 @@ +# app/src/trading/market_analysis/result.py + +from __future__ import annotations + +from src.core.numbers import safe_float +from src.core.types import JsonDict +from src.trading.market_analysis.htf import ( + safe_market_phase, + safe_market_state, + safe_trend_direction, + safe_trend_quality, + safe_trend_strength, + safe_volatility_state, +) +from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, + MarketAnalysisResult, + MarketPhase, + MarketState, + MarketStructure, + MomentumState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def build_market_analysis_result( + *, + symbol: str, + interval: str, + state: MarketState, + trend: TrendDirection, + volatility: VolatilityState, + close_price: float, + ema_fast: float, + ema_slow: float, + atr_value: float, + atr_percent: float, + rsi_value: float | None, + candles_count: int, + reason: str, + is_trade_allowed: bool, + payload: JsonDict, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + market_structure: MarketStructure, + market_structure_reason: str, + trend_gap_percent: float | None, + trend_consistency: float | None, + trend_efficiency: float | None, + ema_distance_atr_ratio: float | None, + phase_direction: TrendDirection, + phase_change_percent: float | None, + phase_reason: str | None, + ema_fast_slope_percent: float | None, + ema_slow_slope_percent: float | None, + phase_direction_consistency: float | None, + current_interval_change_percent: float | None, + current_interval_direction: TrendDirection, + current_interval_label: str, + momentum_state: MomentumState, + momentum_direction: TrendDirection, + momentum_change_percent: float | None, + momentum_strength: float | None, + breakout_level: float | None, + breakout_distance_percent: float | None, + breakout_reason: str | None, + trend_quality_score_value: float | None, + ema_distance_state: EmaDistanceState, + entry_timing_state: EntryTimingState, + entry_timing_reason: str | None, + htf_interval: str, + htf_context: JsonDict | None, + htf_trend_context: JsonDict | None, + market_score: int | None = None, + market_score_label: str | None = None, + market_long_score: int | None = None, + market_short_score: int | None = None, +) -> MarketAnalysisResult: + # HTF-контексты могут быть пустыми/None, если анализ старшего ТФ + # не выполнился или вернул fallback. Защищаем .get(...) ниже. + htf_context = htf_context or {} + htf_trend_context = htf_trend_context or {} + + return MarketAnalysisResult( + symbol=symbol, + interval=interval, + state=state, + trend=trend, + volatility=volatility, + close_price=close_price, + ema_fast=ema_fast, + ema_slow=ema_slow, + atr=atr_value, + atr_percent=atr_percent, + rsi=rsi_value, + candles_count=candles_count, + reason=reason, + is_trade_allowed=is_trade_allowed, + payload=payload, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + market_structure=market_structure, + market_structure_reason=market_structure_reason, + trend_gap_percent=trend_gap_percent, + trend_consistency=trend_consistency, + trend_efficiency=trend_efficiency, + ema_distance_atr_ratio=ema_distance_atr_ratio, + phase_direction=phase_direction, + phase_change_percent=phase_change_percent, + phase_reason=phase_reason, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_slow_slope_percent=ema_slow_slope_percent, + phase_direction_consistency=phase_direction_consistency, + current_interval_change_percent=current_interval_change_percent, + current_interval_direction=current_interval_direction, + current_interval_label=current_interval_label, + momentum_state=momentum_state, + momentum_direction=momentum_direction, + momentum_change_percent=momentum_change_percent, + momentum_strength=momentum_strength, + breakout_level=breakout_level, + breakout_distance_percent=breakout_distance_percent, + breakout_reason=breakout_reason, + + # HTF volatility context. + htf_interval=str(htf_context.get("htf_interval") or htf_interval), + htf_atr_percent=safe_float(htf_context.get("htf_atr_percent")), + htf_atr_percent_baseline=safe_float( + htf_context.get("htf_atr_percent_baseline") + ), + htf_volatility_ratio=safe_float( + htf_context.get("htf_volatility_ratio") + ), + htf_volatility=safe_volatility_state( + htf_context.get("htf_volatility") + ), + + # Advanced trend quality. + trend_quality_score=trend_quality_score_value, + ema_distance_state=ema_distance_state, + entry_timing_state=entry_timing_state, + entry_timing_reason=entry_timing_reason, + + # HTF trend context. Используем safe_* функции, + # чтобы неожиданные значения не ломали диагностику. + htf_market_state=safe_market_state( + htf_trend_context.get("htf_market_state") + ), + htf_trend=safe_trend_direction( + htf_trend_context.get("htf_trend") + ), + htf_trend_strength=safe_trend_strength( + htf_trend_context.get("htf_trend_strength") + ), + htf_trend_quality=safe_trend_quality( + htf_trend_context.get("htf_trend_quality") + ), + htf_market_phase=safe_market_phase( + htf_trend_context.get("htf_market_phase") + ), + htf_alignment=str(htf_trend_context.get("htf_alignment") or ""), + htf_confirmation_score=safe_float( + htf_trend_context.get("htf_confirmation_score") + ), + htf_reason=str(htf_trend_context.get("htf_reason") or ""), + + # Общая оценка рынка для UI/diagnostics/adaptive sizing. + market_score=market_score, + market_score_label=market_score_label, + market_long_score=market_long_score, + market_short_score=market_short_score, + ) \ No newline at end of file diff --git a/app/src/trading/market_analysis/scoring.py b/app/src/trading/market_analysis/scoring.py new file mode 100644 index 0000000..a3ae364 --- /dev/null +++ b/app/src/trading/market_analysis/scoring.py @@ -0,0 +1,89 @@ +# app/src/trading/market_analysis/scoring.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, + MarketPhase, + MomentumState, +) + + +def trend_quality_score( + *, + trend_consistency: float | None, + trend_efficiency: float | None, + candle_noise_score: float | None, + price_position_score: float | None, +) -> float | None: + values: list[float] = [] + + if trend_consistency is not None: + values.append(trend_consistency) + + if trend_efficiency is not None: + values.append(trend_efficiency) + + if candle_noise_score is not None: + values.append(candle_noise_score) + + if price_position_score is not None: + values.append(price_position_score) + + if not values: + return None + + return sum(values) / len(values) + + +def classify_ema_distance_state( + ema_distance_atr_ratio: float | None, +) -> EmaDistanceState: + if ema_distance_atr_ratio is None: + return EmaDistanceState.UNKNOWN + + if ema_distance_atr_ratio < 0.30: + return EmaDistanceState.COMPRESSED + + if ema_distance_atr_ratio < 1.8: + return EmaDistanceState.HEALTHY + + if ema_distance_atr_ratio < 2.8: + return EmaDistanceState.EXTENDED + + return EmaDistanceState.OVEREXTENDED + + +def classify_entry_timing( + *, + ema_distance_state: EmaDistanceState, + momentum_state: MomentumState, + momentum_strength: float | None, + market_phase: MarketPhase, +) -> tuple[EntryTimingState, str]: + strength = momentum_strength or 0.0 + + if ema_distance_state == EmaDistanceState.OVEREXTENDED: + return EntryTimingState.CHASING, "EMA_OVEREXTENDED" + + if ( + ema_distance_state == EmaDistanceState.EXTENDED + and momentum_state in { + MomentumState.BREAKOUT_UP, + MomentumState.BREAKOUT_DOWN, + } + and strength >= 1.5 + ): + return EntryTimingState.LATE, "BREAKOUT_ALREADY_EXTENDED" + + if market_phase == MarketPhase.PULLBACK: + return EntryTimingState.EARLY, "PULLBACK_ENTRY_ZONE" + + if ema_distance_state == EmaDistanceState.HEALTHY: + return EntryTimingState.NORMAL, "HEALTHY_TREND_DISTANCE" + + if ema_distance_state == EmaDistanceState.COMPRESSED: + return EntryTimingState.UNKNOWN, "EMA_COMPRESSED" + + return EntryTimingState.UNKNOWN, "ENTRY_TIMING_UNKNOWN" \ No newline at end of file diff --git a/app/src/trading/market_analysis/service.py b/app/src/trading/market_analysis/service.py index c2f3758..65104ee 100644 --- a/app/src/trading/market_analysis/service.py +++ b/app/src/trading/market_analysis/service.py @@ -2,52 +2,236 @@ from __future__ import annotations -from collections.abc import Sequence +from enum import StrEnum from src.core.numbers import safe_float -from src.core.types import JsonDict, NumericLike -from src.integrations.exchange.models import Kline from src.integrations.exchange.service import ExchangeService from src.trading.market_analysis.indicators import atr, ema, rsi from src.trading.market_analysis.models import ( - EntryTimingState, EmaDistanceState, + EntryTimingState, MarketAnalysisResult, MarketPhase, MarketState, + MarketStructure, MomentumState, TrendDirection, TrendQuality, TrendStrength, VolatilityState, ) +from src.trading.market_analysis.structure import market_structure +from src.trading.market_analysis.filters import is_trade_allowed as check_trade_allowed +from src.trading.market_analysis.scoring import ( + classify_ema_distance_state, + classify_entry_timing, + trend_quality_score, +) +from src.trading.market_analysis.payload import build_market_analysis_payload +from src.trading.market_analysis.htf import ( + htf_trend_context as build_htf_trend_context, + htf_volatility_context as build_htf_volatility_context, +) +from src.trading.market_analysis.indicators.momentum import ( + momentum_breakout_state, + recent_change_percent, +) +from src.trading.market_analysis.indicators.trend import ( + classify_trend, + classify_trend_quality, + classify_trend_strength, + ema_distance_atr_ratio as calculate_ema_distance_atr_ratio, + ema_slope_percent, + trend_consistency as calculate_trend_consistency, + trend_efficiency as calculate_trend_efficiency, + trend_gap_percent_value, +) +from src.trading.market_analysis.indicators.volatility import ( + adaptive_threshold, + atr_percent_baseline as calculate_atr_percent_baseline, + classify_volatility, +) +from src.trading.market_analysis.phase import ( + classify_market_phase, + classify_phase_direction, + phase_direction_consistency as calculate_phase_direction_consistency, +) +from src.trading.market_analysis.quality import ( + candle_noise_score as calculate_candle_noise_score, + price_position_score as calculate_price_position_score, +) +from src.trading.market_analysis.state import classify_market_state +from src.trading.market_analysis.reason import build_market_reason +from src.trading.market_analysis.unknown import build_unknown_market_analysis_result +from src.trading.market_analysis.result import build_market_analysis_result + + +class EntrySide(StrEnum): + BUY = "BUY" + SELL = "SELL" class MarketAnalysisService: + # Базовые индикаторы _fast_ema_period = 20 _slow_ema_period = 50 - _atr_baseline_window = 50 _atr_period = 14 + _atr_baseline_window = 50 _rsi_period = 14 + + # Минимум свечей нужен, чтобы EMA/ATR/RSI/структура не считались + # на слишком коротком и случайном участке. _min_candles = 60 + + # Волатильность _low_volatility_atr_percent = 0.05 _high_volatility_atr_percent = 1.8 + + # Тренд _ema_fast_slope_window = 3 _ema_slow_slope_window = 5 _trend_consistency_window = 20 + + # Качество свечей / положение цены _candle_noise_window = 12 _min_clean_body_ratio = 0.45 _min_clean_candle_score = 0.55 + _price_position_window = 5 + + # Было 0.8 — это слишком жёстко. + # При 5 свечах требовалось почти идеальное удержание цены по стороне EMA. + # Из-за этого бот пропускал нормальные движения после отката. _min_price_position_score = 0.6 + + # Фаза рынка / откаты _phase_window = 8 _pullback_min_direction_consistency = 0.6 + + # Momentum / breakout + # Было 5 — поздно замечал импульс. + # Для 5m свечей окно 3 быстрее ловит начало движения. _momentum_window = 3 _momentum_decay_window = 2 + + # Было 40 — слишком далеко смотрел назад. + # После локального движения breakout часто не определялся вовремя. _breakout_lookback = 20 - _htf_interval = "15m" + + # Старший таймфрейм + _htf_interval = "1h" _htf_limit = 200 + # Было 0.65 — слишком строго. + # HTF должен защищать от входа против рынка, но не душить нормальный вход. + _min_htf_confirmation_score = 0.55 + + # Структура рынка + _structure_window = 30 + _structure_swing_left = 2 + _structure_swing_right = 2 + + # RSI + # Было 68 / 32 — слишком рано считал рынок перегретым. + # Для трендовой стратегии это мешало входам по сильному движению. + _rsi_overbought = 72.0 + _rsi_oversold = 28.0 + + # Оценка направления входа + _entry_trend_bonus = 12 + _entry_trend_penalty = 25 + _entry_momentum_bonus = 10 + _entry_momentum_penalty = 20 + _entry_structure_bonus = 8 + _entry_structure_penalty = 18 + _entry_current_candle_bonus = 8 + _entry_current_candle_penalty = 10 + _entry_strong_current_move_bonus = 4 + _entry_htf_against_penalty = 20 + + # Общая оценка рынка (market_score) + + _market_score_start = 70 + + _market_state_trend_bonus = 10 + _market_state_range_penalty = 15 + _market_state_high_volatility_penalty = 20 + _market_state_low_volatility_penalty = 20 + _market_state_unknown_penalty = 30 + + _market_trend_bonus = 5 + _market_trend_flat_penalty = 10 + _market_trend_unknown_penalty = 20 + + _market_volatility_normal_bonus = 8 + _market_volatility_high_penalty = 12 + _market_volatility_low_penalty = 10 + _market_volatility_unknown_penalty = 15 + + _market_trend_strength_strong_bonus = 8 + _market_trend_strength_normal_bonus = 4 + _market_trend_strength_weak_penalty = 10 + + _market_trend_quality_clean_bonus = 8 + _market_trend_quality_normal_bonus = 4 + _market_trend_quality_noisy_penalty = 25 + + _market_phase_impulse_bonus = 6 + _market_phase_pullback_penalty = 4 + _market_phase_range_penalty = 10 + _market_phase_squeeze_penalty = 8 + + _market_structure_bonus = 6 + _market_structure_mixed_penalty = 10 + + _market_breakout_bonus = 6 + _market_momentum_bonus = 4 + _market_exhausted_penalty = 8 + _market_flat_momentum_penalty = 4 + + _market_current_candle_large_bonus = 8 + _market_current_candle_medium_bonus = 6 + _market_current_candle_small_bonus = 3 + + _market_ema_healthy_bonus = 5 + _market_ema_compressed_penalty = 8 + _market_ema_extended_penalty = 5 + _market_ema_overextended_penalty = 18 + + _market_entry_normal_bonus = 6 + _market_entry_early_penalty = 3 + _market_entry_late_penalty = 12 + _market_entry_chasing_penalty = 20 + + _market_htf_aligned_bonus = 10 + _market_htf_same_tf_bonus = 5 + _market_htf_neutral_penalty = 4 + _market_htf_against_penalty = 25 + _market_htf_unknown_penalty = 10 + + _market_range_with_htf_bonus = 10 + + _market_htf_confirmation_bonus = 5 + _market_htf_confirmation_penalty = 10 + + _market_trade_not_allowed_penalty = 8 + + # Главная функция анализа рынка. + # + # Последовательность: + # 1. Получаем 5m свечи. + # 2. Считаем EMA / ATR / RSI. + # 3. Считаем адаптивные пороги от ATR. + # 4. Определяем волатильность. + # 5. Определяем направление тренда. + # 6. Определяем силу и качество тренда. + # 7. Определяем momentum / breakout. + # 8. Определяем фазу рынка. + # 9. Определяем тайминг входа. + # 10. Определяем структуру рынка. + # 11. Проверяем старший таймфрейм. + # 12. Решаем, разрешён ли вход. + # 13. Собираем payload для стратегии, UI и журнала. def analyze( self, symbol: str, @@ -62,21 +246,23 @@ class MarketAnalysisService: limit=limit, ) except Exception as exc: - return self._unknown( + return build_unknown_market_analysis_result( symbol=symbol, interval=interval, reason=f"Не удалось получить свечи: {exc}", + htf_interval=self._htf_interval, ) candles = batch.candles closes = [item.close_price for item in candles] if len(candles) < self._min_candles: - return self._unknown( + return build_unknown_market_analysis_result( symbol=batch.symbol, interval=interval, reason="Недостаточно свечей для анализа рынка.", candles_count=len(candles), + htf_interval=self._htf_interval, ) close_price = closes[-1] if closes else None @@ -92,18 +278,21 @@ class MarketAnalysisService: or ema_slow is None or atr_value is None ): - return self._unknown( + return build_unknown_market_analysis_result( symbol=batch.symbol, interval=interval, reason="Недостаточно данных для расчёта EMA / ATR.", candles_count=len(candles), + htf_interval=self._htf_interval, ) atr_percent = (atr_value / close_price) * 100 - atr_percent_baseline = self._atr_percent_baseline( + atr_percent_baseline = calculate_atr_percent_baseline( candles=candles, close_price=close_price, + atr_period=self._atr_period, + atr_baseline_window=self._atr_baseline_window, ) volatility_ratio = ( @@ -112,99 +301,105 @@ class MarketAnalysisService: else None ) - htf_context = self._htf_volatility_context( + htf_context = build_htf_volatility_context( + self, symbol=batch.symbol, base_interval=interval, ) + # HTF context может быть пустым при ошибке получения старшего ТФ. + htf_context = htf_context or {} + htf_volatility_ratio = safe_float( htf_context.get("htf_volatility_ratio") ) - momentum_threshold_percent = self._adaptive_threshold( + momentum_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.7, minimum=0.08, ) - momentum_decay_threshold_percent = self._adaptive_threshold( + momentum_decay_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.12, minimum=0.03, ) - breakout_distance_threshold_percent = self._adaptive_threshold( + breakout_distance_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.25, minimum=0.04, ) - phase_direction_threshold_percent = self._adaptive_threshold( + phase_direction_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.18, minimum=0.04, ) - pullback_min_change_percent = self._adaptive_threshold( + pullback_min_change_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.45, minimum=0.08, ) - fast_slope_threshold_percent = self._adaptive_threshold( + fast_slope_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.08, minimum=0.01, ) - slow_slope_threshold_percent = self._adaptive_threshold( + slow_slope_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.03, minimum=0.005, ) - weak_trend_gap_threshold_percent = self._adaptive_threshold( + weak_trend_gap_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.18, minimum=0.05, ) - strong_trend_gap_threshold_percent = self._adaptive_threshold( + strong_trend_gap_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.55, minimum=0.18, ) - trend_direction_gap_threshold_percent = self._adaptive_threshold( + trend_direction_gap_threshold_percent = adaptive_threshold( atr_percent=atr_percent, multiplier=0.12, minimum=0.025, ) - trend_gap_percent = self._trend_gap_percent_value( + trend_gap_percent = trend_gap_percent_value( ema_fast=ema_fast, ema_slow=ema_slow, ) - ema_fast_slope_percent = self._ema_slope_percent( + ema_fast_slope_percent = ema_slope_percent( closes=closes, period=self._fast_ema_period, window=self._ema_fast_slope_window, ) - ema_slow_slope_percent = self._ema_slope_percent( + ema_slow_slope_percent = ema_slope_percent( closes=closes, period=self._slow_ema_period, window=self._ema_slow_slope_window, ) - volatility = self._classify_volatility( + volatility = classify_volatility( atr_percent=atr_percent, volatility_ratio=volatility_ratio, htf_volatility_ratio=htf_volatility_ratio, + low_volatility_atr_percent=self._low_volatility_atr_percent, + high_volatility_atr_percent=self._high_volatility_atr_percent, ) - trend = self._classify_trend( + trend = classify_trend( ema_fast=ema_fast, ema_slow=ema_slow, ema_fast_slope_percent=ema_fast_slope_percent, @@ -214,68 +409,90 @@ class MarketAnalysisService: trend_direction_gap_threshold_percent=trend_direction_gap_threshold_percent, ) - trend_strength = self._classify_trend_strength( + trend_strength = classify_trend_strength( trend_gap_percent=trend_gap_percent, weak_threshold_percent=weak_trend_gap_threshold_percent, strong_threshold_percent=strong_trend_gap_threshold_percent, ) - trend_consistency = self._trend_consistency( + trend_consistency = calculate_trend_consistency( closes=closes, trend=trend, + trend_consistency_window=self._trend_consistency_window, ) - trend_efficiency = self._trend_efficiency( + trend_efficiency = calculate_trend_efficiency( closes=closes, + trend_consistency_window=self._trend_consistency_window, ) - ema_distance_atr_ratio = self._ema_distance_atr_ratio( + ema_distance_atr_ratio = calculate_ema_distance_atr_ratio( ema_fast=ema_fast, ema_slow=ema_slow, atr_value=atr_value, ) - candle_noise_score = self._candle_noise_score(candles) + candle_noise_score = calculate_candle_noise_score( + candles, + candle_noise_window=self._candle_noise_window, + min_clean_body_ratio=self._min_clean_body_ratio, + ) - price_position_score = self._price_position_score( + price_position_score = calculate_price_position_score( closes=closes, ema_fast=ema_fast, trend=trend, + price_position_window=self._price_position_window, ) - trend_quality_score = self._trend_quality_score( + trend_quality_score_value = trend_quality_score( trend_consistency=trend_consistency, trend_efficiency=trend_efficiency, candle_noise_score=candle_noise_score, price_position_score=price_position_score, ) - ema_distance_state = self._classify_ema_distance_state( + ema_distance_state = classify_ema_distance_state( ema_distance_atr_ratio=ema_distance_atr_ratio, ) - trend_quality = self._classify_trend_quality( + trend_quality = classify_trend_quality( trend_consistency=trend_consistency, trend_efficiency=trend_efficiency, ema_distance_atr_ratio=ema_distance_atr_ratio, candle_noise_score=candle_noise_score, price_position_score=price_position_score, trend_strength=trend_strength, + min_clean_candle_score=self._min_clean_candle_score, + min_price_position_score=self._min_price_position_score, ) - phase_change_percent = self._recent_change_percent( + phase_change_percent = recent_change_percent( closes=closes, window=self._phase_window, ) - phase_direction = self._classify_phase_direction( + phase_direction = classify_phase_direction( phase_change_percent, threshold_percent=phase_direction_threshold_percent, ) - phase_direction_consistency = self._phase_direction_consistency( + phase_direction_consistency = calculate_phase_direction_consistency( closes=closes, phase_direction=phase_direction, + phase_window=self._phase_window, + ) + + # Текущая свеча — последняя свеча из batch. + # Обычно это ещё формирующаяся свеча текущего 5m-интервала. + current_interval_change_percent = self._candle_change_percent( + candles, + index=-1, + ) + + current_interval_direction = classify_phase_direction( + current_interval_change_percent, + threshold_percent=phase_direction_threshold_percent, ) ( @@ -286,14 +503,17 @@ class MarketAnalysisService: breakout_level, breakout_distance_percent, breakout_reason, - ) = self._momentum_breakout_state( + ) = momentum_breakout_state( closes=closes, + momentum_window=self._momentum_window, + momentum_decay_window=self._momentum_decay_window, + breakout_lookback=self._breakout_lookback, momentum_change_threshold_percent=momentum_threshold_percent, momentum_decay_threshold_percent=momentum_decay_threshold_percent, breakout_distance_threshold_percent=breakout_distance_threshold_percent, ) - market_phase, phase_reason = self._classify_market_phase( + market_phase, phase_reason = classify_market_phase( trend=trend, volatility=volatility, trend_strength=trend_strength, @@ -303,16 +523,27 @@ class MarketAnalysisService: phase_change_percent=phase_change_percent, phase_direction_consistency=phase_direction_consistency, pullback_min_change_percent=pullback_min_change_percent, + pullback_min_direction_consistency=self._pullback_min_direction_consistency, ) - entry_timing_state, entry_timing_reason = self._classify_entry_timing( + entry_timing_state, entry_timing_reason = classify_entry_timing( ema_distance_state=ema_distance_state, momentum_state=momentum_state, momentum_strength=momentum_strength, market_phase=market_phase, ) - state = self._classify_market_state( + market_structure_value, market_structure_reason = market_structure( + candles, + atr_percent=atr_percent, + candle_noise_score=candle_noise_score, + structure_window=self._structure_window, + structure_swing_left=self._structure_swing_left, + structure_swing_right=self._structure_swing_right, + min_clean_candle_score=self._min_clean_candle_score, + ) + + state = classify_market_state( trend=trend, volatility=volatility, trend_strength=trend_strength, @@ -326,15 +557,34 @@ class MarketAnalysisService: slow_slope_threshold_percent=slow_slope_threshold_percent, candle_noise_score=candle_noise_score, price_position_score=price_position_score, + min_clean_candle_score=self._min_clean_candle_score, + min_price_position_score=self._min_price_position_score, ) - is_trade_allowed = self._is_trade_allowed( + htf_trend_context = build_htf_trend_context( + self, + symbol=batch.symbol, + base_interval=interval, + local_state=state, + local_trend=trend, + ) + + # HTF trend context может быть пустым при ошибке анализа старшего ТФ. + htf_trend_context = htf_trend_context or {} + + htf_confirmation_score = safe_float( + htf_trend_context.get("htf_confirmation_score") + ) + htf_alignment = str(htf_trend_context.get("htf_alignment") or "") + + is_trade_allowed = check_trade_allowed( state=state, trend=trend, volatility=volatility, trend_strength=trend_strength, trend_quality=trend_quality, market_phase=market_phase, + market_structure=market_structure_value, momentum_state=momentum_state, momentum_direction=momentum_direction, candle_noise_score=candle_noise_score, @@ -343,9 +593,75 @@ class MarketAnalysisService: ema_distance_state=ema_distance_state, entry_timing_state=entry_timing_state, fast_slope_threshold_percent=fast_slope_threshold_percent, + htf_alignment=htf_alignment, + htf_confirmation_score=htf_confirmation_score, + min_htf_confirmation_score=self._min_htf_confirmation_score, + min_clean_candle_score=self._min_clean_candle_score, + min_price_position_score=self._min_price_position_score, + rsi_value=rsi_value, + rsi_overbought=self._rsi_overbought, + rsi_oversold=self._rsi_oversold, ) - reason = self._reason( + # Общая оценка рынка 0..100. + # Пока используется для UI/диагностики, дальше можно подключить + # к execution confidence и adaptive sizing. + market_score = self._market_score( + state=state, + trend=trend, + volatility=volatility, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + market_structure=market_structure_value, + momentum_state=momentum_state, + momentum_direction=momentum_direction, + ema_distance_state=ema_distance_state, + entry_timing_state=entry_timing_state, + current_interval_change_percent=current_interval_change_percent, + current_interval_direction=current_interval_direction, + htf_alignment=htf_alignment, + htf_confirmation_score=htf_confirmation_score, + is_trade_allowed=is_trade_allowed, + ) + market_score_label = self._market_score_label(market_score) + + market_long_score = self._entry_score( + side=EntrySide.BUY, + market_score=market_score, + trend=trend, + momentum_direction=momentum_direction, + market_structure=market_structure_value, + current_interval_direction=current_interval_direction, + current_interval_change_percent=current_interval_change_percent, + htf_alignment=htf_alignment, + ) + + market_short_score = self._entry_score( + side=EntrySide.SELL, + market_score=market_score, + trend=trend, + momentum_direction=momentum_direction, + market_structure=market_structure_value, + current_interval_direction=current_interval_direction, + current_interval_change_percent=current_interval_change_percent, + htf_alignment=htf_alignment, + ) + + # API Dzengi возвращает последнюю формирующуюся свечу: + # index=-1 — текущая незакрытая свеча; + # index=-2 — последняя полностью закрытая свеча. + last_closed_candle_change_percent = self._candle_change_percent( + candles, + index=-2, + ) + + last_closed_candle_direction = classify_phase_direction( + last_closed_candle_change_percent, + threshold_percent=phase_direction_threshold_percent, + ) + + reason = build_market_reason( state=state, volatility=volatility, atr_percent=atr_percent, @@ -358,9 +674,76 @@ class MarketAnalysisService: price_position_score=price_position_score, ema_distance_state=ema_distance_state, entry_timing_state=entry_timing_state, + min_clean_candle_score=self._min_clean_candle_score, + min_price_position_score=self._min_price_position_score, + rsi_overbought=self._rsi_overbought, + rsi_oversold=self._rsi_oversold, ) - return MarketAnalysisResult( + payload = build_market_analysis_payload( + symbol=batch.symbol, + interval=interval, + state=state, + trend=trend, + volatility=volatility, + trend_strength=trend_strength, + trend_quality=trend_quality, + market_phase=market_phase, + phase_direction=phase_direction, + phase_change_percent=phase_change_percent, + phase_direction_consistency=phase_direction_consistency, + current_interval_change_percent=current_interval_change_percent, + current_interval_direction=current_interval_direction, + current_interval_label=interval, + last_closed_candle_change_percent=last_closed_candle_change_percent, + last_closed_candle_direction=last_closed_candle_direction, + phase_reason=phase_reason, + market_structure=market_structure_value, + market_structure_reason=market_structure_reason, + momentum_state=momentum_state, + momentum_direction=momentum_direction, + momentum_change_percent=momentum_change_percent, + momentum_strength=momentum_strength, + breakout_level=breakout_level, + breakout_distance_percent=breakout_distance_percent, + breakout_reason=breakout_reason, + trend_gap_percent=trend_gap_percent, + ema_fast_slope_percent=ema_fast_slope_percent, + ema_slow_slope_percent=ema_slow_slope_percent, + trend_consistency=trend_consistency, + trend_efficiency=trend_efficiency, + trend_quality_score_value=trend_quality_score_value, + ema_distance_atr_ratio=ema_distance_atr_ratio, + ema_distance_state=ema_distance_state, + entry_timing_state=entry_timing_state, + entry_timing_reason=entry_timing_reason, + candle_noise_score=candle_noise_score, + price_position_score=price_position_score, + close_price=close_price, + ema_fast_period=self._fast_ema_period, + ema_slow_period=self._slow_ema_period, + ema_fast=ema_fast, + ema_slow=ema_slow, + atr_period=self._atr_period, + atr_value=atr_value, + atr_percent=atr_percent, + atr_percent_baseline=atr_percent_baseline, + volatility_ratio=volatility_ratio, + rsi_period=self._rsi_period, + rsi_value=rsi_value, + rsi_overbought=self._rsi_overbought, + rsi_oversold=self._rsi_oversold, + candles_count=len(candles), + is_trade_allowed=is_trade_allowed, + htf_context=htf_context, + htf_trend_context=htf_trend_context, + market_score=market_score, + market_score_label=market_score_label, + market_long_score=market_long_score, + market_short_score=market_short_score, + ) + + return build_market_analysis_result( symbol=batch.symbol, interval=interval, state=state, @@ -369,100 +752,18 @@ class MarketAnalysisService: close_price=close_price, ema_fast=ema_fast, ema_slow=ema_slow, - atr=atr_value, + atr_value=atr_value, atr_percent=atr_percent, - rsi=rsi_value, + rsi_value=rsi_value, candles_count=len(candles), reason=reason, is_trade_allowed=is_trade_allowed, - payload={ - "symbol": batch.symbol, - "interval": interval, - "market_state": state.value, - "trend": trend.value, - "volatility": volatility.value, - "market_trend_strength": trend_strength.value, - "market_trend_quality": trend_quality.value, - "market_phase": market_phase.value, - "market_phase_direction": phase_direction.value, - "market_phase_change_percent": round(phase_change_percent, 5) - if phase_change_percent is not None - else None, - "market_phase_direction_consistency": round(phase_direction_consistency, 3) - if phase_direction_consistency is not None - else None, - "market_phase_reason": phase_reason, - "momentum_state": momentum_state.value, - "momentum_direction": momentum_direction.value, - "momentum_change_percent": round(momentum_change_percent, 5) - if momentum_change_percent is not None - else None, - "momentum_strength": round(momentum_strength, 3) - if momentum_strength is not None - else None, - "breakout_level": breakout_level, - "breakout_distance_percent": round(breakout_distance_percent, 5) - if breakout_distance_percent is not None - else None, - "breakout_reason": breakout_reason, - "market_trend_gap_percent": round(trend_gap_percent, 5) - if trend_gap_percent is not None - else None, - "ema_fast_slope_percent": round(ema_fast_slope_percent, 5) - if ema_fast_slope_percent is not None - else None, - "ema_slow_slope_percent": round(ema_slow_slope_percent, 5) - if ema_slow_slope_percent is not None - else None, - "market_trend_consistency": round(trend_consistency, 3) - if trend_consistency is not None - else None, - "market_trend_efficiency": round(trend_efficiency, 3) - if trend_efficiency is not None - else None, - "trend_quality_score": round(trend_quality_score, 3) - if trend_quality_score is not None - else None, - "ema_distance_atr_ratio": round(ema_distance_atr_ratio, 3) - if ema_distance_atr_ratio is not None - else None, - "ema_distance_state": ema_distance_state.value, - "entry_timing_state": entry_timing_state.value, - "entry_timing_reason": entry_timing_reason, - "candle_noise_score": round(candle_noise_score, 3) - if candle_noise_score is not None - else None, - "price_position_score": round(price_position_score, 3) - if price_position_score is not None - else None, - "close_price": close_price, - "ema_fast_period": self._fast_ema_period, - "ema_slow_period": self._slow_ema_period, - "ema_fast": round(ema_fast, 8), - "ema_slow": round(ema_slow, 8), - "atr_period": self._atr_period, - "atr": round(atr_value, 8), - "atr_percent": round(atr_percent, 4), - "atr_percent_baseline": round(atr_percent_baseline, 4) - if atr_percent_baseline is not None - else None, - "volatility_ratio": round(volatility_ratio, 4) - if volatility_ratio is not None - else None, - "rsi_period": self._rsi_period, - "rsi": round(rsi_value, 2) if rsi_value is not None else None, - "candles_count": len(candles), - "is_trade_allowed": is_trade_allowed, - "htf_interval": htf_context.get("htf_interval"), - "htf_atr_percent": htf_context.get("htf_atr_percent"), - "htf_atr_percent_baseline": htf_context.get("htf_atr_percent_baseline"), - "htf_volatility_ratio": htf_context.get("htf_volatility_ratio"), - "htf_volatility": htf_context.get("htf_volatility"), - "htf_reason": htf_context.get("htf_reason"), - }, + payload=payload, trend_strength=trend_strength, trend_quality=trend_quality, market_phase=market_phase, + market_structure=market_structure_value, + market_structure_reason=market_structure_reason, trend_gap_percent=trend_gap_percent, trend_consistency=trend_consistency, trend_efficiency=trend_efficiency, @@ -473,6 +774,9 @@ class MarketAnalysisService: ema_fast_slope_percent=ema_fast_slope_percent, ema_slow_slope_percent=ema_slow_slope_percent, phase_direction_consistency=phase_direction_consistency, + current_interval_change_percent=current_interval_change_percent, + current_interval_direction=current_interval_direction, + current_interval_label=interval, momentum_state=momentum_state, momentum_direction=momentum_direction, momentum_change_percent=momentum_change_percent, @@ -480,881 +784,20 @@ class MarketAnalysisService: breakout_level=breakout_level, breakout_distance_percent=breakout_distance_percent, breakout_reason=breakout_reason, - htf_interval=str(htf_context.get("htf_interval") or self._htf_interval), - htf_atr_percent=safe_float(htf_context.get("htf_atr_percent")), - htf_atr_percent_baseline=safe_float( - htf_context.get("htf_atr_percent_baseline") - ), - htf_volatility_ratio=safe_float(htf_context.get("htf_volatility_ratio")), - htf_volatility=( - VolatilityState(str(htf_context["htf_volatility"])) - if htf_context.get("htf_volatility") - else None - ), - trend_quality_score=trend_quality_score, + trend_quality_score_value=trend_quality_score_value, ema_distance_state=ema_distance_state, entry_timing_state=entry_timing_state, entry_timing_reason=entry_timing_reason, - ) - - def _htf_volatility_context( - self, - *, - symbol: str, - base_interval: str, - ) -> JsonDict: - if base_interval == self._htf_interval: - return { - "htf_interval": self._htf_interval, - "htf_atr_percent": None, - "htf_atr_percent_baseline": None, - "htf_volatility_ratio": None, - "htf_volatility": None, - "htf_reason": "HTF_SKIPPED_SAME_INTERVAL", - } - - try: - batch = ExchangeService().get_klines( - symbol=symbol, - interval=self._htf_interval, - limit=self._htf_limit, - ) - except Exception as exc: - return { - "htf_interval": self._htf_interval, - "htf_atr_percent": None, - "htf_atr_percent_baseline": None, - "htf_volatility_ratio": None, - "htf_volatility": None, - "htf_reason": f"HTF_KLINES_ERROR: {exc}", - } - - candles = batch.candles - closes = [item.close_price for item in candles] - - if len(candles) < self._min_candles or not closes: - return { - "htf_interval": self._htf_interval, - "htf_atr_percent": None, - "htf_atr_percent_baseline": None, - "htf_volatility_ratio": None, - "htf_volatility": None, - "htf_reason": "HTF_NOT_ENOUGH_CANDLES", - } - - close_price = safe_float(closes[-1]) - atr_value = atr(candles, self._atr_period) - - if close_price is None or close_price <= 0 or atr_value is None: - return { - "htf_interval": self._htf_interval, - "htf_atr_percent": None, - "htf_atr_percent_baseline": None, - "htf_volatility_ratio": None, - "htf_volatility": None, - "htf_reason": "HTF_ATR_UNAVAILABLE", - } - - htf_atr_percent = (atr_value / close_price) * 100 - htf_baseline = self._atr_percent_baseline( - candles=candles, - close_price=close_price, - ) - - htf_ratio = ( - htf_atr_percent / htf_baseline - if htf_baseline is not None and htf_baseline > 0 - else None - ) - - htf_volatility = self._classify_volatility( - atr_percent=htf_atr_percent, - volatility_ratio=htf_ratio, - htf_volatility_ratio=None, - ) - - return { - "htf_interval": self._htf_interval, - "htf_atr_percent": round(htf_atr_percent, 4), - "htf_atr_percent_baseline": round(htf_baseline, 4) - if htf_baseline is not None - else None, - "htf_volatility_ratio": round(htf_ratio, 4) - if htf_ratio is not None - else None, - "htf_volatility": htf_volatility.value, - "htf_reason": "HTF_OK", - } - - def _atr_percent_baseline( - self, - *, - candles: Sequence[Kline], - close_price: float, - ) -> float | None: - if close_price <= 0: - return None - - values: list[float] = [] - - window: list[Kline] = list( - candles[-self._atr_baseline_window:] - ) - - for index in range(self._atr_period, len(window) + 1): - part: list[Kline] = window[:index] - atr_value = atr(part, self._atr_period) - - if atr_value is None: - continue - - close = getattr(part[-1], "close_price", None) - - if close is None or close <= 0: - continue - - values.append((atr_value / close) * 100) - - if not values: - return None - - values.sort() - middle = len(values) // 2 - - if len(values) % 2 == 1: - return values[middle] - - return (values[middle - 1] + values[middle]) / 2 - - def _momentum_breakout_state( - self, - *, - closes: list[float], - momentum_change_threshold_percent: float, - momentum_decay_threshold_percent: float, - breakout_distance_threshold_percent: float, - ) -> tuple[ - MomentumState, - TrendDirection, - float | None, - float | None, - float | None, - float | None, - str | None, - ]: - if len(closes) < max(self._momentum_window + 1, self._breakout_lookback + 1): - return ( - MomentumState.UNKNOWN, - TrendDirection.UNKNOWN, - None, - None, - None, - None, - "NOT_ENOUGH_DATA", - ) - - first_price = closes[-(self._momentum_window + 1)] - last_price = closes[-1] - - if first_price <= 0 or last_price <= 0: - return ( - MomentumState.UNKNOWN, - TrendDirection.UNKNOWN, - None, - None, - None, - None, - "INVALID_PRICE", - ) - - momentum_change_percent = ((last_price - first_price) / first_price) * 100 - abs_change = abs(momentum_change_percent) - - recent_change_percent = self._recent_change_percent( - closes=closes, - window=self._momentum_decay_window, - ) - - recent_abs_change = ( - abs(recent_change_percent) - if recent_change_percent is not None - else None - ) - - if ( - momentum_change_percent >= momentum_change_threshold_percent - and recent_change_percent is not None - and recent_change_percent > momentum_decay_threshold_percent - ): - momentum_direction = TrendDirection.UP - - elif ( - momentum_change_percent <= -momentum_change_threshold_percent - and recent_change_percent is not None - and recent_change_percent < -momentum_decay_threshold_percent - ): - momentum_direction = TrendDirection.DOWN - - else: - momentum_direction = TrendDirection.FLAT - - if momentum_direction == TrendDirection.FLAT: - if recent_abs_change is not None: - momentum_strength = min( - recent_abs_change / momentum_decay_threshold_percent, - 3.0, - ) - else: - momentum_strength = 0.0 - else: - momentum_strength = min( - abs_change / momentum_change_threshold_percent, - 3.0, - ) - - lookback_window = closes[-(self._breakout_lookback + 1):-1] - previous_high = max(lookback_window) - previous_low = min(lookback_window) - - if previous_high <= 0 or previous_low <= 0: - return ( - MomentumState.UNKNOWN, - TrendDirection.UNKNOWN, - momentum_change_percent, - momentum_strength, - None, - None, - "INVALID_BREAKOUT_LEVEL", - ) - - if last_price > previous_high: - breakout_distance_percent = ((last_price - previous_high) / previous_high) * 100 - - if breakout_distance_percent >= breakout_distance_threshold_percent: - return ( - MomentumState.BREAKOUT_UP, - TrendDirection.UP, - momentum_change_percent, - momentum_strength, - previous_high, - breakout_distance_percent, - "PRICE_ABOVE_LOOKBACK_HIGH", - ) - - if last_price < previous_low: - breakout_distance_percent = ((previous_low - last_price) / previous_low) * 100 - - if breakout_distance_percent >= breakout_distance_threshold_percent: - return ( - MomentumState.BREAKOUT_DOWN, - TrendDirection.DOWN, - momentum_change_percent, - momentum_strength, - previous_low, - breakout_distance_percent, - "PRICE_BELOW_LOOKBACK_LOW", - ) - - if momentum_direction == TrendDirection.UP: - return ( - MomentumState.MOMENTUM_UP, - TrendDirection.UP, - momentum_change_percent, - momentum_strength, - None, - None, - "FAST_UP_MOVE", - ) - - if momentum_direction == TrendDirection.DOWN: - return ( - MomentumState.MOMENTUM_DOWN, - TrendDirection.DOWN, - momentum_change_percent, - momentum_strength, - None, - None, - "FAST_DOWN_MOVE", - ) - - return ( - MomentumState.NONE, - TrendDirection.FLAT, - momentum_change_percent, - momentum_strength, - None, - None, - "NO_SIGNIFICANT_MOMENTUM", + htf_interval=self._htf_interval, + htf_context=htf_context, + htf_trend_context=htf_trend_context, + market_score=market_score, + market_score_label=market_score_label, + market_long_score=market_long_score, + market_short_score=market_short_score, ) - def _trend_gap_percent_value( - self, - *, - ema_fast: float, - ema_slow: float, - ) -> float | None: - if ema_slow <= 0: - return None - - return ((ema_fast - ema_slow) / ema_slow) * 100 - - def _adaptive_threshold( - self, - *, - atr_percent: NumericLike | None, - multiplier: NumericLike, - minimum: NumericLike, - ) -> float: - atr_value = safe_float(atr_percent) - multiplier_value = safe_float(multiplier) - minimum_value = safe_float(minimum) or 0.0 - - if atr_value is None or atr_value <= 0 or multiplier_value is None: - return minimum_value - - return max(minimum_value, atr_value * multiplier_value) - - def _ema_slope_percent( - self, - *, - closes: list[float], - period: int, - window: int, - ) -> float | None: - required = period + window + 5 - - if len(closes) < required: - return None - - current_ema = ema(closes, period) - - previous_ema = ema( - closes[:-window], - period, - ) - - if ( - current_ema is None - or previous_ema is None - or previous_ema <= 0 - ): - return None - - return ( - (current_ema - previous_ema) - / previous_ema - ) * 100 - - def _classify_trend( - self, - *, - ema_fast: float, - ema_slow: float, - ema_fast_slope_percent: float | None = None, - ema_slow_slope_percent: float | None = None, - fast_slope_threshold_percent: float, - slow_slope_threshold_percent: float, - trend_direction_gap_threshold_percent: float, - ) -> TrendDirection: - gap_percent = self._trend_gap_percent_value( - ema_fast=ema_fast, - ema_slow=ema_slow, - ) - - if gap_percent is None: - return TrendDirection.UNKNOWN - - fast_slope = ema_fast_slope_percent or 0.0 - slow_slope = ema_slow_slope_percent or 0.0 - - fast_up = fast_slope >= fast_slope_threshold_percent - fast_down = fast_slope <= -fast_slope_threshold_percent - - slow_up = slow_slope >= slow_slope_threshold_percent - slow_down = slow_slope <= -slow_slope_threshold_percent - - if gap_percent >= trend_direction_gap_threshold_percent: - if fast_down and slow_down: - return TrendDirection.FLAT - return TrendDirection.UP - - if gap_percent <= -trend_direction_gap_threshold_percent: - if fast_up and slow_up: - return TrendDirection.FLAT - return TrendDirection.DOWN - - if fast_up and slow_up: - return TrendDirection.UP - - if fast_down and slow_down: - return TrendDirection.DOWN - - return TrendDirection.FLAT - - def _classify_trend_strength( - self, - *, - trend_gap_percent: float | None, - weak_threshold_percent: float, - strong_threshold_percent: float, - ) -> TrendStrength: - if trend_gap_percent is None: - return TrendStrength.UNKNOWN - - gap = abs(trend_gap_percent) - - if gap < weak_threshold_percent: - return TrendStrength.WEAK - - if gap < strong_threshold_percent: - return TrendStrength.NORMAL - - return TrendStrength.STRONG - - def _trend_consistency( - self, - *, - closes: list[float], - trend: TrendDirection, - ) -> float | None: - if len(closes) < 2: - return None - - window = closes[-self._trend_consistency_window :] - if len(window) < 2: - return None - - up_moves = 0 - down_moves = 0 - - for previous_price, current_price in zip(window, window[1:]): - if current_price > previous_price: - up_moves += 1 - elif current_price < previous_price: - down_moves += 1 - - total_moves = max(1, len(window) - 1) - - if trend == TrendDirection.UP: - return up_moves / total_moves - - if trend == TrendDirection.DOWN: - return down_moves / total_moves - - return None - - def _trend_efficiency( - self, - *, - closes: list[float], - ) -> float | None: - window = closes[-self._trend_consistency_window :] - - if len(window) < 2: - return None - - net_move = abs(window[-1] - window[0]) - - total_move = 0.0 - - for previous_price, current_price in zip(window, window[1:]): - total_move += abs(current_price - previous_price) - - if total_move <= 0: - return None - - return net_move / total_move - - def _ema_distance_atr_ratio( - self, - *, - ema_fast: float, - ema_slow: float, - atr_value: float, - ) -> float | None: - if atr_value <= 0: - return None - - return abs(ema_fast - ema_slow) / atr_value - - def _classify_trend_quality( - self, - *, - trend_consistency: float | None, - trend_efficiency: float | None, - ema_distance_atr_ratio: float | None, - candle_noise_score: float | None, - price_position_score: float | None, - trend_strength: TrendStrength, - ) -> TrendQuality: - if trend_consistency is None: - return TrendQuality.UNKNOWN - - if trend_strength == TrendStrength.WEAK: - return TrendQuality.NOISY - - if ( - candle_noise_score is not None - and candle_noise_score < self._min_clean_candle_score - ): - return TrendQuality.NOISY - - if ( - price_position_score is not None - and price_position_score < self._min_price_position_score - ): - return TrendQuality.NOISY - - if ( - trend_efficiency is not None - and trend_efficiency < 0.28 - ): - return TrendQuality.NOISY - - if ( - ema_distance_atr_ratio is not None - and ema_distance_atr_ratio < 0.45 - ): - return TrendQuality.NOISY - - if trend_consistency >= 0.68: - return TrendQuality.CLEAN - - if trend_consistency >= 0.55: - return TrendQuality.NORMAL - - return TrendQuality.NOISY - - def _recent_change_percent( - self, - *, - closes: list[float], - window: int, - ) -> float | None: - if window <= 0 or len(closes) < window + 1: - return None - - first_price = closes[-(window + 1)] - last_price = closes[-1] - - if first_price <= 0: - return None - - return ((last_price - first_price) / first_price) * 100 - - def _classify_phase_direction( - self, - change_percent: float | None, - *, - threshold_percent: float, - ) -> TrendDirection: - if change_percent is None: - return TrendDirection.UNKNOWN - - if change_percent >= threshold_percent: - return TrendDirection.UP - - if change_percent <= -threshold_percent: - return TrendDirection.DOWN - - return TrendDirection.FLAT - - def _phase_direction_consistency( - self, - *, - closes: list[float], - phase_direction: TrendDirection, - ) -> float | None: - window = closes[-(self._phase_window + 1):] - - if len(window) < 2: - return None - - up_moves = 0 - down_moves = 0 - - for previous_price, current_price in zip(window, window[1:]): - if current_price > previous_price: - up_moves += 1 - elif current_price < previous_price: - down_moves += 1 - - total_moves = max(1, len(window) - 1) - - if phase_direction == TrendDirection.UP: - return up_moves / total_moves - - if phase_direction == TrendDirection.DOWN: - return down_moves / total_moves - - return None - - def _is_counter_trend_move( - self, - *, - trend: TrendDirection, - phase_direction: TrendDirection, - ) -> bool: - if trend == TrendDirection.UP: - return phase_direction == TrendDirection.DOWN - - if trend == TrendDirection.DOWN: - return phase_direction == TrendDirection.UP - - return False - - def _classify_market_phase( - self, - *, - trend: TrendDirection, - volatility: VolatilityState, - trend_strength: TrendStrength, - trend_quality: TrendQuality, - rsi_value: float | None, - phase_direction: TrendDirection, - phase_change_percent: float | None, - phase_direction_consistency: float | None, - pullback_min_change_percent: float, - ) -> tuple[MarketPhase, str]: - if volatility == VolatilityState.LOW: - return MarketPhase.SQUEEZE, "LOW_VOLATILITY_SQUEEZE" - - if trend == TrendDirection.FLAT: - return MarketPhase.RANGE, "FLAT_TREND_RANGE" - - if trend not in {TrendDirection.UP, TrendDirection.DOWN}: - return MarketPhase.UNKNOWN, "UNKNOWN_TREND" - - if trend_strength == TrendStrength.WEAK: - return MarketPhase.RANGE, "WEAK_TREND_RANGE" - - if self._is_counter_trend_move( - trend=trend, - phase_direction=phase_direction, - ): - if ( - phase_change_percent is not None - and abs(phase_change_percent) >= pullback_min_change_percent - and phase_direction_consistency is not None - and phase_direction_consistency >= self._pullback_min_direction_consistency - ): - return MarketPhase.PULLBACK, "COUNTER_TREND_MOVE_CONFIRMED" - - return MarketPhase.RANGE, "COUNTER_TREND_MOVE_TOO_WEAK" - - if ( - trend == TrendDirection.UP - and rsi_value is not None - and rsi_value < 45 - and phase_direction == TrendDirection.DOWN - and phase_change_percent is not None - and abs(phase_change_percent) >= pullback_min_change_percent - and phase_direction_consistency is not None - and phase_direction_consistency >= self._pullback_min_direction_consistency - ): - return MarketPhase.PULLBACK, "UPTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE" - - if ( - trend == TrendDirection.DOWN - and rsi_value is not None - and rsi_value > 55 - and phase_direction == TrendDirection.UP - and phase_change_percent is not None - and abs(phase_change_percent) >= pullback_min_change_percent - and phase_direction_consistency is not None - and phase_direction_consistency >= self._pullback_min_direction_consistency - ): - return MarketPhase.PULLBACK, "DOWNTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE" - - return MarketPhase.IMPULSE, "WITH_TREND_OR_NEUTRAL_MOVE" - - def _classify_volatility( - self, - *, - atr_percent: NumericLike, - volatility_ratio: NumericLike | None, - htf_volatility_ratio: NumericLike | None = None, - ) -> VolatilityState: - atr_value = safe_float(atr_percent) - - if atr_value is None or atr_value <= 0: - return VolatilityState.UNKNOWN - - local_ratio = safe_float(volatility_ratio) - htf_ratio = safe_float(htf_volatility_ratio) - - if htf_ratio is not None: - if htf_ratio > 1.8 and (local_ratio is None or local_ratio > 1.1): - return VolatilityState.HIGH - - if htf_ratio < 0.55 and (local_ratio is None or local_ratio < 0.85): - return VolatilityState.LOW - - if local_ratio is None: - if atr_value < self._low_volatility_atr_percent: - return VolatilityState.LOW - - if atr_value > self._high_volatility_atr_percent: - return VolatilityState.HIGH - - return VolatilityState.NORMAL - - if local_ratio < 0.55: - return VolatilityState.LOW - - if local_ratio > 1.8: - return VolatilityState.HIGH - - return VolatilityState.NORMAL - - def _candle_noise_score( - self, - candles: Sequence[Kline], - ) -> float | None: - window = candles[-self._candle_noise_window :] - - if not window: - return None - - clean_count = 0 - total_count = 0 - - for candle in window: - high = getattr(candle, "high_price", None) - low = getattr(candle, "low_price", None) - open_price = getattr(candle, "open_price", None) - close_price = getattr(candle, "close_price", None) - - if ( - high is None - or low is None - or open_price is None - or close_price is None - or high <= low - ): - continue - - candle_range = high - low - body = abs(close_price - open_price) - body_ratio = body / candle_range - - total_count += 1 - - if body_ratio >= self._min_clean_body_ratio: - clean_count += 1 - - if total_count == 0: - return None - - return clean_count / total_count - - - def _price_position_score( - self, - *, - closes: list[float], - ema_fast: float, - trend: TrendDirection, - ) -> float | None: - window = closes[-self._price_position_window :] - - if not window: - return None - - valid_count = 0 - - for close_price in window: - if trend == TrendDirection.UP: - if close_price > ema_fast: - valid_count += 1 - - elif trend == TrendDirection.DOWN: - if close_price < ema_fast: - valid_count += 1 - - else: - return None - - return valid_count / len(window) - - def _classify_market_state( - self, - *, - trend: TrendDirection, - volatility: VolatilityState, - trend_strength: TrendStrength, - trend_quality: TrendQuality, - market_phase: MarketPhase, - momentum_state: MomentumState, - momentum_direction: TrendDirection, - ema_fast_slope_percent: float | None, - ema_slow_slope_percent: float | None, - fast_slope_threshold_percent: float, - slow_slope_threshold_percent: float, - candle_noise_score: float | None, - price_position_score: float | None, - ) -> MarketState: - fast_slope = ema_fast_slope_percent or 0.0 - range_slope_threshold_percent = max( - fast_slope_threshold_percent, - slow_slope_threshold_percent * 2, - ) - - if volatility == VolatilityState.HIGH: - return MarketState.HIGH_VOLATILITY - - if volatility == VolatilityState.LOW: - return MarketState.LOW_VOLATILITY - - if ( - momentum_state in {MomentumState.BREAKOUT_UP, MomentumState.MOMENTUM_UP} - and momentum_direction == TrendDirection.UP - and fast_slope > 0 - ): - return MarketState.TREND_UP - - if ( - momentum_state in {MomentumState.BREAKOUT_DOWN, MomentumState.MOMENTUM_DOWN} - and momentum_direction == TrendDirection.DOWN - and fast_slope < 0 - ): - return MarketState.TREND_DOWN - - if market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}: - return MarketState.RANGE - - if trend_strength == TrendStrength.WEAK: - return MarketState.RANGE - - if ( - trend_quality == TrendQuality.NOISY - and trend_strength != TrendStrength.STRONG - ): - return MarketState.RANGE - - if ( - candle_noise_score is not None - and candle_noise_score < self._min_clean_candle_score - and abs(fast_slope) < range_slope_threshold_percent - ): - return MarketState.RANGE - - if ( - price_position_score is not None - and price_position_score < self._min_price_position_score - and abs(fast_slope) < range_slope_threshold_percent - ): - return MarketState.RANGE - - if trend == TrendDirection.UP: - return MarketState.TREND_UP - - if trend == TrendDirection.DOWN: - return MarketState.TREND_DOWN - - return MarketState.RANGE - - def _is_trade_allowed( + def _market_score( self, *, state: MarketState, @@ -1363,378 +806,277 @@ class MarketAnalysisService: trend_strength: TrendStrength, trend_quality: TrendQuality, market_phase: MarketPhase, + market_structure: MarketStructure, momentum_state: MomentumState, momentum_direction: TrendDirection, - candle_noise_score: float | None, - price_position_score: float | None, - ema_fast_slope_percent: float | None, ema_distance_state: EmaDistanceState, entry_timing_state: EntryTimingState, - fast_slope_threshold_percent: float, - ) -> bool: - if state not in { - MarketState.TREND_UP, - MarketState.TREND_DOWN, - }: - return False + current_interval_change_percent: float | None, + current_interval_direction: TrendDirection, + htf_alignment: str, + htf_confirmation_score: float | None, + is_trade_allowed: bool, + ) -> int: + # Стартовая точка — нейтрально-хороший рынок. + # Все коэффициенты вынесены в начало класса, чтобы стратегию было + # проще калибровать без поиска чисел внутри логики. + score = self._market_score_start - if volatility != VolatilityState.NORMAL: - return False - - if trend_strength == TrendStrength.WEAK: - return False - - if ( - trend_quality == TrendQuality.NOISY - and trend_strength != TrendStrength.STRONG - ): - return False - - if market_phase in { - MarketPhase.RANGE, - MarketPhase.SQUEEZE, - MarketPhase.PULLBACK, - }: - return False - - if trend == TrendDirection.UP: - if momentum_direction == TrendDirection.DOWN: - return False - - if momentum_state == MomentumState.BREAKOUT_DOWN: - return False - - if trend == TrendDirection.DOWN: - if momentum_direction == TrendDirection.UP: - return False - - if momentum_state == MomentumState.BREAKOUT_UP: - return False - - fast_slope = ema_fast_slope_percent or 0.0 - counter_slope_threshold_percent = fast_slope_threshold_percent - - if ( - trend == TrendDirection.UP - and fast_slope < -counter_slope_threshold_percent - ): - return False - - if ( - trend == TrendDirection.DOWN - and fast_slope > counter_slope_threshold_percent - ): - return False - - if ( - candle_noise_score is not None - and candle_noise_score < self._min_clean_candle_score - ): - return False - - if ( - price_position_score is not None - and price_position_score < self._min_price_position_score - ): - return False - - if ema_distance_state in { - EmaDistanceState.COMPRESSED, - EmaDistanceState.OVEREXTENDED, - }: - return False - - if entry_timing_state in { - EntryTimingState.LATE, - EntryTimingState.CHASING, - }: - return False - - return True - - def _reason( - self, - *, - state: MarketState, - volatility: VolatilityState, - atr_percent: float, - rsi_value: float | None, - trend_strength: TrendStrength, - trend_quality: TrendQuality, - market_phase: MarketPhase, - momentum_state: MomentumState, - candle_noise_score: float | None, - price_position_score: float | None, - ema_distance_state: EmaDistanceState, - entry_timing_state: EntryTimingState, - ) -> str: - reasons: list[str] = [] - - if state == MarketState.TREND_UP: - reasons.append("Рынок растёт") - elif state == MarketState.TREND_DOWN: - reasons.append("Рынок снижается") + if state in {MarketState.TREND_UP, MarketState.TREND_DOWN}: + score += self._market_state_trend_bonus elif state == MarketState.RANGE: - reasons.append("Рынок во флэте") + score -= self._market_state_range_penalty elif state == MarketState.HIGH_VOLATILITY: - reasons.append("Рынок слишком волатилен") + score -= self._market_state_high_volatility_penalty elif state == MarketState.LOW_VOLATILITY: - reasons.append("Рынок малоподвижен") - else: - reasons.append("Состояние рынка не определено") + score -= self._market_state_low_volatility_penalty + elif state == MarketState.UNKNOWN: + score -= self._market_state_unknown_penalty + + if trend in {TrendDirection.UP, TrendDirection.DOWN}: + score += self._market_trend_bonus + elif trend == TrendDirection.FLAT: + score -= self._market_trend_flat_penalty + elif trend == TrendDirection.UNKNOWN: + score -= self._market_trend_unknown_penalty + + if volatility == VolatilityState.NORMAL: + score += self._market_volatility_normal_bonus + elif volatility == VolatilityState.HIGH: + score -= self._market_volatility_high_penalty + elif volatility == VolatilityState.LOW: + score -= self._market_volatility_low_penalty + elif volatility == VolatilityState.UNKNOWN: + score -= self._market_volatility_unknown_penalty if trend_strength == TrendStrength.STRONG: - reasons.append("Сильный тренд") + score += self._market_trend_strength_strong_bonus elif trend_strength == TrendStrength.NORMAL: - reasons.append("Нормальный тренд") + score += self._market_trend_strength_normal_bonus elif trend_strength == TrendStrength.WEAK: - reasons.append("Слабый тренд") + score -= self._market_trend_strength_weak_penalty if trend_quality == TrendQuality.CLEAN: - reasons.append("Движение чистое") + score += self._market_trend_quality_clean_bonus elif trend_quality == TrendQuality.NORMAL: - reasons.append("Нормальное качество тренда") + score += self._market_trend_quality_normal_bonus elif trend_quality == TrendQuality.NOISY: - reasons.append("Движение шумное") + score -= self._market_trend_quality_noisy_penalty if market_phase == MarketPhase.IMPULSE: - reasons.append("Фаза импульса") + score += self._market_phase_impulse_bonus elif market_phase == MarketPhase.PULLBACK: - reasons.append("Фаза отката") + score -= self._market_phase_pullback_penalty elif market_phase == MarketPhase.RANGE: - reasons.append("Фаза флэта") + score -= self._market_phase_range_penalty elif market_phase == MarketPhase.SQUEEZE: - reasons.append("Фаза сжатия") + score -= self._market_phase_squeeze_penalty - if momentum_state == MomentumState.BREAKOUT_UP: - reasons.append("Пробой вверх") - elif momentum_state == MomentumState.BREAKOUT_DOWN: - reasons.append("Пробой вниз") - elif momentum_state == MomentumState.MOMENTUM_UP: - reasons.append("Импульс вверх") - elif momentum_state == MomentumState.MOMENTUM_DOWN: - reasons.append("Импульс вниз") - elif momentum_state == MomentumState.NONE: - reasons.append("Сильного импульса нет") + if market_structure in {MarketStructure.HH_HL, MarketStructure.LH_LL}: + score += self._market_structure_bonus + elif market_structure == MarketStructure.MIXED: + score -= self._market_structure_mixed_penalty - if ema_distance_state == EmaDistanceState.COMPRESSED: - reasons.append("EMA сильно сжаты") - elif ema_distance_state == EmaDistanceState.HEALTHY: - reasons.append("EMA-дистанция здоровая") - elif ema_distance_state == EmaDistanceState.EXTENDED: - reasons.append("Тренд расширен") - elif ema_distance_state == EmaDistanceState.OVEREXTENDED: - reasons.append("Тренд перерастянут") + if momentum_state in {MomentumState.BREAKOUT_UP, MomentumState.BREAKOUT_DOWN}: + score += self._market_breakout_bonus + elif momentum_state in {MomentumState.MOMENTUM_UP, MomentumState.MOMENTUM_DOWN}: + score += self._market_momentum_bonus + elif momentum_state == MomentumState.EXHAUSTED: + score -= self._market_exhausted_penalty + elif momentum_direction == TrendDirection.FLAT: + score -= self._market_flat_momentum_penalty - if entry_timing_state == EntryTimingState.EARLY: - reasons.append("Ранняя зона входа") - elif entry_timing_state == EntryTimingState.NORMAL: - reasons.append("Тайминг входа нормальный") - elif entry_timing_state == EntryTimingState.LATE: - reasons.append("Поздний вход") - elif entry_timing_state == EntryTimingState.CHASING: - reasons.append("Вход запрещён: chasing move") + current_change = safe_float(current_interval_change_percent) or 0.0 - if ( - candle_noise_score is not None - and candle_noise_score < self._min_clean_candle_score - ): - reasons.append("Свечи шумные") - - if ( - price_position_score is not None - and price_position_score >= self._min_price_position_score - ): - reasons.append("Цена держится по тренду") - elif ( - price_position_score is not None - and price_position_score < self._min_price_position_score - ): - reasons.append("Цена плохо держится по тренду") - - if volatility == VolatilityState.HIGH: - reasons.append("Высокая волатильность") - elif volatility == VolatilityState.LOW: - reasons.append("Низкая волатильность") - elif volatility == VolatilityState.NORMAL: - reasons.append("Нормальная волатильность") - - rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else "" - - return ( - f"{'. '.join(reasons)}. " - f"ATR={atr_percent:.2f}%{rsi_text}." - ) - - def _unknown( - self, - *, - symbol: str, - interval: str, - reason: str, - candles_count: int = 0, - ) -> MarketAnalysisResult: - return MarketAnalysisResult( - symbol=symbol, - interval=interval, - state=MarketState.UNKNOWN, - trend=TrendDirection.UNKNOWN, - volatility=VolatilityState.UNKNOWN, - close_price=None, - ema_fast=None, - ema_slow=None, - atr=None, - atr_percent=None, - rsi=None, - candles_count=candles_count, - reason=reason, - is_trade_allowed=False, - payload={ - "symbol": symbol, - "interval": interval, - "market_state": MarketState.UNKNOWN.value, - "trend": TrendDirection.UNKNOWN.value, - "volatility": VolatilityState.UNKNOWN.value, - "market_trend_strength": TrendStrength.UNKNOWN.value, - "market_trend_quality": TrendQuality.UNKNOWN.value, - "market_phase": MarketPhase.UNKNOWN.value, - "market_phase_direction": TrendDirection.UNKNOWN.value, - "market_phase_change_percent": None, - "market_phase_direction_consistency": None, - "market_phase_reason": reason, - "momentum_state": MomentumState.UNKNOWN.value, - "momentum_direction": TrendDirection.UNKNOWN.value, - "momentum_change_percent": None, - "momentum_strength": None, - "breakout_level": None, - "breakout_distance_percent": None, - "breakout_reason": reason, - "market_trend_gap_percent": None, - "ema_fast_slope_percent": None, - "ema_slow_slope_percent": None, - "market_trend_consistency": None, - "market_trend_efficiency": None, - "trend_quality_score": None, - "ema_distance_atr_ratio": None, - "ema_distance_state": EmaDistanceState.UNKNOWN.value, - "entry_timing_state": EntryTimingState.UNKNOWN.value, - "entry_timing_reason": reason, - "htf_interval": self._htf_interval, - "htf_atr_percent": None, - "htf_atr_percent_baseline": None, - "htf_volatility_ratio": None, - "htf_volatility": None, - "htf_reason": reason, - "candles_count": candles_count, - "is_trade_allowed": False, - "reason": reason, - }, - trend_strength=TrendStrength.UNKNOWN, - trend_quality=TrendQuality.UNKNOWN, - market_phase=MarketPhase.UNKNOWN, - trend_gap_percent=None, - trend_consistency=None, - trend_efficiency=None, - ema_distance_atr_ratio=None, - phase_direction=TrendDirection.UNKNOWN, - phase_change_percent=None, - phase_reason=reason, - ema_fast_slope_percent=None, - ema_slow_slope_percent=None, - phase_direction_consistency=None, - momentum_state=MomentumState.UNKNOWN, - momentum_direction=TrendDirection.UNKNOWN, - momentum_change_percent=None, - momentum_strength=None, - breakout_level=None, - breakout_distance_percent=None, - breakout_reason=reason, - htf_interval=self._htf_interval, - htf_atr_percent=None, - htf_atr_percent_baseline=None, - htf_volatility_ratio=None, - htf_volatility=None, - trend_quality_score=None, - ema_distance_state=EmaDistanceState.UNKNOWN, - entry_timing_state=EntryTimingState.UNKNOWN, - entry_timing_reason=reason, - ) - - def _trend_quality_score( - self, - *, - trend_consistency: float | None, - trend_efficiency: float | None, - candle_noise_score: float | None, - price_position_score: float | None, - ) -> float | None: - values: list[float] = [] - - if trend_consistency is not None: - values.append(trend_consistency) - - if trend_efficiency is not None: - values.append(trend_efficiency) - - if candle_noise_score is not None: - values.append(candle_noise_score) - - if price_position_score is not None: - values.append(price_position_score) - - if not values: - return None - - return sum(values) / len(values) - - def _classify_ema_distance_state( - self, - ema_distance_atr_ratio: float | None, - ) -> EmaDistanceState: - if ema_distance_atr_ratio is None: - return EmaDistanceState.UNKNOWN - - if ema_distance_atr_ratio < 0.45: - return EmaDistanceState.COMPRESSED - - if ema_distance_atr_ratio < 1.8: - return EmaDistanceState.HEALTHY - - if ema_distance_atr_ratio < 2.8: - return EmaDistanceState.EXTENDED - - return EmaDistanceState.OVEREXTENDED - - def _classify_entry_timing( - self, - *, - ema_distance_state: EmaDistanceState, - momentum_state: MomentumState, - momentum_strength: float | None, - market_phase: MarketPhase, - ) -> tuple[EntryTimingState, str]: - strength = momentum_strength or 0.0 - - if ema_distance_state == EmaDistanceState.OVEREXTENDED: - return EntryTimingState.CHASING, "EMA_OVEREXTENDED" - - if ( - ema_distance_state == EmaDistanceState.EXTENDED - and momentum_state in { - MomentumState.BREAKOUT_UP, - MomentumState.BREAKOUT_DOWN, - } - and strength >= 1.5 - ): - return EntryTimingState.LATE, "BREAKOUT_ALREADY_EXTENDED" - - if market_phase == MarketPhase.PULLBACK: - return EntryTimingState.EARLY, "PULLBACK_ENTRY_ZONE" + if current_interval_direction in {TrendDirection.UP, TrendDirection.DOWN}: + if abs(current_change) >= 0.08: + score += self._market_current_candle_large_bonus + elif abs(current_change) >= 0.05: + score += self._market_current_candle_medium_bonus + elif abs(current_change) >= 0.03: + score += self._market_current_candle_small_bonus if ema_distance_state == EmaDistanceState.HEALTHY: - return EntryTimingState.NORMAL, "HEALTHY_TREND_DISTANCE" + score += self._market_ema_healthy_bonus + elif ema_distance_state == EmaDistanceState.COMPRESSED: + score -= self._market_ema_compressed_penalty + elif ema_distance_state == EmaDistanceState.EXTENDED: + score -= self._market_ema_extended_penalty + elif ema_distance_state == EmaDistanceState.OVEREXTENDED: + score -= self._market_ema_overextended_penalty - if ema_distance_state == EmaDistanceState.COMPRESSED: - return EntryTimingState.UNKNOWN, "EMA_COMPRESSED" + if entry_timing_state == EntryTimingState.NORMAL: + score += self._market_entry_normal_bonus + elif entry_timing_state == EntryTimingState.EARLY: + score -= self._market_entry_early_penalty + elif entry_timing_state == EntryTimingState.LATE: + score -= self._market_entry_late_penalty + elif entry_timing_state == EntryTimingState.CHASING: + score -= self._market_entry_chasing_penalty - return EntryTimingState.UNKNOWN, "ENTRY_TIMING_UNKNOWN" \ No newline at end of file + htf_alignment_upper = str(htf_alignment or "").upper() + + if htf_alignment_upper == "ALIGNED": + score += self._market_htf_aligned_bonus + elif htf_alignment_upper == "SAME_INTERVAL": + score += self._market_htf_same_tf_bonus + elif htf_alignment_upper == "NEUTRAL": + score -= self._market_htf_neutral_penalty + elif htf_alignment_upper == "AGAINST": + score -= self._market_htf_against_penalty + elif htf_alignment_upper == "UNKNOWN": + score -= self._market_htf_unknown_penalty + + # Если локально RANGE, но есть импульс/откат по направлению HTF, + # не режем рынок слишком жёстко. + if ( + state == MarketState.RANGE + and market_phase in {MarketPhase.PULLBACK, MarketPhase.IMPULSE} + and htf_alignment_upper in {"ALIGNED", "SAME_INTERVAL"} + ): + score += self._market_range_with_htf_bonus + + if htf_confirmation_score is not None: + if htf_confirmation_score >= 0.75: + score += self._market_htf_confirmation_bonus + elif htf_confirmation_score < self._min_htf_confirmation_score: + score -= self._market_htf_confirmation_penalty + + if not is_trade_allowed: + score -= self._market_trade_not_allowed_penalty + + # Шумный рынок не должен выглядеть как идеальный, + # даже если тренд и HTF совпадают. + if trend_quality == TrendQuality.NOISY: + score = min(score, 74) + + # Если вход запрещён market-фильтром, + # рынок не должен быть "отличным". + if not is_trade_allowed: + score = min(score, 69) + + return max(0, min(100, int(round(score)))) + + def _market_score_label(self, score: int) -> str: + if score >= 90: + return "отличный" + + if score >= 75: + return "благоприятный" + + if score >= 55: + return "нейтральный" + + if score >= 35: + return "сложный" + + return "неблагоприятный" + + def _entry_score( + self, + *, + side: EntrySide, + market_score: int, + trend: TrendDirection, + momentum_direction: TrendDirection, + market_structure: MarketStructure, + current_interval_direction: TrendDirection, + current_interval_change_percent: float | None, + htf_alignment: str, + ) -> int: + """ + Направленная оценка входа. + + market_score — общий фон рынка. + entry_score — оценка конкретного направления: + Long или Short. + + Поэтому Long/Short могут сильно отличаться даже при одном общем рынке. + """ + score = market_score + + expected_direction = ( + TrendDirection.UP + if side == EntrySide.BUY + else TrendDirection.DOWN + ) + opposite_direction = ( + TrendDirection.DOWN + if side == EntrySide.BUY + else TrendDirection.UP + ) + + positive_structure = ( + MarketStructure.HH_HL + if side == EntrySide.BUY + else MarketStructure.LH_LL + ) + negative_structure = ( + MarketStructure.LH_LL + if side == EntrySide.BUY + else MarketStructure.HH_HL + ) + + if trend == expected_direction: + score += self._entry_trend_bonus + elif trend == opposite_direction: + score -= self._entry_trend_penalty + + if momentum_direction == expected_direction: + score += self._entry_momentum_bonus + elif momentum_direction == opposite_direction: + score -= self._entry_momentum_penalty + + if market_structure == positive_structure: + score += self._entry_structure_bonus + elif market_structure == negative_structure: + score -= self._entry_structure_penalty + + if current_interval_direction == expected_direction: + score += self._entry_current_candle_bonus + elif current_interval_direction == opposite_direction: + score -= self._entry_current_candle_penalty + + current_change = abs(current_interval_change_percent or 0.0) + + if ( + current_interval_direction == expected_direction + and current_change >= 0.08 + ): + score += self._entry_strong_current_move_bonus + + if str(htf_alignment or "").upper() == "AGAINST": + score -= self._entry_htf_against_penalty + + return max(0, min(100, int(round(score)))) + + def _candle_change_percent( + self, + candles, + *, + index: int, + ) -> float: + """ + Изменение конкретной свечи в процентах. + Для API Dzengi: + index=-1 — текущая формирующаяся свеча. + index=-2 — последняя полностью закрытая свеча. + """ + if not candles: + return 0.0 + + try: + candle = candles[index] + except IndexError: + return 0.0 + + open_price = safe_float(getattr(candle, "open_price", None)) + close_price = safe_float(getattr(candle, "close_price", None)) + + if open_price is None or open_price <= 0: + return 0.0 + + if close_price is None or close_price <= 0: + return 0.0 + + return round(((close_price - open_price) / open_price) * 100, 5) \ No newline at end of file diff --git a/app/src/trading/market_analysis/state.py b/app/src/trading/market_analysis/state.py new file mode 100644 index 0000000..c4bc029 --- /dev/null +++ b/app/src/trading/market_analysis/state.py @@ -0,0 +1,104 @@ +# app/src/trading/market_analysis/state.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + MarketPhase, + MarketState, + MomentumState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def classify_market_state( + *, + trend: TrendDirection, + volatility: VolatilityState, + trend_strength: TrendStrength, + trend_quality: TrendQuality, + market_phase: MarketPhase, + momentum_state: MomentumState, + momentum_direction: TrendDirection, + ema_fast_slope_percent: float | None, + ema_slow_slope_percent: float | None, + fast_slope_threshold_percent: float, + slow_slope_threshold_percent: float, + candle_noise_score: float | None, + price_position_score: float | None, + min_clean_candle_score: float, + min_price_position_score: float, +) -> MarketState: + fast_slope = ema_fast_slope_percent or 0.0 + range_slope_threshold_percent = max( + fast_slope_threshold_percent, + slow_slope_threshold_percent * 2, + ) + + if volatility == VolatilityState.HIGH: + return MarketState.HIGH_VOLATILITY + + if volatility == VolatilityState.LOW: + return MarketState.LOW_VOLATILITY + + if ( + trend == TrendDirection.UP + and momentum_state in {MomentumState.BREAKOUT_UP, MomentumState.MOMENTUM_UP} + and momentum_direction == TrendDirection.UP + and fast_slope > 0 + ): + return MarketState.TREND_UP + + if ( + trend == TrendDirection.DOWN + and momentum_state in {MomentumState.BREAKOUT_DOWN, MomentumState.MOMENTUM_DOWN} + and momentum_direction == TrendDirection.DOWN + and fast_slope < 0 + ): + return MarketState.TREND_DOWN + + if market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}: + return MarketState.RANGE + + if trend_strength == TrendStrength.WEAK: + return MarketState.RANGE + + if ( + trend_quality == TrendQuality.NOISY + and trend_strength != TrendStrength.STRONG + ): + return MarketState.RANGE + + if ( + candle_noise_score is not None + and candle_noise_score < min_clean_candle_score + and abs(fast_slope) < range_slope_threshold_percent + ): + return MarketState.RANGE + + if ( + price_position_score is not None + and price_position_score < min_price_position_score + and abs(fast_slope) < range_slope_threshold_percent + ): + return MarketState.RANGE + + if ( + trend == TrendDirection.UP + and trend_strength in {TrendStrength.NORMAL, TrendStrength.STRONG} + and momentum_direction in {TrendDirection.UP, TrendDirection.FLAT} + and fast_slope > 0 + ): + return MarketState.TREND_UP + + if ( + trend == TrendDirection.DOWN + and trend_strength in {TrendStrength.NORMAL, TrendStrength.STRONG} + and momentum_direction in {TrendDirection.DOWN, TrendDirection.FLAT} + and fast_slope < 0 + ): + return MarketState.TREND_DOWN + + return MarketState.RANGE \ No newline at end of file diff --git a/app/src/trading/market_analysis/structure.py b/app/src/trading/market_analysis/structure.py new file mode 100644 index 0000000..226394c --- /dev/null +++ b/app/src/trading/market_analysis/structure.py @@ -0,0 +1,157 @@ +# app/src/trading/market_analysis/structure.py + +from __future__ import annotations + +from collections.abc import Sequence + +from src.core.numbers import safe_float +from src.core.types import NumericLike +from src.integrations.exchange.models import Kline +from src.trading.market_analysis.models import MarketStructure + + +def structure_params( + *, + atr_percent: NumericLike | None, + candle_noise_score: NumericLike | None, + structure_window: int = 30, + structure_swing_left: int = 2, + structure_swing_right: int = 2, + min_clean_candle_score: float = 0.55, +) -> tuple[int, int, int]: + atr_value = safe_float(atr_percent) or 0.0 + noise_value = safe_float(candle_noise_score) + + window = structure_window + left = structure_swing_left + right = structure_swing_right + + if atr_value >= 0.9: + window = 50 + left = 3 + right = 3 + elif atr_value >= 0.45: + window = 40 + left = 3 + right = 2 + elif atr_value <= 0.18: + window = 24 + left = 2 + right = 2 + + if noise_value is not None and noise_value < min_clean_candle_score: + window = max(window, 45) + left = max(left, 3) + right = max(right, 3) + + return window, left, right + + +# определить структуру рынка по swing high / swing low: +# HH/HL = восходящая структура +# LH/LL = нисходящая структура +# MIXED = противоречивая структура +def market_structure( + candles: Sequence[Kline], + *, + atr_percent: NumericLike | None = None, + candle_noise_score: NumericLike | None = None, + structure_window: int = 30, + structure_swing_left: int = 2, + structure_swing_right: int = 2, + min_clean_candle_score: float = 0.55, +) -> tuple[MarketStructure, str]: + resolved_window, left, right = structure_params( + atr_percent=atr_percent, + candle_noise_score=candle_noise_score, + structure_window=structure_window, + structure_swing_left=structure_swing_left, + structure_swing_right=structure_swing_right, + min_clean_candle_score=min_clean_candle_score, + ) + + window = list(candles[-resolved_window:]) + min_required = max(10, left + right + 6) + + if len(window) < min_required: + return MarketStructure.UNKNOWN, "STRUCTURE_NOT_ENOUGH_CANDLES" + + swing_highs: list[float] = [] + swing_lows: list[float] = [] + + for index in range(left, len(window) - right): + current = window[index] + + previous_items = window[index - left:index] + next_items = window[index + 1:index + 1 + right] + + high = safe_float(current.high_price) + low = safe_float(current.low_price) + + if high is None or low is None: + continue + + neighbor_highs: list[float] = [] + neighbor_lows: list[float] = [] + + for item in previous_items + next_items: + item_high = safe_float(item.high_price) + item_low = safe_float(item.low_price) + + if item_high is not None: + neighbor_highs.append(item_high) + + if item_low is not None: + neighbor_lows.append(item_low) + + if len(neighbor_highs) != left + right: + continue + + if len(neighbor_lows) != left + right: + continue + + if all(high > item_high for item_high in neighbor_highs): + swing_highs.append(high) + + if all(low < item_low for item_low in neighbor_lows): + swing_lows.append(low) + + if len(swing_highs) < 2 or len(swing_lows) < 2: + return ( + MarketStructure.UNKNOWN, + f"STRUCTURE_NOT_ENOUGH_SWINGS:" + f"window={resolved_window}:left={left}:right={right}:" + f"highs={len(swing_highs)}:lows={len(swing_lows)}", + ) + + last_high = swing_highs[-1] + prev_high = swing_highs[-2] + + last_low = swing_lows[-1] + prev_low = swing_lows[-2] + + has_hh = last_high > prev_high + has_hl = last_low > prev_low + + has_lh = last_high < prev_high + has_ll = last_low < prev_low + + if has_hh and has_hl: + return ( + MarketStructure.HH_HL, + f"HIGHER_HIGH_HIGHER_LOW:" + f"window={resolved_window}:left={left}:right={right}", + ) + + if has_lh and has_ll: + return ( + MarketStructure.LH_LL, + f"LOWER_HIGH_LOWER_LOW:" + f"window={resolved_window}:left={left}:right={right}", + ) + + return ( + MarketStructure.MIXED, + f"MIXED_MARKET_STRUCTURE:" + f"window={resolved_window}:left={left}:right={right}", + ) \ No newline at end of file diff --git a/app/src/trading/market_analysis/unknown.py b/app/src/trading/market_analysis/unknown.py new file mode 100644 index 0000000..4b90135 --- /dev/null +++ b/app/src/trading/market_analysis/unknown.py @@ -0,0 +1,182 @@ +# app/src/trading/market_analysis/unknown.py + +from __future__ import annotations + +from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, + MarketAnalysisResult, + MarketPhase, + MarketState, + MarketStructure, + MomentumState, + TrendDirection, + TrendQuality, + TrendStrength, + VolatilityState, +) + + +def build_unknown_market_analysis_result( + *, + symbol: str, + interval: str, + reason: str, + candles_count: int = 0, + htf_interval: str = "1h", +) -> MarketAnalysisResult: + # UNKNOWN-result используется, когда полноценный анализ невозможен: + # нет свечей, ошибка API, мало данных или не рассчитались индикаторы. + # + # Важно: payload должен содержать те же ключи, что и обычный market payload, + # чтобы formatter/snapshot/runtime не падали на отсутствующих полях. + payload = { + "symbol": symbol, + "interval": interval, + "market_state": MarketState.UNKNOWN.value, + "trend": TrendDirection.UNKNOWN.value, + "volatility": VolatilityState.UNKNOWN.value, + "market_trend_strength": TrendStrength.UNKNOWN.value, + "market_trend_quality": TrendQuality.UNKNOWN.value, + "market_phase": MarketPhase.UNKNOWN.value, + "market_phase_direction": TrendDirection.UNKNOWN.value, + "market_phase_change_percent": None, + "market_phase_direction_consistency": None, + "market_phase_reason": reason, + + # Текущая свеча / текущий интервал. + "current_interval_change_percent": None, + "current_interval_direction": TrendDirection.UNKNOWN.value, + "current_interval_label": interval, + + # Последняя закрытая свеча. + "last_closed_candle_change_percent": None, + "last_closed_candle_direction": TrendDirection.UNKNOWN.value, + + "market_structure": MarketStructure.UNKNOWN.value, + "market_structure_reason": reason, + + "momentum_state": MomentumState.UNKNOWN.value, + "momentum_direction": TrendDirection.UNKNOWN.value, + "momentum_change_percent": None, + "momentum_strength": None, + "breakout_level": None, + "breakout_distance_percent": None, + "breakout_reason": reason, + + "market_trend_gap_percent": None, + "ema_fast_slope_percent": None, + "ema_slow_slope_percent": None, + "market_trend_consistency": None, + "market_trend_efficiency": None, + "trend_quality_score": None, + "ema_distance_atr_ratio": None, + "ema_distance_state": EmaDistanceState.UNKNOWN.value, + "entry_timing_state": EntryTimingState.UNKNOWN.value, + "entry_timing_reason": reason, + + "close_price": None, + "ema_fast": None, + "ema_slow": None, + "atr": None, + "atr_percent": None, + "rsi": None, + + "market_score": None, + "market_score_label": None, + "market_long_score": None, + "market_short_score": None, + + "htf_interval": htf_interval, + "htf_atr_percent": None, + "htf_atr_percent_baseline": None, + "htf_volatility_ratio": None, + "htf_volatility": None, + "htf_market_state": MarketState.UNKNOWN.value, + "htf_trend": TrendDirection.UNKNOWN.value, + "htf_trend_strength": TrendStrength.UNKNOWN.value, + "htf_trend_quality": TrendQuality.UNKNOWN.value, + "htf_market_phase": MarketPhase.UNKNOWN.value, + "htf_alignment": "UNKNOWN", + "htf_confirmation_score": None, + "htf_reason": reason, + + "candles_count": candles_count, + "is_trade_allowed": False, + "reason": reason, + } + + return MarketAnalysisResult( + symbol=symbol, + interval=interval, + state=MarketState.UNKNOWN, + trend=TrendDirection.UNKNOWN, + volatility=VolatilityState.UNKNOWN, + close_price=None, + ema_fast=None, + ema_slow=None, + atr=None, + atr_percent=None, + rsi=None, + candles_count=candles_count, + reason=reason, + is_trade_allowed=False, + payload=payload, + + trend_strength=TrendStrength.UNKNOWN, + trend_quality=TrendQuality.UNKNOWN, + market_phase=MarketPhase.UNKNOWN, + + trend_gap_percent=None, + trend_consistency=None, + trend_efficiency=None, + ema_distance_atr_ratio=None, + + phase_direction=TrendDirection.UNKNOWN, + phase_change_percent=None, + phase_reason=reason, + + market_structure=MarketStructure.UNKNOWN, + market_structure_reason=reason, + + ema_fast_slope_percent=None, + ema_slow_slope_percent=None, + phase_direction_consistency=None, + + current_interval_change_percent=None, + current_interval_direction=TrendDirection.UNKNOWN, + current_interval_label=interval, + + momentum_state=MomentumState.UNKNOWN, + momentum_direction=TrendDirection.UNKNOWN, + momentum_change_percent=None, + momentum_strength=None, + breakout_level=None, + breakout_distance_percent=None, + breakout_reason=reason, + + htf_interval=htf_interval, + htf_atr_percent=None, + htf_atr_percent_baseline=None, + htf_volatility_ratio=None, + htf_volatility=None, + + trend_quality_score=None, + ema_distance_state=EmaDistanceState.UNKNOWN, + entry_timing_state=EntryTimingState.UNKNOWN, + entry_timing_reason=reason, + + htf_market_state=MarketState.UNKNOWN, + htf_trend=TrendDirection.UNKNOWN, + htf_trend_strength=TrendStrength.UNKNOWN, + htf_trend_quality=TrendQuality.UNKNOWN, + htf_market_phase=MarketPhase.UNKNOWN, + htf_alignment="UNKNOWN", + htf_confirmation_score=None, + htf_reason=reason, + + market_score=None, + market_score_label=None, + market_long_score=None, + market_short_score=None, + ) \ No newline at end of file diff --git a/app/src/trading/strategies/scalp.py b/app/src/trading/strategies/scalp.py index 8606b88..44ebe28 100644 --- a/app/src/trading/strategies/scalp.py +++ b/app/src/trading/strategies/scalp.py @@ -1,10 +1,19 @@ # app/src/trading/strategies/scalp.py - + from __future__ import annotations import time +from typing import Any from src.integrations.exchange.service import ExchangeService +from src.trading.market_analysis.models import ( + MarketState, + MarketStructure, + MomentumState, + TrendDirection, + VolatilityState, +) +from src.trading.market_analysis.service import MarketAnalysisService from src.trading.strategies.base import StrategyContext from src.trading.strategies.signals import SignalResult, SignalType @@ -16,15 +25,13 @@ class ScalpStrategy: _window_ttl_seconds = 30 _price_window_updated_at: dict[str, float] = {} - # короткое окно = быстрая реакция _window_size = 4 - - # ниже порог = чувствительнее TREND _threshold_percent = 0.02 - - # для scalp допускаем чуть больше шума _min_direction_ratio = 0.55 + # SCALP быстрее TREND, но всё равно использует market-analysis фильтры. + _market_interval = "1m" + def reset_runtime(self, symbol: str | None = None) -> None: if symbol is None: self._price_window.clear() @@ -42,24 +49,49 @@ class ScalpStrategy: self._price_window_updated_at.pop(key, None) def analyze(self, context: StrategyContext) -> SignalResult: + market = MarketAnalysisService().analyze( + context.symbol, + interval=self._market_interval, + limit=200, + ) + try: - ticker = ExchangeService().get_price(context.symbol) + snapshot = ExchangeService().get_market_snapshot( + context.symbol, + runtime_key="auto", + ) except Exception as exc: return SignalResult( signal=SignalType.HOLD, - reason="Не удалось получить рыночную цену. Безопасный HOLD.", + reason="Не удалось получить рыночный snapshot. Безопасный HOLD.", confidence=0.0, payload={ "strategy": self.name, "symbol": context.symbol, "error": str(exc), - "entry_block_reason": "MARKET_PRICE_ERROR", + "market_analysis": market.payload, + "entry_block_reason": "MARKET_SNAPSHOT_ERROR", "entry_block_message": "нет данных рынка", }, ) - symbol = ticker.symbol - current_price = float(ticker.price) + symbol = str(snapshot.get("symbol") or context.symbol) + current_price = self._analysis_price(snapshot) + + if current_price <= 0: + return SignalResult( + signal=SignalType.HOLD, + reason="Некорректная рыночная цена. Безопасный HOLD.", + confidence=0.0, + payload={ + "strategy": self.name, + "symbol": symbol, + "snapshot": snapshot, + "market_analysis": market.payload, + "entry_block_reason": "INVALID_MARKET_PRICE", + "entry_block_message": "нет цены", + }, + ) now = time.monotonic() previous_updated_at = self._price_window_updated_at.get(symbol) @@ -69,6 +101,7 @@ class ScalpStrategy: and now - previous_updated_at > self._window_ttl_seconds ): self._price_window.pop(symbol, None) + self._price_window_updated_at.pop(symbol, None) prices = self._price_window.setdefault(symbol, []) prices.append(current_price) @@ -77,18 +110,34 @@ class ScalpStrategy: if len(prices) > self._window_size: prices.pop(0) - base_payload = { - "strategy": self.name, - "symbol": symbol, - "price": current_price, - "runtime_window_ttl_seconds": self._window_ttl_seconds, - "runtime_window_size": len(prices), - } + base_payload = self._base_payload( + symbol=symbol, + current_price=current_price, + snapshot=snapshot, + market=market, + prices=prices, + ) + + market_block = self._market_block_signal( + market=market, + base_payload=base_payload, + ) + + if market_block is not None: + return market_block + + breakout_signal = self._breakout_signal( + market=market, + base_payload=base_payload, + ) + + if breakout_signal is not None: + return breakout_signal if len(prices) < self._window_size: return SignalResult( signal=SignalType.HOLD, - reason="Недостаточно данных для SCALP.", + reason="Недостаточно live-данных для SCALP.", confidence=0.0, payload={ **base_payload, @@ -129,47 +178,334 @@ class ScalpStrategy: "min_direction_ratio": self._min_direction_ratio, } - if ( - change_percent >= self._threshold_percent - and direction_ratio >= self._min_direction_ratio - ): + if market.state == MarketState.TREND_UP: + if ( + change_percent >= self._threshold_percent + and direction_ratio >= self._min_direction_ratio + ): + return SignalResult( + signal=SignalType.BUY, + reason="SCALP BUY подтверждён трендом и коротким импульсом.", + confidence=self._calculate_confidence(change_percent, direction_ratio), + payload=payload, + ) + return SignalResult( - signal=SignalType.BUY, - reason="Быстрый краткосрочный импульс вверх.", - confidence=self._calculate_confidence(change_percent, direction_ratio), - payload=payload, + signal=SignalType.HOLD, + reason="SCALP: тренд вверх есть, но короткий импульс слабый.", + confidence=0.0, + payload={ + **payload, + "entry_block_reason": "WEAK_UP_IMPULSE", + "entry_block_message": "слабый импульс", + "expected_direction": "BUY", + }, ) - if ( - change_percent <= -self._threshold_percent - and direction_ratio >= self._min_direction_ratio - ): - return SignalResult( - signal=SignalType.SELL, - reason="Быстрый краткосрочный импульс вниз.", - confidence=self._calculate_confidence(change_percent, direction_ratio), - payload=payload, - ) + if market.state == MarketState.TREND_DOWN: + if ( + change_percent <= -self._threshold_percent + and direction_ratio >= self._min_direction_ratio + ): + return SignalResult( + signal=SignalType.SELL, + reason="SCALP SELL подтверждён трендом и коротким импульсом.", + confidence=self._calculate_confidence(change_percent, direction_ratio), + payload=payload, + ) - expected_direction = "BUY" if change_percent >= 0 else "SELL" - entry_block_reason = ( - "WEAK_UP_IMPULSE" - if expected_direction == "BUY" - else "WEAK_DOWN_IMPULSE" - ) + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP: тренд вниз есть, но короткий импульс слабый.", + confidence=0.0, + payload={ + **payload, + "entry_block_reason": "WEAK_DOWN_IMPULSE", + "entry_block_message": "слабый импульс", + "expected_direction": "SELL", + }, + ) return SignalResult( signal=SignalType.HOLD, - reason="SCALP-импульс недостаточно сильный.", + reason=f"Market state не подходит для SCALP: {market.state.value}.", confidence=0.0, payload={ **payload, - "entry_block_reason": entry_block_reason, - "entry_block_message": "слабый импульс", - "expected_direction": expected_direction, + "entry_block_reason": "MARKET_STATE_NOT_TREND", + "entry_block_message": "рынок не трендовый", }, ) + def _market_block_signal( + self, + *, + market: Any, + base_payload: dict[str, Any], + ) -> SignalResult | None: + if market.volatility != VolatilityState.NORMAL: + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP заблокирован: волатильность не NORMAL.", + confidence=0.0, + payload={ + **base_payload, + "entry_block_reason": "BAD_SCALP_VOLATILITY", + "entry_block_message": "волатильность не подходит", + }, + ) + + if market.htf_alignment == "AGAINST": + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP заблокирован: старший таймфрейм против входа.", + confidence=0.0, + payload={ + **base_payload, + "entry_block_reason": "HTF_TREND_AGAINST", + "entry_block_message": "старший таймфрейм против входа", + }, + ) + + if market.state not in {MarketState.TREND_UP, MarketState.TREND_DOWN}: + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP заблокирован: нет трендового market state.", + confidence=0.0, + payload={ + **base_payload, + "entry_block_reason": "MARKET_STATE_NOT_TREND", + "entry_block_message": "рынок не трендовый", + }, + ) + + market_structure = ( + market.market_structure.value + if market.market_structure is not None + else "UNKNOWN" + ) + + if market_structure == MarketStructure.MIXED.value: + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP заблокирован: структура рынка смешанная.", + confidence=0.0, + payload={ + **base_payload, + "entry_block_reason": "MARKET_STRUCTURE_MIXED", + "entry_block_message": "структура не подтверждает вход", + }, + ) + + if market.state == MarketState.TREND_UP and market_structure == MarketStructure.LH_LL.value: + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP заблокирован: структура против LONG.", + confidence=0.0, + payload={ + **base_payload, + "entry_block_reason": "MARKET_STRUCTURE_CONFLICT", + "entry_block_message": "структура против LONG", + "expected_direction": "BUY", + }, + ) + + if market.state == MarketState.TREND_DOWN and market_structure == MarketStructure.HH_HL.value: + return SignalResult( + signal=SignalType.HOLD, + reason="SCALP заблокирован: структура против SHORT.", + confidence=0.0, + payload={ + **base_payload, + "entry_block_reason": "MARKET_STRUCTURE_CONFLICT", + "entry_block_message": "структура против SHORT", + "expected_direction": "SELL", + }, + ) + + return None + + def _breakout_signal( + self, + *, + market: Any, + base_payload: dict[str, Any], + ) -> SignalResult | None: + momentum_state = getattr(market, "momentum_state", MomentumState.UNKNOWN) + momentum_direction = getattr(market, "momentum_direction", TrendDirection.UNKNOWN) + momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0) + + if ( + momentum_state == MomentumState.BREAKOUT_UP + and momentum_direction == TrendDirection.UP + and market.state == MarketState.TREND_UP + ): + return SignalResult( + signal=SignalType.BUY, + reason="SCALP BUY по подтверждённому breakout вверх.", + confidence=self._calculate_breakout_confidence(momentum_strength), + payload={ + **base_payload, + "breakout_signal": True, + "expected_direction": "BUY", + "entry_block_reason": None, + "entry_block_message": None, + }, + ) + + if ( + momentum_state == MomentumState.BREAKOUT_DOWN + and momentum_direction == TrendDirection.DOWN + and market.state == MarketState.TREND_DOWN + ): + return SignalResult( + signal=SignalType.SELL, + reason="SCALP SELL по подтверждённому breakout вниз.", + confidence=self._calculate_breakout_confidence(momentum_strength), + payload={ + **base_payload, + "breakout_signal": True, + "expected_direction": "SELL", + "entry_block_reason": None, + "entry_block_message": None, + }, + ) + + return None + + def _base_payload( + self, + *, + symbol: str, + current_price: float, + snapshot: dict[str, Any], + market: Any, + prices: list[float], + ) -> dict[str, Any]: + return { + "strategy": self.name, + "symbol": symbol, + "analysis_price": current_price, + "last_price": snapshot.get("last_price"), + "bid_price": snapshot.get("bid_price"), + "ask_price": snapshot.get("ask_price"), + "market_state": market.state.value, + "market_trend": market.trend.value, + "market_volatility": market.volatility.value, + "market_analysis_interval": market.interval, + "market_analysis_reason": market.reason, + "market_analysis": market.payload, + "market_trend_strength": market.trend_strength.value, + "market_trend_quality": market.trend_quality.value, + "market_phase": market.market_phase.value, + "market_phase_direction": market.phase_direction.value, + "market_phase_change_percent": market.phase_change_percent, + "market_phase_direction_consistency": market.phase_direction_consistency, + "market_phase_reason": market.phase_reason, + "market_structure": ( + market.market_structure.value + if market.market_structure is not None + else "UNKNOWN" + ), + "market_structure_reason": market.market_structure_reason, + "momentum_state": ( + market.momentum_state.value + if market.momentum_state is not None + else "UNKNOWN" + ), + "momentum_direction": ( + market.momentum_direction.value + if market.momentum_direction is not None + else "UNKNOWN" + ), + "momentum_change_percent": market.momentum_change_percent, + "momentum_strength": market.momentum_strength, + "breakout_level": market.breakout_level, + "breakout_distance_percent": market.breakout_distance_percent, + "breakout_reason": market.breakout_reason, + "market_trend_gap_percent": market.trend_gap_percent, + "market_trend_consistency": market.trend_consistency, + "market_trend_efficiency": market.trend_efficiency, + "trend_quality_score": market.trend_quality_score, + "ema_distance_atr_ratio": market.ema_distance_atr_ratio, + "ema_distance_state": ( + market.ema_distance_state.value + if market.ema_distance_state is not None + else "UNKNOWN" + ), + "entry_timing_state": ( + market.entry_timing_state.value + if market.entry_timing_state is not None + else "UNKNOWN" + ), + "entry_timing_reason": market.entry_timing_reason, + "candle_noise_score": market.payload.get("candle_noise_score"), + "price_position_score": market.payload.get("price_position_score"), + "rsi": market.rsi, + "rsi_overbought": market.payload.get("rsi_overbought"), + "rsi_oversold": market.payload.get("rsi_oversold"), + "htf_interval": market.htf_interval, + "htf_market_state": ( + market.htf_market_state.value + if market.htf_market_state is not None + else "UNKNOWN" + ), + "htf_trend": ( + market.htf_trend.value + if market.htf_trend is not None + else "UNKNOWN" + ), + "htf_trend_strength": ( + market.htf_trend_strength.value + if market.htf_trend_strength is not None + else "UNKNOWN" + ), + "htf_trend_quality": ( + market.htf_trend_quality.value + if market.htf_trend_quality is not None + else "UNKNOWN" + ), + "htf_market_phase": ( + market.htf_market_phase.value + if market.htf_market_phase is not None + else "UNKNOWN" + ), + "htf_alignment": market.htf_alignment, + "htf_confirmation_score": market.htf_confirmation_score, + "htf_reason": market.htf_reason, + "runtime_window_ttl_seconds": self._window_ttl_seconds, + "runtime_window_size": len(prices), + } + + def _analysis_price( + self, + snapshot: dict[str, Any], + ) -> float: + bid = self._safe_float(snapshot.get("bid_price")) + ask = self._safe_float(snapshot.get("ask_price")) + + if bid is not None and ask is not None and bid > 0 and ask > 0: + return (bid + ask) / 2 + + last = self._safe_float(snapshot.get("last_price")) + + if last is not None and last > 0: + return last + + return 0.0 + + def _safe_float( + self, + value: float | int | str | None, + ) -> float | None: + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + return None + def _direction_ratio(self, prices: list[float], change_percent: float) -> float: if len(prices) < 2: return 0.0 @@ -190,6 +526,12 @@ class ScalpStrategy: return down_moves / total_moves + def _calculate_breakout_confidence(self, momentum_strength: float) -> float: + strength_score = min(1.0, max(0.0, momentum_strength) / 2) + confidence = 0.55 + (strength_score * 0.35) + + return round(min(0.95, confidence), 2) + def _calculate_confidence( self, change_percent: float, diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py index b6ec495..e9b10cf 100644 --- a/app/src/trading/strategies/trend.py +++ b/app/src/trading/strategies/trend.py @@ -1,19 +1,21 @@ # app/src/trading/strategies/trend.py - + from __future__ import annotations import time - from typing import Any from src.integrations.exchange.service import ExchangeService from src.trading.market_analysis.models import ( + EmaDistanceState, + EntryTimingState, MarketPhase, MarketState, MomentumState, TrendDirection, TrendQuality, TrendStrength, + VolatilityState, ) from src.trading.market_analysis.service import MarketAnalysisService from src.trading.strategies.base import StrategyContext @@ -23,25 +25,50 @@ from src.trading.strategies.signals import SignalResult, SignalType class TrendStrategy: name = "TREND" + # Live-окно цен хранится в памяти стратегии. + # Оно нужно не для общего анализа рынка, а для подтверждения, + # что цена прямо сейчас действительно движется в нужную сторону. _price_window: dict[str, list[float]] = {} - _window_ttl_seconds = 60 _price_window_updated_at: dict[str, float] = {} - # короткое окно оставляем как дополнительное подтверждение импульса + # Через сколько секунд live-окно считается устаревшим. + _window_ttl_seconds = 60 + + # Сколько последних live-точек используем для подтверждения импульса. _window_size = 8 + + # Минимальное изменение цены внутри live-окна. + # 0.05 = 0.05%. _threshold_percent = 0.05 + + # Какая доля движений внутри окна должна идти в сторону сделки. + # 0.6 = минимум 60% шагов должны быть в сторону входа. _min_direction_ratio = 0.6 - # основной таймфрейм анализа рынка + # Основной таймфрейм анализа стратегии. _market_interval = "5m" + # PUBLIC API def reset_runtime(self, symbol: str | None = None) -> None: + """ + Сбрасывает runtime-память стратегии. + + Используется при: + - смене актива; + - смене стратегии; + - перезапуске автоторговли; + - ручном сбросе состояния. + + Если symbol=None — очищаем все live-окна. + Если symbol указан — очищаем только данные этого актива. + """ if symbol is None: self._price_window.clear() self._price_window_updated_at.clear() return normalized_symbol = symbol.upper() + keys_to_delete = [ key for key in self._price_window.keys() if key.upper() == normalized_symbol @@ -52,14 +79,164 @@ class TrendStrategy: self._price_window_updated_at.pop(key, None) def analyze(self, context: StrategyContext) -> SignalResult: + """ + Главная функция стратегии. + + Последовательность анализа: + + 1. Получаем market analysis по свечам 5m: + - состояние рынка; + - тренд; + - волатильность; + - momentum; + - breakout; + - структура рынка; + - EMA-дистанция; + - HTF-тренд 1h. + + 2. Получаем live snapshot: + - bid; + - ask; + - last price. + + 3. Рассчитываем рабочую цену анализа: + - если есть bid/ask — берём середину; + - иначе используем last price. + + 4. Обновляем live-окно последних цен. + + 5. Собираем base_payload: + - все market metrics; + - все причины блокировок; + - данные для UI, журнала и execution confidence. + + 6. Сначала проверяем breakout. + Это важно: после выхода из сжатия рынок ещё может выглядеть + как NOISY / COMPRESSED / RANGE, но первая сделка часто появляется + именно в этот момент. + + 7. Если breakout не найден — применяем защитные фильтры рынка. + + 8. Если рынок разрешён — ждём достаточное live-окно. + + 9. По live-окну подтверждаем направление: + - TREND_UP + live-импульс вверх = BUY; + - TREND_DOWN + live-импульс вниз = SELL; + - иначе HOLD. + + Важно: + Эта функция не открывает сделку. + Она только возвращает BUY / SELL / HOLD. + Сделку потом открывает ExecutionEngine, если сигнал подтвердился. + """ market = MarketAnalysisService().analyze( context.symbol, interval=self._market_interval, limit=200, ) + snapshot_result = self._snapshot_or_hold( + context=context, + market=market, + ) + + if isinstance(snapshot_result, SignalResult): + return snapshot_result + + snapshot = snapshot_result + symbol = str(snapshot.get("symbol") or context.symbol) + current_price = self._analysis_price(snapshot) + + if current_price <= 0: + return self._invalid_price_hold( + symbol=symbol, + snapshot=snapshot, + market=market, + ) + + prices = self._update_price_window( + symbol=symbol, + current_price=current_price, + ) + + base_payload = self._base_payload( + market=market, + symbol=symbol, + snapshot=snapshot, + current_price=current_price, + prices=prices, + ) + + # 1. Сначала проверяем пробой. + # Пробой — это исключение из обычной логики фильтров. + # Его нельзя ставить после market_block, иначе ранние входы + # из сжатия будут отсеиваться как COMPRESSED / NOISY / RANGE. + breakout_signal = self._breakout_signal( + market=market, + base_payload=base_payload, + ) + + if breakout_signal is not None: + return breakout_signal + + # 2. Если пробоя нет — включаем обычную защиту. + # Здесь отсекаются плохие условия: + # - рынок не в тренде; + # - волатильность плохая; + # - HTF против входа; + # - momentum не подтверждает; + # - структура против входа; + # - поздний вход. + market_block = self._market_block_signal( + market=market, + base_payload=base_payload, + ) + + if market_block is not None: + return market_block + + # 3. Если рынок хороший, но live-данных ещё мало, + # не открываем сделку вслепую. + if len(prices) < self._window_size: + return self._hold( + reason="Недостаточно live-данных для подтверждения TREND.", + block_reason="NOT_ENOUGH_LIVE_DATA", + block_message="мало данных", + payload={ + **base_payload, + "window_size": len(prices), + "required_window_size": self._window_size, + }, + ) + + # 4. Подтверждаем направление по live-движению. + return self._live_trend_signal( + market=market, + base_payload=base_payload, + prices=prices, + ) + + # STEP 1. SNAPSHOT / PRICE + def _snapshot_or_hold( + self, + *, + context: StrategyContext, + market: Any, + ) -> dict[str, Any] | SignalResult: + """ + Получает live snapshot с биржи. + + Snapshot нужен для: + - актуальной цены; + - bid/ask; + - проверки spread дальше по цепочке; + - формирования payload для UI и журнала. + + Если snapshot получить нельзя — стратегия возвращает HOLD. + Это безопаснее, чем строить сигнал на устаревших свечах. + """ try: - snapshot = ExchangeService().get_market_snapshot( + return ExchangeService().get_market_snapshot( context.symbol, runtime_key="auto", ) @@ -69,6 +246,7 @@ class TrendStrategy: reason="Не удалось получить рыночный snapshot. Безопасный HOLD.", confidence=0.0, payload={ + **dict(market.payload or {}), "strategy": self.name, "symbol": context.symbol, "error": str(exc), @@ -78,24 +256,81 @@ class TrendStrategy: }, ) - symbol = str(snapshot.get("symbol") or context.symbol) - current_price = self._analysis_price(snapshot) + def _invalid_price_hold( + self, + *, + symbol: str, + snapshot: dict[str, Any], + market: Any, + ) -> SignalResult: + """ + Возвращает HOLD, если цена из snapshot некорректная. - if current_price <= 0: - return SignalResult( - signal=SignalType.HOLD, - reason="Некорректная рыночная цена. Безопасный HOLD.", - confidence=0.0, - payload={ - "strategy": self.name, - "symbol": symbol, - "snapshot": snapshot, - "market_analysis": market.payload, - "entry_block_reason": "INVALID_MARKET_PRICE", - "entry_block_message": "нет цены", - }, - ) + Без валидной цены нельзя: + - обновить live-окно; + - рассчитать импульс; + - рассчитать цену входа. + """ + return SignalResult( + signal=SignalType.HOLD, + reason="Некорректная рыночная цена. Безопасный HOLD.", + confidence=0.0, + payload={ + **dict(market.payload or {}), + "strategy": self.name, + "symbol": symbol, + "snapshot": snapshot, + "market_analysis": market.payload, + "entry_block_reason": "INVALID_MARKET_PRICE", + "entry_block_message": "нет цены", + }, + ) + def _analysis_price( + self, + snapshot: dict[str, Any], + ) -> float: + """ + Выбирает цену для анализа live-импульса. + + Приоритет: + 1. midpoint между bid и ask; + 2. last_price; + 3. 0.0, если цены нет. + + Midpoint лучше last_price, потому что меньше зависит + от случайного последнего трейда. + """ + bid = self._safe_float(snapshot.get("bid_price")) + ask = self._safe_float(snapshot.get("ask_price")) + + if bid is not None and ask is not None and bid > 0 and ask > 0: + return (bid + ask) / 2 + + last = self._safe_float(snapshot.get("last_price")) + if last is not None: + return last + + return 0.0 + + # STEP 2. LIVE PRICE WINDOW + def _update_price_window( + self, + *, + symbol: str, + current_price: float, + ) -> list[float]: + """ + Обновляет live-окно последних цен. + + Логика: + - если окно устарело по TTL — очищаем его; + - добавляем новую цену; + - если цен больше лимита — удаляем самую старую. + + Это окно показывает не общий тренд по свечам, + а краткосрочное движение прямо сейчас. + """ now = time.monotonic() previous_updated_at = self._price_window_updated_at.get(symbol) @@ -104,6 +339,7 @@ class TrendStrategy: and now - previous_updated_at > self._window_ttl_seconds ): self._price_window.pop(symbol, None) + self._price_window_updated_at.pop(symbol, None) prices = self._price_window.setdefault(symbol, []) prices.append(current_price) @@ -112,10 +348,41 @@ class TrendStrategy: if len(prices) > self._window_size: prices.pop(0) - market_phase = self._normalized_market_phase(market) - market_phase_direction = self._normalized_market_phase_direction(market) + return prices - base_payload = { + # STEP 3. PAYLOAD + def _base_payload( + self, + *, + market: Any, + symbol: str, + snapshot: dict[str, Any], + current_price: float, + prices: list[float], + ) -> dict[str, Any]: + """ + Собирает единый payload стратегии. + + Payload используется дальше в: + - AutoTradeState; + - Telegram UI; + - diagnostics; + - journal; + - execution confidence; + - supervisor block logs. + + Поэтому сюда кладём не только итоговый сигнал, + но и все промежуточные признаки рынка. + """ + return { + # Сначала переносим полный payload MarketAnalysisService. + # Это защищает от потери новых market-полей: + # market_long_score / market_short_score, + # last_closed_candle_change_percent, + # current_interval_change_percent и т.д. + **dict(market.payload or {}), + + # Ниже стратегия добавляет/переопределяет runtime-поля. "strategy": self.name, "symbol": symbol, "analysis_price": current_price, @@ -130,11 +397,39 @@ class TrendStrategy: "market_analysis": market.payload, "market_trend_strength": market.trend_strength.value, "market_trend_quality": market.trend_quality.value, - "market_phase": market_phase, - "market_phase_direction": market_phase_direction, + "market_phase": self._normalized_market_phase(market), + "market_phase_direction": self._normalized_market_phase_direction(market), "market_phase_change_percent": market.phase_change_percent, - "market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"), + "market_phase_direction_consistency": market.payload.get( + "market_phase_direction_consistency" + ), + "current_interval_change_percent": market.current_interval_change_percent, + "current_interval_direction": ( + market.current_interval_direction.value + if market.current_interval_direction is not None + else "UNKNOWN" + ), + "current_interval_label": market.current_interval_label or market.interval, + # Общая оценка рынка 0..100. + # Она рассчитана в MarketAnalysisService из всех рыночных факторов + # и дальше используется UI / diagnostics / execution / adaptive size. + "market_score": market.market_score, + "market_score_label": market.market_score_label, + "market_long_score": market.market_long_score, + "market_short_score": market.market_short_score, + "last_closed_candle_change_percent": market.payload.get( + "last_closed_candle_change_percent" + ), + "last_closed_candle_direction": market.payload.get( + "last_closed_candle_direction" + ), "market_phase_reason": market.phase_reason, + "market_structure": ( + market.market_structure.value + if market.market_structure is not None + else "UNKNOWN" + ), + "market_structure_reason": market.market_structure_reason, "momentum_state": ( market.momentum_state.value if market.momentum_state is not None @@ -152,91 +447,439 @@ class TrendStrategy: "breakout_reason": market.breakout_reason, "market_trend_gap_percent": market.trend_gap_percent, "market_trend_consistency": market.trend_consistency, + "market_trend_efficiency": market.trend_efficiency, + "trend_quality_score": market.trend_quality_score, + "ema_distance_atr_ratio": market.ema_distance_atr_ratio, + "ema_distance_state": ( + market.ema_distance_state.value + if market.ema_distance_state is not None + else "UNKNOWN" + ), + "entry_timing_state": ( + market.entry_timing_state.value + if market.entry_timing_state is not None + else "UNKNOWN" + ), + "entry_timing_reason": market.entry_timing_reason, + "candle_noise_score": market.payload.get("candle_noise_score"), + "price_position_score": market.payload.get("price_position_score"), + "rsi": market.rsi, + "rsi_overbought": market.payload.get("rsi_overbought"), + "rsi_oversold": market.payload.get("rsi_oversold"), + "htf_market_state": ( + market.htf_market_state.value + if market.htf_market_state is not None + else "UNKNOWN" + ), + "htf_trend": ( + market.htf_trend.value + if market.htf_trend is not None + else "UNKNOWN" + ), + "htf_trend_strength": ( + market.htf_trend_strength.value + if market.htf_trend_strength is not None + else "UNKNOWN" + ), + "htf_trend_quality": ( + market.htf_trend_quality.value + if market.htf_trend_quality is not None + else "UNKNOWN" + ), + "htf_market_phase": ( + market.htf_market_phase.value + if market.htf_market_phase is not None + else "UNKNOWN" + ), + "htf_alignment": market.htf_alignment, + "htf_confirmation_score": market.htf_confirmation_score, + "htf_reason": market.htf_reason, "runtime_window_ttl_seconds": self._window_ttl_seconds, "runtime_window_size": len(prices), } - breakout_signal = self._breakout_signal(market, base_payload) + # STEP 4. BREAKOUT FIRST + def _breakout_signal( + self, + *, + market: Any, + base_payload: dict[str, Any], + ) -> SignalResult | None: + """ + Проверяет ранний вход по пробою. - if breakout_signal is not None: - return breakout_signal - - if not market.is_trade_allowed: + Почему breakout проверяется ДО обычных блокировок: + - после флэта EMA часто ещё сжаты; + - качество может выглядеть шумным; + - state может не успеть стать идеальным TREND_UP/TREND_DOWN; + - но momentum уже показывает реальный пробой. + + Условия BUY: + - momentum_state = BREAKOUT_UP; + - локальный тренд вверх; + - momentum вверх; + - HTF не против входа. + + Условия SELL: + - momentum_state = BREAKOUT_DOWN; + - локальный тренд вниз; + - momentum вниз; + - HTF не против входа. + """ + momentum_state = getattr(market, "momentum_state", MomentumState.UNKNOWN) + momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0) + + if ( + momentum_state == MomentumState.BREAKOUT_UP + and market.trend == TrendDirection.UP + and market.momentum_direction == TrendDirection.UP + and market.htf_alignment in {"ALIGNED", "SAME_INTERVAL"} + ): return SignalResult( - signal=SignalType.HOLD, + signal=SignalType.BUY, + reason="BREAKOUT_UP подтверждён momentum/breakout semantic layer.", + confidence=self._calculate_breakout_confidence(momentum_strength), + payload={ + **base_payload, + "entry_block_reason": None, + "entry_block_message": None, + "breakout_signal": True, + "expected_direction": "BUY", + }, + ) + + if ( + momentum_state == MomentumState.BREAKOUT_DOWN + and market.trend == TrendDirection.DOWN + and market.momentum_direction == TrendDirection.DOWN + and market.htf_alignment in {"ALIGNED", "SAME_INTERVAL"} + ): + return SignalResult( + signal=SignalType.SELL, + reason="BREAKOUT_DOWN подтверждён momentum/breakout semantic layer.", + confidence=self._calculate_breakout_confidence(momentum_strength), + payload={ + **base_payload, + "entry_block_reason": None, + "entry_block_message": None, + "breakout_signal": True, + "expected_direction": "SELL", + }, + ) + + return None + + # STEP 5. MARKET BLOCKS + # определить ранний impulse-вход из RANGE/SQUEEZE + def _early_impulse_direction(self, market: Any) -> str | None: + if market.state not in {MarketState.RANGE, MarketState.LOW_VOLATILITY}: + return None + + if market.market_phase != MarketPhase.IMPULSE: + return None + + if market.htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}: + return None + + if ( + market.trend == TrendDirection.UP + and market.momentum_direction == TrendDirection.UP + and market.momentum_state in { + MomentumState.MOMENTUM_UP, + MomentumState.BREAKOUT_UP, + } + ): + return "BUY" + + if ( + market.trend == TrendDirection.DOWN + and market.momentum_direction == TrendDirection.DOWN + and market.momentum_state in { + MomentumState.MOMENTUM_DOWN, + MomentumState.BREAKOUT_DOWN, + } + ): + return "SELL" + + return None + + def _market_block_signal( + self, + *, + market: Any, + base_payload: dict[str, Any], + ) -> SignalResult | None: + early_impulse_direction = self._early_impulse_direction(market) + """ + Проверяет защитные фильтры рынка. + + Эта функция НЕ ищет вход. + Она только отвечает на вопрос: + "Можно ли вообще рассматривать вход по TREND?" + + Если найден риск — возвращает HOLD с причиной. + Если всё нормально — возвращает None. + """ + if ( + market.state not in {MarketState.TREND_UP, MarketState.TREND_DOWN} + and early_impulse_direction is None + ): + return self._hold( + reason=f"Market state не подходит для TREND: {market.state.value}.", + block_reason="MARKET_STATE_NOT_TREND", + block_message="рынок не в тренде", + payload=base_payload, + ) + + if market.volatility in { + VolatilityState.LOW, + VolatilityState.UNKNOWN, + }: + return self._hold( + reason="Волатильность не подходит для входа.", + block_reason="BAD_VOLATILITY", + block_message="волатильность не подходит", + payload=base_payload, + ) + + if market.htf_alignment == "AGAINST": + return self._hold( + reason="HTF trend против направления входа.", + block_reason="HTF_TREND_AGAINST", + block_message="старший таймфрейм против входа", + payload=base_payload, + ) + + if market.htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}: + return self._hold( + reason="HTF trend не подтвердил направление входа.", + block_reason="HTF_TREND_NOT_CONFIRMED", + block_message="старший таймфрейм не подтвердил вход", + payload=base_payload, + ) + + # Слабый тренд больше не блокируем всегда. + # Если рынок уже TREND_UP / TREND_DOWN, старший ТФ подтверждает вход, + # качество не NOISY, а фаза IMPULSE — даём live-окну проверить движение. + # Это снижает число пропущенных ранних трендовых входов. + soft_trend_allowed = ( + market.trend_strength == TrendStrength.WEAK + and market.market_phase == MarketPhase.IMPULSE + and market.trend_quality != TrendQuality.NOISY + and market.htf_alignment in {"ALIGNED", "SAME_INTERVAL"} + ) + + if ( + market.trend_strength == TrendStrength.WEAK + and not soft_trend_allowed + ): + return self._hold( + reason="TREND есть, но сила тренда слабая.", + block_reason="WEAK_MARKET_TREND", + block_message="слабый тренд", + payload=base_payload, + ) + + # RANGE / SQUEEZE больше не режем полностью, + # если уже появился momentum/breakout по направлению тренда. + # Иначе бот слишком поздно входит после выхода из флэта. + phase_breakout_context = ( + ( + market.state == MarketState.TREND_UP + and market.momentum_state == MomentumState.BREAKOUT_UP + and market.momentum_direction == TrendDirection.UP + ) + or ( + market.state == MarketState.TREND_DOWN + and market.momentum_state == MomentumState.BREAKOUT_DOWN + and market.momentum_direction == TrendDirection.DOWN + ) + ) + + if ( + market.market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE} + and not phase_breakout_context + ): + return self._hold( + reason="Фаза рынка не подходит для входа по TREND.", + block_reason=f"MARKET_PHASE_{market.market_phase.value}", + block_message="фаза рынка не подходит", + payload=base_payload, + ) + + structure_block = self._market_structure_block( + market=market, + base_payload=base_payload, + ) + + if structure_block is not None: + return structure_block + + momentum_block = self._momentum_block( + market=market, + base_payload=base_payload, + ) + + if momentum_block is not None: + return momentum_block + + # COMPRESSED больше не блокируем здесь жёстко. + # Сжатие EMA может быть не плохим рынком, а ранней стадией выхода из флэта. + # OVEREXTENDED оставляем блокировкой, потому что это часто поздний вход. + if market.ema_distance_state == EmaDistanceState.OVEREXTENDED: + return self._hold( + reason="EMA-дистанция не подходит для входа.", + block_reason=f"EMA_DISTANCE_{market.ema_distance_state.value}", + block_message="EMA-дистанция не подходит", + payload=base_payload, + ) + + if market.entry_timing_state in { + EntryTimingState.LATE, + EntryTimingState.CHASING, + }: + return self._hold( + reason="Тайминг входа запоздалый.", + block_reason=f"ENTRY_TIMING_{market.entry_timing_state.value}", + block_message="тайминг входа запоздалый", + payload=base_payload, + ) + + if not market.is_trade_allowed: + return self._hold( reason=f"Market filter: {market.reason}", - confidence=0.0, + block_reason="MARKET_FILTER_BLOCKED", + block_message="рынок сейчас не подходит для входа", payload={ **base_payload, "market_filter_blocked": True, - "entry_block_reason": "MARKET_FILTER_BLOCKED", - "entry_block_message": "рынок сейчас не подходит для входа", }, ) - if market.trend_strength == TrendStrength.WEAK: - return SignalResult( - signal=SignalType.HOLD, - reason="TREND есть, но сила тренда слабая.", - confidence=0.0, + return None + + def _market_structure_block( + self, + *, + market: Any, + base_payload: dict[str, Any], + ) -> SignalResult | None: + """ + Проверяет структуру рынка. + + Для LONG структура LH/LL плохая: + - lower high; + - lower low; + - рынок делает понижающиеся экстремумы. + + Для SHORT структура HH/HL плохая: + - higher high; + - higher low; + - рынок делает повышающиеся экстремумы. + """ + market_structure = ( + market.market_structure.value + if market.market_structure is not None + else "UNKNOWN" + ) + + if market.state == MarketState.TREND_UP and market_structure == "LH_LL": + return self._hold( + reason="Структура рынка против LONG.", + block_reason="MARKET_STRUCTURE_CONFLICT", + block_message="структура рынка против LONG", payload={ **base_payload, - "entry_block_reason": "WEAK_MARKET_TREND", - "entry_block_message": "слабый тренд", + "expected_direction": "BUY", }, ) - if market.market_phase == MarketPhase.PULLBACK: - return SignalResult( - signal=SignalType.HOLD, - reason="TREND есть, но рынок находится в откате.", - confidence=0.0, + if market.state == MarketState.TREND_DOWN and market_structure == "HH_HL": + return self._hold( + reason="Структура рынка против SHORT.", + block_reason="MARKET_STRUCTURE_CONFLICT", + block_message="структура рынка против SHORT", payload={ **base_payload, - "entry_block_reason": "MARKET_PULLBACK", - "entry_block_message": "откат", + "expected_direction": "SELL", }, ) - if market.trend_quality == TrendQuality.NOISY: - return SignalResult( - signal=SignalType.HOLD, - reason="TREND есть, но движение шумное.", - confidence=0.0, - payload={ - **base_payload, - "entry_block_reason": "NOISY_MARKET_TREND", - "entry_block_message": "шумный тренд", - }, - ) + return None - if len(prices) < self._window_size: - return SignalResult( - signal=SignalType.HOLD, - reason="Недостаточно live-данных для подтверждения TREND.", - confidence=0.0, - payload={ - **base_payload, - "window_size": len(prices), - "required_window_size": self._window_size, - "entry_block_reason": "NOT_ENOUGH_LIVE_DATA", - "entry_block_message": "мало данных", - }, - ) + def _momentum_block( + self, + *, + market: Any, + base_payload: dict[str, Any], + ) -> SignalResult | None: + # Проверяет, не идёт ли momentum явно против входа. + # Важно: + # FLAT больше не блокируем сразу. + # Если общий TREND-контекст хороший, live-окно ниже само проверит, + # есть ли реальное движение прямо сейчас. + # Блокируем только явный momentum против направления сделки. + if market.state == MarketState.TREND_UP: + if market.momentum_direction == TrendDirection.DOWN: + return self._hold( + reason="Momentum явно против LONG.", + block_reason="MOMENTUM_CONFLICT", + block_message="momentum против LONG", + payload={ + **base_payload, + "expected_direction": "BUY", + }, + ) + if market.state == MarketState.TREND_DOWN: + if market.momentum_direction == TrendDirection.UP: + return self._hold( + reason="Momentum явно против SHORT.", + block_reason="MOMENTUM_CONFLICT", + block_message="momentum против SHORT", + payload={ + **base_payload, + "expected_direction": "SELL", + }, + ) + + return None + + # ========================================================= + # STEP 6. LIVE TREND CONFIRMATION + # ========================================================= + + def _live_trend_signal( + self, + *, + market: Any, + base_payload: dict[str, Any], + prices: list[float], + ) -> SignalResult: + """ + Финальное подтверждение обычного TREND-входа. + + Market analysis говорит: + - рынок в тренде; + - направление известно; + - фильтры разрешили вход. + + Но перед сделкой нужно проверить live-окно: + - цена реально пошла в нужную сторону; + - движение не единичный случайный тик; + - достаточно шагов подтверждают направление. + """ first_price = prices[0] last_price = prices[-1] if first_price <= 0: - return SignalResult( - signal=SignalType.HOLD, + return self._hold( reason="Некорректная стартовая цена в live-окне.", - confidence=0.0, + block_reason="INVALID_WINDOW_PRICE", + block_message="ошибка цены", payload={ **base_payload, "prices": prices, - "entry_block_reason": "INVALID_WINDOW_PRICE", - "entry_block_message": "ошибка цены", }, ) @@ -254,174 +897,181 @@ class TrendStrategy: "min_direction_ratio": self._min_direction_ratio, } - if market.state == MarketState.TREND_UP: - if ( - change_percent >= self._threshold_percent - and direction_ratio >= self._min_direction_ratio - ): - return SignalResult( - signal=SignalType.BUY, - reason="TREND_UP подтверждён market analysis и live-импульсом.", - confidence=self._calculate_confidence(change_percent, direction_ratio), - payload=payload, - ) + early_impulse_direction = self._early_impulse_direction(market) - return SignalResult( - signal=SignalType.HOLD, - reason="TREND_UP есть, но live-импульс вверх недостаточно сильный.", - confidence=0.0, - payload={ - **payload, - "entry_block_reason": "WEAK_UP_IMPULSE", - "entry_block_message": "слабый импульс", - "expected_direction": "BUY", - }, + if market.state == MarketState.TREND_UP: + return self._trend_up_signal( + change_percent=change_percent, + direction_ratio=direction_ratio, + payload=payload, ) if market.state == MarketState.TREND_DOWN: - if ( - change_percent <= -self._threshold_percent - and direction_ratio >= self._min_direction_ratio - ): - return SignalResult( - signal=SignalType.SELL, - reason="TREND_DOWN подтверждён market analysis и live-импульсом.", - confidence=self._calculate_confidence(change_percent, direction_ratio), - payload=payload, - ) + return self._trend_down_signal( + change_percent=change_percent, + direction_ratio=direction_ratio, + payload=payload, + ) - return SignalResult( - signal=SignalType.HOLD, - reason="TREND_DOWN есть, но live-импульс вниз недостаточно сильный.", - confidence=0.0, + if early_impulse_direction == "BUY": + return self._trend_up_signal( + change_percent=change_percent, + direction_ratio=direction_ratio, payload={ **payload, - "entry_block_reason": "WEAK_DOWN_IMPULSE", - "entry_block_message": "слабый импульс", - "expected_direction": "SELL", + "early_impulse_signal": True, + "expected_direction": "BUY", }, ) - return SignalResult( - signal=SignalType.HOLD, + if early_impulse_direction == "SELL": + return self._trend_down_signal( + change_percent=change_percent, + direction_ratio=direction_ratio, + payload={ + **payload, + "early_impulse_signal": True, + "expected_direction": "SELL", + }, + ) + + return self._hold( reason=f"Market state не подходит для TREND: {market.state.value}.", - confidence=0.0, - payload={ - **payload, - "entry_block_reason": "MARKET_STATE_NOT_TREND", - "entry_block_message": "рынок флэт", - }, + block_reason="MARKET_STATE_NOT_TREND", + block_message="рынок не в тренде", + payload=payload, ) - def _breakout_signal(self, market, base_payload: dict) -> SignalResult | None: - momentum_state = getattr(market, "momentum_state", MomentumState.UNKNOWN) - momentum_direction = getattr(market, "momentum_direction", TrendDirection.UNKNOWN) - momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0) - + def _trend_up_signal( + self, + *, + change_percent: float, + direction_ratio: float, + payload: dict[str, Any], + ) -> SignalResult: + """ + Формирует BUY, если live-окно подтвердило рост. + """ if ( - momentum_state == MomentumState.BREAKOUT_UP - and market.state == MarketState.TREND_UP + change_percent >= self._threshold_percent + and direction_ratio >= self._min_direction_ratio ): return SignalResult( signal=SignalType.BUY, - reason="BREAKOUT_UP подтверждён momentum/breakout semantic layer.", - confidence=self._calculate_breakout_confidence(momentum_strength), + reason="TREND_UP подтверждён market analysis и live-импульсом.", + confidence=self._calculate_confidence( + change_percent, + direction_ratio, + ), payload={ - **base_payload, + **payload, "entry_block_reason": None, "entry_block_message": None, - "breakout_signal": True, "expected_direction": "BUY", }, ) + return self._hold( + reason="TREND_UP есть, но live-импульс вверх недостаточно сильный.", + block_reason="WEAK_UP_IMPULSE", + block_message="слабый импульс вверх", + payload={ + **payload, + "expected_direction": "BUY", + }, + ) + + def _trend_down_signal( + self, + *, + change_percent: float, + direction_ratio: float, + payload: dict[str, Any], + ) -> SignalResult: + """ + Формирует SELL, если live-окно подтвердило снижение. + """ if ( - momentum_state == MomentumState.BREAKOUT_DOWN - and market.state == MarketState.TREND_DOWN + change_percent <= -self._threshold_percent + and direction_ratio >= self._min_direction_ratio ): return SignalResult( signal=SignalType.SELL, - reason="BREAKOUT_DOWN подтверждён momentum/breakout semantic layer.", - confidence=self._calculate_breakout_confidence(momentum_strength), + reason="TREND_DOWN подтверждён market analysis и live-импульсом.", + confidence=self._calculate_confidence( + change_percent, + direction_ratio, + ), payload={ - **base_payload, + **payload, "entry_block_reason": None, "entry_block_message": None, - "breakout_signal": True, "expected_direction": "SELL", }, ) - if ( - momentum_state == MomentumState.BREAKOUT_DOWN - and market.state == MarketState.TREND_UP - ): - return SignalResult( - signal=SignalType.HOLD, - reason="Пробой вниз против TREND_UP считается коррекцией, вход в SHORT запрещён.", - confidence=0.0, - payload={ - **base_payload, - "entry_block_reason": "COUNTER_TREND_BREAKOUT", - "entry_block_message": "пробой против тренда", - "expected_direction": "BUY", - }, - ) + return self._hold( + reason="TREND_DOWN есть, но live-импульс вниз недостаточно сильный.", + block_reason="WEAK_DOWN_IMPULSE", + block_message="слабый импульс вниз", + payload={ + **payload, + "expected_direction": "SELL", + }, + ) - if ( - momentum_state == MomentumState.BREAKOUT_UP - and market.state == MarketState.TREND_DOWN - ): - return SignalResult( - signal=SignalType.HOLD, - reason="Пробой вверх против TREND_DOWN считается откатом, вход в LONG запрещён.", - confidence=0.0, - payload={ - **base_payload, - "entry_block_reason": "COUNTER_TREND_BREAKOUT", - "entry_block_message": "пробой против тренда", - "expected_direction": "SELL", - }, - ) - return None + # ========================================================= + # RESULT HELPERS + # ========================================================= - def _calculate_breakout_confidence(self, momentum_strength: float) -> float: - strength_score = min(1.0, max(0.0, momentum_strength) / 2) - - confidence = 0.55 + (strength_score * 0.35) - - return round(min(0.95, confidence), 2) - - - def _analysis_price( + def _hold( self, - snapshot: dict[str, Any], - ) -> float: - bid = self._safe_float(snapshot.get("bid_price")) - ask = self._safe_float(snapshot.get("ask_price")) + *, + reason: str, + block_reason: str, + block_message: str, + payload: dict[str, Any], + ) -> SignalResult: + """ + Унифицированный HOLD. - if bid is not None and ask is not None and bid > 0 and ask > 0: - return (bid + ask) / 2 + Все блокировки входа должны проходить через эту функцию, + чтобы UI, журнал и supervisor получали одинаковые поля: + - entry_block_reason; + - entry_block_message. + """ + return SignalResult( + signal=SignalType.HOLD, + reason=reason, + confidence=0.0, + payload={ + **payload, + "entry_block_reason": block_reason, + "entry_block_message": block_message, + }, + ) - last = self._safe_float(snapshot.get("last_price")) - if last is not None: - return last - - return 0.0 - - def _safe_float( - self, - value: float | int | str | None, - ) -> float | None: - if value is None: - return None - - try: - return float(value) - except (TypeError, ValueError): - return None + # ========================================================= + # CALCULATIONS + # ========================================================= def _direction_ratio(self, prices: list[float], change_percent: float) -> float: + """ + Считает долю движений в сторону общего изменения. + + Пример: + prices = [100, 101, 102, 101.5, 103] + + Если итоговое движение вверх: + считаем долю шагов, где цена росла. + + Если итоговое движение вниз: + считаем долю шагов, где цена снижалась. + + Это защищает от ситуации: + цена вроде изменилась на нужный процент, + но внутри окна движение было рваным и случайным. + """ if len(prices) < 2: return 0.0 @@ -441,36 +1091,35 @@ class TrendStrategy: return down_moves / total_moves - def _normalized_market_phase(self, market) -> str: - phase = market.market_phase.value - momentum_state = market.momentum_state.value + def _calculate_breakout_confidence(self, momentum_strength: float) -> float: + """ + Рассчитывает confidence для breakout-сигнала. - active_momentum_states = { - "MOMENTUM_UP", - "MOMENTUM_DOWN", - "BREAKOUT_UP", - "BREAKOUT_DOWN", - } + Breakout начинается с базовой уверенности 0.55. + Чем сильнее momentum_strength, тем выше confidence. + Максимум ограничен 0.95, чтобы execution confidence + всё равно учитывал spread, подтверждение и качество исполнения. + """ + strength_score = min(1.0, max(0.0, momentum_strength) / 2) + confidence = 0.55 + (strength_score * 0.35) - if phase == "IMPULSE" and momentum_state not in active_momentum_states: - return "UNKNOWN" - - return phase - - - def _normalized_market_phase_direction(self, market) -> str: - phase = self._normalized_market_phase(market) - - if phase == "UNKNOWN": - return "UNKNOWN" - - return market.phase_direction.value + return round(min(0.95, confidence), 2) def _calculate_confidence( self, change_percent: float, direction_ratio: float, ) -> float: + """ + Рассчитывает confidence обычного TREND-сигнала. + + Учитываются: + - сила движения относительно threshold; + - направленность движения внутри live-окна. + + Чем сильнее и чище live-импульс, + тем выше confidence. + """ strength = abs(change_percent) / self._threshold_percent if strength < 1: @@ -481,4 +1130,47 @@ class TrendStrategy: confidence = 0.3 + (strength_score * 0.4) + (direction_score * 0.3) - return round(min(1.0, confidence), 2) \ No newline at end of file + return round(min(1.0, confidence), 2) + + # ========================================================= + # NORMALIZATION + # ========================================================= + + def _normalized_market_phase(self, market: Any) -> str: + """ + Безопасно возвращает market_phase строкой. + """ + if market.market_phase is None: + return "UNKNOWN" + + return market.market_phase.value + + def _normalized_market_phase_direction(self, market: Any) -> str: + """ + Безопасно возвращает phase_direction строкой. + """ + if market.phase_direction is None: + return "UNKNOWN" + + return market.phase_direction.value + + def _safe_float( + self, + value: float | int | str | None, + ) -> float | None: + """ + Безопасное приведение к float. + + Нужно потому, что snapshot может вернуть числа: + - как float; + - как int; + - как строку; + - как None. + """ + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + return None \ No newline at end of file diff --git a/app/tools/ws_probe.py b/app/tools/ws_probe.py index 0a021f9..34d4fec 100644 --- a/app/tools/ws_probe.py +++ b/app/tools/ws_probe.py @@ -92,7 +92,6 @@ async def main() -> None: header_sets.append({"X-MBX-APIKEY": API_KEY}) paths = [ - "/api/v2/depth", "/api/v1/depth", "/ws", "/websocket", diff --git a/docs/architecture/execution_refactoring.md b/docs/architecture/execution_refactoring.md new file mode 100644 index 0000000..024967d --- /dev/null +++ b/docs/architecture/execution_refactoring.md @@ -0,0 +1,902 @@ +# Execution Architecture Overview + +## Архитектурные уровни + +### Foundation +- constants.py +- models.py +- pricing.py +- calculations.py +- position_metrics.py +- resets.py + +### Business Operations +- position_actions.py +- flip.py +- risk_close.py + +### Runtime +- position_runtime.py +- position_protection.py +- runtime_actions.py +- position_exit_decision.py + +### Orchestration +- supervisor.py +- engine.py + +### Sizing +- sizing.py + + +# Execution refactoring roadmap + +Цель: безопасный поэтапный рефакторинг `app/src/trading/execution`. + +Принципы: + +- сначала аудит; +- без изменения бизнес-логики; +- без изменения payload; +- без изменения EventBus; +- без изменения JournalService; +- без изменения ExecutionDecision; +- каждый шаг проверяется перезапуском бота. + +## Статус файлов + +| Файл | Статус | Комментарий | +|---|---|---| +| constants.py | Done | Этап 1 завершён | +| flip.py | Done stage 1 | Reject helper, payload builders, grouping | +| position_actions.py | Done stage 1 | Reject helper, payload builders, grouping | +| risk_close.py | Done | Risk close helper | +| calculations.py | Not audited | | +| engine.py | Not audited | | +| models.py | Not audited | | +| position_exit_decision.py | Not audited | | +| position_metrics.py | Not audited | | +| position_protection.py | Not audited | | +| position_runtime.py | Not audited | | +| pricing.py | Done | Добавлен `_build_execution_price()`, убраны дубли сборки ExecutionPrice | +| quality.py | Not audited | | +| resets.py | Not audited | | +| runtime_actions.py | Not audited | | +| sizing.py | Not audited | | +| supervisor.py | Not audited | | + +Completed: + +- constants.py +- models.py +- calculations.py +- pricing.py +- resets.py +- risk_close.py +- position_metrics.py +- position_runtime.py +- position_protection.py (safe refactoring) +- flip.py (safe refactoring) +- position_actions.py (safe refactoring) +- runtime_actions.py (safe refactoring) +- supervisor.py (safe refactoring) +- constants.py (completed) + +## Правила аудита + +Для каждого файла фиксируем: + +- назначение; +- размер и сложность; +- зависимости; +- безопасные улучшения; +- что нельзя трогать; +- рекомендуемый следующий шаг. + +## Порядок аудита + +## Стандарт структуры ExecutionMixin + +Порядок методов: + +1. Payload builders +2. Journal helpers +3. Decision helpers +4. Validation / Checks +5. Execution methods +6. Utility methods + +### Уже обработаны + +- constants.py +- flip.py +- position_actions.py +- risk_close.py + +### Этап A — маленькие и базовые файлы + +1. quality.py +2. models.py +3. calculations.py +4. resets.py +5. pricing.py +6. engine.py + +### Этап B — средние файлы + +7. position_runtime.py +8. sizing.py +9. runtime_actions.py +10. position_exit_decision.py + +### Этап C — крупные и рискованные файлы + +11. position_metrics.py +12. supervisor.py +13. position_protection.py + +## quality.py + +Статус: Empty / candidate for removal later + +Назначение: +- Файл существует, но сейчас не содержит логики. + +Размер и сложность: +- 2 строки. +- Сложность отсутствует. + +Зависимости: +- Нужно отдельно проверить, импортируется ли где-то `src.trading.execution.quality`. + +Безопасные улучшения: +- Сейчас ничего не менять. + +Что нельзя трогать: +- Не удалять файл до проверки импортов. + +Рекомендуемый следующий шаг: +- Позже выполнить grep по проекту: + `grep -R "execution.quality\|from src.trading.execution import quality" app/src` + +## models.py + +Статус: Completed (без изменений) + +Назначение: +- DTO результата выполнения торгового действия. + +Размер: +- Отличный. + +Связность: +- Минимальная. + +Безопасные улучшения: +- Не требуются. + +Что нельзя менять: +- Структуру ExecutionDecision. +- Имена полей. +- Поведение. + +Итог: +Файл соответствует целевой архитектуре и рефакторинга не требует. + +## calculations.py + +Статус: Completed after minor cleanup + +Назначение: +- Compatibility-wrapper для старых методов расчёта. +- Реальные расчёты централизованы в position_metrics.py. + +Размер: +- Небольшой. + +Связность: +- Зависит от PositionState и build_position_metrics(). +- Связность нормальная. + +Безопасные улучшения: +- Только косметика форматирования. + +Что нельзя менять: +- Не удалять wrapper-методы. +- Не менять возвращаемые значения. +- Не переносить расчёты обратно в этот файл. + +Итог: +Файл архитектурно нормальный. Основная логика уже вынесена в position_metrics.py. + +## resets.py + +Статус: Completed (без изменений) + +Назначение: +- Централизованный reset состояния AutoTradeState. + +Размер: +- Хороший. + +Связность: +- Минимальная. + +Безопасные улучшения: +- Пока не требуются. + +Возможные будущие улучшения: +- Только группировка полей по смысловым секциям без изменения поведения. + +Что нельзя менять: +- Состав очищаемых полей. +- Порядок вызова методов reset. + +Итог: +Файл соответствует целевой архитектуре и рефакторинга не требует. + +## pricing.py + +Статус: Completed after minor cleanup + +Назначение: +- Получение execution-цены для входа, выхода и market last. +- Проверка свежести execution snapshot. +- Нормализация bid/ask/last цены. + +Размер: +- Нормальный. + +Связность: +- Зависит от ExchangeService, AutoTradeState и ExecutionPrice. +- Связность ожидаемая для pricing-слоя. + +Что сделано: +- Добавлен helper `_build_execution_price()`. +- Убраны повторяющиеся сборки `ExecutionPrice`. +- Логика выбора bid/ask/last не менялась. +- Проверка свежести snapshot не менялась. + +Безопасные улучшения: +- Завершены. + +Что нельзя менять: +- Роли pricing: + - `LONG_ENTRY_ASK` + - `SHORT_ENTRY_BID` + - `ENTRY_LAST` + - `LONG_EXIT_BID` + - `SHORT_EXIT_ASK` + - `EXIT_LAST` + - `MARKET_LAST` +- Логику выбора ask/bid для LONG/SHORT. +- Поведение `_ensure_fresh_snapshot()`. + +Итог: +Файл соответствует целевой архитектуре. Рефакторинг на текущем этапе завершён. + + +## position_metrics.py + +Статус: Audited / no changes now + +Назначение: +- Центральная точка расчёта метрик открытой и планируемой позиции. +- Формирует PositionMetrics и PlannedPositionMetrics. + +Размер: +- Большой, но оправданный. +- В файле много вычислений, но они хорошо разделены на helpers. + +Связность: +- Основная связность нормальная: PositionState, NumericLike, safe_float. +- Потенциально спорная связность: `_trading_fee()` обращается к ExchangeService. + +Что хорошо: +- Есть единая функция `build_position_metrics()`. +- Есть отдельная функция `build_planned_position_metrics()`. +- Расчёты вынесены в маленькие private helpers. +- Формулы читаемые. + +Безопасные улучшения: +- Сейчас не требуются. + +Что нельзя менять: +- Формулы PnL. +- Округления. +- Поведение при None/invalid values. +- Расчёт commission. +- Расчёт overnight cashflow. +- Поведение `_trading_fee()`. + +Будущий возможный этап: +- Отдельно обсудить, нужно ли выносить получение trading fee из position_metrics.py. +- Но только после тестов и сверки PnL. + +Итог: +Файл архитектурно важный и в целом хорошо организован. На текущем безопасном этапе правки не нужны. + +## position_runtime.py + +Статус: Audited / minor cleanup only + +Назначение: +- Обновление runtime PnL открытой позиции. +- Синхронизация PositionState с AutoTradeState. +- Обновление runtime-памяти позиции: peak PnL, MFE/MAE, best/worst price, fatigue. + +Размер: +- Средний. + +Связность: +- Зависит от PositionState, AutoTradeState, ExecutionPrice и build_position_metrics(). +- Связность ожидаемая. + +Что хорошо: +- PnL и price move считаются через position_metrics.py. +- Runtime-память позиции вынесена в отдельный helper. +- Fatigue score/state вынесены отдельно. + +Что настораживает: +- `_sync_state_from_position()` частично дублирует reset-логику из resets.py. +- Пока это не трогаем, чтобы не изменить поведение. + +Безопасные улучшения: +- Только косметика форматирования. + +Что нельзя менять: +- Поведение `_sync_state_from_position()`. +- Состав полей, которые сбрасываются при `position.side == "NONE"`. +- Логику peak PnL, MFE/MAE, best/worst price. +- Логику fatigue score. + +Итог: +Файл можно оставить как есть. Возможный будущий этап — аккуратно сравнить reset-поля с resets.py, но без автоматического объединения. + +# Progress + +Completed: + +- constants.py +- models.py +- calculations.py +- pricing.py +- resets.py +- risk_close.py +- position_metrics.py +- position_runtime.py +- flip.py (safe refactoring) +- position_actions.py (safe refactoring) + +Current status: + +- Центральные расчёты execution уже унифицированы. +- PnL рассчитывается только через position_metrics.py. +- Pricing унифицирован. +- Runtime обновляется через единый pipeline. +- Все изменения выполнены без изменения бизнес-логики. + +# Architecture decisions + +Принятые правила: + +1. Любые вычисления позиции должны происходить только через position_metrics.py. + +2. Pricing не должен содержать бизнес-логику. + +3. Mixins должны иметь следующую структуру: + +- helpers +- journal helpers +- validation +- execution +- utility + +4. Все безопасные рефакторинги выполняются без изменения поведения execution. + +## position_protection.py + +Статус: Audited / candidate for safe payload extraction + +Назначение: +- Runtime protection открытой позиции. +- Управляет break-even, profit lock и trailing stop. +- Проверяет причины закрытия позиции по protection-логике. +- Логирует события runtime protection. + +Размер: +- Большой. + +Связность: +- Зависит от PositionState, AutoTradeState, PositionMetrics, ExecutionPrice. +- Использует JournalService и EventBus для runtime protection событий. +- Использует build_position_metrics() для расчётов позиции. + +Что хорошо: +- Цена выхода получается один раз в `_process_runtime_protection()`. +- PositionMetrics считается один раз. +- Break-even, profit lock и trailing stop разделены по отдельным методам. +- Закрытие позиции выполняется через общий `_close_position()`. + +Что настораживает: +- Большой payload внутри `_log_runtime_protection_event()`. +- Константы thresholds пока находятся прямо в файле. +- Файл совмещает protection-логику и logging payload. + +Безопасные улучшения: +- Вынести payload из `_log_runtime_protection_event()` в `_build_runtime_protection_payload()`. + +Что нельзя менять: +- Protection thresholds. +- Логику активации break-even. +- Логику profit lock. +- Логику trailing stop. +- Порядок проверки close reason. +- forced_reason при закрытии. +- JournalService/EventBus события. + +Итог: +Файл рабочий, но требует безопасного структурного улучшения: сначала вынести payload builder без изменения поведения. + +## position_protection.py + +Статус: Completed (safe refactoring stage 1) + +Назначение: +- Runtime-защита открытой позиции. +- Управляет break-even, profit lock и trailing stop. +- Проверяет условия принудительного закрытия позиции. +- Формирует runtime protection события. + +Размер: +- Большой. + +Связность: +- PositionState +- AutoTradeState +- PositionMetrics +- ExecutionPrice +- JournalService +- EventBus + +Что сделано: +- Добавлен helper `_build_runtime_protection_payload()`. +- Построение payload вынесено из `_log_runtime_protection_event()`. +- `_log_runtime_protection_event()` теперь отвечает только за: + - построение payload; + - запись в JournalService; + - публикацию EventBus. +- Поведение protection полностью сохранено. + +Что НЕ изменялось: +- Break-even. +- Profit lock. +- Trailing stop. +- Protection thresholds. +- Алгоритм закрытия позиции. +- Journal payload. +- EventBus payload. + +Что нельзя менять на следующих этапах: +- Последовательность обработки protection. +- Логику определения close reason. +- Формулы расчёта protection уровней. +- Runtime protection thresholds. + +Возможный следующий этап: +- При необходимости вынести protection thresholds в отдельный constants.py, + но только после завершения всего безопасного рефакторинга execution. + +Итог: +Файл приведён к единому стилю execution. + +## position_exit_decision.py + +Статус: Audited / no changes now + +Назначение: +- Runtime-intelligence решение о закрытии позиции. +- Определяет close reason по giveback, time-decay, hard-loss и нормальному pullback. + +Размер: +- Средний/большой. + +Связность: +- Зависит от AutoTradeState, PositionState, PositionMetrics. +- Использует build_position_metrics(). +- Использует get_position_exit_thresholds() из execution/constants.py. + +Что хорошо: +- Расчёты позиции берутся из position_metrics.py. +- Thresholds вынесены из файла. +- Giveback и time-decay разделены по отдельным методам. +- Нет JournalService/EventBus/payload. + +Что настораживает: +- Длинные методы `_giveback_close_reason()` и `_time_decay_close_reason()`. +- Много строковых close reason прямо внутри условий. +- Decision-методы частично изменяют state. + +Безопасные улучшения: +- Сейчас не требуются. + +Что нельзя менять: +- Порядок проверки giveback/time-decay. +- Порядок условий внутри `_giveback_close_reason()`. +- Порядок условий внутри `_time_decay_close_reason()`. +- Строковые close reason. +- Изменения state внутри decision-логики. +- Thresholds. + +Будущий возможный этап: +- После завершения безопасного аудита можно отдельно обсудить вынос reason-кодов в constants.py. +- Дробление длинных методов делать только отдельным этапом с тестами. + +Итог: +Файл архитектурно понятный, но чувствительный к порядку условий. На текущем этапе оставляем без изменений. + +## runtime_actions.py + +Статус: Audited / candidate for safe payload extraction + +Назначение: +- Runtime autonomous actions для открытой позиции. +- Обрабатывает autonomous EXIT / REDUCE / PROTECT. +- Проверяет cooldown runtime actions. +- Логирует runtime action события. + +Размер: +- Средний/большой. + +Связность: +- Зависит от AutoTradeState, PositionState, ExecutionDecision. +- Использует JournalService и EventBus. +- Использует execution constants и get_position_exit_thresholds(). + +Что хорошо: +- Основной вход — `process_runtime_action()`. +- Cooldown вынесен в `_runtime_action_cooldown_active()`. +- Early exit guard вынесен в `_early_exit_guard_active()`. +- Закрытие позиции выполняется через общий `_close_position()`. + +Что настораживает: +- Большой payload внутри `_log_runtime_action()`. +- `_log_runtime_action()` совмещает dedupe, payload, Journal, EventBus, state update и ExecutionDecision. + +Безопасные улучшения: +- Вынести payload из `_log_runtime_action()` в `_build_runtime_action_payload()`. + +Что нельзя менять: +- Порядок обработки action. +- Early exit guard. +- Confidence threshold. +- Cooldown logic. +- Deduplication key. +- State updates в `_log_runtime_action()`. +- Journal/EventBus payload. + +Итог: +Файл рабочий. Первый безопасный шаг — вынести payload builder без изменения поведения. + +## runtime_actions.py + +Статус: Completed (safe refactoring stage 1) + +Что сделано: +- Добавлен helper `_build_runtime_action_payload()`. +- Payload вынесен из `_log_runtime_action()`. +- `_log_runtime_action()` сохранил dedupe, JournalService, EventBus, state update и ExecutionDecision. +- Поведение runtime actions не менялось. + +Что НЕ изменялось: +- Порядок обработки autonomous action. +- Cooldown logic. +- Early exit guard. +- Confidence threshold. +- Deduplication key. +- Journal/EventBus payload. +- State updates. + +Итог: +Файл приведён к единому стилю execution. + +## supervisor.py + +Статус: Audited / candidate for safe payload extraction + +Назначение: +- Execution supervisor перед исполнением торгового действия. +- Блокирует исполнение при emergency halt, cooldown, degraded market, stale execution, entry block, low confidence и signal conflict. + +Размер: +- Средний/большой. + +Связность: +- Зависит от AutoTradeState, ExecutionDecision, JournalService, EventBus. +- Использует execution thresholds/settings из состояния и констант engine. + +Что хорошо: +- Основной вход — `_process_execution_supervisor()`. +- Причины блокировки разделены по отдельным методам. +- UI-тексты вынесены в `_human_execution_block()`. +- Есть dedupe через `_last_supervisor_block_key`. + +Что настораживает: +- Большой payload внутри `_block_execution()`. +- `_block_execution()` совмещает state update, UI block, dedupe, payload, JournalService, EventBus и ExecutionDecision. + +Безопасные улучшения: +- Вынести payload из `_block_execution()` в `_build_supervisor_block_payload()`. + +Что нельзя менять: +- Порядок проверок в `_process_execution_supervisor()`. +- Логику emergency halt. +- Cooldown logic. +- Degraded market logic. +- Early impulse allowance. +- Stale execution logic. +- Entry block logic. +- Low execution confidence logic. +- Conflict signal logic. +- UI-тексты в `_human_execution_block()`. +- Dedupe key. +- Journal/EventBus payload. + +Итог: +Файл рабочий. Первый безопасный шаг — вынести payload builder без изменения поведения. + +## supervisor.py + +Статус: Completed (safe refactoring stage 1) + +Что сделано: +- Добавлен helper `_build_supervisor_block_payload()`. +- Payload вынесен из `_block_execution()`. +- `_block_execution()` сохранил: + - state update; + - UI block; + - dedupe; + - JournalService; + - EventBus; + - ExecutionDecision. +- Поведение supervisor не менялось. + +Что НЕ изменялось: +- Порядок проверок supervisor. +- Emergency halt. +- Cooldown logic. +- Degraded market logic. +- Early impulse allowance. +- Stale execution logic. +- Entry block logic. +- Low confidence logic. +- Signal conflict logic. +- UI-тексты. +- Dedupe key. +- Journal/EventBus payload. + +Итог: +Файл приведён к единому стилю execution. + +## sizing.py + +Статус: Audited / no logic changes + +Назначение: +- Расчёт размера позиции по risk и stop-loss. +- Adaptive size multiplier. +- Market score для sizing. +- Синхронизация adaptive size/effective risk в AutoTradeState. +- Ограничение размера позиции по margin limit. +- Округление размера позиции. + +Размер: +- Средний/большой. + +Связность: +- Зависит от AutoTradeState, ExecutionPrice, safe_float. +- Активно изменяет поля AutoTradeState. + +Что хорошо: +- Расчёт размера, multiplier, market score, margin limit и rounding разделены. +- Нет JournalService/EventBus/payload. +- `_sync_effective_risk_after_margin_limit()` вынесен отдельно. + +Что настораживает: +- Очень чувствительный файл: любые изменения влияют на реальные размеры сделок. +- Много state updates. +- Повторяются вызовы `_sync_adaptive_size_state(...0...)`, но пока их лучше не трогать. + +Безопасные улучшения: +- Только косметика форматирования. + +Что нельзя менять: +- Формулу base size. +- Adaptive multiplier thresholds. +- Market score thresholds. +- Execution quality multipliers. +- Margin limit logic. +- Effective risk recalculation. +- Rounding logic. + +Итог: +Файл архитектурно понятный, но очень чувствительный. На текущем этапе оставляем без изменения логики. + +## engine.py + +Статус: Audited / minor cleanup only + +Назначение: +- Главная orchestration-точка execution layer. +- Управляет последовательностью execution pipeline. + +Размер: +- Нормальный. + +Связность: +- Собирает execution mixins. +- Использует AutoTradeState, ExecutionDecision и PositionState. +- Не содержит JournalService/EventBus/payload. + +Что хорошо: +- `process()` имеет понятный последовательный pipeline. +- Бизнес-логика вынесена в mixins. +- Risk close, runtime protection, supervisor, flip и open position разделены. +- Нет прямых расчётов PnL/sizing/pricing внутри engine. + +Pipeline: +1. Sync state. +2. Проверка RUNNING. +3. Update unrealized PnL. +4. Risk close. +5. Runtime protection. +6. Signal readiness. +7. Execution supervisor. +8. Duplicate signal guard. +9. Flip. +10. Open position. +11. Skip if no action. + +Безопасные улучшения: +- Только косметика форматирования. + +Что нельзя менять: +- Порядок pipeline. +- Состав mixins. +- Class-level настройки. +- Duplicate signal guard. +- Порядок flip/open position. + +Итог: +Файл соответствует роли orchestration layer. Логических правок не требуется. + +## flip.py + +Статус: Completed (safe refactoring stage 1) + +Что сделано: +- Добавлен helper `_reject_flip()`. +- Вынесены payload builders: + - `_build_flip_rejected_payload()` + - `_build_flip_blocked_payload()` + - `_build_flip_executed_payload()` +- Методы сгруппированы по смыслу: + - payload builders + - journal helpers + - decision helpers + - flip checks + - execution +- Поведение flip не менялось. + +Что НЕ изменялось: +- `_flip_position()` не разбивался. +- `_flip_block_reason()` не разбивался. +- Алгоритм flip не менялся. +- Порядок проверок не менялся. +- Payload не расширялся. +- EventBus не менялся. +- JournalService не менялся. +- ExecutionDecision не менялся. + +Что нельзя менять на текущем этапе: +- Порядок расчёта exit/entry price. +- Расчёт pnl. +- Обновление cycle stats. +- Логику loss cooldown. +- Создание новой PositionState. +- Порядок reset runtime/protection state. +- Порядок Journal/EventBus событий. + +Будущий этап: +- Только после завершения всего аудита можно отдельно рассмотреть аккуратное разбиение `_flip_position()` на несколько внутренних шагов. +- Расширение payload для анализа стратегии делать отдельным этапом, не смешивать с safe refactoring. + +Итог: +Файл приведён к единому стилю execution. На текущем безопасном этапе дополнительных правок не требуется. + +## position_actions.py + +Статус: Completed (safe refactoring stage 1) + +Что сделано: +- Добавлен helper `_reject_position_open()`. +- Вынесены payload builders: + - `_build_position_open_rejected_payload()` + - `_build_position_opened_payload()` + - `_build_position_closed_payload()` +- Методы сгруппированы: + - trade id + - payload builders + - journal helpers + - decision helpers + - position actions +- Поведение открытия/закрытия позиции не менялось. + +Что НЕ изменялось: +- `_open_position_if_empty()` не разбивался. +- `_close_position()` не разбивался. +- Алгоритм открытия позиции не менялся. +- Алгоритм закрытия позиции не менялся. +- Payload не расширялся. +- JournalService/EventBus не менялись. +- ExecutionDecision не менялся. + +Что нельзя менять на текущем этапе: +- Порядок расчёта entry/exit price. +- Расчёт size. +- Margin limit. +- Расчёт pnl. +- Обновление cycle stats. +- Loss cooldown. +- Reset position/protection runtime. +- Порядок Journal/EventBus событий. + +Итог: +Файл приведён к единому стилю execution. На текущем безопасном этапе дополнительных правок не требуется. + +## constants.py + +Статус: Completed + +Назначение: +- Единая точка констант execution layer. +- Хранит execution actions, types, reasons, pricing modes, runtime actions, position health/risk/exit thresholds и flip filters. + +Что сделано ранее: +- Удалены дубли констант. +- Константы сгруппированы по смысловым разделам. +- Сохранены существующие имена констант. +- Логика не менялась. + +Что хорошо: +- Есть asset-specific thresholds. +- Есть helper `asset_symbol()`. +- Есть helper `get_position_thresholds()`. +- Health/exit thresholds доступны через отдельные helpers. +- `build_flip_action()` оставлен совместимым. + +Что нельзя менять: +- Имена существующих констант. +- Значения thresholds. +- Структуру `DEFAULT_POSITION_THRESHOLDS`. +- Структуру `POSITION_THRESHOLDS_BY_ASSET`. +- Поведение helper-функций. + +Итог: +Файл завершён. Дополнительных правок на safe stage не требуется. + +# Safe refactoring stage 1 — completed + +Статус: завершён. + +Проверены все файлы `app/src/trading/execution`. + +Итог: +- payload builders вынесены из крупных execution-файлов; +- reject/block helpers добавлены там, где это безопасно; +- pricing унифицирован через `_build_execution_price()`; +- position metrics признан центральной точкой расчётов; +- engine подтверждён как orchestration layer; +- бизнес-логика не менялась; +- payload не расширялся; +- бот после каждого шага успешно перезапускался. \ No newline at end of file