Stage 07.4.3.10 — Risk and position control

This commit is contained in:
2026-05-05 11:26:46 +03:00
parent 8dd6298712
commit 163e8efe82
5 changed files with 440 additions and 9 deletions

View File

@@ -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"<b>✅ Paper position closed</b>\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":

View File

@@ -66,3 +66,17 @@ class AutoTradeState:
# плечо
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

View File

@@ -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

View File

@@ -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 контроля