07.4.3.19.3 — Strategy Noise Filter & Signal Intent Layer

This commit is contained in:
2026-05-10 12:53:43 +03:00
parent e72e2e51db
commit 1692cb4d81
4 changed files with 288 additions and 48 deletions

View File

@@ -7,12 +7,12 @@ import time
from datetime import datetime from datetime import datetime
from src.core.config import load_settings from src.core.config import load_settings
from src.core.event_bus import EventBus
from src.trading.auto.state import AutoTradeState from src.trading.auto.state import AutoTradeState
from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
from src.trading.strategies.base import BaseStrategy, StrategyContext from src.trading.strategies.base import BaseStrategy, StrategyContext
from src.trading.strategies.registry import StrategyRegistry from src.trading.strategies.registry import StrategyRegistry
from src.core.event_bus import EventBus
from src.trading.execution.engine import ExecutionEngine
class AutoTradeService: class AutoTradeService:
@@ -31,9 +31,10 @@ class AutoTradeService:
_last_signal_reason: str = "" _last_signal_reason: str = ""
_last_signal_confidence: float = 0.0 _last_signal_confidence: float = 0.0
_last_signal_payload: dict | None = None _last_signal_payload: dict | None = None
_last_signal_started_at: float | None = None
_same_signal_count = 0 _same_signal_count = 0
# debug: принудительно выставить сигнал и decision # debug: принудительно выставить сигнал и decision
def debug_force_signal( def debug_force_signal(
self, self,
*, *,
@@ -50,7 +51,7 @@ class AutoTradeService:
previous_signal = state.last_signal previous_signal = state.last_signal
previous_decision_status = state.decision_status previous_decision_status = state.decision_status
if previous_signal != normalized_signal or state.signal_started_at is None: if previous_signal != normalized_signal or state.signal_started_at is None:
state.signal_started_at = time.monotonic() state.signal_started_at = time.monotonic()
@@ -70,6 +71,11 @@ class AutoTradeService:
state.is_signal_confirmed = True state.is_signal_confirmed = True
state.is_signal_ready = True state.is_signal_ready = True
signal_intent = self._signal_intent(
state=state,
signal=state.last_signal,
)
EventBus.emit( EventBus.emit(
"auto_decision_changed", "auto_decision_changed",
{ {
@@ -77,6 +83,7 @@ class AutoTradeService:
"previous_decision_status": previous_decision_status, "previous_decision_status": previous_decision_status,
"decision_status": state.decision_status, "decision_status": state.decision_status,
"signal": state.last_signal, "signal": state.last_signal,
"signal_intent": signal_intent,
"repeat_count": state.last_signal_repeat_count, "repeat_count": state.last_signal_repeat_count,
"confidence": state.last_signal_confidence, "confidence": state.last_signal_confidence,
"symbol": state.symbol, "symbol": state.symbol,
@@ -88,7 +95,7 @@ class AutoTradeService:
) )
return state return state
# установить капитал, выделенный под автоторговлю # установить капитал, выделенный под автоторговлю
def set_allocated_balance_usd(self, value: float) -> AutoTradeState: def set_allocated_balance_usd(self, value: float) -> AutoTradeState:
state = self.get_state() state = self.get_state()
@@ -100,7 +107,7 @@ class AutoTradeService:
state.execution_block_reason = None state.execution_block_reason = None
state.execution_size_adjustment_reason = None state.execution_size_adjustment_reason = None
return state return state
# получить текущее состояние автоторговли # получить текущее состояние автоторговли
def get_state(self) -> AutoTradeState: def get_state(self) -> AutoTradeState:
if not self._state.symbol: if not self._state.symbol:
@@ -160,6 +167,7 @@ class AutoTradeService:
self._reset_signal_tracking() self._reset_signal_tracking()
state.last_signal = "HOLD" state.last_signal = "HOLD"
state.signal_started_at = time.monotonic() state.signal_started_at = time.monotonic()
EventBus.emit( EventBus.emit(
"auto_status_changed", "auto_status_changed",
{ {
@@ -233,14 +241,14 @@ class AutoTradeService:
state = self.get_state() state = self.get_state()
state.risk_percent = risk_percent state.risk_percent = risk_percent
return state return state
# установить плечо # установить плечо
def set_leverage(self, leverage: float) -> AutoTradeState: def set_leverage(self, leverage: float) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.leverage = leverage state.leverage = leverage
return state return state
# установить stop loss в % # установить stop loss в %
def set_stop_loss_percent(self, value: float | None) -> AutoTradeState: def set_stop_loss_percent(self, value: float | None) -> AutoTradeState:
state = self.get_state() state = self.get_state()
state.stop_loss_percent = value state.stop_loss_percent = value
@@ -257,7 +265,7 @@ class AutoTradeService:
state = self.get_state() state = self.get_state()
state.max_loss_usd = value state.max_loss_usd = value
return state return state
# установить максимальное использование баланса под маржу # установить максимальное использование баланса под маржу
def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState: def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState:
state = self.get_state() state = self.get_state()
@@ -272,6 +280,7 @@ class AutoTradeService:
self._last_signal_reason = "" self._last_signal_reason = ""
self._last_signal_confidence = 0.0 self._last_signal_confidence = 0.0
self._last_signal_payload = None self._last_signal_payload = None
self._last_signal_started_at = None
self._same_signal_count = 0 self._same_signal_count = 0
state = self.get_state() state = self.get_state()
@@ -300,6 +309,34 @@ class AutoTradeService:
state = self.get_state() state = self.get_state()
return StrategyRegistry.get(state.strategy) 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( def _update_decision_state(
self, self,
@@ -355,6 +392,7 @@ class AutoTradeService:
previous_signal = self._last_signal_value previous_signal = self._last_signal_value
previous_count = self._same_signal_count previous_count = self._same_signal_count
is_same_signal = signal_key == self._last_signal_key is_same_signal = signal_key == self._last_signal_key
now = time.monotonic()
if is_same_signal: if is_same_signal:
self._same_signal_count += 1 self._same_signal_count += 1
@@ -377,6 +415,7 @@ class AutoTradeService:
reason=self._last_signal_reason, reason=self._last_signal_reason,
confidence=self._last_signal_confidence, confidence=self._last_signal_confidence,
payload=self._last_signal_payload, payload=self._last_signal_payload,
duration_seconds=self._signal_duration_seconds(now=now),
) )
else: else:
self._log_signal_event( self._log_signal_event(
@@ -396,6 +435,7 @@ class AutoTradeService:
self._last_signal_reason = reason self._last_signal_reason = reason
self._last_signal_confidence = confidence self._last_signal_confidence = confidence
self._last_signal_payload = payload self._last_signal_payload = payload
self._last_signal_started_at = now
self._same_signal_count = 1 self._same_signal_count = 1
self._update_signal_state_fields( self._update_signal_state_fields(
@@ -405,6 +445,27 @@ class AutoTradeService:
confidence=confidence, 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 для экрана автоторговли # обновить поля state для экрана автоторговли
def _update_signal_state_fields( def _update_signal_state_fields(
self, self,
@@ -431,12 +492,30 @@ class AutoTradeService:
confidence=confidence, 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: if previous_signal != state.last_signal:
EventBus.emit( EventBus.emit(
"auto_signal_changed", "auto_signal_changed",
{ {
"previous_signal": previous_signal, "previous_signal": previous_signal,
"signal": state.last_signal, "signal": state.last_signal,
"signal_intent": signal_intent,
"repeat_count": state.last_signal_repeat_count, "repeat_count": state.last_signal_repeat_count,
"confidence": state.last_signal_confidence, "confidence": state.last_signal_confidence,
}, },
@@ -449,6 +528,7 @@ class AutoTradeService:
"previous_decision_status": previous_decision_status, "previous_decision_status": previous_decision_status,
"decision_status": state.decision_status, "decision_status": state.decision_status,
"signal": state.last_signal, "signal": state.last_signal,
"signal_intent": signal_intent,
"repeat_count": state.last_signal_repeat_count, "repeat_count": state.last_signal_repeat_count,
"confidence": state.last_signal_confidence, "confidence": state.last_signal_confidence,
"symbol": state.symbol, "symbol": state.symbol,
@@ -458,7 +538,7 @@ class AutoTradeService:
}, },
) )
# записать одиночный сигнал в журнал # одиночные BUY / SELL больше не пишем в журнал как полезные события
def _log_signal_event( def _log_signal_event(
self, self,
*, *,
@@ -469,34 +549,7 @@ class AutoTradeService:
confidence: float, confidence: float,
payload: dict | None, payload: dict | None,
) -> None: ) -> None:
emoji_map = { return
"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
# записать итог серии одинаковых сигналов при смене сигнала # записать итог серии одинаковых сигналов при смене сигнала
def _log_signal_summary( def _log_signal_summary(
@@ -510,20 +563,19 @@ class AutoTradeService:
reason: str, reason: str,
confidence: float, confidence: float,
payload: dict | None, payload: dict | None,
duration_seconds: int,
) -> None: ) -> None:
emoji_map = { if previous_signal != "HOLD":
"BUY": "🟢", return
"SELL": "🔴",
"HOLD": "🟡", duration_text = self._format_duration(duration_seconds)
} signal_intent = "HOLD_MARKET"
emoji = emoji_map.get(previous_signal, "")
try: try:
JournalService().log_ui_info( JournalService().log_ui_info(
event_type="auto_signal_summary", event_type="auto_signal_summary",
message=( message=(
f"{emoji} {previous_count} {previous_signal} подряд " f"🟡 HOLD {duration_text} завершён сигналом {next_signal}"
f"до смены на {next_signal}"
), ),
screen="auto", screen="auto",
action="signal_summary", action="signal_summary",
@@ -533,10 +585,13 @@ class AutoTradeService:
"symbol": state.symbol, "symbol": state.symbol,
"signal": previous_signal, "signal": previous_signal,
"next_signal": next_signal, "next_signal": next_signal,
"signal_intent": signal_intent,
"repeat_count": previous_count, "repeat_count": previous_count,
"duration_seconds": duration_seconds,
"duration_text": duration_text,
"confidence": confidence, "confidence": confidence,
"reason": reason, "reason": reason,
"is_strong_signal": confidence > self._ready_confidence, "is_strong_signal": False,
"is_aggregated": True, "is_aggregated": True,
"payload": payload or {}, "payload": payload or {},
}, },
@@ -544,6 +599,47 @@ class AutoTradeService:
except Exception: except Exception:
pass 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: def run_cycle(self) -> AutoTradeState:
state = self.get_state() state = self.get_state()

View File

@@ -374,6 +374,16 @@
- сохранена обработка противоположных сигналов как reversal / flip candidates - сохранена обработка противоположных сигналов как reversal / flip candidates
- подготовлена база для Signal Intent Layer в следующем этапе - подготовлена база для 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 ### 07.4.4
⏳ Grid Strategy ⏳ Grid Strategy

View File

@@ -350,6 +350,16 @@
- сохранена обработка противоположных сигналов как reversal / flip candidates - сохранена обработка противоположных сигналов как reversal / flip candidates
- подготовлена база для Signal Intent Layer в следующем этапе - подготовлена база для 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 ### 07.4.4

View File

@@ -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. Система стала меньше реагировать на шумовые микросигналы и начала фиксировать не только сырой сигнал, но и его смысл относительно текущей позиции.