""" engine.py — Feature Engineering + Heavy XGBoost Engine + signal mapping. Features: RSI, MACD, ATR, VWAP distance, Bollinger position/width, EMA crossovers, volume delta, candle momentum, support/resistance distance, volatility compression, regime score. Model: XGBoost classifier trained per-symbol on the candle history. Label = does the close 4 candles ahead beat fees+buffer (BUY-worthy)? Outputs: probability_up, expected_return, downside_risk, confidence, market_regime, 4-candle forecast path, action (BUY/HOLD/SELL). """ import logging import numpy as np import pandas as pd log = logging.getLogger("engine") try: from xgboost import XGBClassifier HAVE_XGB = True except ImportError: # graceful degrade to logistic-style heuristic HAVE_XGB = False FEE_BUFFER_PCT = 0.5 # round-trip fees + slippage threshold, in % FORECAST_CANDLES = 4 FEATURES = [ "rsi", "macd_hist", "atr_pct", "vwap_dist", "bb_pos", "bb_width", "ema_cross", "vol_delta", "momentum", "sup_dist", "res_dist", "vol_compress", "regime_score", ] # ------------------------------------------------------------------ features def build_features(df: pd.DataFrame) -> pd.DataFrame: out = df.copy() c, h, l, v = out["Close"], out["High"], out["Low"], out["Volume"] # RSI(14) delta = c.diff() gain = delta.clip(lower=0).rolling(14).mean() loss = (-delta.clip(upper=0)).rolling(14).mean() out["rsi"] = 100 - 100 / (1 + gain / loss.replace(0, np.nan)) # MACD histogram ema12, ema26 = c.ewm(span=12).mean(), c.ewm(span=26).mean() macd = ema12 - ema26 out["macd_hist"] = (macd - macd.ewm(span=9).mean()) / c * 100 # ATR% (14) tr = pd.concat( [h - l, (h - c.shift()).abs(), (l - c.shift()).abs()], axis=1 ).max(axis=1) out["atr_pct"] = tr.rolling(14).mean() / c * 100 # VWAP distance (rolling 96 candles) tp = (h + l + c) / 3 vwap = (tp * v).rolling(96).sum() / v.rolling(96).sum() out["vwap"] = vwap out["vwap_dist"] = (c - vwap) / vwap * 100 # Bollinger (20, 2) mid = c.rolling(20).mean() sd = c.rolling(20).std() out["bb_pos"] = (c - mid) / (2 * sd) out["bb_width"] = 4 * sd / mid * 100 # EMA crossover state (9 vs 21) out["ema_cross"] = np.sign(c.ewm(span=9).mean() - c.ewm(span=21).mean()) # volume delta: up-candle vs down-candle volume balance (12 candles) up_v = v.where(c > c.shift(), 0.0).rolling(12).sum() dn_v = v.where(c <= c.shift(), 0.0).rolling(12).sum() out["vol_delta"] = (up_v - dn_v) / (up_v + dn_v).replace(0, np.nan) # candle momentum (3-candle return %) out["momentum"] = c.pct_change(3) * 100 # support / resistance distance (rolling 48-candle extremes) out["sup_dist"] = (c - l.rolling(48).min()) / c * 100 out["res_dist"] = (h.rolling(48).max() - c) / c * 100 # volatility compression: current bb_width vs its 96-candle mean out["vol_compress"] = out["bb_width"] / out["bb_width"].rolling(96).mean() # regime score: trend + flow composite in [-100, 100] trend = np.sign(c.ewm(span=21).mean().diff(8)) out["regime_score"] = ( 40 * trend + 30 * out["ema_cross"] + 30 * out["vol_delta"].fillna(0) ) return out def classify_regime(row) -> str: s = row["regime_score"] if s >= 40: return "MARKUP" if s >= 10: return "ACCUMULATION" if s <= -40: return "MARKDOWN" if s <= -10: return "DISTRIBUTION" return "RANGE" # ------------------------------------------------------------------ model def _label(df: pd.DataFrame) -> pd.Series: fwd = df["Close"].shift(-FORECAST_CANDLES) / df["Close"] - 1 return (fwd * 100 > FEE_BUFFER_PCT).astype(int) def train_model(feat_df: pd.DataFrame): """Train XGBoost on this symbol's candle history. Returns (model, acc).""" df = feat_df.dropna(subset=FEATURES).copy() df["y"] = _label(df) df = df.iloc[:-FORECAST_CANDLES] if len(df) < 150: return None, 0.0 split = int(len(df) * 0.8) Xtr, ytr = df[FEATURES].iloc[:split], df["y"].iloc[:split] Xte, yte = df[FEATURES].iloc[split:], df["y"].iloc[split:] if not HAVE_XGB: return None, 0.0 model = XGBClassifier( n_estimators=200, max_depth=4, learning_rate=0.08, subsample=0.9, colsample_bytree=0.8, eval_metric="logloss", n_jobs=2, ) model.fit(Xtr, ytr) acc = float((model.predict(Xte) == yte).mean()) if len(yte) else 0.0 return model, acc # ------------------------------------------------------------------ analysis def _nz(x, default=0.0): try: x = float(x) return x if np.isfinite(x) else default except Exception: return default def analyze(df: pd.DataFrame, model=None, model_acc: float = 0.0) -> dict: feat = build_features(df) row = feat.iloc[-1] price = float(row["Close"]) # probability_up if model is not None: X = feat[FEATURES].iloc[[-1]].fillna(0) prob_up = min(99.0, max(1.0, float(model.predict_proba(X)[0, 1]) * 100)) else: # heuristic fallback if xgboost missing prob_up = 50 + 25 * float(np.tanh(row["regime_score"] / 60)) atr = _nz(row["atr_pct"], 1.0) or 1.0 expected_return = (prob_up / 100 - 0.5) * 2 * atr * FORECAST_CANDLES ** 0.5 downside_risk = atr * 1.65 # ~95% one-candle stress confidence = max(0.0, min(100.0, 100 * abs(prob_up - 50) / 50 * 0.6 + model_acc * 100 * 0.4)) regime = classify_regime(row) # 4-candle forecast path (drift from expected_return, ATR cone) drift = expected_return / FORECAST_CANDLES / 100 path = [price * (1 + drift) ** (i + 1) for i in range(FORECAST_CANDLES)] upper = [p * (1 + atr / 100 * (i + 1) ** 0.5) for i, p in enumerate(path)] lower = [p * (1 - atr / 100 * (i + 1) ** 0.5) for i, p in enumerate(path)] # action mapping bullish = ( prob_up >= 60 and expected_return > FEE_BUFFER_PCT and regime not in ("MARKDOWN", "DISTRIBUTION") and _nz(row["vol_delta"]) > 0 ) bearish = prob_up <= 42 or regime == "MARKDOWN" action = "BUY" if bullish else "SELL" if bearish else "HOLD" return { "current_price": price, "action": action, "probability_up": round(prob_up, 1), "expected_return": round(expected_return, 2), "downside_risk": round(downside_risk, 2), "confidence": round(confidence, 1), "market_regime": regime, "model_acc": round(model_acc * 100, 1), "rsi": round(_nz(row["rsi"], 50.0), 1), "macd_hist": round(_nz(row["macd_hist"]), 3), "atr_pct": round(atr, 2), "vwap": _nz(row["vwap"], price), "vol_delta": round(_nz(row["vol_delta"]), 2), "ema_cross": int(_nz(row["ema_cross"])), "sup_dist": round(_nz(row["sup_dist"]), 2), "res_dist": round(_nz(row["res_dist"]), 2), "regime_score": round(_nz(row["regime_score"]), 0), "forecast_path": path, "forecast_upper": upper, "forecast_lower": lower, "features": {f: float(np.nan_to_num(row[f])) for f in FEATURES}, }