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