""" Price Consensus Engine - Multi-Source Aggregation with MAD Outlier Detection. Queries 7+ price sources in parallel, applies Median Absolute Deviation (MAD) outlier filtering (z-score > 3 = outlier), and computes a weighted mean price using source reliability scores. Sources: DexScreener, GeckoTerminal, Jupiter (Solana), DIA, CoinGecko, CryptoCompare, Coinpaprika - all free tier, no paid keys required. Depends on: httpx, numpy (for median/percentile), optional env keys. """ import asyncio import logging import os import time from dataclasses import dataclass, field from typing import Any import httpx import numpy as np logger = logging.getLogger(__name__) # ── Source Reliability Scores (0.0-1.0, higher = more trusted) ───────────── # These are initial weights based on historical accuracy, API stability, # and data freshness. They can be adjusted via _source_stats over time. DEFAULT_SOURCE_WEIGHTS = { "dexscreener": 0.90, # Direct DEX data, excellent for on-chain tokens "geckoterminal": 0.92, # CoinGecko's DEX aggregator, very reliable "jupiter": 0.88, # Solana's primary aggregator, excellent for Solana "dia": 0.85, # Oracle-grade data, transparent methodology "coingecko": 0.88, # CEX + DEX aggregation, broad coverage "cryptocompare": 0.82, # Institutional-grade, slower updates on microcaps "coinpaprika": 0.78, # Good coverage, slightly less reliable on low-cap "birdeye": 0.86, # Good Solana coverage, needs API key } # ── Data Classes ──────────────────────────────────────────────────────────── @dataclass class PriceSource: """A single price data provider.""" name: str weight: float # Reliability score 0-1 fetcher: Any = None # Async callable: (address, chain) → Optional[float] last_price: float = 0.0 last_latency: float = 0.0 error_count: int = 0 @dataclass class PriceConsensus: """Result of multi-source price consensus.""" price: float | None = None confidence: float = 0.0 # 0-100% sources_used: list[str] = field(default_factory=list) outlier_sources: list[str] = field(default_factory=list) failed_sources: list[str] = field(default_factory=list) individual_prices: dict[str, float] = field(default_factory=dict) median: float | None = None mad: float | None = None std_dev: float | None = None spread_pct: float | None = None # (max-min)/median * 100 @property def is_reliable(self) -> bool: return self.confidence >= 60.0 and self.price is not None # ── Price Consensus Engine ───────────────────────────────────────────────── class PriceConsensusEngine: """Multi-source price aggregation with MAD-based outlier rejection. Fetches from all configured sources in parallel, removes statistical outliers (z-score > 3 using Median Absolute Deviation), and computes a weighted mean of the remaining prices. Falls back gracefully if fewer than 2 sources respond. """ # Timeout per source fetch PER_SOURCE_TIMEOUT = 10.0 # If a source fails this many times consecutively, lower its effective weight MAX_CONSECUTIVE_ERRORS = 5 def __init__(self): self._sources: dict[str, PriceSource] = {} self._lock = asyncio.Lock() self._setup_sources() def _setup_sources(self): """Register all price sources with their fetcher callables.""" sources = [ ("dexscreener", self._fetch_dexscreener, DEFAULT_SOURCE_WEIGHTS["dexscreener"]), ("geckoterminal", self._fetch_geckoterminal, DEFAULT_SOURCE_WEIGHTS["geckoterminal"]), ("jupiter", self._fetch_jupiter, DEFAULT_SOURCE_WEIGHTS["jupiter"]), ("dia", self._fetch_dia, DEFAULT_SOURCE_WEIGHTS["dia"]), ("coingecko", self._fetch_coingecko, DEFAULT_SOURCE_WEIGHTS["coingecko"]), ("cryptocompare", self._fetch_cryptocompare, DEFAULT_SOURCE_WEIGHTS["cryptocompare"]), ("coinpaprika", self._fetch_coinpaprika, DEFAULT_SOURCE_WEIGHTS["coinpaprika"]), ] # Birdeye if key is available birdeye_key = os.getenv("BIRDEYE_API_KEY", "") if birdeye_key and birdeye_key != "your_birdeye_key_here": sources.append(("birdeye", self._fetch_birdeye, DEFAULT_SOURCE_WEIGHTS["birdeye"])) for name, fetcher, weight in sources: self._sources[name] = PriceSource(name=name, weight=weight, fetcher=fetcher) logger.info(f"PriceConsensusEngine: {len(self._sources)} sources registered: {list(self._sources.keys())}") # ── Source Fetchers ────────────────────────────────────────────────── async def _fetch_dexscreener(self, address: str, chain: str) -> float | None: """DexScreener free API - no key required.""" try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( f"https://api.dexscreener.com/latest/dex/tokens/{address}", headers={"Accept": "application/json"}, ) if r.status_code == 200: data = r.json() pairs = data.get("pairs", []) if pairs: # Find the pair with highest liquidity best = max(pairs, key=lambda p: float(p.get("liquidity", {}).get("usd", 0) or 0)) price = best.get("priceUsd") if price: return float(price) return None except Exception as e: logger.debug(f"DexScreener fetch error: {e}") return None async def _fetch_geckoterminal(self, address: str, chain: str) -> float | None: """GeckoTerminal free API - no key required.""" network = self._chain_to_gecko_network(chain) try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( f"https://api.geckoterminal.com/api/v2/networks/{network}/tokens/{address}", headers={"Accept": "application/json"}, ) if r.status_code == 200: data = r.json() token_data = data.get("data", {}) attrs = token_data.get("attributes", {}) price = attrs.get("price_usd") if price: return float(price) return None except Exception as e: logger.debug(f"GeckoTerminal fetch error: {e}") return None async def _fetch_jupiter(self, address: str, chain: str) -> float | None: """Jupiter price API - free, Solana only.""" if chain.lower() not in ("solana", "sol"): return None try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( f"https://price.jup.ag/v6/price?ids={address}", headers={"Accept": "application/json"}, ) if r.status_code == 200: data = r.json() token_data = data.get("data", {}).get(address) if token_data: price = token_data.get("price") if price: return float(price) return None except Exception as e: logger.debug(f"Jupiter fetch error: {e}") return None async def _fetch_dia(self, address: str, chain: str) -> float | None: """DIA oracle price feed - free, no key.""" dia_chain = self._chain_to_dia_chain(chain) if not dia_chain: return None try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( f"https://api.diadata.org/v1/assetQuotation/{dia_chain}/{address}", headers={"Accept": "application/json"}, ) if r.status_code == 200: data = r.json() price = data.get("Price") if price: return float(price) return None except Exception as e: logger.debug(f"DIA fetch error: {e}") return None async def _fetch_coingecko(self, address: str, chain: str) -> float | None: """CoinGecko token price by contract - free tier.""" cg_chain = self._chain_to_coingecko_platform(chain) if not cg_chain: return None api_key = os.getenv("COINGECKO_API_KEY", "") headers = {"Accept": "application/json"} if api_key: headers["x-cg-demo-api-key"] = api_key try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( f"https://api.coingecko.com/api/v3/simple/token_price/{cg_chain}", params={ "contract_addresses": address, "vs_currencies": "usd", }, headers=headers, ) if r.status_code == 200: data = r.json() price = data.get(address.lower(), {}).get("usd") if price: return float(price) return None except Exception as e: logger.debug(f"CoinGecko fetch error: {e}") return None async def _fetch_cryptocompare(self, address: str, chain: str) -> float | None: """CryptoCompare price API - free tier.""" api_key = os.getenv("CRYPTOCOMPARE_API_KEY", "") headers = {"Accept": "application/json"} if api_key: headers["authorization"] = f"Apikey {api_key}" try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( "https://min-api.cryptocompare.com/data/price", params={ "fsym": address, "tsyms": "USD", }, headers=headers, ) if r.status_code == 200: data = r.json() price = data.get("USD") if price: return float(price) return None except Exception as e: logger.debug(f"CryptoCompare fetch error: {e}") return None async def _fetch_coinpaprika(self, address: str, chain: str) -> float | None: """Coinpaprika free API - no key required.""" try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: # Try by contract address lookup r = await client.get( f"https://api.coinpaprika.com/v1/contracts/{chain}/{address}", headers={"Accept": "application/json"}, ) if r.status_code == 200: data = r.json() coin_id = data.get("id") if coin_id: # Get ticker for this coin r2 = await client.get( f"https://api.coinpaprika.com/v1/tickers/{coin_id}", headers={"Accept": "application/json"}, ) if r2.status_code == 200: ticker = r2.json() price = ticker.get("quotes", {}).get("USD", {}).get("price") if price: return float(price) return None except Exception as e: logger.debug(f"Coinpaprika fetch error: {e}") return None async def _fetch_birdeye(self, address: str, chain: str) -> float | None: """Birdeye price API - requires BIRDEYE_API_KEY.""" api_key = os.getenv("BIRDEYE_API_KEY", "") if not api_key: return None try: async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: r = await client.get( "https://public-api.birdeye.so/defi/price", params={"address": address}, headers={ "X-API-KEY": api_key, "accept": "application/json", }, ) if r.status_code == 200: data = r.json() price = data.get("data", {}).get("value") if price: return float(price) return None except Exception as e: logger.debug(f"Birdeye fetch error: {e}") return None # ── Chain Name Normalization ────────────────────────────────────────── @staticmethod def _chain_to_gecko_network(chain: str) -> str: mapping = { "solana": "solana", "sol": "solana", "ethereum": "eth", "eth": "eth", "1": "eth", "base": "base", "8453": "base", "bsc": "bsc", "56": "bsc", "bnb": "bsc", "arbitrum": "arbitrum", "42161": "arbitrum", "polygon": "polygon_pos", "137": "polygon_pos", "matic": "polygon_pos", "optimism": "optimism", "10": "optimism", "avalanche": "avax", "43114": "avax", "fantom": "fantom", "250": "fantom", } return mapping.get(chain.lower(), chain.lower()) @staticmethod def _chain_to_dia_chain(chain: str) -> str | None: mapping = { "solana": "Solana", "sol": "Solana", "ethereum": "Ethereum", "eth": "Ethereum", "1": "Ethereum", "base": "Base", "8453": "Base", "bsc": "BSC", "56": "BSC", "bnb": "BSC", "arbitrum": "Arbitrum", "42161": "Arbitrum", "polygon": "Polygon", "137": "Polygon", "optimism": "Optimism", "10": "Optimism", } return mapping.get(chain.lower()) @staticmethod def _chain_to_coingecko_platform(chain: str) -> str | None: mapping = { "solana": "solana", "sol": "solana", "ethereum": "ethereum", "eth": "ethereum", "1": "ethereum", "base": "base", "8453": "base", "bsc": "binance-smart-chain", "56": "binance-smart-chain", "bnb": "binance-smart-chain", "arbitrum": "arbitrum-one", "42161": "arbitrum-one", "polygon": "polygon-pos", "137": "polygon-pos", "matic": "polygon-pos", "optimism": "optimistic-ethereum", "10": "optimistic-ethereum", "avalanche": "avalanche", "43114": "avalanche", "fantom": "fantom", "250": "fantom", } return mapping.get(chain.lower()) # ── Core Consensus Logic ────────────────────────────────────────────── async def get_consensus_price( self, token_address: str, chain: str = "solana", ) -> PriceConsensus: """Fetch prices from all sources and compute consensus. Args: token_address: Token contract address / mint chain: Blockchain identifier (solana, ethereum, base, etc.) Returns: PriceConsensus with consensus price, confidence, and breakdown. """ if not self._sources: return PriceConsensus( price=None, confidence=0.0, failed_sources=["no_sources_configured"], ) # Fire all source fetchers in parallel tasks = [] source_names = [] for name, source in self._sources.items(): tasks.append(source.fetcher(token_address, chain)) source_names.append(name) start = time.monotonic() results = await asyncio.gather(*tasks, return_exceptions=True) # Collect successful prices and track failures prices: dict[str, float] = {} failed: list[str] = [] for name, result in zip(source_names, results, strict=False): if isinstance(result, Exception): logger.debug(f"Source {name} exception: {result}") failed.append(name) async with self._lock: if name in self._sources: self._sources[name].error_count += 1 elif result is not None and isinstance(result, (int, float)): if result > 0: prices[name] = float(result) latency = time.monotonic() - start async with self._lock: if name in self._sources: self._sources[name].last_price = float(result) self._sources[name].last_latency = latency self._sources[name].error_count = 0 else: failed.append(name) # If no sources returned a price, return null consensus if not prices: logger.warning(f"No price sources responded for {token_address} on {chain}") return PriceConsensus( price=None, confidence=0.0, failed_sources=failed, ) price_values = list(prices.values()) price_names = list(prices.keys()) # Single source: return it but with low confidence if len(price_values) == 1: return PriceConsensus( price=price_values[0], confidence=30.0, sources_used=price_names, failed_sources=failed, individual_prices=prices, median=price_values[0], ) # ── MAD-based Outlier Detection ───────────────────────────────── arr = np.array(price_values) median = float(np.median(arr)) mad = float(np.median(np.abs(arr - median))) # If MAD is zero (all prices identical), no outliers if mad == 0: weighted_avg = self._weighted_mean(prices) return PriceConsensus( price=weighted_avg, confidence=95.0 if len(price_values) >= 3 else 70.0, sources_used=price_names, outlier_sources=[], failed_sources=failed, individual_prices=prices, median=median, mad=0.0, std_dev=0.0, spread_pct=0.0, ) # Compute modified z-scores using MAD # z_i = 0.6745 * (x_i - median) / MAD z_scores = 0.6745 * (arr - median) / mad # Outlier threshold: |z| > 3 (very conservative - classic threshold) inliers_mask = np.abs(z_scores) <= 3.0 outliers_mask = ~inliers_mask inlier_prices = { name: price for name, price, is_inlier in zip(price_names, price_values, inliers_mask, strict=False) if is_inlier } outlier_names = [ name for name, price, is_outlier in zip(price_names, price_values, outliers_mask, strict=False) if is_outlier ] # If all prices are outliers, fall back to all with low confidence if not inlier_prices: logger.warning(f"All prices flagged as outliers for {token_address} - using all with low confidence") weighted_avg = self._weighted_mean(prices) return PriceConsensus( price=weighted_avg, confidence=10.0, sources_used=price_names, outlier_sources=[], failed_sources=failed, individual_prices=prices, median=median, mad=float(mad), std_dev=float(np.std(arr)), spread_pct=self._spread_pct(price_values), ) # Compute weighted mean of inliers consensus_price = self._weighted_mean(inlier_prices) # Confidence calculation total_sources = len(self._sources) inlier_count = len(inlier_prices) responder_count = len(price_values) # Base confidence from inlier agreement ratio if inlier_count >= 3: agreement_ratio = inlier_count / responder_count confidence = agreement_ratio * 85.0 + 10.0 # 70-95 range elif inlier_count == 2: confidence = 55.0 else: confidence = 35.0 # Penalize if we had many failures failure_penalty = (len(failed) / max(total_sources, 1)) * 20.0 confidence = max(5.0, confidence - failure_penalty) # Bonus for low spread among inliers inlier_values = list(inlier_prices.values()) if len(inlier_values) >= 2: spread = self._spread_pct(inlier_values) if spread is not None and spread < 2.0: confidence = min(100.0, confidence + 10.0) return PriceConsensus( price=round(consensus_price, 12), confidence=round(confidence, 1), sources_used=list(inlier_prices.keys()), outlier_sources=outlier_names, failed_sources=failed, individual_prices=prices, median=round(median, 12), mad=round(float(mad), 12) if mad else None, std_dev=round(float(np.std(arr)), 12), spread_pct=self._spread_pct(price_values), ) # ── Helpers ─────────────────────────────────────────────────────────── def _weighted_mean(self, prices: dict[str, float]) -> float: """Weighted mean using source reliability weights, adjusted by error history.""" if not prices: return 0.0 total_weight = 0.0 weighted_sum = 0.0 for name, price in prices.items(): source = self._sources.get(name) if source: # Reduce weight if source has errors error_penalty = min(0.5, source.error_count * 0.1) weight = source.weight * (1.0 - error_penalty) else: weight = 0.5 weighted_sum += price * weight total_weight += weight return weighted_sum / total_weight if total_weight > 0 else 0.0 @staticmethod def _spread_pct(values: list[float]) -> float | None: """(max - min) / median * 100. Lower = more consensus.""" if len(values) < 2: return None arr = np.array(values) median = float(np.median(arr)) if median == 0: return None return round(float((arr.max() - arr.min()) / median * 100), 2) # ── Stats ───────────────────────────────────────────────────────────── async def stats(self) -> dict[str, Any]: """Return per-source stats and aggregate metrics.""" source_stats = {} async with self._lock: for name, src in self._sources.items(): source_stats[name] = { "weight": src.weight, "effective_weight": round(src.weight * (1.0 - min(0.5, src.error_count * 0.1)), 3), "last_price": src.last_price, "last_latency": round(src.last_latency, 3), "error_count": src.error_count, } return { "total_sources": len(self._sources), "sources": source_stats, } # ── Singleton ───────────────────────────────────────────────────────────── _price_engine: PriceConsensusEngine | None = None def get_price_consensus() -> PriceConsensusEngine: """Get the global PriceConsensusEngine singleton.""" global _price_engine if _price_engine is None: _price_engine = PriceConsensusEngine() return _price_engine