07.4.4.1.8 Execution Freshness and Market Quality Layer
This commit is contained in:
@@ -159,6 +159,7 @@ def _build_waiting_text(state) -> str:
|
||||
_signal_line(state),
|
||||
_market_state_line(state),
|
||||
_entry_block_line(state),
|
||||
_execution_quality_line(state),
|
||||
*_signal_confidence_lines(state),
|
||||
*_execution_block_lines(state),
|
||||
]
|
||||
@@ -173,8 +174,7 @@ def _build_waiting_text(state) -> str:
|
||||
"",
|
||||
*signal_lines,
|
||||
"",
|
||||
"🧾 <b>Подготовка ордера</b>",
|
||||
"",
|
||||
"🧾 Подготовка ордера",
|
||||
_order_header_line(state),
|
||||
f"<b>{_price_label_for_signal(state)}</b> · {_format_usd_or_dash(price)}",
|
||||
_estimated_size_text(state, price),
|
||||
@@ -217,6 +217,7 @@ def _build_active_position_text(state) -> str:
|
||||
f"<b>Зарезервировано</b> · $ {_format_money_compact(reserved)}",
|
||||
f"<b>P&L</b> {_format_signed_usd_with_direction(pnl)}",
|
||||
_market_state_line(state),
|
||||
_execution_quality_line(state),
|
||||
*_execution_block_lines(state),
|
||||
"",
|
||||
(
|
||||
@@ -286,7 +287,7 @@ def _entry_block_line(state) -> str:
|
||||
signal = (state.last_signal or "HOLD").upper()
|
||||
|
||||
if signal == "HOLD":
|
||||
return f"Ожидание · {compact_message}"
|
||||
return f"Условие · {compact_message}"
|
||||
|
||||
if signal in {"BUY", "SELL"}:
|
||||
return f"Вход · {compact_message}"
|
||||
@@ -294,12 +295,55 @@ def _entry_block_line(state) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _execution_quality_line(state) -> str:
|
||||
quality = getattr(state, "execution_quality", None)
|
||||
reason = getattr(state, "execution_quality_reason", None)
|
||||
spread_percent = getattr(state, "spread_percent", None)
|
||||
age_seconds = getattr(state, "snapshot_age_seconds", None)
|
||||
|
||||
if not quality:
|
||||
return ""
|
||||
|
||||
if quality == "GOOD":
|
||||
return ""
|
||||
|
||||
if reason == "WIDE_SPREAD" and spread_percent is not None:
|
||||
return f"⚠️ Рынок · spread {_format_percent(spread_percent)}"
|
||||
|
||||
if reason == "AGING_SNAPSHOT" and age_seconds is not None:
|
||||
return f"⚠️ Рынок · данные стареют ({age_seconds:.1f}с)"
|
||||
|
||||
if reason == "STALE_SNAPSHOT":
|
||||
return "⛔ Вход · рынок неактуален"
|
||||
|
||||
if reason == "HIGH_SPREAD" and spread_percent is not None:
|
||||
return f"⛔ Вход · высокий spread {_format_percent(spread_percent)}"
|
||||
|
||||
if reason == "SNAPSHOT_UNAVAILABLE":
|
||||
return "⚠️ Рынок · нет depth snapshot"
|
||||
|
||||
if reason == "SNAPSHOT_ERROR":
|
||||
return "⛔ Вход · нет данных рынка"
|
||||
|
||||
message = getattr(state, "execution_quality_message", None)
|
||||
|
||||
if not message:
|
||||
return ""
|
||||
|
||||
return f"⚠️ Рынок · {message}"
|
||||
|
||||
|
||||
def _execution_block_lines(state) -> list[str]:
|
||||
lines: list[str] = []
|
||||
|
||||
reason = getattr(state, "execution_block_reason", None)
|
||||
if reason:
|
||||
lines.append(f"Исполнение · {reason}")
|
||||
if reason and reason not in {
|
||||
"высокий spread",
|
||||
"spread повышен",
|
||||
"snapshot устарел",
|
||||
"рынок неактуален",
|
||||
}:
|
||||
lines.append(f"Вход · {reason}")
|
||||
|
||||
adjustment = getattr(state, "execution_size_adjustment_reason", None)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.trading.execution.engine import ExecutionEngine
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.strategies.base import BaseStrategy, StrategyContext
|
||||
from src.trading.strategies.registry import StrategyRegistry
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
|
||||
|
||||
class AutoTradeService:
|
||||
@@ -42,6 +43,12 @@ class AutoTradeService:
|
||||
_last_logged_entry_block_reason: str | None = None
|
||||
_same_signal_count = 0
|
||||
|
||||
_max_snapshot_age_seconds = 5.0
|
||||
_warning_snapshot_age_seconds = 2.0
|
||||
_max_spread_percent = 0.15
|
||||
_warning_spread_percent = 0.08
|
||||
_last_logged_execution_quality_key: str | None = None
|
||||
|
||||
# debug: принудительно выставить сигнал и decision
|
||||
def debug_force_signal(
|
||||
self,
|
||||
@@ -325,6 +332,12 @@ class AutoTradeService:
|
||||
state.entry_block_message = None
|
||||
state.runtime_expired_reason = None
|
||||
state.runtime_expired_message = None
|
||||
state.snapshot_age_seconds = None
|
||||
state.spread_percent = None
|
||||
state.execution_quality = None
|
||||
state.execution_quality_reason = None
|
||||
state.execution_quality_message = None
|
||||
state.market_runtime_degraded = False
|
||||
|
||||
# собрать контекст для стратегии
|
||||
def _build_strategy_context(self) -> StrategyContext:
|
||||
@@ -952,6 +965,203 @@ class AutoTradeService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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 = 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:
|
||||
state.execution_quality = "BLOCKED"
|
||||
state.execution_quality_reason = "SNAPSHOT_ERROR"
|
||||
state.execution_quality_message = "нет market data"
|
||||
state.market_runtime_degraded = True
|
||||
|
||||
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 = self._safe_float(snapshot.get("bid_price"))
|
||||
ask_price = self._safe_float(snapshot.get("ask_price"))
|
||||
last_price = self._safe_float(snapshot.get("last_price"))
|
||||
age_seconds = self._safe_float(snapshot.get("age_seconds"))
|
||||
is_fresh = bool(snapshot.get("is_fresh", False))
|
||||
source = str(snapshot.get("source") or "")
|
||||
|
||||
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 state.spread_percent is not None and state.spread_percent > self._max_spread_percent:
|
||||
state.execution_quality = "BLOCKED"
|
||||
state.execution_quality_reason = "HIGH_SPREAD"
|
||||
state.execution_quality_message = "высокий spread"
|
||||
state.market_runtime_degraded = False
|
||||
|
||||
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 and state.spread_percent > self._warning_spread_percent:
|
||||
state.execution_quality = "WARNING"
|
||||
state.execution_quality_reason = "WIDE_SPREAD"
|
||||
state.execution_quality_message = "spread повышен"
|
||||
state.market_runtime_degraded = False
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
"max_spread_percent": self._max_spread_percent,
|
||||
"warning_spread_percent": self._warning_spread_percent,
|
||||
},
|
||||
)
|
||||
|
||||
def _spread_percent(
|
||||
self,
|
||||
*,
|
||||
bid_price: float | None,
|
||||
ask_price: float | None,
|
||||
) -> float | None:
|
||||
if bid_price is None or ask_price is None:
|
||||
return None
|
||||
|
||||
if bid_price <= 0 or ask_price <= 0:
|
||||
return None
|
||||
|
||||
mid_price = (bid_price + ask_price) / 2
|
||||
if mid_price <= 0:
|
||||
return None
|
||||
|
||||
spread = ask_price - bid_price
|
||||
if spread < 0:
|
||||
return None
|
||||
|
||||
return round((spread / mid_price) * 100, 5)
|
||||
|
||||
def _safe_float(self, value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _log_execution_quality_if_changed(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
payload: dict,
|
||||
) -> 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
|
||||
|
||||
def run_cycle(self) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
|
||||
@@ -969,6 +1179,8 @@ class AutoTradeService:
|
||||
payload=result.payload,
|
||||
)
|
||||
|
||||
self._sync_execution_quality_state(state)
|
||||
|
||||
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
self._log_signal_if_changed(
|
||||
@@ -980,6 +1192,7 @@ class AutoTradeService:
|
||||
payload=result.payload,
|
||||
)
|
||||
|
||||
ExecutionEngine().process(state)
|
||||
if state.execution_quality != "BLOCKED":
|
||||
ExecutionEngine().process(state)
|
||||
|
||||
return state
|
||||
@@ -137,4 +137,22 @@ class AutoTradeState:
|
||||
runtime_expired_reason: str | None = None
|
||||
|
||||
# человекочитаемое сообщение runtime expiration
|
||||
runtime_expired_message: str | None = None
|
||||
runtime_expired_message: str | None = None
|
||||
|
||||
# возраст последнего market snapshot в секундах
|
||||
snapshot_age_seconds: float | None = None
|
||||
|
||||
# spread между bid/ask в %
|
||||
spread_percent: float | None = None
|
||||
|
||||
# качество рынка для исполнения: GOOD / WARNING / BLOCKED / UNKNOWN
|
||||
execution_quality: str | None = None
|
||||
|
||||
# код причины качества исполнения
|
||||
execution_quality_reason: str | None = None
|
||||
|
||||
# человекочитаемое объяснение качества исполнения
|
||||
execution_quality_message: str | None = None
|
||||
|
||||
# признак деградации runtime market data
|
||||
market_runtime_degraded: bool = False
|
||||
Reference in New Issue
Block a user