Stage 07.4.4.1.14 — Execution refactoring and runtime semantics

This commit is contained in:
2026-07-02 16:25:18 +03:00
parent d9e6392e28
commit e08c604ff5
72 changed files with 13626 additions and 4233 deletions

View File

@@ -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",

View File

@@ -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)
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
]

View File

@@ -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",
}

View File

@@ -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
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)

View File

@@ -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,

View File

@@ -132,4 +132,14 @@ class KlineBatch:
symbol: str
interval: str
candles: list[Kline]
source: str
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

View File

@@ -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(),
)

View File

@@ -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):

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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 = [
"<b>🧾 Позиция открыта</b>",
"",
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"🧾 Открытие · <b>{symbol}</b> {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 = [
"<b>🧾 Сделка закрыта</b>",
f"{pnl_icon} {pnl_label} · {pnl_text}",
"",
f"{symbol} · {side} {leverage}",
f"Вход: ${entry_price}",
f"Выход: ${exit_price}",
f"Размер: {size}",
f"💰 Закрытие · <b>{symbol}</b> {side_icon} {side}",
f"<b>{pnl_label}</b> {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 = [
"<b>🧾 Сделка развернута</b>",
f"{pnl_label} {pnl_icon} {pnl_text}",
f"{symbol} · {strategy} {old_icon} {old_side}{new_icon} {new_side}",
f"🔄 Разворот · <b>{symbol}</b> {old_icon} {old_side}{new_icon} {new_side}",
f"<b>{pnl_label}</b> {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"<b>⚠️ Flip отменён</b>\n\n"
f"{icon} {symbol} · {target_side}\n"
f"Текущая позиция: {position_side}\n\n"
f"Недостаточно условий для разворота\n"
f"{reason}\n"
f"Сила сигнала: {confidence:.2f}"
f"<b>Flip отменён {symbol} {icon} {target_side}</b>\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(".")
return mapping.get(priority.upper(), "●○○")

View File

@@ -38,9 +38,27 @@ def build_signal_notification(event: RuntimeEvent) -> NotificationMessage | None
strength_bar = _strength_bar(priority)
lines = [
f"<b>Сигнал {icon} {symbol} · {direction}</b>",
f"⚡️ Сигнал · <b>{symbol}</b> {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}"
)

View File

@@ -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:

View File

@@ -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 (
"<b>🧯 Защита позиции</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\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()

View File

@@ -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:]
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 "Неблагоприятный"

View File

@@ -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 (
"<b>📈 Рынок</b>\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 (
"<b>📈 Рынок</b>\n"
f"{mode_line()}"
"\n"
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
f"<b>$ {format_usd_amount(price)}</b> {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="<b>📈 Рынок</b>",
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="<b>📈 Рынок</b>",
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="<b>📈 Рынок</b>",
exc=exc,
network_details="Рыночные данные недоступны.\nОбнови экран.",
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
retry_callback_data="market:retry",
)

View File

@@ -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 лог: <b>ВКЛ</b>"
return "🐞 Debug лог: <b>ВЫКЛ</b>"
@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:
"<b>📒 Журнал</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\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"):

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)
# Поддерживаем внутренний AUTH_ERROR и enum-value.
return reason in {
"AUTH_ERROR",
ExchangeStatusCode.AUTH_ERROR.value,
}

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"
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", "критичного застоя нет"

View File

@@ -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)},
)
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)})

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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,
*,

View File

@@ -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"
)
return datetime.now().strftime("%H:%M:%S")

View File

@@ -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}"

View File

@@ -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, "Нет торгового действия.")
return self._skip_execution(
state,
"Нет торгового действия.",
)

View File

@@ -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}.",
)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
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,
)

View File

@@ -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
return "FRESH"

View File

@@ -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
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.")

View File

@@ -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

View File

@@ -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)
if pnl is None:
return False
return pnl <= -abs(max_loss_usd)

View File

@@ -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,
)
)
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

View File

@@ -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

View File

@@ -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",

View File

@@ -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),

View File

@@ -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

View File

@@ -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))

View File

@@ -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",
]

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
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

View File

@@ -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"),
}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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}."

View File

@@ -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,
)

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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}",
)

View File

@@ -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,
)

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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",