07.4.4.1.10.3 — Telegram Diagnostic Screen
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
366
app/src/trading/diagnostics/snapshot.py
Normal file
366
app/src/trading/diagnostics/snapshot.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user