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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user