419 lines
14 KiB
Python
419 lines
14 KiB
Python
# app/src/trading/execution/engine.py
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
|
||
from src.core.event_bus import EventBus
|
||
from src.integrations.exchange.service import ExchangeService
|
||
from src.trading.auto.state import AutoTradeState
|
||
from src.trading.execution.models import ExecutionDecision
|
||
from src.trading.journal.service import JournalService
|
||
from src.trading.position.state import PositionState
|
||
|
||
|
||
class ExecutionEngine:
|
||
_position = PositionState()
|
||
|
||
def get_position(self) -> PositionState:
|
||
return type(self)._position
|
||
|
||
def process(self, state: AutoTradeState) -> ExecutionDecision:
|
||
self._sync_state_from_position(state)
|
||
|
||
if state.status != "RUNNING":
|
||
return ExecutionDecision("NONE", False, "Execution доступен только в режиме RUNNING.")
|
||
|
||
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.")
|
||
|
||
if self._should_flip_position(state):
|
||
return self._flip_position(state)
|
||
|
||
if state.last_signal == "BUY":
|
||
return self._open_position_if_empty(state=state, side="LONG", action="OPEN_LONG")
|
||
|
||
if state.last_signal == "SELL":
|
||
return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT")
|
||
|
||
return ExecutionDecision("NONE", False, "Нет торгового действия.")
|
||
|
||
def _open_position_if_empty(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
side: str,
|
||
action: str,
|
||
) -> 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)
|
||
entry_price = ticker.price
|
||
except Exception as exc:
|
||
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
|
||
|
||
now = self._now_time()
|
||
size = self._calculate_position_size(state)
|
||
|
||
type(self)._position = PositionState(
|
||
side=side,
|
||
symbol=state.symbol,
|
||
entry_price=entry_price,
|
||
size=size,
|
||
leverage=state.leverage,
|
||
unrealized_pnl_usd=0.0,
|
||
opened_at=now,
|
||
updated_at=now,
|
||
)
|
||
|
||
self._sync_state_from_position(state)
|
||
|
||
payload = {
|
||
"execution_type": "ENTRY",
|
||
"action": action,
|
||
"symbol": state.symbol,
|
||
"side": side,
|
||
"entry_price": entry_price,
|
||
"size": size,
|
||
"leverage": state.leverage,
|
||
"signal": state.last_signal,
|
||
"confidence": state.last_signal_confidence,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"reason": state.last_signal_reason,
|
||
"opened_at": now,
|
||
}
|
||
|
||
JournalService().log_ui_info(
|
||
event_type="paper_position_opened",
|
||
message=f"Paper ENTRY открыта: {side} {state.symbol}",
|
||
screen="auto",
|
||
action="paper_execution",
|
||
payload=payload,
|
||
)
|
||
|
||
EventBus.emit("paper_position_opened", payload)
|
||
|
||
return ExecutionDecision(action, True, f"Paper ENTRY {side} открыта.")
|
||
|
||
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
|
||
position = type(self)._position
|
||
|
||
if position.side == "NONE":
|
||
self._sync_state_from_position(state)
|
||
return ExecutionDecision("NONE", False, "Нет позиции для flip.")
|
||
|
||
new_side = self._target_side_from_signal(state.last_signal)
|
||
if new_side is None:
|
||
return ExecutionDecision("NONE", False, "Нет направления для flip.")
|
||
|
||
try:
|
||
ticker = ExchangeService().get_price(state.symbol)
|
||
flip_price = ticker.price
|
||
except Exception as exc:
|
||
return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}")
|
||
|
||
now = self._now_time()
|
||
pnl = self._calculate_pnl(flip_price)
|
||
new_size = self._calculate_position_size(state)
|
||
|
||
old_side = position.side
|
||
old_entry_price = position.entry_price
|
||
old_size = position.size
|
||
old_leverage = position.leverage
|
||
old_opened_at = position.opened_at
|
||
|
||
type(self)._position = PositionState(
|
||
side=new_side,
|
||
symbol=state.symbol,
|
||
entry_price=flip_price,
|
||
size=new_size,
|
||
leverage=state.leverage,
|
||
unrealized_pnl_usd=0.0,
|
||
opened_at=now,
|
||
updated_at=now,
|
||
)
|
||
|
||
self._sync_state_from_position(state)
|
||
|
||
payload = {
|
||
"execution_type": "FLIP",
|
||
"action": f"FLIP_{old_side}_TO_{new_side}",
|
||
"symbol": state.symbol,
|
||
"old_side": old_side,
|
||
"new_side": new_side,
|
||
"side": new_side,
|
||
"entry_price": old_entry_price,
|
||
"exit_price": flip_price,
|
||
"new_entry_price": flip_price,
|
||
"old_size": old_size,
|
||
"new_size": new_size,
|
||
"size": new_size,
|
||
"old_leverage": old_leverage,
|
||
"leverage": state.leverage,
|
||
"pnl": pnl,
|
||
"signal": state.last_signal,
|
||
"confidence": state.last_signal_confidence,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"reason": state.last_signal_reason,
|
||
"opened_at": old_opened_at,
|
||
"closed_at": now,
|
||
"new_opened_at": now,
|
||
}
|
||
|
||
JournalService().log_ui_info(
|
||
event_type="paper_position_flipped",
|
||
message=f"Paper FLIP выполнен: {old_side} → {new_side} {state.symbol}",
|
||
screen="auto",
|
||
action="paper_execution",
|
||
payload=payload,
|
||
)
|
||
|
||
EventBus.emit("paper_position_flipped", payload)
|
||
|
||
return ExecutionDecision(
|
||
f"FLIP_{old_side}_TO_{new_side}",
|
||
True,
|
||
f"Paper FLIP выполнен: {old_side} → {new_side}.",
|
||
)
|
||
|
||
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, "Нет открытой позиции для закрытия.")
|
||
|
||
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)
|
||
|
||
now = self._now_time()
|
||
|
||
payload = {
|
||
"execution_type": "EXIT",
|
||
"action": "CLOSE",
|
||
"symbol": state.symbol,
|
||
"side": position.side,
|
||
"entry_price": position.entry_price,
|
||
"exit_price": exit_price,
|
||
"size": position.size,
|
||
"leverage": position.leverage,
|
||
"pnl": pnl,
|
||
"signal": state.last_signal,
|
||
"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 закрыта по риску {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,
|
||
)
|
||
|
||
EventBus.emit("paper_position_closed", payload)
|
||
|
||
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
|
||
|
||
if position.side == "NONE":
|
||
return False
|
||
|
||
if position.side == "LONG" and state.last_signal == "SELL":
|
||
return True
|
||
|
||
if position.side == "SHORT" and state.last_signal == "BUY":
|
||
return True
|
||
|
||
return False
|
||
|
||
def _target_side_from_signal(self, signal: str | None) -> str | None:
|
||
if signal == "BUY":
|
||
return "LONG"
|
||
|
||
if signal == "SELL":
|
||
return "SHORT"
|
||
|
||
return None
|
||
|
||
def _update_unrealized_pnl(self, state: AutoTradeState) -> None:
|
||
position = type(self)._position
|
||
|
||
if position.side == "NONE":
|
||
self._sync_state_from_position(state)
|
||
return
|
||
|
||
try:
|
||
ticker = ExchangeService().get_price(position.symbol or state.symbol)
|
||
current_price = ticker.price
|
||
except Exception:
|
||
self._sync_state_from_position(state)
|
||
return
|
||
|
||
position.unrealized_pnl_usd = self._calculate_pnl(current_price)
|
||
position.updated_at = self._now_time()
|
||
|
||
self._sync_state_from_position(state)
|
||
|
||
def _calculate_position_size(self, state: AutoTradeState) -> float:
|
||
risk_percent = state.risk_percent or 0.0
|
||
leverage = state.leverage or 1.0
|
||
return round((risk_percent * leverage) / 100, 8)
|
||
|
||
def _calculate_pnl(self, current_price: float) -> float:
|
||
position = type(self)._position
|
||
|
||
entry = position.entry_price or 0.0
|
||
size = position.size or 0.0
|
||
|
||
if position.side == "LONG":
|
||
return round((current_price - entry) * size, 4)
|
||
|
||
if position.side == "SHORT":
|
||
return round((entry - current_price) * size, 4)
|
||
|
||
return 0.0
|
||
|
||
def _sync_state_from_position(self, state: AutoTradeState) -> None:
|
||
position = type(self)._position
|
||
|
||
state.position_side = position.side
|
||
state.entry_price = position.entry_price
|
||
state.position_size = position.size
|
||
state.unrealized_pnl_usd = position.unrealized_pnl_usd
|
||
|
||
def _now_time(self) -> str:
|
||
return datetime.now().strftime("%H:%M:%S") |