Files
dzentra_bot/app/src/trading/strategies/trend.py

1176 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app/src/trading/strategies/trend.py
from __future__ import annotations
import time
from typing import Any
from src.integrations.exchange.service import ExchangeService
from src.trading.market_analysis.models import (
EmaDistanceState,
EntryTimingState,
MarketPhase,
MarketState,
MomentumState,
TrendDirection,
TrendQuality,
TrendStrength,
VolatilityState,
)
from src.trading.market_analysis.service import MarketAnalysisService
from src.trading.strategies.base import StrategyContext
from src.trading.strategies.signals import SignalResult, SignalType
class TrendStrategy:
name = "TREND"
# Live-окно цен хранится в памяти стратегии.
# Оно нужно не для общего анализа рынка, а для подтверждения,
# что цена прямо сейчас действительно движется в нужную сторону.
_price_window: dict[str, list[float]] = {}
_price_window_updated_at: dict[str, float] = {}
# Через сколько секунд live-окно считается устаревшим.
_window_ttl_seconds = 60
# Сколько последних live-точек используем для подтверждения импульса.
_window_size = 8
# Минимальное изменение цены внутри live-окна.
# 0.05 = 0.05%.
_threshold_percent = 0.05
# Какая доля движений внутри окна должна идти в сторону сделки.
# 0.6 = минимум 60% шагов должны быть в сторону входа.
_min_direction_ratio = 0.6
# Основной таймфрейм анализа стратегии.
_market_interval = "5m"
# PUBLIC API
def reset_runtime(self, symbol: str | None = None) -> None:
"""
Сбрасывает runtime-память стратегии.
Используется при:
- смене актива;
- смене стратегии;
- перезапуске автоторговли;
- ручном сбросе состояния.
Если symbol=None — очищаем все live-окна.
Если symbol указан — очищаем только данные этого актива.
"""
if symbol is None:
self._price_window.clear()
self._price_window_updated_at.clear()
return
normalized_symbol = symbol.upper()
keys_to_delete = [
key for key in self._price_window.keys()
if key.upper() == normalized_symbol
]
for key in keys_to_delete:
self._price_window.pop(key, None)
self._price_window_updated_at.pop(key, None)
def analyze(self, context: StrategyContext) -> SignalResult:
"""
Главная функция стратегии.
Последовательность анализа:
1. Получаем market analysis по свечам 5m:
- состояние рынка;
- тренд;
- волатильность;
- momentum;
- breakout;
- структура рынка;
- EMA-дистанция;
- HTF-тренд 1h.
2. Получаем live snapshot:
- bid;
- ask;
- last price.
3. Рассчитываем рабочую цену анализа:
- если есть bid/ask — берём середину;
- иначе используем last price.
4. Обновляем live-окно последних цен.
5. Собираем base_payload:
- все market metrics;
- все причины блокировок;
- данные для UI, журнала и execution confidence.
6. Сначала проверяем breakout.
Это важно: после выхода из сжатия рынок ещё может выглядеть
как NOISY / COMPRESSED / RANGE, но первая сделка часто появляется
именно в этот момент.
7. Если breakout не найден — применяем защитные фильтры рынка.
8. Если рынок разрешён — ждём достаточное live-окно.
9. По live-окну подтверждаем направление:
- TREND_UP + live-импульс вверх = BUY;
- TREND_DOWN + live-импульс вниз = SELL;
- иначе HOLD.
Важно:
Эта функция не открывает сделку.
Она только возвращает BUY / SELL / HOLD.
Сделку потом открывает ExecutionEngine, если сигнал подтвердился.
"""
market = MarketAnalysisService().analyze(
context.symbol,
interval=self._market_interval,
limit=200,
)
snapshot_result = self._snapshot_or_hold(
context=context,
market=market,
)
if isinstance(snapshot_result, SignalResult):
return snapshot_result
snapshot = snapshot_result
symbol = str(snapshot.get("symbol") or context.symbol)
current_price = self._analysis_price(snapshot)
if current_price <= 0:
return self._invalid_price_hold(
symbol=symbol,
snapshot=snapshot,
market=market,
)
prices = self._update_price_window(
symbol=symbol,
current_price=current_price,
)
base_payload = self._base_payload(
market=market,
symbol=symbol,
snapshot=snapshot,
current_price=current_price,
prices=prices,
)
# 1. Сначала проверяем пробой.
# Пробой — это исключение из обычной логики фильтров.
# Его нельзя ставить после market_block, иначе ранние входы
# из сжатия будут отсеиваться как COMPRESSED / NOISY / RANGE.
breakout_signal = self._breakout_signal(
market=market,
base_payload=base_payload,
)
if breakout_signal is not None:
return breakout_signal
# 2. Если пробоя нет — включаем обычную защиту.
# Здесь отсекаются плохие условия:
# - рынок не в тренде;
# - волатильность плохая;
# - HTF против входа;
# - momentum не подтверждает;
# - структура против входа;
# - поздний вход.
market_block = self._market_block_signal(
market=market,
base_payload=base_payload,
)
if market_block is not None:
return market_block
# 3. Если рынок хороший, но live-данных ещё мало,
# не открываем сделку вслепую.
if len(prices) < self._window_size:
return self._hold(
reason="Недостаточно live-данных для подтверждения TREND.",
block_reason="NOT_ENOUGH_LIVE_DATA",
block_message="мало данных",
payload={
**base_payload,
"window_size": len(prices),
"required_window_size": self._window_size,
},
)
# 4. Подтверждаем направление по live-движению.
return self._live_trend_signal(
market=market,
base_payload=base_payload,
prices=prices,
)
# STEP 1. SNAPSHOT / PRICE
def _snapshot_or_hold(
self,
*,
context: StrategyContext,
market: Any,
) -> dict[str, Any] | SignalResult:
"""
Получает live snapshot с биржи.
Snapshot нужен для:
- актуальной цены;
- bid/ask;
- проверки spread дальше по цепочке;
- формирования payload для UI и журнала.
Если snapshot получить нельзя — стратегия возвращает HOLD.
Это безопаснее, чем строить сигнал на устаревших свечах.
"""
try:
return ExchangeService().get_market_snapshot(
context.symbol,
runtime_key="auto",
)
except Exception as exc:
return SignalResult(
signal=SignalType.HOLD,
reason="Не удалось получить рыночный snapshot. Безопасный HOLD.",
confidence=0.0,
payload={
**dict(market.payload or {}),
"strategy": self.name,
"symbol": context.symbol,
"error": str(exc),
"market_analysis": market.payload,
"entry_block_reason": "MARKET_SNAPSHOT_ERROR",
"entry_block_message": "нет данных рынка",
},
)
def _invalid_price_hold(
self,
*,
symbol: str,
snapshot: dict[str, Any],
market: Any,
) -> SignalResult:
"""
Возвращает HOLD, если цена из snapshot некорректная.
Без валидной цены нельзя:
- обновить live-окно;
- рассчитать импульс;
- рассчитать цену входа.
"""
return SignalResult(
signal=SignalType.HOLD,
reason="Некорректная рыночная цена. Безопасный HOLD.",
confidence=0.0,
payload={
**dict(market.payload or {}),
"strategy": self.name,
"symbol": symbol,
"snapshot": snapshot,
"market_analysis": market.payload,
"entry_block_reason": "INVALID_MARKET_PRICE",
"entry_block_message": "нет цены",
},
)
def _analysis_price(
self,
snapshot: dict[str, Any],
) -> float:
"""
Выбирает цену для анализа live-импульса.
Приоритет:
1. midpoint между bid и ask;
2. last_price;
3. 0.0, если цены нет.
Midpoint лучше last_price, потому что меньше зависит
от случайного последнего трейда.
"""
bid = self._safe_float(snapshot.get("bid_price"))
ask = self._safe_float(snapshot.get("ask_price"))
if bid is not None and ask is not None and bid > 0 and ask > 0:
return (bid + ask) / 2
last = self._safe_float(snapshot.get("last_price"))
if last is not None:
return last
return 0.0
# STEP 2. LIVE PRICE WINDOW
def _update_price_window(
self,
*,
symbol: str,
current_price: float,
) -> list[float]:
"""
Обновляет live-окно последних цен.
Логика:
- если окно устарело по TTL — очищаем его;
- добавляем новую цену;
- если цен больше лимита — удаляем самую старую.
Это окно показывает не общий тренд по свечам,
а краткосрочное движение прямо сейчас.
"""
now = time.monotonic()
previous_updated_at = self._price_window_updated_at.get(symbol)
if (
previous_updated_at is not None
and now - previous_updated_at > self._window_ttl_seconds
):
self._price_window.pop(symbol, None)
self._price_window_updated_at.pop(symbol, None)
prices = self._price_window.setdefault(symbol, [])
prices.append(current_price)
self._price_window_updated_at[symbol] = now
if len(prices) > self._window_size:
prices.pop(0)
return prices
# STEP 3. PAYLOAD
def _base_payload(
self,
*,
market: Any,
symbol: str,
snapshot: dict[str, Any],
current_price: float,
prices: list[float],
) -> dict[str, Any]:
"""
Собирает единый payload стратегии.
Payload используется дальше в:
- AutoTradeState;
- Telegram UI;
- diagnostics;
- journal;
- execution confidence;
- supervisor block logs.
Поэтому сюда кладём не только итоговый сигнал,
но и все промежуточные признаки рынка.
"""
return {
# Сначала переносим полный payload MarketAnalysisService.
# Это защищает от потери новых market-полей:
# market_long_score / market_short_score,
# last_closed_candle_change_percent,
# current_interval_change_percent и т.д.
**dict(market.payload or {}),
# Ниже стратегия добавляет/переопределяет runtime-поля.
"strategy": self.name,
"symbol": symbol,
"analysis_price": current_price,
"last_price": snapshot.get("last_price"),
"bid_price": snapshot.get("bid_price"),
"ask_price": snapshot.get("ask_price"),
"market_state": market.state.value,
"market_trend": market.trend.value,
"market_volatility": market.volatility.value,
"market_analysis_interval": market.interval,
"market_analysis_reason": market.reason,
"market_analysis": market.payload,
"market_trend_strength": market.trend_strength.value,
"market_trend_quality": market.trend_quality.value,
"market_phase": self._normalized_market_phase(market),
"market_phase_direction": self._normalized_market_phase_direction(market),
"market_phase_change_percent": market.phase_change_percent,
"market_phase_direction_consistency": market.payload.get(
"market_phase_direction_consistency"
),
"current_interval_change_percent": market.current_interval_change_percent,
"current_interval_direction": (
market.current_interval_direction.value
if market.current_interval_direction is not None
else "UNKNOWN"
),
"current_interval_label": market.current_interval_label or market.interval,
# Общая оценка рынка 0..100.
# Она рассчитана в MarketAnalysisService из всех рыночных факторов
# и дальше используется UI / diagnostics / execution / adaptive size.
"market_score": market.market_score,
"market_score_label": market.market_score_label,
"market_long_score": market.market_long_score,
"market_short_score": market.market_short_score,
"last_closed_candle_change_percent": market.payload.get(
"last_closed_candle_change_percent"
),
"last_closed_candle_direction": market.payload.get(
"last_closed_candle_direction"
),
"market_phase_reason": market.phase_reason,
"market_structure": (
market.market_structure.value
if market.market_structure is not None
else "UNKNOWN"
),
"market_structure_reason": market.market_structure_reason,
"momentum_state": (
market.momentum_state.value
if market.momentum_state is not None
else "UNKNOWN"
),
"momentum_direction": (
market.momentum_direction.value
if market.momentum_direction is not None
else "UNKNOWN"
),
"momentum_change_percent": market.momentum_change_percent,
"momentum_strength": market.momentum_strength,
"breakout_level": market.breakout_level,
"breakout_distance_percent": market.breakout_distance_percent,
"breakout_reason": market.breakout_reason,
"market_trend_gap_percent": market.trend_gap_percent,
"market_trend_consistency": market.trend_consistency,
"market_trend_efficiency": market.trend_efficiency,
"trend_quality_score": market.trend_quality_score,
"ema_distance_atr_ratio": market.ema_distance_atr_ratio,
"ema_distance_state": (
market.ema_distance_state.value
if market.ema_distance_state is not None
else "UNKNOWN"
),
"entry_timing_state": (
market.entry_timing_state.value
if market.entry_timing_state is not None
else "UNKNOWN"
),
"entry_timing_reason": market.entry_timing_reason,
"candle_noise_score": market.payload.get("candle_noise_score"),
"price_position_score": market.payload.get("price_position_score"),
"rsi": market.rsi,
"rsi_overbought": market.payload.get("rsi_overbought"),
"rsi_oversold": market.payload.get("rsi_oversold"),
"htf_market_state": (
market.htf_market_state.value
if market.htf_market_state is not None
else "UNKNOWN"
),
"htf_trend": (
market.htf_trend.value
if market.htf_trend is not None
else "UNKNOWN"
),
"htf_trend_strength": (
market.htf_trend_strength.value
if market.htf_trend_strength is not None
else "UNKNOWN"
),
"htf_trend_quality": (
market.htf_trend_quality.value
if market.htf_trend_quality is not None
else "UNKNOWN"
),
"htf_market_phase": (
market.htf_market_phase.value
if market.htf_market_phase is not None
else "UNKNOWN"
),
"htf_alignment": market.htf_alignment,
"htf_confirmation_score": market.htf_confirmation_score,
"htf_reason": market.htf_reason,
"runtime_window_ttl_seconds": self._window_ttl_seconds,
"runtime_window_size": len(prices),
}
# STEP 4. BREAKOUT FIRST
def _breakout_signal(
self,
*,
market: Any,
base_payload: dict[str, Any],
) -> SignalResult | None:
"""
Проверяет ранний вход по пробою.
Почему breakout проверяется ДО обычных блокировок:
- после флэта EMA часто ещё сжаты;
- качество может выглядеть шумным;
- state может не успеть стать идеальным TREND_UP/TREND_DOWN;
- но momentum уже показывает реальный пробой.
Условия BUY:
- momentum_state = BREAKOUT_UP;
- локальный тренд вверх;
- momentum вверх;
- HTF не против входа.
Условия SELL:
- momentum_state = BREAKOUT_DOWN;
- локальный тренд вниз;
- momentum вниз;
- HTF не против входа.
"""
momentum_state = getattr(market, "momentum_state", MomentumState.UNKNOWN)
momentum_strength = float(getattr(market, "momentum_strength", 0.0) or 0.0)
if (
momentum_state == MomentumState.BREAKOUT_UP
and market.trend == TrendDirection.UP
and market.momentum_direction == TrendDirection.UP
and market.htf_alignment in {"ALIGNED", "SAME_INTERVAL"}
):
return SignalResult(
signal=SignalType.BUY,
reason="BREAKOUT_UP подтверждён momentum/breakout semantic layer.",
confidence=self._calculate_breakout_confidence(momentum_strength),
payload={
**base_payload,
"entry_block_reason": None,
"entry_block_message": None,
"breakout_signal": True,
"expected_direction": "BUY",
},
)
if (
momentum_state == MomentumState.BREAKOUT_DOWN
and market.trend == TrendDirection.DOWN
and market.momentum_direction == TrendDirection.DOWN
and market.htf_alignment in {"ALIGNED", "SAME_INTERVAL"}
):
return SignalResult(
signal=SignalType.SELL,
reason="BREAKOUT_DOWN подтверждён momentum/breakout semantic layer.",
confidence=self._calculate_breakout_confidence(momentum_strength),
payload={
**base_payload,
"entry_block_reason": None,
"entry_block_message": None,
"breakout_signal": True,
"expected_direction": "SELL",
},
)
return None
# STEP 5. MARKET BLOCKS
# определить ранний impulse-вход из RANGE/SQUEEZE
def _early_impulse_direction(self, market: Any) -> str | None:
if market.state not in {MarketState.RANGE, MarketState.LOW_VOLATILITY}:
return None
if market.market_phase != MarketPhase.IMPULSE:
return None
if market.htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}:
return None
if (
market.trend == TrendDirection.UP
and market.momentum_direction == TrendDirection.UP
and market.momentum_state in {
MomentumState.MOMENTUM_UP,
MomentumState.BREAKOUT_UP,
}
):
return "BUY"
if (
market.trend == TrendDirection.DOWN
and market.momentum_direction == TrendDirection.DOWN
and market.momentum_state in {
MomentumState.MOMENTUM_DOWN,
MomentumState.BREAKOUT_DOWN,
}
):
return "SELL"
return None
def _market_block_signal(
self,
*,
market: Any,
base_payload: dict[str, Any],
) -> SignalResult | None:
early_impulse_direction = self._early_impulse_direction(market)
"""
Проверяет защитные фильтры рынка.
Эта функция НЕ ищет вход.
Она только отвечает на вопрос:
"Можно ли вообще рассматривать вход по TREND?"
Если найден риск — возвращает HOLD с причиной.
Если всё нормально — возвращает None.
"""
if (
market.state not in {MarketState.TREND_UP, MarketState.TREND_DOWN}
and early_impulse_direction is None
):
return self._hold(
reason=f"Market state не подходит для TREND: {market.state.value}.",
block_reason="MARKET_STATE_NOT_TREND",
block_message="рынок не в тренде",
payload=base_payload,
)
if market.volatility in {
VolatilityState.LOW,
VolatilityState.UNKNOWN,
}:
return self._hold(
reason="Волатильность не подходит для входа.",
block_reason="BAD_VOLATILITY",
block_message="волатильность не подходит",
payload=base_payload,
)
if market.htf_alignment == "AGAINST":
return self._hold(
reason="HTF trend против направления входа.",
block_reason="HTF_TREND_AGAINST",
block_message="старший таймфрейм против входа",
payload=base_payload,
)
if market.htf_alignment not in {"ALIGNED", "SAME_INTERVAL"}:
return self._hold(
reason="HTF trend не подтвердил направление входа.",
block_reason="HTF_TREND_NOT_CONFIRMED",
block_message="старший таймфрейм не подтвердил вход",
payload=base_payload,
)
# Слабый тренд больше не блокируем всегда.
# Если рынок уже TREND_UP / TREND_DOWN, старший ТФ подтверждает вход,
# качество не NOISY, а фаза IMPULSE — даём live-окну проверить движение.
# Это снижает число пропущенных ранних трендовых входов.
soft_trend_allowed = (
market.trend_strength == TrendStrength.WEAK
and market.market_phase == MarketPhase.IMPULSE
and market.trend_quality != TrendQuality.NOISY
and market.htf_alignment in {"ALIGNED", "SAME_INTERVAL"}
)
if (
market.trend_strength == TrendStrength.WEAK
and not soft_trend_allowed
):
return self._hold(
reason="TREND есть, но сила тренда слабая.",
block_reason="WEAK_MARKET_TREND",
block_message="слабый тренд",
payload=base_payload,
)
# RANGE / SQUEEZE больше не режем полностью,
# если уже появился momentum/breakout по направлению тренда.
# Иначе бот слишком поздно входит после выхода из флэта.
phase_breakout_context = (
(
market.state == MarketState.TREND_UP
and market.momentum_state == MomentumState.BREAKOUT_UP
and market.momentum_direction == TrendDirection.UP
)
or (
market.state == MarketState.TREND_DOWN
and market.momentum_state == MomentumState.BREAKOUT_DOWN
and market.momentum_direction == TrendDirection.DOWN
)
)
if (
market.market_phase in {MarketPhase.RANGE, MarketPhase.SQUEEZE}
and not phase_breakout_context
):
return self._hold(
reason="Фаза рынка не подходит для входа по TREND.",
block_reason=f"MARKET_PHASE_{market.market_phase.value}",
block_message="фаза рынка не подходит",
payload=base_payload,
)
structure_block = self._market_structure_block(
market=market,
base_payload=base_payload,
)
if structure_block is not None:
return structure_block
momentum_block = self._momentum_block(
market=market,
base_payload=base_payload,
)
if momentum_block is not None:
return momentum_block
# COMPRESSED больше не блокируем здесь жёстко.
# Сжатие EMA может быть не плохим рынком, а ранней стадией выхода из флэта.
# OVEREXTENDED оставляем блокировкой, потому что это часто поздний вход.
if market.ema_distance_state == EmaDistanceState.OVEREXTENDED:
return self._hold(
reason="EMA-дистанция не подходит для входа.",
block_reason=f"EMA_DISTANCE_{market.ema_distance_state.value}",
block_message="EMA-дистанция не подходит",
payload=base_payload,
)
if market.entry_timing_state in {
EntryTimingState.LATE,
EntryTimingState.CHASING,
}:
return self._hold(
reason="Тайминг входа запоздалый.",
block_reason=f"ENTRY_TIMING_{market.entry_timing_state.value}",
block_message="тайминг входа запоздалый",
payload=base_payload,
)
if not market.is_trade_allowed:
return self._hold(
reason=f"Market filter: {market.reason}",
block_reason="MARKET_FILTER_BLOCKED",
block_message="рынок сейчас не подходит для входа",
payload={
**base_payload,
"market_filter_blocked": True,
},
)
return None
def _market_structure_block(
self,
*,
market: Any,
base_payload: dict[str, Any],
) -> SignalResult | None:
"""
Проверяет структуру рынка.
Для LONG структура LH/LL плохая:
- lower high;
- lower low;
- рынок делает понижающиеся экстремумы.
Для SHORT структура HH/HL плохая:
- higher high;
- higher low;
- рынок делает повышающиеся экстремумы.
"""
market_structure = (
market.market_structure.value
if market.market_structure is not None
else "UNKNOWN"
)
if market.state == MarketState.TREND_UP and market_structure == "LH_LL":
return self._hold(
reason="Структура рынка против LONG.",
block_reason="MARKET_STRUCTURE_CONFLICT",
block_message="структура рынка против LONG",
payload={
**base_payload,
"expected_direction": "BUY",
},
)
if market.state == MarketState.TREND_DOWN and market_structure == "HH_HL":
return self._hold(
reason="Структура рынка против SHORT.",
block_reason="MARKET_STRUCTURE_CONFLICT",
block_message="структура рынка против SHORT",
payload={
**base_payload,
"expected_direction": "SELL",
},
)
return None
def _momentum_block(
self,
*,
market: Any,
base_payload: dict[str, Any],
) -> SignalResult | None:
# Проверяет, не идёт ли momentum явно против входа.
# Важно:
# FLAT больше не блокируем сразу.
# Если общий TREND-контекст хороший, live-окно ниже само проверит,
# есть ли реальное движение прямо сейчас.
# Блокируем только явный momentum против направления сделки.
if market.state == MarketState.TREND_UP:
if market.momentum_direction == TrendDirection.DOWN:
return self._hold(
reason="Momentum явно против LONG.",
block_reason="MOMENTUM_CONFLICT",
block_message="momentum против LONG",
payload={
**base_payload,
"expected_direction": "BUY",
},
)
if market.state == MarketState.TREND_DOWN:
if market.momentum_direction == TrendDirection.UP:
return self._hold(
reason="Momentum явно против SHORT.",
block_reason="MOMENTUM_CONFLICT",
block_message="momentum против SHORT",
payload={
**base_payload,
"expected_direction": "SELL",
},
)
return None
# =========================================================
# STEP 6. LIVE TREND CONFIRMATION
# =========================================================
def _live_trend_signal(
self,
*,
market: Any,
base_payload: dict[str, Any],
prices: list[float],
) -> SignalResult:
"""
Финальное подтверждение обычного TREND-входа.
Market analysis говорит:
- рынок в тренде;
- направление известно;
- фильтры разрешили вход.
Но перед сделкой нужно проверить live-окно:
- цена реально пошла в нужную сторону;
- движение не единичный случайный тик;
- достаточно шагов подтверждают направление.
"""
first_price = prices[0]
last_price = prices[-1]
if first_price <= 0:
return self._hold(
reason="Некорректная стартовая цена в live-окне.",
block_reason="INVALID_WINDOW_PRICE",
block_message="ошибка цены",
payload={
**base_payload,
"prices": prices,
},
)
change_percent = ((last_price - first_price) / first_price) * 100
direction_ratio = self._direction_ratio(prices, change_percent)
payload = {
**base_payload,
"first_price": first_price,
"current_price": last_price,
"change_percent": round(change_percent, 5),
"direction_ratio": round(direction_ratio, 3),
"window_size": len(prices),
"threshold_percent": self._threshold_percent,
"min_direction_ratio": self._min_direction_ratio,
}
early_impulse_direction = self._early_impulse_direction(market)
if market.state == MarketState.TREND_UP:
return self._trend_up_signal(
change_percent=change_percent,
direction_ratio=direction_ratio,
payload=payload,
)
if market.state == MarketState.TREND_DOWN:
return self._trend_down_signal(
change_percent=change_percent,
direction_ratio=direction_ratio,
payload=payload,
)
if early_impulse_direction == "BUY":
return self._trend_up_signal(
change_percent=change_percent,
direction_ratio=direction_ratio,
payload={
**payload,
"early_impulse_signal": True,
"expected_direction": "BUY",
},
)
if early_impulse_direction == "SELL":
return self._trend_down_signal(
change_percent=change_percent,
direction_ratio=direction_ratio,
payload={
**payload,
"early_impulse_signal": True,
"expected_direction": "SELL",
},
)
return self._hold(
reason=f"Market state не подходит для TREND: {market.state.value}.",
block_reason="MARKET_STATE_NOT_TREND",
block_message="рынок не в тренде",
payload=payload,
)
def _trend_up_signal(
self,
*,
change_percent: float,
direction_ratio: float,
payload: dict[str, Any],
) -> SignalResult:
"""
Формирует BUY, если live-окно подтвердило рост.
"""
if (
change_percent >= self._threshold_percent
and direction_ratio >= self._min_direction_ratio
):
return SignalResult(
signal=SignalType.BUY,
reason="TREND_UP подтверждён market analysis и live-импульсом.",
confidence=self._calculate_confidence(
change_percent,
direction_ratio,
),
payload={
**payload,
"entry_block_reason": None,
"entry_block_message": None,
"expected_direction": "BUY",
},
)
return self._hold(
reason="TREND_UP есть, но live-импульс вверх недостаточно сильный.",
block_reason="WEAK_UP_IMPULSE",
block_message="слабый импульс вверх",
payload={
**payload,
"expected_direction": "BUY",
},
)
def _trend_down_signal(
self,
*,
change_percent: float,
direction_ratio: float,
payload: dict[str, Any],
) -> SignalResult:
"""
Формирует SELL, если live-окно подтвердило снижение.
"""
if (
change_percent <= -self._threshold_percent
and direction_ratio >= self._min_direction_ratio
):
return SignalResult(
signal=SignalType.SELL,
reason="TREND_DOWN подтверждён market analysis и live-импульсом.",
confidence=self._calculate_confidence(
change_percent,
direction_ratio,
),
payload={
**payload,
"entry_block_reason": None,
"entry_block_message": None,
"expected_direction": "SELL",
},
)
return self._hold(
reason="TREND_DOWN есть, но live-импульс вниз недостаточно сильный.",
block_reason="WEAK_DOWN_IMPULSE",
block_message="слабый импульс вниз",
payload={
**payload,
"expected_direction": "SELL",
},
)
# =========================================================
# RESULT HELPERS
# =========================================================
def _hold(
self,
*,
reason: str,
block_reason: str,
block_message: str,
payload: dict[str, Any],
) -> SignalResult:
"""
Унифицированный HOLD.
Все блокировки входа должны проходить через эту функцию,
чтобы UI, журнал и supervisor получали одинаковые поля:
- entry_block_reason;
- entry_block_message.
"""
return SignalResult(
signal=SignalType.HOLD,
reason=reason,
confidence=0.0,
payload={
**payload,
"entry_block_reason": block_reason,
"entry_block_message": block_message,
},
)
# =========================================================
# CALCULATIONS
# =========================================================
def _direction_ratio(self, prices: list[float], change_percent: float) -> float:
"""
Считает долю движений в сторону общего изменения.
Пример:
prices = [100, 101, 102, 101.5, 103]
Если итоговое движение вверх:
считаем долю шагов, где цена росла.
Если итоговое движение вниз:
считаем долю шагов, где цена снижалась.
Это защищает от ситуации:
цена вроде изменилась на нужный процент,
но внутри окна движение было рваным и случайным.
"""
if len(prices) < 2:
return 0.0
up_moves = 0
down_moves = 0
for previous_price, current_price in zip(prices, prices[1:]):
if current_price > previous_price:
up_moves += 1
elif current_price < previous_price:
down_moves += 1
total_moves = max(1, len(prices) - 1)
if change_percent >= 0:
return up_moves / total_moves
return down_moves / total_moves
def _calculate_breakout_confidence(self, momentum_strength: float) -> float:
"""
Рассчитывает confidence для breakout-сигнала.
Breakout начинается с базовой уверенности 0.55.
Чем сильнее momentum_strength, тем выше confidence.
Максимум ограничен 0.95, чтобы execution confidence
всё равно учитывал spread, подтверждение и качество исполнения.
"""
strength_score = min(1.0, max(0.0, momentum_strength) / 2)
confidence = 0.55 + (strength_score * 0.35)
return round(min(0.95, confidence), 2)
def _calculate_confidence(
self,
change_percent: float,
direction_ratio: float,
) -> float:
"""
Рассчитывает confidence обычного TREND-сигнала.
Учитываются:
- сила движения относительно threshold;
- направленность движения внутри live-окна.
Чем сильнее и чище live-импульс,
тем выше confidence.
"""
strength = abs(change_percent) / self._threshold_percent
if strength < 1:
return 0.0
strength_score = min(1.0, strength / 3)
direction_score = min(1.0, direction_ratio)
confidence = 0.3 + (strength_score * 0.4) + (direction_score * 0.3)
return round(min(1.0, confidence), 2)
# =========================================================
# NORMALIZATION
# =========================================================
def _normalized_market_phase(self, market: Any) -> str:
"""
Безопасно возвращает market_phase строкой.
"""
if market.market_phase is None:
return "UNKNOWN"
return market.market_phase.value
def _normalized_market_phase_direction(self, market: Any) -> str:
"""
Безопасно возвращает phase_direction строкой.
"""
if market.phase_direction is None:
return "UNKNOWN"
return market.phase_direction.value
def _safe_float(
self,
value: float | int | str | None,
) -> float | None:
"""
Безопасное приведение к float.
Нужно потому, что snapshot может вернуть числа:
- как float;
- как int;
- как строку;
- как None.
"""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None