diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py index b2b5404..551bbc1 100644 --- a/app/src/trading/auto/service.py +++ b/app/src/trading/auto/service.py @@ -7,12 +7,12 @@ import time from datetime import datetime from src.core.config import load_settings +from src.core.event_bus import EventBus from src.trading.auto.state import AutoTradeState +from src.trading.execution.engine import ExecutionEngine from src.trading.journal.service import JournalService from src.trading.strategies.base import BaseStrategy, StrategyContext from src.trading.strategies.registry import StrategyRegistry -from src.core.event_bus import EventBus -from src.trading.execution.engine import ExecutionEngine class AutoTradeService: @@ -31,9 +31,10 @@ class AutoTradeService: _last_signal_reason: str = "" _last_signal_confidence: float = 0.0 _last_signal_payload: dict | None = None + _last_signal_started_at: float | None = None _same_signal_count = 0 - # debug: принудительно выставить сигнал и decision + # debug: принудительно выставить сигнал и decision def debug_force_signal( self, *, @@ -50,7 +51,7 @@ class AutoTradeService: 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() @@ -70,6 +71,11 @@ class AutoTradeService: 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", { @@ -77,6 +83,7 @@ class AutoTradeService: "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, @@ -88,7 +95,7 @@ class AutoTradeService: ) return state - + # установить капитал, выделенный под автоторговлю def set_allocated_balance_usd(self, value: float) -> AutoTradeState: state = self.get_state() @@ -100,7 +107,7 @@ class AutoTradeService: state.execution_block_reason = None state.execution_size_adjustment_reason = None return state - + # получить текущее состояние автоторговли def get_state(self) -> AutoTradeState: if not self._state.symbol: @@ -160,6 +167,7 @@ class AutoTradeService: self._reset_signal_tracking() state.last_signal = "HOLD" state.signal_started_at = time.monotonic() + EventBus.emit( "auto_status_changed", { @@ -233,14 +241,14 @@ class AutoTradeService: state = self.get_state() state.risk_percent = risk_percent return state - + # установить плечо def set_leverage(self, leverage: float) -> AutoTradeState: state = self.get_state() state.leverage = leverage return state - - # установить stop loss в % + + # установить stop loss в % def set_stop_loss_percent(self, value: float | None) -> AutoTradeState: state = self.get_state() state.stop_loss_percent = value @@ -257,7 +265,7 @@ class AutoTradeService: state = self.get_state() state.max_loss_usd = value return state - + # установить максимальное использование баланса под маржу def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState: state = self.get_state() @@ -272,6 +280,7 @@ class AutoTradeService: self._last_signal_reason = "" self._last_signal_confidence = 0.0 self._last_signal_payload = None + self._last_signal_started_at = None self._same_signal_count = 0 state = self.get_state() @@ -300,6 +309,34 @@ class AutoTradeService: state = self.get_state() return StrategyRegistry.get(state.strategy) + # определить смысл сигнала с учетом открытой позиции + 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, @@ -355,6 +392,7 @@ class AutoTradeService: 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 @@ -377,6 +415,7 @@ class AutoTradeService: 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( @@ -396,6 +435,7 @@ class AutoTradeService: 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( @@ -405,6 +445,27 @@ class AutoTradeService: 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, @@ -431,12 +492,30 @@ class AutoTradeService: 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, }, @@ -449,6 +528,7 @@ class AutoTradeService: "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, @@ -458,7 +538,7 @@ class AutoTradeService: }, ) - # записать одиночный сигнал в журнал + # одиночные BUY / SELL больше не пишем в журнал как полезные события def _log_signal_event( self, *, @@ -469,34 +549,7 @@ class AutoTradeService: confidence: float, payload: dict | None, ) -> None: - emoji_map = { - "BUY": "🟢", - "SELL": "🔴", - "HOLD": "🟡", - } - emoji = emoji_map.get(signal, "•") - - try: - JournalService().log_ui_info( - event_type="auto_signal_generated", - message=f"{emoji} Сигнал автоторговли {signal}: {reason}", - screen="auto", - action="run_cycle", - payload={ - "strategy": strategy_name, - "status": state.status, - "symbol": state.symbol, - "signal": signal, - "confidence": confidence, - "reason": reason, - "repeat_count": 1, - "is_strong_signal": confidence > self._ready_confidence, - "is_aggregated": False, - "payload": payload or {}, - }, - ) - except Exception: - pass + return # записать итог серии одинаковых сигналов при смене сигнала def _log_signal_summary( @@ -510,20 +563,19 @@ class AutoTradeService: reason: str, confidence: float, payload: dict | None, + duration_seconds: int, ) -> None: - emoji_map = { - "BUY": "🟢", - "SELL": "🔴", - "HOLD": "🟡", - } - emoji = emoji_map.get(previous_signal, "•") + if previous_signal != "HOLD": + return + + duration_text = self._format_duration(duration_seconds) + signal_intent = "HOLD_MARKET" try: JournalService().log_ui_info( event_type="auto_signal_summary", message=( - f"{emoji} {previous_count} {previous_signal} подряд " - f"до смены на {next_signal}" + f"🟡 HOLD {duration_text} завершён сигналом {next_signal}" ), screen="auto", action="signal_summary", @@ -533,10 +585,13 @@ class AutoTradeService: "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": confidence > self._ready_confidence, + "is_strong_signal": False, "is_aggregated": True, "payload": payload or {}, }, @@ -544,6 +599,47 @@ class AutoTradeService: 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 + + try: + JournalService().log_ui_info( + event_type="auto_signal_ready", + message=( + f"Сигнал {normalized_signal} готов: " + f"{signal_intent}, confidence={confidence:.2f}, " + f"repeats={state.last_signal_repeat_count}" + ), + 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, + }, + ) + except Exception: + pass + # выполнить один цикл анализа рынка def run_cycle(self) -> AutoTradeState: state = self.get_state() diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 5118cdc..af7f530 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -374,6 +374,16 @@ - сохранена обработка противоположных сигналов как reversal / flip candidates - подготовлена база для Signal Intent Layer в следующем этапе +#### 07.4.3.19.3 ✅ Strategy Noise Filter & Signal Intent Layer +- убрано журналирование одиночных BUY / SELL без серии +- HOLD-серии переведены с repeat-count на duration формат +- добавлен формат 🟡 HOLD 5м 36с завершён сигналом SELL +- добавлен signal_intent в payload сигналов +- добавлены intent-типы ENTRY_CANDIDATE, REVERSAL_CANDIDATE, REINFORCE_POSITION, HOLD_MARKET, NOISE +- добавлена position-aware интерпретация сигналов +- добавлено отдельное событие готового сигнала +- подготовлена база для стандартизации журнала в 07.4.3.19.4 + ### 07.4.4 ⏳ Grid Strategy diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index 10a3d74..3ceb6d5 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -350,6 +350,16 @@ - сохранена обработка противоположных сигналов как reversal / flip candidates - подготовлена база для Signal Intent Layer в следующем этапе +#### 07.4.3.19.3 ✅ Strategy Noise Filter & Signal Intent Layer +- убрано журналирование одиночных BUY / SELL без серии +- HOLD-серии переведены с repeat-count на duration формат +- добавлен формат 🟡 HOLD 5м 36с завершён сигналом SELL +- добавлен signal_intent в payload сигналов +- добавлены intent-типы ENTRY_CANDIDATE, REVERSAL_CANDIDATE, REINFORCE_POSITION, HOLD_MARKET, NOISE +- добавлена position-aware интерпретация сигналов +- добавлено отдельное событие готового сигнала +- подготовлена база для стандартизации журнала в 07.4.3.19.4 + --- ### 07.4.4 diff --git a/docs/stages/stage-07_4_3_19_3-strategy_noise_filter_signal_intent_layer.md b/docs/stages/stage-07_4_3_19_3-strategy_noise_filter_signal_intent_layer.md new file mode 100644 index 0000000..bcb623b --- /dev/null +++ b/docs/stages/stage-07_4_3_19_3-strategy_noise_filter_signal_intent_layer.md @@ -0,0 +1,124 @@ +# 07.4.3.19.3 — Strategy Noise Filter & Signal Intent Layer + +## Статус + +Этап завершён. + +## Цель этапа + +Цель этапа — снизить шум от краткосрочных стратегических сигналов и добавить смысловой слой signal intent, чтобы журнал фиксировал не каждый сырой BUY / SELL / HOLD, а только полезные runtime-переходы. + +До этого этапа журнал уже был очищен от UI refresh spam, но оставались частые записи вида: + +- BUY завершился без серии +- SELL завершился без серии +- 21 HOLD подряд до смены на SELL + +Такие записи были полезны на этапе разработки, но плохо подходили для runtime-аудита. + +## Что изменено + +### 1. Убрано журналирование одиночных BUY / SELL + +Одиночные BUY / SELL, которые не дошли до серии и не стали READY-сигналом, больше не пишутся в журнал как полезные события. + +Это снижает шум от микросигналов SCALP-стратегии, когда стратегия на один цикл выдаёт BUY или SELL, а затем возвращается в HOLD. + +### 2. HOLD-серии теперь логируются по длительности + +Сводка HOLD теперь формируется по времени, а не по количеству повторов. + +Новый формат сообщения: + +- 🟡 HOLD 5м 36с завершён сигналом SELL + +Такой формат понятнее, потому что не зависит напрямую от внутреннего интервала цикла. + +### 3. Добавлен signal intent + +В payload сигнальных событий добавлен смысловой intent: + +- ENTRY_CANDIDATE +- REVERSAL_CANDIDATE +- REINFORCE_POSITION +- HOLD_MARKET +- NOISE + +Intent рассчитывается с учётом текущей позиции. + +### 4. Добавлена position-aware интерпретация сигналов + +Новая логика интерпретации: + +- LONG + BUY → REINFORCE_POSITION +- SHORT + SELL → REINFORCE_POSITION +- LONG + SELL → REVERSAL_CANDIDATE +- SHORT + BUY → REVERSAL_CANDIDATE +- NONE + BUY / SELL → ENTRY_CANDIDATE +- HOLD → HOLD_MARKET + +Это подготавливает систему к следующему уровню фильтрации стратегий и execution-логики. + +### 5. Добавлено отдельное событие готового сигнала + +Когда сигнал доходит до READY, в журнал пишется отдельное событие: + +- auto_signal_ready + +В payload добавляются: + +- signal +- signal_intent +- confidence +- repeat_count +- position_side +- decision_status +- strategy +- symbol + +## Что больше не пишется в журнал + +Из журнала убраны как регулярные runtime-события: + +- одиночный BUY без серии +- одиночный SELL без серии +- BUY завершился без серии +- SELL завершился без серии +- HOLD → HOLD + +Эти события могут быть возвращены позже как debug-level telemetry, но не должны попадать в основной journal-аудит. + +## Что остаётся в журнале + +После этапа журнал должен содержать только полезные signal/runtime события: + +- HOLD duration summary +- READY signal +- execution events +- blocked flip +- notification errors +- market/runtime events +- journal/system critical events + +## Основной изменённый файл + +- app/src/trading/auto/service.py + +## Проверка + +После правок нужно выполнить: + + python -m compileall src + python -m src.main + +После запуска проверить журнал: + +1. В журнале больше нет частых записей «BUY завершился без серии» и «SELL завершился без серии». +2. HOLD-серии отображаются по времени. +3. READY-сигналы содержат signal_intent в payload. +4. Execution events продолжают приходить как раньше. +5. Flip protection из этапа 07.4.3.19.1 не сломан. + +## Итог + +Этап подготовил основу для дальнейшей стандартизации журнала и будущего Signal Intent Layer. Система стала меньше реагировать на шумовые микросигналы и начала фиксировать не только сырой сигнал, но и его смысл относительно текущей позиции.