Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Access CoinGecko crypto market data: spot prices, OHLC, trending coins, exchange listings, NFTs, and global stats.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
tools/utils.py
1#!/usr/bin/env python32"""3CoinGecko API Utilities45This module provides utility functions for CoinGecko API tools including:6- Natural language time parsing7- Automatic data splitting for large time ranges8- Input validation and normalization9- Cryptocurrency search functionality (search by name/symbol with market cap ranking)10"""1112import re13from datetime import datetime, timedelta14from typing import Union, List, Tuple, Optional, Dict15import os16from dotenv import load_dotenv1718# Load environment variables19load_dotenv()2021import requests22from core.http_client import proxied_get232425def normalize_timestamp_to_seconds(timestamp: Union[int, float]) -> int:26"""27Automatically detect and convert timestamp format (seconds or milliseconds) to seconds.2829Args:30timestamp: Unix timestamp in seconds or milliseconds3132Returns:33int: Unix timestamp in seconds3435Example:36>>> normalize_timestamp_to_seconds(1672531200) # seconds37167253120038>>> normalize_timestamp_to_seconds(1672531200000) # milliseconds39167253120040"""41timestamp = int(timestamp)4243# Timestamps after year 2001 and before year 2286 in milliseconds format44# Milliseconds: 13 digits, starts around 1000000000000 (2001)45# Seconds: 10 digits, starts around 1000000000 (2001)46if timestamp > 1000000000000: # Likely milliseconds (13+ digits)47return timestamp // 100048else: # Likely seconds (10 digits or less)49return timestamp505152def normalize_timestamp_to_milliseconds(timestamp: Union[int, float]) -> int:53"""54Automatically detect and convert timestamp format (seconds or milliseconds) to milliseconds.5556Args:57timestamp: Unix timestamp in seconds or milliseconds5859Returns:60int: Unix timestamp in milliseconds6162Example:63>>> normalize_timestamp_to_milliseconds(1672531200) # seconds64167253120000065>>> normalize_timestamp_to_milliseconds(1672531200000) # milliseconds66167253120000067"""68timestamp = int(timestamp)6970if timestamp > 1000000000000: # Already milliseconds71return timestamp72else: # Convert from seconds to milliseconds73return timestamp * 1000747576def parse_flexible_time(time_input: Union[str, int, float]) -> int:77"""78Parse flexible time input to Unix timestamp with automatic seconds/milliseconds conversion.7980Args:81time_input: Can be:82- Unix timestamp (int/float) - automatically detects seconds vs milliseconds83- ISO date string (YYYY-MM-DD)84- Natural language (e.g., "2 weeks ago", "yesterday", "last month")85- Datetime string (YYYY-MM-DD HH:MM:SS)8687Returns:88int: Unix timestamp in seconds8990Example:91>>> parse_flexible_time("2023-01-01")92167253120093>>> parse_flexible_time("2 weeks ago")94# Returns timestamp for 2 weeks ago95>>> parse_flexible_time(1672531200)96167253120097>>> parse_flexible_time(1672531200000) # milliseconds auto-converted98167253120099"""100if isinstance(time_input, (int, float)):101# Auto-convert milliseconds to seconds if needed102return normalize_timestamp_to_seconds(time_input)103104# Clean string input and try to parse as timestamp first105time_str = str(time_input).strip().lower()106# Remove common shell artifacts like quotes107time_str = time_str.strip("'\"")108109# Try to parse as numeric timestamp first110if time_str.isdigit():111return normalize_timestamp_to_seconds(int(time_str))112113# Try ISO date formats114iso_patterns = [115r'^\d{4}-\d{2}-\d{2}$', # YYYY-MM-DD116r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$', # YYYY-MM-DD HH:MM:SS117r'^\d{4}/\d{2}/\d{2}$', # YYYY/MM/DD118r'^\d{2}/\d{2}/\d{4}$', # MM/DD/YYYY119r'^\d{2}-\d{2}-\d{4}$' # DD-MM-YYYY (for historical data format)120]121122for pattern in iso_patterns:123if re.match(pattern, time_str):124return _parse_date_string(time_str)125126# Parse natural language127return _parse_natural_language(time_str)128129130def _parse_date_string(date_str: str) -> int:131"""Parse various date string formats to Unix timestamp."""132formats = [133'%Y-%m-%d',134'%Y-%m-%d %H:%M:%S',135'%Y/%m/%d',136'%m/%d/%Y',137'%d-%m-%Y'138]139140for fmt in formats:141try:142dt = datetime.strptime(date_str, fmt)143return int(dt.timestamp())144except ValueError:145continue146147raise ValueError(f"Unable to parse date string: {date_str}")148149150def _parse_natural_language(text: str) -> int:151"""Parse natural language time expressions to Unix timestamp."""152now = datetime.now()153154# Handle "today", "yesterday", etc.155if text in ['today', 'now']:156return int(now.timestamp())157elif text == 'yesterday':158return int((now - timedelta(days=1)).timestamp())159elif text == 'tomorrow':160return int((now + timedelta(days=1)).timestamp())161162# Handle relative time expressions163relative_patterns = [164(r'(\d+)\s*(second|sec)s?\s*ago', 'seconds'),165(r'(\d+)\s*(minute|min)s?\s*ago', 'minutes'),166(r'(\d+)\s*(hour|hr)s?\s*ago', 'hours'),167(r'(\d+)\s*(day)s?\s*ago', 'days'),168(r'(\d+)\s*(week)s?\s*ago', 'weeks'),169(r'(\d+)\s*(month)s?\s*ago', 'months'),170(r'(\d+)\s*(year)s?\s*ago', 'years'),171(r'last\s*(week)', 'weeks'),172(r'last\s*(month)', 'months'),173(r'last\s*(year)', 'years')174]175176for pattern, unit in relative_patterns:177match = re.search(pattern, text)178if match:179if match.group(1).isdigit():180amount = int(match.group(1))181else:182amount = 1 # for "last week", etc.183184if unit == 'seconds':185delta = timedelta(seconds=amount)186elif unit == 'minutes':187delta = timedelta(minutes=amount)188elif unit == 'hours':189delta = timedelta(hours=amount)190elif unit == 'days':191delta = timedelta(days=amount)192elif unit == 'weeks':193delta = timedelta(weeks=amount)194elif unit == 'months':195# Approximate months as 30 days196delta = timedelta(days=amount * 30)197elif unit == 'years':198# Approximate years as 365 days199delta = timedelta(days=amount * 365)200else:201continue202203return int((now - delta).timestamp())204205raise ValueError(f"Unable to parse natural language time: {text}")206207208def split_time_range(from_timestamp: int, to_timestamp: int, max_days: int = 180) -> List[Tuple[int, int]]:209"""210Split a time range into chunks that don't exceed the maximum days limit.211212Args:213from_timestamp: Start timestamp214to_timestamp: End timestamp215max_days: Maximum days per chunk (default 180 for CoinGecko)216217Returns:218List of (start, end) timestamp tuples219220Example:221>>> splits = split_time_range(1640995200, 1672531200, 180) # 1 year range222>>> len(splits)2233 # Split into 3 chunks224"""225if from_timestamp >= to_timestamp:226raise ValueError("from_timestamp must be less than to_timestamp")227228total_seconds = to_timestamp - from_timestamp229max_seconds = max_days * 24 * 60 * 60 # Convert days to seconds230231if total_seconds <= max_seconds:232return [(from_timestamp, to_timestamp)]233234chunks = []235current_start = from_timestamp236237while current_start < to_timestamp:238current_end = min(current_start + max_seconds, to_timestamp)239chunks.append((current_start, current_end))240current_start = current_end241242return chunks243244245def validate_coin_input(coin_input: str) -> str:246"""247Validate and normalize coin input (ID or symbol).248249Args:250coin_input: Coin ID or symbol251252Returns:253str: Normalized coin input254255Raises:256ValueError: If input is invalid257"""258if not coin_input or not isinstance(coin_input, str):259raise ValueError("Coin input must be a non-empty string")260261return coin_input.strip().lower()262263264def format_dd_mm_yyyy_date(timestamp: int) -> str:265"""266Convert Unix timestamp to dd-mm-yyyy format for CoinGecko historical API.267268Args:269timestamp: Unix timestamp270271Returns:272str: Date in dd-mm-yyyy format273"""274dt = datetime.utcfromtimestamp(timestamp)275return dt.strftime('%d-%m-%Y')276277278def get_days_difference(from_timestamp: int, to_timestamp: int) -> int:279"""280Calculate the number of days between two timestamps.281282Args:283from_timestamp: Start timestamp284to_timestamp: End timestamp285286Returns:287int: Number of days288"""289return int((to_timestamp - from_timestamp) / (24 * 60 * 60))290291292def merge_ohlc_data(data_chunks: List[List]) -> List:293"""294Merge multiple OHLC data chunks into a single list.295296Args:297data_chunks: List of OHLC data chunks298299Returns:300List: Merged OHLC data sorted by timestamp301"""302merged = []303for chunk in data_chunks:304merged.extend(chunk)305306# Sort by timestamp (first element in each OHLC array)307merged.sort(key=lambda x: x[0])308309# Remove duplicates (same timestamp)310seen_timestamps = set()311unique_data = []312for item in merged:313timestamp = item[0]314if timestamp not in seen_timestamps:315seen_timestamps.add(timestamp)316unique_data.append(item)317318return unique_data319320321def merge_market_chart_data(data_chunks: List[dict]) -> dict:322"""323Merge multiple market chart data chunks into a single dictionary.324325Args:326data_chunks: List of market chart data dictionaries327328Returns:329dict: Merged market chart data with sorted timestamps330"""331merged = {332'prices': [],333'market_caps': [],334'total_volumes': []335}336337for chunk in data_chunks:338if 'prices' in chunk:339merged['prices'].extend(chunk['prices'])340if 'market_caps' in chunk:341merged['market_caps'].extend(chunk['market_caps'])342if 'total_volumes' in chunk:343merged['total_volumes'].extend(chunk['total_volumes'])344345# Sort all arrays by timestamp346for key in merged:347merged[key].sort(key=lambda x: x[0])348349# Remove duplicates350seen_timestamps = set()351unique_data = []352for item in merged[key]:353timestamp = item[0]354if timestamp not in seen_timestamps:355seen_timestamps.add(timestamp)356unique_data.append(item)357merged[key] = unique_data358359return merged360361362def search_coin_by_name(query: str) -> Optional[Dict[str, str]]:363"""364Search for a cryptocurrency by name or symbol with intelligent input detection.365366- All uppercase input (e.g., "BTC") is treated as symbol search367- Input with ≤3 letters (e.g., "btc", "eth", "sol") is treated as symbol search368- Other mixed/lowercase input (e.g., "bitcoin", "Bitcoin") is treated as name search369- Symbol search: prioritizes exact symbol match, then market cap ranking370- Name search: prioritizes exact name match, then market cap ranking371372Special cases handled:373- ORDER/order/Order -> Orderly Network (orderly-network)374- orderly-network -> Returns directly as valid CoinGecko ID375376Args:377query (str): Search query - uppercase or ≤3 letters for symbol, otherwise name search378379Returns:380Optional[Dict[str, str]]: Dictionary with symbol, name, and id of the best match, or None if not found381382Example:383>>> search_coin_by_name("BTC") # Symbol search (uppercase)384{'symbol': 'BTC', 'name': 'Bitcoin', 'id': 'bitcoin'}385>>> search_coin_by_name("btc") # Symbol search (≤3 letters)386{'symbol': 'BTC', 'name': 'Bitcoin', 'id': 'bitcoin'}387>>> search_coin_by_name("bitcoin") # Name search (>3 letters, mixed case)388{'symbol': 'BTC', 'name': 'Bitcoin', 'id': 'bitcoin'}389>>> search_coin_by_name("order") # Special case for Orderly Network390{'symbol': 'ORDER', 'name': 'Orderly Network', 'id': 'orderly-network'}391392Raises:393ValueError: If API key is missing or query is invalid394requests.RequestException: If API request fails395"""396if not query or not isinstance(query, str):397raise ValueError("Query must be a non-empty string")398399# Special case mappings for ambiguous tokens and direct CoinGecko IDs400# When users say "ORDER", they almost always mean Orderly Network401# When users say "WOO", they mean WOO Network (id: woo-network, not woo)402special_mappings = {403'order': {'symbol': 'ORDER', 'name': 'Orderly Network', 'id': 'orderly-network'},404'ORDER': {'symbol': 'ORDER', 'name': 'Orderly Network', 'id': 'orderly-network'},405'Order': {'symbol': 'ORDER', 'name': 'Orderly Network', 'id': 'orderly-network'},406'orderly-network': {'symbol': 'ORDER', 'name': 'Orderly Network', 'id': 'orderly-network'}, # Handle direct ID407'woo': {'symbol': 'WOO', 'name': 'WOO', 'id': 'woo-network'},408'WOO': {'symbol': 'WOO', 'name': 'WOO', 'id': 'woo-network'},409'Woo': {'symbol': 'WOO', 'name': 'WOO', 'id': 'woo-network'},410'woo-network': {'symbol': 'WOO', 'name': 'WOO', 'id': 'woo-network'}, # Handle direct ID411}412413# Check for special cases first414query_stripped = query.strip()415if query_stripped in special_mappings:416return special_mappings[query_stripped]417418# Get API key419api_key = os.getenv("COINGECKO_API_KEY")420if not api_key:421raise ValueError(422"COINGECKO_API_KEY environment variable is required. "423"Get your API key from https://coingecko.com/en/api"424)425426# Prepare API request427url = "https://pro-api.coingecko.com/api/v3/search"428headers = {429"x-cg-pro-api-key": api_key,430"accept": "application/json"431}432params = {433"query": query.strip()434}435436try:437# Make single API request (no retry logic)438response = proxied_get(url, headers=headers, params=params, timeout=15)439response.raise_for_status()440441data = response.json()442443# Extract coins from search results444coins = data.get("coins", [])445446if not coins:447return None448449# Determine if input is symbol or name based on case and length450query_stripped = query.strip()451is_symbol_search = query_stripped.isupper() or len(query_stripped) <= 3452453if is_symbol_search:454# Symbol search: check for exact symbol OR exact name match in order of relevance455# This ensures "SOLANA" matches the real Solana (name match at index 0)456# rather than a meme coin with symbol "SOLANA" at index 18457for coin in coins:458coin_symbol = coin.get("symbol", "")459coin_name = coin.get("name", "")460if coin_symbol.upper() == query_stripped.upper() or coin_name.upper() == query_stripped.upper():461return {462"symbol": coin.get("symbol", "").upper(),463"name": coin.get("name", ""),464"id": coin.get("id", "")465}466467# No exact symbol or name match, return highest market cap468best_coin = coins[0]469return {470"symbol": best_coin.get("symbol", "").upper(),471"name": best_coin.get("name", ""),472"id": best_coin.get("id", "")473}474else:475# Name search: prioritize exact name match, then market cap476for coin in coins:477coin_name = coin.get("name", "")478if coin_name.lower() == query_stripped.lower():479return {480"symbol": coin.get("symbol", "").upper(),481"name": coin.get("name", ""),482"id": coin.get("id", "")483}484485# No exact name match, return highest market cap486best_coin = coins[0]487return {488"symbol": best_coin.get("symbol", "").upper(),489"name": best_coin.get("name", ""),490"id": best_coin.get("id", "")491}492493except requests.exceptions.Timeout:494raise requests.RequestException("Request timeout - CoinGecko API may be slow")495except requests.exceptions.ConnectionError:496raise requests.RequestException("Connection error - check internet connection")497except requests.exceptions.RequestException as e:498raise requests.RequestException(f"API request failed: {e}")499500return None501502503