07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
This commit is contained in:
488
app/src/trading/auto/execution_quality.py
Normal file
488
app/src/trading/auto/execution_quality.py
Normal 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
|
||||
Reference in New Issue
Block a user