Really-amin's picture
Preserve Short Hunter datasource gateway compatibility and fixes
ec64180 verified
Raw
History Blame Contribute Delete
13.1 kB
"""Normalized provider primitives for the Short Hunter datasource gateway.
The gateway must be truthful: if a provider cannot supply a capability we return
structured degraded/unavailable state instead of fabricating data.
"""
from __future__ import annotations
import asyncio
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Tuple
import httpx
SOURCE_LIVE = "LIVE"
SOURCE_DEGRADED = "DEGRADED"
SOURCE_CACHED = "CACHED"
SOURCE_UNAVAILABLE = "UNAVAILABLE"
SOURCE_DISABLED = "DISABLED"
DATA_REAL = "REAL"
DATA_PARTIAL = "PARTIAL"
DATA_CACHED = "CACHED"
DATA_UNAVAILABLE = "UNAVAILABLE"
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; ShortHunterDatasourceGateway/1.0; +https://huggingface.co/spaces)",
"Accept": "application/json",
}
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_spot_symbol(symbol: str) -> str:
raw = (symbol or "BTCUSDT").upper().strip()
raw = raw.replace("/", "").replace("-", "").replace("_", "")
if raw.endswith("USDT"):
return raw
if raw.endswith("USD") and not raw.endswith("USDT"):
return f"{raw[:-3]}USDT"
return f"{raw}USDT"
def base_asset(symbol: str) -> str:
s = normalize_spot_symbol(symbol)
if s.endswith("USDT"):
return s[:-4]
if s.endswith("USD"):
return s[:-3]
return s
def kucoin_futures_symbol(symbol: str) -> str:
"""Best-effort KuCoin Futures USDT-M contract symbol.
KuCoin Futures commonly uses XBTUSDTM for BTC and <BASE>USDTM for most other
USDT-margined contracts. The universe endpoint later confirms the exact
symbol and aliases.
"""
base = base_asset(symbol)
if base == "BTC":
return "XBTUSDTM"
return f"{base}USDTM"
def to_float(value: Any, default: Optional[float] = None) -> Optional[float]:
try:
if value is None or value == "":
return default
return float(value)
except Exception:
return default
def to_int(value: Any, default: Optional[int] = None) -> Optional[int]:
try:
if value is None or value == "":
return default
return int(float(value))
except Exception:
return default
@dataclass
class ProviderResult:
success: bool
source: str
capability: str
sourceMode: str
dataState: str
data: Any = None
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
missingCapabilities: List[str] = field(default_factory=list)
latencyMs: Optional[int] = None
timestamp: str = field(default_factory=utc_now)
providerStatus: Optional[int] = None
cached: bool = False
noTradeGuard: bool = False
noTradeGuardReason: Optional[str] = None
providerUsed: Optional[str] = None
fallbackUsed: bool = False
providersTried: List[Dict[str, Any]] = field(default_factory=list)
cacheUsed: bool = False
degradedReason: Optional[str] = None
freshnessMs: Optional[int] = None
healthScore: Optional[int] = None
networkErrorType: Optional[str] = None
providerStatusCode: Optional[int] = None
isGeoBlockedPossible: bool = False
isRateLimited: bool = False
shouldTryProxy: bool = False
suggestedRemediation: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"success": self.success,
"source": self.source,
"provider": self.source,
"providerUsed": self.providerUsed or self.source,
"capability": self.capability,
"sourceMode": self.sourceMode,
"dataState": self.dataState,
"latencyMs": self.latencyMs,
"timestamp": self.timestamp,
"data": self.data,
"errors": self.errors,
"warnings": self.warnings,
"missingCapabilities": self.missingCapabilities,
"providerStatus": self.providerStatus,
"cached": self.cached,
"noTradeGuard": self.noTradeGuard,
"noTradeGuardReason": self.noTradeGuardReason,
"fallbackUsed": self.fallbackUsed,
"providersTried": self.providersTried,
"cacheUsed": self.cacheUsed or self.cached,
"degradedReason": self.degradedReason,
"freshnessMs": self.freshnessMs,
"healthScore": self.healthScore,
"networkErrorType": self.networkErrorType,
"providerStatusCode": self.providerStatusCode,
"isGeoBlockedPossible": self.isGeoBlockedPossible,
"isRateLimited": self.isRateLimited,
"shouldTryProxy": self.shouldTryProxy,
"suggestedRemediation": self.suggestedRemediation,
}
def ok(source: str, capability: str, data: Any, latency_ms: Optional[int] = None, warnings: Optional[List[str]] = None) -> ProviderResult:
return ProviderResult(
success=True,
source=source,
capability=capability,
sourceMode=SOURCE_LIVE,
dataState=DATA_REAL,
data=data,
latencyMs=latency_ms,
warnings=warnings or [],
)
def fail(source: str, capability: str, error: str, latency_ms: Optional[int] = None, status: Optional[int] = None) -> ProviderResult:
return ProviderResult(
success=False,
source=source,
capability=capability,
sourceMode=SOURCE_UNAVAILABLE,
dataState=DATA_UNAVAILABLE,
data=None,
errors=[str(error)],
missingCapabilities=[capability],
latencyMs=latency_ms,
providerStatus=status,
noTradeGuard=True,
noTradeGuardReason=f"{capability} unavailable from {source}",
)
def disabled(source: str, capability: str, reason: str) -> ProviderResult:
return ProviderResult(
success=False,
source=source,
capability=capability,
sourceMode=SOURCE_DISABLED,
dataState=DATA_UNAVAILABLE,
data=None,
errors=[str(reason)],
missingCapabilities=[capability],
noTradeGuard=False,
noTradeGuardReason=str(reason),
)
def degraded(source: str, capability: str, data: Any, missing: List[str], warnings: List[str], latency_ms: Optional[int] = None) -> ProviderResult:
return ProviderResult(
success=True,
source=source,
capability=capability,
sourceMode=SOURCE_DEGRADED,
dataState=DATA_PARTIAL,
data=data,
warnings=warnings,
missingCapabilities=missing,
latencyMs=latency_ms,
noTradeGuard=bool(missing),
noTradeGuardReason=("; ".join(missing) + " missing") if missing else None,
)
class CircuitState:
"""Lightweight in-memory health/cooldown tracker per provider capability."""
def __init__(self, max_failures: int = 3, cooldown_seconds: int = 90) -> None:
self.max_failures = int(os.getenv("SH_PROVIDER_MAX_FAILURES", str(max_failures)))
self.cooldown_seconds = int(os.getenv("SH_PROVIDER_COOLDOWN_SECONDS", str(cooldown_seconds)))
self._state: Dict[str, Dict[str, Any]] = {}
def key(self, provider: str, capability: str) -> str:
return f"{provider}:{capability}"
def can_call(self, provider: str, capability: str) -> Tuple[bool, Optional[str]]:
entry = self._state.get(self.key(provider, capability))
if not entry:
return True, None
cooldown_until = entry.get("cooldownUntil")
if cooldown_until and time.time() < cooldown_until:
return False, f"provider in cooldown for {int(cooldown_until - time.time())}s"
return True, None
def record_success(self, provider: str, capability: str, latency_ms: Optional[int]) -> None:
self._state[self.key(provider, capability)] = {
"provider": provider,
"capability": capability,
"status": "healthy",
"failures": 0,
"lastSuccess": utc_now(),
"lastLatencyMs": latency_ms,
"cooldownUntil": None,
}
def record_failure(self, provider: str, capability: str, error: str) -> None:
key = self.key(provider, capability)
entry = self._state.get(key, {"provider": provider, "capability": capability, "failures": 0})
failures = int(entry.get("failures", 0)) + 1
status = "degraded" if failures < self.max_failures else "unavailable"
cooldown_until = time.time() + self.cooldown_seconds if failures >= self.max_failures else None
entry.update({
"status": status,
"failures": failures,
"lastError": utc_now(),
"lastErrorMessage": str(error)[:400],
"cooldownUntil": cooldown_until,
})
self._state[key] = entry
def snapshot(self) -> Dict[str, Any]:
now = time.time()
values = []
for entry in self._state.values():
cloned = dict(entry)
if cloned.get("cooldownUntil"):
cloned["cooldownRemainingSeconds"] = max(0, int(cloned["cooldownUntil"] - now))
values.append(cloned)
total = len(values)
return {
"total": total,
"healthy": sum(1 for v in values if v.get("status") == "healthy"),
"degraded": sum(1 for v in values if v.get("status") == "degraded"),
"unavailable": sum(1 for v in values if v.get("status") == "unavailable"),
"providers": values,
}
circuit = CircuitState()
async def fetch_json(
source: str,
capability: str,
url: str,
*,
params: Optional[Dict[str, Any]] = None,
timeout: float = 10.0,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Any], Optional[str], Optional[int], Optional[int]]:
"""Fetch JSON with latency and structured failure details."""
allowed, reason = circuit.can_call(source, capability)
if not allowed:
return None, reason or "provider in cooldown", None, None
started = time.perf_counter()
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(min(timeout, 12.0), connect=4.0),
headers={**DEFAULT_HEADERS, **(headers or {})},
follow_redirects=True,
trust_env=True,
) as client:
response = await client.get(url, params=params)
latency = int((time.perf_counter() - started) * 1000)
if response.status_code < 200 or response.status_code >= 300:
message = f"HTTP {response.status_code} {response.text[:160]}"
circuit.record_failure(source, capability, message)
return None, message, response.status_code, latency
try:
payload = response.json()
except Exception as exc:
message = f"invalid JSON: {exc}"
circuit.record_failure(source, capability, message)
return None, message, response.status_code, latency
circuit.record_success(source, capability, latency)
return payload, None, response.status_code, latency
except Exception as exc:
latency = int((time.perf_counter() - started) * 1000)
message = f"{type(exc).__name__}: {exc}"
circuit.record_failure(source, capability, message)
return None, message, None, latency
async def first_success(tasks: Iterable, capability: str) -> ProviderResult:
"""Run provider calls sequentially by priority until one succeeds.
Sequential rotation is intentional to respect free rate limits and keep
provenance clear. Provider functions must return ProviderResult.
"""
errors: List[str] = []
missing: List[str] = []
for task in tasks:
result = await task()
if result.success and result.data not in (None, [], {}):
if errors:
result.warnings.extend(errors)
return result
errors.extend([f"{result.source}: {e}" for e in result.errors])
missing.extend(result.missingCapabilities)
return ProviderResult(
success=False,
source="rotation",
capability=capability,
sourceMode=SOURCE_UNAVAILABLE,
dataState=DATA_UNAVAILABLE,
data=None,
errors=errors or [f"no provider supplied {capability}"],
missingCapabilities=sorted(set(missing or [capability])),
noTradeGuard=True,
noTradeGuardReason=f"{capability} unavailable from all configured providers",
)
async def gather_capabilities(calls: Dict[str, Any]) -> Dict[str, ProviderResult]:
"""Run independent capability calls concurrently while preserving results."""
keys = list(calls.keys())
results = await asyncio.gather(*[calls[k]() for k in keys], return_exceptions=True)
output: Dict[str, ProviderResult] = {}
for key, value in zip(keys, results):
if isinstance(value, ProviderResult):
output[key] = value
else:
output[key] = fail("internal", key, str(value))
return output