diff --git a/app/src/telegram/handlers/auto/ui.py b/app/src/telegram/handlers/auto/ui.py
index 96801b4..8aa1640 100644
--- a/app/src/telegram/handlers/auto/ui.py
+++ b/app/src/telegram/handlers/auto/ui.py
@@ -208,6 +208,7 @@ def _build_active_position_text(state) -> str:
f"Доступно · $ {_format_money_compact(available)}",
f"Зарезервировано · $ {_format_money_compact(reserved)}",
f"P&L {_format_signed_usd_with_direction(pnl)}",
+ *_execution_block_lines(state),
"",
(
f"{side_icon} {_asset_symbol(state.symbol)} · "
diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py
index bb04d2c..c9e4cc5 100644
--- a/app/src/trading/auto/state.py
+++ b/app/src/trading/auto/state.py
@@ -92,4 +92,16 @@ class AutoTradeState:
allocated_balance_usd: float = 1000.0
# зафиксированный результат закрытых paper-сделок
- realized_pnl_usd: float = 0.0
\ No newline at end of file
+ realized_pnl_usd: float = 0.0
+
+ # последнее execution-действие
+ last_execution_action: str | None = None
+
+ # последняя execution-причина
+ last_execution_reason: str | None = None
+
+ # последняя причина блокировки flip
+ last_flip_block_reason: str | None = None
+
+ # время последнего успешного flip
+ last_flip_at: str | None = None
\ No newline at end of file
diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py
index 3eb2419..1d2c5f2 100644
--- a/app/src/trading/execution/engine.py
+++ b/app/src/trading/execution/engine.py
@@ -26,6 +26,11 @@ class _ExecutionPrice:
class ExecutionEngine:
_position = PositionState()
_size_precision = 5
+ _min_flip_confidence = 0.75
+ _min_flip_repeat_count = 3
+ _min_flip_hold_seconds = 60
+ _loss_flip_confidence = 0.9
+ _last_flip_block_key: str | None = None
def get_position(self) -> PositionState:
return type(self)._position
@@ -46,6 +51,11 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.")
if self._should_flip_position(state):
+ flip_block_reason = self._flip_block_reason(state)
+
+ if flip_block_reason is not None:
+ return self._block_flip(state, flip_block_reason)
+
return self._flip_position(state)
if state.last_signal == "BUY":
@@ -112,6 +122,10 @@ class ExecutionEngine:
)
self._sync_state_from_position(state)
+ state.execution_block_reason = None
+ state.last_flip_block_reason = None
+ state.last_execution_action = action
+ state.last_execution_reason = f"Paper ENTRY {side} открыта."
payload = {
"execution_type": "ENTRY",
@@ -210,6 +224,12 @@ class ExecutionEngine:
)
self._sync_state_from_position(state)
+ state.execution_block_reason = None
+ state.last_flip_block_reason = None
+ state.last_execution_action = f"FLIP_{old_side}_TO_{new_side}"
+ state.last_execution_reason = "Paper FLIP выполнен."
+ state.last_flip_at = now
+ type(self)._last_flip_block_key = None
payload = {
"execution_type": "FLIP",
@@ -333,6 +353,19 @@ class ExecutionEngine:
type(self)._position = PositionState()
self._sync_state_from_position(state)
+ state.execution_block_reason = None
+ state.last_flip_block_reason = None
+ state.last_execution_action = (
+ f"FORCE_CLOSE_{forced_reason}"
+ if forced_reason is not None
+ else "CLOSE"
+ )
+ state.last_execution_reason = (
+ f"Paper EXIT выполнена по риску: {forced_reason}."
+ if forced_reason is not None
+ else "Paper EXIT выполнена."
+ )
+ type(self)._last_flip_block_key = None
if forced_reason is not None:
return ExecutionDecision(
@@ -431,6 +464,106 @@ class ExecutionEngine:
return False
+ def _flip_block_reason(self, state: AutoTradeState) -> str | None:
+ position = type(self)._position
+
+ confidence = float(state.last_signal_confidence or 0.0)
+ repeat_count = int(state.last_signal_repeat_count or 0)
+ unrealized_pnl = float(state.unrealized_pnl_usd or 0.0)
+ hold_seconds = self._position_hold_seconds(position)
+
+ if confidence < self._min_flip_confidence:
+ return (
+ "Flip blocked: signal confidence "
+ f"{confidence:.2f} < {self._min_flip_confidence:.2f}."
+ )
+
+ if repeat_count < self._min_flip_repeat_count:
+ return (
+ "Flip blocked: repeat count "
+ f"{repeat_count} < {self._min_flip_repeat_count}."
+ )
+
+ if hold_seconds is not None and hold_seconds < self._min_flip_hold_seconds:
+ return (
+ "Flip blocked: position hold time "
+ f"{hold_seconds}s < {self._min_flip_hold_seconds}s."
+ )
+
+ if unrealized_pnl < 0 and confidence < self._loss_flip_confidence:
+ return (
+ "Flip blocked: position is negative and signal is not strong enough "
+ f"({confidence:.2f} < {self._loss_flip_confidence:.2f})."
+ )
+
+ return None
+
+ def _block_flip(
+ self,
+ state: AutoTradeState,
+ reason: str,
+ ) -> ExecutionDecision:
+ position = type(self)._position
+ confidence = float(state.last_signal_confidence or 0.0)
+
+ state.execution_block_reason = reason
+ state.last_flip_block_reason = reason
+ state.last_execution_action = "FLIP_BLOCKED"
+ state.last_execution_reason = reason
+
+ block_key = (
+ f"{position.side}:"
+ f"{state.last_signal}:"
+ f"{state.last_signal_repeat_count}:"
+ f"{confidence:.2f}:"
+ f"{reason}"
+ )
+
+ if block_key != type(self)._last_flip_block_key:
+ type(self)._last_flip_block_key = block_key
+
+ payload = {
+ "execution_type": "FLIP_BLOCKED",
+ "symbol": state.symbol,
+ "position_side": position.side,
+ "signal": state.last_signal,
+ "confidence": confidence,
+ "repeat_count": state.last_signal_repeat_count,
+ "reason": reason,
+ "unrealized_pnl_usd": state.unrealized_pnl_usd,
+ "opened_at": position.opened_at,
+ "updated_at": position.updated_at,
+ }
+
+ JournalService().log_ui_info(
+ event_type="paper_flip_blocked",
+ message=f"Paper FLIP заблокирован: {reason}",
+ screen="auto",
+ action="paper_execution",
+ payload=payload,
+ )
+
+ EventBus.emit("paper_flip_blocked", payload)
+
+ return ExecutionDecision("NONE", False, reason)
+
+ def _position_hold_seconds(self, position: PositionState) -> int | None:
+ if not position.opened_at:
+ return None
+
+ try:
+ opened_at = datetime.strptime(position.opened_at, "%H:%M:%S")
+ now = datetime.strptime(self._now_time(), "%H:%M:%S")
+
+ seconds = int((now - opened_at).total_seconds())
+
+ if seconds < 0:
+ seconds += 24 * 60 * 60
+
+ return seconds
+ except Exception:
+ return None
+
def _target_side_from_signal(self, signal: str | None) -> str | None:
if signal == "BUY":
return "LONG"
diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md
index 332222e..cab5e3f 100644
--- a/docs/roadmap/master-roadmap.md
+++ b/docs/roadmap/master-roadmap.md
@@ -342,7 +342,7 @@
---
-#### 07.4.3.19 — Strategy Audit & Signal Quality Layer
+### 07.4.3.19 — Strategy Audit & Signal Quality Layer
- audit SCALP false flip behavior
- add position-aware signal handling
- prevent weak/medium signal flips
@@ -352,6 +352,17 @@
- classify signals as ENTRY / HOLD / EXIT / FLIP
- tune SCALP thresholds
+#### 07.4.3.19.1 - Position-aware Flip Protection
+- добавлена проверка confidence перед flip
+- добавлено подтверждение flip по количеству повторов сигнала
+- добавлена минимальная длительность удержания позиции перед flip
+- добавлена защита от flip в убыточной позиции без сильного сигнала
+- реализована блокировка flip через RuntimeEvent logging
+- добавлен dedupe для повторяющихся событий flip-блокировки через _last_flip_block_key
+- синхронизировано execution-state после ENTRY / FLIP / CLOSE
+- исправлена очистка ghost-позиций после forced exit
+- стабилизирован lifecycle paper execution во время ночного runtime-тестирования
+
### 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 b4a99b6..1e24fe6 100644
--- a/docs/roadmap/stage-07-auto-trading-roadmap.md
+++ b/docs/roadmap/stage-07-auto-trading-roadmap.md
@@ -326,16 +326,18 @@
---
-#### 07.4.3.19 — Strategy Audit & Signal Quality Layer
+### 07.4.3.19 — Strategy Audit & Signal Quality Layer
-- audit SCALP false flip behavior
-- add position-aware signal handling
-- prevent weak/medium signal flips
-- add min hold time before flip
-- add flip cooldown
-- add spread/slippage buffer
-- classify signals as ENTRY / HOLD / EXIT / FLIP
-- tune SCALP thresholds
+#### 07.4.3.19.1 - Position-aware Flip Protection (Защита Flip-позиций) ✅
+- добавлена проверка confidence перед flip
+- добавлено подтверждение flip по количеству повторов сигнала
+- добавлена минимальная длительность удержания позиции перед flip
+- добавлена защита от flip в убыточной позиции без сильного сигнала
+- реализована блокировка flip через RuntimeEvent logging
+- добавлен dedupe для повторяющихся событий flip-блокировки через _last_flip_block_key
+- синхронизировано execution-state после ENTRY / FLIP / CLOSE
+- исправлена очистка ghost-позиций после forced exit
+- стабилизирован lifecycle paper execution во время ночного runtime-тестирования
---
diff --git a/docs/stages/stage-07_4_3_19_1-position_aware_flip_protection.md b/docs/stages/stage-07_4_3_19_1-position_aware_flip_protection.md
new file mode 100644
index 0000000..fb57924
--- /dev/null
+++ b/docs/stages/stage-07_4_3_19_1-position_aware_flip_protection.md
@@ -0,0 +1,137 @@
+# 07.4.3.19.1 — Position-aware Flip Protection
+
+## Цель этапа
+
+Добавить первый защитный слой качества execution-сигналов: запретить быстрые и слабые flip-сделки, когда уже открыта paper-позиция.
+
+До этапа логика была слишком простой:
+
+ LONG + SELL = FLIP
+ SHORT + BUY = FLIP
+
+Из-за этого SCALP мог часто переворачивать позицию на рыночном шуме.
+
+## Что изменено
+
+### 1. Добавлены execution-поля в AutoTradeState
+
+В `app/src/trading/auto/state.py` добавлены поля:
+
+ last_execution_action
+ last_execution_reason
+ last_flip_block_reason
+ last_flip_at
+
+Они нужны для хранения последнего execution-действия и причины блокировки flip.
+
+### 2. Добавлена flip-защита в ExecutionEngine
+
+В `app/src/trading/execution/engine.py` добавлены параметры:
+
+ _min_flip_confidence = 0.75
+ _min_flip_repeat_count = 3
+ _min_flip_hold_seconds = 60
+ _loss_flip_confidence = 0.9
+
+Теперь flip проходит только если:
+
+- противоположный сигнал подтверждён;
+- confidence >= 0.75;
+- repeat_count >= 3;
+- позиция открыта не меньше 60 секунд;
+- если позиция в минусе, confidence должен быть >= 0.9.
+
+### 3. Добавлен paper_flip_blocked
+
+Если flip заблокирован, создаётся событие:
+
+ paper_flip_blocked
+
+В payload пишется:
+
+ execution_type
+ symbol
+ position_side
+ signal
+ confidence
+ repeat_count
+ reason
+ unrealized_pnl_usd
+ opened_at
+ updated_at
+
+### 4. Добавлена защита от спама одинаковых блокировок
+
+В `ExecutionEngine` добавлен `_last_flip_block_key`.
+
+Одинаковая причина блокировки с теми же параметрами не пишется в журнал каждую итерацию.
+
+### 5. Сброс block-state после успешных execution-событий
+
+После успешных событий очищаются:
+
+ execution_block_reason
+ last_flip_block_reason
+ _last_flip_block_key
+
+Это сделано для:
+
+ paper_position_opened
+ paper_position_flipped
+ paper_position_closed
+
+## Проверено на ночном прогоне
+
+Ночной прогон подтвердил:
+
+- paper position opened работает;
+- paper flip blocked сработал при repeat_count = 2;
+- flip прошёл только после repeat_count = 3;
+- flip не спамил журнал повторяющимися блокировками;
+- UI и position state не разошлись;
+- PnL и reserved balance отображались корректно.
+
+Пример подтверждённой последовательности:
+
+ paper_flip_blocked
+ reason: Flip blocked: repeat count 2 < 3.
+
+ paper_position_flipped
+ repeat_count: 3
+
+## Результат
+
+Execution стал position-aware на уровне flip-защиты.
+
+Система больше не должна мгновенно переворачивать позицию на первом слабом противоположном сигнале.
+
+## Следующий этап
+
+Следующий логичный подэтап:
+
+ 07.4.3.19.2 — Journal Noise Reduction & Position-aware Signal Logging
+
+Основная цель следующего шага:
+
+- сократить шум в журнале;
+- убрать лишние auto_screen_refresh_* события;
+- не слать одинаковые same-direction strong signal alerts при уже открытой позиции;
+- разделить сигналы на reversal candidate и position reinforced.
+
+## Roadmap update
+
+#### 07.4.3.19.1 — Position-aware Flip Protection
+- added execution-state tracking fields
+- added flip confidence gate
+- added flip repeat-count gate
+- added minimum position hold-time gate
+- added negative-PnL flip protection
+- added paper_flip_blocked journal event
+- added duplicate flip-block suppression
+- reset flip block state after open / flip / close
+- validated flip protection with overnight paper runtime
+
+## Commit
+
+ git add app/src/trading/auto/state.py app/src/trading/execution/engine.py app/src/telegram/handlers/auto/ui.py
+ git commit -m "07.4.3.19.1 — Position-aware Flip Protection"