Add pattern features to Torch forecaster

This commit is contained in:
Codex
2026-06-22 08:14:42 +03:00
parent 42f96f0a39
commit 680475948b
2 changed files with 200 additions and 1 deletions
+199
View File
@@ -24,6 +24,18 @@ DEFAULT_TORCH_FEATURES = (
"macd_hist_percent",
"ema50_gap_percent",
"ema200_gap_percent",
"pattern_score",
"pattern_bullish",
"pattern_bearish",
"pattern_range",
"pattern_pullback",
"pattern_oversold_reversal",
"pattern_stabilized_drop",
"pattern_breakout",
"pattern_breakdown",
"pattern_fast_drop",
"pattern_volume_spike",
"pattern_range_position_20",
)
@@ -220,9 +232,196 @@ def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle)
return _safe_feature((candle.close - candle.ema_50) / close) if candle.ema_50 is not None else 0.0
if name == "ema200_gap_percent":
return _safe_feature((candle.close - candle.ema_200) / close) if candle.ema_200 is not None else 0.0
if name.startswith("pattern_"):
return _pattern_feature_value(name, candles, index)
return 0.0
def _pattern_feature_value(name: str, candles: list[Candle], index: int) -> float:
pattern = _pattern_snapshot(candles, index)
if name == "pattern_score":
return pattern["score"]
if name == "pattern_bullish":
return pattern["bullish"]
if name == "pattern_bearish":
return pattern["bearish"]
if name == "pattern_range":
return pattern["range"]
if name == "pattern_pullback":
return pattern["pullback"]
if name == "pattern_oversold_reversal":
return pattern["oversold_reversal"]
if name == "pattern_stabilized_drop":
return pattern["stabilized_drop"]
if name == "pattern_breakout":
return pattern["breakout"]
if name == "pattern_breakdown":
return pattern["breakdown"]
if name == "pattern_fast_drop":
return pattern["fast_drop"]
if name == "pattern_volume_spike":
return pattern["volume_spike"]
if name == "pattern_range_position_20":
return pattern["range_position_20"]
return 0.0
def _pattern_snapshot(candles: list[Candle], index: int) -> dict[str, float]:
if index < 29:
return {
"score": 0.0,
"bullish": 0.0,
"bearish": 0.0,
"range": 0.0,
"pullback": 0.0,
"oversold_reversal": 0.0,
"stabilized_drop": 0.0,
"breakout": 0.0,
"breakdown": 0.0,
"fast_drop": 0.0,
"volume_spike": 0.0,
"range_position_20": 0.5,
}
window = candles[: index + 1]
latest = window[-1]
previous = window[-2]
high20 = max(candle.high for candle in window[-20:])
low20 = min(candle.low for candle in window[-20:])
width20 = max(0.0, high20 - low20)
range_position_20 = _clamp((latest.close - low20) / width20, 0.0, 1.0) if width20 else 0.5
close_3 = window[-4].close if len(window) >= 4 else window[0].close
close_10 = window[-11].close if len(window) >= 11 else window[0].close
close_20 = window[-21].close if len(window) >= 21 else window[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)
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
)
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 = _pattern_stabilized_drop(
candles=window,
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 = bool(volume_ratio >= 1.6)
score = 0.50
if fast_drop and breakdown:
score = 0.18
elif breakdown:
score = 0.24
elif pullback:
score = 0.76
elif oversold_reversal:
score = 0.68
elif stabilized_drop:
score = 0.58
elif breakout:
score = 0.72
elif uptrend:
score = 0.64
elif range_market:
score = 0.48
elif downtrend:
score = 0.28
bullish = float(pullback or oversold_reversal or stabilized_drop or breakout or uptrend)
bearish = float((fast_drop and breakdown) or breakdown or downtrend)
return {
"score": score,
"bullish": bullish,
"bearish": bearish,
"range": float(range_market),
"pullback": float(pullback),
"oversold_reversal": float(oversold_reversal),
"stabilized_drop": float(stabilized_drop),
"breakout": float(breakout),
"breakdown": float(breakdown),
"fast_drop": float(fast_drop),
"volume_spike": float(volume_spike),
"range_position_20": range_position_20,
}
def _percent_change(current: float, previous: float) -> float:
return ((current - previous) / previous * 100) if previous else 0.0
def _pattern_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)
def _log_change(current: float, previous: float) -> float:
if current <= 0 or previous <= 0:
return 0.0