| """ |
| RugCharts Volume Authenticity Scorer |
| ===================================== |
| Fake volume detection across 4 layers: statistical, graph, heuristic, ML. |
| Produces Authentic Score (100 - fake_volume%) with bootstrap confidence intervals. |
| |
| Wired into DataBus as 'volume_authenticity' chain. |
| Powers the RugCharts competitive moat β no other platform shows this. |
| |
| Reference: Cong et al. (2023), Victor & Weintraud (2021), Niedermayer (2024) |
| """ |
|
|
| import json |
| import logging |
| import math |
| import os |
| from collections import defaultdict |
| from datetime import datetime |
|
|
| import numpy as np |
| import redis |
|
|
| logger = logging.getLogger("volume_authenticity") |
|
|
| REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis") |
| REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) |
| REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") |
|
|
| CACHE_TTL = 600 |
|
|
| |
|
|
|
|
| class DetectionSignal: |
| """A single detection signal from any layer.""" |
|
|
| __slots__ = ("confidence", "detail", "score", "source") |
|
|
| def __init__(self, source: str, score: float, confidence: float, detail: str = ""): |
| self.source = source |
| self.score = score |
| self.confidence = confidence |
| self.detail = detail |
|
|
| def to_dict(self): |
| return { |
| "source": self.source, |
| "score": round(self.score, 3), |
| "confidence": round(self.confidence, 3), |
| "detail": self.detail, |
| } |
|
|
|
|
| class AuthenticityResult: |
| """Complete volume authenticity analysis.""" |
|
|
| __slots__ = ( |
| "authentic_score", |
| "ci_lower", |
| "ci_upper", |
| "component_breakdown", |
| "confidence", |
| "data_quality", |
| "fake_volume_pct", |
| "method_count", |
| "risk_level", |
| "scan_timestamp", |
| "signals", |
| "source", |
| ) |
|
|
| def __init__(self): |
| self.source = "volume_authenticity_scorer" |
|
|
| def to_dict(self): |
| return { |
| "fake_volume_pct": self.fake_volume_pct, |
| "authentic_score": self.authentic_score, |
| "confidence": self.confidence, |
| "ci_lower": self.ci_lower, |
| "ci_upper": self.ci_upper, |
| "component_breakdown": self.component_breakdown, |
| "data_quality": self.data_quality, |
| "method_count": self.method_count, |
| "risk_level": self.risk_level, |
| "scan_timestamp": self.scan_timestamp, |
| "source": self.source, |
| } |
|
|
|
|
| |
|
|
|
|
| def benfords_law_test(trade_sizes: list[float]) -> tuple[float, float, str]: |
| """Benford's Law first-digit test. Returns (score 0-1, confidence, detail). |
| |
| Cong et al. (2023): regulated exchanges 0% failure, unregulated Tier-2 75%+. |
| """ |
| if len(trade_sizes) < 30: |
| return 0.0, 0.0, "insufficient_data" |
|
|
| |
| benford_expected = np.array([math.log10(1 + 1 / d) for d in range(1, 10)]) |
|
|
| |
| first_digits = [] |
| for size in trade_sizes: |
| if size <= 0: |
| continue |
| first_digit = int(str(abs(size)).strip("0.").lstrip("0")[:1] or "0") |
| if 1 <= first_digit <= 9: |
| first_digits.append(first_digit) |
|
|
| if len(first_digits) < 30: |
| return 0.0, 0.0, "insufficient_data" |
|
|
| observed = np.zeros(9) |
| for d in first_digits: |
| observed[d - 1] += 1 |
| observed = observed / observed.sum() |
|
|
| |
| n = len(first_digits) |
| chi2 = n * np.sum((observed - benford_expected) ** 2 / benford_expected) |
|
|
| |
| |
| if chi2 > 20.09: |
| score = min(1.0, chi2 / 50.0) |
| conf = min(1.0, n / 200.0) |
| return score, conf, f"Benford deviation ΟΒ²={chi2:.1f} (p<0.01)" |
| elif chi2 > 15.51: |
| score = min(1.0, chi2 / 50.0) * 0.7 |
| conf = min(1.0, n / 200.0) * 0.8 |
| return score, conf, f"Benford deviation ΟΒ²={chi2:.1f} (p<0.05)" |
| else: |
| return 0.0, min(1.0, n / 500.0), f"Benford normal ΟΒ²={chi2:.1f}" |
|
|
|
|
| def trade_size_clustering(trade_sizes: list[float]) -> tuple[float, float, str]: |
| """Test for unnatural clustering at round numbers.""" |
| if len(trade_sizes) < 20: |
| return 0.0, 0.0, "insufficient_data" |
|
|
| |
| round_count = 0 |
| for size in trade_sizes: |
| if size <= 0: |
| continue |
| log10 = math.log10(size) |
| frac = log10 - math.floor(log10) |
| |
| if frac < 0.05 or frac > 0.95: |
| round_count += 1 |
|
|
| round_ratio = round_count / len(trade_sizes) if trade_sizes else 0 |
| |
| if round_ratio > 0.5: |
| score = min(1.0, (round_ratio - 0.2) / 0.5) |
| return score, min(1.0, len(trade_sizes) / 100.0), f"Round clustering {round_ratio:.0%}" |
| return 0.0, min(1.0, len(trade_sizes) / 200.0), f"Normal clustering {round_ratio:.0%}" |
|
|
|
|
| def inter_trade_timing(timestamps: list[float]) -> tuple[float, float, str]: |
| """Bot-like regularity in trade timing (coefficient of variation).""" |
| if len(timestamps) < 10: |
| return 0.0, 0.0, "insufficient_data" |
|
|
| intervals = np.diff(sorted(timestamps)) |
| intervals = intervals[intervals > 0] |
|
|
| if len(intervals) < 5: |
| return 0.0, 0.0, "insufficient_data" |
|
|
| cv = np.std(intervals) / np.mean(intervals) if np.mean(intervals) > 0 else 1.0 |
|
|
| |
| if cv < 0.05: |
| return 0.9, 0.85, f"Mechanical timing CV={cv:.3f}" |
| elif cv < 0.1: |
| return 0.6, 0.7, f"Regular timing CV={cv:.3f}" |
| elif cv < 0.2: |
| return 0.2, 0.5, f"Semi-regular CV={cv:.3f}" |
| return 0.0, 0.6, f"Natural timing CV={cv:.3f}" |
|
|
|
|
| |
|
|
|
|
| def volume_liquidity_ratio(volume_24h: float, liquidity: float) -> tuple[float, float, str]: |
| """Volume-to-liquidity ratio. >10x is critical wash trading indicator.""" |
| if liquidity <= 0: |
| return 0.0, 0.0, "no_liquidity" |
|
|
| ratio = volume_24h / liquidity |
| if ratio > 10: |
| return 0.95, 0.9, f"Critical V/L={ratio:.1f}x" |
| elif ratio > 5: |
| return 0.7, 0.8, f"High V/L={ratio:.1f}x" |
| elif ratio > 2: |
| return 0.3, 0.6, f"Elevated V/L={ratio:.1f}x" |
| return 0.0, 0.5, f"Normal V/L={ratio:.1f}x" |
|
|
|
|
| def wallet_concentration_gini(wallet_volumes: dict[str, float]) -> tuple[float, float, str]: |
| """Gini coefficient for wallet volume distribution.""" |
| if len(wallet_volumes) < 2: |
| return 0.0, 0.0, "insufficient_wallets" |
|
|
| volumes = sorted(wallet_volumes.values()) |
| n = len(volumes) |
| total = sum(volumes) |
| if total <= 0: |
| return 0.0, 0.0, "zero_volume" |
|
|
| |
| gini = (2 * sum((i + 1) * volumes[i] for i in range(n))) / (n * total) - (n + 1) / n |
|
|
| if gini > 0.8: |
| return 0.9, min(1.0, n / 50.0), f"Extreme concentration Gini={gini:.2f}" |
| elif gini > 0.5: |
| return 0.5, min(1.0, n / 30.0), f"High concentration Gini={gini:.2f}" |
| return 0.0, min(1.0, n / 20.0), f"Normal Gini={gini:.2f}" |
|
|
|
|
| |
|
|
|
|
| def buy_sell_ratio_anomaly(buy_count: int, sell_count: int) -> tuple[float, float, str]: |
| """Extreme buy/sell ratios suggest chart painting.""" |
| total = buy_count + sell_count |
| if total < 10: |
| return 0.0, 0.0, "insufficient_trades" |
|
|
| buy_ratio = buy_count / total if total > 0 else 0.5 |
|
|
| |
| if buy_ratio > 0.8 or buy_ratio < 0.2: |
| return 0.8, min(1.0, total / 50.0), f"Extreme ratio {buy_ratio:.0%} buy" |
| elif buy_ratio > 0.7 or buy_ratio < 0.3: |
| return 0.4, min(1.0, total / 30.0), f"Suspicious ratio {buy_ratio:.0%} buy" |
| return 0.0, min(1.0, total / 20.0), f"Normal ratio {buy_ratio:.0%} buy" |
|
|
|
|
| def unique_wallets_check(unique_wallets: int) -> tuple[float, float, str]: |
| """Low unique wallet count = likely wash trading cluster.""" |
| if unique_wallets < 10: |
| return 0.9, 0.6, f"Critical: {unique_wallets} wallets" |
| elif unique_wallets < 50: |
| return 0.6, 0.5, f"Low: {unique_wallets} wallets" |
| elif unique_wallets < 100: |
| return 0.3, 0.5, f"Moderate: {unique_wallets} wallets" |
| return 0.0, 0.7, f"Healthy: {unique_wallets} wallets" |
|
|
|
|
| def tx_per_wallet(avg_tx_per_wallet: float) -> tuple[float, float, str]: |
| """High tx per wallet suggests bot operations.""" |
| if avg_tx_per_wallet > 20: |
| return 0.85, 0.7, f"Bot-like: {avg_tx_per_wallet:.1f} tx/wallet" |
| elif avg_tx_per_wallet > 10: |
| return 0.5, 0.6, f"Elevated: {avg_tx_per_wallet:.1f} tx/wallet" |
| elif avg_tx_per_wallet > 5: |
| return 0.2, 0.5, f"Moderate: {avg_tx_per_wallet:.1f} tx/wallet" |
| return 0.0, 0.5, f"Normal: {avg_tx_per_wallet:.1f} tx/wallet" |
|
|
|
|
| |
|
|
|
|
| class VolumeAuthenticityScorer: |
| """Multi-layer volume authenticity scoring. |
| |
| Weights (from RugCharts spec): |
| statistical: 0.25 |
| vl_ratio: 0.20 |
| wallet_concentration: 0.20 |
| graph: 0.20 |
| buy_sell: 0.15 |
| """ |
|
|
| DEFAULT_WEIGHTS = { |
| "statistical": 0.25, |
| "vl_ratio": 0.20, |
| "wallet_concentration": 0.20, |
| "graph": 0.20, |
| "buy_sell": 0.15, |
| } |
|
|
| def __init__(self, weights: dict[str, float] | None = None): |
| self.weights = weights or self.DEFAULT_WEIGHTS.copy() |
|
|
| def compute(self, signals: list[DetectionSignal], tx_count: int) -> AuthenticityResult: |
| """Compute fake volume % from all detection signals.""" |
| result = AuthenticityResult() |
|
|
| |
| category_scores: dict[str, list[float]] = defaultdict(list) |
| category_confs: dict[str, list[float]] = defaultdict(list) |
|
|
| for sig in signals: |
| cat = self._categorize_signal(sig.source) |
| category_scores[cat].append(sig.score) |
| category_confs[cat].append(sig.confidence) |
|
|
| |
| weighted_sum = 0.0 |
| weight_total = 0.0 |
| breakdown = {} |
|
|
| for cat, w in self.weights.items(): |
| if cat not in category_scores: |
| continue |
| scores = category_scores[cat] |
| confs = category_confs[cat] |
| |
| total_conf = sum(confs) |
| avg = np.average(scores, weights=confs) if total_conf > 0 else np.mean(scores) |
| weighted_sum += w * avg |
| weight_total += w |
| breakdown[cat] = round(avg * 100, 1) |
|
|
| if weight_total == 0: |
| result.fake_volume_pct = 0.0 |
| result.authentic_score = 100.0 |
| result.confidence = 0.0 |
| result.ci_lower = 0.0 |
| result.ci_upper = 0.0 |
| result.component_breakdown = {} |
| result.data_quality = "insufficient" |
| result.method_count = 0 |
| result.signals = [s.to_dict() for s in signals] |
| result.risk_level = "UNKNOWN" |
| result.scan_timestamp = datetime.utcnow().isoformat() |
| return result |
|
|
| |
| fake_pct = (weighted_sum / weight_total) * 100 |
|
|
| |
| method_coverage = len(breakdown) / len(self.weights) |
| data_suff = min(tx_count / 1000, 1.0) |
| conf = method_coverage * data_suff |
|
|
| |
| ci_lo, ci_hi = self._bootstrap_ci(signals, weight_total) |
|
|
| |
| if tx_count >= 1000: |
| quality = "high" |
| elif tx_count >= 100: |
| quality = "medium" |
| else: |
| quality = "low" |
|
|
| |
| if fake_pct >= 80: |
| risk = "CRITICAL" |
| elif fake_pct >= 50: |
| risk = "HIGH" |
| elif fake_pct >= 20: |
| risk = "MEDIUM" |
| else: |
| risk = "LOW" |
|
|
| result.fake_volume_pct = round(fake_pct, 1) |
| result.authentic_score = round(100 - fake_pct, 1) |
| result.confidence = round(conf * 100, 1) |
| result.ci_lower = round(ci_lo, 1) |
| result.ci_upper = round(ci_hi, 1) |
| result.component_breakdown = breakdown |
| result.data_quality = quality |
| result.method_count = len(breakdown) |
| result.signals = [s.to_dict() for s in signals] |
| result.risk_level = risk |
| result.scan_timestamp = datetime.utcnow().isoformat() |
|
|
| return result |
|
|
| def _categorize_signal(self, source: str) -> str: |
| """Map signal source to weight category.""" |
| stat_signals = {"benford", "trade_clustering", "inter_trade_timing"} |
| vl_signals = {"vl_ratio"} |
| wc_signals = {"gini", "unique_wallets", "tx_per_wallet"} |
| graph_signals = {"common_funder", "scc", "cycle_detect", "self_trade"} |
| bs_signals = {"buy_sell_ratio"} |
|
|
| if source in stat_signals: |
| return "statistical" |
| elif source in vl_signals: |
| return "vl_ratio" |
| elif source in wc_signals: |
| return "wallet_concentration" |
| elif source in graph_signals: |
| return "graph" |
| elif source in bs_signals: |
| return "buy_sell" |
| return "statistical" |
|
|
| def _bootstrap_ci( |
| self, |
| signals: list[DetectionSignal], |
| w_total: float, |
| n_bootstrap: int = 1000, |
| ci: float = 0.95, |
| ) -> tuple[float, float]: |
| """Bootstrap confidence interval for fake volume estimate.""" |
| if not signals: |
| return 0.0, 0.0 |
|
|
| weights = [self.weights.get(self._categorize_signal(s.source), 0.2) for s in signals] |
| scores = [s.score for s in signals] |
|
|
| estimates = [] |
| rng = np.random.RandomState(42) |
| for _ in range(n_bootstrap): |
| idx = rng.choice(len(signals), size=len(signals), replace=True) |
| w_sum = sum(weights[i] for i in idx) |
| if w_sum > 0: |
| est = np.average([scores[i] for i in idx], weights=[weights[i] for i in idx]) * 100 |
| estimates.append(est) |
|
|
| if not estimates: |
| return 0.0, 0.0 |
|
|
| alpha = 1 - ci |
| return ( |
| np.percentile(estimates, alpha / 2 * 100), |
| np.percentile(estimates, (1 - alpha / 2) * 100), |
| ) |
|
|
|
|
| |
|
|
|
|
| def _redis_connect(): |
| return redis.Redis( |
| host=REDIS_HOST, |
| port=REDIS_PORT, |
| password=REDIS_PASSWORD, |
| decode_responses=True, |
| socket_connect_timeout=2, |
| ) |
|
|
|
|
| async def analyze_volume_authenticity(address: str = "", chain: str = "ethereum", **kw) -> dict | None: |
| """DataBus provider: Full fake volume analysis for a token pair. |
| |
| Collects trade data from available sources, runs through all 4 detection layers, |
| and returns AuthenticityResult with fake_volume_pct and Authentic Score. |
| """ |
| if not address: |
| return None |
|
|
| cache_key = f"volume_auth:{chain}:{address}" |
| try: |
| r = _redis_connect() |
| cached = r.get(cache_key) |
| if cached: |
| r.close() |
| return json.loads(cached) |
| r.close() |
| except Exception: |
| pass |
|
|
| signals = [] |
| tx_count = 0 |
| volume_24h = float(kw.get("volume_24h", 0)) |
| liquidity = float(kw.get("liquidity_usd", 0)) |
| unique_wallets = int(kw.get("unique_wallets", 0)) |
| buy_count = int(kw.get("buy_count", 0)) |
| sell_count = int(kw.get("sell_count", 0)) |
|
|
| |
| trade_sizes = kw.get("trade_sizes", []) |
| timestamps = kw.get("timestamps", []) |
| wallet_volumes = kw.get("wallet_volumes", {}) |
|
|
| |
| if not trade_sizes and chain in ("solana", "ethereum", "bsc", "base"): |
| try: |
| import httpx |
|
|
| api_key = kw.get("api_key", "") or os.getenv("HELIUS_API_KEY", "") |
| if chain == "solana" and api_key: |
| async with httpx.AsyncClient(timeout=10) as c: |
| |
| r = await c.post( |
| f"https://mainnet.helius-rpc.com/?api-key={api_key}", |
| json={ |
| "jsonrpc": "2.0", |
| "id": 1, |
| "method": "getSignaturesForAddress", |
| "params": [address, {"limit": 50}], |
| }, |
| ) |
| if r.status_code == 200: |
| sigs = r.json().get("result", []) |
| tx_count = len(sigs) |
| timestamps = [s.get("blockTime", 0) for s in sigs if s.get("blockTime")] |
| |
| except Exception as e: |
| logger.debug(f"Tx fetch failed: {e}") |
|
|
| |
| if trade_sizes: |
| b_score, b_conf, b_detail = benfords_law_test(trade_sizes) |
| signals.append(DetectionSignal("benford", b_score, b_conf, b_detail)) |
|
|
| c_score, c_conf, c_detail = trade_size_clustering(trade_sizes) |
| signals.append(DetectionSignal("trade_clustering", c_score, c_conf, c_detail)) |
|
|
| if timestamps: |
| t_score, t_conf, t_detail = inter_trade_timing(timestamps) |
| signals.append(DetectionSignal("inter_trade_timing", t_score, t_conf, t_detail)) |
|
|
| |
| if volume_24h > 0 or liquidity > 0: |
| v_score, v_conf, v_detail = volume_liquidity_ratio(volume_24h, liquidity) |
| signals.append(DetectionSignal("vl_ratio", v_score, v_conf, v_detail)) |
|
|
| if wallet_volumes: |
| g_score, g_conf, g_detail = wallet_concentration_gini(wallet_volumes) |
| signals.append(DetectionSignal("gini", g_score, g_conf, g_detail)) |
|
|
| |
| if buy_count + sell_count > 0: |
| bs_score, bs_conf, bs_detail = buy_sell_ratio_anomaly(buy_count, sell_count) |
| signals.append(DetectionSignal("buy_sell_ratio", bs_score, bs_conf, bs_detail)) |
|
|
| if unique_wallets > 0: |
| uw_score, uw_conf, uw_detail = unique_wallets_check(unique_wallets) |
| signals.append(DetectionSignal("unique_wallets", uw_score, uw_conf, uw_detail)) |
|
|
| avg_tx = tx_count / unique_wallets if unique_wallets > 0 else 0 |
| tx_score, tx_conf, tx_detail = tx_per_wallet(avg_tx) |
| signals.append(DetectionSignal("tx_per_wallet", tx_score, tx_conf, tx_detail)) |
|
|
| |
| scorer = VolumeAuthenticityScorer() |
| result = scorer.compute(signals, max(tx_count, len(trade_sizes))) |
|
|
| |
| output = { |
| **result.to_dict(), |
| "token_address": address, |
| "chain": chain, |
| "tx_count": max(tx_count, len(trade_sizes)), |
| "unique_wallets": unique_wallets, |
| "volume_24h_usd": volume_24h, |
| "liquidity_usd": liquidity, |
| "signals": result.signals, |
| } |
|
|
| |
| try: |
| r = _redis_connect() |
| r.setex(cache_key, CACHE_TTL, json.dumps(output, default=str)) |
| r.close() |
| except Exception: |
| pass |
|
|
| return output |
|
|
|
|
| |
|
|
|
|
| def quick_authenticity_score( |
| volume_24h: float, |
| liquidity: float, |
| unique_wallets: int, |
| tx_count: int, |
| buy_count: int = 0, |
| sell_count: int = 0, |
| ) -> dict: |
| """Fast authenticity check with minimal data. Returns fake_volume_pct + risk.""" |
| signals = [] |
| if liquidity > 0: |
| v_score, v_conf, v_detail = volume_liquidity_ratio(volume_24h, liquidity) |
| signals.append(DetectionSignal("vl_ratio", v_score, v_conf, v_detail)) |
| if unique_wallets > 0: |
| uw_score, uw_conf, uw_detail = unique_wallets_check(unique_wallets) |
| signals.append(DetectionSignal("unique_wallets", uw_score, uw_conf, uw_detail)) |
| avg_tx = tx_count / unique_wallets if unique_wallets > 0 else 0 |
| tx_score, tx_conf, tx_detail = tx_per_wallet(avg_tx) |
| signals.append(DetectionSignal("tx_per_wallet", tx_score, tx_conf, tx_detail)) |
| if buy_count + sell_count > 0: |
| bs_score, bs_conf, bs_detail = buy_sell_ratio_anomaly(buy_count, sell_count) |
| signals.append(DetectionSignal("buy_sell_ratio", bs_score, bs_conf, bs_detail)) |
|
|
| scorer = VolumeAuthenticityScorer() |
| result = scorer.compute(signals, tx_count) |
| return result.to_dict() |
|
|