""" Advanced Indicators — Absorption Bubbles + Pro Scalper logic. Implements TradingView-style indicators using OHLCV data: 1. Absorption Bubbles: Detects volume absorption on wicks (normalized volume vs rolling std dev) 2. Pro Scalper: Kalman-filtered Supertrend + VWMA bands for overbought/oversold zones These work with yfinance OHLCV data. For TradingView screener data (no candle history), a simplified version is computed from available fields. """ import numpy as np import pandas as pd from typing import Optional # ────────────────────────────────────────────── # Absorption Bubbles # ────────────────────────────────────────────── def compute_absorption_bubbles( df: pd.DataFrame, lookback: int = 20, threshold: float = 2.0, ) -> dict: """Detect absorption events on candle wicks. Logic (based on TradingView 'Absorption Bubbles' by profitprotrading): - Normalize volume against a rolling standard deviation over `lookback` bars - Identify candles where wick volume (estimated) exceeds `threshold` std devs - Green bubbles on upper wicks = selling absorption (sellers absorb buying pressure) - Red bubbles on lower wicks = buying absorption (buyers absorb selling pressure) Returns: dict with absorption analysis results """ if df is None or len(df) < lookback + 5: return {"absorption_detected": False, "events": []} close = df["Close"].astype(float).values open_ = df["Open"].astype(float).values high = df["High"].astype(float).values low = df["Low"].astype(float).values volume = df["Volume"].astype(float).values n = len(df) # Calculate wick ratios (proxy for wick volume distribution) body = np.abs(close - open_) full_range = high - low full_range = np.where(full_range == 0, 1e-10, full_range) # avoid div by zero upper_wick = high - np.maximum(close, open_) lower_wick = np.minimum(close, open_) - low # Wick volume estimation: distribute volume proportionally to wick size upper_wick_vol = volume * (upper_wick / full_range) lower_wick_vol = volume * (lower_wick / full_range) # Rolling mean and std of volume vol_series = pd.Series(volume) vol_mean = vol_series.rolling(lookback).mean().values vol_std = vol_series.rolling(lookback).std().values vol_std = np.where(vol_std == 0, 1e-10, vol_std) # Normalized wick volumes (z-scores) upper_z = (upper_wick_vol - vol_mean) / vol_std lower_z = (lower_wick_vol - vol_mean) / vol_std # Detect absorption events events = [] recent_buying_absorption = [] recent_selling_absorption = [] # Look at last 5 bars for recent events for i in range(max(lookback, n - 5), n): if np.isnan(upper_z[i]) or np.isnan(lower_z[i]): continue # Selling absorption: large upper wick volume (sellers absorb buyers pushing up) if upper_z[i] > threshold: events.append({ "bar_index": i, "type": "selling_absorption", "strength": round(float(upper_z[i]), 2), "price_level": round(float(high[i]), 4), }) recent_selling_absorption.append(float(upper_z[i])) # Buying absorption: large lower wick volume (buyers absorb sellers pushing down) if lower_z[i] > threshold: events.append({ "bar_index": i, "type": "buying_absorption", "strength": round(float(lower_z[i]), 2), "price_level": round(float(low[i]), 4), }) recent_buying_absorption.append(float(lower_z[i])) # Also check body absorption (aggressive directional volume in body) body_ratio = body / full_range body_vol = volume * body_ratio body_z = (body_vol - vol_mean) / vol_std last_bar = n - 1 body_absorption = None if not np.isnan(body_z[last_bar]) and body_z[last_bar] > threshold: if close[last_bar] > open_[last_bar]: body_absorption = "aggressive_buying" else: body_absorption = "aggressive_selling" # Summary for current bar current_upper_z = float(upper_z[last_bar]) if not np.isnan(upper_z[last_bar]) else 0 current_lower_z = float(lower_z[last_bar]) if not np.isnan(lower_z[last_bar]) else 0 return { "absorption_detected": len(events) > 0, "events": events, "current_bar": { "upper_wick_z": round(current_upper_z, 2), "lower_wick_z": round(current_lower_z, 2), "selling_absorption": current_upper_z > threshold, "buying_absorption": current_lower_z > threshold, "body_absorption": body_absorption, }, "recent_buying_strength": round(max(recent_buying_absorption), 2) if recent_buying_absorption else 0, "recent_selling_strength": round(max(recent_selling_absorption), 2) if recent_selling_absorption else 0, "signal_bias": _absorption_bias(recent_buying_absorption, recent_selling_absorption), } def _absorption_bias(buying: list, selling: list) -> str: """Determine directional bias from absorption events. Buying absorption (red bubbles on lower wicks) = support forming = bullish Selling absorption (green bubbles on upper wicks) = resistance forming = bearish """ buy_strength = sum(buying) if buying else 0 sell_strength = sum(selling) if selling else 0 if buy_strength > sell_strength and buy_strength > 0: return "bullish" # Buyers absorbing = support = price likely to go up elif sell_strength > buy_strength and sell_strength > 0: return "bearish" # Sellers absorbing = resistance = price likely to go down else: return "neutral" # ────────────────────────────────────────────── # Pro Scalper (Kalman-filtered Supertrend + VWMA Bands) # ────────────────────────────────────────────── def compute_pro_scalper( df: pd.DataFrame, atr_period: int = 10, atr_multiplier: float = 3.0, kalman_gain: float = 0.7, vwma_period: int = 20, vwma_multiplier: float = 2.0, ) -> dict: """Compute Pro Scalper signals. Logic (based on TradingView 'Pro Scalper' by profitprotrading): - Kalman-filtered Supertrend for buy/sell signals - VWMA bands for overbought/oversold zones - Reversal signals when price hits OB/OS zones Returns: dict with scalper signals and zones """ if df is None or len(df) < max(atr_period, vwma_period) + 10: return {"signal": "neutral", "confidence": 0} close = df["Close"].astype(float).values high = df["High"].astype(float).values low = df["Low"].astype(float).values volume = df["Volume"].astype(float).values n = len(df) # ── Kalman-filtered Supertrend ── # Step 1: Compute ATR tr = np.maximum( high[1:] - low[1:], np.maximum( np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1]) ) ) tr = np.insert(tr, 0, high[0] - low[0]) atr = pd.Series(tr).rolling(atr_period).mean().values # Step 2: Kalman filter on price (smoothed source) kalman_price = np.zeros(n) kalman_price[0] = close[0] for i in range(1, n): kalman_price[i] = kalman_price[i - 1] + kalman_gain * (close[i] - kalman_price[i - 1]) # Step 3: Supertrend using Kalman-filtered price hl2 = (high + low) / 2.0 upper_band = np.zeros(n) lower_band = np.zeros(n) supertrend = np.zeros(n) direction = np.ones(n) # 1 = bullish, -1 = bearish for i in range(atr_period, n): if np.isnan(atr[i]): continue # Use Kalman-filtered midpoint for band calculation mid = (kalman_price[i] + hl2[i]) / 2.0 upper_band[i] = mid + atr_multiplier * atr[i] lower_band[i] = mid - atr_multiplier * atr[i] # Supertrend logic if i > atr_period: # Adjust bands (don't let them move against the trend) if lower_band[i] < lower_band[i - 1] and close[i - 1] > lower_band[i - 1]: lower_band[i] = lower_band[i - 1] if upper_band[i] > upper_band[i - 1] and close[i - 1] < upper_band[i - 1]: upper_band[i] = upper_band[i - 1] # Direction if direction[i - 1] == 1: if close[i] < lower_band[i]: direction[i] = -1 supertrend[i] = upper_band[i] else: direction[i] = 1 supertrend[i] = lower_band[i] else: if close[i] > upper_band[i]: direction[i] = 1 supertrend[i] = lower_band[i] else: direction[i] = -1 supertrend[i] = upper_band[i] # Detect buy/sell signal (direction change) scalper_signal = "neutral" signal_bar = -1 for i in range(n - 1, max(n - 4, atr_period), -1): if direction[i] == 1 and direction[i - 1] == -1: scalper_signal = "buy" signal_bar = i break elif direction[i] == -1 and direction[i - 1] == 1: scalper_signal = "sell" signal_bar = i break # Current trend direction current_direction = "bullish" if direction[n - 1] == 1 else "bearish" # ── VWMA Bands (Overbought/Oversold Zones) ── vwma = _compute_vwma(close, volume, vwma_period) vwma_std = pd.Series(close).rolling(vwma_period).std().values upper_vwma = vwma + vwma_multiplier * vwma_std lower_vwma = vwma - vwma_multiplier * vwma_std # Check if current price is in OB/OS zone last_price = close[n - 1] last_upper = upper_vwma[n - 1] if not np.isnan(upper_vwma[n - 1]) else float('inf') last_lower = lower_vwma[n - 1] if not np.isnan(lower_vwma[n - 1]) else float('-inf') zone = "neutral" if last_price >= last_upper: zone = "overbought" elif last_price <= last_lower: zone = "oversold" # Reversal signal: price was in OB/OS and is now reversing reversal = None if n >= 3: prev_price = close[n - 2] if prev_price >= upper_vwma[n - 2] if not np.isnan(upper_vwma[n - 2]) else False: if last_price < prev_price: reversal = "bearish_reversal" if prev_price <= lower_vwma[n - 2] if not np.isnan(lower_vwma[n - 2]) else False: if last_price > prev_price: reversal = "bullish_reversal" # Confidence based on signal recency and zone alignment confidence = _scalper_confidence(scalper_signal, zone, reversal, signal_bar, n) return { "signal": scalper_signal, "direction": current_direction, "zone": zone, "reversal": reversal, "supertrend_level": round(float(supertrend[n - 1]), 4) if supertrend[n - 1] != 0 else None, "vwma_upper": round(float(last_upper), 4) if not np.isnan(last_upper) else None, "vwma_lower": round(float(last_lower), 4) if not np.isnan(last_lower) else None, "confidence": confidence, "bars_since_signal": n - 1 - signal_bar if signal_bar > 0 else None, } def _compute_vwma(close: np.ndarray, volume: np.ndarray, period: int) -> np.ndarray: """Compute Volume-Weighted Moving Average.""" vwma = np.full(len(close), np.nan) for i in range(period - 1, len(close)): window_vol = volume[i - period + 1:i + 1] window_close = close[i - period + 1:i + 1] total_vol = window_vol.sum() if total_vol > 0: vwma[i] = (window_close * window_vol).sum() / total_vol else: vwma[i] = window_close.mean() return vwma def _scalper_confidence(signal: str, zone: str, reversal: Optional[str], signal_bar: int, n: int) -> float: """Calculate confidence score for scalper signal (0-1).""" conf = 0.5 # Signal present if signal == "buy": conf += 0.2 if zone == "oversold": conf += 0.15 # Buy in oversold = high confidence elif zone == "overbought": conf -= 0.2 # Buy in overbought = risky if reversal == "bullish_reversal": conf += 0.15 elif signal == "sell": conf += 0.2 if zone == "overbought": conf += 0.15 elif zone == "oversold": conf -= 0.2 if reversal == "bearish_reversal": conf += 0.15 else: conf = 0.3 # No signal # Recency bonus (signal within last 2 bars) if signal_bar > 0 and (n - 1 - signal_bar) <= 2: conf += 0.1 return round(max(0.0, min(1.0, conf)), 2) # ────────────────────────────────────────────── # Simplified versions for TradingView screener data # (no candle history, only current indicator values) # ────────────────────────────────────────────── def estimate_absorption_from_indicators(indicators: dict) -> dict: """Estimate absorption-like signals from TradingView screener data. Without full OHLCV history, we approximate using: - Volume ratio (high volume = potential absorption) - RSI extremes (oversold/overbought = absorption zones) - ADX (low ADX + high volume = absorption/consolidation) - Price vs range position """ vol_ratio = indicators.get("volume_ratio") rsi = indicators.get("rsi") adx = indicators.get("adx") change = indicators.get("change_pct", 0) trend = indicators.get("trend", "unknown") absorption_score = 0.0 bias = "neutral" events = [] # High volume with small price change = absorption if vol_ratio and vol_ratio > 1.5 and abs(change or 0) < 1.5: absorption_score += 0.3 events.append("high_volume_low_movement") # Determine bias from trend if "bullish" in trend: bias = "bullish" elif "bearish" in trend: bias = "bearish" # RSI at extremes with volume = absorption zone if rsi is not None: if rsi < 35 and (vol_ratio or 0) > 1.0: absorption_score += 0.3 bias = "bullish" events.append("oversold_zone") elif rsi > 65 and (vol_ratio or 0) > 1.0: absorption_score += 0.2 bias = "bearish" events.append("overbought_zone") # Low ADX (no trend) + volume = range absorption if adx is not None and adx < 25 and vol_ratio and vol_ratio > 1.0: absorption_score += 0.2 events.append("range_absorption") # Volume spike if vol_ratio and vol_ratio > 2.0: absorption_score += 0.2 events.append("volume_spike") # Moderate volume with strong trend = trend absorption (continuation) if vol_ratio and vol_ratio > 0.8 and "strong" in trend: absorption_score += 0.15 if "bullish" in trend: bias = "bullish" events.append("trend_continuation_support") elif "bearish" in trend: bias = "bearish" events.append("trend_continuation_resistance") # If no specific bias detected, infer from trend if bias == "neutral" and absorption_score > 0: if "bullish" in trend: bias = "bullish" elif "bearish" in trend: bias = "bearish" return { "absorption_detected": absorption_score > 0.2, "absorption_score": round(min(1.0, absorption_score), 2), "signal_bias": bias, "events": events, } def estimate_scalper_from_indicators(indicators: dict) -> dict: """Estimate Pro Scalper-like signals from TradingView screener data. Approximates Kalman Supertrend using: - EMA alignment for trend direction - Stochastic for OB/OS zones - TradingView's own recommendation score """ ema9 = indicators.get("ema9") ema21 = indicators.get("ema21") ema50 = indicators.get("ema50") price = indicators.get("current_price") stoch_k = indicators.get("stoch_k") stoch_d = indicators.get("stoch_d") rsi = indicators.get("rsi") tv_rec = indicators.get("tv_recommend") adx = indicators.get("adx") signal = "neutral" direction = "neutral" zone = "neutral" reversal = None confidence = 0.3 # Direction from EMA alignment (proxy for Supertrend direction) if price and ema9 and ema21: if price > ema9 > ema21: direction = "bullish" signal = "buy" confidence = 0.6 elif price < ema9 < ema21: direction = "bearish" signal = "sell" confidence = 0.6 # OB/OS zone from Stochastic (proxy for VWMA bands) if stoch_k is not None: if stoch_k > 80: zone = "overbought" if signal == "buy": confidence -= 0.15 # Buying in OB is risky elif stoch_k < 20: zone = "oversold" if signal == "sell": confidence -= 0.15 # Selling in OS is risky # Reversal detection if stoch_k is not None and stoch_d is not None: if stoch_k < 20 and stoch_k > stoch_d: reversal = "bullish_reversal" if signal != "sell": confidence += 0.1 elif stoch_k > 80 and stoch_k < stoch_d: reversal = "bearish_reversal" if signal != "buy": confidence += 0.1 # TV recommendation alignment if tv_rec is not None: if tv_rec > 0.3 and signal == "buy": confidence += 0.1 elif tv_rec < -0.3 and signal == "sell": confidence += 0.1 elif tv_rec > 0.3 and signal == "neutral": signal = "buy" confidence = 0.5 elif tv_rec < -0.3 and signal == "neutral": signal = "sell" confidence = 0.5 # ADX strength bonus if adx is not None and adx > 25 and signal != "neutral": confidence += 0.1 return { "signal": signal, "direction": direction, "zone": zone, "reversal": reversal, "confidence": round(max(0.0, min(1.0, confidence)), 2), }