Stage 07.4.4.1.14 — Execution refactoring and runtime semantics
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(), "●○○")
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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 "Неблагоприятный"
|
||||
505
app/src/telegram/handlers/market.py
Normal file
505
app/src/telegram/handlers/market.py
Normal 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",
|
||||
)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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", "критичного застоя нет"
|
||||
@@ -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)})
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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")
|
||||
360
app/src/trading/execution/constants.py
Normal file
360
app/src/trading/execution/constants.py
Normal 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}"
|
||||
@@ -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,
|
||||
"Нет торгового действия.",
|
||||
)
|
||||
@@ -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
408
app/src/trading/execution/position_exit_decision.py
Normal file
408
app/src/trading/execution/position_exit_decision.py
Normal 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
|
||||
@@ -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
|
||||
512
app/src/trading/execution/position_metrics.py
Normal file
512
app/src/trading/execution/position_metrics.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
196
app/src/trading/market_analysis/filters.py
Normal file
196
app/src/trading/market_analysis/filters.py
Normal 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
|
||||
487
app/src/trading/market_analysis/htf.py
Normal file
487
app/src/trading/market_analysis/htf.py
Normal 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))
|
||||
13
app/src/trading/market_analysis/indicators/__init__.py
Normal file
13
app/src/trading/market_analysis/indicators/__init__.py
Normal 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",
|
||||
]
|
||||
218
app/src/trading/market_analysis/indicators/momentum.py
Normal file
218
app/src/trading/market_analysis/indicators/momentum.py
Normal 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",
|
||||
)
|
||||
243
app/src/trading/market_analysis/indicators/trend.py
Normal file
243
app/src/trading/market_analysis/indicators/trend.py
Normal 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
|
||||
129
app/src/trading/market_analysis/indicators/volatility.py
Normal file
129
app/src/trading/market_analysis/indicators/volatility.py
Normal 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
|
||||
25
app/src/trading/market_analysis/indicators/volume.py
Normal file
25
app/src/trading/market_analysis/indicators/volume.py
Normal 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
|
||||
@@ -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
|
||||
190
app/src/trading/market_analysis/payload.py
Normal file
190
app/src/trading/market_analysis/payload.py
Normal 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"),
|
||||
}
|
||||
139
app/src/trading/market_analysis/phase.py
Normal file
139
app/src/trading/market_analysis/phase.py
Normal 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"
|
||||
81
app/src/trading/market_analysis/quality.py
Normal file
81
app/src/trading/market_analysis/quality.py
Normal 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)
|
||||
135
app/src/trading/market_analysis/reason.py
Normal file
135
app/src/trading/market_analysis/reason.py
Normal 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}."
|
||||
178
app/src/trading/market_analysis/result.py
Normal file
178
app/src/trading/market_analysis/result.py
Normal 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,
|
||||
)
|
||||
89
app/src/trading/market_analysis/scoring.py
Normal file
89
app/src/trading/market_analysis/scoring.py
Normal 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
104
app/src/trading/market_analysis/state.py
Normal file
104
app/src/trading/market_analysis/state.py
Normal 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
|
||||
157
app/src/trading/market_analysis/structure.py
Normal file
157
app/src/trading/market_analysis/structure.py
Normal 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}",
|
||||
)
|
||||
182
app/src/trading/market_analysis/unknown.py
Normal file
182
app/src/trading/market_analysis/unknown.py
Normal 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,
|
||||
)
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user