07.4.4.1.11 — Advanced Trend Quality & EMA Distance Layer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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, {'"': """, "'": "'"})
|
||||
|
||||
|
||||
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>"
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user