# 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))