- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
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: # noqa: SIM117
|
|
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. # noqa: RUF002
|
|
|
|
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
|