openclaw-live-scanner / src /advanced_indicators.py
David Chan Mun POON (SG)
fix: always show absorption/scalper indicators, lower thresholds, force AI to reference them
c5db704
Raw
History Blame Contribute Delete
18.7 kB
"""
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),
}