814 lines
30 KiB
Python
814 lines
30 KiB
Python
# app/src/trading/auto/signal_runtime.py
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from typing import Callable, cast
|
||
|
||
from src.core.event_bus import EventBus
|
||
from src.core.numbers import safe_float
|
||
from src.core.types import JsonDict, NumericLike
|
||
from src.integrations.exchange.service import ExchangeService
|
||
from src.trading.auto.state import AutoTradeState
|
||
from src.trading.journal.service import JournalService
|
||
|
||
|
||
class AutoSignalRuntimeMixin:
|
||
_loop_interval_seconds: int
|
||
|
||
_confirm_repeats: int
|
||
_confirm_min_duration_seconds: int
|
||
_ready_confidence: float
|
||
_execution_confidence_required_score: float
|
||
|
||
_signal_ttl_seconds: int
|
||
_market_analysis_ttl_seconds: int
|
||
_last_logged_runtime_expired_key: str | None
|
||
|
||
_last_signal_key: str | None
|
||
_last_signal_value: str | None
|
||
_last_signal_reason: str
|
||
_last_signal_confidence: float
|
||
_last_signal_payload: JsonDict | None
|
||
_last_signal_started_at: float | None
|
||
_same_signal_count: int
|
||
|
||
# получить state из основного AutoTradeService
|
||
def get_state(self) -> AutoTradeState:
|
||
raise NotImplementedError
|
||
|
||
# сбросить runtime tracking в основном AutoTradeService
|
||
def _reset_signal_tracking(self) -> None:
|
||
raise NotImplementedError
|
||
|
||
# debug: принудительно выставить сигнал и decision
|
||
def debug_force_signal(
|
||
self,
|
||
*,
|
||
signal: str,
|
||
confidence: NumericLike = 0.9,
|
||
repeat_count: int = 2,
|
||
reason: str = "DEBUG SIGNAL",
|
||
) -> AutoTradeState:
|
||
state = self.get_state()
|
||
confidence_value = safe_float(confidence) or 0.0
|
||
|
||
normalized_signal = signal.strip().upper()
|
||
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
|
||
normalized_signal = "HOLD"
|
||
|
||
previous_signal = state.last_signal
|
||
previous_decision_status = state.decision_status
|
||
|
||
if previous_signal != normalized_signal or state.signal_started_at is None:
|
||
state.signal_started_at = time.monotonic()
|
||
|
||
state.last_signal = normalized_signal
|
||
state.last_signal_repeat_count = repeat_count
|
||
state.last_signal_confidence = confidence_value
|
||
state.last_signal_reason = reason
|
||
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
|
||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||
state.signal_confirmation_missing_repeats = 0
|
||
state.signal_confirmation_progress = 1.0
|
||
state.signal_confirmation_reason = "debug confirmation"
|
||
|
||
if normalized_signal == "HOLD":
|
||
state.decision_status = "WAITING"
|
||
state.decision_reason = "Debug HOLD."
|
||
state.is_signal_confirmed = False
|
||
state.is_signal_ready = False
|
||
else:
|
||
state.decision_status = "READY"
|
||
state.decision_reason = "Debug READY signal."
|
||
state.is_signal_confirmed = True
|
||
state.is_signal_ready = True
|
||
|
||
signal_intent = self._signal_intent(
|
||
state=state,
|
||
signal=state.last_signal,
|
||
)
|
||
|
||
EventBus.emit(
|
||
"auto_decision_changed",
|
||
{
|
||
"previous_signal": previous_signal,
|
||
"previous_decision_status": previous_decision_status,
|
||
"decision_status": state.decision_status,
|
||
"signal": state.last_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"confidence": state.last_signal_confidence,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"leverage": state.leverage,
|
||
"reason": state.last_signal_reason,
|
||
"debug": True,
|
||
},
|
||
)
|
||
|
||
return state
|
||
|
||
# определить смысл сигнала с учетом открытой позиции
|
||
def _signal_intent(self, *, state: AutoTradeState, signal: str | None) -> str:
|
||
normalized_signal = (signal or "HOLD").upper()
|
||
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
|
||
|
||
if normalized_signal == "HOLD":
|
||
return "HOLD_MARKET"
|
||
|
||
if normalized_signal not in {"BUY", "SELL"}:
|
||
return "NOISE"
|
||
|
||
if position_side == "NONE":
|
||
return "ENTRY_CANDIDATE"
|
||
|
||
if position_side == "LONG" and normalized_signal == "BUY":
|
||
return "REINFORCE_POSITION"
|
||
|
||
if position_side == "SHORT" and normalized_signal == "SELL":
|
||
return "REINFORCE_POSITION"
|
||
|
||
if position_side == "LONG" and normalized_signal == "SELL":
|
||
return "REVERSAL_CANDIDATE"
|
||
|
||
if position_side == "SHORT" and normalized_signal == "BUY":
|
||
return "REVERSAL_CANDIDATE"
|
||
|
||
return "NOISE"
|
||
|
||
# обновить статус решения по текущему сигналу
|
||
def _update_decision_state(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
confidence: float,
|
||
) -> None:
|
||
state.is_signal_confirmed = False
|
||
state.is_signal_ready = False
|
||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||
|
||
if signal == "HOLD":
|
||
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.decision_status = "WAITING"
|
||
state.decision_reason = "Нет торгового направления."
|
||
return
|
||
|
||
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
|
||
)
|
||
|
||
missing_repeats = max(0, self._confirm_repeats - self._same_signal_count)
|
||
missing_seconds = max(
|
||
0,
|
||
self._confirm_min_duration_seconds - signal_age_seconds,
|
||
)
|
||
|
||
repeat_progress = min(
|
||
1.0,
|
||
self._same_signal_count / max(1, self._confirm_repeats),
|
||
)
|
||
time_progress = min(
|
||
1.0,
|
||
signal_age_seconds / max(1, self._confirm_min_duration_seconds),
|
||
)
|
||
|
||
confirmation_progress = min(repeat_progress, time_progress)
|
||
|
||
state.signal_confirmation_seconds = signal_age_seconds
|
||
state.signal_confirmation_missing_repeats = missing_repeats
|
||
state.signal_confirmation_progress = round(confirmation_progress, 3)
|
||
|
||
if missing_repeats > 0 or missing_seconds > 0:
|
||
state.decision_status = "CONFIRMING"
|
||
state.signal_confirmation_reason = (
|
||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с"
|
||
)
|
||
state.decision_reason = (
|
||
f"Сигнал {signal} подтверждается: "
|
||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с."
|
||
)
|
||
return
|
||
|
||
state.is_signal_confirmed = True
|
||
state.signal_confirmation_reason = "сигнал подтверждён"
|
||
|
||
if confidence < self._ready_confidence:
|
||
state.decision_status = "BLOCKED"
|
||
state.decision_reason = (
|
||
f"Сигнал {signal} подтверждён, но уверенность низкая: "
|
||
f"{confidence:.2f} < {self._ready_confidence:.2f}."
|
||
)
|
||
return
|
||
|
||
self._sync_execution_confidence_state(
|
||
state=state,
|
||
signal=signal,
|
||
confidence=confidence,
|
||
)
|
||
|
||
if (
|
||
state.execution_confidence_score is not None
|
||
and state.execution_confidence_score < self._execution_confidence_required_score
|
||
):
|
||
state.decision_status = "BLOCKED"
|
||
state.decision_reason = (
|
||
f"Execution confidence низкий: "
|
||
f"{state.execution_confidence_score:.2f} < "
|
||
f"{self._execution_confidence_required_score:.2f}."
|
||
)
|
||
return
|
||
|
||
state.is_signal_ready = True
|
||
state.signal_confirmation_progress = 1.0
|
||
state.decision_status = "READY"
|
||
state.decision_reason = (
|
||
f"Сигнал {signal} подтверждён по повторам и времени удержания."
|
||
)
|
||
|
||
# записать новый сигнал и итог предыдущей серии при смене сигнала
|
||
def _log_signal_if_changed(
|
||
self,
|
||
*,
|
||
strategy_name: str,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
payload: JsonDict | None,
|
||
) -> None:
|
||
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
|
||
previous_signal = self._last_signal_value
|
||
previous_count = self._same_signal_count
|
||
is_same_signal = signal_key == self._last_signal_key
|
||
now = time.monotonic()
|
||
|
||
if is_same_signal:
|
||
self._same_signal_count += 1
|
||
self._last_signal_reason = reason
|
||
self._last_signal_confidence = confidence
|
||
self._last_signal_payload = payload
|
||
|
||
self._update_signal_state_fields(
|
||
state=state,
|
||
signal=signal,
|
||
reason=reason,
|
||
confidence=confidence,
|
||
)
|
||
return
|
||
|
||
if previous_signal is not None and previous_signal != signal:
|
||
if previous_count > 1:
|
||
self._log_signal_summary(
|
||
strategy_name=strategy_name,
|
||
state=state,
|
||
previous_signal=previous_signal,
|
||
previous_count=previous_count,
|
||
next_signal=signal,
|
||
reason=self._last_signal_reason,
|
||
confidence=self._last_signal_confidence,
|
||
payload=self._last_signal_payload,
|
||
duration_seconds=self._signal_duration_seconds(now=now),
|
||
)
|
||
else:
|
||
self._log_signal_event(
|
||
strategy_name=strategy_name,
|
||
state=state,
|
||
signal=previous_signal,
|
||
reason=f"{previous_signal} завершился без серии.",
|
||
confidence=self._last_signal_confidence,
|
||
payload={
|
||
"previous_signal": previous_signal,
|
||
"next_signal": signal,
|
||
},
|
||
)
|
||
|
||
self._last_signal_key = signal_key
|
||
self._last_signal_value = signal
|
||
self._last_signal_reason = reason
|
||
self._last_signal_confidence = confidence
|
||
self._last_signal_payload = payload
|
||
self._last_signal_started_at = now
|
||
self._same_signal_count = 1
|
||
|
||
self._update_signal_state_fields(
|
||
state=state,
|
||
signal=signal,
|
||
reason=reason,
|
||
confidence=confidence,
|
||
)
|
||
|
||
# рассчитать длительность текущей серии сигналов
|
||
def _signal_duration_seconds(self, *, now: float) -> int:
|
||
if self._last_signal_started_at is None:
|
||
return max(0, int(self._same_signal_count * self._loop_interval_seconds))
|
||
|
||
return max(0, int(now - self._last_signal_started_at))
|
||
|
||
# отформатировать длительность для журнала
|
||
def _format_duration(self, total_seconds: int) -> str:
|
||
total_seconds = max(0, int(total_seconds))
|
||
|
||
hours = total_seconds // 3600
|
||
minutes = (total_seconds % 3600) // 60
|
||
seconds = total_seconds % 60
|
||
|
||
if hours > 0:
|
||
return f"{hours}ч {minutes:02d}м {seconds:02d}с"
|
||
|
||
if minutes > 0:
|
||
return f"{minutes}м {seconds:02d}с"
|
||
|
||
return f"{seconds}с"
|
||
|
||
# обновить поля state для экрана автоторговли
|
||
def _update_signal_state_fields(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
) -> None:
|
||
previous_signal = state.last_signal
|
||
previous_decision_status = state.decision_status
|
||
|
||
if previous_signal != signal or state.signal_started_at is None:
|
||
state.signal_started_at = time.monotonic()
|
||
|
||
state.last_signal = signal
|
||
state.last_signal_repeat_count = self._same_signal_count
|
||
state.last_signal_confidence = confidence
|
||
state.last_signal_reason = reason
|
||
state.signal_updated_at = time.monotonic()
|
||
state.runtime_expired_reason = None
|
||
state.runtime_expired_message = None
|
||
|
||
self._update_decision_state(
|
||
state=state,
|
||
signal=signal,
|
||
confidence=confidence,
|
||
)
|
||
|
||
signal_intent = self._signal_intent(
|
||
state=state,
|
||
signal=state.last_signal,
|
||
)
|
||
|
||
if (
|
||
previous_decision_status != state.decision_status
|
||
and state.decision_status == "READY"
|
||
):
|
||
self._log_ready_signal(
|
||
state=state,
|
||
signal=state.last_signal,
|
||
reason=state.last_signal_reason or reason,
|
||
confidence=state.last_signal_confidence,
|
||
signal_intent=signal_intent,
|
||
)
|
||
|
||
if previous_signal != state.last_signal:
|
||
EventBus.emit(
|
||
"auto_signal_changed",
|
||
{
|
||
"previous_signal": previous_signal,
|
||
"signal": state.last_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"confidence": state.last_signal_confidence,
|
||
},
|
||
)
|
||
|
||
if previous_decision_status != state.decision_status:
|
||
EventBus.emit(
|
||
"auto_decision_changed",
|
||
{
|
||
"previous_decision_status": previous_decision_status,
|
||
"decision_status": state.decision_status,
|
||
"signal": state.last_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"confidence": state.last_signal_confidence,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"leverage": state.leverage,
|
||
"reason": state.last_signal_reason,
|
||
},
|
||
)
|
||
|
||
# одиночные BUY / SELL больше не пишем в журнал как полезные события
|
||
def _log_signal_event(
|
||
self,
|
||
*,
|
||
strategy_name: str,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
payload: JsonDict | None,
|
||
) -> None:
|
||
return
|
||
|
||
# записать итог серии одинаковых сигналов при смене сигнала
|
||
def _log_signal_summary(
|
||
self,
|
||
*,
|
||
strategy_name: str,
|
||
state: AutoTradeState,
|
||
previous_signal: str,
|
||
previous_count: int,
|
||
next_signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
payload: JsonDict | None,
|
||
duration_seconds: int,
|
||
) -> None:
|
||
if previous_signal != "HOLD":
|
||
return
|
||
|
||
duration_text = self._format_duration(duration_seconds)
|
||
signal_intent = "HOLD_MARKET"
|
||
|
||
try:
|
||
JournalService().log_ui_info(
|
||
event_type="signal_summary",
|
||
message=(
|
||
f"HOLD длился {duration_text} и завершился сигналом {next_signal}."
|
||
),
|
||
screen="auto",
|
||
action="signal_summary",
|
||
payload={
|
||
"strategy": strategy_name,
|
||
"status": state.status,
|
||
"symbol": state.symbol,
|
||
"signal": previous_signal,
|
||
"next_signal": next_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": previous_count,
|
||
"duration_seconds": duration_seconds,
|
||
"duration_text": duration_text,
|
||
"confidence": confidence,
|
||
"reason": reason,
|
||
"is_strong_signal": False,
|
||
"is_aggregated": True,
|
||
"payload": payload or {},
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# записать событие готовности сигнала к исполнению
|
||
def _log_ready_signal(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str | None,
|
||
reason: str,
|
||
confidence: float,
|
||
signal_intent: str,
|
||
) -> None:
|
||
normalized_signal = (signal or "HOLD").upper()
|
||
if normalized_signal not in {"BUY", "SELL"}:
|
||
return
|
||
|
||
snapshot = ExchangeService().get_market_snapshot(
|
||
state.symbol,
|
||
runtime_key="auto",
|
||
)
|
||
|
||
try:
|
||
JournalService().log_ui_info(
|
||
event_type="signal_ready",
|
||
message=(
|
||
f"Сигнал {normalized_signal} подтверждён и готов к исполнению."
|
||
),
|
||
screen="auto",
|
||
action="signal_ready",
|
||
payload={
|
||
"strategy": state.strategy,
|
||
"status": state.status,
|
||
"symbol": state.symbol,
|
||
"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_seconds": state.signal_confirmation_seconds,
|
||
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
|
||
"confirmation_progress": state.signal_confirmation_progress,
|
||
"bid_price": snapshot.get("bid_price"),
|
||
"ask_price": snapshot.get("ask_price"),
|
||
"last_price": snapshot.get("last_price"),
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# сбросить устаревшие signal / market runtime данные
|
||
def _expire_runtime_if_needed(self, state: AutoTradeState) -> None:
|
||
now = time.monotonic()
|
||
|
||
signal_updated_at = getattr(state, "signal_updated_at", None)
|
||
if signal_updated_at is not None:
|
||
signal_updated = safe_float(signal_updated_at)
|
||
if signal_updated is None:
|
||
return
|
||
|
||
signal_age = now - signal_updated
|
||
if signal_age > self._signal_ttl_seconds:
|
||
previous_signal = state.last_signal
|
||
|
||
self._reset_signal_tracking()
|
||
|
||
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
|
||
state.runtime_expired_message = "сигнал устарел и был сброшен"
|
||
|
||
self._log_runtime_expired_if_changed(
|
||
state=state,
|
||
reason="SIGNAL_TTL_EXPIRED",
|
||
message="Сигнал устарел и был сброшен.",
|
||
payload={
|
||
"previous_signal": previous_signal,
|
||
"signal_age_seconds": int(signal_age),
|
||
"signal_ttl_seconds": self._signal_ttl_seconds,
|
||
},
|
||
)
|
||
|
||
return
|
||
|
||
market_updated_at = getattr(state, "market_analysis_updated_at", None)
|
||
if market_updated_at is not None:
|
||
market_updated = safe_float(market_updated_at)
|
||
|
||
if market_updated is None:
|
||
return
|
||
|
||
market_age = now - market_updated
|
||
|
||
if market_age > self._market_analysis_ttl_seconds:
|
||
state.market_state = None
|
||
state.market_trend = None
|
||
state.market_volatility = None
|
||
state.market_analysis_interval = None
|
||
state.market_analysis_reason = None
|
||
state.market_analysis_updated_at = None
|
||
state.entry_block_reason = None
|
||
state.entry_block_message = None
|
||
state.market_trend_strength = None
|
||
state.market_trend_quality = None
|
||
state.market_phase = None
|
||
state.market_phase_direction = None
|
||
state.market_trend_gap_percent = None
|
||
state.market_trend_consistency = None
|
||
state.market_trend_efficiency = None
|
||
state.trend_quality_score = None
|
||
state.ema_distance_atr_ratio = None
|
||
state.ema_distance_state = None
|
||
state.entry_timing_state = None
|
||
state.entry_timing_reason = None
|
||
state.ema_fast_slope_percent = None
|
||
state.ema_slow_slope_percent = None
|
||
state.candle_noise_score = None
|
||
state.price_position_score = None
|
||
state.htf_interval = None
|
||
state.htf_atr_percent = None
|
||
state.htf_atr_percent_baseline = None
|
||
state.htf_volatility_ratio = None
|
||
state.htf_volatility = None
|
||
state.momentum_state = None
|
||
state.momentum_direction = None
|
||
state.momentum_change_percent = None
|
||
state.momentum_strength = None
|
||
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 = "анализ рынка устарел"
|
||
|
||
self._log_runtime_expired_if_changed(
|
||
state=state,
|
||
reason="MARKET_ANALYSIS_TTL_EXPIRED",
|
||
message="Анализ рынка устарел и был сброшен.",
|
||
payload={
|
||
"market_age_seconds": int(market_age),
|
||
"market_analysis_ttl_seconds": self._market_analysis_ttl_seconds,
|
||
},
|
||
)
|
||
|
||
# записать событие устаревания runtime данных
|
||
def _log_runtime_expired_if_changed(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
reason: str,
|
||
message: str,
|
||
payload: JsonDict,
|
||
) -> None:
|
||
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
|
||
|
||
if key == type(self)._last_logged_runtime_expired_key:
|
||
return
|
||
|
||
type(self)._last_logged_runtime_expired_key = key
|
||
|
||
try:
|
||
JournalService().log_ui_warning(
|
||
event_type="runtime_expired",
|
||
message=message,
|
||
screen="auto",
|
||
action="runtime_expiration",
|
||
payload={
|
||
**payload,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"status": state.status,
|
||
"runtime_expired_reason": reason,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# синхронизировать итоговый execution confidence
|
||
def _sync_execution_confidence_state(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
confidence: float,
|
||
) -> None:
|
||
if signal not in {"BUY", "SELL"}:
|
||
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
|
||
return
|
||
|
||
signal_score = self._clamp_score(confidence)
|
||
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
|
||
market_score = self._market_confidence_score(state)
|
||
execution_quality_confidence_score = cast(
|
||
Callable[[AutoTradeState], float],
|
||
getattr(self, "_execution_quality_confidence_score"),
|
||
)
|
||
execution_score = execution_quality_confidence_score(state)
|
||
|
||
score = (
|
||
signal_score * 0.35
|
||
+ confirmation_score * 0.20
|
||
+ market_score * 0.25
|
||
+ execution_score * 0.20
|
||
)
|
||
|
||
score = round(self._clamp_score(score), 3)
|
||
|
||
state.execution_confidence_score = score
|
||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
||
state.execution_confidence_level = self._execution_confidence_level(score)
|
||
state.execution_confidence_reason = self._execution_confidence_reason(state)
|
||
state.execution_confidence_factors = {
|
||
"signal_score": round(signal_score, 3),
|
||
"confirmation_score": round(confirmation_score, 3),
|
||
"market_score": round(market_score, 3),
|
||
"execution_score": round(execution_score, 3),
|
||
"required_score": self._execution_confidence_required_score,
|
||
"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,
|
||
"execution_quality": state.execution_quality,
|
||
"execution_quality_reason": state.execution_quality_reason,
|
||
"spread_percent": state.spread_percent,
|
||
"momentum_state": getattr(state, "momentum_state", None),
|
||
"momentum_direction": getattr(state, "momentum_direction", None),
|
||
"momentum_change_percent": getattr(state, "momentum_change_percent", None),
|
||
"momentum_strength": getattr(state, "momentum_strength", None),
|
||
"breakout_level": getattr(state, "breakout_level", None),
|
||
"breakout_distance_percent": getattr(state, "breakout_distance_percent", None),
|
||
"breakout_reason": getattr(state, "breakout_reason", None),
|
||
}
|
||
|
||
# рассчитать 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
|
||
trend_quality_score = safe_float(state.trend_quality_score)
|
||
|
||
if market_state in {
|
||
"HIGH_VOLATILITY",
|
||
"LOW_VOLATILITY",
|
||
"RANGE",
|
||
"UNKNOWN",
|
||
None,
|
||
"",
|
||
}:
|
||
return 0.25
|
||
|
||
score = 0.65
|
||
|
||
if strength == "STRONG":
|
||
score += 0.2
|
||
elif strength == "NORMAL":
|
||
score += 0.1
|
||
elif strength == "WEAK":
|
||
score -= 0.25
|
||
|
||
if quality == "CLEAN":
|
||
score += 0.12
|
||
elif quality == "NORMAL":
|
||
score += 0.04
|
||
elif quality == "NOISY":
|
||
score -= 0.25
|
||
|
||
if phase == "IMPULSE":
|
||
score += 0.1
|
||
elif phase == "PULLBACK":
|
||
score -= 0.25
|
||
elif phase in {"RANGE", "SQUEEZE"}:
|
||
score -= 0.3
|
||
|
||
if ema_distance_state == "HEALTHY":
|
||
score += 0.08
|
||
elif ema_distance_state == "EXTENDED":
|
||
score -= 0.08
|
||
elif ema_distance_state == "COMPRESSED":
|
||
score -= 0.18
|
||
elif ema_distance_state == "OVEREXTENDED":
|
||
score -= 0.35
|
||
|
||
if entry_timing_state == "NORMAL":
|
||
score += 0.08
|
||
elif entry_timing_state == "EARLY":
|
||
score -= 0.05
|
||
elif entry_timing_state == "LATE":
|
||
score -= 0.2
|
||
elif entry_timing_state == "CHASING":
|
||
score -= 0.35
|
||
|
||
if trend_quality_score is not None:
|
||
if trend_quality_score >= 0.7:
|
||
score += 0.08
|
||
elif trend_quality_score < 0.45:
|
||
score -= 0.15
|
||
|
||
return self._clamp_score(score)
|
||
|
||
# определить уровень execution confidence
|
||
def _execution_confidence_level(self, score: float) -> str:
|
||
if score >= 0.75:
|
||
return "HIGH"
|
||
|
||
if score >= self._execution_confidence_required_score:
|
||
return "NORMAL"
|
||
|
||
return "LOW"
|
||
|
||
# сформировать причину execution confidence
|
||
def _execution_confidence_reason(self, state: AutoTradeState) -> str:
|
||
score = state.execution_confidence_score
|
||
|
||
if score is None:
|
||
return "execution confidence не рассчитан"
|
||
|
||
if score < self._execution_confidence_required_score:
|
||
return "низкая совокупная уверенность входа"
|
||
|
||
if state.execution_confidence_level == "HIGH":
|
||
return "высокая совокупная уверенность входа"
|
||
|
||
return "достаточная совокупная уверенность входа"
|
||
|
||
# ограничить score диапазоном 0.0..1.0
|
||
def _clamp_score(self, value: NumericLike | None) -> float:
|
||
if value is None:
|
||
return 0.0
|
||
|
||
numeric = safe_float(value)
|
||
|
||
if numeric is None:
|
||
return 0.0
|
||
|
||
return max(0.0, min(1.0, numeric)) |