07.4.4.1.9 Adaptive Market Diagnostics Layer
This commit is contained in:
@@ -158,6 +158,7 @@ def _build_waiting_text(state) -> str:
|
||||
signal_lines = [
|
||||
_signal_line(state),
|
||||
_market_state_line(state),
|
||||
_market_diagnostics_line(state),
|
||||
_entry_block_line(state),
|
||||
_execution_quality_line(state),
|
||||
*_signal_confidence_lines(state),
|
||||
@@ -261,6 +262,49 @@ def _market_state_line(state) -> str:
|
||||
return labels.get(market_state, "⏳ Рынок · Идёт анализ")
|
||||
|
||||
|
||||
def _market_diagnostics_line(state) -> str:
|
||||
strength = getattr(state, "market_trend_strength", None)
|
||||
quality = getattr(state, "market_trend_quality", None)
|
||||
phase = getattr(state, "market_phase", None)
|
||||
|
||||
if not strength and not quality and not phase:
|
||||
return ""
|
||||
|
||||
strength_labels = {
|
||||
"WEAK": "слабый",
|
||||
"NORMAL": "нормальный",
|
||||
"STRONG": "сильный",
|
||||
}
|
||||
|
||||
quality_labels = {
|
||||
"CLEAN": "чистый",
|
||||
"NOISY": "шумный",
|
||||
}
|
||||
|
||||
phase_labels = {
|
||||
"IMPULSE": "импульс",
|
||||
"PULLBACK": "откат",
|
||||
"RANGE": "флэт",
|
||||
"SQUEEZE": "сжатие",
|
||||
}
|
||||
|
||||
parts = []
|
||||
|
||||
if strength in strength_labels:
|
||||
parts.append(strength_labels[strength])
|
||||
|
||||
if quality in quality_labels:
|
||||
parts.append(quality_labels[quality])
|
||||
|
||||
if phase in phase_labels:
|
||||
parts.append(phase_labels[phase])
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return f"Анализ · {' · '.join(parts)}"
|
||||
|
||||
|
||||
def _compact_entry_block_message(message: str) -> str:
|
||||
normalized = message.strip().lower()
|
||||
|
||||
|
||||
@@ -378,6 +378,9 @@ class AutoTradeService:
|
||||
state.execution_quality_reason = None
|
||||
state.execution_quality_message = None
|
||||
state.market_runtime_degraded = False
|
||||
state.market_trend_strength = None
|
||||
state.market_trend_quality = None
|
||||
state.market_phase = None
|
||||
|
||||
# собрать контекст для стратегии
|
||||
def _build_strategy_context(self) -> StrategyContext:
|
||||
@@ -746,6 +749,9 @@ class AutoTradeService:
|
||||
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_analysis_interval = payload.get("market_analysis_interval")
|
||||
state.market_analysis_reason = payload.get("market_analysis_reason")
|
||||
state.market_analysis_updated_at = time.monotonic()
|
||||
|
||||
@@ -115,6 +115,15 @@ class AutoTradeState:
|
||||
# волатильность: LOW / NORMAL / HIGH / UNKNOWN
|
||||
market_volatility: str | None = None
|
||||
|
||||
# сила тренда: WEAK / NORMAL / STRONG / UNKNOWN
|
||||
market_trend_strength: str | None = None
|
||||
|
||||
# качество тренда: CLEAN / NOISY / UNKNOWN
|
||||
market_trend_quality: str | None = None
|
||||
|
||||
# фаза рынка: IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN
|
||||
market_phase: str | None = None
|
||||
|
||||
# таймфрейм анализа рынка
|
||||
market_analysis_interval: str | None = None
|
||||
|
||||
|
||||
@@ -29,6 +29,27 @@ class VolatilityState(StrEnum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class TrendStrength(StrEnum):
|
||||
WEAK = "WEAK"
|
||||
NORMAL = "NORMAL"
|
||||
STRONG = "STRONG"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class TrendQuality(StrEnum):
|
||||
CLEAN = "CLEAN"
|
||||
NOISY = "NOISY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class MarketPhase(StrEnum):
|
||||
IMPULSE = "IMPULSE"
|
||||
PULLBACK = "PULLBACK"
|
||||
RANGE = "RANGE"
|
||||
SQUEEZE = "SQUEEZE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MarketAnalysisResult:
|
||||
symbol: str
|
||||
@@ -49,4 +70,10 @@ class MarketAnalysisResult:
|
||||
reason: str
|
||||
is_trade_allowed: bool
|
||||
|
||||
payload: dict
|
||||
payload: dict
|
||||
|
||||
trend_strength: TrendStrength
|
||||
trend_quality: TrendQuality
|
||||
market_phase: MarketPhase
|
||||
trend_gap_percent: float | None
|
||||
trend_consistency: float | None
|
||||
@@ -1,13 +1,14 @@
|
||||
# app/src/trading/market_analysis/service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.market_analysis.indicators import atr, ema, rsi
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketAnalysisResult,
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
TrendDirection,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
VolatilityState,
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ class MarketAnalysisService:
|
||||
_high_volatility_atr_percent = 1.8
|
||||
|
||||
_trend_gap_percent = 0.03
|
||||
_trend_consistency_window = 20
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
@@ -77,12 +79,29 @@ class MarketAnalysisService:
|
||||
)
|
||||
|
||||
atr_percent = (atr_value / close_price) * 100
|
||||
trend_gap_percent = self._trend_gap_percent_value(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
)
|
||||
|
||||
volatility = self._classify_volatility(atr_percent)
|
||||
trend = self._classify_trend(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
)
|
||||
trend_strength = self._classify_trend_strength(trend_gap_percent)
|
||||
trend_consistency = self._trend_consistency(
|
||||
closes=closes,
|
||||
trend=trend,
|
||||
)
|
||||
trend_quality = self._classify_trend_quality(trend_consistency)
|
||||
market_phase = self._classify_market_phase(
|
||||
trend=trend,
|
||||
volatility=volatility,
|
||||
trend_strength=trend_strength,
|
||||
trend_quality=trend_quality,
|
||||
rsi_value=rsi_value,
|
||||
)
|
||||
|
||||
state = self._classify_market_state(
|
||||
trend=trend,
|
||||
@@ -123,6 +142,15 @@ class MarketAnalysisService:
|
||||
"market_state": state.value,
|
||||
"trend": trend.value,
|
||||
"volatility": volatility.value,
|
||||
"market_trend_strength": trend_strength.value,
|
||||
"market_trend_quality": trend_quality.value,
|
||||
"market_phase": market_phase.value,
|
||||
"market_trend_gap_percent": round(trend_gap_percent, 5)
|
||||
if trend_gap_percent is not None
|
||||
else None,
|
||||
"market_trend_consistency": round(trend_consistency, 3)
|
||||
if trend_consistency is not None
|
||||
else None,
|
||||
"close_price": close_price,
|
||||
"ema_fast_period": self._fast_ema_period,
|
||||
"ema_slow_period": self._slow_ema_period,
|
||||
@@ -136,18 +164,37 @@ class MarketAnalysisService:
|
||||
"candles_count": len(candles),
|
||||
"is_trade_allowed": is_trade_allowed,
|
||||
},
|
||||
trend_strength=trend_strength,
|
||||
trend_quality=trend_quality,
|
||||
market_phase=market_phase,
|
||||
trend_gap_percent=trend_gap_percent,
|
||||
trend_consistency=trend_consistency,
|
||||
)
|
||||
|
||||
def _trend_gap_percent_value(
|
||||
self,
|
||||
*,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
) -> float | None:
|
||||
if ema_slow <= 0:
|
||||
return None
|
||||
|
||||
return ((ema_fast - ema_slow) / ema_slow) * 100
|
||||
|
||||
def _classify_trend(
|
||||
self,
|
||||
*,
|
||||
ema_fast: float,
|
||||
ema_slow: float,
|
||||
) -> TrendDirection:
|
||||
if ema_slow <= 0:
|
||||
return TrendDirection.UNKNOWN
|
||||
gap_percent = self._trend_gap_percent_value(
|
||||
ema_fast=ema_fast,
|
||||
ema_slow=ema_slow,
|
||||
)
|
||||
|
||||
gap_percent = ((ema_fast - ema_slow) / ema_slow) * 100
|
||||
if gap_percent is None:
|
||||
return TrendDirection.UNKNOWN
|
||||
|
||||
if gap_percent >= self._trend_gap_percent:
|
||||
return TrendDirection.UP
|
||||
@@ -157,6 +204,99 @@ class MarketAnalysisService:
|
||||
|
||||
return TrendDirection.FLAT
|
||||
|
||||
def _classify_trend_strength(
|
||||
self,
|
||||
trend_gap_percent: float | None,
|
||||
) -> TrendStrength:
|
||||
if trend_gap_percent is None:
|
||||
return TrendStrength.UNKNOWN
|
||||
|
||||
gap = abs(trend_gap_percent)
|
||||
|
||||
if gap < 0.08:
|
||||
return TrendStrength.WEAK
|
||||
|
||||
if gap < 0.25:
|
||||
return TrendStrength.NORMAL
|
||||
|
||||
return TrendStrength.STRONG
|
||||
|
||||
def _trend_consistency(
|
||||
self,
|
||||
*,
|
||||
closes: list[float],
|
||||
trend: TrendDirection,
|
||||
) -> float | None:
|
||||
if len(closes) < 2:
|
||||
return None
|
||||
|
||||
window = closes[-self._trend_consistency_window :]
|
||||
if len(window) < 2:
|
||||
return None
|
||||
|
||||
up_moves = 0
|
||||
down_moves = 0
|
||||
|
||||
for previous_price, current_price in zip(window, window[1:]):
|
||||
if current_price > previous_price:
|
||||
up_moves += 1
|
||||
elif current_price < previous_price:
|
||||
down_moves += 1
|
||||
|
||||
total_moves = max(1, len(window) - 1)
|
||||
|
||||
if trend == TrendDirection.UP:
|
||||
return up_moves / total_moves
|
||||
|
||||
if trend == TrendDirection.DOWN:
|
||||
return down_moves / total_moves
|
||||
|
||||
return None
|
||||
|
||||
def _classify_trend_quality(
|
||||
self,
|
||||
trend_consistency: float | None,
|
||||
) -> TrendQuality:
|
||||
if trend_consistency is None:
|
||||
return TrendQuality.UNKNOWN
|
||||
|
||||
if trend_consistency >= 0.6:
|
||||
return TrendQuality.CLEAN
|
||||
|
||||
return TrendQuality.NOISY
|
||||
|
||||
def _classify_market_phase(
|
||||
self,
|
||||
*,
|
||||
trend: TrendDirection,
|
||||
volatility: VolatilityState,
|
||||
trend_strength: TrendStrength,
|
||||
trend_quality: TrendQuality,
|
||||
rsi_value: float | None,
|
||||
) -> MarketPhase:
|
||||
if volatility == VolatilityState.LOW:
|
||||
return MarketPhase.SQUEEZE
|
||||
|
||||
if trend == TrendDirection.FLAT:
|
||||
return MarketPhase.RANGE
|
||||
|
||||
if trend not in {TrendDirection.UP, TrendDirection.DOWN}:
|
||||
return MarketPhase.UNKNOWN
|
||||
|
||||
if trend_strength == TrendStrength.WEAK:
|
||||
return MarketPhase.RANGE
|
||||
|
||||
if trend_quality == TrendQuality.NOISY:
|
||||
return MarketPhase.PULLBACK
|
||||
|
||||
if trend == TrendDirection.UP and rsi_value is not None and rsi_value < 45:
|
||||
return MarketPhase.PULLBACK
|
||||
|
||||
if trend == TrendDirection.DOWN and rsi_value is not None and rsi_value > 55:
|
||||
return MarketPhase.PULLBACK
|
||||
|
||||
return MarketPhase.IMPULSE
|
||||
|
||||
def _classify_volatility(self, atr_percent: float) -> VolatilityState:
|
||||
if atr_percent <= 0:
|
||||
return VolatilityState.UNKNOWN
|
||||
@@ -249,8 +389,18 @@ class MarketAnalysisService:
|
||||
"market_state": MarketState.UNKNOWN.value,
|
||||
"trend": TrendDirection.UNKNOWN.value,
|
||||
"volatility": VolatilityState.UNKNOWN.value,
|
||||
"market_trend_strength": TrendStrength.UNKNOWN.value,
|
||||
"market_trend_quality": TrendQuality.UNKNOWN.value,
|
||||
"market_phase": MarketPhase.UNKNOWN.value,
|
||||
"market_trend_gap_percent": None,
|
||||
"market_trend_consistency": None,
|
||||
"candles_count": candles_count,
|
||||
"is_trade_allowed": False,
|
||||
"reason": reason,
|
||||
},
|
||||
trend_strength=TrendStrength.UNKNOWN,
|
||||
trend_quality=TrendQuality.UNKNOWN,
|
||||
market_phase=MarketPhase.UNKNOWN,
|
||||
trend_gap_percent=None,
|
||||
trend_consistency=None,
|
||||
)
|
||||
@@ -5,7 +5,12 @@ from __future__ import annotations
|
||||
import time
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.market_analysis.models import MarketState
|
||||
from src.trading.market_analysis.models import (
|
||||
MarketPhase,
|
||||
MarketState,
|
||||
TrendQuality,
|
||||
TrendStrength,
|
||||
)
|
||||
from src.trading.market_analysis.service import MarketAnalysisService
|
||||
from src.trading.strategies.base import StrategyContext
|
||||
from src.trading.strategies.signals import SignalResult, SignalType
|
||||
@@ -116,6 +121,11 @@ class TrendStrategy:
|
||||
"market_analysis_interval": market.interval,
|
||||
"market_analysis_reason": market.reason,
|
||||
"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_trend_gap_percent": market.trend_gap_percent,
|
||||
"market_trend_consistency": market.trend_consistency,
|
||||
"runtime_window_ttl_seconds": self._window_ttl_seconds,
|
||||
"runtime_window_size": len(prices),
|
||||
}
|
||||
@@ -133,6 +143,42 @@ class TrendStrategy:
|
||||
},
|
||||
)
|
||||
|
||||
if market.trend_strength == TrendStrength.WEAK:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="TREND есть, но сила тренда слабая.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "WEAK_MARKET_TREND",
|
||||
"entry_block_message": "слабый тренд",
|
||||
},
|
||||
)
|
||||
|
||||
if market.trend_quality == TrendQuality.NOISY:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="TREND есть, но движение шумное.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "NOISY_MARKET_TREND",
|
||||
"entry_block_message": "шумный тренд",
|
||||
},
|
||||
)
|
||||
|
||||
if market.market_phase == MarketPhase.PULLBACK:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
reason="TREND есть, но рынок находится в откате.",
|
||||
confidence=0.0,
|
||||
payload={
|
||||
**base_payload,
|
||||
"entry_block_reason": "MARKET_PULLBACK",
|
||||
"entry_block_message": "откат",
|
||||
},
|
||||
)
|
||||
|
||||
if len(prices) < self._window_size:
|
||||
return SignalResult(
|
||||
signal=SignalType.HOLD,
|
||||
|
||||
Reference in New Issue
Block a user