Really-amin commited on
Commit
a808cd1
·
verified ·
1 Parent(s): dd79656

Upload backend/services/binance_dns_connector.py with huggingface_hub

Browse files
backend/services/binance_dns_connector.py CHANGED
@@ -1,448 +1,438 @@
1
  #!/usr/bin/env python3
2
  """
3
- Binance DNS Connector with Multi-Endpoint Failover
4
- Handles Binance API connections with automatic DNS-based failover across multiple mirror endpoints
 
 
 
 
 
 
 
 
5
  """
6
 
7
- import httpx
8
- from typing import Optional, Dict, Any, List
9
  import asyncio
10
  import logging
11
- from datetime import datetime
12
  import time
 
 
 
 
 
 
13
 
14
  logger = logging.getLogger(__name__)
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  class BinanceDNSConnector:
18
- """
19
- Binance API connector with DNS-based failover support
20
-
21
- Features:
22
- - Multiple DNS endpoints for Binance global distribution
23
- - Automatic failover on connection errors
24
- - Health tracking per endpoint
25
- - Round-robin with health-based selection
26
- - Exponential backoff for failed endpoints
27
- """
28
-
29
- # Multiple DNS entries for Binance (global distribution)
30
- BINANCE_GLOBAL_ENDPOINTS = [
31
- "https://api.binance.com", # Primary (Global)
32
- "https://api1.binance.com", # Mirror 1
33
- "https://api2.binance.com", # Mirror 2
34
- "https://api3.binance.com", # Mirror 3
35
- "https://api4.binance.com", # Mirror 4 (if available)
36
- ]
37
-
38
- BINANCE_US_ENDPOINTS = [
39
- "https://api.binance.us", # US users
40
- ]
41
-
42
- def __init__(self, use_us: bool = False, timeout: float = 10.0):
43
- """
44
- Initialize Binance DNS connector
45
-
46
- Args:
47
- use_us: If True, use Binance US endpoints
48
- timeout: Request timeout in seconds
49
- """
50
- self.endpoints = self.BINANCE_US_ENDPOINTS if use_us else self.BINANCE_GLOBAL_ENDPOINTS
51
  self.timeout = timeout
52
  self.use_us = use_us
53
-
54
- # Health tracking for each endpoint
55
  self.endpoint_health: Dict[str, Dict[str, Any]] = {
56
- endpoint: {
57
  "available": True,
58
  "consecutive_failures": 0,
 
59
  "last_success": None,
60
  "last_failure": None,
61
  "total_requests": 0,
62
  "successful_requests": 0,
63
  "failed_requests": 0,
64
  "avg_response_time": 0.0,
65
- "backoff_until": 0.0
66
  }
67
- for endpoint in self.endpoints
68
  }
69
-
70
- self.current_endpoint_index = 0
71
-
72
- logger.info(f"🌐 Binance DNS Connector initialized: {len(self.endpoints)} endpoints available")
73
-
74
  def _get_next_healthy_endpoint(self) -> Optional[str]:
75
- """
76
- Get next healthy endpoint using intelligent selection
77
-
78
- Strategy:
79
- 1. Filter endpoints not in backoff
80
- 2. Prefer endpoints with recent success
81
- 3. Round-robin among healthy endpoints
82
-
83
- Returns:
84
- Next healthy endpoint URL or None if all down
85
- """
86
  now = time.time()
87
-
88
- # Get available endpoints (not in backoff)
89
  available = [
90
- endpoint for endpoint in self.endpoints
91
- if self.endpoint_health[endpoint]["backoff_until"] <= now
92
  ]
93
-
94
  if not available:
95
- # All endpoints in backoff - return least recently failed
96
- logger.warning("🚨 All Binance endpoints in backoff! Using least recently failed.")
97
- return min(
98
- self.endpoints,
99
- key=lambda e: self.endpoint_health[e]["backoff_until"]
100
- )
101
-
102
- # Sort by success rate and recent activity
103
- def score_endpoint(endpoint: str) -> float:
104
- health = self.endpoint_health[endpoint]
105
- total = health["total_requests"]
106
-
107
- if total == 0:
108
- return 0 # New endpoint - high priority
109
-
110
- success_rate = health["successful_requests"] / total
111
- score = (1 - success_rate) * 100 # Lower is better
112
-
113
- # Add penalty for consecutive failures
114
- score += health["consecutive_failures"] * 10
115
-
116
- return score
117
-
118
- # Get best endpoint
119
- best_endpoint = min(available, key=score_endpoint)
120
-
121
- return best_endpoint
122
-
123
- def _record_success(self, endpoint: str, response_time: float):
124
- """Record successful request"""
125
- health = self.endpoint_health[endpoint]
126
- health["consecutive_failures"] = 0
127
- health["last_success"] = datetime.now().isoformat()
128
- health["total_requests"] += 1
129
- health["successful_requests"] += 1
130
- health["backoff_until"] = 0.0
131
-
132
- # Update average response time
133
- if health["avg_response_time"] == 0:
134
- health["avg_response_time"] = response_time
135
  else:
136
- # Exponential moving average
137
- health["avg_response_time"] = 0.7 * health["avg_response_time"] + 0.3 * response_time
138
-
139
- def _record_failure(self, endpoint: str, error: str):
140
- """Record failed request with exponential backoff"""
141
- health = self.endpoint_health[endpoint]
142
- health["consecutive_failures"] += 1
143
- health["last_failure"] = datetime.now().isoformat()
144
- health["total_requests"] += 1
145
- health["failed_requests"] += 1
146
-
147
- # Exponential backoff: 2^failures seconds (max 300s = 5 min)
148
- backoff_duration = min(2 ** health["consecutive_failures"], 300)
149
- health["backoff_until"] = time.time() + backoff_duration
150
-
151
- logger.warning(
152
- f"❌ Binance endpoint failed: {endpoint} - {error} "
153
- f"(failures: {health['consecutive_failures']}, backoff: {backoff_duration}s)"
154
- )
155
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  async def get(
157
  self,
158
  path: str,
159
  params: Optional[Dict] = None,
160
- max_retries: int = None
161
- ) -> Optional[Dict[str, Any]]:
162
- """
163
- Make GET request with automatic DNS failover
164
-
165
- Args:
166
- path: API endpoint path (e.g., "/api/v3/ticker/price")
167
- params: Query parameters
168
- max_retries: Maximum retry attempts (default: number of endpoints)
169
-
170
- Returns:
171
- JSON response or None if all endpoints failed
172
- """
173
  if max_retries is None:
174
- max_retries = len(self.endpoints)
175
-
176
- last_error = None
177
-
178
  for attempt in range(max_retries):
179
  endpoint = self._get_next_healthy_endpoint()
180
-
181
  if not endpoint:
182
- logger.error("🚨 No Binance endpoints available!")
183
  break
184
-
185
- url = f"{endpoint}{path}"
186
- start_time = time.time()
187
-
188
  try:
189
- async with httpx.AsyncClient(timeout=self.timeout) as client:
190
- response = await client.get(url, params=params)
191
- response.raise_for_status()
192
-
193
- response_time = time.time() - start_time
194
- self._record_success(endpoint, response_time)
195
-
 
196
  logger.info(
197
- f"Binance {path} - {endpoint} - {response_time*1000:.0f}ms"
 
 
 
198
  )
199
-
200
- return response.json()
201
-
202
- except httpx.HTTPStatusError as e:
203
- last_error = f"HTTP {e.response.status_code}"
204
- self._record_failure(endpoint, last_error)
205
-
206
- # If rate limited, try next endpoint immediately
207
- if e.response.status_code == 429:
208
- logger.warning(f"⚠️ Binance rate limit hit on {endpoint}, trying next...")
209
  continue
210
-
211
- # For other HTTP errors, might still retry
212
- if attempt < max_retries - 1:
213
- await asyncio.sleep(0.3)
214
-
215
  except httpx.TimeoutException:
216
  last_error = "Timeout"
217
  self._record_failure(endpoint, last_error)
218
-
219
- if attempt < max_retries - 1:
220
- await asyncio.sleep(0.3)
221
-
222
- except Exception as e:
223
- last_error = str(e)
224
  self._record_failure(endpoint, last_error)
225
-
226
- if attempt < max_retries - 1:
227
- await asyncio.sleep(0.3)
228
-
229
- logger.error(f"❌ All Binance endpoints failed for {path}: {last_error}")
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  return None
231
-
232
  async def post(
233
  self,
234
  path: str,
235
  data: Optional[Dict] = None,
236
  params: Optional[Dict] = None,
237
- max_retries: int = None
238
- ) -> Optional[Dict[str, Any]]:
239
- """
240
- Make POST request with automatic DNS failover
241
-
242
- Args:
243
- path: API endpoint path
244
- data: Request body data
245
- params: Query parameters
246
- max_retries: Maximum retry attempts
247
-
248
- Returns:
249
- JSON response or None if all endpoints failed
250
- """
251
  if max_retries is None:
252
  max_retries = len(self.endpoints)
253
-
254
- last_error = None
255
-
256
  for attempt in range(max_retries):
257
  endpoint = self._get_next_healthy_endpoint()
258
-
259
  if not endpoint:
260
- logger.error("🚨 No Binance endpoints available!")
261
  break
262
-
263
- url = f"{endpoint}{path}"
264
- start_time = time.time()
265
-
266
  try:
267
- async with httpx.AsyncClient(timeout=self.timeout) as client:
268
- response = await client.post(url, json=data, params=params)
269
- response.raise_for_status()
270
-
271
- response_time = time.time() - start_time
272
- self._record_success(endpoint, response_time)
273
-
274
- logger.info(
275
- f" Binance POST {path} - {endpoint} - {response_time*1000:.0f}ms"
276
- )
277
-
278
- return response.json()
279
-
280
- except Exception as e:
281
- last_error = str(e)
282
  self._record_failure(endpoint, last_error)
283
-
284
- if attempt < max_retries - 1:
285
- await asyncio.sleep(0.3)
286
-
287
- logger.error(f"❌ All Binance endpoints failed for POST {path}: {last_error}")
288
  return None
289
-
290
  def get_health_status(self) -> Dict[str, Any]:
291
- """
292
- Get health status of all Binance endpoints
293
-
294
- Returns:
295
- Dict with health information for each endpoint
296
- """
297
  now = time.time()
298
-
299
  return {
300
- "connector_type": "Binance US" if self.use_us else "Binance Global",
 
 
 
 
301
  "total_endpoints": len(self.endpoints),
302
  "endpoints": [
303
  {
304
- "url": endpoint,
305
- "available": health["backoff_until"] <= now,
306
- "consecutive_failures": health["consecutive_failures"],
 
307
  "success_rate": (
308
- 100 * health["successful_requests"] / health["total_requests"]
309
- if health["total_requests"] > 0 else 0
310
  ),
311
- "total_requests": health["total_requests"],
312
- "avg_response_time_ms": health["avg_response_time"] * 1000,
313
- "last_success": health["last_success"],
314
- "last_failure": health["last_failure"],
315
- "backoff_until": (
316
- datetime.fromtimestamp(health["backoff_until"]).isoformat()
317
- if health["backoff_until"] > now else None
318
- )
319
  }
320
- for endpoint, health in self.endpoint_health.items()
321
- ]
322
  }
323
-
324
- def reset_health(self, endpoint: Optional[str] = None):
325
- """
326
- Reset health tracking for endpoint(s)
327
-
328
- Args:
329
- endpoint: Specific endpoint to reset, or None to reset all
330
- """
331
- if endpoint:
332
- if endpoint in self.endpoint_health:
333
- self.endpoint_health[endpoint]["consecutive_failures"] = 0
334
- self.endpoint_health[endpoint]["backoff_until"] = 0.0
335
- logger.info(f"🔄 Reset health for {endpoint}")
336
- else:
337
- for ep in self.endpoint_health:
338
- self.endpoint_health[ep]["consecutive_failures"] = 0
339
- self.endpoint_health[ep]["backoff_until"] = 0.0
340
- logger.info("🔄 Reset health for all endpoints")
341
 
342
 
343
- # ===== GLOBAL INSTANCES =====
 
344
 
345
- _binance_global_connector: Optional[BinanceDNSConnector] = None
346
- _binance_us_connector: Optional[BinanceDNSConnector] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
 
349
  def get_binance_connector(use_us: bool = False) -> BinanceDNSConnector:
350
- """
351
- Get singleton Binance connector instance
352
-
353
- Args:
354
- use_us: If True, return US connector, else global connector
355
-
356
- Returns:
357
- BinanceDNSConnector instance
358
- """
359
- global _binance_global_connector, _binance_us_connector
360
-
361
  if use_us:
362
- if _binance_us_connector is None:
363
- _binance_us_connector = BinanceDNSConnector(use_us=True)
364
- return _binance_us_connector
365
- else:
366
- if _binance_global_connector is None:
367
- _binance_global_connector = BinanceDNSConnector(use_us=False)
368
- return _binance_global_connector
369
-
370
-
371
- # ===== CONVENIENCE FUNCTIONS =====
372
-
373
- async def binance_get(path: str, params: Optional[Dict] = None, use_us: bool = False) -> Optional[Dict]:
374
- """
375
- Convenience function for Binance GET requests with failover
376
-
377
- Args:
378
- path: API path (e.g., "/api/v3/ticker/price")
379
- params: Query parameters
380
- use_us: Use Binance US endpoints
381
-
382
- Returns:
383
- JSON response or None
384
- """
385
- connector = get_binance_connector(use_us=use_us)
386
- return await connector.get(path, params=params)
387
-
388
-
389
- async def binance_post(
390
- path: str,
391
- data: Optional[Dict] = None,
392
- params: Optional[Dict] = None,
393
- use_us: bool = False
394
- ) -> Optional[Dict]:
395
- """
396
- Convenience function for Binance POST requests with failover
397
-
398
- Args:
399
- path: API path
400
- data: Request body
401
- params: Query parameters
402
- use_us: Use Binance US endpoints
403
-
404
- Returns:
405
- JSON response or None
406
- """
407
- connector = get_binance_connector(use_us=use_us)
408
- return await connector.post(path, data=data, params=params)
409
-
410
-
411
- # ===== TEST =====
412
-
413
- if __name__ == "__main__":
414
- async def test():
415
- print("=" * 70)
416
- print("Testing Binance DNS Connector")
417
- print("=" * 70)
418
-
419
- connector = get_binance_connector(use_us=False)
420
-
421
- # Test 1: Get BTC price
422
- print("\n1. Testing BTC price fetch:")
423
- result = await connector.get("/api/v3/ticker/price", params={"symbol": "BTCUSDT"})
424
- if result:
425
- print(f" ✅ BTC Price: ${float(result.get('price', 0)):,.2f}")
426
- else:
427
- print(" ❌ Failed to fetch BTC price")
428
-
429
- # Test 2: Get multiple prices
430
- print("\n2. Testing multiple price fetch:")
431
- result = await connector.get("/api/v3/ticker/price")
432
- if result:
433
- print(f" ✅ Fetched {len(result)} prices")
434
- else:
435
- print(" ❌ Failed to fetch prices")
436
-
437
- # Test 3: Health status
438
- print("\n3. Health Status:")
439
- health = connector.get_health_status()
440
- print(f" Total endpoints: {health['total_endpoints']}")
441
- for ep in health['endpoints']:
442
- status = "✅" if ep['available'] else "❌"
443
- print(f" {status} {ep['url']}: {ep['success_rate']:.1f}% success, {ep['total_requests']} requests")
444
-
445
- print("\n" + "=" * 70)
446
- print("Test completed!")
447
-
448
- asyncio.run(test())
 
1
  #!/usr/bin/env python3
2
  """
3
+ Binance + KuCoin DNS connector for Hugging Face / US-restricted egress.
4
+
5
+ HTTP 451 on api.binance.com* is IP geo-block — NOT fixed by mirror hostnames alone.
6
+ Strategy (in order):
7
+ 1. data-api.binance.vision (official public market data — works from US/HF)
8
+ 2. data.binance.com
9
+ 3. Standard api.binance.com mirrors
10
+ 4. DoH direct-IP + Host header
11
+ 5. KuCoin equivalent endpoints
12
+ 6. Optional EXCHANGE_EGRESS_PROXY env (non-US proxy URL)
13
  """
14
 
15
+ from __future__ import annotations
16
+
17
  import asyncio
18
  import logging
19
+ import os
20
  import time
21
+ from datetime import datetime
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ import httpx
25
+
26
+ from backend.services.exchange_dns_resolver import ExchangeDNSResolver
27
 
28
  logger = logging.getLogger(__name__)
29
 
30
+ # Official public market-data host first (bypasses US 451 on trading API)
31
+ BINANCE_GLOBAL_ENDPOINTS = [
32
+ "https://data-api.binance.vision",
33
+ "https://data.binance.com",
34
+ "https://api.binance.com",
35
+ "https://api1.binance.com",
36
+ "https://api2.binance.com",
37
+ "https://api3.binance.com",
38
+ "https://api4.binance.com",
39
+ ]
40
+
41
+ BINANCE_US_ENDPOINTS = ["https://api.binance.us"]
42
+
43
+ KUCOIN_ENDPOINTS = [
44
+ "https://api.kucoin.com",
45
+ "https://api-futures.kucoin.com",
46
+ ]
47
+
48
+ KUCOIN_INTERVAL_MAP = {
49
+ "1m": "1min",
50
+ "3m": "3min",
51
+ "5m": "5min",
52
+ "15m": "15min",
53
+ "30m": "30min",
54
+ "1h": "1hour",
55
+ "2h": "2hour",
56
+ "4h": "4hour",
57
+ "6h": "6hour",
58
+ "8h": "8hour",
59
+ "12h": "12hour",
60
+ "1d": "1day",
61
+ "1w": "1week",
62
+ }
63
+
64
+
65
+ def _binance_to_kucoin_symbol(symbol: str) -> str:
66
+ sym = symbol.upper().replace("/", "").replace("-", "")
67
+ for quote in ("USDT", "USDC", "BUSD", "USD"):
68
+ if sym.endswith(quote) and len(sym) > len(quote):
69
+ return f"{sym[:-len(quote)]}-{quote}"
70
+ return sym
71
+
72
+
73
+ def _egress_proxy() -> Optional[str]:
74
+ return (os.getenv("EXCHANGE_EGRESS_PROXY") or os.getenv("BINANCE_PROXY_URL") or "").strip() or None
75
+
76
 
77
  class BinanceDNSConnector:
78
+ """Binance market-data connector with Vision-first routing and KuCoin fallback."""
79
+
80
+ def __init__(self, use_us: bool = False, timeout: float = 12.0) -> None:
81
+ self.endpoints = BINANCE_US_ENDPOINTS if use_us else list(BINANCE_GLOBAL_ENDPOINTS)
82
+ custom = (os.getenv("BINANCE_PREFERRED_HOST") or "").strip()
83
+ if custom and custom not in self.endpoints:
84
+ self.endpoints.insert(0, custom.rstrip("/"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  self.timeout = timeout
86
  self.use_us = use_us
87
+ self._doh = ExchangeDNSResolver()
 
88
  self.endpoint_health: Dict[str, Dict[str, Any]] = {
89
+ ep: {
90
  "available": True,
91
  "consecutive_failures": 0,
92
+ "geo_blocked": False,
93
  "last_success": None,
94
  "last_failure": None,
95
  "total_requests": 0,
96
  "successful_requests": 0,
97
  "failed_requests": 0,
98
  "avg_response_time": 0.0,
99
+ "backoff_until": 0.0,
100
  }
101
+ for ep in self.endpoints
102
  }
103
+ logger.info("Binance DNS Connector: %s endpoints (vision-first)", len(self.endpoints))
104
+
 
 
 
105
  def _get_next_healthy_endpoint(self) -> Optional[str]:
 
 
 
 
 
 
 
 
 
 
 
106
  now = time.time()
 
 
107
  available = [
108
+ ep for ep in self.endpoints
109
+ if self.endpoint_health[ep]["backoff_until"] <= now
110
  ]
 
111
  if not available:
112
+ return min(self.endpoints, key=lambda e: self.endpoint_health[e]["backoff_until"])
113
+
114
+ def score(ep: str) -> float:
115
+ h = self.endpoint_health[ep]
116
+ if h.get("geo_blocked"):
117
+ return 1000.0
118
+ total = h["total_requests"] or 1
119
+ fail_rate = h["failed_requests"] / total
120
+ return fail_rate * 100 + h["consecutive_failures"] * 5
121
+
122
+ return min(available, key=score)
123
+
124
+ def _record_success(self, endpoint: str, response_time: float) -> None:
125
+ h = self.endpoint_health[endpoint]
126
+ h["consecutive_failures"] = 0
127
+ h["geo_blocked"] = False
128
+ h["last_success"] = datetime.now().isoformat()
129
+ h["total_requests"] += 1
130
+ h["successful_requests"] += 1
131
+ h["backoff_until"] = 0.0
132
+ if h["avg_response_time"] == 0:
133
+ h["avg_response_time"] = response_time
134
+ else:
135
+ h["avg_response_time"] = 0.7 * h["avg_response_time"] + 0.3 * response_time
136
+
137
+ def _record_failure(self, endpoint: str, error: str, geo: bool = False) -> None:
138
+ h = self.endpoint_health[endpoint]
139
+ h["consecutive_failures"] += 1
140
+ h["last_failure"] = datetime.now().isoformat()
141
+ h["total_requests"] += 1
142
+ h["failed_requests"] += 1
143
+ if geo or "451" in error:
144
+ h["geo_blocked"] = True
145
+ h["backoff_until"] = time.time() + 30
 
 
 
 
 
 
146
  else:
147
+ backoff = min(2 ** h["consecutive_failures"], 120)
148
+ h["backoff_until"] = time.time() + backoff
149
+ logger.warning("Binance endpoint failed: %s - %s", endpoint, error)
150
+
151
+ async def _http_get(
152
+ self,
153
+ base_url: str,
154
+ path: str,
155
+ params: Optional[Dict] = None,
156
+ ) -> Optional[httpx.Response]:
157
+ proxy = _egress_proxy()
158
+ client_kwargs: Dict[str, Any] = {"timeout": self.timeout, "follow_redirects": True}
159
+ if proxy:
160
+ client_kwargs["proxy"] = proxy
161
+ async with httpx.AsyncClient(**client_kwargs) as client:
162
+ return await client.get(f"{base_url}{path}", params=params)
163
+
164
+ async def _try_kucoin(self, path: str, params: Optional[Dict]) -> Optional[Any]:
165
+ """Map common Binance public paths to KuCoin."""
166
+ params = dict(params or {})
167
+ base = KUCOIN_ENDPOINTS[0]
168
+
169
+ if path == "/api/v3/ticker/price":
170
+ symbol = params.get("symbol", "BTCUSDT")
171
+ ksym = _binance_to_kucoin_symbol(str(symbol))
172
+ resp = await self._http_get(base, "/api/v1/market/stats", {"symbol": ksym})
173
+ if resp and resp.status_code == 200:
174
+ body = resp.json()
175
+ if body.get("code") == "200000":
176
+ data = body.get("data") or {}
177
+ return {"symbol": symbol, "price": data.get("last", data.get("buy", "0"))}
178
+ return None
179
+
180
+ if path == "/api/v3/klines":
181
+ symbol = params.get("symbol", "BTCUSDT")
182
+ interval = params.get("interval", "1h")
183
+ limit = int(params.get("limit", 100))
184
+ ksym = _binance_to_kucoin_symbol(str(symbol))
185
+ ktype = KUCOIN_INTERVAL_MAP.get(interval, "1hour")
186
+ resp = await self._http_get(
187
+ base,
188
+ "/api/v1/market/candles",
189
+ {"symbol": ksym, "type": ktype},
190
+ )
191
+ if resp and resp.status_code == 200:
192
+ body = resp.json()
193
+ if body.get("code") == "200000":
194
+ rows = (body.get("data") or [])[-limit:]
195
+ # KuCoin: [time, open, close, high, low, volume, turnover]
196
+ return [
197
+ [int(r[0]), r[1], r[3], r[4], r[2], r[5], 0, 0, 0, 0, 0, 0]
198
+ for r in rows
199
+ ]
200
+ return None
201
+
202
+ if path == "/api/v3/ticker/24hr":
203
+ symbol = params.get("symbol", "BTCUSDT")
204
+ ksym = _binance_to_kucoin_symbol(str(symbol))
205
+ resp = await self._http_get(base, "/api/v1/market/stats", {"symbol": ksym})
206
+ if resp and resp.status_code == 200:
207
+ body = resp.json()
208
+ if body.get("code") == "200000":
209
+ d = body.get("data") or {}
210
+ return {
211
+ "symbol": symbol,
212
+ "lastPrice": d.get("last"),
213
+ "priceChangePercent": str(float(d.get("changeRate", 0)) * 100),
214
+ "highPrice": d.get("high"),
215
+ "lowPrice": d.get("low"),
216
+ "volume": d.get("vol"),
217
+ }
218
+ return None
219
+
220
+ return None
221
+
222
  async def get(
223
  self,
224
  path: str,
225
  params: Optional[Dict] = None,
226
+ max_retries: Optional[int] = None,
227
+ ) -> Optional[Any]:
 
 
 
 
 
 
 
 
 
 
 
228
  if max_retries is None:
229
+ max_retries = len(self.endpoints) + 2
230
+
231
+ last_error = "unknown"
232
+
233
  for attempt in range(max_retries):
234
  endpoint = self._get_next_healthy_endpoint()
 
235
  if not endpoint:
 
236
  break
237
+
238
+ start = time.time()
 
 
239
  try:
240
+ resp = await self._http_get(endpoint, path, params)
241
+ if resp is None:
242
+ last_error = "no response"
243
+ self._record_failure(endpoint, last_error)
244
+ continue
245
+
246
+ if resp.status_code == 200:
247
+ self._record_success(endpoint, time.time() - start)
248
  logger.info(
249
+ "Binance %s via %s (%.0fms)",
250
+ path,
251
+ endpoint.replace("https://", ""),
252
+ (time.time() - start) * 1000,
253
  )
254
+ return resp.json()
255
+
256
+ last_error = f"HTTP {resp.status_code}"
257
+ self._record_failure(endpoint, last_error, geo=(resp.status_code == 451))
258
+ if resp.status_code == 451:
 
 
 
 
 
259
  continue
260
+
 
 
 
 
261
  except httpx.TimeoutException:
262
  last_error = "Timeout"
263
  self._record_failure(endpoint, last_error)
264
+ except Exception as exc:
265
+ last_error = str(exc)
 
 
 
 
266
  self._record_failure(endpoint, last_error)
267
+
268
+ if attempt < max_retries - 1:
269
+ await asyncio.sleep(0.15)
270
+
271
+ # DoH direct-IP (api.binance.com mirrors only)
272
+ for endpoint in ("https://api.binance.com", "https://data-api.binance.vision"):
273
+ resp = await self._doh.fetch_via_doh(endpoint, path, params, timeout=self.timeout)
274
+ if resp and resp.status_code == 200:
275
+ logger.info("Binance %s via DoH %s", path, endpoint)
276
+ return resp.json()
277
+
278
+ # KuCoin fallback
279
+ kucoin_result = await self._try_kucoin(path, params)
280
+ if kucoin_result is not None:
281
+ logger.info("Binance path %s served via KuCoin fallback", path)
282
+ return kucoin_result
283
+
284
+ logger.error("All Binance/KuCoin routes failed for %s: %s", path, last_error)
285
  return None
286
+
287
  async def post(
288
  self,
289
  path: str,
290
  data: Optional[Dict] = None,
291
  params: Optional[Dict] = None,
292
+ max_retries: Optional[int] = None,
293
+ ) -> Optional[Any]:
 
 
 
 
 
 
 
 
 
 
 
 
294
  if max_retries is None:
295
  max_retries = len(self.endpoints)
296
+
297
+ last_error = "unknown"
 
298
  for attempt in range(max_retries):
299
  endpoint = self._get_next_healthy_endpoint()
 
300
  if not endpoint:
 
301
  break
 
 
 
 
302
  try:
303
+ proxy = _egress_proxy()
304
+ kwargs: Dict[str, Any] = {"timeout": self.timeout}
305
+ if proxy:
306
+ kwargs["proxy"] = proxy
307
+ async with httpx.AsyncClient(**kwargs) as client:
308
+ resp = await client.post(f"{endpoint}{path}", json=data, params=params)
309
+ if resp.status_code == 200:
310
+ return resp.json()
311
+ last_error = f"HTTP {resp.status_code}"
312
+ self._record_failure(endpoint, last_error, geo=(resp.status_code == 451))
313
+ except Exception as exc:
314
+ last_error = str(exc)
 
 
 
315
  self._record_failure(endpoint, last_error)
316
+ logger.error("All Binance POST routes failed for %s: %s", path, last_error)
 
 
 
 
317
  return None
318
+
319
  def get_health_status(self) -> Dict[str, Any]:
 
 
 
 
 
 
320
  now = time.time()
 
321
  return {
322
+ "connector_type": "Binance US" if self.use_us else "Binance Global (vision-first)",
323
+ "vision_primary": "https://data-api.binance.vision",
324
+ "kucoin_fallback": KUCOIN_ENDPOINTS[0],
325
+ "doh_enabled": True,
326
+ "egress_proxy_configured": bool(_egress_proxy()),
327
  "total_endpoints": len(self.endpoints),
328
  "endpoints": [
329
  {
330
+ "url": ep,
331
+ "available": h["backoff_until"] <= now,
332
+ "geo_blocked": h.get("geo_blocked", False),
333
+ "consecutive_failures": h["consecutive_failures"],
334
  "success_rate": (
335
+ 100 * h["successful_requests"] / h["total_requests"]
336
+ if h["total_requests"] else 0
337
  ),
338
+ "total_requests": h["total_requests"],
339
+ "avg_response_time_ms": h["avg_response_time"] * 1000,
340
+ "last_success": h["last_success"],
341
+ "last_failure": h["last_failure"],
 
 
 
 
342
  }
343
+ for ep, h in self.endpoint_health.items()
344
+ ],
345
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
 
348
+ class KuCoinDNSConnector:
349
+ """KuCoin public API with DoH + optional egress proxy."""
350
 
351
+ def __init__(self, timeout: float = 12.0) -> None:
352
+ self.endpoints = list(KUCOIN_ENDPOINTS)
353
+ self.timeout = timeout
354
+ self._doh = ExchangeDNSResolver()
355
+
356
+ async def get(
357
+ self,
358
+ path: str,
359
+ params: Optional[Dict] = None,
360
+ ) -> Optional[Any]:
361
+ for base in self.endpoints:
362
+ try:
363
+ resp = await self._http_get(base, path, params)
364
+ if resp and resp.status_code == 200:
365
+ body = resp.json()
366
+ if body.get("code") == "200000":
367
+ return body.get("data")
368
+ except Exception as exc:
369
+ logger.debug("KuCoin %s failed: %s", base, exc)
370
+
371
+ hostname = "api.kucoin.com"
372
+ ip = await self._doh.resolve(hostname)
373
+ if ip:
374
+ try:
375
+ proxy = _egress_proxy()
376
+ kwargs: Dict[str, Any] = {
377
+ "timeout": self.timeout,
378
+ "verify": False,
379
+ "headers": {"Host": hostname},
380
+ }
381
+ if proxy:
382
+ kwargs["proxy"] = proxy
383
+ async with httpx.AsyncClient(**kwargs) as client:
384
+ resp = await client.get(f"https://{ip}{path}", params=params)
385
+ if resp.status_code == 200:
386
+ body = resp.json()
387
+ if body.get("code") == "200000":
388
+ return body.get("data")
389
+ except Exception as exc:
390
+ logger.debug("KuCoin DoH failed: %s", exc)
391
+ return None
392
+
393
+ async def _http_get(self, base_url: str, path: str, params: Optional[Dict]) -> Optional[httpx.Response]:
394
+ proxy = _egress_proxy()
395
+ kwargs: Dict[str, Any] = {"timeout": self.timeout}
396
+ if proxy:
397
+ kwargs["proxy"] = proxy
398
+ async with httpx.AsyncClient(**kwargs) as client:
399
+ return await client.get(f"{base_url}{path}", params=params)
400
+
401
+ def get_health_status(self) -> Dict[str, Any]:
402
+ return {
403
+ "connector_type": "KuCoin",
404
+ "endpoints": self.endpoints,
405
+ "doh_enabled": True,
406
+ "egress_proxy_configured": bool(_egress_proxy()),
407
+ }
408
+
409
+
410
+ _binance_global: Optional[BinanceDNSConnector] = None
411
+ _binance_us: Optional[BinanceDNSConnector] = None
412
+ _kucoin: Optional[KuCoinDNSConnector] = None
413
 
414
 
415
  def get_binance_connector(use_us: bool = False) -> BinanceDNSConnector:
416
+ global _binance_global, _binance_us
 
 
 
 
 
 
 
 
 
 
417
  if use_us:
418
+ if _binance_us is None:
419
+ _binance_us = BinanceDNSConnector(use_us=True)
420
+ return _binance_us
421
+ if _binance_global is None:
422
+ _binance_global = BinanceDNSConnector(use_us=False)
423
+ return _binance_global
424
+
425
+
426
+ def get_kucoin_connector() -> KuCoinDNSConnector:
427
+ global _kucoin
428
+ if _kucoin is None:
429
+ _kucoin = KuCoinDNSConnector()
430
+ return _kucoin
431
+
432
+
433
+ async def binance_get(path: str, params: Optional[Dict] = None, use_us: bool = False) -> Optional[Any]:
434
+ return await get_binance_connector(use_us=use_us).get(path, params=params)
435
+
436
+
437
+ async def kucoin_get(path: str, params: Optional[Dict] = None) -> Optional[Any]:
438
+ return await get_kucoin_connector().get(path, params=params)