"""Motore segnali: squeeze detection + ML confirmation su dati live.""" from __future__ import annotations import numpy as np import pandas as pd from sklearn.ensemble import GradientBoostingClassifier from sklearn.preprocessing import StandardScaler def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray, window: int = 14) -> np.ndarray: n = len(close) result = np.full(n, np.nan) for i in range(window, n): wc = close[i - window : i] wh = high[i - window : i] wl = low[i - window : i] ma = np.mean(wc) bb_std = np.std(wc) tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1)))) atr = np.mean(tr[1:]) kc_r = (ma + 1.5 * atr) - (ma - 1.5 * atr) bb_r = (ma + 2 * bb_std) - (ma - 2 * bb_std) if kc_r > 0: result[i] = bb_r / kc_r return result def build_features(df: pd.DataFrame, i: int, squeeze_duration: int, squeeze_avg_vol: float, kcr_val: float) -> np.ndarray | None: if i < 100 or i >= len(df): return None o = df["open"].values h = df["high"].values l = df["low"].values c = df["close"].values v = df["volume"].values feats = [] for w in [12, 24, 48]: if i < w: feats.extend([0] * 12) continue win_c = c[i - w : i] win_o = o[i - w : i] win_h = h[i - w : i] win_l = l[i - w : i] win_v = v[i - w : i] mn, mx = win_l.min(), max(win_h.max(), win_c.max()) rng = mx - mn if mx - mn > 0 else 1e-10 total = win_h - win_l total = np.where(total == 0, 1e-10, total) body = np.abs(win_c - win_o) / total direction = np.sign(win_c - win_o) log_c = np.log(np.where(win_c == 0, 1e-10, win_c)) rets = np.diff(log_c) v_mean = np.mean(win_v) feats.extend([ np.mean(rets) if len(rets) > 0 else 0, np.std(rets) if len(rets) > 0 else 0, np.sum(rets) if len(rets) > 0 else 0, float(pd.Series(rets).skew()) if len(rets) > 2 else 0, float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0, np.mean(body), np.std(body), np.mean(direction), np.mean(direction[-min(3, w):]), (win_c[-1] - mn) / rng, win_v[-1] / v_mean if v_mean > 0 else 1, np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0, ]) feats.extend([ squeeze_duration, squeeze_duration / (24 * 4), kcr_val, v[i - 1] / squeeze_avg_vol if squeeze_avg_vol > 0 else 1, np.mean(v[max(0, i - 3) : i]) / squeeze_avg_vol if squeeze_avg_vol > 0 else 1, ]) h48 = np.max(h[max(0, i - 48) : i]) l48 = np.min(l[max(0, i - 48) : i]) r48 = h48 - l48 feats.append((c[i - 1] - l48) / r48 if r48 > 0 else 0.5) tr = np.maximum(h[i - 14 : i] - l[i - 14 : i], np.maximum(np.abs(h[i - 14 : i] - np.roll(c[i - 14 : i], 1)), np.abs(l[i - 14 : i] - np.roll(c[i - 14 : i], 1)))) atr = np.mean(tr[1:]) feats.append(atr / c[i - 1] if c[i - 1] > 0 else 0) first_ret = (c[i - 1] - c[i - 2]) / c[i - 2] if i >= 2 and c[i - 2] > 0 else 0 feats.append(first_ret) return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6) class SignalEngine: """Rileva squeeze e genera segnali ML in real-time.""" def __init__(self, bb_w: int = 14, sq_thr: float = 0.8, ml_thr: float = 0.70, min_squeeze_bars: int = 5): self.bb_w = bb_w self.sq_thr = sq_thr self.ml_thr = ml_thr self.min_squeeze_bars = min_squeeze_bars self.model: GradientBoostingClassifier | None = None self.scaler: StandardScaler | None = None self.in_squeeze = False self.squeeze_start_idx = 0 self.trained = False def _new_model(self) -> GradientBoostingClassifier: return GradientBoostingClassifier( n_estimators=150, max_depth=4, min_samples_leaf=10, learning_rate=0.05, subsample=0.8, random_state=42, ) def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict: """Split temporale (no shuffle) per stimare la performance out-of-sample. Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni. Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con confidenza >= ml_thr — cioè i trade che la strategia aprirebbe davvero. """ n_test = int(len(X) * test_frac) n_train = len(X) - n_test if n_train < 30 or n_test < 5: return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test} scaler = StandardScaler() X_tr = scaler.fit_transform(X[:n_train]) X_te = scaler.transform(X[n_train:]) y_tr, y_te = y[:n_train], y[n_train:] model = self._new_model() model.fit(X_tr, y_tr) up_idx = list(model.classes_).index(1) p_up = model.predict_proba(X_te)[:, up_idx] test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100) oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100) long_sig = p_up >= self.ml_thr short_sig = p_up <= (1 - self.ml_thr) n_sig = int((long_sig | short_sig).sum()) if n_sig > 0: correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum()) sig_prec = round(correct / n_sig * 100, 1) else: sig_prec = None return { "oos_train_accuracy": round(oos_train_acc, 1), "oos_test_accuracy": round(test_acc, 1), "oos_test_samples": n_test, "oos_signals": n_sig, "oos_signal_precision": sig_prec, } def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict: """Addestra il modello su dati storici.""" close = df["close"].values high = df["high"].values low = df["low"].values volume = df["volume"].values n = len(df) kcr = keltner_ratio(close, high, low, self.bb_w) X_all, y_all = [], [] in_sq = False sq_start = 0 for i in range(self.bb_w + 1, n - lookahead): if np.isnan(kcr[i]): continue is_sq = kcr[i] < self.sq_thr if is_sq and not in_sq: in_sq = True sq_start = i elif not is_sq and in_sq: in_sq = False duration = i - sq_start if duration < self.min_squeeze_bars: continue avg_vol = np.mean(volume[sq_start:i]) feats = build_features(df, i, duration, avg_vol, kcr[i]) if feats is None: continue actual = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1] X_all.append(feats) y_all.append(1 if actual > 0 else 0) if len(X_all) < 30: return {"error": "not enough training samples", "samples": len(X_all)} X = np.array(X_all) y = np.array(y_all) oos = self._validate_oos(X, y) self.scaler = StandardScaler() X_s = self.scaler.fit_transform(X) self.model = self._new_model() self.model.fit(X_s, y) self.trained = True preds = self.model.predict(X_s) train_acc = float(np.mean(preds == y) * 100) return { "samples": len(X), "up_ratio": round(float(np.mean(y) * 100), 1), "train_accuracy": round(train_acc, 1), **oos, } def check_signal(self, df: pd.DataFrame) -> dict | None: """Controlla se c'è un segnale sulle ultime candele. Ritorna dict con direzione e probabilità, oppure None. """ if not self.trained: return None close = df["close"].values high = df["high"].values low = df["low"].values volume = df["volume"].values n = len(df) kcr = keltner_ratio(close, high, low, self.bb_w) if n < self.bb_w + 10: return None last_kcr = kcr[-1] prev_kcr = kcr[-2] if n > 1 else np.nan if np.isnan(last_kcr) or np.isnan(prev_kcr): return None was_squeeze = prev_kcr < self.sq_thr is_released = last_kcr >= self.sq_thr if not (was_squeeze and is_released): self.in_squeeze = prev_kcr < self.sq_thr if self.in_squeeze and not hasattr(self, '_sq_start_tracking'): self._sq_start_tracking = n - 1 if not self.in_squeeze: self._sq_start_tracking = None return None sq_start = getattr(self, '_sq_start_tracking', n - 10) if sq_start is None: sq_start = n - 10 duration = (n - 1) - sq_start if duration < self.min_squeeze_bars: self._sq_start_tracking = None return None avg_vol = np.mean(volume[max(0, sq_start) : n - 1]) feats = build_features(df, n - 1, duration, avg_vol, last_kcr) self._sq_start_tracking = None if feats is None: return None feats_s = self.scaler.transform(feats.reshape(1, -1)) proba = self.model.predict_proba(feats_s)[0] up_idx = list(self.model.classes_).index(1) p_up = proba[up_idx] if p_up >= self.ml_thr: return {"direction": "buy", "probability": p_up, "squeeze_duration": duration} elif p_up <= (1 - self.ml_thr): return {"direction": "sell", "probability": 1 - p_up, "squeeze_duration": duration} return None