Thang6822 commited on
Commit
707c123
·
1 Parent(s): 43b0e93

Add Hugging Face-safe crypto data fallbacks

Browse files
Files changed (2) hide show
  1. backend/main.py +114 -19
  2. backend/test_api_regressions.py +49 -0
backend/main.py CHANGED
@@ -911,6 +911,73 @@ SYMBOLS: Dict[str, SymbolConfig] = {
911
  "DIA": _s("DIA","Dow Jones ETF","DIA","ETF",{"yfinance":"DIA"}),
912
  }
913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
 
915
  # ──────────────────────────────────────────────────────────────────────────────
916
  # TTL Cache (unchanged from v3, with improved stats)
@@ -1052,9 +1119,11 @@ def _get_canonical_symbol(sym: str) -> str:
1052
  if s.startswith("BINANCE:") and s[8:] in SYMBOLS:
1053
  return s[8:]
1054
  # Search in mappings
1055
- for reg_id, cfg in SYMBOLS.items():
1056
- if s == cfg.mappings.get("binance") or s == cfg.mappings.get("coingecko") or s == cfg.mappings.get("twelvedata"):
1057
- return reg_id
 
 
1058
  return s
1059
 
1060
 
@@ -1216,7 +1285,9 @@ def _normalize_ohlcv(records: List[Dict[str, Any]], interval: str = "1h") -> Lis
1216
  async def fetch_binance(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1217
  await _rate_limit("binance")
1218
  cfg = SYMBOLS[symbol]
1219
- endpoint_symbol = cfg.mappings["binance"]
 
 
1220
 
1221
  # Define request details
1222
  endpoint = "/fapi/v1/klines" if cfg.binance_type == "futures" else "/api/v3/klines"
@@ -1276,7 +1347,9 @@ async def fetch_binance(symbol: str, interval: str, limit: int) -> List[Dict[str
1276
  async def fetch_bybit(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1277
  """Bybit V5 kline endpoint — free, no API key."""
1278
  await _rate_limit("bybit")
1279
- endpoint_symbol = SYMBOLS[symbol].mappings["bybit"]
 
 
1280
  bybit_cat = SYMBOLS[symbol].bybit_category
1281
  params = {
1282
  "category": bybit_cat,
@@ -1360,7 +1433,7 @@ async def fetch_coingecko(symbol: str, interval: str, limit: int) -> List[Dict[s
1360
  90+ days → weekly candles
1361
  """
1362
  await _rate_limit("coingecko")
1363
- cg_id = SYMBOLS[symbol].coingecko_id
1364
  if not cg_id:
1365
  raise RuntimeError("No CoinGecko ID for this symbol")
1366
 
@@ -1409,7 +1482,9 @@ async def fetch_coingecko(symbol: str, interval: str, limit: int) -> List[Dict[s
1409
 
1410
  async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1411
  await _rate_limit("twelvedata")
1412
- endpoint_symbol = SYMBOLS[symbol].mappings["twelvedata"]
 
 
1413
  params = {
1414
  "symbol": endpoint_symbol,
1415
  "interval": TWELVE_INTERVAL_MAP[interval],
@@ -1459,8 +1534,8 @@ async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[
1459
 
1460
 
1461
  async def fetch_finnhub(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1462
- mappings = SYMBOLS[symbol].mappings
1463
- if "finnhub" not in mappings:
1464
  return []
1465
 
1466
  cb = source_breakers.get("finnhub")
@@ -1474,7 +1549,7 @@ async def fetch_finnhub(symbol: str, interval: str, limit: int) -> List[Dict[str
1474
  r = await client.get(
1475
  "https://finnhub.io/api/v1/stock/candle",
1476
  params={
1477
- "symbol": mappings["finnhub"],
1478
  "resolution": FINNHUB_RESOLUTION_MAP.get(interval, "D"),
1479
  "count": limit,
1480
  "token": finnhub_pool.next_key(),
@@ -1512,7 +1587,9 @@ def _resample_4h(df: pd.DataFrame) -> pd.DataFrame:
1512
 
1513
  async def fetch_yfinance(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1514
  await _rate_limit("yfinance")
1515
- ticker = SYMBOLS[symbol].mappings["yfinance"]
 
 
1516
  yf_interval = YF_INTERVAL_MAP[interval]
1517
  period = YF_PERIOD_MAP[interval]
1518
  logger.info("[yfinance] %s %s", symbol, interval)
@@ -1942,10 +2019,16 @@ async def _build_synthetic_symbol_history(
1942
  return synthetic_rows, source
1943
 
1944
 
1945
- def _get_source_priority(symbol: str) -> List[str]:
1946
  cfg = SYMBOLS[symbol]
1947
  priority = CATEGORY_SOURCE_PRIORITY.get(cfg.category, DEFAULT_SOURCE_PRIORITY)
1948
- return [s for s in priority if s in cfg.mappings]
 
 
 
 
 
 
1949
 
1950
 
1951
  async def _run_historical_fetch(
@@ -1957,7 +2040,7 @@ async def _run_historical_fetch(
1957
  if _is_synthetic_symbol(symbol):
1958
  return await _build_synthetic_symbol_history(symbol, interval, fetch_limit, cache_key)
1959
 
1960
- priority = _get_source_priority(symbol)
1961
  errors: List[str] = []
1962
 
1963
  for source in priority:
@@ -2091,18 +2174,21 @@ async def fetch_ticker(symbol: str, interval: Optional[str] = None) -> Dict[str,
2091
  return result
2092
 
2093
  cfg = SYMBOLS[symbol]
2094
- priority = _get_source_priority(symbol)
2095
  client = await GlobalHTTPClient.get_client()
2096
 
2097
  for source in priority:
2098
  try:
2099
  if source == "binance":
 
 
 
2100
  await _rate_limit("binance")
2101
  base_url = "https://fapi.binance.com" if cfg.binance_type == "futures" else "https://api.binance.com"
2102
  endpoint = "/fapi/v1/ticker/24hr" if cfg.binance_type == "futures" else "/api/v3/ticker/24hr"
2103
  r = await client.get(
2104
  f"{base_url}{endpoint}",
2105
- params={"symbol": cfg.mappings["binance"]},
2106
  timeout=10.0,
2107
  )
2108
  d = r.json()
@@ -2116,10 +2202,13 @@ async def fetch_ticker(symbol: str, interval: Optional[str] = None) -> Dict[str,
2116
  "source": "binance",
2117
  }
2118
  elif source == "twelvedata":
 
 
 
2119
  await _rate_limit("twelvedata")
2120
  r = await client.get(
2121
  "https://api.twelvedata.com/quote",
2122
- params={"symbol": cfg.mappings["twelvedata"], "apikey": twelvedata_pool.next_key()},
2123
  timeout=10.0,
2124
  )
2125
  d = r.json()
@@ -2137,9 +2226,12 @@ async def fetch_ticker(symbol: str, interval: Optional[str] = None) -> Dict[str,
2137
  "source": "twelvedata",
2138
  }
2139
  elif source == "bybit":
 
 
 
2140
  await _rate_limit("bybit")
2141
  url = "https://api.bybit.com/v5/market/tickers"
2142
- query_params = {"category": cfg.bybit_category, "symbol": cfg.mappings["bybit"]}
2143
  headers = {}
2144
 
2145
  if settings.bybit_api_key and settings.bybit_api_secret:
@@ -2175,8 +2267,11 @@ async def fetch_ticker(symbol: str, interval: Optional[str] = None) -> Dict[str,
2175
  "source": "bybit",
2176
  }
2177
  elif source == "yfinance":
 
 
 
2178
  def _yf_info():
2179
- ticker = yf.Ticker(cfg.mappings["yfinance"])
2180
  history = ticker.history(period="5d")
2181
  if history.empty:
2182
  return None
 
911
  "DIA": _s("DIA","Dow Jones ETF","DIA","ETF",{"yfinance":"DIA"}),
912
  }
913
 
914
+ CRYPTO_QUOTE_SUFFIXES: Tuple[str, ...] = ("USDT", "USDC", "USD", "BTC", "ETH")
915
+ HF_CRYPTO_SOURCE_PRIORITY_DAILY: List[str] = [
916
+ "yfinance",
917
+ "twelvedata",
918
+ "finnhub",
919
+ "coingecko",
920
+ "binance",
921
+ "bybit",
922
+ ]
923
+ HF_CRYPTO_SOURCE_PRIORITY_INTRADAY: List[str] = [
924
+ "twelvedata",
925
+ "yfinance",
926
+ "finnhub",
927
+ "coingecko",
928
+ "binance",
929
+ "bybit",
930
+ ]
931
+
932
+
933
+ def _split_crypto_symbol(symbol: str) -> Tuple[Optional[str], Optional[str]]:
934
+ upper_symbol = symbol.upper()
935
+ for quote in CRYPTO_QUOTE_SUFFIXES:
936
+ if upper_symbol.endswith(quote) and len(upper_symbol) > len(quote):
937
+ return upper_symbol[: -len(quote)], quote
938
+ return None, None
939
+
940
+
941
+ def _get_dynamic_crypto_mappings(symbol: str) -> Dict[str, str]:
942
+ cfg = SYMBOLS[symbol]
943
+ if cfg.category != "Crypto":
944
+ return {}
945
+
946
+ base, quote = _split_crypto_symbol(cfg.symbol)
947
+ if not base or not quote:
948
+ return {}
949
+
950
+ derived: Dict[str, str] = {}
951
+ normalized_quote = "USD" if quote == "USDT" else quote
952
+ binance_symbol = cfg.mappings.get("binance")
953
+
954
+ if "twelvedata" not in cfg.mappings:
955
+ derived["twelvedata"] = f"{base}/{normalized_quote}"
956
+
957
+ if normalized_quote == "USD" and "yfinance" not in cfg.mappings:
958
+ derived["yfinance"] = f"{base}-USD"
959
+
960
+ if "finnhub" not in cfg.mappings:
961
+ if binance_symbol:
962
+ derived["finnhub"] = f"BINANCE:{binance_symbol}"
963
+ elif quote in {"USD", "USDT", "USDC"}:
964
+ finnhub_quote = "USDT" if quote == "USD" else quote
965
+ derived["finnhub"] = f"BINANCE:{base}{finnhub_quote}"
966
+
967
+ return derived
968
+
969
+
970
+ def _get_symbol_mapping(symbol: str, source: str) -> Optional[str]:
971
+ cfg = SYMBOLS[symbol]
972
+ direct_mapping = cfg.mappings.get(source)
973
+ if direct_mapping:
974
+ return direct_mapping
975
+ return _get_dynamic_crypto_mappings(symbol).get(source)
976
+
977
+
978
+ def _has_source_mapping(symbol: str, source: str) -> bool:
979
+ return _get_symbol_mapping(symbol, source) is not None
980
+
981
 
982
  # ──────────────────────────────────────────────────────────────────────────────
983
  # TTL Cache (unchanged from v3, with improved stats)
 
1119
  if s.startswith("BINANCE:") and s[8:] in SYMBOLS:
1120
  return s[8:]
1121
  # Search in mappings
1122
+ for reg_id in SYMBOLS:
1123
+ for source_name in ("binance", "bybit", "coingecko", "twelvedata", "yfinance", "finnhub"):
1124
+ mapping = _get_symbol_mapping(reg_id, source_name)
1125
+ if mapping and s == mapping.upper():
1126
+ return reg_id
1127
  return s
1128
 
1129
 
 
1285
  async def fetch_binance(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1286
  await _rate_limit("binance")
1287
  cfg = SYMBOLS[symbol]
1288
+ endpoint_symbol = _get_symbol_mapping(symbol, "binance")
1289
+ if not endpoint_symbol:
1290
+ raise RuntimeError("No Binance mapping for this symbol")
1291
 
1292
  # Define request details
1293
  endpoint = "/fapi/v1/klines" if cfg.binance_type == "futures" else "/api/v3/klines"
 
1347
  async def fetch_bybit(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1348
  """Bybit V5 kline endpoint — free, no API key."""
1349
  await _rate_limit("bybit")
1350
+ endpoint_symbol = _get_symbol_mapping(symbol, "bybit")
1351
+ if not endpoint_symbol:
1352
+ raise RuntimeError("No Bybit mapping for this symbol")
1353
  bybit_cat = SYMBOLS[symbol].bybit_category
1354
  params = {
1355
  "category": bybit_cat,
 
1433
  90+ days → weekly candles
1434
  """
1435
  await _rate_limit("coingecko")
1436
+ cg_id = _get_symbol_mapping(symbol, "coingecko") or SYMBOLS[symbol].coingecko_id
1437
  if not cg_id:
1438
  raise RuntimeError("No CoinGecko ID for this symbol")
1439
 
 
1482
 
1483
  async def fetch_twelvedata(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1484
  await _rate_limit("twelvedata")
1485
+ endpoint_symbol = _get_symbol_mapping(symbol, "twelvedata")
1486
+ if not endpoint_symbol:
1487
+ raise RuntimeError("No TwelveData mapping for this symbol")
1488
  params = {
1489
  "symbol": endpoint_symbol,
1490
  "interval": TWELVE_INTERVAL_MAP[interval],
 
1534
 
1535
 
1536
  async def fetch_finnhub(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1537
+ finnhub_symbol = _get_symbol_mapping(symbol, "finnhub")
1538
+ if not finnhub_symbol:
1539
  return []
1540
 
1541
  cb = source_breakers.get("finnhub")
 
1549
  r = await client.get(
1550
  "https://finnhub.io/api/v1/stock/candle",
1551
  params={
1552
+ "symbol": finnhub_symbol,
1553
  "resolution": FINNHUB_RESOLUTION_MAP.get(interval, "D"),
1554
  "count": limit,
1555
  "token": finnhub_pool.next_key(),
 
1587
 
1588
  async def fetch_yfinance(symbol: str, interval: str, limit: int) -> List[Dict[str, Any]]:
1589
  await _rate_limit("yfinance")
1590
+ ticker = _get_symbol_mapping(symbol, "yfinance")
1591
+ if not ticker:
1592
+ raise RuntimeError("No yfinance mapping for this symbol")
1593
  yf_interval = YF_INTERVAL_MAP[interval]
1594
  period = YF_PERIOD_MAP[interval]
1595
  logger.info("[yfinance] %s %s", symbol, interval)
 
2019
  return synthetic_rows, source
2020
 
2021
 
2022
+ def _get_source_priority(symbol: str, interval: Optional[str] = None) -> List[str]:
2023
  cfg = SYMBOLS[symbol]
2024
  priority = CATEGORY_SOURCE_PRIORITY.get(cfg.category, DEFAULT_SOURCE_PRIORITY)
2025
+ if settings.is_hf and cfg.category == "Crypto":
2026
+ priority = (
2027
+ HF_CRYPTO_SOURCE_PRIORITY_DAILY
2028
+ if interval in {"1d", "1w"}
2029
+ else HF_CRYPTO_SOURCE_PRIORITY_INTRADAY
2030
+ )
2031
+ return [source_name for source_name in priority if _has_source_mapping(symbol, source_name)]
2032
 
2033
 
2034
  async def _run_historical_fetch(
 
2040
  if _is_synthetic_symbol(symbol):
2041
  return await _build_synthetic_symbol_history(symbol, interval, fetch_limit, cache_key)
2042
 
2043
+ priority = _get_source_priority(symbol, interval)
2044
  errors: List[str] = []
2045
 
2046
  for source in priority:
 
2174
  return result
2175
 
2176
  cfg = SYMBOLS[symbol]
2177
+ priority = _get_source_priority(symbol, interval=interval)
2178
  client = await GlobalHTTPClient.get_client()
2179
 
2180
  for source in priority:
2181
  try:
2182
  if source == "binance":
2183
+ binance_symbol = _get_symbol_mapping(symbol, "binance")
2184
+ if not binance_symbol:
2185
+ continue
2186
  await _rate_limit("binance")
2187
  base_url = "https://fapi.binance.com" if cfg.binance_type == "futures" else "https://api.binance.com"
2188
  endpoint = "/fapi/v1/ticker/24hr" if cfg.binance_type == "futures" else "/api/v3/ticker/24hr"
2189
  r = await client.get(
2190
  f"{base_url}{endpoint}",
2191
+ params={"symbol": binance_symbol},
2192
  timeout=10.0,
2193
  )
2194
  d = r.json()
 
2202
  "source": "binance",
2203
  }
2204
  elif source == "twelvedata":
2205
+ twelvedata_symbol = _get_symbol_mapping(symbol, "twelvedata")
2206
+ if not twelvedata_symbol:
2207
+ continue
2208
  await _rate_limit("twelvedata")
2209
  r = await client.get(
2210
  "https://api.twelvedata.com/quote",
2211
+ params={"symbol": twelvedata_symbol, "apikey": twelvedata_pool.next_key()},
2212
  timeout=10.0,
2213
  )
2214
  d = r.json()
 
2226
  "source": "twelvedata",
2227
  }
2228
  elif source == "bybit":
2229
+ bybit_symbol = _get_symbol_mapping(symbol, "bybit")
2230
+ if not bybit_symbol:
2231
+ continue
2232
  await _rate_limit("bybit")
2233
  url = "https://api.bybit.com/v5/market/tickers"
2234
+ query_params = {"category": cfg.bybit_category, "symbol": bybit_symbol}
2235
  headers = {}
2236
 
2237
  if settings.bybit_api_key and settings.bybit_api_secret:
 
2267
  "source": "bybit",
2268
  }
2269
  elif source == "yfinance":
2270
+ yfinance_symbol = _get_symbol_mapping(symbol, "yfinance")
2271
+ if not yfinance_symbol:
2272
+ continue
2273
  def _yf_info():
2274
+ ticker = yf.Ticker(yfinance_symbol)
2275
  history = ticker.history(period="5d")
2276
  if history.empty:
2277
  return None
backend/test_api_regressions.py CHANGED
@@ -95,6 +95,55 @@ class ApiRegressionTests(unittest.TestCase):
95
  self.assertNotIn("binance", main._get_source_priority("EURUSD"))
96
  self.assertEqual(main._get_source_priority("DXY"), ["yfinance", "twelvedata"])
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def test_historical_fetch_falls_back_after_provider_http_error(self) -> None:
99
  sample_rows = [
100
  {"time": i, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}
 
95
  self.assertNotIn("binance", main._get_source_priority("EURUSD"))
96
  self.assertEqual(main._get_source_priority("DXY"), ["yfinance", "twelvedata"])
97
 
98
+ def test_crypto_dynamic_source_mappings_are_derived_for_hf_safe_fallbacks(self) -> None:
99
+ self.assertEqual(main._get_symbol_mapping("BTCUSD", "twelvedata"), "BTC/USD")
100
+ self.assertEqual(main._get_symbol_mapping("BTCUSD", "yfinance"), "BTC-USD")
101
+ self.assertEqual(main._get_symbol_mapping("BTCUSD", "finnhub"), "BINANCE:BTCUSDT")
102
+ self.assertEqual(main._get_canonical_symbol("BTC-USD"), "BTCUSD")
103
+
104
+ def test_crypto_source_priority_prefers_hf_safe_providers_on_hf(self) -> None:
105
+ with patch.object(main.settings, "is_hf", True):
106
+ self.assertEqual(
107
+ main._get_source_priority("BTCUSD", "1d"),
108
+ ["yfinance", "twelvedata", "finnhub", "coingecko", "binance", "bybit"],
109
+ )
110
+ self.assertEqual(
111
+ main._get_source_priority("BTCUSD", "1h"),
112
+ ["twelvedata", "yfinance", "finnhub", "coingecko", "binance", "bybit"],
113
+ )
114
+
115
+ def test_crypto_historical_fetch_on_hf_uses_hf_safe_fallback_first(self) -> None:
116
+ sample_rows = [
117
+ {"time": i, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}
118
+ for i in range(1, 41)
119
+ ]
120
+ attempts: list[str] = []
121
+
122
+ async def fake_twelvedata(symbol: str, interval: str, limit: int) -> list[dict[str, float]]:
123
+ attempts.append("twelvedata")
124
+ return sample_rows[-limit:]
125
+
126
+ async def fake_yfinance(symbol: str, interval: str, limit: int) -> list[dict[str, float]]:
127
+ attempts.append("yfinance")
128
+ raise RuntimeError("should not be needed after TwelveData success")
129
+
130
+ with patch.object(main.settings, "is_hf", True), patch.object(
131
+ main,
132
+ "fetch_twelvedata",
133
+ side_effect=fake_twelvedata,
134
+ ), patch.object(
135
+ main,
136
+ "fetch_yfinance",
137
+ side_effect=fake_yfinance,
138
+ ):
139
+ rows, source = asyncio.run(
140
+ main._run_historical_fetch("BTCUSD", "1h", 40, "hist_btcusd_1h_hf_test")
141
+ )
142
+
143
+ self.assertEqual(source, "twelvedata")
144
+ self.assertEqual(len(rows), 40)
145
+ self.assertEqual(attempts, ["twelvedata"])
146
+
147
  def test_historical_fetch_falls_back_after_provider_http_error(self) -> None:
148
  sample_rows = [
149
  {"time": i, "open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0}