| """ |
| Aggressive Caching Shield - Solana Tracker Data API Client |
| Multi-key load balanced with per-key rate limiting and quota tracking. |
| |
| Keys configured: |
| PRIMARY: noble-flint-3959.secure.data.solanatracker.io (no header, subdomain auth) |
| SECONDARY: data.solanatracker.io + x-api-key: st_ZMzXzdUI54TXQPx5E6JkG |
| |
| Both free tier: 2,500 req/month, 3 RPS each = 5,000 combined, 6 RPS burst |
| Load balancing: round-robin with 429 fallback to next key |
| """ |
|
|
| import asyncio |
| import hashlib |
| import json |
| import logging |
| import time |
| from dataclasses import dataclass |
|
|
| import httpx |
|
|
| logger = logging.getLogger("solana_tracker") |
|
|
| |
| KEY_CONFIGS = [ |
| { |
| "name": "primary", |
| "base_url": "https://noble-flint-3959.secure.data.solanatracker.io", |
| "api_key": None, |
| "rate_rps": 3.0, |
| "monthly_quota": 2500, |
| }, |
| { |
| "name": "secondary", |
| "base_url": "https://data.solanatracker.io", |
| "api_key": "st_ZMzXzdUI54TXQPx5E6JkG", |
| "rate_rps": 3.0, |
| "monthly_quota": 2500, |
| }, |
| ] |
|
|
| |
| TTL_PRICE = 8 |
| TTL_TOKEN = 30 |
| TTL_WALLET = 20 |
| TTL_TRADES = 20 |
| TTL_OHLCV = 60 |
| TTL_SEARCH = 45 |
| TTL_LATEST = 10 |
| TTL_TRENDING = 180 |
| TTL_HOLDERS = 60 |
|
|
|
|
| @dataclass |
| class KeyState: |
| """Per-key usage and health tracking.""" |
|
|
| name: str |
| base_url: str |
| api_key: str | None |
| rate_rps: float |
| monthly_quota: int |
| calls_this_month: int = 0 |
| month_key: str = "" |
| tokens: float = 0.0 |
| last_refill: float = 0.0 |
| rate_limited_until: float = 0.0 |
| consecutive_429s: int = 0 |
| http: httpx.AsyncClient | None = None |
|
|
| def __post_init__(self): |
| self.tokens = self.rate_rps |
| self.last_refill = time.monotonic() |
| self.month_key = time.strftime("%Y-%m") |
|
|
| def can_use(self, now: float) -> bool: |
| if now < self.rate_limited_until: |
| return False |
| current_month = time.strftime("%Y-%m") |
| if current_month != self.month_key: |
| self.month_key = current_month |
| self.calls_this_month = 0 |
| return self.calls_this_month < self.monthly_quota |
|
|
| def refill(self, now: float): |
| elapsed = now - self.last_refill |
| self.tokens = min(self.rate_rps, self.tokens + elapsed * self.rate_rps) |
| self.last_refill = now |
|
|
| def mark_used(self): |
| self.tokens -= 1.0 |
| self.calls_this_month += 1 |
|
|
| def mark_429(self, now: float): |
| self.consecutive_429s += 1 |
| backoff = min(60, 5 * (2 ** min(self.consecutive_429s, 4))) |
| self.rate_limited_until = now + backoff |
|
|
| def mark_success(self): |
| self.consecutive_429s = 0 |
| self.rate_limited_until = 0.0 |
|
|
|
|
| class SolanaTrackerClient: |
| """Multi-key load balanced client for Solana Tracker Data API. |
| |
| Round-robins between PRIMARY (secure endpoint) and SECONDARY (generic). |
| On 429, falls through to next key with exponential backoff. |
| """ |
|
|
| def __init__(self): |
| self._keys = [KeyState(**cfg) for cfg in KEY_CONFIGS] |
| self._key_index = 0 |
| self._key_lock = asyncio.Lock() |
| self._l1: dict[str, tuple] = {} |
| self._l1_lock = asyncio.Lock() |
| self.cache_hits = 0 |
| self.cache_misses = 0 |
|
|
| def _cache_key(self, path: str, params: dict) -> str: |
| raw = f"{path}:{json.dumps(params or {}, sort_keys=True, default=str)}" |
| return hashlib.sha256(raw.encode()).hexdigest()[:24] |
|
|
| async def _cache_get(self, key: str) -> dict | None: |
| async with self._l1_lock: |
| entry = self._l1.get(key) |
| if entry: |
| expiry, data = entry |
| if time.monotonic() < expiry: |
| self.cache_hits += 1 |
| return data |
| del self._l1[key] |
| self.cache_misses += 1 |
| return None |
|
|
| async def _cache_set(self, key: str, data: dict, ttl: int): |
| async with self._l1_lock: |
| self._l1[key] = (time.monotonic() + ttl, data) |
| if len(self._l1) > 1024: |
| oldest = min(self._l1.keys(), key=lambda k: self._l1[k][0]) |
| del self._l1[oldest] |
|
|
| async def _get_http(self, key: KeyState) -> httpx.AsyncClient: |
| if key.http is None: |
| headers = {"Accept": "application/json"} |
| if key.api_key: |
| headers["x-api-key"] = key.api_key |
| key.http = httpx.AsyncClient(timeout=15.0, headers=headers) |
| return key.http |
|
|
| async def _pick_key(self) -> KeyState | None: |
| async with self._key_lock: |
| now = time.monotonic() |
| for _ in range(len(self._keys)): |
| key = self._keys[self._key_index] |
| self._key_index = (self._key_index + 1) % len(self._keys) |
| key.refill(now) |
| if not key.can_use(now): |
| continue |
| if key.tokens < 1.0: |
| continue |
| key.mark_used() |
| return key |
| return None |
|
|
| async def _call(self, path: str, params: dict | None = None, ttl: int = TTL_TOKEN) -> dict | None: |
| cache_key = self._cache_key(path, params or {}) |
| cached = await self._cache_get(cache_key) |
| if cached is not None: |
| return cached |
|
|
| for _attempt in range(min(len(self._keys), 2)): |
| key = await self._pick_key() |
| if key is None: |
| logger.warning("ST: all keys exhausted") |
| return None |
|
|
| http = await self._get_http(key) |
| url = f"{key.base_url}{path}" |
| now = time.monotonic() |
|
|
| try: |
| if params: |
| clean = {k: v for k, v in params.items() if v is not None} |
| resp = await http.get(url, params=clean) |
| else: |
| resp = await http.get(url) |
|
|
| if resp.status_code == 200: |
| key.mark_success() |
| data = resp.json() |
| await self._cache_set(cache_key, data, ttl) |
| return data |
| elif resp.status_code == 429: |
| key.mark_429(now) |
| logger.debug(f"ST 429 on {key.name}, rotating") |
| continue |
| elif resp.status_code == 401: |
| logger.error(f"ST 401 on {key.name}, key invalid") |
| key.rate_limited_until = now + 3600 |
| continue |
| else: |
| logger.warning(f"ST {resp.status_code} on {key.name}: {resp.text[:100]}") |
| return None |
| except httpx.TimeoutException: |
| logger.debug(f"ST timeout on {key.name}, rotating") |
| continue |
| except Exception as e: |
| logger.warning(f"ST error on {key.name}: {e}") |
| continue |
|
|
| logger.warning(f"ST: all attempts failed for {path}") |
| return None |
|
|
| |
| async def get_price(self, mint: str) -> dict | None: |
| return await self._call("/price", {"token": mint}, TTL_PRICE) |
|
|
| async def get_token(self, mint: str) -> dict | None: |
| return await self._call(f"/tokens/{mint}", None, TTL_TOKEN) |
|
|
| async def search_tokens(self, **kwargs) -> dict | None: |
| params = {k: v for k, v in kwargs.items() if v is not None} |
| params.setdefault("limit", 20) |
| return await self._call("/search", params, TTL_SEARCH) |
|
|
| async def get_tokens_latest(self, limit: int = 20) -> dict | None: |
| return await self._call("/tokens/latest", {"limit": min(limit, 100)}, TTL_LATEST) |
|
|
| async def get_tokens_trending(self, limit: int = 20) -> dict | None: |
| return await self._call("/tokens/trending", {"limit": min(limit, 100)}, TTL_TRENDING) |
|
|
| async def get_token_holders(self, mint: str, limit: int = 20) -> dict | None: |
| return await self._call(f"/tokens/{mint}/holders", {"limit": min(limit, 50)}, TTL_HOLDERS) |
|
|
| async def get_token_trades(self, mint: str, limit: int = 20, cursor: str | None = None) -> dict | None: |
| params = {"limit": min(limit, 100)} |
| if cursor: |
| params["cursor"] = cursor |
| return await self._call(f"/trades/{mint}", params, TTL_TRADES) |
|
|
| |
| async def get_wallet(self, address: str) -> dict | None: |
| return await self._call(f"/wallet/{address}", None, TTL_WALLET) |
|
|
| async def get_wallet_trades(self, address: str, limit: int = 20, cursor: str | None = None) -> dict | None: |
| params = {"limit": min(limit, 100)} |
| if cursor: |
| params["cursor"] = cursor |
| return await self._call(f"/wallet/{address}/trades", params, TTL_TRADES) |
|
|
| async def get_wallet_pnl(self, address: str) -> dict | None: |
| return await self._call(f"/wallet/{address}/pnl", None, TTL_WALLET) |
|
|
| |
| async def get_ohlcv(self, mint: str, timeframe: str = "1H", limit: int = 100) -> dict | None: |
| return await self._call( |
| f"/ohlcv/{mint}", |
| { |
| "timeframe": timeframe, |
| "limit": min(limit, 500), |
| }, |
| TTL_OHLCV, |
| ) |
|
|
| |
| def stats(self) -> dict: |
| now = time.monotonic() |
| keys_info = [] |
| for k in self._keys: |
| k.refill(now) |
| keys_info.append( |
| { |
| "name": k.name, |
| "tokens": round(k.tokens, 1), |
| "calls_this_month": k.calls_this_month, |
| "quota_remaining": k.monthly_quota - k.calls_this_month, |
| "rate_limited": k.rate_limited_until > now, |
| "consecutive_429s": k.consecutive_429s, |
| } |
| ) |
|
|
| def _get(): |
| return { |
| "keys": keys_info, |
| "cache_hits": self.cache_hits, |
| "cache_misses": self.cache_misses, |
| "l1_size": len(self._l1), |
| "combined_quota": sum(k.monthly_quota for k in self._keys), |
| "combined_used": sum(k.calls_this_month for k in self._keys), |
| } |
|
|
| return _get() |
|
|
|
|
| _client: SolanaTrackerClient | None = None |
|
|
|
|
| def get_solana_tracker() -> SolanaTrackerClient: |
| global _client |
| if _client is None: |
| _client = SolanaTrackerClient() |
| return _client |
|
|