07.4.3.16 — Production Execution Pricing Layer
This commit is contained in:
@@ -108,6 +108,10 @@ class AutoTradeRunner:
|
||||
MarketDataRunner.start(
|
||||
symbol_provider=lambda: service.get_state().symbol,
|
||||
interval_seconds=1,
|
||||
runtime_key="auto",
|
||||
screen="auto",
|
||||
action="market_data",
|
||||
runtime_label="[AUTO]",
|
||||
)
|
||||
|
||||
if cls._task is not None and not cls._task.done():
|
||||
@@ -117,7 +121,7 @@ class AutoTradeRunner:
|
||||
|
||||
@classmethod
|
||||
def stop(cls) -> None:
|
||||
MarketDataRunner.stop()
|
||||
MarketDataRunner.stop("auto")
|
||||
|
||||
if cls._task is None:
|
||||
return
|
||||
@@ -134,7 +138,7 @@ class AutoTradeRunner:
|
||||
|
||||
if state.status == "OFF":
|
||||
cls._task = None
|
||||
MarketDataRunner.stop()
|
||||
MarketDataRunner.stop("auto")
|
||||
break
|
||||
|
||||
service.run_cycle()
|
||||
@@ -346,6 +350,7 @@ class AutoTradeRunner:
|
||||
f"{payload.get('risk_reason')}:"
|
||||
f"{payload.get('is_forced')}:"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_execution_alert_text(
|
||||
cls,
|
||||
@@ -416,7 +421,7 @@ class AutoTradeRunner:
|
||||
f"New size: {new_size}\n\n"
|
||||
f"PnL: {pnl}"
|
||||
)
|
||||
|
||||
|
||||
return "<b>📄 Paper execution event</b>"
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -157,6 +157,9 @@ class AutoTradeService:
|
||||
return state, "Автоторговля активирована."
|
||||
|
||||
state.status = "RUNNING"
|
||||
self._reset_signal_tracking()
|
||||
state.last_signal = "HOLD"
|
||||
state.signal_started_at = time.monotonic()
|
||||
EventBus.emit(
|
||||
"auto_status_changed",
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Callable
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
|
||||
|
||||
from src.integrations.exchange.market_data_runner import MarketDataRunner
|
||||
from src.trading.debug.service import DebugTradeService
|
||||
|
||||
|
||||
@@ -24,6 +25,8 @@ class DebugTradeRunner:
|
||||
_current_screen: str | None = None
|
||||
|
||||
_interval_seconds = 5
|
||||
_market_interval_seconds = 1
|
||||
|
||||
_last_text: str | None = None
|
||||
_last_refresh_at: float = 0.0
|
||||
_retry_after_until: float = 0.0
|
||||
@@ -77,9 +80,21 @@ class DebugTradeRunner:
|
||||
|
||||
@classmethod
|
||||
def start(cls) -> None:
|
||||
state = DebugTradeService().get_state()
|
||||
service = DebugTradeService()
|
||||
state = service.get_state()
|
||||
state.status = "RUNNING"
|
||||
|
||||
MarketDataRunner.start(
|
||||
symbol_provider=lambda: DebugTradeService().get_state().symbol,
|
||||
interval_seconds=cls._market_interval_seconds,
|
||||
runtime_key="debug_auto",
|
||||
screen="debug_auto",
|
||||
action="market_data",
|
||||
runtime_label="[DEBUG]",
|
||||
)
|
||||
|
||||
cls._last_text = None
|
||||
|
||||
if cls._task is not None and not cls._task.done():
|
||||
return
|
||||
|
||||
@@ -87,6 +102,8 @@ class DebugTradeRunner:
|
||||
|
||||
@classmethod
|
||||
def stop(cls) -> None:
|
||||
MarketDataRunner.stop("debug_auto")
|
||||
|
||||
if cls._task is None:
|
||||
return
|
||||
|
||||
@@ -102,6 +119,7 @@ class DebugTradeRunner:
|
||||
|
||||
if state.status == "OFF":
|
||||
cls._task = None
|
||||
MarketDataRunner.stop("debug_auto")
|
||||
break
|
||||
|
||||
service.process()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
@@ -13,6 +14,15 @@ from src.trading.journal.service import JournalService
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ExecutionPrice:
|
||||
price: float
|
||||
source: str
|
||||
age_seconds: float | None
|
||||
updated_at: str
|
||||
pricing_role: str
|
||||
|
||||
|
||||
class ExecutionEngine:
|
||||
_position = PositionState()
|
||||
_size_precision = 5
|
||||
@@ -60,7 +70,8 @@ class ExecutionEngine:
|
||||
return ExecutionDecision("NONE", False, "Позиция уже открыта.")
|
||||
|
||||
try:
|
||||
entry_price = self._entry_price_for_side(state.symbol, side)
|
||||
entry = self._entry_price_for_side(state.symbol, side)
|
||||
entry_price = entry.price
|
||||
except Exception as exc:
|
||||
return ExecutionDecision("NONE", False, f"Не удалось получить цену для paper execution: {exc}")
|
||||
|
||||
@@ -116,6 +127,10 @@ class ExecutionEngine:
|
||||
"reason": state.last_signal_reason,
|
||||
"opened_at": now,
|
||||
"pricing": "ask_for_long_bid_for_short",
|
||||
"pricing_role": entry.pricing_role,
|
||||
"price_source": entry.source,
|
||||
"price_age_seconds": entry.age_seconds,
|
||||
"price_updated_at": entry.updated_at,
|
||||
}
|
||||
|
||||
JournalService().log_ui_info(
|
||||
@@ -142,8 +157,10 @@ class ExecutionEngine:
|
||||
return ExecutionDecision("NONE", False, "Нет направления для flip.")
|
||||
|
||||
try:
|
||||
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)
|
||||
exit_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
entry_execution = self._entry_price_for_side(state.symbol, new_side)
|
||||
exit_price = exit_execution.price
|
||||
new_entry_price = entry_execution.price
|
||||
except Exception as exc:
|
||||
return ExecutionDecision("NONE", False, f"Ошибка получения цены для flip: {exc}")
|
||||
|
||||
@@ -218,6 +235,14 @@ class ExecutionEngine:
|
||||
"closed_at": now,
|
||||
"new_opened_at": now,
|
||||
"pricing": "exit_by_side_then_entry_by_side",
|
||||
"exit_pricing_role": exit_execution.pricing_role,
|
||||
"exit_price_source": exit_execution.source,
|
||||
"exit_price_age_seconds": exit_execution.age_seconds,
|
||||
"exit_price_updated_at": exit_execution.updated_at,
|
||||
"entry_pricing_role": entry_execution.pricing_role,
|
||||
"entry_price_source": entry_execution.source,
|
||||
"entry_price_age_seconds": entry_execution.age_seconds,
|
||||
"entry_price_updated_at": entry_execution.updated_at,
|
||||
}
|
||||
|
||||
JournalService().log_ui_info(
|
||||
@@ -243,6 +268,7 @@ class ExecutionEngine:
|
||||
forced_reason: str | None = None,
|
||||
forced_exit_price: float | None = None,
|
||||
forced_pnl: float | None = None,
|
||||
forced_price_meta: _ExecutionPrice | None = None,
|
||||
) -> ExecutionDecision:
|
||||
position = type(self)._position
|
||||
|
||||
@@ -252,9 +278,11 @@ class ExecutionEngine:
|
||||
|
||||
if forced_exit_price is not None:
|
||||
exit_price = forced_exit_price
|
||||
exit_execution = forced_price_meta
|
||||
else:
|
||||
try:
|
||||
exit_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
exit_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
exit_price = exit_execution.price
|
||||
except Exception as exc:
|
||||
return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}")
|
||||
|
||||
@@ -283,6 +311,10 @@ class ExecutionEngine:
|
||||
"opened_at": position.opened_at,
|
||||
"closed_at": now,
|
||||
"pricing": "bid_for_long_exit_ask_for_short_exit",
|
||||
"pricing_role": exit_execution.pricing_role if exit_execution else None,
|
||||
"price_source": exit_execution.source if exit_execution else None,
|
||||
"price_age_seconds": exit_execution.age_seconds if exit_execution else None,
|
||||
"price_updated_at": exit_execution.updated_at if exit_execution else None,
|
||||
}
|
||||
|
||||
JournalService().log_ui_info(
|
||||
@@ -318,7 +350,8 @@ class ExecutionEngine:
|
||||
return None
|
||||
|
||||
try:
|
||||
current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
current_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
current_price = current_execution.price
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -331,6 +364,7 @@ class ExecutionEngine:
|
||||
forced_reason="MAX_LOSS",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
if self._is_stop_loss_hit(state, price_move_percent):
|
||||
@@ -339,6 +373,7 @@ class ExecutionEngine:
|
||||
forced_reason="STOP_LOSS",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
if self._is_take_profit_hit(state, price_move_percent):
|
||||
@@ -347,6 +382,7 @@ class ExecutionEngine:
|
||||
forced_reason="TAKE_PROFIT",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -412,7 +448,8 @@ class ExecutionEngine:
|
||||
return
|
||||
|
||||
try:
|
||||
current_price = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
current_execution = self._exit_price_for_side(position.symbol or state.symbol, position.side)
|
||||
current_price = current_execution.price
|
||||
except Exception:
|
||||
self._sync_state_from_position(state)
|
||||
return
|
||||
@@ -438,7 +475,7 @@ class ExecutionEngine:
|
||||
|
||||
if price is None:
|
||||
try:
|
||||
price = self._signal_entry_price(state)
|
||||
price = self._signal_entry_price(state).price
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@@ -487,7 +524,7 @@ class ExecutionEngine:
|
||||
state.execution_size_adjustment_reason = "MARGIN_LIMIT"
|
||||
return self._round_size(max_size)
|
||||
|
||||
def _signal_entry_price(self, state: AutoTradeState) -> float:
|
||||
def _signal_entry_price(self, state: AutoTradeState) -> _ExecutionPrice:
|
||||
if state.last_signal == "BUY":
|
||||
return self._entry_price_for_side(state.symbol, "LONG")
|
||||
|
||||
@@ -496,50 +533,83 @@ class ExecutionEngine:
|
||||
|
||||
return self._market_last_price(state.symbol)
|
||||
|
||||
def _entry_price_for_side(self, symbol: str, side: str) -> float:
|
||||
snapshot = ExchangeService().get_market_snapshot(symbol)
|
||||
def _entry_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
|
||||
if side == "LONG":
|
||||
return self._snapshot_price(snapshot, "ask_price", "last_price")
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.ask_price, "ask_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="LONG_ENTRY_ASK",
|
||||
)
|
||||
|
||||
if side == "SHORT":
|
||||
return self._snapshot_price(snapshot, "bid_price", "last_price")
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.bid_price, "bid_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="SHORT_ENTRY_BID",
|
||||
)
|
||||
|
||||
return self._snapshot_price(snapshot, "last_price")
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="ENTRY_LAST",
|
||||
)
|
||||
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> float:
|
||||
snapshot = ExchangeService().get_market_snapshot(symbol)
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> _ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
|
||||
if side == "LONG":
|
||||
return self._snapshot_price(snapshot, "bid_price", "last_price")
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.bid_price, "bid_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="LONG_EXIT_BID",
|
||||
)
|
||||
|
||||
if side == "SHORT":
|
||||
return self._snapshot_price(snapshot, "ask_price", "last_price")
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.ask_price, "ask_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="SHORT_EXIT_ASK",
|
||||
)
|
||||
|
||||
return self._snapshot_price(snapshot, "last_price")
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="EXIT_LAST",
|
||||
)
|
||||
|
||||
def _market_last_price(self, symbol: str) -> float:
|
||||
snapshot = ExchangeService().get_market_snapshot(symbol)
|
||||
return self._snapshot_price(snapshot, "last_price")
|
||||
def _market_last_price(self, symbol: str) -> _ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
|
||||
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)
|
||||
return _ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="MARKET_LAST",
|
||||
)
|
||||
|
||||
def _snapshot_price(self, raw_price: object, name: str) -> float:
|
||||
if raw_price is None:
|
||||
raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
|
||||
raise ValueError(f"Execution snapshot price '{name}' is missing.")
|
||||
|
||||
price = float(raw_price)
|
||||
|
||||
if price <= 0:
|
||||
raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}")
|
||||
raise ValueError(f"Execution snapshot price '{name}' is invalid: {price}")
|
||||
|
||||
return price
|
||||
|
||||
|
||||
Reference in New Issue
Block a user