diff --git a/openbb_platform/core/openbb_core/provider/standard_models/crypto_price.py b/openbb_platform/core/openbb_core/provider/standard_models/crypto_price.py new file mode 100644 index 000000000000..ee117ba82710 --- /dev/null +++ b/openbb_platform/core/openbb_core/provider/standard_models/crypto_price.py @@ -0,0 +1,65 @@ +"""Crypto Real-time Price Standard Model.""" + +from datetime import datetime +from typing import List, Optional, Set, Union + +from openbb_core.provider.abstract.data import Data +from openbb_core.provider.abstract.query_params import QueryParams +from openbb_core.provider.utils.descriptions import ( + DATA_DESCRIPTIONS, + QUERY_DESCRIPTIONS, +) +from pydantic import Field, field_validator + + +class CryptoPriceQueryParams(QueryParams): + """Crypto Real-time Price Query.""" + + symbol: str = Field( + description=QUERY_DESCRIPTIONS.get("symbol", "") + + " Can use coin IDs (bitcoin, ethereum) or symbols (btc, eth). Multiple symbols supported." + ) + + @field_validator("symbol", mode="before", check_fields=False) + @classmethod + def validate_symbol(cls, v: Union[str, List[str], Set[str]]): + """Convert field to lowercase for coin IDs.""" + if isinstance(v, str): + return v.lower().strip() + return ",".join([symbol.lower().strip() for symbol in list(v)]) + + +class CryptoPriceData(Data): + """Crypto Real-time Price Data.""" + + symbol: str = Field(description=DATA_DESCRIPTIONS.get("symbol", "") + " (Crypto)") + name: Optional[str] = Field( + default=None, description="Name of the cryptocurrency." + ) + price: float = Field( + description="Current price of the cryptocurrency.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + market_cap: Optional[float] = Field( + default=None, + description="Market capitalization.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + market_cap_rank: Optional[int] = Field( + default=None, + description="Market cap rank.", + ) + volume_24h: Optional[float] = Field( + default=None, + description="24-hour trading volume.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + change_24h: Optional[float] = Field( + default=None, + description="24-hour price change in percentage.", + json_schema_extra={"x-unit_measurement": "percent", "x-frontend_multiply": 100}, + ) + last_updated: Optional[datetime] = Field( + default=None, + description="Last updated timestamp.", + ) diff --git a/openbb_platform/extensions/crypto/openbb_crypto/price/price_router.py b/openbb_platform/extensions/crypto/openbb_crypto/price/price_router.py index efce03361499..367493846cbe 100644 --- a/openbb_platform/extensions/crypto/openbb_crypto/price/price_router.py +++ b/openbb_platform/extensions/crypto/openbb_crypto/price/price_router.py @@ -56,3 +56,31 @@ async def historical( ) -> OBBject: """Get historical price data for cryptocurrency pair(s) within a provider.""" return await OBBject.from_query(Query(**locals())) + + +@router.command( + model="CryptoPrice", + examples=[ + APIEx(parameters={"symbol": "bitcoin", "provider": "coingecko"}), + APIEx(parameters={"symbol": "bitcoin,ethereum", "vs_currency": "eur", "provider": "coingecko"}), + APIEx( + description="Get real-time prices for multiple cryptocurrencies with market data.", + parameters={ + "symbol": "bitcoin,ethereum,cardano", + "vs_currency": "usd", + "include_market_cap": True, + "include_24hr_vol": True, + "include_24hr_change": True, + "provider": "coingecko", + }, + ), + ], +) +async def quote( + cc: CommandContext, + provider_choices: ProviderChoices, + standard_params: StandardParams, + extra_params: ExtraParams, +) -> OBBject: + """Get real-time price data for cryptocurrency(s) from CoinGecko.""" + return await OBBject.from_query(Query(**locals())) diff --git a/openbb_platform/providers/coingecko/README.md b/openbb_platform/providers/coingecko/README.md new file mode 100644 index 000000000000..e45bed8f4b04 --- /dev/null +++ b/openbb_platform/providers/coingecko/README.md @@ -0,0 +1,127 @@ +# OpenBB CoinGecko Provider + +This extension integrates the [CoinGecko](https://www.coingecko.com) data provider into the OpenBB Platform, providing comprehensive real-time and historical cryptocurrency data. + +## Installation + +To install the extension: + +```bash +pip install openbb-coingecko +``` + +## Coverage + +The following endpoints are covered by this extension: + +- `obb.crypto.price.historical` - Historical cryptocurrency price data +- `obb.crypto.price.quote` - Real-time cryptocurrency prices +- `obb.crypto.search` - Search for cryptocurrencies + +## Features + +### Real-time Data +- Current cryptocurrency prices +- Market capitalization data +- 24-hour trading volume +- 24-hour price changes +- Last updated timestamps + +### Historical Data +- Historical price charts +- Market cap history +- Volume data over time +- Flexible date ranges +- Multiple interval options + +### Search Functionality +- Search cryptocurrencies by name or symbol +- Get comprehensive coin information +- Access to CoinGecko coin IDs for API calls + +## API Key Setup + +While CoinGecko offers a free tier, we recommend using an API key for production use: + +1. Visit [CoinGecko API Pricing](https://www.coingecko.com/en/api/pricing) +2. Sign up for a Pro API plan +3. Visit your [Developer Dashboard](https://www.coingecko.com/en/developers/dashboard) +4. Copy your API key +5. Add it to your OpenBB credentials as `coingecko_api_key` + +## Usage Examples + +### Real-time Prices + +```python +from openbb import obb + +# Get current Bitcoin price +result = obb.crypto.price.quote(symbol="bitcoin", provider="coingecko") + +# Get multiple cryptocurrencies with market data +result = obb.crypto.price.quote( + symbol="bitcoin,ethereum,cardano", + vs_currency="usd", + include_market_cap=True, + include_24hr_vol=True, + include_24hr_change=True, + provider="coingecko" +) +``` + +### Historical Data + +```python +# Get Bitcoin historical data for the last 30 days +result = obb.crypto.price.historical( + symbol="bitcoin", + interval="30d", + vs_currency="usd", + provider="coingecko" +) + +# Get historical data with specific date range +result = obb.crypto.price.historical( + symbol="ethereum", + start_date="2024-01-01", + end_date="2024-01-31", + vs_currency="eur", + provider="coingecko" +) +``` + +### Search Cryptocurrencies + +```python +# Search for cryptocurrencies +result = obb.crypto.search(query="bitcoin", provider="coingecko") + +# Get all available cryptocurrencies +result = obb.crypto.search(provider="coingecko") +``` + +## Supported Currencies + +The provider supports pricing in multiple fiat and cryptocurrency currencies: + +**Fiat:** USD, EUR, JPY, GBP, AUD, CAD, CHF, CNY, HKD, INR, KRW, MXN, NOK, NZD, PHP, PLN, RUB, SEK, SGD, THB, TRY, TWD, ZAR + +**Crypto:** BTC, ETH, LTC, BCH, BNB, EOS, XRP, XLM, LINK, DOT, YFI + +## Rate Limits + +- **Free API:** 10-50 calls/minute +- **Pro API:** 500+ calls/minute (depending on plan) + +For production use, we recommend upgrading to a Pro plan to avoid rate limiting. + +## Data Sources + +CoinGecko aggregates data from over 400+ exchanges worldwide, providing comprehensive and reliable cryptocurrency market data. The platform tracks 10,000+ different crypto-assets and is one of the most trusted sources in the cryptocurrency space. + +## Support + +For issues related to this provider, please visit the [OpenBB Platform repository](https://github.com/OpenBB-finance/OpenBB). + +For CoinGecko API-specific questions, refer to the [CoinGecko API Documentation](https://docs.coingecko.com/reference/introduction). diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/__init__.py b/openbb_platform/providers/coingecko/openbb_coingecko/__init__.py new file mode 100644 index 000000000000..045e68c759c8 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/__init__.py @@ -0,0 +1,34 @@ +"""CoinGecko Provider Module.""" + +from openbb_core.provider.abstract.provider import Provider +from openbb_coingecko.models.crypto_historical import CoinGeckoCryptoHistoricalFetcher +from openbb_coingecko.models.crypto_price import CoinGeckoCryptoPriceFetcher +from openbb_coingecko.models.crypto_search import CoinGeckoCryptoSearchFetcher + +coingecko_provider = Provider( + name="coingecko", + website="https://www.coingecko.com", + description=( + "CoinGecko is the world's largest independent cryptocurrency data aggregator " + "with over 10,000+ different crypto-assets tracked across more than 400+ " + "exchanges worldwide. CoinGecko provides real-time pricing, market data, " + "and comprehensive cryptocurrency information." + ), + credentials=["coingecko_api_key"], + fetcher_dict={ + "CryptoHistorical": CoinGeckoCryptoHistoricalFetcher, + "CryptoPrice": CoinGeckoCryptoPriceFetcher, + "CryptoSearch": CoinGeckoCryptoSearchFetcher, + }, + repr_name="CoinGecko", + instructions=( + "To get a CoinGecko API key:\n" + "1. Visit https://www.coingecko.com/en/api/pricing\n" + "2. Sign up for a Pro API plan (required for API key access)\n" + "3. Once subscribed, visit your developer dashboard at " + "https://www.coingecko.com/en/developers/dashboard\n" + "4. Copy your API key and add it to your OpenBB credentials as 'coingecko_api_key'\n\n" + "Note: CoinGecko offers a free tier with limited requests, " + "but an API key is recommended for production use." + ), +) diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/models/__init__.py b/openbb_platform/providers/coingecko/openbb_coingecko/models/__init__.py new file mode 100644 index 000000000000..c920bdaab779 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/models/__init__.py @@ -0,0 +1 @@ +"""CoinGecko provider models.""" diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_historical.py b/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_historical.py new file mode 100644 index 000000000000..b10f9d7eb427 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_historical.py @@ -0,0 +1,202 @@ +"""CoinGecko Crypto Historical Price Model.""" + +"""CoinGecko Crypto Historical Price Model.""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional, Union +from warnings import warn + +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.standard_models.crypto_historical import ( + CryptoHistoricalData, + CryptoHistoricalQueryParams, +) +from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS +from openbb_core.provider.utils.errors import EmptyDataError +from openbb_coingecko.utils.helpers import make_request, validate_symbol +from pydantic import Field, field_validator + + +class CoinGeckoCryptoHistoricalQueryParams(CryptoHistoricalQueryParams): + """CoinGecko Crypto Historical Price Query. + + Source: https://docs.coingecko.com/reference/coins-id-market-chart + """ + + __json_schema_extra__ = { + "symbol": {"multiple_items_allowed": True}, + "interval": { + "choices": ["1d", "7d", "14d", "30d", "90d", "180d", "365d", "max"] + }, + } + + vs_currency: str = Field( + default="usd", + description="The target currency of market data (usd, eur, jpy, etc.)", + ) + interval: Literal["1d", "7d", "14d", "30d", "90d", "180d", "365d", "max"] = Field( + default="30d", + description=QUERY_DESCRIPTIONS.get("interval", "") + + " Defaults to '30d'. Use 'max' for maximum available history.", + ) + precision: Optional[Literal["full"]] = Field( + default=None, + description=( + "The precision of the data. Use 'full' for full precision, " + "otherwise 2 decimals." + ), + ) + + @field_validator("vs_currency", mode="before", check_fields=False) + @classmethod + def validate_vs_currency(cls, v: str) -> str: + """Validate and normalize vs_currency.""" + return v.lower().strip() + + @field_validator("symbol", mode="before", check_fields=False) + @classmethod + def validate_symbol(cls, v: Union[str, List[str]]) -> str: + """Validate and normalize symbol(s).""" + if isinstance(v, str): + return validate_symbol(v) + return ",".join([validate_symbol(symbol) for symbol in v]) + + +class CoinGeckoCryptoHistoricalData(CryptoHistoricalData): + """CoinGecko Crypto Historical Price Data.""" + + market_cap: Optional[float] = Field( + default=None, + description="Market capitalization at the time.", + json_schema_extra={"x-unit_measurement": "currency"}, + ) + + +class CoinGeckoCryptoHistoricalFetcher( + Fetcher[ + CoinGeckoCryptoHistoricalQueryParams, + List[CoinGeckoCryptoHistoricalData], + ] +): + """Transform the query, extract and transform the data from the CoinGecko endpoints.""" + + @staticmethod + def transform_query(params: Dict[str, Any]) -> CoinGeckoCryptoHistoricalQueryParams: + """Transform the query parameters.""" + return CoinGeckoCryptoHistoricalQueryParams(**params) + + @staticmethod + async def aextract_data( + query: CoinGeckoCryptoHistoricalQueryParams, + credentials: Optional[Dict[str, str]], + **kwargs: Any, + ) -> List[Dict]: + """Return the raw data from the CoinGecko endpoint.""" + api_key = credentials.get("coingecko_api_key") if credentials else None + + symbols = query.symbol.split(",") + results = [] + + for symbol in symbols: + try: + # Convert interval to days for CoinGecko API + days = _parse_interval_to_days(query.interval) + + # Build parameters for the API call + params = { + "vs_currency": query.vs_currency, + "days": days, + } + + if query.precision: + params["precision"] = query.precision + + # Handle date range if provided + if query.start_date and query.end_date: + # Convert dates to timestamps for range endpoint + start_timestamp = int(query.start_date.timestamp()) + end_timestamp = int(query.end_date.timestamp()) + + endpoint = f"coins/{symbol}/market_chart/range" + params = { + "vs_currency": query.vs_currency, + "from": start_timestamp, + "to": end_timestamp, + } + if query.precision: + params["precision"] = query.precision + else: + endpoint = f"coins/{symbol}/market_chart" + + data = make_request(endpoint, params, api_key) + + if not data or not isinstance(data, dict): + warn(f"No data found for symbol: {symbol}") + continue + + # Transform the data structure + prices = data.get("prices", []) + market_caps = data.get("market_caps", []) + volumes = data.get("total_volumes", []) + + if not prices: + warn(f"No price data found for symbol: {symbol}") + continue + + # Combine the data + for i, price_data in enumerate(prices): + timestamp, price = price_data + + # Get corresponding market cap and volume data + market_cap = market_caps[i][1] if i < len(market_caps) else None + volume = volumes[i][1] if i < len(volumes) else None + + # Convert timestamp to datetime + date = datetime.fromtimestamp(timestamp / 1000) + + result = { + "symbol": symbol.upper(), + "date": date, + "open": price, # CoinGecko doesn't provide OHLC, only price points + "high": price, + "low": price, + "close": price, + "volume": volume, + "market_cap": market_cap, + } + + results.append(result) + + except Exception as e: + warn(f"Error fetching data for {symbol}: {str(e)}") + continue + + if not results: + raise EmptyDataError("No data found for any of the provided symbols.") + + return results + + @staticmethod + def transform_data( + query: CoinGeckoCryptoHistoricalQueryParams, + data: List[Dict], + **kwargs: Any, + ) -> List[CoinGeckoCryptoHistoricalData]: + """Return the transformed data.""" + return [CoinGeckoCryptoHistoricalData.model_validate(d) for d in data] + + +def _parse_interval_to_days(interval: str) -> Union[int, str]: + """Parse interval string to number of days for CoinGecko API.""" + interval_map = { + "1d": 1, + "7d": 7, + "14d": 14, + "30d": 30, + "90d": 90, + "180d": 180, + "365d": 365, + "max": "max", + } + + return interval_map.get(interval.lower(), 30) diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_price.py b/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_price.py new file mode 100644 index 000000000000..3cb503302522 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_price.py @@ -0,0 +1,169 @@ +"""CoinGecko Real-time Crypto Price Model.""" + +from datetime import datetime +from typing import Any, Dict, List, Optional, Union +from warnings import warn + +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.standard_models.crypto_price import ( + CryptoPriceData, + CryptoPriceQueryParams, +) +from openbb_core.provider.utils.descriptions import QUERY_DESCRIPTIONS +from openbb_core.provider.utils.errors import EmptyDataError +from openbb_coingecko.utils.helpers import make_request, validate_symbol +from pydantic import Field, field_validator + + +class CoinGeckoCryptoPriceQueryParams(CryptoPriceQueryParams): + """CoinGecko Real-time Crypto Price Query. + + Source: https://docs.coingecko.com/reference/simple-price + """ + + vs_currency: str = Field( + default="usd", + description="The target currency of market data (usd, eur, jpy, etc.)", + ) + include_market_cap: bool = Field( + default=True, + description="Include market cap in the response.", + ) + include_24hr_vol: bool = Field( + default=True, + description="Include 24hr volume in the response.", + ) + include_24hr_change: bool = Field( + default=True, + description="Include 24hr change in the response.", + ) + include_last_updated_at: bool = Field( + default=True, + description="Include last updated timestamp in the response.", + ) + precision: Optional[str] = Field( + default=None, + description=( + "The precision of the data. Use 'full' for full precision, " + "otherwise 2 decimals." + ), + ) + + @field_validator("vs_currency", mode="before", check_fields=False) + @classmethod + def validate_vs_currency(cls, v: str) -> str: + """Validate and normalize vs_currency.""" + return v.lower().strip() + + @field_validator("symbol", mode="before", check_fields=False) + @classmethod + def validate_symbol(cls, v: Union[str, List[str]]) -> str: + """Validate and normalize symbol(s).""" + if isinstance(v, str): + return validate_symbol(v) + return ",".join([validate_symbol(symbol) for symbol in v]) + + +class CoinGeckoCryptoPriceData(CryptoPriceData): + """CoinGecko Real-time Crypto Price Data.""" + + +class CoinGeckoCryptoPriceFetcher( + Fetcher[ + CoinGeckoCryptoPriceQueryParams, + List[CoinGeckoCryptoPriceData], + ] +): + """Transform the query, extract and transform the data from the CoinGecko endpoints.""" + + @staticmethod + def transform_query(params: Dict[str, Any]) -> CoinGeckoCryptoPriceQueryParams: + """Transform the query parameters.""" + return CoinGeckoCryptoPriceQueryParams(**params) + + @staticmethod + async def aextract_data( + query: CoinGeckoCryptoPriceQueryParams, + credentials: Optional[Dict[str, str]], + **kwargs: Any, + ) -> List[Dict]: + """Return the raw data from the CoinGecko endpoint.""" + api_key = credentials.get("coingecko_api_key") if credentials else None + + symbols = query.symbol.split(",") + + # Build parameters for the API call + params = { + "ids": ",".join(symbols), + "vs_currencies": query.vs_currency, + "include_market_cap": str(query.include_market_cap).lower(), + "include_24hr_vol": str(query.include_24hr_vol).lower(), + "include_24hr_change": str(query.include_24hr_change).lower(), + "include_last_updated_at": str(query.include_last_updated_at).lower(), + } + + if query.precision: + params["precision"] = query.precision + + endpoint = "simple/price" + data = make_request(endpoint, params, api_key) + + if not data or not isinstance(data, dict): + raise EmptyDataError("No price data found.") + + results = [] + + for coin_id, price_data in data.items(): + if not isinstance(price_data, dict): + warn(f"Invalid price data format for {coin_id}") + continue + + # Get the price for the requested vs_currency + price = price_data.get(query.vs_currency) + if price is None: + warn(f"No price found for {coin_id} in {query.vs_currency}") + continue + + # Extract additional data + market_cap_key = f"{query.vs_currency}_market_cap" + volume_key = f"{query.vs_currency}_24h_vol" + change_key = f"{query.vs_currency}_24h_change" + + market_cap = price_data.get(market_cap_key) + volume_24h = price_data.get(volume_key) + change_24h = price_data.get(change_key) + + # Handle last updated timestamp + last_updated = None + if "last_updated_at" in price_data: + try: + last_updated = datetime.fromtimestamp(price_data["last_updated_at"]) + except (ValueError, TypeError): + pass + + result = { + "symbol": coin_id.upper(), + "name": None, # Not available in simple/price endpoint + "price": price, + "market_cap": market_cap, + "market_cap_rank": None, # Not available in simple/price endpoint + "volume_24h": volume_24h, + "change_24h": change_24h, + "last_updated": last_updated, + } + + results.append(result) + + if not results: + raise EmptyDataError("No valid price data found for any of the provided symbols.") + + return results + + @staticmethod + def transform_data( + query: CoinGeckoCryptoPriceQueryParams, + data: List[Dict], + **kwargs: Any, + ) -> List[CoinGeckoCryptoPriceData]: + """Return the transformed data.""" + return [CoinGeckoCryptoPriceData.model_validate(d) for d in data] diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_search.py b/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_search.py new file mode 100644 index 000000000000..e5310de40ee1 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/models/crypto_search.py @@ -0,0 +1,132 @@ +"""CoinGecko Crypto Search Model.""" + +from typing import Any, Dict, List, Optional + +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.standard_models.crypto_search import ( + CryptoSearchData, + CryptoSearchQueryParams, +) +from openbb_core.provider.utils.errors import EmptyDataError +from openbb_coingecko.utils.helpers import make_request +from pydantic import Field + + +class CoinGeckoCryptoSearchQueryParams(CryptoSearchQueryParams): + """CoinGecko Crypto Search Query. + + Source: https://docs.coingecko.com/reference/search-data + """ + + +class CoinGeckoCryptoSearchData(CryptoSearchData): + """CoinGecko Crypto Search Data.""" + + id: Optional[str] = Field( + default=None, + description="CoinGecko coin ID (used for API calls).", + ) + api_symbol: Optional[str] = Field( + default=None, + description="API symbol used by CoinGecko.", + ) + market_cap_rank: Optional[int] = Field( + default=None, + description="Market cap rank of the cryptocurrency.", + ) + thumb: Optional[str] = Field( + default=None, + description="URL to the thumbnail image of the cryptocurrency.", + ) + large: Optional[str] = Field( + default=None, + description="URL to the large image of the cryptocurrency.", + ) + + +class CoinGeckoCryptoSearchFetcher( + Fetcher[ + CoinGeckoCryptoSearchQueryParams, + List[CoinGeckoCryptoSearchData], + ] +): + """Transform the query, extract and transform the data from the CoinGecko endpoints.""" + + @staticmethod + def transform_query(params: Dict[str, Any]) -> CoinGeckoCryptoSearchQueryParams: + """Transform the query parameters.""" + return CoinGeckoCryptoSearchQueryParams(**params) + + @staticmethod + async def aextract_data( + query: CoinGeckoCryptoSearchQueryParams, + credentials: Optional[Dict[str, str]], + **kwargs: Any, + ) -> List[Dict]: + """Return the raw data from the CoinGecko endpoint.""" + api_key = credentials.get("coingecko_api_key") if credentials else None + + if query.query: + # Use search endpoint for specific query + endpoint = "search" + params = {"query": query.query} + + data = make_request(endpoint, params, api_key) + + if not data or not isinstance(data, dict): + raise EmptyDataError("No search results found.") + + # Extract coins from search results + coins = data.get("coins", []) + + if not coins: + raise EmptyDataError(f"No cryptocurrencies found for query: {query.query}") + + results = [] + for coin in coins: + result = { + "symbol": coin.get("symbol", "").upper(), + "name": coin.get("name"), + "id": coin.get("id"), + "api_symbol": coin.get("api_symbol"), + "market_cap_rank": coin.get("market_cap_rank"), + "thumb": coin.get("thumb"), + "large": coin.get("large"), + } + results.append(result) + + return results + + else: + # Use coins list endpoint for all available coins + endpoint = "coins/list" + params = {"include_platform": "false"} + + data = make_request(endpoint, params, api_key) + + if not data or not isinstance(data, list): + raise EmptyDataError("No cryptocurrency list data found.") + + results = [] + for coin in data: + result = { + "symbol": coin.get("symbol", "").upper(), + "name": coin.get("name"), + "id": coin.get("id"), + "api_symbol": coin.get("symbol"), + "market_cap_rank": None, # Not available in list endpoint + "thumb": None, # Not available in list endpoint + "large": None, # Not available in list endpoint + } + results.append(result) + + return results + + @staticmethod + def transform_data( + query: CoinGeckoCryptoSearchQueryParams, + data: List[Dict], + **kwargs: Any, + ) -> List[CoinGeckoCryptoSearchData]: + """Return the transformed data.""" + return [CoinGeckoCryptoSearchData.model_validate(d) for d in data] diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/utils/__init__.py b/openbb_platform/providers/coingecko/openbb_coingecko/utils/__init__.py new file mode 100644 index 000000000000..2b6507f0f147 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/utils/__init__.py @@ -0,0 +1 @@ +"""CoinGecko provider utilities.""" diff --git a/openbb_platform/providers/coingecko/openbb_coingecko/utils/helpers.py b/openbb_platform/providers/coingecko/openbb_coingecko/utils/helpers.py new file mode 100644 index 000000000000..f09df4db5716 --- /dev/null +++ b/openbb_platform/providers/coingecko/openbb_coingecko/utils/helpers.py @@ -0,0 +1,146 @@ +"""CoinGecko API Helper Functions.""" + +import asyncio +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlencode + +import requests +from openbb_core.provider.utils.errors import EmptyDataError + + +class CoinGeckoAPIError(Exception): + """CoinGecko API Error.""" + + +def get_coingecko_base_url(use_pro_api: bool = False) -> str: + """Get the appropriate CoinGecko API base URL.""" + if use_pro_api: + return "https://pro-api.coingecko.com/api/v3" + return "https://api.coingecko.com/api/v3" + + +def build_headers(api_key: Optional[str] = None) -> Dict[str, str]: + """Build headers for CoinGecko API requests.""" + headers = { + "accept": "application/json", + "User-Agent": "OpenBB/1.0.0", + } + + if api_key: + headers["x-cg-pro-api-key"] = api_key + + return headers + + +def build_url( + endpoint: str, + params: Optional[Dict[str, Any]] = None, + api_key: Optional[str] = None, +) -> str: + """Build complete URL for CoinGecko API requests.""" + base_url = get_coingecko_base_url(use_pro_api=bool(api_key)) + url = f"{base_url}/{endpoint.lstrip('/')}" + + if params: + # Filter out None values + filtered_params = {k: v for k, v in params.items() if v is not None} + if filtered_params: + url += f"?{urlencode(filtered_params)}" + + return url + + +def make_request( + endpoint: str, + params: Optional[Dict[str, Any]] = None, + api_key: Optional[str] = None, + timeout: int = 30, +) -> Union[Dict, List]: + """Make synchronous request to CoinGecko API.""" + url = build_url(endpoint, params, api_key) + headers = build_headers(api_key) + + try: + response = requests.get(url, headers=headers, timeout=timeout) + response.raise_for_status() + + data = response.json() + + if not data: + raise EmptyDataError("No data returned from CoinGecko API") + + return data + + except requests.exceptions.HTTPError as e: + if e.response.status_code == 429: + raise CoinGeckoAPIError( + "Rate limit exceeded. Please try again later or upgrade your API plan." + ) from e + elif e.response.status_code == 401: + raise CoinGeckoAPIError( + "Invalid API key. Please check your CoinGecko API key." + ) from e + elif e.response.status_code == 404: + raise CoinGeckoAPIError( + "Endpoint not found or invalid parameters." + ) from e + else: + raise CoinGeckoAPIError( + f"HTTP error {e.response.status_code}: {e.response.text}" + ) from e + + except requests.exceptions.RequestException as e: + raise CoinGeckoAPIError(f"Request failed: {str(e)}") from e + + except ValueError as e: + raise CoinGeckoAPIError(f"Invalid JSON response: {str(e)}") from e + + +async def amake_request( + endpoint: str, + params: Optional[Dict[str, Any]] = None, + api_key: Optional[str] = None, + timeout: int = 30, +) -> Union[Dict, List]: + """Make asynchronous request to CoinGecko API.""" + # For now, we'll use the synchronous version in a thread pool + # In a production environment, you might want to use aiohttp + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, make_request, endpoint, params, api_key, timeout + ) + + +def validate_symbol(symbol: str) -> str: + """Validate and normalize cryptocurrency symbol.""" + if not symbol: + raise ValueError("Symbol cannot be empty") + + # CoinGecko uses lowercase coin IDs + return symbol.lower().strip() + + +def parse_interval_to_days(interval: str) -> int: + """Parse interval string to number of days for CoinGecko API.""" + interval_map = { + "1d": 1, + "7d": 7, + "14d": 14, + "30d": 30, + "90d": 90, + "180d": 180, + "365d": 365, + "max": 365 * 10, # 10 years as max + } + + return interval_map.get(interval.lower(), 30) # Default to 30 days + + +def get_supported_vs_currencies() -> List[str]: + """Get list of supported vs currencies for CoinGecko API.""" + return [ + "usd", "eur", "jpy", "btc", "eth", "ltc", "bch", "bnb", "eos", "xrp", + "xlm", "link", "dot", "yfi", "gbp", "aud", "cad", "chf", "cny", "hkd", + "inr", "krw", "mxn", "nok", "nzd", "php", "pln", "rub", "sek", "sgd", + "thb", "try", "twd", "zar" + ] diff --git a/openbb_platform/providers/coingecko/pyproject.toml b/openbb_platform/providers/coingecko/pyproject.toml new file mode 100644 index 000000000000..5548e1540ba2 --- /dev/null +++ b/openbb_platform/providers/coingecko/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "openbb-coingecko" +version = "1.0.0" +description = "CoinGecko Provider for OpenBB Platform - Real-time and historical cryptocurrency data" +authors = ["OpenBB Team "] +license = "AGPL-3.0-only" +readme = "README.md" +packages = [{ include = "openbb_coingecko" }] + +[tool.poetry.dependencies] +python = ">=3.9.21,<3.13" +openbb-core = "^1.4.8" +requests = "^2.31.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.plugins."openbb_provider_extension"] +coingecko = "openbb_coingecko:coingecko_provider" diff --git a/openbb_platform/providers/coingecko/tests/__init__.py b/openbb_platform/providers/coingecko/tests/__init__.py new file mode 100644 index 000000000000..dec54e8ea4be --- /dev/null +++ b/openbb_platform/providers/coingecko/tests/__init__.py @@ -0,0 +1 @@ +"""CoinGecko provider tests.""" diff --git a/openbb_platform/providers/coingecko/tests/test_coingecko_fetchers.py b/openbb_platform/providers/coingecko/tests/test_coingecko_fetchers.py new file mode 100644 index 000000000000..51dcd59025be --- /dev/null +++ b/openbb_platform/providers/coingecko/tests/test_coingecko_fetchers.py @@ -0,0 +1,253 @@ +"""Tests for CoinGecko fetchers.""" + +import pytest +from datetime import date, datetime +from unittest.mock import Mock, patch + +# Skip tests if imports fail (e.g., in CI without provider installed) +try: + from openbb_coingecko.models.crypto_historical import ( + CoinGeckoCryptoHistoricalFetcher, + CoinGeckoCryptoHistoricalQueryParams, + ) + from openbb_coingecko.models.crypto_price import ( + CoinGeckoCryptoPriceFetcher, + CoinGeckoCryptoPriceQueryParams, + ) + from openbb_coingecko.models.crypto_search import ( + CoinGeckoCryptoSearchFetcher, + CoinGeckoCryptoSearchQueryParams, + ) + IMPORTS_AVAILABLE = True +except ImportError: + IMPORTS_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not IMPORTS_AVAILABLE, reason="CoinGecko provider not available" +) + + +class TestCoinGeckoCryptoHistoricalFetcher: + """Test CoinGecko Crypto Historical Fetcher.""" + + @pytest.fixture + def query_params(self): + """Return query parameters for testing.""" + return { + "symbol": "bitcoin", + "vs_currency": "usd", + "interval": "30d", + "start_date": None, + "end_date": None, + } + + @pytest.fixture + def mock_historical_data(self): + """Return mock historical data.""" + return { + "prices": [ + [1640995200000, 47000.0], # 2022-01-01 + [1641081600000, 47500.0], # 2022-01-02 + ], + "market_caps": [ + [1640995200000, 890000000000.0], + [1641081600000, 900000000000.0], + ], + "total_volumes": [ + [1640995200000, 25000000000.0], + [1641081600000, 26000000000.0], + ], + } + + def test_transform_query(self, query_params): + """Test query transformation.""" + result = CoinGeckoCryptoHistoricalFetcher.transform_query(query_params) + assert isinstance(result, CoinGeckoCryptoHistoricalQueryParams) + assert result.symbol == "bitcoin" + assert result.vs_currency == "usd" + assert result.interval == "30d" + + @patch("openbb_coingecko.models.crypto_historical.make_request") + async def test_aextract_data(self, mock_request, query_params, mock_historical_data): + """Test data extraction.""" + mock_request.return_value = mock_historical_data + + query = CoinGeckoCryptoHistoricalFetcher.transform_query(query_params) + result = await CoinGeckoCryptoHistoricalFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["symbol"] == "BITCOIN" + assert result[0]["close"] == 47000.0 + assert result[0]["market_cap"] == 890000000000.0 + + def test_transform_data(self, query_params, mock_historical_data): + """Test data transformation.""" + query = CoinGeckoCryptoHistoricalFetcher.transform_query(query_params) + + # Simulate processed data + processed_data = [ + { + "symbol": "BITCOIN", + "date": datetime(2022, 1, 1), + "open": 47000.0, + "high": 47000.0, + "low": 47000.0, + "close": 47000.0, + "volume": 25000000000.0, + "market_cap": 890000000000.0, + } + ] + + result = CoinGeckoCryptoHistoricalFetcher.transform_data(query, processed_data) + + assert len(result) == 1 + assert result[0].symbol == "BITCOIN" + assert result[0].close == 47000.0 + assert result[0].market_cap == 890000000000.0 + + +class TestCoinGeckoCryptoPriceFetcher: + """Test CoinGecko Crypto Price Fetcher.""" + + @pytest.fixture + def query_params(self): + """Return query parameters for testing.""" + return { + "symbol": "bitcoin", + "vs_currency": "usd", + "include_market_cap": True, + "include_24hr_vol": True, + "include_24hr_change": True, + "include_last_updated_at": True, + } + + @pytest.fixture + def mock_price_data(self): + """Return mock price data.""" + return { + "bitcoin": { + "usd": 47000.0, + "usd_market_cap": 890000000000.0, + "usd_24h_vol": 25000000000.0, + "usd_24h_change": 2.5, + "last_updated_at": 1640995200, + } + } + + def test_transform_query(self, query_params): + """Test query transformation.""" + result = CoinGeckoCryptoPriceFetcher.transform_query(query_params) + assert isinstance(result, CoinGeckoCryptoPriceQueryParams) + assert result.symbol == "bitcoin" + assert result.vs_currency == "usd" + assert result.include_market_cap is True + + @patch("openbb_coingecko.models.crypto_price.make_request") + async def test_aextract_data(self, mock_request, query_params, mock_price_data): + """Test data extraction.""" + mock_request.return_value = mock_price_data + + query = CoinGeckoCryptoPriceFetcher.transform_query(query_params) + result = await CoinGeckoCryptoPriceFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["symbol"] == "BITCOIN" + assert result[0]["price"] == 47000.0 + assert result[0]["market_cap"] == 890000000000.0 + assert result[0]["change_24h"] == 2.5 + + def test_transform_data(self, query_params): + """Test data transformation.""" + query = CoinGeckoCryptoPriceFetcher.transform_query(query_params) + + # Simulate processed data + processed_data = [ + { + "symbol": "BITCOIN", + "name": None, + "price": 47000.0, + "market_cap": 890000000000.0, + "market_cap_rank": None, + "volume_24h": 25000000000.0, + "change_24h": 2.5, + "last_updated": datetime(2022, 1, 1), + } + ] + + result = CoinGeckoCryptoPriceFetcher.transform_data(query, processed_data) + + assert len(result) == 1 + assert result[0].symbol == "BITCOIN" + assert result[0].price == 47000.0 + assert result[0].change_24h == 2.5 + + +class TestCoinGeckoCryptoSearchFetcher: + """Test CoinGecko Crypto Search Fetcher.""" + + @pytest.fixture + def query_params(self): + """Return query parameters for testing.""" + return {"query": "bitcoin"} + + @pytest.fixture + def mock_search_data(self): + """Return mock search data.""" + return { + "coins": [ + { + "id": "bitcoin", + "name": "Bitcoin", + "symbol": "btc", + "market_cap_rank": 1, + "thumb": "https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png", + "large": "https://assets.coingecko.com/coins/images/1/large/bitcoin.png", + } + ] + } + + def test_transform_query(self, query_params): + """Test query transformation.""" + result = CoinGeckoCryptoSearchFetcher.transform_query(query_params) + assert isinstance(result, CoinGeckoCryptoSearchQueryParams) + assert result.query == "bitcoin" + + @patch("openbb_coingecko.models.crypto_search.make_request") + async def test_aextract_data(self, mock_request, query_params, mock_search_data): + """Test data extraction.""" + mock_request.return_value = mock_search_data + + query = CoinGeckoCryptoSearchFetcher.transform_query(query_params) + result = await CoinGeckoCryptoSearchFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["symbol"] == "BTC" + assert result[0]["name"] == "Bitcoin" + assert result[0]["id"] == "bitcoin" + + def test_transform_data(self, query_params): + """Test data transformation.""" + query = CoinGeckoCryptoSearchFetcher.transform_query(query_params) + + # Simulate processed data + processed_data = [ + { + "symbol": "BTC", + "name": "Bitcoin", + "id": "bitcoin", + "api_symbol": "btc", + "market_cap_rank": 1, + "thumb": "https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png", + "large": "https://assets.coingecko.com/coins/images/1/large/bitcoin.png", + } + ] + + result = CoinGeckoCryptoSearchFetcher.transform_data(query, processed_data) + + assert len(result) == 1 + assert result[0].symbol == "BTC" + assert result[0].name == "Bitcoin" + assert result[0].id == "bitcoin" diff --git a/openbb_platform/providers/coingecko/tests/test_coingecko_helpers.py b/openbb_platform/providers/coingecko/tests/test_coingecko_helpers.py new file mode 100644 index 000000000000..3782e7bcfd15 --- /dev/null +++ b/openbb_platform/providers/coingecko/tests/test_coingecko_helpers.py @@ -0,0 +1,221 @@ +"""Tests for CoinGecko helper functions.""" + +import pytest +from unittest.mock import Mock, patch +import requests + +# Skip tests if imports fail (e.g., in CI without provider installed) +try: + from openbb_coingecko.utils.helpers import ( + build_headers, + build_url, + get_coingecko_base_url, + get_supported_vs_currencies, + make_request, + parse_interval_to_days, + validate_symbol, + CoinGeckoAPIError, + ) + IMPORTS_AVAILABLE = True +except ImportError: + IMPORTS_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not IMPORTS_AVAILABLE, reason="CoinGecko provider not available" +) + + +class TestCoinGeckoHelpers: + """Test CoinGecko helper functions.""" + + def test_get_coingecko_base_url_free(self): + """Test getting free API base URL.""" + url = get_coingecko_base_url(use_pro_api=False) + assert url == "https://api.coingecko.com/api/v3" + + def test_get_coingecko_base_url_pro(self): + """Test getting Pro API base URL.""" + url = get_coingecko_base_url(use_pro_api=True) + assert url == "https://pro-api.coingecko.com/api/v3" + + def test_build_headers_without_api_key(self): + """Test building headers without API key.""" + headers = build_headers() + expected = { + "accept": "application/json", + "User-Agent": "OpenBB/1.0.0", + } + assert headers == expected + + def test_build_headers_with_api_key(self): + """Test building headers with API key.""" + api_key = "test_api_key" + headers = build_headers(api_key) + expected = { + "accept": "application/json", + "User-Agent": "OpenBB/1.0.0", + "x-cg-pro-api-key": api_key, + } + assert headers == expected + + def test_build_url_without_params(self): + """Test building URL without parameters.""" + url = build_url("simple/price") + assert url == "https://api.coingecko.com/api/v3/simple/price" + + def test_build_url_with_params(self): + """Test building URL with parameters.""" + params = {"ids": "bitcoin", "vs_currencies": "usd"} + url = build_url("simple/price", params) + assert "https://api.coingecko.com/api/v3/simple/price?" in url + assert "ids=bitcoin" in url + assert "vs_currencies=usd" in url + + def test_build_url_with_api_key(self): + """Test building URL with API key (uses Pro API).""" + url = build_url("simple/price", api_key="test_key") + assert url == "https://pro-api.coingecko.com/api/v3/simple/price" + + def test_build_url_filters_none_params(self): + """Test that None parameters are filtered out.""" + params = {"ids": "bitcoin", "vs_currencies": None, "include_market_cap": "true"} + url = build_url("simple/price", params) + assert "vs_currencies" not in url + assert "ids=bitcoin" in url + assert "include_market_cap=true" in url + + def test_validate_symbol_single(self): + """Test validating single symbol.""" + result = validate_symbol("Bitcoin") + assert result == "bitcoin" + + def test_validate_symbol_with_spaces(self): + """Test validating symbol with spaces.""" + result = validate_symbol(" Bitcoin ") + assert result == "bitcoin" + + def test_validate_symbol_empty_raises_error(self): + """Test that empty symbol raises error.""" + with pytest.raises(ValueError, match="Symbol cannot be empty"): + validate_symbol("") + + def test_parse_interval_to_days(self): + """Test parsing interval to days.""" + assert parse_interval_to_days("1d") == 1 + assert parse_interval_to_days("7d") == 7 + assert parse_interval_to_days("30d") == 30 + assert parse_interval_to_days("365d") == 365 + assert parse_interval_to_days("max") == 365 * 10 + assert parse_interval_to_days("invalid") == 30 # default + + def test_get_supported_vs_currencies(self): + """Test getting supported vs currencies.""" + currencies = get_supported_vs_currencies() + assert isinstance(currencies, list) + assert "usd" in currencies + assert "eur" in currencies + assert "btc" in currencies + assert "eth" in currencies + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_success(self, mock_get): + """Test successful API request.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"bitcoin": {"usd": 50000}} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = make_request("simple/price", {"ids": "bitcoin", "vs_currencies": "usd"}) + + assert result == {"bitcoin": {"usd": 50000}} + mock_get.assert_called_once() + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_http_error_429(self, mock_get): + """Test handling of rate limit error.""" + mock_response = Mock() + mock_response.status_code = 429 + mock_response.text = "Rate limit exceeded" + + http_error = requests.exceptions.HTTPError() + http_error.response = mock_response + mock_get.return_value.raise_for_status.side_effect = http_error + + with pytest.raises(CoinGeckoAPIError, match="Rate limit exceeded"): + make_request("simple/price") + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_http_error_401(self, mock_get): + """Test handling of authentication error.""" + mock_response = Mock() + mock_response.status_code = 401 + mock_response.text = "Invalid API key" + + http_error = requests.exceptions.HTTPError() + http_error.response = mock_response + mock_get.return_value.raise_for_status.side_effect = http_error + + with pytest.raises(CoinGeckoAPIError, match="Invalid API key"): + make_request("simple/price") + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_http_error_404(self, mock_get): + """Test handling of not found error.""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = "Not found" + + http_error = requests.exceptions.HTTPError() + http_error.response = mock_response + mock_get.return_value.raise_for_status.side_effect = http_error + + with pytest.raises(CoinGeckoAPIError, match="Endpoint not found"): + make_request("simple/price") + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_connection_error(self, mock_get): + """Test handling of connection error.""" + mock_get.side_effect = requests.exceptions.ConnectionError("Connection failed") + + with pytest.raises(CoinGeckoAPIError, match="Request failed"): + make_request("simple/price") + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_json_error(self, mock_get): + """Test handling of JSON parsing error.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + with pytest.raises(CoinGeckoAPIError, match="Invalid JSON response"): + make_request("simple/price") + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_empty_data(self, mock_get): + """Test handling of empty data response.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = None + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + with pytest.raises(Exception): # Should raise EmptyDataError + make_request("simple/price") + + @patch("openbb_coingecko.utils.helpers.requests.get") + def test_make_request_with_timeout(self, mock_get): + """Test request with custom timeout.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"test": "data"} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + make_request("simple/price", timeout=60) + + # Check that timeout was passed to requests.get + args, kwargs = mock_get.call_args + assert kwargs["timeout"] == 60 diff --git a/openbb_platform/providers/coingecko/tests/test_coingecko_integration.py b/openbb_platform/providers/coingecko/tests/test_coingecko_integration.py new file mode 100644 index 000000000000..417bc1d46c3c --- /dev/null +++ b/openbb_platform/providers/coingecko/tests/test_coingecko_integration.py @@ -0,0 +1,228 @@ +"""Integration tests for CoinGecko provider.""" + +import pytest +from datetime import date + +# Skip integration tests in CI to avoid network dependencies +pytestmark = pytest.mark.skip(reason="Integration tests require network access") + +# Skip tests if imports fail (e.g., in CI without provider installed) +try: + from openbb_coingecko.models.crypto_historical import CoinGeckoCryptoHistoricalFetcher + from openbb_coingecko.models.crypto_price import CoinGeckoCryptoPriceFetcher + from openbb_coingecko.models.crypto_search import CoinGeckoCryptoSearchFetcher + IMPORTS_AVAILABLE = True +except ImportError: + IMPORTS_AVAILABLE = False + + +@pytest.mark.integration +class TestCoinGeckoIntegration: + """Integration tests for CoinGecko provider.""" + + @pytest.mark.asyncio + async def test_crypto_historical_bitcoin(self): + """Test historical data for Bitcoin.""" + params = { + "symbol": "bitcoin", + "vs_currency": "usd", + "interval": "7d", + } + + query = CoinGeckoCryptoHistoricalFetcher.transform_query(params) + result = await CoinGeckoCryptoHistoricalFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) > 0 + + # Check data structure + first_item = result[0] + assert "symbol" in first_item + assert "date" in first_item + assert "close" in first_item + assert "volume" in first_item + + # Transform and validate + transformed = CoinGeckoCryptoHistoricalFetcher.transform_data(query, result) + assert len(transformed) > 0 + assert transformed[0].symbol.upper() == "BITCOIN" + + @pytest.mark.asyncio + async def test_crypto_historical_multiple_symbols(self): + """Test historical data for multiple cryptocurrencies.""" + params = { + "symbol": "bitcoin,ethereum", + "vs_currency": "usd", + "interval": "1d", + } + + query = CoinGeckoCryptoHistoricalFetcher.transform_query(params) + result = await CoinGeckoCryptoHistoricalFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) > 0 + + # Should have data for both symbols + symbols = {item["symbol"] for item in result} + assert len(symbols) >= 1 # At least one symbol should work + + @pytest.mark.asyncio + async def test_crypto_historical_with_date_range(self): + """Test historical data with specific date range.""" + params = { + "symbol": "bitcoin", + "vs_currency": "usd", + "start_date": date(2024, 1, 1), + "end_date": date(2024, 1, 7), + } + + query = CoinGeckoCryptoHistoricalFetcher.transform_query(params) + result = await CoinGeckoCryptoHistoricalFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) > 0 + + @pytest.mark.asyncio + async def test_crypto_price_bitcoin(self): + """Test real-time price for Bitcoin.""" + params = { + "symbol": "bitcoin", + "vs_currency": "usd", + "include_market_cap": True, + "include_24hr_vol": True, + "include_24hr_change": True, + } + + query = CoinGeckoCryptoPriceFetcher.transform_query(params) + result = await CoinGeckoCryptoPriceFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) == 1 + + # Check data structure + first_item = result[0] + assert "symbol" in first_item + assert "price" in first_item + assert first_item["price"] > 0 + + # Transform and validate + transformed = CoinGeckoCryptoPriceFetcher.transform_data(query, result) + assert len(transformed) == 1 + assert transformed[0].price > 0 + + @pytest.mark.asyncio + async def test_crypto_price_multiple_symbols(self): + """Test real-time prices for multiple cryptocurrencies.""" + params = { + "symbol": "bitcoin,ethereum,cardano", + "vs_currency": "usd", + "include_market_cap": True, + "include_24hr_vol": True, + } + + query = CoinGeckoCryptoPriceFetcher.transform_query(params) + result = await CoinGeckoCryptoPriceFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) >= 1 # At least one symbol should work + + # All items should have valid prices + for item in result: + assert item["price"] > 0 + + @pytest.mark.asyncio + async def test_crypto_price_different_currency(self): + """Test real-time price in different currency.""" + params = { + "symbol": "bitcoin", + "vs_currency": "eur", + "include_market_cap": True, + } + + query = CoinGeckoCryptoPriceFetcher.transform_query(params) + result = await CoinGeckoCryptoPriceFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["price"] > 0 + + @pytest.mark.asyncio + async def test_crypto_search_with_query(self): + """Test cryptocurrency search with query.""" + params = {"query": "bitcoin"} + + query = CoinGeckoCryptoSearchFetcher.transform_query(params) + result = await CoinGeckoCryptoSearchFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) > 0 + + # Check data structure + first_item = result[0] + assert "symbol" in first_item + assert "name" in first_item + assert "id" in first_item + + # Transform and validate + transformed = CoinGeckoCryptoSearchFetcher.transform_data(query, result) + assert len(transformed) > 0 + assert any("bitcoin" in item.name.lower() for item in transformed if item.name) + + @pytest.mark.asyncio + async def test_crypto_search_all_coins(self): + """Test getting all available cryptocurrencies.""" + params = {"query": None} + + query = CoinGeckoCryptoSearchFetcher.transform_query(params) + result = await CoinGeckoCryptoSearchFetcher.aextract_data(query, None) + + assert isinstance(result, list) + assert len(result) > 1000 # Should have many cryptocurrencies + + # Check data structure + first_item = result[0] + assert "symbol" in first_item + assert "name" in first_item + assert "id" in first_item + + @pytest.mark.asyncio + async def test_error_handling_invalid_symbol(self): + """Test error handling for invalid symbol.""" + params = { + "symbol": "invalid_coin_that_does_not_exist", + "vs_currency": "usd", + } + + query = CoinGeckoCryptoPriceFetcher.transform_query(params) + + # This should handle the error gracefully + try: + result = await CoinGeckoCryptoPriceFetcher.aextract_data(query, None) + # If no exception, result should be empty or contain error info + assert isinstance(result, list) + except Exception as e: + # Should raise a meaningful error + assert "No" in str(e) or "not found" in str(e).lower() + + @pytest.mark.asyncio + async def test_rate_limiting_handling(self): + """Test that rate limiting is handled gracefully.""" + # This test makes multiple rapid requests to test rate limiting + params = { + "symbol": "bitcoin", + "vs_currency": "usd", + } + + query = CoinGeckoCryptoPriceFetcher.transform_query(params) + + # Make multiple requests rapidly + for _ in range(3): + try: + result = await CoinGeckoCryptoPriceFetcher.aextract_data(query, None) + assert isinstance(result, list) + except Exception as e: + # Rate limiting errors should be handled gracefully + if "rate limit" in str(e).lower(): + assert "rate limit" in str(e).lower() + else: + raise