07.4.4.1.1 — Market State Human UI + HOLD Lifecycle Fix
This commit is contained in:
@@ -67,4 +67,34 @@ class SymbolValidationResult:
|
|||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PrivateAuthHealth:
|
class PrivateAuthHealth:
|
||||||
ok: bool
|
ok: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# MARKET ANALYSIS / KLINES
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Kline:
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
|
||||||
|
open_time: int
|
||||||
|
|
||||||
|
open_price: float
|
||||||
|
high_price: float
|
||||||
|
low_price: float
|
||||||
|
close_price: float
|
||||||
|
|
||||||
|
volume: float
|
||||||
|
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class KlineBatch:
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
candles: list[Kline]
|
||||||
|
source: str
|
||||||
@@ -22,6 +22,60 @@ class ExchangeRestClient:
|
|||||||
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
||||||
self.timeout = self.settings.exchange_timeout_sec
|
self.timeout = self.settings.exchange_timeout_sec
|
||||||
|
|
||||||
|
def get_payload(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> object:
|
||||||
|
query = f"?{urlencode(params)}" if params else ""
|
||||||
|
url = f"{self.base_url}{path}{query}"
|
||||||
|
|
||||||
|
request_headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "dzentra-bot/2.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
request_headers.update(headers)
|
||||||
|
|
||||||
|
request = Request(
|
||||||
|
url=url,
|
||||||
|
method="GET",
|
||||||
|
headers=request_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=self.timeout) as response:
|
||||||
|
status = getattr(response, "status", 200)
|
||||||
|
body = response.read().decode("utf-8")
|
||||||
|
except HTTPError as exc:
|
||||||
|
error_body = ""
|
||||||
|
try:
|
||||||
|
error_body = exc.read().decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
message = f"HTTP {exc.code} from exchange: {exc.reason}"
|
||||||
|
if error_body:
|
||||||
|
message += f" | body: {error_body}"
|
||||||
|
|
||||||
|
raise ExchangeResponseError(message) from exc
|
||||||
|
except URLError as exc:
|
||||||
|
raise ExchangeConnectionError(
|
||||||
|
f"Network error while calling exchange: {exc.reason}"
|
||||||
|
) from exc
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise ExchangeConnectionError("Timeout while calling exchange.") from exc
|
||||||
|
|
||||||
|
if status != 200:
|
||||||
|
raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(body)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
|
||||||
|
|
||||||
def get_json(
|
def get_json(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from src.integrations.exchange.models import (
|
|||||||
ExchangeHealth,
|
ExchangeHealth,
|
||||||
ExchangeSymbol,
|
ExchangeSymbol,
|
||||||
ExecutionPriceSnapshot,
|
ExecutionPriceSnapshot,
|
||||||
|
Kline,
|
||||||
|
KlineBatch,
|
||||||
PrivateAuthHealth,
|
PrivateAuthHealth,
|
||||||
SymbolValidationResult,
|
SymbolValidationResult,
|
||||||
TickerPrice,
|
TickerPrice,
|
||||||
@@ -142,6 +144,185 @@ class ExchangeService:
|
|||||||
def _runtime_key(self, runtime_key: str | None) -> str:
|
def _runtime_key(self, runtime_key: str | None) -> str:
|
||||||
return (runtime_key or self._default_runtime_key).strip().lower()
|
return (runtime_key or self._default_runtime_key).strip().lower()
|
||||||
|
|
||||||
|
def get_klines(
|
||||||
|
self,
|
||||||
|
symbol: str | None = None,
|
||||||
|
*,
|
||||||
|
interval: str = "1m",
|
||||||
|
limit: int = 200,
|
||||||
|
price_type: str = "bid",
|
||||||
|
) -> KlineBatch:
|
||||||
|
symbol_to_use = symbol or self.settings.default_symbol
|
||||||
|
|
||||||
|
if limit <= 0:
|
||||||
|
limit = 200
|
||||||
|
|
||||||
|
if limit > 200:
|
||||||
|
limit = 200
|
||||||
|
|
||||||
|
if interval not in {"1m", "5m", "15m"}:
|
||||||
|
raise ExchangeError(f"Unsupported kline interval: {interval}")
|
||||||
|
|
||||||
|
normalized_price_type = price_type.strip().lower()
|
||||||
|
|
||||||
|
if normalized_price_type not in {"bid", "ask"}:
|
||||||
|
normalized_price_type = "bid"
|
||||||
|
|
||||||
|
if not self.settings.exchange_enabled:
|
||||||
|
raise ExchangeError("Klines are not available in mock exchange mode.")
|
||||||
|
|
||||||
|
validation = self.validate_symbol(symbol_to_use)
|
||||||
|
if not validation.is_valid:
|
||||||
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
|
client = ExchangeRestClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = client.get_payload(
|
||||||
|
"/api/v2/klines",
|
||||||
|
params={
|
||||||
|
"symbol": validation.normalized_symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"limit": str(limit),
|
||||||
|
"priceType": normalized_price_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_exchange_error(
|
||||||
|
endpoint="klines",
|
||||||
|
exc=exc,
|
||||||
|
symbol=validation.normalized_symbol,
|
||||||
|
extra_payload={
|
||||||
|
"interval": interval,
|
||||||
|
"limit": limit,
|
||||||
|
"price_type": normalized_price_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise ExchangeError(str(exc)) from exc
|
||||||
|
|
||||||
|
candles = self._parse_klines_payload(
|
||||||
|
payload=payload,
|
||||||
|
symbol=validation.normalized_symbol,
|
||||||
|
interval=interval,
|
||||||
|
source=f"rest_klines:{normalized_price_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return KlineBatch(
|
||||||
|
symbol=validation.normalized_symbol,
|
||||||
|
interval=interval,
|
||||||
|
candles=candles[-limit:],
|
||||||
|
source=f"rest_klines:{normalized_price_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_klines_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payload: object,
|
||||||
|
symbol: str,
|
||||||
|
interval: str,
|
||||||
|
source: str,
|
||||||
|
) -> list[Kline]:
|
||||||
|
raw_items = self._extract_klines_items(payload)
|
||||||
|
|
||||||
|
candles: list[Kline] = []
|
||||||
|
|
||||||
|
for item in raw_items:
|
||||||
|
candle = self._parse_kline_item(
|
||||||
|
item=item,
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
if candle is not None:
|
||||||
|
candles.append(candle)
|
||||||
|
|
||||||
|
candles.sort(key=lambda item: item.open_time)
|
||||||
|
|
||||||
|
return candles
|
||||||
|
|
||||||
|
def _extract_klines_items(self, payload: object) -> list:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(payload.get("klines"), list):
|
||||||
|
return payload["klines"]
|
||||||
|
|
||||||
|
if isinstance(payload.get("candles"), list):
|
||||||
|
return payload["candles"]
|
||||||
|
|
||||||
|
if isinstance(payload.get("data"), list):
|
||||||
|
return payload["data"]
|
||||||
|
|
||||||
|
inner = payload.get("payload")
|
||||||
|
if isinstance(inner, list):
|
||||||
|
return inner
|
||||||
|
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
if isinstance(inner.get("klines"), list):
|
||||||
|
return inner["klines"]
|
||||||
|
|
||||||
|
if isinstance(inner.get("candles"), list):
|
||||||
|
return inner["candles"]
|
||||||
|
|
||||||
|
if isinstance(inner.get("data"), list):
|
||||||
|
return inner["data"]
|
||||||
|
|
||||||
|
if isinstance(payload.get("result"), list):
|
||||||
|
return payload["result"]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_kline_item(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
item: object,
|
||||||
|
symbol: str,
|
||||||
|
interval: str,
|
||||||
|
source: str,
|
||||||
|
) -> Kline | None:
|
||||||
|
try:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
open_time = (
|
||||||
|
item.get("openTime")
|
||||||
|
or item.get("open_time")
|
||||||
|
or item.get("time")
|
||||||
|
or item.get("timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
return Kline(
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
open_time=int(open_time),
|
||||||
|
open_price=float(item.get("open")),
|
||||||
|
high_price=float(item.get("high")),
|
||||||
|
low_price=float(item.get("low")),
|
||||||
|
close_price=float(item.get("close")),
|
||||||
|
volume=float(item.get("volume") or 0.0),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(item, list) and len(item) >= 6:
|
||||||
|
return Kline(
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
open_time=int(item[0]),
|
||||||
|
open_price=float(item[1]),
|
||||||
|
high_price=float(item[2]),
|
||||||
|
low_price=float(item[3]),
|
||||||
|
close_price=float(item[4]),
|
||||||
|
volume=float(item[5] or 0.0),
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def get_health(self) -> ExchangeHealth:
|
def get_health(self) -> ExchangeHealth:
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
return mock_exchange_health()
|
return mock_exchange_health()
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ def _build_waiting_text(state) -> str:
|
|||||||
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
|
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
|
||||||
"",
|
"",
|
||||||
_signal_line(state),
|
_signal_line(state),
|
||||||
|
_market_state_line(state),
|
||||||
*_signal_confidence_lines(state),
|
*_signal_confidence_lines(state),
|
||||||
*_execution_block_lines(state),
|
*_execution_block_lines(state),
|
||||||
"",
|
"",
|
||||||
@@ -208,6 +209,7 @@ def _build_active_position_text(state) -> str:
|
|||||||
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
|
f"<b>Доступно</b> · $ {_format_money_compact(available)}",
|
||||||
f"<b>Зарезервировано</b> · $ {_format_money_compact(reserved)}",
|
f"<b>Зарезервировано</b> · $ {_format_money_compact(reserved)}",
|
||||||
f"<b>P&L</b> {_format_signed_usd_with_direction(pnl)}",
|
f"<b>P&L</b> {_format_signed_usd_with_direction(pnl)}",
|
||||||
|
_market_state_line(state),
|
||||||
*_execution_block_lines(state),
|
*_execution_block_lines(state),
|
||||||
"",
|
"",
|
||||||
(
|
(
|
||||||
@@ -235,6 +237,22 @@ def _build_active_position_text(state) -> str:
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _market_state_line(state) -> str:
|
||||||
|
market_state = getattr(state, "market_state", None)
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
"TREND_UP": "📈 Рынок · Рост",
|
||||||
|
"TREND_DOWN": "📉 Рынок · Падение",
|
||||||
|
"RANGE": "🟰 Рынок · Флэт",
|
||||||
|
"HIGH_VOLATILITY": "⚠️ Рынок · Волатильность",
|
||||||
|
"LOW_VOLATILITY": "🟰 Рынок · Спокойный",
|
||||||
|
"UNKNOWN": "⏳ Рынок · Анализ",
|
||||||
|
None: "⏳ Рынок · Анализ",
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.get(market_state, "⏳ Рынок · Анализ")
|
||||||
|
|
||||||
|
|
||||||
def _execution_block_lines(state) -> list[str]:
|
def _execution_block_lines(state) -> list[str]:
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class AutoTradeService:
|
|||||||
confidence: float,
|
confidence: float,
|
||||||
payload: dict | None,
|
payload: dict | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}:{reason}"
|
signal_key = f"{state.status}:{state.symbol}:{strategy_name}:{signal}"
|
||||||
previous_signal = self._last_signal_value
|
previous_signal = self._last_signal_value
|
||||||
previous_count = self._same_signal_count
|
previous_count = self._same_signal_count
|
||||||
is_same_signal = signal_key == self._last_signal_key
|
is_same_signal = signal_key == self._last_signal_key
|
||||||
@@ -396,6 +396,10 @@ class AutoTradeService:
|
|||||||
|
|
||||||
if is_same_signal:
|
if is_same_signal:
|
||||||
self._same_signal_count += 1
|
self._same_signal_count += 1
|
||||||
|
self._last_signal_reason = reason
|
||||||
|
self._last_signal_confidence = confidence
|
||||||
|
self._last_signal_payload = payload
|
||||||
|
|
||||||
self._update_signal_state_fields(
|
self._update_signal_state_fields(
|
||||||
state=state,
|
state=state,
|
||||||
signal=signal,
|
signal=signal,
|
||||||
@@ -404,7 +408,7 @@ class AutoTradeService:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if previous_signal is not None:
|
if previous_signal is not None and previous_signal != signal:
|
||||||
if previous_count > 1:
|
if previous_count > 1:
|
||||||
self._log_signal_summary(
|
self._log_signal_summary(
|
||||||
strategy_name=strategy_name,
|
strategy_name=strategy_name,
|
||||||
@@ -636,7 +640,21 @@ class AutoTradeService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# выполнить один цикл анализа рынка
|
def _sync_market_analysis_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: AutoTradeState,
|
||||||
|
payload: dict | None,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
state.market_state = payload.get("market_state")
|
||||||
|
state.market_trend = payload.get("market_trend")
|
||||||
|
state.market_volatility = payload.get("market_volatility")
|
||||||
|
state.market_analysis_interval = payload.get("market_analysis_interval")
|
||||||
|
state.market_analysis_reason = payload.get("market_analysis_reason")
|
||||||
|
|
||||||
def run_cycle(self) -> AutoTradeState:
|
def run_cycle(self) -> AutoTradeState:
|
||||||
state = self.get_state()
|
state = self.get_state()
|
||||||
|
|
||||||
@@ -647,6 +665,11 @@ class AutoTradeService:
|
|||||||
context = self._build_strategy_context()
|
context = self._build_strategy_context()
|
||||||
result = strategy.analyze(context)
|
result = strategy.analyze(context)
|
||||||
|
|
||||||
|
self._sync_market_analysis_state(
|
||||||
|
state=state,
|
||||||
|
payload=result.payload,
|
||||||
|
)
|
||||||
|
|
||||||
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
state.last_check_at = datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
self._log_signal_if_changed(
|
self._log_signal_if_changed(
|
||||||
|
|||||||
@@ -104,4 +104,19 @@ class AutoTradeState:
|
|||||||
last_flip_block_reason: str | None = None
|
last_flip_block_reason: str | None = None
|
||||||
|
|
||||||
# время последнего успешного flip
|
# время последнего успешного flip
|
||||||
last_flip_at: str | None = None
|
last_flip_at: str | None = None
|
||||||
|
|
||||||
|
# состояние рынка по Market State Engine
|
||||||
|
market_state: str | None = None
|
||||||
|
|
||||||
|
# направление тренда: UP / DOWN / FLAT / UNKNOWN
|
||||||
|
market_trend: str | None = None
|
||||||
|
|
||||||
|
# волатильность: LOW / NORMAL / HIGH / UNKNOWN
|
||||||
|
market_volatility: str | None = None
|
||||||
|
|
||||||
|
# таймфрейм анализа рынка
|
||||||
|
market_analysis_interval: str | None = None
|
||||||
|
|
||||||
|
# объяснение последнего анализа рынка
|
||||||
|
market_analysis_reason: str | None = None
|
||||||
3
app/src/trading/market_analysis/__init__.py
Normal file
3
app/src/trading/market_analysis/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# app/src/trading/market_analysis/__init__.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
67
app/src/trading/market_analysis/indicators.py
Normal file
67
app/src/trading/market_analysis/indicators.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# app/src/trading/market_analysis/indicators.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.integrations.exchange.models import Kline
|
||||||
|
|
||||||
|
|
||||||
|
def ema(values: list[float], period: int) -> float | None:
|
||||||
|
if period <= 0 or len(values) < period:
|
||||||
|
return None
|
||||||
|
|
||||||
|
multiplier = 2 / (period + 1)
|
||||||
|
current = sum(values[:period]) / period
|
||||||
|
|
||||||
|
for value in values[period:]:
|
||||||
|
current = (value - current) * multiplier + current
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def atr(candles: list[Kline], period: int = 14) -> float | None:
|
||||||
|
if period <= 0 or len(candles) < period + 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
true_ranges: list[float] = []
|
||||||
|
|
||||||
|
for previous, current in zip(candles, candles[1:]):
|
||||||
|
high_low = current.high_price - current.low_price
|
||||||
|
high_close = abs(current.high_price - previous.close_price)
|
||||||
|
low_close = abs(current.low_price - previous.close_price)
|
||||||
|
|
||||||
|
true_ranges.append(max(high_low, high_close, low_close))
|
||||||
|
|
||||||
|
if len(true_ranges) < period:
|
||||||
|
return None
|
||||||
|
|
||||||
|
recent = true_ranges[-period:]
|
||||||
|
return sum(recent) / period
|
||||||
|
|
||||||
|
|
||||||
|
def rsi(values: list[float], period: int = 14) -> float | None:
|
||||||
|
if period <= 0 or len(values) < period + 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
gains: list[float] = []
|
||||||
|
losses: list[float] = []
|
||||||
|
|
||||||
|
recent = values[-(period + 1):]
|
||||||
|
|
||||||
|
for previous, current in zip(recent, recent[1:]):
|
||||||
|
change = current - previous
|
||||||
|
|
||||||
|
if change > 0:
|
||||||
|
gains.append(change)
|
||||||
|
losses.append(0.0)
|
||||||
|
else:
|
||||||
|
gains.append(0.0)
|
||||||
|
losses.append(abs(change))
|
||||||
|
|
||||||
|
average_gain = sum(gains) / period
|
||||||
|
average_loss = sum(losses) / period
|
||||||
|
|
||||||
|
if average_loss == 0:
|
||||||
|
return 100.0
|
||||||
|
|
||||||
|
rs = average_gain / average_loss
|
||||||
|
return 100 - (100 / (1 + rs))
|
||||||
52
app/src/trading/market_analysis/models.py
Normal file
52
app/src/trading/market_analysis/models.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# app/src/trading/market_analysis/models.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class MarketState(StrEnum):
|
||||||
|
TREND_UP = "TREND_UP"
|
||||||
|
TREND_DOWN = "TREND_DOWN"
|
||||||
|
RANGE = "RANGE"
|
||||||
|
HIGH_VOLATILITY = "HIGH_VOLATILITY"
|
||||||
|
LOW_VOLATILITY = "LOW_VOLATILITY"
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
class TrendDirection(StrEnum):
|
||||||
|
UP = "UP"
|
||||||
|
DOWN = "DOWN"
|
||||||
|
FLAT = "FLAT"
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
class VolatilityState(StrEnum):
|
||||||
|
LOW = "LOW"
|
||||||
|
NORMAL = "NORMAL"
|
||||||
|
HIGH = "HIGH"
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MarketAnalysisResult:
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
|
||||||
|
state: MarketState
|
||||||
|
trend: TrendDirection
|
||||||
|
volatility: VolatilityState
|
||||||
|
|
||||||
|
close_price: float | None
|
||||||
|
ema_fast: float | None
|
||||||
|
ema_slow: float | None
|
||||||
|
atr: float | None
|
||||||
|
atr_percent: float | None
|
||||||
|
rsi: float | None
|
||||||
|
|
||||||
|
candles_count: int
|
||||||
|
reason: str
|
||||||
|
is_trade_allowed: bool
|
||||||
|
|
||||||
|
payload: dict
|
||||||
256
app/src/trading/market_analysis/service.py
Normal file
256
app/src/trading/market_analysis/service.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# app/src/trading/market_analysis/service.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.trading.market_analysis.indicators import atr, ema, rsi
|
||||||
|
from src.trading.market_analysis.models import (
|
||||||
|
MarketAnalysisResult,
|
||||||
|
MarketState,
|
||||||
|
TrendDirection,
|
||||||
|
VolatilityState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketAnalysisService:
|
||||||
|
_fast_ema_period = 20
|
||||||
|
_slow_ema_period = 50
|
||||||
|
_atr_period = 14
|
||||||
|
_rsi_period = 14
|
||||||
|
|
||||||
|
_min_candles = 60
|
||||||
|
|
||||||
|
_low_volatility_atr_percent = 0.05
|
||||||
|
_high_volatility_atr_percent = 1.8
|
||||||
|
|
||||||
|
_trend_gap_percent = 0.03
|
||||||
|
|
||||||
|
def analyze(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
interval: str = "5m",
|
||||||
|
limit: int = 200,
|
||||||
|
) -> MarketAnalysisResult:
|
||||||
|
try:
|
||||||
|
batch = ExchangeService().get_klines(
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._unknown(
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
reason=f"Не удалось получить свечи: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
candles = batch.candles
|
||||||
|
closes = [item.close_price for item in candles]
|
||||||
|
|
||||||
|
if len(candles) < self._min_candles:
|
||||||
|
return self._unknown(
|
||||||
|
symbol=batch.symbol,
|
||||||
|
interval=interval,
|
||||||
|
reason="Недостаточно свечей для анализа рынка.",
|
||||||
|
candles_count=len(candles),
|
||||||
|
)
|
||||||
|
|
||||||
|
close_price = closes[-1] if closes else None
|
||||||
|
ema_fast = ema(closes, self._fast_ema_period)
|
||||||
|
ema_slow = ema(closes, self._slow_ema_period)
|
||||||
|
atr_value = atr(candles, self._atr_period)
|
||||||
|
rsi_value = rsi(closes, self._rsi_period)
|
||||||
|
|
||||||
|
if (
|
||||||
|
close_price is None
|
||||||
|
or close_price <= 0
|
||||||
|
or ema_fast is None
|
||||||
|
or ema_slow is None
|
||||||
|
or atr_value is None
|
||||||
|
):
|
||||||
|
return self._unknown(
|
||||||
|
symbol=batch.symbol,
|
||||||
|
interval=interval,
|
||||||
|
reason="Недостаточно данных для расчёта EMA / ATR.",
|
||||||
|
candles_count=len(candles),
|
||||||
|
)
|
||||||
|
|
||||||
|
atr_percent = (atr_value / close_price) * 100
|
||||||
|
|
||||||
|
volatility = self._classify_volatility(atr_percent)
|
||||||
|
trend = self._classify_trend(
|
||||||
|
ema_fast=ema_fast,
|
||||||
|
ema_slow=ema_slow,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = self._classify_market_state(
|
||||||
|
trend=trend,
|
||||||
|
volatility=volatility,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_trade_allowed = state in {
|
||||||
|
MarketState.TREND_UP,
|
||||||
|
MarketState.TREND_DOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = self._reason(
|
||||||
|
state=state,
|
||||||
|
trend=trend,
|
||||||
|
volatility=volatility,
|
||||||
|
atr_percent=atr_percent,
|
||||||
|
rsi_value=rsi_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MarketAnalysisResult(
|
||||||
|
symbol=batch.symbol,
|
||||||
|
interval=interval,
|
||||||
|
state=state,
|
||||||
|
trend=trend,
|
||||||
|
volatility=volatility,
|
||||||
|
close_price=close_price,
|
||||||
|
ema_fast=ema_fast,
|
||||||
|
ema_slow=ema_slow,
|
||||||
|
atr=atr_value,
|
||||||
|
atr_percent=atr_percent,
|
||||||
|
rsi=rsi_value,
|
||||||
|
candles_count=len(candles),
|
||||||
|
reason=reason,
|
||||||
|
is_trade_allowed=is_trade_allowed,
|
||||||
|
payload={
|
||||||
|
"symbol": batch.symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"market_state": state.value,
|
||||||
|
"trend": trend.value,
|
||||||
|
"volatility": volatility.value,
|
||||||
|
"close_price": close_price,
|
||||||
|
"ema_fast_period": self._fast_ema_period,
|
||||||
|
"ema_slow_period": self._slow_ema_period,
|
||||||
|
"ema_fast": round(ema_fast, 8),
|
||||||
|
"ema_slow": round(ema_slow, 8),
|
||||||
|
"atr_period": self._atr_period,
|
||||||
|
"atr": round(atr_value, 8),
|
||||||
|
"atr_percent": round(atr_percent, 4),
|
||||||
|
"rsi_period": self._rsi_period,
|
||||||
|
"rsi": round(rsi_value, 2) if rsi_value is not None else None,
|
||||||
|
"candles_count": len(candles),
|
||||||
|
"is_trade_allowed": is_trade_allowed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _classify_trend(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ema_fast: float,
|
||||||
|
ema_slow: float,
|
||||||
|
) -> TrendDirection:
|
||||||
|
if ema_slow <= 0:
|
||||||
|
return TrendDirection.UNKNOWN
|
||||||
|
|
||||||
|
gap_percent = ((ema_fast - ema_slow) / ema_slow) * 100
|
||||||
|
|
||||||
|
if gap_percent >= self._trend_gap_percent:
|
||||||
|
return TrendDirection.UP
|
||||||
|
|
||||||
|
if gap_percent <= -self._trend_gap_percent:
|
||||||
|
return TrendDirection.DOWN
|
||||||
|
|
||||||
|
return TrendDirection.FLAT
|
||||||
|
|
||||||
|
def _classify_volatility(self, atr_percent: float) -> VolatilityState:
|
||||||
|
if atr_percent <= 0:
|
||||||
|
return VolatilityState.UNKNOWN
|
||||||
|
|
||||||
|
if atr_percent < self._low_volatility_atr_percent:
|
||||||
|
return VolatilityState.LOW
|
||||||
|
|
||||||
|
if atr_percent > self._high_volatility_atr_percent:
|
||||||
|
return VolatilityState.HIGH
|
||||||
|
|
||||||
|
return VolatilityState.NORMAL
|
||||||
|
|
||||||
|
def _classify_market_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
trend: TrendDirection,
|
||||||
|
volatility: VolatilityState,
|
||||||
|
) -> MarketState:
|
||||||
|
if volatility == VolatilityState.HIGH:
|
||||||
|
return MarketState.HIGH_VOLATILITY
|
||||||
|
|
||||||
|
if volatility == VolatilityState.LOW:
|
||||||
|
return MarketState.LOW_VOLATILITY
|
||||||
|
|
||||||
|
if trend == TrendDirection.UP:
|
||||||
|
return MarketState.TREND_UP
|
||||||
|
|
||||||
|
if trend == TrendDirection.DOWN:
|
||||||
|
return MarketState.TREND_DOWN
|
||||||
|
|
||||||
|
if trend == TrendDirection.FLAT:
|
||||||
|
return MarketState.RANGE
|
||||||
|
|
||||||
|
return MarketState.UNKNOWN
|
||||||
|
|
||||||
|
def _reason(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: MarketState,
|
||||||
|
trend: TrendDirection,
|
||||||
|
volatility: VolatilityState,
|
||||||
|
atr_percent: float,
|
||||||
|
rsi_value: float | None,
|
||||||
|
) -> str:
|
||||||
|
rsi_text = f", RSI={rsi_value:.2f}" if rsi_value is not None else ""
|
||||||
|
|
||||||
|
if state == MarketState.TREND_UP:
|
||||||
|
return f"Рынок в восходящем тренде. ATR={atr_percent:.2f}%{rsi_text}."
|
||||||
|
|
||||||
|
if state == MarketState.TREND_DOWN:
|
||||||
|
return f"Рынок в нисходящем тренде. ATR={atr_percent:.2f}%{rsi_text}."
|
||||||
|
|
||||||
|
if state == MarketState.RANGE:
|
||||||
|
return f"Рынок в боковике. Тренд не подтверждён. ATR={atr_percent:.2f}%{rsi_text}."
|
||||||
|
|
||||||
|
if state == MarketState.HIGH_VOLATILITY:
|
||||||
|
return f"Рынок слишком волатильный. ATR={atr_percent:.2f}%{rsi_text}."
|
||||||
|
|
||||||
|
if state == MarketState.LOW_VOLATILITY:
|
||||||
|
return f"Рынок слишком спокойный. ATR={atr_percent:.2f}%{rsi_text}."
|
||||||
|
|
||||||
|
return f"Состояние рынка не определено. Trend={trend}, volatility={volatility}."
|
||||||
|
|
||||||
|
def _unknown(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
interval: str,
|
||||||
|
reason: str,
|
||||||
|
candles_count: int = 0,
|
||||||
|
) -> MarketAnalysisResult:
|
||||||
|
return MarketAnalysisResult(
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
state=MarketState.UNKNOWN,
|
||||||
|
trend=TrendDirection.UNKNOWN,
|
||||||
|
volatility=VolatilityState.UNKNOWN,
|
||||||
|
close_price=None,
|
||||||
|
ema_fast=None,
|
||||||
|
ema_slow=None,
|
||||||
|
atr=None,
|
||||||
|
atr_percent=None,
|
||||||
|
rsi=None,
|
||||||
|
candles_count=candles_count,
|
||||||
|
reason=reason,
|
||||||
|
is_trade_allowed=False,
|
||||||
|
payload={
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"market_state": MarketState.UNKNOWN.value,
|
||||||
|
"trend": TrendDirection.UNKNOWN.value,
|
||||||
|
"volatility": VolatilityState.UNKNOWN.value,
|
||||||
|
"candles_count": candles_count,
|
||||||
|
"is_trade_allowed": False,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
|
from src.trading.market_analysis.models import MarketState
|
||||||
|
from src.trading.market_analysis.service import MarketAnalysisService
|
||||||
from src.trading.strategies.base import StrategyContext
|
from src.trading.strategies.base import StrategyContext
|
||||||
from src.trading.strategies.signals import SignalResult, SignalType
|
from src.trading.strategies.signals import SignalResult, SignalType
|
||||||
|
|
||||||
@@ -12,18 +14,26 @@ class TrendStrategy:
|
|||||||
|
|
||||||
_price_window: dict[str, list[float]] = {}
|
_price_window: dict[str, list[float]] = {}
|
||||||
|
|
||||||
# длиннее окно = меньше шума
|
# короткое окно оставляем как дополнительное подтверждение импульса
|
||||||
_window_size = 8
|
_window_size = 8
|
||||||
|
|
||||||
# общий порог изменения за окно
|
|
||||||
_threshold_percent = 0.05
|
_threshold_percent = 0.05
|
||||||
|
|
||||||
# сколько движений внутри окна должно быть в сторону сигнала
|
|
||||||
_min_direction_ratio = 0.6
|
_min_direction_ratio = 0.6
|
||||||
|
|
||||||
|
# основной таймфрейм анализа рынка
|
||||||
|
_market_interval = "5m"
|
||||||
|
|
||||||
def analyze(self, context: StrategyContext) -> SignalResult:
|
def analyze(self, context: StrategyContext) -> SignalResult:
|
||||||
|
market = MarketAnalysisService().analyze(
|
||||||
|
context.symbol,
|
||||||
|
interval=self._market_interval,
|
||||||
|
limit=200,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
snapshot = ExchangeService().get_market_snapshot(context.symbol)
|
snapshot = ExchangeService().get_market_snapshot(
|
||||||
|
context.symbol,
|
||||||
|
runtime_key="auto",
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return SignalResult(
|
return SignalResult(
|
||||||
signal=SignalType.HOLD,
|
signal=SignalType.HOLD,
|
||||||
@@ -33,6 +43,7 @@ class TrendStrategy:
|
|||||||
"strategy": self.name,
|
"strategy": self.name,
|
||||||
"symbol": context.symbol,
|
"symbol": context.symbol,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
|
"market_analysis": market.payload,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +59,7 @@ class TrendStrategy:
|
|||||||
"strategy": self.name,
|
"strategy": self.name,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
|
"market_analysis": market.payload,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,15 +69,39 @@ class TrendStrategy:
|
|||||||
if len(prices) > self._window_size:
|
if len(prices) > self._window_size:
|
||||||
prices.pop(0)
|
prices.pop(0)
|
||||||
|
|
||||||
|
base_payload = {
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not market.is_trade_allowed:
|
||||||
|
return SignalResult(
|
||||||
|
signal=SignalType.HOLD,
|
||||||
|
reason=f"Market filter: {market.reason}",
|
||||||
|
confidence=0.0,
|
||||||
|
payload={
|
||||||
|
**base_payload,
|
||||||
|
"market_filter_blocked": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if len(prices) < self._window_size:
|
if len(prices) < self._window_size:
|
||||||
return SignalResult(
|
return SignalResult(
|
||||||
signal=SignalType.HOLD,
|
signal=SignalType.HOLD,
|
||||||
reason="Недостаточно данных для анализа тренда.",
|
reason="Недостаточно live-данных для подтверждения TREND.",
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
payload={
|
payload={
|
||||||
"strategy": self.name,
|
**base_payload,
|
||||||
"symbol": symbol,
|
|
||||||
"price": current_price,
|
|
||||||
"window_size": len(prices),
|
"window_size": len(prices),
|
||||||
"required_window_size": self._window_size,
|
"required_window_size": self._window_size,
|
||||||
},
|
},
|
||||||
@@ -77,11 +113,10 @@ class TrendStrategy:
|
|||||||
if first_price <= 0:
|
if first_price <= 0:
|
||||||
return SignalResult(
|
return SignalResult(
|
||||||
signal=SignalType.HOLD,
|
signal=SignalType.HOLD,
|
||||||
reason="Некорректная стартовая цена в окне.",
|
reason="Некорректная стартовая цена в live-окне.",
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
payload={
|
payload={
|
||||||
"strategy": self.name,
|
**base_payload,
|
||||||
"symbol": symbol,
|
|
||||||
"prices": prices,
|
"prices": prices,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -90,14 +125,9 @@ class TrendStrategy:
|
|||||||
direction_ratio = self._direction_ratio(prices, change_percent)
|
direction_ratio = self._direction_ratio(prices, change_percent)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"strategy": self.name,
|
**base_payload,
|
||||||
"symbol": symbol,
|
|
||||||
"analysis_price": last_price,
|
|
||||||
"first_price": first_price,
|
"first_price": first_price,
|
||||||
"current_price": last_price,
|
"current_price": last_price,
|
||||||
"last_price": snapshot.get("last_price"),
|
|
||||||
"bid_price": snapshot.get("bid_price"),
|
|
||||||
"ask_price": snapshot.get("ask_price"),
|
|
||||||
"change_percent": round(change_percent, 5),
|
"change_percent": round(change_percent, 5),
|
||||||
"direction_ratio": round(direction_ratio, 3),
|
"direction_ratio": round(direction_ratio, 3),
|
||||||
"window_size": len(prices),
|
"window_size": len(prices),
|
||||||
@@ -105,31 +135,47 @@ class TrendStrategy:
|
|||||||
"min_direction_ratio": self._min_direction_ratio,
|
"min_direction_ratio": self._min_direction_ratio,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if market.state == MarketState.TREND_UP:
|
||||||
change_percent >= self._threshold_percent
|
if (
|
||||||
and direction_ratio >= self._min_direction_ratio
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
return SignalResult(
|
return SignalResult(
|
||||||
signal=SignalType.BUY,
|
signal=SignalType.HOLD,
|
||||||
reason="Устойчивый рост цены в окне TREND.",
|
reason="TREND_UP есть, но live-импульс вверх недостаточно сильный.",
|
||||||
confidence=self._calculate_confidence(change_percent, direction_ratio),
|
confidence=0.0,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if market.state == MarketState.TREND_DOWN:
|
||||||
change_percent <= -self._threshold_percent
|
if (
|
||||||
and direction_ratio >= self._min_direction_ratio
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
return SignalResult(
|
return SignalResult(
|
||||||
signal=SignalType.SELL,
|
signal=SignalType.HOLD,
|
||||||
reason="Устойчивое снижение цены в окне TREND.",
|
reason="TREND_DOWN есть, но live-импульс вниз недостаточно сильный.",
|
||||||
confidence=self._calculate_confidence(change_percent, direction_ratio),
|
confidence=0.0,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SignalResult(
|
return SignalResult(
|
||||||
signal=SignalType.HOLD,
|
signal=SignalType.HOLD,
|
||||||
reason="Тренд недостаточно устойчивый.",
|
reason=f"Market state не подходит для TREND: {market.state.value}.",
|
||||||
confidence=0.0,
|
confidence=0.0,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -396,6 +396,23 @@
|
|||||||
- централизован EVENT_TITLES mapping
|
- централизован EVENT_TITLES mapping
|
||||||
- журнал подготовлен к filters/search layer
|
- журнал подготовлен к filters/search layer
|
||||||
|
|
||||||
|
#### 07.4.4.1.1 ✅ Market State Human UI + HOLD Lifecycle Fix
|
||||||
|
- добавлено короткое human-readable отображение состояния рынка
|
||||||
|
- технические market_state значения скрыты из основного Auto UI
|
||||||
|
- убраны `trend=...` и `volatility=...` из Telegram-экрана
|
||||||
|
- убран timeframe анализа из основного UI как лишняя техническая деталь
|
||||||
|
- добавлены UI-состояния `📈 Рынок · Рост`, `📉 Рынок · Падение`, `🟰 Рынок · Флэт`, `⚠️ Рынок · Волатильность`, `⏳ Рынок · Анализ`
|
||||||
|
- подтверждена работа REST klines как основы аналитики рынка
|
||||||
|
- Market State Engine переведён на анализ свечей OHLCV
|
||||||
|
- добавлены EMA20 / EMA50 для определения направления тренда
|
||||||
|
- добавлен ATR для оценки волатильности рынка
|
||||||
|
- добавлен RSI в аналитический payload
|
||||||
|
- TREND-стратегия стала market-aware и использует состояние рынка как фильтр входа
|
||||||
|
- market analysis синхронизируется в AutoTradeState
|
||||||
|
- исправлен баг ложного завершения `HOLD → HOLD`
|
||||||
|
- HOLD summary теперь пишется только при реальной смене сигнала
|
||||||
|
- этап подготовил основу для Market State Journal Events и BTC/ETH Relative Strength Layer
|
||||||
|
|
||||||
### 07.4.4
|
### 07.4.4
|
||||||
⏳ Grid Strategy
|
⏳ Grid Strategy
|
||||||
|
|
||||||
|
|||||||
@@ -372,6 +372,23 @@
|
|||||||
- централизован EVENT_TITLES mapping
|
- централизован EVENT_TITLES mapping
|
||||||
- журнал подготовлен к filters/search layer
|
- журнал подготовлен к filters/search layer
|
||||||
|
|
||||||
|
#### 07.4.4.1.1 ✅ Market State Human UI + HOLD Lifecycle Fix
|
||||||
|
- добавлено короткое human-readable отображение состояния рынка
|
||||||
|
- технические market_state значения скрыты из основного Auto UI
|
||||||
|
- убраны `trend=...` и `volatility=...` из Telegram-экрана
|
||||||
|
- убран timeframe анализа из основного UI как лишняя техническая деталь
|
||||||
|
- добавлены UI-состояния `📈 Рынок · Рост`, `📉 Рынок · Падение`, `🟰 Рынок · Флэт`, `⚠️ Рынок · Волатильность`, `⏳ Рынок · Анализ`
|
||||||
|
- подтверждена работа REST klines как основы аналитики рынка
|
||||||
|
- Market State Engine переведён на анализ свечей OHLCV
|
||||||
|
- добавлены EMA20 / EMA50 для определения направления тренда
|
||||||
|
- добавлен ATR для оценки волатильности рынка
|
||||||
|
- добавлен RSI в аналитический payload
|
||||||
|
- TREND-стратегия стала market-aware и использует состояние рынка как фильтр входа
|
||||||
|
- market analysis синхронизируется в AutoTradeState
|
||||||
|
- исправлен баг ложного завершения `HOLD → HOLD`
|
||||||
|
- HOLD summary теперь пишется только при реальной смене сигнала
|
||||||
|
- этап подготовил основу для Market State Journal Events и BTC/ETH Relative Strength Layer
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 07.4.4
|
### 07.4.4
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# 07.4.4.1.1 — Market State Human UI + HOLD Lifecycle Fix
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
Этап завершён.
|
||||||
|
|
||||||
|
## Цель этапа
|
||||||
|
|
||||||
|
Цель этапа — довести первый рабочий слой Market State Engine до понятного пользовательского отображения в Auto UI, исправить некорректное журналирование HOLD-серий и подготовить аналитику рынка к дальнейшему использованию в стратегии.
|
||||||
|
|
||||||
|
После этапа 07.4.4.1 в системе уже появился базовый Market State Engine:
|
||||||
|
|
||||||
|
- загрузка свечей через REST `/api/v2/klines`
|
||||||
|
- анализ 200 свечей
|
||||||
|
- поддержка таймфреймов 1m / 5m / 15m
|
||||||
|
- расчёт EMA / ATR / RSI
|
||||||
|
- определение состояния рынка
|
||||||
|
- проброс market analysis в стратегию TREND
|
||||||
|
- блокировка входов при неподходящем рынке
|
||||||
|
|
||||||
|
Но после первичной интеграции оставались проблемы:
|
||||||
|
|
||||||
|
- технический вид строки рынка в Auto UI
|
||||||
|
- отображение `UNKNOWN`, `trend=UNKNOWN`, `volatility=UNKNOWN`
|
||||||
|
- слишком длинный формат market state в Telegram
|
||||||
|
- лишний технический таймфрейм в основном UI
|
||||||
|
- некорректное журналирование `HOLD → HOLD`
|
||||||
|
- ложные события вида `HOLD 37с завершён сигналом HOLD`
|
||||||
|
|
||||||
|
Этап 07.4.4.1.1 устраняет эти проблемы и делает аналитику рынка пригодной для ежедневного наблюдения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что изменено
|
||||||
|
|
||||||
|
### 1. Market State Engine переведён в human-readable UI
|
||||||
|
|
||||||
|
Техническое отображение рынка было заменено на короткий пользовательский формат.
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
- `Рынок · TREND_UP · trend=UP · volatility=NORMAL · 5m`
|
||||||
|
- `Рынок · UNKNOWN · trend=UNKNOWN · volatility=UNKNOWN · 5m`
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
- `📈 Рынок · Рост`
|
||||||
|
- `📉 Рынок · Падение`
|
||||||
|
- `🟰 Рынок · Флэт`
|
||||||
|
- `⚠️ Рынок · Волатильность`
|
||||||
|
- `⏳ Рынок · Анализ`
|
||||||
|
|
||||||
|
Это делает экран автоторговли понятным без знания внутренних enum-значений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Из основного UI убран технический timeframe
|
||||||
|
|
||||||
|
Таймфрейм анализа, например `5m`, оставлен во внутреннем состоянии и payload, но убран из основного Telegram UI.
|
||||||
|
|
||||||
|
Причина:
|
||||||
|
|
||||||
|
- для стратегии timeframe важен
|
||||||
|
- для пользователя в основном экране он перегружает интерфейс
|
||||||
|
- новичку важнее понять итог: рынок растёт, падает, во флэте или опасен
|
||||||
|
|
||||||
|
Теперь основной Auto UI показывает торговый смысл, а не внутренние настройки движка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Добавлена короткая интерпретация состояний рынка
|
||||||
|
|
||||||
|
Состояния Market State Engine теперь отображаются так:
|
||||||
|
|
||||||
|
| Внутреннее состояние | UI-отображение | Смысл |
|
||||||
|
|---|---|---|
|
||||||
|
| `TREND_UP` | `📈 Рынок · Рост` | Рынок находится в восходящем тренде |
|
||||||
|
| `TREND_DOWN` | `📉 Рынок · Падение` | Рынок находится в нисходящем тренде |
|
||||||
|
| `RANGE` | `🟰 Рынок · Флэт` | Рынок движется боком, тренда нет |
|
||||||
|
| `HIGH_VOLATILITY` | `⚠️ Рынок · Волатильность` | Рынок слишком резкий и опасный для входа |
|
||||||
|
| `LOW_VOLATILITY` | `🟰 Рынок · Спокойный` | Рынок слишком спокойный, импульса мало |
|
||||||
|
| `UNKNOWN` | `⏳ Рынок · Анализ` | Данных ещё мало или анализ временно не определён |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что было сделано в части аналитики
|
||||||
|
|
||||||
|
### 1. REST-свечи стали основой анализа рынка
|
||||||
|
|
||||||
|
До этого стратегия TREND работала в основном по короткому live-окну цен.
|
||||||
|
|
||||||
|
Теперь аналитический слой использует свечи биржи:
|
||||||
|
|
||||||
|
- open
|
||||||
|
- high
|
||||||
|
- low
|
||||||
|
- close
|
||||||
|
- volume
|
||||||
|
|
||||||
|
Простыми словами, свеча показывает, как цена вела себя за выбранный период: где открылась, куда поднималась, куда падала, где закрылась и какой был объём торгов.
|
||||||
|
|
||||||
|
Для начального анализа используются:
|
||||||
|
|
||||||
|
- `1m`
|
||||||
|
- `5m`
|
||||||
|
- `15m`
|
||||||
|
|
||||||
|
В текущей стратегии основным рабочим таймфреймом выбран `5m`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Добавлен расчёт EMA
|
||||||
|
|
||||||
|
EMA — это средняя цена, которая сильнее учитывает последние значения.
|
||||||
|
|
||||||
|
В системе используются:
|
||||||
|
|
||||||
|
- `EMA20` — более быстрая средняя
|
||||||
|
- `EMA50` — более медленная средняя
|
||||||
|
|
||||||
|
Логика простая:
|
||||||
|
|
||||||
|
- если EMA20 выше EMA50 — рынок склоняется к росту
|
||||||
|
- если EMA20 ниже EMA50 — рынок склоняется к падению
|
||||||
|
- если расстояние между EMA маленькое — рынок во флэте
|
||||||
|
|
||||||
|
EMA помогает отличать настоящий тренд от случайного движения цены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Добавлен расчёт ATR
|
||||||
|
|
||||||
|
ATR — это показатель волатильности.
|
||||||
|
|
||||||
|
Простыми словами, ATR показывает, насколько сильно рынок обычно двигается за свечу.
|
||||||
|
|
||||||
|
Если ATR высокий:
|
||||||
|
|
||||||
|
- рынок может резко прыгать
|
||||||
|
- входы становятся опаснее
|
||||||
|
- стопы может выбивать случайным шумом
|
||||||
|
|
||||||
|
Если ATR слишком низкий:
|
||||||
|
|
||||||
|
- рынок почти не движется
|
||||||
|
- сигнал может быть слабым
|
||||||
|
- сделка может долго стоять без результата
|
||||||
|
|
||||||
|
На основе ATR система определяет:
|
||||||
|
|
||||||
|
- нормальную волатильность
|
||||||
|
- слишком высокую волатильность
|
||||||
|
- слишком низкую волатильность
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Добавлен расчёт RSI
|
||||||
|
|
||||||
|
RSI — это индикатор силы движения.
|
||||||
|
|
||||||
|
Простыми словами, он помогает понять, не перегрет ли рынок.
|
||||||
|
|
||||||
|
RSI пока используется как часть аналитического payload и объяснения, но не является главным фильтром входа.
|
||||||
|
|
||||||
|
Это подготовка к следующим версиям стратегии, где RSI можно будет использовать для:
|
||||||
|
|
||||||
|
- фильтрации поздних входов
|
||||||
|
- защиты от покупки на перегретом росте
|
||||||
|
- защиты от продажи после слишком сильного падения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Добавлена классификация состояния рынка
|
||||||
|
|
||||||
|
Market State Engine теперь классифицирует рынок в понятные состояния:
|
||||||
|
|
||||||
|
- `TREND_UP`
|
||||||
|
- `TREND_DOWN`
|
||||||
|
- `RANGE`
|
||||||
|
- `HIGH_VOLATILITY`
|
||||||
|
- `LOW_VOLATILITY`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
Это важный переход от простой реакции на цену к анализу структуры рынка.
|
||||||
|
|
||||||
|
Раньше стратегия могла видеть только:
|
||||||
|
|
||||||
|
- цена немного выросла
|
||||||
|
- цена немного упала
|
||||||
|
|
||||||
|
Теперь стратегия получает контекст:
|
||||||
|
|
||||||
|
- рынок действительно растёт
|
||||||
|
- рынок действительно падает
|
||||||
|
- рынок во флэте
|
||||||
|
- рынок слишком резкий
|
||||||
|
- рынок слишком спокойный
|
||||||
|
- данных пока недостаточно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. TREND-стратегия стала market-aware
|
||||||
|
|
||||||
|
TREND теперь использует Market State Engine как фильтр.
|
||||||
|
|
||||||
|
Сигнал BUY возможен только если:
|
||||||
|
|
||||||
|
- Market State Engine видит `TREND_UP`
|
||||||
|
- live-импульс подтверждает движение вверх
|
||||||
|
|
||||||
|
Сигнал SELL возможен только если:
|
||||||
|
|
||||||
|
- Market State Engine видит `TREND_DOWN`
|
||||||
|
- live-импульс подтверждает движение вниз
|
||||||
|
|
||||||
|
Если рынок:
|
||||||
|
|
||||||
|
- `RANGE`
|
||||||
|
- `HIGH_VOLATILITY`
|
||||||
|
- `LOW_VOLATILITY`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
стратегия возвращает HOLD.
|
||||||
|
|
||||||
|
Это снижает количество случайных входов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Market analysis сохраняется в AutoTradeState
|
||||||
|
|
||||||
|
В состояние автоторговли добавлены поля:
|
||||||
|
|
||||||
|
- `market_state`
|
||||||
|
- `market_trend`
|
||||||
|
- `market_volatility`
|
||||||
|
- `market_analysis_interval`
|
||||||
|
- `market_analysis_reason`
|
||||||
|
|
||||||
|
Теперь UI, журнал и будущие стратегии могут использовать последнее рассчитанное состояние рынка.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Исправление HOLD lifecycle bug
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
В журнал попадали события вида:
|
||||||
|
|
||||||
|
- `[DEMO] 🟡 HOLD 37с завершён сигналом HOLD.`
|
||||||
|
|
||||||
|
Это было некорректно.
|
||||||
|
|
||||||
|
Если сигнал был HOLD и следующим сигналом снова стал HOLD, это не завершение серии.
|
||||||
|
|
||||||
|
Правильное завершение должно быть только при реальной смене:
|
||||||
|
|
||||||
|
- `HOLD → BUY`
|
||||||
|
- `HOLD → SELL`
|
||||||
|
- `BUY → HOLD`
|
||||||
|
- `SELL → HOLD`
|
||||||
|
- `BUY → SELL`
|
||||||
|
- `SELL → BUY`
|
||||||
|
|
||||||
|
Но не:
|
||||||
|
|
||||||
|
- `HOLD → HOLD`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Причина
|
||||||
|
|
||||||
|
Внутренний `signal_key` включал не только сам сигнал, но и `reason`.
|
||||||
|
|
||||||
|
Из-за этого один и тот же HOLD мог считаться новым сигналом, если изменилась причина.
|
||||||
|
|
||||||
|
Было логически так:
|
||||||
|
|
||||||
|
- HOLD с причиной A
|
||||||
|
- HOLD с причиной B
|
||||||
|
|
||||||
|
Система воспринимала это как смену сигнала, хотя направление не изменилось.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Исправление
|
||||||
|
|
||||||
|
`signal_key` был упрощён до устойчивой идентичности сигнала:
|
||||||
|
|
||||||
|
- status
|
||||||
|
- symbol
|
||||||
|
- strategy
|
||||||
|
- signal
|
||||||
|
|
||||||
|
Причина сигнала теперь обновляется внутри текущей серии, но не разрывает её.
|
||||||
|
|
||||||
|
Дополнительно добавлена проверка:
|
||||||
|
|
||||||
|
- summary пишется только если `previous_signal != signal`
|
||||||
|
|
||||||
|
Теперь `HOLD → HOLD` не создаёт ложную запись в журнале.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что больше не должно появляться
|
||||||
|
|
||||||
|
После этапа в журнале больше не должно быть:
|
||||||
|
|
||||||
|
- `HOLD завершён сигналом HOLD`
|
||||||
|
- ложных HOLD summary при изменении причины
|
||||||
|
- технических строк `trend=UNKNOWN`
|
||||||
|
- технических строк `volatility=UNKNOWN`
|
||||||
|
- длинного market-state формата в основном Auto UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что остаётся в системе
|
||||||
|
|
||||||
|
Market State Engine продолжает сохранять технические данные внутри payload:
|
||||||
|
|
||||||
|
- market_state
|
||||||
|
- trend
|
||||||
|
- volatility
|
||||||
|
- interval
|
||||||
|
- EMA20
|
||||||
|
- EMA50
|
||||||
|
- ATR
|
||||||
|
- ATR percent
|
||||||
|
- RSI
|
||||||
|
- candles count
|
||||||
|
- reason
|
||||||
|
|
||||||
|
Но в основном UI показывается только короткий человекочитаемый итог.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Основные изменённые файлы
|
||||||
|
|
||||||
|
- app/src/telegram/handlers/auto/ui.py
|
||||||
|
- app/src/trading/auto/service.py
|
||||||
|
- app/src/trading/auto/state.py
|
||||||
|
- app/src/trading/strategies/trend.py
|
||||||
|
- app/src/trading/market_analysis/models.py
|
||||||
|
- app/src/trading/market_analysis/indicators.py
|
||||||
|
- app/src/trading/market_analysis/service.py
|
||||||
|
- app/src/integrations/exchange/models.py
|
||||||
|
- app/src/integrations/exchange/service.py
|
||||||
|
- app/src/integrations/exchange/rest_client.py
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
После правок необходимо выполнить:
|
||||||
|
|
||||||
|
python -m compileall src
|
||||||
|
python -m src.main
|
||||||
|
|
||||||
|
После запуска проверить:
|
||||||
|
|
||||||
|
1. Автоторговля запускается без ошибок.
|
||||||
|
2. В Auto UI отображается короткая строка рынка:
|
||||||
|
- `📈 Рынок · Рост`
|
||||||
|
- `📉 Рынок · Падение`
|
||||||
|
- `🟰 Рынок · Флэт`
|
||||||
|
- `⚠️ Рынок · Волатильность`
|
||||||
|
- `⏳ Рынок · Анализ`
|
||||||
|
3. В UI больше нет технических строк `trend=...` и `volatility=...`.
|
||||||
|
4. В UI больше не показывается `5m` как техническая деталь.
|
||||||
|
5. При `TREND_UP` стратегия может готовить BUY только после live-подтверждения.
|
||||||
|
6. При `TREND_DOWN` стратегия может готовить SELL только после live-подтверждения.
|
||||||
|
7. При `RANGE`, `HIGH_VOLATILITY`, `LOW_VOLATILITY`, `UNKNOWN` стратегия остаётся в HOLD.
|
||||||
|
8. В журнале больше не появляются события `HOLD завершён сигналом HOLD`.
|
||||||
|
9. HOLD-серия логируется только при переходе к другому сигналу.
|
||||||
|
10. Execution logic не менялась и продолжает работать как раньше.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Этап 07.4.4.1.1 завершил первичную пользовательскую доводку Market State Engine.
|
||||||
|
|
||||||
|
Система получила:
|
||||||
|
|
||||||
|
- понятное отображение состояния рынка
|
||||||
|
- скрытие технических деталей из основного UI
|
||||||
|
- стабильный lifecycle HOLD-сигналов
|
||||||
|
- исправление ложного журналирования HOLD-серий
|
||||||
|
- полноценную базу аналитики на свечах
|
||||||
|
- подготовку к следующему этапу market-state journal events и дальнейшей стратегии BTC/ETH.
|
||||||
|
|
||||||
|
Это важный переход от простого signal bot к market-aware trading system.
|
||||||
Reference in New Issue
Block a user