diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py index 0a0b3c3..70f643d 100644 --- a/app/src/trading/auto/runner.py +++ b/app/src/trading/auto/runner.py @@ -376,6 +376,8 @@ class AutoTradeRunner: exit_price = cls._format_price(payload.get("exit_price")) size = cls._format_size(payload.get("size")) pnl = cls._format_pnl(payload.get("pnl")) + risk_reason = payload.get("risk_reason") + risk_line = f"\nRisk: {risk_reason}" if risk_reason else "" return ( f"✅ Paper position closed\n\n" @@ -384,6 +386,7 @@ class AutoTradeRunner: f"Exit: $ {exit_price}\n" f"Size: {size}\n\n" f"PnL: {pnl}" + f"{risk_line}" ) if event_type == "paper_position_flipped": diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py index ef071f0..384fd08 100644 --- a/app/src/trading/auto/state.py +++ b/app/src/trading/auto/state.py @@ -65,4 +65,18 @@ class AutoTradeState: max_drawdown_usd: float | None = None # плечо - leverage: float | None = 2.0 \ No newline at end of file + leverage: float | None = 2.0 + + # stop loss по движению цены в % + #stop_loss_percent: float | None = 2.0 + + # take profit по движению цены в % + #take_profit_percent: float | None = 3.0 + + # максимальный допустимый paper-убыток в USD + #max_loss_usd: float | None = None + + # для демонстрации рисков: стоп-лосс и тейк-профит по риску в % от капитала + stop_loss_percent: float | None = None + take_profit_percent: float | None = None + max_loss_usd: float | None = 0.01 \ No newline at end of file diff --git a/app/src/trading/execution/engine.py b/app/src/trading/execution/engine.py index a5e5579..1759eb9 100644 --- a/app/src/trading/execution/engine.py +++ b/app/src/trading/execution/engine.py @@ -26,6 +26,10 @@ class ExecutionEngine: self._update_unrealized_pnl(state) + risk_decision = self._risk_close_decision(state) + if risk_decision is not None: + return risk_decision + if state.decision_status != "READY" or not state.is_signal_ready: return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.") @@ -183,20 +187,31 @@ class ExecutionEngine: f"Paper FLIP выполнен: {old_side} → {new_side}.", ) - def _close_position(self, state: AutoTradeState) -> ExecutionDecision: + def _close_position( + self, + state: AutoTradeState, + *, + forced_reason: str | None = None, + forced_exit_price: float | None = None, + forced_pnl: float | None = None, + ) -> ExecutionDecision: position = type(self)._position if position.side == "NONE": self._sync_state_from_position(state) return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.") - try: - ticker = ExchangeService().get_price(state.symbol) - exit_price = ticker.price - except Exception as exc: - return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") + if forced_exit_price is not None: + exit_price = forced_exit_price + else: + try: + ticker = ExchangeService().get_price(state.symbol) + exit_price = ticker.price + except Exception as exc: + return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}") + + pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price) - pnl = self._calculate_pnl(exit_price) now = self._now_time() payload = { @@ -213,13 +228,19 @@ class ExecutionEngine: "confidence": state.last_signal_confidence, "repeat_count": state.last_signal_repeat_count, "reason": state.last_signal_reason, + "risk_reason": forced_reason, + "is_forced": forced_reason is not None, "opened_at": position.opened_at, "closed_at": now, } JournalService().log_ui_info( event_type="paper_position_closed", - message=f"Paper EXIT закрыта: {position.side} {state.symbol}", + message=( + f"Paper EXIT закрыта по риску {forced_reason}: {position.side} {state.symbol}" + if forced_reason is not None + else f"Paper EXIT закрыта: {position.side} {state.symbol}" + ), screen="auto", action="paper_execution", payload=payload, @@ -230,8 +251,101 @@ class ExecutionEngine: type(self)._position = PositionState() self._sync_state_from_position(state) + if forced_reason is not None: + return ExecutionDecision( + f"FORCE_CLOSE_{forced_reason}", + True, + f"Paper EXIT выполнена по риску: {forced_reason}.", + ) + return ExecutionDecision("CLOSE", True, "Paper EXIT выполнена.") + def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None: + position = type(self)._position + + if position.side == "NONE": + return None + + try: + ticker = ExchangeService().get_price(position.symbol or state.symbol) + current_price = ticker.price + except Exception: + return None + + price_move_percent = self._calculate_price_move_percent(current_price) + unrealized_pnl = self._calculate_pnl(current_price) + + if self._is_stop_loss_hit(state, price_move_percent): + return self._close_position( + state, + forced_reason="STOP_LOSS", + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + ) + + if self._is_take_profit_hit(state, price_move_percent): + return self._close_position( + state, + forced_reason="TAKE_PROFIT", + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + ) + + if self._is_max_loss_hit(state, unrealized_pnl): + return self._close_position( + state, + forced_reason="MAX_LOSS", + forced_exit_price=current_price, + forced_pnl=unrealized_pnl, + ) + + return None + + def _is_stop_loss_hit( + self, + state: AutoTradeState, + price_move_percent: float, + ) -> bool: + if state.stop_loss_percent is None: + return False + + return price_move_percent <= -abs(state.stop_loss_percent) + + def _is_take_profit_hit( + self, + state: AutoTradeState, + price_move_percent: float, + ) -> bool: + if state.take_profit_percent is None: + return False + + return price_move_percent >= abs(state.take_profit_percent) + + def _is_max_loss_hit( + self, + state: AutoTradeState, + unrealized_pnl: float, + ) -> bool: + if state.max_loss_usd is None: + return False + + return unrealized_pnl <= -abs(state.max_loss_usd) + + def _calculate_price_move_percent(self, current_price: float) -> float: + position = type(self)._position + + entry = position.entry_price or 0.0 + if entry <= 0: + return 0.0 + + if position.side == "LONG": + return round(((current_price - entry) / entry) * 100, 4) + + if position.side == "SHORT": + return round(((entry - current_price) / entry) * 100, 4) + + return 0.0 + def _should_flip_position(self, state: AutoTradeState) -> bool: position = type(self)._position diff --git a/docs/stages/stage-07_4_3_10-risk_position_control.md b/docs/stages/stage-07_4_3_10-risk_position_control.md new file mode 100644 index 0000000..c0bf5ad --- /dev/null +++ b/docs/stages/stage-07_4_3_10-risk_position_control.md @@ -0,0 +1,300 @@ +# Stage 07.4.3.10 — Risk & Position Control + +## 📌 Обзор + +На данном этапе реализована система контроля риска для paper-trading: + +- Stop Loss (по движению цены) +- Take Profit (по движению цены) +- Max Loss (по абсолютному убытку) +- Принудительное закрытие позиции (forced execution) +- Интеграция с EventBus, Journal и Telegram alerts + +--- + +## ⚙️ Поддерживаемые risk-механики + +### 1. Stop Loss (%) + +Процентное ограничение убытка относительно цены входа. +Закрывает позицию при неблагоприятном движении цены: +LONG: цена ↓ +SHORT: цена ↑ + +### 2. Take Profit (%) +Процентное ограничение прибыли. +Фиксирует прибыль при благоприятном движении: +LONG: цена ↑ +SHORT: цена ↓ + +### 3. Max Loss (USD) +Абсолютное ограничение убытка в USD. + +--- + +## 🧠 Логика работы + +Risk-контроль выполняется до execution сигнала: +1. Обновление PnL +2. Проверка risk-условий +3. При необходимости → forced CLOSE +4. Только потом → обычный execution + +### 🔁 Типы execution событий + +ENTRY +paper_position_opened + +EXIT (обычный) +paper_position_closed + +EXIT (forced) +``` +{ + "execution_type": "EXIT", + "risk_reason": "STOP_LOSS | TAKE_PROFIT | MAX_LOSS", + "is_forced": true +} +``` + +FLIP +paper_position_flipped + +### 📡 EventBus события + +* paper_position_opened +* paper_position_closed +* paper_position_flipped + +## 🧾 Journal + +Пример записи: +[DEMO] Paper EXIT закрыта по риску TAKE_PROFIT: LONG BTC/USD + +Payload: +``` +{ + "risk_reason": "TAKE_PROFIT", + "is_forced": true, + "pnl": 0.75 +} +``` + +## 📲 Telegram Alerts + +Пример: +``` +✅ Paper position closed + +LONG · BTC / USD · x2 +Entry: $ 81 022.85 +Exit: $ 81 041.70 +Size: 0.04 + +PnL: 🟢 +0.75 USD +Risk: TAKE_PROFIT +``` + +## 📊 Расчёты + +PnL: +LONG: (current - entry) * size +SHORT: (entry - current) * size + +Price Move %: +LONG: (current - entry) / entry * 100 +SHORT: (entry - current) / entry * 100 + +--- + +## 🧪 Тестирование + +### STOP LOSS +stop_loss_percent = 0.01 + +Ожидаемо: +Risk: STOP_LOSS +PnL: 🔴 отрицательный + +### TAKE PROFIT +take_profit_percent = 0.01 + +Ожидаемо: +Risk: TAKE_PROFIT +PnL: 🟢 положительный + +### MAX LOSS +max_loss_usd = 0.01 + +Ожидаемо: +Risk: MAX_LOSS + +### SHORT сценарий +/debug_signal SELL 0.95 3 + +Проверяется обратная логика TP/SL. + +--- + +## ⚠️ Важно + +* Risk работает только в RUNNING +* Работает независимо от новых сигналов +* Может закрыть позицию без нового READY +* Не конфликтует с flip logic + +--- + +## 🧩 Архитектура + +AutoTradeService + ↓ +ExecutionEngine.process() + ↓ +Risk Control (NEW) + ↓ +Execution (ENTRY / EXIT / FLIP) + ↓ +EventBus + ↓ +AutoTradeRunner + ↓ +Telegram + Journal + +--- + +## 🚀 Результат этапа + +* Добавлен полноценный риск-контроль +* Реализованы forced exits +* Интеграция с UI, Journal и Telegram +* Подготовка к real trading + +--- + +## Математика расчётов + +Обозначения: + +- P_entry — цена входа +- P_current — текущая цена +- size — размер позиции +- side — направление (LONG / SHORT) + +--- + +### 1. PnL + +#### LONG: +PnL = (P_current - P_entry) * size + +#### SHORT: +PnL = (P_entry - P_current) * size + +--- + +### 2. Stop Loss + +#### LONG: +если P_current <= P_entry * (1 - SL%) + +#### SHORT: +если P_current >= P_entry * (1 + SL%) + +--- + +### 3. Take Profit + +#### LONG: +если P_current >= P_entry * (1 + TP%) + +#### SHORT: +если P_current <= P_entry * (1 - TP%) + +--- + +### 4. Max Loss + +условие: +PnL <= -max_loss_usd + +--- + +## Приоритет проверок + +1. Max Loss (самый жёсткий) +2. Stop Loss +3. Take Profit + +Первое выполненное условие закрывает позицию. + +--- + +## Execution Flow + +ExecutionEngine.process(): + +1. update_unrealized_pnl() +2. check_risk_conditions() +3. если риск сработал → close_position(risk_reason) +4. иначе: + - flip + - open + +--- + +## Payload событий + +### paper_position_closed + +Добавлено: + +- risk_reason: STOP_LOSS | TAKE_PROFIT | MAX_LOSS +- is_forced: true + +--- + +## Telegram отображение + +Пример: + +PnL: 🟢 +0.75 USD +Risk: TAKE_PROFIT + +--- + +## Journal + +Message: +Paper EXIT закрыта по риску TAKE_PROFIT + +Payload: +{ + "pnl": ..., + "risk_reason": "TAKE_PROFIT", + "is_forced": true +} + +--- + +## Важные нюансы + +- Risk работает ТОЛЬКО в RUNNING +- Работает независимо от сигналов +- Срабатывает раньше flip +- Не зависит от decision_status + +--- + +## Результат + +Добавлен критический слой защиты: + +- ограничение убытков +- фиксация прибыли +- автономное поведение системы + +Это база для: +- real trading +- advanced risk management +- portfolio-level контроля diff --git a/docs/stages/07_4_3_9_position_flip_flow.md b/docs/stages/stage-07_4_3_9-position_flip_flow.md similarity index 100% rename from docs/stages/07_4_3_9_position_flip_flow.md rename to docs/stages/stage-07_4_3_9-position_flip_flow.md