07.4.3.14 — Auto Trading UI. Realistic Pricing & Debug Live Tools

This commit is contained in:
2026-05-09 01:34:46 +03:00
parent ee78f9774a
commit df76490783
15 changed files with 2161 additions and 464 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import math
from datetime import datetime
from src.core.event_bus import EventBus
@@ -14,6 +15,7 @@ from src.trading.position.state import PositionState
class ExecutionEngine:
_position = PositionState()
_size_precision = 5
def get_position(self) -> PositionState:
return type(self)._position
@@ -58,8 +60,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Позиция уже открыта.")
try:
ticker = ExchangeService().get_price(state.symbol)
entry_price = ticker.price
entry_price = self._entry_price_for_side(state.symbol, side)
except Exception as exc:
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
@@ -72,13 +73,22 @@ class ExecutionEngine:
False,
"Позиция не открыта: невозможно рассчитать size без Stop Loss.",
)
size = self._adjust_size_by_margin_limit(
state=state,
entry_price=entry_price,
size=size,
)
size = self._round_order_size(size)
if size <= 0:
return ExecutionDecision(
"NONE",
False,
"Позиция не открыта: итоговый size равен 0.",
)
type(self)._position = PositionState(
side=side,
symbol=state.symbol,
@@ -105,6 +115,7 @@ class ExecutionEngine:
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
"opened_at": now,
"pricing": "ask_for_long_bid_for_short",
}
JournalService().log_ui_info(
@@ -131,14 +142,14 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет направления для flip.")
try:
ticker = ExchangeService().get_price(state.symbol)
flip_price = ticker.price
exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
new_entry_price = self._entry_price_for_side(state.symbol, new_side)
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, entry_price=flip_price)
pnl = self._calculate_pnl(exit_price)
new_size = self._calculate_position_size(state, entry_price=new_entry_price)
if new_size <= 0:
return ExecutionDecision(
@@ -146,13 +157,24 @@ class ExecutionEngine:
False,
"Flip отменён: невозможно рассчитать size без Stop Loss.",
)
new_size = self._adjust_size_by_margin_limit(
state=state,
entry_price=flip_price,
entry_price=new_entry_price,
size=new_size,
)
new_size = self._round_order_size(new_size)
if new_size <= 0:
return ExecutionDecision(
"NONE",
False,
"Flip отменён: итоговый size равен 0.",
)
state.realized_pnl_usd += pnl
old_side = position.side
old_entry_price = position.entry_price
old_size = position.size
@@ -162,7 +184,7 @@ class ExecutionEngine:
type(self)._position = PositionState(
side=new_side,
symbol=state.symbol,
entry_price=flip_price,
entry_price=new_entry_price,
size=new_size,
leverage=state.leverage,
unrealized_pnl_usd=0.0,
@@ -180,8 +202,8 @@ class ExecutionEngine:
"new_side": new_side,
"side": new_side,
"entry_price": old_entry_price,
"exit_price": flip_price,
"new_entry_price": flip_price,
"exit_price": exit_price,
"new_entry_price": new_entry_price,
"old_size": old_size,
"new_size": new_size,
"size": new_size,
@@ -195,6 +217,7 @@ class ExecutionEngine:
"opened_at": old_opened_at,
"closed_at": now,
"new_opened_at": now,
"pricing": "exit_by_side_then_entry_by_side",
}
JournalService().log_ui_info(
@@ -231,13 +254,14 @@ class ExecutionEngine:
exit_price = forced_exit_price
else:
try:
ticker = ExchangeService().get_price(state.symbol)
exit_price = ticker.price
exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
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)
state.realized_pnl_usd += pnl
now = self._now_time()
payload = {
@@ -258,6 +282,7 @@ class ExecutionEngine:
"is_forced": forced_reason is not None,
"opened_at": position.opened_at,
"closed_at": now,
"pricing": "bid_for_long_exit_ask_for_short_exit",
}
JournalService().log_ui_info(
@@ -293,8 +318,7 @@ class ExecutionEngine:
return None
try:
ticker = ExchangeService().get_price(position.symbol or state.symbol)
current_price = ticker.price
current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
except Exception:
return None
@@ -327,34 +351,19 @@ class ExecutionEngine:
return None
def _is_stop_loss_hit(
self,
state: AutoTradeState,
price_move_percent: float,
) -> bool:
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:
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:
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:
@@ -371,7 +380,7 @@ class ExecutionEngine:
return round(((entry - current_price) / entry) * 100, 4)
return 0.0
def _should_flip_position(self, state: AutoTradeState) -> bool:
position = type(self)._position
@@ -403,8 +412,7 @@ class ExecutionEngine:
return
try:
ticker = ExchangeService().get_price(position.symbol or state.symbol)
current_price = ticker.price
current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
except Exception:
self._sync_state_from_position(state)
return
@@ -430,15 +438,14 @@ class ExecutionEngine:
if price is None:
try:
ticker = ExchangeService().get_price(state.symbol)
price = ticker.price
price = self._signal_entry_price(state)
except Exception:
return 0.0
if price <= 0:
return 0.0
balance_usd = 1000.0
balance_usd = state.allocated_balance_usd
target_risk_usd = balance_usd * (state.risk_percent / 100)
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
@@ -446,8 +453,7 @@ class ExecutionEngine:
return 0.0
size = target_risk_usd / stop_loss_distance_usd
return round(size, 8)
return self._round_size(size)
def _adjust_size_by_margin_limit(
self,
@@ -462,26 +468,85 @@ class ExecutionEngine:
state.execution_size_adjustment_reason = None
if max_percent is None or max_percent <= 0:
return round(size, 8)
return self._round_size(size)
leverage = state.leverage or 1.0
if leverage <= 0 or entry_price <= 0:
state.execution_block_reason = "Invalid leverage or entry price."
return 0.0
balance_usd = 1000.0
balance_usd = state.allocated_balance_usd
max_reserved_usd = balance_usd * (max_percent / 100)
max_notional_usd = max_reserved_usd * leverage
max_size = max_notional_usd / entry_price
if size <= max_size:
return round(size, 8)
return self._round_size(size)
state.execution_size_adjustment_reason = "MARGIN_LIMIT"
return self._round_size(max_size)
def _signal_entry_price(self, state: AutoTradeState) -> float:
if state.last_signal == "BUY":
return self._entry_price_for_side(state.symbol, "LONG")
if state.last_signal == "SELL":
return self._entry_price_for_side(state.symbol, "SHORT")
return self._market_last_price(state.symbol)
def _entry_price_for_side(self, symbol: str, side: str) -> float:
snapshot = ExchangeService().get_market_snapshot(symbol)
if side == "LONG":
return self._snapshot_price(snapshot, "ask_price", "last_price")
if side == "SHORT":
return self._snapshot_price(snapshot, "bid_price", "last_price")
return self._snapshot_price(snapshot, "last_price")
def _exit_price_for_side(self, symbol: str, side: str) -> float:
snapshot = ExchangeService().get_market_snapshot(symbol)
if side == "LONG":
return self._snapshot_price(snapshot, "bid_price", "last_price")
if side == "SHORT":
return self._snapshot_price(snapshot, "ask_price", "last_price")
return self._snapshot_price(snapshot, "last_price")
def _market_last_price(self, symbol: str) -> float:
snapshot = ExchangeService().get_market_snapshot(symbol)
return self._snapshot_price(snapshot, "last_price")
def _snapshot_price(
self,
snapshot: dict[str, object],
primary_key: str,
fallback_key: str | None = None,
) -> float:
raw_price = snapshot.get(primary_key)
if raw_price is None and fallback_key is not None:
raw_price = snapshot.get(fallback_key)
if raw_price is None:
raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
price = float(raw_price)
if price <= 0:
raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}")
return price
def _round_size(self, size: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(size) * factor) / factor
return round(max_size, 8)
def _calculate_pnl(self, current_price: float) -> float:
position = type(self)._position
@@ -504,5 +569,9 @@ class ExecutionEngine:
state.position_size = position.size
state.unrealized_pnl_usd = position.unrealized_pnl_usd
def _round_order_size(self, value: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(value) * factor) / factor
def _now_time(self) -> str:
return datetime.now().strftime("%H:%M:%S")