Align with Twelve Data API docs and add Telegram command menu

data_provider:
- Switch to Authorization header (recommended over apikey query param)
- Enforce 800 credits/day daily limit with warning at 80%
- Fall back to synthetic data when daily limit is reached
- Fix null OHLC field handling (API can return null for any field)

telegram_bot:
- Register commands with set_my_commands so Telegram shows / menu
- Add /credits command to show daily API credit usage and limits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harwinder
2026-05-04 15:33:32 -04:00
parent 71c3fb6d82
commit 0e95a34d82
2 changed files with 98 additions and 28 deletions
+57 -27
View File
@@ -60,9 +60,10 @@ class ForexDataProvider:
Timeframe.W1: 0.143,
}
# Free plan: 8 API credits per minute (each symbol = 1 credit)
# Free plan: 8 credits/minute, 800 credits/day
RATE_LIMIT = 8
RATE_WINDOW = 60.0
DAILY_LIMIT = 800
def __init__(self, config: PlatformConfig):
self.config = config
@@ -72,12 +73,17 @@ class ForexDataProvider:
)
self._cache: Dict[str, pd.DataFrame] = {}
self._cache_timestamps: Dict[str, float] = {}
self._request_times: deque = deque() # timestamps of recent API credits used
self._request_times: deque = deque() # timestamps of recent per-minute credits
self._daily_credits: int = 0 # credits used today
self._daily_reset: float = time.time() + 86400 # next midnight reset
self._no_api_data: set = set() # td_symbols with no values from API (use synthetic)
self._session = requests.Session()
# Docs recommend Authorization header over apikey query param
self._session.headers.update({
"User-Agent": "Mozilla/5.0 (compatible; ForexIntel/1.0)"
"User-Agent": "Mozilla/5.0 (compatible; ForexIntel/1.0)",
})
if self._api_key:
self._session.headers["Authorization"] = f"apikey {self._api_key}"
@staticmethod
def _to_td_symbol(symbol: str) -> str:
@@ -199,27 +205,48 @@ class ForexDataProvider:
# ── Private Methods ────────────────────────────────────────────
def _throttle(self, credits: int = 1) -> None:
def _throttle(self, credits: int = 1) -> bool:
"""
Block until `credits` API credits are available.
Block until `credits` per-minute API credits are available.
Returns False if the daily limit is reached (caller should use synthetic data).
Twelve Data free tier: 8 credits/minute. Each symbol in any request
(single or batch) consumes 1 credit. This method enforces the limit
regardless of whether requests are batched or individual.
Twelve Data free tier: 8 credits/minute, 800 credits/day.
Each symbol in any request (single or batch) consumes 1 credit.
"""
# Reset daily counter after 24 hours
now = time.time()
if now >= self._daily_reset:
self._daily_credits = 0
self._daily_reset = now + 86400
# Daily limit check
if self._daily_credits + credits > self.DAILY_LIMIT:
logger.warning(
f"Daily API limit reached ({self._daily_credits}/{self.DAILY_LIMIT} credits) "
"— using cached/synthetic data for remaining requests today."
)
return False
# Warn when approaching daily limit
if self._daily_credits + credits > self.DAILY_LIMIT * 0.8:
logger.warning(
f"Approaching daily API limit: {self._daily_credits}/{self.DAILY_LIMIT} credits used."
)
# Per-minute rate limit
while True:
now = time.time()
# Evict credits older than the rate window
while self._request_times and now - self._request_times[0] >= self.RATE_WINDOW:
self._request_times.popleft()
available = self.RATE_LIMIT - len(self._request_times)
if available >= credits:
ts = time.time()
for _ in range(credits):
self._request_times.append(time.time())
return
self._request_times.append(ts)
self._daily_credits += credits
return True
# Wait until the oldest credit expires
wait = (self._request_times[0] + self.RATE_WINDOW) - now + 0.2
logger.info(
f"Rate limit: {len(self._request_times)}/{self.RATE_LIMIT} credits used "
@@ -246,7 +273,8 @@ class ForexDataProvider:
outputsize = max(outputsize, 300)
# Each symbol in a batch request costs 1 credit
self._throttle(credits=len(symbols))
if not self._throttle(credits=len(symbols)):
return # daily limit reached — callers will use synthetic data
resp = self._session.get(
f"{self.TD_BASE_URL}/time_series",
@@ -254,7 +282,6 @@ class ForexDataProvider:
"symbol": ",".join(td_symbols),
"interval": interval,
"outputsize": outputsize,
"apikey": self._api_key,
"timezone": "UTC",
"format": "json",
},
@@ -283,7 +310,7 @@ class ForexDataProvider:
values = sym_data.get("values")
if not values:
if td_sym not in self._no_api_data:
logger.warning(
logger.info(
f"No API data for {td_sym} — using synthetic data"
)
self._no_api_data.add(td_sym)
@@ -301,13 +328,14 @@ class ForexDataProvider:
records = [
{
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"),
"Open": float(v["open"]),
"High": float(v["high"]),
"Low": float(v["low"]),
"Close": float(v["close"]),
"Volume": float(v.get("volume") or 0),
"Open": float(v["open"]) if v.get("open") is not None else None,
"High": float(v["high"]) if v.get("high") is not None else None,
"Low": float(v["low"]) if v.get("low") is not None else None,
"Close": float(v["close"]) if v.get("close") is not None else None,
"Volume": float(v["volume"]) if v.get("volume") is not None else 0.0,
}
for v in reversed(values)
if v.get("datetime")
]
df = pd.DataFrame(records).set_index("Datetime")
df.index.name = "Datetime"
@@ -341,7 +369,8 @@ class ForexDataProvider:
)
outputsize = max(outputsize, 300)
self._throttle(credits=1)
if not self._throttle(credits=1):
return self._generate_synthetic_data(symbol, timeframe, lookback_days)
try:
resp = self._session.get(
@@ -350,7 +379,6 @@ class ForexDataProvider:
"symbol": td_symbol,
"interval": interval,
"outputsize": outputsize,
"apikey": self._api_key,
"timezone": "UTC",
"format": "json",
},
@@ -369,16 +397,18 @@ class ForexDataProvider:
raise DataIntegrityError(f"Empty response from Twelve Data for {td_symbol}")
# Values arrive newest-first; reverse to chronological order
# API may return null for any OHLC field — map to None for pandas NaN handling
records = [
{
"Datetime": pd.Timestamp(v["datetime"], tz="UTC"),
"Open": float(v["open"]),
"High": float(v["high"]),
"Low": float(v["low"]),
"Close": float(v["close"]),
"Volume": float(v.get("volume") or 0),
"Open": float(v["open"]) if v.get("open") is not None else None,
"High": float(v["high"]) if v.get("high") is not None else None,
"Low": float(v["low"]) if v.get("low") is not None else None,
"Close": float(v["close"]) if v.get("close") is not None else None,
"Volume": float(v["volume"]) if v.get("volume") is not None else 0.0,
}
for v in reversed(values)
if v.get("datetime")
]
df = pd.DataFrame(records).set_index("Datetime")