Stage 07.4.3 — trend strategy, signal aggregation and journal UX improvements
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -26,4 +26,13 @@ class AutoTradeState:
|
||||
last_check_at: str | None = None
|
||||
|
||||
# последний сигнал стратегии
|
||||
last_signal: str | None = None
|
||||
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
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
91
app/src/trading/strategies/trend.py
Normal file
91
app/src/trading/strategies/trend.py
Normal file
@@ -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),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user