CryptoSentinel_AI / app /price_fetcher.py
mgbam's picture
Update app/price_fetcher.py
fd31489 verified
raw
history blame
6.43 kB
"""
Provides a robust, asynchronous PriceFetcher class for caching cryptocurrency prices.
Features:
- Asynchronous fetching using httpx.AsyncClient.
- Multi-API fallback for high availability.
- Rate-limit (429) and error handling.
- Concurrency-safe in-memory cache.
- Decoupled design for easy extension with new data sources.
"""
import asyncio
import logging
from typing import Callable, TypedDict, Awaitable
import httpx
# --- Configuration ---
# Set up a structured logger
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
# Define the structure for a price parsing function
PriceParser = Callable[[dict], Awaitable[dict[str, float]]]
# Define the structure for a data source, linking a URL to its parser
class PriceSource(TypedDict):
name: str
url: str
params: dict
parser: PriceParser
# --- Main Class: PriceFetcher ---
class PriceFetcher:
"""Manages fetching and caching crypto prices from multiple APIs asynchronously."""
def __init__(self, client: httpx.AsyncClient, coins: list[str]):
"""
Initializes the PriceFetcher.
Args:
client: An instance of httpx.AsyncClient for making API calls.
coins: A list of coin IDs to fetch (e.g., ['bitcoin', 'ethereum']).
"""
self.client = client
self.coins = coins
self._prices: dict[str, float | str] = {coin: "--" for coin in coins}
self._lock = asyncio.Lock() # Lock to prevent race conditions on the cache
self.sources: list[PriceSource] = self._configure_sources()
def _configure_sources(self) -> list[PriceSource]:
"""Defines the API sources and their parsers."""
return [
{
"name": "CoinGecko",
"url": "https://api.coingecko.com/api/v3/simple/price",
"params": {
"ids": ",".join(self.coins),
"vs_currencies": "usd"
},
"parser": self._parse_coingecko,
},
{
"name": "CoinCap",
"url": "https://api.coincap.io/v2/assets",
"params": {"ids": ",".join(self.coins)},
"parser": self._parse_coincap,
},
]
async def _parse_coingecko(self, data: dict) -> dict[str, float]:
"""Parses the JSON response from CoinGecko."""
try:
return {
coin: float(data[coin]["usd"])
for coin in self.coins if coin in data
}
except (KeyError, TypeError) as e:
logging.error("❌ [CoinGecko] Failed to parse response: %s", e)
return {}
async def _parse_coincap(self, data: dict) -> dict[str, float]:
"""Parses the JSON response from CoinCap."""
try:
# CoinCap returns a list under the 'data' key
return {
item["id"]: float(item["priceUsd"])
for item in data.get("data", []) if item.get("id") in self.coins
}
except (KeyError, TypeError, ValueError) as e:
logging.error("❌ [CoinCap] Failed to parse response: %s", e)
return {}
def get_current_prices(self) -> dict[str, float | str]:
"""Returns a copy of the current price cache. Thread-safe read."""
return self._prices.copy()
async def update_prices_async(self) -> None:
"""
Asynchronously fetches prices, trying each source until one succeeds.
Updates the internal price cache in a concurrency-safe manner.
"""
for source in self.sources:
name, url, params, parser = source.values()
try:
resp = await self.client.get(url, params=params, timeout=10)
resp.raise_for_status()
new_prices = await parser(resp.json())
if not new_prices: # Parser failed to extract data
continue
async with self._lock:
self._prices.update(new_prices)
logging.info("βœ… [%s] Prices updated: %s", name, new_prices)
return # Success, so we exit the loop
except httpx.HTTPStatusError as e:
status = e.response.status_code
log_msg = f"⚠️ [{name}] HTTP error {status}"
if status == 429:
log_msg += " (Rate Limit). Trying next source..."
logging.warning(log_msg)
except (httpx.RequestError, asyncio.TimeoutError) as e:
logging.warning("⚠️ [%s] Request failed: %s. Trying next source...", name, e)
# Brief pause before trying the next API source
await asyncio.sleep(1)
logging.error("❌ All price APIs failed. Retaining stale prices.")
async def run_price_updates_periodically(fetcher: PriceFetcher, interval_seconds: int):
"""A background task runner to keep prices updated."""
logging.info("πŸš€ Starting periodic price updates...")
while True:
await fetcher.update_prices_async()
await asyncio.sleep(interval_seconds)
# --- Example Usage ---
if __name__ == "__main__":
async def main():
"""Demonstrates how to use the PriceFetcher."""
target_coins = ["bitcoin", "ethereum", "dogecoin"]
async with httpx.AsyncClient() as client:
price_fetcher = PriceFetcher(client, coins=target_coins)
# Run the price updates in the background
update_task = asyncio.create_task(
run_price_updates_periodically(price_fetcher, interval_seconds=10)
)
# In a real app, the server would be running. Here, we just print prices.
for i in range(5):
await asyncio.sleep(11)
current_prices = price_fetcher.get_current_prices()
print(f"--- Main App Reading Cache ({i+1}/5) ---")
for coin, price in current_prices.items():
print(f" {coin.capitalize()}: ${price}")
# Cleanly shut down the background task
update_task.cancel()
try:
await update_task
except asyncio.CancelledError:
logging.info("Shutdown complete.")
asyncio.run(main())