Stage 07.4.3.10 — Risk and position control
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user