diff --git a/app/src/telegram/handlers/auto.py b/app/src/telegram/handlers/auto.py
index 5e520dc..774f6c7 100644
--- a/app/src/telegram/handlers/auto.py
+++ b/app/src/telegram/handlers/auto.py
@@ -76,7 +76,9 @@ def _build_auto_text() -> str:
f"Риск: {risk}\n"
f"PnL: {state.pnl_usd:.2f} USD\n"
f"Последний анализ: {state.last_check_at or '—'}\n"
- f"Сигнал: {_signal_label(state.last_signal)}"
+ 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 '—'}"
)
diff --git a/app/src/telegram/handlers/journal_ui.py b/app/src/telegram/handlers/journal_ui.py
index ef9380d..d347977 100644
--- a/app/src/telegram/handlers/journal_ui.py
+++ b/app/src/telegram/handlers/journal_ui.py
@@ -21,6 +21,8 @@ LEVEL_ICONS = {
}
EVENT_TITLES = {
+ "auto_signal_generated": "Сигнал автоторговли",
+ "auto_signal_summary": "Итог серии сигналов",
"app_start": "Запуск приложения",
"system_open_alert": "Система загружена с предупреждениями",
"system_open_requested": "Открытие системы",
@@ -165,16 +167,40 @@ def render_clear_confirm(
return "\n".join(lines)
-def _normalize_datetime(value: str) -> str:
+def _parse_local_datetime(value: str) -> datetime | None:
try:
settings = load_settings()
dt = datetime.fromisoformat(value)
+
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
- dt = dt.astimezone(ZoneInfo(settings.tz))
- return dt.strftime("%Y-%m-%d %H:%M:%S")
+
+ return dt.astimezone(ZoneInfo(settings.tz))
except Exception:
- return value
+ return None
+
+
+def _date_group_label(dt: datetime | None) -> str:
+ if dt is None:
+ return "Без даты"
+
+ settings = load_settings()
+ today = datetime.now(ZoneInfo(settings.tz)).date()
+
+ if dt.date() == today:
+ return "Сегодня"
+
+ if (today - dt.date()).days == 1:
+ return "Вчера"
+
+ return dt.strftime("%Y-%m-%d")
+
+
+def _time_label(dt: datetime | None, raw_value: str) -> str:
+ if dt is None:
+ return raw_value
+
+ return dt.strftime("%H:%M:%S")
def _event_title(event_type: str) -> str:
@@ -189,29 +215,101 @@ def _humanize_message(message: str) -> str:
return message
+def _payload(event: dict) -> dict:
+ payload = event.get("payload")
+ return payload if isinstance(payload, dict) else {}
+
+
+def _render_auto_signal(event: dict, created_time: str) -> list[str]:
+ payload = _payload(event)
+
+ signal = str(payload.get("signal", "HOLD")).upper()
+ strategy = str(payload.get("strategy", "AUTO")).upper()
+ symbol = str(payload.get("symbol", "—"))
+ reason = str(payload.get("reason", ""))
+ confidence = float(payload.get("confidence", 0.0) or 0.0)
+ repeat_count = int(payload.get("repeat_count", 1) or 1)
+ is_strong_signal = bool(payload.get("is_strong_signal", False))
+ is_aggregated = bool(payload.get("is_aggregated", False))
+
+ signal_icon = {
+ "BUY": "🟢",
+ "SELL": "🔴",
+ "HOLD": "🟡",
+ }.get(signal, "•")
+
+ prefix = ""
+ if is_strong_signal:
+ prefix += "📈 "
+ if is_aggregated:
+ prefix += "🧠 "
+
+ lines = [
+ f"{prefix}{signal_icon} AUTO · {signal}",
+ f"{created_time} · {strategy} · {symbol}",
+ ]
+
+ if is_aggregated:
+ lines.append(f"{repeat_count} {signal} подряд")
+
+ if confidence > 0:
+ lines.append(f"Уверенность: {confidence:.2f}")
+
+ if reason:
+ lines.append(f"Причина: {reason}")
+
+ return lines
+
+
+def _render_default_event(event: dict, created_time: str) -> list[str]:
+ level = str(event.get("level", "INFO")).upper()
+ icon = LEVEL_ICONS.get(level, "•")
+ title = _event_title(str(event.get("event_type", "")))
+ message = _humanize_message(str(event.get("message", "")))
+
+ lines = [
+ f"{icon} {level} · {title}",
+ f"{created_time}",
+ ]
+
+ if message:
+ lines.append(message)
+
+ return lines
+
+
def render(events, page, total_pages):
lines = [
"📒 Журнал",
"",
"МОНИТОРИНГ",
"",
- "Последние события:",
- "",
]
if not events:
lines.append("Событий пока нет.")
return "\n".join(lines)
- for event in events:
- level = str(event.get("level", "INFO")).upper()
- icon = LEVEL_ICONS.get(level, "•")
- title = _event_title(str(event.get("event_type", "")))
- created_at = _normalize_datetime(str(event.get("created_at", "")))
- message = _humanize_message(str(event.get("message", "")))
+ current_group = None
+
+ for event in events:
+ raw_created_at = str(event.get("created_at", ""))
+ dt = _parse_local_datetime(raw_created_at)
+ group_label = _date_group_label(dt)
+ created_time = _time_label(dt, raw_created_at)
+
+ if group_label != current_group:
+ current_group = group_label
+ lines.append(f"{group_label}")
+ lines.append("")
+
+ event_type = str(event.get("event_type", ""))
+
+ if event_type in {"auto_signal_generated", "auto_signal_summary"}:
+ lines.extend(_render_auto_signal(event, created_time))
+ else:
+ lines.extend(_render_default_event(event, created_time))
- lines.append(f"{icon} [ {level} ] {title}")
- lines.append(f"• {created_at}")
lines.append("")
return "\n".join(lines).rstrip()
\ No newline at end of file
diff --git a/app/src/trading/auto/runner.py b/app/src/trading/auto/runner.py
index 56575e3..f98ef94 100644
--- a/app/src/trading/auto/runner.py
+++ b/app/src/trading/auto/runner.py
@@ -105,9 +105,6 @@ class AutoTradeRunner:
# обновить live-экран Telegram
@classmethod
async def _refresh_screen(cls) -> None:
- if cls._current_screen != "auto":
- return
-
if not all(
[
cls._bot,
diff --git a/app/src/trading/auto/service.py b/app/src/trading/auto/service.py
index 634e17c..8a6ca52 100644
--- a/app/src/trading/auto/service.py
+++ b/app/src/trading/auto/service.py
@@ -7,6 +7,7 @@ from datetime import datetime
from src.core.config import load_settings
from src.trading.auto.state import AutoTradeState
+from src.trading.journal.service import JournalService
from src.trading.strategies.base import BaseStrategy, StrategyContext
from src.trading.strategies.registry import StrategyRegistry
@@ -15,6 +16,9 @@ class AutoTradeService:
_state = AutoTradeState()
_loop_task: asyncio.Task | None = None
_loop_interval_seconds = 5
+ _last_signal_key: str | None = None
+ _last_signal_value: str | None = None
+ _same_signal_count = 0
# получить текущее состояние автоторговли
def get_state(self) -> AutoTradeState:
@@ -108,6 +112,9 @@ class AutoTradeService:
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
return state
# установить риск
@@ -131,6 +138,135 @@ class AutoTradeService:
state = self.get_state()
return StrategyRegistry.get(state.strategy)
+ # записать новый сигнал и итог предыдущей серии при смене сигнала
+ def _log_signal_if_changed(
+ self,
+ *,
+ strategy_name: str,
+ state: AutoTradeState,
+ signal: str,
+ reason: str,
+ confidence: float,
+ payload: dict | None,
+ ) -> None:
+ signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}:{reason}"
+ previous_signal = self._last_signal_value
+ previous_count = self._same_signal_count
+ is_same_signal = signal_key == self._last_signal_key
+
+ if is_same_signal:
+ self._same_signal_count += 1
+ return
+
+ if previous_signal is not None:
+ if previous_count > 1:
+ self._log_signal_summary(
+ strategy_name=strategy_name,
+ state=state,
+ previous_signal=previous_signal,
+ previous_count=previous_count,
+ next_signal=signal,
+ )
+ else:
+ self._log_signal_event(
+ strategy_name=strategy_name,
+ state=state,
+ signal=previous_signal,
+ reason=f"{previous_signal} завершился без серии.",
+ confidence=0.0,
+ payload={
+ "previous_signal": previous_signal,
+ "next_signal": signal,
+ },
+ )
+
+ self._last_signal_key = signal_key
+ self._last_signal_value = signal
+ self._same_signal_count = 1
+
+ # Новый сигнал не пишем сразу.
+ # Он попадёт в журнал при следующей смене сигнала:
+ # либо как одиночный сигнал, либо как серия.
+
+ # записать сам сигнал в журнал
+ def _log_signal_event(
+ self,
+ *,
+ strategy_name: str,
+ state: AutoTradeState,
+ signal: str,
+ reason: str,
+ confidence: float,
+ payload: dict | None,
+ ) -> None:
+ emoji_map = {
+ "BUY": "🟢",
+ "SELL": "🔴",
+ "HOLD": "🟡",
+ }
+ emoji = emoji_map.get(signal, "•")
+
+ try:
+ JournalService().log_ui_info(
+ event_type="auto_signal_generated",
+ message=f"{emoji} Сигнал автоторговли {signal}: {reason}",
+ screen="auto",
+ action="run_cycle",
+ payload={
+ "strategy": strategy_name,
+ "status": state.status,
+ "symbol": state.symbol,
+ "signal": signal,
+ "confidence": confidence,
+ "reason": reason,
+ "repeat_count": 1,
+ "is_strong_signal": confidence > 0.7,
+ "is_aggregated": False,
+ "payload": payload or {},
+ },
+ )
+ except Exception:
+ pass
+
+ # записать итог серии одинаковых сигналов при смене сигнала
+ def _log_signal_summary(
+ self,
+ *,
+ strategy_name: str,
+ state: AutoTradeState,
+ previous_signal: str,
+ previous_count: int,
+ next_signal: str,
+ ) -> None:
+ emoji_map = {
+ "BUY": "🟢",
+ "SELL": "🔴",
+ "HOLD": "🟡",
+ }
+ emoji = emoji_map.get(previous_signal, "•")
+
+ try:
+ JournalService().log_ui_info(
+ event_type="auto_signal_summary",
+ message=(
+ f"{emoji} {previous_count} {previous_signal} подряд "
+ f"до смены на {next_signal}"
+ ),
+ screen="auto",
+ action="signal_summary",
+ payload={
+ "strategy": strategy_name,
+ "status": state.status,
+ "symbol": state.symbol,
+ "signal": previous_signal,
+ "next_signal": next_signal,
+ "repeat_count": previous_count,
+ "is_aggregated": True,
+ },
+ )
+ except Exception:
+ pass
+
# выполнить один цикл анализа рынка
def run_cycle(self) -> AutoTradeState:
state = self.get_state()
@@ -145,4 +281,17 @@ class AutoTradeService:
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,
+ state=state,
+ signal=result.signal.value,
+ reason=result.reason,
+ confidence=result.confidence,
+ 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
\ No newline at end of file
diff --git a/app/src/trading/auto/state.py b/app/src/trading/auto/state.py
index 42ffe97..e9c52b3 100644
--- a/app/src/trading/auto/state.py
+++ b/app/src/trading/auto/state.py
@@ -26,4 +26,13 @@ class AutoTradeState:
last_check_at: str | None = None
# последний сигнал стратегии
- last_signal: str | None = None
\ No newline at end of file
+ last_signal: str | None = None
+
+ # количество одинаковых сигналов подряд
+ last_signal_repeat_count: int = 0
+
+ # уверенность последнего сигнала от 0.0 до 1.0
+ last_signal_confidence: float = 0.0
+
+ # причина последнего сигнала
+ last_signal_reason: str | None = None
\ No newline at end of file
diff --git a/app/src/trading/strategies/registry.py b/app/src/trading/strategies/registry.py
index 4d83d90..c861724 100644
--- a/app/src/trading/strategies/registry.py
+++ b/app/src/trading/strategies/registry.py
@@ -4,13 +4,14 @@ from __future__ import annotations
from src.trading.strategies.base import BaseStrategy
from src.trading.strategies.hold import HoldStrategy
+from src.trading.strategies.trend import TrendStrategy
class StrategyRegistry:
# доступные стратегии
_strategies: dict[str, BaseStrategy] = {
"HOLD": HoldStrategy(),
- "TREND": HoldStrategy(),
+ "TREND": TrendStrategy(),
"GRID": HoldStrategy(),
"SCALP": HoldStrategy(),
}
diff --git a/app/src/trading/strategies/trend.py b/app/src/trading/strategies/trend.py
new file mode 100644
index 0000000..145c2f5
--- /dev/null
+++ b/app/src/trading/strategies/trend.py
@@ -0,0 +1,91 @@
+# app/src/trading/strategies/trend.py
+
+from __future__ import annotations
+
+from src.integrations.exchange.service import ExchangeService
+from src.trading.strategies.base import StrategyContext
+from src.trading.strategies.signals import SignalResult, SignalType
+
+
+class TrendStrategy:
+ name = "TREND"
+
+ _last_prices: dict[str, float] = {}
+ _threshold_percent = 0.02
+
+ # анализ простого тренда по изменению цены
+ def analyze(self, context: StrategyContext) -> SignalResult:
+ try:
+ ticker = ExchangeService().get_price(context.symbol)
+ except Exception as exc:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Не удалось получить рыночную цену. Безопасный HOLD.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": context.symbol,
+ "error": str(exc),
+ },
+ )
+
+ symbol = ticker.symbol
+ current_price = ticker.price
+ previous_price = self._last_prices.get(symbol)
+
+ self._last_prices[symbol] = current_price
+
+ if previous_price is None or previous_price <= 0:
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Недостаточно данных для определения тренда.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "price": current_price,
+ },
+ )
+
+ change_percent = ((current_price - previous_price) / previous_price) * 100
+
+ if change_percent >= self._threshold_percent:
+ return SignalResult(
+ signal=SignalType.BUY,
+ reason="Цена растёт выше порога тренда.",
+ confidence=min(1.0, abs(change_percent) / self._threshold_percent),
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "previous_price": previous_price,
+ "current_price": current_price,
+ "change_percent": round(change_percent, 5),
+ },
+ )
+
+ if change_percent <= -self._threshold_percent:
+ return SignalResult(
+ signal=SignalType.SELL,
+ reason="Цена падает ниже порога тренда.",
+ confidence=min(1.0, abs(change_percent) / self._threshold_percent),
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "previous_price": previous_price,
+ "current_price": current_price,
+ "change_percent": round(change_percent, 5),
+ },
+ )
+
+ return SignalResult(
+ signal=SignalType.HOLD,
+ reason="Изменение цены ниже порога тренда.",
+ confidence=0.0,
+ payload={
+ "strategy": self.name,
+ "symbol": symbol,
+ "previous_price": previous_price,
+ "current_price": current_price,
+ "change_percent": round(change_percent, 5),
+ },
+ )
\ No newline at end of file
diff --git a/docs/stages/stage-07_4_3-trend-strategy-and-signal-journal-ux.md b/docs/stages/stage-07_4_3-trend-strategy-and-signal-journal-ux.md
new file mode 100644
index 0000000..a8d8660
--- /dev/null
+++ b/docs/stages/stage-07_4_3-trend-strategy-and-signal-journal-ux.md
@@ -0,0 +1,143 @@
+cat > docs/stages/stage-07_4_3-trend-strategy-and-signal-journal-ux.md << 'EOF'
+# 07.4.3 — Trend Strategy + Signal Journal UX
+
+## 🎯 Цель этапа
+
+Перевести автоторговлю из mock-режима в первый реальный аналитический режим:
+
+- добавить первую рабочую стратегию TrendStrategy;
+- начать генерировать реальные BUY / SELL / HOLD сигналы;
+- внедрить осмысленный журнал сигналов;
+- сделать экран автоторговли информативным.
+
+---
+
+## 🚀 Что реализовано
+
+### 1. TrendStrategy
+
+Добавлена стратегия TREND (Trend Following).
+
+Логика:
+
+- анализ изменения цены;
+- если цена растёт выше порога → BUY;
+- если цена падает ниже порога → SELL;
+- если изменение незначительное → HOLD.
+
+Порог чувствительности: ~0.02%
+
+---
+
+### 2. StrategyRegistry
+
+Стратегия подключена через реестр:
+
+- TREND → TrendStrategy
+- GRID → HoldStrategy
+- SCALP → HoldStrategy
+- HOLD → HoldStrategy
+
+---
+
+### 3. Новый формат сигналов
+
+Каждый сигнал содержит:
+
+- signal — BUY / SELL / HOLD
+- confidence — 0.0 … 1.0
+- reason — причина сигнала
+- payload — технические данные
+
+---
+
+### 4. Журнал
+
+Добавлены события:
+
+- auto_signal_generated
+- auto_signal_summary
+
+Логика:
+
+- одинаковые сигналы не спамят журнал;
+- считается серия сигналов;
+- при смене сигнала пишется итог серии.
+
+Примеры:
+
+- 15 HOLD подряд до смены на SELL
+- 1 BUY завершился без серии
+- 7 SELL подряд до смены на HOLD
+
+---
+
+### 5. Агрегация
+
+- одиночный сигнал → отдельная запись
+- серия сигналов → одно итоговое событие
+- промежуточные повторы не пишутся
+
+---
+
+### 6. Сильные сигналы
+
+confidence > 0.7
+
+---
+
+### 7. Экран автоторговли
+
+Добавлено:
+
+- repeat_count (повторы сигнала)
+- confidence
+- reason
+
+Пример:
+
+Сигнал: 🟡 HOLD · 18 подряд
+Уверенность: 0.00
+Причина: Изменение цены ниже порога тренда
+
+---
+
+### 8. Live-обновление экрана
+
+Теперь автоэкран:
+
+- обновляется независимо от текущего раздела;
+- не “умирает” при переходе между экранами.
+
+---
+
+## 📂 Изменённые файлы
+
+- app/src/trading/strategies/trend.py
+- app/src/trading/strategies/registry.py
+- app/src/trading/auto/service.py
+- app/src/trading/auto/state.py
+- app/src/trading/auto/runner.py
+- app/src/telegram/handlers/auto.py
+- app/src/telegram/handlers/journal_ui.py
+
+---
+
+## 🔜 Далее
+
+07.4.3.1 — Stabilization
+
+- подтверждение сигнала (repeat_count)
+- фильтр confidence
+- антидребезг
+- статус: ожидание → подтверждение → вход
+
+---
+
+## ✅ Итог
+
+✔ добавлена TrendStrategy
+✔ появились реальные сигналы
+✔ журнал стал читаемым
+✔ реализована агрегация
+✔ улучшен UXцй
\ No newline at end of file