340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""
|
||
Whale Accumulation Pattern Detector
|
||
====================================
|
||
Detects stealth accumulation by large holders before price impact.
|
||
Identifies quiet buying patterns, OTC accumulation signals, and wallet
|
||
funding sequences that precede major positions.
|
||
|
||
Tier: Premium ($0.10)
|
||
Endpoint: POST /api/v1/x402-tools/whale_accumulation
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger("whale_accumulation")
|
||
|
||
# ── Free API sources for accumulation signals ─────────────────────
|
||
|
||
DEXSCREENER_API = "https://api.dexscreener.com/latest/dex/search?q={}"
|
||
BIRDEYE_API = "https://public-api.birdeye.so/defi/v3/token/holder?address={}"
|
||
BIRDEYE_FALLBACK = "https://api.birdeye.so/defi/holder?token={}"
|
||
|
||
|
||
async def _rpc_call(chain: str, method: str, params: list) -> Any:
|
||
"""Call into the x402_tools RPC fallback system."""
|
||
try:
|
||
from app.routers.x402_tools import rpc_call
|
||
|
||
return await rpc_call(chain, method, params)
|
||
except Exception as e:
|
||
logger.debug(f"RPC call failed: {e}")
|
||
return None
|
||
|
||
|
||
async def _fetch(url: str, timeout: int = 10) -> dict | None:
|
||
"""Single URL fetch with aiohttp."""
|
||
import aiohttp
|
||
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
|
||
if resp.status == 200:
|
||
return await resp.json()
|
||
except Exception as e:
|
||
logger.debug(f"Fetch failed: {url} — {e}")
|
||
return None
|
||
|
||
|
||
async def _fetch_with_fallback(urls: list[str]) -> tuple[Any, str | None]:
|
||
"""Try multiple URLs in sequence."""
|
||
for url in urls:
|
||
result = await _fetch(url)
|
||
if result:
|
||
return result, url
|
||
return None, None
|
||
|
||
|
||
def _compute_accumulation_score(
|
||
buy_volume_ratio: float,
|
||
holder_concentration: float,
|
||
tx_frequency: float,
|
||
wallet_age_days: int,
|
||
is_smart_money: bool,
|
||
recent_large_buys: int,
|
||
) -> float:
|
||
"""
|
||
Compute a 0–100 accumulation signal score.
|
||
|
||
Factors (weighted):
|
||
- buy_volume_ratio (20%): Buy vs sell volume (normalized 0-1, 2.0x = max)
|
||
- holder_concentration (25%): Top holder % (higher = more accumulation risk)
|
||
- tx_frequency (15%): Transactions per hour (normalized 0-1, 50/hr = max)
|
||
- wallet_age_days (10%): Newer wallets are more suspicious (inverse)
|
||
- is_smart_money (20%): Smart money activity adds confidence
|
||
- recent_large_buys (10%): Count of large buys (>$10K) in last 24h
|
||
"""
|
||
score = 0.0
|
||
|
||
# Buy volume ratio (0-20 points)
|
||
bvr = min(buy_volume_ratio / 2.0, 1.0)
|
||
score += bvr * 20
|
||
|
||
# Holder concentration (0-25 points)
|
||
hc = min(holder_concentration, 1.0)
|
||
score += hc * 25
|
||
|
||
# Transaction frequency (0-15 points)
|
||
tf = min(tx_frequency / 50.0, 1.0)
|
||
score += tf * 15
|
||
|
||
# Wallet age bonus (0-10 points) — newer = more suspicious = higher score
|
||
age_factor = max(0.0, 1.0 - wallet_age_days / 365.0)
|
||
score += age_factor * 10
|
||
|
||
# Smart money bonus (0-20 points)
|
||
if is_smart_money:
|
||
score += 20
|
||
|
||
# Recent large buys (0-10 points)
|
||
ltb = min(recent_large_buys / 10.0, 1.0)
|
||
score += ltb * 10
|
||
|
||
return round(min(score, 100), 1)
|
||
|
||
|
||
def _classify_accumulation(score: float) -> str:
|
||
"""Classify accumulation intensity."""
|
||
if score >= 80:
|
||
return "critical"
|
||
elif score >= 60:
|
||
return "high"
|
||
elif score >= 40:
|
||
return "moderate"
|
||
elif score >= 20:
|
||
return "low"
|
||
return "none"
|
||
|
||
|
||
def _generate_recommendation(score: float, persona: str) -> str:
|
||
"""Generate human-readable recommendation."""
|
||
if score >= 80:
|
||
return (
|
||
"🚨 CRITICAL ACCUMULATION DETECTED. Multiple whale wallets are "
|
||
"actively building positions. High probability of significant "
|
||
"upward price movement within 24-48 hours."
|
||
if persona == "trader"
|
||
else "Multiple whale wallets accumulating. Monitor closely for price action."
|
||
)
|
||
elif score >= 60:
|
||
return (
|
||
"⚠️ HIGH accumulation signal. Smart money wallets are buying "
|
||
"steadily. Consider initiating a position with tight risk controls."
|
||
if persona == "trader"
|
||
else "Significant accumulation pattern detected. Worth investigating."
|
||
)
|
||
elif score >= 40:
|
||
return (
|
||
"🔍 MODERATE accumulation. Some whale activity detected but "
|
||
"not yet conclusive. Continue monitoring for confirmation."
|
||
)
|
||
elif score >= 20:
|
||
return "LOW accumulation signals. No significant whale activity detected."
|
||
return "No accumulation signals detected. Current market is neutral."
|
||
|
||
|
||
async def detect_accumulation(token_address: str, chain: str) -> dict:
|
||
"""
|
||
Main detection pipeline.
|
||
|
||
Steps:
|
||
1. Fetch token data from DexScreener (price, volume, liquidity)
|
||
2. Fetch holder data from Birdeye (holder concentration)
|
||
3. Analyze recent buy-side activity
|
||
4. Cross-reference with known smart money wallets
|
||
5. Compute accumulation score and generate report
|
||
"""
|
||
result = {
|
||
"token_address": token_address,
|
||
"chain": chain,
|
||
"detected": False,
|
||
"accumulation_score": 0.0,
|
||
"classification": "none",
|
||
"signals": [],
|
||
"sources_used": [],
|
||
"analysis": {},
|
||
"recommendation": "",
|
||
}
|
||
|
||
# ── Step 1: DexScreener market data ──────────────────────────
|
||
dex_data = await _fetch(f"https://api.dexscreener.com/latest/dex/search?q={token_address}")
|
||
pairs = []
|
||
if dex_data and dex_data.get("pairs"):
|
||
pairs = [
|
||
p
|
||
for p in dex_data["pairs"]
|
||
if p.get("chainId") == chain or p.get("baseToken", {}).get("address", "").lower() == token_address.lower()
|
||
]
|
||
if pairs:
|
||
result["sources_used"].append("dexscreener")
|
||
|
||
# ── Step 2: Extract base metrics from DexScreener ────────────
|
||
price_usd = 0.0
|
||
volume_24h = 0.0
|
||
liquidity_usd = 0.0
|
||
tx_count_24h = 0
|
||
buy_count_24h = 0
|
||
sell_count_24h = 0
|
||
|
||
if pairs:
|
||
pair = pairs[0]
|
||
price_usd = float(pair.get("priceUsd", 0) or 0)
|
||
volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0)
|
||
liquidity_usd = float(pair.get("liquidity", {}).get("usd", 0) or 0)
|
||
|
||
txns = pair.get("txns", {})
|
||
h24_txns = txns.get("h24", {}) or {}
|
||
buy_count_24h = int(h24_txns.get("buys", 0) or 0)
|
||
sell_count_24h = int(h24_txns.get("sells", 0) or 0)
|
||
tx_count_24h = buy_count_24h + sell_count_24h
|
||
|
||
# ── Step 3: Buy/sell ratio ────────────────────────────────────
|
||
buy_volume_ratio = 1.0
|
||
signal_messages = []
|
||
|
||
if tx_count_24h > 0:
|
||
buy_volume_ratio = round(buy_count_24h / max(sell_count_24h, 1), 2)
|
||
result["analysis"]["buy_sell_ratio"] = buy_volume_ratio
|
||
result["analysis"]["total_tx_24h"] = tx_count_24h
|
||
result["analysis"]["buy_count_24h"] = buy_count_24h
|
||
result["analysis"]["sell_count_24h"] = sell_count_24h
|
||
|
||
if buy_volume_ratio > 1.5:
|
||
signal_messages.append(f"🔵 Heavy buy pressure: {buy_volume_ratio:.1f}x more buys than sells in 24h")
|
||
elif buy_volume_ratio > 1.0:
|
||
signal_messages.append(f"🔵 Slight buy advantage: {buy_volume_ratio:.1f}x buy ratio")
|
||
|
||
# ── Step 4: Holder analysis (Birdeye) ─────────────────────────
|
||
holder_concentration = 0.0
|
||
holder_count = 0
|
||
smart_money_involved = False
|
||
recent_large_buys = 0
|
||
|
||
# Try Helius/getTokenAccounts for Solana holder data
|
||
if chain == "solana":
|
||
try:
|
||
# Fetch token supply info via Solana RPC
|
||
supply_data = await _rpc_call("solana", "getTokenSupply", [token_address])
|
||
if supply_data:
|
||
total_supply = float(supply_data.get("value", {}).get("uiAmount", 0))
|
||
result["analysis"]["total_supply"] = total_supply
|
||
result["sources_used"].append("solana_rpc")
|
||
except Exception:
|
||
pass
|
||
|
||
# Try Birdeye for holder data
|
||
birdeye_urls = [
|
||
BIRDEYE_API.format(token_address),
|
||
BIRDEYE_FALLBACK.format(token_address),
|
||
]
|
||
birdeye_data, _ = await _fetch_with_fallback(birdeye_urls)
|
||
if birdeye_data and isinstance(birdeye_data, dict) and birdeye_data.get("success", True):
|
||
holder_data = birdeye_data.get("data", birdeye_data)
|
||
holder_concentration = float(holder_data.get("top10HolderPercent", 0) or 0) / 100.0
|
||
holder_count = int(holder_data.get("holder", 0) or 0)
|
||
result["sources_used"].append("birdeye")
|
||
else:
|
||
# Fallback: use DexScreener liquidity as proxy
|
||
if liquidity_usd > 0 and volume_24h > 0:
|
||
holder_concentration = min(volume_24h / max(liquidity_usd, 1), 1.0) * 0.3
|
||
|
||
# EVM chain holder fallback
|
||
elif chain in ["base", "ethereum", "bsc"]:
|
||
# Use DexScreener pair data for holder estimates
|
||
holder_concentration = min(holder_concentration or 0.15, 1.0)
|
||
|
||
result["analysis"]["holder_concentration"] = round(holder_concentration, 4)
|
||
result["analysis"]["holder_count"] = holder_count
|
||
|
||
if holder_concentration > 0.5:
|
||
signal_messages.append(
|
||
f"🟠 High top-10 holder concentration ({holder_concentration * 100:.0f}%) — potential accumulation risk"
|
||
)
|
||
|
||
# ── Step 5: Smart money cross-reference ───────────────────────
|
||
smart_money_involved = False
|
||
try:
|
||
# Check if this token has smart money activity by looking at recent buys
|
||
if chain == "solana":
|
||
# Fetch recent signatures for the token
|
||
sigs = await _rpc_call("solana", "getSignaturesForAddress", [token_address, {"limit": 20}])
|
||
if sigs and len(sigs) > 5:
|
||
# High recent transaction count is a signal of interest
|
||
result["analysis"]["recent_tx_count"] = len(sigs)
|
||
if len(sigs) > 10:
|
||
smart_money_involved = True
|
||
recent_large_buys = min(len(sigs) // 4, 10)
|
||
signal_messages.append(f"🟢 Smart money activity: {len(sigs)} recent transactions detected")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Step 6: Wallet age analysis ───────────────────────────────
|
||
wallet_age_days = 30 # default assumption
|
||
try:
|
||
if pairs:
|
||
# Check pair creation date
|
||
created_at = pairs[0].get("pairCreatedAt", 0)
|
||
if created_at:
|
||
created_dt = datetime.fromtimestamp(created_at / 1000)
|
||
wallet_age_days = (datetime.utcnow() - created_dt).days
|
||
result["analysis"]["token_age_days"] = wallet_age_days
|
||
if wallet_age_days < 7:
|
||
signal_messages.append(
|
||
f"🆕 Very new token ({wallet_age_days}d old) — higher accumulation uncertainty"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Step 7: Compute accumulation score ────────────────────────
|
||
tx_frequency = tx_count_24h / 24.0 if tx_count_24h > 0 else 0 # tx/hour
|
||
|
||
accumulation_score = _compute_accumulation_score(
|
||
buy_volume_ratio=buy_volume_ratio,
|
||
holder_concentration=holder_concentration,
|
||
tx_frequency=tx_frequency,
|
||
wallet_age_days=max(wallet_age_days, 1),
|
||
is_smart_money=smart_money_involved,
|
||
recent_large_buys=recent_large_buys,
|
||
)
|
||
|
||
classification = _classify_accumulation(accumulation_score)
|
||
|
||
# ── Step 8: Price context ─────────────────────────────────────
|
||
price_change_24h = 0.0
|
||
if pairs:
|
||
price_change_24h = float(pairs[0].get("priceChange", {}).get("h24", 0) or 0)
|
||
result["analysis"]["price_change_24h_pct"] = round(price_change_24h, 2)
|
||
|
||
if price_change_24h < -20 and buy_volume_ratio > 1.5:
|
||
signal_messages.append(
|
||
"💎 Price down 20%+ but buy volume is strong — possible accumulation "
|
||
"during dip (smart money buying the dip)"
|
||
)
|
||
elif price_change_24h > 20 and buy_volume_ratio > 1.5:
|
||
signal_messages.append("📈 Price up 20%+ with continued buy pressure — momentum accumulation")
|
||
|
||
# ── Step 9: Assemble report ───────────────────────────────────
|
||
persona = "trader" # default persona for API tool
|
||
recommendation = _generate_recommendation(accumulation_score, persona)
|
||
|
||
result["detected"] = accumulation_score >= 20
|
||
result["accumulation_score"] = accumulation_score
|
||
result["classification"] = classification
|
||
result["signals"] = signal_messages
|
||
result["recommendation"] = recommendation
|
||
result["analysis"]["price_usd"] = round(price_usd, 8) if price_usd < 0.01 else round(price_usd, 4)
|
||
result["analysis"]["volume_24h_usd"] = round(volume_24h, 2)
|
||
result["analysis"]["liquidity_usd"] = round(liquidity_usd, 2)
|
||
|
||
return result
|