07.4.4.1.10.3 — Telegram Diagnostic Screen

This commit is contained in:
2026-05-16 09:23:37 +03:00
parent 8e1c09ad66
commit 2c75f95b46
16 changed files with 2902 additions and 243 deletions

View File

@@ -18,6 +18,8 @@ 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.trading.diagnostics.formatter import SemanticDiagnosticFormatter
from src.trading.diagnostics.snapshot import SemanticDiagnosticSnapshotBuilder
class AutoTradeRunner:
@@ -269,13 +271,15 @@ class AutoTradeRunner:
if signal not in {"BUY", "SELL"}:
return
if cls._is_position_aligned_signal(state=state, signal=signal):
cls._log_position_aligned_signal_suppressed(
state=state,
payload=payload,
signal=signal,
)
return
# Если сигнал совпадает с открытой позицией, не публикуем событие,
# чтобы не создавать избыточные уведомления
#if cls._is_position_aligned_signal(state=state, signal=signal):
# cls._log_position_aligned_signal_suppressed(
# state=state,
# payload=payload,
# signal=signal,
# )
# return
cls._publish_strong_signal_event(state=state, payload=payload)
return
@@ -284,6 +288,7 @@ class AutoTradeRunner:
"paper_position_opened",
"paper_position_closed",
"paper_position_flipped",
"paper_flip_blocked",
}:
cls._publish_execution_event(
state=state,
@@ -292,6 +297,18 @@ class AutoTradeRunner:
)
return
@classmethod
def _notification_reason_lines(cls, state) -> list[str]:
snapshot = SemanticDiagnosticSnapshotBuilder().build(
state,
is_configured=True,
)
return SemanticDiagnosticFormatter().build_notification_reason_lines(
snapshot,
limit=2,
)
@classmethod
def _is_position_aligned_signal(cls, *, state, signal: str) -> bool:
position_side = str(getattr(state, "position_side", "NONE") or "NONE").upper()
@@ -390,6 +407,8 @@ class AutoTradeRunner:
"reason": reason,
"position_context": position_context,
"decision_status": state.decision_status,
"semantic_lines": cls._notification_reason_lines(state),
"position_side": position_context,
},
priority=priority.lower(),
dedupe_key=(
@@ -423,6 +442,8 @@ class AutoTradeRunner:
old_side = str(payload.get("old_side") or "")
new_side = str(payload.get("new_side") or side or "")
semantic_lines = cls._notification_reason_lines(state)
RuntimeEventPublisher.publish(
RuntimeEvent(
event_type=runtime_event_type,
@@ -436,6 +457,8 @@ class AutoTradeRunner:
"new_side": new_side,
"leverage": payload.get("leverage") if payload.get("leverage") is not None else state.leverage,
**payload,
"strategy": state.strategy,
"semantic_lines": semantic_lines,
},
priority="normal",
dedupe_key=cls._execution_dedupe_key(
@@ -451,6 +474,7 @@ class AutoTradeRunner:
"paper_position_opened": RuntimeEventType.POSITION_OPENED,
"paper_position_closed": RuntimeEventType.POSITION_CLOSED,
"paper_position_flipped": RuntimeEventType.POSITION_FLIPPED,
"paper_flip_blocked": RuntimeEventType.POSITION_FLIP_BLOCKED,
}
return mapping.get(event_type)
@@ -460,6 +484,7 @@ class AutoTradeRunner:
RuntimeEventType.POSITION_OPENED: "Paper position opened",
RuntimeEventType.POSITION_CLOSED: "Paper position closed",
RuntimeEventType.POSITION_FLIPPED: "Paper position flipped",
RuntimeEventType.POSITION_FLIP_BLOCKED: "Flip blocked",
}
return mapping.get(event_type, "Paper execution event")

View File

@@ -230,6 +230,12 @@ class AutoTradeService:
state.status = "RUNNING"
self._reset_signal_tracking()
state.cycle_realized_pnl_usd = 0.0
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
state.last_flip_reason = None
state.last_flip_monotonic_at = None
state.last_signal = "HOLD"
state.signal_started_at = time.monotonic()
@@ -261,6 +267,12 @@ class AutoTradeService:
)
if previous_status == "OFF":
state.cycle_realized_pnl_usd = 0.0
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
state.last_flip_reason = None
state.last_flip_monotonic_at = None
return state, "Включён режим наблюдения."
return state, "Автоторговля переведена в режим наблюдения."
@@ -275,6 +287,12 @@ class AutoTradeService:
return state, "Автоторговля уже выключена."
state.status = "OFF"
state.cycle_realized_pnl_usd = 0.0
state.last_flip_old_side = None
state.last_flip_new_side = None
state.last_flip_pnl_usd = None
state.last_flip_reason = None
state.last_flip_monotonic_at = None
self.stop_loop()
EventBus.emit(

View File

@@ -94,6 +94,18 @@ class AutoTradeState:
# зафиксированный результат закрытых paper-сделок
realized_pnl_usd: float = 0.0
# cumulative realized pnl за текущий цикл автоторговли
cycle_realized_pnl_usd: float = 0.0
# данные последнего flip
last_flip_old_side: str | None = None
last_flip_new_side: str | None = None
last_flip_pnl_usd: float | None = None
last_flip_reason: str | None = None
# monotonic timestamp последнего flip
last_flip_monotonic_at: float | None = None
# последнее execution-действие
last_execution_action: str | None = None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
# app/src/trading/diagnostics/snapshot.py
from __future__ import annotations
import time
from typing import Any
from src.trading.auto.state import AutoTradeState
class SemanticDiagnosticSnapshotBuilder:
def build(self, state: AutoTradeState, *, is_configured: bool) -> dict[str, Any]:
now = time.monotonic()
signal_age_seconds = self._age_seconds(
now=now,
started_at=state.signal_started_at,
)
market_age_seconds = self._age_seconds(
now=now,
started_at=state.market_analysis_updated_at,
)
blockers = self._blockers(state)
health_score = self._health_score(state=state, blockers=blockers)
severity = self._severity(
state=state,
health_score=health_score,
blockers=blockers,
)
return {
"status": {
"status": state.status,
"symbol": state.symbol,
"strategy": state.strategy,
"is_configured": is_configured,
},
"signal": {
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"decision_status": state.decision_status,
"is_confirmed": state.is_signal_confirmed,
"is_ready": state.is_signal_ready,
"repeat_count": state.last_signal_repeat_count,
"confirmation_progress": state.signal_confirmation_progress,
"age_seconds": signal_age_seconds,
"reason": state.last_signal_reason,
},
"market": {
"state": state.market_state,
"trend": state.market_trend,
"volatility": state.market_volatility,
"trend_strength": state.market_trend_strength,
"trend_quality": state.market_trend_quality,
"phase": state.market_phase,
"phase_direction": state.market_phase_direction,
"entry_block_reason": state.entry_block_reason,
"entry_block_message": state.entry_block_message,
"age_seconds": market_age_seconds,
},
"momentum": {
"state": getattr(state, "momentum_state", None),
"direction": getattr(state, "momentum_direction", None),
"strength": getattr(state, "momentum_strength", None),
"change_percent": getattr(state, "momentum_change_percent", None),
"breakout_level": getattr(state, "breakout_level", None),
"breakout_distance_percent": getattr(
state,
"breakout_distance_percent",
None,
),
"is_breakout": getattr(state, "momentum_state", None)
in {"BREAKOUT_UP", "BREAKOUT_DOWN"},
"breakout_reason": getattr(state, "breakout_reason", None),
},
"execution": {
"quality": state.execution_quality,
"quality_reason": state.execution_quality_reason,
"quality_message": state.execution_quality_message,
"semantic_status": state.execution_semantic_status,
"semantic_message": state.execution_semantic_message,
"semantic_reason": state.execution_semantic_reason,
"confidence_score": state.execution_confidence_score,
"confidence_level": state.execution_confidence_level,
"confidence_reason": state.execution_confidence_reason,
"spread_percent": state.spread_percent,
"snapshot_age_seconds": state.snapshot_age_seconds,
"market_runtime_degraded": state.market_runtime_degraded,
},
"adaptive_size": {
"base": state.adaptive_size_base,
"final": state.adaptive_size_final,
"multiplier": state.adaptive_size_multiplier,
"effective_risk_percent": state.effective_risk_percent,
"effective_target_risk_usd": state.effective_target_risk_usd,
"reason": state.adaptive_size_reason,
"factors": state.adaptive_size_factors,
},
"position": {
"side": state.position_side,
"entry_price": state.entry_price,
"size": state.position_size,
"leverage": state.leverage,
"unrealized_pnl_usd": state.unrealized_pnl_usd,
"realized_pnl_usd": state.realized_pnl_usd,
"cycle_realized_pnl_usd": state.cycle_realized_pnl_usd,
"last_execution_action": state.last_execution_action,
"last_execution_reason": state.last_execution_reason,
"last_flip_old_side": state.last_flip_old_side,
"last_flip_new_side": state.last_flip_new_side,
"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,
},
"runtime_health": {
"health_score": health_score,
"severity": severity,
"is_runtime_degraded": self._is_runtime_degraded(state),
"signal_age_seconds": signal_age_seconds,
"market_age_seconds": market_age_seconds,
"runtime_expired_reason": state.runtime_expired_reason,
"runtime_expired_message": state.runtime_expired_message,
"has_market_data": state.market_state is not None,
"has_momentum_data": getattr(state, "momentum_state", None) is not None,
},
"summary": {
"health_score": health_score,
"severity": severity,
"assessment": self._assessment(severity),
"mode": self._display_mode(
severity=severity,
blockers=blockers,
state=state,
),
"headline_mode": (
"POSITION"
if state.position_side != "NONE"
else "ENTRY"
),
"main_message": self._main_message(state=state, blockers=blockers),
"market": state.market_state,
"phase": state.market_phase,
"momentum": getattr(state, "momentum_state", None),
"execution": state.execution_semantic_status,
"position": state.position_side,
"is_ready": state.is_signal_ready,
"is_blocked": bool(blockers),
"blockers": blockers,
},
}
def _age_seconds(
self,
*,
now: float,
started_at: float | None,
) -> int | None:
if started_at is None:
return None
return max(0, int(now - float(started_at)))
def _is_runtime_degraded(self, state: AutoTradeState) -> bool:
return bool(
state.market_runtime_degraded
or state.execution_quality == "BLOCKED"
or state.runtime_expired_reason
)
def _health_score(
self,
*,
state: AutoTradeState,
blockers: list[str],
) -> int:
score = 100
if state.status != "RUNNING":
score -= 10
if blockers:
score -= min(35, len(blockers) * 12)
if state.execution_quality == "BLOCKED":
score -= 30
elif state.execution_quality == "WARNING":
score -= 15
if state.market_state in {"RANGE", "HIGH_VOLATILITY", "LOW_VOLATILITY"}:
score -= 15
if state.market_trend_strength == "WEAK":
score -= 10
if state.market_trend_quality == "NOISY":
score -= 10
if state.market_phase in {"RANGE", "SQUEEZE", "PULLBACK"}:
score -= 10
if state.market_runtime_degraded:
score -= 15
if state.runtime_expired_reason:
score -= 20
if state.is_signal_ready:
score += 10
return max(0, min(100, score))
def _severity(
self,
*,
state: AutoTradeState,
health_score: int,
blockers: list[str],
) -> str:
signal = str(state.last_signal or "HOLD").upper()
has_ready_signal = bool(state.is_signal_ready)
has_position = state.position_side != "NONE"
has_waiting_data_blocker = any(
str(item).strip().lower()
in {
"мало данных",
"мало live-данных",
"недостаточно live-данных",
}
for item in blockers
)
if has_waiting_data_blocker:
return "WAITING"
if (
state.execution_quality == "BLOCKED"
or state.decision_status == "BLOCKED"
or state.runtime_expired_reason
):
return "RED"
if has_position:
if health_score < 45:
return "RED"
if blockers or state.execution_quality == "WARNING" or health_score < 75:
return "YELLOW"
return "GREEN"
if signal == "HOLD" and not has_ready_signal:
return "WAITING"
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
return "YELLOW"
if health_score < 45:
return "YELLOW"
if blockers or state.execution_quality == "WARNING" or health_score < 75:
return "YELLOW"
return "GREEN"
def _assessment(self, severity: str) -> str:
if severity == "GREEN":
return "стабильно"
if severity == "WAITING":
return "ожидание"
if severity == "YELLOW":
return "осторожно"
return "вход нежелателен"
def _display_mode(
self,
*,
severity: str,
blockers: list[str],
state: AutoTradeState | None = None,
) -> str:
if state is not None and state.position_side != "NONE":
return "EXPANDED"
if severity == "GREEN" and not blockers:
return "COMPACT"
return "EXPANDED"
def _main_message(
self,
*,
state: AutoTradeState,
blockers: list[str],
) -> str:
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
return "Ожидание: рынок без направления."
return "Осторожно: рынок не подходит."
if state.execution_quality == "BLOCKED":
reason = str(state.execution_quality_reason or "")
if reason == "HIGH_SPREAD":
return "Вход нежелателен: спред мешает входу."
if reason == "STALE_SNAPSHOT":
return "Вход нежелателен: данные рынка устарели."
if reason in {"SNAPSHOT_ERROR", "SNAPSHOT_UNAVAILABLE"}:
return "Вход нежелателен: нет надёжных данных рынка."
return "Вход нежелателен: исполнение заблокировано."
if state.entry_block_message:
return f"Рынок не готов: {state.entry_block_message}."
if state.execution_quality == "WARNING":
return "Вход рискованный: качество исполнения снижено."
if state.is_signal_ready:
return "Сигнал готов, вход разрешён."
if state.last_signal in {"BUY", "SELL"}:
return "Сигнал есть, идёт подтверждение."
if blockers:
return f"Есть ограничения: {', '.join(blockers)}."
return "Критичных ограничений нет."
def _blockers(self, state: AutoTradeState) -> list[str]:
blockers: list[str] = []
if state.entry_block_reason == "MARKET_FILTER_BLOCKED":
if state.market_state == "RANGE" or state.market_phase == "RANGE":
blockers.append("рынок без направления")
elif state.entry_block_message:
blockers.append(str(state.entry_block_message))
else:
blockers.append("рынок не подходит")
return blockers
if state.entry_block_message:
blockers.append(str(state.entry_block_message))
if state.execution_quality == "BLOCKED":
blockers.append(str(state.execution_quality_message or "исполнение заблокировано"))
if state.decision_status == "BLOCKED":
blockers.append(str(state.decision_reason or "решение заблокировано"))
if state.runtime_expired_message:
blockers.append(str(state.runtime_expired_message))
return blockers

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import time
import math
from dataclasses import dataclass
from datetime import datetime
@@ -227,6 +228,13 @@ 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()
old_side = position.side
old_entry_price = position.entry_price
@@ -341,6 +349,7 @@ class ExecutionEngine:
pnl = forced_pnl if forced_pnl is not None else self._calculate_pnl(exit_price)
state.realized_pnl_usd += pnl
state.cycle_realized_pnl_usd += pnl
now = self._now_time()
@@ -404,6 +413,7 @@ class ExecutionEngine:
f"Позиция закрыта по правилу защиты: {forced_reason}.",
)
return ExecutionDecision("CLOSE", True, "Позиция закрыта.")
def _risk_close_decision(self, state: AutoTradeState) -> ExecutionDecision | None:

View File

@@ -110,6 +110,9 @@ class TrendStrategy:
if len(prices) > self._window_size:
prices.pop(0)
market_phase = self._normalized_market_phase(market)
market_phase_direction = self._normalized_market_phase_direction(market)
base_payload = {
"strategy": self.name,
"symbol": symbol,
@@ -125,8 +128,8 @@ class TrendStrategy:
"market_analysis": market.payload,
"market_trend_strength": market.trend_strength.value,
"market_trend_quality": market.trend_quality.value,
"market_phase": market.market_phase.value,
"market_phase_direction": market.phase_direction.value,
"market_phase": market_phase,
"market_phase_direction": market_phase_direction,
"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,
@@ -305,7 +308,10 @@ class TrendStrategy:
momentum_direction = getattr(market, "momentum_direction", TrendDirection.UNKNOWN)
momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0)
if momentum_state == MomentumState.BREAKOUT_UP:
if (
momentum_state == MomentumState.BREAKOUT_UP
and market.state == MarketState.TREND_UP
):
return SignalResult(
signal=SignalType.BUY,
reason="BREAKOUT_UP подтверждён momentum/breakout semantic layer.",
@@ -319,7 +325,10 @@ class TrendStrategy:
},
)
if momentum_state == MomentumState.BREAKOUT_DOWN:
if (
momentum_state == MomentumState.BREAKOUT_DOWN
and market.state == MarketState.TREND_DOWN
):
return SignalResult(
signal=SignalType.SELL,
reason="BREAKOUT_DOWN подтверждён momentum/breakout semantic layer.",
@@ -333,6 +342,37 @@ class TrendStrategy:
},
)
if (
momentum_state == MomentumState.BREAKOUT_DOWN
and market.state == MarketState.TREND_UP
):
return SignalResult(
signal=SignalType.HOLD,
reason="Пробой вниз против TREND_UP считается коррекцией, вход в SHORT запрещён.",
confidence=0.0,
payload={
**base_payload,
"entry_block_reason": "COUNTER_TREND_BREAKOUT",
"entry_block_message": "пробой против тренда",
"expected_direction": "BUY",
},
)
if (
momentum_state == MomentumState.BREAKOUT_UP
and market.state == MarketState.TREND_DOWN
):
return SignalResult(
signal=SignalType.HOLD,
reason="Пробой вверх против TREND_DOWN считается откатом, вход в LONG запрещён.",
confidence=0.0,
payload={
**base_payload,
"entry_block_reason": "COUNTER_TREND_BREAKOUT",
"entry_block_message": "пробой против тренда",
"expected_direction": "SELL",
},
)
return None
def _calculate_breakout_confidence(self, momentum_strength: float) -> float:
@@ -384,6 +424,31 @@ class TrendStrategy:
return down_moves / total_moves
def _normalized_market_phase(self, market) -> str:
phase = market.market_phase.value
momentum_state = market.momentum_state.value
active_momentum_states = {
"MOMENTUM_UP",
"MOMENTUM_DOWN",
"BREAKOUT_UP",
"BREAKOUT_DOWN",
}
if phase == "IMPULSE" and momentum_state not in active_momentum_states:
return "UNKNOWN"
return phase
def _normalized_market_phase_direction(self, market) -> str:
phase = self._normalized_market_phase(market)
if phase == "UNKNOWN":
return "UNKNOWN"
return market.phase_direction.value
def _calculate_confidence(
self,
change_percent: float,