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:
+57
-27
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user