07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics

This commit is contained in:
2026-05-28 10:30:54 +03:00
parent f9a25e7671
commit d9e6392e28
75 changed files with 9934 additions and 10508 deletions

View File

@@ -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

View File

@@ -1 +1,3 @@
# app/src/trading/auto/__init__.py
"""Package marker."""

View 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

View 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

View 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

View 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
)

View 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, "Состояние рынка анализируется.")

View 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"

View 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"

View File

@@ -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

View 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))

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View 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

View 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}.",
)

View 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,
"Позиция закрыта.",
)

View 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

View 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)

View 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

View 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

View 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

View 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)

View 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,
)

View 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

View 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)

View File

@@ -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,
)
)

View 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

View File

@@ -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,
),
),
)

View File

@@ -1 +0,0 @@
"""Package marker."""

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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