1361 lines
49 KiB
Python
1361 lines
49 KiB
Python
# app/src/trading/auto/service.py
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import time
|
||
from datetime import datetime
|
||
|
||
from src.core.config import load_settings
|
||
from src.core.event_bus import EventBus
|
||
from src.trading.auto.state import AutoTradeState
|
||
from src.trading.execution.engine import ExecutionEngine
|
||
from src.trading.journal.service import JournalService
|
||
from src.trading.strategies.base import BaseStrategy, StrategyContext
|
||
from src.trading.strategies.registry import StrategyRegistry
|
||
from src.integrations.exchange.service import ExchangeService
|
||
|
||
|
||
class AutoTradeService:
|
||
_state = AutoTradeState()
|
||
_loop_task: asyncio.Task | None = None
|
||
_loop_interval_seconds = 5
|
||
|
||
# минимальное количество повторов BUY / SELL для подтверждения сигнала
|
||
_confirm_repeats = 2
|
||
|
||
# минимальное время удержания BUY / SELL сигнала для подтверждения
|
||
_confirm_min_duration_seconds = 10
|
||
|
||
# минимальная уверенность для готовности к будущему execution
|
||
_ready_confidence = 0.3
|
||
|
||
_signal_ttl_seconds = 90
|
||
_market_analysis_ttl_seconds = 180
|
||
_last_logged_runtime_expired_key: str | None = None
|
||
|
||
_last_signal_key: str | None = None
|
||
_last_signal_value: str | None = None
|
||
_last_signal_reason: str = ""
|
||
_last_signal_confidence: float = 0.0
|
||
_last_signal_payload: dict | None = None
|
||
_last_signal_started_at: float | None = None
|
||
_last_logged_market_state: str | None = None
|
||
_last_logged_market_trend: str | None = None
|
||
_last_logged_market_volatility: str | None = None
|
||
_last_logged_entry_block_reason: str | None = None
|
||
_same_signal_count = 0
|
||
|
||
_max_snapshot_age_seconds = 5.0
|
||
_warning_snapshot_age_seconds = 2.0
|
||
_spread_warning_enter_percent = 0.08
|
||
_spread_warning_exit_percent = 0.06
|
||
_spread_block_enter_percent = 0.15
|
||
_spread_block_exit_percent = 0.12
|
||
_last_logged_execution_quality_key: str | None = None
|
||
|
||
def _spread_execution_quality(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
spread_percent: float | None,
|
||
) -> tuple[str | None, str | None, str | None, bool]:
|
||
if spread_percent is None:
|
||
return None, None, None, False
|
||
|
||
previous_quality = state.execution_quality
|
||
previous_reason = state.execution_quality_reason
|
||
|
||
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
|
||
if spread_percent > self._spread_block_exit_percent:
|
||
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
|
||
|
||
if spread_percent > self._spread_warning_exit_percent:
|
||
return "WARNING", "WIDE_SPREAD", "spread повышен", False
|
||
|
||
return "GOOD", "MARKET_OK", "рынок готов", False
|
||
|
||
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
|
||
if spread_percent >= self._spread_block_enter_percent:
|
||
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
|
||
|
||
if spread_percent > self._spread_warning_exit_percent:
|
||
return "WARNING", "WIDE_SPREAD", "spread повышен", False
|
||
|
||
return "GOOD", "MARKET_OK", "рынок готов", False
|
||
|
||
if spread_percent >= self._spread_block_enter_percent:
|
||
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
|
||
|
||
if spread_percent >= self._spread_warning_enter_percent:
|
||
return "WARNING", "WIDE_SPREAD", "spread повышен", False
|
||
|
||
return "GOOD", "MARKET_OK", "рынок готов", False
|
||
|
||
# debug: принудительно выставить сигнал и decision
|
||
def debug_force_signal(
|
||
self,
|
||
*,
|
||
signal: str,
|
||
confidence: float = 0.9,
|
||
repeat_count: int = 2,
|
||
reason: str = "DEBUG SIGNAL",
|
||
) -> AutoTradeState:
|
||
state = self.get_state()
|
||
|
||
normalized_signal = signal.strip().upper()
|
||
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
|
||
normalized_signal = "HOLD"
|
||
|
||
previous_signal = state.last_signal
|
||
previous_decision_status = state.decision_status
|
||
|
||
if previous_signal != normalized_signal or state.signal_started_at is None:
|
||
state.signal_started_at = time.monotonic()
|
||
|
||
state.last_signal = normalized_signal
|
||
state.last_signal_repeat_count = repeat_count
|
||
state.last_signal_confidence = confidence
|
||
state.last_signal_reason = reason
|
||
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
|
||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||
state.signal_confirmation_missing_repeats = 0
|
||
state.signal_confirmation_progress = 1.0
|
||
state.signal_confirmation_reason = "debug confirmation"
|
||
|
||
if normalized_signal == "HOLD":
|
||
state.decision_status = "WAITING"
|
||
state.decision_reason = "Debug HOLD."
|
||
state.is_signal_confirmed = False
|
||
state.is_signal_ready = False
|
||
else:
|
||
state.decision_status = "READY"
|
||
state.decision_reason = "Debug READY signal."
|
||
state.is_signal_confirmed = True
|
||
state.is_signal_ready = True
|
||
|
||
signal_intent = self._signal_intent(
|
||
state=state,
|
||
signal=state.last_signal,
|
||
)
|
||
|
||
EventBus.emit(
|
||
"auto_decision_changed",
|
||
{
|
||
"previous_signal": previous_signal,
|
||
"previous_decision_status": previous_decision_status,
|
||
"decision_status": state.decision_status,
|
||
"signal": state.last_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"confidence": state.last_signal_confidence,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"leverage": state.leverage,
|
||
"reason": state.last_signal_reason,
|
||
"debug": True,
|
||
},
|
||
)
|
||
|
||
return state
|
||
|
||
# установить капитал, выделенный под автоторговлю
|
||
def set_allocated_balance_usd(self, value: float) -> AutoTradeState:
|
||
state = self.get_state()
|
||
|
||
if value <= 0:
|
||
value = 1000.0
|
||
|
||
state.allocated_balance_usd = value
|
||
state.execution_block_reason = None
|
||
state.execution_size_adjustment_reason = None
|
||
return state
|
||
|
||
# получить текущее состояние автоторговли
|
||
def get_state(self) -> AutoTradeState:
|
||
if not self._state.symbol:
|
||
self._state.symbol = load_settings().default_symbol
|
||
return self._state
|
||
|
||
# проверить, запущен ли background loop
|
||
def is_loop_running(self) -> bool:
|
||
return self._loop_task is not None and not self._loop_task.done()
|
||
|
||
# запустить background loop, если он ещё не запущен
|
||
def start_loop(self) -> None:
|
||
if self.is_loop_running():
|
||
return
|
||
|
||
self._loop_task = asyncio.create_task(self._loop_worker())
|
||
|
||
# остановить background loop
|
||
def stop_loop(self) -> None:
|
||
if self._loop_task is None:
|
||
return
|
||
|
||
self._loop_task.cancel()
|
||
self._loop_task = None
|
||
|
||
# рабочий цикл автоторговли
|
||
async def _loop_worker(self) -> None:
|
||
while True:
|
||
state = self.get_state()
|
||
|
||
if state.status == "OFF":
|
||
break
|
||
|
||
self.run_cycle()
|
||
await asyncio.sleep(self._loop_interval_seconds)
|
||
|
||
# запустить активную торговлю
|
||
def start(self) -> tuple[AutoTradeState, str]:
|
||
state = self.get_state()
|
||
previous_status = state.status
|
||
|
||
if state.status == "RUNNING":
|
||
return state, "Автоторговля уже активна."
|
||
|
||
if state.status == "OBSERVING":
|
||
state.status = "RUNNING"
|
||
EventBus.emit(
|
||
"auto_status_changed",
|
||
{
|
||
"previous_status": previous_status,
|
||
"status": state.status,
|
||
},
|
||
)
|
||
return state, "Автоторговля активирована."
|
||
|
||
state.status = "RUNNING"
|
||
self._reset_signal_tracking()
|
||
state.last_signal = "HOLD"
|
||
state.signal_started_at = time.monotonic()
|
||
|
||
EventBus.emit(
|
||
"auto_status_changed",
|
||
{
|
||
"previous_status": previous_status,
|
||
"status": state.status,
|
||
},
|
||
)
|
||
return state, "Автоторговля запущена."
|
||
|
||
# включить режим наблюдения
|
||
def observe(self) -> tuple[AutoTradeState, str]:
|
||
state = self.get_state()
|
||
previous_status = state.status
|
||
|
||
if previous_status == "OBSERVING":
|
||
return state, "Режим наблюдения уже включён."
|
||
|
||
state.status = "OBSERVING"
|
||
|
||
EventBus.emit(
|
||
"auto_status_changed",
|
||
{
|
||
"previous_status": previous_status,
|
||
"status": state.status,
|
||
},
|
||
)
|
||
|
||
if previous_status == "OFF":
|
||
return state, "Включён режим наблюдения."
|
||
|
||
return state, "Автоторговля переведена в режим наблюдения."
|
||
|
||
# полностью выключить автоторговлю
|
||
def stop(self) -> tuple[AutoTradeState, str]:
|
||
state = self.get_state()
|
||
previous_status = state.status
|
||
|
||
if state.status == "OFF":
|
||
self.stop_loop()
|
||
return state, "Автоторговля уже выключена."
|
||
|
||
state.status = "OFF"
|
||
self.stop_loop()
|
||
|
||
EventBus.emit(
|
||
"auto_status_changed",
|
||
{
|
||
"previous_status": previous_status,
|
||
"status": state.status,
|
||
},
|
||
)
|
||
|
||
return state, "Автоторговля выключена."
|
||
|
||
# установить инструмент
|
||
def set_symbol(self, symbol: str) -> AutoTradeState:
|
||
state = self.get_state()
|
||
previous_symbol = state.symbol
|
||
|
||
state.symbol = symbol
|
||
self._reset_signal_tracking()
|
||
|
||
StrategyRegistry.reset_runtime(symbol=previous_symbol)
|
||
StrategyRegistry.reset_runtime(symbol=symbol)
|
||
|
||
return state
|
||
|
||
# установить стратегию
|
||
def set_strategy(self, strategy: str) -> AutoTradeState:
|
||
state = self.get_state()
|
||
previous_strategy = state.strategy
|
||
normalized_strategy = strategy.strip().upper()
|
||
|
||
state.strategy = normalized_strategy
|
||
self._reset_signal_tracking()
|
||
|
||
StrategyRegistry.reset_runtime(previous_strategy)
|
||
StrategyRegistry.reset_runtime(normalized_strategy)
|
||
|
||
return state
|
||
|
||
# установить риск
|
||
def set_risk_percent(self, risk_percent: float) -> AutoTradeState:
|
||
state = self.get_state()
|
||
state.risk_percent = risk_percent
|
||
return state
|
||
|
||
# установить плечо
|
||
def set_leverage(self, leverage: float) -> AutoTradeState:
|
||
state = self.get_state()
|
||
state.leverage = leverage
|
||
return state
|
||
|
||
# установить stop loss в %
|
||
def set_stop_loss_percent(self, value: float | None) -> AutoTradeState:
|
||
state = self.get_state()
|
||
state.stop_loss_percent = value
|
||
return state
|
||
|
||
# установить take profit в %
|
||
def set_take_profit_percent(self, value: float | None) -> AutoTradeState:
|
||
state = self.get_state()
|
||
state.take_profit_percent = value
|
||
return state
|
||
|
||
# установить max loss в USD
|
||
def set_max_loss_usd(self, value: float | None) -> AutoTradeState:
|
||
state = self.get_state()
|
||
state.max_loss_usd = value
|
||
return state
|
||
|
||
# установить максимальное использование баланса под маржу
|
||
def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState:
|
||
state = self.get_state()
|
||
state.max_reserved_balance_percent = value
|
||
state.execution_block_reason = None
|
||
return state
|
||
|
||
# сбросить внутренний трекинг сигналов
|
||
def _reset_signal_tracking(self) -> None:
|
||
self._last_signal_key = None
|
||
self._last_signal_value = None
|
||
self._last_signal_reason = ""
|
||
self._last_signal_confidence = 0.0
|
||
self._last_signal_payload = None
|
||
self._last_signal_started_at = None
|
||
self._same_signal_count = 0
|
||
|
||
state = self.get_state()
|
||
state.last_signal_repeat_count = 0
|
||
state.last_signal_confidence = 0.0
|
||
state.last_signal_reason = None
|
||
state.decision_status = "WAITING"
|
||
state.decision_reason = None
|
||
state.is_signal_confirmed = False
|
||
state.is_signal_ready = False
|
||
state.signal_confirmation_seconds = 0
|
||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||
state.signal_confirmation_progress = 0.0
|
||
state.signal_confirmation_reason = None
|
||
state.execution_block_reason = None
|
||
state.execution_semantic_status = None
|
||
state.execution_semantic_message = None
|
||
state.execution_semantic_reason = None
|
||
state.signal_started_at = None
|
||
state.signal_updated_at = None
|
||
state.market_state = None
|
||
state.market_trend = None
|
||
state.market_volatility = None
|
||
state.market_analysis_interval = None
|
||
state.market_analysis_reason = None
|
||
state.market_analysis_updated_at = None
|
||
state.market_runtime_degraded = False
|
||
state.market_trend_strength = None
|
||
state.market_trend_quality = None
|
||
state.market_phase = None
|
||
state.market_phase_direction = None
|
||
state.entry_block_reason = None
|
||
state.entry_block_message = None
|
||
state.runtime_expired_reason = None
|
||
state.runtime_expired_message = None
|
||
state.snapshot_age_seconds = None
|
||
state.spread_percent = None
|
||
state.execution_quality = None
|
||
state.execution_quality_reason = None
|
||
state.execution_quality_message = None
|
||
|
||
# собрать контекст для стратегии
|
||
def _build_strategy_context(self) -> StrategyContext:
|
||
state = self.get_state()
|
||
|
||
return StrategyContext(
|
||
symbol=state.symbol,
|
||
status=state.status,
|
||
risk_percent=state.risk_percent,
|
||
)
|
||
|
||
# получить стратегию для текущего цикла
|
||
def _get_strategy(self) -> BaseStrategy:
|
||
state = self.get_state()
|
||
return StrategyRegistry.get(state.strategy)
|
||
|
||
# определить смысл сигнала с учетом открытой позиции
|
||
def _signal_intent(self, *, state: AutoTradeState, signal: str | None) -> str:
|
||
normalized_signal = (signal or "HOLD").upper()
|
||
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
|
||
|
||
if normalized_signal == "HOLD":
|
||
return "HOLD_MARKET"
|
||
|
||
if normalized_signal not in {"BUY", "SELL"}:
|
||
return "NOISE"
|
||
|
||
if position_side == "NONE":
|
||
return "ENTRY_CANDIDATE"
|
||
|
||
if position_side == "LONG" and normalized_signal == "BUY":
|
||
return "REINFORCE_POSITION"
|
||
|
||
if position_side == "SHORT" and normalized_signal == "SELL":
|
||
return "REINFORCE_POSITION"
|
||
|
||
if position_side == "LONG" and normalized_signal == "SELL":
|
||
return "REVERSAL_CANDIDATE"
|
||
|
||
if position_side == "SHORT" and normalized_signal == "BUY":
|
||
return "REVERSAL_CANDIDATE"
|
||
|
||
return "NOISE"
|
||
|
||
# обновить статус решения по текущему сигналу
|
||
def _update_decision_state(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
confidence: float,
|
||
) -> None:
|
||
state.is_signal_confirmed = False
|
||
state.is_signal_ready = False
|
||
|
||
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
|
||
|
||
if signal == "HOLD":
|
||
state.signal_confirmation_seconds = 0
|
||
state.signal_confirmation_missing_repeats = self._confirm_repeats
|
||
state.signal_confirmation_progress = 0.0
|
||
state.signal_confirmation_reason = None
|
||
state.decision_status = "WAITING"
|
||
state.decision_reason = "Нет торгового направления."
|
||
return
|
||
|
||
now = time.monotonic()
|
||
|
||
if state.signal_started_at is None:
|
||
signal_age_seconds = 0
|
||
else:
|
||
signal_age_seconds = max(0, int(now - float(state.signal_started_at)))
|
||
|
||
missing_repeats = max(0, self._confirm_repeats - self._same_signal_count)
|
||
missing_seconds = max(
|
||
0,
|
||
self._confirm_min_duration_seconds - signal_age_seconds,
|
||
)
|
||
|
||
repeat_progress = min(
|
||
1.0,
|
||
self._same_signal_count / max(1, self._confirm_repeats),
|
||
)
|
||
time_progress = min(
|
||
1.0,
|
||
signal_age_seconds / max(1, self._confirm_min_duration_seconds),
|
||
)
|
||
|
||
confirmation_progress = min(repeat_progress, time_progress)
|
||
|
||
state.signal_confirmation_seconds = signal_age_seconds
|
||
state.signal_confirmation_missing_repeats = missing_repeats
|
||
state.signal_confirmation_progress = round(confirmation_progress, 3)
|
||
|
||
if missing_repeats > 0 or missing_seconds > 0:
|
||
state.decision_status = "CONFIRMING"
|
||
state.signal_confirmation_reason = (
|
||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с"
|
||
)
|
||
state.decision_reason = (
|
||
f"Сигнал {signal} подтверждается: "
|
||
f"{self._same_signal_count}/{self._confirm_repeats} повторов, "
|
||
f"{signal_age_seconds}/{self._confirm_min_duration_seconds}с."
|
||
)
|
||
return
|
||
|
||
state.is_signal_confirmed = True
|
||
state.signal_confirmation_reason = "сигнал подтверждён"
|
||
|
||
if confidence < self._ready_confidence:
|
||
state.decision_status = "BLOCKED"
|
||
state.decision_reason = (
|
||
f"Сигнал {signal} подтверждён, но уверенность низкая: "
|
||
f"{confidence:.2f} < {self._ready_confidence:.2f}."
|
||
)
|
||
return
|
||
|
||
state.is_signal_ready = True
|
||
state.signal_confirmation_progress = 1.0
|
||
state.decision_status = "READY"
|
||
state.decision_reason = (
|
||
f"Сигнал {signal} подтверждён по повторам и времени удержания."
|
||
)
|
||
|
||
# записать новый сигнал и итог предыдущей серии при смене сигнала
|
||
def _log_signal_if_changed(
|
||
self,
|
||
*,
|
||
strategy_name: str,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
payload: dict | None,
|
||
) -> None:
|
||
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
|
||
previous_signal = self._last_signal_value
|
||
previous_count = self._same_signal_count
|
||
is_same_signal = signal_key == self._last_signal_key
|
||
now = time.monotonic()
|
||
|
||
if is_same_signal:
|
||
self._same_signal_count += 1
|
||
self._last_signal_reason = reason
|
||
self._last_signal_confidence = confidence
|
||
self._last_signal_payload = payload
|
||
|
||
self._update_signal_state_fields(
|
||
state=state,
|
||
signal=signal,
|
||
reason=reason,
|
||
confidence=confidence,
|
||
)
|
||
return
|
||
|
||
if previous_signal is not None and previous_signal != signal:
|
||
if previous_count > 1:
|
||
self._log_signal_summary(
|
||
strategy_name=strategy_name,
|
||
state=state,
|
||
previous_signal=previous_signal,
|
||
previous_count=previous_count,
|
||
next_signal=signal,
|
||
reason=self._last_signal_reason,
|
||
confidence=self._last_signal_confidence,
|
||
payload=self._last_signal_payload,
|
||
duration_seconds=self._signal_duration_seconds(now=now),
|
||
)
|
||
else:
|
||
self._log_signal_event(
|
||
strategy_name=strategy_name,
|
||
state=state,
|
||
signal=previous_signal,
|
||
reason=f"{previous_signal} завершился без серии.",
|
||
confidence=self._last_signal_confidence,
|
||
payload={
|
||
"previous_signal": previous_signal,
|
||
"next_signal": signal,
|
||
},
|
||
)
|
||
|
||
self._last_signal_key = signal_key
|
||
self._last_signal_value = signal
|
||
self._last_signal_reason = reason
|
||
self._last_signal_confidence = confidence
|
||
self._last_signal_payload = payload
|
||
self._last_signal_started_at = now
|
||
self._same_signal_count = 1
|
||
|
||
self._update_signal_state_fields(
|
||
state=state,
|
||
signal=signal,
|
||
reason=reason,
|
||
confidence=confidence,
|
||
)
|
||
|
||
def _signal_duration_seconds(self, *, now: float) -> int:
|
||
if self._last_signal_started_at is None:
|
||
return max(0, int(self._same_signal_count * self._loop_interval_seconds))
|
||
|
||
return max(0, int(now - self._last_signal_started_at))
|
||
|
||
def _format_duration(self, total_seconds: int) -> str:
|
||
total_seconds = max(0, int(total_seconds))
|
||
|
||
hours = total_seconds // 3600
|
||
minutes = (total_seconds % 3600) // 60
|
||
seconds = total_seconds % 60
|
||
|
||
if hours > 0:
|
||
return f"{hours}ч {minutes:02d}м {seconds:02d}с"
|
||
|
||
if minutes > 0:
|
||
return f"{minutes}м {seconds:02d}с"
|
||
|
||
return f"{seconds}с"
|
||
|
||
# обновить поля state для экрана автоторговли
|
||
def _update_signal_state_fields(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
) -> None:
|
||
previous_signal = state.last_signal
|
||
previous_decision_status = state.decision_status
|
||
|
||
if previous_signal != signal or state.signal_started_at is None:
|
||
state.signal_started_at = time.monotonic()
|
||
|
||
state.last_signal = signal
|
||
state.last_signal_repeat_count = self._same_signal_count
|
||
state.last_signal_confidence = confidence
|
||
state.last_signal_reason = reason
|
||
state.signal_updated_at = time.monotonic()
|
||
state.runtime_expired_reason = None
|
||
state.runtime_expired_message = None
|
||
|
||
self._update_decision_state(
|
||
state=state,
|
||
signal=signal,
|
||
confidence=confidence,
|
||
)
|
||
|
||
signal_intent = self._signal_intent(
|
||
state=state,
|
||
signal=state.last_signal,
|
||
)
|
||
|
||
if (
|
||
previous_decision_status != state.decision_status
|
||
and state.decision_status == "READY"
|
||
):
|
||
self._log_ready_signal(
|
||
state=state,
|
||
signal=state.last_signal,
|
||
reason=state.last_signal_reason or reason,
|
||
confidence=state.last_signal_confidence,
|
||
signal_intent=signal_intent,
|
||
)
|
||
|
||
if previous_signal != state.last_signal:
|
||
EventBus.emit(
|
||
"auto_signal_changed",
|
||
{
|
||
"previous_signal": previous_signal,
|
||
"signal": state.last_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"confidence": state.last_signal_confidence,
|
||
},
|
||
)
|
||
|
||
if previous_decision_status != state.decision_status:
|
||
EventBus.emit(
|
||
"auto_decision_changed",
|
||
{
|
||
"previous_decision_status": previous_decision_status,
|
||
"decision_status": state.decision_status,
|
||
"signal": state.last_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"confidence": state.last_signal_confidence,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"leverage": state.leverage,
|
||
"reason": state.last_signal_reason,
|
||
},
|
||
)
|
||
|
||
# одиночные BUY / SELL больше не пишем в журнал как полезные события
|
||
def _log_signal_event(
|
||
self,
|
||
*,
|
||
strategy_name: str,
|
||
state: AutoTradeState,
|
||
signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
payload: dict | None,
|
||
) -> None:
|
||
return
|
||
|
||
# записать итог серии одинаковых сигналов при смене сигнала
|
||
def _log_signal_summary(
|
||
self,
|
||
*,
|
||
strategy_name: str,
|
||
state: AutoTradeState,
|
||
previous_signal: str,
|
||
previous_count: int,
|
||
next_signal: str,
|
||
reason: str,
|
||
confidence: float,
|
||
payload: dict | None,
|
||
duration_seconds: int,
|
||
) -> None:
|
||
if previous_signal != "HOLD":
|
||
return
|
||
|
||
duration_text = self._format_duration(duration_seconds)
|
||
signal_intent = "HOLD_MARKET"
|
||
|
||
try:
|
||
JournalService().log_ui_info(
|
||
event_type="signal_summary",
|
||
message=(
|
||
f"HOLD длился {duration_text} и завершился сигналом {next_signal}."
|
||
),
|
||
screen="auto",
|
||
action="signal_summary",
|
||
payload={
|
||
"strategy": strategy_name,
|
||
"status": state.status,
|
||
"symbol": state.symbol,
|
||
"signal": previous_signal,
|
||
"next_signal": next_signal,
|
||
"signal_intent": signal_intent,
|
||
"repeat_count": previous_count,
|
||
"duration_seconds": duration_seconds,
|
||
"duration_text": duration_text,
|
||
"confidence": confidence,
|
||
"reason": reason,
|
||
"is_strong_signal": False,
|
||
"is_aggregated": True,
|
||
"payload": payload or {},
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _log_ready_signal(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
signal: str | None,
|
||
reason: str,
|
||
confidence: float,
|
||
signal_intent: str,
|
||
) -> None:
|
||
normalized_signal = (signal or "HOLD").upper()
|
||
if normalized_signal not in {"BUY", "SELL"}:
|
||
return
|
||
|
||
try:
|
||
JournalService().log_ui_info(
|
||
event_type="signal_ready",
|
||
message=(
|
||
f"Сигнал {normalized_signal} подтверждён и готов к исполнению."
|
||
),
|
||
screen="auto",
|
||
action="signal_ready",
|
||
payload={
|
||
"strategy": state.strategy,
|
||
"status": state.status,
|
||
"symbol": state.symbol,
|
||
"signal": normalized_signal,
|
||
"signal_intent": signal_intent,
|
||
"confidence": confidence,
|
||
"reason": reason,
|
||
"repeat_count": state.last_signal_repeat_count,
|
||
"position_side": state.position_side,
|
||
"decision_status": state.decision_status,
|
||
"is_strong_signal": confidence > self._ready_confidence,
|
||
"is_aggregated": False,
|
||
"confirmation_seconds": state.signal_confirmation_seconds,
|
||
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
|
||
"confirmation_progress": state.signal_confirmation_progress,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _sync_market_analysis_state(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
payload: dict | None,
|
||
) -> None:
|
||
if not isinstance(payload, dict):
|
||
return
|
||
|
||
previous_market_state = state.market_state
|
||
previous_market_trend = state.market_trend
|
||
previous_market_volatility = state.market_volatility
|
||
|
||
state.market_state = payload.get("market_state")
|
||
state.market_trend = payload.get("market_trend")
|
||
state.market_volatility = payload.get("market_volatility")
|
||
state.market_trend_strength = payload.get("market_trend_strength")
|
||
state.market_trend_quality = payload.get("market_trend_quality")
|
||
state.market_phase = payload.get("market_phase")
|
||
state.market_phase_direction = payload.get("market_phase_direction")
|
||
state.market_analysis_interval = payload.get("market_analysis_interval")
|
||
state.market_analysis_reason = payload.get("market_analysis_reason")
|
||
state.market_analysis_updated_at = time.monotonic()
|
||
state.entry_block_reason = payload.get("entry_block_reason")
|
||
state.entry_block_message = payload.get("entry_block_message")
|
||
|
||
self._log_market_state_if_changed(
|
||
state=state,
|
||
payload=payload,
|
||
previous_market_state=previous_market_state,
|
||
previous_market_trend=previous_market_trend,
|
||
previous_market_volatility=previous_market_volatility,
|
||
)
|
||
|
||
self._log_entry_block_if_changed(
|
||
state=state,
|
||
payload=payload,
|
||
)
|
||
|
||
def _log_entry_block_if_changed(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
payload: dict,
|
||
) -> None:
|
||
reason = state.entry_block_reason
|
||
message = state.entry_block_message
|
||
|
||
if not reason or not message:
|
||
return
|
||
|
||
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}:{message}"
|
||
|
||
if key == type(self)._last_logged_entry_block_reason:
|
||
return
|
||
|
||
type(self)._last_logged_entry_block_reason = key
|
||
|
||
try:
|
||
JournalService().log_ui_info(
|
||
event_type="entry_blocked",
|
||
message=f"Вход в позицию не выполнен: {message}.",
|
||
screen="auto",
|
||
action="entry_diagnostics",
|
||
payload={
|
||
**payload,
|
||
"entry_block_reason": reason,
|
||
"entry_block_message": message,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"status": state.status,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _log_market_state_if_changed(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
payload: dict,
|
||
previous_market_state: str | None,
|
||
previous_market_trend: str | None,
|
||
previous_market_volatility: str | None,
|
||
) -> None:
|
||
market_state = state.market_state
|
||
market_trend = state.market_trend
|
||
market_volatility = state.market_volatility
|
||
|
||
if not market_state or market_state == "UNKNOWN":
|
||
return
|
||
|
||
state_changed = (
|
||
market_state != previous_market_state
|
||
and market_state != type(self)._last_logged_market_state
|
||
)
|
||
|
||
volatility_changed = (
|
||
market_volatility is not None
|
||
and market_volatility != previous_market_volatility
|
||
and market_volatility != type(self)._last_logged_market_volatility
|
||
)
|
||
|
||
if not state_changed and not volatility_changed:
|
||
return
|
||
|
||
journal_payload = {
|
||
**payload,
|
||
"previous_market_state": previous_market_state,
|
||
"previous_market_trend": previous_market_trend,
|
||
"previous_market_volatility": previous_market_volatility,
|
||
"current_market_state": market_state,
|
||
"current_market_trend": market_trend,
|
||
"current_market_volatility": market_volatility,
|
||
}
|
||
|
||
try:
|
||
if state_changed:
|
||
self._write_market_journal_event(
|
||
event_type="market_state_changed",
|
||
market_state=market_state,
|
||
message=self._market_state_message(market_state),
|
||
payload=journal_payload,
|
||
)
|
||
|
||
if volatility_changed:
|
||
self._write_market_journal_event(
|
||
event_type="market_volatility_changed",
|
||
market_state=market_state,
|
||
message=self._market_volatility_message(market_volatility),
|
||
payload=journal_payload,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
type(self)._last_logged_market_state = market_state
|
||
type(self)._last_logged_market_trend = market_trend
|
||
type(self)._last_logged_market_volatility = market_volatility
|
||
|
||
def _write_market_journal_event(
|
||
self,
|
||
*,
|
||
event_type: str,
|
||
market_state: str,
|
||
message: str,
|
||
payload: dict,
|
||
) -> None:
|
||
level = self._market_journal_level(market_state)
|
||
|
||
if level == "WARNING":
|
||
JournalService().log_ui_warning(
|
||
event_type=event_type,
|
||
message=message,
|
||
screen="auto",
|
||
action="market_analysis",
|
||
payload=payload,
|
||
)
|
||
return
|
||
|
||
JournalService().log_ui_info(
|
||
event_type=event_type,
|
||
message=message,
|
||
screen="auto",
|
||
action="market_analysis",
|
||
payload=payload,
|
||
)
|
||
|
||
def _market_volatility_message(self, market_volatility: str | None) -> str:
|
||
messages = {
|
||
"LOW": "Волатильность изменена: низкая.",
|
||
"NORMAL": "Волатильность изменена: нормальная.",
|
||
"HIGH": "Волатильность изменена: высокая.",
|
||
}
|
||
|
||
return messages.get(str(market_volatility or ""), "Волатильность не определена.")
|
||
|
||
def _market_journal_level(self, market_state: str) -> str:
|
||
if market_state == "HIGH_VOLATILITY":
|
||
return "WARNING"
|
||
|
||
return "INFO"
|
||
|
||
def _market_state_message(self, market_state: str) -> str:
|
||
messages = {
|
||
"TREND_UP": "Состояние рынка изменено: рост.",
|
||
"TREND_DOWN": "Состояние рынка изменено: снижение.",
|
||
"RANGE": "Состояние рынка изменено: нет выраженного направления.",
|
||
"HIGH_VOLATILITY": "Состояние рынка изменено: высокая волатильность.",
|
||
"LOW_VOLATILITY": "Состояние рынка изменено: низкая активность.",
|
||
}
|
||
|
||
return messages.get(market_state, "Состояние рынка анализируется.")
|
||
|
||
def _expire_runtime_if_needed(self, state: AutoTradeState) -> None:
|
||
now = time.monotonic()
|
||
|
||
signal_updated_at = getattr(state, "signal_updated_at", None)
|
||
if signal_updated_at is not None:
|
||
signal_age = now - float(signal_updated_at)
|
||
|
||
if signal_age > self._signal_ttl_seconds:
|
||
previous_signal = state.last_signal
|
||
|
||
self._reset_signal_tracking()
|
||
|
||
state.runtime_expired_reason = "SIGNAL_TTL_EXPIRED"
|
||
state.runtime_expired_message = "сигнал устарел и был сброшен"
|
||
|
||
self._log_runtime_expired_if_changed(
|
||
state=state,
|
||
reason="SIGNAL_TTL_EXPIRED",
|
||
message="Сигнал устарел и был сброшен.",
|
||
payload={
|
||
"previous_signal": previous_signal,
|
||
"signal_age_seconds": int(signal_age),
|
||
"signal_ttl_seconds": self._signal_ttl_seconds,
|
||
},
|
||
)
|
||
|
||
return
|
||
|
||
market_updated_at = getattr(state, "market_analysis_updated_at", None)
|
||
if market_updated_at is not None:
|
||
market_age = now - float(market_updated_at)
|
||
|
||
if market_age > self._market_analysis_ttl_seconds:
|
||
state.market_state = None
|
||
state.market_trend = None
|
||
state.market_volatility = None
|
||
state.market_analysis_interval = None
|
||
state.market_analysis_reason = None
|
||
state.market_analysis_updated_at = None
|
||
state.entry_block_reason = None
|
||
state.entry_block_message = None
|
||
state.runtime_expired_reason = "MARKET_ANALYSIS_TTL_EXPIRED"
|
||
state.runtime_expired_message = "анализ рынка устарел"
|
||
|
||
self._log_runtime_expired_if_changed(
|
||
state=state,
|
||
reason="MARKET_ANALYSIS_TTL_EXPIRED",
|
||
message="Анализ рынка устарел и был сброшен.",
|
||
payload={
|
||
"market_age_seconds": int(market_age),
|
||
"market_analysis_ttl_seconds": self._market_analysis_ttl_seconds,
|
||
},
|
||
)
|
||
|
||
def _log_runtime_expired_if_changed(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
reason: str,
|
||
message: str,
|
||
payload: dict,
|
||
) -> None:
|
||
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
|
||
|
||
if key == type(self)._last_logged_runtime_expired_key:
|
||
return
|
||
|
||
type(self)._last_logged_runtime_expired_key = key
|
||
|
||
try:
|
||
JournalService().log_ui_warning(
|
||
event_type="runtime_expired",
|
||
message=message,
|
||
screen="auto",
|
||
action="runtime_expiration",
|
||
payload={
|
||
**payload,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"status": state.status,
|
||
"runtime_expired_reason": reason,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _sync_execution_quality_state(self, state: AutoTradeState) -> None:
|
||
try:
|
||
snapshot = ExchangeService().get_market_snapshot(
|
||
state.symbol,
|
||
runtime_key="auto",
|
||
)
|
||
except Exception as exc:
|
||
fallback_price = None
|
||
|
||
try:
|
||
fallback_price = float(
|
||
ExchangeService().get_price(
|
||
state.symbol,
|
||
runtime_key="auto",
|
||
).price
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
state.snapshot_age_seconds = None
|
||
state.spread_percent = None
|
||
|
||
if fallback_price is not None and fallback_price > 0:
|
||
state.execution_quality = "WARNING"
|
||
state.execution_quality_reason = "SNAPSHOT_UNAVAILABLE"
|
||
state.execution_quality_message = "нет depth snapshot"
|
||
state.market_runtime_degraded = True
|
||
else:
|
||
state.execution_quality = "BLOCKED"
|
||
state.execution_quality_reason = "SNAPSHOT_ERROR"
|
||
state.execution_quality_message = "нет данных рынка"
|
||
state.market_runtime_degraded = True
|
||
|
||
self._log_execution_quality_if_changed(
|
||
state=state,
|
||
payload={
|
||
"error": str(exc),
|
||
"error_type": type(exc).__name__,
|
||
"fallback_price_available": fallback_price is not None,
|
||
},
|
||
)
|
||
return
|
||
|
||
bid_price = self._safe_float(snapshot.get("bid_price"))
|
||
ask_price = self._safe_float(snapshot.get("ask_price"))
|
||
last_price = self._safe_float(snapshot.get("last_price"))
|
||
age_seconds = self._safe_float(snapshot.get("age_seconds"))
|
||
is_fresh = bool(snapshot.get("is_fresh", False))
|
||
source = str(snapshot.get("source") or "")
|
||
|
||
state.snapshot_age_seconds = age_seconds
|
||
state.spread_percent = self._spread_percent(
|
||
bid_price=bid_price,
|
||
ask_price=ask_price,
|
||
)
|
||
|
||
if age_seconds is not None and age_seconds > self._max_snapshot_age_seconds:
|
||
state.execution_quality = "BLOCKED"
|
||
state.execution_quality_reason = "STALE_SNAPSHOT"
|
||
state.execution_quality_message = "snapshot устарел"
|
||
state.market_runtime_degraded = True
|
||
|
||
elif age_seconds is not None and age_seconds > self._warning_snapshot_age_seconds:
|
||
state.execution_quality = "WARNING"
|
||
state.execution_quality_reason = "AGING_SNAPSHOT"
|
||
state.execution_quality_message = "snapshot стареет"
|
||
state.market_runtime_degraded = not is_fresh
|
||
|
||
elif state.spread_percent is not None:
|
||
(
|
||
state.execution_quality,
|
||
state.execution_quality_reason,
|
||
state.execution_quality_message,
|
||
state.market_runtime_degraded,
|
||
) = self._spread_execution_quality(
|
||
state=state,
|
||
spread_percent=state.spread_percent,
|
||
)
|
||
|
||
else:
|
||
state.execution_quality = "GOOD"
|
||
state.execution_quality_reason = "MARKET_OK"
|
||
state.execution_quality_message = "рынок готов"
|
||
state.market_runtime_degraded = False
|
||
|
||
if state.execution_quality == "BLOCKED":
|
||
state.execution_block_reason = state.execution_quality_message
|
||
|
||
elif state.execution_block_reason == state.execution_quality_message:
|
||
state.execution_block_reason = None
|
||
|
||
self._log_execution_quality_if_changed(
|
||
state=state,
|
||
payload={
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
"bid_price": bid_price,
|
||
"ask_price": ask_price,
|
||
"last_price": last_price,
|
||
"snapshot_age_seconds": age_seconds,
|
||
"spread_percent": state.spread_percent,
|
||
"is_fresh": is_fresh,
|
||
"source": source,
|
||
"execution_quality": state.execution_quality,
|
||
"execution_quality_reason": state.execution_quality_reason,
|
||
"execution_quality_message": state.execution_quality_message,
|
||
"market_runtime_degraded": state.market_runtime_degraded,
|
||
"max_snapshot_age_seconds": self._max_snapshot_age_seconds,
|
||
"warning_snapshot_age_seconds": self._warning_snapshot_age_seconds,
|
||
"spread_warning_enter_percent": self._spread_warning_enter_percent,
|
||
"spread_warning_exit_percent": self._spread_warning_exit_percent,
|
||
"spread_block_enter_percent": self._spread_block_enter_percent,
|
||
"spread_block_exit_percent": self._spread_block_exit_percent,
|
||
},
|
||
)
|
||
|
||
def _spread_percent(
|
||
self,
|
||
*,
|
||
bid_price: float | None,
|
||
ask_price: float | None,
|
||
) -> float | None:
|
||
if bid_price is None or ask_price is None:
|
||
return None
|
||
|
||
if bid_price <= 0 or ask_price <= 0:
|
||
return None
|
||
|
||
mid_price = (bid_price + ask_price) / 2
|
||
if mid_price <= 0:
|
||
return None
|
||
|
||
spread = ask_price - bid_price
|
||
if spread < 0:
|
||
return None
|
||
|
||
return round((spread / mid_price) * 100, 5)
|
||
|
||
def _safe_float(self, value: object) -> float | None:
|
||
if value is None:
|
||
return None
|
||
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
def _log_execution_quality_if_changed(
|
||
self,
|
||
*,
|
||
state: AutoTradeState,
|
||
payload: dict,
|
||
) -> None:
|
||
quality = state.execution_quality
|
||
reason = state.execution_quality_reason
|
||
message = state.execution_quality_message
|
||
|
||
if not quality or not reason or not message:
|
||
return
|
||
|
||
key = f"{state.status}:{state.symbol}:{state.strategy}:{quality}:{reason}:{message}"
|
||
|
||
if key == type(self)._last_logged_execution_quality_key:
|
||
return
|
||
|
||
type(self)._last_logged_execution_quality_key = key
|
||
|
||
if quality == "GOOD":
|
||
return
|
||
|
||
try:
|
||
log_payload = {
|
||
**payload,
|
||
"status": state.status,
|
||
"symbol": state.symbol,
|
||
"strategy": state.strategy,
|
||
}
|
||
|
||
if quality == "BLOCKED":
|
||
JournalService().log_ui_warning(
|
||
event_type="execution_quality_changed",
|
||
message=f"Качество исполнения: {message}.",
|
||
screen="auto",
|
||
action="execution_quality",
|
||
payload=log_payload,
|
||
)
|
||
return
|
||
|
||
JournalService().log_ui_info(
|
||
event_type="execution_quality_changed",
|
||
message=f"Качество исполнения: {message}.",
|
||
screen="auto",
|
||
action="execution_quality",
|
||
payload=log_payload,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
|
||
if state.execution_quality == "BLOCKED":
|
||
state.execution_semantic_status = "BLOCKED"
|
||
state.execution_semantic_message = self._execution_block_semantic_message(state)
|
||
state.execution_semantic_reason = state.execution_quality_reason
|
||
return
|
||
|
||
if state.position_side != "NONE":
|
||
state.execution_semantic_status = "POSITION_OPEN"
|
||
state.execution_semantic_message = "📌 Исполнение · позиция открыта"
|
||
state.execution_semantic_reason = state.last_execution_reason
|
||
return
|
||
|
||
if state.decision_status == "READY" and state.is_signal_ready:
|
||
state.execution_semantic_status = "READY"
|
||
state.execution_semantic_message = "✅ Исполнение · готово"
|
||
state.execution_semantic_reason = state.decision_reason
|
||
return
|
||
|
||
if state.decision_status == "CONFIRMING":
|
||
state.execution_semantic_status = "WAITING_SIGNAL"
|
||
state.execution_semantic_message = "⏳ Исполнение · ждёт подтверждения"
|
||
state.execution_semantic_reason = state.decision_reason
|
||
return
|
||
|
||
if state.last_signal in {"BUY", "SELL"}:
|
||
state.execution_semantic_status = "WAITING_SIGNAL"
|
||
state.execution_semantic_message = "⏳ Исполнение · сигнал проверяется"
|
||
state.execution_semantic_reason = state.decision_reason
|
||
return
|
||
|
||
state.execution_semantic_status = "IDLE"
|
||
state.execution_semantic_message = ""
|
||
state.execution_semantic_reason = state.decision_reason
|
||
|
||
def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
|
||
reason = state.execution_quality_reason
|
||
|
||
if reason == "STALE_SNAPSHOT":
|
||
return "⛔ Исполнение · рынок неактуален"
|
||
|
||
if reason == "HIGH_SPREAD":
|
||
return "⛔ Исполнение · высокий spread"
|
||
|
||
if reason == "SNAPSHOT_ERROR":
|
||
return "⛔ Исполнение · нет данных рынка"
|
||
|
||
if reason == "SNAPSHOT_UNAVAILABLE":
|
||
return "⚠️ Исполнение · нет стакана"
|
||
|
||
return "⛔ Исполнение · заблокировано"
|
||
|
||
def run_cycle(self) -> AutoTradeState:
|
||
state = self.get_state()
|
||
|
||
if state.status == "OFF":
|
||
return state
|
||
|
||
self._expire_runtime_if_needed(state)
|
||
|
||
strategy = self._get_strategy()
|
||
context = self._build_strategy_context()
|
||
result = strategy.analyze(context)
|
||
|
||
self._sync_market_analysis_state(
|
||
state=state,
|
||
payload=result.payload,
|
||
)
|
||
|
||
self._sync_execution_quality_state(state)
|
||
|
||
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
||
|
||
self._log_signal_if_changed(
|
||
strategy_name=strategy.name,
|
||
state=state,
|
||
signal=result.signal.value,
|
||
reason=result.reason,
|
||
confidence=result.confidence,
|
||
payload=result.payload,
|
||
)
|
||
|
||
if state.execution_quality != "BLOCKED":
|
||
ExecutionEngine().process(state)
|
||
|
||
self._sync_execution_semantic_state(state)
|
||
|
||
return state |