Spaces:
Running
Running
Thang6822 commited on
Commit ·
707c123
1
Parent(s): 43b0e93
Add Hugging Face-safe crypto data fallbacks
Browse files- backend/main.py +114 -19
- 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
|
| 1056 |
-
|
| 1057 |
-
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
| 1463 |
-
if
|
| 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":
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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":
|
| 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":
|
| 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(
|
| 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}
|