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_loaded": "Баланс",
"balance_summary_error": "Баланс", "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) market_state = getattr(state, "market_state", None)
labels = { labels = {
"TREND_UP": "📈 Тренд · Восходящий", "TREND_UP": "📈 Тренд · Вверх",
"TREND_DOWN": "📉 Тренд · Нисходящий", "TREND_DOWN": "📉 Тренд · Вниз",
"RANGE": "🟰 Тренд · Нет выраженного направления", "RANGE": "🟰 Рынок · Флэт",
"HIGH_VOLATILITY": "⚠️ Рынок · Высокая волатильность", "HIGH_VOLATILITY": "⚠️ Рынок · Высокая волатильность",
"LOW_VOLATILITY": "🟰 Рынок · Низкая активность", "LOW_VOLATILITY": "🟰 Рынок · Низкая активность",
"UNKNOWN": "⏳ Рынок · Идёт анализ", "UNKNOWN": "⏳ Рынок · Идёт анализ",
@@ -260,13 +260,38 @@ def _market_state_line(state) -> str:
return labels.get(market_state, "⏳ Рынок · Идёт анализ") 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: def _entry_block_line(state) -> str:
message = getattr(state, "entry_block_message", None) message = getattr(state, "entry_block_message", None)
if not message: if not message:
return "" 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]: def _execution_block_lines(state) -> list[str]:

View File

@@ -26,6 +26,10 @@ class AutoTradeService:
# минимальная уверенность для готовности к будущему execution # минимальная уверенность для готовности к будущему execution
_ready_confidence = 0.3 _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_key: str | None = None
_last_signal_value: str | None = None _last_signal_value: str | None = None
_last_signal_reason: str = "" _last_signal_reason: str = ""
@@ -310,13 +314,17 @@ class AutoTradeService:
state.is_signal_ready = False state.is_signal_ready = False
state.execution_block_reason = None state.execution_block_reason = None
state.signal_started_at = None state.signal_started_at = None
state.signal_updated_at = None
state.market_state = None state.market_state = None
state.market_trend = None state.market_trend = None
state.market_volatility = None state.market_volatility = None
state.market_analysis_interval = None state.market_analysis_interval = None
state.market_analysis_reason = None state.market_analysis_reason = None
state.market_analysis_updated_at = None
state.entry_block_reason = None state.entry_block_reason = None
state.entry_block_message = None state.entry_block_message = None
state.runtime_expired_reason = None
state.runtime_expired_message = None
# собрать контекст для стратегии # собрать контекст для стратегии
def _build_strategy_context(self) -> StrategyContext: def _build_strategy_context(self) -> StrategyContext:
@@ -513,6 +521,9 @@ class AutoTradeService:
state.last_signal_repeat_count = self._same_signal_count state.last_signal_repeat_count = self._same_signal_count
state.last_signal_confidence = confidence state.last_signal_confidence = confidence
state.last_signal_reason = reason 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( self._update_decision_state(
state=state, state=state,
@@ -684,6 +695,7 @@ class AutoTradeService:
state.market_volatility = payload.get("market_volatility") state.market_volatility = payload.get("market_volatility")
state.market_analysis_interval = payload.get("market_analysis_interval") state.market_analysis_interval = payload.get("market_analysis_interval")
state.market_analysis_reason = payload.get("market_analysis_reason") 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_reason = payload.get("entry_block_reason")
state.entry_block_message = payload.get("entry_block_message") state.entry_block_message = payload.get("entry_block_message")
@@ -853,12 +865,100 @@ class AutoTradeService:
} }
return messages.get(market_state, "Состояние рынка анализируется.") 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: def run_cycle(self) -> AutoTradeState:
state = self.get_state() state = self.get_state()
if state.status == "OFF": if state.status == "OFF":
return state return state
self._expire_runtime_if_needed(state)
strategy = self._get_strategy() strategy = self._get_strategy()
context = self._build_strategy_context() context = self._build_strategy_context()

View File

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

View File

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

View File

@@ -493,6 +493,32 @@
- подготовлена база для signal aging/reset system - подготовлена база для signal aging/reset system
- подготовлена база для adaptive runtime memory management - подготовлена база для adaptive runtime memory management
#### 07.4.4.1.6 ✅ Signal Aging & Runtime Expiration
- добавлены поля signal_updated_at и market_analysis_updated_at в AutoTradeState
- добавлены runtime_expired_reason и runtime_expired_message
- внедрён TTL для signal runtime
- внедрён TTL для market analysis runtime
- добавлен runtime expiration handler в AutoTradeService
- добавлено событие runtime_expired для журнала
- добавлена защита от spam logging одинаковых runtime expiration событий
- signal tracking теперь обновляет время последнего сигнала
- market analysis sync теперь обновляет время последней аналитики
- stale signal runtime сбрасывается при превышении TTL
- stale market diagnostics очищаются при превышении TTL
- TrendStrategy получила TTL для live price window
- ScalpStrategy получила отдельный TTL для live price window
- reset_runtime теперь очищает price window и timestamp window
- предотвращено использование старых цен после runtime-паузы
- HOLD timer сохранён как индикатор живого runtime цикла
- Telegram UI переведён на компактные market state labels
- entry diagnostics в UI разделены на Ожидание и Вход
- добавлен compact mapping для длинных entry_block_message
- подтверждена корректная работа runtime lifecycle на флэт-рынке
- выявлен uncovered HOLD diagnostic scenario для следующего этапа
- подготовлена база для advanced market diagnostics layer
- подготовлена база для adaptive thresholds
- подготовлена база для signal freshness-aware execution
--- ---
### 07.4.5 ### 07.4.5

View File

@@ -469,6 +469,31 @@
- подготовлена база для signal aging/reset system - подготовлена база для signal aging/reset system
- подготовлена база для adaptive runtime memory management - подготовлена база для adaptive runtime memory management
#### 07.4.4.1.6 ✅ Signal Aging & Runtime Expiration
- добавлены поля signal_updated_at и market_analysis_updated_at в AutoTradeState
- добавлены runtime_expired_reason и runtime_expired_message
- внедрён TTL для signal runtime
- внедрён TTL для market analysis runtime
- добавлен runtime expiration handler в AutoTradeService
- добавлено событие runtime_expired для журнала
- добавлена защита от spam logging одинаковых runtime expiration событий
- signal tracking теперь обновляет время последнего сигнала
- market analysis sync теперь обновляет время последней аналитики
- stale signal runtime сбрасывается при превышении TTL
- stale market diagnostics очищаются при превышении TTL
- TrendStrategy получила TTL для live price window
- ScalpStrategy получила отдельный TTL для live price window
- reset_runtime теперь очищает price window и timestamp window
- предотвращено использование старых цен после runtime-паузы
- HOLD timer сохранён как индикатор живого runtime цикла
- Telegram UI переведён на компактные market state labels
- entry diagnostics в UI разделены на Ожидание и Вход
- добавлен compact mapping для длинных entry_block_message
- подтверждена корректная работа runtime lifecycle на флэт-рынке
- выявлен uncovered HOLD diagnostic scenario для следующего этапа
- подготовлена база для advanced market diagnostics layer
- подготовлена база для adaptive thresholds
- подготовлена база для signal freshness-aware execution
--- ---

View File

@@ -0,0 +1,261 @@
# 07.4.4.1.6 — Signal Aging & Runtime Expiration
## Цель этапа
Добавить слой контроля возраста runtime-состояния автоторговли, чтобы сигналы, market analysis и внутренние окна стратегий не жили бесконечно и не влияли на новые решения после пауз, смены актива, смены стратегии или простоя рынка.
Этап закрывает проблему “устаревшего runtime”:
- старый BUY/SELL не должен продолжаться после долгой паузы;
- READY-состояние не должно сохраняться бесконечно;
- market diagnostics не должны визуально залипать;
- runtime-окна стратегий не должны использовать старые цены после простоя;
- HOLD lifecycle должен оставаться живым и понятным в UI.
---
## Что было до этапа
До внедрения Signal Aging runtime-состояние было логически изолировано по активам и стратегиям, но не имело срока жизни.
Это означало, что:
- `_price_window` мог хранить старые цены;
- `last_signal` мог оставаться актуальным дольше, чем фактический рынок;
- `READY` мог визуально выглядеть свежим, хотя сигнал уже устарел;
- market diagnostics могли оставаться на экране после смены условий;
- пользователь видел состояние, но не всегда понимал, насколько оно свежее.
---
## Что внедрено
### 1. Signal aging fields в AutoTradeState
В состояние автоторговли добавлены поля для отслеживания возраста runtime:
```python
signal_updated_at
market_analysis_updated_at
runtime_expired_reason
runtime_expired_message
```
Теперь состояние знает:
- когда последний раз обновлялся сигнал;
- когда последний раз обновлялась аналитика рынка;
- была ли runtime-информация сброшена по expiration;
- почему она была сброшена.
---
### 2. Signal TTL
Добавлен TTL для сигналов:
```python
_signal_ttl_seconds = 90
```
Если сигнал живёт дольше допустимого времени без актуального обновления, runtime сбрасывается.
Это защищает от ситуаций:
```text
BUY появился
бот/рынок/цикл остановился
через долгое время BUY всё ещё считается актуальным
```
Теперь такой сигнал считается устаревшим и должен быть пересобран заново.
---
### 3. Market analysis TTL
Добавлен TTL для market diagnostics:
```python
_market_analysis_ttl_seconds = 180
```
Если анализ рынка устарел, очищаются:
```python
market_state
market_trend
market_volatility
market_analysis_interval
market_analysis_reason
entry_block_reason
entry_block_message
```
Это предотвращает визуальное залипание старого состояния рынка.
---
### 4. Runtime expiration handler
В AutoTradeService добавлен метод:
```python
_expire_runtime_if_needed()
```
Он проверяет возраст:
- текущего сигнала;
- последнего market analysis;
- runtime diagnostics.
При необходимости он сбрасывает устаревшие данные и подготавливает state к новой оценке рынка.
---
### 5. Runtime expiration logging
Добавлено событие журнала:
```python
runtime_expired
```
Оно используется для фиксации случаев, когда runtime был сброшен по TTL.
Логика защищена от spam logging одинаковых expiration-событий.
---
### 6. Runtime window TTL для TREND
В TrendStrategy добавлен TTL live-окна:
```python
_window_ttl_seconds = 60
_price_window_updated_at
```
Теперь live-окно TREND очищается, если между обновлениями был слишком большой разрыв.
Это защищает от анализа старых цен как будто они свежие.
---
### 7. Runtime window TTL для SCALP
В ScalpStrategy добавлен отдельный TTL:
```python
_window_ttl_seconds = 30
_price_window_updated_at
```
SCALP чувствительнее к времени, поэтому его окно живёт меньше.
---
### 8. Runtime reset теперь очищает не только цены
`reset_runtime()` теперь очищает:
- `_price_window`;
- `_price_window_updated_at`.
Это важно, потому что сам факт времени последнего обновления тоже является runtime-состоянием.
---
### 9. UI продолжает показывать HOLD duration
Строка:
```text
Сигнал 🟡 HOLD · 27с
```
оставлена намеренно.
Это не торговый сигнал, а индикатор живого runtime-цикла:
- экран обновляется;
- HOLD не залип;
- цикл автоторговли работает;
- пользователь видит возраст текущего режима ожидания.
---
### 10. Compact Telegram UI
В рамках проверки этапа UI был приведён к более короткому виду.
Market state теперь отображается компактно:
```python
"TREND_UP": "📈 Тренд · Вверх"
"TREND_DOWN": "📉 Тренд · Вниз"
"RANGE": "🟰 Рынок · Флэт"
"HIGH_VOLATILITY": "⚠️ Рынок · Высокая волатильность"
"LOW_VOLATILITY": "🟰 Рынок · Низкая активность"
```
Entry diagnostics разделены по смыслу:
```text
Ожидание · слабый импульс
Вход · слабый импульс
```
---
## Проверка этапа
Проверено на live UI:
- HOLD timer работает;
- market state отображается компактно;
- после runtime reset старые данные не протекают;
- UI не залипает на старом активе;
- entry diagnostics стали короче;
- runtime state не смешивается между активами;
- выбранный актив отображается корректно;
- подготовка ордера продолжает рассчитываться штатно.
---
## Что обнаружено дополнительно
На LTC был выявлен uncovered HOLD diagnostic сценарий:
```text
Сигнал 🟡 HOLD
📉 Тренд · Вниз
```
При этом UI не показал причину, почему при нисходящем тренде не был выдан SELL.
Причина: не все HOLD-ветки TrendStrategy передают `entry_block_reason` и `entry_block_message`.
Это не блокер этапа 07.4.4.1.6, потому что относится не к aging, а к полноте диагностического слоя стратегии.
Рекомендуется вынести в следующий этап:
```text
Advanced Market Diagnostics Layer
```
---
## Итог этапа
Этап 07.4.4.1.6 добавил фундамент runtime expiration:
- сигналы получили возраст;
- market analysis получил возраст;
- runtime-окна стратегий получили TTL;
- stale state теперь можно сбрасывать;
- UI показывает живое состояние runtime;
- система подготовлена к adaptive thresholds и расширенной диагностике входа.