07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
# app/src/trading/accounts/service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.models import BalanceSummary
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.storage.repositories.balance_snapshots import BalanceSnapshotRepository
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
)
|
||||
from src.storage.repositories.balance_snapshots import (
|
||||
BalanceSnapshotRepository,
|
||||
)
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
@@ -12,12 +20,27 @@ class AccountsService:
|
||||
self.snapshot_repository = BalanceSnapshotRepository()
|
||||
self.journal = JournalService()
|
||||
|
||||
# получить live balance summary через typed exchange runtime layer
|
||||
def get_live_balance_summary(self) -> list[BalanceSummary]:
|
||||
balances = self.exchange_service.get_balance_summary()
|
||||
try:
|
||||
balances = self.exchange_service.get_balance_summary()
|
||||
|
||||
except Exception as exc:
|
||||
runtime_status = build_exchange_error_status(exc)
|
||||
|
||||
self._log_balance_runtime_error(runtime_status)
|
||||
|
||||
raise
|
||||
|
||||
self._save_snapshot(balances)
|
||||
|
||||
return balances
|
||||
|
||||
def _save_snapshot(self, balances: list[BalanceSummary]) -> None:
|
||||
# сохранить snapshot баланса
|
||||
def _save_snapshot(
|
||||
self,
|
||||
balances: list[BalanceSummary],
|
||||
) -> None:
|
||||
payload = {
|
||||
"assets": [
|
||||
{
|
||||
@@ -35,22 +58,62 @@ class AccountsService:
|
||||
source="portfolio_screen",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.journal.log_warning(
|
||||
"balance_snapshot_error",
|
||||
f"Не удалось сохранить snapshot баланса: {exc}",
|
||||
{"assets_count": len(balances)},
|
||||
{
|
||||
"assets_count": len(balances),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
self.journal.log_info(
|
||||
"balance_snapshot_saved",
|
||||
f"Snapshot баланса сохранён. Активов: {len(balances)}",
|
||||
{"assets_count": len(balances)},
|
||||
{
|
||||
"assets_count": len(balances),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# записать typed runtime exchange error для balances
|
||||
def _log_balance_runtime_error(
|
||||
self,
|
||||
runtime_status,
|
||||
) -> None:
|
||||
try:
|
||||
payload = {
|
||||
"status_code": runtime_status.code.value,
|
||||
"is_available": runtime_status.is_available,
|
||||
"is_auth_ok": runtime_status.is_auth_ok,
|
||||
"reason": runtime_status.reason,
|
||||
"raw_status": runtime_status.raw_status,
|
||||
"raw_error": runtime_status.raw_error,
|
||||
}
|
||||
|
||||
if runtime_status.code == ExchangeStatusCode.AUTH_ERROR:
|
||||
self.journal.log_warning(
|
||||
"balance_auth_error",
|
||||
runtime_status.message,
|
||||
payload,
|
||||
)
|
||||
return
|
||||
|
||||
self.journal.log_warning(
|
||||
"balance_exchange_error",
|
||||
runtime_status.message,
|
||||
payload,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1 +1,3 @@
|
||||
# app/src/trading/auto/__init__.py
|
||||
|
||||
"""Package marker."""
|
||||
555
app/src/trading/auto/auto_lifecycle.py
Normal file
555
app/src/trading/auto/auto_lifecycle.py
Normal file
@@ -0,0 +1,555 @@
|
||||
# app/src/trading/auto/auto_lifecycle.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.engine import ExecutionEngine
|
||||
from src.trading.strategies.base import BaseStrategy, StrategyContext
|
||||
from src.trading.strategies.registry import StrategyRegistry
|
||||
from src.trading.auto.execution_quality import AutoExecutionQualityMixin
|
||||
from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin
|
||||
from src.trading.auto.market_runtime import AutoMarketRuntimeMixin
|
||||
from src.trading.auto.position_intelligence import AutoPositionIntelligenceMixin
|
||||
from src.trading.auto.position_health import AutoPositionHealthMixin
|
||||
from src.trading.auto.execution_semantic import AutoExecutionSemanticMixin
|
||||
from src.trading.auto.autonomous_management import AutoAutonomousManagementMixin
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.trading.auto.execution_semantic import AutoExecutionSemanticMixin
|
||||
from src.trading.auto.position_health import AutoPositionHealthMixin
|
||||
from src.trading.auto.position_intelligence import AutoPositionIntelligenceMixin
|
||||
from src.trading.auto.market_runtime import AutoMarketRuntimeMixin
|
||||
from src.trading.auto.execution_quality import AutoExecutionQualityMixin
|
||||
from src.trading.auto.signal_runtime import AutoSignalRuntimeMixin
|
||||
|
||||
|
||||
class AutoLifecycleMixin(
|
||||
AutoSignalRuntimeMixin,
|
||||
AutoExecutionQualityMixin,
|
||||
AutoMarketRuntimeMixin,
|
||||
AutoPositionHealthMixin,
|
||||
AutoPositionIntelligenceMixin,
|
||||
AutoAutonomousManagementMixin,
|
||||
AutoExecutionSemanticMixin,
|
||||
):
|
||||
_state: AutoTradeState
|
||||
_loop_task: asyncio.Task | None
|
||||
_loop_interval_seconds: int
|
||||
|
||||
_confirm_min_duration_seconds: int
|
||||
_confirm_repeats: int
|
||||
_execution_confidence_required_score: float
|
||||
|
||||
|
||||
# Записать изменение режима автоторговли в журнал.
|
||||
def _log_auto_status_changed(
|
||||
self,
|
||||
*,
|
||||
previous_status: str,
|
||||
new_status: str,
|
||||
action: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
state = self.get_state()
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="auto_status_changed",
|
||||
message=message,
|
||||
screen="auto",
|
||||
action=action,
|
||||
payload={
|
||||
"previous_status": previous_status,
|
||||
"new_status": new_status,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"cycle_number": state.cycle_number,
|
||||
"risk_percent": state.risk_percent,
|
||||
"leverage": state.leverage,
|
||||
"allocated_balance_usd": state.allocated_balance_usd,
|
||||
"stop_loss_percent": state.stop_loss_percent,
|
||||
"take_profit_percent": state.take_profit_percent,
|
||||
"max_loss_usd": state.max_loss_usd,
|
||||
"max_reserved_balance_percent": state.max_reserved_balance_percent,
|
||||
},
|
||||
)
|
||||
|
||||
# установить капитал, выделенный под автоторговлю
|
||||
def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
|
||||
numeric_value = safe_float(value)
|
||||
|
||||
if numeric_value is None or numeric_value <= 0:
|
||||
numeric_value = 1000.0
|
||||
|
||||
state.allocated_balance_usd = numeric_value
|
||||
state.execution_block_reason = None
|
||||
state.execution_size_adjustment_reason = None
|
||||
return state
|
||||
|
||||
# получить текущее состояние автоторговли
|
||||
def get_state(self) -> AutoTradeState:
|
||||
if not self._state.symbol:
|
||||
self._state.symbol = load_settings().default_symbol
|
||||
return self._state
|
||||
|
||||
# проверить, запущен ли background loop
|
||||
def is_loop_running(self) -> bool:
|
||||
return self._loop_task is not None and not self._loop_task.done()
|
||||
|
||||
# запустить background loop, если он ещё не запущен
|
||||
def start_loop(self) -> None:
|
||||
if self.is_loop_running():
|
||||
return
|
||||
|
||||
self._loop_task = asyncio.create_task(self._loop_worker())
|
||||
|
||||
# остановить background loop
|
||||
def stop_loop(self) -> None:
|
||||
if self._loop_task is None:
|
||||
return
|
||||
|
||||
self._loop_task.cancel()
|
||||
self._loop_task = None
|
||||
|
||||
# рабочий цикл автоторговли
|
||||
async def _loop_worker(self) -> None:
|
||||
while True:
|
||||
state = self.get_state()
|
||||
|
||||
if state.status == "OFF":
|
||||
break
|
||||
|
||||
self.run_cycle()
|
||||
await asyncio.sleep(self._loop_interval_seconds)
|
||||
|
||||
# запустить активную торговлю
|
||||
def start(self) -> tuple[AutoTradeState, str]:
|
||||
state = self.get_state()
|
||||
previous_status = state.status
|
||||
|
||||
if state.status == "RUNNING":
|
||||
return state, "Автоторговля уже активна."
|
||||
|
||||
if state.status == "OBSERVING":
|
||||
state.status = "RUNNING"
|
||||
|
||||
EventBus.emit(
|
||||
"auto_status_changed",
|
||||
{
|
||||
"previous_status": previous_status,
|
||||
"status": state.status,
|
||||
},
|
||||
)
|
||||
|
||||
self._log_auto_status_changed(
|
||||
previous_status=previous_status,
|
||||
new_status=state.status,
|
||||
action="start",
|
||||
message="Автоторговля активирована.",
|
||||
)
|
||||
|
||||
return state, "Автоторговля активирована."
|
||||
|
||||
state.status = "RUNNING"
|
||||
self._reset_signal_tracking()
|
||||
state.cycle_realized_pnl_usd = 0.0
|
||||
state.cycle_closed_trades = 0
|
||||
state.cycle_winning_trades = 0
|
||||
state.cycle_started_at = time.monotonic()
|
||||
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
|
||||
state.last_flip_old_side = None
|
||||
state.last_flip_new_side = None
|
||||
state.last_flip_pnl_usd = None
|
||||
state.last_flip_reason = None
|
||||
state.last_flip_monotonic_at = None
|
||||
state.last_signal = "HOLD"
|
||||
state.signal_started_at = time.monotonic()
|
||||
|
||||
EventBus.emit(
|
||||
"auto_status_changed",
|
||||
{
|
||||
"previous_status": previous_status,
|
||||
"status": state.status,
|
||||
},
|
||||
)
|
||||
|
||||
self._log_auto_status_changed(
|
||||
previous_status=previous_status,
|
||||
new_status=state.status,
|
||||
action="start",
|
||||
message="Автоторговля запущена.",
|
||||
)
|
||||
|
||||
return state, "Автоторговля запущена."
|
||||
|
||||
# включить режим наблюдения
|
||||
def observe(self) -> tuple[AutoTradeState, str]:
|
||||
state = self.get_state()
|
||||
previous_status = state.status
|
||||
|
||||
if previous_status == "OBSERVING":
|
||||
return state, "Режим наблюдения уже включён."
|
||||
|
||||
state.status = "OBSERVING"
|
||||
|
||||
EventBus.emit(
|
||||
"auto_status_changed",
|
||||
{
|
||||
"previous_status": previous_status,
|
||||
"status": state.status,
|
||||
},
|
||||
)
|
||||
|
||||
if previous_status == "OFF":
|
||||
state.cycle_realized_pnl_usd = 0.0
|
||||
state.cycle_closed_trades = 0
|
||||
state.cycle_winning_trades = 0
|
||||
state.cycle_started_at = time.monotonic()
|
||||
state.last_flip_old_side = None
|
||||
state.last_flip_new_side = None
|
||||
state.last_flip_pnl_usd = None
|
||||
state.last_flip_reason = None
|
||||
state.last_flip_monotonic_at = None
|
||||
|
||||
self._log_auto_status_changed(
|
||||
previous_status=previous_status,
|
||||
new_status=state.status,
|
||||
action="observe",
|
||||
message="Включён режим наблюдения.",
|
||||
)
|
||||
|
||||
return state, "Включён режим наблюдения."
|
||||
|
||||
self._log_auto_status_changed(
|
||||
previous_status=previous_status,
|
||||
new_status=state.status,
|
||||
action="observe",
|
||||
message="Автоторговля переведена в режим наблюдения.",
|
||||
)
|
||||
|
||||
return state, "Автоторговля переведена в режим наблюдения."
|
||||
|
||||
# полностью выключить автоторговлю
|
||||
def stop(self) -> tuple[AutoTradeState, str]:
|
||||
state = self.get_state()
|
||||
previous_status = state.status
|
||||
|
||||
if state.status == "OFF":
|
||||
self.stop_loop()
|
||||
return state, "Автоторговля уже выключена."
|
||||
|
||||
state.status = "OFF"
|
||||
state.cycle_realized_pnl_usd = 0.0
|
||||
state.cycle_closed_trades = 0
|
||||
state.cycle_winning_trades = 0
|
||||
state.cycle_started_at = None
|
||||
state.adaptive_size_changed_at = None
|
||||
state.last_flip_old_side = None
|
||||
state.last_flip_new_side = None
|
||||
state.last_flip_pnl_usd = None
|
||||
state.last_flip_reason = None
|
||||
state.last_flip_monotonic_at = None
|
||||
self.stop_loop()
|
||||
|
||||
EventBus.emit(
|
||||
"auto_status_changed",
|
||||
{
|
||||
"previous_status": previous_status,
|
||||
"status": state.status,
|
||||
},
|
||||
)
|
||||
|
||||
self._log_auto_status_changed(
|
||||
previous_status=previous_status,
|
||||
new_status=state.status,
|
||||
action="stop",
|
||||
message="Автоторговля выключена.",
|
||||
)
|
||||
|
||||
return state, "Автоторговля выключена."
|
||||
|
||||
# установить инструмент
|
||||
def set_symbol(self, symbol: str) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
previous_symbol = state.symbol
|
||||
|
||||
state.symbol = symbol
|
||||
self._reset_signal_tracking()
|
||||
|
||||
StrategyRegistry.reset_runtime(symbol=previous_symbol)
|
||||
StrategyRegistry.reset_runtime(symbol=symbol)
|
||||
|
||||
return state
|
||||
|
||||
# установить стратегию
|
||||
def set_strategy(self, strategy: str) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
previous_strategy = state.strategy
|
||||
normalized_strategy = strategy.strip().upper()
|
||||
|
||||
state.strategy = normalized_strategy
|
||||
self._reset_signal_tracking()
|
||||
|
||||
StrategyRegistry.reset_runtime(previous_strategy)
|
||||
StrategyRegistry.reset_runtime(normalized_strategy)
|
||||
|
||||
return state
|
||||
|
||||
# установить риск
|
||||
def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
state.risk_percent = safe_float(risk_percent)
|
||||
return state
|
||||
|
||||
# установить плечо
|
||||
def set_leverage(self, leverage: NumericLike) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
state.leverage = safe_float(leverage)
|
||||
return state
|
||||
|
||||
# установить stop loss в %
|
||||
def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
state.stop_loss_percent = safe_float(value)
|
||||
return state
|
||||
|
||||
# установить take profit в %
|
||||
def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
state.take_profit_percent = safe_float(value)
|
||||
return state
|
||||
|
||||
# установить max loss в USD
|
||||
def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
state.max_loss_usd = safe_float(value)
|
||||
return state
|
||||
|
||||
# установить максимальное использование баланса под маржу
|
||||
def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
state.max_reserved_balance_percent = safe_float(value)
|
||||
state.execution_block_reason = None
|
||||
return state
|
||||
|
||||
# сбросить внутренний трекинг сигналов и runtime state
|
||||
def _reset_signal_tracking(self) -> None:
|
||||
self._last_signal_key = None
|
||||
self._last_signal_value = None
|
||||
self._last_signal_reason = ""
|
||||
self._last_signal_confidence = 0.0
|
||||
self._last_signal_payload = None
|
||||
self._last_signal_started_at = None
|
||||
self._same_signal_count = 0
|
||||
|
||||
state = self.get_state()
|
||||
|
||||
state.adaptive_size_base = None
|
||||
state.adaptive_size_final = None
|
||||
state.adaptive_size_multiplier = None
|
||||
state.adaptive_size_reason = None
|
||||
state.adaptive_size_factors = None
|
||||
state.effective_risk_percent = None
|
||||
state.effective_target_risk_usd = None
|
||||
|
||||
state.last_signal_repeat_count = 0
|
||||
state.last_signal_confidence = 0.0
|
||||
state.last_signal_reason = None
|
||||
state.decision_status = "WAITING"
|
||||
state.decision_reason = None
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
state.signal_confirmation_seconds = 0
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||
state.signal_confirmation_progress = 0.0
|
||||
state.signal_confirmation_reason = None
|
||||
state.signal_started_at = None
|
||||
state.signal_updated_at = None
|
||||
|
||||
state.execution_block_reason = None
|
||||
state.execution_semantic_status = None
|
||||
state.execution_semantic_message = None
|
||||
state.execution_semantic_reason = None
|
||||
state.execution_quality = None
|
||||
state.execution_quality_reason = None
|
||||
state.execution_quality_message = None
|
||||
state.execution_price_source = None
|
||||
state.execution_price_age_seconds = None
|
||||
state.execution_bid_price = None
|
||||
state.execution_ask_price = None
|
||||
state.execution_last_price = None
|
||||
state.execution_price_freshness = None
|
||||
state.execution_confidence_score = None
|
||||
state.execution_confidence_level = None
|
||||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
||||
state.execution_confidence_reason = None
|
||||
state.execution_confidence_factors = None
|
||||
|
||||
state.market_state = None
|
||||
state.market_trend = None
|
||||
state.market_volatility = None
|
||||
state.market_analysis_interval = None
|
||||
state.market_analysis_reason = None
|
||||
state.market_analysis_updated_at = None
|
||||
state.market_runtime_degraded = False
|
||||
state.market_trend_strength = None
|
||||
state.market_trend_quality = None
|
||||
state.market_phase = None
|
||||
state.market_phase_direction = None
|
||||
|
||||
state.market_trend_gap_percent = None
|
||||
state.market_trend_consistency = None
|
||||
state.market_trend_efficiency = None
|
||||
state.trend_quality_score = None
|
||||
state.ema_distance_atr_ratio = None
|
||||
state.ema_distance_state = None
|
||||
state.entry_timing_state = None
|
||||
state.entry_timing_reason = None
|
||||
state.ema_fast_slope_percent = None
|
||||
state.ema_slow_slope_percent = None
|
||||
state.candle_noise_score = None
|
||||
state.price_position_score = None
|
||||
|
||||
state.htf_interval = None
|
||||
state.htf_atr_percent = None
|
||||
state.htf_atr_percent_baseline = None
|
||||
state.htf_volatility_ratio = None
|
||||
state.htf_volatility = None
|
||||
|
||||
state.entry_block_reason = None
|
||||
state.entry_block_message = None
|
||||
|
||||
state.momentum_state = None
|
||||
state.momentum_direction = None
|
||||
state.momentum_change_percent = None
|
||||
state.momentum_strength = None
|
||||
state.breakout_level = None
|
||||
state.breakout_distance_percent = None
|
||||
state.breakout_reason = None
|
||||
|
||||
state.runtime_expired_reason = None
|
||||
state.runtime_expired_message = None
|
||||
state.snapshot_age_seconds = None
|
||||
state.spread_percent = None
|
||||
|
||||
state.position_pnl_percent = None
|
||||
state.position_hold_seconds = None
|
||||
state.position_pressure = None
|
||||
state.position_health_score = None
|
||||
state.position_health_status = None
|
||||
state.position_health_reason = None
|
||||
state.position_risk_level = None
|
||||
state.position_risk_reason = None
|
||||
state.position_trend_alignment = None
|
||||
state.position_adverse_momentum = False
|
||||
state.position_exit_pressure = None
|
||||
|
||||
state.position_lifecycle_stage = None
|
||||
state.position_hold_quality = None
|
||||
state.position_decay_state = None
|
||||
state.position_exit_confidence = None
|
||||
state.position_exit_signal = None
|
||||
state.position_intelligence_reason = None
|
||||
state.position_recommended_action = None
|
||||
|
||||
state.position_peak_pnl_usd = None
|
||||
state.position_peak_pnl_percent = None
|
||||
state.position_mfe_percent = None
|
||||
state.position_mae_percent = None
|
||||
state.position_fatigue_score = None
|
||||
state.position_fatigue_state = None
|
||||
state.position_giveback_percent = None
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = None
|
||||
|
||||
state.autonomous_action = None
|
||||
state.autonomous_action_reason = None
|
||||
state.autonomous_action_confidence = None
|
||||
state.autonomous_protection_required = False
|
||||
state.autonomous_reduce_required = False
|
||||
state.autonomous_exit_required = False
|
||||
state.autonomous_last_action = None
|
||||
state.autonomous_last_action_reason = None
|
||||
state.autonomous_last_action_at = None
|
||||
|
||||
state.last_loss_monotonic_at = None
|
||||
|
||||
# собрать контекст для стратегии
|
||||
def _build_strategy_context(self) -> StrategyContext:
|
||||
state = self.get_state()
|
||||
|
||||
return StrategyContext(
|
||||
symbol=state.symbol,
|
||||
status=state.status,
|
||||
risk_percent=state.risk_percent,
|
||||
)
|
||||
|
||||
# получить стратегию для текущего цикла
|
||||
def _get_strategy(self) -> BaseStrategy:
|
||||
state = self.get_state()
|
||||
return StrategyRegistry.get(state.strategy)
|
||||
|
||||
# выполнить один полный runtime cycle автоторговли
|
||||
def run_cycle(self) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
|
||||
if state.status == "OFF":
|
||||
return state
|
||||
|
||||
if not self._sync_market_availability_state(state):
|
||||
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
||||
self._sync_execution_semantic_state(state)
|
||||
return state
|
||||
|
||||
self._expire_runtime_if_needed(state)
|
||||
|
||||
strategy = self._get_strategy()
|
||||
context = self._build_strategy_context()
|
||||
result = strategy.analyze(context)
|
||||
|
||||
self._sync_market_analysis_state(
|
||||
state=state,
|
||||
payload=result.payload,
|
||||
)
|
||||
|
||||
self._sync_execution_quality_state(state)
|
||||
|
||||
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
self._log_signal_if_changed(
|
||||
strategy_name=strategy.name,
|
||||
state=state,
|
||||
signal=result.signal.value,
|
||||
reason=result.reason,
|
||||
confidence=result.confidence,
|
||||
payload=result.payload,
|
||||
)
|
||||
|
||||
if state.execution_quality != "BLOCKED":
|
||||
ExecutionEngine().process(state)
|
||||
|
||||
self._sync_position_health_state(state)
|
||||
self._sync_position_intelligence_state(state)
|
||||
self._sync_autonomous_trade_management(state)
|
||||
|
||||
if state.execution_quality != "BLOCKED":
|
||||
ExecutionEngine().process_runtime_action(state)
|
||||
|
||||
self._sync_execution_semantic_state(state)
|
||||
|
||||
return state
|
||||
69
app/src/trading/auto/autonomous_management.py
Normal file
69
app/src/trading/auto/autonomous_management.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# app/src/trading/auto/autonomous_management.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
class AutoAutonomousManagementMixin:
|
||||
# синхронизировать автономное управление открытой позицией
|
||||
def _sync_autonomous_trade_management(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
if state.position_side == "NONE":
|
||||
state.autonomous_action = None
|
||||
state.autonomous_action_reason = None
|
||||
state.autonomous_action_confidence = None
|
||||
state.autonomous_protection_required = False
|
||||
state.autonomous_reduce_required = False
|
||||
state.autonomous_exit_required = False
|
||||
return
|
||||
|
||||
exit_signal = str(state.position_exit_signal or "HOLD").upper()
|
||||
exit_confidence = safe_float(state.position_exit_confidence) or 0.0
|
||||
|
||||
action = "HOLD"
|
||||
reason = "позиция удерживается"
|
||||
|
||||
protect_required = False
|
||||
reduce_required = False
|
||||
exit_required = False
|
||||
|
||||
if exit_signal == "WATCH":
|
||||
action = "WATCH"
|
||||
reason = "позиция требует наблюдения"
|
||||
|
||||
elif exit_signal == "REDUCE_OR_PROTECT":
|
||||
if state.position_pressure in {"HIGH_LOSS", "LOSS"}:
|
||||
action = "REDUCE"
|
||||
reduce_required = True
|
||||
reason = "позиция должна быть уменьшена"
|
||||
else:
|
||||
action = "PROTECT"
|
||||
protect_required = True
|
||||
reason = "позиция требует защиты"
|
||||
|
||||
elif exit_signal == "EXIT":
|
||||
action = "EXIT"
|
||||
exit_required = True
|
||||
reason = "позиция требует закрытия"
|
||||
|
||||
if (
|
||||
state.position_adverse_momentum
|
||||
and state.position_trend_alignment == "AGAINST"
|
||||
and exit_confidence >= 0.65
|
||||
):
|
||||
action = "EXIT"
|
||||
exit_required = True
|
||||
reduce_required = False
|
||||
protect_required = False
|
||||
reason = "рынок агрессивно движется против позиции"
|
||||
|
||||
state.autonomous_action = action
|
||||
state.autonomous_action_reason = reason
|
||||
state.autonomous_action_confidence = exit_confidence
|
||||
state.autonomous_protection_required = protect_required
|
||||
state.autonomous_reduce_required = reduce_required
|
||||
state.autonomous_exit_required = exit_required
|
||||
488
app/src/trading/auto/execution_quality.py
Normal file
488
app/src/trading/auto/execution_quality.py
Normal file
@@ -0,0 +1,488 @@
|
||||
# app/src/trading/auto/execution_quality.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
)
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
class AutoExecutionQualityMixin:
|
||||
_spread_thresholds_by_asset: dict[str, dict[str, float]]
|
||||
_default_spread_thresholds: dict[str, float]
|
||||
|
||||
_max_snapshot_age_seconds: float
|
||||
_warning_snapshot_age_seconds: float
|
||||
|
||||
_last_logged_execution_quality_key: str | None
|
||||
|
||||
# получить базовый asset из symbol для spread thresholds
|
||||
def _asset_symbol(self, symbol: str | None) -> str:
|
||||
if not symbol:
|
||||
return ""
|
||||
|
||||
base = str(symbol).split("_", 1)[0].upper()
|
||||
|
||||
if "/" in base:
|
||||
return base.split("/", 1)[0]
|
||||
|
||||
for suffix in ("USDT", "USD", "EUR", "BTC"):
|
||||
if base.endswith(suffix) and len(base) > len(suffix):
|
||||
return base[: -len(suffix)]
|
||||
|
||||
return base
|
||||
|
||||
# получить spread thresholds для конкретного инструмента
|
||||
def _spread_thresholds(self, symbol: str | None) -> dict[str, float]:
|
||||
asset = self._asset_symbol(symbol)
|
||||
|
||||
return self._spread_thresholds_by_asset.get(
|
||||
asset,
|
||||
self._default_spread_thresholds,
|
||||
)
|
||||
|
||||
# синхронизировать единый статус биржи/торговой сессии в AutoTradeState
|
||||
def _sync_market_availability_state(self, state: AutoTradeState) -> bool:
|
||||
try:
|
||||
status = ExchangeService().get_symbol_runtime_status(state.symbol)
|
||||
except Exception as exc:
|
||||
status = build_exchange_error_status(exc)
|
||||
|
||||
state.market_is_open = status.is_open
|
||||
state.market_status = status.code.value
|
||||
state.market_status_message = status.ui_line
|
||||
state.market_status_updated_at = time.monotonic()
|
||||
|
||||
if status.is_open:
|
||||
self._clear_exchange_block_state(state)
|
||||
return True
|
||||
|
||||
self._apply_exchange_block_state(
|
||||
state=state,
|
||||
status=status,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
# очистить старую блокировку биржи, если рынок снова доступен
|
||||
def _clear_exchange_block_state(self, state: AutoTradeState) -> None:
|
||||
if state.execution_quality_reason not in {
|
||||
"MARKET_BREAK",
|
||||
"EXCHANGE_UNAVAILABLE",
|
||||
"AUTH_ERROR",
|
||||
"TIME_ERROR",
|
||||
"INVALID_SYMBOL",
|
||||
"MARKET_CLOSED",
|
||||
}:
|
||||
return
|
||||
|
||||
state.execution_quality = None
|
||||
state.execution_quality_reason = None
|
||||
state.execution_quality_message = None
|
||||
state.execution_block_reason = None
|
||||
state.market_runtime_degraded = False
|
||||
|
||||
state.entry_block_reason = None
|
||||
state.entry_block_message = None
|
||||
|
||||
# применить блокировку execution по единому ExchangeRuntimeStatus
|
||||
def _apply_exchange_block_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
status: ExchangeRuntimeStatus,
|
||||
) -> None:
|
||||
reason = self._exchange_execution_reason(status)
|
||||
message = status.ui_line or status.message
|
||||
|
||||
state.execution_quality = "BLOCKED"
|
||||
state.execution_quality_reason = reason
|
||||
state.execution_quality_message = message
|
||||
state.execution_block_reason = message
|
||||
state.market_runtime_degraded = True
|
||||
|
||||
state.entry_block_reason = reason
|
||||
state.entry_block_message = message
|
||||
|
||||
state.decision_status = "WAITING"
|
||||
state.decision_reason = message
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
|
||||
self._log_exchange_availability_if_changed(
|
||||
state=state,
|
||||
status=status,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# преобразовать typed exchange status в код причины execution layer
|
||||
def _exchange_execution_reason(self, status: ExchangeRuntimeStatus) -> str:
|
||||
if status.code == ExchangeStatusCode.BREAK:
|
||||
return "MARKET_BREAK"
|
||||
|
||||
if status.code == ExchangeStatusCode.AUTH_ERROR:
|
||||
return "AUTH_ERROR"
|
||||
|
||||
if status.code == ExchangeStatusCode.TIME_ERROR:
|
||||
return "TIME_ERROR"
|
||||
|
||||
if status.code == ExchangeStatusCode.INVALID_SYMBOL:
|
||||
return "INVALID_SYMBOL"
|
||||
|
||||
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
|
||||
return "EXCHANGE_UNAVAILABLE"
|
||||
|
||||
return "MARKET_BREAK"
|
||||
|
||||
# залогировать изменение доступности биржи/рынка
|
||||
def _log_exchange_availability_if_changed(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
status: ExchangeRuntimeStatus,
|
||||
reason: str,
|
||||
) -> None:
|
||||
key = (
|
||||
f"{state.status}:{state.symbol}:{state.strategy}:"
|
||||
f"{status.code.value}:{reason}:{status.ui_line}"
|
||||
)
|
||||
|
||||
if key == type(self)._last_logged_execution_quality_key:
|
||||
return
|
||||
|
||||
type(self)._last_logged_execution_quality_key = key
|
||||
|
||||
try:
|
||||
JournalService().log_ui_warning(
|
||||
event_type="exchange_availability_changed",
|
||||
message=status.ui_line,
|
||||
screen="auto",
|
||||
action="exchange_status",
|
||||
payload={
|
||||
"status": state.status,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"exchange_status_code": status.code.value,
|
||||
"exchange_reason": status.reason,
|
||||
"execution_reason": reason,
|
||||
"is_open": status.is_open,
|
||||
"is_available": status.is_available,
|
||||
"is_auth_ok": status.is_auth_ok,
|
||||
"message": status.message,
|
||||
"raw_status": status.raw_status,
|
||||
"raw_error": status.raw_error,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# рассчитать качество исполнения на основе spread
|
||||
def _spread_execution_quality(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
spread_percent: NumericLike | None,
|
||||
) -> tuple[str | None, str | None, str | None, bool]:
|
||||
spread = safe_float(spread_percent)
|
||||
|
||||
if spread is None:
|
||||
return None, None, None, False
|
||||
|
||||
thresholds = self._spread_thresholds(state.symbol)
|
||||
|
||||
warning_enter = thresholds["warning_enter"]
|
||||
warning_exit = thresholds["warning_exit"]
|
||||
block_enter = thresholds["block_enter"]
|
||||
block_exit = thresholds["block_exit"]
|
||||
|
||||
previous_quality = state.execution_quality
|
||||
previous_reason = state.execution_quality_reason
|
||||
|
||||
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
|
||||
if spread > block_exit:
|
||||
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
|
||||
|
||||
if spread > warning_exit:
|
||||
return "WARNING", "WIDE_SPREAD", "spread повышен", False
|
||||
|
||||
return "GOOD", "MARKET_OK", "рынок готов", False
|
||||
|
||||
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
|
||||
if spread >= block_enter:
|
||||
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
|
||||
|
||||
if spread > warning_exit:
|
||||
return "WARNING", "WIDE_SPREAD", "spread повышен", False
|
||||
|
||||
return "GOOD", "MARKET_OK", "рынок готов", False
|
||||
|
||||
if spread >= block_enter:
|
||||
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
|
||||
|
||||
if spread >= warning_enter:
|
||||
return "WARNING", "WIDE_SPREAD", "spread повышен", False
|
||||
|
||||
return "GOOD", "MARKET_OK", "рынок готов", False
|
||||
|
||||
# синхронизировать runtime quality исполнения
|
||||
def _sync_execution_quality_state(self, state: AutoTradeState) -> None:
|
||||
try:
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception as exc:
|
||||
fallback_price = None
|
||||
|
||||
try:
|
||||
fallback_price = safe_float(
|
||||
ExchangeService().get_price(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
).price
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
state.snapshot_age_seconds = None
|
||||
state.spread_percent = None
|
||||
|
||||
if fallback_price is not None and fallback_price > 0:
|
||||
state.execution_quality = "WARNING"
|
||||
state.execution_quality_reason = "SNAPSHOT_UNAVAILABLE"
|
||||
state.execution_quality_message = "нет depth snapshot"
|
||||
state.market_runtime_degraded = True
|
||||
else:
|
||||
status = build_exchange_error_status(exc)
|
||||
self._apply_exchange_block_state(
|
||||
state=state,
|
||||
status=status,
|
||||
)
|
||||
|
||||
self._log_execution_quality_if_changed(
|
||||
state=state,
|
||||
payload={
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"fallback_price_available": fallback_price is not None,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
bid_price = safe_float(snapshot.get("bid_price"))
|
||||
ask_price = safe_float(snapshot.get("ask_price"))
|
||||
last_price = safe_float(snapshot.get("last_price"))
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
is_fresh = bool(snapshot.get("is_fresh", False))
|
||||
source = str(snapshot.get("source") or "")
|
||||
|
||||
self._sync_execution_pricing_state(
|
||||
state,
|
||||
snapshot,
|
||||
)
|
||||
|
||||
state.snapshot_age_seconds = age_seconds
|
||||
state.spread_percent = self._spread_percent(
|
||||
bid_price=bid_price,
|
||||
ask_price=ask_price,
|
||||
)
|
||||
|
||||
if age_seconds is not None and age_seconds > self._max_snapshot_age_seconds:
|
||||
state.execution_quality = "BLOCKED"
|
||||
state.execution_quality_reason = "STALE_SNAPSHOT"
|
||||
state.execution_quality_message = "snapshot устарел"
|
||||
state.market_runtime_degraded = True
|
||||
|
||||
elif age_seconds is not None and age_seconds > self._warning_snapshot_age_seconds:
|
||||
state.execution_quality = "WARNING"
|
||||
state.execution_quality_reason = "AGING_SNAPSHOT"
|
||||
state.execution_quality_message = "snapshot стареет"
|
||||
state.market_runtime_degraded = not is_fresh
|
||||
|
||||
elif state.spread_percent is not None:
|
||||
(
|
||||
state.execution_quality,
|
||||
state.execution_quality_reason,
|
||||
state.execution_quality_message,
|
||||
state.market_runtime_degraded,
|
||||
) = self._spread_execution_quality(
|
||||
state=state,
|
||||
spread_percent=state.spread_percent,
|
||||
)
|
||||
|
||||
else:
|
||||
state.execution_quality = "GOOD"
|
||||
state.execution_quality_reason = "MARKET_OK"
|
||||
state.execution_quality_message = "рынок готов"
|
||||
state.market_runtime_degraded = False
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
state.execution_block_reason = state.execution_quality_message
|
||||
|
||||
elif state.execution_block_reason == state.execution_quality_message:
|
||||
state.execution_block_reason = None
|
||||
|
||||
spread_thresholds = self._spread_thresholds(state.symbol)
|
||||
|
||||
self._log_execution_quality_if_changed(
|
||||
state=state,
|
||||
payload={
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"bid_price": bid_price,
|
||||
"ask_price": ask_price,
|
||||
"last_price": last_price,
|
||||
"snapshot_age_seconds": age_seconds,
|
||||
"spread_percent": state.spread_percent,
|
||||
"is_fresh": is_fresh,
|
||||
"source": source,
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"execution_quality_message": state.execution_quality_message,
|
||||
"market_runtime_degraded": state.market_runtime_degraded,
|
||||
"max_snapshot_age_seconds": self._max_snapshot_age_seconds,
|
||||
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
|
||||
"spread_asset": self._asset_symbol(state.symbol),
|
||||
"spread_warning_enter_percent": spread_thresholds["warning_enter"],
|
||||
"spread_warning_exit_percent": spread_thresholds["warning_exit"],
|
||||
"spread_block_enter_percent": spread_thresholds["block_enter"],
|
||||
"spread_block_exit_percent": spread_thresholds["block_exit"],
|
||||
},
|
||||
)
|
||||
|
||||
# рассчитать spread между bid/ask в процентах
|
||||
def _spread_percent(
|
||||
self,
|
||||
*,
|
||||
bid_price: NumericLike | None,
|
||||
ask_price: NumericLike | None,
|
||||
) -> float | None:
|
||||
bid = safe_float(bid_price)
|
||||
ask = safe_float(ask_price)
|
||||
|
||||
if bid is None or ask is None:
|
||||
return None
|
||||
|
||||
if bid <= 0 or ask <= 0:
|
||||
return None
|
||||
|
||||
mid_price = (bid + ask) / 2
|
||||
|
||||
if mid_price <= 0:
|
||||
return None
|
||||
|
||||
spread = ask - bid
|
||||
|
||||
if spread < 0:
|
||||
return None
|
||||
|
||||
return round((spread / mid_price) * 100, 5)
|
||||
|
||||
# синхронизировать execution pricing данные в state
|
||||
def _sync_execution_pricing_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
snapshot: dict[str, object],
|
||||
) -> None:
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
|
||||
state.execution_price_source = str(snapshot.get("source") or "")
|
||||
state.execution_price_age_seconds = age_seconds
|
||||
state.execution_bid_price = safe_float(snapshot.get("bid_price"))
|
||||
state.execution_ask_price = safe_float(snapshot.get("ask_price"))
|
||||
state.execution_last_price = safe_float(snapshot.get("last_price"))
|
||||
|
||||
if age_seconds is None:
|
||||
state.execution_price_freshness = "UNKNOWN"
|
||||
elif age_seconds <= 1:
|
||||
state.execution_price_freshness = "FRESH"
|
||||
elif age_seconds <= self._warning_snapshot_age_seconds:
|
||||
state.execution_price_freshness = "AGING"
|
||||
else:
|
||||
state.execution_price_freshness = "STALE"
|
||||
|
||||
# записать событие изменения execution quality
|
||||
def _log_execution_quality_if_changed(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
payload: dict[str, object],
|
||||
) -> None:
|
||||
quality = state.execution_quality
|
||||
reason = state.execution_quality_reason
|
||||
message = state.execution_quality_message
|
||||
|
||||
if not quality or not reason or not message:
|
||||
return
|
||||
|
||||
key = f"{state.status}:{state.symbol}:{state.strategy}:{quality}:{reason}:{message}"
|
||||
|
||||
if key == type(self)._last_logged_execution_quality_key:
|
||||
return
|
||||
|
||||
type(self)._last_logged_execution_quality_key = key
|
||||
|
||||
if quality == "GOOD":
|
||||
return
|
||||
|
||||
try:
|
||||
log_payload = {
|
||||
**payload,
|
||||
"status": state.status,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
}
|
||||
|
||||
if quality == "BLOCKED":
|
||||
JournalService().log_ui_warning(
|
||||
event_type="execution_quality_changed",
|
||||
message=f"Качество исполнения: {message}.",
|
||||
screen="auto",
|
||||
action="execution_quality",
|
||||
payload=log_payload,
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="execution_quality_changed",
|
||||
message=f"Качество исполнения: {message}.",
|
||||
screen="auto",
|
||||
action="execution_quality",
|
||||
payload=log_payload,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# рассчитать confidence execution quality для общего execution confidence
|
||||
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
|
||||
quality = state.execution_quality
|
||||
reason = state.execution_quality_reason
|
||||
|
||||
if quality == "GOOD":
|
||||
return 1.0
|
||||
|
||||
if quality == "WARNING":
|
||||
if reason == "WIDE_SPREAD":
|
||||
return 0.65
|
||||
|
||||
if reason == "AGING_SNAPSHOT":
|
||||
return 0.6
|
||||
|
||||
if reason == "SNAPSHOT_UNAVAILABLE":
|
||||
return 0.55
|
||||
|
||||
return 0.6
|
||||
|
||||
if quality == "BLOCKED":
|
||||
return 0.0
|
||||
|
||||
return 0.5
|
||||
120
app/src/trading/auto/execution_semantic.py
Normal file
120
app/src/trading/auto/execution_semantic.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# app/src/trading/auto/execution_semantic.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeStatusCode,
|
||||
is_exchange_status_reason,
|
||||
)
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
class AutoExecutionSemanticMixin:
|
||||
_execution_confidence_required_score: float
|
||||
|
||||
# синхронизировать semantic-статус execution слоя для UI
|
||||
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
|
||||
if state.execution_quality == "BLOCKED":
|
||||
state.execution_semantic_status = "BLOCKED"
|
||||
state.execution_semantic_message = self._execution_block_semantic_message(state)
|
||||
state.execution_semantic_reason = state.execution_quality_reason
|
||||
return
|
||||
|
||||
if state.decision_status == "BLOCKED":
|
||||
state.execution_semantic_status = "BLOCKED"
|
||||
|
||||
if (
|
||||
state.execution_confidence_score is not None
|
||||
and state.execution_confidence_score < self._execution_confidence_required_score
|
||||
):
|
||||
state.execution_semantic_message = "⛔ Исполнение · низкая уверенность"
|
||||
state.execution_semantic_reason = state.execution_confidence_reason
|
||||
return
|
||||
|
||||
state.execution_semantic_message = "⛔ Исполнение · сигнал заблокирован"
|
||||
state.execution_semantic_reason = state.decision_reason
|
||||
return
|
||||
|
||||
if state.position_side != "NONE":
|
||||
state.execution_semantic_status = "POSITION_OPEN"
|
||||
state.execution_semantic_message = "📌 Исполнение · позиция открыта"
|
||||
state.execution_semantic_reason = state.last_execution_reason
|
||||
return
|
||||
|
||||
if state.decision_status == "READY" and state.is_signal_ready:
|
||||
state.execution_semantic_status = "READY"
|
||||
state.execution_semantic_message = "✅ Исполнение · готово"
|
||||
state.execution_semantic_reason = state.decision_reason
|
||||
return
|
||||
|
||||
if state.decision_status == "CONFIRMING":
|
||||
state.execution_semantic_status = "WAITING_SIGNAL"
|
||||
state.execution_semantic_message = "⏳ Исполнение · ждёт подтверждения"
|
||||
state.execution_semantic_reason = state.decision_reason
|
||||
return
|
||||
|
||||
if state.last_signal in {"BUY", "SELL"}:
|
||||
state.execution_semantic_status = "WAITING_SIGNAL"
|
||||
state.execution_semantic_message = "⏳ Исполнение · сигнал проверяется"
|
||||
state.execution_semantic_reason = state.decision_reason
|
||||
return
|
||||
|
||||
state.execution_semantic_status = "IDLE"
|
||||
state.execution_semantic_message = ""
|
||||
state.execution_semantic_reason = state.decision_reason
|
||||
|
||||
# вернуть человекочитаемое сообщение блокировки execution слоя
|
||||
def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
|
||||
reason = str(state.execution_quality_reason or "")
|
||||
message = str(state.execution_quality_message or "")
|
||||
|
||||
if self._is_exchange_unavailable(reason):
|
||||
return "⛔ Исполнение · биржа недоступна"
|
||||
|
||||
if self._is_exchange_break(reason):
|
||||
return "⏸️ Исполнение · перерыв на бирже"
|
||||
|
||||
if self._is_auth_error(reason):
|
||||
return "⛔ Исполнение · неверный API Key"
|
||||
|
||||
if reason == "STALE_SNAPSHOT":
|
||||
return "⛔ Исполнение · рынок неактуален"
|
||||
|
||||
if reason == "HIGH_SPREAD":
|
||||
return "⛔ Исполнение · высокий spread"
|
||||
|
||||
if reason == "SNAPSHOT_ERROR":
|
||||
return "⛔ Исполнение · нет данных рынка"
|
||||
|
||||
if reason == "SNAPSHOT_UNAVAILABLE":
|
||||
return "⚠️ Исполнение · нет стакана"
|
||||
|
||||
if message:
|
||||
return f"⛔ Исполнение · {message}"
|
||||
|
||||
return "⛔ Исполнение · заблокировано"
|
||||
|
||||
# проверить, что блокировка пришла из единого exchange status layer
|
||||
def _is_exchange_unavailable(self, reason: str) -> bool:
|
||||
return (
|
||||
is_exchange_status_reason(reason)
|
||||
and reason
|
||||
in {
|
||||
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value,
|
||||
ExchangeStatusCode.TIME_ERROR.value,
|
||||
}
|
||||
)
|
||||
|
||||
# проверить, что причина блокировки — торговый перерыв, а не ошибка доступа
|
||||
def _is_exchange_break(self, reason: str) -> bool:
|
||||
return (
|
||||
is_exchange_status_reason(reason)
|
||||
and reason == ExchangeStatusCode.BREAK.value
|
||||
)
|
||||
|
||||
# проверить ошибку приватного доступа / API key
|
||||
def _is_auth_error(self, reason: str) -> bool:
|
||||
return (
|
||||
is_exchange_status_reason(reason)
|
||||
and reason == ExchangeStatusCode.AUTH_ERROR.value
|
||||
)
|
||||
274
app/src/trading/auto/market_runtime.py
Normal file
274
app/src/trading/auto/market_runtime.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# app/src/trading/auto/market_runtime.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
class AutoMarketRuntimeMixin:
|
||||
_last_logged_market_state: str | None
|
||||
_last_logged_market_trend: str | None
|
||||
_last_logged_market_volatility: str | None
|
||||
_last_logged_entry_block_reason: str | None
|
||||
_last_logged_entry_block_at: float | None = None
|
||||
_entry_block_log_ttl_seconds: int = 900
|
||||
|
||||
# синхронизировать market analysis payload в AutoTradeState
|
||||
def _sync_market_analysis_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
payload: JsonDict | None,
|
||||
) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
previous_market_state = state.market_state
|
||||
previous_market_trend = state.market_trend
|
||||
previous_market_volatility = state.market_volatility
|
||||
|
||||
state.market_state = str(payload.get("market_state") or "")
|
||||
state.market_trend = str(payload.get("trend") or payload.get("market_trend") or "")
|
||||
state.market_volatility = str(payload.get("volatility") or payload.get("market_volatility") or "")
|
||||
state.market_trend_strength = str(payload.get("market_trend_strength") or "")
|
||||
state.market_trend_quality = str(payload.get("market_trend_quality") or "")
|
||||
state.market_phase = str(payload.get("market_phase") or "")
|
||||
state.market_phase_direction = str(payload.get("market_phase_direction") or "")
|
||||
state.market_trend_gap_percent = safe_float(payload.get("market_trend_gap_percent"))
|
||||
state.market_trend_consistency = safe_float(payload.get("market_trend_consistency"))
|
||||
state.market_trend_efficiency = safe_float(payload.get("market_trend_efficiency"))
|
||||
state.trend_quality_score = safe_float(payload.get("trend_quality_score"))
|
||||
state.ema_distance_atr_ratio = safe_float(payload.get("ema_distance_atr_ratio"))
|
||||
state.ema_distance_state = str(payload.get("ema_distance_state") or "")
|
||||
state.entry_timing_state = str(payload.get("entry_timing_state") or "")
|
||||
state.entry_timing_reason = str(payload.get("entry_timing_reason") or "")
|
||||
state.ema_fast_slope_percent = safe_float(payload.get("ema_fast_slope_percent"))
|
||||
state.ema_slow_slope_percent = safe_float(payload.get("ema_slow_slope_percent"))
|
||||
state.candle_noise_score = safe_float(payload.get("candle_noise_score"))
|
||||
state.price_position_score = safe_float(payload.get("price_position_score"))
|
||||
state.htf_interval = str(payload.get("htf_interval") or "")
|
||||
state.htf_atr_percent = safe_float(payload.get("htf_atr_percent"))
|
||||
state.htf_atr_percent_baseline = safe_float(payload.get("htf_atr_percent_baseline"))
|
||||
state.htf_volatility_ratio = safe_float(payload.get("htf_volatility_ratio"))
|
||||
state.htf_volatility = str(payload.get("htf_volatility") or "")
|
||||
state.market_analysis_interval = str(payload.get("interval") or payload.get("market_analysis_interval") or "")
|
||||
state.market_analysis_reason = str(payload.get("reason") or payload.get("market_analysis_reason") or "")
|
||||
state.momentum_state = str(payload.get("momentum_state") or "")
|
||||
state.momentum_direction = str(payload.get("momentum_direction") or "")
|
||||
state.momentum_change_percent = safe_float(payload.get("momentum_change_percent"))
|
||||
state.momentum_strength = safe_float(payload.get("momentum_strength"))
|
||||
state.breakout_level = safe_float(payload.get("breakout_level"))
|
||||
state.breakout_distance_percent = safe_float(payload.get("breakout_distance_percent"))
|
||||
state.breakout_reason = str(payload.get("breakout_reason") or "")
|
||||
state.entry_block_reason = str(payload.get("entry_block_reason") or "")
|
||||
state.entry_block_message = str(payload.get("entry_block_message") or "")
|
||||
|
||||
self._log_market_state_if_changed(
|
||||
state=state,
|
||||
payload=payload,
|
||||
previous_market_state=previous_market_state,
|
||||
previous_market_trend=previous_market_trend,
|
||||
previous_market_volatility=previous_market_volatility,
|
||||
)
|
||||
|
||||
self._log_entry_block_if_changed(
|
||||
state=state,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# записать entry-block событие, если причина изменилась или истёк TTL
|
||||
def _log_entry_block_if_changed(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
payload: JsonDict,
|
||||
) -> None:
|
||||
reason = state.entry_block_reason
|
||||
message = state.entry_block_message
|
||||
|
||||
if not reason or not message:
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
# status специально не входит в key:
|
||||
# RUNNING / OBSERVING не должны создавать дубли одной и той же причины.
|
||||
key = f"{state.symbol}:{state.strategy}:{reason}:{message}"
|
||||
|
||||
last_logged_at = type(self)._last_logged_entry_block_at
|
||||
ttl_expired = (
|
||||
last_logged_at is None
|
||||
or now - last_logged_at >= type(self)._entry_block_log_ttl_seconds
|
||||
)
|
||||
|
||||
if (
|
||||
key == type(self)._last_logged_entry_block_reason
|
||||
and not ttl_expired
|
||||
):
|
||||
return
|
||||
|
||||
type(self)._last_logged_entry_block_reason = key
|
||||
type(self)._last_logged_entry_block_at = now
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
event_type="entry_blocked",
|
||||
message=f"Вход в позицию не выполнен: {message}.",
|
||||
screen="auto",
|
||||
action="entry_diagnostics",
|
||||
payload={
|
||||
**payload,
|
||||
"entry_block_reason": reason,
|
||||
"entry_block_message": message,
|
||||
"entry_block_key": key,
|
||||
"entry_block_ttl_seconds": type(self)._entry_block_log_ttl_seconds,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"status": state.status,
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_trend_strength": state.market_trend_strength,
|
||||
"market_trend_quality": state.market_trend_quality,
|
||||
"market_phase": state.market_phase,
|
||||
"market_phase_direction": state.market_phase_direction,
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"momentum_strength": state.momentum_strength,
|
||||
"momentum_change_percent": state.momentum_change_percent,
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"last_signal": state.last_signal,
|
||||
"last_signal_confidence": state.last_signal_confidence,
|
||||
"last_signal_reason": state.last_signal_reason,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# записать market state / volatility событие, если состояние изменилось
|
||||
def _log_market_state_if_changed(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
payload: JsonDict,
|
||||
previous_market_state: str | None,
|
||||
previous_market_trend: str | None,
|
||||
previous_market_volatility: str | None,
|
||||
) -> None:
|
||||
market_state = state.market_state
|
||||
market_trend = state.market_trend
|
||||
market_volatility = state.market_volatility
|
||||
|
||||
if not market_state or market_state == "UNKNOWN":
|
||||
return
|
||||
|
||||
state_changed = (
|
||||
market_state != previous_market_state
|
||||
and market_state != type(self)._last_logged_market_state
|
||||
)
|
||||
|
||||
volatility_changed = (
|
||||
market_volatility is not None
|
||||
and market_volatility != previous_market_volatility
|
||||
and market_volatility != type(self)._last_logged_market_volatility
|
||||
)
|
||||
|
||||
if not state_changed and not volatility_changed:
|
||||
return
|
||||
|
||||
journal_payload = {
|
||||
**payload,
|
||||
"previous_market_state": previous_market_state,
|
||||
"previous_market_trend": previous_market_trend,
|
||||
"previous_market_volatility": previous_market_volatility,
|
||||
"current_market_state": market_state,
|
||||
"current_market_trend": market_trend,
|
||||
"current_market_volatility": market_volatility,
|
||||
}
|
||||
|
||||
try:
|
||||
if state_changed:
|
||||
self._write_market_journal_event(
|
||||
event_type="market_state_changed",
|
||||
market_state=market_state,
|
||||
message=self._market_state_message(market_state),
|
||||
payload=journal_payload,
|
||||
)
|
||||
|
||||
if volatility_changed:
|
||||
self._write_market_journal_event(
|
||||
event_type="market_volatility_changed",
|
||||
market_state=market_state,
|
||||
message=self._market_volatility_message(market_volatility),
|
||||
payload=journal_payload,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
type(self)._last_logged_market_state = market_state
|
||||
type(self)._last_logged_market_trend = market_trend
|
||||
type(self)._last_logged_market_volatility = market_volatility
|
||||
|
||||
# записать market journal событие с нужным уровнем важности
|
||||
def _write_market_journal_event(
|
||||
self,
|
||||
*,
|
||||
event_type: str,
|
||||
market_state: str,
|
||||
message: str,
|
||||
payload: JsonDict,
|
||||
) -> None:
|
||||
level = self._market_journal_level(market_state)
|
||||
|
||||
if level == "WARNING":
|
||||
JournalService().log_ui_warning(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
screen="auto",
|
||||
action="market_analysis",
|
||||
payload=payload,
|
||||
)
|
||||
return
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
screen="auto",
|
||||
action="market_analysis",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# получить человекочитаемое сообщение по volatility
|
||||
def _market_volatility_message(self, market_volatility: str | None) -> str:
|
||||
messages = {
|
||||
"LOW": "Волатильность изменена: низкая.",
|
||||
"NORMAL": "Волатильность изменена: нормальная.",
|
||||
"HIGH": "Волатильность изменена: высокая.",
|
||||
}
|
||||
|
||||
return messages.get(str(market_volatility or ""), "Волатильность не определена.")
|
||||
|
||||
# определить уровень journal события для market state
|
||||
def _market_journal_level(self, market_state: str | None) -> str:
|
||||
if market_state == "HIGH_VOLATILITY":
|
||||
return "WARNING"
|
||||
|
||||
return "INFO"
|
||||
|
||||
# получить человекочитаемое сообщение по market state
|
||||
def _market_state_message(self, market_state: str) -> str:
|
||||
messages = {
|
||||
"TREND_UP": "Состояние рынка изменено: рост.",
|
||||
"TREND_DOWN": "Состояние рынка изменено: снижение.",
|
||||
"RANGE": "Состояние рынка изменено: нет выраженного направления.",
|
||||
"HIGH_VOLATILITY": "Состояние рынка изменено: высокая волатильность.",
|
||||
"LOW_VOLATILITY": "Состояние рынка изменено: низкая активность.",
|
||||
}
|
||||
|
||||
return messages.get(market_state, "Состояние рынка анализируется.")
|
||||
318
app/src/trading/auto/position_health.py
Normal file
318
app/src/trading/auto/position_health.py
Normal file
@@ -0,0 +1,318 @@
|
||||
# app/src/trading/auto/position_health.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
class AutoPositionHealthMixin:
|
||||
# синхронизировать runtime health/risk состояние открытой позиции
|
||||
def _sync_position_health_state(self, state: AutoTradeState) -> None:
|
||||
if state.position_side == "NONE" or state.entry_price is None:
|
||||
state.position_pnl_percent = None
|
||||
state.position_hold_seconds = None
|
||||
state.position_pressure = None
|
||||
state.position_health_score = None
|
||||
state.position_health_status = None
|
||||
state.position_health_reason = None
|
||||
state.position_risk_level = None
|
||||
state.position_risk_reason = None
|
||||
state.position_trend_alignment = None
|
||||
state.position_adverse_momentum = False
|
||||
state.position_exit_pressure = None
|
||||
return
|
||||
|
||||
pnl_percent = self._position_pnl_percent(state)
|
||||
hold_seconds = self._position_hold_seconds(state)
|
||||
trend_alignment = self._position_trend_alignment(state)
|
||||
adverse_momentum = self._has_adverse_position_momentum(state)
|
||||
|
||||
pressure = self._position_pressure(
|
||||
state=state,
|
||||
pnl_percent=pnl_percent,
|
||||
)
|
||||
|
||||
health_score = self._position_health_score(
|
||||
state=state,
|
||||
pnl_percent=pnl_percent,
|
||||
trend_alignment=trend_alignment,
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
|
||||
risk_level, risk_reason = self._position_risk_level(
|
||||
state=state,
|
||||
pnl_percent=pnl_percent,
|
||||
trend_alignment=trend_alignment,
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
|
||||
state.position_pnl_percent = pnl_percent
|
||||
state.position_hold_seconds = hold_seconds
|
||||
state.position_pressure = pressure
|
||||
state.position_health_score = health_score
|
||||
state.position_health_status = self._position_health_status(health_score)
|
||||
state.position_health_reason = self._position_health_reason(
|
||||
pressure=pressure,
|
||||
trend_alignment=trend_alignment,
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
state.position_risk_level = risk_level
|
||||
state.position_risk_reason = risk_reason
|
||||
state.position_trend_alignment = trend_alignment
|
||||
state.position_adverse_momentum = adverse_momentum
|
||||
state.position_exit_pressure = self._position_exit_pressure(
|
||||
state=state,
|
||||
pnl_percent=pnl_percent,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
|
||||
# рассчитать PnL позиции в процентах от notional
|
||||
def _position_pnl_percent(self, state: AutoTradeState) -> float | None:
|
||||
entry_price = safe_float(state.entry_price)
|
||||
size = safe_float(state.position_size)
|
||||
pnl = safe_float(state.unrealized_pnl_usd)
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return None
|
||||
|
||||
if size is None or size <= 0:
|
||||
return None
|
||||
|
||||
if pnl is None:
|
||||
return None
|
||||
|
||||
notional = entry_price * size
|
||||
|
||||
if notional <= 0:
|
||||
return None
|
||||
|
||||
return round((pnl / notional) * 100, 4)
|
||||
|
||||
# рассчитать время удержания открытой позиции
|
||||
def _position_hold_seconds(self, state: AutoTradeState) -> int | None:
|
||||
opened_at = getattr(state, "position_opened_monotonic_at", None)
|
||||
|
||||
if opened_at is None:
|
||||
return None
|
||||
|
||||
opened = safe_float(opened_at)
|
||||
|
||||
if opened is None:
|
||||
return None
|
||||
|
||||
return max(0, int(time.monotonic() - opened))
|
||||
|
||||
# определить давление на позицию по PnL
|
||||
def _position_pressure(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
pnl_percent: NumericLike | None,
|
||||
) -> str:
|
||||
pnl = safe_float(state.unrealized_pnl_usd) or 0.0
|
||||
percent = safe_float(pnl_percent)
|
||||
|
||||
if percent is None:
|
||||
if pnl < 0:
|
||||
return "LOSS"
|
||||
|
||||
if pnl > 0:
|
||||
return "PROFIT"
|
||||
|
||||
return "FLAT"
|
||||
|
||||
if percent <= -0.8:
|
||||
return "HIGH_LOSS"
|
||||
|
||||
if percent <= -0.3:
|
||||
return "LOSS"
|
||||
|
||||
if percent >= 0.8:
|
||||
return "STRONG_PROFIT"
|
||||
|
||||
if percent >= 0.3:
|
||||
return "PROFIT"
|
||||
|
||||
return "FLAT"
|
||||
|
||||
# определить alignment позиции относительно тренда
|
||||
def _position_trend_alignment(self, state: AutoTradeState) -> str:
|
||||
side = str(state.position_side or "NONE").upper()
|
||||
market_state = str(state.market_state or "").upper()
|
||||
trend = str(state.market_trend or "").upper()
|
||||
|
||||
if side == "NONE":
|
||||
return "NONE"
|
||||
|
||||
if side == "LONG":
|
||||
if market_state == "TREND_UP" or trend == "UP":
|
||||
return "ALIGNED"
|
||||
|
||||
if market_state == "TREND_DOWN" or trend == "DOWN":
|
||||
return "AGAINST"
|
||||
|
||||
if side == "SHORT":
|
||||
if market_state == "TREND_DOWN" or trend == "DOWN":
|
||||
return "ALIGNED"
|
||||
|
||||
if market_state == "TREND_UP" or trend == "UP":
|
||||
return "AGAINST"
|
||||
|
||||
return "NEUTRAL"
|
||||
|
||||
# проверить, направлен ли momentum против позиции
|
||||
def _has_adverse_position_momentum(self, state: AutoTradeState) -> bool:
|
||||
side = str(state.position_side or "NONE").upper()
|
||||
momentum_direction = str(state.momentum_direction or "").upper()
|
||||
momentum_state = str(state.momentum_state or "").upper()
|
||||
|
||||
if side == "LONG":
|
||||
return (
|
||||
momentum_direction == "DOWN"
|
||||
or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
|
||||
)
|
||||
|
||||
if side == "SHORT":
|
||||
return (
|
||||
momentum_direction == "UP"
|
||||
or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
# рассчитать health score позиции
|
||||
def _position_health_score(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
pnl_percent: NumericLike | None,
|
||||
trend_alignment: str,
|
||||
adverse_momentum: bool,
|
||||
) -> int:
|
||||
score = 100
|
||||
percent = safe_float(pnl_percent)
|
||||
|
||||
if percent is not None:
|
||||
if percent <= -1.0:
|
||||
score -= 35
|
||||
elif percent <= -0.5:
|
||||
score -= 22
|
||||
elif percent < 0:
|
||||
score -= 10
|
||||
elif percent >= 0.8:
|
||||
score += 5
|
||||
|
||||
if trend_alignment == "AGAINST":
|
||||
score -= 25
|
||||
elif trend_alignment == "NEUTRAL":
|
||||
score -= 8
|
||||
|
||||
if adverse_momentum:
|
||||
score -= 20
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
score -= 15
|
||||
elif state.execution_quality == "WARNING":
|
||||
score -= 8
|
||||
|
||||
if state.market_runtime_degraded:
|
||||
score -= 10
|
||||
|
||||
return max(0, min(100, score))
|
||||
|
||||
# классифицировать health status по score
|
||||
def _position_health_status(self, score: int | None) -> str:
|
||||
if score is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
if score >= 80:
|
||||
return "HEALTHY"
|
||||
|
||||
if score >= 55:
|
||||
return "WATCH"
|
||||
|
||||
if score >= 35:
|
||||
return "PRESSURE"
|
||||
|
||||
return "DANGER"
|
||||
|
||||
# сформировать человекочитаемую причину health состояния
|
||||
def _position_health_reason(
|
||||
self,
|
||||
*,
|
||||
pressure: str,
|
||||
trend_alignment: str,
|
||||
adverse_momentum: bool,
|
||||
) -> str:
|
||||
if trend_alignment == "AGAINST" and adverse_momentum:
|
||||
return "тренд и momentum против позиции"
|
||||
|
||||
if trend_alignment == "AGAINST":
|
||||
return "тренд против позиции"
|
||||
|
||||
if adverse_momentum:
|
||||
return "momentum против позиции"
|
||||
|
||||
if pressure in {"HIGH_LOSS", "LOSS"}:
|
||||
return "позиция под давлением"
|
||||
|
||||
if pressure in {"PROFIT", "STRONG_PROFIT"}:
|
||||
return "позиция в прибыли"
|
||||
|
||||
return "позиция стабильна"
|
||||
|
||||
# определить runtime risk level позиции
|
||||
def _position_risk_level(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
pnl_percent: NumericLike | None,
|
||||
trend_alignment: str,
|
||||
adverse_momentum: bool,
|
||||
) -> tuple[str, str]:
|
||||
percent = safe_float(pnl_percent)
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
return "HIGH", "исполнение заблокировано"
|
||||
|
||||
if percent is not None and percent <= -1.0:
|
||||
return "HIGH", "сильная просадка позиции"
|
||||
|
||||
if trend_alignment == "AGAINST" and adverse_momentum:
|
||||
return "HIGH", "рынок движется против позиции"
|
||||
|
||||
if percent is not None and percent < 0:
|
||||
if trend_alignment == "AGAINST" or adverse_momentum:
|
||||
return "ELEVATED", "убыток усиливается рыночным контекстом"
|
||||
|
||||
return "MODERATE", "позиция в минусе"
|
||||
|
||||
if adverse_momentum:
|
||||
return "MODERATE", "momentum против позиции"
|
||||
|
||||
return "LOW", "критичных рисков нет"
|
||||
|
||||
# определить давление на выход из позиции
|
||||
def _position_exit_pressure(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
pnl_percent: NumericLike | None,
|
||||
risk_level: str,
|
||||
) -> str:
|
||||
percent = safe_float(pnl_percent)
|
||||
|
||||
if risk_level == "HIGH":
|
||||
return "HIGH"
|
||||
|
||||
if risk_level == "ELEVATED":
|
||||
return "WATCH"
|
||||
|
||||
if percent is not None and percent <= -0.5:
|
||||
return "WATCH"
|
||||
|
||||
return "LOW"
|
||||
420
app/src/trading/auto/position_intelligence.py
Normal file
420
app/src/trading/auto/position_intelligence.py
Normal file
@@ -0,0 +1,420 @@
|
||||
# app/src/trading/auto/position_intelligence.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
class AutoPositionIntelligenceMixin:
|
||||
# синхронизировать intelligence-состояние открытой позиции
|
||||
def _sync_position_intelligence_state(self, state: AutoTradeState) -> None:
|
||||
if state.position_side == "NONE" or state.entry_price is None:
|
||||
state.position_lifecycle_stage = None
|
||||
state.position_hold_quality = None
|
||||
state.position_decay_state = None
|
||||
state.position_exit_confidence = None
|
||||
state.position_exit_signal = None
|
||||
state.position_intelligence_reason = None
|
||||
state.position_recommended_action = None
|
||||
state.position_peak_pnl_usd = None
|
||||
state.position_peak_pnl_percent = None
|
||||
state.position_mfe_percent = None
|
||||
state.position_mae_percent = None
|
||||
state.position_fatigue_score = None
|
||||
state.position_fatigue_state = None
|
||||
state.position_giveback_percent = None
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = None
|
||||
return
|
||||
|
||||
lifecycle_stage = self._position_lifecycle_stage(state)
|
||||
hold_quality = self._position_hold_quality(state)
|
||||
decay_state = self._position_decay_state(state)
|
||||
|
||||
self._sync_advanced_position_analytics(
|
||||
state=state,
|
||||
lifecycle_stage=lifecycle_stage,
|
||||
hold_quality=hold_quality,
|
||||
decay_state=decay_state,
|
||||
)
|
||||
|
||||
exit_confidence = self._position_exit_confidence(
|
||||
state=state,
|
||||
hold_quality=hold_quality,
|
||||
decay_state=decay_state,
|
||||
)
|
||||
|
||||
exit_signal = self._position_exit_signal(exit_confidence)
|
||||
|
||||
state.position_lifecycle_stage = lifecycle_stage
|
||||
state.position_hold_quality = hold_quality
|
||||
state.position_decay_state = decay_state
|
||||
state.position_exit_confidence = exit_confidence
|
||||
state.position_exit_signal = exit_signal
|
||||
state.position_intelligence_reason = self._position_intelligence_reason(
|
||||
state=state,
|
||||
hold_quality=hold_quality,
|
||||
decay_state=decay_state,
|
||||
exit_signal=exit_signal,
|
||||
)
|
||||
state.position_recommended_action = self._position_recommended_action(
|
||||
exit_signal
|
||||
)
|
||||
|
||||
# определить lifecycle stage позиции по времени удержания
|
||||
def _position_lifecycle_stage(self, state: AutoTradeState) -> str:
|
||||
hold_seconds = state.position_hold_seconds
|
||||
|
||||
if hold_seconds is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
if hold_seconds < 60:
|
||||
return "NEW"
|
||||
|
||||
if hold_seconds < 300:
|
||||
return "ACTIVE"
|
||||
|
||||
if hold_seconds < 900:
|
||||
return "MATURE"
|
||||
|
||||
return "AGED"
|
||||
|
||||
# определить качество удержания позиции
|
||||
def _position_hold_quality(self, state: AutoTradeState) -> str:
|
||||
health_status = str(state.position_health_status or "").upper()
|
||||
pressure = str(state.position_pressure or "").upper()
|
||||
trend_alignment = str(state.position_trend_alignment or "").upper()
|
||||
|
||||
if health_status == "DANGER":
|
||||
return "BAD"
|
||||
|
||||
if pressure == "HIGH_LOSS":
|
||||
return "BAD"
|
||||
|
||||
if trend_alignment == "AGAINST" and state.position_adverse_momentum:
|
||||
return "BAD"
|
||||
|
||||
if health_status == "PRESSURE":
|
||||
return "WEAK"
|
||||
|
||||
if pressure == "LOSS":
|
||||
return "WEAK"
|
||||
|
||||
if pressure in {"PROFIT", "STRONG_PROFIT"} and trend_alignment == "ALIGNED":
|
||||
return "GOOD"
|
||||
|
||||
if health_status == "HEALTHY":
|
||||
return "GOOD"
|
||||
|
||||
return "NEUTRAL"
|
||||
|
||||
# определить тип ухудшения позиции
|
||||
def _position_decay_state(self, state: AutoTradeState) -> str:
|
||||
pressure = str(state.position_pressure or "").upper()
|
||||
trend_alignment = str(state.position_trend_alignment or "").upper()
|
||||
lifecycle = str(state.position_lifecycle_stage or "").upper()
|
||||
|
||||
if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum:
|
||||
return "ACCELERATING_LOSS"
|
||||
|
||||
if trend_alignment == "AGAINST" and state.position_adverse_momentum:
|
||||
return "CONTEXT_DECAY"
|
||||
|
||||
if pressure == "PROFIT" and state.position_adverse_momentum:
|
||||
return "PROFIT_DECAY"
|
||||
|
||||
if lifecycle == "AGED" and pressure == "FLAT":
|
||||
return "TIME_DECAY"
|
||||
|
||||
return "NONE"
|
||||
|
||||
# рассчитать confidence для выхода из позиции
|
||||
def _position_exit_confidence(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
hold_quality: str,
|
||||
decay_state: str,
|
||||
) -> float:
|
||||
score = 0.0
|
||||
|
||||
risk_level = str(state.position_risk_level or "").upper()
|
||||
exit_pressure = str(state.position_exit_pressure or "").upper()
|
||||
|
||||
if risk_level == "HIGH":
|
||||
score += 0.45
|
||||
elif risk_level == "ELEVATED":
|
||||
score += 0.30
|
||||
elif risk_level == "MODERATE":
|
||||
score += 0.15
|
||||
|
||||
if exit_pressure == "HIGH":
|
||||
score += 0.30
|
||||
elif exit_pressure == "WATCH":
|
||||
score += 0.15
|
||||
|
||||
if hold_quality == "BAD":
|
||||
score += 0.25
|
||||
elif hold_quality == "WEAK":
|
||||
score += 0.15
|
||||
|
||||
if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}:
|
||||
score += 0.25
|
||||
elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}:
|
||||
score += 0.15
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
score += 0.10
|
||||
|
||||
return round(max(0.0, min(1.0, score)), 3)
|
||||
|
||||
# определить semantic exit signal по confidence
|
||||
def _position_exit_signal(self, exit_confidence: float | None) -> str:
|
||||
if exit_confidence is None:
|
||||
return "NONE"
|
||||
|
||||
if exit_confidence >= 0.75:
|
||||
return "EXIT"
|
||||
|
||||
if exit_confidence >= 0.50:
|
||||
return "REDUCE_OR_PROTECT"
|
||||
|
||||
if exit_confidence >= 0.30:
|
||||
return "WATCH"
|
||||
|
||||
return "HOLD"
|
||||
|
||||
# сформировать объяснение position intelligence
|
||||
def _position_intelligence_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
hold_quality: str,
|
||||
decay_state: str,
|
||||
exit_signal: str,
|
||||
) -> str:
|
||||
if exit_signal == "EXIT":
|
||||
return "позиция требует выхода"
|
||||
|
||||
if exit_signal == "REDUCE_OR_PROTECT":
|
||||
return "позицию нужно защитить или уменьшить"
|
||||
|
||||
if decay_state != "NONE":
|
||||
return "качество удержания ухудшается"
|
||||
|
||||
if hold_quality == "GOOD":
|
||||
return "позицию можно удерживать"
|
||||
|
||||
if hold_quality == "WEAK":
|
||||
return "позиция требует наблюдения"
|
||||
|
||||
return "критичных признаков выхода нет"
|
||||
|
||||
# определить рекомендуемое действие по exit signal
|
||||
def _position_recommended_action(self, exit_signal: str | None) -> str:
|
||||
if exit_signal == "EXIT":
|
||||
return "CLOSE"
|
||||
|
||||
if exit_signal == "REDUCE_OR_PROTECT":
|
||||
return "PROTECT"
|
||||
|
||||
if exit_signal == "WATCH":
|
||||
return "WATCH"
|
||||
|
||||
return "HOLD"
|
||||
|
||||
# синхронизировать advanced analytics позиции
|
||||
def _sync_advanced_position_analytics(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
lifecycle_stage: str,
|
||||
hold_quality: str,
|
||||
decay_state: str,
|
||||
) -> None:
|
||||
pnl = safe_float(state.unrealized_pnl_usd)
|
||||
pnl_percent = safe_float(state.position_pnl_percent)
|
||||
|
||||
peak_pnl = safe_float(state.position_peak_pnl_usd)
|
||||
peak_pnl_percent = safe_float(state.position_peak_pnl_percent)
|
||||
|
||||
if pnl is not None:
|
||||
if peak_pnl is None or pnl > peak_pnl:
|
||||
state.position_peak_pnl_usd = pnl
|
||||
|
||||
if pnl_percent is not None:
|
||||
if peak_pnl_percent is None or pnl_percent > peak_pnl_percent:
|
||||
state.position_peak_pnl_percent = pnl_percent
|
||||
|
||||
state.position_mfe_percent = self._position_mfe_percent(state)
|
||||
state.position_mae_percent = self._position_mae_percent(state)
|
||||
state.position_giveback_percent = self._position_giveback_percent(state)
|
||||
|
||||
fatigue_score = self._position_fatigue_score(
|
||||
state=state,
|
||||
lifecycle_stage=lifecycle_stage,
|
||||
hold_quality=hold_quality,
|
||||
decay_state=decay_state,
|
||||
)
|
||||
|
||||
state.position_fatigue_score = fatigue_score
|
||||
state.position_fatigue_state = self._position_fatigue_state(fatigue_score)
|
||||
state.position_conviction_state = self._position_conviction_state(state)
|
||||
state.position_exit_urgency = self._position_exit_urgency(state)
|
||||
state.position_reversal_risk = self._position_reversal_risk(state)
|
||||
|
||||
# рассчитать maximum favorable excursion позиции
|
||||
def _position_mfe_percent(self, state: AutoTradeState) -> float | None:
|
||||
peak = safe_float(state.position_peak_pnl_percent)
|
||||
|
||||
if peak is None:
|
||||
return None
|
||||
|
||||
return round(max(0.0, peak), 4)
|
||||
|
||||
# рассчитать maximum adverse excursion позиции
|
||||
def _position_mae_percent(self, state: AutoTradeState) -> float | None:
|
||||
current = safe_float(state.position_pnl_percent)
|
||||
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
return round(min(0.0, current), 4)
|
||||
|
||||
# рассчитать процент отдачи прибыли от peak pnl
|
||||
def _position_giveback_percent(self, state: AutoTradeState) -> float | None:
|
||||
peak = safe_float(state.position_peak_pnl_percent)
|
||||
current = safe_float(state.position_pnl_percent)
|
||||
|
||||
if peak is None or current is None:
|
||||
return None
|
||||
|
||||
if peak <= 0:
|
||||
return 0.0
|
||||
|
||||
giveback = peak - current
|
||||
|
||||
if giveback <= 0:
|
||||
return 0.0
|
||||
|
||||
return round((giveback / peak) * 100, 2)
|
||||
|
||||
# рассчитать fatigue score позиции
|
||||
def _position_fatigue_score(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
lifecycle_stage: str,
|
||||
hold_quality: str,
|
||||
decay_state: str,
|
||||
) -> float:
|
||||
score = 0.0
|
||||
|
||||
giveback = safe_float(state.position_giveback_percent) or 0.0
|
||||
hold_seconds = safe_float(state.position_hold_seconds) or 0.0
|
||||
|
||||
if lifecycle_stage == "AGED":
|
||||
score += 0.25
|
||||
elif lifecycle_stage == "MATURE":
|
||||
score += 0.15
|
||||
|
||||
if hold_quality == "BAD":
|
||||
score += 0.30
|
||||
elif hold_quality == "WEAK":
|
||||
score += 0.18
|
||||
|
||||
if decay_state in {"ACCELERATING_LOSS", "CONTEXT_DECAY"}:
|
||||
score += 0.30
|
||||
elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}:
|
||||
score += 0.18
|
||||
|
||||
if giveback >= 70:
|
||||
score += 0.30
|
||||
elif giveback >= 45:
|
||||
score += 0.20
|
||||
elif giveback >= 25:
|
||||
score += 0.10
|
||||
|
||||
if hold_seconds >= 1800:
|
||||
score += 0.15
|
||||
elif hold_seconds >= 900:
|
||||
score += 0.08
|
||||
|
||||
if state.position_adverse_momentum:
|
||||
score += 0.15
|
||||
|
||||
return round(max(0.0, min(1.0, score)), 3)
|
||||
|
||||
# определить fatigue state позиции
|
||||
def _position_fatigue_state(self, score: float | None) -> str:
|
||||
value = safe_float(score)
|
||||
|
||||
if value is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
if value >= 0.75:
|
||||
return "EXHAUSTED"
|
||||
|
||||
if value >= 0.50:
|
||||
return "TIRED"
|
||||
|
||||
if value >= 0.25:
|
||||
return "WATCH"
|
||||
|
||||
return "FRESH"
|
||||
|
||||
# определить conviction state позиции
|
||||
def _position_conviction_state(self, state: AutoTradeState) -> str:
|
||||
health = str(state.position_health_status or "").upper()
|
||||
fatigue = str(state.position_fatigue_state or "").upper()
|
||||
alignment = str(state.position_trend_alignment or "").upper()
|
||||
|
||||
if health == "DANGER" or fatigue == "EXHAUSTED":
|
||||
return "BROKEN"
|
||||
|
||||
if alignment == "AGAINST" or fatigue == "TIRED":
|
||||
return "WEAKENING"
|
||||
|
||||
if health == "HEALTHY" and alignment == "ALIGNED":
|
||||
return "STRONG"
|
||||
|
||||
return "NEUTRAL"
|
||||
|
||||
# определить срочность выхода из позиции
|
||||
def _position_exit_urgency(self, state: AutoTradeState) -> str:
|
||||
exit_signal = str(state.position_exit_signal or "").upper()
|
||||
fatigue = str(state.position_fatigue_state or "").upper()
|
||||
risk = str(state.position_risk_level or "").upper()
|
||||
|
||||
if exit_signal == "EXIT" or risk == "HIGH":
|
||||
return "IMMEDIATE"
|
||||
|
||||
if fatigue == "EXHAUSTED":
|
||||
return "HIGH"
|
||||
|
||||
if exit_signal == "REDUCE_OR_PROTECT" or fatigue == "TIRED":
|
||||
return "MEDIUM"
|
||||
|
||||
if exit_signal == "WATCH":
|
||||
return "LOW"
|
||||
|
||||
return "NONE"
|
||||
|
||||
# определить риск разворота позиции
|
||||
def _position_reversal_risk(self, state: AutoTradeState) -> str:
|
||||
giveback = safe_float(state.position_giveback_percent) or 0.0
|
||||
fatigue = str(state.position_fatigue_state or "").upper()
|
||||
adverse = bool(state.position_adverse_momentum)
|
||||
|
||||
if adverse and giveback >= 45:
|
||||
return "HIGH"
|
||||
|
||||
if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25:
|
||||
return "ELEVATED"
|
||||
|
||||
if adverse:
|
||||
return "MODERATE"
|
||||
|
||||
return "LOW"
|
||||
@@ -400,6 +400,57 @@ class AutoTradeRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _signal_price_payload(
|
||||
cls,
|
||||
*,
|
||||
state,
|
||||
payload: JsonDict,
|
||||
signal: str,
|
||||
) -> JsonDict:
|
||||
bid_price = safe_float(payload.get("bid_price"))
|
||||
ask_price = safe_float(payload.get("ask_price"))
|
||||
last_price = safe_float(payload.get("last_price"))
|
||||
|
||||
if bid_price is None:
|
||||
bid_price = safe_float(getattr(state, "execution_bid_price", None))
|
||||
|
||||
if ask_price is None:
|
||||
ask_price = safe_float(getattr(state, "execution_ask_price", None))
|
||||
|
||||
if last_price is None:
|
||||
last_price = safe_float(getattr(state, "execution_last_price", None))
|
||||
|
||||
signal_price = None
|
||||
signal_price_role = "last"
|
||||
|
||||
if signal == "BUY":
|
||||
signal_price = ask_price or last_price or bid_price
|
||||
signal_price_role = "ask"
|
||||
|
||||
elif signal == "SELL":
|
||||
signal_price = bid_price or last_price or ask_price
|
||||
signal_price_role = "bid"
|
||||
|
||||
else:
|
||||
signal_price = last_price or ask_price or bid_price
|
||||
|
||||
return {
|
||||
"bid_price": bid_price,
|
||||
"ask_price": ask_price,
|
||||
"last_price": last_price,
|
||||
"signal_price": signal_price,
|
||||
"signal_price_role": signal_price_role,
|
||||
"signal_price_source": (
|
||||
payload.get("price_source")
|
||||
or getattr(state, "execution_price_source", None)
|
||||
),
|
||||
"signal_price_age_seconds": (
|
||||
payload.get("price_age_seconds")
|
||||
or getattr(state, "execution_price_age_seconds", None)
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _publish_strong_signal_event(
|
||||
cls,
|
||||
@@ -410,6 +461,7 @@ class AutoTradeRunner:
|
||||
signal = str(payload.get("signal", "")).upper()
|
||||
symbol = str(payload.get("symbol") or state.symbol or "—")
|
||||
strategy = str(payload.get("strategy") or state.strategy or "—")
|
||||
|
||||
repeat_count_value = (
|
||||
payload.get("repeat_count")
|
||||
if payload.get("repeat_count") is not None
|
||||
@@ -417,18 +469,35 @@ class AutoTradeRunner:
|
||||
)
|
||||
|
||||
repeat_count = int(safe_float(repeat_count_value) or 0)
|
||||
confidence = safe_float(
|
||||
payload.get("confidence")
|
||||
)
|
||||
|
||||
confidence = safe_float(payload.get("confidence"))
|
||||
|
||||
if confidence is None:
|
||||
confidence = safe_float(state.last_signal_confidence)
|
||||
|
||||
if confidence is None:
|
||||
confidence = 0.0
|
||||
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
|
||||
|
||||
leverage = (
|
||||
payload.get("leverage")
|
||||
if payload.get("leverage") is not None
|
||||
else state.leverage
|
||||
)
|
||||
|
||||
reason = str(payload.get("reason") or state.last_signal_reason or "—")
|
||||
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
|
||||
position_context = str(getattr(state, "position_side", "NONE") or "NONE").upper()
|
||||
is_aligned_signal = cls._is_position_aligned_signal(
|
||||
state=state,
|
||||
signal=signal,
|
||||
)
|
||||
|
||||
price_payload = cls._signal_price_payload(
|
||||
state=state,
|
||||
payload=payload,
|
||||
signal=signal,
|
||||
)
|
||||
|
||||
semantic_lines = cls._notification_reason_lines(state)
|
||||
|
||||
priority = cls._alert_priority(
|
||||
confidence=confidence,
|
||||
@@ -449,12 +518,11 @@ class AutoTradeRunner:
|
||||
"leverage": leverage,
|
||||
"reason": reason,
|
||||
"position_context": position_context,
|
||||
"decision_status": state.decision_status,
|
||||
"semantic_lines": cls._notification_reason_lines(state),
|
||||
"position_side": position_context,
|
||||
"bid_price": payload.get("bid_price"),
|
||||
"ask_price": payload.get("ask_price"),
|
||||
"last_price": payload.get("last_price"),
|
||||
"is_position_aligned_signal": is_aligned_signal,
|
||||
"decision_status": state.decision_status,
|
||||
"semantic_lines": semantic_lines,
|
||||
**price_payload,
|
||||
},
|
||||
priority=priority.lower(),
|
||||
dedupe_key=(
|
||||
@@ -466,7 +534,8 @@ class AutoTradeRunner:
|
||||
f"{repeat_count}:"
|
||||
f"{confidence:.2f}:"
|
||||
f"{state.decision_status}:"
|
||||
f"{reason}"
|
||||
f"{reason}:"
|
||||
f"aligned={is_aligned_signal}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
814
app/src/trading/auto/signal_runtime.py
Normal file
814
app/src/trading/auto/signal_runtime.py
Normal file
@@ -0,0 +1,814 @@
|
||||
# app/src/trading/auto/signal_runtime.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable, cast
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
class AutoSignalRuntimeMixin:
|
||||
_loop_interval_seconds: int
|
||||
|
||||
_confirm_repeats: int
|
||||
_confirm_min_duration_seconds: int
|
||||
_ready_confidence: float
|
||||
_execution_confidence_required_score: float
|
||||
|
||||
_signal_ttl_seconds: int
|
||||
_market_analysis_ttl_seconds: int
|
||||
_last_logged_runtime_expired_key: str | None
|
||||
|
||||
_last_signal_key: str | None
|
||||
_last_signal_value: str | None
|
||||
_last_signal_reason: str
|
||||
_last_signal_confidence: float
|
||||
_last_signal_payload: JsonDict | None
|
||||
_last_signal_started_at: float | None
|
||||
_same_signal_count: int
|
||||
|
||||
# получить state из основного AutoTradeService
|
||||
def get_state(self) -> AutoTradeState:
|
||||
raise NotImplementedError
|
||||
|
||||
# сбросить runtime tracking в основном AutoTradeService
|
||||
def _reset_signal_tracking(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
# debug: принудительно выставить сигнал и decision
|
||||
def debug_force_signal(
|
||||
self,
|
||||
*,
|
||||
signal: str,
|
||||
confidence: NumericLike = 0.9,
|
||||
repeat_count: int = 2,
|
||||
reason: str = "DEBUG SIGNAL",
|
||||
) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
confidence_value = safe_float(confidence) or 0.0
|
||||
|
||||
normalized_signal = signal.strip().upper()
|
||||
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
|
||||
normalized_signal = "HOLD"
|
||||
|
||||
previous_signal = state.last_signal
|
||||
previous_decision_status = state.decision_status
|
||||
|
||||
if previous_signal != normalized_signal or state.signal_started_at is None:
|
||||
state.signal_started_at = time.monotonic()
|
||||
|
||||
state.last_signal = normalized_signal
|
||||
state.last_signal_repeat_count = repeat_count
|
||||
state.last_signal_confidence = confidence_value
|
||||
state.last_signal_reason = reason
|
||||
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
state.signal_confirmation_missing_repeats = 0
|
||||
state.signal_confirmation_progress = 1.0
|
||||
state.signal_confirmation_reason = "debug confirmation"
|
||||
|
||||
if normalized_signal == "HOLD":
|
||||
state.decision_status = "WAITING"
|
||||
state.decision_reason = "Debug HOLD."
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
else:
|
||||
state.decision_status = "READY"
|
||||
state.decision_reason = "Debug READY signal."
|
||||
state.is_signal_confirmed = True
|
||||
state.is_signal_ready = True
|
||||
|
||||
signal_intent = self._signal_intent(
|
||||
state=state,
|
||||
signal=state.last_signal,
|
||||
)
|
||||
|
||||
EventBus.emit(
|
||||
"auto_decision_changed",
|
||||
{
|
||||
"previous_signal": previous_signal,
|
||||
"previous_decision_status": previous_decision_status,
|
||||
"decision_status": state.decision_status,
|
||||
"signal": state.last_signal,
|
||||
"signal_intent": signal_intent,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"leverage": state.leverage,
|
||||
"reason": state.last_signal_reason,
|
||||
"debug": True,
|
||||
},
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
# определить смысл сигнала с учетом открытой позиции
|
||||
def _signal_intent(self, *, state: AutoTradeState, signal: str | None) -> str:
|
||||
normalized_signal = (signal or "HOLD").upper()
|
||||
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
|
||||
|
||||
if normalized_signal == "HOLD":
|
||||
return "HOLD_MARKET"
|
||||
|
||||
if normalized_signal not in {"BUY", "SELL"}:
|
||||
return "NOISE"
|
||||
|
||||
if position_side == "NONE":
|
||||
return "ENTRY_CANDIDATE"
|
||||
|
||||
if position_side == "LONG" and normalized_signal == "BUY":
|
||||
return "REINFORCE_POSITION"
|
||||
|
||||
if position_side == "SHORT" and normalized_signal == "SELL":
|
||||
return "REINFORCE_POSITION"
|
||||
|
||||
if position_side == "LONG" and normalized_signal == "SELL":
|
||||
return "REVERSAL_CANDIDATE"
|
||||
|
||||
if position_side == "SHORT" and normalized_signal == "BUY":
|
||||
return "REVERSAL_CANDIDATE"
|
||||
|
||||
return "NOISE"
|
||||
|
||||
# обновить статус решения по текущему сигналу
|
||||
def _update_decision_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
signal: str,
|
||||
confidence: float,
|
||||
) -> None:
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
|
||||
if signal == "HOLD":
|
||||
state.signal_confirmation_seconds = 0
|
||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||
state.signal_confirmation_progress = 0.0
|
||||
state.signal_confirmation_reason = None
|
||||
state.decision_status = "WAITING"
|
||||
state.decision_reason = "Нет торгового направления."
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
if state.signal_started_at is None:
|
||||
signal_age_seconds = 0
|
||||
else:
|
||||
signal_started = safe_float(state.signal_started_at)
|
||||
signal_age_seconds = (
|
||||
max(0, int(now - signal_started))
|
||||
if signal_started is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
missing_repeats = max(0, self._confirm_repeats - self._same_signal_count)
|
||||
missing_seconds = max(
|
||||
0,
|
||||
self._confirm_min_duration_seconds - signal_age_seconds,
|
||||
)
|
||||
|
||||
repeat_progress = min(
|
||||
1.0,
|
||||
self._same_signal_count / max(1, self._confirm_repeats),
|
||||
)
|
||||
time_progress = min(
|
||||
1.0,
|
||||
signal_age_seconds / max(1, self._confirm_min_duration_seconds),
|
||||
)
|
||||
|
||||
confirmation_progress = min(repeat_progress, time_progress)
|
||||
|
||||
state.signal_confirmation_seconds = signal_age_seconds
|
||||
state.signal_confirmation_missing_repeats = missing_repeats
|
||||
state.signal_confirmation_progress = round(confirmation_progress, 3)
|
||||
|
||||
if missing_repeats > 0 or missing_seconds > 0:
|
||||
state.decision_status = "CONFIRMING"
|
||||
state.signal_confirmation_reason = (
|
||||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с"
|
||||
)
|
||||
state.decision_reason = (
|
||||
f"Сигнал {signal} подтверждается: "
|
||||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с."
|
||||
)
|
||||
return
|
||||
|
||||
state.is_signal_confirmed = True
|
||||
state.signal_confirmation_reason = "сигнал подтверждён"
|
||||
|
||||
if confidence < self._ready_confidence:
|
||||
state.decision_status = "BLOCKED"
|
||||
state.decision_reason = (
|
||||
f"Сигнал {signal} подтверждён, но уверенность низкая: "
|
||||
f"{confidence:.2f} < {self._ready_confidence:.2f}."
|
||||
)
|
||||
return
|
||||
|
||||
self._sync_execution_confidence_state(
|
||||
state=state,
|
||||
signal=signal,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
if (
|
||||
state.execution_confidence_score is not None
|
||||
and state.execution_confidence_score < self._execution_confidence_required_score
|
||||
):
|
||||
state.decision_status = "BLOCKED"
|
||||
state.decision_reason = (
|
||||
f"Execution confidence низкий: "
|
||||
f"{state.execution_confidence_score:.2f} < "
|
||||
f"{self._execution_confidence_required_score:.2f}."
|
||||
)
|
||||
return
|
||||
|
||||
state.is_signal_ready = True
|
||||
state.signal_confirmation_progress = 1.0
|
||||
state.decision_status = "READY"
|
||||
state.decision_reason = (
|
||||
f"Сигнал {signal} подтверждён по повторам и времени удержания."
|
||||
)
|
||||
|
||||
# записать новый сигнал и итог предыдущей серии при смене сигнала
|
||||
def _log_signal_if_changed(
|
||||
self,
|
||||
*,
|
||||
strategy_name: str,
|
||||
state: AutoTradeState,
|
||||
signal: str,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
payload: JsonDict | None,
|
||||
) -> None:
|
||||
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
|
||||
previous_signal = self._last_signal_value
|
||||
previous_count = self._same_signal_count
|
||||
is_same_signal = signal_key == self._last_signal_key
|
||||
now = time.monotonic()
|
||||
|
||||
if is_same_signal:
|
||||
self._same_signal_count += 1
|
||||
self._last_signal_reason = reason
|
||||
self._last_signal_confidence = confidence
|
||||
self._last_signal_payload = payload
|
||||
|
||||
self._update_signal_state_fields(
|
||||
state=state,
|
||||
signal=signal,
|
||||
reason=reason,
|
||||
confidence=confidence,
|
||||
)
|
||||
return
|
||||
|
||||
if previous_signal is not None and previous_signal != signal:
|
||||
if previous_count > 1:
|
||||
self._log_signal_summary(
|
||||
strategy_name=strategy_name,
|
||||
state=state,
|
||||
previous_signal=previous_signal,
|
||||
previous_count=previous_count,
|
||||
next_signal=signal,
|
||||
reason=self._last_signal_reason,
|
||||
confidence=self._last_signal_confidence,
|
||||
payload=self._last_signal_payload,
|
||||
duration_seconds=self._signal_duration_seconds(now=now),
|
||||
)
|
||||
else:
|
||||
self._log_signal_event(
|
||||
strategy_name=strategy_name,
|
||||
state=state,
|
||||
signal=previous_signal,
|
||||
reason=f"{previous_signal} завершился без серии.",
|
||||
confidence=self._last_signal_confidence,
|
||||
payload={
|
||||
"previous_signal": previous_signal,
|
||||
"next_signal": signal,
|
||||
},
|
||||
)
|
||||
|
||||
self._last_signal_key = signal_key
|
||||
self._last_signal_value = signal
|
||||
self._last_signal_reason = reason
|
||||
self._last_signal_confidence = confidence
|
||||
self._last_signal_payload = payload
|
||||
self._last_signal_started_at = now
|
||||
self._same_signal_count = 1
|
||||
|
||||
self._update_signal_state_fields(
|
||||
state=state,
|
||||
signal=signal,
|
||||
reason=reason,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
# рассчитать длительность текущей серии сигналов
|
||||
def _signal_duration_seconds(self, *, now: float) -> int:
|
||||
if self._last_signal_started_at is None:
|
||||
return max(0, int(self._same_signal_count * self._loop_interval_seconds))
|
||||
|
||||
return max(0, int(now - self._last_signal_started_at))
|
||||
|
||||
# отформатировать длительность для журнала
|
||||
def _format_duration(self, total_seconds: int) -> str:
|
||||
total_seconds = max(0, int(total_seconds))
|
||||
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}ч {minutes:02d}м {seconds:02d}с"
|
||||
|
||||
if minutes > 0:
|
||||
return f"{minutes}м {seconds:02d}с"
|
||||
|
||||
return f"{seconds}с"
|
||||
|
||||
# обновить поля state для экрана автоторговли
|
||||
def _update_signal_state_fields(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
signal: str,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
) -> None:
|
||||
previous_signal = state.last_signal
|
||||
previous_decision_status = state.decision_status
|
||||
|
||||
if previous_signal != signal or state.signal_started_at is None:
|
||||
state.signal_started_at = time.monotonic()
|
||||
|
||||
state.last_signal = signal
|
||||
state.last_signal_repeat_count = self._same_signal_count
|
||||
state.last_signal_confidence = confidence
|
||||
state.last_signal_reason = reason
|
||||
state.signal_updated_at = time.monotonic()
|
||||
state.runtime_expired_reason = None
|
||||
state.runtime_expired_message = None
|
||||
|
||||
self._update_decision_state(
|
||||
state=state,
|
||||
signal=signal,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
signal_intent = self._signal_intent(
|
||||
state=state,
|
||||
signal=state.last_signal,
|
||||
)
|
||||
|
||||
if (
|
||||
previous_decision_status != state.decision_status
|
||||
and state.decision_status == "READY"
|
||||
):
|
||||
self._log_ready_signal(
|
||||
state=state,
|
||||
signal=state.last_signal,
|
||||
reason=state.last_signal_reason or reason,
|
||||
confidence=state.last_signal_confidence,
|
||||
signal_intent=signal_intent,
|
||||
)
|
||||
|
||||
if previous_signal != state.last_signal:
|
||||
EventBus.emit(
|
||||
"auto_signal_changed",
|
||||
{
|
||||
"previous_signal": previous_signal,
|
||||
"signal": state.last_signal,
|
||||
"signal_intent": signal_intent,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"confidence": state.last_signal_confidence,
|
||||
},
|
||||
)
|
||||
|
||||
if previous_decision_status != state.decision_status:
|
||||
EventBus.emit(
|
||||
"auto_decision_changed",
|
||||
{
|
||||
"previous_decision_status": previous_decision_status,
|
||||
"decision_status": state.decision_status,
|
||||
"signal": state.last_signal,
|
||||
"signal_intent": signal_intent,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"leverage": state.leverage,
|
||||
"reason": state.last_signal_reason,
|
||||
},
|
||||
)
|
||||
|
||||
# одиночные BUY / SELL больше не пишем в журнал как полезные события
|
||||
def _log_signal_event(
|
||||
self,
|
||||
*,
|
||||
strategy_name: str,
|
||||
state: AutoTradeState,
|
||||
signal: str,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
payload: JsonDict | None,
|
||||
) -> None:
|
||||
return
|
||||
|
||||
# записать итог серии одинаковых сигналов при смене сигнала
|
||||
def _log_signal_summary(
|
||||
self,
|
||||
*,
|
||||
strategy_name: str,
|
||||
state: AutoTradeState,
|
||||
previous_signal: str,
|
||||
previous_count: int,
|
||||
next_signal: str,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
payload: JsonDict | None,
|
||||
duration_seconds: int,
|
||||
) -> None:
|
||||
if previous_signal != "HOLD":
|
||||
return
|
||||
|
||||
duration_text = self._format_duration(duration_seconds)
|
||||
signal_intent = "HOLD_MARKET"
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
event_type="signal_summary",
|
||||
message=(
|
||||
f"HOLD длился {duration_text} и завершился сигналом {next_signal}."
|
||||
),
|
||||
screen="auto",
|
||||
action="signal_summary",
|
||||
payload={
|
||||
"strategy": strategy_name,
|
||||
"status": state.status,
|
||||
"symbol": state.symbol,
|
||||
"signal": previous_signal,
|
||||
"next_signal": next_signal,
|
||||
"signal_intent": signal_intent,
|
||||
"repeat_count": previous_count,
|
||||
"duration_seconds": duration_seconds,
|
||||
"duration_text": duration_text,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
"is_strong_signal": False,
|
||||
"is_aggregated": True,
|
||||
"payload": payload or {},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# записать событие готовности сигнала к исполнению
|
||||
def _log_ready_signal(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
signal: str | None,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
signal_intent: str,
|
||||
) -> None:
|
||||
normalized_signal = (signal or "HOLD").upper()
|
||||
if normalized_signal not in {"BUY", "SELL"}:
|
||||
return
|
||||
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
event_type="signal_ready",
|
||||
message=(
|
||||
f"Сигнал {normalized_signal} подтверждён и готов к исполнению."
|
||||
),
|
||||
screen="auto",
|
||||
action="signal_ready",
|
||||
payload={
|
||||
"strategy": state.strategy,
|
||||
"status": state.status,
|
||||
"symbol": state.symbol,
|
||||
"signal": normalized_signal,
|
||||
"signal_intent": signal_intent,
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"position_side": state.position_side,
|
||||
"decision_status": state.decision_status,
|
||||
"is_strong_signal": confidence > self._ready_confidence,
|
||||
"is_aggregated": False,
|
||||
"confirmation_seconds": state.signal_confirmation_seconds,
|
||||
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
|
||||
"confirmation_progress": state.signal_confirmation_progress,
|
||||
"bid_price": snapshot.get("bid_price"),
|
||||
"ask_price": snapshot.get("ask_price"),
|
||||
"last_price": snapshot.get("last_price"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# сбросить устаревшие signal / market runtime данные
|
||||
def _expire_runtime_if_needed(self, state: AutoTradeState) -> None:
|
||||
now = time.monotonic()
|
||||
|
||||
signal_updated_at = getattr(state, "signal_updated_at", None)
|
||||
if signal_updated_at is not None:
|
||||
signal_updated = safe_float(signal_updated_at)
|
||||
if signal_updated is None:
|
||||
return
|
||||
|
||||
signal_age = now - signal_updated
|
||||
if signal_age > self._signal_ttl_seconds:
|
||||
previous_signal = state.last_signal
|
||||
|
||||
self._reset_signal_tracking()
|
||||
|
||||
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
|
||||
state.runtime_expired_message = "сигнал устарел и был сброшен"
|
||||
|
||||
self._log_runtime_expired_if_changed(
|
||||
state=state,
|
||||
reason="SIGNAL_TTL_EXPIRED",
|
||||
message="Сигнал устарел и был сброшен.",
|
||||
payload={
|
||||
"previous_signal": previous_signal,
|
||||
"signal_age_seconds": int(signal_age),
|
||||
"signal_ttl_seconds": self._signal_ttl_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
market_updated_at = getattr(state, "market_analysis_updated_at", None)
|
||||
if market_updated_at is not None:
|
||||
market_updated = safe_float(market_updated_at)
|
||||
|
||||
if market_updated is None:
|
||||
return
|
||||
|
||||
market_age = now - market_updated
|
||||
|
||||
if market_age > self._market_analysis_ttl_seconds:
|
||||
state.market_state = None
|
||||
state.market_trend = None
|
||||
state.market_volatility = None
|
||||
state.market_analysis_interval = None
|
||||
state.market_analysis_reason = None
|
||||
state.market_analysis_updated_at = None
|
||||
state.entry_block_reason = None
|
||||
state.entry_block_message = None
|
||||
state.market_trend_strength = None
|
||||
state.market_trend_quality = None
|
||||
state.market_phase = None
|
||||
state.market_phase_direction = None
|
||||
state.market_trend_gap_percent = None
|
||||
state.market_trend_consistency = None
|
||||
state.market_trend_efficiency = None
|
||||
state.trend_quality_score = None
|
||||
state.ema_distance_atr_ratio = None
|
||||
state.ema_distance_state = None
|
||||
state.entry_timing_state = None
|
||||
state.entry_timing_reason = None
|
||||
state.ema_fast_slope_percent = None
|
||||
state.ema_slow_slope_percent = None
|
||||
state.candle_noise_score = None
|
||||
state.price_position_score = None
|
||||
state.htf_interval = None
|
||||
state.htf_atr_percent = None
|
||||
state.htf_atr_percent_baseline = None
|
||||
state.htf_volatility_ratio = None
|
||||
state.htf_volatility = None
|
||||
state.momentum_state = None
|
||||
state.momentum_direction = None
|
||||
state.momentum_change_percent = None
|
||||
state.momentum_strength = None
|
||||
state.breakout_level = None
|
||||
state.breakout_distance_percent = None
|
||||
state.breakout_reason = None
|
||||
state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED"
|
||||
state.runtime_expired_message = "анализ рынка устарел"
|
||||
|
||||
self._log_runtime_expired_if_changed(
|
||||
state=state,
|
||||
reason="MARKET_ANALYSIS_TTL_EXPIRED",
|
||||
message="Анализ рынка устарел и был сброшен.",
|
||||
payload={
|
||||
"market_age_seconds": int(market_age),
|
||||
"market_analysis_ttl_seconds": self._market_analysis_ttl_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
# записать событие устаревания runtime данных
|
||||
def _log_runtime_expired_if_changed(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
message: str,
|
||||
payload: JsonDict,
|
||||
) -> None:
|
||||
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
|
||||
|
||||
if key == type(self)._last_logged_runtime_expired_key:
|
||||
return
|
||||
|
||||
type(self)._last_logged_runtime_expired_key = key
|
||||
|
||||
try:
|
||||
JournalService().log_ui_warning(
|
||||
event_type="runtime_expired",
|
||||
message=message,
|
||||
screen="auto",
|
||||
action="runtime_expiration",
|
||||
payload={
|
||||
**payload,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
"status": state.status,
|
||||
"runtime_expired_reason": reason,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# синхронизировать итоговый execution confidence
|
||||
def _sync_execution_confidence_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
signal: str,
|
||||
confidence: float,
|
||||
) -> None:
|
||||
if signal not in {"BUY", "SELL"}:
|
||||
state.execution_confidence_score = None
|
||||
state.execution_confidence_level = None
|
||||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
||||
state.execution_confidence_reason = None
|
||||
state.execution_confidence_factors = None
|
||||
return
|
||||
|
||||
signal_score = self._clamp_score(confidence)
|
||||
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
|
||||
market_score = self._market_confidence_score(state)
|
||||
execution_quality_confidence_score = cast(
|
||||
Callable[[AutoTradeState], float],
|
||||
getattr(self, "_execution_quality_confidence_score"),
|
||||
)
|
||||
execution_score = execution_quality_confidence_score(state)
|
||||
|
||||
score = (
|
||||
signal_score * 0.35
|
||||
+ confirmation_score * 0.20
|
||||
+ market_score * 0.25
|
||||
+ execution_score * 0.20
|
||||
)
|
||||
|
||||
score = round(self._clamp_score(score), 3)
|
||||
|
||||
state.execution_confidence_score = score
|
||||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
||||
state.execution_confidence_level = self._execution_confidence_level(score)
|
||||
state.execution_confidence_reason = self._execution_confidence_reason(state)
|
||||
state.execution_confidence_factors = {
|
||||
"signal_score": round(signal_score, 3),
|
||||
"confirmation_score": round(confirmation_score, 3),
|
||||
"market_score": round(market_score, 3),
|
||||
"execution_score": round(execution_score, 3),
|
||||
"required_score": self._execution_confidence_required_score,
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_trend_strength": state.market_trend_strength,
|
||||
"market_trend_quality": state.market_trend_quality,
|
||||
"market_phase": state.market_phase,
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"spread_percent": state.spread_percent,
|
||||
"momentum_state": getattr(state, "momentum_state", None),
|
||||
"momentum_direction": getattr(state, "momentum_direction", None),
|
||||
"momentum_change_percent": getattr(state, "momentum_change_percent", None),
|
||||
"momentum_strength": getattr(state, "momentum_strength", None),
|
||||
"breakout_level": getattr(state, "breakout_level", None),
|
||||
"breakout_distance_percent": getattr(state, "breakout_distance_percent", None),
|
||||
"breakout_reason": getattr(state, "breakout_reason", None),
|
||||
}
|
||||
|
||||
# рассчитать market confidence для итогового execution confidence
|
||||
def _market_confidence_score(self, state: AutoTradeState) -> float:
|
||||
market_state = state.market_state
|
||||
strength = state.market_trend_strength
|
||||
quality = state.market_trend_quality
|
||||
phase = state.market_phase
|
||||
ema_distance_state = state.ema_distance_state
|
||||
entry_timing_state = state.entry_timing_state
|
||||
trend_quality_score = safe_float(state.trend_quality_score)
|
||||
|
||||
if market_state in {
|
||||
"HIGH_VOLATILITY",
|
||||
"LOW_VOLATILITY",
|
||||
"RANGE",
|
||||
"UNKNOWN",
|
||||
None,
|
||||
"",
|
||||
}:
|
||||
return 0.25
|
||||
|
||||
score = 0.65
|
||||
|
||||
if strength == "STRONG":
|
||||
score += 0.2
|
||||
elif strength == "NORMAL":
|
||||
score += 0.1
|
||||
elif strength == "WEAK":
|
||||
score -= 0.25
|
||||
|
||||
if quality == "CLEAN":
|
||||
score += 0.12
|
||||
elif quality == "NORMAL":
|
||||
score += 0.04
|
||||
elif quality == "NOISY":
|
||||
score -= 0.25
|
||||
|
||||
if phase == "IMPULSE":
|
||||
score += 0.1
|
||||
elif phase == "PULLBACK":
|
||||
score -= 0.25
|
||||
elif phase in {"RANGE", "SQUEEZE"}:
|
||||
score -= 0.3
|
||||
|
||||
if ema_distance_state == "HEALTHY":
|
||||
score += 0.08
|
||||
elif ema_distance_state == "EXTENDED":
|
||||
score -= 0.08
|
||||
elif ema_distance_state == "COMPRESSED":
|
||||
score -= 0.18
|
||||
elif ema_distance_state == "OVEREXTENDED":
|
||||
score -= 0.35
|
||||
|
||||
if entry_timing_state == "NORMAL":
|
||||
score += 0.08
|
||||
elif entry_timing_state == "EARLY":
|
||||
score -= 0.05
|
||||
elif entry_timing_state == "LATE":
|
||||
score -= 0.2
|
||||
elif entry_timing_state == "CHASING":
|
||||
score -= 0.35
|
||||
|
||||
if trend_quality_score is not None:
|
||||
if trend_quality_score >= 0.7:
|
||||
score += 0.08
|
||||
elif trend_quality_score < 0.45:
|
||||
score -= 0.15
|
||||
|
||||
return self._clamp_score(score)
|
||||
|
||||
# определить уровень execution confidence
|
||||
def _execution_confidence_level(self, score: float) -> str:
|
||||
if score >= 0.75:
|
||||
return "HIGH"
|
||||
|
||||
if score >= self._execution_confidence_required_score:
|
||||
return "NORMAL"
|
||||
|
||||
return "LOW"
|
||||
|
||||
# сформировать причину execution confidence
|
||||
def _execution_confidence_reason(self, state: AutoTradeState) -> str:
|
||||
score = state.execution_confidence_score
|
||||
|
||||
if score is None:
|
||||
return "execution confidence не рассчитан"
|
||||
|
||||
if score < self._execution_confidence_required_score:
|
||||
return "низкая совокупная уверенность входа"
|
||||
|
||||
if state.execution_confidence_level == "HIGH":
|
||||
return "высокая совокупная уверенность входа"
|
||||
|
||||
return "достаточная совокупная уверенность входа"
|
||||
|
||||
# ограничить score диапазоном 0.0..1.0
|
||||
def _clamp_score(self, value: NumericLike | None) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
|
||||
numeric = safe_float(value)
|
||||
|
||||
if numeric is None:
|
||||
return 0.0
|
||||
|
||||
return max(0.0, min(1.0, numeric))
|
||||
@@ -404,4 +404,13 @@ class AutoTradeState:
|
||||
market_status_updated_at: float | None = None
|
||||
|
||||
# номер текущего цикла автоторговли, для которого была зафиксирована статистика
|
||||
cycle_number: int = 0
|
||||
cycle_number: int = 0
|
||||
|
||||
# уникальный номер сделки внутри runtime
|
||||
trade_sequence: int = 0
|
||||
|
||||
# id текущей открытой сделки
|
||||
current_trade_id: str | None = None
|
||||
|
||||
# номер цикла, в котором открыта текущая сделка
|
||||
current_trade_cycle_number: int | None = None
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.integrations.exchange.runtime_ui import format_runtime_exchange_alert
|
||||
|
||||
|
||||
class SemanticDiagnosticFormatter:
|
||||
@@ -17,6 +18,11 @@ class SemanticDiagnosticFormatter:
|
||||
execution = snapshot.get("execution", {})
|
||||
adaptive = snapshot.get("adaptive_size", {})
|
||||
runtime = snapshot.get("runtime_health", {})
|
||||
exchange_statuses = runtime.get("exchange_statuses") or []
|
||||
exchange_status = runtime.get("exchange_status")
|
||||
|
||||
if not exchange_statuses and exchange_status:
|
||||
exchange_statuses = [exchange_status]
|
||||
summary = snapshot.get("summary", {})
|
||||
position = snapshot.get("position", {})
|
||||
|
||||
@@ -33,6 +39,9 @@ class SemanticDiagnosticFormatter:
|
||||
self._status_block(status),
|
||||
]
|
||||
|
||||
for item in exchange_statuses:
|
||||
sections.append(self._runtime_exchange_block(item))
|
||||
|
||||
return "\n\n".join(
|
||||
section.strip()
|
||||
for section in sections
|
||||
@@ -47,11 +56,16 @@ class SemanticDiagnosticFormatter:
|
||||
market=market,
|
||||
momentum=momentum,
|
||||
),
|
||||
]
|
||||
for item in exchange_statuses:
|
||||
sections.append(self._runtime_exchange_block(item))
|
||||
|
||||
sections.extend([
|
||||
self._execution_block(execution),
|
||||
self._signal_block(signal),
|
||||
self._market_block(market),
|
||||
self._momentum_block(momentum),
|
||||
]
|
||||
])
|
||||
|
||||
if mode != "COMPACT":
|
||||
if has_position:
|
||||
@@ -752,6 +766,7 @@ class SemanticDiagnosticFormatter:
|
||||
quality = data.get("trend_quality")
|
||||
volatility = data.get("volatility")
|
||||
market_closed = data.get("market_is_open") is False
|
||||
market_data_state = self._market_live_state(data.get("age_seconds"))
|
||||
|
||||
lines = [
|
||||
(
|
||||
@@ -759,10 +774,7 @@ class SemanticDiagnosticFormatter:
|
||||
f"Рынок · "
|
||||
f"{self._market_title(data)}"
|
||||
),
|
||||
(
|
||||
f"• Данные: "
|
||||
f"{self._market_live_state(data.get('age_seconds'))}"
|
||||
),
|
||||
f"• Данные: {market_data_state}",
|
||||
]
|
||||
|
||||
if market_closed:
|
||||
@@ -1215,23 +1227,23 @@ class SemanticDiagnosticFormatter:
|
||||
).strip()
|
||||
|
||||
def _status_block(self, data: JsonDict) -> str:
|
||||
status = str(data.get("status") or "")
|
||||
status = str(data.get("status") or "").upper()
|
||||
|
||||
if status == "RUNNING":
|
||||
icon = "🟢"
|
||||
title = "работает"
|
||||
elif status == "OBSERVING":
|
||||
icon = "🟡"
|
||||
title = "наблюдение"
|
||||
icon = "👀"
|
||||
title = "под наблюдением"
|
||||
elif status == "OFF":
|
||||
icon = "⛔️"
|
||||
icon = "⚪️"
|
||||
title = "остановлена"
|
||||
else:
|
||||
icon = "⚪"
|
||||
icon = "⛔️"
|
||||
title = "не готова"
|
||||
|
||||
return (
|
||||
f"{icon} Автоторговля · {title}\n"
|
||||
f"{icon} Автоторговля {title}\n"
|
||||
f"• Актив: {self._format_system_symbol(data.get('symbol'))}\n"
|
||||
f"• Стратегия: {data.get('strategy') or '—'}\n"
|
||||
f"• Настроено: {self._bool(data.get('is_configured'))}"
|
||||
@@ -1319,7 +1331,7 @@ class SemanticDiagnosticFormatter:
|
||||
age_seconds = None
|
||||
|
||||
if age_seconds is None:
|
||||
add("Нет live-данных")
|
||||
add("Live-поток недоступен")
|
||||
elif age_seconds > 60:
|
||||
add("Данные рынка устарели")
|
||||
|
||||
@@ -1798,6 +1810,9 @@ class SemanticDiagnosticFormatter:
|
||||
):
|
||||
return "⛔️"
|
||||
|
||||
if data.get("age_seconds") is None:
|
||||
return "🟡"
|
||||
|
||||
if state == "UNKNOWN":
|
||||
return "⚪️"
|
||||
|
||||
@@ -1904,7 +1919,7 @@ class SemanticDiagnosticFormatter:
|
||||
seconds_float = safe_float(value)
|
||||
|
||||
if seconds_float is None:
|
||||
return "нет данных"
|
||||
return "REST"
|
||||
|
||||
seconds = int(seconds_float)
|
||||
|
||||
@@ -1991,4 +2006,10 @@ class SemanticDiagnosticFormatter:
|
||||
if not items:
|
||||
return ""
|
||||
|
||||
return "• Структура: " + " · ".join(items[:4])
|
||||
return "• Структура: " + " · ".join(items[:4])
|
||||
|
||||
def _runtime_exchange_block(
|
||||
self,
|
||||
data: JsonDict,
|
||||
) -> str:
|
||||
return format_runtime_exchange_alert(data)
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.core.numbers import safe_float
|
||||
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
|
||||
|
||||
|
||||
class SemanticDiagnosticSnapshotBuilder:
|
||||
@@ -38,6 +39,8 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
current_price=position_current_price,
|
||||
)
|
||||
|
||||
runtime_exchange_alerts = self._runtime_exchange_alerts(state)
|
||||
|
||||
return {
|
||||
"status": {
|
||||
"status": state.status,
|
||||
@@ -161,6 +164,12 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"adverse_momentum": position_health.get("adverse_momentum"),
|
||||
},
|
||||
"runtime_health": {
|
||||
"exchange_statuses": runtime_exchange_alerts,
|
||||
"exchange_status": (
|
||||
runtime_exchange_alerts[0]
|
||||
if runtime_exchange_alerts
|
||||
else None
|
||||
),
|
||||
"health_score": health_score,
|
||||
"severity": severity,
|
||||
"is_runtime_degraded": self._is_runtime_degraded(state),
|
||||
@@ -800,4 +809,10 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
return None
|
||||
|
||||
move = entry_price * (take_profit_percent / 100)
|
||||
return move * position_size
|
||||
return move * position_size
|
||||
|
||||
def _runtime_exchange_alerts(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> list[dict[str, Any]]:
|
||||
return build_runtime_exchange_alerts(symbol=state.symbol)
|
||||
142
app/src/trading/execution/calculations.py
Normal file
142
app/src/trading/execution/calculations.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# app/src/trading/execution/calculations.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
class _ExecutionCalculationsProtocol(Protocol):
|
||||
"""
|
||||
Protocol для доступа к shared position state.
|
||||
"""
|
||||
|
||||
_position: PositionState
|
||||
|
||||
|
||||
class ExecutionCalculationsMixin(
|
||||
_ExecutionCalculationsProtocol,
|
||||
):
|
||||
"""
|
||||
Execution math/calculation helpers.
|
||||
|
||||
Отвечает за:
|
||||
- pnl calculations
|
||||
- price move calculations
|
||||
- shared execution math helpers
|
||||
- execution timestamps
|
||||
"""
|
||||
|
||||
# =========================================================
|
||||
# PRICE MOVE %
|
||||
# =========================================================
|
||||
|
||||
def _calculate_price_move_percent(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
"""
|
||||
Рассчитать изменение цены относительно entry.
|
||||
|
||||
LONG:
|
||||
(current - entry) / entry
|
||||
|
||||
SHORT:
|
||||
(entry - current) / entry
|
||||
"""
|
||||
|
||||
position = type(self)._position
|
||||
|
||||
price = safe_float(current_price) or 0.0
|
||||
|
||||
entry = safe_float(
|
||||
position.entry_price
|
||||
) or 0.0
|
||||
|
||||
if entry <= 0:
|
||||
return 0.0
|
||||
|
||||
# -----------------------------------------------------
|
||||
# LONG
|
||||
# -----------------------------------------------------
|
||||
|
||||
if position.side == "LONG":
|
||||
return round(
|
||||
((price - entry) / entry) * 100,
|
||||
4,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# SHORT
|
||||
# -----------------------------------------------------
|
||||
|
||||
if position.side == "SHORT":
|
||||
return round(
|
||||
((entry - price) / entry) * 100,
|
||||
4,
|
||||
)
|
||||
|
||||
return 0.0
|
||||
|
||||
# =========================================================
|
||||
# PNL
|
||||
# =========================================================
|
||||
|
||||
def _calculate_pnl(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
"""
|
||||
Рассчитать unrealized pnl позиции.
|
||||
"""
|
||||
|
||||
position = type(self)._position
|
||||
|
||||
price = safe_float(current_price) or 0.0
|
||||
|
||||
entry = safe_float(
|
||||
position.entry_price
|
||||
) or 0.0
|
||||
|
||||
size = safe_float(
|
||||
position.size
|
||||
) or 0.0
|
||||
|
||||
# -----------------------------------------------------
|
||||
# LONG
|
||||
# -----------------------------------------------------
|
||||
|
||||
if position.side == "LONG":
|
||||
return round(
|
||||
(price - entry) * size,
|
||||
4,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# SHORT
|
||||
# -----------------------------------------------------
|
||||
|
||||
if position.side == "SHORT":
|
||||
return round(
|
||||
(entry - price) * size,
|
||||
4,
|
||||
)
|
||||
|
||||
return 0.0
|
||||
|
||||
# =========================================================
|
||||
# TIME
|
||||
# =========================================================
|
||||
|
||||
def _now_time(self) -> str:
|
||||
"""
|
||||
Current execution timestamp.
|
||||
"""
|
||||
|
||||
return datetime.now().strftime(
|
||||
"%H:%M:%S"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
446
app/src/trading/execution/flip.py
Normal file
446
app/src/trading/execution/flip.py
Normal file
@@ -0,0 +1,446 @@
|
||||
# app/src/trading/execution/flip.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
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
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
|
||||
|
||||
class _ExecutionFlipProtocol(Protocol):
|
||||
_position: PositionState
|
||||
_min_flip_confidence: float
|
||||
_min_flip_repeat_count: int
|
||||
_min_flip_hold_seconds: int
|
||||
_flip_cooldown_seconds: int
|
||||
_loss_flip_confidence: float
|
||||
_last_flip_block_key: str | None
|
||||
|
||||
def _create_trade_id(self, state: AutoTradeState, side: str) -> str: ...
|
||||
|
||||
# получить exit price для текущей стороны позиции
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
|
||||
|
||||
# получить entry price для новой стороны позиции
|
||||
def _entry_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
|
||||
|
||||
# рассчитать размер позиции
|
||||
def _calculate_position_size(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
entry_price: float | None = None,
|
||||
) -> float: ...
|
||||
|
||||
# ограничить размер позиции margin-limit правилом
|
||||
def _adjust_size_by_margin_limit(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
entry_price: float,
|
||||
size: float,
|
||||
) -> float: ...
|
||||
|
||||
# пересчитать effective risk после margin-limit
|
||||
def _sync_effective_risk_after_margin_limit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
base_size: float,
|
||||
final_size: float,
|
||||
) -> None: ...
|
||||
|
||||
# округлить размер позиции
|
||||
def _round_size(self, size) -> float: ...
|
||||
|
||||
# рассчитать PnL позиции
|
||||
def _calculate_pnl(self, current_price) -> float: ...
|
||||
|
||||
# синхронизировать AutoTradeState с PositionState
|
||||
def _sync_state_from_position(self, state: AutoTradeState) -> None: ...
|
||||
|
||||
# посчитать время удержания позиции
|
||||
def _position_hold_seconds(self, position: PositionState) -> int | None: ...
|
||||
|
||||
# получить текущее время строкой
|
||||
def _now_time(self) -> str: ...
|
||||
|
||||
|
||||
class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
# записать отказ flip execution в журнал
|
||||
def _log_flip_rejected(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
payload: JsonDict = {
|
||||
"execution_type": "FLIP_REJECTED",
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"signal": state.last_signal,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": state.last_signal_reason,
|
||||
"reject_reason": reason,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"opened_at": position.opened_at,
|
||||
"updated_at": position.updated_at,
|
||||
}
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="position_flip_rejected",
|
||||
message=f"Flip позиции отклонён: {reason}",
|
||||
screen="auto",
|
||||
action="paper_execution",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# проверить, нужен ли flip позиции по текущему сигналу
|
||||
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
|
||||
|
||||
# определить причину блокировки flip, если flip сейчас опасен
|
||||
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
|
||||
position = type(self)._position
|
||||
|
||||
confidence = safe_float(state.last_signal_confidence) or 0.0
|
||||
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
|
||||
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
|
||||
hold_seconds = self._position_hold_seconds(position)
|
||||
momentum_direction = getattr(state, "momentum_direction", None)
|
||||
momentum_state = getattr(state, "momentum_state", None)
|
||||
signal = (state.last_signal or "").upper()
|
||||
|
||||
if confidence < self._min_flip_confidence:
|
||||
return (
|
||||
"уверенность сигнала ниже порога "
|
||||
f"({confidence:.2f} < {self._min_flip_confidence:.2f})"
|
||||
)
|
||||
|
||||
if repeat_count < self._min_flip_repeat_count:
|
||||
return (
|
||||
"сигнал ещё не подтверждён нужным количеством повторов "
|
||||
f"({repeat_count} < {self._min_flip_repeat_count})"
|
||||
)
|
||||
|
||||
if hold_seconds is not None and hold_seconds < self._min_flip_hold_seconds:
|
||||
return (
|
||||
"позиция открыта слишком недавно "
|
||||
f"({hold_seconds}с < {self._min_flip_hold_seconds}с)"
|
||||
)
|
||||
|
||||
if self._flip_cooldown_active(state):
|
||||
return (
|
||||
"flip cooldown активен "
|
||||
f"(< {self._flip_cooldown_seconds}с)"
|
||||
)
|
||||
|
||||
if signal == "BUY" and momentum_direction == "DOWN":
|
||||
return "momentum направлен против BUY сигнала"
|
||||
|
||||
if signal == "SELL" and momentum_direction == "UP":
|
||||
return "momentum направлен против SELL сигнала"
|
||||
|
||||
if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}:
|
||||
if confidence < 0.85:
|
||||
return (
|
||||
"flip заблокирован во время breakout impulse "
|
||||
f"({confidence:.2f} < 0.85)"
|
||||
)
|
||||
|
||||
if unrealized_pnl < 0 and confidence < self._loss_flip_confidence:
|
||||
return (
|
||||
"позиция сейчас в минусе, а сигнал недостаточно сильный "
|
||||
f"({confidence:.2f} < {self._loss_flip_confidence:.2f})"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# записать блокировку flip в state, journal и event bus
|
||||
def _block_flip(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
) -> ExecutionDecision:
|
||||
position = type(self)._position
|
||||
confidence = safe_float(state.last_signal_confidence) or 0.0
|
||||
|
||||
state.execution_block_reason = reason
|
||||
state.last_flip_block_reason = reason
|
||||
state.last_execution_action = "FLIP_BLOCKED"
|
||||
state.last_execution_reason = reason
|
||||
|
||||
block_key = (
|
||||
f"{position.side}:"
|
||||
f"{state.last_signal}:"
|
||||
f"{state.last_signal_repeat_count}:"
|
||||
f"{confidence:.2f}:"
|
||||
f"{reason}"
|
||||
)
|
||||
|
||||
if block_key != type(self)._last_flip_block_key:
|
||||
type(self)._last_flip_block_key = block_key
|
||||
|
||||
payload: JsonDict = {
|
||||
"execution_type": "FLIP_BLOCKED",
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"signal": state.last_signal,
|
||||
"confidence": confidence,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": reason,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"opened_at": position.opened_at,
|
||||
"updated_at": position.updated_at,
|
||||
}
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="position_flip_blocked",
|
||||
message=f"Смена направления позиции заблокирована: {reason}.",
|
||||
screen="auto",
|
||||
action="paper_execution",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit("paper_flip_blocked", payload)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
# проверить, активен ли cooldown после последнего flip
|
||||
def _flip_cooldown_active(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> bool:
|
||||
ts = getattr(state, "last_flip_monotonic_at", None)
|
||||
|
||||
if ts is None:
|
||||
return False
|
||||
|
||||
return (
|
||||
time.monotonic() - float(ts)
|
||||
) < self._flip_cooldown_seconds
|
||||
|
||||
# определить сторону позиции по сигналу BUY / SELL
|
||||
def _target_side_from_signal(self, signal: str | None) -> str | None:
|
||||
if signal == "BUY":
|
||||
return "LONG"
|
||||
|
||||
if signal == "SELL":
|
||||
return "SHORT"
|
||||
|
||||
return None
|
||||
|
||||
# закрыть текущую позицию и открыть новую в противоположную сторону
|
||||
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
|
||||
position = type(self)._position
|
||||
|
||||
if position.side == "NONE":
|
||||
self._sync_state_from_position(state)
|
||||
reason = "Нет позиции для flip."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
new_side = self._target_side_from_signal(state.last_signal)
|
||||
|
||||
if new_side is None:
|
||||
reason = "Нет направления для flip."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
try:
|
||||
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:
|
||||
reason = f"Ошибка получения цены для flip: {exc}"
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
now = self._now_time()
|
||||
opened_monotonic_at = time.monotonic()
|
||||
pnl = self._calculate_pnl(exit_price)
|
||||
new_size = self._calculate_position_size(
|
||||
state,
|
||||
entry_price=new_entry_price,
|
||||
)
|
||||
|
||||
if new_size <= 0:
|
||||
reason = "Flip отменён: невозможно рассчитать adaptive size."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
new_size = self._adjust_size_by_margin_limit(
|
||||
state=state,
|
||||
entry_price=new_entry_price,
|
||||
size=new_size,
|
||||
)
|
||||
|
||||
self._sync_effective_risk_after_margin_limit(
|
||||
state,
|
||||
base_size=state.adaptive_size_base or 0.0,
|
||||
final_size=new_size,
|
||||
)
|
||||
|
||||
new_size = self._round_size(new_size)
|
||||
|
||||
if new_size <= 0:
|
||||
reason = "Flip отменён: итоговый size равен 0."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
state.realized_pnl_usd += pnl
|
||||
state.cycle_realized_pnl_usd += pnl
|
||||
state.cycle_closed_trades += 1
|
||||
|
||||
if pnl > 0:
|
||||
state.cycle_winning_trades += 1
|
||||
|
||||
old_side = position.side
|
||||
old_entry_price = position.entry_price
|
||||
old_size = position.size
|
||||
old_leverage = position.leverage
|
||||
old_opened_at = position.opened_at
|
||||
|
||||
state.last_flip_old_side = old_side
|
||||
state.last_flip_new_side = new_side
|
||||
state.last_flip_pnl_usd = pnl
|
||||
state.last_flip_reason = state.last_signal_reason
|
||||
state.last_flip_monotonic_at = time.monotonic()
|
||||
|
||||
old_trade_id = position.trade_id or state.current_trade_id
|
||||
old_trade_sequence = position.trade_sequence or state.trade_sequence
|
||||
old_trade_cycle_number = (
|
||||
position.trade_cycle_number
|
||||
or state.current_trade_cycle_number
|
||||
or state.cycle_number
|
||||
)
|
||||
|
||||
new_trade_id = self._create_trade_id(state, new_side)
|
||||
|
||||
state.current_trade_id = new_trade_id
|
||||
state.current_trade_cycle_number = state.cycle_number
|
||||
|
||||
type(self)._position = PositionState(
|
||||
trade_id=new_trade_id,
|
||||
trade_cycle_number=state.current_trade_cycle_number,
|
||||
trade_sequence=state.trade_sequence,
|
||||
side=new_side,
|
||||
symbol=state.symbol,
|
||||
entry_price=new_entry_price,
|
||||
size=new_size,
|
||||
leverage=state.leverage,
|
||||
unrealized_pnl_usd=0.0,
|
||||
opened_at=now,
|
||||
opened_monotonic_at=opened_monotonic_at,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
state.execution_block_reason = None
|
||||
state.last_flip_block_reason = None
|
||||
state.last_execution_action = f"FLIP_{old_side}_TO_{new_side}"
|
||||
state.last_execution_reason = "Направление позиции изменено."
|
||||
state.last_flip_at = now
|
||||
|
||||
type(self)._last_flip_block_key = None
|
||||
|
||||
payload: JsonDict = {
|
||||
"trade_id": old_trade_id,
|
||||
"closed_trade_id": old_trade_id,
|
||||
"new_trade_id": new_trade_id,
|
||||
"trade_sequence": old_trade_sequence,
|
||||
"trade_cycle_number": old_trade_cycle_number,
|
||||
"closed_trade_sequence": old_trade_sequence,
|
||||
"closed_trade_cycle_number": old_trade_cycle_number,
|
||||
"new_trade_sequence": state.trade_sequence,
|
||||
"new_trade_cycle_number": state.current_trade_cycle_number,
|
||||
"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": exit_price,
|
||||
"new_entry_price": new_entry_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,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"execution_confidence_level": state.execution_confidence_level,
|
||||
"execution_confidence_reason": state.execution_confidence_reason,
|
||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
||||
"adaptive_size_reason": state.adaptive_size_reason,
|
||||
"adaptive_size_factors": state.adaptive_size_factors,
|
||||
"effective_risk_percent": state.effective_risk_percent,
|
||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||
"adaptive_size_base": state.adaptive_size_base,
|
||||
"adaptive_size_final": state.adaptive_size_final,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": state.last_signal_reason,
|
||||
"opened_at": old_opened_at,
|
||||
"new_opened_monotonic_at": opened_monotonic_at,
|
||||
"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(
|
||||
event_type="position_flipped",
|
||||
message=f"Направление позиции изменено: {old_side} → {new_side}.",
|
||||
screen="auto",
|
||||
action="paper_execution",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit("paper_position_flipped", payload)
|
||||
|
||||
return ExecutionDecision(
|
||||
f"FLIP_{old_side}_TO_{new_side}",
|
||||
True,
|
||||
f"Направление позиции изменено: {old_side} → {new_side}.",
|
||||
)
|
||||
478
app/src/trading/execution/position_actions.py
Normal file
478
app/src/trading/execution/position_actions.py
Normal file
@@ -0,0 +1,478 @@
|
||||
# app/src/trading/execution/position_actions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.models import ExecutionDecision
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
class _ExecutionPositionActionsProtocol(Protocol):
|
||||
_position: PositionState
|
||||
_last_flip_block_key: str | None
|
||||
|
||||
# создать trade id
|
||||
def _create_trade_id(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
side: str,
|
||||
) -> str: ...
|
||||
|
||||
# получить entry execution price
|
||||
def _entry_price_for_side(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
) -> ExecutionPrice: ...
|
||||
|
||||
# получить exit execution price
|
||||
def _exit_price_for_side(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
) -> ExecutionPrice: ...
|
||||
|
||||
# рассчитать adaptive size
|
||||
def _calculate_position_size(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
entry_price: float | None = None,
|
||||
) -> float: ...
|
||||
|
||||
# ограничить size margin limit
|
||||
def _adjust_size_by_margin_limit(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
entry_price: float,
|
||||
size: float,
|
||||
) -> float: ...
|
||||
|
||||
# обновить effective risk после margin limit
|
||||
def _sync_effective_risk_after_margin_limit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
base_size: float,
|
||||
final_size: float,
|
||||
) -> None: ...
|
||||
|
||||
# округлить size
|
||||
def _round_size(self, size: NumericLike | None) -> float: ...
|
||||
|
||||
# синхронизировать state с position
|
||||
def _sync_state_from_position(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None: ...
|
||||
|
||||
# посчитать pnl
|
||||
def _calculate_pnl(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float: ...
|
||||
|
||||
# получить текущее время
|
||||
def _now_time(self) -> str: ...
|
||||
|
||||
# reset runtime protection state
|
||||
def _reset_runtime_protection_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ExecutionPositionActionsMixin(_ExecutionPositionActionsProtocol):
|
||||
# создать новый trade_id для связки open -> close
|
||||
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
|
||||
state.trade_sequence = int(state.trade_sequence or 0) + 1
|
||||
cycle_number = int(state.cycle_number or 0)
|
||||
|
||||
return (
|
||||
f"trade-{cycle_number}-"
|
||||
f"{state.trade_sequence}-"
|
||||
f"{side.lower()}-"
|
||||
f"{int(time.time())}"
|
||||
)
|
||||
|
||||
# записать отказ открытия позиции в журнал
|
||||
def _log_position_open_rejected(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
side: str,
|
||||
action: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
payload: JsonDict = {
|
||||
"execution_type": "ENTRY_REJECTED",
|
||||
"action": action,
|
||||
"symbol": state.symbol,
|
||||
"side": side,
|
||||
"signal": state.last_signal,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"execution_confidence_level": state.execution_confidence_level,
|
||||
"execution_confidence_reason": state.execution_confidence_reason,
|
||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
||||
"adaptive_size_reason": state.adaptive_size_reason,
|
||||
"adaptive_size_factors": state.adaptive_size_factors,
|
||||
"effective_risk_percent": state.effective_risk_percent,
|
||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||
"adaptive_size_base": state.adaptive_size_base,
|
||||
"adaptive_size_final": state.adaptive_size_final,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": state.last_signal_reason,
|
||||
"reject_reason": reason,
|
||||
}
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="position_open_rejected",
|
||||
message=f"Открытие позиции {side} отклонено: {reason}",
|
||||
screen="auto",
|
||||
action="paper_execution",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# открыть позицию, если сейчас позиции нет
|
||||
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)
|
||||
|
||||
if position.side == side:
|
||||
reason = f"Позиция {side} уже открыта."
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
reason = (
|
||||
f"Позиция уже открыта в другом направлении: "
|
||||
f"{position.side}, новый запрос: {side}."
|
||||
)
|
||||
|
||||
self._log_position_open_rejected(
|
||||
state=state,
|
||||
side=side,
|
||||
action=action,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
try:
|
||||
entry = self._entry_price_for_side(state.symbol, side)
|
||||
entry_price = entry.price
|
||||
|
||||
except Exception as exc:
|
||||
reason = f"Не удалось получить цену для paper execution: {exc}"
|
||||
|
||||
self._log_position_open_rejected(
|
||||
state=state,
|
||||
side=side,
|
||||
action=action,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
now = self._now_time()
|
||||
opened_monotonic_at = time.monotonic()
|
||||
|
||||
size = self._calculate_position_size(
|
||||
state,
|
||||
entry_price=entry_price,
|
||||
)
|
||||
|
||||
if size <= 0:
|
||||
reason = "Позиция не открыта: невозможно рассчитать adaptive size."
|
||||
|
||||
self._log_position_open_rejected(
|
||||
state=state,
|
||||
side=side,
|
||||
action=action,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
size = self._adjust_size_by_margin_limit(
|
||||
state=state,
|
||||
entry_price=entry_price,
|
||||
size=size,
|
||||
)
|
||||
|
||||
self._sync_effective_risk_after_margin_limit(
|
||||
state,
|
||||
base_size=state.adaptive_size_base or 0.0,
|
||||
final_size=size,
|
||||
)
|
||||
|
||||
size = self._round_size(size)
|
||||
|
||||
if size <= 0:
|
||||
reason = "Позиция не открыта: итоговый size равен 0."
|
||||
|
||||
self._log_position_open_rejected(
|
||||
state=state,
|
||||
side=side,
|
||||
action=action,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
|
||||
trade_id = self._create_trade_id(state, side)
|
||||
state.current_trade_id = trade_id
|
||||
state.current_trade_cycle_number = state.cycle_number
|
||||
|
||||
type(self)._position = PositionState(
|
||||
trade_id=trade_id,
|
||||
trade_cycle_number=state.current_trade_cycle_number,
|
||||
trade_sequence=state.trade_sequence,
|
||||
side=side,
|
||||
symbol=state.symbol,
|
||||
entry_price=entry_price,
|
||||
size=size,
|
||||
leverage=state.leverage,
|
||||
unrealized_pnl_usd=0.0,
|
||||
opened_at=now,
|
||||
opened_monotonic_at=opened_monotonic_at,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
state.execution_block_reason = None
|
||||
state.last_flip_block_reason = None
|
||||
state.last_execution_action = action
|
||||
state.last_execution_reason = f"Позиция {side} открыта."
|
||||
|
||||
payload: JsonDict = {
|
||||
"trade_id": trade_id,
|
||||
"trade_sequence": state.trade_sequence,
|
||||
"trade_cycle_number": state.current_trade_cycle_number,
|
||||
"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,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"execution_confidence_level": state.execution_confidence_level,
|
||||
"execution_confidence_reason": state.execution_confidence_reason,
|
||||
"adaptive_size_multiplier": state.adaptive_size_multiplier,
|
||||
"adaptive_size_reason": state.adaptive_size_reason,
|
||||
"adaptive_size_factors": state.adaptive_size_factors,
|
||||
"effective_risk_percent": state.effective_risk_percent,
|
||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||
"adaptive_size_base": state.adaptive_size_base,
|
||||
"adaptive_size_final": state.adaptive_size_final,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": state.last_signal_reason,
|
||||
"opened_at": now,
|
||||
"opened_monotonic_at": opened_monotonic_at,
|
||||
"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(
|
||||
event_type="position_opened",
|
||||
message=f"Позиция {side} открыта: {state.symbol}.",
|
||||
screen="auto",
|
||||
action="paper_execution",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit("paper_position_opened", payload)
|
||||
|
||||
return ExecutionDecision(action, True, f"Позиция {side} открыта.")
|
||||
|
||||
# закрыть открытую позицию
|
||||
def _close_position(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
forced_reason: str | None = None,
|
||||
forced_exit_price: NumericLike | None = None,
|
||||
forced_pnl: NumericLike | None = None,
|
||||
forced_price_meta: ExecutionPrice | 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 = safe_float(forced_exit_price) or 0.0
|
||||
exit_execution = forced_price_meta
|
||||
|
||||
else:
|
||||
try:
|
||||
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}",
|
||||
)
|
||||
|
||||
pnl = (
|
||||
safe_float(forced_pnl)
|
||||
if forced_pnl is not None
|
||||
else self._calculate_pnl(exit_price)
|
||||
)
|
||||
|
||||
if pnl is None:
|
||||
pnl = 0.0
|
||||
|
||||
state.realized_pnl_usd += pnl
|
||||
state.cycle_realized_pnl_usd += pnl
|
||||
state.cycle_closed_trades += 1
|
||||
|
||||
if pnl > 0:
|
||||
state.cycle_winning_trades += 1
|
||||
|
||||
if pnl < 0:
|
||||
state.last_loss_monotonic_at = time.monotonic()
|
||||
|
||||
now = self._now_time()
|
||||
|
||||
trade_id = (
|
||||
position.trade_id
|
||||
or state.current_trade_id
|
||||
)
|
||||
|
||||
payload: JsonDict = {
|
||||
"trade_id": trade_id,
|
||||
"trade_sequence": position.trade_sequence or state.trade_sequence,
|
||||
"trade_cycle_number": (
|
||||
position.trade_cycle_number
|
||||
or state.current_trade_cycle_number
|
||||
),
|
||||
"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,
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
||||
close_reason = forced_reason or "MANUAL"
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="position_closed",
|
||||
message=f"Позиция {position.side} закрыта: {close_reason}.",
|
||||
screen="auto",
|
||||
action="paper_execution",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit(
|
||||
"paper_position_closed",
|
||||
payload,
|
||||
)
|
||||
|
||||
type(self)._position = PositionState()
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
state.position_opened_monotonic_at = None
|
||||
state.current_trade_id = None
|
||||
state.current_trade_cycle_number = None
|
||||
|
||||
self._reset_runtime_protection_state(state)
|
||||
|
||||
state.execution_block_reason = None
|
||||
state.last_flip_block_reason = None
|
||||
|
||||
state.last_execution_action = (
|
||||
f"FORCE_CLOSE_{forced_reason}"
|
||||
if forced_reason is not None
|
||||
else "CLOSE"
|
||||
)
|
||||
|
||||
state.last_execution_reason = (
|
||||
f"Позиция закрыта по правилу защиты: {forced_reason}."
|
||||
if forced_reason is not None
|
||||
else "Позиция закрыта."
|
||||
)
|
||||
|
||||
type(self)._last_flip_block_key = None
|
||||
|
||||
if forced_reason is not None:
|
||||
return ExecutionDecision(
|
||||
f"FORCE_CLOSE_{forced_reason}",
|
||||
True,
|
||||
f"Позиция закрыта по правилу защиты: {forced_reason}.",
|
||||
)
|
||||
|
||||
return ExecutionDecision(
|
||||
"CLOSE",
|
||||
True,
|
||||
"Позиция закрыта.",
|
||||
)
|
||||
209
app/src/trading/execution/position_intelligence.py
Normal file
209
app/src/trading/execution/position_intelligence.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# app/src/trading/execution/position_intelligence.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
class _ExecutionPositionIntelligenceProtocol(Protocol):
|
||||
_position: PositionState
|
||||
|
||||
# посчитать изменение цены позиции в процентах
|
||||
def _calculate_price_move_percent(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
...
|
||||
|
||||
# посчитать время удержания позиции в секундах
|
||||
def _position_hold_seconds(
|
||||
self,
|
||||
position: PositionState,
|
||||
) -> int | None:
|
||||
...
|
||||
|
||||
|
||||
class ExecutionPositionIntelligenceMixin(_ExecutionPositionIntelligenceProtocol):
|
||||
# определить причину закрытия позиции по position intelligence
|
||||
def _runtime_intelligence_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> str | None:
|
||||
giveback_reason = self._giveback_close_reason(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
if giveback_reason is not None:
|
||||
return giveback_reason
|
||||
|
||||
time_decay_reason = self._time_decay_close_reason(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
if time_decay_reason is not None:
|
||||
return time_decay_reason
|
||||
|
||||
return None
|
||||
|
||||
# определить закрытие по возврату прибыли от пика
|
||||
def _giveback_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> str | None:
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
|
||||
peak_percent = safe_float(
|
||||
getattr(state, "position_peak_pnl_percent", None)
|
||||
)
|
||||
|
||||
if peak_percent is None or peak_percent <= 0:
|
||||
return None
|
||||
|
||||
if pnl_percent is None:
|
||||
return None
|
||||
|
||||
giveback = peak_percent - pnl_percent
|
||||
|
||||
if giveback <= 0:
|
||||
return None
|
||||
|
||||
giveback_percent = round((giveback / peak_percent) * 100, 2)
|
||||
|
||||
fatigue_state = str(
|
||||
getattr(state, "position_fatigue_state", "") or ""
|
||||
).upper()
|
||||
|
||||
reversal_risk = str(
|
||||
getattr(state, "position_reversal_risk", "") or ""
|
||||
).upper()
|
||||
|
||||
adverse_momentum = bool(
|
||||
getattr(state, "position_adverse_momentum", False)
|
||||
)
|
||||
|
||||
exit_confidence = safe_float(
|
||||
getattr(state, "position_exit_confidence", None)
|
||||
) or 0.0
|
||||
|
||||
if (
|
||||
peak_percent >= 0.75
|
||||
and giveback_percent >= 55
|
||||
and pnl_percent > 0
|
||||
):
|
||||
return "GIVEBACK_PROTECTION"
|
||||
|
||||
if (
|
||||
peak_percent >= 0.50
|
||||
and giveback_percent >= 40
|
||||
and adverse_momentum
|
||||
):
|
||||
return "GIVEBACK_MOMENTUM_REVERSAL"
|
||||
|
||||
if (
|
||||
peak_percent >= 0.50
|
||||
and giveback_percent >= 35
|
||||
and fatigue_state in {"TIRED", "EXHAUSTED"}
|
||||
):
|
||||
return "GIVEBACK_FATIGUE_EXIT"
|
||||
|
||||
if (
|
||||
peak_percent >= 0.50
|
||||
and giveback_percent >= 35
|
||||
and reversal_risk in {"ELEVATED", "HIGH"}
|
||||
and exit_confidence >= 0.50
|
||||
):
|
||||
return "GIVEBACK_REVERSAL_RISK"
|
||||
|
||||
return None
|
||||
|
||||
# определить закрытие по устареванию позиции во времени
|
||||
def _time_decay_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> str | None:
|
||||
hold_seconds = safe_float(
|
||||
getattr(state, "position_hold_seconds", None)
|
||||
)
|
||||
|
||||
if hold_seconds is None:
|
||||
hold_seconds = safe_float(
|
||||
self._position_hold_seconds(type(self)._position)
|
||||
)
|
||||
|
||||
if hold_seconds is None:
|
||||
return None
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
|
||||
fatigue_state = str(
|
||||
getattr(state, "position_fatigue_state", "") or ""
|
||||
).upper()
|
||||
|
||||
conviction_state = str(
|
||||
getattr(state, "position_conviction_state", "") or ""
|
||||
).upper()
|
||||
|
||||
decay_state = str(
|
||||
getattr(state, "position_decay_state", "") or ""
|
||||
).upper()
|
||||
|
||||
adverse_momentum = bool(
|
||||
getattr(state, "position_adverse_momentum", False)
|
||||
)
|
||||
|
||||
market_runtime_degraded = bool(
|
||||
getattr(state, "market_runtime_degraded", False)
|
||||
)
|
||||
|
||||
if pnl_percent is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
hold_seconds >= 2400
|
||||
and -0.15 <= pnl_percent <= 0.25
|
||||
and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"}
|
||||
):
|
||||
return "TIME_DECAY_EXIT"
|
||||
|
||||
if (
|
||||
hold_seconds >= 1800
|
||||
and -0.20 <= pnl_percent <= 0.35
|
||||
and fatigue_state in {"TIRED", "EXHAUSTED"}
|
||||
):
|
||||
return "TIME_DECAY_FATIGUE_EXIT"
|
||||
|
||||
if (
|
||||
hold_seconds >= 1200
|
||||
and pnl_percent <= 0.20
|
||||
and adverse_momentum
|
||||
):
|
||||
return "TIME_DECAY_ADVERSE_MOMENTUM"
|
||||
|
||||
if (
|
||||
hold_seconds >= 1200
|
||||
and pnl_percent <= 0.30
|
||||
and market_runtime_degraded
|
||||
):
|
||||
return "TIME_DECAY_DEGRADED_MARKET"
|
||||
|
||||
if (
|
||||
hold_seconds >= 1800
|
||||
and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"}
|
||||
and pnl_percent <= 0.30
|
||||
):
|
||||
return "TIME_DECAY_CONTEXT_DECAY"
|
||||
|
||||
return None
|
||||
398
app/src/trading/execution/position_protection.py
Normal file
398
app/src/trading/execution/position_protection.py
Normal file
@@ -0,0 +1,398 @@
|
||||
# app/src/trading/execution/position_protection.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import ClassVar, Protocol
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.models import ExecutionDecision
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
class _ExecutionPositionProtectionProtocol(Protocol):
|
||||
_position: ClassVar[PositionState]
|
||||
|
||||
# получить цену закрытия позиции по стороне
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice:
|
||||
...
|
||||
|
||||
# посчитать PnL позиции
|
||||
def _calculate_pnl(self, current_price: NumericLike | None) -> float:
|
||||
...
|
||||
|
||||
# посчитать движение цены от входа в процентах
|
||||
def _calculate_price_move_percent(self, current_price: NumericLike | None) -> float:
|
||||
...
|
||||
|
||||
# закрыть позицию
|
||||
def _close_position(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
forced_reason: str | None = None,
|
||||
forced_exit_price: NumericLike | None = None,
|
||||
forced_pnl: NumericLike | None = None,
|
||||
forced_price_meta: ExecutionPrice | None = None,
|
||||
) -> ExecutionDecision:
|
||||
...
|
||||
|
||||
# сбросить состояние runtime-защиты
|
||||
def _reset_runtime_protection_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
# получить intelligence-причину закрытия позиции
|
||||
def _runtime_intelligence_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> str | None:
|
||||
...
|
||||
|
||||
|
||||
class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
# обработать runtime-защиту открытой позиции
|
||||
def _process_runtime_protection(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> ExecutionDecision | None:
|
||||
position = type(self)._position
|
||||
|
||||
if position.side == "NONE":
|
||||
self._reset_runtime_protection_state(state)
|
||||
return None
|
||||
|
||||
try:
|
||||
current_execution = self._exit_price_for_side(
|
||||
position.symbol or state.symbol,
|
||||
position.side,
|
||||
)
|
||||
current_price = current_execution.price
|
||||
except Exception:
|
||||
self._sync_runtime_protection_state(
|
||||
state=state,
|
||||
status="DEGRADED",
|
||||
reason="нет актуальной цены для protection engine",
|
||||
)
|
||||
return None
|
||||
|
||||
self._sync_runtime_protection_state(
|
||||
state=state,
|
||||
status="ACTIVE",
|
||||
reason="protection engine активен",
|
||||
)
|
||||
|
||||
self._update_break_even_protection(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
self._update_profit_lock_protection(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
self._update_trailing_stop_protection(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
close_reason = self._runtime_protection_close_reason(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
if close_reason is None:
|
||||
close_reason = self._runtime_intelligence_close_reason(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
if close_reason is None:
|
||||
return None
|
||||
|
||||
pnl = self._calculate_pnl(current_price)
|
||||
|
||||
return self._close_position(
|
||||
state,
|
||||
forced_reason=close_reason,
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=pnl,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
# синхронизировать состояние protection engine
|
||||
def _sync_runtime_protection_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
status: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
state.position_protection_status = status
|
||||
state.position_protection_reason = reason
|
||||
state.runtime_protection_updated_at = time.monotonic()
|
||||
|
||||
# активировать break-even защиту
|
||||
def _update_break_even_protection(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
if state.break_even_armed:
|
||||
return
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
|
||||
if pnl_percent < 0.35:
|
||||
return
|
||||
|
||||
entry_price = safe_float(position.entry_price)
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return
|
||||
|
||||
state.break_even_armed = True
|
||||
state.break_even_price = entry_price
|
||||
state.runtime_protection_action = "BREAK_EVEN_ARMED"
|
||||
state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован"
|
||||
state.runtime_protection_updated_at = time.monotonic()
|
||||
|
||||
self._log_runtime_protection_event(
|
||||
state=state,
|
||||
action="BREAK_EVEN_ARMED",
|
||||
reason=state.runtime_protection_reason,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
# активировать profit lock защиту
|
||||
def _update_profit_lock_protection(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
|
||||
if pnl_percent < 0.75:
|
||||
return
|
||||
|
||||
entry_price = safe_float(position.entry_price)
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return
|
||||
|
||||
if position.side == "LONG":
|
||||
lock_price = entry_price * 1.003
|
||||
elif position.side == "SHORT":
|
||||
lock_price = entry_price * 0.997
|
||||
else:
|
||||
return
|
||||
|
||||
previous_price = safe_float(state.profit_lock_price)
|
||||
|
||||
if previous_price is not None:
|
||||
if position.side == "LONG" and lock_price <= previous_price:
|
||||
return
|
||||
|
||||
if position.side == "SHORT" and lock_price >= previous_price:
|
||||
return
|
||||
|
||||
state.profit_lock_active = True
|
||||
state.profit_lock_price = round(lock_price, 8)
|
||||
state.runtime_protection_action = "PROFIT_LOCK_ACTIVE"
|
||||
state.runtime_protection_reason = "часть прибыли защищена profit lock"
|
||||
state.runtime_protection_updated_at = time.monotonic()
|
||||
|
||||
self._log_runtime_protection_event(
|
||||
state=state,
|
||||
action="PROFIT_LOCK_ACTIVE",
|
||||
reason=state.runtime_protection_reason,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
# активировать trailing stop защиту
|
||||
def _update_trailing_stop_protection(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
|
||||
if pnl_percent < 1.0:
|
||||
return
|
||||
|
||||
trail_distance_percent = 0.35
|
||||
|
||||
if position.side == "LONG":
|
||||
trail_price = current_price * (1 - trail_distance_percent / 100)
|
||||
previous_price = safe_float(state.trailing_stop_price)
|
||||
|
||||
if previous_price is not None and trail_price <= previous_price:
|
||||
return
|
||||
|
||||
elif position.side == "SHORT":
|
||||
trail_price = current_price * (1 + trail_distance_percent / 100)
|
||||
previous_price = safe_float(state.trailing_stop_price)
|
||||
|
||||
if previous_price is not None and trail_price >= previous_price:
|
||||
return
|
||||
|
||||
else:
|
||||
return
|
||||
|
||||
state.trailing_stop_active = True
|
||||
state.trailing_stop_price = round(trail_price, 8)
|
||||
state.runtime_protection_action = "TRAILING_STOP_ACTIVE"
|
||||
state.runtime_protection_reason = "trailing stop подтянут вслед за прибылью"
|
||||
state.runtime_protection_updated_at = time.monotonic()
|
||||
|
||||
self._log_runtime_protection_event(
|
||||
state=state,
|
||||
action="TRAILING_STOP_ACTIVE",
|
||||
reason=state.runtime_protection_reason,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
# определить причину закрытия по защите
|
||||
def _runtime_protection_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> str | None:
|
||||
position = type(self)._position
|
||||
|
||||
fatigue_state = str(getattr(state, "position_fatigue_state", "") or "").upper()
|
||||
reversal_risk = str(getattr(state, "position_reversal_risk", "") or "").upper()
|
||||
exit_urgency = str(getattr(state, "position_exit_urgency", "") or "").upper()
|
||||
conviction = str(getattr(state, "position_conviction_state", "") or "").upper()
|
||||
risk_level = str(getattr(state, "position_risk_level", "") or "").upper()
|
||||
exit_signal = str(getattr(state, "position_exit_signal", "") or "").upper()
|
||||
decay_state = str(getattr(state, "position_decay_state", "") or "").upper()
|
||||
|
||||
if exit_urgency == "IMMEDIATE":
|
||||
return "LIFECYCLE_EXIT"
|
||||
|
||||
if conviction == "BROKEN":
|
||||
return "CONVICTION_BROKEN"
|
||||
|
||||
if fatigue_state == "EXHAUSTED" and reversal_risk in {"ELEVATED", "HIGH"}:
|
||||
return "FATIGUE_EXIT"
|
||||
|
||||
if (
|
||||
state.position_adverse_momentum
|
||||
and reversal_risk == "HIGH"
|
||||
and risk_level in {"ELEVATED", "HIGH"}
|
||||
):
|
||||
return "MOMENTUM_EXIT"
|
||||
|
||||
if (
|
||||
getattr(state, "market_runtime_degraded", False)
|
||||
and exit_signal in {"EXIT", "REDUCE_OR_PROTECT"}
|
||||
and decay_state != "NONE"
|
||||
):
|
||||
return "DEGRADATION_EXIT"
|
||||
|
||||
if position.side == "LONG":
|
||||
if (
|
||||
state.trailing_stop_active
|
||||
and state.trailing_stop_price is not None
|
||||
and current_price <= state.trailing_stop_price
|
||||
):
|
||||
return "TRAILING_STOP"
|
||||
|
||||
if (
|
||||
state.profit_lock_active
|
||||
and state.profit_lock_price is not None
|
||||
and current_price <= state.profit_lock_price
|
||||
):
|
||||
return "PROFIT_LOCK"
|
||||
|
||||
if (
|
||||
state.break_even_armed
|
||||
and state.break_even_price is not None
|
||||
and current_price <= state.break_even_price
|
||||
):
|
||||
return "BREAK_EVEN"
|
||||
|
||||
if position.side == "SHORT":
|
||||
if (
|
||||
state.trailing_stop_active
|
||||
and state.trailing_stop_price is not None
|
||||
and current_price >= state.trailing_stop_price
|
||||
):
|
||||
return "TRAILING_STOP"
|
||||
|
||||
if (
|
||||
state.profit_lock_active
|
||||
and state.profit_lock_price is not None
|
||||
and current_price >= state.profit_lock_price
|
||||
):
|
||||
return "PROFIT_LOCK"
|
||||
|
||||
if (
|
||||
state.break_even_armed
|
||||
and state.break_even_price is not None
|
||||
and current_price >= state.break_even_price
|
||||
):
|
||||
return "BREAK_EVEN"
|
||||
|
||||
return None
|
||||
|
||||
# записать событие runtime-защиты в журнал
|
||||
def _log_runtime_protection_event(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
action: str,
|
||||
reason: str,
|
||||
current_price: float,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
payload: JsonDict = {
|
||||
"execution_type": "RUNTIME_PROTECTION",
|
||||
"action": action,
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"entry_price": position.entry_price,
|
||||
"current_price": current_price,
|
||||
"size": position.size,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"position_pnl_percent": self._calculate_price_move_percent(current_price),
|
||||
"break_even_armed": state.break_even_armed,
|
||||
"break_even_price": state.break_even_price,
|
||||
"profit_lock_active": state.profit_lock_active,
|
||||
"profit_lock_price": state.profit_lock_price,
|
||||
"trailing_stop_active": state.trailing_stop_active,
|
||||
"trailing_stop_price": state.trailing_stop_price,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="runtime_protection_updated",
|
||||
message=f"Runtime protection: {action}. {reason}.",
|
||||
screen="auto",
|
||||
action="runtime_protection",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit("runtime_protection_updated", payload)
|
||||
317
app/src/trading/execution/position_runtime.py
Normal file
317
app/src/trading/execution/position_runtime.py
Normal file
@@ -0,0 +1,317 @@
|
||||
# app/src/trading/execution/position_runtime.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from src.core.types import NumericLike
|
||||
from src.core.numbers import safe_float
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.position.state import PositionState
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
|
||||
|
||||
class _ExecutionRuntimeProtocol(Protocol):
|
||||
_position: PositionState
|
||||
|
||||
def _calculate_pnl(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float: ...
|
||||
|
||||
def _calculate_price_move_percent(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float: ...
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
|
||||
def _now_time(self) -> str: ...
|
||||
|
||||
|
||||
class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
# получить текущую paper-позицию
|
||||
def get_position(self) -> PositionState:
|
||||
return type(self)._position
|
||||
|
||||
# обновить unrealized PnL и runtime-память позиции
|
||||
def _update_unrealized_pnl(self, state: AutoTradeState) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
if position.side == "NONE":
|
||||
self._sync_state_from_position(state)
|
||||
return
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
pnl = self._calculate_pnl(current_price)
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
|
||||
position.unrealized_pnl_usd = pnl
|
||||
position.updated_at = self._now_time()
|
||||
|
||||
if position.peak_unrealized_pnl_usd is None or pnl > position.peak_unrealized_pnl_usd:
|
||||
position.peak_unrealized_pnl_usd = pnl
|
||||
|
||||
if position.peak_pnl_percent is None or pnl_percent > position.peak_pnl_percent:
|
||||
position.peak_pnl_percent = pnl_percent
|
||||
|
||||
if position.max_favorable_excursion_percent is None:
|
||||
position.max_favorable_excursion_percent = max(0.0, pnl_percent)
|
||||
else:
|
||||
position.max_favorable_excursion_percent = max(
|
||||
position.max_favorable_excursion_percent,
|
||||
pnl_percent,
|
||||
)
|
||||
|
||||
if position.max_adverse_excursion_percent is None:
|
||||
position.max_adverse_excursion_percent = min(0.0, pnl_percent)
|
||||
else:
|
||||
position.max_adverse_excursion_percent = min(
|
||||
position.max_adverse_excursion_percent,
|
||||
pnl_percent,
|
||||
)
|
||||
|
||||
self._sync_position_runtime_memory(
|
||||
position=position,
|
||||
current_price=current_price,
|
||||
pnl_percent=pnl_percent,
|
||||
)
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
# синхронизировать AutoTradeState с текущей paper-позицией
|
||||
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
|
||||
|
||||
if position.side == "NONE":
|
||||
state.position_opened_monotonic_at = None
|
||||
state.position_peak_pnl_usd = None
|
||||
state.position_peak_pnl_percent = None
|
||||
state.position_mfe_percent = None
|
||||
state.position_mae_percent = None
|
||||
state.position_fatigue_score = None
|
||||
state.position_fatigue_state = None
|
||||
state.position_giveback_percent = None
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = None
|
||||
return
|
||||
|
||||
state.position_opened_monotonic_at = position.opened_monotonic_at
|
||||
state.position_peak_pnl_usd = position.peak_unrealized_pnl_usd
|
||||
state.position_peak_pnl_percent = position.peak_pnl_percent
|
||||
state.position_mfe_percent = position.max_favorable_excursion_percent
|
||||
state.position_mae_percent = position.max_adverse_excursion_percent
|
||||
state.position_fatigue_score = position.fatigue_score
|
||||
state.position_fatigue_state = position.fatigue_state
|
||||
|
||||
# обновить best/worst price и fatigue state позиции
|
||||
def _sync_position_runtime_memory(
|
||||
self,
|
||||
*,
|
||||
position: PositionState,
|
||||
current_price: float,
|
||||
pnl_percent: float,
|
||||
) -> None:
|
||||
if position.best_price_seen is None:
|
||||
position.best_price_seen = current_price
|
||||
|
||||
if position.worst_price_seen is None:
|
||||
position.worst_price_seen = current_price
|
||||
|
||||
if position.side == "LONG":
|
||||
position.best_price_seen = max(position.best_price_seen, current_price)
|
||||
position.worst_price_seen = min(position.worst_price_seen, current_price)
|
||||
|
||||
elif position.side == "SHORT":
|
||||
position.best_price_seen = min(position.best_price_seen, current_price)
|
||||
position.worst_price_seen = max(position.worst_price_seen, current_price)
|
||||
|
||||
peak = safe_float(position.peak_pnl_percent) or 0.0
|
||||
giveback_score = 0.0
|
||||
|
||||
if peak > 0:
|
||||
giveback = max(0.0, peak - pnl_percent)
|
||||
giveback_score = min(1.0, giveback / max(0.01, peak))
|
||||
|
||||
fatigue = 0.0
|
||||
|
||||
if giveback_score >= 0.70:
|
||||
fatigue += 0.35
|
||||
elif giveback_score >= 0.45:
|
||||
fatigue += 0.25
|
||||
elif giveback_score >= 0.25:
|
||||
fatigue += 0.12
|
||||
|
||||
if pnl_percent < 0:
|
||||
fatigue += 0.20
|
||||
|
||||
position.fatigue_score = round(max(0.0, min(1.0, fatigue)), 3)
|
||||
|
||||
if position.fatigue_score >= 0.75:
|
||||
position.fatigue_state = "EXHAUSTED"
|
||||
elif position.fatigue_score >= 0.50:
|
||||
position.fatigue_state = "TIRED"
|
||||
elif position.fatigue_score >= 0.25:
|
||||
position.fatigue_state = "WATCH"
|
||||
else:
|
||||
position.fatigue_state = "FRESH"
|
||||
|
||||
# посчитать время удержания позиции в секундах
|
||||
def _position_hold_seconds(self, position: PositionState) -> int | None:
|
||||
opened_monotonic_at = safe_float(
|
||||
getattr(position, "opened_monotonic_at", None)
|
||||
)
|
||||
|
||||
if opened_monotonic_at is not None:
|
||||
return max(0, int(time.monotonic() - opened_monotonic_at))
|
||||
|
||||
if not position.opened_at:
|
||||
return None
|
||||
|
||||
try:
|
||||
opened_at = datetime.strptime(position.opened_at, "%H:%M:%S")
|
||||
now = datetime.strptime(self._now_time(), "%H:%M:%S")
|
||||
|
||||
seconds = int((now - opened_at).total_seconds())
|
||||
|
||||
if seconds < 0:
|
||||
seconds += 24 * 60 * 60
|
||||
|
||||
return seconds
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# обновить runtime-метрики позиции по текущей цене
|
||||
def _refresh_position_runtime_metrics(
|
||||
self,
|
||||
*,
|
||||
position: PositionState,
|
||||
current_price: float,
|
||||
) -> None:
|
||||
price_move_percent = self._calculate_price_move_percent(current_price)
|
||||
pnl = safe_float(position.unrealized_pnl_usd)
|
||||
|
||||
if pnl is not None:
|
||||
peak_pnl = safe_float(position.peak_unrealized_pnl_usd)
|
||||
|
||||
if peak_pnl is None or pnl > peak_pnl:
|
||||
position.peak_unrealized_pnl_usd = pnl
|
||||
|
||||
peak_percent = safe_float(position.peak_pnl_percent)
|
||||
|
||||
if peak_percent is None or price_move_percent > peak_percent:
|
||||
position.peak_pnl_percent = price_move_percent
|
||||
|
||||
mfe = safe_float(position.max_favorable_excursion_percent)
|
||||
mae = safe_float(position.max_adverse_excursion_percent)
|
||||
|
||||
if mfe is None or price_move_percent > mfe:
|
||||
position.max_favorable_excursion_percent = price_move_percent
|
||||
|
||||
if mae is None or price_move_percent < mae:
|
||||
position.max_adverse_excursion_percent = price_move_percent
|
||||
|
||||
best_price = safe_float(position.best_price_seen)
|
||||
worst_price = safe_float(position.worst_price_seen)
|
||||
|
||||
if best_price is None:
|
||||
position.best_price_seen = current_price
|
||||
elif position.side == "LONG" and current_price > best_price:
|
||||
position.best_price_seen = current_price
|
||||
elif position.side == "SHORT" and current_price < best_price:
|
||||
position.best_price_seen = current_price
|
||||
|
||||
if worst_price is None:
|
||||
position.worst_price_seen = current_price
|
||||
elif position.side == "LONG" and current_price < worst_price:
|
||||
position.worst_price_seen = current_price
|
||||
elif position.side == "SHORT" and current_price > worst_price:
|
||||
position.worst_price_seen = current_price
|
||||
|
||||
fatigue_score = self._runtime_fatigue_score(position)
|
||||
position.fatigue_score = fatigue_score
|
||||
position.fatigue_state = self._runtime_fatigue_state(fatigue_score)
|
||||
|
||||
# рассчитать fatigue score позиции
|
||||
def _runtime_fatigue_score(self, position: PositionState) -> float:
|
||||
score = 0.0
|
||||
|
||||
mfe = safe_float(position.max_favorable_excursion_percent) or 0.0
|
||||
current_peak = safe_float(position.peak_pnl_percent) or 0.0
|
||||
mae = safe_float(position.max_adverse_excursion_percent) or 0.0
|
||||
|
||||
hold_seconds = 0
|
||||
|
||||
opened_at = safe_float(position.opened_monotonic_at)
|
||||
if opened_at is not None:
|
||||
hold_seconds = max(0, int(time.monotonic() - opened_at))
|
||||
|
||||
if hold_seconds >= 1800:
|
||||
score += 0.25
|
||||
elif hold_seconds >= 900:
|
||||
score += 0.15
|
||||
elif hold_seconds >= 300:
|
||||
score += 0.08
|
||||
|
||||
if mfe > 0 and current_peak > 0:
|
||||
giveback = max(0.0, mfe - current_peak)
|
||||
|
||||
if giveback >= 0.75:
|
||||
score += 0.25
|
||||
elif giveback >= 0.45:
|
||||
score += 0.18
|
||||
elif giveback >= 0.25:
|
||||
score += 0.10
|
||||
|
||||
if mae <= -1.0:
|
||||
score += 0.25
|
||||
elif mae <= -0.5:
|
||||
score += 0.15
|
||||
|
||||
return round(max(0.0, min(1.0, score)), 3)
|
||||
|
||||
# преобразовать fatigue score в semantic state
|
||||
def _runtime_fatigue_state(self, score: float | None) -> str:
|
||||
value = safe_float(score)
|
||||
|
||||
if value is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
if value >= 0.75:
|
||||
return "EXHAUSTED"
|
||||
|
||||
if value >= 0.50:
|
||||
return "TIRED"
|
||||
|
||||
if value >= 0.25:
|
||||
return "WATCH"
|
||||
|
||||
return "FRESH"
|
||||
|
||||
# сбросить lifecycle-метрики позиции в AutoTradeState
|
||||
def _reset_position_lifecycle_state(self, state: AutoTradeState) -> None:
|
||||
state.position_peak_pnl_usd = None
|
||||
state.position_peak_pnl_percent = None
|
||||
state.position_mfe_percent = None
|
||||
state.position_mae_percent = None
|
||||
state.position_fatigue_score = None
|
||||
state.position_fatigue_state = None
|
||||
state.position_giveback_percent = None
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = None
|
||||
134
app/src/trading/execution/pricing.py
Normal file
134
app/src/trading/execution/pricing.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# app/src/trading/execution/pricing.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ExecutionPrice:
|
||||
price: float
|
||||
source: str
|
||||
age_seconds: float | None
|
||||
updated_at: str
|
||||
pricing_role: str
|
||||
|
||||
|
||||
class ExecutionPricingMixin:
|
||||
# получить цену входа по текущему сигналу
|
||||
def _signal_entry_price(self, state: AutoTradeState) -> ExecutionPrice:
|
||||
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) -> ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
|
||||
if snapshot.age_seconds is not None and snapshot.age_seconds > 5:
|
||||
raise ValueError("Execution snapshot is stale.")
|
||||
|
||||
if side == "LONG":
|
||||
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 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 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) -> ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
|
||||
if snapshot.age_seconds is not None and snapshot.age_seconds > 5:
|
||||
raise ValueError("Execution snapshot is stale.")
|
||||
|
||||
if side == "LONG":
|
||||
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 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 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) -> ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
# проверить и нормализовать цену из execution snapshot
|
||||
def _snapshot_price(
|
||||
self,
|
||||
raw_price: NumericLike | None,
|
||||
name: str,
|
||||
) -> float:
|
||||
if raw_price is None:
|
||||
raise ValueError(
|
||||
f"Execution snapshot price '{name}' is missing."
|
||||
)
|
||||
|
||||
price = safe_float(raw_price)
|
||||
|
||||
if price is None:
|
||||
raise ValueError(
|
||||
f"Execution snapshot price '{name}' is invalid."
|
||||
)
|
||||
|
||||
if price <= 0:
|
||||
raise ValueError(
|
||||
f"Execution snapshot price '{name}' is invalid: {price}"
|
||||
)
|
||||
|
||||
return price
|
||||
74
app/src/trading/execution/resets.py
Normal file
74
app/src/trading/execution/resets.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# app/src/trading/execution/resets.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
class _ExecutionResetsProtocol(Protocol):
|
||||
"""
|
||||
Protocol для reset mixin.
|
||||
|
||||
Сейчас пустой, но оставлен для единообразия архитектуры.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ExecutionResetsMixin(_ExecutionResetsProtocol):
|
||||
"""
|
||||
Общие reset-функции execution слоя.
|
||||
|
||||
Здесь находятся методы очистки runtime/protection/
|
||||
lifecycle состояния позиции.
|
||||
|
||||
Это позволяет избежать циклических зависимостей между:
|
||||
- position_actions.py
|
||||
- position_protection.py
|
||||
- runtime_actions.py
|
||||
"""
|
||||
|
||||
def _reset_runtime_protection_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
"""
|
||||
Полный reset runtime protection состояния позиции.
|
||||
Вызывается после закрытия позиции.
|
||||
"""
|
||||
|
||||
state.position_protection_status = None
|
||||
state.position_protection_reason = None
|
||||
|
||||
state.break_even_armed = False
|
||||
state.break_even_price = None
|
||||
|
||||
state.trailing_stop_active = False
|
||||
state.trailing_stop_price = None
|
||||
|
||||
state.profit_lock_active = False
|
||||
state.profit_lock_price = None
|
||||
|
||||
state.runtime_protection_action = None
|
||||
state.runtime_protection_reason = None
|
||||
state.runtime_protection_updated_at = None
|
||||
|
||||
def _reset_position_lifecycle_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
"""
|
||||
Reset lifecycle состояния позиции.
|
||||
Используется после полного закрытия позиции.
|
||||
"""
|
||||
|
||||
state.position_opened_monotonic_at = None
|
||||
|
||||
state.last_flip_old_side = None
|
||||
state.last_flip_new_side = None
|
||||
state.last_flip_pnl_usd = None
|
||||
state.last_flip_reason = None
|
||||
|
||||
state.execution_block_reason = None
|
||||
state.last_flip_block_reason = None
|
||||
121
app/src/trading/execution/risk_close.py
Normal file
121
app/src/trading/execution/risk_close.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# app/src/trading/execution/risk_close.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.models import ExecutionDecision
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
class _ExecutionRiskCloseProtocol(Protocol):
|
||||
_position: PositionState
|
||||
|
||||
# получить цену выхода для стороны позиции
|
||||
def _exit_price_for_side(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
) -> ExecutionPrice: ...
|
||||
|
||||
# посчитать движение цены позиции в процентах
|
||||
def _calculate_price_move_percent(self, current_price) -> float: ...
|
||||
|
||||
# посчитать текущий PnL позиции
|
||||
def _calculate_pnl(self, current_price) -> float: ...
|
||||
|
||||
# закрыть открытую позицию
|
||||
def _close_position(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
forced_reason: str | None = None,
|
||||
forced_exit_price=None,
|
||||
forced_pnl=None,
|
||||
forced_price_meta: ExecutionPrice | None = None,
|
||||
) -> ExecutionDecision: ...
|
||||
|
||||
|
||||
class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol):
|
||||
# проверить, нужно ли закрыть позицию по max loss / stop loss / take profit
|
||||
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
|
||||
position = type(self)._position
|
||||
|
||||
if position.side == "NONE":
|
||||
return None
|
||||
|
||||
try:
|
||||
current_execution = self._exit_price_for_side(
|
||||
position.symbol or state.symbol,
|
||||
position.side,
|
||||
)
|
||||
current_price = current_execution.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_max_loss_hit(state, unrealized_pnl):
|
||||
return self._close_position(
|
||||
state,
|
||||
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):
|
||||
return self._close_position(
|
||||
state,
|
||||
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):
|
||||
return self._close_position(
|
||||
state,
|
||||
forced_reason="TAKE_PROFIT",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# проверить, достигнут ли stop loss в процентах
|
||||
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)
|
||||
|
||||
# проверить, достигнут ли take profit в процентах
|
||||
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)
|
||||
|
||||
# проверить, достигнут ли максимальный убыток в USD
|
||||
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)
|
||||
309
app/src/trading/execution/runtime_actions.py
Normal file
309
app/src/trading/execution/runtime_actions.py
Normal file
@@ -0,0 +1,309 @@
|
||||
# app/src/trading/execution/runtime_actions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
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 _ExecutionRuntimeActionsProtocol(Protocol):
|
||||
_position: PositionState
|
||||
|
||||
def _sync_state_from_position(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None: ...
|
||||
|
||||
def _close_position(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
forced_reason: str | None = None,
|
||||
) -> ExecutionDecision: ...
|
||||
|
||||
|
||||
class ExecutionRuntimeActionsMixin(
|
||||
_ExecutionRuntimeActionsProtocol
|
||||
):
|
||||
"""
|
||||
Runtime autonomous actions subsystem.
|
||||
|
||||
Отвечает за:
|
||||
- runtime EXIT
|
||||
- runtime REDUCE
|
||||
- runtime PROTECT
|
||||
- cooldown runtime действий
|
||||
- runtime logging
|
||||
"""
|
||||
|
||||
_runtime_action_cooldown_seconds = 30
|
||||
_last_runtime_action_key: str | None = None
|
||||
|
||||
# =========================================================
|
||||
# PUBLIC
|
||||
# =========================================================
|
||||
|
||||
def process_runtime_action(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> ExecutionDecision:
|
||||
"""
|
||||
Главный runtime action processor.
|
||||
"""
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
position = type(self)._position
|
||||
|
||||
if state.status != "RUNNING":
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Runtime action доступен только в режиме RUNNING.",
|
||||
)
|
||||
|
||||
if position.side == "NONE":
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Нет открытой позиции для runtime action.",
|
||||
)
|
||||
|
||||
action = str(
|
||||
getattr(state, "autonomous_action", "") or ""
|
||||
).upper()
|
||||
|
||||
confidence = safe_float(
|
||||
getattr(state, "autonomous_action_confidence", None)
|
||||
) or 0.0
|
||||
|
||||
reason = str(
|
||||
getattr(state, "autonomous_action_reason", "") or ""
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# NO ACTION
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action in {"", "HOLD", "WATCH"}:
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Runtime action не требуется.",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# COOLDOWN
|
||||
# -----------------------------------------------------
|
||||
|
||||
if self._runtime_action_cooldown_active(state, action):
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Runtime action cooldown активен.",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# PROTECT
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action == "PROTECT":
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action="PROTECT",
|
||||
reason=reason or "позиция требует защиты",
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# REDUCE
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action == "REDUCE":
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action="REDUCE",
|
||||
reason=reason or "позиция требует уменьшения",
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# EXIT
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action == "EXIT":
|
||||
|
||||
if confidence < 0.75:
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action="EXIT_BLOCKED",
|
||||
reason=(
|
||||
"autonomous exit заблокирован: "
|
||||
f"confidence {confidence:.2f} < 0.75"
|
||||
),
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
)
|
||||
|
||||
decision = self._close_position(
|
||||
state,
|
||||
forced_reason="AUTONOMOUS_EXIT",
|
||||
)
|
||||
|
||||
state.autonomous_last_action = "EXIT"
|
||||
state.autonomous_last_action_reason = (
|
||||
reason or decision.reason
|
||||
)
|
||||
state.autonomous_last_action_at = (
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
return decision
|
||||
|
||||
# -----------------------------------------------------
|
||||
# UNKNOWN ACTION
|
||||
# -----------------------------------------------------
|
||||
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
f"Неизвестный runtime action: {action}.",
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# COOLDOWN
|
||||
# =========================================================
|
||||
|
||||
def _runtime_action_cooldown_active(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Проверка cooldown runtime action.
|
||||
"""
|
||||
|
||||
ts = safe_float(
|
||||
getattr(state, "autonomous_last_action_at", None)
|
||||
)
|
||||
|
||||
last_action = str(
|
||||
getattr(state, "autonomous_last_action", "") or ""
|
||||
).upper()
|
||||
|
||||
if ts is None:
|
||||
return False
|
||||
|
||||
if last_action != action:
|
||||
return False
|
||||
|
||||
return (
|
||||
time.monotonic() - ts
|
||||
) < self._runtime_action_cooldown_seconds
|
||||
|
||||
# =========================================================
|
||||
# LOGGING
|
||||
# =========================================================
|
||||
|
||||
def _log_runtime_action(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
action: str,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
executed: bool,
|
||||
) -> ExecutionDecision:
|
||||
"""
|
||||
Runtime action logging + deduplication.
|
||||
"""
|
||||
|
||||
position = type(self)._position
|
||||
|
||||
key = (
|
||||
f"{state.symbol}:"
|
||||
f"{position.side}:"
|
||||
f"{action}:"
|
||||
f"{reason}:"
|
||||
f"{confidence:.2f}"
|
||||
)
|
||||
|
||||
if key != type(self)._last_runtime_action_key:
|
||||
|
||||
type(self)._last_runtime_action_key = key
|
||||
|
||||
payload: JsonDict = {
|
||||
"execution_type": "RUNTIME_ACTION",
|
||||
"action": action,
|
||||
"executed": executed,
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"entry_price": position.entry_price,
|
||||
"size": position.size,
|
||||
"unrealized_pnl_usd": (
|
||||
state.unrealized_pnl_usd
|
||||
),
|
||||
"position_health_status": getattr(
|
||||
state,
|
||||
"position_health_status",
|
||||
None,
|
||||
),
|
||||
"position_risk_level": getattr(
|
||||
state,
|
||||
"position_risk_level",
|
||||
None,
|
||||
),
|
||||
"position_exit_signal": getattr(
|
||||
state,
|
||||
"position_exit_signal",
|
||||
None,
|
||||
),
|
||||
"position_exit_confidence": getattr(
|
||||
state,
|
||||
"position_exit_confidence",
|
||||
None,
|
||||
),
|
||||
"autonomous_action": getattr(
|
||||
state,
|
||||
"autonomous_action",
|
||||
None,
|
||||
),
|
||||
"confidence": confidence,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="runtime_position_action",
|
||||
message=(
|
||||
f"Runtime action: {action}. "
|
||||
f"Причина: {reason}."
|
||||
),
|
||||
screen="auto",
|
||||
action="runtime_position_action",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit(
|
||||
"runtime_position_action",
|
||||
payload,
|
||||
)
|
||||
|
||||
state.autonomous_last_action = action
|
||||
state.autonomous_last_action_reason = reason
|
||||
state.autonomous_last_action_at = time.monotonic()
|
||||
|
||||
return ExecutionDecision(
|
||||
action,
|
||||
executed,
|
||||
reason,
|
||||
)
|
||||
405
app/src/trading/execution/sizing.py
Normal file
405
app/src/trading/execution/sizing.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# app/src/trading/execution/sizing.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
|
||||
|
||||
class _ExecutionSizingProtocol(Protocol):
|
||||
_size_precision: int
|
||||
|
||||
# получить цену входа по текущему сигналу
|
||||
def _signal_entry_price(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> ExecutionPrice:
|
||||
...
|
||||
|
||||
# округлить размер позиции
|
||||
def _round_size(
|
||||
self,
|
||||
size: NumericLike | None,
|
||||
) -> float:
|
||||
...
|
||||
|
||||
|
||||
class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
# рассчитать итоговый размер позиции с учётом риска и adaptive multiplier
|
||||
def _calculate_position_size(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
entry_price: float | None = None,
|
||||
) -> float:
|
||||
if state.risk_percent is None or state.risk_percent <= 0:
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=0.0,
|
||||
final_size=0.0,
|
||||
multiplier=0.0,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
if state.stop_loss_percent is None or state.stop_loss_percent <= 0:
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=0.0,
|
||||
final_size=0.0,
|
||||
multiplier=0.0,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
price = entry_price
|
||||
|
||||
if price is None:
|
||||
try:
|
||||
price = self._signal_entry_price(state).price
|
||||
except Exception:
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=0.0,
|
||||
final_size=0.0,
|
||||
multiplier=0.0,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
if price <= 0:
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=0.0,
|
||||
final_size=0.0,
|
||||
multiplier=0.0,
|
||||
)
|
||||
return 0.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)
|
||||
|
||||
if stop_loss_distance_usd <= 0:
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=0.0,
|
||||
final_size=0.0,
|
||||
multiplier=0.0,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
base_size = target_risk_usd / stop_loss_distance_usd
|
||||
multiplier = self._adaptive_size_multiplier(state)
|
||||
final_size = base_size * multiplier
|
||||
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=base_size,
|
||||
final_size=final_size,
|
||||
multiplier=multiplier,
|
||||
)
|
||||
|
||||
return self._round_size(final_size)
|
||||
|
||||
# рассчитать коэффициент изменения размера позиции по runtime/context факторам
|
||||
def _adaptive_size_multiplier(self, state: AutoTradeState) -> float:
|
||||
multiplier = 1.0
|
||||
|
||||
execution_confidence_score = getattr(
|
||||
state,
|
||||
"execution_confidence_score",
|
||||
None,
|
||||
)
|
||||
score_raw = safe_float(execution_confidence_score)
|
||||
|
||||
if score_raw is not None:
|
||||
score = max(0.0, min(1.0, score_raw))
|
||||
|
||||
if score < 0.55:
|
||||
multiplier *= 0.0
|
||||
elif score < 0.65:
|
||||
multiplier *= 0.65
|
||||
elif score < 0.75:
|
||||
multiplier *= 0.85
|
||||
elif score >= 0.85:
|
||||
multiplier *= 1.15
|
||||
|
||||
market_state = getattr(state, "market_state", None)
|
||||
market_trend_strength = getattr(state, "market_trend_strength", None)
|
||||
market_trend_quality = getattr(state, "market_trend_quality", None)
|
||||
market_phase = getattr(state, "market_phase", None)
|
||||
|
||||
if market_state in {
|
||||
"HIGH_VOLATILITY",
|
||||
"LOW_VOLATILITY",
|
||||
"RANGE",
|
||||
"CHAOTIC",
|
||||
"LIQUIDITY_VOID",
|
||||
}:
|
||||
multiplier *= 0.65
|
||||
|
||||
if market_trend_strength == "STRONG":
|
||||
multiplier *= 1.1
|
||||
elif market_trend_strength == "WEAK":
|
||||
multiplier *= 0.75
|
||||
|
||||
if market_trend_quality == "CLEAN":
|
||||
multiplier *= 1.05
|
||||
elif market_trend_quality == "NOISY":
|
||||
multiplier *= 0.75
|
||||
|
||||
if market_phase == "IMPULSE":
|
||||
multiplier *= 1.1
|
||||
elif market_phase == "PULLBACK":
|
||||
multiplier *= 0.8
|
||||
elif market_phase in {"RANGE", "SQUEEZE"}:
|
||||
multiplier *= 0.7
|
||||
|
||||
momentum_state = getattr(state, "momentum_state", None)
|
||||
momentum_direction = getattr(state, "momentum_direction", None)
|
||||
momentum_strength = getattr(state, "momentum_strength", None)
|
||||
|
||||
signal = (state.last_signal or "").upper()
|
||||
|
||||
if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}:
|
||||
multiplier *= 1.15
|
||||
elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}:
|
||||
multiplier *= 1.05
|
||||
|
||||
strength = safe_float(momentum_strength)
|
||||
|
||||
if strength is not None:
|
||||
if strength >= 1.5:
|
||||
multiplier *= 1.1
|
||||
elif strength <= 0.7:
|
||||
multiplier *= 0.8
|
||||
|
||||
if signal == "BUY" and momentum_direction == "DOWN":
|
||||
multiplier *= 0.65
|
||||
|
||||
if signal == "SELL" and momentum_direction == "UP":
|
||||
multiplier *= 0.65
|
||||
|
||||
execution_quality = getattr(state, "execution_quality", None)
|
||||
execution_quality_reason = getattr(
|
||||
state,
|
||||
"execution_quality_reason",
|
||||
None,
|
||||
)
|
||||
|
||||
if execution_quality == "BLOCKED":
|
||||
multiplier *= 0.0
|
||||
elif execution_quality == "WARNING":
|
||||
if execution_quality_reason == "WIDE_SPREAD":
|
||||
multiplier *= 0.75
|
||||
elif execution_quality_reason == "AGING_SNAPSHOT":
|
||||
multiplier *= 0.8
|
||||
elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE":
|
||||
multiplier *= 0.7
|
||||
else:
|
||||
multiplier *= 0.8
|
||||
|
||||
if getattr(state, "market_runtime_degraded", False):
|
||||
multiplier *= 0.75
|
||||
|
||||
return round(max(0.0, min(1.25, multiplier)), 4)
|
||||
|
||||
# синхронизировать рассчитанный adaptive size в AutoTradeState
|
||||
def _sync_adaptive_size_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
base_size: float,
|
||||
final_size: float,
|
||||
multiplier: float,
|
||||
) -> None:
|
||||
reason = self._adaptive_size_reason(multiplier)
|
||||
|
||||
state.adaptive_size_base = self._round_size(base_size)
|
||||
state.adaptive_size_final = self._round_size(final_size)
|
||||
state.adaptive_size_multiplier = multiplier
|
||||
|
||||
if multiplier != 1:
|
||||
state.adaptive_size_changed_at = time.monotonic()
|
||||
|
||||
base_risk_percent = safe_float(state.risk_percent) or 0.0
|
||||
|
||||
state.effective_risk_percent = round(
|
||||
base_risk_percent * multiplier,
|
||||
4,
|
||||
)
|
||||
|
||||
state.effective_target_risk_usd = round(
|
||||
state.allocated_balance_usd
|
||||
* (state.effective_risk_percent / 100),
|
||||
4,
|
||||
)
|
||||
|
||||
state.adaptive_size_reason = reason
|
||||
state.adaptive_size_factors = {
|
||||
"execution_confidence_score": getattr(
|
||||
state,
|
||||
"execution_confidence_score",
|
||||
None,
|
||||
),
|
||||
"execution_confidence_level": getattr(
|
||||
state,
|
||||
"execution_confidence_level",
|
||||
None,
|
||||
),
|
||||
"market_state": getattr(state, "market_state", None),
|
||||
"market_trend_strength": getattr(
|
||||
state,
|
||||
"market_trend_strength",
|
||||
None,
|
||||
),
|
||||
"market_trend_quality": getattr(
|
||||
state,
|
||||
"market_trend_quality",
|
||||
None,
|
||||
),
|
||||
"market_phase": getattr(state, "market_phase", None),
|
||||
"momentum_state": getattr(state, "momentum_state", None),
|
||||
"momentum_direction": getattr(
|
||||
state,
|
||||
"momentum_direction",
|
||||
None,
|
||||
),
|
||||
"momentum_strength": getattr(
|
||||
state,
|
||||
"momentum_strength",
|
||||
None,
|
||||
),
|
||||
"execution_quality": getattr(state, "execution_quality", None),
|
||||
"execution_quality_reason": getattr(
|
||||
state,
|
||||
"execution_quality_reason",
|
||||
None,
|
||||
),
|
||||
"spread_percent": getattr(state, "spread_percent", None),
|
||||
"base_size": self._round_size(base_size),
|
||||
"final_size": self._round_size(final_size),
|
||||
"multiplier": multiplier,
|
||||
}
|
||||
|
||||
if multiplier <= 0:
|
||||
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_ZERO"
|
||||
elif multiplier < 1:
|
||||
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_REDUCED"
|
||||
elif multiplier > 1:
|
||||
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_INCREASED"
|
||||
else:
|
||||
state.execution_size_adjustment_reason = None
|
||||
|
||||
# пересчитать effective risk после ограничения размера по margin limit
|
||||
def _sync_effective_risk_after_margin_limit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
base_size: float,
|
||||
final_size: float,
|
||||
) -> None:
|
||||
adaptive_final = safe_float(state.adaptive_size_final) or 0.0
|
||||
|
||||
if adaptive_final <= 0:
|
||||
state.effective_risk_percent = 0.0
|
||||
state.effective_target_risk_usd = 0.0
|
||||
return
|
||||
|
||||
margin_ratio = max(
|
||||
0.0,
|
||||
min(1.0, final_size / adaptive_final),
|
||||
)
|
||||
|
||||
current_effective_risk = safe_float(
|
||||
state.effective_risk_percent
|
||||
) or 0.0
|
||||
|
||||
state.effective_risk_percent = round(
|
||||
current_effective_risk * margin_ratio,
|
||||
4,
|
||||
)
|
||||
|
||||
state.effective_target_risk_usd = round(
|
||||
state.allocated_balance_usd
|
||||
* (state.effective_risk_percent / 100),
|
||||
4,
|
||||
)
|
||||
|
||||
# вернуть текстовую причину изменения adaptive size
|
||||
def _adaptive_size_reason(self, multiplier: float) -> str:
|
||||
if multiplier <= 0:
|
||||
return "adaptive size заблокировал вход"
|
||||
|
||||
if multiplier < 0.75:
|
||||
return "размер позиции сильно уменьшен по risk/runtime факторам"
|
||||
|
||||
if multiplier < 1:
|
||||
return "размер позиции умеренно уменьшен по risk/runtime факторам"
|
||||
|
||||
if multiplier > 1:
|
||||
return "размер позиции увеличен при сильном execution context"
|
||||
|
||||
return "размер позиции без adaptive корректировки"
|
||||
|
||||
# ограничить размер позиции по максимальному резервированию баланса
|
||||
def _adjust_size_by_margin_limit(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
entry_price: float,
|
||||
size: float,
|
||||
) -> float:
|
||||
max_percent = state.max_reserved_balance_percent
|
||||
|
||||
if max_percent is None or max_percent <= 0:
|
||||
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 = 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 self._round_size(size)
|
||||
|
||||
state.execution_size_adjustment_reason = "MARGIN_LIMIT"
|
||||
|
||||
limited_size = self._round_size(max_size)
|
||||
|
||||
adaptive_final = safe_float(state.adaptive_size_final) or 0.0
|
||||
|
||||
if adaptive_final > 0:
|
||||
effective_multiplier = limited_size / adaptive_final
|
||||
|
||||
if effective_multiplier < 0.5:
|
||||
state.adaptive_size_reason = (
|
||||
"размер позиции сильно ограничен margin limit"
|
||||
)
|
||||
else:
|
||||
state.adaptive_size_reason = (
|
||||
"размер позиции ограничен margin limit"
|
||||
)
|
||||
|
||||
return limited_size
|
||||
|
||||
# округлить размер позиции вниз до допустимой точности
|
||||
def _round_size(self, size: NumericLike | None) -> float:
|
||||
value = safe_float(size)
|
||||
|
||||
if value is None:
|
||||
return 0.0
|
||||
|
||||
factor = 10 ** self._size_precision
|
||||
return math.floor(value * factor) / factor
|
||||
172
app/src/trading/execution/supervisor.py
Normal file
172
app/src/trading/execution/supervisor.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# app/src/trading/execution/supervisor.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.models import ExecutionDecision
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
class _ExecutionSupervisorProtocol(Protocol):
|
||||
_emergency_halt_drawdown_usd: float
|
||||
_emergency_halt_loss_streak: int
|
||||
_execution_cooldown_after_loss_seconds: int
|
||||
_max_execution_snapshot_age_seconds: int
|
||||
_degraded_market_block_states: set[str]
|
||||
_conflict_execution_block: bool
|
||||
|
||||
|
||||
class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
# проверить все supervisor-блокировки перед исполнением
|
||||
def _process_execution_supervisor(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> ExecutionDecision | None:
|
||||
for reason, action in (
|
||||
(self._execution_halt_reason(state), "EXECUTION_HALTED"),
|
||||
(self._execution_cooldown_reason(state), "EXECUTION_COOLDOWN"),
|
||||
(self._degraded_market_reason(state), "DEGRADED_MARKET"),
|
||||
(self._stale_execution_reason(state), "STALE_EXECUTION"),
|
||||
(self._conflict_signal_reason(state), "SIGNAL_CONFLICT"),
|
||||
):
|
||||
if reason is not None:
|
||||
return self._block_execution(
|
||||
state=state,
|
||||
reason=reason,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# определить, нужно ли аварийно остановить execution
|
||||
def _execution_halt_reason(self, state: AutoTradeState) -> str | None:
|
||||
pnl = safe_float(state.cycle_realized_pnl_usd) or 0.0
|
||||
|
||||
if pnl <= -abs(self._emergency_halt_drawdown_usd):
|
||||
return "execution emergency halt: cycle drawdown limit exceeded"
|
||||
|
||||
closed = safe_float(state.cycle_closed_trades) or 0
|
||||
wins = safe_float(state.cycle_winning_trades) or 0
|
||||
losses = max(0, int(closed - wins))
|
||||
|
||||
if losses >= self._emergency_halt_loss_streak:
|
||||
return "execution emergency halt: loss streak exceeded"
|
||||
|
||||
return None
|
||||
|
||||
# определить, активен ли cooldown после убыточной сделки
|
||||
def _execution_cooldown_reason(self, state: AutoTradeState) -> str | None:
|
||||
ts = safe_float(getattr(state, "last_loss_monotonic_at", None))
|
||||
|
||||
if ts is None:
|
||||
return None
|
||||
|
||||
delta = time.monotonic() - ts
|
||||
|
||||
if delta < self._execution_cooldown_after_loss_seconds:
|
||||
remaining = int(self._execution_cooldown_after_loss_seconds - delta)
|
||||
return f"execution cooldown after loss ({remaining}s remaining)"
|
||||
|
||||
return None
|
||||
|
||||
# определить, запрещает ли состояние рынка исполнение
|
||||
def _degraded_market_reason(self, state: AutoTradeState) -> str | None:
|
||||
market_state = getattr(state, "market_state", None)
|
||||
|
||||
if market_state in self._degraded_market_block_states:
|
||||
return f"market state blocked execution: {market_state}"
|
||||
|
||||
return None
|
||||
|
||||
# определить, устарели ли данные для исполнения
|
||||
def _stale_execution_reason(self, state: AutoTradeState) -> str | None:
|
||||
age = safe_float(getattr(state, "execution_price_age_seconds", None))
|
||||
|
||||
if age is None:
|
||||
age = safe_float(getattr(state, "snapshot_age_seconds", None))
|
||||
|
||||
if age is None:
|
||||
return None
|
||||
|
||||
if age > self._max_execution_snapshot_age_seconds:
|
||||
return f"execution snapshot stale: {age:.2f}s"
|
||||
|
||||
return None
|
||||
|
||||
# определить конфликт сигнала с momentum или трендом
|
||||
def _conflict_signal_reason(self, state: AutoTradeState) -> str | None:
|
||||
if not self._conflict_execution_block:
|
||||
return None
|
||||
|
||||
signal = (state.last_signal or "").upper()
|
||||
momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper()
|
||||
trend_direction = str(getattr(state, "market_trend", "") or "").upper()
|
||||
|
||||
if signal == "BUY":
|
||||
if momentum_direction == "DOWN":
|
||||
return "BUY conflicts with momentum"
|
||||
|
||||
if trend_direction == "DOWN":
|
||||
return "BUY conflicts with trend"
|
||||
|
||||
if signal == "SELL":
|
||||
if momentum_direction == "UP":
|
||||
return "SELL conflicts with momentum"
|
||||
|
||||
if trend_direction == "UP":
|
||||
return "SELL conflicts with trend"
|
||||
|
||||
return None
|
||||
|
||||
# заблокировать execution и записать событие в журнал
|
||||
def _block_execution(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
action: str,
|
||||
) -> ExecutionDecision:
|
||||
state.execution_block_reason = reason
|
||||
state.last_execution_action = action
|
||||
state.last_execution_reason = reason
|
||||
|
||||
key_reason = reason
|
||||
|
||||
if action == "EXECUTION_COOLDOWN":
|
||||
key_reason = "execution cooldown after loss"
|
||||
|
||||
key = f"{action}:{state.symbol}:{key_reason}"
|
||||
last_key = getattr(type(self), "_last_supervisor_block_key", None)
|
||||
|
||||
if key != last_key:
|
||||
setattr(type(self), "_last_supervisor_block_key", key)
|
||||
|
||||
payload: JsonDict = {
|
||||
"execution_type": "SUPERVISOR_BLOCK",
|
||||
"action": action,
|
||||
"symbol": state.symbol,
|
||||
"reason": reason,
|
||||
"market_state": getattr(state, "market_state", None),
|
||||
"signal": state.last_signal,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"cycle_realized_pnl_usd": state.cycle_realized_pnl_usd,
|
||||
}
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="execution_supervisor_block",
|
||||
message=f"Execution supervisor blocked action: {reason}",
|
||||
screen="auto",
|
||||
action="execution_supervisor",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit("execution_supervisor_block", payload)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
@@ -135,6 +135,7 @@ def _metadata_rows(
|
||||
export_limit: int,
|
||||
account_mode: str,
|
||||
journal_level: str,
|
||||
export_filter_label: str = "Всё",
|
||||
) -> list[list[str]]:
|
||||
exported_count = len(rows)
|
||||
is_limited = total_count > exported_count
|
||||
@@ -143,6 +144,7 @@ def _metadata_rows(
|
||||
["Экспорт журнала"],
|
||||
["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")],
|
||||
["Аккаунт", account_mode.upper()],
|
||||
["Фильтр", export_filter_label],
|
||||
["Уровень журнала", journal_level],
|
||||
["Всего записей в журнале", str(total_count)],
|
||||
["Записей в файле", str(exported_count)],
|
||||
@@ -161,6 +163,7 @@ def build_csv(
|
||||
export_limit: int,
|
||||
account_mode: str,
|
||||
journal_level: str,
|
||||
export_filter_label: str = "Всё",
|
||||
) -> bytes:
|
||||
output = StringIO()
|
||||
writer = csv.writer(
|
||||
@@ -176,6 +179,7 @@ def build_csv(
|
||||
export_limit=export_limit,
|
||||
account_mode=account_mode,
|
||||
journal_level=journal_level,
|
||||
export_filter_label=export_filter_label,
|
||||
):
|
||||
writer.writerow(metadata_row)
|
||||
|
||||
@@ -194,6 +198,7 @@ def build_xlsx(
|
||||
export_limit: int,
|
||||
account_mode: str,
|
||||
journal_level: str,
|
||||
export_filter_label: str = "Всё",
|
||||
) -> bytes:
|
||||
sheet_rows: list[list[str]] = []
|
||||
|
||||
@@ -204,6 +209,7 @@ def build_xlsx(
|
||||
export_limit=export_limit,
|
||||
account_mode=account_mode,
|
||||
journal_level=journal_level,
|
||||
export_filter_label=export_filter_label,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
64
app/src/trading/journal/filters.py
Normal file
64
app/src/trading/journal/filters.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# app/src/trading/journal/filters.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JournalExportFilter:
|
||||
# key используется в callback_data и имени файла
|
||||
key: str
|
||||
|
||||
# label показываем в UI и metadata экспорта
|
||||
label: str
|
||||
|
||||
# description можно использовать позже в UI/подсказках
|
||||
description: str
|
||||
|
||||
|
||||
JOURNAL_EXPORT_FILTERS: dict[str, JournalExportFilter] = {
|
||||
"all": JournalExportFilter(
|
||||
key="all",
|
||||
label="Всё",
|
||||
description="Все записи журнала.",
|
||||
),
|
||||
"auto": JournalExportFilter(
|
||||
key="auto",
|
||||
label="Автоторговля",
|
||||
description="События автоторговли, сигналов, execution и runtime.",
|
||||
),
|
||||
"trades": JournalExportFilter(
|
||||
key="trades",
|
||||
label="Сделки",
|
||||
description="Открытия, закрытия, flip и trade-события.",
|
||||
),
|
||||
"errors": JournalExportFilter(
|
||||
key="errors",
|
||||
label="Ошибки",
|
||||
description="ERROR, CRITICAL и важные WARNING.",
|
||||
),
|
||||
"not_auto": JournalExportFilter(
|
||||
key="not_auto",
|
||||
label="Без авто",
|
||||
description="Все записи, кроме автоторговли.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_journal_export_filter(value: str | None) -> str:
|
||||
# Защита от неизвестных callback_data.
|
||||
key = str(value or "all").strip().lower()
|
||||
|
||||
if key in JOURNAL_EXPORT_FILTERS:
|
||||
return key
|
||||
|
||||
return "all"
|
||||
|
||||
|
||||
def get_journal_export_filter(value: str | None) -> JournalExportFilter:
|
||||
return JOURNAL_EXPORT_FILTERS[normalize_journal_export_filter(value)]
|
||||
|
||||
|
||||
def journal_export_filter_label(value: str | None) -> str:
|
||||
return get_journal_export_filter(value).label
|
||||
@@ -10,6 +10,11 @@ from src.core.config import load_settings
|
||||
from src.storage.repositories.journal import JournalRepository
|
||||
from src.storage.session import check_database_health
|
||||
from src.trading.journal.exporter import build_csv, build_xlsx
|
||||
from src.trading.journal.filters import (
|
||||
journal_export_filter_label,
|
||||
normalize_journal_export_filter,
|
||||
)
|
||||
|
||||
|
||||
EXPORT_LIMIT = 10000
|
||||
|
||||
@@ -201,8 +206,17 @@ class JournalService:
|
||||
def get_total_count(self) -> int:
|
||||
return self.repository.count_events()
|
||||
|
||||
def get_export_rows(self, limit: int = EXPORT_LIMIT) -> list[dict[str, Any]]:
|
||||
return self.repository.list_export_rows(limit=limit)
|
||||
def get_export_rows(
|
||||
self,
|
||||
limit: int = EXPORT_LIMIT,
|
||||
export_filter: str = "all",
|
||||
) -> list[dict[str, Any]]:
|
||||
filter_key = normalize_journal_export_filter(export_filter)
|
||||
|
||||
return self.repository.list_export_rows(
|
||||
limit=limit,
|
||||
export_filter=filter_key,
|
||||
)
|
||||
|
||||
def _journal_level(self) -> str:
|
||||
return "INFO+"
|
||||
@@ -216,20 +230,34 @@ class JournalService:
|
||||
|
||||
return now.strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
def build_export_filename(self, extension: str) -> str:
|
||||
def build_export_filename(
|
||||
self,
|
||||
extension: str,
|
||||
export_filter: str = "all",
|
||||
) -> str:
|
||||
safe_extension = extension.lower().strip().lstrip(".")
|
||||
safe_level = self._journal_level().lower().replace("+", "_plus")
|
||||
safe_filter = normalize_journal_export_filter(export_filter)
|
||||
|
||||
return (
|
||||
f"journal_"
|
||||
f"{self._account_mode()}_"
|
||||
f"{safe_filter}_"
|
||||
f"{safe_level}_"
|
||||
f"{self._export_timestamp()}."
|
||||
f"{safe_extension}"
|
||||
)
|
||||
|
||||
def export_csv(self, limit: int = EXPORT_LIMIT) -> bytes:
|
||||
rows = self.get_export_rows(limit=limit)
|
||||
def export_csv(
|
||||
self,
|
||||
limit: int = EXPORT_LIMIT,
|
||||
export_filter: str = "all",
|
||||
) -> bytes:
|
||||
filter_key = normalize_journal_export_filter(export_filter)
|
||||
rows = self.get_export_rows(
|
||||
limit=limit,
|
||||
export_filter=filter_key,
|
||||
)
|
||||
|
||||
return build_csv(
|
||||
rows,
|
||||
@@ -237,10 +265,19 @@ class JournalService:
|
||||
export_limit=limit,
|
||||
account_mode=self._account_mode(),
|
||||
journal_level=self._journal_level(),
|
||||
export_filter_label=journal_export_filter_label(filter_key),
|
||||
)
|
||||
|
||||
def export_xlsx(self, limit: int = EXPORT_LIMIT) -> bytes:
|
||||
rows = self.get_export_rows(limit=limit)
|
||||
def export_xlsx(
|
||||
self,
|
||||
limit: int = EXPORT_LIMIT,
|
||||
export_filter: str = "all",
|
||||
) -> bytes:
|
||||
filter_key = normalize_journal_export_filter(export_filter)
|
||||
rows = self.get_export_rows(
|
||||
limit=limit,
|
||||
export_filter=filter_key,
|
||||
)
|
||||
|
||||
return build_xlsx(
|
||||
rows,
|
||||
@@ -248,6 +285,7 @@ class JournalService:
|
||||
export_limit=limit,
|
||||
account_mode=self._account_mode(),
|
||||
journal_level=self._journal_level(),
|
||||
export_filter_label=journal_export_filter_label(filter_key),
|
||||
)
|
||||
|
||||
def clear_all(self) -> int:
|
||||
@@ -289,4 +327,100 @@ class JournalService:
|
||||
},
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
return deleted_count
|
||||
|
||||
def _build_trade_payload(
|
||||
self,
|
||||
*,
|
||||
state: object,
|
||||
action: str,
|
||||
trade_id: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Единый payload сделки для будущего анализа стратегии.
|
||||
payload: dict[str, Any] = {
|
||||
"trade_id": trade_id,
|
||||
"action": action,
|
||||
"symbol": getattr(state, "symbol", None),
|
||||
"strategy": getattr(state, "strategy", None),
|
||||
"cycle_number": getattr(state, "cycle_number", None),
|
||||
"status": getattr(state, "status", None),
|
||||
|
||||
"position_side": getattr(state, "position_side", None),
|
||||
"entry_price": getattr(state, "entry_price", None),
|
||||
"position_size": getattr(state, "position_size", None),
|
||||
"leverage": getattr(state, "leverage", None),
|
||||
|
||||
"unrealized_pnl_usd": getattr(state, "unrealized_pnl_usd", None),
|
||||
"realized_pnl_usd": getattr(state, "realized_pnl_usd", None),
|
||||
"cycle_realized_pnl_usd": getattr(state, "cycle_realized_pnl_usd", None),
|
||||
"cycle_closed_trades": getattr(state, "cycle_closed_trades", None),
|
||||
"cycle_winning_trades": getattr(state, "cycle_winning_trades", None),
|
||||
|
||||
"last_signal": getattr(state, "last_signal", None),
|
||||
"last_signal_confidence": getattr(state, "last_signal_confidence", None),
|
||||
"last_signal_reason": getattr(state, "last_signal_reason", None),
|
||||
"decision_status": getattr(state, "decision_status", None),
|
||||
"decision_reason": getattr(state, "decision_reason", None),
|
||||
|
||||
"market_state": getattr(state, "market_state", None),
|
||||
"market_trend": getattr(state, "market_trend", None),
|
||||
"market_trend_strength": getattr(state, "market_trend_strength", None),
|
||||
"market_trend_quality": getattr(state, "market_trend_quality", None),
|
||||
"market_phase": getattr(state, "market_phase", None),
|
||||
"market_phase_direction": getattr(state, "market_phase_direction", None),
|
||||
|
||||
"momentum_state": getattr(state, "momentum_state", None),
|
||||
"momentum_direction": getattr(state, "momentum_direction", None),
|
||||
"momentum_strength": getattr(state, "momentum_strength", None),
|
||||
"momentum_change_percent": getattr(state, "momentum_change_percent", None),
|
||||
|
||||
"execution_quality": getattr(state, "execution_quality", None),
|
||||
"execution_quality_reason": getattr(state, "execution_quality_reason", None),
|
||||
"execution_confidence_score": getattr(state, "execution_confidence_score", None),
|
||||
"execution_confidence_level": getattr(state, "execution_confidence_level", None),
|
||||
|
||||
"spread_percent": getattr(state, "spread_percent", None),
|
||||
"snapshot_age_seconds": getattr(state, "snapshot_age_seconds", None),
|
||||
|
||||
"adaptive_size_base": getattr(state, "adaptive_size_base", None),
|
||||
"adaptive_size_final": getattr(state, "adaptive_size_final", None),
|
||||
"adaptive_size_multiplier": getattr(state, "adaptive_size_multiplier", None),
|
||||
"adaptive_size_reason": getattr(state, "adaptive_size_reason", None),
|
||||
|
||||
"position_mfe_percent": getattr(state, "position_mfe_percent", None),
|
||||
"position_mae_percent": getattr(state, "position_mae_percent", None),
|
||||
"position_peak_pnl_usd": getattr(state, "position_peak_pnl_usd", None),
|
||||
"position_hold_seconds": getattr(state, "position_hold_seconds", None),
|
||||
}
|
||||
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
|
||||
return payload
|
||||
|
||||
def log_trade_event(
|
||||
self,
|
||||
*,
|
||||
event_type: str,
|
||||
message: str,
|
||||
state: object,
|
||||
action: str,
|
||||
trade_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
# Trade-события пишем в общий журнал, чтобы экспорт CSV/XLSX уже работал без новой таблицы.
|
||||
self.log_info(
|
||||
event_type=event_type,
|
||||
message=self._build_message(message),
|
||||
payload=self._build_payload(
|
||||
screen="auto",
|
||||
action=action,
|
||||
payload=self._build_trade_payload(
|
||||
state=state,
|
||||
action=action,
|
||||
trade_id=trade_id,
|
||||
extra=payload,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
"""Package marker."""
|
||||
@@ -1,37 +0,0 @@
|
||||
# app/src/trading/orders/models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderDraft:
|
||||
symbol: str
|
||||
side: str
|
||||
order_type: str
|
||||
quantity: str
|
||||
price: str | None = None
|
||||
status: str = "draft"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderEntryContext:
|
||||
symbol: str
|
||||
side: str
|
||||
order_type: str
|
||||
base_currency: str
|
||||
balance_currency: str
|
||||
quote_currency: str
|
||||
available_balance: float
|
||||
reference_price: float
|
||||
last_price: float
|
||||
bid_price: float
|
||||
ask_price: float
|
||||
quantity_presets: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderValidationResult:
|
||||
is_valid: bool
|
||||
errors: list[str] = field(default_factory=list)
|
||||
@@ -1,626 +0,0 @@
|
||||
# /app/src/trading/orders/service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN, ROUND_UP
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.integrations.exchange.models import ExchangeSymbol
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.storage.repositories.order_drafts import OrderDraftRepository
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.orders.models import OrderDraft, OrderEntryContext, OrderValidationResult
|
||||
|
||||
|
||||
class OrderDraftsService:
|
||||
def __init__(self) -> None:
|
||||
self.settings = load_settings()
|
||||
self.repository = OrderDraftRepository()
|
||||
self.journal = JournalService()
|
||||
self.exchange = ExchangeService()
|
||||
|
||||
def build_draft(
|
||||
self,
|
||||
*,
|
||||
side: str,
|
||||
order_type: str,
|
||||
quantity: str,
|
||||
price: str | None = None,
|
||||
) -> OrderDraft:
|
||||
return OrderDraft(
|
||||
symbol=self.settings.default_symbol,
|
||||
side=side.upper(),
|
||||
order_type=order_type.upper(),
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
status="draft",
|
||||
)
|
||||
|
||||
def get_entry_rules(self) -> dict[str, str | None]:
|
||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||
symbol_info = validation.symbol_info
|
||||
|
||||
if symbol_info is None:
|
||||
return {
|
||||
"min_qty": None,
|
||||
"step_size": None,
|
||||
"min_notional": None,
|
||||
"tick_size": None,
|
||||
}
|
||||
|
||||
min_qty = getattr(symbol_info, "min_qty", None)
|
||||
step_size = getattr(symbol_info, "step_size", None)
|
||||
min_notional = getattr(symbol_info, "min_notional", None)
|
||||
tick_size = getattr(symbol_info, "tick_size", None)
|
||||
|
||||
return {
|
||||
"min_qty": str(min_qty) if min_qty not in (None, "") else None,
|
||||
"step_size": str(step_size) if step_size not in (None, "") else None,
|
||||
"min_notional": str(min_notional) if min_notional not in (None, "") else None,
|
||||
"tick_size": str(tick_size) if tick_size not in (None, "") else None,
|
||||
}
|
||||
|
||||
def save_draft(self, draft: OrderDraft) -> None:
|
||||
validation = self.validate_draft(draft)
|
||||
if not validation.is_valid:
|
||||
try:
|
||||
self.journal.log_warning(
|
||||
"order_draft_validation_failed",
|
||||
"Черновик ордера не прошёл валидацию.",
|
||||
{
|
||||
"symbol": draft.symbol,
|
||||
"side": draft.side,
|
||||
"order_type": draft.order_type,
|
||||
"quantity": draft.quantity,
|
||||
"price": draft.price,
|
||||
"errors": validation.errors,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ValueError("; ".join(validation.errors))
|
||||
|
||||
payload = {
|
||||
"source": "trade_screen",
|
||||
"mode": "draft_only",
|
||||
"price": draft.price,
|
||||
}
|
||||
|
||||
self.repository.add_draft(
|
||||
symbol=draft.symbol,
|
||||
side=draft.side,
|
||||
order_type=draft.order_type,
|
||||
quantity=draft.quantity,
|
||||
status=draft.status,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
try:
|
||||
self.journal.log_info(
|
||||
"order_draft_saved",
|
||||
"Черновик ордера сохранён.",
|
||||
{
|
||||
"symbol": draft.symbol,
|
||||
"side": draft.side,
|
||||
"order_type": draft.order_type,
|
||||
"quantity": draft.quantity,
|
||||
"price": draft.price,
|
||||
"status": draft.status,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def validate_draft(self, draft: OrderDraft) -> OrderValidationResult:
|
||||
errors: list[str] = []
|
||||
|
||||
if draft.side not in {"BUY", "SELL"}:
|
||||
errors.append("Сторона ордера должна быть BUY или SELL.")
|
||||
|
||||
if draft.order_type not in {"MARKET", "LIMIT"}:
|
||||
errors.append("Тип ордера должен быть MARKET или LIMIT.")
|
||||
|
||||
symbol_validation = self.exchange.validate_symbol(draft.symbol)
|
||||
if not symbol_validation.is_valid:
|
||||
errors.append(symbol_validation.message)
|
||||
|
||||
quantity = self._to_decimal(draft.quantity)
|
||||
if quantity is None or quantity <= 0:
|
||||
errors.append("Количество должно быть числом больше нуля.")
|
||||
|
||||
symbol_info = symbol_validation.symbol_info
|
||||
|
||||
if quantity is not None and quantity > 0 and symbol_info is not None:
|
||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||
if min_qty is not None and min_qty > 0 and quantity < min_qty:
|
||||
errors.append(
|
||||
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
|
||||
)
|
||||
|
||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
||||
errors.append(
|
||||
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
|
||||
)
|
||||
|
||||
if draft.order_type == "LIMIT":
|
||||
if not draft.price:
|
||||
errors.append("Для LIMIT ордера требуется цена.")
|
||||
else:
|
||||
price = self._to_decimal(draft.price)
|
||||
if price is None or price <= 0:
|
||||
errors.append("Цена должна быть числом больше нуля.")
|
||||
else:
|
||||
tick_size = self._to_decimal(getattr(symbol_info, "tick_size", None))
|
||||
if tick_size is not None and tick_size > 0:
|
||||
if not self._fits_step(price, tick_size):
|
||||
errors.append(
|
||||
f"Цена должна соответствовать шагу tickSize = {getattr(symbol_info, 'tick_size', None)}."
|
||||
)
|
||||
|
||||
if quantity is not None and quantity > 0 and symbol_info is not None:
|
||||
reference_price = self._resolve_reference_price_for_validation(draft, symbol_info)
|
||||
if reference_price is not None:
|
||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||
if min_notional is not None and min_notional > 0:
|
||||
notional = quantity * reference_price
|
||||
if notional < min_notional:
|
||||
errors.append(
|
||||
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
|
||||
)
|
||||
|
||||
return OrderValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def list_recent_drafts(self, limit: int = 5) -> list[dict[str, str | int]]:
|
||||
return self.repository.list_recent_drafts(limit=limit)
|
||||
|
||||
def get_draft_by_id(self, draft_id: str) -> dict[str, str] | None:
|
||||
return self.repository.get_draft_by_id(draft_id)
|
||||
|
||||
def get_entry_context(self, *, side: str, order_type: str) -> OrderEntryContext:
|
||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||
if not validation.is_valid or validation.symbol_info is None:
|
||||
raise ValueError(validation.message)
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
balances = self.exchange.get_balance_summary()
|
||||
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
|
||||
|
||||
base_asset = (symbol_info.base_asset or "").strip()
|
||||
quote_asset = (symbol_info.quote_asset or "").strip()
|
||||
|
||||
if not base_asset or not quote_asset:
|
||||
message = (
|
||||
"Биржа не вернула base/quote валюту для инструмента. "
|
||||
"Невозможно корректно рассчитать контекст ордера."
|
||||
)
|
||||
try:
|
||||
self.journal.log_error(
|
||||
"order_entry_context_assets_missing",
|
||||
message,
|
||||
{
|
||||
"symbol": self.settings.default_symbol,
|
||||
"base_asset": base_asset or None,
|
||||
"quote_asset": quote_asset or None,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ValueError(message)
|
||||
|
||||
base_currency = base_asset.upper()
|
||||
quote_currency = quote_asset.upper()
|
||||
|
||||
available_by_currency = {
|
||||
item.currency.upper(): float(item.available)
|
||||
for item in balances
|
||||
}
|
||||
|
||||
side_upper = side.upper()
|
||||
order_type_upper = order_type.upper()
|
||||
|
||||
if side_upper == "BUY":
|
||||
balance_currency = quote_currency
|
||||
available_balance = available_by_currency.get(balance_currency, 0.0)
|
||||
reference_price = float(market["ask_price"])
|
||||
max_qty = (available_balance / reference_price) if reference_price > 0 else 0.0
|
||||
else:
|
||||
balance_currency = base_currency
|
||||
available_balance = available_by_currency.get(balance_currency, 0.0)
|
||||
reference_price = float(market["bid_price"])
|
||||
max_qty = available_balance
|
||||
|
||||
quantity_presets = self._build_quantity_presets(
|
||||
max_qty=max_qty,
|
||||
reference_price=reference_price,
|
||||
symbol_info=symbol_info,
|
||||
)
|
||||
|
||||
return OrderEntryContext(
|
||||
symbol=self.settings.default_symbol,
|
||||
side=side_upper,
|
||||
order_type=order_type_upper,
|
||||
base_currency=base_currency,
|
||||
balance_currency=balance_currency,
|
||||
quote_currency=quote_currency,
|
||||
available_balance=available_balance,
|
||||
reference_price=reference_price,
|
||||
last_price=float(market["last_price"]),
|
||||
bid_price=float(market["bid_price"]),
|
||||
ask_price=float(market["ask_price"]),
|
||||
quantity_presets=quantity_presets,
|
||||
)
|
||||
|
||||
def validate_entry_quantity(
|
||||
self,
|
||||
*,
|
||||
side: str,
|
||||
order_type: str,
|
||||
quantity: str,
|
||||
price: str | None = None,
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
|
||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||
if not validation.is_valid or validation.symbol_info is None:
|
||||
errors.append(validation.message)
|
||||
return errors
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
quantity_dec = self._to_decimal(quantity)
|
||||
|
||||
if quantity_dec is None or quantity_dec <= 0:
|
||||
errors.append("Количество должно быть числом больше нуля.")
|
||||
return errors
|
||||
|
||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||
if min_qty is not None and min_qty > 0 and quantity_dec < min_qty:
|
||||
errors.append(
|
||||
f"Количество должно быть не меньше minQty = {getattr(symbol_info, 'min_qty', None)}."
|
||||
)
|
||||
|
||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity_dec, step_size):
|
||||
errors.append(
|
||||
f"Количество должно соответствовать шагу stepSize = {getattr(symbol_info, 'step_size', None)}."
|
||||
)
|
||||
|
||||
reference_price = self._resolve_reference_price_for_entry(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
price=price,
|
||||
)
|
||||
|
||||
if reference_price is not None:
|
||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||
if min_notional is not None and min_notional > 0:
|
||||
notional = quantity_dec * reference_price
|
||||
if notional < min_notional:
|
||||
errors.append(
|
||||
f"Сумма ордера должна быть не меньше minNotional = {getattr(symbol_info, 'min_notional', None)}."
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
def normalize_preset_quantity(
|
||||
self,
|
||||
*,
|
||||
side: str,
|
||||
order_type: str,
|
||||
raw_quantity: str,
|
||||
price: str | None = None,
|
||||
) -> str | None:
|
||||
return self._normalize_entry_quantity_with_rules(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
raw_quantity=raw_quantity,
|
||||
price=price,
|
||||
raise_to_minimum=True,
|
||||
)
|
||||
|
||||
def normalize_entry_quantity(
|
||||
self,
|
||||
*,
|
||||
side: str,
|
||||
order_type: str,
|
||||
raw_quantity: str,
|
||||
price: str | None = None,
|
||||
) -> str | None:
|
||||
return self._normalize_entry_quantity_with_rules(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
raw_quantity=raw_quantity,
|
||||
price=price,
|
||||
raise_to_minimum=True,
|
||||
)
|
||||
|
||||
def _normalize_entry_quantity_with_rules(
|
||||
self,
|
||||
*,
|
||||
side: str,
|
||||
order_type: str,
|
||||
raw_quantity: str,
|
||||
price: str | None = None,
|
||||
raise_to_minimum: bool,
|
||||
) -> str | None:
|
||||
validation = self.exchange.validate_symbol(self.settings.default_symbol)
|
||||
if not validation.is_valid or validation.symbol_info is None:
|
||||
return self.normalize_quantity(raw_quantity)
|
||||
|
||||
original_quantity = self._to_decimal((raw_quantity or "").strip().replace(",", "."))
|
||||
if original_quantity is None or original_quantity <= 0:
|
||||
return None
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||
|
||||
minimum_allowed = min_qty if min_qty is not None and min_qty > 0 else None
|
||||
|
||||
reference_price = self._resolve_reference_price_for_entry(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
price=price,
|
||||
)
|
||||
if (
|
||||
reference_price is not None
|
||||
and reference_price > 0
|
||||
and min_notional is not None
|
||||
and min_notional > 0
|
||||
):
|
||||
min_by_notional = min_notional / reference_price
|
||||
if step_size is not None and step_size > 0:
|
||||
min_by_notional = self._ceil_to_step(min_by_notional, step_size)
|
||||
|
||||
if minimum_allowed is None or min_by_notional > minimum_allowed:
|
||||
minimum_allowed = min_by_notional
|
||||
|
||||
quantity = original_quantity
|
||||
|
||||
if step_size is not None and step_size > 0:
|
||||
quantity = self._floor_to_step(quantity, step_size)
|
||||
|
||||
if quantity <= 0:
|
||||
if raise_to_minimum and minimum_allowed is not None and minimum_allowed > 0:
|
||||
quantity = minimum_allowed
|
||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
||||
quantity = self._ceil_to_step(quantity, step_size)
|
||||
else:
|
||||
return None
|
||||
|
||||
if raise_to_minimum and minimum_allowed is not None and quantity < minimum_allowed:
|
||||
quantity = minimum_allowed
|
||||
if step_size is not None and step_size > 0 and not self._fits_step(quantity, step_size):
|
||||
quantity = self._ceil_to_step(quantity, step_size)
|
||||
|
||||
if quantity <= 0:
|
||||
return None
|
||||
|
||||
return self._format_decimal(quantity)
|
||||
|
||||
def _build_quantity_presets(
|
||||
self,
|
||||
*,
|
||||
max_qty: float,
|
||||
reference_price: float,
|
||||
symbol_info: ExchangeSymbol,
|
||||
) -> list[str]:
|
||||
percents = [0.01, 0.05, 0.10, 0.25, 0.50, 1.00]
|
||||
|
||||
max_qty_dec = self._to_decimal(max_qty)
|
||||
reference_price_dec = self._to_decimal(reference_price)
|
||||
|
||||
if max_qty_dec is None or max_qty_dec <= 0:
|
||||
return []
|
||||
|
||||
step_size = self._to_decimal(getattr(symbol_info, "step_size", None))
|
||||
min_qty = self._to_decimal(getattr(symbol_info, "min_qty", None))
|
||||
min_notional = self._to_decimal(getattr(symbol_info, "min_notional", None))
|
||||
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for percent in percents:
|
||||
qty = max_qty_dec * Decimal(str(percent))
|
||||
qty = self._normalize_quantity_to_exchange_rules(
|
||||
quantity=qty,
|
||||
step_size=step_size,
|
||||
)
|
||||
|
||||
if qty is None or qty <= 0:
|
||||
continue
|
||||
|
||||
if min_qty is not None and min_qty > 0 and qty < min_qty:
|
||||
continue
|
||||
|
||||
if reference_price_dec is not None and reference_price_dec > 0:
|
||||
if min_notional is not None and min_notional > 0:
|
||||
if qty * reference_price_dec < min_notional:
|
||||
continue
|
||||
|
||||
if qty > max_qty_dec:
|
||||
continue
|
||||
|
||||
text = self._format_decimal(qty)
|
||||
if text == "0" or text in seen:
|
||||
continue
|
||||
|
||||
seen.add(text)
|
||||
result.append(text)
|
||||
|
||||
if result:
|
||||
return result
|
||||
|
||||
fallback = self._normalize_quantity_to_exchange_rules(
|
||||
quantity=max_qty_dec,
|
||||
step_size=step_size,
|
||||
)
|
||||
if fallback is None or fallback <= 0:
|
||||
return []
|
||||
|
||||
if min_qty is not None and min_qty > 0 and fallback < min_qty:
|
||||
return []
|
||||
|
||||
if reference_price_dec is not None and reference_price_dec > 0:
|
||||
if min_notional is not None and min_notional > 0:
|
||||
if fallback * reference_price_dec < min_notional:
|
||||
return []
|
||||
|
||||
return [self._format_decimal(fallback)]
|
||||
|
||||
@staticmethod
|
||||
def normalize_side(raw: str) -> str | None:
|
||||
value = (raw or "").strip().upper()
|
||||
if value in {"BUY", "SELL"}:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def normalize_order_type(raw: str) -> str | None:
|
||||
value = (raw or "").strip().upper()
|
||||
if value in {"MARKET", "LIMIT"}:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def normalize_quantity(raw: str) -> str | None:
|
||||
value = (raw or "").strip().replace(",", ".")
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
quantity = float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if quantity <= 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def normalize_price(raw: str) -> str | None:
|
||||
value = (raw or "").strip().replace(",", ".")
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
price = float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if price <= 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _format_number(value: float) -> str:
|
||||
text = f"{value:.8f}"
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
@staticmethod
|
||||
def _format_decimal(value: Decimal) -> str:
|
||||
text = f"{value:.8f}"
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
@staticmethod
|
||||
def _to_decimal(value: str | float | Decimal | None) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fits_step(value: Decimal, step: Decimal) -> bool:
|
||||
if step <= 0:
|
||||
return True
|
||||
ratio = value / step
|
||||
return ratio == ratio.to_integral_value()
|
||||
|
||||
@staticmethod
|
||||
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step <= 0:
|
||||
return value
|
||||
ratio = (value / step).to_integral_value(rounding=ROUND_DOWN)
|
||||
return ratio * step
|
||||
|
||||
@staticmethod
|
||||
def _ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step <= 0:
|
||||
return value
|
||||
ratio = (value / step).to_integral_value(rounding=ROUND_UP)
|
||||
return ratio * step
|
||||
|
||||
def _normalize_quantity_to_exchange_rules(
|
||||
self,
|
||||
*,
|
||||
quantity: Decimal,
|
||||
step_size: Decimal | None,
|
||||
) -> Decimal | None:
|
||||
if quantity <= 0:
|
||||
return None
|
||||
|
||||
if step_size is not None and step_size > 0:
|
||||
quantity = self._floor_to_step(quantity, step_size)
|
||||
|
||||
if quantity <= 0:
|
||||
return None
|
||||
|
||||
return quantity
|
||||
|
||||
def _resolve_reference_price_for_validation(
|
||||
self,
|
||||
draft: OrderDraft,
|
||||
symbol_info: ExchangeSymbol | None,
|
||||
) -> Decimal | None:
|
||||
price = self._to_decimal(draft.price)
|
||||
if price is not None and price > 0:
|
||||
return price
|
||||
|
||||
if symbol_info is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
market = self.exchange.get_market_snapshot(draft.symbol)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if draft.side.upper() == "BUY":
|
||||
return self._to_decimal(market.get("ask_price"))
|
||||
return self._to_decimal(market.get("bid_price"))
|
||||
|
||||
def _resolve_reference_price_for_entry(
|
||||
self,
|
||||
*,
|
||||
side: str,
|
||||
order_type: str,
|
||||
price: str | None = None,
|
||||
) -> Decimal | None:
|
||||
if order_type.upper() == "LIMIT":
|
||||
explicit_price = self._to_decimal(price)
|
||||
if explicit_price is not None and explicit_price > 0:
|
||||
return explicit_price
|
||||
|
||||
try:
|
||||
market = self.exchange.get_market_snapshot(self.settings.default_symbol)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if side.upper() == "BUY":
|
||||
return self._to_decimal(market.get("ask_price"))
|
||||
return self._to_decimal(market.get("bid_price"))
|
||||
|
||||
def calculate_notional(self, quantity: str, price: str | None) -> float | None:
|
||||
q = self._to_decimal(quantity)
|
||||
p = self._to_decimal(price) if price else None
|
||||
|
||||
if q is None or p is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(q * p)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,11 +0,0 @@
|
||||
# /app/src/trading/orders/states.py
|
||||
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class NewOrderDraftStates(StatesGroup):
|
||||
waiting_side = State()
|
||||
waiting_type = State()
|
||||
waiting_quantity = State()
|
||||
waiting_price = State()
|
||||
waiting_confirm = State()
|
||||
@@ -13,6 +13,15 @@ class PositionState:
|
||||
# торговый инструмент
|
||||
symbol: str = ""
|
||||
|
||||
# id сделки, к которой относится текущая позиция
|
||||
trade_id: str | None = None
|
||||
|
||||
# порядковый номер сделки внутри runtime
|
||||
trade_sequence: int | None = None
|
||||
|
||||
# номер auto-cycle, в котором открыта сделка
|
||||
trade_cycle_number: int | None = None
|
||||
|
||||
# цена входа
|
||||
entry_price: float | None = None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user