07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics

This commit is contained in:
2026-05-28 10:30:54 +03:00
parent f9a25e7671
commit d9e6392e28
75 changed files with 9934 additions and 10508 deletions

View File

@@ -0,0 +1,488 @@
# app/src/trading/auto/execution_quality.py
from __future__ import annotations
import time
from src.core.numbers import safe_float
from src.core.types import NumericLike
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import (
ExchangeRuntimeStatus,
ExchangeStatusCode,
build_exchange_error_status,
)
from src.trading.auto.state import AutoTradeState
from src.trading.journal.service import JournalService
class AutoExecutionQualityMixin:
_spread_thresholds_by_asset: dict[str, dict[str, float]]
_default_spread_thresholds: dict[str, float]
_max_snapshot_age_seconds: float
_warning_snapshot_age_seconds: float
_last_logged_execution_quality_key: str | None
# получить базовый asset из symbol для spread thresholds
def _asset_symbol(self, symbol: str | None) -> str:
if not symbol:
return ""
base = str(symbol).split("_", 1)[0].upper()
if "/" in base:
return base.split("/", 1)[0]
for suffix in ("USDT", "USD", "EUR", "BTC"):
if base.endswith(suffix) and len(base) > len(suffix):
return base[: -len(suffix)]
return base
# получить spread thresholds для конкретного инструмента
def _spread_thresholds(self, symbol: str | None) -> dict[str, float]:
asset = self._asset_symbol(symbol)
return self._spread_thresholds_by_asset.get(
asset,
self._default_spread_thresholds,
)
# синхронизировать единый статус биржи/торговой сессии в AutoTradeState
def _sync_market_availability_state(self, state: AutoTradeState) -> bool:
try:
status = ExchangeService().get_symbol_runtime_status(state.symbol)
except Exception as exc:
status = build_exchange_error_status(exc)
state.market_is_open = status.is_open
state.market_status = status.code.value
state.market_status_message = status.ui_line
state.market_status_updated_at = time.monotonic()
if status.is_open:
self._clear_exchange_block_state(state)
return True
self._apply_exchange_block_state(
state=state,
status=status,
)
return False
# очистить старую блокировку биржи, если рынок снова доступен
def _clear_exchange_block_state(self, state: AutoTradeState) -> None:
if state.execution_quality_reason not in {
"MARKET_BREAK",
"EXCHANGE_UNAVAILABLE",
"AUTH_ERROR",
"TIME_ERROR",
"INVALID_SYMBOL",
"MARKET_CLOSED",
}:
return
state.execution_quality = None
state.execution_quality_reason = None
state.execution_quality_message = None
state.execution_block_reason = None
state.market_runtime_degraded = False
state.entry_block_reason = None
state.entry_block_message = None
# применить блокировку execution по единому ExchangeRuntimeStatus
def _apply_exchange_block_state(
self,
*,
state: AutoTradeState,
status: ExchangeRuntimeStatus,
) -> None:
reason = self._exchange_execution_reason(status)
message = status.ui_line or status.message
state.execution_quality = "BLOCKED"
state.execution_quality_reason = reason
state.execution_quality_message = message
state.execution_block_reason = message
state.market_runtime_degraded = True
state.entry_block_reason = reason
state.entry_block_message = message
state.decision_status = "WAITING"
state.decision_reason = message
state.is_signal_confirmed = False
state.is_signal_ready = False
self._log_exchange_availability_if_changed(
state=state,
status=status,
reason=reason,
)
# преобразовать typed exchange status в код причины execution layer
def _exchange_execution_reason(self, status: ExchangeRuntimeStatus) -> str:
if status.code == ExchangeStatusCode.BREAK:
return "MARKET_BREAK"
if status.code == ExchangeStatusCode.AUTH_ERROR:
return "AUTH_ERROR"
if status.code == ExchangeStatusCode.TIME_ERROR:
return "TIME_ERROR"
if status.code == ExchangeStatusCode.INVALID_SYMBOL:
return "INVALID_SYMBOL"
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
return "EXCHANGE_UNAVAILABLE"
return "MARKET_BREAK"
# залогировать изменение доступности биржи/рынка
def _log_exchange_availability_if_changed(
self,
*,
state: AutoTradeState,
status: ExchangeRuntimeStatus,
reason: str,
) -> None:
key = (
f"{state.status}:{state.symbol}:{state.strategy}:"
f"{status.code.value}:{reason}:{status.ui_line}"
)
if key == type(self)._last_logged_execution_quality_key:
return
type(self)._last_logged_execution_quality_key = key
try:
JournalService().log_ui_warning(
event_type="exchange_availability_changed",
message=status.ui_line,
screen="auto",
action="exchange_status",
payload={
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
"exchange_status_code": status.code.value,
"exchange_reason": status.reason,
"execution_reason": reason,
"is_open": status.is_open,
"is_available": status.is_available,
"is_auth_ok": status.is_auth_ok,
"message": status.message,
"raw_status": status.raw_status,
"raw_error": status.raw_error,
},
)
except Exception:
pass
# рассчитать качество исполнения на основе spread
def _spread_execution_quality(
self,
*,
state: AutoTradeState,
spread_percent: NumericLike | None,
) -> tuple[str | None, str | None, str | None, bool]:
spread = safe_float(spread_percent)
if spread is None:
return None, None, None, False
thresholds = self._spread_thresholds(state.symbol)
warning_enter = thresholds["warning_enter"]
warning_exit = thresholds["warning_exit"]
block_enter = thresholds["block_enter"]
block_exit = thresholds["block_exit"]
previous_quality = state.execution_quality
previous_reason = state.execution_quality_reason
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
if spread > block_exit:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread >= warning_enter:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
# синхронизировать runtime quality исполнения
def _sync_execution_quality_state(self, state: AutoTradeState) -> None:
try:
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
except Exception as exc:
fallback_price = None
try:
fallback_price = safe_float(
ExchangeService().get_price(
state.symbol,
runtime_key="auto",
).price
)
except Exception:
pass
state.snapshot_age_seconds = None
state.spread_percent = None
if fallback_price is not None and fallback_price > 0:
state.execution_quality = "WARNING"
state.execution_quality_reason = "SNAPSHOT_UNAVAILABLE"
state.execution_quality_message = "нет depth snapshot"
state.market_runtime_degraded = True
else:
status = build_exchange_error_status(exc)
self._apply_exchange_block_state(
state=state,
status=status,
)
self._log_execution_quality_if_changed(
state=state,
payload={
"error": str(exc),
"error_type": type(exc).__name__,
"fallback_price_available": fallback_price is not None,
},
)
return
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
last_price = safe_float(snapshot.get("last_price"))
age_seconds = safe_float(snapshot.get("age_seconds"))
is_fresh = bool(snapshot.get("is_fresh", False))
source = str(snapshot.get("source") or "")
self._sync_execution_pricing_state(
state,
snapshot,
)
state.snapshot_age_seconds = age_seconds
state.spread_percent = self._spread_percent(
bid_price=bid_price,
ask_price=ask_price,
)
if age_seconds is not None and age_seconds > self._max_snapshot_age_seconds:
state.execution_quality = "BLOCKED"
state.execution_quality_reason = "STALE_SNAPSHOT"
state.execution_quality_message = "snapshot устарел"
state.market_runtime_degraded = True
elif age_seconds is not None and age_seconds > self._warning_snapshot_age_seconds:
state.execution_quality = "WARNING"
state.execution_quality_reason = "AGING_SNAPSHOT"
state.execution_quality_message = "snapshot стареет"
state.market_runtime_degraded = not is_fresh
elif state.spread_percent is not None:
(
state.execution_quality,
state.execution_quality_reason,
state.execution_quality_message,
state.market_runtime_degraded,
) = self._spread_execution_quality(
state=state,
spread_percent=state.spread_percent,
)
else:
state.execution_quality = "GOOD"
state.execution_quality_reason = "MARKET_OK"
state.execution_quality_message = "рынок готов"
state.market_runtime_degraded = False
if state.execution_quality == "BLOCKED":
state.execution_block_reason = state.execution_quality_message
elif state.execution_block_reason == state.execution_quality_message:
state.execution_block_reason = None
spread_thresholds = self._spread_thresholds(state.symbol)
self._log_execution_quality_if_changed(
state=state,
payload={
"symbol": state.symbol,
"strategy": state.strategy,
"bid_price": bid_price,
"ask_price": ask_price,
"last_price": last_price,
"snapshot_age_seconds": age_seconds,
"spread_percent": state.spread_percent,
"is_fresh": is_fresh,
"source": source,
"execution_quality": state.execution_quality,
"execution_quality_reason": state.execution_quality_reason,
"execution_quality_message": state.execution_quality_message,
"market_runtime_degraded": state.market_runtime_degraded,
"max_snapshot_age_seconds": self._max_snapshot_age_seconds,
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
"spread_asset": self._asset_symbol(state.symbol),
"spread_warning_enter_percent": spread_thresholds["warning_enter"],
"spread_warning_exit_percent": spread_thresholds["warning_exit"],
"spread_block_enter_percent": spread_thresholds["block_enter"],
"spread_block_exit_percent": spread_thresholds["block_exit"],
},
)
# рассчитать spread между bid/ask в процентах
def _spread_percent(
self,
*,
bid_price: NumericLike | None,
ask_price: NumericLike | None,
) -> float | None:
bid = safe_float(bid_price)
ask = safe_float(ask_price)
if bid is None or ask is None:
return None
if bid <= 0 or ask <= 0:
return None
mid_price = (bid + ask) / 2
if mid_price <= 0:
return None
spread = ask - bid
if spread < 0:
return None
return round((spread / mid_price) * 100, 5)
# синхронизировать execution pricing данные в state
def _sync_execution_pricing_state(
self,
state: AutoTradeState,
snapshot: dict[str, object],
) -> None:
age_seconds = safe_float(snapshot.get("age_seconds"))
state.execution_price_source = str(snapshot.get("source") or "")
state.execution_price_age_seconds = age_seconds
state.execution_bid_price = safe_float(snapshot.get("bid_price"))
state.execution_ask_price = safe_float(snapshot.get("ask_price"))
state.execution_last_price = safe_float(snapshot.get("last_price"))
if age_seconds is None:
state.execution_price_freshness = "UNKNOWN"
elif age_seconds <= 1:
state.execution_price_freshness = "FRESH"
elif age_seconds <= self._warning_snapshot_age_seconds:
state.execution_price_freshness = "AGING"
else:
state.execution_price_freshness = "STALE"
# записать событие изменения execution quality
def _log_execution_quality_if_changed(
self,
*,
state: AutoTradeState,
payload: dict[str, object],
) -> None:
quality = state.execution_quality
reason = state.execution_quality_reason
message = state.execution_quality_message
if not quality or not reason or not message:
return
key = f"{state.status}:{state.symbol}:{state.strategy}:{quality}:{reason}:{message}"
if key == type(self)._last_logged_execution_quality_key:
return
type(self)._last_logged_execution_quality_key = key
if quality == "GOOD":
return
try:
log_payload = {
**payload,
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
}
if quality == "BLOCKED":
JournalService().log_ui_warning(
event_type="execution_quality_changed",
message=f"Качество исполнения: {message}.",
screen="auto",
action="execution_quality",
payload=log_payload,
)
return
JournalService().log_ui_info(
event_type="execution_quality_changed",
message=f"Качество исполнения: {message}.",
screen="auto",
action="execution_quality",
payload=log_payload,
)
except Exception:
pass
# рассчитать confidence execution quality для общего execution confidence
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
quality = state.execution_quality
reason = state.execution_quality_reason
if quality == "GOOD":
return 1.0
if quality == "WARNING":
if reason == "WIDE_SPREAD":
return 0.65
if reason == "AGING_SNAPSHOT":
return 0.6
if reason == "SNAPSHOT_UNAVAILABLE":
return 0.55
return 0.6
if quality == "BLOCKED":
return 0.0
return 0.5