Stage 07.4.3.1 — trend strategy stabilization

This commit is contained in:
2026-05-01 18:25:27 +03:00
parent ec8e53c416
commit 38c8686a9b
8 changed files with 735 additions and 49 deletions

View File

@@ -46,6 +46,101 @@ def _signal_label(signal: str | None) -> str:
return mapping.get(signal or "", "")
# красивое отображение решения
def _decision_label(status: str) -> str:
mapping = {
"WAITING": "🟡 Ожидание",
"CONFIRMING": "🟠 Подтверждение",
"READY": "🟢 Готово к входу",
"BLOCKED": "🔴 Заблокировано",
}
return mapping.get(status, status)
# компактное значение или заглушка
def _value_or_dash(value: object) -> str:
if value is None:
return ""
return str(value)
# формат цены
def _price_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.2f}"
# формат USD
def _usd_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.2f} USD"
# формат размера позиции
def _size_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.8f}".rstrip("0").rstrip(".")
# формат плеча
def _leverage_or_dash(value: float | None) -> str:
if value is None:
return ""
return f"{value:.1f}x"
# формат торгового инструмента для UI
def _format_symbol(symbol: str | None) -> str:
if not symbol:
return ""
base_symbol = symbol.split("_", 1)[0]
parts = base_symbol.split("/", 1)
if len(parts) == 2:
return f"{parts[0]} / {parts[1]}"
return base_symbol
# стратегия для компактного UI
def _compact_strategy(strategy: str | None) -> str:
if not strategy:
return ""
return strategy.upper()
# плечо для компактного UI
def _compact_leverage(value: float | None) -> str:
if value is None:
return ""
return f"x{value:g}"
# проверка, настроена ли автоторговля минимально
def _is_auto_configured(state) -> bool:
return bool(
state.symbol
and state.strategy
and state.risk_percent is not None
)
# строка инструмента / стратегии / плеча
def _context_line(state) -> str:
symbol = _format_symbol(state.symbol)
strategy = _compact_strategy(state.strategy)
leverage = _compact_leverage(state.leverage)
if leverage == "":
return f"{symbol} · {strategy}"
return f"{symbol} · {strategy} · {leverage}"
# клавиатура автоторговли
def _auto_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
@@ -64,21 +159,46 @@ def _build_auto_text() -> str:
service = AutoTradeService()
state = service.get_state()
strategy = _strategy_label(state.strategy)
account_mode = "DEMO" if "DEMO" in mode_line().upper() else "LIVE"
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else ""
configured = _is_auto_configured(state)
header = (
"<b>🤖 Автоторговля</b>\n"
f"🔸 {account_mode} аккаунт\n\n"
)
if state.status == "OFF":
if not configured:
return (
f"{header}"
"⚪ Выключена\n\n"
"⚠️ Не настроена\n"
"Настрой параметры"
)
return (
f"{header}"
"⚪ Выключена\n\n"
f"{_context_line(state)}\n"
f"Risk: {risk}"
)
status_line = (
"🟢 Активна"
if state.status == "RUNNING"
else "👀 Наблюдение"
)
return (
"<b>🤖 Автоторговля</b>\n"
f"{mode_line()}"
f"Статус: {_status_label(state.status)}\n"
f"Стратегия: {strategy}\n"
f"Инструмент: {state.symbol}\n"
f"Риск: {risk}\n"
f"PnL: {state.pnl_usd:.2f} USD\n"
f"Последний анализ: {state.last_check_at or ''}\n"
f"Сигнал: {_signal_label(state.last_signal)} · {state.last_signal_repeat_count} подряд\n"
f"Уверенность: {state.last_signal_confidence:.2f}\n"
f"Причина: {state.last_signal_reason or ''}"
f"{header}"
f"{status_line}\n\n"
f"{_context_line(state)}\n\n"
f"{_signal_label(state.last_signal)} ×{state.last_signal_repeat_count} "
f"· {state.decision_status}\n\n"
f"Pos: {_value_or_dash(state.position_side)} | "
f"PnL: {_usd_or_dash(state.unrealized_pnl_usd)}\n"
f"Risk: {risk}"
)

View File

@@ -168,10 +168,16 @@ async def open_system_management(callback: CallbackQuery) -> None:
@router.callback_query(F.data == "settings:auto")
async def open_auto_settings(callback: CallbackQuery) -> None:
AutoTradeRunner.set_current_screen("settings_auto")
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
AutoTradeRunner.unregister_screen(
chat_id=callback.message.chat.id,
message_id=callback.message.message_id,
)
state = AutoTradeService().get_state()
strategy_map = {
@@ -181,13 +187,15 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
}
strategy = strategy_map.get(state.strategy or "", "")
risk = f"{state.risk_percent:.1f}%" if state.risk_percent is not None else ""
leverage = f"x{state.leverage:g}" if state.leverage is not None else ""
text = (
"<b>🤖 Автоторговля</b>\n\n"
"<b>СИСТЕМА</b> · Настройки\n\n"
f"Стратегия: {strategy}\n"
f"Инструмент: {state.symbol}\n"
f"Риск: {risk}\n\n"
f"Риск: {risk}\n"
f"Плечо: {leverage}\n\n"
"Выберите настройку:"
)
@@ -195,9 +203,10 @@ async def open_auto_settings(callback: CallbackQuery) -> None:
builder.button(text="🧠 Стратегия", callback_data="settings:auto_strategy")
builder.button(text="📈 Инструмент", callback_data="settings:auto_symbol")
builder.button(text="🛡️ Риск", callback_data="settings:auto_risk")
builder.button(text="⚙️ Плечо", callback_data="settings:auto_leverage")
builder.button(text="⬅️ Назад", callback_data="system:management")
builder.button(text="🤖 Автоторговля", callback_data="auto:home")
builder.adjust(2, 1, 2)
builder.adjust(2, 2, 2)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
await callback.answer()
@@ -313,6 +322,46 @@ async def set_auto_risk(callback: CallbackQuery) -> None:
await callback.answer("Риск обновлён")
@router.callback_query(F.data == "settings:auto_leverage")
async def open_auto_leverage_settings(callback: CallbackQuery) -> None:
AutoTradeRunner.set_current_screen("settings_auto")
if callback.message is None:
await callback.answer("Сообщение не найдено", show_alert=True)
return
text = (
"<b>⚙️ Плечо</b>\n\n"
"<b>СИСТЕМА</b> · Настройки · Автоторговля\n\n"
"Выберите плечо:"
)
builder = InlineKeyboardBuilder()
builder.button(text="x1", callback_data="settings:auto_leverage:1")
builder.button(text="x2", callback_data="settings:auto_leverage:2")
builder.button(text="x3", callback_data="settings:auto_leverage:3")
builder.button(text="x5", callback_data="settings:auto_leverage:5")
builder.button(text="x10", callback_data="settings:auto_leverage:10")
builder.button(text="x20", callback_data="settings:auto_leverage:20")
builder.button(text="⬅️ Назад", callback_data="settings:auto")
builder.adjust(3, 3, 1)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
await callback.answer()
@router.callback_query(F.data.startswith("settings:auto_leverage:"))
async def set_auto_leverage(callback: CallbackQuery) -> None:
leverage = float(callback.data.split(":", 2)[2])
AutoTradeService().set_leverage(leverage)
if callback.message is not None:
await open_auto_settings(callback)
AutoTradeRunner.set_current_screen("settings_auto")
await callback.answer("Плечо обновлено")
@router.callback_query(F.data == "settings:trade")
async def open_trade_settings(callback: CallbackQuery) -> None:
if callback.message is None:

View File

@@ -3,9 +3,11 @@
from __future__ import annotations
import asyncio
import time
from typing import Callable
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramRetryAfter
from src.trading.auto.service import AutoTradeService
@@ -18,9 +20,11 @@ class AutoTradeRunner:
_render_text: Callable[[], str] | None = None
_render_markup: Callable[[], object] | None = None
_current_screen: str | None = None
_interval_seconds = 5
_interval_seconds = 15
_last_text: str | None = None
_retry_after_until: float = 0.0
# зарегистрировать live-экран для автообновления
@classmethod
def register_screen(
cls,
@@ -36,8 +40,8 @@ class AutoTradeRunner:
cls._message_id = message_id
cls._render_text = render_text
cls._render_markup = render_markup
cls._last_text = None
# удалить ранее зарегистрированный live-экран
@classmethod
async def delete_registered_screen(
cls,
@@ -62,13 +66,27 @@ class AutoTradeRunner:
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
@classmethod
def unregister_screen(
cls,
*,
chat_id: int,
message_id: int,
) -> None:
if cls._chat_id != chat_id or cls._message_id != message_id:
return
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
# переключить активный экран
@classmethod
def set_current_screen(cls, screen: str) -> None:
cls._current_screen = screen
# запустить background runner
@classmethod
def start(cls) -> None:
if cls._task is not None and not cls._task.done():
@@ -76,7 +94,6 @@ class AutoTradeRunner:
cls._task = asyncio.create_task(cls._worker())
# остановить background runner
@classmethod
def stop(cls) -> None:
if cls._task is None:
@@ -85,7 +102,6 @@ class AutoTradeRunner:
cls._task.cancel()
cls._task = None
# background loop автоторговли
@classmethod
async def _worker(cls) -> None:
service = AutoTradeService()
@@ -102,9 +118,11 @@ class AutoTradeRunner:
await cls._refresh_screen()
await asyncio.sleep(cls._interval_seconds)
# обновить live-экран Telegram
@classmethod
async def _refresh_screen(cls) -> None:
if time.monotonic() < cls._retry_after_until:
return
if not all(
[
cls._bot,
@@ -116,12 +134,36 @@ class AutoTradeRunner:
):
return
text = cls._render_text()
if text == cls._last_text:
return
try:
await cls._bot.edit_message_text(
chat_id=cls._chat_id,
message_id=cls._message_id,
text=cls._render_text(),
text=text,
reply_markup=cls._render_markup(),
)
cls._last_text = text
except TelegramRetryAfter as exc:
cls._retry_after_until = time.monotonic() + exc.retry_after + 5
except TelegramBadRequest as exc:
error_text = str(exc).lower()
if "message is not modified" in error_text:
cls._last_text = text
return
if "message to edit not found" in error_text:
cls._message_id = None
cls._render_text = None
cls._render_markup = None
cls._last_text = None
return
except Exception:
pass

View File

@@ -16,8 +16,18 @@ class AutoTradeService:
_state = AutoTradeState()
_loop_task: asyncio.Task | None = None
_loop_interval_seconds = 5
# минимальное количество повторов BUY / SELL для подтверждения сигнала
_confirm_repeats = 3
# минимальная уверенность для готовности к будущему execution
_ready_confidence = 0.7
_last_signal_key: str | None = None
_last_signal_value: str | None = None
_last_signal_reason: str = ""
_last_signal_confidence: float = 0.0
_last_signal_payload: dict | None = None
_same_signal_count = 0
# получить текущее состояние автоторговли
@@ -106,15 +116,14 @@ class AutoTradeService:
def set_symbol(self, symbol: str) -> AutoTradeState:
state = self.get_state()
state.symbol = symbol
self._reset_signal_tracking()
return state
# установить стратегию
def set_strategy(self, strategy: str) -> AutoTradeState:
state = self.get_state()
state.strategy = strategy.strip().upper()
self._last_signal_key = None
self._last_signal_value = None
self._same_signal_count = 0
self._reset_signal_tracking()
return state
# установить риск
@@ -122,6 +131,30 @@ class AutoTradeService:
state = self.get_state()
state.risk_percent = risk_percent
return state
# установить плечо
def set_leverage(self, leverage: float) -> AutoTradeState:
state = self.get_state()
state.leverage = leverage
return state
# сбросить внутренний трекинг сигналов
def _reset_signal_tracking(self) -> None:
self._last_signal_key = None
self._last_signal_value = None
self._last_signal_reason = ""
self._last_signal_confidence = 0.0
self._last_signal_payload = None
self._same_signal_count = 0
state = self.get_state()
state.last_signal_repeat_count = 0
state.last_signal_confidence = 0.0
state.last_signal_reason = None
state.decision_status = "WAITING"
state.decision_reason = None
state.is_signal_confirmed = False
state.is_signal_ready = False
# собрать контекст для стратегии
def _build_strategy_context(self) -> StrategyContext:
@@ -138,6 +171,46 @@ class AutoTradeService:
state = self.get_state()
return StrategyRegistry.get(state.strategy)
# обновить статус решения по текущему сигналу
def _update_decision_state(
self,
*,
state: AutoTradeState,
signal: str,
confidence: float,
) -> None:
state.is_signal_confirmed = False
state.is_signal_ready = False
if signal == "HOLD":
state.decision_status = "WAITING"
state.decision_reason = "Нет торгового направления."
return
if self._same_signal_count < self._confirm_repeats:
state.decision_status = "CONFIRMING"
state.decision_reason = (
f"Сигнал {signal} подтверждается: "
f"{self._same_signal_count}/{self._confirm_repeats} повторов."
)
return
state.is_signal_confirmed = True
if confidence < self._ready_confidence:
state.decision_status = "BLOCKED"
state.decision_reason = (
f"Сигнал {signal} подтверждён, но уверенность низкая: "
f"{confidence:.2f} < {self._ready_confidence:.2f}."
)
return
state.is_signal_ready = True
state.decision_status = "READY"
state.decision_reason = (
f"Сигнал {signal} подтверждён и готов к будущему execution."
)
# записать новый сигнал и итог предыдущей серии при смене сигнала
def _log_signal_if_changed(
self,
@@ -156,6 +229,12 @@ class AutoTradeService:
if is_same_signal:
self._same_signal_count += 1
self._update_signal_state_fields(
state=state,
signal=signal,
reason=reason,
confidence=confidence,
)
return
if previous_signal is not None:
@@ -166,6 +245,9 @@ class AutoTradeService:
previous_signal=previous_signal,
previous_count=previous_count,
next_signal=signal,
reason=self._last_signal_reason,
confidence=self._last_signal_confidence,
payload=self._last_signal_payload,
)
else:
self._log_signal_event(
@@ -173,7 +255,7 @@ class AutoTradeService:
state=state,
signal=previous_signal,
reason=f"{previous_signal} завершился без серии.",
confidence=0.0,
confidence=self._last_signal_confidence,
payload={
"previous_signal": previous_signal,
"next_signal": signal,
@@ -182,13 +264,39 @@ class AutoTradeService:
self._last_signal_key = signal_key
self._last_signal_value = signal
self._last_signal_reason = reason
self._last_signal_confidence = confidence
self._last_signal_payload = payload
self._same_signal_count = 1
# Новый сигнал не пишем сразу.
# Он попадёт в журнал при следующей смене сигнала:
# либо как одиночный сигнал, либо как серия.
# записать сам сигнал в журнал
self._update_signal_state_fields(
state=state,
signal=signal,
reason=reason,
confidence=confidence,
)
# обновить поля state для экрана автоторговли
def _update_signal_state_fields(
self,
*,
state: AutoTradeState,
signal: str,
reason: str,
confidence: float,
) -> None:
state.last_signal = signal
state.last_signal_repeat_count = self._same_signal_count
state.last_signal_confidence = confidence
state.last_signal_reason = reason
self._update_decision_state(
state=state,
signal=signal,
confidence=confidence,
)
# записать одиночный сигнал в журнал
def _log_signal_event(
self,
*,
@@ -220,7 +328,7 @@ class AutoTradeService:
"confidence": confidence,
"reason": reason,
"repeat_count": 1,
"is_strong_signal": confidence > 0.7,
"is_strong_signal": confidence > self._ready_confidence,
"is_aggregated": False,
"payload": payload or {},
},
@@ -237,6 +345,9 @@ class AutoTradeService:
previous_signal: str,
previous_count: int,
next_signal: str,
reason: str,
confidence: float,
payload: dict | None,
) -> None:
emoji_map = {
"BUY": "🟢",
@@ -261,7 +372,11 @@ class AutoTradeService:
"signal": previous_signal,
"next_signal": next_signal,
"repeat_count": previous_count,
"confidence": confidence,
"reason": reason,
"is_strong_signal": confidence > self._ready_confidence,
"is_aggregated": True,
"payload": payload or {},
},
)
except Exception:
@@ -279,7 +394,6 @@ class AutoTradeService:
result = strategy.analyze(context)
state.last_check_at = datetime.now().strftime("%H:%M:%S")
state.last_signal = result.signal.value
self._log_signal_if_changed(
strategy_name=strategy.name,
@@ -290,8 +404,4 @@ class AutoTradeService:
payload=result.payload,
)
state.last_signal_repeat_count = self._same_signal_count
state.last_signal_confidence = result.confidence
state.last_signal_reason = result.reason
return state

View File

@@ -25,7 +25,7 @@ class AutoTradeState:
# время последней проверки
last_check_at: str | None = None
# последний сигнал стратегии
# последний сырой сигнал стратегии
last_signal: str | None = None
# количество одинаковых сигналов подряд
@@ -35,4 +35,34 @@ class AutoTradeState:
last_signal_confidence: float = 0.0
# причина последнего сигнала
last_signal_reason: str | None = None
last_signal_reason: str | None = None
# статус торгового решения: WAITING / CONFIRMING / READY / BLOCKED
decision_status: str = "WAITING"
# человекочитаемое объяснение решения
decision_reason: str | None = None
# сигнал подтверждён по количеству повторов
is_signal_confirmed: bool = False
# сигнал готов к будущему execution
is_signal_ready: bool = False
# текущая позиция: NONE / LONG / SHORT
position_side: str = "NONE"
# цена входа
entry_price: float | None = None
# размер позиции
position_size: float | None = None
# нереализованный PnL
unrealized_pnl_usd: float | None = None
# максимальная просадка
max_drawdown_usd: float | None = None
# плечо
leverage: float | None = 2.0