07.4.4.1.6 — Signal Aging & Runtime Expiration

This commit is contained in:
2026-05-11 13:33:21 +03:00
parent e17f847603
commit fe33e0c026
9 changed files with 491 additions and 6 deletions

View File

@@ -76,6 +76,8 @@ EVENT_TITLES = {
"balance_summary_loaded": "Баланс",
"balance_summary_error": "Баланс",
"runtime_expired": "Runtime",
}

View File

@@ -248,9 +248,9 @@ def _market_state_line(state) -> str:
market_state = getattr(state, "market_state", None)
labels = {
"TREND_UP": "📈 Тренд · Восходящий",
"TREND_DOWN": "📉 Тренд · Нисходящий",
"RANGE": "🟰 Тренд · Нет выраженного направления",
"TREND_UP": "📈 Тренд · Вверх",
"TREND_DOWN": "📉 Тренд · Вниз",
"RANGE": "🟰 Рынок · Флэт",
"HIGH_VOLATILITY": "⚠️ Рынок · Высокая волатильность",
"LOW_VOLATILITY": "🟰 Рынок · Низкая активность",
"UNKNOWN": "⏳ Рынок · Идёт анализ",
@@ -260,13 +260,38 @@ def _market_state_line(state) -> str:
return labels.get(market_state, "⏳ Рынок · Идёт анализ")
def _compact_entry_block_message(message: str) -> str:
normalized = message.strip().lower()
mapping = {
"рынок сейчас не подходит для входа": "слабый импульс",
"слабый импульс вверх": "слабый импульс",
"слабый импульс вниз": "слабый импульс",
"недостаточно live-данных": "мало данных",
"мало live-данных": "мало данных",
"высокая волатильность": "волатильность",
"низкая активность": "низкая активность",
}
return mapping.get(normalized, message)
def _entry_block_line(state) -> str:
message = getattr(state, "entry_block_message", None)
if not message:
return ""
return f"Вход в позицию · {message}"
compact_message = _compact_entry_block_message(str(message))
signal = (state.last_signal or "HOLD").upper()
if signal == "HOLD":
return f"Ожидание · {compact_message}"
if signal in {"BUY", "SELL"}:
return f"Вход · {compact_message}"
return ""
def _execution_block_lines(state) -> list[str]:

View File

@@ -26,6 +26,10 @@ class AutoTradeService:
# минимальная уверенность для готовности к будущему execution
_ready_confidence = 0.3
_signal_ttl_seconds = 90
_market_analysis_ttl_seconds = 180
_last_logged_runtime_expired_key: str | None = None
_last_signal_key: str | None = None
_last_signal_value: str | None = None
_last_signal_reason: str = ""
@@ -310,13 +314,17 @@ class AutoTradeService:
state.is_signal_ready = False
state.execution_block_reason = None
state.signal_started_at = None
state.signal_updated_at = None
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.runtime_expired_reason = None
state.runtime_expired_message = None
# собрать контекст для стратегии
def _build_strategy_context(self) -> StrategyContext:
@@ -513,6 +521,9 @@ class AutoTradeService:
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,
@@ -684,6 +695,7 @@ class AutoTradeService:
state.market_volatility = payload.get("market_volatility")
state.market_analysis_interval = payload.get("market_analysis_interval")
state.market_analysis_reason = payload.get("market_analysis_reason")
state.market_analysis_updated_at = time.monotonic()
state.entry_block_reason = payload.get("entry_block_reason")
state.entry_block_message = payload.get("entry_block_message")
@@ -853,12 +865,100 @@ class AutoTradeService:
}
return messages.get(market_state, "Состояние рынка анализируется.")
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_age = now - float(signal_updated_at)
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_age = now - float(market_updated_at)
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.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,
},
)
def _log_runtime_expired_if_changed(
self,
*,
state: AutoTradeState,
reason: str,
message: str,
payload: dict,
) -> 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
def run_cycle(self) -> AutoTradeState:
state = self.get_state()
if state.status == "OFF":
return state
self._expire_runtime_if_needed(state)
strategy = self._get_strategy()
context = self._build_strategy_context()

View File

@@ -125,4 +125,16 @@ class AutoTradeState:
entry_block_reason: str | None = None
# человекочитаемое объяснение причины не входа
entry_block_message: str | None = None
entry_block_message: str | None = None
# время последнего обновления сигнала, monotonic timestamp
signal_updated_at: float | None = None
# время последнего обновления market analysis, monotonic timestamp
market_analysis_updated_at: float | None = None
# причина runtime expiration
runtime_expired_reason: str | None = None
# человекочитаемое сообщение runtime expiration
runtime_expired_message: str | None = None

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import time
from src.integrations.exchange.service import ExchangeService
from src.trading.strategies.base import StrategyContext
from src.trading.strategies.signals import SignalResult, SignalType
@@ -11,6 +13,8 @@ class ScalpStrategy:
name = "SCALP"
_price_window: dict[str, list[float]] = {}
_window_ttl_seconds = 30
_price_window_updated_at: dict[str, float] = {}
# короткое окно = быстрая реакция
_window_size = 4
@@ -24,6 +28,7 @@ class ScalpStrategy:
def reset_runtime(self, symbol: str | None = None) -> None:
if symbol is None:
self._price_window.clear()
self._price_window_updated_at.clear()
return
normalized_symbol = symbol.upper()
@@ -34,6 +39,7 @@ class ScalpStrategy:
for key in keys_to_delete:
self._price_window.pop(key, None)
self._price_window_updated_at.pop(key, None)
def analyze(self, context: StrategyContext) -> SignalResult:
try:
@@ -53,8 +59,18 @@ class ScalpStrategy:
symbol = ticker.symbol
current_price = float(ticker.price)
now = time.monotonic()
previous_updated_at = self._price_window_updated_at.get(symbol)
if (
previous_updated_at is not None
and now - previous_updated_at > self._window_ttl_seconds
):
self._price_window.pop(symbol, None)
prices = self._price_window.setdefault(symbol, [])
prices.append(current_price)
self._price_window_updated_at[symbol] = now
if len(prices) > self._window_size:
prices.pop(0)

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import time
from src.integrations.exchange.service import ExchangeService
from src.trading.market_analysis.models import MarketState
from src.trading.market_analysis.service import MarketAnalysisService
@@ -13,6 +15,8 @@ class TrendStrategy:
name = "TREND"
_price_window: dict[str, list[float]] = {}
_window_ttl_seconds = 60
_price_window_updated_at: dict[str, float] = {}
# короткое окно оставляем как дополнительное подтверждение импульса
_window_size = 8
@@ -25,6 +29,7 @@ class TrendStrategy:
def reset_runtime(self, symbol: str | None = None) -> None:
if symbol is None:
self._price_window.clear()
self._price_window_updated_at.clear()
return
normalized_symbol = symbol.upper()
@@ -35,6 +40,7 @@ class TrendStrategy:
for key in keys_to_delete:
self._price_window.pop(key, None)
self._price_window_updated_at.pop(key, None)
def analyze(self, context: StrategyContext) -> SignalResult:
market = MarketAnalysisService().analyze(
@@ -77,8 +83,18 @@ class TrendStrategy:
},
)
now = time.monotonic()
previous_updated_at = self._price_window_updated_at.get(symbol)
if (
previous_updated_at is not None
and now - previous_updated_at > self._window_ttl_seconds
):
self._price_window.pop(symbol, None)
prices = self._price_window.setdefault(symbol, [])
prices.append(current_price)
self._price_window_updated_at[symbol] = now
if len(prices) > self._window_size:
prices.pop(0)
@@ -96,6 +112,8 @@ class TrendStrategy:
"market_analysis_interval": market.interval,
"market_analysis_reason": market.reason,
"market_analysis": market.payload,
"runtime_window_ttl_seconds": self._window_ttl_seconds,
"runtime_window_size": len(prices),
}
if not market.is_trade_allowed: