diff --git a/README.md b/README.md index d8536e7..e4f6084 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Dashboard: --epochs 60 ``` -Новый artifact версии 3 обучается как multifeature direct-horizon модель: вход `input_size=14` включает доходности, форму свечи, объем, ATR%, RSI, MACD histogram и расстояние до EMA50/EMA200; цель обучается сразу на горизонт `TIME_SERIES_FORECAST_HORIZON`, без умножения one-step прогноза. +Новый artifact версии 3 обучается как multifeature direct-horizon модель: вход `input_size=26` включает доходности, форму свечи, объем, ATR%, RSI, MACD histogram, расстояние до EMA50/EMA200 и числовые признаки текущего шаблона пары: score, bullish/bearish/range, pullback, reversal, stabilized drop, breakout/breakdown, fast drop, volume spike и позицию цены в 20-свечном диапазоне. Цель обучается сразу на горизонт `TIME_SERIES_FORECAST_HORIZON`, без умножения one-step прогноза. Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py index 814b9c0..4b1a2f9 100644 --- a/crypto_spot_bot/time_series.py +++ b/crypto_spot_bot/time_series.py @@ -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