#!/usr/bin/env python3 """Market data API client for live token prices. Calls Ben's teleo-ai-api endpoint for ownership coin prices. Used by the Telegram bot to give Rio real-time market context. Epimetheus owns this module. Rhea: static API key pattern. """ import logging import re from pathlib import Path import aiohttp logger = logging.getLogger("market-data") API_URL = "https://teleo-ai-api-257133920458.us-east4.run.app/v0/chat/tool/market-data" API_KEY_FILE = "/opt/teleo-eval/secrets/market-data-key" DEXSCREENER_SEARCH_URL = "https://api.dexscreener.com/latest/dex/search" ENTITY_TOKEN_MAP = { "omnipair": "OMFG", "omfg": "OMFG", "avici": "AVICI", "umbra": "UMBRA", "metadao": "META", "sanctum": "CLOUD", "drift": "DRIFT", "ore": "ORE", "jupiter": "JUP", } _BARE_TICKER_STOPWORDS = { "FDV", "TVL", "API", "USD", "USDC", "SOL", "YES", "NO", "BUY", "SELL", } # Cache: avoid hitting the API on every message _cache: dict[str, dict] = {} # token_name → {data, timestamp} CACHE_TTL = 300 # 5 minutes def extract_market_data_tokens(text: str, extra_terms: list[str] | tuple[str, ...] = ()) -> list[str]: """Extract token tickers from market-data questions while preserving order.""" seen: set[str] = set() tokens: list[str] = [] def add(token: str | None) -> None: if not token: return normalized = token.upper().strip("$") if len(normalized) < 2 or normalized in _BARE_TICKER_STOPWORDS or normalized in seen: return seen.add(normalized) tokens.append(normalized) for ticker in re.findall(r"\$([A-Za-z][A-Za-z0-9]{1,9})\b", text): add(ticker) marketish = re.search( r"\b(price|volume|fdv|fully\s+diluted|market\s+cap|mcap|liquidity|buy|sell|token|coin)\b", text, flags=re.IGNORECASE, ) if marketish: for ticker in re.findall(r"\b([A-Z][A-Z0-9]{1,9})\b", text): add(ticker) lowered = text.lower() for name, ticker in ENTITY_TOKEN_MAP.items(): if re.search(rf"\b{re.escape(name)}\b", lowered): add(ticker) for term in extra_terms: term_upper = str(term).upper().strip("$") if term_upper in ENTITY_TOKEN_MAP.values(): add(term_upper) return tokens def _format_usd(value) -> str | None: try: number = float(value) except (TypeError, ValueError): return None if number >= 1_000_000_000: return f"${number / 1_000_000_000:.2f}B" if number >= 1_000_000: return f"${number / 1_000_000:.2f}M" if number >= 1_000: return f"${number / 1_000:.2f}K" return f"${number:,.2f}" def _needs_public_market_augmentation(data: dict) -> bool: result = str(data.get("result") or "").lower() if not result: return True return "fdv" not in result or "volume" not in result def _merge_market_data(primary: dict, public: dict) -> dict: merged = dict(primary) primary_result = str(primary.get("result") or "").strip() public_result = str(public.get("result") or "").strip() if primary_result and public_result: merged["result"] = f"{primary_result}\n{public_result}" elif public_result: merged["result"] = public_result merged["public_market_data"] = { k: v for k, v in public.items() if k != "pair" } merged["public_market_pair"] = public.get("pair") return merged def _dex_pair_score(pair: dict, token: str) -> tuple[int, float]: token_lower = token.lower() base = pair.get("baseToken") or {} symbol = str(base.get("symbol") or "").lower() name = str(base.get("name") or "").lower() score = 0 if symbol == token_lower: score += 100 elif token_lower in symbol: score += 50 if token_lower in name: score += 25 liquidity = ((pair.get("liquidity") or {}).get("usd") or 0) try: liquidity_value = float(liquidity) except (TypeError, ValueError): liquidity_value = 0 return score, liquidity_value async def _get_dexscreener_token_data(token_name: str) -> dict | None: token_upper = token_name.upper().strip("$") try: async with aiohttp.ClientSession() as session: async with session.get( DEXSCREENER_SEARCH_URL, params={"q": token_upper}, timeout=aiohttp.ClientTimeout(total=10), ) as resp: if resp.status >= 400: logger.warning("DexScreener %s -> %d", token_upper, resp.status) return None body = await resp.json() except Exception as e: logger.warning("DexScreener error for %s: %s", token_upper, e) return None pairs = body.get("pairs") or [] if not pairs: return None best = max(pairs, key=lambda pair: _dex_pair_score(pair, token_upper)) score, _ = _dex_pair_score(best, token_upper) if score <= 0: return None volume_24h = (best.get("volume") or {}).get("h24") liquidity_usd = (best.get("liquidity") or {}).get("usd") price_change_24h = (best.get("priceChange") or {}).get("h24") base = best.get("baseToken") or {} fdv = best.get("fdv") market_cap = best.get("marketCap") price = best.get("priceUsd") parts = [ f"Live market data for {token_upper}", f"source: DexScreener", f"pair: {base.get('symbol') or token_upper} on {best.get('chainId', 'unknown')}/{best.get('dexId', 'unknown')}", ] if price: parts.append(f"price: ${price}") formatted_fdv = _format_usd(fdv) if formatted_fdv: parts.append(f"FDV: {formatted_fdv}") formatted_mcap = _format_usd(market_cap) if formatted_mcap: parts.append(f"market cap: {formatted_mcap}") formatted_volume = _format_usd(volume_24h) if formatted_volume: parts.append(f"24h volume: {formatted_volume}") formatted_liquidity = _format_usd(liquidity_usd) if formatted_liquidity: parts.append(f"liquidity: {formatted_liquidity}") if price_change_24h is not None: parts.append(f"24h change: {price_change_24h}%") if best.get("url"): parts.append(f"url: {best['url']}") return { "provider": "dexscreener", "token": token_upper, "result": " | ".join(parts), "price": price, "fdv": fdv, "market_cap": market_cap, "volume_24h": volume_24h, "liquidity_usd": liquidity_usd, "price_change_24h": price_change_24h, "pair": best, } def _load_api_key() -> str | None: """Load the market-data API key from secrets.""" try: return Path(API_KEY_FILE).read_text().strip() except Exception: logger.warning("Market data API key not found at %s", API_KEY_FILE) return None async def get_token_price(token_name: str) -> dict | None: """Fetch live market data for a token. Returns dict with price, market_cap, volume, etc. or None on failure. Caches results for CACHE_TTL seconds. """ import time token_upper = token_name.upper().strip("$") # Check cache cached = _cache.get(token_upper) if cached and time.time() - cached["timestamp"] < CACHE_TTL: return cached["data"] key = _load_api_key() if key: try: async with aiohttp.ClientSession() as session: async with session.post( API_URL, headers={ "X-Internal-Key": key, "Content-Type": "application/json", }, json={"token": token_upper}, timeout=aiohttp.ClientTimeout(total=10), ) as resp: if resp.status < 400: data = await resp.json() if _needs_public_market_augmentation(data): public_data = await _get_dexscreener_token_data(token_upper) if public_data: data = _merge_market_data(data, public_data) _cache[token_upper] = { "data": data, "timestamp": time.time(), } return data logger.warning("Market data API %s -> %d", token_upper, resp.status) except Exception as e: logger.warning("Market data API error for %s: %s", token_upper, e) data = await _get_dexscreener_token_data(token_upper) if data: _cache[token_upper] = { "data": data, "timestamp": time.time(), } return data def format_price_context(data: dict, token_name: str) -> str: """Format market data into a concise string for the LLM prompt.""" if not data: return "" # API returns a "result" text field with pre-formatted data result_text = data.get("result", "") if result_text: return result_text # Fallback for structured JSON responses parts = [f"Live market data for {token_name}:"] price = data.get("price") or data.get("current_price") if price: parts.append(f"Price: ${price}") mcap = data.get("market_cap") or data.get("marketCap") if mcap: if isinstance(mcap, (int, float)) and mcap > 1_000_000: parts.append(f"Market cap: ${mcap/1_000_000:.1f}M") else: parts.append(f"Market cap: {mcap}") volume = data.get("volume") or data.get("volume_24h") if volume: parts.append(f"24h volume: ${volume}") change = data.get("price_change_24h") or data.get("change_24h") if change: parts.append(f"24h change: {change}") return " | ".join(parts) if len(parts) > 1 else ""