Stage 07.4.4.1.14 — Execution refactoring and runtime semantics
This commit is contained in:
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.config import load_settings
|
||||
@@ -18,29 +17,19 @@ 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_semantics import AutoPositionSemanticsMixin
|
||||
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,
|
||||
AutoPositionSemanticsMixin,
|
||||
AutoAutonomousManagementMixin,
|
||||
AutoExecutionSemanticMixin,
|
||||
):
|
||||
@@ -52,8 +41,6 @@ class AutoLifecycleMixin(
|
||||
_confirm_repeats: int
|
||||
_execution_confidence_required_score: float
|
||||
|
||||
|
||||
# Записать изменение режима автоторговли в журнал.
|
||||
def _log_auto_status_changed(
|
||||
self,
|
||||
*,
|
||||
@@ -85,7 +72,6 @@ class AutoLifecycleMixin(
|
||||
},
|
||||
)
|
||||
|
||||
# установить капитал, выделенный под автоторговлю
|
||||
def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
|
||||
@@ -99,24 +85,20 @@ class AutoLifecycleMixin(
|
||||
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
|
||||
@@ -124,7 +106,6 @@ class AutoLifecycleMixin(
|
||||
self._loop_task.cancel()
|
||||
self._loop_task = None
|
||||
|
||||
# рабочий цикл автоторговли
|
||||
async def _loop_worker(self) -> None:
|
||||
while True:
|
||||
state = self.get_state()
|
||||
@@ -135,7 +116,6 @@ class AutoLifecycleMixin(
|
||||
self.run_cycle()
|
||||
await asyncio.sleep(self._loop_interval_seconds)
|
||||
|
||||
# запустить активную торговлю
|
||||
def start(self) -> tuple[AutoTradeState, str]:
|
||||
state = self.get_state()
|
||||
previous_status = state.status
|
||||
@@ -145,6 +125,15 @@ class AutoLifecycleMixin(
|
||||
|
||||
if state.status == "OBSERVING":
|
||||
state.status = "RUNNING"
|
||||
# При ручном запуске из OBSERVING очищаем старую cooldown-блокировку,
|
||||
# чтобы запуск не наследовал паузу прошлого цикла.
|
||||
state.loss_cooldown_active = False
|
||||
state.loss_cooldown_reason = None
|
||||
state.last_loss_monotonic_at = None
|
||||
state.execution_block_title = None
|
||||
state.execution_block_message = None
|
||||
state.execution_block_action = None
|
||||
state.execution_block_reason = None
|
||||
|
||||
EventBus.emit(
|
||||
"auto_status_changed",
|
||||
@@ -168,6 +157,17 @@ class AutoLifecycleMixin(
|
||||
state.cycle_realized_pnl_usd = 0.0
|
||||
state.cycle_closed_trades = 0
|
||||
state.cycle_winning_trades = 0
|
||||
# Новый цикл должен начинаться без старой блокировки после убытков.
|
||||
state.cycle_losing_trades = 0
|
||||
state.cycle_consecutive_losses = 0
|
||||
state.loss_cooldown_active = False
|
||||
state.loss_cooldown_reason = None
|
||||
state.last_loss_monotonic_at = None
|
||||
state.execution_block_title = None
|
||||
state.execution_block_message = None
|
||||
state.execution_block_action = None
|
||||
state.cycle_trade_fees_usd = 0.0
|
||||
state.cycle_overnight_fees_usd = 0.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
|
||||
@@ -195,7 +195,6 @@ class AutoLifecycleMixin(
|
||||
|
||||
return state, "Автоторговля запущена."
|
||||
|
||||
# включить режим наблюдения
|
||||
def observe(self) -> tuple[AutoTradeState, str]:
|
||||
state = self.get_state()
|
||||
previous_status = state.status
|
||||
@@ -216,13 +215,28 @@ class AutoLifecycleMixin(
|
||||
if previous_status == "OFF":
|
||||
state.cycle_realized_pnl_usd = 0.0
|
||||
state.cycle_closed_trades = 0
|
||||
state.cycle_losing_trades = 0
|
||||
state.cycle_consecutive_losses = 0
|
||||
state.loss_cooldown_active = False
|
||||
state.loss_cooldown_reason = None
|
||||
state.last_loss_monotonic_at = None
|
||||
state.cycle_winning_trades = 0
|
||||
state.cycle_trade_fees_usd = 0.0
|
||||
state.cycle_overnight_fees_usd = 0.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
|
||||
state.position_stall_state = None
|
||||
state.position_stall_reason = None
|
||||
state.position_mfe_percent = None
|
||||
state.position_mae_percent = None
|
||||
state.execution_block_title = None
|
||||
state.execution_block_message = None
|
||||
state.execution_block_action = None
|
||||
state.execution_block_reason = None
|
||||
|
||||
self._log_auto_status_changed(
|
||||
previous_status=previous_status,
|
||||
@@ -242,7 +256,6 @@ class AutoLifecycleMixin(
|
||||
|
||||
return state, "Автоторговля переведена в режим наблюдения."
|
||||
|
||||
# полностью выключить автоторговлю
|
||||
def stop(self) -> tuple[AutoTradeState, str]:
|
||||
state = self.get_state()
|
||||
previous_status = state.status
|
||||
@@ -254,7 +267,18 @@ class AutoLifecycleMixin(
|
||||
state.status = "OFF"
|
||||
state.cycle_realized_pnl_usd = 0.0
|
||||
state.cycle_closed_trades = 0
|
||||
state.cycle_losing_trades = 0
|
||||
state.cycle_consecutive_losses = 0
|
||||
state.loss_cooldown_active = False
|
||||
state.loss_cooldown_reason = None
|
||||
state.last_loss_monotonic_at = None
|
||||
state.execution_block_title = None
|
||||
state.execution_block_message = None
|
||||
state.execution_block_action = None
|
||||
state.execution_block_reason = None
|
||||
state.cycle_winning_trades = 0
|
||||
state.cycle_trade_fees_usd = 0.0
|
||||
state.cycle_overnight_fees_usd = 0.0
|
||||
state.cycle_started_at = None
|
||||
state.adaptive_size_changed_at = None
|
||||
state.last_flip_old_side = None
|
||||
@@ -262,6 +286,10 @@ class AutoLifecycleMixin(
|
||||
state.last_flip_pnl_usd = None
|
||||
state.last_flip_reason = None
|
||||
state.last_flip_monotonic_at = None
|
||||
state.position_stall_state = None
|
||||
state.position_stall_reason = None
|
||||
state.position_mfe_percent = None
|
||||
state.position_mae_percent = None
|
||||
self.stop_loop()
|
||||
|
||||
EventBus.emit(
|
||||
@@ -281,7 +309,6 @@ class AutoLifecycleMixin(
|
||||
|
||||
return state, "Автоторговля выключена."
|
||||
|
||||
# установить инструмент
|
||||
def set_symbol(self, symbol: str) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
previous_symbol = state.symbol
|
||||
@@ -294,7 +321,6 @@ class AutoLifecycleMixin(
|
||||
|
||||
return state
|
||||
|
||||
# установить стратегию
|
||||
def set_strategy(self, strategy: str) -> AutoTradeState:
|
||||
state = self.get_state()
|
||||
previous_strategy = state.strategy
|
||||
@@ -308,44 +334,37 @@ class AutoLifecycleMixin(
|
||||
|
||||
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
|
||||
@@ -364,7 +383,9 @@ class AutoLifecycleMixin(
|
||||
state.adaptive_size_factors = None
|
||||
state.effective_risk_percent = None
|
||||
state.effective_target_risk_usd = None
|
||||
state.execution_size_adjustment_reason = None
|
||||
|
||||
state.last_signal = "HOLD"
|
||||
state.last_signal_repeat_count = 0
|
||||
state.last_signal_confidence = 0.0
|
||||
state.last_signal_reason = None
|
||||
@@ -410,6 +431,18 @@ class AutoLifecycleMixin(
|
||||
state.market_trend_quality = None
|
||||
state.market_phase = None
|
||||
state.market_phase_direction = None
|
||||
state.market_structure = None
|
||||
state.market_structure_reason = None
|
||||
state.market_score = None
|
||||
state.market_score_label = None
|
||||
state.market_long_score = None
|
||||
state.market_short_score = None
|
||||
|
||||
state.last_closed_candle_change_percent = None
|
||||
state.last_closed_candle_direction = None
|
||||
state.current_interval_change_percent = None
|
||||
state.current_interval_direction = None
|
||||
state.current_interval_label = None
|
||||
|
||||
state.market_trend_gap_percent = None
|
||||
state.market_trend_consistency = None
|
||||
@@ -429,6 +462,14 @@ class AutoLifecycleMixin(
|
||||
state.htf_atr_percent_baseline = None
|
||||
state.htf_volatility_ratio = None
|
||||
state.htf_volatility = None
|
||||
state.htf_market_state = None
|
||||
state.htf_trend = None
|
||||
state.htf_trend_strength = None
|
||||
state.htf_trend_quality = None
|
||||
state.htf_market_phase = None
|
||||
state.htf_alignment = None
|
||||
state.htf_confirmation_score = None
|
||||
state.htf_reason = None
|
||||
|
||||
state.entry_block_reason = None
|
||||
state.entry_block_message = None
|
||||
@@ -476,6 +517,20 @@ class AutoLifecycleMixin(
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = None
|
||||
state.position_stall_state = None
|
||||
state.position_stall_reason = None
|
||||
|
||||
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
|
||||
|
||||
state.autonomous_action = None
|
||||
state.autonomous_action_reason = None
|
||||
@@ -486,10 +541,16 @@ class AutoLifecycleMixin(
|
||||
state.autonomous_last_action = None
|
||||
state.autonomous_last_action_reason = None
|
||||
state.autonomous_last_action_at = None
|
||||
|
||||
state.last_loss_monotonic_at = None
|
||||
|
||||
# собрать контекст для стратегии
|
||||
# Сброс именно runtime-блокировки, чтобы после нового запуска
|
||||
# не оставалась старая пауза после прошлой убыточной сделки.
|
||||
state.loss_cooldown_active = False
|
||||
state.loss_cooldown_reason = None
|
||||
state.execution_block_title = None
|
||||
state.execution_block_message = None
|
||||
state.execution_block_action = None
|
||||
|
||||
def _build_strategy_context(self) -> StrategyContext:
|
||||
state = self.get_state()
|
||||
|
||||
@@ -499,12 +560,10 @@ class AutoLifecycleMixin(
|
||||
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()
|
||||
|
||||
@@ -520,6 +579,16 @@ class AutoLifecycleMixin(
|
||||
|
||||
strategy = self._get_strategy()
|
||||
context = self._build_strategy_context()
|
||||
# Последовательность принятия решения:
|
||||
# 1. Проверяем доступность рынка и live-данных.
|
||||
# 2. Стратегия анализирует свечи 5m + HTF 1h + live snapshot.
|
||||
# 3. Стратегия возвращает HOLD / BUY / SELL.
|
||||
# 4. Market runtime переносит payload стратегии в общий state.
|
||||
# 5. Execution quality проверяет spread, свежесть цены и стакан.
|
||||
# 6. Signal runtime подтверждает BUY/SELL по повторам и времени.
|
||||
# 7. ExecutionEngine открывает сделку только если сигнал READY.
|
||||
# 8. Если позиция открыта — protection/semantics решают,
|
||||
# удерживать, защищать или закрывать позицию.
|
||||
result = strategy.analyze(context)
|
||||
|
||||
self._sync_market_analysis_state(
|
||||
@@ -529,6 +598,27 @@ class AutoLifecycleMixin(
|
||||
|
||||
self._sync_execution_quality_state(state)
|
||||
|
||||
engine = ExecutionEngine()
|
||||
|
||||
# Перед health/semantics обновляем runtime PnL позиции,
|
||||
# иначе position intelligence может работать по данным прошлого цикла.
|
||||
engine._update_unrealized_pnl(state)
|
||||
|
||||
# ВАЖНО:
|
||||
# раньше position health/intelligence обновлялись только после ExecutionEngine.process().
|
||||
# Из-за этого runtime protection внутри execution мог принимать решение
|
||||
# по старому состоянию позиции.
|
||||
#
|
||||
# Теперь перед execution обновляем:
|
||||
# - health позиции
|
||||
# - semantics позиции
|
||||
# - autonomous management
|
||||
#
|
||||
# Это уменьшает задержку реакции защиты на смену trend/momentum/market context.
|
||||
self._sync_position_health_state(state)
|
||||
self._sync_position_semantics_state(state)
|
||||
self._sync_autonomous_trade_management(state)
|
||||
|
||||
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
self._log_signal_if_changed(
|
||||
@@ -540,15 +630,17 @@ class AutoLifecycleMixin(
|
||||
payload=result.payload,
|
||||
)
|
||||
|
||||
if state.execution_quality != "BLOCKED":
|
||||
ExecutionEngine().process(state)
|
||||
engine.process(state)
|
||||
|
||||
# Повторная синхронизация после execution:
|
||||
# если позиция была открыта/закрыта/перевернута, UI и runtime state
|
||||
# сразу получают актуальное состояние.
|
||||
self._sync_position_health_state(state)
|
||||
self._sync_position_intelligence_state(state)
|
||||
self._sync_position_semantics_state(state)
|
||||
self._sync_autonomous_trade_management(state)
|
||||
|
||||
if state.execution_quality != "BLOCKED":
|
||||
ExecutionEngine().process_runtime_action(state)
|
||||
if state.execution_quality != "BLOCKED" and engine.get_position().side != "NONE":
|
||||
engine.process_runtime_action(state)
|
||||
|
||||
self._sync_execution_semantic_state(state)
|
||||
|
||||
|
||||
@@ -4,6 +4,23 @@ from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.constants import (
|
||||
AUTONOMOUS_ACTION_EXIT,
|
||||
AUTONOMOUS_ACTION_HOLD,
|
||||
AUTONOMOUS_ACTION_PROTECT,
|
||||
AUTONOMOUS_ACTION_REDUCE,
|
||||
AUTONOMOUS_ACTION_WATCH,
|
||||
AUTONOMOUS_AGGRESSIVE_EXIT_CONFIDENCE_THRESHOLD,
|
||||
AUTONOMOUS_EXIT_CONFIDENCE_THRESHOLD,
|
||||
POSITION_EXIT_SIGNAL_EXIT,
|
||||
POSITION_EXIT_SIGNAL_HOLD,
|
||||
POSITION_EXIT_SIGNAL_REDUCE_OR_PROTECT,
|
||||
POSITION_EXIT_SIGNAL_WATCH,
|
||||
POSITION_PRESSURE_HIGH_LOSS,
|
||||
POSITION_PRESSURE_LOSS,
|
||||
POSITION_TREND_AGAINST,
|
||||
POSITION_SIDE_NONE,
|
||||
)
|
||||
|
||||
|
||||
class AutoAutonomousManagementMixin:
|
||||
@@ -12,7 +29,9 @@ class AutoAutonomousManagementMixin:
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
if state.position_side == "NONE":
|
||||
# Если позиции нет или она неполная, очищаем autonomous-state,
|
||||
# чтобы не осталось старого действия от прошлой позиции.
|
||||
if state.position_side == POSITION_SIDE_NONE or state.entry_price is None:
|
||||
state.autonomous_action = None
|
||||
state.autonomous_action_reason = None
|
||||
state.autonomous_action_confidence = None
|
||||
@@ -21,41 +40,53 @@ class AutoAutonomousManagementMixin:
|
||||
state.autonomous_exit_required = False
|
||||
return
|
||||
|
||||
exit_signal = str(state.position_exit_signal or "HOLD").upper()
|
||||
exit_signal = str(
|
||||
state.position_exit_signal
|
||||
or POSITION_EXIT_SIGNAL_HOLD
|
||||
).upper()
|
||||
exit_confidence = safe_float(state.position_exit_confidence) or 0.0
|
||||
position_pressure = str(state.position_pressure or "").upper()
|
||||
trend_alignment = str(state.position_trend_alignment or "").upper()
|
||||
|
||||
action = "HOLD"
|
||||
action = AUTONOMOUS_ACTION_HOLD
|
||||
reason = "позиция удерживается"
|
||||
|
||||
protect_required = False
|
||||
reduce_required = False
|
||||
exit_required = False
|
||||
|
||||
if exit_signal == "WATCH":
|
||||
action = "WATCH"
|
||||
if exit_signal == POSITION_EXIT_SIGNAL_WATCH:
|
||||
action = AUTONOMOUS_ACTION_WATCH
|
||||
reason = "позиция требует наблюдения"
|
||||
|
||||
elif exit_signal == "REDUCE_OR_PROTECT":
|
||||
if state.position_pressure in {"HIGH_LOSS", "LOSS"}:
|
||||
action = "REDUCE"
|
||||
elif exit_signal == POSITION_EXIT_SIGNAL_REDUCE_OR_PROTECT:
|
||||
if position_pressure in {POSITION_PRESSURE_HIGH_LOSS, POSITION_PRESSURE_LOSS}:
|
||||
action = AUTONOMOUS_ACTION_REDUCE
|
||||
reduce_required = True
|
||||
reason = "позиция должна быть уменьшена"
|
||||
else:
|
||||
action = "PROTECT"
|
||||
action = AUTONOMOUS_ACTION_PROTECT
|
||||
protect_required = True
|
||||
reason = "позиция требует защиты"
|
||||
|
||||
elif exit_signal == "EXIT":
|
||||
action = "EXIT"
|
||||
exit_required = True
|
||||
reason = "позиция требует закрытия"
|
||||
elif exit_signal == POSITION_EXIT_SIGNAL_EXIT:
|
||||
if exit_confidence >= AUTONOMOUS_EXIT_CONFIDENCE_THRESHOLD:
|
||||
action = AUTONOMOUS_ACTION_EXIT
|
||||
exit_required = True
|
||||
reason = "позиция требует закрытия"
|
||||
else:
|
||||
action = AUTONOMOUS_ACTION_PROTECT
|
||||
protect_required = True
|
||||
reason = "позиция требует защиты перед возможным выходом"
|
||||
|
||||
# Жёсткая эскалация: если и тренд, и momentum против позиции,
|
||||
# автономное управление должно требовать выход, а не частичную защиту.
|
||||
if (
|
||||
state.position_adverse_momentum
|
||||
and state.position_trend_alignment == "AGAINST"
|
||||
and exit_confidence >= 0.65
|
||||
and trend_alignment == POSITION_TREND_AGAINST
|
||||
and exit_confidence >= AUTONOMOUS_AGGRESSIVE_EXIT_CONFIDENCE_THRESHOLD
|
||||
):
|
||||
action = "EXIT"
|
||||
action = AUTONOMOUS_ACTION_EXIT
|
||||
exit_required = True
|
||||
reduce_required = False
|
||||
protect_required = False
|
||||
|
||||
@@ -235,11 +235,29 @@ class AutoExecutionQualityMixin:
|
||||
|
||||
# синхронизировать runtime quality исполнения
|
||||
def _sync_execution_quality_state(self, state: AutoTradeState) -> None:
|
||||
if state.market_is_open is False:
|
||||
return
|
||||
|
||||
try:
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
|
||||
if (
|
||||
age_seconds is not None
|
||||
and age_seconds > self._warning_snapshot_age_seconds
|
||||
):
|
||||
try:
|
||||
snapshot = ExchangeService().refresh_market_snapshot_cache(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as exc:
|
||||
fallback_price = None
|
||||
|
||||
@@ -253,13 +271,22 @@ class AutoExecutionQualityMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Snapshot недоступен — очищаем все pricing-поля,
|
||||
# чтобы UI/execution не использовали старые bid/ask/last.
|
||||
state.snapshot_age_seconds = None
|
||||
state.spread_percent = 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 = fallback_price
|
||||
state.execution_price_freshness = "UNKNOWN"
|
||||
|
||||
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.execution_block_reason = None
|
||||
state.market_runtime_degraded = True
|
||||
else:
|
||||
status = build_exchange_error_status(exc)
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeStatusCode,
|
||||
is_exchange_status_reason,
|
||||
)
|
||||
from src.integrations.exchange.status import ExchangeStatusCode
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
|
||||
|
||||
@@ -14,6 +11,16 @@ class AutoExecutionSemanticMixin:
|
||||
|
||||
# синхронизировать semantic-статус execution слоя для UI
|
||||
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
|
||||
if state.execution_block_reason:
|
||||
state.execution_semantic_status = "BLOCKED"
|
||||
state.execution_semantic_message = (
|
||||
f"⛔ Исполнение · {state.execution_block_message}"
|
||||
if state.execution_block_message
|
||||
else "⛔ Исполнение · заблокировано"
|
||||
)
|
||||
state.execution_semantic_reason = state.execution_block_reason
|
||||
return
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
state.execution_semantic_status = "BLOCKED"
|
||||
state.execution_semantic_message = self._execution_block_semantic_message(state)
|
||||
@@ -96,25 +103,29 @@ class AutoExecutionSemanticMixin:
|
||||
|
||||
# проверить, что блокировка пришла из единого 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,
|
||||
}
|
||||
)
|
||||
# Поддерживаем оба формата:
|
||||
# 1) внутренние execution reason: EXCHANGE_UNAVAILABLE / TIME_ERROR
|
||||
# 2) значения ExchangeStatusCode, если они попадут сюда напрямую.
|
||||
return reason in {
|
||||
"EXCHANGE_UNAVAILABLE",
|
||||
"TIME_ERROR",
|
||||
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
|
||||
)
|
||||
# AutoExecutionQualityMixin сейчас кладёт MARKET_BREAK,
|
||||
# а ExchangeStatusCode может прийти как BREAK.
|
||||
return reason in {
|
||||
"MARKET_BREAK",
|
||||
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
|
||||
)
|
||||
# Поддерживаем внутренний AUTH_ERROR и enum-value.
|
||||
return reason in {
|
||||
"AUTH_ERROR",
|
||||
ExchangeStatusCode.AUTH_ERROR.value,
|
||||
}
|
||||
@@ -11,10 +11,11 @@ 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
|
||||
# Последние залогированные состояния нужны для dedupe journal-событий.
|
||||
# Dedupe market-событий отдельно по symbol/strategy,
|
||||
# чтобы разные инструменты не подавляли события друг друга.
|
||||
_last_logged_market_key: str | None = None
|
||||
_last_logged_entry_block_reason: str | None = None
|
||||
_last_logged_entry_block_at: float | None = None
|
||||
_entry_block_log_ttl_seconds: int = 900
|
||||
|
||||
@@ -39,6 +40,38 @@ class AutoMarketRuntimeMixin:
|
||||
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 "")
|
||||
|
||||
# Общая оценка рынка нужна UI, diagnostics, execution confidence
|
||||
# и adaptive sizing. Это не отдельная метрика тренда, а итоговая
|
||||
# оценка всего рыночного контекста.
|
||||
state.market_score = safe_float(payload.get("market_score"))
|
||||
state.market_score_label = str(payload.get("market_score_label") or "")
|
||||
|
||||
# market_long_score / market_short_score — направленные оценки входа.
|
||||
# Это не общий market_score, а оценка конкретно Long/Short.
|
||||
state.market_long_score = safe_float(payload.get("market_long_score"))
|
||||
state.market_short_score = safe_float(payload.get("market_short_score"))
|
||||
|
||||
state.last_closed_candle_change_percent = safe_float(
|
||||
payload.get("last_closed_candle_change_percent")
|
||||
)
|
||||
state.last_closed_candle_direction = str(
|
||||
payload.get("last_closed_candle_direction") or ""
|
||||
)
|
||||
|
||||
state.current_interval_change_percent = safe_float(
|
||||
payload.get("current_interval_change_percent")
|
||||
)
|
||||
state.current_interval_direction = str(
|
||||
payload.get("current_interval_direction") or ""
|
||||
)
|
||||
state.current_interval_label = str(
|
||||
payload.get("current_interval_label") or ""
|
||||
)
|
||||
|
||||
state.market_structure = str(payload.get("market_structure") or "")
|
||||
state.market_structure_reason = str(payload.get("market_structure_reason") 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"))
|
||||
@@ -51,13 +84,34 @@ class AutoMarketRuntimeMixin:
|
||||
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.htf_market_state = str(payload.get("htf_market_state") or "")
|
||||
state.htf_trend = str(payload.get("htf_trend") or "")
|
||||
state.htf_trend_strength = str(payload.get("htf_trend_strength") or "")
|
||||
state.htf_trend_quality = str(payload.get("htf_trend_quality") or "")
|
||||
state.htf_market_phase = str(payload.get("htf_market_phase") or "")
|
||||
state.htf_alignment = str(payload.get("htf_alignment") or "")
|
||||
state.htf_confirmation_score = safe_float(payload.get("htf_confirmation_score"))
|
||||
state.htf_reason = str(payload.get("htf_reason") 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.market_analysis_updated_at = time.monotonic()
|
||||
|
||||
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"))
|
||||
@@ -65,9 +119,14 @@ class AutoMarketRuntimeMixin:
|
||||
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 "")
|
||||
|
||||
if state.runtime_expired_reason == "MARKET_ANALYSIS_TTL_EXPIRED":
|
||||
state.runtime_expired_reason = None
|
||||
state.runtime_expired_message = None
|
||||
|
||||
self._log_market_state_if_changed(
|
||||
state=state,
|
||||
payload=payload,
|
||||
@@ -136,10 +195,27 @@ class AutoMarketRuntimeMixin:
|
||||
"market_trend_quality": state.market_trend_quality,
|
||||
"market_phase": state.market_phase,
|
||||
"market_phase_direction": state.market_phase_direction,
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
"current_interval_change_percent": state.current_interval_change_percent,
|
||||
"current_interval_direction": state.current_interval_direction,
|
||||
"current_interval_label": state.current_interval_label,
|
||||
"market_structure": state.market_structure,
|
||||
"market_structure_reason": state.market_structure_reason,
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"momentum_strength": state.momentum_strength,
|
||||
"momentum_change_percent": state.momentum_change_percent,
|
||||
"htf_market_state": state.htf_market_state,
|
||||
"htf_trend": state.htf_trend,
|
||||
"htf_trend_strength": state.htf_trend_strength,
|
||||
"htf_trend_quality": state.htf_trend_quality,
|
||||
"htf_market_phase": state.htf_market_phase,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"htf_reason": state.htf_reason,
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
@@ -168,17 +244,28 @@ class AutoMarketRuntimeMixin:
|
||||
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
|
||||
# Один ключ на текущее рыночное состояние.
|
||||
# Так journal не будет спамить одинаковыми событиями,
|
||||
# но изменения по другому symbol/strategy не потеряются.
|
||||
market_key = (
|
||||
f"{state.symbol}:"
|
||||
f"{state.strategy}:"
|
||||
f"{market_state}:"
|
||||
f"{market_trend}:"
|
||||
f"{market_volatility}"
|
||||
)
|
||||
|
||||
state_changed = market_state != previous_market_state
|
||||
|
||||
volatility_changed = (
|
||||
market_volatility is not None
|
||||
bool(market_volatility)
|
||||
and market_volatility != "UNKNOWN"
|
||||
and market_volatility != previous_market_volatility
|
||||
and market_volatility != type(self)._last_logged_market_volatility
|
||||
)
|
||||
|
||||
if market_key == type(self)._last_logged_market_key:
|
||||
return
|
||||
|
||||
if not state_changed and not volatility_changed:
|
||||
return
|
||||
|
||||
@@ -211,9 +298,7 @@ class AutoMarketRuntimeMixin:
|
||||
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
|
||||
type(self)._last_logged_market_key = market_key
|
||||
|
||||
# записать market journal событие с нужным уровнем важности
|
||||
def _write_market_journal_event(
|
||||
|
||||
@@ -2,11 +2,35 @@
|
||||
|
||||
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
|
||||
from src.trading.execution.constants import (
|
||||
EXECUTION_QUALITY_BLOCKED,
|
||||
EXECUTION_QUALITY_WARNING,
|
||||
MARKET_VOLATILITY_HIGH_STATES,
|
||||
POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT,
|
||||
POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT,
|
||||
POSITION_EXIT_PRESSURE_LOSS_PERCENT,
|
||||
POSITION_HEALTH_DANGER,
|
||||
POSITION_HEALTH_HEALTHY,
|
||||
POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT,
|
||||
POSITION_HEALTH_PNL_HARD_LOSS_PERCENT,
|
||||
POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT,
|
||||
POSITION_HEALTH_PNL_PRESSURE_PERCENT,
|
||||
POSITION_HEALTH_PRESSURE,
|
||||
POSITION_HEALTH_UNKNOWN,
|
||||
POSITION_HEALTH_WATCH,
|
||||
POSITION_MOMENTUM_STRONG,
|
||||
POSITION_RISK_ELEVATED,
|
||||
POSITION_RISK_HIGH,
|
||||
POSITION_RISK_LOW,
|
||||
POSITION_RISK_MODERATE,
|
||||
POSITION_STOP_LOSS_RATIO_CRITICAL,
|
||||
POSITION_STOP_LOSS_RATIO_WARNING,
|
||||
POSITION_STOP_LOSS_RATIO_WATCH,
|
||||
get_position_health_thresholds,
|
||||
)
|
||||
|
||||
|
||||
class AutoPositionHealthMixin:
|
||||
@@ -26,8 +50,11 @@ class AutoPositionHealthMixin:
|
||||
state.position_exit_pressure = None
|
||||
return
|
||||
|
||||
pnl_percent = self._position_pnl_percent(state)
|
||||
hold_seconds = self._position_hold_seconds(state)
|
||||
# PnL % и время удержания больше не считаем здесь.
|
||||
# Эти значения должны приходить из единого расчёта position_metrics.py
|
||||
# через execution/position_runtime.py.
|
||||
pnl_percent = safe_float(state.position_pnl_percent)
|
||||
hold_seconds = state.position_hold_seconds
|
||||
trend_alignment = self._position_trend_alignment(state)
|
||||
adverse_momentum = self._has_adverse_position_momentum(state)
|
||||
|
||||
@@ -70,41 +97,8 @@ class AutoPositionHealthMixin:
|
||||
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))
|
||||
def _health_thresholds(self, state: AutoTradeState) -> dict[str, float]:
|
||||
return get_position_health_thresholds(state.symbol)
|
||||
|
||||
# определить давление на позицию по PnL
|
||||
def _position_pressure(
|
||||
@@ -125,16 +119,18 @@ class AutoPositionHealthMixin:
|
||||
|
||||
return "FLAT"
|
||||
|
||||
if percent <= -0.8:
|
||||
thresholds = self._health_thresholds(state)
|
||||
|
||||
if percent <= thresholds["high_loss"]:
|
||||
return "HIGH_LOSS"
|
||||
|
||||
if percent <= -0.3:
|
||||
if percent <= thresholds["loss"]:
|
||||
return "LOSS"
|
||||
|
||||
if percent >= 0.8:
|
||||
if percent >= thresholds["strong_profit"]:
|
||||
return "STRONG_PROFIT"
|
||||
|
||||
if percent >= 0.3:
|
||||
if percent >= thresholds["profit"]:
|
||||
return "PROFIT"
|
||||
|
||||
return "FLAT"
|
||||
@@ -144,12 +140,21 @@ class AutoPositionHealthMixin:
|
||||
side = str(state.position_side or "NONE").upper()
|
||||
market_state = str(state.market_state or "").upper()
|
||||
trend = str(state.market_trend or "").upper()
|
||||
htf_trend = str(getattr(state, "htf_trend", "") or "").upper()
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
|
||||
if side == "NONE":
|
||||
return "NONE"
|
||||
|
||||
# HTF AGAINST важнее локального тренда:
|
||||
# если старший таймфрейм против позиции, позиция считается рискованной.
|
||||
if htf_alignment == "AGAINST":
|
||||
return "AGAINST"
|
||||
|
||||
if side == "LONG":
|
||||
if market_state == "TREND_UP" or trend == "UP":
|
||||
if htf_trend in {"DOWN"}:
|
||||
return "NEUTRAL"
|
||||
return "ALIGNED"
|
||||
|
||||
if market_state == "TREND_DOWN" or trend == "DOWN":
|
||||
@@ -157,6 +162,8 @@ class AutoPositionHealthMixin:
|
||||
|
||||
if side == "SHORT":
|
||||
if market_state == "TREND_DOWN" or trend == "DOWN":
|
||||
if htf_trend in {"UP"}:
|
||||
return "NEUTRAL"
|
||||
return "ALIGNED"
|
||||
|
||||
if market_state == "TREND_UP" or trend == "UP":
|
||||
@@ -169,17 +176,45 @@ class AutoPositionHealthMixin:
|
||||
side = str(state.position_side or "NONE").upper()
|
||||
momentum_direction = str(state.momentum_direction or "").upper()
|
||||
momentum_state = str(state.momentum_state or "").upper()
|
||||
momentum_strength = safe_float(getattr(state, "momentum_strength", None)) or 0.0
|
||||
|
||||
current_interval_direction = str(
|
||||
getattr(state, "current_interval_direction", "") or ""
|
||||
).upper()
|
||||
current_interval_change_percent = safe_float(
|
||||
getattr(state, "current_interval_change_percent", None)
|
||||
)
|
||||
|
||||
current_interval_move_abs = abs(current_interval_change_percent or 0.0)
|
||||
|
||||
current_interval_against_long = (
|
||||
current_interval_direction == "DOWN"
|
||||
and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT
|
||||
)
|
||||
|
||||
current_interval_against_short = (
|
||||
current_interval_direction == "UP"
|
||||
and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT
|
||||
)
|
||||
|
||||
if side == "LONG":
|
||||
return (
|
||||
momentum_direction == "DOWN"
|
||||
or momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
|
||||
momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
|
||||
or current_interval_against_long
|
||||
or (
|
||||
momentum_direction == "DOWN"
|
||||
and momentum_strength >= POSITION_MOMENTUM_STRONG
|
||||
)
|
||||
)
|
||||
|
||||
if side == "SHORT":
|
||||
return (
|
||||
momentum_direction == "UP"
|
||||
or momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
|
||||
momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
|
||||
or current_interval_against_short
|
||||
or (
|
||||
momentum_direction == "UP"
|
||||
and momentum_strength >= POSITION_MOMENTUM_STRONG
|
||||
)
|
||||
)
|
||||
|
||||
return False
|
||||
@@ -195,28 +230,68 @@ class AutoPositionHealthMixin:
|
||||
) -> int:
|
||||
score = 100
|
||||
percent = safe_float(pnl_percent)
|
||||
stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None))
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
market_structure = str(getattr(state, "market_structure", "") or "").upper()
|
||||
market_phase = str(getattr(state, "market_phase", "") or "").upper()
|
||||
trend_quality = str(getattr(state, "market_trend_quality", "") or "").upper()
|
||||
volatility = str(getattr(state, "market_volatility", "") or "").upper()
|
||||
|
||||
if percent is not None:
|
||||
if percent <= -1.0:
|
||||
score -= 35
|
||||
elif percent <= -0.5:
|
||||
score -= 22
|
||||
if percent <= POSITION_HEALTH_PNL_HARD_LOSS_PERCENT:
|
||||
score -= 40
|
||||
elif percent <= POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT:
|
||||
score -= 30
|
||||
elif percent <= POSITION_HEALTH_PNL_PRESSURE_PERCENT:
|
||||
score -= 18
|
||||
elif percent < 0:
|
||||
score -= 10
|
||||
elif percent >= 0.8:
|
||||
score -= 8
|
||||
elif percent >= POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT:
|
||||
score += 5
|
||||
|
||||
# Если позиция прошла большую часть stop loss — ухудшаем score заранее.
|
||||
if stop_loss_percent is not None and stop_loss_percent > 0:
|
||||
loss_ratio = abs(percent) / stop_loss_percent if percent < 0 else 0.0
|
||||
|
||||
if loss_ratio >= POSITION_STOP_LOSS_RATIO_CRITICAL:
|
||||
score -= 25
|
||||
elif loss_ratio >= POSITION_STOP_LOSS_RATIO_WARNING:
|
||||
score -= 15
|
||||
|
||||
if trend_alignment == "AGAINST":
|
||||
score -= 25
|
||||
score -= 30
|
||||
elif trend_alignment == "NEUTRAL":
|
||||
score -= 8
|
||||
score -= 10
|
||||
|
||||
if adverse_momentum:
|
||||
score -= 20
|
||||
score -= 25
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
if htf_alignment == "AGAINST":
|
||||
score -= 20
|
||||
elif htf_alignment == "NEUTRAL":
|
||||
score -= 8
|
||||
|
||||
if market_structure == "MIXED":
|
||||
score -= 12
|
||||
elif market_structure == "LH_LL" and state.position_side == "LONG":
|
||||
score -= 18
|
||||
elif market_structure == "HH_HL" and state.position_side == "SHORT":
|
||||
score -= 18
|
||||
|
||||
if market_phase in {"RANGE", "SQUEEZE"}:
|
||||
score -= 10
|
||||
elif market_phase == "PULLBACK" and trend_alignment != "ALIGNED":
|
||||
score -= 12
|
||||
|
||||
if trend_quality == "NOISY":
|
||||
score -= 12
|
||||
|
||||
if volatility in MARKET_VOLATILITY_HIGH_STATES:
|
||||
score -= 12
|
||||
|
||||
if state.execution_quality == EXECUTION_QUALITY_BLOCKED:
|
||||
score -= 15
|
||||
elif state.execution_quality == "WARNING":
|
||||
elif state.execution_quality == EXECUTION_QUALITY_WARNING:
|
||||
score -= 8
|
||||
|
||||
if state.market_runtime_degraded:
|
||||
@@ -227,18 +302,18 @@ class AutoPositionHealthMixin:
|
||||
# классифицировать health status по score
|
||||
def _position_health_status(self, score: int | None) -> str:
|
||||
if score is None:
|
||||
return "UNKNOWN"
|
||||
return POSITION_HEALTH_UNKNOWN
|
||||
|
||||
if score >= 80:
|
||||
return "HEALTHY"
|
||||
return POSITION_HEALTH_HEALTHY
|
||||
|
||||
if score >= 55:
|
||||
return "WATCH"
|
||||
if score >= 60:
|
||||
return POSITION_HEALTH_WATCH
|
||||
|
||||
if score >= 35:
|
||||
return "PRESSURE"
|
||||
if score >= 40:
|
||||
return POSITION_HEALTH_PRESSURE
|
||||
|
||||
return "DANGER"
|
||||
return POSITION_HEALTH_DANGER
|
||||
|
||||
# сформировать человекочитаемую причину health состояния
|
||||
def _position_health_reason(
|
||||
@@ -275,26 +350,86 @@ class AutoPositionHealthMixin:
|
||||
adverse_momentum: bool,
|
||||
) -> tuple[str, str]:
|
||||
percent = safe_float(pnl_percent)
|
||||
stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None))
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
market_structure = str(getattr(state, "market_structure", "") or "").upper()
|
||||
volatility = str(getattr(state, "market_volatility", "") or "").upper()
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
return "HIGH", "исполнение заблокировано"
|
||||
current_interval_direction = str(
|
||||
getattr(state, "current_interval_direction", "") or ""
|
||||
).upper()
|
||||
current_interval_change_percent = safe_float(
|
||||
getattr(state, "current_interval_change_percent", None)
|
||||
)
|
||||
current_interval_move_abs = abs(current_interval_change_percent or 0.0)
|
||||
|
||||
if percent is not None and percent <= -1.0:
|
||||
return "HIGH", "сильная просадка позиции"
|
||||
current_interval_against_position = (
|
||||
(
|
||||
state.position_side == "LONG"
|
||||
and current_interval_direction == "DOWN"
|
||||
)
|
||||
or (
|
||||
state.position_side == "SHORT"
|
||||
and current_interval_direction == "UP"
|
||||
)
|
||||
)
|
||||
|
||||
if state.execution_quality == EXECUTION_QUALITY_BLOCKED:
|
||||
return POSITION_RISK_HIGH, "исполнение заблокировано"
|
||||
|
||||
if percent is not None:
|
||||
if percent <= POSITION_HEALTH_PNL_HARD_LOSS_PERCENT:
|
||||
return POSITION_RISK_HIGH, "сильная просадка позиции"
|
||||
|
||||
if stop_loss_percent is not None and stop_loss_percent > 0 and percent < 0:
|
||||
loss_ratio = abs(percent) / stop_loss_percent
|
||||
|
||||
if loss_ratio >= POSITION_STOP_LOSS_RATIO_CRITICAL:
|
||||
return POSITION_RISK_HIGH, "позиция близко к stop loss"
|
||||
|
||||
if loss_ratio >= POSITION_STOP_LOSS_RATIO_WARNING:
|
||||
return POSITION_RISK_ELEVATED, "позиция прошла больше половины stop loss"
|
||||
|
||||
if trend_alignment == "AGAINST" and adverse_momentum:
|
||||
return "HIGH", "рынок движется против позиции"
|
||||
return POSITION_RISK_HIGH, "рынок движется против позиции"
|
||||
|
||||
if htf_alignment == "AGAINST" and adverse_momentum:
|
||||
return POSITION_RISK_HIGH, "старший таймфрейм и momentum против позиции"
|
||||
|
||||
if (
|
||||
state.position_side == "LONG"
|
||||
and market_structure == "LH_LL"
|
||||
):
|
||||
return POSITION_RISK_ELEVATED, "структура рынка против LONG"
|
||||
|
||||
if (
|
||||
state.position_side == "SHORT"
|
||||
and market_structure == "HH_HL"
|
||||
):
|
||||
return POSITION_RISK_ELEVATED, "структура рынка против SHORT"
|
||||
|
||||
if volatility in MARKET_VOLATILITY_HIGH_STATES and percent is not None and percent < 0:
|
||||
return POSITION_RISK_ELEVATED, "убыток в высокой волатильности"
|
||||
|
||||
if percent is not None and percent < 0:
|
||||
if trend_alignment == "AGAINST" or adverse_momentum:
|
||||
return "ELEVATED", "убыток усиливается рыночным контекстом"
|
||||
return POSITION_RISK_ELEVATED, "убыток усиливается рыночным контекстом"
|
||||
|
||||
return "MODERATE", "позиция в минусе"
|
||||
return POSITION_RISK_MODERATE, "позиция в минусе"
|
||||
|
||||
if current_interval_against_position and current_interval_move_abs >= POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT:
|
||||
return POSITION_RISK_ELEVATED, "текущая 5м свеча против позиции"
|
||||
|
||||
if current_interval_against_position and percent is not None and percent < 0:
|
||||
return POSITION_RISK_ELEVATED, "убыток усиливается текущей 5м свечой"
|
||||
|
||||
if adverse_momentum:
|
||||
return "MODERATE", "momentum против позиции"
|
||||
return POSITION_RISK_MODERATE, "momentum против позиции"
|
||||
|
||||
return "LOW", "критичных рисков нет"
|
||||
if htf_alignment == "AGAINST":
|
||||
return POSITION_RISK_MODERATE, "старший таймфрейм против позиции"
|
||||
|
||||
return POSITION_RISK_LOW, "критичных рисков нет"
|
||||
|
||||
# определить давление на выход из позиции
|
||||
def _position_exit_pressure(
|
||||
@@ -305,14 +440,22 @@ class AutoPositionHealthMixin:
|
||||
risk_level: str,
|
||||
) -> str:
|
||||
percent = safe_float(pnl_percent)
|
||||
stop_loss_percent = safe_float(getattr(state, "stop_loss_percent", None))
|
||||
|
||||
if risk_level == "HIGH":
|
||||
if risk_level == POSITION_RISK_HIGH:
|
||||
return "HIGH"
|
||||
|
||||
if risk_level == "ELEVATED":
|
||||
if risk_level in {POSITION_RISK_ELEVATED, POSITION_RISK_MODERATE}:
|
||||
return "WATCH"
|
||||
|
||||
if percent is not None and percent <= -0.5:
|
||||
return "WATCH"
|
||||
if percent is not None:
|
||||
if percent <= POSITION_EXIT_PRESSURE_LOSS_PERCENT:
|
||||
return "WATCH"
|
||||
|
||||
if stop_loss_percent is not None and stop_loss_percent > 0 and percent < 0:
|
||||
loss_ratio = abs(percent) / stop_loss_percent
|
||||
|
||||
if loss_ratio >= POSITION_STOP_LOSS_RATIO_WATCH:
|
||||
return "WATCH"
|
||||
|
||||
return "LOW"
|
||||
@@ -1,14 +1,37 @@
|
||||
# app/src/trading/auto/position_intelligence.py
|
||||
# app/src/trading/auto/position_semantics.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.constants import (
|
||||
POSITION_EXIT_DAMPING_MATURE_MULTIPLIER,
|
||||
POSITION_EXIT_DAMPING_MATURE_SECONDS,
|
||||
POSITION_EXIT_DAMPING_NEW_MULTIPLIER,
|
||||
POSITION_EXIT_DAMPING_NEW_SECONDS,
|
||||
POSITION_EXIT_SIGNAL_EXIT_CONFIDENCE,
|
||||
POSITION_EXIT_SIGNAL_PROTECT_CONFIDENCE,
|
||||
POSITION_EXIT_SIGNAL_WATCH_CONFIDENCE,
|
||||
POSITION_GIVEBACK_HIGH_PERCENT,
|
||||
POSITION_GIVEBACK_LOW_PERCENT,
|
||||
POSITION_GIVEBACK_MEDIUM_PERCENT,
|
||||
POSITION_LIFECYCLE_ACTIVE_SECONDS,
|
||||
POSITION_LIFECYCLE_MATURE_SECONDS,
|
||||
POSITION_LIFECYCLE_NEW_SECONDS,
|
||||
POSITION_REVERSAL_ELEVATED_GIVEBACK_PERCENT,
|
||||
POSITION_REVERSAL_HIGH_GIVEBACK_PERCENT,
|
||||
POSITION_STALL_ADVERSE_MAE_PERCENT,
|
||||
POSITION_STALL_CONFIRMED_SECONDS,
|
||||
POSITION_STALL_DEVELOPING_SECONDS,
|
||||
POSITION_STALL_EARLY_SECONDS,
|
||||
POSITION_STALL_LOW_PROGRESS_MFE_PERCENT,
|
||||
POSITION_STALL_LOW_PROGRESS_PNL_PERCENT,
|
||||
)
|
||||
|
||||
|
||||
class AutoPositionIntelligenceMixin:
|
||||
# синхронизировать intelligence-состояние открытой позиции
|
||||
def _sync_position_intelligence_state(self, state: AutoTradeState) -> None:
|
||||
class AutoPositionSemanticsMixin:
|
||||
# синхронизировать semantics-состояние открытой позиции
|
||||
def _sync_position_semantics_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
|
||||
@@ -27,11 +50,26 @@ class AutoPositionIntelligenceMixin:
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = None
|
||||
state.position_stall_state = None
|
||||
state.position_stall_reason = None
|
||||
return
|
||||
|
||||
lifecycle_stage = self._position_lifecycle_stage(state)
|
||||
hold_quality = self._position_hold_quality(state)
|
||||
decay_state = self._position_decay_state(state)
|
||||
|
||||
# Передаём свежий lifecycle_stage явно,
|
||||
# чтобы decay не читал старое значение из state.
|
||||
decay_state = self._position_decay_state(
|
||||
state=state,
|
||||
lifecycle_stage=lifecycle_stage,
|
||||
)
|
||||
|
||||
# Сначала записываем базовые поля.
|
||||
# Advanced analytics ниже обновит MFE/MAE/giveback/fatigue,
|
||||
# а уже после этого можно корректно рассчитывать stall.
|
||||
state.position_lifecycle_stage = lifecycle_stage
|
||||
state.position_hold_quality = hold_quality
|
||||
state.position_decay_state = decay_state
|
||||
|
||||
self._sync_advanced_position_analytics(
|
||||
state=state,
|
||||
@@ -40,6 +78,12 @@ class AutoPositionIntelligenceMixin:
|
||||
decay_state=decay_state,
|
||||
)
|
||||
|
||||
# Stall считаем после обновления MFE/MAE,
|
||||
# иначе он может читать устаревшее значение position_mfe_percent.
|
||||
stall_state, stall_reason = self._position_stall_state(state)
|
||||
state.position_stall_state = stall_state
|
||||
state.position_stall_reason = stall_reason
|
||||
|
||||
exit_confidence = self._position_exit_confidence(
|
||||
state=state,
|
||||
hold_quality=hold_quality,
|
||||
@@ -48,12 +92,14 @@ class AutoPositionIntelligenceMixin:
|
||||
|
||||
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(
|
||||
|
||||
# Срочность выхода считаем после записи свежего exit_signal,
|
||||
# иначе urgency может читать сигнал прошлого цикла.
|
||||
state.position_exit_urgency = self._position_exit_urgency(state)
|
||||
|
||||
state.position_intelligence_reason = self._position_semantics_reason(
|
||||
state=state,
|
||||
hold_quality=hold_quality,
|
||||
decay_state=decay_state,
|
||||
@@ -70,13 +116,13 @@ class AutoPositionIntelligenceMixin:
|
||||
if hold_seconds is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
if hold_seconds < 60:
|
||||
if hold_seconds < POSITION_LIFECYCLE_NEW_SECONDS:
|
||||
return "NEW"
|
||||
|
||||
if hold_seconds < 300:
|
||||
if hold_seconds < POSITION_LIFECYCLE_ACTIVE_SECONDS:
|
||||
return "ACTIVE"
|
||||
|
||||
if hold_seconds < 900:
|
||||
if hold_seconds < POSITION_LIFECYCLE_MATURE_SECONDS:
|
||||
return "MATURE"
|
||||
|
||||
return "AGED"
|
||||
@@ -111,10 +157,15 @@ class AutoPositionIntelligenceMixin:
|
||||
return "NEUTRAL"
|
||||
|
||||
# определить тип ухудшения позиции
|
||||
def _position_decay_state(self, state: AutoTradeState) -> str:
|
||||
def _position_decay_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
lifecycle_stage: str,
|
||||
) -> 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()
|
||||
lifecycle = str(lifecycle_stage or "").upper()
|
||||
|
||||
if pressure in {"HIGH_LOSS", "LOSS"} and state.position_adverse_momentum:
|
||||
return "ACCELERATING_LOSS"
|
||||
@@ -142,6 +193,17 @@ class AutoPositionIntelligenceMixin:
|
||||
|
||||
risk_level = str(state.position_risk_level or "").upper()
|
||||
exit_pressure = str(state.position_exit_pressure or "").upper()
|
||||
market_quality = str(
|
||||
getattr(state, "market_trend_quality", "") or ""
|
||||
).upper()
|
||||
|
||||
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
|
||||
hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) or 0.0
|
||||
|
||||
adverse_momentum = bool(getattr(state, "position_adverse_momentum", False))
|
||||
trend_alignment = str(
|
||||
getattr(state, "position_trend_alignment", "") or ""
|
||||
).upper()
|
||||
|
||||
if risk_level == "HIGH":
|
||||
score += 0.45
|
||||
@@ -168,6 +230,53 @@ class AutoPositionIntelligenceMixin:
|
||||
if state.execution_quality == "BLOCKED":
|
||||
score += 0.10
|
||||
|
||||
stall_state = str(
|
||||
getattr(state, "position_stall_state", "") or ""
|
||||
).upper()
|
||||
|
||||
# Если позиция застряла, повышаем внимание к выходу.
|
||||
# Особенно важно для NOISY рынка: там долгое удержание около нуля
|
||||
# часто просто накапливает комиссии и даёт серию мелких убытков.
|
||||
if stall_state == "ADVERSE_STALLED":
|
||||
score += 0.20
|
||||
elif stall_state == "NOISY_STALLED":
|
||||
score += 0.15
|
||||
elif stall_state == "STALLED":
|
||||
score += 0.10
|
||||
|
||||
# NOISY рынок не запрещает торговлю полностью,
|
||||
# но позицию в шуме нужно сопровождать агрессивнее:
|
||||
# если после входа позиция уже в минусе или momentum против неё,
|
||||
# повышаем готовность к защите/выходу.
|
||||
if market_quality == "NOISY":
|
||||
if pnl_percent is not None and pnl_percent < 0:
|
||||
score += 0.10
|
||||
|
||||
if adverse_momentum:
|
||||
score += 0.12
|
||||
|
||||
if trend_alignment == "AGAINST":
|
||||
score += 0.10
|
||||
|
||||
# Новую позицию не закрываем слишком агрессивно:
|
||||
# первые минуты часто дают техническую просадку из-за spread/волны.
|
||||
#
|
||||
# Но если есть реальное ухудшение — HIGH risk, adverse momentum
|
||||
# вместе с трендом против позиции — dampening не применяем.
|
||||
severe_deterioration = (
|
||||
risk_level == "HIGH"
|
||||
or (
|
||||
adverse_momentum
|
||||
and trend_alignment == "AGAINST"
|
||||
)
|
||||
)
|
||||
|
||||
if not severe_deterioration:
|
||||
if hold_seconds < POSITION_EXIT_DAMPING_NEW_SECONDS:
|
||||
score *= POSITION_EXIT_DAMPING_NEW_MULTIPLIER
|
||||
elif hold_seconds < POSITION_EXIT_DAMPING_MATURE_SECONDS:
|
||||
score *= POSITION_EXIT_DAMPING_MATURE_MULTIPLIER
|
||||
|
||||
return round(max(0.0, min(1.0, score)), 3)
|
||||
|
||||
# определить semantic exit signal по confidence
|
||||
@@ -175,19 +284,19 @@ class AutoPositionIntelligenceMixin:
|
||||
if exit_confidence is None:
|
||||
return "NONE"
|
||||
|
||||
if exit_confidence >= 0.75:
|
||||
if exit_confidence >= POSITION_EXIT_SIGNAL_EXIT_CONFIDENCE:
|
||||
return "EXIT"
|
||||
|
||||
if exit_confidence >= 0.50:
|
||||
if exit_confidence >= POSITION_EXIT_SIGNAL_PROTECT_CONFIDENCE:
|
||||
return "REDUCE_OR_PROTECT"
|
||||
|
||||
if exit_confidence >= 0.30:
|
||||
if exit_confidence >= POSITION_EXIT_SIGNAL_WATCH_CONFIDENCE:
|
||||
return "WATCH"
|
||||
|
||||
return "HOLD"
|
||||
|
||||
# сформировать объяснение position intelligence
|
||||
def _position_intelligence_reason(
|
||||
# сформировать объяснение position semantics
|
||||
def _position_semantics_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
@@ -262,7 +371,6 @@ class AutoPositionIntelligenceMixin:
|
||||
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 позиции
|
||||
@@ -281,7 +389,15 @@ class AutoPositionIntelligenceMixin:
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
return round(min(0.0, current), 4)
|
||||
previous_mae = safe_float(state.position_mae_percent)
|
||||
|
||||
# MAE — это максимальное неблагоприятное движение за всю жизнь позиции.
|
||||
# Поэтому мы не пересчитываем его от текущего PnL,
|
||||
# а сохраняем самый глубокий исторический минус.
|
||||
if previous_mae is None:
|
||||
return round(min(0.0, current), 4)
|
||||
|
||||
return round(min(previous_mae, current, 0.0), 4)
|
||||
|
||||
# рассчитать процент отдачи прибыли от peak pnl
|
||||
def _position_giveback_percent(self, state: AutoTradeState) -> float | None:
|
||||
@@ -330,16 +446,16 @@ class AutoPositionIntelligenceMixin:
|
||||
elif decay_state in {"PROFIT_DECAY", "TIME_DECAY"}:
|
||||
score += 0.18
|
||||
|
||||
if giveback >= 70:
|
||||
if giveback >= POSITION_GIVEBACK_HIGH_PERCENT:
|
||||
score += 0.30
|
||||
elif giveback >= 45:
|
||||
elif giveback >= POSITION_GIVEBACK_MEDIUM_PERCENT:
|
||||
score += 0.20
|
||||
elif giveback >= 25:
|
||||
elif giveback >= POSITION_GIVEBACK_LOW_PERCENT:
|
||||
score += 0.10
|
||||
|
||||
if hold_seconds >= 1800:
|
||||
if hold_seconds >= POSITION_LIFECYCLE_MATURE_SECONDS * 2:
|
||||
score += 0.15
|
||||
elif hold_seconds >= 900:
|
||||
elif hold_seconds >= POSITION_LIFECYCLE_MATURE_SECONDS:
|
||||
score += 0.08
|
||||
|
||||
if state.position_adverse_momentum:
|
||||
@@ -371,7 +487,10 @@ class AutoPositionIntelligenceMixin:
|
||||
fatigue = str(state.position_fatigue_state or "").upper()
|
||||
alignment = str(state.position_trend_alignment or "").upper()
|
||||
|
||||
if health == "DANGER" or fatigue == "EXHAUSTED":
|
||||
if health == "DANGER":
|
||||
return "BROKEN"
|
||||
|
||||
if fatigue == "EXHAUSTED" and alignment == "AGAINST":
|
||||
return "BROKEN"
|
||||
|
||||
if alignment == "AGAINST" or fatigue == "TIRED":
|
||||
@@ -388,7 +507,7 @@ class AutoPositionIntelligenceMixin:
|
||||
fatigue = str(state.position_fatigue_state or "").upper()
|
||||
risk = str(state.position_risk_level or "").upper()
|
||||
|
||||
if exit_signal == "EXIT" or risk == "HIGH":
|
||||
if exit_signal == "EXIT" and risk == "HIGH":
|
||||
return "IMMEDIATE"
|
||||
|
||||
if fatigue == "EXHAUSTED":
|
||||
@@ -408,13 +527,72 @@ class AutoPositionIntelligenceMixin:
|
||||
fatigue = str(state.position_fatigue_state or "").upper()
|
||||
adverse = bool(state.position_adverse_momentum)
|
||||
|
||||
if adverse and giveback >= 45:
|
||||
if adverse and giveback >= POSITION_REVERSAL_HIGH_GIVEBACK_PERCENT:
|
||||
return "HIGH"
|
||||
|
||||
if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= 25:
|
||||
if fatigue in {"TIRED", "EXHAUSTED"} and giveback >= POSITION_REVERSAL_ELEVATED_GIVEBACK_PERCENT:
|
||||
return "ELEVATED"
|
||||
|
||||
if adverse:
|
||||
return "MODERATE"
|
||||
|
||||
return "LOW"
|
||||
return "LOW"
|
||||
|
||||
# определить, застряла ли позиция без нормального движения
|
||||
def _position_stall_state(self, state: AutoTradeState) -> tuple[str, str]:
|
||||
hold_seconds = safe_float(getattr(state, "position_hold_seconds", None)) or 0.0
|
||||
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
|
||||
mfe = max(
|
||||
safe_float(state.position_peak_pnl_percent) or 0.0,
|
||||
safe_float(state.position_pnl_percent) or 0.0,
|
||||
)
|
||||
mae = safe_float(getattr(state, "position_mae_percent", None)) or 0.0
|
||||
|
||||
market_quality = str(
|
||||
getattr(state, "market_trend_quality", "") or ""
|
||||
).upper()
|
||||
|
||||
adverse_momentum = bool(
|
||||
getattr(state, "position_adverse_momentum", False)
|
||||
)
|
||||
|
||||
trend_alignment = str(
|
||||
getattr(state, "position_trend_alignment", "") or ""
|
||||
).upper()
|
||||
|
||||
if hold_seconds < POSITION_STALL_EARLY_SECONDS:
|
||||
return "EARLY", "позиция открыта недавно"
|
||||
|
||||
if pnl_percent is None:
|
||||
return "NONE", "нет данных PnL"
|
||||
|
||||
# low_progress = позиция не дала нормального плюса
|
||||
# и сейчас находится около нуля.
|
||||
# MAE используем отдельно: если был глубокий минус,
|
||||
# это уже не просто "стоит", а ухудшение качества позиции.
|
||||
low_progress = (
|
||||
abs(pnl_percent) <= POSITION_STALL_LOW_PROGRESS_PNL_PERCENT
|
||||
and mfe <= POSITION_STALL_LOW_PROGRESS_MFE_PERCENT
|
||||
)
|
||||
had_adverse_excursion = mae <= POSITION_STALL_ADVERSE_MAE_PERCENT
|
||||
|
||||
if not low_progress:
|
||||
return "NONE", "позиция развивается"
|
||||
|
||||
# Пока прошло меньше 10 минут —
|
||||
# обычный рынок ещё может "раскачаться".
|
||||
if hold_seconds < POSITION_STALL_DEVELOPING_SECONDS:
|
||||
return "NONE", "позиция ещё развивается"
|
||||
|
||||
# После 10 минут рынок уже начинает говорить сам за себя.
|
||||
if adverse_momentum or trend_alignment == "AGAINST" or had_adverse_excursion:
|
||||
return "ADVERSE_STALLED", "позиция застряла, рынок против неё"
|
||||
|
||||
if market_quality == "NOISY":
|
||||
return "NOISY_STALLED", "позиция застряла в шумном рынке"
|
||||
|
||||
# Только для обычного рынка спустя длительное время.
|
||||
if hold_seconds >= POSITION_STALL_CONFIRMED_SECONDS:
|
||||
return "STALLED", "позиция долго не развивается"
|
||||
|
||||
return "NONE", "критичного застоя нет"
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import time
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
|
||||
@@ -31,8 +31,8 @@ class AutoTradeRunner:
|
||||
_bot: ClassVar[Bot | None] = None
|
||||
_chat_id: ClassVar[int | None] = None
|
||||
_message_id: ClassVar[int | None] = None
|
||||
_render_text: ClassVar[staticmethod | None] = None
|
||||
_render_markup: ClassVar[staticmethod | None] = None
|
||||
_render_text: ClassVar[Any] = None
|
||||
_render_markup: ClassVar[Any] = None
|
||||
_current_screen: ClassVar[str | None] = None
|
||||
_analysis_interval_seconds = 5
|
||||
_ui_interval_seconds = 30
|
||||
@@ -44,6 +44,14 @@ class AutoTradeRunner:
|
||||
_last_screen_state_key: ClassVar[str | None] = None
|
||||
_position_aligned_signal_log_interval_seconds = 900
|
||||
_last_position_aligned_signal_log_at_by_key: dict[str, float] = {}
|
||||
_edit_lock: ClassVar[asyncio.Lock | None] = None
|
||||
|
||||
@classmethod
|
||||
def edit_lock(cls) -> asyncio.Lock:
|
||||
if cls._edit_lock is None:
|
||||
cls._edit_lock = asyncio.Lock()
|
||||
|
||||
return cls._edit_lock
|
||||
|
||||
@classmethod
|
||||
def register_screen(
|
||||
@@ -53,13 +61,13 @@ class AutoTradeRunner:
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
render_text: Callable[[], str],
|
||||
render_markup: Callable[[], object],
|
||||
render_markup: Callable[[], Any],
|
||||
) -> None:
|
||||
cls._bot = bot
|
||||
cls._chat_id = chat_id
|
||||
cls._message_id = message_id
|
||||
cls._render_text = staticmethod(render_text)
|
||||
cls._render_markup = staticmethod(render_markup)
|
||||
cls._render_text = render_text
|
||||
cls._render_markup = render_markup
|
||||
cls._last_text = None
|
||||
cls._last_semantic_text = None
|
||||
cls._last_screen_state_key = None
|
||||
@@ -169,6 +177,7 @@ class AutoTradeRunner:
|
||||
if cls._task is not None and not cls._task.done():
|
||||
return
|
||||
|
||||
cls._last_event_version = EventBus.version()
|
||||
cls._task = asyncio.create_task(cls._worker())
|
||||
|
||||
@classmethod
|
||||
@@ -209,37 +218,53 @@ class AutoTradeRunner:
|
||||
|
||||
state = service.get_state()
|
||||
|
||||
previous_event_version = cls._last_event_version
|
||||
current_event_version = EventBus.version()
|
||||
has_important_event = current_event_version != cls._last_event_version
|
||||
events = EventBus.events_after(previous_event_version)
|
||||
has_important_event = bool(events)
|
||||
|
||||
screen_state_key = cls._screen_state_key(state)
|
||||
has_screen_state_changed = screen_state_key != cls._last_screen_state_key
|
||||
|
||||
if has_screen_state_changed:
|
||||
cls._last_screen_state_key = screen_state_key
|
||||
|
||||
force_refresh = False
|
||||
|
||||
if has_important_event:
|
||||
for event_version, event_type, payload in events:
|
||||
if (
|
||||
event_type == "auto_decision_changed"
|
||||
and cls._has_position_opened_event(events)
|
||||
):
|
||||
continue
|
||||
|
||||
if event_type in {
|
||||
"paper_position_opened",
|
||||
"paper_position_closed",
|
||||
"paper_position_flipped",
|
||||
}:
|
||||
force_refresh = True
|
||||
|
||||
try:
|
||||
await cls._handle_important_event(
|
||||
state=state,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
)
|
||||
except Exception as exc:
|
||||
cls._log_refresh_error(
|
||||
"auto_event_handler_error",
|
||||
{
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"event_type": event_type,
|
||||
"event_version": event_version,
|
||||
},
|
||||
)
|
||||
|
||||
cls._last_event_version = current_event_version
|
||||
|
||||
event_type, _ = EventBus.last_event()
|
||||
force_refresh = event_type in {
|
||||
"paper_position_opened",
|
||||
"paper_position_closed",
|
||||
"paper_position_flipped",
|
||||
}
|
||||
|
||||
try:
|
||||
await cls._handle_important_event(state)
|
||||
except Exception as exc:
|
||||
cls._log_refresh_error(
|
||||
"auto_event_handler_error",
|
||||
{
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await cls._refresh_screen(
|
||||
force=force_refresh or has_screen_state_changed
|
||||
@@ -258,14 +283,47 @@ class AutoTradeRunner:
|
||||
@classmethod
|
||||
async def process_last_event_now(cls) -> None:
|
||||
state = AutoTradeService().get_state()
|
||||
await cls._handle_important_event(state)
|
||||
|
||||
previous_event_version = cls._last_event_version
|
||||
current_event_version = EventBus.version()
|
||||
events = EventBus.events_after(previous_event_version)
|
||||
|
||||
for event_version, event_type, payload in events:
|
||||
try:
|
||||
await cls._handle_important_event(
|
||||
state=state,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
)
|
||||
except Exception as exc:
|
||||
cls._log_refresh_error(
|
||||
"auto_event_handler_error",
|
||||
{
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"event_type": event_type,
|
||||
"event_version": event_version,
|
||||
},
|
||||
)
|
||||
|
||||
cls._last_event_version = current_event_version
|
||||
|
||||
@classmethod
|
||||
def _has_position_opened_event(cls, events) -> bool:
|
||||
for _, event_type, payload in events:
|
||||
if event_type == "paper_position_opened":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def _handle_important_event(
|
||||
cls,
|
||||
*,
|
||||
state,
|
||||
event_type: str | None,
|
||||
payload: JsonDict | None,
|
||||
) -> None:
|
||||
event_type, payload = EventBus.last_event()
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
@@ -277,15 +335,15 @@ class AutoTradeRunner:
|
||||
if signal not in {"BUY", "SELL"}:
|
||||
return
|
||||
|
||||
# Если сигнал совпадает с открытой позицией, не публикуем событие,
|
||||
# чтобы не создавать избыточные уведомления
|
||||
#if cls._is_position_aligned_signal(state=state, signal=signal):
|
||||
# cls._log_position_aligned_signal_suppressed(
|
||||
# state=state,
|
||||
# payload=payload,
|
||||
# signal=signal,
|
||||
# )
|
||||
# return
|
||||
signal_intent = str(payload.get("signal_intent") or "").upper()
|
||||
|
||||
if signal_intent == "REINFORCE_POSITION":
|
||||
cls._log_position_aligned_signal_suppressed(
|
||||
state=state,
|
||||
payload=payload,
|
||||
signal=signal,
|
||||
)
|
||||
return
|
||||
|
||||
cls._publish_strong_signal_event(state=state, payload=payload)
|
||||
return
|
||||
@@ -485,11 +543,17 @@ class AutoTradeRunner:
|
||||
)
|
||||
|
||||
reason = str(payload.get("reason") or state.last_signal_reason or "—")
|
||||
position_context = str(getattr(state, "position_side", "NONE") or "NONE").upper()
|
||||
is_aligned_signal = cls._is_position_aligned_signal(
|
||||
state=state,
|
||||
signal=signal,
|
||||
)
|
||||
signal_intent = str(payload.get("signal_intent") or "").upper()
|
||||
if signal_intent == "ENTRY_CANDIDATE":
|
||||
position_context = "NONE"
|
||||
else:
|
||||
position_context = str(
|
||||
payload.get("position_side")
|
||||
or getattr(state, "position_side", "NONE")
|
||||
or "NONE"
|
||||
).upper()
|
||||
|
||||
is_aligned_signal = signal_intent == "REINFORCE_POSITION"
|
||||
|
||||
price_payload = cls._signal_price_payload(
|
||||
state=state,
|
||||
@@ -510,9 +574,13 @@ class AutoTradeRunner:
|
||||
source="auto_trade_runner",
|
||||
title=f"Auto strong signal {signal}",
|
||||
payload={
|
||||
"execution_block_title": getattr(state, "execution_block_title", None),
|
||||
"execution_block_message": getattr(state, "execution_block_message", None),
|
||||
"execution_block_action": getattr(state, "execution_block_action", None),
|
||||
"symbol": symbol,
|
||||
"strategy": strategy,
|
||||
"signal": signal,
|
||||
"signal_intent": signal_intent,
|
||||
"repeat_count": repeat_count,
|
||||
"confidence": confidence,
|
||||
"leverage": leverage,
|
||||
@@ -521,6 +589,13 @@ class AutoTradeRunner:
|
||||
"position_side": position_context,
|
||||
"is_position_aligned_signal": is_aligned_signal,
|
||||
"decision_status": state.decision_status,
|
||||
|
||||
# market_score передаём в уведомления,
|
||||
# чтобы позже можно было показывать “Рынок · благоприятный · 82%”
|
||||
# не только в экране, но и в событиях/алертах.
|
||||
"market_score": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
|
||||
"semantic_lines": semantic_lines,
|
||||
**price_payload,
|
||||
},
|
||||
@@ -531,11 +606,8 @@ class AutoTradeRunner:
|
||||
f"{symbol}:"
|
||||
f"{strategy}:"
|
||||
f"{signal}:"
|
||||
f"{repeat_count}:"
|
||||
f"{confidence:.2f}:"
|
||||
f"{state.decision_status}:"
|
||||
f"{reason}:"
|
||||
f"aligned={is_aligned_signal}"
|
||||
f"{signal_intent}:"
|
||||
f"{state.decision_status}"
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -577,6 +649,12 @@ class AutoTradeRunner:
|
||||
else state.leverage
|
||||
),
|
||||
"strategy": state.strategy,
|
||||
|
||||
# Фиксируем market_score на момент открытия/закрытия/flip,
|
||||
# чтобы журнал и уведомления показывали рыночный контекст сделки.
|
||||
"market_score": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
|
||||
"semantic_lines": semantic_lines,
|
||||
},
|
||||
priority="normal",
|
||||
@@ -701,6 +779,12 @@ class AutoTradeRunner:
|
||||
getattr(state, "market_trend_quality", None),
|
||||
getattr(state, "market_phase", None),
|
||||
getattr(state, "market_phase_direction", None),
|
||||
|
||||
# Общая оценка рынка влияет на заголовок блока “Рынок”
|
||||
# и на adaptive size, поэтому изменение score должно сразу обновлять UI.
|
||||
getattr(state, "market_score", None),
|
||||
getattr(state, "market_score_label", None),
|
||||
|
||||
getattr(state, "entry_block_reason", None),
|
||||
getattr(state, "entry_block_message", None),
|
||||
getattr(state, "execution_quality", None),
|
||||
@@ -721,121 +805,99 @@ class AutoTradeRunner:
|
||||
getattr(state, "cycle_winning_trades", None),
|
||||
getattr(state, "last_execution_action", None),
|
||||
getattr(state, "last_execution_reason", None),
|
||||
getattr(state, "execution_block_title", None),
|
||||
getattr(state, "execution_block_message", None),
|
||||
getattr(state, "execution_block_action", None),
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _refresh_screen(cls, *, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
async with cls.edit_lock():
|
||||
now = time.monotonic()
|
||||
|
||||
if now < cls._retry_after_until:
|
||||
cls._log_refresh_skip(
|
||||
"retry_after_active",
|
||||
{"retry_after_until": cls._retry_after_until, "now": now},
|
||||
)
|
||||
return
|
||||
|
||||
if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds:
|
||||
cls._log_refresh_skip(
|
||||
"ui_interval_not_reached",
|
||||
{
|
||||
"elapsed": round(now - cls._last_ui_refresh_at, 2),
|
||||
"interval": cls._ui_interval_seconds,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if not all(
|
||||
[
|
||||
cls._bot,
|
||||
cls._chat_id,
|
||||
cls._message_id,
|
||||
cls._render_text,
|
||||
cls._render_markup,
|
||||
]
|
||||
):
|
||||
cls._log_refresh_skip(
|
||||
"screen_not_registered",
|
||||
{
|
||||
"has_bot": cls._bot is not None,
|
||||
"chat_id": cls._chat_id,
|
||||
"message_id": cls._message_id,
|
||||
"has_render_text": cls._render_text is not None,
|
||||
"has_render_markup": cls._render_markup is not None,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
render_text = cls._render_text
|
||||
render_markup = cls._render_markup
|
||||
bot = cls._bot
|
||||
|
||||
if (
|
||||
render_text is None
|
||||
or render_markup is None
|
||||
or bot is None
|
||||
):
|
||||
return
|
||||
|
||||
text = render_text()
|
||||
semantic_text = build_auto_notification_text()
|
||||
|
||||
if semantic_text == cls._last_semantic_text:
|
||||
cls._log_refresh_skip("text_not_changed")
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(
|
||||
chat_id=cls._chat_id,
|
||||
message_id=cls._message_id,
|
||||
text=text,
|
||||
reply_markup=render_markup(),
|
||||
)
|
||||
cls._last_text = text
|
||||
cls._last_semantic_text = semantic_text
|
||||
cls._last_ui_refresh_at = now
|
||||
|
||||
cls._log_refresh_success(
|
||||
{
|
||||
"chat_id": cls._chat_id,
|
||||
"message_id": cls._message_id,
|
||||
"text_length": len(text),
|
||||
}
|
||||
)
|
||||
|
||||
except TelegramRetryAfter as exc:
|
||||
cls._retry_after_until = time.monotonic() + exc.retry_after + 15
|
||||
cls._last_ui_refresh_at = time.monotonic()
|
||||
return
|
||||
|
||||
except TelegramBadRequest as exc:
|
||||
error_text = str(exc).lower()
|
||||
|
||||
if "message is not modified" in error_text:
|
||||
cls._last_text = text
|
||||
cls._last_semantic_text = semantic_text
|
||||
cls._last_ui_refresh_at = now
|
||||
cls._log_refresh_skip("telegram_message_not_modified")
|
||||
return
|
||||
|
||||
if "message to edit not found" in error_text:
|
||||
cls._message_id = None
|
||||
cls._render_text = None
|
||||
cls._render_markup = None
|
||||
cls._last_text = None
|
||||
cls._log_refresh_error(
|
||||
"telegram_message_to_edit_not_found",
|
||||
{"error": str(exc)},
|
||||
if now < cls._retry_after_until:
|
||||
cls._log_refresh_skip(
|
||||
"retry_after_active",
|
||||
{"retry_after_until": cls._retry_after_until, "now": now},
|
||||
)
|
||||
return
|
||||
|
||||
cls._log_refresh_error(
|
||||
"telegram_bad_request",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
if not force and now - cls._last_ui_refresh_at < cls._ui_interval_seconds:
|
||||
cls._log_refresh_skip(
|
||||
"ui_interval_not_reached",
|
||||
{
|
||||
"elapsed": round(now - cls._last_ui_refresh_at, 2),
|
||||
"interval": cls._ui_interval_seconds,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as exc:
|
||||
cls._log_refresh_error(
|
||||
"unexpected_refresh_error",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
bot = cls._bot
|
||||
chat_id = cls._chat_id
|
||||
message_id = cls._message_id
|
||||
render_text = cls._render_text
|
||||
render_markup = cls._render_markup
|
||||
|
||||
if (
|
||||
bot is None
|
||||
or chat_id is None
|
||||
or message_id is None
|
||||
or render_text is None
|
||||
or render_markup is None
|
||||
):
|
||||
cls._log_refresh_skip("screen_not_registered")
|
||||
return
|
||||
|
||||
text = render_text()
|
||||
semantic_text = build_auto_notification_text()
|
||||
markup = render_markup()
|
||||
|
||||
if (
|
||||
bot is not cls._bot
|
||||
or chat_id != cls._chat_id
|
||||
or message_id != cls._message_id
|
||||
or render_text is not cls._render_text
|
||||
or render_markup is not cls._render_markup
|
||||
):
|
||||
cls._log_refresh_skip("screen_changed_during_render")
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
|
||||
cls._last_text = text
|
||||
cls._last_semantic_text = semantic_text
|
||||
cls._last_ui_refresh_at = now
|
||||
|
||||
except TelegramRetryAfter as exc:
|
||||
cls._retry_after_until = time.monotonic() + exc.retry_after + 15
|
||||
cls._last_ui_refresh_at = time.monotonic()
|
||||
return
|
||||
|
||||
except TelegramBadRequest as exc:
|
||||
error_text = str(exc).lower()
|
||||
|
||||
if "message is not modified" in error_text:
|
||||
cls._last_text = text
|
||||
cls._last_semantic_text = semantic_text
|
||||
cls._last_ui_refresh_at = now
|
||||
return
|
||||
|
||||
if "message to edit not found" in error_text:
|
||||
cls._message_id = None
|
||||
cls._render_text = None
|
||||
cls._render_markup = None
|
||||
cls._last_text = None
|
||||
cls._last_semantic_text = None
|
||||
return
|
||||
|
||||
cls._log_refresh_error("telegram_bad_request", {"error": str(exc)})
|
||||
|
||||
except Exception as exc:
|
||||
cls._log_refresh_error("unexpected_refresh_error", {"error": str(exc)})
|
||||
@@ -11,16 +11,13 @@ from src.trading.auto.state import AutoTradeState
|
||||
|
||||
class AutoTradeService(AutoLifecycleMixin):
|
||||
|
||||
# =========================================================
|
||||
# GLOBAL SERVICE STATE
|
||||
# =========================================================
|
||||
|
||||
# единый runtime state автоторговли
|
||||
# хранит:
|
||||
# - сигналы
|
||||
# - market context
|
||||
# - execution context
|
||||
# - pnl
|
||||
# - PnL открытой позиции и реализованный PnL
|
||||
# - lifecycle
|
||||
# - protection state
|
||||
_state = AutoTradeState()
|
||||
@@ -32,52 +29,40 @@ class AutoTradeService(AutoLifecycleMixin):
|
||||
|
||||
# интервал между auto-trading циклами
|
||||
# run_cycle() вызывается каждые N секунд
|
||||
_loop_interval_seconds = 5
|
||||
_loop_interval_seconds: int = 5
|
||||
|
||||
# =========================================================
|
||||
# SIGNAL CONFIRMATION ENGINE
|
||||
# =========================================================
|
||||
|
||||
# минимальное количество одинаковых BUY/SELL подряд
|
||||
# чтобы сигнал считался подтвержденным
|
||||
_confirm_repeats = 2
|
||||
_confirm_repeats: int = 2
|
||||
|
||||
# минимальное время удержания сигнала
|
||||
# перед execution
|
||||
_confirm_min_duration_seconds = 10
|
||||
_confirm_min_duration_seconds: int = 10
|
||||
|
||||
# =========================================================
|
||||
# EXECUTION CONFIDENCE RULES
|
||||
# =========================================================
|
||||
|
||||
# минимальный confidence для READY state
|
||||
# ниже -> сигнал не считается готовым
|
||||
_ready_confidence = 0.3
|
||||
_ready_confidence = 0.45
|
||||
|
||||
# минимальный execution confidence
|
||||
# для реального допуска execution engine
|
||||
_execution_confidence_required_score = 0.55
|
||||
_execution_confidence_required_score = 0.65
|
||||
|
||||
# =========================================================
|
||||
# RUNTIME TTL
|
||||
# =========================================================
|
||||
|
||||
# время жизни signal runtime
|
||||
# после ttl сигнал считается устаревшим
|
||||
_signal_ttl_seconds = 90
|
||||
_signal_ttl_seconds: int = 90
|
||||
|
||||
# время жизни market analysis runtime
|
||||
# после ttl market context считается stale
|
||||
_market_analysis_ttl_seconds = 180
|
||||
_market_analysis_ttl_seconds: int = 180
|
||||
|
||||
# последний logged runtime expiration key
|
||||
# нужен чтобы не спамить одинаковыми логами
|
||||
_last_logged_runtime_expired_key: str | None = None
|
||||
|
||||
# =========================================================
|
||||
# SIGNAL MEMORY
|
||||
# =========================================================
|
||||
|
||||
# уникальный ключ последнего сигнала
|
||||
# используется для deduplication
|
||||
_last_signal_key: str | None = None
|
||||
@@ -100,40 +85,20 @@ class AutoTradeService(AutoLifecycleMixin):
|
||||
# нужен для confirmation timing
|
||||
_last_signal_started_at: float | None = None
|
||||
|
||||
# =========================================================
|
||||
# MARKET STATE LOG MEMORY
|
||||
# =========================================================
|
||||
|
||||
# последние logged market states
|
||||
# нужны чтобы не дублировать одинаковые runtime logs
|
||||
|
||||
_last_logged_market_state: str | None = None
|
||||
_last_logged_market_trend: str | None = None
|
||||
_last_logged_market_volatility: str | None = None
|
||||
|
||||
# последнее logged reason блокировки входа
|
||||
_last_logged_entry_block_reason: str | None = None
|
||||
|
||||
# количество одинаковых сигналов подряд
|
||||
# используется confirmation engine
|
||||
_same_signal_count = 0
|
||||
_same_signal_count: int = 0
|
||||
|
||||
# =========================================================
|
||||
# EXECUTION SNAPSHOT VALIDATION
|
||||
# =========================================================
|
||||
|
||||
# максимальный допустимый возраст execution snapshot
|
||||
# старше -> snapshot stale
|
||||
_max_snapshot_age_seconds = 5.0
|
||||
_max_snapshot_age_seconds: float = 5.0
|
||||
|
||||
# warning threshold snapshot age
|
||||
# выше -> degraded execution quality
|
||||
_warning_snapshot_age_seconds = 2.0
|
||||
_warning_snapshot_age_seconds: float = 2.0
|
||||
|
||||
# =========================================================
|
||||
# SPREAD RISK THRESHOLDS
|
||||
# =========================================================
|
||||
|
||||
# asset-specific spread thresholds
|
||||
#
|
||||
# warning_enter:
|
||||
|
||||
@@ -149,6 +149,12 @@ class AutoSignalRuntimeMixin:
|
||||
state.is_signal_ready = False
|
||||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||||
|
||||
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
|
||||
|
||||
if signal == "HOLD":
|
||||
state.signal_confirmation_seconds = 0
|
||||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||||
@@ -160,15 +166,12 @@ class AutoSignalRuntimeMixin:
|
||||
|
||||
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
|
||||
)
|
||||
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(
|
||||
@@ -369,10 +372,17 @@ class AutoSignalRuntimeMixin:
|
||||
signal=state.last_signal,
|
||||
)
|
||||
|
||||
if (
|
||||
ready_changed = (
|
||||
previous_decision_status != state.decision_status
|
||||
and state.decision_status == "READY"
|
||||
):
|
||||
)
|
||||
|
||||
ready_signal_changed = (
|
||||
previous_signal != state.last_signal
|
||||
and state.decision_status == "READY"
|
||||
)
|
||||
|
||||
if ready_changed or ready_signal_changed:
|
||||
self._log_ready_signal(
|
||||
state=state,
|
||||
signal=state.last_signal,
|
||||
@@ -390,13 +400,19 @@ class AutoSignalRuntimeMixin:
|
||||
"signal_intent": signal_intent,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"symbol": state.symbol,
|
||||
"strategy": state.strategy,
|
||||
},
|
||||
)
|
||||
|
||||
if previous_decision_status != state.decision_status:
|
||||
if (
|
||||
previous_decision_status != state.decision_status
|
||||
or ready_signal_changed
|
||||
):
|
||||
EventBus.emit(
|
||||
"auto_decision_changed",
|
||||
{
|
||||
"previous_signal": previous_signal,
|
||||
"previous_decision_status": previous_decision_status,
|
||||
"decision_status": state.decision_status,
|
||||
"signal": state.last_signal,
|
||||
@@ -485,10 +501,13 @@ class AutoSignalRuntimeMixin:
|
||||
if normalized_signal not in {"BUY", "SELL"}:
|
||||
return
|
||||
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
try:
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception:
|
||||
snapshot = {}
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
@@ -499,24 +518,141 @@ class AutoSignalRuntimeMixin:
|
||||
screen="auto",
|
||||
action="signal_ready",
|
||||
payload={
|
||||
"strategy": state.strategy,
|
||||
# ---------- Event ----------
|
||||
"event_type": "signal_ready",
|
||||
"action": "signal_ready",
|
||||
"is_aggregated": False,
|
||||
"is_strong_signal": confidence > self._ready_confidence,
|
||||
|
||||
# ---------- Runtime ----------
|
||||
"status": state.status,
|
||||
"strategy": state.strategy,
|
||||
"symbol": state.symbol,
|
||||
"cycle_number": state.cycle_number,
|
||||
|
||||
# ---------- Signal ----------
|
||||
"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 ----------
|
||||
"confirmation_seconds": state.signal_confirmation_seconds,
|
||||
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
|
||||
"confirmation_missing_repeats": state.signal_confirmation_missing_repeats,
|
||||
"confirmation_progress": state.signal_confirmation_progress,
|
||||
"confirmation_reason": state.signal_confirmation_reason,
|
||||
|
||||
# ---------- Decision ----------
|
||||
"decision_status": state.decision_status,
|
||||
"decision_reason": state.decision_reason,
|
||||
"is_signal_confirmed": state.is_signal_confirmed,
|
||||
"is_signal_ready": state.is_signal_ready,
|
||||
|
||||
# ---------- Position Context ----------
|
||||
"position_side": state.position_side,
|
||||
"entry_price": state.entry_price,
|
||||
"position_size": state.position_size,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"current_trade_id": state.current_trade_id,
|
||||
"current_trade_cycle_number": state.current_trade_cycle_number,
|
||||
|
||||
# ---------- Risk Settings ----------
|
||||
"risk_percent": state.risk_percent,
|
||||
"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,
|
||||
"allocated_balance_usd": state.allocated_balance_usd,
|
||||
"leverage": state.leverage,
|
||||
|
||||
# ---------- Execution Confidence ----------
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"execution_confidence_level": state.execution_confidence_level,
|
||||
"execution_confidence_required_score": state.execution_confidence_required_score,
|
||||
"execution_confidence_reason": state.execution_confidence_reason,
|
||||
"execution_confidence_factors": state.execution_confidence_factors,
|
||||
|
||||
# ---------- Execution Quality ----------
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"execution_quality_message": state.execution_quality_message,
|
||||
"spread_percent": state.spread_percent,
|
||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||
|
||||
# ---------- Live Snapshot ----------
|
||||
"bid_price": snapshot.get("bid_price"),
|
||||
"ask_price": snapshot.get("ask_price"),
|
||||
"last_price": snapshot.get("last_price"),
|
||||
|
||||
# ---------- Market Score ----------
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
|
||||
# ---------- Market ----------
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_volatility": state.market_volatility,
|
||||
"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,
|
||||
|
||||
# ---------- Candle ----------
|
||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
||||
"current_interval_change_percent": state.current_interval_change_percent,
|
||||
"current_interval_direction": state.current_interval_direction,
|
||||
"current_interval_label": state.current_interval_label,
|
||||
|
||||
# ---------- Structure ----------
|
||||
"market_structure": state.market_structure,
|
||||
"market_structure_reason": state.market_structure_reason,
|
||||
|
||||
# ---------- Trend Quality ----------
|
||||
"market_trend_gap_percent": state.market_trend_gap_percent,
|
||||
"market_trend_consistency": state.market_trend_consistency,
|
||||
"market_trend_efficiency": state.market_trend_efficiency,
|
||||
"trend_quality_score": state.trend_quality_score,
|
||||
"ema_distance_atr_ratio": state.ema_distance_atr_ratio,
|
||||
"ema_distance_state": state.ema_distance_state,
|
||||
"entry_timing_state": state.entry_timing_state,
|
||||
"entry_timing_reason": state.entry_timing_reason,
|
||||
|
||||
# ---------- Momentum / Breakout ----------
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"momentum_change_percent": state.momentum_change_percent,
|
||||
"momentum_strength": state.momentum_strength,
|
||||
"breakout_level": state.breakout_level,
|
||||
"breakout_distance_percent": state.breakout_distance_percent,
|
||||
"breakout_reason": state.breakout_reason,
|
||||
|
||||
# ---------- HTF ----------
|
||||
"htf_interval": state.htf_interval,
|
||||
"htf_atr_percent": state.htf_atr_percent,
|
||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
||||
"htf_volatility": state.htf_volatility,
|
||||
"htf_market_state": state.htf_market_state,
|
||||
"htf_trend": state.htf_trend,
|
||||
"htf_trend_strength": state.htf_trend_strength,
|
||||
"htf_trend_quality": state.htf_trend_quality,
|
||||
"htf_market_phase": state.htf_market_phase,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"htf_reason": state.htf_reason,
|
||||
|
||||
# ---------- Runtime Health ----------
|
||||
"market_runtime_degraded": state.market_runtime_degraded,
|
||||
"runtime_expired_reason": state.runtime_expired_reason,
|
||||
"runtime_expired_message": state.runtime_expired_message,
|
||||
"market_is_open": state.market_is_open,
|
||||
"market_status": state.market_status,
|
||||
"market_status_message": state.market_status_message,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
@@ -536,7 +672,36 @@ class AutoSignalRuntimeMixin:
|
||||
if signal_age > self._signal_ttl_seconds:
|
||||
previous_signal = state.last_signal
|
||||
|
||||
self._reset_signal_tracking()
|
||||
# Сбрасываем только signal runtime.
|
||||
# Нельзя вызывать _reset_signal_tracking(), потому что она также
|
||||
# очищает market/HTF/momentum context.
|
||||
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.last_signal = "HOLD"
|
||||
state.last_signal_repeat_count = 0
|
||||
state.last_signal_confidence = 0.0
|
||||
state.last_signal_reason = None
|
||||
state.signal_started_at = None
|
||||
state.signal_updated_at = None
|
||||
state.decision_status = "WAITING"
|
||||
state.decision_reason = "Сигнал устарел."
|
||||
state.is_signal_confirmed = False
|
||||
state.is_signal_ready = False
|
||||
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.execution_confidence_score = None
|
||||
state.execution_confidence_level = None
|
||||
state.execution_confidence_reason = None
|
||||
state.execution_confidence_factors = None
|
||||
state.execution_confidence_required_score = self._execution_confidence_required_score
|
||||
|
||||
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
|
||||
state.runtime_expired_message = "сигнал устарел и был сброшен"
|
||||
@@ -576,6 +741,19 @@ class AutoSignalRuntimeMixin:
|
||||
state.market_trend_quality = None
|
||||
state.market_phase = None
|
||||
state.market_phase_direction = None
|
||||
state.current_interval_change_percent = None
|
||||
state.current_interval_direction = None
|
||||
state.current_interval_label = None
|
||||
state.last_closed_candle_change_percent = None
|
||||
state.last_closed_candle_direction = None
|
||||
# Сбрасываем общую оценку рынка вместе с market context,
|
||||
# чтобы UI не показывал старый процент после истечения TTL.
|
||||
state.market_score = None
|
||||
state.market_score_label = None
|
||||
state.market_long_score = None
|
||||
state.market_short_score = None
|
||||
state.market_structure = None
|
||||
state.market_structure_reason = None
|
||||
state.market_trend_gap_percent = None
|
||||
state.market_trend_consistency = None
|
||||
state.market_trend_efficiency = None
|
||||
@@ -593,6 +771,15 @@ class AutoSignalRuntimeMixin:
|
||||
state.htf_atr_percent_baseline = None
|
||||
state.htf_volatility_ratio = None
|
||||
state.htf_volatility = None
|
||||
state.htf_market_state = None
|
||||
state.htf_trend = None
|
||||
state.htf_trend_strength = None
|
||||
state.htf_trend_quality = None
|
||||
state.htf_market_phase = None
|
||||
state.htf_alignment = None
|
||||
state.htf_confirmation_score = None
|
||||
state.htf_reason = None
|
||||
|
||||
state.momentum_state = None
|
||||
state.momentum_direction = None
|
||||
state.momentum_change_percent = None
|
||||
@@ -600,6 +787,7 @@ class AutoSignalRuntimeMixin:
|
||||
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 = "анализ рынка устарел"
|
||||
|
||||
@@ -664,7 +852,16 @@ class AutoSignalRuntimeMixin:
|
||||
|
||||
signal_score = self._clamp_score(confidence)
|
||||
confirmation_score = self._clamp_score(state.signal_confirmation_progress)
|
||||
market_score = self._market_confidence_score(state)
|
||||
|
||||
# ВАЖНО:
|
||||
# market_score теперь считается с учётом направления сигнала.
|
||||
# Раньше BUY мог получить хороший market_score просто потому,
|
||||
# что рынок трендовый, даже если тренд/моментум были против BUY.
|
||||
market_score = self._market_confidence_score(
|
||||
state=state,
|
||||
signal=signal,
|
||||
)
|
||||
|
||||
execution_quality_confidence_score = cast(
|
||||
Callable[[AutoTradeState], float],
|
||||
getattr(self, "_execution_quality_confidence_score"),
|
||||
@@ -687,14 +884,27 @@ class AutoSignalRuntimeMixin:
|
||||
state.execution_confidence_factors = {
|
||||
"signal_score": round(signal_score, 3),
|
||||
"confirmation_score": round(confirmation_score, 3),
|
||||
# market_score здесь — направленная рыночная оценка 0.0..1.0
|
||||
# именно для текущего BUY / SELL сигнала.
|
||||
# state.market_score — общая оценка рынка 0..100 без привязки к сигналу.
|
||||
"market_score": round(market_score, 3),
|
||||
"market_score_raw": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
"execution_score": round(execution_score, 3),
|
||||
"required_score": self._execution_confidence_required_score,
|
||||
"signal": signal,
|
||||
"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,
|
||||
"current_interval_change_percent": getattr(state, "current_interval_change_percent", None),
|
||||
"current_interval_direction": getattr(state, "current_interval_direction", None),
|
||||
"current_interval_label": getattr(state, "current_interval_label", None),
|
||||
"market_structure": getattr(state, "market_structure", None),
|
||||
"market_structure_reason": getattr(state, "market_structure_reason", None),
|
||||
"htf_alignment": getattr(state, "htf_alignment", None),
|
||||
"htf_confirmation_score": getattr(state, "htf_confirmation_score", None),
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"spread_percent": state.spread_percent,
|
||||
@@ -708,71 +918,215 @@ class AutoSignalRuntimeMixin:
|
||||
}
|
||||
|
||||
# рассчитать 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
|
||||
def _market_confidence_score(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
signal: str,
|
||||
) -> float:
|
||||
market_state = str(state.market_state or "").upper()
|
||||
market_trend = str(state.market_trend or "").upper()
|
||||
strength = str(state.market_trend_strength or "").upper()
|
||||
quality = str(state.market_trend_quality or "").upper()
|
||||
phase = str(state.market_phase or "").upper()
|
||||
current_interval_direction = str(
|
||||
getattr(state, "current_interval_direction", "") or ""
|
||||
).upper()
|
||||
current_interval_change_percent = safe_float(
|
||||
getattr(state, "current_interval_change_percent", None)
|
||||
)
|
||||
ema_distance_state = str(state.ema_distance_state or "").upper()
|
||||
entry_timing_state = str(state.entry_timing_state or "").upper()
|
||||
momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper()
|
||||
momentum_state = str(getattr(state, "momentum_state", "") or "").upper()
|
||||
trend_quality_score = safe_float(state.trend_quality_score)
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
htf_confirmation_score = safe_float(getattr(state, "htf_confirmation_score", None))
|
||||
market_structure = str(getattr(state, "market_structure", "") or "").upper()
|
||||
normalized_signal = str(signal or "").upper()
|
||||
|
||||
if market_state in {
|
||||
"HIGH_VOLATILITY",
|
||||
"LOW_VOLATILITY",
|
||||
"RANGE",
|
||||
"UNKNOWN",
|
||||
None,
|
||||
"",
|
||||
}:
|
||||
early_impulse_market = (
|
||||
market_state == "RANGE"
|
||||
and phase == "IMPULSE"
|
||||
and htf_alignment in {"ALIGNED", "SAME_INTERVAL"}
|
||||
and (
|
||||
(
|
||||
normalized_signal == "BUY"
|
||||
and market_trend == "UP"
|
||||
and momentum_direction == "UP"
|
||||
and momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
|
||||
)
|
||||
or (
|
||||
normalized_signal == "SELL"
|
||||
and market_trend == "DOWN"
|
||||
and momentum_direction == "DOWN"
|
||||
and momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
current_interval_supports_signal = (
|
||||
(
|
||||
normalized_signal == "BUY"
|
||||
and current_interval_direction == "UP"
|
||||
)
|
||||
or (
|
||||
normalized_signal == "SELL"
|
||||
and current_interval_direction == "DOWN"
|
||||
)
|
||||
)
|
||||
|
||||
current_interval_against_signal = (
|
||||
(
|
||||
normalized_signal == "BUY"
|
||||
and current_interval_direction == "DOWN"
|
||||
)
|
||||
or (
|
||||
normalized_signal == "SELL"
|
||||
and current_interval_direction == "UP"
|
||||
)
|
||||
)
|
||||
|
||||
current_interval_move_abs = abs(current_interval_change_percent or 0.0)
|
||||
|
||||
if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "UNKNOWN", ""}:
|
||||
return 0.15
|
||||
|
||||
if market_state == "RANGE" and not early_impulse_market:
|
||||
return 0.15
|
||||
|
||||
# Жёсткая защита от входа против локального тренда.
|
||||
if normalized_signal == "BUY" and market_trend == "DOWN":
|
||||
return 0.05
|
||||
|
||||
if normalized_signal == "SELL" and market_trend == "UP":
|
||||
return 0.05
|
||||
|
||||
# Жёсткая защита от входа против momentum.
|
||||
if normalized_signal == "BUY" and momentum_direction == "DOWN":
|
||||
return 0.05
|
||||
|
||||
if normalized_signal == "SELL" and momentum_direction == "UP":
|
||||
return 0.05
|
||||
|
||||
# После ужесточения фильтров лучше не давать высокий confidence,
|
||||
# если momentum вообще не подтверждает направление входа.
|
||||
if normalized_signal == "BUY" and momentum_direction != "UP":
|
||||
return 0.25
|
||||
|
||||
score = 0.65
|
||||
if normalized_signal == "SELL" and momentum_direction != "DOWN":
|
||||
return 0.25
|
||||
|
||||
# HTF против входа должен почти обнулять рыночную часть confidence.
|
||||
if htf_alignment == "AGAINST":
|
||||
return 0.05
|
||||
|
||||
if htf_alignment == "UNKNOWN":
|
||||
return 0.25
|
||||
|
||||
if htf_confirmation_score is not None and htf_confirmation_score < 0.55:
|
||||
return 0.35
|
||||
|
||||
# Структура против направления входа.
|
||||
if normalized_signal == "BUY" and market_structure == "LH_LL":
|
||||
return 0.10
|
||||
|
||||
if normalized_signal == "SELL" and market_structure == "HH_HL":
|
||||
return 0.10
|
||||
|
||||
if market_structure == "MIXED":
|
||||
score_penalty_for_structure = 0.08
|
||||
else:
|
||||
score_penalty_for_structure = 0.0
|
||||
|
||||
score = 0.60
|
||||
score -= score_penalty_for_structure
|
||||
|
||||
if early_impulse_market:
|
||||
score += 0.10
|
||||
|
||||
# “Сейчас (5м)” — короткий подтверждающий фактор.
|
||||
# Он не открывает сделку сам по себе, но усиливает или ослабляет market_score.
|
||||
if current_interval_supports_signal:
|
||||
score += 0.05
|
||||
|
||||
if current_interval_against_signal:
|
||||
score -= 0.10
|
||||
|
||||
if current_interval_move_abs >= 0.12:
|
||||
score -= 0.05
|
||||
|
||||
if strength == "STRONG":
|
||||
score += 0.2
|
||||
score += 0.16
|
||||
elif strength == "NORMAL":
|
||||
score += 0.1
|
||||
score += 0.08
|
||||
elif strength == "WEAK":
|
||||
score -= 0.25
|
||||
|
||||
if quality == "CLEAN":
|
||||
score += 0.12
|
||||
score += 0.10
|
||||
elif quality == "NORMAL":
|
||||
score += 0.04
|
||||
elif quality == "NOISY":
|
||||
score -= 0.25
|
||||
score -= 0.12
|
||||
|
||||
if phase == "IMPULSE":
|
||||
score += 0.1
|
||||
score += 0.08
|
||||
elif phase == "PULLBACK":
|
||||
score -= 0.25
|
||||
elif phase in {"RANGE", "SQUEEZE"}:
|
||||
score -= 0.3
|
||||
score -= 0.35
|
||||
|
||||
if ema_distance_state == "HEALTHY":
|
||||
score += 0.08
|
||||
elif ema_distance_state == "EXTENDED":
|
||||
score -= 0.08
|
||||
score -= 0.10
|
||||
elif ema_distance_state == "COMPRESSED":
|
||||
score -= 0.18
|
||||
score -= 0.20
|
||||
elif ema_distance_state == "OVEREXTENDED":
|
||||
score -= 0.35
|
||||
score -= 0.40
|
||||
|
||||
if entry_timing_state == "NORMAL":
|
||||
score += 0.08
|
||||
elif entry_timing_state == "EARLY":
|
||||
score -= 0.05
|
||||
score -= 0.08
|
||||
elif entry_timing_state == "LATE":
|
||||
score -= 0.2
|
||||
score -= 0.25
|
||||
elif entry_timing_state == "CHASING":
|
||||
score -= 0.35
|
||||
score -= 0.40
|
||||
|
||||
if momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}:
|
||||
score += 0.06
|
||||
elif momentum_state in {"MOMENTUM_UP", "MOMENTUM_DOWN"}:
|
||||
score += 0.04
|
||||
|
||||
current_interval_penalty = 0.0
|
||||
|
||||
if normalized_signal == "BUY" and current_interval_direction == "DOWN":
|
||||
current_interval_penalty = 0.08
|
||||
|
||||
if normalized_signal == "SELL" and current_interval_direction == "UP":
|
||||
current_interval_penalty = 0.08
|
||||
|
||||
if trend_quality_score is not None:
|
||||
if trend_quality_score >= 0.7:
|
||||
score += 0.08
|
||||
score += 0.06
|
||||
elif trend_quality_score < 0.45:
|
||||
score -= 0.15
|
||||
score -= 0.18
|
||||
|
||||
if htf_alignment == "ALIGNED":
|
||||
score += 0.12
|
||||
|
||||
if htf_confirmation_score is not None and htf_confirmation_score >= 0.75:
|
||||
score += 0.06
|
||||
|
||||
if normalized_signal == "BUY" and market_structure == "HH_HL":
|
||||
score += 0.08
|
||||
|
||||
if normalized_signal == "SELL" and market_structure == "LH_LL":
|
||||
score += 0.08
|
||||
|
||||
score -= current_interval_penalty
|
||||
|
||||
return self._clamp_score(score)
|
||||
|
||||
|
||||
@@ -14,14 +14,11 @@ class AutoTradeState:
|
||||
strategy: str | None = "TREND"
|
||||
|
||||
# торговый инструмент
|
||||
symbol: str = "BTC/USD_LEVERAGE"
|
||||
symbol: str = "ETH/USD_LEVERAGE"
|
||||
|
||||
# риск на одну сделку в %
|
||||
risk_percent: float | None = 1.0
|
||||
|
||||
# текущий PnL
|
||||
pnl_usd: float = 0.0
|
||||
|
||||
# время последней проверки
|
||||
last_check_at: str | None = None
|
||||
|
||||
@@ -106,6 +103,15 @@ class AutoTradeState:
|
||||
position_exit_urgency: str | None = None
|
||||
position_reversal_risk: str | None = None
|
||||
|
||||
# stall-состояние позиции:
|
||||
# NONE — позиция развивается нормально
|
||||
# EARLY — ещё рано оценивать
|
||||
# STALLED — позиция стоит на месте
|
||||
# NOISY_STALLED — позиция застряла в шумном рынке
|
||||
# ADVERSE_STALLED — позиция застряла и рынок начинает идти против неё
|
||||
position_stall_state: str | None = None
|
||||
position_stall_reason: str | None = None
|
||||
|
||||
# autonomous trade management
|
||||
autonomous_action: str | None = None
|
||||
autonomous_action_reason: str | None = None
|
||||
@@ -153,7 +159,7 @@ class AutoTradeState:
|
||||
stop_loss_percent: float | None = 1.0
|
||||
|
||||
# take profit по движению цены в %
|
||||
take_profit_percent: float | None = None
|
||||
take_profit_percent: float | None = 2.0
|
||||
|
||||
# максимальный допустимый paper-убыток в USD
|
||||
max_loss_usd: float | None = None
|
||||
@@ -164,6 +170,11 @@ class AutoTradeState:
|
||||
# последняя причина блокировки execution
|
||||
execution_block_reason: str | None = None
|
||||
|
||||
# человекочитаемая блокировка совершения сделок для UI / Telegram
|
||||
execution_block_title: str | None = None
|
||||
execution_block_message: str | None = None
|
||||
execution_block_action: str | None = None
|
||||
|
||||
# причина авто-уменьшения размера позиции
|
||||
execution_size_adjustment_reason: str | None = None
|
||||
|
||||
@@ -182,6 +193,24 @@ class AutoTradeState:
|
||||
# количество прибыльных закрытых сделок
|
||||
cycle_winning_trades: int = 0
|
||||
|
||||
# количество убыточных сделок в текущем цикле
|
||||
cycle_losing_trades: int = 0
|
||||
|
||||
# серия убыточных сделок подряд
|
||||
cycle_consecutive_losses: int = 0
|
||||
|
||||
# активна ли cooldown-блокировка после серии убытков
|
||||
loss_cooldown_active: bool = False
|
||||
|
||||
# причина cooldown-блокировки
|
||||
loss_cooldown_reason: str | None = None
|
||||
|
||||
# сумма комиссий за сделки RT в текущем цикле
|
||||
cycle_trade_fees_usd: float = 0.0
|
||||
|
||||
# сумма списаний/начислений за левередж в текущем цикле
|
||||
cycle_overnight_fees_usd: float = 0.0
|
||||
|
||||
# время запуска текущего цикла
|
||||
cycle_started_at: float | None = None
|
||||
|
||||
@@ -230,6 +259,38 @@ class AutoTradeState:
|
||||
# направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN
|
||||
market_phase_direction: str | None = None
|
||||
|
||||
# общая оценка рынка 0..100 на основе всех market-метрик:
|
||||
# HTF trend, локальный trend, фаза, структура, волатильность, качество, timing.
|
||||
market_score: float | None = None
|
||||
|
||||
# человекочитаемая категория market_score:
|
||||
# отличный / благоприятный / нейтральный / сложный / неблагоприятный
|
||||
market_score_label: str | None = None
|
||||
|
||||
# направленная оценка входа 0..100.
|
||||
# market_long_score — насколько хорош вход в Long.
|
||||
# market_short_score — насколько хорош вход в Short.
|
||||
# Это не дубль market_score: market_score = общий рынок,
|
||||
# long/short score = оценка конкретного направления.
|
||||
market_long_score: float | None = None
|
||||
market_short_score: float | None = None
|
||||
|
||||
# последняя полностью закрытая свеча.
|
||||
# В Dzengi последняя candle[-1] обычно текущая формирующаяся,
|
||||
# поэтому закрытая свеча берётся как candle[-2].
|
||||
last_closed_candle_change_percent: float | None = None
|
||||
last_closed_candle_direction: str | None = None
|
||||
|
||||
# движение внутри текущей свечи/интервала анализа.
|
||||
# Используется как short-term фактор для входа/выхода и UI.
|
||||
current_interval_change_percent: float | None = None
|
||||
current_interval_direction: str | None = None
|
||||
current_interval_label: str | None = None
|
||||
|
||||
# структура рынка: HH_HL / LH_LL / MIXED / UNKNOWN
|
||||
market_structure: str | None = None
|
||||
market_structure_reason: str | None = None
|
||||
|
||||
# advanced trend quality metrics
|
||||
market_trend_gap_percent: float | None = None
|
||||
market_trend_consistency: float | None = None
|
||||
@@ -253,6 +314,16 @@ class AutoTradeState:
|
||||
htf_volatility_ratio: float | None = None
|
||||
htf_volatility: str | None = None
|
||||
|
||||
# higher timeframe trend context
|
||||
htf_market_state: str | None = None
|
||||
htf_trend: str | None = None
|
||||
htf_trend_strength: str | None = None
|
||||
htf_trend_quality: str | None = None
|
||||
htf_market_phase: str | None = None
|
||||
htf_alignment: str | None = None
|
||||
htf_confirmation_score: float | None = None
|
||||
htf_reason: str | None = None
|
||||
|
||||
# состояние momentum/breakout semantic engine
|
||||
# NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN
|
||||
momentum_state: str | None = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,9 @@ class SemanticRuntimeDiagnostics:
|
||||
"age_seconds": market_age_seconds,
|
||||
"entry_block_reason": state.entry_block_reason,
|
||||
"entry_block_message": state.entry_block_message,
|
||||
# Общая оценка рынка 0..100 для UI/диагностики.
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
}
|
||||
|
||||
def _momentum_section(self, state: AutoTradeState) -> dict[str, Any]:
|
||||
@@ -141,6 +144,10 @@ class SemanticRuntimeDiagnostics:
|
||||
"effective_risk_percent": state.effective_risk_percent,
|
||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||
"size_adjustment_reason": state.execution_size_adjustment_reason,
|
||||
# Сохраняем market_score рядом с adaptive size,
|
||||
# чтобы было видно, повлиял ли рынок на размер позиции.
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
}
|
||||
|
||||
def _position_section(self, state: AutoTradeState) -> dict[str, Any]:
|
||||
@@ -198,6 +205,8 @@ class SemanticRuntimeDiagnostics:
|
||||
"mode": state.status,
|
||||
"signal": state.last_signal,
|
||||
"market": state.market_state,
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"phase": state.market_phase,
|
||||
"momentum": state.momentum_state,
|
||||
"execution": state.execution_semantic_status,
|
||||
|
||||
@@ -8,6 +8,9 @@ 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
|
||||
from src.integrations.exchange.market_data_runner import MarketDataRunner
|
||||
from src.trading.execution.position_metrics import build_position_metrics
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
class SemanticDiagnosticSnapshotBuilder:
|
||||
@@ -55,6 +58,8 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"is_confirmed": state.is_signal_confirmed,
|
||||
"is_ready": state.is_signal_ready,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"required_repeats": state.signal_confirmation_missing_repeats
|
||||
+ state.last_signal_repeat_count,
|
||||
"confirmation_progress": state.signal_confirmation_progress,
|
||||
"age_seconds": signal_age_seconds,
|
||||
"reason": state.last_signal_reason,
|
||||
@@ -67,6 +72,17 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"trend_quality": state.market_trend_quality,
|
||||
"phase": state.market_phase,
|
||||
"phase_direction": state.market_phase_direction,
|
||||
# Таймфрейм локального анализа рынка.
|
||||
"interval": state.market_analysis_interval,
|
||||
"current_interval_change_percent": state.current_interval_change_percent,
|
||||
"current_interval_direction": state.current_interval_direction,
|
||||
"current_interval_label": state.current_interval_label,
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
||||
"entry_block_reason": state.entry_block_reason,
|
||||
"entry_block_message": state.entry_block_message,
|
||||
"age_seconds": market_age_seconds,
|
||||
@@ -74,6 +90,8 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"market_status": state.market_status,
|
||||
"market_status_message": state.market_status_message,
|
||||
"market_status_updated_at": state.market_status_updated_at,
|
||||
"market_structure": state.market_structure,
|
||||
"market_structure_reason": state.market_structure_reason,
|
||||
"trend_gap_percent": state.market_trend_gap_percent,
|
||||
"trend_consistency": state.market_trend_consistency,
|
||||
"trend_efficiency": state.market_trend_efficiency,
|
||||
@@ -91,6 +109,14 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
||||
"htf_volatility": state.htf_volatility,
|
||||
"htf_market_state": state.htf_market_state,
|
||||
"htf_trend": state.htf_trend,
|
||||
"htf_trend_strength": state.htf_trend_strength,
|
||||
"htf_trend_quality": state.htf_trend_quality,
|
||||
"htf_market_phase": state.htf_market_phase,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"htf_reason": state.htf_reason,
|
||||
},
|
||||
"momentum": {
|
||||
"state": getattr(state, "momentum_state", None),
|
||||
@@ -129,6 +155,11 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"effective_target_risk_usd": state.effective_target_risk_usd,
|
||||
"reason": state.adaptive_size_reason,
|
||||
"factors": state.adaptive_size_factors,
|
||||
# Общая оценка рынка на момент расчёта размера позиции.
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
},
|
||||
"position": {
|
||||
"side": state.position_side,
|
||||
@@ -164,6 +195,7 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"adverse_momentum": position_health.get("adverse_momentum"),
|
||||
},
|
||||
"runtime_health": {
|
||||
"market_data_runtime": MarketDataRunner.get_runtime_state("auto"),
|
||||
"exchange_statuses": runtime_exchange_alerts,
|
||||
"exchange_status": (
|
||||
runtime_exchange_alerts[0]
|
||||
@@ -201,6 +233,10 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"main_message": self._main_message(state=state, blockers=blockers),
|
||||
|
||||
"market": state.market_state,
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
"phase": state.market_phase,
|
||||
"momentum": getattr(state, "momentum_state", None),
|
||||
"execution": state.execution_semantic_status,
|
||||
@@ -271,7 +307,16 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
) -> int:
|
||||
score = 100
|
||||
|
||||
if state.status != "RUNNING":
|
||||
# Если MarketAnalysisService уже дал общую оценку рынка,
|
||||
# diagnostics-health лучше строить от неё, а дальше корректировать
|
||||
# runtime-блокировками, execution quality и статусом автоторговли.
|
||||
market_score = safe_float(getattr(state, "market_score", None))
|
||||
if market_score is not None:
|
||||
score = int(max(0, min(100, market_score)))
|
||||
|
||||
if state.status == "OFF":
|
||||
score -= 25
|
||||
elif state.status != "RUNNING":
|
||||
score -= 10
|
||||
|
||||
if blockers:
|
||||
@@ -282,39 +327,51 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
elif state.execution_quality == "WARNING":
|
||||
score -= 15
|
||||
|
||||
if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}:
|
||||
score -= 15
|
||||
if market_score is None:
|
||||
# Старый fallback: если общей оценки рынка нет,
|
||||
# health_score собирается из отдельных market-метрик.
|
||||
if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}:
|
||||
score -= 15
|
||||
|
||||
if state.market_trend_strength == "WEAK":
|
||||
score -= 10
|
||||
if state.market_trend_strength == "WEAK":
|
||||
score -= 10
|
||||
|
||||
if state.market_trend_quality == "NOISY":
|
||||
score -= 10
|
||||
if state.market_trend_quality == "NOISY":
|
||||
score -= 10
|
||||
|
||||
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
|
||||
score -= 10
|
||||
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
|
||||
score -= 10
|
||||
|
||||
if state.ema_distance_state == "COMPRESSED":
|
||||
score -= 10
|
||||
if state.market_structure == "MIXED":
|
||||
score -= 15
|
||||
|
||||
if state.ema_distance_state == "EXTENDED":
|
||||
score -= 8
|
||||
if state.market_structure == "HH_HL" and state.market_trend == "DOWN":
|
||||
score -= 20
|
||||
|
||||
if state.ema_distance_state == "OVEREXTENDED":
|
||||
score -= 25
|
||||
if state.market_structure == "LH_LL" and state.market_trend == "UP":
|
||||
score -= 20
|
||||
|
||||
if state.entry_timing_state == "LATE":
|
||||
score -= 18
|
||||
if state.ema_distance_state == "COMPRESSED":
|
||||
score -= 10
|
||||
|
||||
if state.entry_timing_state == "CHASING":
|
||||
score -= 30
|
||||
if state.ema_distance_state == "EXTENDED":
|
||||
score -= 8
|
||||
|
||||
trend_quality_score = safe_float(state.trend_quality_score)
|
||||
if trend_quality_score is not None:
|
||||
if trend_quality_score < 0.45:
|
||||
score -= 12
|
||||
elif trend_quality_score >= 0.7:
|
||||
score += 5
|
||||
if state.ema_distance_state == "OVEREXTENDED":
|
||||
score -= 25
|
||||
|
||||
if state.entry_timing_state == "LATE":
|
||||
score -= 18
|
||||
|
||||
if state.entry_timing_state == "CHASING":
|
||||
score -= 30
|
||||
|
||||
trend_quality_score = safe_float(state.trend_quality_score)
|
||||
if trend_quality_score is not None:
|
||||
if trend_quality_score < 0.45:
|
||||
score -= 12
|
||||
elif trend_quality_score >= 0.7:
|
||||
score += 5
|
||||
|
||||
if state.market_runtime_degraded:
|
||||
score -= 15
|
||||
@@ -340,6 +397,9 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
|
||||
if state.market_is_open is False:
|
||||
return "RED"
|
||||
|
||||
if state.market_score is not None and state.market_score < 25:
|
||||
return "RED"
|
||||
|
||||
has_waiting_data_blocker = any(
|
||||
str(item).strip().lower()
|
||||
@@ -354,6 +414,24 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
if has_waiting_data_blocker:
|
||||
return "WAITING"
|
||||
|
||||
# Структура рынка — это hard-block, а не обычное ожидание.
|
||||
if state.entry_block_reason in {
|
||||
"MARKET_STRUCTURE_CONFLICT",
|
||||
"MARKET_STRUCTURE_MIXED",
|
||||
}:
|
||||
return "RED"
|
||||
|
||||
# Market filter сначала оцениваем как блокировку,
|
||||
# иначе HOLD преждевременно вернёт WAITING.
|
||||
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
|
||||
if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
|
||||
return "RED"
|
||||
|
||||
if state.market_trend_quality == "NOISY":
|
||||
return "RED"
|
||||
|
||||
return "YELLOW"
|
||||
|
||||
if (
|
||||
state.execution_quality == "BLOCKED"
|
||||
or state.decision_status == "BLOCKED"
|
||||
@@ -373,15 +451,6 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
if signal == "HOLD" and not has_ready_signal:
|
||||
return "WAITING"
|
||||
|
||||
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
|
||||
if state.market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
|
||||
return "RED"
|
||||
|
||||
if state.market_trend_quality == "NOISY":
|
||||
return "RED"
|
||||
|
||||
return "YELLOW"
|
||||
|
||||
if health_score < 45:
|
||||
return "YELLOW"
|
||||
|
||||
@@ -426,12 +495,17 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
if state.market_is_open is False:
|
||||
return state.market_status_message or "Биржа временно недоступна для торговли."
|
||||
|
||||
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
|
||||
if state.market_state == "RANGE" or state.market_phase == "RANGE":
|
||||
return "Ожидание: рынок без направления."
|
||||
# Структура рынка — отдельная жёсткая причина блокировки,
|
||||
# чтобы UI не показывал её как обычное ожидание рынка.
|
||||
if state.entry_block_reason == "MARKET_STRUCTURE_CONFLICT":
|
||||
return "Вход заблокирован: структура рынка против направления."
|
||||
|
||||
if state.entry_block_reason == "MARKET_STRUCTURE_MIXED":
|
||||
return "Вход заблокирован: структура рынка не подтверждает направление."
|
||||
|
||||
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
|
||||
return self._market_filter_message(state)
|
||||
|
||||
return "Осторожно: рынок не подходит."
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
reason = str(state.execution_quality_reason or "")
|
||||
|
||||
@@ -463,6 +537,36 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
|
||||
return "Критичных ограничений нет."
|
||||
|
||||
def _market_filter_message(self, state: AutoTradeState) -> str:
|
||||
if state.market_volatility == "HIGH":
|
||||
return "Ожидание: движение слишком резкое, вход сейчас рискованный."
|
||||
|
||||
if state.entry_timing_state in {"LATE", "CHASING"}:
|
||||
return "Ожидание: цена уже сильно прошла, входить поздно."
|
||||
|
||||
if state.ema_distance_state == "OVEREXTENDED":
|
||||
return "Ожидание: цена ушла слишком далеко после импульса."
|
||||
|
||||
if state.ema_distance_state == "COMPRESSED":
|
||||
return "Ожидание: рынок слишком сжат, направление ещё не подтвердилось."
|
||||
|
||||
if state.market_trend_quality == "NOISY":
|
||||
return "Ожидание: движение есть, но оно шумное и ненадёжное."
|
||||
|
||||
if state.market_structure == "MIXED":
|
||||
return "Ожидание: структура рынка противоречивая."
|
||||
|
||||
if state.market_phase == "PULLBACK":
|
||||
return "Ожидание: рынок в откате, ждём подтверждения продолжения."
|
||||
|
||||
if state.market_state == "RANGE" or state.market_phase in {"RANGE", "SQUEEZE"}:
|
||||
return "Ожидание: рынок пока без понятного направления."
|
||||
|
||||
if state.entry_block_message:
|
||||
return f"Ожидание: {state.entry_block_message}."
|
||||
|
||||
return "Ожидание: условия для входа пока не совпали."
|
||||
|
||||
def _blockers(self, state: AutoTradeState) -> list[str]:
|
||||
blockers: list[str] = []
|
||||
|
||||
@@ -473,9 +577,15 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
)
|
||||
return blockers
|
||||
|
||||
if state.entry_block_reason in {
|
||||
"MARKET_STRUCTURE_CONFLICT",
|
||||
"MARKET_STRUCTURE_MIXED",
|
||||
}:
|
||||
blockers.append(str(state.entry_block_message or "структура рынка не подтверждает вход"))
|
||||
|
||||
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
|
||||
if state.market_state == "RANGE" or state.market_phase == "RANGE":
|
||||
blockers.append("рынок без направления")
|
||||
blockers.append(self._market_filter_message(state).replace("Ожидание: ", "").rstrip("."))
|
||||
elif state.entry_block_message:
|
||||
blockers.append(str(state.entry_block_message))
|
||||
else:
|
||||
@@ -493,8 +603,15 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
if state.entry_timing_state == "CHASING":
|
||||
blockers.append("вход запрещён: chasing move")
|
||||
|
||||
# Добавляем entry_block_message только если это не тот же текст,
|
||||
# который уже был добавлен выше через MARKET_FILTER_BLOCKED / STRUCTURE.
|
||||
if state.entry_block_message:
|
||||
blockers.append(str(state.entry_block_message))
|
||||
message = str(state.entry_block_message)
|
||||
|
||||
normalized = message.strip()
|
||||
|
||||
if normalized and normalized not in blockers:
|
||||
blockers.append(normalized)
|
||||
|
||||
if state.execution_quality == "BLOCKED":
|
||||
blockers.append(str(state.execution_quality_message or "исполнение заблокировано"))
|
||||
@@ -531,51 +648,74 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
}
|
||||
|
||||
entry_price = safe_float(state.entry_price)
|
||||
position_size = safe_float(state.position_size)
|
||||
pnl = safe_float(state.unrealized_pnl_usd)
|
||||
stop_loss_usd = safe_float(state.effective_target_risk_usd)
|
||||
max_loss_usd = safe_float(state.max_loss_usd)
|
||||
|
||||
price_move_percent = self._position_price_move_percent(
|
||||
side=state.position_side,
|
||||
entry_price=entry_price,
|
||||
metrics = build_position_metrics(
|
||||
PositionState(
|
||||
side=state.position_side or "NONE",
|
||||
symbol=state.symbol,
|
||||
entry_price=entry_price,
|
||||
size=position_size,
|
||||
leverage=state.leverage,
|
||||
unrealized_pnl_usd=pnl,
|
||||
opened_monotonic_at=state.position_opened_monotonic_at,
|
||||
),
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
price_move_percent = metrics.price_move_percent
|
||||
|
||||
risk_used_percent = self._position_risk_used_percent(
|
||||
pnl=pnl,
|
||||
stop_loss_usd=stop_loss_usd,
|
||||
max_loss_usd=max_loss_usd,
|
||||
)
|
||||
|
||||
trend_alignment = self._position_trend_alignment(state)
|
||||
adverse_momentum = self._has_adverse_momentum(state)
|
||||
pressure_state = self._position_pressure_state(
|
||||
pnl=pnl,
|
||||
risk_used_percent=risk_used_percent,
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
health_state = str(state.position_health_status or "")
|
||||
health_score = state.position_health_score
|
||||
health_message = state.position_health_reason
|
||||
pressure_state = str(state.position_pressure or "")
|
||||
trend_alignment = str(state.position_trend_alignment or "")
|
||||
adverse_momentum = bool(state.position_adverse_momentum)
|
||||
|
||||
opened_age_seconds = self._age_seconds(
|
||||
now=time.monotonic(),
|
||||
started_at=state.position_opened_monotonic_at,
|
||||
)
|
||||
if not trend_alignment:
|
||||
trend_alignment = self._position_trend_alignment(state)
|
||||
|
||||
health_score = self._position_health_score(
|
||||
pnl=pnl,
|
||||
risk_used_percent=risk_used_percent,
|
||||
trend_alignment=trend_alignment,
|
||||
adverse_momentum=adverse_momentum,
|
||||
pressure_state=pressure_state,
|
||||
opened_age_seconds=opened_age_seconds,
|
||||
)
|
||||
if not adverse_momentum:
|
||||
adverse_momentum = self._has_adverse_momentum(state)
|
||||
|
||||
health_state = self._position_health_state(health_score)
|
||||
health_message = self._position_health_message(
|
||||
health_state=health_state,
|
||||
pressure_state=pressure_state,
|
||||
trend_alignment=trend_alignment,
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
if not pressure_state:
|
||||
pressure_state = self._position_pressure_state(
|
||||
pnl=pnl,
|
||||
risk_used_percent=risk_used_percent,
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
|
||||
if not health_state:
|
||||
health_score = self._position_health_score(
|
||||
pnl=pnl,
|
||||
risk_used_percent=risk_used_percent,
|
||||
trend_alignment=trend_alignment or "NEUTRAL",
|
||||
adverse_momentum=adverse_momentum,
|
||||
pressure_state=pressure_state or "UNKNOWN",
|
||||
opened_age_seconds=self._age_seconds(
|
||||
now=time.monotonic(),
|
||||
started_at=state.position_opened_monotonic_at,
|
||||
),
|
||||
)
|
||||
|
||||
health_state = self._position_health_state(health_score)
|
||||
|
||||
if not health_message:
|
||||
health_message = self._position_health_message(
|
||||
health_state=health_state,
|
||||
pressure_state=pressure_state or "UNKNOWN",
|
||||
trend_alignment=trend_alignment or "NEUTRAL",
|
||||
adverse_momentum=adverse_momentum,
|
||||
)
|
||||
|
||||
return {
|
||||
"health_state": health_state,
|
||||
@@ -588,29 +728,6 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
"adverse_momentum": adverse_momentum,
|
||||
}
|
||||
|
||||
def _position_price_move_percent(
|
||||
self,
|
||||
*,
|
||||
side: str | None,
|
||||
entry_price: float | None,
|
||||
current_price: float | None,
|
||||
) -> float | None:
|
||||
if entry_price is None or current_price is None:
|
||||
return None
|
||||
|
||||
if entry_price <= 0 or current_price <= 0:
|
||||
return None
|
||||
|
||||
normalized_side = str(side or "").upper()
|
||||
|
||||
if normalized_side == "LONG":
|
||||
return round(((current_price - entry_price) / entry_price) * 100, 4)
|
||||
|
||||
if normalized_side == "SHORT":
|
||||
return round(((entry_price - current_price) / entry_price) * 100, 4)
|
||||
|
||||
return None
|
||||
|
||||
def _position_risk_used_percent(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -5,138 +5,94 @@ 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
|
||||
from src.trading.execution.position_metrics import build_position_metrics
|
||||
|
||||
|
||||
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 %
|
||||
# =========================================================
|
||||
|
||||
class ExecutionCalculationsMixin(_ExecutionCalculationsProtocol):
|
||||
# Единая точка расчёта движения цены позиции.
|
||||
# Вся логика вынесена в position_metrics.py,
|
||||
# чтобы LONG/SHORT считались одинаково во всех частях execution.
|
||||
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
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
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
|
||||
# =========================================================
|
||||
return metrics.price_move_percent
|
||||
|
||||
# Единая точка расчёта итогового PnL.
|
||||
# Возвращает net PnL:
|
||||
# gross PnL - комиссия вход/выход + overnight cashflow.
|
||||
def _calculate_pnl(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
"""
|
||||
Рассчитать unrealized pnl позиции.
|
||||
"""
|
||||
|
||||
position = type(self)._position
|
||||
|
||||
price = safe_float(current_price) or 0.0
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
entry = safe_float(
|
||||
position.entry_price
|
||||
) or 0.0
|
||||
return metrics.net_pnl_usd
|
||||
|
||||
size = safe_float(
|
||||
position.size
|
||||
) or 0.0
|
||||
# Gross PnL без комиссии и overnight.
|
||||
# Оставляем метод как совместимый wrapper,
|
||||
# но сам расчёт теперь берётся из position_metrics.py.
|
||||
def _calculate_gross_pnl(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
position = type(self)._position
|
||||
|
||||
# -----------------------------------------------------
|
||||
# LONG
|
||||
# -----------------------------------------------------
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
if position.side == "LONG":
|
||||
return round(
|
||||
(price - entry) * size,
|
||||
4,
|
||||
)
|
||||
return metrics.gross_pnl_usd
|
||||
|
||||
# -----------------------------------------------------
|
||||
# SHORT
|
||||
# -----------------------------------------------------
|
||||
# Комиссия вход + предполагаемый выход.
|
||||
# Теперь считается централизованно через position_metrics.py.
|
||||
def _calculate_round_trip_commission(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
position = type(self)._position
|
||||
|
||||
if position.side == "SHORT":
|
||||
return round(
|
||||
(entry - price) * size,
|
||||
4,
|
||||
)
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
return 0.0
|
||||
return metrics.commission_usd
|
||||
|
||||
# =========================================================
|
||||
# TIME
|
||||
# =========================================================
|
||||
# Overnight / leverage cashflow.
|
||||
# Может быть отрицательным или положительным,
|
||||
# зависит от ставки биржи для LONG/SHORT.
|
||||
def _calculate_overnight_cashflow(
|
||||
self,
|
||||
current_price: NumericLike | None,
|
||||
) -> float:
|
||||
position = type(self)._position
|
||||
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
return metrics.overnight_cashflow_usd
|
||||
|
||||
def _now_time(self) -> str:
|
||||
"""
|
||||
Current execution timestamp.
|
||||
"""
|
||||
|
||||
return datetime.now().strftime(
|
||||
"%H:%M:%S"
|
||||
)
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
360
app/src/trading/execution/constants.py
Normal file
360
app/src/trading/execution/constants.py
Normal file
@@ -0,0 +1,360 @@
|
||||
# app/src/trading/execution/constants.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# ----- Runtime autonomous actions -----
|
||||
|
||||
RUNTIME_ACTION_COOLDOWN_SECONDS = 30
|
||||
RUNTIME_EXIT_CONFIDENCE_THRESHOLD = 0.75
|
||||
|
||||
RUNTIME_ACTION_SKIPPED = "RUNTIME_ACTION_SKIPPED"
|
||||
RUNTIME_ACTION_COOLDOWN = "RUNTIME_ACTION_COOLDOWN"
|
||||
RUNTIME_ACTION_UNKNOWN = "RUNTIME_ACTION_UNKNOWN"
|
||||
|
||||
|
||||
# ----- Auto / execution states -----
|
||||
|
||||
AUTO_STATUS_RUNNING = "RUNNING"
|
||||
|
||||
EXECUTION_STATUS_RUNNING = "RUNNING"
|
||||
EXECUTION_DECISION_READY = "READY"
|
||||
|
||||
|
||||
# ----- Position sides -----
|
||||
|
||||
POSITION_SIDE_NONE = "NONE"
|
||||
POSITION_SIDE_LONG = "LONG"
|
||||
POSITION_SIDE_SHORT = "SHORT"
|
||||
|
||||
|
||||
# ----- Signals -----
|
||||
|
||||
SIGNAL_BUY = "BUY"
|
||||
SIGNAL_SELL = "SELL"
|
||||
|
||||
|
||||
# ----- Execution actions -----
|
||||
|
||||
EXECUTION_ACTION_NONE = "NONE"
|
||||
EXECUTION_ACTION_OPEN_LONG = "OPEN_LONG"
|
||||
EXECUTION_ACTION_OPEN_SHORT = "OPEN_SHORT"
|
||||
EXECUTION_ACTION_CLOSE = "CLOSE"
|
||||
EXECUTION_ACTION_FLIP_BLOCKED = "FLIP_BLOCKED"
|
||||
|
||||
EXECUTION_ACTION_FORCE_CLOSE_PREFIX = "FORCE_CLOSE_"
|
||||
|
||||
|
||||
# -----Execution types -----
|
||||
|
||||
EXECUTION_TYPE_ENTRY = "ENTRY"
|
||||
EXECUTION_TYPE_EXIT = "EXIT"
|
||||
EXECUTION_TYPE_ENTRY_REJECTED = "ENTRY_REJECTED"
|
||||
EXECUTION_TYPE_RUNTIME_ACTION = "RUNTIME_ACTION"
|
||||
|
||||
EXECUTION_TYPE_FLIP = "FLIP"
|
||||
EXECUTION_TYPE_FLIP_REJECTED = "FLIP_REJECTED"
|
||||
EXECUTION_TYPE_FLIP_BLOCKED = "FLIP_BLOCKED"
|
||||
|
||||
|
||||
# ----- Execution reasons -----
|
||||
|
||||
EXECUTION_REASON_MANUAL = "MANUAL"
|
||||
EXECUTION_REASON_AUTONOMOUS_EXIT = "AUTONOMOUS_EXIT"
|
||||
|
||||
|
||||
# ----- Pricing modes -----
|
||||
|
||||
PRICING_ENTRY_MODE = "ask_for_long_bid_for_short"
|
||||
PRICING_EXIT_MODE = "bid_for_long_exit_ask_for_short_exit"
|
||||
PRICING_FLIP_MODE = "exit_by_side_then_entry_by_side"
|
||||
|
||||
|
||||
# ----- Execution limits -----
|
||||
|
||||
EXECUTION_MAX_CONSECUTIVE_LOSSES = 5
|
||||
|
||||
# ----- Execution quality -----
|
||||
|
||||
EXECUTION_QUALITY_BLOCKED = "BLOCKED"
|
||||
EXECUTION_QUALITY_WARNING = "WARNING"
|
||||
|
||||
|
||||
# ----- Autonomous management -----
|
||||
|
||||
AUTONOMOUS_ACTION_HOLD = "HOLD"
|
||||
AUTONOMOUS_ACTION_WATCH = "WATCH"
|
||||
AUTONOMOUS_ACTION_PROTECT = "PROTECT"
|
||||
AUTONOMOUS_ACTION_REDUCE = "REDUCE"
|
||||
AUTONOMOUS_ACTION_EXIT = "EXIT"
|
||||
AUTONOMOUS_ACTION_EXIT_BLOCKED = "EXIT_BLOCKED"
|
||||
|
||||
AUTONOMOUS_EXIT_CONFIDENCE_THRESHOLD = 0.75
|
||||
AUTONOMOUS_AGGRESSIVE_EXIT_CONFIDENCE_THRESHOLD = 0.65
|
||||
|
||||
|
||||
# ----- Position exit signals -----
|
||||
|
||||
POSITION_EXIT_SIGNAL_HOLD = "HOLD"
|
||||
POSITION_EXIT_SIGNAL_WATCH = "WATCH"
|
||||
POSITION_EXIT_SIGNAL_REDUCE_OR_PROTECT = "REDUCE_OR_PROTECT"
|
||||
POSITION_EXIT_SIGNAL_EXIT = "EXIT"
|
||||
|
||||
POSITION_EXIT_SIGNAL_EXIT_CONFIDENCE = 0.75
|
||||
POSITION_EXIT_SIGNAL_PROTECT_CONFIDENCE = 0.50
|
||||
POSITION_EXIT_SIGNAL_WATCH_CONFIDENCE = 0.30
|
||||
|
||||
|
||||
# ----- Position pressure / trend -----
|
||||
|
||||
POSITION_PRESSURE_HIGH_LOSS = "HIGH_LOSS"
|
||||
POSITION_PRESSURE_LOSS = "LOSS"
|
||||
|
||||
POSITION_TREND_AGAINST = "AGAINST"
|
||||
|
||||
|
||||
# ----- Position risk -----
|
||||
|
||||
POSITION_RISK_HIGH = "HIGH"
|
||||
POSITION_RISK_ELEVATED = "ELEVATED"
|
||||
POSITION_RISK_MODERATE = "MODERATE"
|
||||
POSITION_RISK_LOW = "LOW"
|
||||
|
||||
|
||||
# ----- Position health -----
|
||||
|
||||
POSITION_HEALTH_HEALTHY = "HEALTHY"
|
||||
POSITION_HEALTH_WATCH = "WATCH"
|
||||
POSITION_HEALTH_PRESSURE = "PRESSURE"
|
||||
POSITION_HEALTH_DANGER = "DANGER"
|
||||
POSITION_HEALTH_UNKNOWN = "UNKNOWN"
|
||||
|
||||
POSITION_HEALTH_PNL_HARD_LOSS_PERCENT = -1.0
|
||||
POSITION_HEALTH_PNL_HIGH_PRESSURE_PERCENT = -0.6
|
||||
POSITION_HEALTH_PNL_PRESSURE_PERCENT = -0.25
|
||||
POSITION_HEALTH_PNL_GOOD_PROFIT_PERCENT = 0.8
|
||||
|
||||
POSITION_EXIT_PRESSURE_LOSS_PERCENT = -0.4
|
||||
|
||||
|
||||
# ----- Position stop-loss ratios -----
|
||||
|
||||
POSITION_STOP_LOSS_RATIO_WATCH = 0.50
|
||||
POSITION_STOP_LOSS_RATIO_WARNING = 0.55
|
||||
POSITION_STOP_LOSS_RATIO_CRITICAL = 0.80
|
||||
|
||||
|
||||
# ----- Position momentum / candle thresholds -----
|
||||
|
||||
POSITION_MOMENTUM_STRONG = 0.80
|
||||
|
||||
POSITION_CURRENT_INTERVAL_ADVERSE_MOVE_PERCENT = 0.04
|
||||
POSITION_CURRENT_INTERVAL_RISK_MOVE_PERCENT = 0.12
|
||||
|
||||
|
||||
# ----- Position lifecycle / semantics -----
|
||||
|
||||
POSITION_LIFECYCLE_NEW_SECONDS = 60
|
||||
POSITION_LIFECYCLE_ACTIVE_SECONDS = 300
|
||||
POSITION_LIFECYCLE_MATURE_SECONDS = 900
|
||||
|
||||
POSITION_EXIT_DAMPING_NEW_SECONDS = 300
|
||||
POSITION_EXIT_DAMPING_MATURE_SECONDS = 900
|
||||
POSITION_EXIT_DAMPING_NEW_MULTIPLIER = 0.45
|
||||
POSITION_EXIT_DAMPING_MATURE_MULTIPLIER = 0.70
|
||||
|
||||
POSITION_GIVEBACK_HIGH_PERCENT = 70
|
||||
POSITION_GIVEBACK_MEDIUM_PERCENT = 45
|
||||
POSITION_GIVEBACK_LOW_PERCENT = 25
|
||||
|
||||
POSITION_REVERSAL_HIGH_GIVEBACK_PERCENT = 45
|
||||
POSITION_REVERSAL_ELEVATED_GIVEBACK_PERCENT = 25
|
||||
|
||||
POSITION_STALL_EARLY_SECONDS = 300
|
||||
POSITION_STALL_DEVELOPING_SECONDS = 600
|
||||
POSITION_STALL_CONFIRMED_SECONDS = 900
|
||||
POSITION_STALL_LOW_PROGRESS_PNL_PERCENT = 0.25
|
||||
POSITION_STALL_LOW_PROGRESS_MFE_PERCENT = 0.35
|
||||
POSITION_STALL_ADVERSE_MAE_PERCENT = -0.35
|
||||
|
||||
|
||||
# ----- Market / flip filters -----
|
||||
|
||||
MARKET_VOLATILITY_HIGH_STATES = {"HIGH", "HIGH_VOLATILITY"}
|
||||
|
||||
MARKET_STATE_FLIP_BLOCKED = {
|
||||
"RANGE",
|
||||
"HIGH_VOLATILITY",
|
||||
"LOW_VOLATILITY",
|
||||
"UNKNOWN",
|
||||
"",
|
||||
}
|
||||
|
||||
MARKET_PHASE_FLIP_BLOCKED = {
|
||||
"RANGE",
|
||||
"UNKNOWN",
|
||||
"",
|
||||
}
|
||||
|
||||
FLIP_MIN_EXECUTION_CONFIDENCE = 0.70
|
||||
FLIP_MIN_HTF_CONFIRMATION_SCORE = 0.65
|
||||
FLIP_BREAKOUT_CONFIDENCE_THRESHOLD = 0.85
|
||||
|
||||
# ----- Asset-specific thresholds -----
|
||||
|
||||
DEFAULT_POSITION_THRESHOLDS = {
|
||||
"health": {
|
||||
"high_loss": -0.75,
|
||||
"loss": -0.40,
|
||||
"profit": 0.45,
|
||||
"strong_profit": 1.20,
|
||||
},
|
||||
"exit": {
|
||||
"min_hold": 1500,
|
||||
"neutral_min_hold": 1800,
|
||||
"neutral_band": 0.40,
|
||||
"normal_pullback": -0.45,
|
||||
"hard_loss": -0.90,
|
||||
"noisy_min_hold": 600,
|
||||
"noisy_loss_exit": -0.30,
|
||||
"noisy_profit_giveback": 35,
|
||||
"clean_giveback_min_peak": 1.10,
|
||||
"clean_giveback_percent": 55,
|
||||
"noisy_giveback_min_peak": 0.40,
|
||||
"noisy_giveback_percent": 35,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
POSITION_THRESHOLDS_BY_ASSET = {
|
||||
"BTC": {
|
||||
"health": {
|
||||
"high_loss": -0.65,
|
||||
"loss": -0.30,
|
||||
"profit": 0.30,
|
||||
"strong_profit": 0.90,
|
||||
},
|
||||
"exit": {
|
||||
"min_hold": 1200,
|
||||
"neutral_min_hold": 1500,
|
||||
"neutral_band": 0.30,
|
||||
"normal_pullback": -0.35,
|
||||
"hard_loss": -0.75,
|
||||
"noisy_min_hold": 600,
|
||||
"noisy_loss_exit": -0.25,
|
||||
"noisy_profit_giveback": 35,
|
||||
"clean_giveback_min_peak": 1.20,
|
||||
"clean_giveback_percent": 55,
|
||||
"noisy_giveback_min_peak": 0.45,
|
||||
"noisy_giveback_percent": 35,
|
||||
},
|
||||
},
|
||||
"ETH": {
|
||||
"health": {
|
||||
"high_loss": -0.85,
|
||||
"loss": -0.45,
|
||||
"profit": 0.40,
|
||||
"strong_profit": 1.10,
|
||||
},
|
||||
"exit": {
|
||||
"min_hold": 1500,
|
||||
"neutral_min_hold": 1800,
|
||||
"neutral_band": 0.40,
|
||||
"normal_pullback": -0.45,
|
||||
"hard_loss": -0.85,
|
||||
"noisy_min_hold": 600,
|
||||
"noisy_loss_exit": -0.30,
|
||||
"noisy_profit_giveback": 35,
|
||||
"clean_giveback_min_peak": 1.10,
|
||||
"clean_giveback_percent": 55,
|
||||
"noisy_giveback_min_peak": 0.40,
|
||||
"noisy_giveback_percent": 35,
|
||||
},
|
||||
},
|
||||
"LTC": {
|
||||
"health": {
|
||||
"high_loss": -1.00,
|
||||
"loss": -0.55,
|
||||
"profit": 0.55,
|
||||
"strong_profit": 1.35,
|
||||
},
|
||||
"exit": {
|
||||
"min_hold": 1800,
|
||||
"neutral_min_hold": 2100,
|
||||
"neutral_band": 0.45,
|
||||
"normal_pullback": -0.55,
|
||||
"hard_loss": -1.00,
|
||||
"noisy_min_hold": 600,
|
||||
"noisy_loss_exit": -0.40,
|
||||
"noisy_profit_giveback": 35,
|
||||
"clean_giveback_min_peak": 1.20,
|
||||
"clean_giveback_percent": 55,
|
||||
"noisy_giveback_min_peak": 0.45,
|
||||
"noisy_giveback_percent": 35,
|
||||
},
|
||||
},
|
||||
"XRP": {
|
||||
"health": {
|
||||
"high_loss": -1.10,
|
||||
"loss": -0.60,
|
||||
"profit": 0.60,
|
||||
"strong_profit": 1.50,
|
||||
},
|
||||
"exit": {
|
||||
"min_hold": 1800,
|
||||
"neutral_min_hold": 2100,
|
||||
"neutral_band": 0.50,
|
||||
"normal_pullback": -0.60,
|
||||
"hard_loss": -1.10,
|
||||
"noisy_min_hold": 600,
|
||||
"noisy_loss_exit": -0.40,
|
||||
"noisy_profit_giveback": 35,
|
||||
"clean_giveback_min_peak": 1.20,
|
||||
"clean_giveback_percent": 55,
|
||||
"noisy_giveback_min_peak": 0.45,
|
||||
"noisy_giveback_percent": 35,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---- Helpers -----
|
||||
|
||||
def asset_symbol(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
|
||||
|
||||
|
||||
def get_position_thresholds(symbol: str | None) -> dict[str, dict[str, float]]:
|
||||
asset = asset_symbol(symbol)
|
||||
|
||||
return POSITION_THRESHOLDS_BY_ASSET.get(
|
||||
asset,
|
||||
DEFAULT_POSITION_THRESHOLDS,
|
||||
)
|
||||
|
||||
|
||||
def get_position_health_thresholds(symbol: str | None) -> dict[str, float]:
|
||||
return get_position_thresholds(symbol)["health"]
|
||||
|
||||
|
||||
def get_position_exit_thresholds(symbol: str | None) -> dict[str, float]:
|
||||
return get_position_thresholds(symbol)["exit"]
|
||||
|
||||
|
||||
def build_flip_action(
|
||||
old_side: str,
|
||||
new_side: str,
|
||||
) -> str:
|
||||
return f"FLIP_{old_side}_TO_{new_side}"
|
||||
@@ -2,22 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
#import math
|
||||
#from dataclasses import dataclass
|
||||
#from datetime import datetime
|
||||
|
||||
#from src.core.event_bus import EventBus
|
||||
#from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.models import ExecutionDecision
|
||||
#from src.trading.journal.service import JournalService
|
||||
from src.trading.position.state import PositionState
|
||||
#from src.core.numbers import safe_float
|
||||
#from src.core.types import NumericLike
|
||||
from src.trading.execution.pricing import ExecutionPricingMixin
|
||||
from src.trading.execution.position_runtime import ExecutionPositionRuntimeMixin
|
||||
from src.trading.execution.position_intelligence import ExecutionPositionIntelligenceMixin
|
||||
from src.trading.execution.position_exit_decision import ExecutionPositionExitDecisionMixin
|
||||
from src.trading.execution.position_protection import ExecutionPositionProtectionMixin
|
||||
from src.trading.execution.supervisor import ExecutionSupervisorMixin
|
||||
from src.trading.execution.sizing import ExecutionSizingMixin
|
||||
@@ -27,6 +17,17 @@ from src.trading.execution.position_actions import ExecutionPositionActionsMixin
|
||||
from src.trading.execution.runtime_actions import ExecutionRuntimeActionsMixin
|
||||
from src.trading.execution.calculations import ExecutionCalculationsMixin
|
||||
from src.trading.execution.resets import ExecutionResetsMixin
|
||||
from src.trading.execution.constants import (
|
||||
EXECUTION_ACTION_NONE,
|
||||
EXECUTION_ACTION_OPEN_LONG,
|
||||
EXECUTION_ACTION_OPEN_SHORT,
|
||||
EXECUTION_DECISION_READY,
|
||||
EXECUTION_STATUS_RUNNING,
|
||||
POSITION_SIDE_LONG,
|
||||
POSITION_SIDE_SHORT,
|
||||
SIGNAL_BUY,
|
||||
SIGNAL_SELL,
|
||||
)
|
||||
|
||||
|
||||
class ExecutionEngine(
|
||||
@@ -34,7 +35,7 @@ class ExecutionEngine(
|
||||
ExecutionResetsMixin,
|
||||
ExecutionPricingMixin,
|
||||
ExecutionPositionRuntimeMixin,
|
||||
ExecutionPositionIntelligenceMixin,
|
||||
ExecutionPositionExitDecisionMixin,
|
||||
ExecutionSizingMixin,
|
||||
ExecutionPositionActionsMixin,
|
||||
ExecutionPositionProtectionMixin,
|
||||
@@ -45,11 +46,11 @@ class ExecutionEngine(
|
||||
):
|
||||
_position = PositionState()
|
||||
_size_precision = 5
|
||||
_min_flip_confidence = 0.75
|
||||
_min_flip_repeat_count = 3
|
||||
_min_flip_hold_seconds = 60
|
||||
_min_flip_confidence = 0.65
|
||||
_min_flip_repeat_count = 2
|
||||
_min_flip_hold_seconds = 20
|
||||
_flip_cooldown_seconds = 45
|
||||
_loss_flip_confidence = 0.9
|
||||
_loss_flip_confidence = 0.75
|
||||
_last_flip_block_key: str | None = None
|
||||
_runtime_action_cooldown_seconds = 30
|
||||
_last_runtime_action_key: str | None = None
|
||||
@@ -61,7 +62,6 @@ class ExecutionEngine(
|
||||
_max_execution_snapshot_age_seconds = 5
|
||||
|
||||
_degraded_market_block_states = {
|
||||
"HIGH_VOLATILITY",
|
||||
"CHAOTIC",
|
||||
"LIQUIDITY_VOID",
|
||||
}
|
||||
@@ -70,47 +70,81 @@ class ExecutionEngine(
|
||||
|
||||
_last_supervisor_block_key: str | None = None
|
||||
|
||||
# вернуть ExecutionDecision без выполнения торгового действия
|
||||
def _skip_execution(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
) -> ExecutionDecision:
|
||||
state.last_execution_action = EXECUTION_ACTION_NONE
|
||||
state.last_execution_reason = reason
|
||||
|
||||
return ExecutionDecision(
|
||||
EXECUTION_ACTION_NONE,
|
||||
False,
|
||||
reason,
|
||||
)
|
||||
|
||||
def process(self, state: AutoTradeState) -> ExecutionDecision:
|
||||
# Synchronize runtime state
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
if state.status != "RUNNING":
|
||||
return ExecutionDecision("NONE", False, "Execution доступен только в режиме RUNNING.")
|
||||
if state.status != EXECUTION_STATUS_RUNNING:
|
||||
return self._skip_execution(
|
||||
state,
|
||||
"Execution доступен только в режиме RUNNING.",
|
||||
)
|
||||
|
||||
self._update_unrealized_pnl(state)
|
||||
|
||||
# Emergency risk management
|
||||
risk_decision = self._risk_close_decision(state)
|
||||
if risk_decision is not None:
|
||||
return risk_decision
|
||||
|
||||
# Runtime position protection
|
||||
protection_decision = self._process_runtime_protection(state)
|
||||
if protection_decision is not None:
|
||||
return protection_decision
|
||||
|
||||
# Signal readiness validation
|
||||
if state.decision_status != EXECUTION_DECISION_READY or not state.is_signal_ready:
|
||||
reason = (
|
||||
f"Execution ожидает READY "
|
||||
f"(decision={state.decision_status}, "
|
||||
f"ready={state.is_signal_ready})."
|
||||
)
|
||||
|
||||
return self._skip_execution(
|
||||
state,
|
||||
reason,
|
||||
)
|
||||
|
||||
# Execution supervisor
|
||||
supervisor_decision = self._process_execution_supervisor(state)
|
||||
if supervisor_decision is not None:
|
||||
return supervisor_decision
|
||||
|
||||
if state.decision_status != "READY" or not state.is_signal_ready:
|
||||
return ExecutionDecision("NONE", False, "Сигнал ещё не готов к execution.")
|
||||
|
||||
# Existing position validation
|
||||
position = type(self)._position
|
||||
|
||||
# Не пытаемся повторно открыть позицию в ту же сторону.
|
||||
# Сигнал остаётся валидным для UI/Telegram, но execution не дублируется.
|
||||
if position.side == "LONG" and state.last_signal == "BUY":
|
||||
if position.side == POSITION_SIDE_LONG and state.last_signal == SIGNAL_BUY:
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
EXECUTION_ACTION_NONE,
|
||||
False,
|
||||
"Сигнал BUY совпадает с уже открытой LONG позицией.",
|
||||
)
|
||||
|
||||
if position.side == "SHORT" and state.last_signal == "SELL":
|
||||
if position.side == POSITION_SIDE_SHORT and state.last_signal == SIGNAL_SELL:
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
EXECUTION_ACTION_NONE,
|
||||
False,
|
||||
"Сигнал SELL совпадает с уже открытой SHORT позицией.",
|
||||
)
|
||||
|
||||
# Position flip
|
||||
if self._should_flip_position(state):
|
||||
flip_block_reason = self._flip_block_reason(state)
|
||||
|
||||
@@ -119,10 +153,22 @@ class ExecutionEngine(
|
||||
|
||||
return self._flip_position(state)
|
||||
|
||||
if state.last_signal == "BUY":
|
||||
return self._open_position_if_empty(state=state, side="LONG", action="OPEN_LONG")
|
||||
# New position opening
|
||||
if state.last_signal == SIGNAL_BUY:
|
||||
return self._open_position_if_empty(
|
||||
state=state,
|
||||
side=POSITION_SIDE_LONG,
|
||||
action=EXECUTION_ACTION_OPEN_LONG,
|
||||
)
|
||||
|
||||
if state.last_signal == "SELL":
|
||||
return self._open_position_if_empty(state=state, side="SHORT", action="OPEN_SHORT")
|
||||
if state.last_signal == SIGNAL_SELL:
|
||||
return self._open_position_if_empty(
|
||||
state=state,
|
||||
side=POSITION_SIDE_SHORT,
|
||||
action=EXECUTION_ACTION_OPEN_SHORT,
|
||||
)
|
||||
|
||||
return ExecutionDecision("NONE", False, "Нет торгового действия.")
|
||||
return self._skip_execution(
|
||||
state,
|
||||
"Нет торгового действия.",
|
||||
)
|
||||
@@ -7,12 +7,33 @@ 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.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
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
from src.trading.execution.position_metrics import build_position_metrics
|
||||
from src.trading.execution.constants import (
|
||||
EXECUTION_ACTION_FLIP_BLOCKED,
|
||||
EXECUTION_ACTION_NONE,
|
||||
EXECUTION_MAX_CONSECUTIVE_LOSSES,
|
||||
EXECUTION_TYPE_FLIP,
|
||||
EXECUTION_TYPE_FLIP_BLOCKED,
|
||||
EXECUTION_TYPE_FLIP_REJECTED,
|
||||
FLIP_BREAKOUT_CONFIDENCE_THRESHOLD,
|
||||
FLIP_MIN_EXECUTION_CONFIDENCE,
|
||||
FLIP_MIN_HTF_CONFIRMATION_SCORE,
|
||||
MARKET_PHASE_FLIP_BLOCKED,
|
||||
MARKET_STATE_FLIP_BLOCKED,
|
||||
POSITION_SIDE_LONG,
|
||||
POSITION_SIDE_NONE,
|
||||
POSITION_SIDE_SHORT,
|
||||
PRICING_FLIP_MODE,
|
||||
SIGNAL_BUY,
|
||||
SIGNAL_SELL,
|
||||
build_flip_action,
|
||||
)
|
||||
|
||||
|
||||
class _ExecutionFlipProtocol(Protocol):
|
||||
@@ -24,57 +45,280 @@ class _ExecutionFlipProtocol(Protocol):
|
||||
_loss_flip_confidence: float
|
||||
_last_flip_block_key: str | None
|
||||
|
||||
def _create_trade_id(self, state: AutoTradeState, side: str) -> str: ...
|
||||
def _create_trade_id(self, state: AutoTradeState, side: str) -> str:
|
||||
...
|
||||
|
||||
# получить exit price для текущей стороны позиции
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> ExecutionPrice: ...
|
||||
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 _entry_price_for_side(self, symbol: str, side: str) -> ExecutionPrice:
|
||||
...
|
||||
|
||||
# рассчитать размер позиции
|
||||
def _calculate_position_size(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
entry_price: float | None = None,
|
||||
) -> float: ...
|
||||
) -> float:
|
||||
...
|
||||
|
||||
# ограничить размер позиции margin-limit правилом
|
||||
def _adjust_size_by_margin_limit(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
entry_price: float,
|
||||
size: float,
|
||||
) -> float: ...
|
||||
) -> float:
|
||||
...
|
||||
|
||||
# пересчитать effective risk после margin-limit
|
||||
def _sync_effective_risk_after_margin_limit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
base_size: float,
|
||||
final_size: float,
|
||||
) -> None: ...
|
||||
) -> None:
|
||||
...
|
||||
|
||||
# округлить размер позиции
|
||||
def _round_size(self, size) -> float: ...
|
||||
def _round_size(self, size: NumericLike | None) -> float:
|
||||
...
|
||||
|
||||
# рассчитать PnL позиции
|
||||
def _calculate_pnl(self, current_price) -> float: ...
|
||||
def _sync_state_from_position(self, state: AutoTradeState) -> None:
|
||||
...
|
||||
|
||||
# синхронизировать AutoTradeState с PositionState
|
||||
def _sync_state_from_position(self, state: AutoTradeState) -> None: ...
|
||||
def _now_time(self) -> str:
|
||||
...
|
||||
|
||||
# посчитать время удержания позиции
|
||||
def _position_hold_seconds(self, position: PositionState) -> int | None: ...
|
||||
def _reset_runtime_protection_state(self, state: AutoTradeState) -> None:
|
||||
...
|
||||
|
||||
# получить текущее время строкой
|
||||
def _now_time(self) -> str: ...
|
||||
def _reset_position_lifecycle_state(self, state: AutoTradeState) -> None:
|
||||
...
|
||||
|
||||
|
||||
class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
# ---------- Payload builders ----------
|
||||
# собрать payload отказа flip без изменения состояния
|
||||
def _build_flip_rejected_payload(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
) -> JsonDict:
|
||||
position = type(self)._position
|
||||
|
||||
return {
|
||||
"execution_type": EXECUTION_TYPE_FLIP_REJECTED,
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"signal": state.last_signal,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": state.last_signal_reason,
|
||||
"reject_reason": reason,
|
||||
# Общая оценка рынка на момент отказа flip.
|
||||
"market_score": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_phase": state.market_phase,
|
||||
"market_trend_quality": state.market_trend_quality,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"entry_timing_state": state.entry_timing_state,
|
||||
"opened_at": position.opened_at,
|
||||
"updated_at": position.updated_at,
|
||||
}
|
||||
|
||||
# собрать payload блокировки flip без изменения состояния
|
||||
def _build_flip_blocked_payload(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
) -> JsonDict:
|
||||
position = type(self)._position
|
||||
|
||||
return {
|
||||
"execution_type": EXECUTION_TYPE_FLIP_BLOCKED,
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"signal": state.last_signal,
|
||||
"confidence": confidence,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"reason": reason,
|
||||
# Общая оценка рынка на момент блокировки flip.
|
||||
"market_score": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_phase": state.market_phase,
|
||||
"market_structure": state.market_structure,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"opened_at": position.opened_at,
|
||||
"updated_at": position.updated_at,
|
||||
}
|
||||
|
||||
# собрать payload выполненного flip без изменения состояния
|
||||
def _build_flip_executed_payload(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
old_trade_id: str | None,
|
||||
old_trade_sequence: int | None,
|
||||
old_trade_cycle_number: int | None,
|
||||
new_trade_id: str,
|
||||
old_side: str,
|
||||
new_side: str,
|
||||
old_entry_price: float | None,
|
||||
exit_price: float,
|
||||
new_entry_price: float,
|
||||
old_size: float | None,
|
||||
new_size: float,
|
||||
old_leverage: float | None,
|
||||
pnl: float,
|
||||
metrics,
|
||||
flip_action: str,
|
||||
now: str,
|
||||
opened_monotonic_at: float,
|
||||
old_opened_at: str | None,
|
||||
exit_execution: ExecutionPrice,
|
||||
entry_execution: ExecutionPrice,
|
||||
) -> JsonDict:
|
||||
return {
|
||||
"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": EXECUTION_TYPE_FLIP,
|
||||
"action": flip_action,
|
||||
"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,
|
||||
|
||||
# ---------- PnL / Metrics ----------
|
||||
"net_pnl_usd": metrics.net_pnl_usd,
|
||||
"gross_pnl_usd": metrics.gross_pnl_usd,
|
||||
"commission_usd": metrics.commission_usd,
|
||||
"overnight_cashflow_usd": metrics.overnight_cashflow_usd,
|
||||
"pnl_percent": metrics.pnl_percent,
|
||||
"price_move_percent": metrics.price_move_percent,
|
||||
"entry_notional_usd": metrics.entry_notional_usd,
|
||||
"current_notional_usd": metrics.current_notional_usd,
|
||||
"margin_usd": metrics.margin_usd,
|
||||
"hold_seconds": metrics.hold_seconds,
|
||||
"overnight_count": metrics.overnight_count,
|
||||
|
||||
"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,
|
||||
# Общая оценка рынка на момент смены направления позиции.
|
||||
# Фиксируем её вместе с adaptive size, чтобы видеть контекст flip.
|
||||
"market_score": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
"opened_at": old_opened_at,
|
||||
"new_opened_monotonic_at": opened_monotonic_at,
|
||||
"closed_at": now,
|
||||
"new_opened_at": now,
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_phase": state.market_phase,
|
||||
"market_structure": state.market_structure,
|
||||
|
||||
# ---------- Position health ----------
|
||||
"position_hold_seconds": state.position_hold_seconds,
|
||||
"position_health_status": state.position_health_status,
|
||||
"position_health_score": state.position_health_score,
|
||||
"position_health_reason": state.position_health_reason,
|
||||
"position_risk_level": state.position_risk_level,
|
||||
"position_risk_reason": state.position_risk_reason,
|
||||
"position_trend_alignment": state.position_trend_alignment,
|
||||
"position_adverse_momentum": state.position_adverse_momentum,
|
||||
|
||||
# ---------- Position intelligence ----------
|
||||
"position_exit_signal": state.position_exit_signal,
|
||||
"position_exit_confidence": state.position_exit_confidence,
|
||||
"position_exit_urgency": state.position_exit_urgency,
|
||||
"position_reversal_risk": state.position_reversal_risk,
|
||||
"position_fatigue_state": state.position_fatigue_state,
|
||||
"position_giveback_percent": state.position_giveback_percent,
|
||||
"position_mfe_percent": state.position_mfe_percent,
|
||||
"position_mae_percent": state.position_mae_percent,
|
||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
||||
|
||||
# ---------- Autonomous ----------
|
||||
"autonomous_action": state.autonomous_action,
|
||||
"autonomous_action_reason": state.autonomous_action_reason,
|
||||
"autonomous_action_confidence": state.autonomous_action_confidence,
|
||||
"autonomous_protection_required": state.autonomous_protection_required,
|
||||
"autonomous_reduce_required": state.autonomous_reduce_required,
|
||||
"autonomous_exit_required": state.autonomous_exit_required,
|
||||
|
||||
# ---------- Runtime protection ----------
|
||||
"position_protection_status": state.position_protection_status,
|
||||
"position_protection_reason": state.position_protection_reason,
|
||||
"runtime_protection_action": state.runtime_protection_action,
|
||||
"runtime_protection_reason": state.runtime_protection_reason,
|
||||
"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,
|
||||
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"pricing": PRICING_FLIP_MODE,
|
||||
"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,
|
||||
}
|
||||
|
||||
# ---------- Journal helpers ----------
|
||||
# записать отказ flip execution в журнал
|
||||
def _log_flip_rejected(
|
||||
self,
|
||||
@@ -82,21 +326,10 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
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,
|
||||
}
|
||||
payload = self._build_flip_rejected_payload(
|
||||
state=state,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="position_flip_rejected",
|
||||
@@ -106,77 +339,16 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
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
|
||||
# ---------- Decision helpers ----------
|
||||
# записать отказ flip и вернуть стандартное решение без исполнения
|
||||
def _reject_flip(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
) -> ExecutionDecision:
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
||||
|
||||
# записать блокировку flip в state, journal и event bus
|
||||
def _block_flip(
|
||||
@@ -189,7 +361,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
|
||||
state.execution_block_reason = reason
|
||||
state.last_flip_block_reason = reason
|
||||
state.last_execution_action = "FLIP_BLOCKED"
|
||||
state.last_execution_action = EXECUTION_ACTION_FLIP_BLOCKED
|
||||
state.last_execution_reason = reason
|
||||
|
||||
block_key = (
|
||||
@@ -203,18 +375,11 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
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,
|
||||
}
|
||||
payload = self._build_flip_blocked_payload(
|
||||
state=state,
|
||||
reason=reason,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="position_flip_blocked",
|
||||
@@ -226,48 +391,182 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
|
||||
EventBus.emit("paper_flip_blocked", payload)
|
||||
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
||||
|
||||
# ---------- Flip checks ----------
|
||||
# проверить, нужен ли flip позиции по текущему сигналу
|
||||
def _should_flip_position(self, state: AutoTradeState) -> bool:
|
||||
position = type(self)._position
|
||||
signal = str(state.last_signal or "").upper()
|
||||
|
||||
if position.side == POSITION_SIDE_NONE:
|
||||
return False
|
||||
|
||||
if position.side == POSITION_SIDE_LONG and signal == SIGNAL_SELL:
|
||||
return True
|
||||
|
||||
if position.side == POSITION_SIDE_SHORT and signal == SIGNAL_BUY:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# определить причину блокировки flip, если flip сейчас опасен
|
||||
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
|
||||
position = type(self)._position
|
||||
|
||||
signal = str(state.last_signal or "").upper()
|
||||
confidence = safe_float(state.last_signal_confidence) or 0.0
|
||||
execution_confidence = safe_float(state.execution_confidence_score)
|
||||
repeat_count = int(safe_float(state.last_signal_repeat_count) or 0)
|
||||
unrealized_pnl = safe_float(state.unrealized_pnl_usd) or 0.0
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=position.entry_price,
|
||||
)
|
||||
hold_seconds = metrics.hold_seconds
|
||||
|
||||
market_state = str(getattr(state, "market_state", "") or "").upper()
|
||||
market_trend = str(getattr(state, "market_trend", "") or "").upper()
|
||||
market_phase = str(getattr(state, "market_phase", "") or "").upper()
|
||||
market_quality = str(getattr(state, "market_trend_quality", "") or "").upper()
|
||||
market_structure = str(getattr(state, "market_structure", "") or "").upper()
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
htf_score = safe_float(getattr(state, "htf_confirmation_score", None))
|
||||
entry_timing = str(getattr(state, "entry_timing_state", "") or "").upper()
|
||||
|
||||
momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper()
|
||||
momentum_state = str(getattr(state, "momentum_state", "") or "").upper()
|
||||
|
||||
if confidence < self._min_flip_confidence:
|
||||
return (
|
||||
"уверенность flip-сигнала ниже порога "
|
||||
f"({confidence:.2f} < {self._min_flip_confidence:.2f})"
|
||||
)
|
||||
|
||||
if (
|
||||
execution_confidence is not None
|
||||
and execution_confidence < FLIP_MIN_EXECUTION_CONFIDENCE
|
||||
):
|
||||
return (
|
||||
"execution confidence для flip недостаточный "
|
||||
f"({execution_confidence:.2f} < {FLIP_MIN_EXECUTION_CONFIDENCE:.2f})"
|
||||
)
|
||||
|
||||
if repeat_count < self._min_flip_repeat_count:
|
||||
return (
|
||||
"flip-сигнал ещё не подтверждён нужным количеством повторов "
|
||||
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 f"flip cooldown активен (< {self._flip_cooldown_seconds}с)"
|
||||
|
||||
if market_state in MARKET_STATE_FLIP_BLOCKED:
|
||||
return f"market state не подходит для flip: {market_state or 'UNKNOWN'}"
|
||||
|
||||
if market_phase in MARKET_PHASE_FLIP_BLOCKED:
|
||||
return f"market phase не подходит для flip: {market_phase or 'UNKNOWN'}"
|
||||
|
||||
if market_quality == "NOISY":
|
||||
return "flip заблокирован: тренд шумный"
|
||||
|
||||
if htf_alignment != "ALIGNED":
|
||||
return f"flip заблокирован: HTF не подтверждает направление ({htf_alignment or 'UNKNOWN'})"
|
||||
|
||||
if htf_score is None or htf_score < FLIP_MIN_HTF_CONFIRMATION_SCORE:
|
||||
return f"flip заблокирован: слабое HTF-подтверждение ({htf_score})"
|
||||
|
||||
if entry_timing in {"LATE", "CHASING"}:
|
||||
return f"flip заблокирован: плохой тайминг входа ({entry_timing})"
|
||||
|
||||
if signal == SIGNAL_BUY:
|
||||
if market_trend == "DOWN":
|
||||
return "BUY flip против основного market trend"
|
||||
|
||||
if momentum_direction != "UP":
|
||||
return "momentum не подтверждает BUY flip"
|
||||
|
||||
if momentum_state == "BREAKOUT_DOWN":
|
||||
return "BUY flip против breakout вниз"
|
||||
|
||||
if market_structure == "LH_LL":
|
||||
return "BUY flip против bearish market structure"
|
||||
|
||||
if signal == SIGNAL_SELL:
|
||||
if market_trend == "UP":
|
||||
return "SELL flip против основного market trend"
|
||||
|
||||
if momentum_direction != "DOWN":
|
||||
return "momentum не подтверждает SELL flip"
|
||||
|
||||
if momentum_state == "BREAKOUT_UP":
|
||||
return "SELL flip против breakout вверх"
|
||||
|
||||
if market_structure == "HH_HL":
|
||||
return "SELL flip против bullish market structure"
|
||||
|
||||
if market_structure == "MIXED":
|
||||
return "flip заблокирован: структура рынка смешанная"
|
||||
|
||||
if (
|
||||
momentum_state in {"BREAKOUT_UP", "BREAKOUT_DOWN"}
|
||||
and confidence < FLIP_BREAKOUT_CONFIDENCE_THRESHOLD
|
||||
):
|
||||
return (
|
||||
"flip заблокирован во время breakout impulse "
|
||||
f"({confidence:.2f} < {FLIP_BREAKOUT_CONFIDENCE_THRESHOLD:.2f})"
|
||||
)
|
||||
|
||||
if unrealized_pnl < 0 and confidence < self._loss_flip_confidence:
|
||||
return (
|
||||
"позиция сейчас в минусе, а flip-сигнал недостаточно сильный "
|
||||
f"({confidence:.2f} < {self._loss_flip_confidence:.2f})"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# проверить, активен ли cooldown после последнего flip
|
||||
def _flip_cooldown_active(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> bool:
|
||||
ts = getattr(state, "last_flip_monotonic_at", None)
|
||||
def _flip_cooldown_active(self, state: AutoTradeState) -> bool:
|
||||
ts = safe_float(getattr(state, "last_flip_monotonic_at", None))
|
||||
|
||||
if ts is None:
|
||||
return False
|
||||
|
||||
return (
|
||||
time.monotonic() - float(ts)
|
||||
) < self._flip_cooldown_seconds
|
||||
return (time.monotonic() - ts) < self._flip_cooldown_seconds
|
||||
|
||||
# определить сторону позиции по сигналу BUY / SELL
|
||||
def _target_side_from_signal(self, signal: str | None) -> str | None:
|
||||
if signal == "BUY":
|
||||
return "LONG"
|
||||
normalized_signal = str(signal or "").upper()
|
||||
|
||||
if signal == "SELL":
|
||||
return "SHORT"
|
||||
if normalized_signal == SIGNAL_BUY:
|
||||
return POSITION_SIDE_LONG
|
||||
|
||||
if normalized_signal == SIGNAL_SELL:
|
||||
return POSITION_SIDE_SHORT
|
||||
|
||||
return None
|
||||
|
||||
# ---------- Execution ----------
|
||||
# закрыть текущую позицию и открыть новую в противоположную сторону
|
||||
def _flip_position(self, state: AutoTradeState) -> ExecutionDecision:
|
||||
position = type(self)._position
|
||||
|
||||
if position.side == "NONE":
|
||||
if position.side == POSITION_SIDE_NONE:
|
||||
self._sync_state_from_position(state)
|
||||
reason = "Нет позиции для flip."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
return self._reject_flip(state=state, reason=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)
|
||||
return self._reject_flip(state=state, reason=reason)
|
||||
|
||||
try:
|
||||
exit_execution = self._exit_price_for_side(
|
||||
@@ -283,12 +582,19 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
|
||||
except Exception as exc:
|
||||
reason = f"Ошибка получения цены для flip: {exc}"
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
return self._reject_flip(state=state, reason=reason)
|
||||
|
||||
now = self._now_time()
|
||||
opened_monotonic_at = time.monotonic()
|
||||
pnl = self._calculate_pnl(exit_price)
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=exit_price,
|
||||
)
|
||||
|
||||
# net_pnl_usd может быть None при неполных метриках,
|
||||
# поэтому нормализуем в 0.0, чтобы статистика цикла не падала.
|
||||
pnl = safe_float(metrics.net_pnl_usd) or 0.0
|
||||
|
||||
new_size = self._calculate_position_size(
|
||||
state,
|
||||
entry_price=new_entry_price,
|
||||
@@ -296,8 +602,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
|
||||
if new_size <= 0:
|
||||
reason = "Flip отменён: невозможно рассчитать adaptive size."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
return self._reject_flip(state=state, reason=reason)
|
||||
|
||||
new_size = self._adjust_size_by_margin_limit(
|
||||
state=state,
|
||||
@@ -315,21 +620,50 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
|
||||
if new_size <= 0:
|
||||
reason = "Flip отменён: итоговый size равен 0."
|
||||
self._log_flip_rejected(state=state, reason=reason)
|
||||
return ExecutionDecision("NONE", False, reason)
|
||||
return self._reject_flip(state=state, reason=reason)
|
||||
|
||||
state.realized_pnl_usd += pnl
|
||||
state.cycle_realized_pnl_usd += pnl
|
||||
state.cycle_closed_trades += 1
|
||||
state.cycle_trade_fees_usd += abs(safe_float(metrics.commission_usd) or 0.0)
|
||||
state.cycle_overnight_fees_usd += safe_float(metrics.overnight_cashflow_usd) or 0.0
|
||||
|
||||
if pnl > 0:
|
||||
state.cycle_winning_trades += 1
|
||||
|
||||
# прибыльный flip закрывает серию убытков
|
||||
state.cycle_consecutive_losses = 0
|
||||
|
||||
state.loss_cooldown_active = False
|
||||
state.loss_cooldown_reason = None
|
||||
|
||||
elif pnl < 0:
|
||||
state.cycle_losing_trades += 1
|
||||
state.cycle_consecutive_losses += 1
|
||||
|
||||
state.last_loss_monotonic_at = time.monotonic()
|
||||
|
||||
if state.cycle_consecutive_losses >= EXECUTION_MAX_CONSECUTIVE_LOSSES:
|
||||
state.loss_cooldown_active = True
|
||||
state.loss_cooldown_reason = (
|
||||
f"{state.cycle_consecutive_losses} подряд убыточных сделок"
|
||||
)
|
||||
|
||||
old_side = position.side
|
||||
old_entry_price = position.entry_price
|
||||
old_size = position.size
|
||||
old_leverage = position.leverage
|
||||
old_opened_at = position.opened_at
|
||||
flip_action = build_flip_action(old_side, new_side)
|
||||
|
||||
self._reset_runtime_protection_state(state)
|
||||
self._reset_position_lifecycle_state(state)
|
||||
|
||||
# Flip открывает новую позицию, поэтому autonomous runtime прошлой позиции
|
||||
# нельзя переносить на новую сделку.
|
||||
state.autonomous_last_action = None
|
||||
state.autonomous_last_action_reason = None
|
||||
state.autonomous_last_action_at = None
|
||||
|
||||
state.last_flip_old_side = old_side
|
||||
state.last_flip_new_side = new_side
|
||||
@@ -367,67 +701,39 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
|
||||
state.position_opened_monotonic_at = opened_monotonic_at
|
||||
|
||||
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_action = flip_action
|
||||
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,
|
||||
}
|
||||
payload = self._build_flip_executed_payload(
|
||||
state=state,
|
||||
old_trade_id=old_trade_id,
|
||||
old_trade_sequence=old_trade_sequence,
|
||||
old_trade_cycle_number=old_trade_cycle_number,
|
||||
new_trade_id=new_trade_id,
|
||||
old_side=old_side,
|
||||
new_side=new_side,
|
||||
old_entry_price=old_entry_price,
|
||||
exit_price=exit_price,
|
||||
new_entry_price=new_entry_price,
|
||||
old_size=old_size,
|
||||
new_size=new_size,
|
||||
old_leverage=old_leverage,
|
||||
pnl=pnl,
|
||||
metrics=metrics,
|
||||
flip_action=flip_action,
|
||||
now=now,
|
||||
opened_monotonic_at=opened_monotonic_at,
|
||||
old_opened_at=old_opened_at,
|
||||
exit_execution=exit_execution,
|
||||
entry_execution=entry_execution,
|
||||
)
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="position_flipped",
|
||||
@@ -440,7 +746,7 @@ class ExecutionFlipMixin(_ExecutionFlipProtocol):
|
||||
EventBus.emit("paper_position_flipped", payload)
|
||||
|
||||
return ExecutionDecision(
|
||||
f"FLIP_{old_side}_TO_{new_side}",
|
||||
flip_action,
|
||||
True,
|
||||
f"Направление позиции изменено: {old_side} → {new_side}.",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
408
app/src/trading/execution/position_exit_decision.py
Normal file
408
app/src/trading/execution/position_exit_decision.py
Normal file
@@ -0,0 +1,408 @@
|
||||
# app/src/trading/execution/position_exit_decision.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import ClassVar, Protocol
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.execution.position_metrics import PositionMetrics, build_position_metrics
|
||||
from src.trading.position.state import PositionState
|
||||
from src.trading.execution.constants import get_position_exit_thresholds
|
||||
|
||||
|
||||
class _ExecutionPositionExitDecisionProtocol(Protocol):
|
||||
_position: ClassVar[PositionState]
|
||||
|
||||
|
||||
class ExecutionPositionExitDecisionMixin(_ExecutionPositionExitDecisionProtocol):
|
||||
"""
|
||||
Execution-слой принятия решения о runtime-закрытии позиции.
|
||||
|
||||
Важно:
|
||||
- этот файл НЕ рассчитывает PnL, движение цены и время удержания сам;
|
||||
- все числовые метрики позиции берутся из position_metrics.py;
|
||||
- здесь остаётся только логика принятия решения: закрывать позицию или нет.
|
||||
"""
|
||||
|
||||
def _runtime_intelligence_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
) -> str | None:
|
||||
metrics = build_position_metrics(
|
||||
type(self)._position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
# Защита от раннего выхода на обычной волне/откате.
|
||||
# Если позиция открыта недавно и просадка ещё в рамках нормальной
|
||||
# волатильности актива, intelligence-close не закрывает сделку.
|
||||
if self._is_normal_pullback_wave(state=state, metrics=metrics):
|
||||
return None
|
||||
|
||||
giveback_reason = self._giveback_close_reason(
|
||||
state=state,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
if giveback_reason is not None:
|
||||
self._sync_intelligence_exit_state(
|
||||
state=state,
|
||||
reason=giveback_reason,
|
||||
algorithm="GIVEBACK",
|
||||
)
|
||||
return giveback_reason
|
||||
|
||||
time_decay_reason = self._time_decay_close_reason(
|
||||
state=state,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
if time_decay_reason is not None:
|
||||
self._sync_intelligence_exit_state(
|
||||
state=state,
|
||||
reason=time_decay_reason,
|
||||
algorithm="TIME_DECAY",
|
||||
)
|
||||
return time_decay_reason
|
||||
|
||||
return None
|
||||
|
||||
def _sync_intelligence_exit_state(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
reason: str,
|
||||
algorithm: str,
|
||||
) -> None:
|
||||
# В AutoTradeState сейчас нет отдельного поля position_exit_algorithm.
|
||||
# Поэтому алгоритм пишем в position_intelligence_reason — это поле уже есть
|
||||
# в state и попадёт дальше в диагностику / журнал закрытия.
|
||||
state.position_intelligence_reason = algorithm
|
||||
|
||||
state.runtime_protection_action = "INTELLIGENCE_EXIT"
|
||||
state.runtime_protection_reason = reason
|
||||
state.runtime_protection_updated_at = time.monotonic()
|
||||
|
||||
def _giveback_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
metrics: PositionMetrics,
|
||||
) -> str | None:
|
||||
price_move_percent = metrics.price_move_percent
|
||||
|
||||
peak_percent = safe_float(
|
||||
getattr(state, "position_peak_pnl_percent", None)
|
||||
)
|
||||
|
||||
if peak_percent is None or peak_percent <= 0:
|
||||
return None
|
||||
|
||||
giveback = peak_percent - price_move_percent
|
||||
|
||||
if giveback <= 0:
|
||||
return None
|
||||
|
||||
giveback_percent = round((giveback / peak_percent) * 100, 2)
|
||||
|
||||
# Сохраняем рассчитанный giveback в state,
|
||||
# чтобы журнал закрытия видел именно то значение,
|
||||
# на основании которого принято решение.
|
||||
state.position_giveback_percent = giveback_percent
|
||||
|
||||
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
|
||||
|
||||
thresholds = self._exit_thresholds(state)
|
||||
|
||||
market_quality = str(
|
||||
getattr(state, "market_trend_quality", "") or ""
|
||||
).upper()
|
||||
|
||||
stall_state = str(
|
||||
getattr(state, "position_stall_state", "") or ""
|
||||
).upper()
|
||||
|
||||
# В CLEAN рынке даём прибыли больше пространства.
|
||||
# В NOISY рынке фиксируем быстрее, потому что откаты чаще съедают прибыль.
|
||||
if market_quality == "NOISY":
|
||||
min_peak = thresholds["noisy_giveback_min_peak"]
|
||||
giveback_limit = thresholds["noisy_giveback_percent"]
|
||||
else:
|
||||
min_peak = thresholds["clean_giveback_min_peak"]
|
||||
giveback_limit = thresholds["clean_giveback_percent"]
|
||||
|
||||
if (
|
||||
peak_percent >= min_peak
|
||||
and giveback_percent >= giveback_limit
|
||||
and price_move_percent > 0.10
|
||||
):
|
||||
return (
|
||||
"NOISY_GIVEBACK_EXIT"
|
||||
if market_quality == "NOISY"
|
||||
else "CLEAN_GIVEBACK_EXIT"
|
||||
)
|
||||
|
||||
if (
|
||||
stall_state in {"NOISY_STALLED", "ADVERSE_STALLED"}
|
||||
and peak_percent >= min_peak
|
||||
and giveback_percent >= max(25, giveback_limit - 10)
|
||||
and price_move_percent > 0
|
||||
):
|
||||
return "STALL_GIVEBACK_EXIT"
|
||||
|
||||
if (
|
||||
peak_percent >= 1.50
|
||||
and giveback_percent >= 50
|
||||
and price_move_percent > 0.25
|
||||
):
|
||||
return "GIVEBACK_PROFIT_LOCK"
|
||||
|
||||
if (
|
||||
peak_percent >= 1.20
|
||||
and giveback_percent >= 60
|
||||
and price_move_percent > 0.15
|
||||
):
|
||||
return "GIVEBACK_PROTECTION"
|
||||
|
||||
if (
|
||||
peak_percent >= 1.00
|
||||
and giveback_percent >= 50
|
||||
and adverse_momentum
|
||||
):
|
||||
return "GIVEBACK_MOMENTUM_REVERSAL"
|
||||
|
||||
if (
|
||||
peak_percent >= 1.00
|
||||
and giveback_percent >= 45
|
||||
and fatigue_state in {"TIRED", "EXHAUSTED"}
|
||||
):
|
||||
return "GIVEBACK_FATIGUE_EXIT"
|
||||
|
||||
if (
|
||||
peak_percent >= 1.00
|
||||
and giveback_percent >= 45
|
||||
and reversal_risk in {"ELEVATED", "HIGH"}
|
||||
and exit_confidence >= 0.60
|
||||
):
|
||||
return "GIVEBACK_REVERSAL_RISK"
|
||||
|
||||
return None
|
||||
|
||||
def _time_decay_close_reason(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
metrics: PositionMetrics,
|
||||
) -> str | None:
|
||||
hold_seconds = metrics.hold_seconds
|
||||
|
||||
if hold_seconds is None:
|
||||
return None
|
||||
|
||||
price_move_percent = metrics.price_move_percent
|
||||
thresholds = self._exit_thresholds(state)
|
||||
|
||||
# Hard-loss — отдельный аварийный intelligence-exit.
|
||||
# Если движение цены уже глубже допустимого порога,
|
||||
# не ждём fatigue / time-decay / adverse momentum.
|
||||
if price_move_percent <= thresholds["hard_loss"]:
|
||||
return "HARD_LOSS_EXIT"
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
net_pnl_usd = safe_float(getattr(metrics, "net_pnl_usd", None)) or 0.0
|
||||
|
||||
risk_level = str(
|
||||
getattr(state, "position_risk_level", "") or ""
|
||||
).upper()
|
||||
|
||||
# Time-decay не должен закрывать позицию просто потому,
|
||||
# что она долго стоит около нуля.
|
||||
# Разрешаем time-decay закрытие только если:
|
||||
# - сделка уже покрыла RT-комиссию и net PnL положительный;
|
||||
# - или есть реальное ухудшение: adverse momentum / HIGH risk / BROKEN conviction.
|
||||
real_deterioration = (
|
||||
adverse_momentum
|
||||
or risk_level == "HIGH"
|
||||
or conviction_state == "BROKEN"
|
||||
)
|
||||
|
||||
market_quality = str(
|
||||
getattr(state, "market_trend_quality", "") or ""
|
||||
).upper()
|
||||
|
||||
peak_percent = safe_float(
|
||||
getattr(state, "position_peak_pnl_percent", None)
|
||||
) or 0.0
|
||||
|
||||
giveback_percent = safe_float(
|
||||
getattr(state, "position_giveback_percent", None)
|
||||
) or 0.0
|
||||
|
||||
# Специальный быстрый выход для NOISY рынка.
|
||||
# В шумном рынке не ждём классический time-decay 1500-2100 секунд:
|
||||
# если позиция после минимального времени уже в минусе
|
||||
# или быстро отдаёт прибыль, закрываем раньше.
|
||||
if market_quality == "NOISY" and hold_seconds >= thresholds["noisy_min_hold"]:
|
||||
if (
|
||||
price_move_percent <= thresholds["noisy_loss_exit"]
|
||||
and adverse_momentum
|
||||
):
|
||||
return "NOISY_ADVERSE_EXIT"
|
||||
|
||||
if (
|
||||
peak_percent > 0
|
||||
and giveback_percent >= thresholds["noisy_profit_giveback"]
|
||||
and price_move_percent > 0
|
||||
):
|
||||
return "NOISY_PROFIT_GIVEBACK_EXIT"
|
||||
|
||||
if net_pnl_usd <= 0 and not real_deterioration:
|
||||
return None
|
||||
|
||||
# Нейтральную позицию по ETH/BTC/LTC/XRP держим дольше.
|
||||
# Например для ETH: если движение внутри ±0.40%,
|
||||
# не закрываем её по time-decay раньше neutral_min_hold.
|
||||
if (
|
||||
hold_seconds < thresholds["neutral_min_hold"]
|
||||
and abs(price_move_percent) <= thresholds["neutral_band"]
|
||||
and not real_deterioration
|
||||
):
|
||||
return None
|
||||
|
||||
if (
|
||||
hold_seconds >= thresholds["neutral_min_hold"]
|
||||
and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"]
|
||||
and conviction_state in {"WEAKENING", "BROKEN", "NEUTRAL"}
|
||||
):
|
||||
return "TIME_DECAY_EXIT"
|
||||
|
||||
if (
|
||||
hold_seconds >= thresholds["min_hold"]
|
||||
and -thresholds["neutral_band"] <= price_move_percent <= thresholds["neutral_band"]
|
||||
and fatigue_state in {"TIRED", "EXHAUSTED"}
|
||||
):
|
||||
return "TIME_DECAY_FATIGUE_EXIT"
|
||||
|
||||
if (
|
||||
hold_seconds >= thresholds["min_hold"]
|
||||
and price_move_percent <= thresholds["normal_pullback"]
|
||||
and adverse_momentum
|
||||
):
|
||||
return "TIME_DECAY_ADVERSE_MOMENTUM"
|
||||
|
||||
if (
|
||||
hold_seconds >= thresholds["min_hold"]
|
||||
and price_move_percent <= thresholds["normal_pullback"]
|
||||
and market_runtime_degraded
|
||||
):
|
||||
return "TIME_DECAY_DEGRADED_MARKET"
|
||||
|
||||
if (
|
||||
hold_seconds >= thresholds["neutral_min_hold"]
|
||||
and decay_state in {"TIME_DECAY", "CONTEXT_DECAY"}
|
||||
and price_move_percent <= thresholds["neutral_band"]
|
||||
):
|
||||
return "TIME_DECAY_CONTEXT_DECAY"
|
||||
|
||||
return None
|
||||
|
||||
def _exit_thresholds(self, state: AutoTradeState) -> dict[str, float]:
|
||||
return get_position_exit_thresholds(
|
||||
getattr(state, "symbol", None)
|
||||
)
|
||||
|
||||
def _is_normal_pullback_wave(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
metrics: PositionMetrics,
|
||||
) -> bool:
|
||||
thresholds = self._exit_thresholds(state)
|
||||
|
||||
hold_seconds = safe_float(metrics.hold_seconds)
|
||||
price_move_percent = safe_float(metrics.price_move_percent)
|
||||
|
||||
if hold_seconds is None or price_move_percent is None:
|
||||
return False
|
||||
|
||||
# Если убыток уже глубже hard_loss — это не обычный откат.
|
||||
if price_move_percent <= thresholds["hard_loss"]:
|
||||
return False
|
||||
|
||||
if hold_seconds >= thresholds["min_hold"]:
|
||||
return False
|
||||
|
||||
if price_move_percent < thresholds["normal_pullback"]:
|
||||
return False
|
||||
|
||||
adverse_momentum = bool(
|
||||
getattr(state, "position_adverse_momentum", False)
|
||||
)
|
||||
|
||||
risk_level = str(
|
||||
getattr(state, "position_risk_level", "") or ""
|
||||
).upper()
|
||||
|
||||
conviction_state = str(
|
||||
getattr(state, "position_conviction_state", "") or ""
|
||||
).upper()
|
||||
|
||||
# Если есть реальное ухудшение, это уже не обычный откат.
|
||||
# Так мы не блокируем быстрый выход в NOISY рынке,
|
||||
# когда momentum/риск явно против позиции.
|
||||
if adverse_momentum or risk_level == "HIGH" or conviction_state == "BROKEN":
|
||||
return False
|
||||
|
||||
market_phase = str(getattr(state, "market_phase", "") or "").upper()
|
||||
market_quality = str(getattr(state, "market_trend_quality", "") or "").upper()
|
||||
market_structure = str(getattr(state, "market_structure", "") or "").upper()
|
||||
trend_alignment = str(getattr(state, "position_trend_alignment", "") or "").upper()
|
||||
|
||||
# Обычный откат/шум/флэт после входа не должен сразу закрывать сделку.
|
||||
if market_phase in {"PULLBACK", "RANGE", "SQUEEZE"}:
|
||||
return True
|
||||
|
||||
if market_quality == "NOISY" and trend_alignment != "AGAINST":
|
||||
return True
|
||||
|
||||
if market_structure in {"HH_HL", "LH_LL", "MIXED"} and trend_alignment != "AGAINST":
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,209 +0,0 @@
|
||||
# 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
|
||||
512
app/src/trading/execution/position_metrics.py
Normal file
512
app/src/trading/execution/position_metrics.py
Normal file
@@ -0,0 +1,512 @@
|
||||
# app/src/trading/execution/position_metrics.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
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.position.state import PositionState
|
||||
|
||||
|
||||
# Единый снимок расчётов по открытой позиции.
|
||||
# Все числовые показатели позиции должны считаться здесь один раз,
|
||||
# а остальные части бота должны только использовать готовые значения.
|
||||
@dataclass(slots=True)
|
||||
class PositionMetrics:
|
||||
symbol: str
|
||||
side: str
|
||||
|
||||
entry_price: float | None
|
||||
current_price: float | None
|
||||
size: float | None
|
||||
leverage: float | None
|
||||
|
||||
entry_notional_usd: float
|
||||
current_notional_usd: float
|
||||
margin_usd: float
|
||||
|
||||
# - price_move_percent = движение цены от входа.
|
||||
price_move_percent: float
|
||||
# - gross_pnl_usd = PnL без комиссий и overnight.
|
||||
gross_pnl_usd: float
|
||||
# - commission_usd = комиссия вход + предполагаемый выход.
|
||||
commission_usd: float
|
||||
# - overnight_cashflow_usd = списание или начисление за leverage.
|
||||
overnight_cashflow_usd: float
|
||||
# - net_pnl_usd = итоговый PnL после комиссии и overnight.
|
||||
net_pnl_usd: float
|
||||
# - pnl_percent = net PnL в процентах от notional входа.
|
||||
pnl_percent: float
|
||||
|
||||
hold_seconds: int | None
|
||||
overnight_count: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PlannedPositionMetrics:
|
||||
"""
|
||||
Единый расчёт планируемой позиции до открытия.
|
||||
|
||||
Используется на этапе:
|
||||
- подготовки ордера,
|
||||
- оценки размера,
|
||||
- оценки маржи,
|
||||
- оценки комиссии,
|
||||
- отображения в UI.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
side: str
|
||||
|
||||
entry_price: float | None
|
||||
size: float | None
|
||||
leverage: float | None
|
||||
|
||||
notional_usd: float
|
||||
margin_usd: float
|
||||
commission_usd: float
|
||||
|
||||
|
||||
def build_position_metrics(
|
||||
position: PositionState,
|
||||
*,
|
||||
current_price: NumericLike | None,
|
||||
) -> PositionMetrics:
|
||||
"""
|
||||
Главная функция расчёта метрик уже открытой позиции.
|
||||
|
||||
Сюда нужно постепенно перенести все расчёты, которые сейчас разбросаны по:
|
||||
- execution/calculations.py
|
||||
- execution/position_runtime.py
|
||||
- execution/risk_close.py
|
||||
- execution/position_protection.py
|
||||
- auto/position_health.py
|
||||
|
||||
Последовательность:
|
||||
1. Нормализуем входные данные позиции.
|
||||
2. Считаем notional и margin.
|
||||
3. Считаем движение цены.
|
||||
4. Считаем gross PnL.
|
||||
5. Считаем комиссии.
|
||||
6. Считаем overnight cashflow.
|
||||
7. Считаем net PnL.
|
||||
8. Считаем PnL % от notional входа.
|
||||
"""
|
||||
|
||||
price = safe_float(current_price)
|
||||
entry = safe_float(position.entry_price)
|
||||
size = safe_float(position.size)
|
||||
leverage = safe_float(position.leverage) or 1.0
|
||||
|
||||
entry_notional = _notional(entry, size)
|
||||
current_notional = _notional(price, size)
|
||||
margin = _margin(current_notional, leverage)
|
||||
|
||||
price_move_percent = _price_move_percent(
|
||||
side=position.side,
|
||||
entry_price=entry,
|
||||
current_price=price,
|
||||
)
|
||||
|
||||
gross_pnl = _gross_pnl_usd(
|
||||
side=position.side,
|
||||
entry_price=entry,
|
||||
current_price=price,
|
||||
size=size,
|
||||
)
|
||||
|
||||
commission = _round_trip_commission_usd(
|
||||
symbol=position.symbol,
|
||||
entry_price=entry,
|
||||
current_price=price,
|
||||
size=size,
|
||||
)
|
||||
|
||||
hold_seconds = _hold_seconds(position)
|
||||
|
||||
overnight_cashflow, overnight_count = _overnight_cashflow_usd(
|
||||
symbol=position.symbol,
|
||||
side=position.side,
|
||||
current_price=price,
|
||||
size=size,
|
||||
leverage=leverage,
|
||||
hold_seconds=hold_seconds,
|
||||
)
|
||||
|
||||
net_pnl = round(gross_pnl - commission + overnight_cashflow, 4)
|
||||
|
||||
pnl_percent = _pnl_percent(
|
||||
pnl_usd=net_pnl,
|
||||
entry_notional_usd=entry_notional,
|
||||
)
|
||||
|
||||
return PositionMetrics(
|
||||
symbol=position.symbol,
|
||||
side=position.side,
|
||||
entry_price=entry,
|
||||
current_price=price,
|
||||
size=size,
|
||||
leverage=leverage,
|
||||
entry_notional_usd=entry_notional,
|
||||
current_notional_usd=current_notional,
|
||||
margin_usd=margin,
|
||||
price_move_percent=price_move_percent,
|
||||
gross_pnl_usd=gross_pnl,
|
||||
commission_usd=commission,
|
||||
overnight_cashflow_usd=overnight_cashflow,
|
||||
net_pnl_usd=net_pnl,
|
||||
pnl_percent=pnl_percent,
|
||||
hold_seconds=hold_seconds,
|
||||
overnight_count=overnight_count,
|
||||
)
|
||||
|
||||
|
||||
def build_planned_position_metrics(
|
||||
*,
|
||||
symbol: str,
|
||||
side: str,
|
||||
entry_price: NumericLike | None,
|
||||
size: NumericLike | None,
|
||||
leverage: NumericLike | None,
|
||||
) -> PlannedPositionMetrics:
|
||||
"""
|
||||
Расчёт планируемой позиции до открытия.
|
||||
|
||||
Здесь нет PnL, потому что позиции ещё нет.
|
||||
Считаем только:
|
||||
- объём позиции,
|
||||
- маржу,
|
||||
- примерную round-trip комиссию.
|
||||
"""
|
||||
|
||||
price = safe_float(entry_price)
|
||||
parsed_size = safe_float(size)
|
||||
parsed_leverage = safe_float(leverage) or 1.0
|
||||
|
||||
notional = _notional(price, parsed_size)
|
||||
margin = _margin(notional, parsed_leverage)
|
||||
|
||||
commission = _round_trip_commission_usd(
|
||||
symbol=symbol,
|
||||
entry_price=price,
|
||||
current_price=price,
|
||||
size=parsed_size,
|
||||
)
|
||||
|
||||
return PlannedPositionMetrics(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
entry_price=price,
|
||||
size=parsed_size,
|
||||
leverage=parsed_leverage,
|
||||
notional_usd=notional,
|
||||
margin_usd=margin,
|
||||
commission_usd=commission,
|
||||
)
|
||||
|
||||
|
||||
def _price_move_percent(
|
||||
*,
|
||||
side: str | None,
|
||||
entry_price: float | None,
|
||||
current_price: float | None,
|
||||
) -> float:
|
||||
"""
|
||||
Считает движение цены от входа.
|
||||
|
||||
LONG:
|
||||
цена выше входа = плюс.
|
||||
|
||||
SHORT:
|
||||
цена ниже входа = плюс.
|
||||
"""
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return 0.0
|
||||
|
||||
if current_price is None or current_price <= 0:
|
||||
return 0.0
|
||||
|
||||
normalized_side = str(side or "").upper()
|
||||
|
||||
if normalized_side == "LONG":
|
||||
return round(((current_price - entry_price) / entry_price) * 100, 4)
|
||||
|
||||
if normalized_side == "SHORT":
|
||||
return round(((entry_price - current_price) / entry_price) * 100, 4)
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _gross_pnl_usd(
|
||||
*,
|
||||
side: str | None,
|
||||
entry_price: float | None,
|
||||
current_price: float | None,
|
||||
size: float | None,
|
||||
) -> float:
|
||||
"""
|
||||
Считает PnL без комиссий.
|
||||
|
||||
Это “грязная” прибыль/убыток только от изменения цены.
|
||||
"""
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return 0.0
|
||||
|
||||
if current_price is None or current_price <= 0:
|
||||
return 0.0
|
||||
|
||||
if size is None or size <= 0:
|
||||
return 0.0
|
||||
|
||||
normalized_side = str(side or "").upper()
|
||||
|
||||
if normalized_side == "LONG":
|
||||
return round((current_price - entry_price) * size, 4)
|
||||
|
||||
if normalized_side == "SHORT":
|
||||
return round((entry_price - current_price) * size, 4)
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _round_trip_commission_usd(
|
||||
*,
|
||||
symbol: str | None,
|
||||
entry_price: float | None,
|
||||
current_price: float | None,
|
||||
size: float | None,
|
||||
) -> float:
|
||||
"""
|
||||
Считает комиссию вход + выход.
|
||||
|
||||
Для открытой позиции:
|
||||
- вход уже был по entry_price;
|
||||
- выход предполагается по current_price.
|
||||
|
||||
Для планируемой позиции:
|
||||
- entry_price и current_price могут быть одинаковыми.
|
||||
"""
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return 0.0
|
||||
|
||||
if current_price is None or current_price <= 0:
|
||||
return 0.0
|
||||
|
||||
if size is None or size <= 0:
|
||||
return 0.0
|
||||
|
||||
fee_percent = _trading_fee_percent(symbol)
|
||||
|
||||
if fee_percent <= 0:
|
||||
return 0.0
|
||||
|
||||
entry_notional = entry_price * size
|
||||
exit_notional = current_price * size
|
||||
|
||||
return round((entry_notional + exit_notional) * (fee_percent / 100), 4)
|
||||
|
||||
|
||||
def _overnight_cashflow_usd(
|
||||
*,
|
||||
symbol: str | None,
|
||||
side: str | None,
|
||||
current_price: float | None,
|
||||
size: float | None,
|
||||
leverage: float | None,
|
||||
hold_seconds: int | None,
|
||||
) -> tuple[float, int]:
|
||||
"""
|
||||
Считает overnight/leverage cashflow.
|
||||
|
||||
Значение может быть:
|
||||
- отрицательным, если биржа списывает funding/overnight;
|
||||
- положительным, если ставка по стороне позиции положительная;
|
||||
- нулевым, если плечо x1 или срок удержания меньше периода списания.
|
||||
"""
|
||||
|
||||
parsed_leverage = safe_float(leverage) or 1.0
|
||||
|
||||
if parsed_leverage <= 1:
|
||||
return 0.0, 0
|
||||
|
||||
if current_price is None or current_price <= 0:
|
||||
return 0.0, 0
|
||||
|
||||
if size is None or size <= 0:
|
||||
return 0.0, 0
|
||||
|
||||
if hold_seconds is None or hold_seconds <= 0:
|
||||
return 0.0, 0
|
||||
|
||||
rate = _overnight_rate_for_side(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
)
|
||||
|
||||
if rate is None:
|
||||
return 0.0, 0
|
||||
|
||||
period_seconds = _overnight_period_seconds(symbol)
|
||||
overnight_count = int(hold_seconds // period_seconds)
|
||||
|
||||
if overnight_count <= 0:
|
||||
return 0.0, 0
|
||||
|
||||
notional = current_price * size
|
||||
cashflow = notional * (rate / 100) * overnight_count
|
||||
|
||||
return round(cashflow, 4), overnight_count
|
||||
|
||||
|
||||
def _pnl_percent(
|
||||
*,
|
||||
pnl_usd: float,
|
||||
entry_notional_usd: float,
|
||||
) -> float:
|
||||
"""
|
||||
Считает net PnL в процентах от notional входа.
|
||||
|
||||
Важно:
|
||||
здесь используется net PnL, то есть уже после комиссии и overnight.
|
||||
"""
|
||||
|
||||
if entry_notional_usd <= 0:
|
||||
return 0.0
|
||||
|
||||
return round((pnl_usd / entry_notional_usd) * 100, 4)
|
||||
|
||||
|
||||
def _notional(
|
||||
price: float | None,
|
||||
size: float | None,
|
||||
) -> float:
|
||||
"""
|
||||
Считает объём позиции в USD.
|
||||
"""
|
||||
|
||||
if price is None or price <= 0:
|
||||
return 0.0
|
||||
|
||||
if size is None or size <= 0:
|
||||
return 0.0
|
||||
|
||||
return round(price * size, 4)
|
||||
|
||||
|
||||
def _margin(
|
||||
notional_usd: float,
|
||||
leverage: float | None,
|
||||
) -> float:
|
||||
"""
|
||||
Считает занятые собственные средства.
|
||||
|
||||
Пример:
|
||||
notional $1000 при плече x2 = margin $500.
|
||||
"""
|
||||
|
||||
parsed_leverage = safe_float(leverage) or 1.0
|
||||
|
||||
if parsed_leverage <= 0:
|
||||
return 0.0
|
||||
|
||||
if notional_usd <= 0:
|
||||
return 0.0
|
||||
|
||||
return round(notional_usd / parsed_leverage, 4)
|
||||
|
||||
|
||||
def _hold_seconds(position: PositionState) -> int | None:
|
||||
"""
|
||||
Считает время удержания позиции.
|
||||
|
||||
Основной источник — opened_monotonic_at.
|
||||
Это надёжнее, чем строковое время opened_at.
|
||||
"""
|
||||
|
||||
opened_at = safe_float(getattr(position, "opened_monotonic_at", None))
|
||||
|
||||
if opened_at is None:
|
||||
return None
|
||||
|
||||
return max(0, int(time.monotonic() - opened_at))
|
||||
|
||||
|
||||
def _overnight_period_seconds(symbol: str | None) -> int:
|
||||
"""
|
||||
Возвращает период списания overnight.
|
||||
|
||||
Сейчас логика сохранена как в старом коде:
|
||||
- BTC/ETH: каждые 8 часов;
|
||||
- остальные активы: раз в 24 часа.
|
||||
"""
|
||||
|
||||
normalized = str(symbol or "").upper()
|
||||
|
||||
if normalized.startswith("BTC/") or normalized.startswith("BTC"):
|
||||
return 8 * 60 * 60
|
||||
|
||||
if normalized.startswith("ETH/") or normalized.startswith("ETH"):
|
||||
return 8 * 60 * 60
|
||||
|
||||
return 24 * 60 * 60
|
||||
|
||||
|
||||
def _overnight_rate_for_side(
|
||||
*,
|
||||
symbol: str | None,
|
||||
side: str | None,
|
||||
) -> float | None:
|
||||
# Берёт overnight rate для стороны позиции.
|
||||
# LONG использует overnight_long_rate.
|
||||
# SHORT использует overnight_short_rate.
|
||||
|
||||
fee = _trading_fee(symbol)
|
||||
|
||||
if fee is None:
|
||||
return None
|
||||
|
||||
normalized_side = str(side or "").upper()
|
||||
|
||||
if normalized_side == "LONG":
|
||||
return safe_float(getattr(fee, "overnight_long_rate", None))
|
||||
|
||||
if normalized_side == "SHORT":
|
||||
return safe_float(getattr(fee, "overnight_short_rate", None))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _trading_fee_percent(symbol: str | None) -> float:
|
||||
# Возвращает торговую комиссию в процентах.
|
||||
# Если комиссию получить не удалось — возвращаем 0,
|
||||
# чтобы расчёт позиции не падал.
|
||||
|
||||
fee = _trading_fee(symbol)
|
||||
|
||||
if fee is None:
|
||||
return 0.0
|
||||
|
||||
return safe_float(getattr(fee, "fee_percent", None)) or 0.0
|
||||
|
||||
|
||||
def _trading_fee(symbol: str | None):
|
||||
# Получает объект комиссии с биржи.
|
||||
# В этом первом варианте кеш специально не добавлен сюда,
|
||||
# чтобы не усложнять файл. Кеш уже есть в ExchangeService/старом коде.
|
||||
# Если потребуется — на следующем шаге добавим cache именно здесь.
|
||||
|
||||
normalized_symbol = str(symbol or "").strip()
|
||||
|
||||
if not normalized_symbol:
|
||||
return None
|
||||
|
||||
try:
|
||||
return ExchangeService().get_trading_fee(normalized_symbol)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -10,27 +10,63 @@ 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.position_metrics import PositionMetrics, build_position_metrics
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.position.state import PositionState
|
||||
|
||||
|
||||
PROTECTION_THRESHOLDS_BY_ASSET = {
|
||||
"BTC": {
|
||||
"break_even_activate": 0.45,
|
||||
"break_even_buffer": 0.12,
|
||||
"profit_lock_activate": 0.95,
|
||||
"profit_lock_distance": 0.55,
|
||||
"trailing_activate": 1.35,
|
||||
"trailing_distance": 0.35,
|
||||
},
|
||||
"ETH": {
|
||||
"break_even_activate": 0.60,
|
||||
"break_even_buffer": 0.18,
|
||||
"profit_lock_activate": 1.20,
|
||||
"profit_lock_distance": 0.70,
|
||||
"trailing_activate": 1.60,
|
||||
"trailing_distance": 0.45,
|
||||
},
|
||||
"LTC": {
|
||||
"break_even_activate": 0.75,
|
||||
"break_even_buffer": 0.22,
|
||||
"profit_lock_activate": 1.45,
|
||||
"profit_lock_distance": 0.85,
|
||||
"trailing_activate": 1.90,
|
||||
"trailing_distance": 0.60,
|
||||
},
|
||||
"XRP": {
|
||||
"break_even_activate": 0.85,
|
||||
"break_even_buffer": 0.25,
|
||||
"profit_lock_activate": 1.60,
|
||||
"profit_lock_distance": 0.95,
|
||||
"trailing_activate": 2.10,
|
||||
"trailing_distance": 0.70,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_PROTECTION_THRESHOLDS = {
|
||||
"break_even_activate": 0.65,
|
||||
"break_even_buffer": 0.20,
|
||||
"profit_lock_activate": 1.30,
|
||||
"profit_lock_distance": 0.75,
|
||||
"trailing_activate": 1.75,
|
||||
"trailing_distance": 0.50,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
@@ -42,14 +78,12 @@ class _ExecutionPositionProtectionProtocol(Protocol):
|
||||
) -> ExecutionDecision:
|
||||
...
|
||||
|
||||
# сбросить состояние runtime-защиты
|
||||
def _reset_runtime_protection_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
# получить intelligence-причину закрытия позиции
|
||||
def _runtime_intelligence_close_reason(
|
||||
self,
|
||||
*,
|
||||
@@ -60,7 +94,8 @@ class _ExecutionPositionProtectionProtocol(Protocol):
|
||||
|
||||
|
||||
class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
# обработать runtime-защиту открытой позиции
|
||||
# Главный runtime protection processor.
|
||||
# Здесь один раз получаем цену выхода и один раз считаем PositionMetrics.
|
||||
def _process_runtime_protection(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
@@ -76,7 +111,12 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
position.symbol or state.symbol,
|
||||
position.side,
|
||||
)
|
||||
current_price = current_execution.price
|
||||
|
||||
current_price = safe_float(current_execution.price)
|
||||
|
||||
if current_price is None or current_price <= 0:
|
||||
raise ValueError("invalid execution price")
|
||||
|
||||
except Exception:
|
||||
self._sync_runtime_protection_state(
|
||||
state=state,
|
||||
@@ -85,6 +125,11 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
)
|
||||
return None
|
||||
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
self._sync_runtime_protection_state(
|
||||
state=state,
|
||||
status="ACTIVE",
|
||||
@@ -94,16 +139,19 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
self._update_break_even_protection(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
self._update_profit_lock_protection(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
self._update_trailing_stop_protection(
|
||||
state=state,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
close_reason = self._runtime_protection_close_reason(
|
||||
@@ -120,17 +168,14 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
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_pnl=metrics.net_pnl_usd,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
# синхронизировать состояние protection engine
|
||||
def _sync_runtime_protection_state(
|
||||
self,
|
||||
*,
|
||||
@@ -142,30 +187,39 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
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,
|
||||
metrics: PositionMetrics,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
if state.break_even_armed:
|
||||
return
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
price_move_percent = metrics.price_move_percent
|
||||
thresholds = self._protection_thresholds(state)
|
||||
|
||||
if pnl_percent < 0.35:
|
||||
if price_move_percent < thresholds["break_even_activate"]:
|
||||
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
|
||||
|
||||
buffer_percent = thresholds["break_even_buffer"]
|
||||
|
||||
if position.side == "LONG":
|
||||
state.break_even_price = entry_price * (1 + buffer_percent / 100)
|
||||
elif position.side == "SHORT":
|
||||
state.break_even_price = entry_price * (1 - buffer_percent / 100)
|
||||
else:
|
||||
return
|
||||
|
||||
state.runtime_protection_action = "BREAK_EVEN_ARMED"
|
||||
state.runtime_protection_reason = "позиция вышла в прибыль, break-even активирован"
|
||||
state.runtime_protection_updated_at = time.monotonic()
|
||||
@@ -175,31 +229,38 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
action="BREAK_EVEN_ARMED",
|
||||
reason=state.runtime_protection_reason,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
# активировать profit lock защиту
|
||||
def _update_profit_lock_protection(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
metrics: PositionMetrics,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
price_move_percent = metrics.price_move_percent
|
||||
thresholds = self._protection_thresholds(state)
|
||||
|
||||
if pnl_percent < 0.75:
|
||||
if price_move_percent < thresholds["profit_lock_activate"]:
|
||||
return
|
||||
|
||||
entry_price = safe_float(position.entry_price)
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return
|
||||
|
||||
lock_distance_percent = thresholds["profit_lock_distance"]
|
||||
|
||||
if position.side == "LONG":
|
||||
lock_price = entry_price * 1.003
|
||||
min_lock_price = entry_price * 1.001
|
||||
dynamic_lock_price = current_price * (1 - lock_distance_percent / 100)
|
||||
lock_price = max(min_lock_price, dynamic_lock_price)
|
||||
elif position.side == "SHORT":
|
||||
lock_price = entry_price * 0.997
|
||||
min_lock_price = entry_price * 0.999
|
||||
dynamic_lock_price = current_price * (1 + lock_distance_percent / 100)
|
||||
lock_price = min(min_lock_price, dynamic_lock_price)
|
||||
else:
|
||||
return
|
||||
|
||||
@@ -208,7 +269,6 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
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
|
||||
|
||||
@@ -223,23 +283,25 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
action="PROFIT_LOCK_ACTIVE",
|
||||
reason=state.runtime_protection_reason,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
# активировать trailing stop защиту
|
||||
def _update_trailing_stop_protection(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
current_price: float,
|
||||
metrics: PositionMetrics,
|
||||
) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
pnl_percent = self._calculate_price_move_percent(current_price)
|
||||
price_move_percent = metrics.price_move_percent
|
||||
thresholds = self._protection_thresholds(state)
|
||||
|
||||
if pnl_percent < 1.0:
|
||||
if price_move_percent < thresholds["trailing_activate"]:
|
||||
return
|
||||
|
||||
trail_distance_percent = 0.35
|
||||
trail_distance_percent = thresholds["trailing_distance"]
|
||||
|
||||
if position.side == "LONG":
|
||||
trail_price = current_price * (1 - trail_distance_percent / 100)
|
||||
@@ -269,9 +331,9 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
action="TRAILING_STOP_ACTIVE",
|
||||
reason=state.runtime_protection_reason,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
# определить причину закрытия по защите
|
||||
def _runtime_protection_close_reason(
|
||||
self,
|
||||
*,
|
||||
@@ -280,37 +342,6 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
) -> 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
|
||||
@@ -333,7 +364,7 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
):
|
||||
return "BREAK_EVEN"
|
||||
|
||||
if position.side == "SHORT":
|
||||
elif position.side == "SHORT":
|
||||
if (
|
||||
state.trailing_stop_active
|
||||
and state.trailing_stop_price is not None
|
||||
@@ -357,7 +388,164 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
|
||||
return None
|
||||
|
||||
# записать событие runtime-защиты в журнал
|
||||
def _build_runtime_protection_payload(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
action: str,
|
||||
reason: str,
|
||||
current_price: float,
|
||||
metrics: PositionMetrics,
|
||||
) -> JsonDict:
|
||||
position = type(self)._position
|
||||
|
||||
return {
|
||||
# ---------- Trade ----------
|
||||
"trade_id": position.trade_id,
|
||||
"trade_sequence": position.trade_sequence,
|
||||
"trade_cycle_number": position.trade_cycle_number,
|
||||
|
||||
# ---------- Event ----------
|
||||
"execution_type": "RUNTIME_PROTECTION",
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
|
||||
# ---------- Runtime ----------
|
||||
"status": state.status,
|
||||
"strategy": state.strategy,
|
||||
"cycle_number": state.cycle_number,
|
||||
|
||||
# ---------- Position ----------
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"entry_price": position.entry_price,
|
||||
"current_price": current_price,
|
||||
"size": position.size,
|
||||
"leverage": position.leverage,
|
||||
|
||||
"opened_at": position.opened_at,
|
||||
"updated_at": position.updated_at,
|
||||
|
||||
# ---------- Metrics ----------
|
||||
"position_pnl_percent": metrics.price_move_percent,
|
||||
"net_pnl_usd": metrics.net_pnl_usd,
|
||||
"gross_pnl_usd": metrics.gross_pnl_usd,
|
||||
"commission_usd": metrics.commission_usd,
|
||||
"overnight_cashflow_usd": metrics.overnight_cashflow_usd,
|
||||
"margin_usd": metrics.margin_usd,
|
||||
"hold_seconds": metrics.hold_seconds,
|
||||
|
||||
# ---------- Runtime protection ----------
|
||||
"position_protection_status": state.position_protection_status,
|
||||
"position_protection_reason": state.position_protection_reason,
|
||||
"runtime_protection_action": state.runtime_protection_action,
|
||||
"runtime_protection_reason": state.runtime_protection_reason,
|
||||
"runtime_protection_updated_at": state.runtime_protection_updated_at,
|
||||
|
||||
"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,
|
||||
|
||||
# ---------- Protection thresholds ----------
|
||||
"protection_thresholds": self._protection_thresholds(state),
|
||||
|
||||
# ---------- Position Intelligence ----------
|
||||
"position_health_status": state.position_health_status,
|
||||
"position_health_score": state.position_health_score,
|
||||
"position_health_reason": state.position_health_reason,
|
||||
|
||||
"position_exit_signal": state.position_exit_signal,
|
||||
"position_exit_confidence": state.position_exit_confidence,
|
||||
"position_exit_urgency": state.position_exit_urgency,
|
||||
|
||||
"position_risk_level": state.position_risk_level,
|
||||
"position_risk_reason": state.position_risk_reason,
|
||||
|
||||
"position_trend_alignment": state.position_trend_alignment,
|
||||
"position_adverse_momentum": state.position_adverse_momentum,
|
||||
|
||||
"position_reversal_risk": state.position_reversal_risk,
|
||||
"position_fatigue_state": state.position_fatigue_state,
|
||||
|
||||
"position_giveback_percent": state.position_giveback_percent,
|
||||
"position_mfe_percent": state.position_mfe_percent,
|
||||
"position_mae_percent": state.position_mae_percent,
|
||||
|
||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
||||
|
||||
# ---------- Execution ----------
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"execution_confidence_level": state.execution_confidence_level,
|
||||
|
||||
"spread_percent": state.spread_percent,
|
||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||
|
||||
# ---------- Execution price ----------
|
||||
"execution_price_source": state.execution_price_source,
|
||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
||||
"execution_bid_price": state.execution_bid_price,
|
||||
"execution_ask_price": state.execution_ask_price,
|
||||
"execution_last_price": state.execution_last_price,
|
||||
|
||||
# ---------- Market Score ----------
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
|
||||
# ---------- Market ----------
|
||||
"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,
|
||||
|
||||
# ---------- Candle ----------
|
||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
||||
"current_interval_change_percent": state.current_interval_change_percent,
|
||||
"current_interval_direction": state.current_interval_direction,
|
||||
"current_interval_label": state.current_interval_label,
|
||||
|
||||
# ---------- Structure ----------
|
||||
"market_structure": state.market_structure,
|
||||
"market_structure_reason": state.market_structure_reason,
|
||||
|
||||
# ---------- Momentum ----------
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"momentum_strength": state.momentum_strength,
|
||||
"momentum_change_percent": state.momentum_change_percent,
|
||||
"breakout_level": state.breakout_level,
|
||||
"breakout_distance_percent": state.breakout_distance_percent,
|
||||
"breakout_reason": state.breakout_reason,
|
||||
|
||||
# ---------- HTF ----------
|
||||
"htf_interval": state.htf_interval,
|
||||
"htf_atr_percent": state.htf_atr_percent,
|
||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
||||
"htf_volatility": state.htf_volatility,
|
||||
"htf_market_state": state.htf_market_state,
|
||||
"htf_trend": state.htf_trend,
|
||||
"htf_trend_strength": state.htf_trend_strength,
|
||||
"htf_trend_quality": state.htf_trend_quality,
|
||||
"htf_market_phase": state.htf_market_phase,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"htf_reason": state.htf_reason,
|
||||
}
|
||||
|
||||
def _log_runtime_protection_event(
|
||||
self,
|
||||
*,
|
||||
@@ -365,27 +553,15 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
action: str,
|
||||
reason: str,
|
||||
current_price: float,
|
||||
metrics: PositionMetrics,
|
||||
) -> 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,
|
||||
}
|
||||
payload = self._build_runtime_protection_payload(
|
||||
state=state,
|
||||
action=action,
|
||||
reason=reason,
|
||||
current_price=current_price,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="runtime_protection_updated",
|
||||
@@ -395,4 +571,27 @@ class ExecutionPositionProtectionMixin(_ExecutionPositionProtectionProtocol):
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
EventBus.emit("runtime_protection_updated", payload)
|
||||
EventBus.emit("runtime_protection_updated", payload)
|
||||
|
||||
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
|
||||
|
||||
def _protection_thresholds(self, state: AutoTradeState) -> dict[str, float]:
|
||||
asset = self._asset_symbol(state.symbol)
|
||||
|
||||
return PROTECTION_THRESHOLDS_BY_ASSET.get(
|
||||
asset,
|
||||
DEFAULT_PROTECTION_THRESHOLDS,
|
||||
)
|
||||
@@ -2,30 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from typing import 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
|
||||
from src.trading.position.state import PositionState
|
||||
from src.trading.execution.position_metrics import build_position_metrics
|
||||
|
||||
|
||||
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: ...
|
||||
|
||||
|
||||
@@ -34,7 +24,9 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
def get_position(self) -> PositionState:
|
||||
return type(self)._position
|
||||
|
||||
# обновить unrealized PnL и runtime-память позиции
|
||||
# Обновить runtime-метрики открытой позиции.
|
||||
# Важно: PnL, комиссия, overnight и движение цены теперь считаются
|
||||
# один раз через position_metrics.py.
|
||||
def _update_unrealized_pnl(self, state: AutoTradeState) -> None:
|
||||
position = type(self)._position
|
||||
|
||||
@@ -47,43 +39,35 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
position.symbol or state.symbol,
|
||||
position.side,
|
||||
)
|
||||
current_price = current_execution.price
|
||||
current_price = safe_float(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)
|
||||
if current_price is None or current_price <= 0:
|
||||
self._sync_state_from_position(state)
|
||||
return
|
||||
|
||||
position.unrealized_pnl_usd = pnl
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
position.unrealized_pnl_usd = metrics.net_pnl_usd
|
||||
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
|
||||
# Единые runtime-метрики позиции.
|
||||
# Эти значения дальше используют health/semantics/protection,
|
||||
# поэтому не пересчитываем их в других файлах.
|
||||
state.position_pnl_percent = metrics.pnl_percent
|
||||
state.position_hold_seconds = metrics.hold_seconds
|
||||
|
||||
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(
|
||||
self._refresh_position_runtime_metrics(
|
||||
position=position,
|
||||
current_price=current_price,
|
||||
pnl_percent=pnl_percent,
|
||||
price_move_percent=metrics.price_move_percent,
|
||||
pnl_percent=metrics.pnl_percent,
|
||||
hold_seconds=metrics.hold_seconds,
|
||||
)
|
||||
|
||||
self._sync_state_from_position(state)
|
||||
@@ -109,6 +93,17 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
state.position_conviction_state = None
|
||||
state.position_exit_urgency = None
|
||||
state.position_reversal_risk = 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
|
||||
|
||||
state.position_opened_monotonic_at = position.opened_monotonic_at
|
||||
@@ -119,91 +114,22 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
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-метрики позиции по текущей цене
|
||||
# Обновить runtime-память позиции:
|
||||
# peak PnL, MFE/MAE, best/worst price, fatigue.
|
||||
# Само движение цены уже рассчитано выше через position_metrics.py,
|
||||
# поэтому здесь не пересчитываем его повторно.
|
||||
def _refresh_position_runtime_metrics(
|
||||
self,
|
||||
*,
|
||||
position: PositionState,
|
||||
current_price: float,
|
||||
price_move_percent: float,
|
||||
pnl_percent: float,
|
||||
hold_seconds: int | None,
|
||||
) -> None:
|
||||
price_move_percent = self._calculate_price_move_percent(current_price)
|
||||
if price_move_percent is None:
|
||||
return
|
||||
|
||||
pnl = safe_float(position.unrealized_pnl_usd)
|
||||
|
||||
if pnl is not None:
|
||||
@@ -214,8 +140,8 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
|
||||
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
|
||||
if peak_percent is None or pnl_percent > peak_percent:
|
||||
position.peak_pnl_percent = pnl_percent
|
||||
|
||||
mfe = safe_float(position.max_favorable_excursion_percent)
|
||||
mae = safe_float(position.max_adverse_excursion_percent)
|
||||
@@ -243,39 +169,49 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
elif position.side == "SHORT" and current_price > worst_price:
|
||||
position.worst_price_seen = current_price
|
||||
|
||||
fatigue_score = self._runtime_fatigue_score(position)
|
||||
fatigue_score = self._runtime_fatigue_score(
|
||||
position=position,
|
||||
current_pnl_percent=price_move_percent,
|
||||
hold_seconds=hold_seconds,
|
||||
)
|
||||
position.fatigue_score = fatigue_score
|
||||
position.fatigue_state = self._runtime_fatigue_state(fatigue_score)
|
||||
|
||||
# рассчитать fatigue score позиции
|
||||
def _runtime_fatigue_score(self, position: PositionState) -> float:
|
||||
def _runtime_fatigue_score(
|
||||
self,
|
||||
*,
|
||||
position: PositionState,
|
||||
current_pnl_percent: float,
|
||||
hold_seconds: int | None,
|
||||
) -> 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
|
||||
# Время удержания позиции уже рассчитано централизованно
|
||||
# в position_metrics.py, здесь его не пересчитываем.
|
||||
resolved_hold_seconds = hold_seconds or 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:
|
||||
if resolved_hold_seconds >= 1800:
|
||||
score += 0.25
|
||||
elif hold_seconds >= 900:
|
||||
elif resolved_hold_seconds >= 900:
|
||||
score += 0.15
|
||||
elif hold_seconds >= 300:
|
||||
elif resolved_hold_seconds >= 300:
|
||||
score += 0.08
|
||||
|
||||
if mfe > 0 and current_peak > 0:
|
||||
giveback = max(0.0, mfe - current_peak)
|
||||
if mfe > 0:
|
||||
giveback_ratio = max(
|
||||
0.0,
|
||||
(mfe - current_pnl_percent) / max(0.01, mfe),
|
||||
)
|
||||
|
||||
if giveback >= 0.75:
|
||||
if giveback_ratio >= 0.75:
|
||||
score += 0.25
|
||||
elif giveback >= 0.45:
|
||||
elif giveback_ratio >= 0.45:
|
||||
score += 0.18
|
||||
elif giveback >= 0.25:
|
||||
elif giveback_ratio >= 0.25:
|
||||
score += 0.10
|
||||
|
||||
if mae <= -1.0:
|
||||
@@ -301,17 +237,4 @@ class ExecutionPositionRuntimeMixin(_ExecutionRuntimeProtocol):
|
||||
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
|
||||
return "FRESH"
|
||||
@@ -33,79 +33,86 @@ class ExecutionPricingMixin:
|
||||
# получить цену входа по стороне позиции
|
||||
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.")
|
||||
self._ensure_fresh_snapshot(snapshot.age_seconds)
|
||||
|
||||
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,
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.ask_price,
|
||||
price_name="ask_price",
|
||||
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,
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.bid_price,
|
||||
price_name="bid_price",
|
||||
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,
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.last_price,
|
||||
price_name="last_price",
|
||||
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.")
|
||||
self._ensure_fresh_snapshot(snapshot.age_seconds)
|
||||
|
||||
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,
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.bid_price,
|
||||
price_name="bid_price",
|
||||
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,
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.ask_price,
|
||||
price_name="ask_price",
|
||||
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,
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.last_price,
|
||||
price_name="last_price",
|
||||
pricing_role="EXIT_LAST",
|
||||
)
|
||||
|
||||
# получить последнюю рыночную цену
|
||||
def _market_last_price(self, symbol: str) -> ExecutionPrice:
|
||||
snapshot = ExchangeService().get_execution_snapshot(symbol)
|
||||
self._ensure_fresh_snapshot(snapshot.age_seconds)
|
||||
|
||||
return self._build_execution_price(
|
||||
snapshot,
|
||||
raw_price=snapshot.last_price,
|
||||
price_name="last_price",
|
||||
pricing_role="MARKET_LAST",
|
||||
)
|
||||
|
||||
# собрать ExecutionPrice из execution snapshot
|
||||
def _build_execution_price(
|
||||
self,
|
||||
snapshot,
|
||||
*,
|
||||
raw_price: NumericLike | None,
|
||||
price_name: str,
|
||||
pricing_role: str,
|
||||
) -> ExecutionPrice:
|
||||
return ExecutionPrice(
|
||||
price=self._snapshot_price(snapshot.last_price, "last_price"),
|
||||
price=self._snapshot_price(raw_price, price_name),
|
||||
source=snapshot.source,
|
||||
age_seconds=snapshot.age_seconds,
|
||||
updated_at=snapshot.updated_at,
|
||||
pricing_role="MARKET_LAST",
|
||||
pricing_role=pricing_role,
|
||||
)
|
||||
|
||||
# проверить и нормализовать цену из execution snapshot
|
||||
@@ -115,20 +122,30 @@ class ExecutionPricingMixin:
|
||||
name: str,
|
||||
) -> float:
|
||||
if raw_price is None:
|
||||
raise ValueError(
|
||||
f"Execution snapshot price '{name}' is missing."
|
||||
)
|
||||
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."
|
||||
)
|
||||
raise ValueError(f"Execution snapshot price '{name}' is invalid.")
|
||||
|
||||
if price <= 0:
|
||||
raise ValueError(
|
||||
f"Execution snapshot price '{name}' is invalid: {price}"
|
||||
)
|
||||
|
||||
return price
|
||||
return price
|
||||
|
||||
# проверить свежесть execution snapshot
|
||||
def _ensure_fresh_snapshot(self, age_seconds: NumericLike | None) -> None:
|
||||
age = safe_float(age_seconds)
|
||||
|
||||
if age is None:
|
||||
return
|
||||
|
||||
max_age = safe_float(
|
||||
getattr(self, "_max_execution_snapshot_age_seconds", None)
|
||||
) or 5.0
|
||||
|
||||
if age > max_age:
|
||||
raise ValueError(f"Execution snapshot is stale: {age:.2f}s.")
|
||||
@@ -13,6 +13,7 @@ class _ExecutionResetsProtocol(Protocol):
|
||||
|
||||
Сейчас пустой, но оставлен для единообразия архитектуры.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -22,11 +23,6 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol):
|
||||
|
||||
Здесь находятся методы очистки runtime/protection/
|
||||
lifecycle состояния позиции.
|
||||
|
||||
Это позволяет избежать циклических зависимостей между:
|
||||
- position_actions.py
|
||||
- position_protection.py
|
||||
- runtime_actions.py
|
||||
"""
|
||||
|
||||
def _reset_runtime_protection_state(
|
||||
@@ -35,7 +31,7 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol):
|
||||
) -> None:
|
||||
"""
|
||||
Полный reset runtime protection состояния позиции.
|
||||
Вызывается после закрытия позиции.
|
||||
Вызывается после закрытия позиции или перед flip.
|
||||
"""
|
||||
|
||||
state.position_protection_status = None
|
||||
@@ -59,16 +55,52 @@ class ExecutionResetsMixin(_ExecutionResetsProtocol):
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
"""
|
||||
Reset lifecycle состояния позиции.
|
||||
Используется после полного закрытия позиции.
|
||||
Reset lifecycle/runtime состояния закрытой позиции.
|
||||
Используется после полного закрытия позиции или перед flip.
|
||||
"""
|
||||
|
||||
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.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.execution_block_reason = None
|
||||
state.last_flip_block_reason = None
|
||||
@@ -4,10 +4,13 @@ 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.execution.models import ExecutionDecision
|
||||
from src.trading.execution.pricing import ExecutionPrice
|
||||
from src.trading.position.state import PositionState
|
||||
from src.trading.execution.position_metrics import build_position_metrics
|
||||
|
||||
|
||||
class _ExecutionRiskCloseProtocol(Protocol):
|
||||
@@ -18,13 +21,8 @@ class _ExecutionRiskCloseProtocol(Protocol):
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
) -> ExecutionPrice: ...
|
||||
|
||||
# посчитать движение цены позиции в процентах
|
||||
def _calculate_price_move_percent(self, current_price) -> float: ...
|
||||
|
||||
# посчитать текущий PnL позиции
|
||||
def _calculate_pnl(self, current_price) -> float: ...
|
||||
) -> ExecutionPrice:
|
||||
...
|
||||
|
||||
# закрыть открытую позицию
|
||||
def _close_position(
|
||||
@@ -32,13 +30,32 @@ class _ExecutionRiskCloseProtocol(Protocol):
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
forced_reason: str | None = None,
|
||||
forced_exit_price=None,
|
||||
forced_pnl=None,
|
||||
forced_exit_price: NumericLike | None = None,
|
||||
forced_pnl: NumericLike | None = None,
|
||||
forced_price_meta: ExecutionPrice | None = None,
|
||||
) -> ExecutionDecision: ...
|
||||
) -> ExecutionDecision:
|
||||
...
|
||||
|
||||
|
||||
class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol):
|
||||
# закрыть позицию по risk-правилу с уже рассчитанными ценой и PnL
|
||||
def _close_position_by_risk(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
*,
|
||||
reason: str,
|
||||
current_price: NumericLike | None,
|
||||
unrealized_pnl: NumericLike | None,
|
||||
current_execution: ExecutionPrice,
|
||||
) -> ExecutionDecision:
|
||||
return self._close_position(
|
||||
state,
|
||||
forced_reason=reason,
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
)
|
||||
|
||||
# проверить, нужно ли закрыть позицию по max loss / stop loss / take profit
|
||||
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
|
||||
position = type(self)._position
|
||||
@@ -55,34 +72,39 @@ class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
price_move_percent = self._calculate_price_move_percent(current_price)
|
||||
unrealized_pnl = self._calculate_pnl(current_price)
|
||||
metrics = build_position_metrics(
|
||||
position,
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
price_move_percent = metrics.price_move_percent
|
||||
unrealized_pnl = metrics.net_pnl_usd
|
||||
|
||||
if self._is_max_loss_hit(state, unrealized_pnl):
|
||||
return self._close_position(
|
||||
return self._close_position_by_risk(
|
||||
state,
|
||||
forced_reason="MAX_LOSS",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
reason="MAX_LOSS",
|
||||
current_price=current_price,
|
||||
unrealized_pnl=unrealized_pnl,
|
||||
current_execution=current_execution,
|
||||
)
|
||||
|
||||
if self._is_stop_loss_hit(state, price_move_percent):
|
||||
return self._close_position(
|
||||
return self._close_position_by_risk(
|
||||
state,
|
||||
forced_reason="STOP_LOSS",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
reason="STOP_LOSS",
|
||||
current_price=current_price,
|
||||
unrealized_pnl=unrealized_pnl,
|
||||
current_execution=current_execution,
|
||||
)
|
||||
|
||||
if self._is_take_profit_hit(state, price_move_percent):
|
||||
return self._close_position(
|
||||
if self._is_take_profit_hit(state, unrealized_pnl):
|
||||
return self._close_position_by_risk(
|
||||
state,
|
||||
forced_reason="TAKE_PROFIT",
|
||||
forced_exit_price=current_price,
|
||||
forced_pnl=unrealized_pnl,
|
||||
forced_price_meta=current_execution,
|
||||
reason="TAKE_PROFIT",
|
||||
current_price=current_price,
|
||||
unrealized_pnl=unrealized_pnl,
|
||||
current_execution=current_execution,
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -91,31 +113,62 @@ class ExecutionRiskCloseMixin(_ExecutionRiskCloseProtocol):
|
||||
def _is_stop_loss_hit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
price_move_percent: float,
|
||||
price_move_percent: NumericLike | None,
|
||||
) -> bool:
|
||||
if state.stop_loss_percent is None:
|
||||
stop_loss_percent = safe_float(state.stop_loss_percent)
|
||||
price_move = safe_float(price_move_percent)
|
||||
|
||||
if stop_loss_percent is None or stop_loss_percent <= 0:
|
||||
return False
|
||||
|
||||
return price_move_percent <= -abs(state.stop_loss_percent)
|
||||
if price_move is None:
|
||||
return False
|
||||
|
||||
return price_move <= -abs(stop_loss_percent)
|
||||
|
||||
# проверить, достигнут ли take profit в процентах
|
||||
def _is_take_profit_hit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
price_move_percent: float,
|
||||
unrealized_pnl: NumericLike | None,
|
||||
) -> bool:
|
||||
if state.take_profit_percent is None:
|
||||
take_profit_percent = safe_float(state.take_profit_percent)
|
||||
pnl = safe_float(unrealized_pnl)
|
||||
|
||||
if take_profit_percent is None or take_profit_percent <= 0:
|
||||
return False
|
||||
|
||||
return price_move_percent >= abs(state.take_profit_percent)
|
||||
if pnl is None:
|
||||
return False
|
||||
|
||||
position = type(self)._position
|
||||
|
||||
entry_price = safe_float(position.entry_price)
|
||||
size = safe_float(position.size)
|
||||
|
||||
if entry_price is None or entry_price <= 0:
|
||||
return False
|
||||
|
||||
if size is None or size <= 0:
|
||||
return False
|
||||
|
||||
target_profit_usd = abs(entry_price * size * (take_profit_percent / 100))
|
||||
|
||||
return pnl >= target_profit_usd
|
||||
|
||||
# проверить, достигнут ли максимальный убыток в USD
|
||||
def _is_max_loss_hit(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
unrealized_pnl: float,
|
||||
unrealized_pnl: NumericLike | None,
|
||||
) -> bool:
|
||||
if state.max_loss_usd is None:
|
||||
max_loss_usd = safe_float(state.max_loss_usd)
|
||||
pnl = safe_float(unrealized_pnl)
|
||||
|
||||
if max_loss_usd is None or max_loss_usd <= 0:
|
||||
return False
|
||||
|
||||
return unrealized_pnl <= -abs(state.max_loss_usd)
|
||||
if pnl is None:
|
||||
return False
|
||||
|
||||
return pnl <= -abs(max_loss_usd)
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
from typing import ClassVar, Protocol
|
||||
|
||||
from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
@@ -12,10 +12,29 @@ 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.constants import (
|
||||
AUTONOMOUS_ACTION_EXIT,
|
||||
AUTONOMOUS_ACTION_EXIT_BLOCKED,
|
||||
AUTONOMOUS_ACTION_HOLD,
|
||||
AUTONOMOUS_ACTION_PROTECT,
|
||||
AUTONOMOUS_ACTION_REDUCE,
|
||||
AUTONOMOUS_ACTION_WATCH,
|
||||
AUTO_STATUS_RUNNING,
|
||||
EXECUTION_ACTION_NONE,
|
||||
EXECUTION_REASON_AUTONOMOUS_EXIT,
|
||||
EXECUTION_TYPE_RUNTIME_ACTION,
|
||||
POSITION_SIDE_NONE,
|
||||
RUNTIME_ACTION_COOLDOWN,
|
||||
RUNTIME_ACTION_COOLDOWN_SECONDS,
|
||||
RUNTIME_ACTION_SKIPPED,
|
||||
RUNTIME_ACTION_UNKNOWN,
|
||||
RUNTIME_EXIT_CONFIDENCE_THRESHOLD,
|
||||
get_position_exit_thresholds,
|
||||
)
|
||||
|
||||
|
||||
class _ExecutionRuntimeActionsProtocol(Protocol):
|
||||
_position: PositionState
|
||||
_position: ClassVar[PositionState]
|
||||
|
||||
def _sync_state_from_position(
|
||||
self,
|
||||
@@ -33,49 +52,39 @@ class _ExecutionRuntimeActionsProtocol(Protocol):
|
||||
class ExecutionRuntimeActionsMixin(
|
||||
_ExecutionRuntimeActionsProtocol
|
||||
):
|
||||
"""
|
||||
Runtime autonomous actions subsystem.
|
||||
# ----- Runtime autonomous actions subsystem.
|
||||
# Отвечает за:
|
||||
# - runtime EXIT
|
||||
# - runtime REDUCE
|
||||
# - runtime PROTECT
|
||||
# - cooldown runtime действий
|
||||
# - runtime logging
|
||||
|
||||
Отвечает за:
|
||||
- runtime EXIT
|
||||
- runtime REDUCE
|
||||
- runtime PROTECT
|
||||
- cooldown runtime действий
|
||||
- runtime logging
|
||||
"""
|
||||
|
||||
_runtime_action_cooldown_seconds = 30
|
||||
_runtime_action_cooldown_seconds = RUNTIME_ACTION_COOLDOWN_SECONDS
|
||||
_last_runtime_action_key: str | None = None
|
||||
|
||||
# =========================================================
|
||||
# PUBLIC
|
||||
# =========================================================
|
||||
|
||||
# ----- PUBLIC -----
|
||||
def process_runtime_action(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> ExecutionDecision:
|
||||
"""
|
||||
Главный runtime action processor.
|
||||
"""
|
||||
# Главный 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 state.status != AUTO_STATUS_RUNNING:
|
||||
reason = "Runtime action доступен только в режиме RUNNING."
|
||||
state.last_execution_action = RUNTIME_ACTION_SKIPPED
|
||||
state.last_execution_reason = reason
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
||||
|
||||
if position.side == "NONE":
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Нет открытой позиции для runtime action.",
|
||||
)
|
||||
if position.side == POSITION_SIDE_NONE:
|
||||
reason = "Нет открытой позиции для runtime action."
|
||||
state.last_execution_action = RUNTIME_ACTION_SKIPPED
|
||||
state.last_execution_reason = reason
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, reason)
|
||||
|
||||
action = str(
|
||||
getattr(state, "autonomous_action", "") or ""
|
||||
@@ -89,110 +98,106 @@ class ExecutionRuntimeActionsMixin(
|
||||
getattr(state, "autonomous_action_reason", "") or ""
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# NO ACTION
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action in {"", "HOLD", "WATCH"}:
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Runtime action не требуется.",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# COOLDOWN
|
||||
# -----------------------------------------------------
|
||||
if action in {"", AUTONOMOUS_ACTION_HOLD, AUTONOMOUS_ACTION_WATCH}:
|
||||
skip_reason = "Runtime action не требуется."
|
||||
state.last_execution_action = RUNTIME_ACTION_SKIPPED
|
||||
state.last_execution_reason = skip_reason
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason)
|
||||
|
||||
if self._runtime_action_cooldown_active(state, action):
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
"Runtime action cooldown активен.",
|
||||
)
|
||||
skip_reason = "Runtime action cooldown активен."
|
||||
state.last_execution_action = RUNTIME_ACTION_COOLDOWN
|
||||
state.last_execution_reason = skip_reason
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, skip_reason)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# PROTECT
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action == "PROTECT":
|
||||
if action == AUTONOMOUS_ACTION_PROTECT:
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action="PROTECT",
|
||||
action=AUTONOMOUS_ACTION_PROTECT,
|
||||
reason=reason or "позиция требует защиты",
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# REDUCE
|
||||
# -----------------------------------------------------
|
||||
|
||||
if action == "REDUCE":
|
||||
if action == AUTONOMOUS_ACTION_REDUCE:
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action="REDUCE",
|
||||
action=AUTONOMOUS_ACTION_REDUCE,
|
||||
reason=reason or "позиция требует уменьшения",
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------
|
||||
# EXIT
|
||||
# -----------------------------------------------------
|
||||
if action == AUTONOMOUS_ACTION_EXIT:
|
||||
if self._early_exit_guard_active(state):
|
||||
hold_seconds = safe_float(
|
||||
getattr(state, "position_hold_seconds", None)
|
||||
) or 0.0
|
||||
|
||||
if action == "EXIT":
|
||||
thresholds = get_position_exit_thresholds(
|
||||
getattr(state, "symbol", None)
|
||||
)
|
||||
|
||||
min_hold = thresholds["min_hold"]
|
||||
|
||||
if confidence < 0.75:
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action="EXIT_BLOCKED",
|
||||
action=AUTONOMOUS_ACTION_EXIT_BLOCKED,
|
||||
reason=(
|
||||
"autonomous exit заблокирован: "
|
||||
f"confidence {confidence:.2f} < 0.75"
|
||||
"early exit guard: позиция ещё слишком новая для закрытия "
|
||||
f"({hold_seconds:.0f}s < {min_hold:.0f}s)"
|
||||
),
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
cooldown_action=None,
|
||||
)
|
||||
|
||||
if confidence < RUNTIME_EXIT_CONFIDENCE_THRESHOLD:
|
||||
return self._log_runtime_action(
|
||||
state=state,
|
||||
action=AUTONOMOUS_ACTION_EXIT_BLOCKED,
|
||||
reason=(
|
||||
"autonomous exit заблокирован: "
|
||||
f"confidence {confidence:.2f} < "
|
||||
f"{RUNTIME_EXIT_CONFIDENCE_THRESHOLD:.2f}"
|
||||
),
|
||||
confidence=confidence,
|
||||
executed=False,
|
||||
cooldown_action=None,
|
||||
)
|
||||
|
||||
self._log_runtime_action(
|
||||
state=state,
|
||||
action=AUTONOMOUS_ACTION_EXIT,
|
||||
reason=reason or "autonomous exit",
|
||||
confidence=confidence,
|
||||
executed=True,
|
||||
)
|
||||
|
||||
decision = self._close_position(
|
||||
state,
|
||||
forced_reason="AUTONOMOUS_EXIT",
|
||||
forced_reason=EXECUTION_REASON_AUTONOMOUS_EXIT,
|
||||
)
|
||||
|
||||
state.autonomous_last_action = "EXIT"
|
||||
state.autonomous_last_action_reason = (
|
||||
reason or decision.reason
|
||||
)
|
||||
state.autonomous_last_action_at = (
|
||||
time.monotonic()
|
||||
)
|
||||
state.autonomous_last_action = AUTONOMOUS_ACTION_EXIT
|
||||
state.autonomous_last_action_reason = reason or decision.reason
|
||||
state.autonomous_last_action_at = time.monotonic()
|
||||
|
||||
return decision
|
||||
|
||||
# -----------------------------------------------------
|
||||
# UNKNOWN ACTION
|
||||
# -----------------------------------------------------
|
||||
unknown_reason = f"Неизвестный runtime action: {action}."
|
||||
state.last_execution_action = RUNTIME_ACTION_UNKNOWN
|
||||
state.last_execution_reason = unknown_reason
|
||||
|
||||
return ExecutionDecision(
|
||||
"NONE",
|
||||
False,
|
||||
f"Неизвестный runtime action: {action}.",
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# COOLDOWN
|
||||
# =========================================================
|
||||
return ExecutionDecision(EXECUTION_ACTION_NONE, False, unknown_reason)
|
||||
|
||||
# ----- COOLDOWN -----
|
||||
def _runtime_action_cooldown_active(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Проверка cooldown runtime action.
|
||||
"""
|
||||
|
||||
# Проверка cooldown runtime action.
|
||||
ts = safe_float(
|
||||
getattr(state, "autonomous_last_action_at", None)
|
||||
)
|
||||
@@ -211,10 +216,121 @@ class ExecutionRuntimeActionsMixin(
|
||||
time.monotonic() - ts
|
||||
) < self._runtime_action_cooldown_seconds
|
||||
|
||||
# =========================================================
|
||||
# LOGGING
|
||||
# =========================================================
|
||||
def _build_runtime_action_payload(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
position: PositionState,
|
||||
trade_id: str | None,
|
||||
action: str,
|
||||
reason: str,
|
||||
confidence: float,
|
||||
executed: bool,
|
||||
) -> JsonDict:
|
||||
return {
|
||||
# ---------- Trade ----------
|
||||
"trade_id": trade_id,
|
||||
"trade_sequence": position.trade_sequence,
|
||||
"trade_cycle_number": position.trade_cycle_number,
|
||||
|
||||
# ---------- Event ----------
|
||||
"execution_type": EXECUTION_TYPE_RUNTIME_ACTION,
|
||||
"action": action,
|
||||
"executed": executed,
|
||||
"reason": reason,
|
||||
"confidence": confidence,
|
||||
|
||||
# ---------- Runtime ----------
|
||||
"status": state.status,
|
||||
"strategy": state.strategy,
|
||||
"cycle_number": state.cycle_number,
|
||||
|
||||
# ---------- Instrument / Position ----------
|
||||
"symbol": state.symbol,
|
||||
"position_side": position.side,
|
||||
"entry_price": position.entry_price,
|
||||
"size": position.size,
|
||||
"leverage": position.leverage,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
"position_pnl_percent": state.position_pnl_percent,
|
||||
"position_hold_seconds": state.position_hold_seconds,
|
||||
|
||||
# ---------- Position health ----------
|
||||
"position_pressure": state.position_pressure,
|
||||
"position_health_status": state.position_health_status,
|
||||
"position_health_score": state.position_health_score,
|
||||
"position_health_reason": state.position_health_reason,
|
||||
"position_risk_level": state.position_risk_level,
|
||||
"position_risk_reason": state.position_risk_reason,
|
||||
"position_trend_alignment": state.position_trend_alignment,
|
||||
"position_adverse_momentum": state.position_adverse_momentum,
|
||||
"position_exit_pressure": state.position_exit_pressure,
|
||||
|
||||
# ---------- Position intelligence ----------
|
||||
"position_lifecycle_stage": state.position_lifecycle_stage,
|
||||
"position_hold_quality": state.position_hold_quality,
|
||||
"position_decay_state": state.position_decay_state,
|
||||
"position_exit_signal": state.position_exit_signal,
|
||||
"position_exit_confidence": state.position_exit_confidence,
|
||||
"position_exit_urgency": state.position_exit_urgency,
|
||||
"position_reversal_risk": state.position_reversal_risk,
|
||||
"position_intelligence_reason": state.position_intelligence_reason,
|
||||
"position_recommended_action": state.position_recommended_action,
|
||||
|
||||
# ---------- Advanced analytics ----------
|
||||
"position_peak_pnl_usd": state.position_peak_pnl_usd,
|
||||
"position_peak_pnl_percent": state.position_peak_pnl_percent,
|
||||
"position_mfe_percent": state.position_mfe_percent,
|
||||
"position_mae_percent": state.position_mae_percent,
|
||||
"position_fatigue_score": state.position_fatigue_score,
|
||||
"position_fatigue_state": state.position_fatigue_state,
|
||||
"position_giveback_percent": state.position_giveback_percent,
|
||||
"position_stall_state": state.position_stall_state,
|
||||
"position_stall_reason": state.position_stall_reason,
|
||||
|
||||
# ---------- Autonomous management ----------
|
||||
"autonomous_action": state.autonomous_action,
|
||||
"autonomous_action_reason": state.autonomous_action_reason,
|
||||
"autonomous_action_confidence": state.autonomous_action_confidence,
|
||||
"autonomous_protection_required": state.autonomous_protection_required,
|
||||
"autonomous_reduce_required": state.autonomous_reduce_required,
|
||||
"autonomous_exit_required": state.autonomous_exit_required,
|
||||
"autonomous_last_action": state.autonomous_last_action,
|
||||
"autonomous_last_action_reason": state.autonomous_last_action_reason,
|
||||
|
||||
# ---------- Runtime protection ----------
|
||||
"position_protection_status": state.position_protection_status,
|
||||
"position_protection_reason": state.position_protection_reason,
|
||||
"runtime_protection_action": state.runtime_protection_action,
|
||||
"runtime_protection_reason": state.runtime_protection_reason,
|
||||
"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,
|
||||
|
||||
# ---------- Market context ----------
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_volatility": state.market_volatility,
|
||||
"market_trend_quality": state.market_trend_quality,
|
||||
"market_phase": state.market_phase,
|
||||
"market_structure": state.market_structure,
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"momentum_strength": state.momentum_strength,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
|
||||
# ---------- Execution context ----------
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"spread_percent": state.spread_percent,
|
||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||
}
|
||||
|
||||
# ----- LOGGING -----
|
||||
def _log_runtime_action(
|
||||
self,
|
||||
*,
|
||||
@@ -223,14 +339,14 @@ class ExecutionRuntimeActionsMixin(
|
||||
reason: str,
|
||||
confidence: float,
|
||||
executed: bool,
|
||||
cooldown_action: str | None = None,
|
||||
) -> ExecutionDecision:
|
||||
"""
|
||||
Runtime action logging + deduplication.
|
||||
"""
|
||||
|
||||
# Runtime action logging + deduplication.
|
||||
position = type(self)._position
|
||||
trade_id = position.trade_id or state.current_trade_id
|
||||
|
||||
key = (
|
||||
f"{trade_id}:"
|
||||
f"{state.symbol}:"
|
||||
f"{position.side}:"
|
||||
f"{action}:"
|
||||
@@ -239,48 +355,17 @@ class ExecutionRuntimeActionsMixin(
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
payload = self._build_runtime_action_payload(
|
||||
state=state,
|
||||
position=position,
|
||||
trade_id=trade_id,
|
||||
action=action,
|
||||
reason=reason,
|
||||
confidence=confidence,
|
||||
executed=executed,
|
||||
)
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="runtime_position_action",
|
||||
@@ -298,7 +383,9 @@ class ExecutionRuntimeActionsMixin(
|
||||
payload,
|
||||
)
|
||||
|
||||
state.autonomous_last_action = action
|
||||
state.last_execution_action = action
|
||||
state.last_execution_reason = reason
|
||||
state.autonomous_last_action = cooldown_action or action
|
||||
state.autonomous_last_action_reason = reason
|
||||
state.autonomous_last_action_at = time.monotonic()
|
||||
|
||||
@@ -306,4 +393,27 @@ class ExecutionRuntimeActionsMixin(
|
||||
action,
|
||||
executed,
|
||||
reason,
|
||||
)
|
||||
)
|
||||
|
||||
def _early_exit_guard_active(self, state: AutoTradeState) -> bool:
|
||||
hold_seconds = safe_float(getattr(state, "position_hold_seconds", None))
|
||||
pnl_percent = safe_float(getattr(state, "position_pnl_percent", None))
|
||||
|
||||
if hold_seconds is None or pnl_percent is None:
|
||||
return False
|
||||
|
||||
thresholds = get_position_exit_thresholds(
|
||||
getattr(state, "symbol", None)
|
||||
)
|
||||
|
||||
min_hold = thresholds["min_hold"]
|
||||
hard_loss = thresholds["hard_loss"]
|
||||
|
||||
if hold_seconds >= min_hold:
|
||||
return False
|
||||
|
||||
# Если просадка уже критическая — guard не мешает защите.
|
||||
if pnl_percent <= hard_loss:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -38,58 +38,35 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
*,
|
||||
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,
|
||||
)
|
||||
risk_percent = safe_float(state.risk_percent)
|
||||
stop_loss_percent = safe_float(state.stop_loss_percent)
|
||||
balance_usd = safe_float(state.allocated_balance_usd) or 0.0
|
||||
|
||||
if risk_percent is None or 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,
|
||||
)
|
||||
if stop_loss_percent is None or 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
|
||||
price = safe_float(entry_price)
|
||||
|
||||
if price is None:
|
||||
try:
|
||||
price = self._signal_entry_price(state).price
|
||||
price = safe_float(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
|
||||
price = None
|
||||
|
||||
if price <= 0:
|
||||
self._sync_adaptive_size_state(
|
||||
state,
|
||||
base_size=0.0,
|
||||
final_size=0.0,
|
||||
multiplier=0.0,
|
||||
)
|
||||
if price is None or 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)
|
||||
target_risk_usd = balance_usd * (risk_percent / 100)
|
||||
stop_loss_distance_usd = price * (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,
|
||||
)
|
||||
if target_risk_usd <= 0 or 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
|
||||
@@ -105,84 +82,49 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
|
||||
return self._round_size(final_size)
|
||||
|
||||
# рассчитать коэффициент изменения размера позиции по runtime/context факторам
|
||||
# рассчитать коэффициент изменения размера позиции по итоговым runtime/context факторам
|
||||
def _adaptive_size_multiplier(self, state: AutoTradeState) -> float:
|
||||
multiplier = 1.0
|
||||
|
||||
execution_confidence_score = getattr(
|
||||
state,
|
||||
"execution_confidence_score",
|
||||
None,
|
||||
# execution_confidence_score — итоговая готовность входа:
|
||||
# сигнал + подтверждение + рынок + качество исполнения.
|
||||
# Если он ниже required_score, размер должен быть 0.
|
||||
execution_score = safe_float(
|
||||
getattr(state, "execution_confidence_score", None)
|
||||
)
|
||||
required_score = (
|
||||
safe_float(getattr(state, "execution_confidence_required_score", None))
|
||||
or 0.65
|
||||
)
|
||||
score_raw = safe_float(execution_confidence_score)
|
||||
|
||||
if score_raw is not None:
|
||||
score = max(0.0, min(1.0, score_raw))
|
||||
if execution_score is not None:
|
||||
execution_score = max(0.0, min(1.0, execution_score))
|
||||
|
||||
if score < 0.55:
|
||||
if execution_score < required_score:
|
||||
multiplier *= 0.0
|
||||
elif score < 0.65:
|
||||
elif execution_score < 0.75:
|
||||
multiplier *= 0.90
|
||||
elif execution_score < 0.85:
|
||||
multiplier *= 1.00
|
||||
else:
|
||||
multiplier *= 1.10
|
||||
|
||||
# market_score — новая общая оценка рынка 0..100.
|
||||
# Она должна влиять на размер позиции напрямую,
|
||||
# но без повторного ручного штрафования по trend/phase/momentum.
|
||||
market_score = self._market_score_for_sizing(state)
|
||||
|
||||
if market_score is not None:
|
||||
if market_score < 35:
|
||||
multiplier *= 0.0
|
||||
elif market_score < 55:
|
||||
multiplier *= 0.65
|
||||
elif score < 0.75:
|
||||
elif market_score < 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
|
||||
elif market_score < 90:
|
||||
multiplier *= 1.00
|
||||
else:
|
||||
multiplier *= 1.12
|
||||
|
||||
execution_quality = getattr(state, "execution_quality", None)
|
||||
execution_quality_reason = getattr(
|
||||
@@ -191,23 +133,52 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
None,
|
||||
)
|
||||
|
||||
# Качество исполнения оставляем отдельным фактором,
|
||||
# потому что оно связано не с рынком, а с возможностью нормально войти:
|
||||
# spread, snapshot age, стакан, деградация live-данных.
|
||||
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
|
||||
multiplier *= 0.85
|
||||
elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE":
|
||||
multiplier *= 0.7
|
||||
multiplier *= 0.70
|
||||
else:
|
||||
multiplier *= 0.8
|
||||
multiplier *= 0.85
|
||||
|
||||
if getattr(state, "market_runtime_degraded", False):
|
||||
multiplier *= 0.75
|
||||
|
||||
return round(max(0.0, min(1.25, multiplier)), 4)
|
||||
|
||||
# получить market_score для sizing.
|
||||
# Основной источник — state.market_score / state.market_score_percent.
|
||||
# Fallback — market_score из execution_confidence_factors, где он хранится как 0..1.
|
||||
def _market_score_for_sizing(self, state: AutoTradeState) -> float | None:
|
||||
direct_score = safe_float(getattr(state, "market_score", None))
|
||||
|
||||
if direct_score is None:
|
||||
direct_score = safe_float(getattr(state, "market_score_percent", None))
|
||||
|
||||
if direct_score is not None:
|
||||
return max(0.0, min(100.0, direct_score))
|
||||
|
||||
factors = getattr(state, "execution_confidence_factors", None)
|
||||
|
||||
if isinstance(factors, dict):
|
||||
factor_score = safe_float(factors.get("market_score"))
|
||||
|
||||
if factor_score is not None:
|
||||
# Старый market_score внутри execution_confidence_factors хранится как 0..1.
|
||||
if factor_score <= 1.0:
|
||||
factor_score *= 100
|
||||
|
||||
return max(0.0, min(100.0, factor_score))
|
||||
|
||||
return None
|
||||
|
||||
# синхронизировать рассчитанный adaptive size в AutoTradeState
|
||||
def _sync_adaptive_size_state(
|
||||
self,
|
||||
@@ -241,6 +212,7 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
|
||||
state.adaptive_size_reason = reason
|
||||
state.adaptive_size_factors = {
|
||||
"market_score": self._market_score_for_sizing(state),
|
||||
"execution_confidence_score": getattr(
|
||||
state,
|
||||
"execution_confidence_score",
|
||||
@@ -310,6 +282,13 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
state.effective_target_risk_usd = 0.0
|
||||
return
|
||||
|
||||
# Фиксируем итоговый size после margin limit, чтобы UI и journal не показывали старое значение.
|
||||
state.adaptive_size_final = self._round_size(final_size)
|
||||
|
||||
if state.adaptive_size_factors is not None:
|
||||
state.adaptive_size_factors["final_size"] = self._round_size(final_size)
|
||||
state.adaptive_size_factors["margin_limited"] = final_size < adaptive_final
|
||||
|
||||
margin_ratio = max(
|
||||
0.0,
|
||||
min(1.0, final_size / adaptive_final),
|
||||
@@ -354,43 +333,39 @@ class ExecutionSizingMixin(_ExecutionSizingProtocol):
|
||||
entry_price: float,
|
||||
size: float,
|
||||
) -> float:
|
||||
max_percent = state.max_reserved_balance_percent
|
||||
max_percent = safe_float(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
|
||||
leverage = safe_float(state.leverage) or 1.0
|
||||
price = safe_float(entry_price)
|
||||
current_size = safe_float(size) or 0.0
|
||||
|
||||
if leverage <= 0 or entry_price <= 0:
|
||||
if leverage <= 0 or price is None or price <= 0:
|
||||
state.execution_block_reason = "Invalid leverage or entry price."
|
||||
return 0.0
|
||||
|
||||
balance_usd = state.allocated_balance_usd
|
||||
balance_usd = safe_float(state.allocated_balance_usd) or 0.0
|
||||
max_reserved_usd = balance_usd * (max_percent / 100)
|
||||
|
||||
max_notional_usd = max_reserved_usd * leverage
|
||||
max_size = max_notional_usd / entry_price
|
||||
max_size = max_notional_usd / price
|
||||
|
||||
if size <= max_size:
|
||||
return self._round_size(size)
|
||||
if current_size <= max_size:
|
||||
return self._round_size(current_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"
|
||||
)
|
||||
state.adaptive_size_reason = "размер позиции сильно ограничен margin limit"
|
||||
else:
|
||||
state.adaptive_size_reason = (
|
||||
"размер позиции ограничен margin limit"
|
||||
)
|
||||
state.adaptive_size_reason = "размер позиции ограничен margin limit"
|
||||
|
||||
return limited_size
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ class _ExecutionSupervisorProtocol(Protocol):
|
||||
_max_execution_snapshot_age_seconds: int
|
||||
_degraded_market_block_states: set[str]
|
||||
_conflict_execution_block: bool
|
||||
_last_supervisor_block_key: str | None
|
||||
|
||||
|
||||
class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
# проверить все supervisor-блокировки перед исполнением
|
||||
def _process_execution_supervisor(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
@@ -33,6 +33,8 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
(self._execution_cooldown_reason(state), "EXECUTION_COOLDOWN"),
|
||||
(self._degraded_market_reason(state), "DEGRADED_MARKET"),
|
||||
(self._stale_execution_reason(state), "STALE_EXECUTION"),
|
||||
(self._entry_block_reason(state), "ENTRY_BLOCKED"),
|
||||
(self._low_execution_confidence_reason(state), "LOW_EXECUTION_CONFIDENCE"),
|
||||
(self._conflict_signal_reason(state), "SIGNAL_CONFLICT"),
|
||||
):
|
||||
if reason is not None:
|
||||
@@ -42,26 +44,58 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
action=action,
|
||||
)
|
||||
|
||||
self._clear_supervisor_block_state(state)
|
||||
return None
|
||||
|
||||
# определить, нужно ли аварийно остановить execution
|
||||
def _clear_supervisor_block_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
) -> None:
|
||||
supervisor_actions = {
|
||||
"EXECUTION_HALTED",
|
||||
"EXECUTION_COOLDOWN",
|
||||
"DEGRADED_MARKET",
|
||||
"STALE_EXECUTION",
|
||||
"ENTRY_BLOCKED",
|
||||
"LOW_EXECUTION_CONFIDENCE",
|
||||
"SIGNAL_CONFLICT",
|
||||
}
|
||||
|
||||
state.execution_block_title = None
|
||||
state.execution_block_message = None
|
||||
state.execution_block_action = None
|
||||
|
||||
if state.last_execution_action not in supervisor_actions:
|
||||
return
|
||||
|
||||
if state.execution_block_reason == state.last_execution_reason:
|
||||
state.execution_block_reason = None
|
||||
|
||||
type(self)._last_supervisor_block_key = None
|
||||
|
||||
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))
|
||||
# Блокируем цикл только после серии подряд идущих убытков.
|
||||
# Важно: не считаем все убыточные сделки цикла, потому что прибыльная сделка
|
||||
# должна сбрасывать серию убытков.
|
||||
losses = int(getattr(state, "cycle_consecutive_losses", 0) or 0)
|
||||
|
||||
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:
|
||||
# Cooldown после убытка включаем только если его явно активировал execution layer.
|
||||
# Сам факт last_loss_monotonic_at больше НЕ должен блокировать торговлю,
|
||||
# иначе пауза появляется уже после первой убыточной сделки.
|
||||
if not bool(getattr(state, "loss_cooldown_active", False)):
|
||||
return None
|
||||
|
||||
ts = safe_float(getattr(state, "last_loss_monotonic_at", None))
|
||||
|
||||
if ts is None:
|
||||
@@ -75,16 +109,67 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
|
||||
return None
|
||||
|
||||
# определить, запрещает ли состояние рынка исполнение
|
||||
# разрешить ранний вход из RANGE, если уже есть impulse/momentum по сигналу
|
||||
def _early_impulse_execution_allowed(self, state: AutoTradeState) -> bool:
|
||||
signal = str(getattr(state, "last_signal", "") or "").upper()
|
||||
market_state = str(getattr(state, "market_state", "") or "").upper()
|
||||
market_phase = str(getattr(state, "market_phase", "") or "").upper()
|
||||
market_trend = str(getattr(state, "market_trend", "") or "").upper()
|
||||
momentum_state = str(getattr(state, "momentum_state", "") or "").upper()
|
||||
momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper()
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
|
||||
if signal not in {"BUY", "SELL"}:
|
||||
return False
|
||||
|
||||
if market_state != "RANGE":
|
||||
return False
|
||||
|
||||
if market_phase != "IMPULSE":
|
||||
return False
|
||||
|
||||
if htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}:
|
||||
return False
|
||||
|
||||
if signal == "BUY":
|
||||
return (
|
||||
market_trend == "UP"
|
||||
and momentum_direction == "UP"
|
||||
and momentum_state in {"MOMENTUM_UP", "BREAKOUT_UP"}
|
||||
)
|
||||
|
||||
if signal == "SELL":
|
||||
return (
|
||||
market_trend == "DOWN"
|
||||
and momentum_direction == "DOWN"
|
||||
and momentum_state in {"MOMENTUM_DOWN", "BREAKOUT_DOWN"}
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def _degraded_market_reason(self, state: AutoTradeState) -> str | None:
|
||||
market_state = getattr(state, "market_state", None)
|
||||
market_state = str(getattr(state, "market_state", "") or "").upper()
|
||||
volatility = str(getattr(state, "market_volatility", "") or "").upper()
|
||||
|
||||
early_impulse_allowed = self._early_impulse_execution_allowed(state)
|
||||
|
||||
if market_state in self._degraded_market_block_states:
|
||||
if market_state == "RANGE" and early_impulse_allowed:
|
||||
return None
|
||||
|
||||
return f"market state blocked execution: {market_state}"
|
||||
|
||||
if market_state in {"RANGE", "LOW_VOLATILITY", "UNKNOWN", ""}:
|
||||
if market_state == "RANGE" and early_impulse_allowed:
|
||||
return None
|
||||
|
||||
return f"market state blocked execution: {market_state or 'UNKNOWN'}"
|
||||
|
||||
if volatility in {"LOW", "UNKNOWN", ""}:
|
||||
return f"market volatility blocked execution: {volatility or 'UNKNOWN'}"
|
||||
|
||||
return None
|
||||
|
||||
# определить, устарели ли данные для исполнения
|
||||
def _stale_execution_reason(self, state: AutoTradeState) -> str | None:
|
||||
age = safe_float(getattr(state, "execution_price_age_seconds", None))
|
||||
|
||||
@@ -99,32 +184,297 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
|
||||
return None
|
||||
|
||||
# определить конфликт сигнала с momentum или трендом
|
||||
def _entry_block_reason(self, state: AutoTradeState) -> str | None:
|
||||
reason = str(getattr(state, "entry_block_reason", "") or "").strip()
|
||||
message = str(getattr(state, "entry_block_message", "") or "").strip()
|
||||
|
||||
if reason:
|
||||
return message or f"entry blocked by market analysis: {reason}"
|
||||
|
||||
return None
|
||||
|
||||
def _low_execution_confidence_reason(self, state: AutoTradeState) -> str | None:
|
||||
signal = str(getattr(state, "last_signal", "") or "").upper()
|
||||
|
||||
if signal not in {"BUY", "SELL"}:
|
||||
return None
|
||||
|
||||
score = safe_float(getattr(state, "execution_confidence_score", None))
|
||||
required = safe_float(
|
||||
getattr(state, "execution_confidence_required_score", None)
|
||||
)
|
||||
|
||||
if required is None:
|
||||
required = 0.55
|
||||
|
||||
if score is None:
|
||||
return "execution confidence is not calculated"
|
||||
|
||||
if score < required:
|
||||
return f"execution confidence too low: {score:.2f} < {required:.2f}"
|
||||
|
||||
return None
|
||||
|
||||
def _conflict_signal_reason(self, state: AutoTradeState) -> str | None:
|
||||
if not self._conflict_execution_block:
|
||||
return None
|
||||
|
||||
signal = (state.last_signal or "").upper()
|
||||
signal = str(getattr(state, "last_signal", "") or "").upper()
|
||||
momentum_direction = str(getattr(state, "momentum_direction", "") or "").upper()
|
||||
trend_direction = str(getattr(state, "market_trend", "") or "").upper()
|
||||
market_state = str(getattr(state, "market_state", "") or "").upper()
|
||||
market_structure = str(getattr(state, "market_structure", "") or "").upper()
|
||||
htf_alignment = str(getattr(state, "htf_alignment", "") or "").upper()
|
||||
|
||||
if signal not in {"BUY", "SELL"}:
|
||||
return None
|
||||
|
||||
if htf_alignment and htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}:
|
||||
return f"{signal} conflicts with HTF alignment: {htf_alignment}"
|
||||
|
||||
if signal == "BUY":
|
||||
if momentum_direction == "DOWN":
|
||||
return "BUY conflicts with momentum"
|
||||
|
||||
if trend_direction == "DOWN":
|
||||
if trend_direction == "DOWN" or market_state == "TREND_DOWN":
|
||||
return "BUY conflicts with trend"
|
||||
|
||||
if market_structure == "LH_LL":
|
||||
return "BUY conflicts with bearish market structure"
|
||||
|
||||
if signal == "SELL":
|
||||
if momentum_direction == "UP":
|
||||
return "SELL conflicts with momentum"
|
||||
|
||||
if trend_direction == "UP":
|
||||
if trend_direction == "UP" or market_state == "TREND_UP":
|
||||
return "SELL conflicts with trend"
|
||||
|
||||
if market_structure == "HH_HL":
|
||||
return "SELL conflicts with bullish market structure"
|
||||
|
||||
return None
|
||||
|
||||
# заблокировать execution и записать событие в журнал
|
||||
def _human_execution_block(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
reason: str,
|
||||
state: AutoTradeState,
|
||||
) -> tuple[str, str, str]:
|
||||
if action == "EXECUTION_HALTED":
|
||||
# Для UI показываем именно текущую серию убытков подряд,
|
||||
# а не общее количество минусовых сделок за цикл.
|
||||
losses = int(getattr(state, "cycle_consecutive_losses", 0) or 0)
|
||||
|
||||
if "loss streak" in reason:
|
||||
return (
|
||||
"Совершение сделок заблокировано",
|
||||
f"Превышен лимит убыточных сделок · {losses}",
|
||||
"Перезапусти цикл автоторговли",
|
||||
)
|
||||
|
||||
return (
|
||||
"Совершение сделок заблокировано",
|
||||
"Превышен лимит просадки цикла",
|
||||
"Перезапусти цикл автоторговли",
|
||||
)
|
||||
|
||||
if action == "EXECUTION_COOLDOWN":
|
||||
return (
|
||||
"Совершение сделок временно заблокировано",
|
||||
"Пауза после убыточной сделки",
|
||||
"Дождись окончания cooldown",
|
||||
)
|
||||
|
||||
if action == "LOW_EXECUTION_CONFIDENCE":
|
||||
return (
|
||||
"Сделка заблокирована",
|
||||
"Низкая уверенность исполнения",
|
||||
"Дождись более сильного сигнала",
|
||||
)
|
||||
|
||||
if action == "ENTRY_BLOCKED":
|
||||
return (
|
||||
"Сделка заблокирована",
|
||||
str(getattr(state, "entry_block_message", "") or "Рынок сейчас не подходит для входа"),
|
||||
"Дождись подходящих условий",
|
||||
)
|
||||
|
||||
if action == "SIGNAL_CONFLICT":
|
||||
return (
|
||||
"Сделка заблокирована",
|
||||
"Сигнал конфликтует с рынком",
|
||||
"Дождись подтверждения направления",
|
||||
)
|
||||
|
||||
if action == "STALE_EXECUTION":
|
||||
return (
|
||||
"Сделка заблокирована",
|
||||
"Нет актуальных котировок",
|
||||
"Дождись обновления данных",
|
||||
)
|
||||
|
||||
if action == "DEGRADED_MARKET":
|
||||
return (
|
||||
"Сделка заблокирована",
|
||||
"Рыночные условия не подходят",
|
||||
"Дождись нормализации рынка",
|
||||
)
|
||||
|
||||
return (
|
||||
"Сделка заблокирована",
|
||||
reason,
|
||||
"Проверь журнал автоторговли",
|
||||
)
|
||||
|
||||
def _build_supervisor_block_payload(
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
action: str,
|
||||
reason: str,
|
||||
) -> JsonDict:
|
||||
return {
|
||||
# ---------- Event ----------
|
||||
"execution_type": "SUPERVISOR_BLOCK",
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
|
||||
# ---------- Runtime ----------
|
||||
"status": state.status,
|
||||
"strategy": state.strategy,
|
||||
"cycle_number": state.cycle_number,
|
||||
|
||||
# ---------- Instrument ----------
|
||||
"symbol": state.symbol,
|
||||
|
||||
# ---------- Signal ----------
|
||||
"signal": state.last_signal,
|
||||
"confidence": state.last_signal_confidence,
|
||||
"repeat_count": state.last_signal_repeat_count,
|
||||
"signal_reason": state.last_signal_reason,
|
||||
|
||||
# ---------- Decision ----------
|
||||
"decision_status": state.decision_status,
|
||||
"decision_reason": state.decision_reason,
|
||||
"is_signal_confirmed": state.is_signal_confirmed,
|
||||
"is_signal_ready": state.is_signal_ready,
|
||||
|
||||
# ---------- Runtime blocks ----------
|
||||
"entry_block_reason": state.entry_block_reason,
|
||||
"entry_block_message": state.entry_block_message,
|
||||
"execution_block_reason": state.execution_block_reason,
|
||||
"execution_block_title": state.execution_block_title,
|
||||
"execution_block_message": state.execution_block_message,
|
||||
"execution_block_action": state.execution_block_action,
|
||||
"last_flip_block_reason": state.last_flip_block_reason,
|
||||
|
||||
# ---------- Execution ----------
|
||||
"execution_confidence_score": state.execution_confidence_score,
|
||||
"execution_confidence_level": state.execution_confidence_level,
|
||||
"execution_confidence_required_score": state.execution_confidence_required_score,
|
||||
"execution_confidence_reason": state.execution_confidence_reason,
|
||||
"execution_confidence_factors": state.execution_confidence_factors,
|
||||
|
||||
"execution_quality": state.execution_quality,
|
||||
"execution_quality_reason": state.execution_quality_reason,
|
||||
"execution_quality_message": state.execution_quality_message,
|
||||
|
||||
"spread_percent": state.spread_percent,
|
||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||
|
||||
# ---------- Execution price ----------
|
||||
"execution_price_source": state.execution_price_source,
|
||||
"execution_price_age_seconds": state.execution_price_age_seconds,
|
||||
"execution_bid_price": state.execution_bid_price,
|
||||
"execution_ask_price": state.execution_ask_price,
|
||||
"execution_last_price": state.execution_last_price,
|
||||
"execution_price_freshness": state.execution_price_freshness,
|
||||
|
||||
# ---------- Risk settings ----------
|
||||
"risk_percent": state.risk_percent,
|
||||
"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,
|
||||
"allocated_balance_usd": state.allocated_balance_usd,
|
||||
"leverage": state.leverage,
|
||||
|
||||
# ---------- Position ----------
|
||||
"position_side": state.position_side,
|
||||
"entry_price": state.entry_price,
|
||||
"position_size": state.position_size,
|
||||
"unrealized_pnl_usd": state.unrealized_pnl_usd,
|
||||
|
||||
# ---------- Cycle stats ----------
|
||||
"realized_pnl_usd": state.realized_pnl_usd,
|
||||
"cycle_realized_pnl_usd": state.cycle_realized_pnl_usd,
|
||||
"cycle_closed_trades": state.cycle_closed_trades,
|
||||
"cycle_winning_trades": state.cycle_winning_trades,
|
||||
"cycle_losing_trades": state.cycle_losing_trades,
|
||||
"cycle_consecutive_losses": state.cycle_consecutive_losses,
|
||||
"loss_cooldown_active": state.loss_cooldown_active,
|
||||
"loss_cooldown_reason": state.loss_cooldown_reason,
|
||||
|
||||
# ---------- Market score ----------
|
||||
"market_score": state.market_score,
|
||||
"market_score_label": state.market_score_label,
|
||||
"market_long_score": state.market_long_score,
|
||||
"market_short_score": state.market_short_score,
|
||||
|
||||
# ---------- Market ----------
|
||||
"market_state": state.market_state,
|
||||
"market_trend": state.market_trend,
|
||||
"market_volatility": state.market_volatility,
|
||||
"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,
|
||||
|
||||
# ---------- Candle ----------
|
||||
"last_closed_candle_change_percent": state.last_closed_candle_change_percent,
|
||||
"last_closed_candle_direction": state.last_closed_candle_direction,
|
||||
"current_interval_change_percent": state.current_interval_change_percent,
|
||||
"current_interval_direction": state.current_interval_direction,
|
||||
"current_interval_label": state.current_interval_label,
|
||||
|
||||
# ---------- Structure ----------
|
||||
"market_structure": state.market_structure,
|
||||
"market_structure_reason": state.market_structure_reason,
|
||||
|
||||
# ---------- Momentum ----------
|
||||
"momentum_state": state.momentum_state,
|
||||
"momentum_direction": state.momentum_direction,
|
||||
"momentum_strength": state.momentum_strength,
|
||||
"momentum_change_percent": state.momentum_change_percent,
|
||||
"breakout_level": state.breakout_level,
|
||||
"breakout_distance_percent": state.breakout_distance_percent,
|
||||
"breakout_reason": state.breakout_reason,
|
||||
|
||||
# ---------- HTF ----------
|
||||
"htf_interval": state.htf_interval,
|
||||
"htf_atr_percent": state.htf_atr_percent,
|
||||
"htf_atr_percent_baseline": state.htf_atr_percent_baseline,
|
||||
"htf_volatility_ratio": state.htf_volatility_ratio,
|
||||
"htf_volatility": state.htf_volatility,
|
||||
"htf_market_state": state.htf_market_state,
|
||||
"htf_trend": state.htf_trend,
|
||||
"htf_trend_strength": state.htf_trend_strength,
|
||||
"htf_trend_quality": state.htf_trend_quality,
|
||||
"htf_market_phase": state.htf_market_phase,
|
||||
"htf_alignment": state.htf_alignment,
|
||||
"htf_confirmation_score": state.htf_confirmation_score,
|
||||
"htf_reason": state.htf_reason,
|
||||
|
||||
# ---------- Market runtime ----------
|
||||
"market_runtime_degraded": state.market_runtime_degraded,
|
||||
"runtime_expired_reason": state.runtime_expired_reason,
|
||||
"runtime_expired_message": state.runtime_expired_message,
|
||||
"market_is_open": state.market_is_open,
|
||||
"market_status": state.market_status,
|
||||
"market_status_message": state.market_status_message,
|
||||
}
|
||||
|
||||
def _block_execution(
|
||||
self,
|
||||
*,
|
||||
@@ -136,6 +486,16 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
state.last_execution_action = action
|
||||
state.last_execution_reason = reason
|
||||
|
||||
(
|
||||
state.execution_block_title,
|
||||
state.execution_block_message,
|
||||
state.execution_block_action,
|
||||
) = self._human_execution_block(
|
||||
action=action,
|
||||
reason=reason,
|
||||
state=state,
|
||||
)
|
||||
|
||||
key_reason = reason
|
||||
|
||||
if action == "EXECUTION_COOLDOWN":
|
||||
@@ -147,17 +507,11 @@ class ExecutionSupervisorMixin(_ExecutionSupervisorProtocol):
|
||||
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,
|
||||
}
|
||||
payload = self._build_supervisor_block_payload(
|
||||
state=state,
|
||||
action=action,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
JournalService().log_ui_warning(
|
||||
event_type="execution_supervisor_block",
|
||||
|
||||
@@ -119,6 +119,47 @@ class JournalService:
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def log_debug(
|
||||
self,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if not load_settings().journal_debug_enabled:
|
||||
return
|
||||
|
||||
self.log_info(
|
||||
event_type=event_type,
|
||||
message=f"[DEBUG] {message}",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def log_ui_debug(
|
||||
self,
|
||||
*,
|
||||
event_type: str,
|
||||
message: str,
|
||||
screen: str,
|
||||
action: str,
|
||||
user_id: int | None = None,
|
||||
chat_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if not load_settings().journal_debug_enabled:
|
||||
return
|
||||
|
||||
self.log_info(
|
||||
event_type=event_type,
|
||||
message=self._build_message(f"[DEBUG] {message}"),
|
||||
payload=self._build_payload(
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
screen=screen,
|
||||
action=action,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
def log_ui_info(
|
||||
self,
|
||||
*,
|
||||
@@ -338,31 +379,39 @@ class JournalService:
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# Единый payload сделки для будущего анализа стратегии.
|
||||
# Порядок блоков важен: так экспорт журнала легче читать и сравнивать.
|
||||
payload: dict[str, Any] = {
|
||||
# ---------- Trade identity ----------
|
||||
"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),
|
||||
"cycle_number": getattr(state, "cycle_number", None),
|
||||
|
||||
# ---------- Position at event moment ----------
|
||||
"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),
|
||||
|
||||
# ---------- PnL / cycle statistics ----------
|
||||
"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),
|
||||
"cycle_losing_trades": getattr(state, "cycle_losing_trades", None),
|
||||
"cycle_consecutive_losses": getattr(state, "cycle_consecutive_losses", None),
|
||||
|
||||
# ---------- Signal / decision ----------
|
||||
"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 summary ----------
|
||||
"market_state": getattr(state, "market_state", None),
|
||||
"market_trend": getattr(state, "market_trend", None),
|
||||
"market_trend_strength": getattr(state, "market_trend_strength", None),
|
||||
@@ -370,24 +419,44 @@ class JournalService:
|
||||
"market_phase": getattr(state, "market_phase", None),
|
||||
"market_phase_direction": getattr(state, "market_phase_direction", None),
|
||||
|
||||
# ---------- Market score ----------
|
||||
"market_score": getattr(state, "market_score", None),
|
||||
"market_score_label": getattr(state, "market_score_label", None),
|
||||
"market_long_score": getattr(state, "market_long_score", None),
|
||||
"market_short_score": getattr(state, "market_short_score", None),
|
||||
|
||||
# ---------- Candle / interval context ----------
|
||||
"last_closed_candle_change_percent": getattr(state, "last_closed_candle_change_percent", None),
|
||||
"last_closed_candle_direction": getattr(state, "last_closed_candle_direction", None),
|
||||
"current_interval_change_percent": getattr(state, "current_interval_change_percent", None),
|
||||
"current_interval_direction": getattr(state, "current_interval_direction", None),
|
||||
"current_interval_label": getattr(state, "current_interval_label", None),
|
||||
|
||||
# ---------- Market structure ----------
|
||||
"market_structure": getattr(state, "market_structure", None),
|
||||
"market_structure_reason": getattr(state, "market_structure_reason", None),
|
||||
|
||||
# ---------- Momentum / breakout ----------
|
||||
"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 ----------
|
||||
"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 ----------
|
||||
"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 analytics ----------
|
||||
"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),
|
||||
|
||||
196
app/src/trading/market_analysis/filters.py
Normal file
196
app/src/trading/market_analysis/filters.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# app/src/trading/market_analysis/filters.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
EmaDistanceState,
|
||||
EntryTimingState,
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
MarketStructure,
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
# Главный рыночный фильтр входа.
|
||||
#
|
||||
# Этот файл НЕ открывает сделки сам.
|
||||
# Он только отвечает на вопрос:
|
||||
# "Можно ли стратегии вообще рассматривать вход по текущему состоянию рынка?"
|
||||
#
|
||||
# Последовательность:
|
||||
# 1. Проверяем, что рынок действительно трендовый.
|
||||
# 2. Проверяем направление тренда.
|
||||
# 3. Отсекаем плохую волатильность.
|
||||
# 4. Проверяем старший таймфрейм.
|
||||
# 5. Проверяем momentum / breakout.
|
||||
# 6. Проверяем структуру рынка.
|
||||
# 7. Проверяем EMA, свечи, цену и тайминг.
|
||||
#
|
||||
# ВАЖНО:
|
||||
# Резкий breakout может появляться из COMPRESSED / шумного состояния.
|
||||
# Поэтому COMPRESSED и NOISY теперь не всегда блокируют вход,
|
||||
# если есть подтверждённый breakout по тренду.
|
||||
def is_trade_allowed(
|
||||
*,
|
||||
state: MarketState,
|
||||
trend: TrendDirection,
|
||||
volatility: VolatilityState,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
market_phase: MarketPhase,
|
||||
market_structure: MarketStructure,
|
||||
momentum_state: MomentumState,
|
||||
momentum_direction: TrendDirection,
|
||||
candle_noise_score: float | None,
|
||||
price_position_score: float | None,
|
||||
ema_fast_slope_percent: float | None,
|
||||
ema_distance_state: EmaDistanceState,
|
||||
entry_timing_state: EntryTimingState,
|
||||
fast_slope_threshold_percent: float,
|
||||
htf_alignment: str,
|
||||
htf_confirmation_score: float | None,
|
||||
min_htf_confirmation_score: float,
|
||||
min_clean_candle_score: float,
|
||||
min_price_position_score: float,
|
||||
rsi_value: float | None,
|
||||
rsi_overbought: float,
|
||||
rsi_oversold: float,
|
||||
) -> bool:
|
||||
is_up_context = (
|
||||
state == MarketState.TREND_UP
|
||||
and trend == TrendDirection.UP
|
||||
)
|
||||
|
||||
is_down_context = (
|
||||
state == MarketState.TREND_DOWN
|
||||
and trend == TrendDirection.DOWN
|
||||
)
|
||||
|
||||
is_breakout_up = (
|
||||
is_up_context
|
||||
and momentum_state == MomentumState.BREAKOUT_UP
|
||||
and momentum_direction == TrendDirection.UP
|
||||
)
|
||||
|
||||
is_breakout_down = (
|
||||
is_down_context
|
||||
and momentum_state == MomentumState.BREAKOUT_DOWN
|
||||
and momentum_direction == TrendDirection.DOWN
|
||||
)
|
||||
|
||||
is_breakout_with_trend = is_breakout_up or is_breakout_down
|
||||
|
||||
if not is_up_context and not is_down_context:
|
||||
return False
|
||||
|
||||
# Не торгуем только при низкой / неизвестной волатильности.
|
||||
# HIGH не блокируем полностью: для TREND это может быть нормальный импульс.
|
||||
if volatility in {
|
||||
VolatilityState.LOW,
|
||||
VolatilityState.UNKNOWN,
|
||||
}:
|
||||
return False
|
||||
|
||||
if trend_strength == TrendStrength.UNKNOWN:
|
||||
return False
|
||||
|
||||
if trend_strength == TrendStrength.WEAK and not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
if trend_quality == TrendQuality.UNKNOWN:
|
||||
return False
|
||||
|
||||
if trend_quality == TrendQuality.NOISY and not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
if market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}:
|
||||
if not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
if htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}:
|
||||
return False
|
||||
|
||||
if (
|
||||
htf_confirmation_score is not None
|
||||
and htf_confirmation_score < min_htf_confirmation_score
|
||||
and not is_breakout_with_trend
|
||||
):
|
||||
return False
|
||||
|
||||
if rsi_value is not None and not is_breakout_with_trend:
|
||||
if trend == TrendDirection.UP and rsi_value >= rsi_overbought:
|
||||
return False
|
||||
|
||||
if trend == TrendDirection.DOWN and rsi_value <= rsi_oversold:
|
||||
return False
|
||||
|
||||
if trend == TrendDirection.UP:
|
||||
if momentum_direction != TrendDirection.UP:
|
||||
return False
|
||||
|
||||
if momentum_state not in {
|
||||
MomentumState.MOMENTUM_UP,
|
||||
MomentumState.BREAKOUT_UP,
|
||||
}:
|
||||
return False
|
||||
|
||||
if market_structure == MarketStructure.LH_LL:
|
||||
return False
|
||||
|
||||
if trend == TrendDirection.DOWN:
|
||||
if momentum_direction != TrendDirection.DOWN:
|
||||
return False
|
||||
|
||||
if momentum_state not in {
|
||||
MomentumState.MOMENTUM_DOWN,
|
||||
MomentumState.BREAKOUT_DOWN,
|
||||
}:
|
||||
return False
|
||||
|
||||
if market_structure == MarketStructure.HH_HL:
|
||||
return False
|
||||
|
||||
fast_slope = ema_fast_slope_percent or 0.0
|
||||
|
||||
if trend == TrendDirection.UP and fast_slope < fast_slope_threshold_percent:
|
||||
return False
|
||||
|
||||
if trend == TrendDirection.DOWN and fast_slope > -fast_slope_threshold_percent:
|
||||
return False
|
||||
|
||||
if candle_noise_score is None:
|
||||
return False
|
||||
|
||||
if candle_noise_score < min_clean_candle_score and not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
if price_position_score is None:
|
||||
return False
|
||||
|
||||
if price_position_score < min_price_position_score and not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
if ema_distance_state in {
|
||||
EmaDistanceState.OVEREXTENDED,
|
||||
EmaDistanceState.UNKNOWN,
|
||||
}:
|
||||
return False
|
||||
|
||||
if ema_distance_state == EmaDistanceState.COMPRESSED and not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
if entry_timing_state in {
|
||||
EntryTimingState.LATE,
|
||||
EntryTimingState.CHASING,
|
||||
}:
|
||||
return False
|
||||
|
||||
if entry_timing_state == EntryTimingState.UNKNOWN and not is_breakout_with_trend:
|
||||
return False
|
||||
|
||||
return True
|
||||
487
app/src/trading/market_analysis/htf.py
Normal file
487
app/src/trading/market_analysis/htf.py
Normal file
@@ -0,0 +1,487 @@
|
||||
# app/src/trading/market_analysis/htf.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.market_analysis.indicators import atr, ema
|
||||
from src.trading.market_analysis.indicators.trend import (
|
||||
classify_trend,
|
||||
classify_trend_quality,
|
||||
classify_trend_strength,
|
||||
ema_distance_atr_ratio as calculate_ema_distance_atr_ratio,
|
||||
ema_slope_percent,
|
||||
trend_consistency,
|
||||
trend_efficiency,
|
||||
trend_gap_percent_value,
|
||||
)
|
||||
from src.trading.market_analysis.indicators.volatility import (
|
||||
adaptive_threshold,
|
||||
atr_percent_baseline,
|
||||
classify_volatility,
|
||||
)
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
from src.trading.market_analysis.quality import (
|
||||
candle_noise_score as calculate_candle_noise_score,
|
||||
price_position_score as calculate_price_position_score,
|
||||
)
|
||||
|
||||
|
||||
def htf_volatility_context(
|
||||
service,
|
||||
*,
|
||||
symbol: str,
|
||||
base_interval: str,
|
||||
) -> JsonDict:
|
||||
if base_interval == service._htf_interval:
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
"htf_atr_percent_baseline": None,
|
||||
"htf_volatility_ratio": None,
|
||||
"htf_volatility": None,
|
||||
"htf_reason": "HTF_SKIPPED_SAME_INTERVAL",
|
||||
}
|
||||
|
||||
try:
|
||||
batch = ExchangeService().get_klines(
|
||||
symbol=symbol,
|
||||
interval=service._htf_interval,
|
||||
limit=service._htf_limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
"htf_atr_percent_baseline": None,
|
||||
"htf_volatility_ratio": None,
|
||||
"htf_volatility": None,
|
||||
"htf_reason": f"HTF_KLINES_ERROR: {exc}",
|
||||
}
|
||||
|
||||
candles = batch.candles
|
||||
closes = [item.close_price for item in candles]
|
||||
|
||||
if len(candles) < service._min_candles or not closes:
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
"htf_atr_percent_baseline": None,
|
||||
"htf_volatility_ratio": None,
|
||||
"htf_volatility": None,
|
||||
"htf_reason": "HTF_NOT_ENOUGH_CANDLES",
|
||||
}
|
||||
|
||||
close_price = safe_float(closes[-1])
|
||||
atr_value = atr(candles, service._atr_period)
|
||||
|
||||
if close_price is None or close_price <= 0 or atr_value is None:
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
"htf_atr_percent_baseline": None,
|
||||
"htf_volatility_ratio": None,
|
||||
"htf_volatility": None,
|
||||
"htf_reason": "HTF_ATR_UNAVAILABLE",
|
||||
}
|
||||
|
||||
htf_atr_percent = (atr_value / close_price) * 100
|
||||
htf_baseline = atr_percent_baseline(
|
||||
candles=candles,
|
||||
close_price=close_price,
|
||||
atr_period=service._atr_period,
|
||||
atr_baseline_window=service._atr_baseline_window,
|
||||
)
|
||||
|
||||
htf_ratio = (
|
||||
htf_atr_percent / htf_baseline
|
||||
if htf_baseline is not None and htf_baseline > 0
|
||||
else None
|
||||
)
|
||||
|
||||
htf_volatility = classify_volatility(
|
||||
atr_percent=htf_atr_percent,
|
||||
volatility_ratio=htf_ratio,
|
||||
htf_volatility_ratio=None,
|
||||
low_volatility_atr_percent=service._low_volatility_atr_percent,
|
||||
high_volatility_atr_percent=service._high_volatility_atr_percent,
|
||||
)
|
||||
|
||||
return {
|
||||
"htf_interval": service._htf_interval,
|
||||
"htf_atr_percent": round(htf_atr_percent, 4),
|
||||
"htf_atr_percent_baseline": round(htf_baseline, 4)
|
||||
if htf_baseline is not None
|
||||
else None,
|
||||
"htf_volatility_ratio": round(htf_ratio, 4)
|
||||
if htf_ratio is not None
|
||||
else None,
|
||||
"htf_volatility": htf_volatility.value,
|
||||
"htf_reason": "HTF_OK",
|
||||
}
|
||||
|
||||
|
||||
def htf_trend_context(
|
||||
service,
|
||||
*,
|
||||
symbol: str,
|
||||
base_interval: str,
|
||||
local_state: MarketState,
|
||||
local_trend: TrendDirection,
|
||||
) -> JsonDict:
|
||||
if base_interval == service._htf_interval:
|
||||
return {
|
||||
"htf_market_state": local_state.value,
|
||||
"htf_trend": local_trend.value,
|
||||
"htf_trend_strength": TrendStrength.UNKNOWN.value,
|
||||
"htf_trend_quality": TrendQuality.UNKNOWN.value,
|
||||
"htf_market_phase": MarketPhase.UNKNOWN.value,
|
||||
"htf_alignment": "SAME_INTERVAL",
|
||||
"htf_confirmation_score": 1.0,
|
||||
"htf_reason": "HTF_SKIPPED_SAME_INTERVAL",
|
||||
}
|
||||
|
||||
try:
|
||||
batch = ExchangeService().get_klines(
|
||||
symbol=symbol,
|
||||
interval=service._htf_interval,
|
||||
limit=service._htf_limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _htf_unknown_context(f"HTF_KLINES_ERROR: {exc}")
|
||||
|
||||
candles = batch.candles
|
||||
closes = [item.close_price for item in candles]
|
||||
|
||||
if len(candles) < service._min_candles:
|
||||
return _htf_unknown_context("HTF_NOT_ENOUGH_CANDLES")
|
||||
|
||||
close_price = closes[-1] if closes else None
|
||||
ema_fast = ema(closes, service._fast_ema_period)
|
||||
ema_slow = ema(closes, service._slow_ema_period)
|
||||
atr_value = atr(candles, service._atr_period)
|
||||
|
||||
if (
|
||||
close_price is None
|
||||
or close_price <= 0
|
||||
or ema_fast is None
|
||||
or ema_slow is None
|
||||
or atr_value is None
|
||||
):
|
||||
return _htf_unknown_context("HTF_INDICATORS_UNAVAILABLE")
|
||||
|
||||
atr_percent = (atr_value / close_price) * 100
|
||||
|
||||
fast_slope_threshold_percent = adaptive_threshold(
|
||||
atr_percent=atr_percent,
|
||||
multiplier=0.08,
|
||||
minimum=0.01,
|
||||
)
|
||||
|
||||
slow_slope_threshold_percent = adaptive_threshold(
|
||||
atr_percent=atr_percent,
|
||||
multiplier=0.03,
|
||||
minimum=0.005,
|
||||
)
|
||||
|
||||
trend_direction_gap_threshold_percent = adaptive_threshold(
|
||||
atr_percent=atr_percent,
|
||||
multiplier=0.12,
|
||||
minimum=0.025,
|
||||
)
|
||||
|
||||
weak_trend_gap_threshold_percent = adaptive_threshold(
|
||||
atr_percent=atr_percent,
|
||||
multiplier=0.18,
|
||||
minimum=0.05,
|
||||
)
|
||||
|
||||
strong_trend_gap_threshold_percent = adaptive_threshold(
|
||||
atr_percent=atr_percent,
|
||||
multiplier=0.55,
|
||||
minimum=0.18,
|
||||
)
|
||||
|
||||
ema_fast_slope_percent = ema_slope_percent(
|
||||
closes=closes,
|
||||
period=service._fast_ema_period,
|
||||
window=service._ema_fast_slope_window,
|
||||
)
|
||||
|
||||
ema_slow_slope_percent = ema_slope_percent(
|
||||
closes=closes,
|
||||
period=service._slow_ema_period,
|
||||
window=service._ema_slow_slope_window,
|
||||
)
|
||||
|
||||
trend = classify_trend(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
ema_fast_slope_percent=ema_fast_slope_percent,
|
||||
ema_slow_slope_percent=ema_slow_slope_percent,
|
||||
fast_slope_threshold_percent=fast_slope_threshold_percent,
|
||||
slow_slope_threshold_percent=slow_slope_threshold_percent,
|
||||
trend_direction_gap_threshold_percent=trend_direction_gap_threshold_percent,
|
||||
)
|
||||
|
||||
trend_gap_percent = trend_gap_percent_value(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
)
|
||||
|
||||
trend_strength = classify_trend_strength(
|
||||
trend_gap_percent=trend_gap_percent,
|
||||
weak_threshold_percent=weak_trend_gap_threshold_percent,
|
||||
strong_threshold_percent=strong_trend_gap_threshold_percent,
|
||||
)
|
||||
|
||||
trend_consistency_value = trend_consistency(
|
||||
closes=closes,
|
||||
trend=trend,
|
||||
trend_consistency_window=service._trend_consistency_window,
|
||||
)
|
||||
|
||||
trend_efficiency_value = trend_efficiency(
|
||||
closes=closes,
|
||||
trend_consistency_window=service._trend_consistency_window,
|
||||
)
|
||||
|
||||
ema_distance_atr_ratio_value = calculate_ema_distance_atr_ratio(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
atr_value=atr_value,
|
||||
)
|
||||
|
||||
candle_noise_score = calculate_candle_noise_score(
|
||||
candles,
|
||||
candle_noise_window=service._candle_noise_window,
|
||||
min_clean_body_ratio=service._min_clean_body_ratio,
|
||||
)
|
||||
|
||||
price_position_score = calculate_price_position_score(
|
||||
closes=closes,
|
||||
ema_fast=ema_fast,
|
||||
trend=trend,
|
||||
price_position_window=service._price_position_window,
|
||||
)
|
||||
|
||||
trend_quality = classify_trend_quality(
|
||||
trend_consistency=trend_consistency_value,
|
||||
trend_efficiency=trend_efficiency_value,
|
||||
ema_distance_atr_ratio=ema_distance_atr_ratio_value,
|
||||
candle_noise_score=candle_noise_score,
|
||||
price_position_score=price_position_score,
|
||||
trend_strength=trend_strength,
|
||||
min_clean_candle_score=service._min_clean_candle_score,
|
||||
min_price_position_score=service._min_price_position_score,
|
||||
)
|
||||
|
||||
market_phase = _htf_market_phase(
|
||||
trend=trend,
|
||||
trend_strength=trend_strength,
|
||||
trend_quality=trend_quality,
|
||||
)
|
||||
|
||||
market_state = _htf_market_state(
|
||||
trend=trend,
|
||||
trend_strength=trend_strength,
|
||||
trend_quality=trend_quality,
|
||||
market_phase=market_phase,
|
||||
)
|
||||
|
||||
alignment = _htf_alignment(
|
||||
local_state=local_state,
|
||||
htf_state=market_state,
|
||||
local_trend=local_trend,
|
||||
htf_trend=trend,
|
||||
)
|
||||
|
||||
confirmation_score = _htf_confirmation_score(
|
||||
alignment=alignment,
|
||||
trend_strength=trend_strength,
|
||||
trend_quality=trend_quality,
|
||||
trend_consistency=trend_consistency_value,
|
||||
trend_efficiency=trend_efficiency_value,
|
||||
)
|
||||
|
||||
return {
|
||||
"htf_market_state": market_state.value,
|
||||
"htf_trend": trend.value,
|
||||
"htf_trend_strength": trend_strength.value,
|
||||
"htf_trend_quality": trend_quality.value,
|
||||
"htf_market_phase": market_phase.value,
|
||||
"htf_alignment": alignment,
|
||||
"htf_confirmation_score": round(confirmation_score, 3),
|
||||
"htf_reason": (
|
||||
f"HTF_{service._htf_interval}:"
|
||||
f"{market_state.value}:"
|
||||
f"{trend.value}:"
|
||||
f"{alignment}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def safe_market_state(value: object) -> MarketState | None:
|
||||
try:
|
||||
return MarketState(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def safe_trend_direction(value: object) -> TrendDirection | None:
|
||||
try:
|
||||
return TrendDirection(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def safe_trend_strength(value: object) -> TrendStrength | None:
|
||||
try:
|
||||
return TrendStrength(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def safe_trend_quality(value: object) -> TrendQuality | None:
|
||||
try:
|
||||
return TrendQuality(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def safe_market_phase(value: object) -> MarketPhase | None:
|
||||
try:
|
||||
return MarketPhase(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def safe_volatility_state(value: object) -> VolatilityState | None:
|
||||
try:
|
||||
return VolatilityState(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _htf_unknown_context(reason: str) -> JsonDict:
|
||||
return {
|
||||
"htf_market_state": MarketState.UNKNOWN.value,
|
||||
"htf_trend": TrendDirection.UNKNOWN.value,
|
||||
"htf_trend_strength": TrendStrength.UNKNOWN.value,
|
||||
"htf_trend_quality": TrendQuality.UNKNOWN.value,
|
||||
"htf_market_phase": MarketPhase.UNKNOWN.value,
|
||||
"htf_alignment": "UNKNOWN",
|
||||
"htf_confirmation_score": None,
|
||||
"htf_reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _htf_market_phase(
|
||||
*,
|
||||
trend: TrendDirection,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
) -> MarketPhase:
|
||||
if trend in {TrendDirection.UNKNOWN, TrendDirection.FLAT}:
|
||||
return MarketPhase.RANGE
|
||||
|
||||
if trend_strength == TrendStrength.WEAK:
|
||||
return MarketPhase.RANGE
|
||||
|
||||
if trend_quality == TrendQuality.NOISY:
|
||||
return MarketPhase.RANGE
|
||||
|
||||
return MarketPhase.IMPULSE
|
||||
|
||||
|
||||
def _htf_market_state(
|
||||
*,
|
||||
trend: TrendDirection,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
market_phase: MarketPhase,
|
||||
) -> MarketState:
|
||||
if trend == TrendDirection.UP:
|
||||
return MarketState.TREND_UP
|
||||
|
||||
if trend == TrendDirection.DOWN:
|
||||
return MarketState.TREND_DOWN
|
||||
|
||||
if trend == TrendDirection.FLAT:
|
||||
return MarketState.RANGE
|
||||
|
||||
return MarketState.UNKNOWN
|
||||
|
||||
|
||||
def _htf_alignment(
|
||||
*,
|
||||
local_state: MarketState,
|
||||
htf_state: MarketState,
|
||||
local_trend: TrendDirection,
|
||||
htf_trend: TrendDirection,
|
||||
) -> str:
|
||||
if htf_trend == TrendDirection.UNKNOWN:
|
||||
return "UNKNOWN"
|
||||
|
||||
if htf_trend == TrendDirection.FLAT:
|
||||
return "NEUTRAL"
|
||||
|
||||
if local_trend == TrendDirection.UP:
|
||||
return "ALIGNED" if htf_trend == TrendDirection.UP else "AGAINST"
|
||||
|
||||
if local_trend == TrendDirection.DOWN:
|
||||
return "ALIGNED" if htf_trend == TrendDirection.DOWN else "AGAINST"
|
||||
|
||||
if local_state == MarketState.TREND_UP:
|
||||
return "ALIGNED" if htf_trend == TrendDirection.UP else "AGAINST"
|
||||
|
||||
if local_state == MarketState.TREND_DOWN:
|
||||
return "ALIGNED" if htf_trend == TrendDirection.DOWN else "AGAINST"
|
||||
|
||||
return "NEUTRAL"
|
||||
|
||||
|
||||
def _htf_confirmation_score(
|
||||
*,
|
||||
alignment: str,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
trend_consistency: float | None,
|
||||
trend_efficiency: float | None,
|
||||
) -> float:
|
||||
if alignment == "AGAINST":
|
||||
return 0.0
|
||||
|
||||
if alignment == "UNKNOWN":
|
||||
return 0.5
|
||||
|
||||
if alignment == "NEUTRAL":
|
||||
return 0.55
|
||||
|
||||
score = 0.65
|
||||
|
||||
if trend_strength == TrendStrength.STRONG:
|
||||
score += 0.15
|
||||
elif trend_strength == TrendStrength.WEAK:
|
||||
score -= 0.2
|
||||
|
||||
if trend_quality == TrendQuality.CLEAN:
|
||||
score += 0.1
|
||||
elif trend_quality == TrendQuality.NOISY:
|
||||
score -= 0.2
|
||||
|
||||
if trend_consistency is not None:
|
||||
score += (trend_consistency - 0.5) * 0.2
|
||||
|
||||
if trend_efficiency is not None:
|
||||
score += (trend_efficiency - 0.3) * 0.15
|
||||
|
||||
return max(0.0, min(1.0, score))
|
||||
13
app/src/trading/market_analysis/indicators/__init__.py
Normal file
13
app/src/trading/market_analysis/indicators/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# app/src/trading/market_analysis/indicators/__init__.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.indicators.trend import ema
|
||||
from src.trading.market_analysis.indicators.volatility import atr
|
||||
from src.trading.market_analysis.indicators.momentum import rsi
|
||||
|
||||
__all__ = [
|
||||
"ema",
|
||||
"atr",
|
||||
"rsi",
|
||||
]
|
||||
218
app/src/trading/market_analysis/indicators/momentum.py
Normal file
218
app/src/trading/market_analysis/indicators/momentum.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# app/src/trading/market_analysis/indicators/momentum.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
)
|
||||
|
||||
|
||||
def rsi(values: list[float], period: int = 14) -> float | None:
|
||||
if period <= 0 or len(values) < period + 1:
|
||||
return None
|
||||
|
||||
gains: list[float] = []
|
||||
losses: list[float] = []
|
||||
|
||||
recent = values[-(period + 1):]
|
||||
|
||||
for previous, current in zip(recent, recent[1:]):
|
||||
change = current - previous
|
||||
|
||||
if change > 0:
|
||||
gains.append(change)
|
||||
losses.append(0.0)
|
||||
else:
|
||||
gains.append(0.0)
|
||||
losses.append(abs(change))
|
||||
|
||||
average_gain = sum(gains) / period
|
||||
average_loss = sum(losses) / period
|
||||
|
||||
if average_loss == 0:
|
||||
return 100.0
|
||||
|
||||
rs = average_gain / average_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def recent_change_percent(
|
||||
*,
|
||||
closes: list[float],
|
||||
window: int,
|
||||
) -> float | None:
|
||||
if window <= 0 or len(closes) < window + 1:
|
||||
return None
|
||||
|
||||
first_price = closes[-(window + 1)]
|
||||
last_price = closes[-1]
|
||||
|
||||
if first_price <= 0:
|
||||
return None
|
||||
|
||||
return ((last_price - first_price) / first_price) * 100
|
||||
|
||||
|
||||
def momentum_breakout_state(
|
||||
*,
|
||||
closes: list[float],
|
||||
momentum_window: int,
|
||||
momentum_decay_window: int,
|
||||
breakout_lookback: int,
|
||||
momentum_change_threshold_percent: float,
|
||||
momentum_decay_threshold_percent: float,
|
||||
breakout_distance_threshold_percent: float,
|
||||
) -> tuple[
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
float | None,
|
||||
float | None,
|
||||
float | None,
|
||||
float | None,
|
||||
str | None,
|
||||
]:
|
||||
if len(closes) < max(momentum_window + 1, breakout_lookback + 1):
|
||||
return (
|
||||
MomentumState.UNKNOWN,
|
||||
TrendDirection.UNKNOWN,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"NOT_ENOUGH_DATA",
|
||||
)
|
||||
|
||||
first_price = closes[-(momentum_window + 1)]
|
||||
last_price = closes[-1]
|
||||
|
||||
if first_price <= 0 or last_price <= 0:
|
||||
return (
|
||||
MomentumState.UNKNOWN,
|
||||
TrendDirection.UNKNOWN,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"INVALID_PRICE",
|
||||
)
|
||||
|
||||
momentum_change_percent = ((last_price - first_price) / first_price) * 100
|
||||
abs_change = abs(momentum_change_percent)
|
||||
|
||||
recent_change_value = recent_change_percent(
|
||||
closes=closes,
|
||||
window=momentum_decay_window,
|
||||
)
|
||||
|
||||
recent_abs_change = (
|
||||
abs(recent_change_value)
|
||||
if recent_change_value is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if (
|
||||
momentum_change_percent >= momentum_change_threshold_percent
|
||||
and recent_change_value is not None
|
||||
and recent_change_value > momentum_decay_threshold_percent
|
||||
):
|
||||
momentum_direction = TrendDirection.UP
|
||||
|
||||
elif (
|
||||
momentum_change_percent <= -momentum_change_threshold_percent
|
||||
and recent_change_value is not None
|
||||
and recent_change_value < -momentum_decay_threshold_percent
|
||||
):
|
||||
momentum_direction = TrendDirection.DOWN
|
||||
|
||||
else:
|
||||
momentum_direction = TrendDirection.FLAT
|
||||
|
||||
if momentum_direction == TrendDirection.FLAT:
|
||||
if recent_abs_change is not None:
|
||||
momentum_strength = min(
|
||||
recent_abs_change / momentum_decay_threshold_percent,
|
||||
3.0,
|
||||
)
|
||||
else:
|
||||
momentum_strength = 0.0
|
||||
else:
|
||||
momentum_strength = min(
|
||||
abs_change / momentum_change_threshold_percent,
|
||||
3.0,
|
||||
)
|
||||
|
||||
lookback_window = closes[-(breakout_lookback + 1):-1]
|
||||
previous_high = max(lookback_window)
|
||||
previous_low = min(lookback_window)
|
||||
|
||||
if previous_high <= 0 or previous_low <= 0:
|
||||
return (
|
||||
MomentumState.UNKNOWN,
|
||||
TrendDirection.UNKNOWN,
|
||||
momentum_change_percent,
|
||||
momentum_strength,
|
||||
None,
|
||||
None,
|
||||
"INVALID_BREAKOUT_LEVEL",
|
||||
)
|
||||
|
||||
if last_price > previous_high:
|
||||
breakout_distance_percent = ((last_price - previous_high) / previous_high) * 100
|
||||
|
||||
if breakout_distance_percent >= breakout_distance_threshold_percent:
|
||||
return (
|
||||
MomentumState.BREAKOUT_UP,
|
||||
TrendDirection.UP,
|
||||
momentum_change_percent,
|
||||
momentum_strength,
|
||||
previous_high,
|
||||
breakout_distance_percent,
|
||||
"PRICE_ABOVE_LOOKBACK_HIGH",
|
||||
)
|
||||
|
||||
if last_price < previous_low:
|
||||
breakout_distance_percent = ((previous_low - last_price) / previous_low) * 100
|
||||
|
||||
if breakout_distance_percent >= breakout_distance_threshold_percent:
|
||||
return (
|
||||
MomentumState.BREAKOUT_DOWN,
|
||||
TrendDirection.DOWN,
|
||||
momentum_change_percent,
|
||||
momentum_strength,
|
||||
previous_low,
|
||||
breakout_distance_percent,
|
||||
"PRICE_BELOW_LOOKBACK_LOW",
|
||||
)
|
||||
|
||||
if momentum_direction == TrendDirection.UP:
|
||||
return (
|
||||
MomentumState.MOMENTUM_UP,
|
||||
TrendDirection.UP,
|
||||
momentum_change_percent,
|
||||
momentum_strength,
|
||||
None,
|
||||
None,
|
||||
"FAST_UP_MOVE",
|
||||
)
|
||||
|
||||
if momentum_direction == TrendDirection.DOWN:
|
||||
return (
|
||||
MomentumState.MOMENTUM_DOWN,
|
||||
TrendDirection.DOWN,
|
||||
momentum_change_percent,
|
||||
momentum_strength,
|
||||
None,
|
||||
None,
|
||||
"FAST_DOWN_MOVE",
|
||||
)
|
||||
|
||||
return (
|
||||
MomentumState.NONE,
|
||||
TrendDirection.FLAT,
|
||||
momentum_change_percent,
|
||||
momentum_strength,
|
||||
None,
|
||||
None,
|
||||
"NO_SIGNIFICANT_MOMENTUM",
|
||||
)
|
||||
243
app/src/trading/market_analysis/indicators/trend.py
Normal file
243
app/src/trading/market_analysis/indicators/trend.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# app/src/trading/market_analysis/indicators/trend.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
)
|
||||
|
||||
|
||||
def ema(values: list[float], period: int) -> float | None:
|
||||
if period <= 0 or len(values) < period:
|
||||
return None
|
||||
|
||||
multiplier = 2 / (period + 1)
|
||||
current = sum(values[:period]) / period
|
||||
|
||||
for value in values[period:]:
|
||||
current = (value - current) * multiplier + current
|
||||
|
||||
return current
|
||||
|
||||
|
||||
def trend_gap_percent_value(
|
||||
*,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
) -> float | None:
|
||||
if ema_slow <= 0:
|
||||
return None
|
||||
|
||||
return ((ema_fast - ema_slow) / ema_slow) * 100
|
||||
|
||||
|
||||
def ema_slope_percent(
|
||||
*,
|
||||
closes: list[float],
|
||||
period: int,
|
||||
window: int,
|
||||
) -> float | None:
|
||||
required = period + window + 5
|
||||
|
||||
if len(closes) < required:
|
||||
return None
|
||||
|
||||
current_ema = ema(closes, period)
|
||||
previous_ema = ema(closes[:-window], period)
|
||||
|
||||
if (
|
||||
current_ema is None
|
||||
or previous_ema is None
|
||||
or previous_ema <= 0
|
||||
):
|
||||
return None
|
||||
|
||||
return ((current_ema - previous_ema) / previous_ema) * 100
|
||||
|
||||
|
||||
def classify_trend(
|
||||
*,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
ema_fast_slope_percent: float | None = None,
|
||||
ema_slow_slope_percent: float | None = None,
|
||||
fast_slope_threshold_percent: float,
|
||||
slow_slope_threshold_percent: float,
|
||||
trend_direction_gap_threshold_percent: float,
|
||||
) -> TrendDirection:
|
||||
gap_percent = trend_gap_percent_value(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
)
|
||||
|
||||
if gap_percent is None:
|
||||
return TrendDirection.UNKNOWN
|
||||
|
||||
fast_slope = ema_fast_slope_percent or 0.0
|
||||
slow_slope = ema_slow_slope_percent or 0.0
|
||||
|
||||
fast_up = fast_slope >= fast_slope_threshold_percent
|
||||
fast_down = fast_slope <= -fast_slope_threshold_percent
|
||||
|
||||
slow_up = slow_slope >= slow_slope_threshold_percent
|
||||
slow_down = slow_slope <= -slow_slope_threshold_percent
|
||||
|
||||
if gap_percent >= trend_direction_gap_threshold_percent:
|
||||
if fast_down and slow_down:
|
||||
return TrendDirection.FLAT
|
||||
return TrendDirection.UP
|
||||
|
||||
if gap_percent <= -trend_direction_gap_threshold_percent:
|
||||
if fast_up and slow_up:
|
||||
return TrendDirection.FLAT
|
||||
return TrendDirection.DOWN
|
||||
|
||||
if fast_up and slow_up:
|
||||
return TrendDirection.UP
|
||||
|
||||
if fast_down and slow_down:
|
||||
return TrendDirection.DOWN
|
||||
|
||||
return TrendDirection.FLAT
|
||||
|
||||
|
||||
def classify_trend_strength(
|
||||
*,
|
||||
trend_gap_percent: float | None,
|
||||
weak_threshold_percent: float,
|
||||
strong_threshold_percent: float,
|
||||
) -> TrendStrength:
|
||||
if trend_gap_percent is None:
|
||||
return TrendStrength.UNKNOWN
|
||||
|
||||
gap = abs(trend_gap_percent)
|
||||
|
||||
if gap < weak_threshold_percent:
|
||||
return TrendStrength.WEAK
|
||||
|
||||
if gap < strong_threshold_percent:
|
||||
return TrendStrength.NORMAL
|
||||
|
||||
return TrendStrength.STRONG
|
||||
|
||||
|
||||
def trend_consistency(
|
||||
*,
|
||||
closes: list[float],
|
||||
trend: TrendDirection,
|
||||
trend_consistency_window: int,
|
||||
) -> float | None:
|
||||
if len(closes) < 2:
|
||||
return None
|
||||
|
||||
window = closes[-trend_consistency_window:]
|
||||
|
||||
if len(window) < 2:
|
||||
return None
|
||||
|
||||
up_moves = 0
|
||||
down_moves = 0
|
||||
|
||||
for previous_price, current_price in zip(window, window[1:]):
|
||||
if current_price > previous_price:
|
||||
up_moves += 1
|
||||
elif current_price < previous_price:
|
||||
down_moves += 1
|
||||
|
||||
total_moves = max(1, len(window) - 1)
|
||||
|
||||
if trend == TrendDirection.UP:
|
||||
return up_moves / total_moves
|
||||
|
||||
if trend == TrendDirection.DOWN:
|
||||
return down_moves / total_moves
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def trend_efficiency(
|
||||
*,
|
||||
closes: list[float],
|
||||
trend_consistency_window: int,
|
||||
) -> float | None:
|
||||
window = closes[-trend_consistency_window:]
|
||||
|
||||
if len(window) < 2:
|
||||
return None
|
||||
|
||||
net_move = abs(window[-1] - window[0])
|
||||
total_move = 0.0
|
||||
|
||||
for previous_price, current_price in zip(window, window[1:]):
|
||||
total_move += abs(current_price - previous_price)
|
||||
|
||||
if total_move <= 0:
|
||||
return None
|
||||
|
||||
return net_move / total_move
|
||||
|
||||
|
||||
def ema_distance_atr_ratio(
|
||||
*,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
atr_value: float,
|
||||
) -> float | None:
|
||||
if atr_value <= 0:
|
||||
return None
|
||||
|
||||
return abs(ema_fast - ema_slow) / atr_value
|
||||
|
||||
|
||||
def classify_trend_quality(
|
||||
*,
|
||||
trend_consistency: float | None,
|
||||
trend_efficiency: float | None,
|
||||
ema_distance_atr_ratio: float | None,
|
||||
candle_noise_score: float | None,
|
||||
price_position_score: float | None,
|
||||
trend_strength: TrendStrength,
|
||||
min_clean_candle_score: float,
|
||||
min_price_position_score: float,
|
||||
) -> TrendQuality:
|
||||
if trend_consistency is None:
|
||||
return TrendQuality.UNKNOWN
|
||||
|
||||
if trend_strength == TrendStrength.WEAK:
|
||||
return TrendQuality.NOISY
|
||||
|
||||
if (
|
||||
candle_noise_score is not None
|
||||
and candle_noise_score < min_clean_candle_score
|
||||
):
|
||||
return TrendQuality.NOISY
|
||||
|
||||
if (
|
||||
price_position_score is not None
|
||||
and price_position_score < min_price_position_score
|
||||
):
|
||||
return TrendQuality.NOISY
|
||||
|
||||
if trend_efficiency is not None and trend_efficiency < 0.28:
|
||||
return TrendQuality.NOISY
|
||||
|
||||
# Сжатые EMA сами по себе не означают шум.
|
||||
# После флэта хороший вход часто начинается именно из сжатия.
|
||||
# Поэтому качество тренда не понижаем только из-за EMA compression.
|
||||
if (
|
||||
ema_distance_atr_ratio is not None
|
||||
and ema_distance_atr_ratio < 0.25
|
||||
and trend_efficiency is not None
|
||||
and trend_efficiency < 0.25
|
||||
):
|
||||
return TrendQuality.NOISY
|
||||
|
||||
if trend_consistency >= 0.68:
|
||||
return TrendQuality.CLEAN
|
||||
|
||||
if trend_consistency >= 0.55:
|
||||
return TrendQuality.NORMAL
|
||||
|
||||
return TrendQuality.NOISY
|
||||
129
app/src/trading/market_analysis/indicators/volatility.py
Normal file
129
app/src/trading/market_analysis/indicators/volatility.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# app/src/trading/market_analysis/indicators/volatility.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.models import Kline
|
||||
from src.trading.market_analysis.models import VolatilityState
|
||||
|
||||
|
||||
def atr(candles: list[Kline], period: int = 14) -> float | None:
|
||||
if period <= 0 or len(candles) < period + 1:
|
||||
return None
|
||||
|
||||
true_ranges: list[float] = []
|
||||
|
||||
for previous, current in zip(candles, candles[1:]):
|
||||
high_low = current.high_price - current.low_price
|
||||
high_close = abs(current.high_price - previous.close_price)
|
||||
low_close = abs(current.low_price - previous.close_price)
|
||||
|
||||
true_ranges.append(max(high_low, high_close, low_close))
|
||||
|
||||
if len(true_ranges) < period:
|
||||
return None
|
||||
|
||||
recent = true_ranges[-period:]
|
||||
return sum(recent) / period
|
||||
|
||||
|
||||
def atr_percent_baseline(
|
||||
*,
|
||||
candles: Sequence[Kline],
|
||||
close_price: float,
|
||||
atr_period: int,
|
||||
atr_baseline_window: int,
|
||||
) -> float | None:
|
||||
if close_price <= 0:
|
||||
return None
|
||||
|
||||
values: list[float] = []
|
||||
window: list[Kline] = list(candles[-atr_baseline_window:])
|
||||
|
||||
for index in range(atr_period, len(window) + 1):
|
||||
part: list[Kline] = window[:index]
|
||||
atr_value = atr(list(part), atr_period)
|
||||
|
||||
if atr_value is None:
|
||||
continue
|
||||
|
||||
close = getattr(part[-1], "close_price", None)
|
||||
|
||||
if close is None or close <= 0:
|
||||
continue
|
||||
|
||||
values.append((atr_value / close) * 100)
|
||||
|
||||
if not values:
|
||||
return None
|
||||
|
||||
values.sort()
|
||||
middle = len(values) // 2
|
||||
|
||||
if len(values) % 2 == 1:
|
||||
return values[middle]
|
||||
|
||||
return (values[middle - 1] + values[middle]) / 2
|
||||
|
||||
|
||||
def adaptive_threshold(
|
||||
*,
|
||||
atr_percent: NumericLike | None,
|
||||
multiplier: NumericLike,
|
||||
minimum: NumericLike,
|
||||
) -> float:
|
||||
atr_value = safe_float(atr_percent)
|
||||
multiplier_value = safe_float(multiplier)
|
||||
minimum_value = safe_float(minimum) or 0.0
|
||||
|
||||
if atr_value is None or atr_value <= 0 or multiplier_value is None:
|
||||
return minimum_value
|
||||
|
||||
return max(minimum_value, atr_value * multiplier_value)
|
||||
|
||||
|
||||
def classify_volatility(
|
||||
*,
|
||||
atr_percent: NumericLike,
|
||||
volatility_ratio: NumericLike | None,
|
||||
htf_volatility_ratio: NumericLike | None = None,
|
||||
low_volatility_atr_percent: NumericLike = 0.05,
|
||||
high_volatility_atr_percent: NumericLike = 1.8,
|
||||
) -> VolatilityState:
|
||||
atr_value = safe_float(atr_percent)
|
||||
|
||||
if atr_value is None or atr_value <= 0:
|
||||
return VolatilityState.UNKNOWN
|
||||
|
||||
local_ratio = safe_float(volatility_ratio)
|
||||
htf_ratio = safe_float(htf_volatility_ratio)
|
||||
|
||||
if htf_ratio is not None:
|
||||
if htf_ratio > 1.8 and (local_ratio is None or local_ratio > 1.1):
|
||||
return VolatilityState.HIGH
|
||||
|
||||
if htf_ratio < 0.55 and (local_ratio is None or local_ratio < 0.85):
|
||||
return VolatilityState.LOW
|
||||
|
||||
if local_ratio is None:
|
||||
low_value = safe_float(low_volatility_atr_percent) or 0.05
|
||||
high_value = safe_float(high_volatility_atr_percent) or 1.8
|
||||
|
||||
if atr_value < low_value:
|
||||
return VolatilityState.LOW
|
||||
|
||||
if atr_value > high_value:
|
||||
return VolatilityState.HIGH
|
||||
|
||||
return VolatilityState.NORMAL
|
||||
|
||||
if local_ratio < 0.55:
|
||||
return VolatilityState.LOW
|
||||
|
||||
if local_ratio > 1.8:
|
||||
return VolatilityState.HIGH
|
||||
|
||||
return VolatilityState.NORMAL
|
||||
25
app/src/trading/market_analysis/indicators/volume.py
Normal file
25
app/src/trading/market_analysis/indicators/volume.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# app/src/trading/market_analysis/indicators/volume.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def average_volume(values: list[float], period: int) -> float | None:
|
||||
if period <= 0 or len(values) < period:
|
||||
return None
|
||||
|
||||
recent = values[-period:]
|
||||
return sum(recent) / period
|
||||
|
||||
|
||||
def volume_ratio(
|
||||
*,
|
||||
current_volume: float | None,
|
||||
average_volume_value: float | None,
|
||||
) -> float | None:
|
||||
if current_volume is None or average_volume_value is None:
|
||||
return None
|
||||
|
||||
if average_volume_value <= 0:
|
||||
return None
|
||||
|
||||
return current_volume / average_volume_value
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/src/trading/market_analysis/models.py
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -17,6 +17,13 @@ class MarketState(StrEnum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class MarketStructure(StrEnum):
|
||||
HH_HL = "HH_HL"
|
||||
LH_LL = "LH_LL"
|
||||
MIXED = "MIXED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class TrendDirection(StrEnum):
|
||||
UP = "UP"
|
||||
DOWN = "DOWN"
|
||||
@@ -79,15 +86,51 @@ class EntryTimingState(StrEnum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HtfContext:
|
||||
# Старший таймфрейм.
|
||||
interval: str | None = None
|
||||
|
||||
# Состояние старшего рынка.
|
||||
market_state: MarketState | None = None
|
||||
trend: TrendDirection | None = None
|
||||
trend_strength: TrendStrength | None = None
|
||||
trend_quality: TrendQuality | None = None
|
||||
market_phase: MarketPhase | None = None
|
||||
|
||||
# Волатильность старшего таймфрейма.
|
||||
volatility: VolatilityState | None = None
|
||||
atr_percent: float | None = None
|
||||
atr_percent_baseline: float | None = None
|
||||
volatility_ratio: float | None = None
|
||||
|
||||
# Подтверждение локального направления старшим ТФ.
|
||||
alignment: str | None = None
|
||||
confirmation_score: float | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MarketAnalysisResult:
|
||||
# Основное
|
||||
symbol: str
|
||||
interval: str
|
||||
candles_count: int
|
||||
reason: str
|
||||
is_trade_allowed: bool
|
||||
payload: JsonDict
|
||||
|
||||
# Основное состояние рынка
|
||||
state: MarketState
|
||||
trend: TrendDirection
|
||||
volatility: VolatilityState
|
||||
trend_strength: TrendStrength
|
||||
trend_quality: TrendQuality
|
||||
market_phase: MarketPhase
|
||||
market_structure: MarketStructure
|
||||
market_structure_reason: str
|
||||
|
||||
# Базовые индикаторы
|
||||
close_price: float | None
|
||||
ema_fast: float | None
|
||||
ema_slow: float | None
|
||||
@@ -95,46 +138,66 @@ class MarketAnalysisResult:
|
||||
atr_percent: float | None
|
||||
rsi: float | None
|
||||
|
||||
candles_count: int
|
||||
reason: str
|
||||
is_trade_allowed: bool
|
||||
|
||||
payload: JsonDict
|
||||
|
||||
trend_strength: TrendStrength
|
||||
trend_quality: TrendQuality
|
||||
market_phase: MarketPhase
|
||||
|
||||
# Метрики тренда
|
||||
trend_gap_percent: float | None
|
||||
trend_consistency: float | None
|
||||
trend_efficiency: float | None
|
||||
trend_quality_score: float | None
|
||||
ema_distance_atr_ratio: float | None
|
||||
ema_fast_slope_percent: float | None
|
||||
ema_slow_slope_percent: float | None
|
||||
|
||||
# EMA distance / entry timing
|
||||
ema_distance_state: EmaDistanceState
|
||||
entry_timing_state: EntryTimingState
|
||||
entry_timing_reason: str | None
|
||||
|
||||
# Фаза рынка
|
||||
phase_direction: TrendDirection
|
||||
phase_change_percent: float | None
|
||||
phase_direction_consistency: float | None
|
||||
phase_reason: str | None
|
||||
|
||||
ema_fast_slope_percent: float | None = None
|
||||
ema_slow_slope_percent: float | None = None
|
||||
# Текущая свеча / интервал
|
||||
current_interval_change_percent: float | None
|
||||
current_interval_direction: TrendDirection
|
||||
current_interval_label: str
|
||||
|
||||
phase_direction_consistency: float | None = None
|
||||
# Momentum / Breakout
|
||||
momentum_state: MomentumState
|
||||
momentum_direction: TrendDirection
|
||||
momentum_change_percent: float | None
|
||||
momentum_strength: float | None
|
||||
breakout_level: float | None
|
||||
breakout_distance_percent: float | None
|
||||
breakout_reason: str | None
|
||||
|
||||
momentum_state: MomentumState | None = None
|
||||
momentum_direction: TrendDirection | None = None
|
||||
momentum_change_percent: float | None = None
|
||||
momentum_strength: float | None = None
|
||||
|
||||
breakout_level: float | None = None
|
||||
breakout_distance_percent: float | None = None
|
||||
breakout_reason: str | None = None
|
||||
# Старший таймфрейм.
|
||||
# Новый сгруппированный объект. Пока можно использовать параллельно
|
||||
# со старыми flat-полями ниже, чтобы не ломать result.py/snapshot.py сразу.
|
||||
htf: HtfContext | None = None
|
||||
|
||||
# Старые flat HTF-поля оставлены для совместимости.
|
||||
# Позже их можно удалить после перевода result.py/snapshot.py/formatter.py на htf.
|
||||
htf_interval: str | None = None
|
||||
htf_atr_percent: float | None = None
|
||||
htf_atr_percent_baseline: float | None = None
|
||||
htf_volatility_ratio: float | None = None
|
||||
htf_volatility: VolatilityState | None = None
|
||||
|
||||
trend_quality_score: float | None = None
|
||||
ema_distance_state: EmaDistanceState | None = None
|
||||
entry_timing_state: EntryTimingState | None = None
|
||||
entry_timing_reason: str | None = None
|
||||
htf_market_state: MarketState | None = None
|
||||
htf_trend: TrendDirection | None = None
|
||||
htf_trend_strength: TrendStrength | None = None
|
||||
htf_trend_quality: TrendQuality | None = None
|
||||
htf_market_phase: MarketPhase | None = None
|
||||
htf_alignment: str | None = None
|
||||
htf_confirmation_score: float | None = None
|
||||
htf_reason: str | None = None
|
||||
|
||||
# Общая оценка рынка 0..100.
|
||||
market_score: int | None = None
|
||||
market_score_label: str | None = None
|
||||
|
||||
# Направленные оценки входа 0..100.
|
||||
market_long_score: int | None = None
|
||||
market_short_score: int | None = None
|
||||
190
app/src/trading/market_analysis/payload.py
Normal file
190
app/src/trading/market_analysis/payload.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# app/src/trading/market_analysis/payload.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import get_value, safe_round
|
||||
from src.core.types import JsonDict
|
||||
from src.trading.market_analysis.models import (
|
||||
EmaDistanceState,
|
||||
EntryTimingState,
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
MarketStructure,
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def build_market_analysis_payload(
|
||||
*,
|
||||
symbol: str,
|
||||
interval: str,
|
||||
state: MarketState,
|
||||
trend: TrendDirection,
|
||||
volatility: VolatilityState,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
market_phase: MarketPhase,
|
||||
phase_direction: TrendDirection,
|
||||
phase_change_percent: float | None,
|
||||
phase_direction_consistency: float | None,
|
||||
current_interval_change_percent: float | None,
|
||||
current_interval_direction: TrendDirection,
|
||||
current_interval_label: str,
|
||||
phase_reason: str | None,
|
||||
market_structure: MarketStructure,
|
||||
market_structure_reason: str | None,
|
||||
momentum_state: MomentumState,
|
||||
momentum_direction: TrendDirection,
|
||||
momentum_change_percent: float | None,
|
||||
momentum_strength: float | None,
|
||||
breakout_level: float | None,
|
||||
breakout_distance_percent: float | None,
|
||||
breakout_reason: str | None,
|
||||
trend_gap_percent: float | None,
|
||||
ema_fast_slope_percent: float | None,
|
||||
ema_slow_slope_percent: float | None,
|
||||
trend_consistency: float | None,
|
||||
trend_efficiency: float | None,
|
||||
trend_quality_score_value: float | None,
|
||||
ema_distance_atr_ratio: float | None,
|
||||
ema_distance_state: EmaDistanceState,
|
||||
entry_timing_state: EntryTimingState,
|
||||
entry_timing_reason: str | None,
|
||||
candle_noise_score: float | None,
|
||||
price_position_score: float | None,
|
||||
close_price: float,
|
||||
ema_fast_period: int,
|
||||
ema_slow_period: int,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
atr_period: int,
|
||||
atr_value: float,
|
||||
atr_percent: float,
|
||||
atr_percent_baseline: float | None,
|
||||
volatility_ratio: float | None,
|
||||
rsi_period: int,
|
||||
rsi_value: float | None,
|
||||
rsi_overbought: float,
|
||||
rsi_oversold: float,
|
||||
candles_count: int,
|
||||
is_trade_allowed: bool,
|
||||
htf_context: JsonDict | None,
|
||||
htf_trend_context: JsonDict | None,
|
||||
market_score: int | None = None,
|
||||
market_score_label: str | None = None,
|
||||
market_long_score: int | None = None,
|
||||
market_short_score: int | None = None,
|
||||
last_closed_candle_change_percent: float | None = None,
|
||||
last_closed_candle_direction: TrendDirection | None = None,
|
||||
) -> JsonDict:
|
||||
# HTF-контексты могут быть пустыми, если старший ТФ временно недоступен.
|
||||
htf_context = htf_context or {}
|
||||
htf_trend_context = htf_trend_context or {}
|
||||
|
||||
return {
|
||||
# ---------- Base ----------
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"candles_count": candles_count,
|
||||
"is_trade_allowed": is_trade_allowed,
|
||||
|
||||
# ---------- Market ----------
|
||||
"market_state": get_value(state),
|
||||
"market_score": market_score,
|
||||
"market_score_label": market_score_label,
|
||||
"market_long_score": market_long_score,
|
||||
"market_short_score": market_short_score,
|
||||
|
||||
# ---------- Trend ----------
|
||||
"trend": get_value(trend),
|
||||
"market_trend_strength": get_value(trend_strength),
|
||||
"market_trend_quality": get_value(trend_quality),
|
||||
"market_trend_gap_percent": safe_round(trend_gap_percent, 5),
|
||||
"market_trend_consistency": safe_round(trend_consistency, 3),
|
||||
"market_trend_efficiency": safe_round(trend_efficiency, 3),
|
||||
"trend_quality_score": safe_round(trend_quality_score_value, 3),
|
||||
|
||||
# ---------- Volatility ----------
|
||||
"volatility": get_value(volatility),
|
||||
"volatility_ratio": safe_round(volatility_ratio, 4),
|
||||
|
||||
# ---------- Phase ----------
|
||||
"market_phase": get_value(market_phase),
|
||||
"market_phase_direction": get_value(phase_direction),
|
||||
"market_phase_change_percent": safe_round(phase_change_percent, 5),
|
||||
"market_phase_direction_consistency": safe_round(phase_direction_consistency, 3),
|
||||
"market_phase_reason": phase_reason,
|
||||
|
||||
# ---------- Current / Last Candle ----------
|
||||
"current_interval_change_percent": safe_round(current_interval_change_percent, 5),
|
||||
"current_interval_direction": get_value(current_interval_direction),
|
||||
"current_interval_label": current_interval_label,
|
||||
"last_closed_candle_change_percent": safe_round(last_closed_candle_change_percent, 5),
|
||||
"last_closed_candle_direction": get_value(last_closed_candle_direction),
|
||||
|
||||
# ---------- Structure ----------
|
||||
"market_structure": get_value(market_structure),
|
||||
"market_structure_reason": market_structure_reason,
|
||||
|
||||
# ---------- Momentum / Breakout ----------
|
||||
"momentum_state": get_value(momentum_state),
|
||||
"momentum_direction": get_value(momentum_direction),
|
||||
"momentum_change_percent": safe_round(momentum_change_percent, 5),
|
||||
"momentum_strength": safe_round(momentum_strength, 3),
|
||||
"breakout_level": breakout_level,
|
||||
"breakout_distance_percent": safe_round(breakout_distance_percent, 5),
|
||||
"breakout_reason": breakout_reason,
|
||||
|
||||
# ---------- EMA ----------
|
||||
"ema_fast_period": ema_fast_period,
|
||||
"ema_slow_period": ema_slow_period,
|
||||
"ema_fast": safe_round(ema_fast, 8),
|
||||
"ema_slow": safe_round(ema_slow, 8),
|
||||
"ema_fast_slope_percent": safe_round(ema_fast_slope_percent, 5),
|
||||
"ema_slow_slope_percent": safe_round(ema_slow_slope_percent, 5),
|
||||
"ema_distance_atr_ratio": safe_round(ema_distance_atr_ratio, 3),
|
||||
"ema_distance_state": get_value(ema_distance_state),
|
||||
|
||||
# ---------- Entry Timing ----------
|
||||
"entry_timing_state": get_value(entry_timing_state),
|
||||
"entry_timing_reason": entry_timing_reason,
|
||||
|
||||
# ---------- Candle / Price Quality ----------
|
||||
"candle_noise_score": safe_round(candle_noise_score, 3),
|
||||
"price_position_score": safe_round(price_position_score, 3),
|
||||
"close_price": safe_round(close_price, 8),
|
||||
|
||||
# ---------- ATR ----------
|
||||
"atr_period": atr_period,
|
||||
"atr": safe_round(atr_value, 8),
|
||||
"atr_percent": safe_round(atr_percent, 4),
|
||||
"atr_percent_baseline": safe_round(atr_percent_baseline, 4),
|
||||
|
||||
# ---------- RSI ----------
|
||||
"rsi_period": rsi_period,
|
||||
"rsi": safe_round(rsi_value, 2),
|
||||
"rsi_overbought": rsi_overbought,
|
||||
"rsi_oversold": rsi_oversold,
|
||||
|
||||
# ---------- HTF Volatility ----------
|
||||
"htf_interval": htf_context.get("htf_interval"),
|
||||
"htf_atr_percent": htf_context.get("htf_atr_percent"),
|
||||
"htf_atr_percent_baseline": htf_context.get("htf_atr_percent_baseline"),
|
||||
"htf_volatility_ratio": htf_context.get("htf_volatility_ratio"),
|
||||
"htf_volatility": htf_context.get("htf_volatility"),
|
||||
"htf_volatility_reason": htf_context.get("htf_reason"),
|
||||
|
||||
# ---------- HTF Trend ----------
|
||||
"htf_market_state": htf_trend_context.get("htf_market_state"),
|
||||
"htf_trend": htf_trend_context.get("htf_trend"),
|
||||
"htf_trend_strength": htf_trend_context.get("htf_trend_strength"),
|
||||
"htf_trend_quality": htf_trend_context.get("htf_trend_quality"),
|
||||
"htf_market_phase": htf_trend_context.get("htf_market_phase"),
|
||||
"htf_alignment": htf_trend_context.get("htf_alignment"),
|
||||
"htf_confirmation_score": htf_trend_context.get("htf_confirmation_score"),
|
||||
"htf_reason": htf_trend_context.get("htf_reason"),
|
||||
}
|
||||
139
app/src/trading/market_analysis/phase.py
Normal file
139
app/src/trading/market_analysis/phase.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# app/src/trading/market_analysis/phase.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketPhase,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def classify_phase_direction(
|
||||
change_percent: float | None,
|
||||
*,
|
||||
threshold_percent: float,
|
||||
) -> TrendDirection:
|
||||
if change_percent is None:
|
||||
return TrendDirection.UNKNOWN
|
||||
|
||||
if change_percent >= threshold_percent:
|
||||
return TrendDirection.UP
|
||||
|
||||
if change_percent <= -threshold_percent:
|
||||
return TrendDirection.DOWN
|
||||
|
||||
return TrendDirection.FLAT
|
||||
|
||||
|
||||
def phase_direction_consistency(
|
||||
*,
|
||||
closes: list[float],
|
||||
phase_direction: TrendDirection,
|
||||
phase_window: int,
|
||||
) -> float | None:
|
||||
window = closes[-(phase_window + 1):]
|
||||
|
||||
if len(window) < 2:
|
||||
return None
|
||||
|
||||
up_moves = 0
|
||||
down_moves = 0
|
||||
|
||||
for previous_price, current_price in zip(window, window[1:]):
|
||||
if current_price > previous_price:
|
||||
up_moves += 1
|
||||
elif current_price < previous_price:
|
||||
down_moves += 1
|
||||
|
||||
total_moves = max(1, len(window) - 1)
|
||||
|
||||
if phase_direction == TrendDirection.UP:
|
||||
return up_moves / total_moves
|
||||
|
||||
if phase_direction == TrendDirection.DOWN:
|
||||
return down_moves / total_moves
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_counter_trend_move(
|
||||
*,
|
||||
trend: TrendDirection,
|
||||
phase_direction: TrendDirection,
|
||||
) -> bool:
|
||||
if trend == TrendDirection.UP:
|
||||
return phase_direction == TrendDirection.DOWN
|
||||
|
||||
if trend == TrendDirection.DOWN:
|
||||
return phase_direction == TrendDirection.UP
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def classify_market_phase(
|
||||
*,
|
||||
trend: TrendDirection,
|
||||
volatility: VolatilityState,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
rsi_value: float | None,
|
||||
phase_direction: TrendDirection,
|
||||
phase_change_percent: float | None,
|
||||
phase_direction_consistency: float | None,
|
||||
pullback_min_change_percent: float,
|
||||
pullback_min_direction_consistency: float,
|
||||
) -> tuple[MarketPhase, str]:
|
||||
if volatility == VolatilityState.LOW:
|
||||
return MarketPhase.SQUEEZE, "LOW_VOLATILITY_SQUEEZE"
|
||||
|
||||
if trend == TrendDirection.FLAT:
|
||||
return MarketPhase.RANGE, "FLAT_TREND_RANGE"
|
||||
|
||||
if trend not in {TrendDirection.UP, TrendDirection.DOWN}:
|
||||
return MarketPhase.UNKNOWN, "UNKNOWN_TREND"
|
||||
|
||||
if trend_strength == TrendStrength.WEAK:
|
||||
return MarketPhase.RANGE, "WEAK_TREND_RANGE"
|
||||
|
||||
if is_counter_trend_move(
|
||||
trend=trend,
|
||||
phase_direction=phase_direction,
|
||||
):
|
||||
if (
|
||||
phase_change_percent is not None
|
||||
and abs(phase_change_percent) >= pullback_min_change_percent
|
||||
and phase_direction_consistency is not None
|
||||
and phase_direction_consistency >= pullback_min_direction_consistency
|
||||
):
|
||||
return MarketPhase.PULLBACK, "COUNTER_TREND_MOVE_CONFIRMED"
|
||||
|
||||
return MarketPhase.RANGE, "COUNTER_TREND_MOVE_TOO_WEAK"
|
||||
|
||||
if (
|
||||
trend == TrendDirection.UP
|
||||
and rsi_value is not None
|
||||
and rsi_value < 45
|
||||
and phase_direction == TrendDirection.DOWN
|
||||
and phase_change_percent is not None
|
||||
and abs(phase_change_percent) >= pullback_min_change_percent
|
||||
and phase_direction_consistency is not None
|
||||
and phase_direction_consistency >= pullback_min_direction_consistency
|
||||
):
|
||||
return MarketPhase.PULLBACK, "UPTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE"
|
||||
|
||||
if (
|
||||
trend == TrendDirection.DOWN
|
||||
and rsi_value is not None
|
||||
and rsi_value > 55
|
||||
and phase_direction == TrendDirection.UP
|
||||
and phase_change_percent is not None
|
||||
and abs(phase_change_percent) >= pullback_min_change_percent
|
||||
and phase_direction_consistency is not None
|
||||
and phase_direction_consistency >= pullback_min_direction_consistency
|
||||
):
|
||||
return MarketPhase.PULLBACK, "DOWNTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE"
|
||||
|
||||
return MarketPhase.IMPULSE, "WITH_TREND_OR_NEUTRAL_MOVE"
|
||||
81
app/src/trading/market_analysis/quality.py
Normal file
81
app/src/trading/market_analysis/quality.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# app/src/trading/market_analysis/quality.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from src.integrations.exchange.models import Kline
|
||||
from src.trading.market_analysis.models import TrendDirection
|
||||
|
||||
|
||||
def candle_noise_score(
|
||||
candles: Sequence[Kline],
|
||||
*,
|
||||
candle_noise_window: int,
|
||||
min_clean_body_ratio: float,
|
||||
) -> float | None:
|
||||
window = candles[-candle_noise_window:]
|
||||
|
||||
if not window:
|
||||
return None
|
||||
|
||||
clean_count = 0
|
||||
total_count = 0
|
||||
|
||||
for candle in window:
|
||||
high = getattr(candle, "high_price", None)
|
||||
low = getattr(candle, "low_price", None)
|
||||
open_price = getattr(candle, "open_price", None)
|
||||
close_price = getattr(candle, "close_price", None)
|
||||
|
||||
if (
|
||||
high is None
|
||||
or low is None
|
||||
or open_price is None
|
||||
or close_price is None
|
||||
or high <= low
|
||||
):
|
||||
continue
|
||||
|
||||
candle_range = high - low
|
||||
body = abs(close_price - open_price)
|
||||
body_ratio = body / candle_range
|
||||
|
||||
total_count += 1
|
||||
|
||||
if body_ratio >= min_clean_body_ratio:
|
||||
clean_count += 1
|
||||
|
||||
if total_count == 0:
|
||||
return None
|
||||
|
||||
return clean_count / total_count
|
||||
|
||||
|
||||
def price_position_score(
|
||||
*,
|
||||
closes: list[float],
|
||||
ema_fast: float,
|
||||
trend: TrendDirection,
|
||||
price_position_window: int,
|
||||
) -> float | None:
|
||||
window = closes[-price_position_window:]
|
||||
|
||||
if not window:
|
||||
return None
|
||||
|
||||
valid_count = 0
|
||||
|
||||
for close_price in window:
|
||||
if trend == TrendDirection.UP:
|
||||
if close_price > ema_fast:
|
||||
valid_count += 1
|
||||
|
||||
elif trend == TrendDirection.DOWN:
|
||||
if close_price < ema_fast:
|
||||
valid_count += 1
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
return valid_count / len(window)
|
||||
135
app/src/trading/market_analysis/reason.py
Normal file
135
app/src/trading/market_analysis/reason.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# app/src/trading/market_analysis/reason.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
EmaDistanceState,
|
||||
EntryTimingState,
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
MomentumState,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def build_market_reason(
|
||||
*,
|
||||
state: MarketState,
|
||||
volatility: VolatilityState,
|
||||
atr_percent: float,
|
||||
rsi_value: float | None,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
market_phase: MarketPhase,
|
||||
momentum_state: MomentumState,
|
||||
candle_noise_score: float | None,
|
||||
price_position_score: float | None,
|
||||
ema_distance_state: EmaDistanceState,
|
||||
entry_timing_state: EntryTimingState,
|
||||
min_clean_candle_score: float,
|
||||
min_price_position_score: float,
|
||||
rsi_overbought: float = 72.0,
|
||||
rsi_oversold: float = 28.0,
|
||||
) -> str:
|
||||
reasons: list[str] = []
|
||||
|
||||
def add(text: str) -> None:
|
||||
# Не даём одинаковым причинам дублироваться в итоговой строке.
|
||||
if text and text not in reasons:
|
||||
reasons.append(text)
|
||||
|
||||
if state == MarketState.TREND_UP:
|
||||
add("Рынок растёт")
|
||||
elif state == MarketState.TREND_DOWN:
|
||||
add("Рынок снижается")
|
||||
elif state == MarketState.RANGE:
|
||||
add("Рынок во флэте")
|
||||
elif state == MarketState.HIGH_VOLATILITY:
|
||||
add("Рынок слишком волатилен")
|
||||
elif state == MarketState.LOW_VOLATILITY:
|
||||
add("Рынок малоподвижен")
|
||||
else:
|
||||
add("Состояние рынка не определено")
|
||||
|
||||
if trend_strength == TrendStrength.STRONG:
|
||||
add("Сильный тренд")
|
||||
elif trend_strength == TrendStrength.NORMAL:
|
||||
add("Нормальный тренд")
|
||||
elif trend_strength == TrendStrength.WEAK:
|
||||
add("Слабый тренд")
|
||||
|
||||
if trend_quality == TrendQuality.CLEAN:
|
||||
add("Движение чистое")
|
||||
elif trend_quality == TrendQuality.NORMAL:
|
||||
add("Нормальное качество тренда")
|
||||
elif trend_quality == TrendQuality.NOISY:
|
||||
add("Движение шумное")
|
||||
|
||||
if market_phase == MarketPhase.IMPULSE:
|
||||
add("Фаза импульса")
|
||||
elif market_phase == MarketPhase.PULLBACK:
|
||||
add("Фаза отката")
|
||||
elif market_phase == MarketPhase.RANGE:
|
||||
add("Фаза флэта")
|
||||
elif market_phase == MarketPhase.SQUEEZE:
|
||||
add("Фаза сжатия")
|
||||
|
||||
if momentum_state == MomentumState.BREAKOUT_UP:
|
||||
add("Пробой вверх")
|
||||
elif momentum_state == MomentumState.BREAKOUT_DOWN:
|
||||
add("Пробой вниз")
|
||||
elif momentum_state == MomentumState.MOMENTUM_UP:
|
||||
add("Импульс вверх")
|
||||
elif momentum_state == MomentumState.MOMENTUM_DOWN:
|
||||
add("Импульс вниз")
|
||||
elif momentum_state == MomentumState.NONE:
|
||||
add("Сильного импульса нет")
|
||||
|
||||
if ema_distance_state == EmaDistanceState.COMPRESSED:
|
||||
add("EMA сильно сжаты")
|
||||
elif ema_distance_state == EmaDistanceState.HEALTHY:
|
||||
add("EMA-дистанция здоровая")
|
||||
elif ema_distance_state == EmaDistanceState.EXTENDED:
|
||||
add("Тренд расширен")
|
||||
elif ema_distance_state == EmaDistanceState.OVEREXTENDED:
|
||||
add("Тренд перерастянут")
|
||||
|
||||
if entry_timing_state == EntryTimingState.EARLY:
|
||||
add("Ранняя зона входа")
|
||||
elif entry_timing_state == EntryTimingState.NORMAL:
|
||||
add("Тайминг входа нормальный")
|
||||
elif entry_timing_state == EntryTimingState.LATE:
|
||||
add("Поздний вход")
|
||||
elif entry_timing_state == EntryTimingState.CHASING:
|
||||
add("Вход запрещён: chasing move")
|
||||
|
||||
if rsi_value is not None:
|
||||
if rsi_value >= rsi_overbought:
|
||||
add("RSI в зоне перекупленности")
|
||||
elif rsi_value <= rsi_oversold:
|
||||
add("RSI в зоне перепроданности")
|
||||
|
||||
if candle_noise_score is not None and candle_noise_score < min_clean_candle_score:
|
||||
add("Свечи шумные")
|
||||
|
||||
if price_position_score is not None:
|
||||
if price_position_score >= min_price_position_score:
|
||||
add("Цена держится по тренду")
|
||||
else:
|
||||
add("Цена плохо держится по тренду")
|
||||
|
||||
if volatility == VolatilityState.HIGH:
|
||||
add("Высокая волатильность")
|
||||
elif volatility == VolatilityState.LOW:
|
||||
add("Низкая волатильность")
|
||||
elif volatility == VolatilityState.NORMAL:
|
||||
add("Нормальная волатильность")
|
||||
|
||||
if not reasons:
|
||||
add("Рынок анализируется")
|
||||
|
||||
rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else ""
|
||||
|
||||
return f"{'. '.join(reasons)}. ATR={atr_percent:.2f}%{rsi_text}."
|
||||
178
app/src/trading/market_analysis/result.py
Normal file
178
app/src/trading/market_analysis/result.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# app/src/trading/market_analysis/result.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict
|
||||
from src.trading.market_analysis.htf import (
|
||||
safe_market_phase,
|
||||
safe_market_state,
|
||||
safe_trend_direction,
|
||||
safe_trend_quality,
|
||||
safe_trend_strength,
|
||||
safe_volatility_state,
|
||||
)
|
||||
from src.trading.market_analysis.models import (
|
||||
EmaDistanceState,
|
||||
EntryTimingState,
|
||||
MarketAnalysisResult,
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
MarketStructure,
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def build_market_analysis_result(
|
||||
*,
|
||||
symbol: str,
|
||||
interval: str,
|
||||
state: MarketState,
|
||||
trend: TrendDirection,
|
||||
volatility: VolatilityState,
|
||||
close_price: float,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
atr_value: float,
|
||||
atr_percent: float,
|
||||
rsi_value: float | None,
|
||||
candles_count: int,
|
||||
reason: str,
|
||||
is_trade_allowed: bool,
|
||||
payload: JsonDict,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
market_phase: MarketPhase,
|
||||
market_structure: MarketStructure,
|
||||
market_structure_reason: str,
|
||||
trend_gap_percent: float | None,
|
||||
trend_consistency: float | None,
|
||||
trend_efficiency: float | None,
|
||||
ema_distance_atr_ratio: float | None,
|
||||
phase_direction: TrendDirection,
|
||||
phase_change_percent: float | None,
|
||||
phase_reason: str | None,
|
||||
ema_fast_slope_percent: float | None,
|
||||
ema_slow_slope_percent: float | None,
|
||||
phase_direction_consistency: float | None,
|
||||
current_interval_change_percent: float | None,
|
||||
current_interval_direction: TrendDirection,
|
||||
current_interval_label: str,
|
||||
momentum_state: MomentumState,
|
||||
momentum_direction: TrendDirection,
|
||||
momentum_change_percent: float | None,
|
||||
momentum_strength: float | None,
|
||||
breakout_level: float | None,
|
||||
breakout_distance_percent: float | None,
|
||||
breakout_reason: str | None,
|
||||
trend_quality_score_value: float | None,
|
||||
ema_distance_state: EmaDistanceState,
|
||||
entry_timing_state: EntryTimingState,
|
||||
entry_timing_reason: str | None,
|
||||
htf_interval: str,
|
||||
htf_context: JsonDict | None,
|
||||
htf_trend_context: JsonDict | None,
|
||||
market_score: int | None = None,
|
||||
market_score_label: str | None = None,
|
||||
market_long_score: int | None = None,
|
||||
market_short_score: int | None = None,
|
||||
) -> MarketAnalysisResult:
|
||||
# HTF-контексты могут быть пустыми/None, если анализ старшего ТФ
|
||||
# не выполнился или вернул fallback. Защищаем .get(...) ниже.
|
||||
htf_context = htf_context or {}
|
||||
htf_trend_context = htf_trend_context or {}
|
||||
|
||||
return MarketAnalysisResult(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
state=state,
|
||||
trend=trend,
|
||||
volatility=volatility,
|
||||
close_price=close_price,
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
atr=atr_value,
|
||||
atr_percent=atr_percent,
|
||||
rsi=rsi_value,
|
||||
candles_count=candles_count,
|
||||
reason=reason,
|
||||
is_trade_allowed=is_trade_allowed,
|
||||
payload=payload,
|
||||
trend_strength=trend_strength,
|
||||
trend_quality=trend_quality,
|
||||
market_phase=market_phase,
|
||||
market_structure=market_structure,
|
||||
market_structure_reason=market_structure_reason,
|
||||
trend_gap_percent=trend_gap_percent,
|
||||
trend_consistency=trend_consistency,
|
||||
trend_efficiency=trend_efficiency,
|
||||
ema_distance_atr_ratio=ema_distance_atr_ratio,
|
||||
phase_direction=phase_direction,
|
||||
phase_change_percent=phase_change_percent,
|
||||
phase_reason=phase_reason,
|
||||
ema_fast_slope_percent=ema_fast_slope_percent,
|
||||
ema_slow_slope_percent=ema_slow_slope_percent,
|
||||
phase_direction_consistency=phase_direction_consistency,
|
||||
current_interval_change_percent=current_interval_change_percent,
|
||||
current_interval_direction=current_interval_direction,
|
||||
current_interval_label=current_interval_label,
|
||||
momentum_state=momentum_state,
|
||||
momentum_direction=momentum_direction,
|
||||
momentum_change_percent=momentum_change_percent,
|
||||
momentum_strength=momentum_strength,
|
||||
breakout_level=breakout_level,
|
||||
breakout_distance_percent=breakout_distance_percent,
|
||||
breakout_reason=breakout_reason,
|
||||
|
||||
# HTF volatility context.
|
||||
htf_interval=str(htf_context.get("htf_interval") or htf_interval),
|
||||
htf_atr_percent=safe_float(htf_context.get("htf_atr_percent")),
|
||||
htf_atr_percent_baseline=safe_float(
|
||||
htf_context.get("htf_atr_percent_baseline")
|
||||
),
|
||||
htf_volatility_ratio=safe_float(
|
||||
htf_context.get("htf_volatility_ratio")
|
||||
),
|
||||
htf_volatility=safe_volatility_state(
|
||||
htf_context.get("htf_volatility")
|
||||
),
|
||||
|
||||
# Advanced trend quality.
|
||||
trend_quality_score=trend_quality_score_value,
|
||||
ema_distance_state=ema_distance_state,
|
||||
entry_timing_state=entry_timing_state,
|
||||
entry_timing_reason=entry_timing_reason,
|
||||
|
||||
# HTF trend context. Используем safe_* функции,
|
||||
# чтобы неожиданные значения не ломали диагностику.
|
||||
htf_market_state=safe_market_state(
|
||||
htf_trend_context.get("htf_market_state")
|
||||
),
|
||||
htf_trend=safe_trend_direction(
|
||||
htf_trend_context.get("htf_trend")
|
||||
),
|
||||
htf_trend_strength=safe_trend_strength(
|
||||
htf_trend_context.get("htf_trend_strength")
|
||||
),
|
||||
htf_trend_quality=safe_trend_quality(
|
||||
htf_trend_context.get("htf_trend_quality")
|
||||
),
|
||||
htf_market_phase=safe_market_phase(
|
||||
htf_trend_context.get("htf_market_phase")
|
||||
),
|
||||
htf_alignment=str(htf_trend_context.get("htf_alignment") or ""),
|
||||
htf_confirmation_score=safe_float(
|
||||
htf_trend_context.get("htf_confirmation_score")
|
||||
),
|
||||
htf_reason=str(htf_trend_context.get("htf_reason") or ""),
|
||||
|
||||
# Общая оценка рынка для UI/diagnostics/adaptive sizing.
|
||||
market_score=market_score,
|
||||
market_score_label=market_score_label,
|
||||
market_long_score=market_long_score,
|
||||
market_short_score=market_short_score,
|
||||
)
|
||||
89
app/src/trading/market_analysis/scoring.py
Normal file
89
app/src/trading/market_analysis/scoring.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# app/src/trading/market_analysis/scoring.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
EmaDistanceState,
|
||||
EntryTimingState,
|
||||
MarketPhase,
|
||||
MomentumState,
|
||||
)
|
||||
|
||||
|
||||
def trend_quality_score(
|
||||
*,
|
||||
trend_consistency: float | None,
|
||||
trend_efficiency: float | None,
|
||||
candle_noise_score: float | None,
|
||||
price_position_score: float | None,
|
||||
) -> float | None:
|
||||
values: list[float] = []
|
||||
|
||||
if trend_consistency is not None:
|
||||
values.append(trend_consistency)
|
||||
|
||||
if trend_efficiency is not None:
|
||||
values.append(trend_efficiency)
|
||||
|
||||
if candle_noise_score is not None:
|
||||
values.append(candle_noise_score)
|
||||
|
||||
if price_position_score is not None:
|
||||
values.append(price_position_score)
|
||||
|
||||
if not values:
|
||||
return None
|
||||
|
||||
return sum(values) / len(values)
|
||||
|
||||
|
||||
def classify_ema_distance_state(
|
||||
ema_distance_atr_ratio: float | None,
|
||||
) -> EmaDistanceState:
|
||||
if ema_distance_atr_ratio is None:
|
||||
return EmaDistanceState.UNKNOWN
|
||||
|
||||
if ema_distance_atr_ratio < 0.30:
|
||||
return EmaDistanceState.COMPRESSED
|
||||
|
||||
if ema_distance_atr_ratio < 1.8:
|
||||
return EmaDistanceState.HEALTHY
|
||||
|
||||
if ema_distance_atr_ratio < 2.8:
|
||||
return EmaDistanceState.EXTENDED
|
||||
|
||||
return EmaDistanceState.OVEREXTENDED
|
||||
|
||||
|
||||
def classify_entry_timing(
|
||||
*,
|
||||
ema_distance_state: EmaDistanceState,
|
||||
momentum_state: MomentumState,
|
||||
momentum_strength: float | None,
|
||||
market_phase: MarketPhase,
|
||||
) -> tuple[EntryTimingState, str]:
|
||||
strength = momentum_strength or 0.0
|
||||
|
||||
if ema_distance_state == EmaDistanceState.OVEREXTENDED:
|
||||
return EntryTimingState.CHASING, "EMA_OVEREXTENDED"
|
||||
|
||||
if (
|
||||
ema_distance_state == EmaDistanceState.EXTENDED
|
||||
and momentum_state in {
|
||||
MomentumState.BREAKOUT_UP,
|
||||
MomentumState.BREAKOUT_DOWN,
|
||||
}
|
||||
and strength >= 1.5
|
||||
):
|
||||
return EntryTimingState.LATE, "BREAKOUT_ALREADY_EXTENDED"
|
||||
|
||||
if market_phase == MarketPhase.PULLBACK:
|
||||
return EntryTimingState.EARLY, "PULLBACK_ENTRY_ZONE"
|
||||
|
||||
if ema_distance_state == EmaDistanceState.HEALTHY:
|
||||
return EntryTimingState.NORMAL, "HEALTHY_TREND_DISTANCE"
|
||||
|
||||
if ema_distance_state == EmaDistanceState.COMPRESSED:
|
||||
return EntryTimingState.UNKNOWN, "EMA_COMPRESSED"
|
||||
|
||||
return EntryTimingState.UNKNOWN, "ENTRY_TIMING_UNKNOWN"
|
||||
File diff suppressed because it is too large
Load Diff
104
app/src/trading/market_analysis/state.py
Normal file
104
app/src/trading/market_analysis/state.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# app/src/trading/market_analysis/state.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def classify_market_state(
|
||||
*,
|
||||
trend: TrendDirection,
|
||||
volatility: VolatilityState,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
market_phase: MarketPhase,
|
||||
momentum_state: MomentumState,
|
||||
momentum_direction: TrendDirection,
|
||||
ema_fast_slope_percent: float | None,
|
||||
ema_slow_slope_percent: float | None,
|
||||
fast_slope_threshold_percent: float,
|
||||
slow_slope_threshold_percent: float,
|
||||
candle_noise_score: float | None,
|
||||
price_position_score: float | None,
|
||||
min_clean_candle_score: float,
|
||||
min_price_position_score: float,
|
||||
) -> MarketState:
|
||||
fast_slope = ema_fast_slope_percent or 0.0
|
||||
range_slope_threshold_percent = max(
|
||||
fast_slope_threshold_percent,
|
||||
slow_slope_threshold_percent * 2,
|
||||
)
|
||||
|
||||
if volatility == VolatilityState.HIGH:
|
||||
return MarketState.HIGH_VOLATILITY
|
||||
|
||||
if volatility == VolatilityState.LOW:
|
||||
return MarketState.LOW_VOLATILITY
|
||||
|
||||
if (
|
||||
trend == TrendDirection.UP
|
||||
and momentum_state in {MomentumState.BREAKOUT_UP, MomentumState.MOMENTUM_UP}
|
||||
and momentum_direction == TrendDirection.UP
|
||||
and fast_slope > 0
|
||||
):
|
||||
return MarketState.TREND_UP
|
||||
|
||||
if (
|
||||
trend == TrendDirection.DOWN
|
||||
and momentum_state in {MomentumState.BREAKOUT_DOWN, MomentumState.MOMENTUM_DOWN}
|
||||
and momentum_direction == TrendDirection.DOWN
|
||||
and fast_slope < 0
|
||||
):
|
||||
return MarketState.TREND_DOWN
|
||||
|
||||
if market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}:
|
||||
return MarketState.RANGE
|
||||
|
||||
if trend_strength == TrendStrength.WEAK:
|
||||
return MarketState.RANGE
|
||||
|
||||
if (
|
||||
trend_quality == TrendQuality.NOISY
|
||||
and trend_strength != TrendStrength.STRONG
|
||||
):
|
||||
return MarketState.RANGE
|
||||
|
||||
if (
|
||||
candle_noise_score is not None
|
||||
and candle_noise_score < min_clean_candle_score
|
||||
and abs(fast_slope) < range_slope_threshold_percent
|
||||
):
|
||||
return MarketState.RANGE
|
||||
|
||||
if (
|
||||
price_position_score is not None
|
||||
and price_position_score < min_price_position_score
|
||||
and abs(fast_slope) < range_slope_threshold_percent
|
||||
):
|
||||
return MarketState.RANGE
|
||||
|
||||
if (
|
||||
trend == TrendDirection.UP
|
||||
and trend_strength in {TrendStrength.NORMAL, TrendStrength.STRONG}
|
||||
and momentum_direction in {TrendDirection.UP, TrendDirection.FLAT}
|
||||
and fast_slope > 0
|
||||
):
|
||||
return MarketState.TREND_UP
|
||||
|
||||
if (
|
||||
trend == TrendDirection.DOWN
|
||||
and trend_strength in {TrendStrength.NORMAL, TrendStrength.STRONG}
|
||||
and momentum_direction in {TrendDirection.DOWN, TrendDirection.FLAT}
|
||||
and fast_slope < 0
|
||||
):
|
||||
return MarketState.TREND_DOWN
|
||||
|
||||
return MarketState.RANGE
|
||||
157
app/src/trading/market_analysis/structure.py
Normal file
157
app/src/trading/market_analysis/structure.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# app/src/trading/market_analysis/structure.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.models import Kline
|
||||
from src.trading.market_analysis.models import MarketStructure
|
||||
|
||||
|
||||
def structure_params(
|
||||
*,
|
||||
atr_percent: NumericLike | None,
|
||||
candle_noise_score: NumericLike | None,
|
||||
structure_window: int = 30,
|
||||
structure_swing_left: int = 2,
|
||||
structure_swing_right: int = 2,
|
||||
min_clean_candle_score: float = 0.55,
|
||||
) -> tuple[int, int, int]:
|
||||
atr_value = safe_float(atr_percent) or 0.0
|
||||
noise_value = safe_float(candle_noise_score)
|
||||
|
||||
window = structure_window
|
||||
left = structure_swing_left
|
||||
right = structure_swing_right
|
||||
|
||||
if atr_value >= 0.9:
|
||||
window = 50
|
||||
left = 3
|
||||
right = 3
|
||||
elif atr_value >= 0.45:
|
||||
window = 40
|
||||
left = 3
|
||||
right = 2
|
||||
elif atr_value <= 0.18:
|
||||
window = 24
|
||||
left = 2
|
||||
right = 2
|
||||
|
||||
if noise_value is not None and noise_value < min_clean_candle_score:
|
||||
window = max(window, 45)
|
||||
left = max(left, 3)
|
||||
right = max(right, 3)
|
||||
|
||||
return window, left, right
|
||||
|
||||
|
||||
# определить структуру рынка по swing high / swing low:
|
||||
# HH/HL = восходящая структура
|
||||
# LH/LL = нисходящая структура
|
||||
# MIXED = противоречивая структура
|
||||
def market_structure(
|
||||
candles: Sequence[Kline],
|
||||
*,
|
||||
atr_percent: NumericLike | None = None,
|
||||
candle_noise_score: NumericLike | None = None,
|
||||
structure_window: int = 30,
|
||||
structure_swing_left: int = 2,
|
||||
structure_swing_right: int = 2,
|
||||
min_clean_candle_score: float = 0.55,
|
||||
) -> tuple[MarketStructure, str]:
|
||||
resolved_window, left, right = structure_params(
|
||||
atr_percent=atr_percent,
|
||||
candle_noise_score=candle_noise_score,
|
||||
structure_window=structure_window,
|
||||
structure_swing_left=structure_swing_left,
|
||||
structure_swing_right=structure_swing_right,
|
||||
min_clean_candle_score=min_clean_candle_score,
|
||||
)
|
||||
|
||||
window = list(candles[-resolved_window:])
|
||||
min_required = max(10, left + right + 6)
|
||||
|
||||
if len(window) < min_required:
|
||||
return MarketStructure.UNKNOWN, "STRUCTURE_NOT_ENOUGH_CANDLES"
|
||||
|
||||
swing_highs: list[float] = []
|
||||
swing_lows: list[float] = []
|
||||
|
||||
for index in range(left, len(window) - right):
|
||||
current = window[index]
|
||||
|
||||
previous_items = window[index - left:index]
|
||||
next_items = window[index + 1:index + 1 + right]
|
||||
|
||||
high = safe_float(current.high_price)
|
||||
low = safe_float(current.low_price)
|
||||
|
||||
if high is None or low is None:
|
||||
continue
|
||||
|
||||
neighbor_highs: list[float] = []
|
||||
neighbor_lows: list[float] = []
|
||||
|
||||
for item in previous_items + next_items:
|
||||
item_high = safe_float(item.high_price)
|
||||
item_low = safe_float(item.low_price)
|
||||
|
||||
if item_high is not None:
|
||||
neighbor_highs.append(item_high)
|
||||
|
||||
if item_low is not None:
|
||||
neighbor_lows.append(item_low)
|
||||
|
||||
if len(neighbor_highs) != left + right:
|
||||
continue
|
||||
|
||||
if len(neighbor_lows) != left + right:
|
||||
continue
|
||||
|
||||
if all(high > item_high for item_high in neighbor_highs):
|
||||
swing_highs.append(high)
|
||||
|
||||
if all(low < item_low for item_low in neighbor_lows):
|
||||
swing_lows.append(low)
|
||||
|
||||
if len(swing_highs) < 2 or len(swing_lows) < 2:
|
||||
return (
|
||||
MarketStructure.UNKNOWN,
|
||||
f"STRUCTURE_NOT_ENOUGH_SWINGS:"
|
||||
f"window={resolved_window}:left={left}:right={right}:"
|
||||
f"highs={len(swing_highs)}:lows={len(swing_lows)}",
|
||||
)
|
||||
|
||||
last_high = swing_highs[-1]
|
||||
prev_high = swing_highs[-2]
|
||||
|
||||
last_low = swing_lows[-1]
|
||||
prev_low = swing_lows[-2]
|
||||
|
||||
has_hh = last_high > prev_high
|
||||
has_hl = last_low > prev_low
|
||||
|
||||
has_lh = last_high < prev_high
|
||||
has_ll = last_low < prev_low
|
||||
|
||||
if has_hh and has_hl:
|
||||
return (
|
||||
MarketStructure.HH_HL,
|
||||
f"HIGHER_HIGH_HIGHER_LOW:"
|
||||
f"window={resolved_window}:left={left}:right={right}",
|
||||
)
|
||||
|
||||
if has_lh and has_ll:
|
||||
return (
|
||||
MarketStructure.LH_LL,
|
||||
f"LOWER_HIGH_LOWER_LOW:"
|
||||
f"window={resolved_window}:left={left}:right={right}",
|
||||
)
|
||||
|
||||
return (
|
||||
MarketStructure.MIXED,
|
||||
f"MIXED_MARKET_STRUCTURE:"
|
||||
f"window={resolved_window}:left={left}:right={right}",
|
||||
)
|
||||
182
app/src/trading/market_analysis/unknown.py
Normal file
182
app/src/trading/market_analysis/unknown.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# app/src/trading/market_analysis/unknown.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_analysis.models import (
|
||||
EmaDistanceState,
|
||||
EntryTimingState,
|
||||
MarketAnalysisResult,
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
MarketStructure,
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
|
||||
def build_unknown_market_analysis_result(
|
||||
*,
|
||||
symbol: str,
|
||||
interval: str,
|
||||
reason: str,
|
||||
candles_count: int = 0,
|
||||
htf_interval: str = "1h",
|
||||
) -> MarketAnalysisResult:
|
||||
# UNKNOWN-result используется, когда полноценный анализ невозможен:
|
||||
# нет свечей, ошибка API, мало данных или не рассчитались индикаторы.
|
||||
#
|
||||
# Важно: payload должен содержать те же ключи, что и обычный market payload,
|
||||
# чтобы formatter/snapshot/runtime не падали на отсутствующих полях.
|
||||
payload = {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"market_state": MarketState.UNKNOWN.value,
|
||||
"trend": TrendDirection.UNKNOWN.value,
|
||||
"volatility": VolatilityState.UNKNOWN.value,
|
||||
"market_trend_strength": TrendStrength.UNKNOWN.value,
|
||||
"market_trend_quality": TrendQuality.UNKNOWN.value,
|
||||
"market_phase": MarketPhase.UNKNOWN.value,
|
||||
"market_phase_direction": TrendDirection.UNKNOWN.value,
|
||||
"market_phase_change_percent": None,
|
||||
"market_phase_direction_consistency": None,
|
||||
"market_phase_reason": reason,
|
||||
|
||||
# Текущая свеча / текущий интервал.
|
||||
"current_interval_change_percent": None,
|
||||
"current_interval_direction": TrendDirection.UNKNOWN.value,
|
||||
"current_interval_label": interval,
|
||||
|
||||
# Последняя закрытая свеча.
|
||||
"last_closed_candle_change_percent": None,
|
||||
"last_closed_candle_direction": TrendDirection.UNKNOWN.value,
|
||||
|
||||
"market_structure": MarketStructure.UNKNOWN.value,
|
||||
"market_structure_reason": reason,
|
||||
|
||||
"momentum_state": MomentumState.UNKNOWN.value,
|
||||
"momentum_direction": TrendDirection.UNKNOWN.value,
|
||||
"momentum_change_percent": None,
|
||||
"momentum_strength": None,
|
||||
"breakout_level": None,
|
||||
"breakout_distance_percent": None,
|
||||
"breakout_reason": reason,
|
||||
|
||||
"market_trend_gap_percent": None,
|
||||
"ema_fast_slope_percent": None,
|
||||
"ema_slow_slope_percent": None,
|
||||
"market_trend_consistency": None,
|
||||
"market_trend_efficiency": None,
|
||||
"trend_quality_score": None,
|
||||
"ema_distance_atr_ratio": None,
|
||||
"ema_distance_state": EmaDistanceState.UNKNOWN.value,
|
||||
"entry_timing_state": EntryTimingState.UNKNOWN.value,
|
||||
"entry_timing_reason": reason,
|
||||
|
||||
"close_price": None,
|
||||
"ema_fast": None,
|
||||
"ema_slow": None,
|
||||
"atr": None,
|
||||
"atr_percent": None,
|
||||
"rsi": None,
|
||||
|
||||
"market_score": None,
|
||||
"market_score_label": None,
|
||||
"market_long_score": None,
|
||||
"market_short_score": None,
|
||||
|
||||
"htf_interval": htf_interval,
|
||||
"htf_atr_percent": None,
|
||||
"htf_atr_percent_baseline": None,
|
||||
"htf_volatility_ratio": None,
|
||||
"htf_volatility": None,
|
||||
"htf_market_state": MarketState.UNKNOWN.value,
|
||||
"htf_trend": TrendDirection.UNKNOWN.value,
|
||||
"htf_trend_strength": TrendStrength.UNKNOWN.value,
|
||||
"htf_trend_quality": TrendQuality.UNKNOWN.value,
|
||||
"htf_market_phase": MarketPhase.UNKNOWN.value,
|
||||
"htf_alignment": "UNKNOWN",
|
||||
"htf_confirmation_score": None,
|
||||
"htf_reason": reason,
|
||||
|
||||
"candles_count": candles_count,
|
||||
"is_trade_allowed": False,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
return MarketAnalysisResult(
|
||||
symbol=symbol,
|
||||
interval=interval,
|
||||
state=MarketState.UNKNOWN,
|
||||
trend=TrendDirection.UNKNOWN,
|
||||
volatility=VolatilityState.UNKNOWN,
|
||||
close_price=None,
|
||||
ema_fast=None,
|
||||
ema_slow=None,
|
||||
atr=None,
|
||||
atr_percent=None,
|
||||
rsi=None,
|
||||
candles_count=candles_count,
|
||||
reason=reason,
|
||||
is_trade_allowed=False,
|
||||
payload=payload,
|
||||
|
||||
trend_strength=TrendStrength.UNKNOWN,
|
||||
trend_quality=TrendQuality.UNKNOWN,
|
||||
market_phase=MarketPhase.UNKNOWN,
|
||||
|
||||
trend_gap_percent=None,
|
||||
trend_consistency=None,
|
||||
trend_efficiency=None,
|
||||
ema_distance_atr_ratio=None,
|
||||
|
||||
phase_direction=TrendDirection.UNKNOWN,
|
||||
phase_change_percent=None,
|
||||
phase_reason=reason,
|
||||
|
||||
market_structure=MarketStructure.UNKNOWN,
|
||||
market_structure_reason=reason,
|
||||
|
||||
ema_fast_slope_percent=None,
|
||||
ema_slow_slope_percent=None,
|
||||
phase_direction_consistency=None,
|
||||
|
||||
current_interval_change_percent=None,
|
||||
current_interval_direction=TrendDirection.UNKNOWN,
|
||||
current_interval_label=interval,
|
||||
|
||||
momentum_state=MomentumState.UNKNOWN,
|
||||
momentum_direction=TrendDirection.UNKNOWN,
|
||||
momentum_change_percent=None,
|
||||
momentum_strength=None,
|
||||
breakout_level=None,
|
||||
breakout_distance_percent=None,
|
||||
breakout_reason=reason,
|
||||
|
||||
htf_interval=htf_interval,
|
||||
htf_atr_percent=None,
|
||||
htf_atr_percent_baseline=None,
|
||||
htf_volatility_ratio=None,
|
||||
htf_volatility=None,
|
||||
|
||||
trend_quality_score=None,
|
||||
ema_distance_state=EmaDistanceState.UNKNOWN,
|
||||
entry_timing_state=EntryTimingState.UNKNOWN,
|
||||
entry_timing_reason=reason,
|
||||
|
||||
htf_market_state=MarketState.UNKNOWN,
|
||||
htf_trend=TrendDirection.UNKNOWN,
|
||||
htf_trend_strength=TrendStrength.UNKNOWN,
|
||||
htf_trend_quality=TrendQuality.UNKNOWN,
|
||||
htf_market_phase=MarketPhase.UNKNOWN,
|
||||
htf_alignment="UNKNOWN",
|
||||
htf_confirmation_score=None,
|
||||
htf_reason=reason,
|
||||
|
||||
market_score=None,
|
||||
market_score_label=None,
|
||||
market_long_score=None,
|
||||
market_short_score=None,
|
||||
)
|
||||
@@ -1,10 +1,19 @@
|
||||
# app/src/trading/strategies/scalp.py
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketState,
|
||||
MarketStructure,
|
||||
MomentumState,
|
||||
TrendDirection,
|
||||
VolatilityState,
|
||||
)
|
||||
from src.trading.market_analysis.service import MarketAnalysisService
|
||||
from src.trading.strategies.base import StrategyContext
|
||||
from src.trading.strategies.signals import SignalResult, SignalType
|
||||
|
||||
@@ -16,15 +25,13 @@ class ScalpStrategy:
|
||||
_window_ttl_seconds = 30
|
||||
_price_window_updated_at: dict[str, float] = {}
|
||||
|
||||
# короткое окно = быстрая реакция
|
||||
_window_size = 4
|
||||
|
||||
# ниже порог = чувствительнее TREND
|
||||
_threshold_percent = 0.02
|
||||
|
||||
# для scalp допускаем чуть больше шума
|
||||
_min_direction_ratio = 0.55
|
||||
|
||||
# SCALP быстрее TREND, но всё равно использует market-analysis фильтры.
|
||||
_market_interval = "1m"
|
||||
|
||||
def reset_runtime(self, symbol: str | None = None) -> None:
|
||||
if symbol is None:
|
||||
self._price_window.clear()
|
||||
@@ -42,24 +49,49 @@ class ScalpStrategy:
|
||||
self._price_window_updated_at.pop(key, None)
|
||||
|
||||
def analyze(self, context: StrategyContext) -> SignalResult:
|
||||
market = MarketAnalysisService().analyze(
|
||||
context.symbol,
|
||||
interval=self._market_interval,
|
||||
limit=200,
|
||||
)
|
||||
|
||||
try:
|
||||
ticker = ExchangeService().get_price(context.symbol)
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
context.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception as exc:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="Не удалось получить рыночную цену. Безопасный HOLD.",
|
||||
reason="Не удалось получить рыночный snapshot. Безопасный HOLD.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
"strategy": self.name,
|
||||
"symbol": context.symbol,
|
||||
"error": str(exc),
|
||||
"entry_block_reason": "MARKET_PRICE_ERROR",
|
||||
"market_analysis": market.payload,
|
||||
"entry_block_reason": "MARKET_SNAPSHOT_ERROR",
|
||||
"entry_block_message": "нет данных рынка",
|
||||
},
|
||||
)
|
||||
|
||||
symbol = ticker.symbol
|
||||
current_price = float(ticker.price)
|
||||
symbol = str(snapshot.get("symbol") or context.symbol)
|
||||
current_price = self._analysis_price(snapshot)
|
||||
|
||||
if current_price <= 0:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="Некорректная рыночная цена. Безопасный HOLD.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
"strategy": self.name,
|
||||
"symbol": symbol,
|
||||
"snapshot": snapshot,
|
||||
"market_analysis": market.payload,
|
||||
"entry_block_reason": "INVALID_MARKET_PRICE",
|
||||
"entry_block_message": "нет цены",
|
||||
},
|
||||
)
|
||||
|
||||
now = time.monotonic()
|
||||
previous_updated_at = self._price_window_updated_at.get(symbol)
|
||||
@@ -69,6 +101,7 @@ class ScalpStrategy:
|
||||
and now - previous_updated_at > self._window_ttl_seconds
|
||||
):
|
||||
self._price_window.pop(symbol, None)
|
||||
self._price_window_updated_at.pop(symbol, None)
|
||||
|
||||
prices = self._price_window.setdefault(symbol, [])
|
||||
prices.append(current_price)
|
||||
@@ -77,18 +110,34 @@ class ScalpStrategy:
|
||||
if len(prices) > self._window_size:
|
||||
prices.pop(0)
|
||||
|
||||
base_payload = {
|
||||
"strategy": self.name,
|
||||
"symbol": symbol,
|
||||
"price": current_price,
|
||||
"runtime_window_ttl_seconds": self._window_ttl_seconds,
|
||||
"runtime_window_size": len(prices),
|
||||
}
|
||||
base_payload = self._base_payload(
|
||||
symbol=symbol,
|
||||
current_price=current_price,
|
||||
snapshot=snapshot,
|
||||
market=market,
|
||||
prices=prices,
|
||||
)
|
||||
|
||||
market_block = self._market_block_signal(
|
||||
market=market,
|
||||
base_payload=base_payload,
|
||||
)
|
||||
|
||||
if market_block is not None:
|
||||
return market_block
|
||||
|
||||
breakout_signal = self._breakout_signal(
|
||||
market=market,
|
||||
base_payload=base_payload,
|
||||
)
|
||||
|
||||
if breakout_signal is not None:
|
||||
return breakout_signal
|
||||
|
||||
if len(prices) < self._window_size:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="Недостаточно данных для SCALP.",
|
||||
reason="Недостаточно live-данных для SCALP.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
@@ -129,47 +178,334 @@ class ScalpStrategy:
|
||||
"min_direction_ratio": self._min_direction_ratio,
|
||||
}
|
||||
|
||||
if (
|
||||
change_percent >= self._threshold_percent
|
||||
and direction_ratio >= self._min_direction_ratio
|
||||
):
|
||||
if market.state == MarketState.TREND_UP:
|
||||
if (
|
||||
change_percent >= self._threshold_percent
|
||||
and direction_ratio >= self._min_direction_ratio
|
||||
):
|
||||
return SignalResult(
|
||||
signal=SignalType.BUY,
|
||||
reason="SCALP BUY подтверждён трендом и коротким импульсом.",
|
||||
confidence=self._calculate_confidence(change_percent, direction_ratio),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
return SignalResult(
|
||||
signal=SignalType.BUY,
|
||||
reason="Быстрый краткосрочный импульс вверх.",
|
||||
confidence=self._calculate_confidence(change_percent, direction_ratio),
|
||||
payload=payload,
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP: тренд вверх есть, но короткий импульс слабый.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**payload,
|
||||
"entry_block_reason": "WEAK_UP_IMPULSE",
|
||||
"entry_block_message": "слабый импульс",
|
||||
"expected_direction": "BUY",
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
change_percent <= -self._threshold_percent
|
||||
and direction_ratio >= self._min_direction_ratio
|
||||
):
|
||||
return SignalResult(
|
||||
signal=SignalType.SELL,
|
||||
reason="Быстрый краткосрочный импульс вниз.",
|
||||
confidence=self._calculate_confidence(change_percent, direction_ratio),
|
||||
payload=payload,
|
||||
)
|
||||
if market.state == MarketState.TREND_DOWN:
|
||||
if (
|
||||
change_percent <= -self._threshold_percent
|
||||
and direction_ratio >= self._min_direction_ratio
|
||||
):
|
||||
return SignalResult(
|
||||
signal=SignalType.SELL,
|
||||
reason="SCALP SELL подтверждён трендом и коротким импульсом.",
|
||||
confidence=self._calculate_confidence(change_percent, direction_ratio),
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
expected_direction = "BUY" if change_percent >= 0 else "SELL"
|
||||
entry_block_reason = (
|
||||
"WEAK_UP_IMPULSE"
|
||||
if expected_direction == "BUY"
|
||||
else "WEAK_DOWN_IMPULSE"
|
||||
)
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP: тренд вниз есть, но короткий импульс слабый.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**payload,
|
||||
"entry_block_reason": "WEAK_DOWN_IMPULSE",
|
||||
"entry_block_message": "слабый импульс",
|
||||
"expected_direction": "SELL",
|
||||
},
|
||||
)
|
||||
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP-импульс недостаточно сильный.",
|
||||
reason=f"Market state не подходит для SCALP: {market.state.value}.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**payload,
|
||||
"entry_block_reason": entry_block_reason,
|
||||
"entry_block_message": "слабый импульс",
|
||||
"expected_direction": expected_direction,
|
||||
"entry_block_reason": "MARKET_STATE_NOT_TREND",
|
||||
"entry_block_message": "рынок не трендовый",
|
||||
},
|
||||
)
|
||||
|
||||
def _market_block_signal(
|
||||
self,
|
||||
*,
|
||||
market: Any,
|
||||
base_payload: dict[str, Any],
|
||||
) -> SignalResult | None:
|
||||
if market.volatility != VolatilityState.NORMAL:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP заблокирован: волатильность не NORMAL.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "BAD_SCALP_VOLATILITY",
|
||||
"entry_block_message": "волатильность не подходит",
|
||||
},
|
||||
)
|
||||
|
||||
if market.htf_alignment == "AGAINST":
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP заблокирован: старший таймфрейм против входа.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "HTF_TREND_AGAINST",
|
||||
"entry_block_message": "старший таймфрейм против входа",
|
||||
},
|
||||
)
|
||||
|
||||
if market.state not in {MarketState.TREND_UP, MarketState.TREND_DOWN}:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP заблокирован: нет трендового market state.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "MARKET_STATE_NOT_TREND",
|
||||
"entry_block_message": "рынок не трендовый",
|
||||
},
|
||||
)
|
||||
|
||||
market_structure = (
|
||||
market.market_structure.value
|
||||
if market.market_structure is not None
|
||||
else "UNKNOWN"
|
||||
)
|
||||
|
||||
if market_structure == MarketStructure.MIXED.value:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP заблокирован: структура рынка смешанная.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "MARKET_STRUCTURE_MIXED",
|
||||
"entry_block_message": "структура не подтверждает вход",
|
||||
},
|
||||
)
|
||||
|
||||
if market.state == MarketState.TREND_UP and market_structure == MarketStructure.LH_LL.value:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP заблокирован: структура против LONG.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "MARKET_STRUCTURE_CONFLICT",
|
||||
"entry_block_message": "структура против LONG",
|
||||
"expected_direction": "BUY",
|
||||
},
|
||||
)
|
||||
|
||||
if market.state == MarketState.TREND_DOWN and market_structure == MarketStructure.HH_HL.value:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="SCALP заблокирован: структура против SHORT.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "MARKET_STRUCTURE_CONFLICT",
|
||||
"entry_block_message": "структура против SHORT",
|
||||
"expected_direction": "SELL",
|
||||
},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _breakout_signal(
|
||||
self,
|
||||
*,
|
||||
market: Any,
|
||||
base_payload: dict[str, Any],
|
||||
) -> SignalResult | None:
|
||||
momentum_state = getattr(market, "momentum_state", MomentumState.UNKNOWN)
|
||||
momentum_direction = getattr(market, "momentum_direction", TrendDirection.UNKNOWN)
|
||||
momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0)
|
||||
|
||||
if (
|
||||
momentum_state == MomentumState.BREAKOUT_UP
|
||||
and momentum_direction == TrendDirection.UP
|
||||
and market.state == MarketState.TREND_UP
|
||||
):
|
||||
return SignalResult(
|
||||
signal=SignalType.BUY,
|
||||
reason="SCALP BUY по подтверждённому breakout вверх.",
|
||||
confidence=self._calculate_breakout_confidence(momentum_strength),
|
||||
payload={
|
||||
**base_payload,
|
||||
"breakout_signal": True,
|
||||
"expected_direction": "BUY",
|
||||
"entry_block_reason": None,
|
||||
"entry_block_message": None,
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
momentum_state == MomentumState.BREAKOUT_DOWN
|
||||
and momentum_direction == TrendDirection.DOWN
|
||||
and market.state == MarketState.TREND_DOWN
|
||||
):
|
||||
return SignalResult(
|
||||
signal=SignalType.SELL,
|
||||
reason="SCALP SELL по подтверждённому breakout вниз.",
|
||||
confidence=self._calculate_breakout_confidence(momentum_strength),
|
||||
payload={
|
||||
**base_payload,
|
||||
"breakout_signal": True,
|
||||
"expected_direction": "SELL",
|
||||
"entry_block_reason": None,
|
||||
"entry_block_message": None,
|
||||
},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _base_payload(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
current_price: float,
|
||||
snapshot: dict[str, Any],
|
||||
market: Any,
|
||||
prices: list[float],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"symbol": symbol,
|
||||
"analysis_price": current_price,
|
||||
"last_price": snapshot.get("last_price"),
|
||||
"bid_price": snapshot.get("bid_price"),
|
||||
"ask_price": snapshot.get("ask_price"),
|
||||
"market_state": market.state.value,
|
||||
"market_trend": market.trend.value,
|
||||
"market_volatility": market.volatility.value,
|
||||
"market_analysis_interval": market.interval,
|
||||
"market_analysis_reason": market.reason,
|
||||
"market_analysis": market.payload,
|
||||
"market_trend_strength": market.trend_strength.value,
|
||||
"market_trend_quality": market.trend_quality.value,
|
||||
"market_phase": market.market_phase.value,
|
||||
"market_phase_direction": market.phase_direction.value,
|
||||
"market_phase_change_percent": market.phase_change_percent,
|
||||
"market_phase_direction_consistency": market.phase_direction_consistency,
|
||||
"market_phase_reason": market.phase_reason,
|
||||
"market_structure": (
|
||||
market.market_structure.value
|
||||
if market.market_structure is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"market_structure_reason": market.market_structure_reason,
|
||||
"momentum_state": (
|
||||
market.momentum_state.value
|
||||
if market.momentum_state is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"momentum_direction": (
|
||||
market.momentum_direction.value
|
||||
if market.momentum_direction is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"momentum_change_percent": market.momentum_change_percent,
|
||||
"momentum_strength": market.momentum_strength,
|
||||
"breakout_level": market.breakout_level,
|
||||
"breakout_distance_percent": market.breakout_distance_percent,
|
||||
"breakout_reason": market.breakout_reason,
|
||||
"market_trend_gap_percent": market.trend_gap_percent,
|
||||
"market_trend_consistency": market.trend_consistency,
|
||||
"market_trend_efficiency": market.trend_efficiency,
|
||||
"trend_quality_score": market.trend_quality_score,
|
||||
"ema_distance_atr_ratio": market.ema_distance_atr_ratio,
|
||||
"ema_distance_state": (
|
||||
market.ema_distance_state.value
|
||||
if market.ema_distance_state is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"entry_timing_state": (
|
||||
market.entry_timing_state.value
|
||||
if market.entry_timing_state is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"entry_timing_reason": market.entry_timing_reason,
|
||||
"candle_noise_score": market.payload.get("candle_noise_score"),
|
||||
"price_position_score": market.payload.get("price_position_score"),
|
||||
"rsi": market.rsi,
|
||||
"rsi_overbought": market.payload.get("rsi_overbought"),
|
||||
"rsi_oversold": market.payload.get("rsi_oversold"),
|
||||
"htf_interval": market.htf_interval,
|
||||
"htf_market_state": (
|
||||
market.htf_market_state.value
|
||||
if market.htf_market_state is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"htf_trend": (
|
||||
market.htf_trend.value
|
||||
if market.htf_trend is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"htf_trend_strength": (
|
||||
market.htf_trend_strength.value
|
||||
if market.htf_trend_strength is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"htf_trend_quality": (
|
||||
market.htf_trend_quality.value
|
||||
if market.htf_trend_quality is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"htf_market_phase": (
|
||||
market.htf_market_phase.value
|
||||
if market.htf_market_phase is not None
|
||||
else "UNKNOWN"
|
||||
),
|
||||
"htf_alignment": market.htf_alignment,
|
||||
"htf_confirmation_score": market.htf_confirmation_score,
|
||||
"htf_reason": market.htf_reason,
|
||||
"runtime_window_ttl_seconds": self._window_ttl_seconds,
|
||||
"runtime_window_size": len(prices),
|
||||
}
|
||||
|
||||
def _analysis_price(
|
||||
self,
|
||||
snapshot: dict[str, Any],
|
||||
) -> float:
|
||||
bid = self._safe_float(snapshot.get("bid_price"))
|
||||
ask = self._safe_float(snapshot.get("ask_price"))
|
||||
|
||||
if bid is not None and ask is not None and bid > 0 and ask > 0:
|
||||
return (bid + ask) / 2
|
||||
|
||||
last = self._safe_float(snapshot.get("last_price"))
|
||||
|
||||
if last is not None and last > 0:
|
||||
return last
|
||||
|
||||
return 0.0
|
||||
|
||||
def _safe_float(
|
||||
self,
|
||||
value: float | int | str | None,
|
||||
) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _direction_ratio(self, prices: list[float], change_percent: float) -> float:
|
||||
if len(prices) < 2:
|
||||
return 0.0
|
||||
@@ -190,6 +526,12 @@ class ScalpStrategy:
|
||||
|
||||
return down_moves / total_moves
|
||||
|
||||
def _calculate_breakout_confidence(self, momentum_strength: float) -> float:
|
||||
strength_score = min(1.0, max(0.0, momentum_strength) / 2)
|
||||
confidence = 0.55 + (strength_score * 0.35)
|
||||
|
||||
return round(min(0.95, confidence), 2)
|
||||
|
||||
def _calculate_confidence(
|
||||
self,
|
||||
change_percent: float,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user