diff --git a/app/src/core/event_titles.py b/app/src/core/event_titles.py index 0a09ec9..4b240ae 100644 --- a/app/src/core/event_titles.py +++ b/app/src/core/event_titles.py @@ -76,6 +76,8 @@ EVENT_TITLES = { "balance_summary_loaded": "Баланс", "balance_summary_error": "Баланс", + + "runtime_expired": "Runtime", } diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py index 08de59e..75d7e0b 100644 --- a/app/src/telegram/handlers/auto/ui.py +++ b/app/src/telegram/handlers/auto/ui.py @@ -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]: diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index 03990e3..32e6371 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -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() diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index 60c07aa..099fca2 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -125,4 +125,16 @@ class AutoTradeState: entry_block_reason: str | None = None # человекочитаемое объяснение причины не входа - entry_block_message: str | None = None \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/src/trading/strategies/scalp.py b/app/src/trading/strategies/scalp.py index eee41d5..1410cca 100644 --- a/app/src/trading/strategies/scalp.py +++ b/app/src/trading/strategies/scalp.py @@ -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) diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py index 986c0c3..91cd4ef 100644 --- a/app/src/trading/strategies/trend.py +++ b/app/src/trading/strategies/trend.py @@ -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: diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 65802c8..69dc324 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -493,6 +493,32 @@ - подготовлена база для signal aging/reset system - подготовлена база для 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 diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index c3f466f..031f479 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -469,6 +469,31 @@ - подготовлена база для signal aging/reset system - подготовлена база для 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 --- diff --git a/docs/stages/stage-07_4_4_1_6-signal_aging_and_runtime_expiration.md b/docs/stages/stage-07_4_4_1_6-signal_aging_and_runtime_expiration.md new file mode 100644 index 0000000..39e65b5 --- /dev/null +++ b/docs/stages/stage-07_4_4_1_6-signal_aging_and_runtime_expiration.md @@ -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 и расширенной диагностике входа. \ No newline at end of file