"""Twelve Data API client with in-memory cache and simple rate limiting.""" from __future__ import annotations import asyncio import logging import time from typing import Any import httpx import pandas as pd from config import SUPPORTED_PAIRS, SUPPORTED_TIMEFRAMES, TWELVE_DATA_API_KEY logger = logging.getLogger(__name__) _API_URL = "https://api.twelvedata.com/time_series" _TTL_SECONDS: dict[str, int] = { "15min": 800, "1h": 3500, "4h": 14000, "1day": 43000, } _cache: dict[tuple[str, str], tuple[float, pd.DataFrame]] = {} _call_times: list[float] = [] _lock = asyncio.Lock() _MAX_CALLS_PER_MIN = 8 _WINDOW = 60.0 def _to_api_symbol(pair: str) -> str: """Convert 'EURUSD' to 'EUR/USD'.""" return f"{pair[:3]}/{pair[3:]}" async def _respect_rate_limit() -> None: now = time.monotonic() cutoff = now - _WINDOW while _call_times and _call_times[0] < cutoff: _call_times.pop(0) if len(_call_times) >= _MAX_CALLS_PER_MIN: sleep_for = _WINDOW - (now - _call_times[0]) + 0.1 logger.info("Rate limit hit, sleeping %.2fs", sleep_for) await asyncio.sleep(sleep_for) _call_times.append(time.monotonic()) async def fetch_ohlc(pair: str, timeframe: str, size: int = 200) -> pd.DataFrame: """Fetch OHLC candles for a forex pair from Twelve Data. Returns a DataFrame indexed by datetime (ascending) with columns [open, high, low, close]. Cached per (pair, timeframe) with timeframe-aware TTL. """ pair = pair.upper() if pair not in SUPPORTED_PAIRS: raise ValueError(f"Unsupported pair: {pair}") if timeframe not in SUPPORTED_TIMEFRAMES: raise ValueError(f"Unsupported timeframe: {timeframe}") key = (pair, timeframe) ttl = _TTL_SECONDS[timeframe] now = time.time() async with _lock: cached = _cache.get(key) if cached and (now - cached[0]) < ttl: return cached[1] await _respect_rate_limit() params: dict[str, Any] = { "symbol": _to_api_symbol(pair), "interval": timeframe, "outputsize": size, "apikey": TWELVE_DATA_API_KEY, "format": "JSON", } try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.get(_API_URL, params=params) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: logger.error("Twelve Data HTTP error: %s", e) raise if data.get("status") == "error" or "values" not in data: msg = data.get("message", "Unknown API error") logger.error("Twelve Data API error: %s", msg) raise ValueError(f"API error: {msg}") df = pd.DataFrame(data["values"]) df["datetime"] = pd.to_datetime(df["datetime"]) for col in ("open", "high", "low", "close"): df[col] = df[col].astype(float) df = df.set_index("datetime")[["open", "high", "low", "close"]].sort_index() _cache[key] = (now, df) return df