Spaces:
Sleeping
Sleeping
David Chan Mun POON (SG)
fix: always show absorption/scalper indicators, lower thresholds, force AI to reference them
c5db704 | """ | |
| 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), | |
| } | |