230 lines
9.2 KiB
Python
230 lines
9.2 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, field
|
|
from typing import Any
|
|
|
|
from crypto_spot_bot.models import Candle, Ticker
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PatternResult:
|
|
label: str
|
|
score: float
|
|
description: str
|
|
tags: list[str] = field(default_factory=list)
|
|
metrics: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
class PatternAnalyzer:
|
|
def analyze(self, candles: list[Candle], ticker: Ticker | None = None) -> PatternResult:
|
|
if len(candles) < 30:
|
|
return PatternResult(
|
|
label="мало данных",
|
|
score=0.0,
|
|
description="Недостаточно свечей для анализа шаблонов.",
|
|
tags=["мало данных"],
|
|
)
|
|
|
|
latest = candles[-1]
|
|
previous = candles[-2]
|
|
high20 = max(candle.high for candle in candles[-20:])
|
|
low20 = min(candle.low for candle in candles[-20:])
|
|
close_3 = candles[-4].close if len(candles) >= 4 else candles[0].close
|
|
close_10 = candles[-11].close if len(candles) >= 11 else candles[0].close
|
|
close_20 = candles[-21].close if len(candles) >= 21 else candles[0].close
|
|
ret_3 = _percent_change(latest.close, close_3)
|
|
ret_10 = _percent_change(latest.close, close_10)
|
|
ret_20 = _percent_change(latest.close, close_20)
|
|
body = abs(latest.close - latest.open)
|
|
lower_wick = max(0.0, min(latest.open, latest.close) - latest.low)
|
|
upper_wick = max(0.0, latest.high - max(latest.open, latest.close))
|
|
atr_percent = (latest.atr_14 / latest.close * 100) if latest.atr_14 and latest.close else 0.0
|
|
volume_ratio = (
|
|
latest.volume / latest.volume_ma_20
|
|
if latest.volume_ma_20 and latest.volume_ma_20 > 0
|
|
else 0.0
|
|
)
|
|
ema_gap_percent = (
|
|
(latest.ema_50 - latest.ema_200) / latest.ema_200 * 100
|
|
if latest.ema_50 and latest.ema_200
|
|
else 0.0
|
|
)
|
|
spread_percent = ticker.spread_percent if ticker else 0.0
|
|
|
|
uptrend = bool(
|
|
latest.ema_20
|
|
and latest.ema_50
|
|
and latest.ema_200
|
|
and latest.ema_20 >= latest.ema_50 >= latest.ema_200
|
|
and latest.close >= latest.ema_50
|
|
)
|
|
downtrend = bool(
|
|
latest.ema_20
|
|
and latest.ema_50
|
|
and latest.ema_200
|
|
and latest.ema_20 <= latest.ema_50 <= latest.ema_200
|
|
and latest.close <= latest.ema_50
|
|
)
|
|
pullback = bool(
|
|
latest.ema_20
|
|
and uptrend
|
|
and latest.close <= latest.ema_20 * 1.012
|
|
and latest.rsi_14 is not None
|
|
and 35 <= latest.rsi_14 <= 58
|
|
)
|
|
oversold_reversal = bool(
|
|
latest.rsi_14 is not None
|
|
and latest.rsi_14 <= 35
|
|
and latest.close > previous.close
|
|
and lower_wick >= body * 1.2
|
|
)
|
|
stabilized_drop = _stabilized_drop(
|
|
candles=candles,
|
|
latest=latest,
|
|
previous=previous,
|
|
ret_3=ret_3,
|
|
ret_10=ret_10,
|
|
ret_20=ret_20,
|
|
atr_percent=atr_percent,
|
|
volume_ratio=volume_ratio,
|
|
lower_wick=lower_wick,
|
|
body=body,
|
|
)
|
|
breakout = bool(
|
|
latest.close >= high20 * 0.995
|
|
and volume_ratio >= 1.15
|
|
and latest.close > latest.open
|
|
)
|
|
breakdown = bool(
|
|
latest.close <= low20 * 1.005
|
|
and volume_ratio >= 1.1
|
|
and latest.close < latest.open
|
|
)
|
|
fast_drop = bool(ret_3 <= -max(1.2, atr_percent * 1.8) or (latest.rsi_14 or 100) <= 25)
|
|
range_market = bool(abs(ret_20) <= max(0.8, atr_percent * 1.2) and abs(ema_gap_percent) <= 0.35)
|
|
volume_spike = volume_ratio >= 1.6
|
|
|
|
tags: list[str] = []
|
|
if uptrend:
|
|
tags.append("восходящий тренд")
|
|
if downtrend:
|
|
tags.append("нисходящий тренд")
|
|
if pullback:
|
|
tags.append("откат к средней")
|
|
if oversold_reversal:
|
|
tags.append("перепроданность с разворотом")
|
|
if stabilized_drop:
|
|
tags.append("стабилизация после падения")
|
|
if breakout:
|
|
tags.append("пробой")
|
|
if breakdown:
|
|
tags.append("пробой вниз")
|
|
if fast_drop:
|
|
tags.append("ускоренное падение")
|
|
if range_market:
|
|
tags.append("боковик")
|
|
if volume_spike:
|
|
tags.append("объемный всплеск")
|
|
|
|
label, score, description = _classify(
|
|
pullback=pullback,
|
|
oversold_reversal=oversold_reversal,
|
|
stabilized_drop=stabilized_drop,
|
|
breakout=breakout,
|
|
breakdown=breakdown,
|
|
fast_drop=fast_drop,
|
|
range_market=range_market,
|
|
uptrend=uptrend,
|
|
downtrend=downtrend,
|
|
)
|
|
metrics = {
|
|
"ret_3_percent": ret_3,
|
|
"ret_10_percent": ret_10,
|
|
"ret_20_percent": ret_20,
|
|
"atr_percent": atr_percent,
|
|
"volume_ratio": volume_ratio,
|
|
"ema_gap_percent": ema_gap_percent,
|
|
"spread_percent": spread_percent,
|
|
"rsi_14": latest.rsi_14,
|
|
"high20": high20,
|
|
"low20": low20,
|
|
"body": body,
|
|
"lower_wick": lower_wick,
|
|
"upper_wick": upper_wick,
|
|
"stabilized_drop": stabilized_drop,
|
|
}
|
|
return PatternResult(
|
|
label=label,
|
|
score=round(score, 4),
|
|
description=description,
|
|
tags=tags or ["нейтрально"],
|
|
metrics=metrics,
|
|
)
|
|
|
|
|
|
def _classify(
|
|
*,
|
|
pullback: bool,
|
|
oversold_reversal: bool,
|
|
stabilized_drop: bool,
|
|
breakout: bool,
|
|
breakdown: bool,
|
|
fast_drop: bool,
|
|
range_market: bool,
|
|
uptrend: bool,
|
|
downtrend: bool,
|
|
) -> tuple[str, float, str]:
|
|
if fast_drop and breakdown:
|
|
return "ускоренное падение", 0.18, "Цена быстро падает на повышенном объеме; входы ограничиваются."
|
|
if breakdown:
|
|
return "пробой вниз", 0.24, "Цена у нижней границы диапазона с давлением продавцов."
|
|
if pullback:
|
|
return "трендовый откат", 0.76, "Восходящий тренд сохраняется, цена откатилась к средней."
|
|
if oversold_reversal:
|
|
return "разворот после перепроданности", 0.68, "RSI низкий, но последняя свеча показывает попытку разворота."
|
|
if stabilized_drop:
|
|
return "стабилизация после падения", 0.58, "После снижения падение замедлилось; возможен короткий отскок."
|
|
if breakout:
|
|
return "пробой вверх", 0.72, "Цена обновляет верхнюю область диапазона с подтверждением объемом."
|
|
if uptrend:
|
|
return "восходящее продолжение", 0.64, "EMA и цена подтверждают восходящее продолжение."
|
|
if range_market:
|
|
return "боковик", 0.48, "Цена движется в диапазоне без сильного направления."
|
|
if downtrend:
|
|
return "нисходящий тренд", 0.28, "EMA и цена показывают нисходящее направление."
|
|
return "нейтрально", 0.50, "Сильного шаблона входа не обнаружено."
|
|
|
|
|
|
def _percent_change(current: float, previous: float) -> float:
|
|
return ((current - previous) / previous * 100) if previous else 0.0
|
|
|
|
|
|
def _stabilized_drop(
|
|
*,
|
|
candles: list[Candle],
|
|
latest: Candle,
|
|
previous: Candle,
|
|
ret_3: float,
|
|
ret_10: float,
|
|
ret_20: float,
|
|
atr_percent: float,
|
|
volume_ratio: float,
|
|
lower_wick: float,
|
|
body: float,
|
|
) -> bool:
|
|
recent_drop = ret_10 <= -max(0.35, atr_percent * 1.1) or ret_20 <= -max(0.6, atr_percent * 1.6)
|
|
if not recent_drop or latest.rsi_14 is None or latest.rsi_14 > 52:
|
|
return False
|
|
recent_lows = [candle.low for candle in candles[-5:-1]]
|
|
no_new_low = bool(recent_lows) and latest.low >= min(recent_lows) * 0.999
|
|
bounce_from_low = ((latest.close - min(candle.low for candle in candles[-6:])) / latest.close * 100) if latest.close else 0.0
|
|
body_base = max(body, latest.close * 0.0001)
|
|
absorption = lower_wick >= body_base * 0.6 or bounce_from_low >= max(0.08, atr_percent * 0.3)
|
|
momentum_stabilized = latest.close >= previous.close or abs(ret_3) <= max(0.25, atr_percent * 0.8) or no_new_low
|
|
volume_present = volume_ratio >= 0.55
|
|
continuing_drop = latest.close < previous.close and not no_new_low and ret_3 <= -max(0.6, atr_percent * 1.2)
|
|
return bool(momentum_stabilized and absorption and volume_present and not continuing_drop)
|