Initial TradeBot implementation
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user