07.4.4.1.9.6 Adaptive Position Sizing + 07.4.4.1.9.6.1 Market Semantic Layer for Adaptive Sizing

This commit is contained in:
2026-05-12 20:25:10 +03:00
parent 1aa8f6c407
commit 8b83055e6a
12 changed files with 1298 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import math
import time
import re
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -62,6 +63,16 @@ def build_auto_text() -> str:
return _build_waiting_text(state)
def build_auto_semantic_text() -> str:
text = build_auto_text()
return re.sub(
r" · \d+с| · \d+м \d+с| · \d+ч \d+м",
"",
text,
)
def _build_not_configured_text(state) -> str:
symbol_ready = state.symbol is not None
strategy_ready = state.strategy is not None
@@ -124,7 +135,7 @@ def _build_stopped_without_position_text(state) -> str:
f"Цена · {_format_plain_or_dash(price)}",
_estimated_size_text(state, price),
_max_reserved_line(state, price),
f"Риск · {_format_money_compact(_target_risk_usd(state))}",
_effective_risk_line(state),
]
if rr_line or risk_line:
@@ -171,6 +182,30 @@ def _execution_confidence_line(state) -> str:
return f"🧠 Уверенность входа · {percent}% низкая"
def _adaptive_size_line(state) -> str:
multiplier = getattr(state, "adaptive_size_multiplier", None)
if multiplier is None:
return ""
percent = int(round(float(multiplier) * 100))
reason = getattr(state, "adaptive_size_reason", None)
if multiplier <= 0:
return "🧮 Размер · вход заблокирован"
if multiplier < 1:
return f"🧮 Размер · {percent}% адаптивно уменьшен"
if multiplier > 1:
return f"🧮 Размер · {percent}% адаптивно увеличен"
if multiplier == 1:
return "🧮 Размер · без корректировки"
return ""
def _build_waiting_text(state) -> str:
price = _signal_entry_price(state)
@@ -206,15 +241,19 @@ def _build_waiting_text(state) -> str:
if signal_lines:
parts.extend(["", *signal_lines])
parts.extend([
"",
order_lines = [
"Подготовка ордера 🧾",
_order_header_line(state),
f"{_price_label_for_signal(state)} · {_format_plain_or_dash(price)}",
_estimated_size_text(state, price),
_adaptive_size_line(state),
_max_reserved_line(state, price),
f"Риск · {_format_money_compact(_target_risk_usd(state))}",
])
_effective_risk_line(state),
]
order_lines = [line for line in order_lines if line]
parts.extend(["", *order_lines])
if rr_line or risk_line:
parts.append("")
@@ -271,6 +310,7 @@ def _build_active_position_text(state) -> str:
"",
f"Размер · {_format_crypto_size(size)}",
f"Позиция · {_format_money_compact(notional)}",
_adaptive_size_line(state),
f"Вход · {_format_plain_or_dash(state.entry_price)}",
f"Цена · {_format_plain_or_dash(price_for_calc)}",
"",
@@ -487,6 +527,15 @@ def _target_risk_usd(state) -> float:
return _allocated_balance(state) * (state.risk_percent / 100)
def _effective_risk_line(state) -> str:
effective_risk_usd = getattr(state, "effective_target_risk_usd", None)
if effective_risk_usd is not None:
return f"Риск · {_format_money_compact(effective_risk_usd)}"
return f"Риск · {_format_money_compact(_target_risk_usd(state))}"
def _estimated_size(state, price: float | None) -> float | None:
if (
price is None
@@ -499,16 +548,24 @@ def _estimated_size(state, price: float | None) -> float | None:
return None
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
if stop_loss_distance_usd <= 0:
return None
risk_size = _target_risk_usd(state) / stop_loss_distance_usd
multiplier = getattr(state, "adaptive_size_multiplier", None)
if multiplier is not None:
risk_size *= float(multiplier)
max_percent = getattr(state, "max_reserved_balance_percent", None)
if max_percent is None or max_percent <= 0:
return _round_size(risk_size)
leverage = state.leverage or 1.0
if leverage <= 0:
return _round_size(risk_size)

View File

@@ -17,6 +17,7 @@ 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
class AutoTradeRunner:
@@ -29,12 +30,14 @@ class AutoTradeRunner:
_current_screen: str | None = None
_analysis_interval_seconds = 5
_ui_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
_position_aligned_signal_log_interval_seconds = 900
_last_position_aligned_signal_log_at_by_key: dict[str, float] = {}
@@ -55,6 +58,8 @@ class AutoTradeRunner:
cls._render_text = render_text
cls._render_markup = render_markup
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
NotificationTargetRegistry.set_default_chat(
bot=bot,
@@ -86,6 +91,8 @@ class AutoTradeRunner:
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@classmethod
def unregister_screen(
@@ -101,6 +108,8 @@ class AutoTradeRunner:
cls._render_text = None
cls._render_markup = None
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@classmethod
async def detach_screen(
@@ -134,6 +143,8 @@ class AutoTradeRunner:
cls._render_markup = None
cls._current_screen = None
cls._last_text = None
cls._last_semantic_text = None
cls._last_screen_state_key = None
@classmethod
def set_current_screen(cls, screen: str) -> None:
@@ -193,11 +204,28 @@ class AutoTradeRunner:
},
)
state = service.get_state()
current_event_version = EventBus.version()
has_important_event = current_event_version != cls._last_event_version
screen_state_key = cls._screen_state_key(state)
has_screen_state_changed = screen_state_key != cls._last_screen_state_key
if has_screen_state_changed:
cls._last_screen_state_key = screen_state_key
force_refresh = False
if has_important_event:
cls._last_event_version = current_event_version
event_type, _ = EventBus.last_event()
force_refresh = event_type in {
"paper_position_opened",
"paper_position_closed",
"paper_position_flipped",
}
try:
await cls._handle_important_event(state)
except Exception as exc:
@@ -210,7 +238,9 @@ class AutoTradeRunner:
)
try:
await cls._refresh_screen(force=has_important_event)
await cls._refresh_screen(
force=force_refresh or has_screen_state_changed
)
except Exception as exc:
cls._log_refresh_error(
"auto_refresh_loop_error",
@@ -491,6 +521,46 @@ class AutoTradeRunner:
except Exception:
pass
@classmethod
def _screen_state_key(cls, state) -> str:
return "|".join(
str(value)
for value in [
getattr(state, "status", None),
getattr(state, "symbol", None),
getattr(state, "strategy", None),
getattr(state, "last_signal", None),
#getattr(state, "last_signal_repeat_count", None),
#getattr(state, "last_signal_confidence", None),
getattr(state, "decision_status", None),
getattr(state, "decision_reason", None),
getattr(state, "market_state", None),
getattr(state, "market_trend", None),
getattr(state, "market_volatility", None),
getattr(state, "market_trend_strength", None),
getattr(state, "market_trend_quality", None),
getattr(state, "market_phase", None),
getattr(state, "market_phase_direction", None),
getattr(state, "entry_block_reason", None),
getattr(state, "entry_block_message", None),
getattr(state, "execution_quality", None),
getattr(state, "execution_quality_reason", None),
getattr(state, "execution_semantic_status", None),
getattr(state, "execution_semantic_message", None),
getattr(state, "execution_confidence_score", None),
getattr(state, "adaptive_size_multiplier", None),
getattr(state, "adaptive_size_reason", None),
getattr(state, "effective_target_risk_usd", None),
getattr(state, "position_side", None),
getattr(state, "entry_price", None),
getattr(state, "position_size", None),
#getattr(state, "unrealized_pnl_usd", None),
getattr(state, "realized_pnl_usd", None),
getattr(state, "last_execution_action", None),
getattr(state, "last_execution_reason", None),
]
)
@classmethod
async def _refresh_screen(cls, *, force: bool = False) -> None:
now = time.monotonic()
@@ -534,8 +604,9 @@ class AutoTradeRunner:
return
text = cls._render_text()
semantic_text = build_auto_semantic_text()
if text == cls._last_text:
if semantic_text == cls._last_semantic_text:
cls._log_refresh_skip("text_not_changed")
return
@@ -547,6 +618,7 @@ class AutoTradeRunner:
reply_markup=cls._render_markup(),
)
cls._last_text = text
cls._last_semantic_text = semantic_text
cls._last_ui_refresh_at = now
cls._log_refresh_success(
@@ -558,20 +630,16 @@ class AutoTradeRunner:
)
except TelegramRetryAfter as exc:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
cls._log_refresh_error(
"telegram_retry_after",
{
"retry_after": exc.retry_after,
"retry_after_until": cls._retry_after_until,
},
)
cls._retry_after_until = time.monotonic() + exc.retry_after + 15
cls._last_ui_refresh_at = time.monotonic()
return
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if "message is not modified" in error_text:
cls._last_text = text
cls._last_semantic_text = semantic_text
cls._last_ui_refresh_at = now
cls._log_refresh_skip("telegram_message_not_modified")
return

View File

@@ -362,6 +362,13 @@ 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
state.adaptive_size_reason = None
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

View File

@@ -207,4 +207,25 @@ class AutoTradeState:
execution_confidence_reason: str | None = None
# детализация факторов confidence для логов / отладки
execution_confidence_factors: dict | None = None
execution_confidence_factors: dict | None = None
# итоговый риск после adaptive sizing
effective_risk_percent: float | None = None
# итоговый риск в USD после adaptive sizing
effective_target_risk_usd: float | None = None
# базовый размер позиции до adaptive sizing
adaptive_size_base: float | None = None
# итоговый размер позиции после adaptive sizing
adaptive_size_final: float | None = None
# итоговый множитель adaptive sizing
adaptive_size_multiplier: float | None = None
# человекочитаемая причина adaptive sizing
adaptive_size_reason: str | None = None
# факторы adaptive sizing для логов / отладки
adaptive_size_factors: dict | None = None

View File

@@ -92,7 +92,7 @@ class ExecutionEngine:
return ExecutionDecision(
"NONE",
False,
"Позиция не открыта: невозможно рассчитать size без Stop Loss.",
"Позиция не открыта: невозможно рассчитать adaptive size.",
)
size = self._adjust_size_by_margin_limit(
@@ -101,6 +101,12 @@ class ExecutionEngine:
size=size,
)
self._sync_effective_risk_after_margin_limit(
state,
base_size=state.adaptive_size_base or 0.0,
final_size=size,
)
size = self._round_order_size(size)
if size <= 0:
@@ -137,6 +143,16 @@ class ExecutionEngine:
"leverage": state.leverage,
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"execution_confidence_score": state.execution_confidence_score,
"execution_confidence_level": state.execution_confidence_level,
"execution_confidence_reason": state.execution_confidence_reason,
"adaptive_size_multiplier": state.adaptive_size_multiplier,
"adaptive_size_reason": state.adaptive_size_reason,
"adaptive_size_factors": state.adaptive_size_factors,
"effective_risk_percent": state.effective_risk_percent,
"effective_target_risk_usd": state.effective_target_risk_usd,
"adaptive_size_base": state.adaptive_size_base,
"adaptive_size_final": state.adaptive_size_final,
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
"opened_at": now,
@@ -186,7 +202,7 @@ class ExecutionEngine:
return ExecutionDecision(
"NONE",
False,
"Flip отменён: невозможно рассчитать size без Stop Loss.",
"Flip отменён: невозможно рассчитать adaptive size.",
)
new_size = self._adjust_size_by_margin_limit(
@@ -195,6 +211,12 @@ class ExecutionEngine:
size=new_size,
)
self._sync_effective_risk_after_margin_limit(
state,
base_size=state.adaptive_size_base or 0.0,
final_size=new_size,
)
new_size = self._round_order_size(new_size)
if new_size <= 0:
@@ -249,6 +271,16 @@ class ExecutionEngine:
"pnl": pnl,
"signal": state.last_signal,
"confidence": state.last_signal_confidence,
"execution_confidence_score": state.execution_confidence_score,
"execution_confidence_level": state.execution_confidence_level,
"execution_confidence_reason": state.execution_confidence_reason,
"adaptive_size_multiplier": state.adaptive_size_multiplier,
"adaptive_size_reason": state.adaptive_size_reason,
"adaptive_size_factors": state.adaptive_size_factors,
"effective_risk_percent": state.effective_risk_percent,
"effective_target_risk_usd": state.effective_target_risk_usd,
"adaptive_size_base": state.adaptive_size_base,
"adaptive_size_final": state.adaptive_size_final,
"repeat_count": state.last_signal_repeat_count,
"reason": state.last_signal_reason,
"opened_at": old_opened_at,
@@ -597,9 +629,11 @@ class ExecutionEngine:
entry_price: float | None = None,
) -> float:
if state.risk_percent is None or state.risk_percent <= 0:
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
return 0.0
if state.stop_loss_percent is None or state.stop_loss_percent <= 0:
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
return 0.0
price = entry_price
@@ -608,9 +642,11 @@ class ExecutionEngine:
try:
price = self._signal_entry_price(state).price
except Exception:
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
return 0.0
if price <= 0:
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
return 0.0
balance_usd = state.allocated_balance_usd
@@ -618,10 +654,177 @@ class ExecutionEngine:
stop_loss_distance_usd = price * (state.stop_loss_percent / 100)
if stop_loss_distance_usd <= 0:
self._sync_adaptive_size_state(state, base_size=0.0, final_size=0.0, multiplier=0.0)
return 0.0
size = target_risk_usd / stop_loss_distance_usd
return self._round_size(size)
base_size = target_risk_usd / stop_loss_distance_usd
multiplier = self._adaptive_size_multiplier(state)
final_size = base_size * multiplier
self._sync_adaptive_size_state(
state,
base_size=base_size,
final_size=final_size,
multiplier=multiplier,
)
return self._round_size(final_size)
def _adaptive_size_multiplier(self, state: AutoTradeState) -> float:
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)))
if score < 0.55:
multiplier *= 0.0
elif score < 0.65:
multiplier *= 0.65
elif score < 0.75:
multiplier *= 0.85
elif score >= 0.85:
multiplier *= 1.15
market_state = getattr(state, "market_state", None)
market_trend_strength = getattr(state, "market_trend_strength", None)
market_trend_quality = getattr(state, "market_trend_quality", None)
market_phase = getattr(state, "market_phase", None)
if market_state in {"HIGH_VOLATILITY", "LOW_VOLATILITY", "RANGE"}:
multiplier *= 0.65
if market_trend_strength == "STRONG":
multiplier *= 1.1
elif market_trend_strength == "WEAK":
multiplier *= 0.75
if market_trend_quality == "CLEAN":
multiplier *= 1.05
elif market_trend_quality == "NOISY":
multiplier *= 0.75
if market_phase == "IMPULSE":
multiplier *= 1.1
elif market_phase == "PULLBACK":
multiplier *= 0.8
elif market_phase in {"RANGE", "SQUEEZE"}:
multiplier *= 0.7
execution_quality = getattr(state, "execution_quality", None)
execution_quality_reason = getattr(state, "execution_quality_reason", None)
if execution_quality == "BLOCKED":
multiplier *= 0.0
elif execution_quality == "WARNING":
if execution_quality_reason == "WIDE_SPREAD":
multiplier *= 0.75
elif execution_quality_reason == "AGING_SNAPSHOT":
multiplier *= 0.8
elif execution_quality_reason == "SNAPSHOT_UNAVAILABLE":
multiplier *= 0.7
else:
multiplier *= 0.8
return round(max(0.0, min(1.25, multiplier)), 4)
def _sync_adaptive_size_state(
self,
state: AutoTradeState,
*,
base_size: float,
final_size: float,
multiplier: float,
) -> None:
reason = self._adaptive_size_reason(multiplier)
state.adaptive_size_base = self._round_size(base_size)
state.adaptive_size_final = self._round_size(final_size)
state.adaptive_size_multiplier = multiplier
base_risk_percent = float(state.risk_percent or 0.0)
state.effective_risk_percent = round(
base_risk_percent * multiplier,
4,
)
state.effective_target_risk_usd = round(
state.allocated_balance_usd
* (state.effective_risk_percent / 100),
4,
)
state.adaptive_size_reason = reason
state.adaptive_size_factors = {
"execution_confidence_score": getattr(state, "execution_confidence_score", None),
"execution_confidence_level": getattr(state, "execution_confidence_level", None),
"market_state": getattr(state, "market_state", None),
"market_trend_strength": getattr(state, "market_trend_strength", None),
"market_trend_quality": getattr(state, "market_trend_quality", None),
"market_phase": getattr(state, "market_phase", None),
"execution_quality": getattr(state, "execution_quality", None),
"execution_quality_reason": getattr(state, "execution_quality_reason", None),
"spread_percent": getattr(state, "spread_percent", None),
"base_size": self._round_size(base_size),
"final_size": self._round_size(final_size),
"multiplier": multiplier,
}
if multiplier <= 0:
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_ZERO"
elif multiplier < 1:
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_REDUCED"
elif multiplier > 1:
state.execution_size_adjustment_reason = "ADAPTIVE_SIZE_INCREASED"
else:
state.execution_size_adjustment_reason = None
def _sync_effective_risk_after_margin_limit(
self,
state: AutoTradeState,
*,
base_size: float,
final_size: float,
) -> None:
adaptive_final = float(state.adaptive_size_final or 0.0)
if adaptive_final <= 0:
state.effective_risk_percent = 0.0
state.effective_target_risk_usd = 0.0
return
margin_ratio = max(
0.0,
min(1.0, final_size / adaptive_final),
)
current_effective_risk = float(state.effective_risk_percent or 0.0)
state.effective_risk_percent = round(
current_effective_risk * margin_ratio,
4,
)
state.effective_target_risk_usd = round(
state.allocated_balance_usd
* (state.effective_risk_percent / 100),
4,
)
def _adaptive_size_reason(self, multiplier: float) -> str:
if multiplier <= 0:
return "adaptive size заблокировал вход"
if multiplier < 0.75:
return "размер позиции сильно уменьшен по risk/runtime факторам"
if multiplier < 1:
return "размер позиции умеренно уменьшен по risk/runtime факторам"
if multiplier > 1:
return "размер позиции увеличен при сильном execution context"
return "размер позиции без adaptive корректировки"
def _adjust_size_by_margin_limit(
self,
@@ -632,9 +835,6 @@ class ExecutionEngine:
) -> float:
max_percent = state.max_reserved_balance_percent
state.execution_block_reason = None
state.execution_size_adjustment_reason = None
if max_percent is None or max_percent <= 0:
return self._round_size(size)
@@ -653,7 +853,24 @@ class ExecutionEngine:
return self._round_size(size)
state.execution_size_adjustment_reason = "MARGIN_LIMIT"
return self._round_size(max_size)
limited_size = self._round_size(max_size)
adaptive_final = float(state.adaptive_size_final or 0.0)
if adaptive_final > 0:
effective_multiplier = limited_size / adaptive_final
if effective_multiplier < 0.5:
state.adaptive_size_reason = (
"размер позиции сильно ограничен margin limit"
)
else:
state.adaptive_size_reason = (
"размер позиции ограничен margin limit"
)
return limited_size
def _signal_entry_price(self, state: AutoTradeState) -> _ExecutionPrice:
if state.last_signal == "BUY":

View File

@@ -80,4 +80,5 @@ class MarketAnalysisResult:
phase_direction: TrendDirection
phase_change_percent: float | None
phase_reason: str | None
phase_reason: str | None
phase_direction_consistency: float | None = None

View File

@@ -25,8 +25,10 @@ class MarketAnalysisService:
_high_volatility_atr_percent = 1.8
_trend_gap_percent = 0.03
_trend_consistency_window = 20
_phase_window = 5
_phase_direction_threshold_percent = 0.03
_phase_window = 8
_phase_direction_threshold_percent = 0.08
_pullback_min_change_percent = 0.18
_pullback_min_direction_consistency = 0.6
def analyze(
self,
@@ -102,6 +104,10 @@ class MarketAnalysisService:
window=self._phase_window,
)
phase_direction = self._classify_phase_direction(phase_change_percent)
phase_direction_consistency = self._phase_direction_consistency(
closes=closes,
phase_direction=phase_direction,
)
market_phase, phase_reason = self._classify_market_phase(
trend=trend,
@@ -110,6 +116,8 @@ class MarketAnalysisService:
trend_quality=trend_quality,
rsi_value=rsi_value,
phase_direction=phase_direction,
phase_change_percent=phase_change_percent,
phase_direction_consistency=phase_direction_consistency,
)
state = self._classify_market_state(
@@ -158,6 +166,9 @@ class MarketAnalysisService:
"market_phase_change_percent": round(phase_change_percent, 5)
if phase_change_percent is not None
else None,
"market_phase_direction_consistency": round(phase_direction_consistency, 3)
if phase_direction_consistency is not None
else None,
"market_phase_reason": phase_reason,
"market_trend_gap_percent": round(trend_gap_percent, 5)
if trend_gap_percent is not None
@@ -186,6 +197,7 @@ class MarketAnalysisService:
phase_direction=phase_direction,
phase_change_percent=phase_change_percent,
phase_reason=phase_reason,
phase_direction_consistency=phase_direction_consistency,
)
def _trend_gap_percent_value(
@@ -313,6 +325,36 @@ class MarketAnalysisService:
return TrendDirection.DOWN
return TrendDirection.FLAT
def _phase_direction_consistency(
self,
*,
closes: list[float],
phase_direction: TrendDirection,
) -> float | None:
window = closes[-(self._phase_window + 1):]
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 phase_direction == TrendDirection.UP:
return up_moves / total_moves
if phase_direction == TrendDirection.DOWN:
return down_moves / total_moves
return None
def _is_counter_trend_move(
self,
@@ -337,6 +379,8 @@ class MarketAnalysisService:
trend_quality: TrendQuality,
rsi_value: float | None,
phase_direction: TrendDirection,
phase_change_percent: float | None,
phase_direction_consistency: float | None,
) -> tuple[MarketPhase, str]:
if volatility == VolatilityState.LOW:
return MarketPhase.SQUEEZE, "LOW_VOLATILITY_SQUEEZE"
@@ -354,13 +398,25 @@ class MarketAnalysisService:
trend=trend,
phase_direction=phase_direction,
):
return MarketPhase.PULLBACK, "COUNTER_TREND_MOVE"
if (
phase_change_percent is not None
and abs(phase_change_percent) >= self._pullback_min_change_percent
and phase_direction_consistency is not None
and phase_direction_consistency >= self._pullback_min_direction_consistency
):
return MarketPhase.PULLBACK, "COUNTER_TREND_MOVE_CONFIRMED"
return MarketPhase.IMPULSE, "COUNTER_TREND_MOVE_TOO_WEAK"
if (
trend == TrendDirection.UP
and rsi_value is not None
and rsi_value < 45
and phase_direction == TrendDirection.DOWN
and phase_change_percent is not None
and abs(phase_change_percent) >= self._pullback_min_change_percent
and phase_direction_consistency is not None
and phase_direction_consistency >= self._pullback_min_direction_consistency
):
return MarketPhase.PULLBACK, "UPTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE"
@@ -369,6 +425,10 @@ class MarketAnalysisService:
and rsi_value is not None
and rsi_value > 55
and phase_direction == TrendDirection.UP
and phase_change_percent is not None
and abs(phase_change_percent) >= self._pullback_min_change_percent
and phase_direction_consistency is not None
and phase_direction_consistency >= self._pullback_min_direction_consistency
):
return MarketPhase.PULLBACK, "DOWNTREND_RSI_PULLBACK_CONFIRMED_BY_PRICE"
@@ -471,6 +531,7 @@ class MarketAnalysisService:
"market_phase": MarketPhase.UNKNOWN.value,
"market_phase_direction": TrendDirection.UNKNOWN.value,
"market_phase_change_percent": None,
"market_phase_direction_consistency": None,
"market_phase_reason": reason,
"market_trend_gap_percent": None,
"market_trend_consistency": None,

View File

@@ -126,6 +126,7 @@ class TrendStrategy:
"market_phase": market.market_phase.value,
"market_phase_direction": market.phase_direction.value,
"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,
"market_trend_gap_percent": market.trend_gap_percent,
"market_trend_consistency": market.trend_consistency,