07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer

This commit is contained in:
2026-05-20 21:15:00 +03:00
parent 2c75f95b46
commit 06ea376cb5
36 changed files with 6260 additions and 2092 deletions

View File

@@ -4,12 +4,16 @@ from __future__ import annotations
import asyncio
import time
from typing import Callable
from collections.abc import Callable
from typing import ClassVar
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.core.event_bus import EventBus
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.notifications.targets import NotificationTargetRegistry
from src.runtime_events.event_types import RuntimeEventType
@@ -17,30 +21,27 @@ from src.runtime_events.models import RuntimeEvent
from src.runtime_events.publisher import RuntimeEventPublisher
from src.trading.auto.service import AutoTradeService
from src.trading.journal.service import JournalService
from src.telegram.handlers.auto.ui import build_auto_semantic_text
from src.telegram.handlers.auto.ui import build_auto_notification_text
from src.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner:
_task: asyncio.Task | None = None
_bot: Bot | None = None
_chat_id: int | None = None
_message_id: int | None = None
_render_text: Callable[[], str] | None = None
_render_markup: Callable[[], object] | None = None
_current_screen: str | None = None
_task: ClassVar[asyncio.Task | None] = None
_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
_current_screen: ClassVar[str | None] = None
_analysis_interval_seconds = 5
_ui_interval_seconds = 30
_last_text: str | None = None
_last_semantic_text: str | None = None
_last_ui_refresh_at: float = 0.0
_last_event_version: int = 0
_retry_after_until: float = 0.0
_last_screen_state_key: str | None = None
_last_text: ClassVar[str | None] = None
_last_semantic_text: ClassVar[str | None] = None
_last_ui_refresh_at: ClassVar[float] = 0.0
_last_event_version: ClassVar[int] = 0
_retry_after_until: ClassVar[float] = 0.0
_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] = {}
@@ -57,8 +58,8 @@ class AutoTradeRunner:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
cls._render_text = render_text
cls._render_markup = render_markup
cls._render_text = staticmethod(render_text)
cls._render_markup = staticmethod(render_markup)
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@@ -260,8 +261,13 @@ class AutoTradeRunner:
await cls._handle_important_event(state)
@classmethod
async def _handle_important_event(cls, state) -> None:
async def _handle_important_event(
cls,
state,
) -> None:
event_type, payload = EventBus.last_event()
if not isinstance(payload, dict):
payload = {}
if event_type == "auto_decision_changed":
if payload.get("decision_status") != "READY":
@@ -298,7 +304,10 @@ class AutoTradeRunner:
return
@classmethod
def _notification_reason_lines(cls, state) -> list[str]:
def _notification_reason_lines(
cls,
state,
) -> list[str]:
snapshot = SemanticDiagnosticSnapshotBuilder().build(
state,
is_configured=True,
@@ -326,14 +335,29 @@ class AutoTradeRunner:
cls,
*,
state,
payload: dict,
payload: JsonDict,
signal: str,
) -> None:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "")
confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0)
repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 0)
confidence = safe_float(
payload.get("confidence")
)
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
log_key = (
f"{position_side}:"
@@ -377,12 +401,31 @@ class AutoTradeRunner:
pass
@classmethod
def _publish_strong_signal_event(cls, *, state, payload: dict) -> None:
def _publish_strong_signal_event(
cls,
*,
state,
payload: JsonDict,
) -> None:
signal = str(payload.get("signal", "")).upper()
symbol = str(payload.get("symbol") or state.symbol or "")
strategy = str(payload.get("strategy") or state.strategy or "")
repeat_count = int(payload.get("repeat_count") or state.last_signal_repeat_count or 0)
confidence = float(payload.get("confidence") or state.last_signal_confidence or 0.0)
repeat_count_value = (
payload.get("repeat_count")
if payload.get("repeat_count") is not None
else state.last_signal_repeat_count
)
repeat_count = int(safe_float(repeat_count_value) or 0)
confidence = safe_float(
payload.get("confidence")
)
if confidence is None:
confidence = safe_float(state.last_signal_confidence)
if confidence is None:
confidence = 0.0
leverage = payload.get("leverage") if payload.get("leverage") is not None else state.leverage
reason = str(payload.get("reason") or state.last_signal_reason or "")
position_context = str(getattr(state, "position_side", "NONE") or "NONE")
@@ -409,6 +452,9 @@ class AutoTradeRunner:
"decision_status": state.decision_status,
"semantic_lines": cls._notification_reason_lines(state),
"position_side": position_context,
"bid_price": payload.get("bid_price"),
"ask_price": payload.get("ask_price"),
"last_price": payload.get("last_price"),
},
priority=priority.lower(),
dedupe_key=(
@@ -431,7 +477,7 @@ class AutoTradeRunner:
*,
state,
event_type: str,
payload: dict,
payload: JsonDict,
) -> None:
runtime_event_type = cls._runtime_execution_event_type(event_type)
if runtime_event_type is None:
@@ -450,13 +496,17 @@ class AutoTradeRunner:
source="auto_trade_runner",
title=cls._execution_event_title(runtime_event_type),
payload={
**payload,
"source_event_type": event_type,
"symbol": symbol,
"side": side,
"old_side": old_side,
"new_side": new_side,
"leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage,
**payload,
"leverage": (
payload.get("leverage")
if payload.get("leverage") is not None
else state.leverage
),
"strategy": state.strategy,
"semantic_lines": semantic_lines,
},
@@ -493,7 +543,7 @@ class AutoTradeRunner:
cls,
*,
runtime_event_type: RuntimeEventType,
payload: dict,
payload: JsonDict,
) -> str:
return (
f"{runtime_event_type.value}:"
@@ -516,27 +566,40 @@ class AutoTradeRunner:
def _alert_priority(
cls,
*,
confidence: float,
confidence: NumericLike,
repeat_count: int,
) -> str:
if confidence >= 0.8 and repeat_count >= 3:
confidence_value = safe_float(confidence) or 0.0
if confidence_value >= 0.8 and repeat_count >= 3:
return "HIGH"
if confidence >= 0.6 or repeat_count >= 2:
if confidence_value >= 0.6 or repeat_count >= 2:
return "MEDIUM"
return "LOW"
@classmethod
def _log_refresh_skip(cls, reason: str, payload: dict | None = None) -> None:
def _log_refresh_skip(
cls,
reason: str,
payload: JsonDict | None = None,
) -> None:
return
@classmethod
def _log_refresh_success(cls, payload: dict | None = None) -> None:
def _log_refresh_success(
cls,
payload: JsonDict | None = None,
) -> None:
return
@classmethod
def _log_refresh_error(cls, reason: str, payload: dict | None = None) -> None:
def _log_refresh_error(
cls,
reason: str,
payload: JsonDict | None = None,
) -> None:
try:
JournalService().log_error(
"auto_screen_refresh_error",
@@ -547,7 +610,10 @@ class AutoTradeRunner:
pass
@classmethod
def _screen_state_key(cls, state) -> str:
def _screen_state_key(
cls,
state,
) -> str:
return "|".join(
str(value)
for value in [
@@ -581,6 +647,9 @@ class AutoTradeRunner:
getattr(state, "position_size", None),
#getattr(state, "unrealized_pnl_usd", None),
getattr(state, "realized_pnl_usd", None),
getattr(state, "cycle_closed_trades", None),
getattr(state, "cycle_realized_pnl_usd", None),
getattr(state, "cycle_winning_trades", None),
getattr(state, "last_execution_action", None),
getattr(state, "last_execution_reason", None),
]
@@ -628,19 +697,30 @@ class AutoTradeRunner:
)
return
text = cls._render_text()
semantic_text = build_auto_semantic_text()
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 cls._bot.edit_message_text(
await bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
text=text,
reply_markup=cls._render_markup(),
reply_markup=render_markup(),
)
cls._last_text = text
cls._last_semantic_text = semantic_text

View File

@@ -8,6 +8,8 @@ from datetime import datetime
from src.core.config import load_settings
from src.core.event_bus import EventBus
from src.core.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.trading.auto.state import AutoTradeState
from src.trading.execution.engine import ExecutionEngine
from src.trading.journal.service import JournalService
@@ -40,7 +42,7 @@ class AutoTradeService:
_last_signal_value: str | None = None
_last_signal_reason: str = ""
_last_signal_confidence: float = 0.0
_last_signal_payload: dict | None = None
_last_signal_payload: JsonDict | None = None
_last_signal_started_at: float | None = None
_last_logged_market_state: str | None = None
_last_logged_market_trend: str | None = None
@@ -50,46 +52,145 @@ class AutoTradeService:
_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
_spread_thresholds_by_asset: dict[str, dict[str, float]] = {
"BTC": {
"warning_enter": 0.08,
"warning_exit": 0.06,
"block_enter": 0.15,
"block_exit": 0.12,
},
"ETH": {
"warning_enter": 0.10,
"warning_exit": 0.08,
"block_enter": 0.18,
"block_exit": 0.15,
},
"LTC": {
"warning_enter": 0.18,
"warning_exit": 0.14,
"block_enter": 0.35,
"block_exit": 0.28,
},
"XRP": {
"warning_enter": 0.20,
"warning_exit": 0.16,
"block_enter": 0.40,
"block_exit": 0.32,
},
}
_default_spread_thresholds: dict[str, float] = {
"warning_enter": 0.12,
"warning_exit": 0.09,
"block_enter": 0.25,
"block_exit": 0.20,
}
_last_logged_execution_quality_key: str | None = None
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 _spread_thresholds(self, symbol: str | None) -> dict[str, float]:
asset = self._asset_symbol(symbol)
return self._spread_thresholds_by_asset.get(
asset,
self._default_spread_thresholds,
)
def _sync_market_availability_state(self, state: AutoTradeState) -> bool:
status = ExchangeService().get_symbol_market_status(state.symbol)
is_open = bool(status.get("is_open"))
market_status = str(status.get("status") or "UNKNOWN")
message = str(status.get("message") or "")
state.market_is_open = is_open
state.market_status = market_status
state.market_status_message = message
state.market_status_updated_at = time.monotonic()
if is_open:
if state.execution_quality_reason == "MARKET_CLOSED":
state.execution_quality = None
state.execution_quality_reason = None
state.execution_quality_message = None
state.execution_block_reason = None
state.market_runtime_degraded = False
return True
state.execution_quality = "BLOCKED"
state.execution_quality_reason = "MARKET_CLOSED"
state.execution_quality_message = "рынок закрыт"
state.execution_block_reason = "рынок закрыт"
state.market_runtime_degraded = True
state.entry_block_reason = "MARKET_CLOSED"
state.entry_block_message = "рынок закрыт"
state.decision_status = "WAITING"
state.decision_reason = message or "Рынок закрыт."
state.is_signal_confirmed = False
state.is_signal_ready = False
return False
def _spread_execution_quality(
self,
*,
state: AutoTradeState,
spread_percent: float | None,
spread_percent: NumericLike | None,
) -> tuple[str | None, str | None, str | None, bool]:
if spread_percent is None:
spread = safe_float(spread_percent)
if spread is None:
return None, None, None, False
thresholds = self._spread_thresholds(state.symbol)
warning_enter = thresholds["warning_enter"]
warning_exit = thresholds["warning_exit"]
block_enter = thresholds["block_enter"]
block_exit = thresholds["block_exit"]
previous_quality = state.execution_quality
previous_reason = state.execution_quality_reason
if previous_quality == "BLOCKED" and previous_reason == "HIGH_SPREAD":
if spread_percent > self._spread_block_exit_percent:
if spread > block_exit:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread_percent > self._spread_warning_exit_percent:
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if previous_quality == "WARNING" and previous_reason == "WIDE_SPREAD":
if spread_percent >= self._spread_block_enter_percent:
if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread_percent > self._spread_warning_exit_percent:
if spread > warning_exit:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
if spread_percent >= self._spread_block_enter_percent:
if spread >= block_enter:
return "BLOCKED", "HIGH_SPREAD", "высокий spread", False
if spread_percent >= self._spread_warning_enter_percent:
if spread >= warning_enter:
return "WARNING", "WIDE_SPREAD", "spread повышен", False
return "GOOD", "MARKET_OK", "рынок готов", False
@@ -99,11 +200,12 @@ class AutoTradeService:
self,
*,
signal: str,
confidence: float = 0.9,
confidence: NumericLike = 0.9,
repeat_count: int = 2,
reason: str = "DEBUG SIGNAL",
) -> AutoTradeState:
state = self.get_state()
confidence_value = safe_float(confidence) or 0.0
normalized_signal = signal.strip().upper()
if normalized_signal not in {"BUY", "SELL", "HOLD"}:
@@ -117,7 +219,7 @@ class AutoTradeService:
state.last_signal = normalized_signal
state.last_signal_repeat_count = repeat_count
state.last_signal_confidence = confidence
state.last_signal_confidence = confidence_value
state.last_signal_reason = reason
state.signal_confirmation_seconds = self._confirm_min_duration_seconds
state.signal_confirmation_required_seconds = self._confirm_min_duration_seconds
@@ -162,13 +264,15 @@ class AutoTradeService:
return state
# установить капитал, выделенный под автоторговлю
def set_allocated_balance_usd(self, value: float) -> AutoTradeState:
def set_allocated_balance_usd(self, value: NumericLike) -> AutoTradeState:
state = self.get_state()
if value <= 0:
value = 1000.0
numeric_value = safe_float(value)
state.allocated_balance_usd = value
if numeric_value is None or numeric_value <= 0:
numeric_value = 1000.0
state.allocated_balance_usd = numeric_value
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
return state
@@ -231,6 +335,10 @@ class AutoTradeService:
state.status = "RUNNING"
self._reset_signal_tracking()
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
state.cycle_started_at = time.monotonic()
state.cycle_number = int(getattr(state, "cycle_number", 0) or 0) + 1
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -268,6 +376,9 @@ class AutoTradeService:
if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
state.cycle_started_at = time.monotonic()
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -288,6 +399,10 @@ class AutoTradeService:
state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0
state.cycle_closed_trades = 0
state.cycle_winning_trades = 0
state.cycle_started_at = None
state.adaptive_size_changed_at = None
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
@@ -333,39 +448,39 @@ class AutoTradeService:
return state
# установить риск
def set_risk_percent(self, risk_percent: float) -> AutoTradeState:
def set_risk_percent(self, risk_percent: NumericLike) -> AutoTradeState:
state = self.get_state()
state.risk_percent = risk_percent
state.risk_percent = safe_float(risk_percent)
return state
# установить плечо
def set_leverage(self, leverage: float) -> AutoTradeState:
def set_leverage(self, leverage: NumericLike) -> AutoTradeState:
state = self.get_state()
state.leverage = leverage
state.leverage = safe_float(leverage)
return state
# установить stop loss в %
def set_stop_loss_percent(self, value: float | None) -> AutoTradeState:
def set_stop_loss_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.stop_loss_percent = value
state.stop_loss_percent = safe_float(value)
return state
# установить take profit в %
def set_take_profit_percent(self, value: float | None) -> AutoTradeState:
def set_take_profit_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.take_profit_percent = value
state.take_profit_percent = safe_float(value)
return state
# установить max loss в USD
def set_max_loss_usd(self, value: float | None) -> AutoTradeState:
def set_max_loss_usd(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.max_loss_usd = value
state.max_loss_usd = safe_float(value)
return state
# установить максимальное использование баланса под маржу
def set_max_reserved_balance_percent(self, value: float | None) -> AutoTradeState:
def set_max_reserved_balance_percent(self, value: NumericLike | None) -> AutoTradeState:
state = self.get_state()
state.max_reserved_balance_percent = value
state.max_reserved_balance_percent = safe_float(value)
state.execution_block_reason = None
return state
@@ -380,6 +495,7 @@ class AutoTradeService:
self._same_signal_count = 0
state = self.get_state()
state.adaptive_size_base = None
state.adaptive_size_final = None
state.adaptive_size_multiplier = None
@@ -387,6 +503,7 @@ class AutoTradeService:
state.adaptive_size_factors = None
state.effective_risk_percent = None
state.effective_target_risk_usd = None
state.last_signal_repeat_count = 0
state.last_signal_confidence = 0.0
state.last_signal_reason = None
@@ -399,6 +516,9 @@ class AutoTradeService:
state.signal_confirmation_missing_repeats = self._confirm_repeats
state.signal_confirmation_progress = 0.0
state.signal_confirmation_reason = None
state.signal_started_at = None
state.signal_updated_at = None
state.execution_block_reason = None
state.execution_semantic_status = None
state.execution_semantic_message = None
@@ -411,8 +531,7 @@ class AutoTradeService:
state.execution_confidence_required_score = self._execution_confidence_required_score
state.execution_confidence_reason = None
state.execution_confidence_factors = None
state.signal_started_at = None
state.signal_updated_at = None
state.market_state = None
state.market_trend = None
state.market_volatility = None
@@ -424,8 +543,29 @@ class AutoTradeService:
state.market_trend_quality = None
state.market_phase = None
state.market_phase_direction = None
state.market_trend_gap_percent = None
state.market_trend_consistency = None
state.market_trend_efficiency = None
state.trend_quality_score = None
state.ema_distance_atr_ratio = None
state.ema_distance_state = None
state.entry_timing_state = None
state.entry_timing_reason = None
state.ema_fast_slope_percent = None
state.ema_slow_slope_percent = None
state.candle_noise_score = None
state.price_position_score = None
state.htf_interval = None
state.htf_atr_percent = None
state.htf_atr_percent_baseline = None
state.htf_volatility_ratio = None
state.htf_volatility = None
state.entry_block_reason = None
state.entry_block_message = None
state.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
@@ -433,6 +573,7 @@ class AutoTradeService:
state.breakout_level = None
state.breakout_distance_percent = None
state.breakout_reason = None
state.runtime_expired_reason = None
state.runtime_expired_message = None
state.snapshot_age_seconds = None
@@ -508,7 +649,12 @@ class AutoTradeService:
if state.signal_started_at is None:
signal_age_seconds = 0
else:
signal_age_seconds = max(0, int(now - float(state.signal_started_at)))
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(
@@ -589,7 +735,7 @@ class AutoTradeService:
signal: str,
reason: str,
confidence: float,
payload: dict | None,
payload: JsonDict | None,
) -> None:
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
previous_signal = self._last_signal_value
@@ -757,7 +903,7 @@ class AutoTradeService:
signal: str,
reason: str,
confidence: float,
payload: dict | None,
payload: JsonDict | None,
) -> None:
return
@@ -772,7 +918,7 @@ class AutoTradeService:
next_signal: str,
reason: str,
confidence: float,
payload: dict | None,
payload: JsonDict | None,
duration_seconds: int,
) -> None:
if previous_signal != "HOLD":
@@ -822,6 +968,11 @@ class AutoTradeService:
if normalized_signal not in {"BUY", "SELL"}:
return
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
try:
JournalService().log_ui_info(
event_type="signal_ready",
@@ -846,6 +997,9 @@ class AutoTradeService:
"confirmation_seconds": state.signal_confirmation_seconds,
"confirmation_required_seconds": state.signal_confirmation_required_seconds,
"confirmation_progress": state.signal_confirmation_progress,
"bid_price": snapshot.get("bid_price"),
"ask_price": snapshot.get("ask_price"),
"last_price": snapshot.get("last_price"),
},
)
except Exception:
@@ -855,7 +1009,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
payload: dict | None,
payload: JsonDict | None,
) -> None:
if not isinstance(payload, dict):
return
@@ -864,25 +1018,42 @@ class AutoTradeService:
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.momentum_state = payload.get("momentum_state")
state.momentum_direction = payload.get("momentum_direction")
state.momentum_change_percent = payload.get("momentum_change_percent")
state.momentum_strength = payload.get("momentum_strength")
state.breakout_level = payload.get("breakout_level")
state.breakout_distance_percent = payload.get("breakout_distance_percent")
state.breakout_reason = payload.get("breakout_reason")
state.market_state = str(payload.get("market_state") or "")
state.market_trend = str(payload.get("trend") or payload.get("market_trend") or "")
state.market_volatility = str(payload.get("volatility") or payload.get("market_volatility") or "")
state.market_trend_strength = str(payload.get("market_trend_strength") or "")
state.market_trend_quality = str(payload.get("market_trend_quality") or "")
state.market_phase = str(payload.get("market_phase") or "")
state.market_phase_direction = str(payload.get("market_phase_direction") or "")
state.market_trend_gap_percent = safe_float(payload.get("market_trend_gap_percent"))
state.market_trend_consistency = safe_float(payload.get("market_trend_consistency"))
state.market_trend_efficiency = safe_float(payload.get("market_trend_efficiency"))
state.trend_quality_score = safe_float(payload.get("trend_quality_score"))
state.ema_distance_atr_ratio = safe_float(payload.get("ema_distance_atr_ratio"))
state.ema_distance_state = str(payload.get("ema_distance_state") or "")
state.entry_timing_state = str(payload.get("entry_timing_state") or "")
state.entry_timing_reason = str(payload.get("entry_timing_reason") or "")
state.ema_fast_slope_percent = safe_float(payload.get("ema_fast_slope_percent"))
state.ema_slow_slope_percent = safe_float(payload.get("ema_slow_slope_percent"))
state.candle_noise_score = safe_float(payload.get("candle_noise_score"))
state.price_position_score = safe_float(payload.get("price_position_score"))
state.htf_interval = str(payload.get("htf_interval") or "")
state.htf_atr_percent = safe_float(payload.get("htf_atr_percent"))
state.htf_atr_percent_baseline = safe_float(payload.get("htf_atr_percent_baseline"))
state.htf_volatility_ratio = safe_float(payload.get("htf_volatility_ratio"))
state.htf_volatility = str(payload.get("htf_volatility") or "")
state.market_analysis_interval = str(payload.get("interval") or payload.get("market_analysis_interval") or "")
state.market_analysis_reason = str(payload.get("reason") or payload.get("market_analysis_reason") or "")
state.momentum_state = str(payload.get("momentum_state") or "")
state.momentum_direction = str(payload.get("momentum_direction") or "")
state.momentum_change_percent = safe_float(payload.get("momentum_change_percent"))
state.momentum_strength = safe_float(payload.get("momentum_strength"))
state.breakout_level = safe_float(payload.get("breakout_level"))
state.breakout_distance_percent = safe_float(payload.get("breakout_distance_percent"))
state.breakout_reason = str(payload.get("breakout_reason") or "")
state.market_analysis_updated_at = time.monotonic()
state.entry_block_reason = payload.get("entry_block_reason")
state.entry_block_message = payload.get("entry_block_message")
state.entry_block_reason = str(payload.get("entry_block_reason") or "")
state.entry_block_message = str(payload.get("entry_block_message") or "")
self._log_market_state_if_changed(
state=state,
@@ -901,7 +1072,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
payload: dict,
payload: JsonDict,
) -> None:
reason = state.entry_block_reason
message = state.entry_block_message
@@ -938,7 +1109,7 @@ class AutoTradeService:
self,
*,
state: AutoTradeState,
payload: dict,
payload: JsonDict,
previous_market_state: str | None,
previous_market_trend: str | None,
previous_market_volatility: str | None,
@@ -1003,7 +1174,7 @@ class AutoTradeService:
event_type: str,
market_state: str,
message: str,
payload: dict,
payload: JsonDict,
) -> None:
level = self._market_journal_level(market_state)
@@ -1034,7 +1205,7 @@ class AutoTradeService:
return messages.get(str(market_volatility or ""), "Волатильность не определена.")
def _market_journal_level(self, market_state: str) -> str:
def _market_journal_level(self, market_state: str | None) -> str:
if market_state == "HIGH_VOLATILITY":
return "WARNING"
@@ -1056,8 +1227,11 @@ class AutoTradeService:
signal_updated_at = getattr(state, "signal_updated_at", None)
if signal_updated_at is not None:
signal_age = now - float(signal_updated_at)
signal_updated = safe_float(signal_updated_at)
if signal_updated is None:
return
signal_age = now - signal_updated
if signal_age > self._signal_ttl_seconds:
previous_signal = state.last_signal
@@ -1081,7 +1255,12 @@ class AutoTradeService:
market_updated_at = getattr(state, "market_analysis_updated_at", None)
if market_updated_at is not None:
market_age = now - float(market_updated_at)
market_updated = safe_float(market_updated_at)
if market_updated is None:
return
market_age = now - market_updated
if market_age > self._market_analysis_ttl_seconds:
state.market_state = None
@@ -1096,7 +1275,23 @@ class AutoTradeService:
state.market_trend_quality = None
state.market_phase = None
state.market_phase_direction = None
state.market_trend_gap_percent = None
state.market_trend_consistency = None
state.market_trend_efficiency = None
state.trend_quality_score = None
state.ema_distance_atr_ratio = None
state.ema_distance_state = None
state.entry_timing_state = None
state.entry_timing_reason = None
state.ema_fast_slope_percent = None
state.ema_slow_slope_percent = None
state.candle_noise_score = None
state.price_position_score = None
state.htf_interval = None
state.htf_atr_percent = None
state.htf_atr_percent_baseline = None
state.htf_volatility_ratio = None
state.htf_volatility = None
state.momentum_state = None
state.momentum_direction = None
state.momentum_change_percent = None
@@ -1123,7 +1318,7 @@ class AutoTradeService:
state: AutoTradeState,
reason: str,
message: str,
payload: dict,
payload: JsonDict,
) -> None:
key = f"{state.status}:{state.symbol}:{state.strategy}:{reason}"
@@ -1159,7 +1354,7 @@ class AutoTradeService:
fallback_price = None
try:
fallback_price = float(
fallback_price = safe_float(
ExchangeService().get_price(
state.symbol,
runtime_key="auto",
@@ -1192,10 +1387,10 @@ class AutoTradeService:
)
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"))
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
last_price = safe_float(snapshot.get("last_price"))
age_seconds = safe_float(snapshot.get("age_seconds"))
is_fresh = bool(snapshot.get("is_fresh", False))
source = str(snapshot.get("source") or "")
@@ -1240,6 +1435,8 @@ class AutoTradeService:
elif state.execution_block_reason == state.execution_quality_message:
state.execution_block_reason = None
spread_thresholds = self._spread_thresholds(state.symbol)
self._log_execution_quality_if_changed(
state=state,
payload={
@@ -1258,49 +1455,46 @@ class AutoTradeService:
"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,
"spread_asset": self._asset_symbol(state.symbol),
"spread_warning_enter_percent": spread_thresholds["warning_enter"],
"spread_warning_exit_percent": spread_thresholds["warning_exit"],
"spread_block_enter_percent": spread_thresholds["block_enter"],
"spread_block_exit_percent": spread_thresholds["block_exit"],
},
)
def _spread_percent(
self,
*,
bid_price: float | None,
ask_price: float | None,
bid_price: NumericLike | None,
ask_price: NumericLike | None,
) -> float | None:
if bid_price is None or ask_price is None:
bid = safe_float(bid_price)
ask = safe_float(ask_price)
if bid is None or ask is None:
return None
if bid_price <= 0 or ask_price <= 0:
if bid <= 0 or ask <= 0:
return None
mid_price = (bid_price + ask_price) / 2
mid_price = (bid + ask) / 2
if mid_price <= 0:
return None
spread = ask_price - bid_price
spread = ask - bid
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,
payload: JsonDict,
) -> None:
quality = state.execution_quality
reason = state.execution_quality_reason
@@ -1408,8 +1602,18 @@ class AutoTradeService:
strength = state.market_trend_strength
quality = state.market_trend_quality
phase = state.market_phase
ema_distance_state = state.ema_distance_state
entry_timing_state = state.entry_timing_state
trend_quality_score = safe_float(state.trend_quality_score)
if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE", "UNKNOWN", None}:
if market_state in {
"HIGH_VOLATILITY",
"LOW_VOLATILITY",
"RANGE",
"UNKNOWN",
None,
"",
}:
return 0.25
score = 0.65
@@ -1422,7 +1626,9 @@ class AutoTradeService:
score -= 0.25
if quality == "CLEAN":
score += 0.1
score += 0.12
elif quality == "NORMAL":
score += 0.04
elif quality == "NOISY":
score -= 0.25
@@ -1433,6 +1639,30 @@ class AutoTradeService:
elif phase in {"RANGE", "SQUEEZE"}:
score -= 0.3
if ema_distance_state == "HEALTHY":
score += 0.08
elif ema_distance_state == "EXTENDED":
score -= 0.08
elif ema_distance_state == "COMPRESSED":
score -= 0.18
elif ema_distance_state == "OVEREXTENDED":
score -= 0.35
if entry_timing_state == "NORMAL":
score += 0.08
elif entry_timing_state == "EARLY":
score -= 0.05
elif entry_timing_state == "LATE":
score -= 0.2
elif entry_timing_state == "CHASING":
score -= 0.35
if trend_quality_score is not None:
if trend_quality_score >= 0.7:
score += 0.08
elif trend_quality_score < 0.45:
score -= 0.15
return self._clamp_score(score)
def _execution_quality_confidence_score(self, state: AutoTradeState) -> float:
@@ -1482,11 +1712,16 @@ class AutoTradeService:
return "достаточная совокупная уверенность входа"
def _clamp_score(self, value: float | int | None) -> float:
def _clamp_score(self, value: NumericLike | None) -> float:
if value is None:
return 0.0
return max(0.0, min(1.0, float(value)))
numeric = safe_float(value)
if numeric is None:
return 0.0
return max(0.0, min(1.0, numeric))
def _sync_execution_semantic_state(self, state: AutoTradeState) -> None:
if state.execution_quality == "BLOCKED":
@@ -1541,6 +1776,9 @@ class AutoTradeService:
def _execution_block_semantic_message(self, state: AutoTradeState) -> str:
reason = state.execution_quality_reason
if reason == "MARKET_CLOSED":
return "⏸️ Исполнение · рынок закрыт"
if reason == "STALE_SNAPSHOT":
return "⛔ Исполнение · рынок неактуален"
@@ -1561,6 +1799,11 @@ class AutoTradeService:
if state.status == "OFF":
return state
if not self._sync_market_availability_state(state):
state.last_check_at = datetime.now().strftime("%H:%M:%S")
self._sync_execution_semantic_state(state)
return state
self._expire_runtime_if_needed(state)
strategy = self._get_strategy()

View File

@@ -97,6 +97,18 @@ class AutoTradeState:
# cumulative realized pnl за текущий цикл автоторговли
cycle_realized_pnl_usd: float = 0.0
# количество закрытых сделок в текущем цикле
cycle_closed_trades: int = 0
# количество прибыльных закрытых сделок
cycle_winning_trades: int = 0
# время запуска текущего цикла
cycle_started_at: float | None = None
# время последней adaptive size корректировки
adaptive_size_changed_at: float | None = None
# данные последнего flip
last_flip_old_side: str | None = None
last_flip_new_side: str | None = None
@@ -130,7 +142,7 @@ class AutoTradeState:
# сила тренда: WEAK / NORMAL / STRONG / UNKNOWN
market_trend_strength: str | None = None
# качество тренда: CLEAN / NOISY / UNKNOWN
# качество тренда: CLEAN / NORMAL / NOISY / UNKNOWN
market_trend_quality: str | None = None
# фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN
@@ -139,6 +151,29 @@ class AutoTradeState:
# направление короткой фазы рынка: UP / DOWN / FLAT / UNKNOWN
market_phase_direction: str | None = None
# advanced trend quality metrics
market_trend_gap_percent: float | None = None
market_trend_consistency: float | None = None
market_trend_efficiency: float | None = None
ema_distance_atr_ratio: float | None = None
ema_fast_slope_percent: float | None = None
ema_slow_slope_percent: float | None = None
candle_noise_score: float | None = None
price_position_score: float | None = None
# advanced trend quality semantic states
trend_quality_score: float | None = None
ema_distance_state: str | None = None
entry_timing_state: str | None = None
entry_timing_reason: str | None = None
# higher timeframe volatility context
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: str | None = None
# состояние momentum/breakout semantic engine
# NONE / MOMENTUM_UP / MOMENTUM_DOWN / BREAKOUT_UP / BREAKOUT_DOWN / UNKNOWN
momentum_state: str | None = None
@@ -262,4 +297,13 @@ class AutoTradeState:
adaptive_size_reason: str | None = None
# факторы adaptive sizing для логов / отладки
adaptive_size_factors: dict | None = None
adaptive_size_factors: dict | None = None
# статус торговой сессии инструмента
market_is_open: bool | None = None
market_status: str | None = None
market_status_message: str | None = None
market_status_updated_at: float | None = None
# номер текущего цикла автоторговли, для которого была зафиксирована статистика
cycle_number: int = 0

View File

@@ -4,33 +4,49 @@ from __future__ import annotations
import asyncio
import time
from typing import Callable
from collections.abc import Callable
from typing import ClassVar, Protocol
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from aiogram.types import InlineKeyboardMarkup
from src.core.telegram_errors import (
is_message_not_modified,
is_message_to_edit_not_found,
)
from src.integrations.exchange.market_data_runner import MarketDataRunner
from src.trading.debug.service import DebugTradeService
from src.notifications.targets import NotificationTargetRegistry
from src.trading.debug.service import DebugTradeService
class RenderText(Protocol):
def __call__(self) -> str: ...
class RenderMarkup(Protocol):
def __call__(self) -> InlineKeyboardMarkup | None: ...
class DebugTradeRunner:
_task: asyncio.Task | None = None
_task: ClassVar[asyncio.Task[None] | None] = None
_bot: Bot | None = None
_chat_id: int | None = None
_message_id: int | None = None
_render_text: Callable[[], str] | None = None
_render_markup: Callable[[], object] | None = None
_bot: ClassVar[Bot | None] = None
_chat_id: ClassVar[int | None] = None
_message_id: ClassVar[int | None] = None
_current_screen: str | None = None
_text_renderer: ClassVar[RenderText | None] = None
_markup_renderer: ClassVar[RenderMarkup | None] = None
_interval_seconds = 5
_market_interval_seconds = 1
_current_screen: ClassVar[str | None] = None
_last_text: str | None = None
_last_refresh_at: float = 0.0
_retry_after_until: float = 0.0
_interval_seconds: ClassVar[int] = 5
_market_interval_seconds: ClassVar[int] = 1
_last_text: ClassVar[str | None] = None
_last_refresh_at: ClassVar[float] = 0.0
_retry_after_until: ClassVar[float] = 0.0
@classmethod
def register_screen(
@@ -39,14 +55,14 @@ class DebugTradeRunner:
bot: Bot,
chat_id: int,
message_id: int,
render_text: Callable[[], str],
render_markup: Callable[[], object],
render_text: RenderText,
render_markup: RenderMarkup,
) -> None:
cls._bot = bot
cls._chat_id = chat_id
cls._message_id = message_id
cls._render_text = render_text
cls._render_markup = render_markup
cls._text_renderer = render_text
cls._markup_renderer = render_markup
cls._last_text = None
NotificationTargetRegistry.set_default_chat(
@@ -54,6 +70,30 @@ class DebugTradeRunner:
chat_id=chat_id,
)
@classmethod
def _reset_screen(cls) -> None:
cls._message_id = None
cls._text_renderer = None
cls._markup_renderer = None
cls._last_text = None
@classmethod
def _reset_runtime(cls) -> None:
cls._bot = None
cls._chat_id = None
cls._current_screen = None
cls._reset_screen()
@classmethod
def _is_screen_ready(cls) -> bool:
return (
cls._bot is not None
and cls._chat_id is not None
and cls._message_id is not None
and cls._text_renderer is not None
and cls._markup_renderer is not None
)
@classmethod
async def delete_registered_screen(
cls,
@@ -75,10 +115,7 @@ class DebugTradeRunner:
except Exception:
pass
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._reset_screen()
@classmethod
async def detach_screen(
@@ -105,13 +142,7 @@ class DebugTradeRunner:
except Exception:
pass
cls._bot = None
cls._chat_id = None
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._current_screen = None
cls._last_text = None
cls._reset_runtime()
@classmethod
def set_current_screen(cls, screen: str) -> None:
@@ -121,6 +152,7 @@ class DebugTradeRunner:
def start(cls) -> None:
service = DebugTradeService()
state = service.get_state()
state.status = "RUNNING"
MarketDataRunner.start(
@@ -167,7 +199,11 @@ class DebugTradeRunner:
await asyncio.sleep(cls._interval_seconds)
@classmethod
async def refresh_screen(cls, *, force: bool = False) -> None:
async def refresh_screen(
cls,
*,
force: bool = False,
) -> None:
if cls._current_screen != "debug_auto":
return
@@ -176,32 +212,43 @@ class DebugTradeRunner:
if now < cls._retry_after_until:
return
if not force and now - cls._last_refresh_at < cls._interval_seconds:
return
if not all(
[
cls._bot,
cls._chat_id,
cls._message_id,
cls._render_text,
cls._render_markup,
]
if (
not force
and now - cls._last_refresh_at < cls._interval_seconds
):
return
text = cls._render_text()
if not cls._is_screen_ready():
return
bot = cls._bot
chat_id = cls._chat_id
message_id = cls._message_id
text_renderer = cls._text_renderer
markup_renderer = cls._markup_renderer
if (
bot is None
or chat_id is None
or message_id is None
or text_renderer is None
or markup_renderer is None
):
return
text = text_renderer()
if text == cls._last_text:
return
try:
await cls._bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
await bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=cls._render_markup(),
reply_markup=markup_renderer(),
)
cls._last_text = text
cls._last_refresh_at = now
@@ -209,18 +256,13 @@ class DebugTradeRunner:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if "message is not modified" in error_text:
if is_message_not_modified(exc):
cls._last_text = text
cls._last_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
if is_message_to_edit_not_found(exc):
cls._reset_screen()
return
except Exception:

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import time
from typing import Any
from src.trading.auto.state import AutoTradeState
from src.core.numbers import safe_float
class SemanticDiagnosticSnapshotBuilder:
@@ -30,6 +31,8 @@ class SemanticDiagnosticSnapshotBuilder:
blockers=blockers,
)
position_current_price = self._position_current_price(state)
return {
"status": {
"status": state.status,
@@ -59,6 +62,27 @@ class SemanticDiagnosticSnapshotBuilder:
"entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message,
"age_seconds": market_age_seconds,
"market_is_open": state.market_is_open,
"market_status": state.market_status,
"market_status_message": state.market_status_message,
"market_status_updated_at": state.market_status_updated_at,
"trend_gap_percent": state.market_trend_gap_percent,
"trend_consistency": state.market_trend_consistency,
"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,
"ema_fast_slope_percent": state.ema_fast_slope_percent,
"ema_slow_slope_percent": state.ema_slow_slope_percent,
"candle_noise_score": state.candle_noise_score,
"price_position_score": state.price_position_score,
"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,
},
"momentum": {
"state": getattr(state, "momentum_state", None),
@@ -113,6 +137,11 @@ class SemanticDiagnosticSnapshotBuilder:
"last_flip_pnl_usd": state.last_flip_pnl_usd,
"last_flip_reason": state.last_flip_reason,
"last_flip_monotonic_at": state.last_flip_monotonic_at,
"current_price": position_current_price,
"adaptive_size_multiplier": state.adaptive_size_multiplier,
"stop_loss_usd": state.effective_target_risk_usd,
"take_profit_usd": self._take_profit_usd(state),
"max_loss_usd": state.max_loss_usd,
},
"runtime_health": {
"health_score": health_score,
@@ -151,19 +180,52 @@ class SemanticDiagnosticSnapshotBuilder:
"is_ready": state.is_signal_ready,
"is_blocked": bool(blockers),
"blockers": blockers,
"symbol": state.symbol,
},
}
def _position_current_price(
self,
state: AutoTradeState,
) -> float | None:
if state.position_side == "NONE":
return None
try:
from src.integrations.exchange.service import ExchangeService
snapshot = ExchangeService().get_market_snapshot(
state.symbol,
runtime_key="auto",
)
side = str(state.position_side or "").upper()
price = snapshot.get("last_price")
if side == "LONG":
price = snapshot.get("bid_price") or price
elif side == "SHORT":
price = snapshot.get("ask_price") or price
return safe_float(price)
except Exception:
return None
def _age_seconds(
self,
*,
now: float,
started_at: float | None,
) -> int | None:
if started_at is None:
started = safe_float(started_at)
if started is None:
return None
return max(0, int(now - float(started_at)))
return max(0, int(now - started))
def _is_runtime_degraded(self, state: AutoTradeState) -> bool:
return bool(
@@ -203,6 +265,28 @@ class SemanticDiagnosticSnapshotBuilder:
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
score -= 10
if state.ema_distance_state == "COMPRESSED":
score -= 10
if state.ema_distance_state == "EXTENDED":
score -= 8
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
@@ -225,6 +309,9 @@ class SemanticDiagnosticSnapshotBuilder:
has_ready_signal = bool(state.is_signal_ready)
has_position = state.position_side != "NONE"
if state.market_is_open is False:
return "RED"
has_waiting_data_blocker = any(
str(item).strip().lower()
in {
@@ -258,6 +345,12 @@ class SemanticDiagnosticSnapshotBuilder:
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:
@@ -301,6 +394,9 @@ class SemanticDiagnosticSnapshotBuilder:
state: AutoTradeState,
blockers: list[str],
) -> str:
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 "Ожидание: рынок без направления."
@@ -341,6 +437,13 @@ class SemanticDiagnosticSnapshotBuilder:
def _blockers(self, state: AutoTradeState) -> list[str]:
blockers: list[str] = []
if state.market_is_open is False:
blockers.append(
state.market_status_message
or "рынок закрыт"
)
return blockers
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
blockers.append("рынок без направления")
@@ -349,7 +452,17 @@ class SemanticDiagnosticSnapshotBuilder:
else:
blockers.append("рынок не подходит")
return blockers
if state.ema_distance_state == "COMPRESSED":
blockers.append("EMA слишком сжаты")
if state.ema_distance_state == "OVEREXTENDED":
blockers.append("тренд перерастянут")
if state.entry_timing_state == "LATE":
blockers.append("поздний вход")
if state.entry_timing_state == "CHASING":
blockers.append("вход запрещён: chasing move")
if state.entry_block_message:
blockers.append(str(state.entry_block_message))
@@ -363,4 +476,26 @@ class SemanticDiagnosticSnapshotBuilder:
if state.runtime_expired_message:
blockers.append(str(state.runtime_expired_message))
return blockers
result: list[str] = []
for item in blockers:
if item and item not in result:
result.append(item)
return result
def _take_profit_usd(self, state: AutoTradeState) -> float | None:
take_profit_percent = safe_float(state.take_profit_percent)
position_size = safe_float(state.position_size)
entry_price = safe_float(state.entry_price)
if (
take_profit_percent is None
or position_size is None
or position_size <= 0
or entry_price is None
or entry_price <= 0
):
return None
move = entry_price * (take_profit_percent / 100)
return move * position_size

View File

@@ -13,6 +13,8 @@ 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 JsonDict, NumericLike
@dataclass(slots=True)
@@ -108,7 +110,7 @@ class ExecutionEngine:
final_size=size,
)
size = self._round_order_size(size)
size = self._round_size(size)
if size <= 0:
return ExecutionDecision(
@@ -134,7 +136,7 @@ class ExecutionEngine:
state.last_execution_action = action
state.last_execution_reason = f"Позиция {side} открыта."
payload = {
payload: JsonDict = {
"execution_type": "ENTRY",
"action": action,
"symbol": state.symbol,
@@ -218,7 +220,7 @@ class ExecutionEngine:
final_size=new_size,
)
new_size = self._round_order_size(new_size)
new_size = self._round_size(new_size)
if new_size <= 0:
return ExecutionDecision(
@@ -230,11 +232,10 @@ class ExecutionEngine:
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
state.last_flip_old_side = old_side
state.last_flip_new_side = new_side
state.last_flip_pnl_usd = pnl
state.last_flip_reason = state.last_signal_reason
state.last_flip_monotonic_at = time.monotonic()
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
old_side = position.side
old_entry_price = position.entry_price
@@ -242,6 +243,12 @@ class ExecutionEngine:
old_leverage = position.leverage
old_opened_at = position.opened_at
state.last_flip_old_side = old_side
state.last_flip_new_side = new_side
state.last_flip_pnl_usd = pnl
state.last_flip_reason = state.last_signal_reason
state.last_flip_monotonic_at = time.monotonic()
type(self)._position = PositionState(
side=new_side,
symbol=state.symbol,
@@ -261,7 +268,7 @@ class ExecutionEngine:
state.last_flip_at = now
type(self)._last_flip_block_key = None
payload = {
payload: JsonDict = {
"execution_type": "FLIP",
"action": f"FLIP_{old_side}_TO_{new_side}",
"symbol": state.symbol,
@@ -326,8 +333,8 @@ class ExecutionEngine:
state: AutoTradeState,
*,
forced_reason: str | None = None,
forced_exit_price: float | None = None,
forced_pnl: float | None = None,
forced_exit_price: NumericLike | None = None,
forced_pnl: NumericLike | None = None,
forced_price_meta: _ExecutionPrice | None = None,
) -> ExecutionDecision:
position = type(self)._position
@@ -337,7 +344,7 @@ class ExecutionEngine:
return ExecutionDecision("NONE", False, "Нет открытой позиции для закрытия.")
if forced_exit_price is not None:
exit_price = forced_exit_price
exit_price = safe_float(forced_exit_price) or 0.0
exit_execution = forced_price_meta
else:
try:
@@ -346,14 +353,26 @@ class ExecutionEngine:
except Exception as exc:
return ExecutionDecision("NONE", False, f"Ошибка получения цены для закрытия: {exc}")
pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price)
pnl = (
safe_float(forced_pnl)
if forced_pnl is not None
else self._calculate_pnl(exit_price)
)
if pnl is None:
pnl = 0.0
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
state.cycle_closed_trades += 1
if pnl > 0:
state.cycle_winning_trades += 1
now = self._now_time()
payload = {
payload: JsonDict = {
"execution_type": "EXIT",
"action": "CLOSE",
"symbol": state.symbol,
@@ -413,7 +432,7 @@ class ExecutionEngine:
f"Позиция закрыта по правилу защиты: {forced_reason}.",
)
return ExecutionDecision("CLOSE", True, "Позиция закрыта.")
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:
@@ -475,18 +494,24 @@ class ExecutionEngine:
return False
return unrealized_pnl <= -abs(state.max_loss_usd)
def _calculate_price_move_percent(self, current_price: float) -> float:
def _calculate_price_move_percent(
self,
current_price: NumericLike | None,
) -> float:
position = type(self)._position
entry = position.entry_price or 0.0
price = safe_float(current_price) or 0.0
entry = safe_float(position.entry_price) or 0.0
if entry <= 0:
return 0.0
if position.side == "LONG":
return round(((current_price - entry) / entry) * 100, 4)
return round(((price - entry) / entry) * 100, 4)
if position.side == "SHORT":
return round(((entry - current_price) / entry) * 100, 4)
return round(((entry - price) / entry) * 100, 4)
return 0.0
@@ -507,9 +532,9 @@ class ExecutionEngine:
def _flip_block_reason(self, state: AutoTradeState) -> str | None:
position = type(self)._position
confidence = float(state.last_signal_confidence or 0.0)
repeat_count = int(state.last_signal_repeat_count or 0)
unrealized_pnl = float(state.unrealized_pnl_usd or 0.0)
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)
@@ -560,7 +585,7 @@ class ExecutionEngine:
reason: str,
) -> ExecutionDecision:
position = type(self)._position
confidence = float(state.last_signal_confidence or 0.0)
confidence = safe_float(state.last_signal_confidence) or 0.0
state.execution_block_reason = reason
state.last_flip_block_reason = reason
@@ -578,7 +603,7 @@ class ExecutionEngine:
if block_key != type(self)._last_flip_block_key:
type(self)._last_flip_block_key = block_key
payload = {
payload: JsonDict = {
"execution_type": "FLIP_BLOCKED",
"symbol": state.symbol,
"position_side": position.side,
@@ -700,8 +725,10 @@ class ExecutionEngine:
multiplier = 1.0
execution_confidence_score = getattr(state, "execution_confidence_score", None)
if execution_confidence_score is not None:
score = max(0.0, min(1.0, float(execution_confidence_score)))
score_raw = safe_float(execution_confidence_score)
if score_raw is not None:
score = max(0.0, min(1.0, score_raw))
if score < 0.55:
multiplier *= 0.0
@@ -750,17 +777,14 @@ class ExecutionEngine:
multiplier *= 1.05
if momentum_strength is not None:
try:
strength = float(momentum_strength)
strength = safe_float(momentum_strength)
if strength is not None:
if strength >= 1.5:
multiplier *= 1.1
elif strength <= 0.7:
multiplier *= 0.8
except Exception:
pass
if signal == "BUY":
if momentum_direction == "DOWN":
multiplier *= 0.75
@@ -800,7 +824,10 @@ class ExecutionEngine:
state.adaptive_size_final = self._round_size(final_size)
state.adaptive_size_multiplier = multiplier
base_risk_percent = float(state.risk_percent or 0.0)
if multiplier != 1:
state.adaptive_size_changed_at = time.monotonic()
base_risk_percent = safe_float(state.risk_percent) or 0.0
state.effective_risk_percent = round(
base_risk_percent * multiplier,
@@ -847,7 +874,7 @@ class ExecutionEngine:
base_size: float,
final_size: float,
) -> None:
adaptive_final = float(state.adaptive_size_final or 0.0)
adaptive_final = safe_float(state.adaptive_size_final) or 0.0
if adaptive_final <= 0:
state.effective_risk_percent = 0.0
@@ -859,7 +886,7 @@ class ExecutionEngine:
min(1.0, final_size / adaptive_final),
)
current_effective_risk = float(state.effective_risk_percent or 0.0)
current_effective_risk = safe_float(state.effective_risk_percent) or 0.0
state.effective_risk_percent = round(
current_effective_risk * margin_ratio,
@@ -917,7 +944,7 @@ class ExecutionEngine:
limited_size = self._round_size(max_size)
adaptive_final = float(state.adaptive_size_final or 0.0)
adaptive_final = safe_float(state.adaptive_size_final) or 0.0
if adaptive_final > 0:
effective_multiplier = limited_size / adaptive_final
@@ -1011,32 +1038,55 @@ class ExecutionEngine:
pricing_role="MARKET_LAST",
)
def _snapshot_price(self, raw_price: object, name: str) -> float:
def _snapshot_price(
self,
raw_price: NumericLike | None,
name: str,
) -> float:
if raw_price is None:
raise ValueError(f"Execution snapshot price '{name}' is missing.")
raise ValueError(
f"Execution snapshot price '{name}' is missing."
)
price = float(raw_price)
price = safe_float(raw_price)
if price is None:
raise ValueError(
f"Execution snapshot price '{name}' is invalid."
)
if price <= 0:
raise ValueError(f"Execution snapshot price '{name}' is invalid: {price}")
raise ValueError(
f"Execution snapshot price '{name}' is invalid: {price}"
)
return price
def _round_size(self, size: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(size) * factor) / factor
def _round_size(self, size: NumericLike | None) -> float:
value = safe_float(size)
def _calculate_pnl(self, current_price: float) -> float:
if value is None:
return 0.0
factor = 10 ** self._size_precision
return math.floor(value * factor) / factor
def _calculate_pnl(
self,
current_price: NumericLike | None,
) -> float:
position = type(self)._position
entry = position.entry_price or 0.0
size = position.size or 0.0
price = safe_float(current_price) or 0.0
entry = safe_float(position.entry_price) or 0.0
size = safe_float(position.size) or 0.0
if position.side == "LONG":
return round((current_price - entry) * size, 4)
return round((price - entry) * size, 4)
if position.side == "SHORT":
return round((entry - current_price) * size, 4)
return round((entry - price) * size, 4)
return 0.0
@@ -1048,9 +1098,5 @@ class ExecutionEngine:
state.position_size = position.size
state.unrealized_pnl_usd = position.unrealized_pnl_usd
def _round_order_size(self, value: float) -> float:
factor = 10 ** self._size_precision
return math.floor(float(value) * factor) / factor
def _now_time(self) -> str:
return datetime.now().strftime("%H:%M:%S")

View File

@@ -1,17 +1,16 @@
# app/src/trading/journal/exporter.py
from __future__ import annotations
import csv
import json
import re
import zipfile
from datetime import datetime
from io import BytesIO, StringIO
from xml.sax.saxutils import escape
from zoneinfo import ZoneInfo
from openpyxl import Workbook
from openpyxl.styles import Font
from src.core.config import load_settings
from src.core.event_titles import event_title
@@ -61,12 +60,12 @@ def _event_title(event_type: object) -> str:
return event_title(event_type)
def _payload(row: dict) -> dict:
def _payload(row: dict[str, object]) -> dict[str, object]:
payload = row.get("payload")
return payload if isinstance(payload, dict) else {}
def _payload_json(payload: dict) -> str:
def _payload_json(payload: dict[str, object]) -> str:
if not payload:
return ""
@@ -74,7 +73,7 @@ def _payload_json(payload: dict) -> str:
return _strip_emoji(text)
def _export_row(row: dict) -> list[str]:
def _export_row(row: dict[str, object]) -> list[str]:
payload = _payload(row)
return [
@@ -108,15 +107,19 @@ def _headers() -> list[str]:
]
def _levels_summary(rows: list[dict]) -> str:
def _levels_summary(rows: list[dict[str, object]]) -> str:
levels = sorted(
{str(row.get("level") or "").upper() for row in rows if row.get("level")}
)
return ", ".join(levels) if levels else ""
def _period_summary(rows: list[dict]) -> str:
dates = [_format_datetime(row.get("created_at")) for row in rows if row.get("created_at")]
def _period_summary(rows: list[dict[str, object]]) -> str:
dates = [
_format_datetime(row.get("created_at"))
for row in rows
if row.get("created_at")
]
dates = [value for value in dates if value]
if not dates:
@@ -127,7 +130,7 @@ def _period_summary(rows: list[dict]) -> str:
def _metadata_rows(
*,
rows: list[dict],
rows: list[dict[str, object]],
total_count: int,
export_limit: int,
account_mode: str,
@@ -152,7 +155,7 @@ def _metadata_rows(
def build_csv(
rows: list[dict],
rows: list[dict[str, object]],
*,
total_count: int,
export_limit: int,
@@ -185,42 +188,199 @@ def build_csv(
def build_xlsx(
rows: list[dict],
rows: list[dict[str, object]],
*,
total_count: int,
export_limit: int,
account_mode: str,
journal_level: str,
) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "Journal"
sheet_rows: list[list[str]] = []
for metadata_row in _metadata_rows(
rows=rows,
total_count=total_count,
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
):
ws.append(metadata_row)
sheet_rows.extend(
_metadata_rows(
rows=rows,
total_count=total_count,
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
)
)
header_row_index = ws.max_row + 1
ws.append(_headers())
for cell in ws[1]:
cell.font = Font(bold=True)
for cell in ws[header_row_index]:
cell.font = Font(bold=True)
sheet_rows.append(_headers())
for row in rows:
ws.append(_export_row(row))
sheet_rows.append(_export_row(row))
for column_cells in ws.columns:
max_length = max(len(str(cell.value or "")) for cell in column_cells)
ws.column_dimensions[column_cells[0].column_letter].width = min(max_length + 2, 60)
return _build_xlsx_bytes(
sheet_name="Journal",
rows=sheet_rows,
)
def _build_xlsx_bytes(
*,
sheet_name: str,
rows: list[list[str]],
) -> bytes:
stream = BytesIO()
wb.save(stream)
return stream.getvalue()
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", _content_types_xml())
archive.writestr("_rels/.rels", _root_rels_xml())
archive.writestr("xl/workbook.xml", _workbook_xml(sheet_name))
archive.writestr("xl/_rels/workbook.xml.rels", _workbook_rels_xml())
archive.writestr("xl/styles.xml", _styles_xml())
archive.writestr("xl/worksheets/sheet1.xml", _worksheet_xml(rows))
return stream.getvalue()
def _worksheet_xml(rows: list[list[str]]) -> str:
header_row_index = _header_row_index(rows)
column_widths = _column_widths(rows)
xml_rows: list[str] = []
for row_index, row in enumerate(rows, start=1):
cells: list[str] = []
for column_index, value in enumerate(row, start=1):
cell_ref = f"{_column_letter(column_index)}{row_index}"
style = ' s="1"' if row_index in {1, header_row_index} else ""
cells.append(
f'<c r="{cell_ref}" t="inlineStr"{style}>'
f"<is><t>{_xml_text(value)}</t></is>"
f"</c>"
)
xml_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
cols_xml = "".join(
(
f'<col min="{index}" max="{index}" '
f'width="{width}" customWidth="1"/>'
)
for index, width in enumerate(column_widths, start=1)
)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f"<cols>{cols_xml}</cols>"
"<sheetData>"
f"{''.join(xml_rows)}"
"</sheetData>"
"</worksheet>"
)
def _header_row_index(rows: list[list[str]]) -> int:
headers = _headers()
for index, row in enumerate(rows, start=1):
if row == headers:
return index
return 1
def _column_widths(rows: list[list[str]]) -> list[int]:
max_columns = max((len(row) for row in rows), default=1)
widths: list[int] = []
for column_index in range(max_columns):
max_length = 0
for row in rows:
if column_index < len(row):
max_length = max(max_length, len(str(row[column_index] or "")))
widths.append(min(max_length + 2, 60))
return widths
def _column_letter(index: int) -> str:
result = ""
while index > 0:
index, remainder = divmod(index - 1, 26)
result = chr(65 + remainder) + result
return result
def _xml_text(value: object) -> str:
text = str(value or "")
return escape(text, {'"': "&quot;", "'": "&apos;"})
def _content_types_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
"</Types>"
)
def _root_rels_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="xl/workbook.xml"/>'
"</Relationships>"
)
def _workbook_xml(sheet_name: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
"<sheets>"
f'<sheet name="{_xml_text(sheet_name)}" sheetId="1" r:id="rId1"/>'
"</sheets>"
"</workbook>"
)
def _workbook_rels_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
'Target="worksheets/sheet1.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
'Target="styles.xml"/>'
"</Relationships>"
)
def _styles_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
"<fonts count=\"2\">"
"<font><sz val=\"11\"/><name val=\"Calibri\"/></font>"
"<font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font>"
"</fonts>"
"<fills count=\"1\"><fill><patternFill patternType=\"none\"/></fill></fills>"
"<borders count=\"1\"><border/></borders>"
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>"
"<cellXfs count=\"2\">"
"<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>"
"<xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>"
"</cellXfs>"
"</styleSheet>"
)

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from src.core.types import JsonDict
class MarketState(StrEnum):
TREND_UP = "TREND_UP"
@@ -46,12 +48,6 @@ class TrendStrength(StrEnum):
UNKNOWN = "UNKNOWN"
class TrendQuality(StrEnum):
CLEAN = "CLEAN"
NOISY = "NOISY"
UNKNOWN = "UNKNOWN"
class MarketPhase(StrEnum):
IMPULSE = "IMPULSE"
PULLBACK = "PULLBACK"
@@ -60,6 +56,29 @@ class MarketPhase(StrEnum):
UNKNOWN = "UNKNOWN"
class TrendQuality(StrEnum):
CLEAN = "CLEAN"
NORMAL = "NORMAL"
NOISY = "NOISY"
UNKNOWN = "UNKNOWN"
class EmaDistanceState(StrEnum):
COMPRESSED = "COMPRESSED"
HEALTHY = "HEALTHY"
EXTENDED = "EXTENDED"
OVEREXTENDED = "OVEREXTENDED"
UNKNOWN = "UNKNOWN"
class EntryTimingState(StrEnum):
EARLY = "EARLY"
NORMAL = "NORMAL"
LATE = "LATE"
CHASING = "CHASING"
UNKNOWN = "UNKNOWN"
@dataclass(slots=True)
class MarketAnalysisResult:
symbol: str
@@ -80,23 +99,42 @@ class MarketAnalysisResult:
reason: str
is_trade_allowed: bool
payload: dict
payload: JsonDict
trend_strength: TrendStrength
trend_quality: TrendQuality
market_phase: MarketPhase
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 = None
ema_slow_slope_percent: float | None = None
phase_direction_consistency: float | None = 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
breakout_reason: str | None = None
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

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import time
from typing import Any
from src.integrations.exchange.service import ExchangeService
from src.trading.market_analysis.models import (
MarketPhase,
@@ -133,8 +135,16 @@ class TrendStrategy:
"market_phase_change_percent": market.phase_change_percent,
"market_phase_direction_consistency": market.payload.get("market_phase_direction_consistency"),
"market_phase_reason": market.phase_reason,
"momentum_state": market.momentum_state.value,
"momentum_direction": market.momentum_direction.value,
"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,
@@ -381,8 +391,12 @@ class TrendStrategy:
confidence = 0.55 + (strength_score * 0.35)
return round(min(0.95, confidence), 2)
def _analysis_price(self, snapshot: dict[str, object]) -> float:
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"))
@@ -395,7 +409,10 @@ class TrendStrategy:
return 0.0
def _safe_float(self, value: object) -> float | None:
def _safe_float(
self,
value: float | int | str | None,
) -> float | None:
if value is None:
return None