默认全部读取/影子开关关闭,网站继续走旧 Tushare 链路;开启单项时只替换该类原料并在失败时回旧,问天保持旧路径。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
185 lines
7.3 KiB
Python
185 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable
|
|
|
|
from backend.data.datahub.errors import DatahubError
|
|
from backend.data.datahub.redact import redact_text
|
|
from backend.data.datahub.settings import DatahubSettings
|
|
|
|
LOGGER = logging.getLogger("xiaobai.datahub")
|
|
UrlOpen = Callable[..., Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DatahubResponse:
|
|
data: Any
|
|
meta: dict[str, Any] = field(default_factory=dict)
|
|
schema_version: int = 1
|
|
status: int = 200
|
|
|
|
|
|
class DatahubClient:
|
|
def __init__(
|
|
self,
|
|
settings: DatahubSettings,
|
|
urlopen: UrlOpen = urllib.request.urlopen,
|
|
) -> None:
|
|
self.settings = settings
|
|
self._urlopen = urlopen
|
|
|
|
def health(self) -> DatahubResponse:
|
|
return self.get("/v1/health")
|
|
|
|
def calendar(self, start: str, end: str) -> DatahubResponse:
|
|
return self.get("/v1/calendar", {"from": start, "to": end})
|
|
|
|
def stocks(self, updated_since: str = "", limit: int | None = None, offset: int = 0) -> DatahubResponse:
|
|
params: dict[str, Any] = {"offset": offset, "limit": limit or self.settings.page_limit}
|
|
if updated_since:
|
|
params["updated_since"] = updated_since
|
|
return self.get("/v1/stocks", params)
|
|
|
|
def daily_bars(self, **params: Any) -> DatahubResponse:
|
|
return self.get("/v1/bars/daily", params)
|
|
|
|
def index_bars(self, **params: Any) -> DatahubResponse:
|
|
return self.get("/v1/indexes/bars", params)
|
|
|
|
def valuation(self, **params: Any) -> DatahubResponse:
|
|
return self.get("/v1/valuation", params)
|
|
|
|
def moneyflow(self, **params: Any) -> DatahubResponse:
|
|
return self.get("/v1/moneyflow", params)
|
|
|
|
def auction(self, **params: Any) -> DatahubResponse:
|
|
return self.get("/v1/auction", params)
|
|
|
|
def dataset_status(self, date: str) -> DatahubResponse:
|
|
return self.get("/v1/datasets/status", {"date": date})
|
|
|
|
def batches(self, date: str, dataset: str = "") -> DatahubResponse:
|
|
params: dict[str, Any] = {"date": date}
|
|
if dataset:
|
|
params["dataset"] = dataset
|
|
return self.get("/v1/batches", params)
|
|
|
|
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
|
if not self.settings.token:
|
|
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
|
query = {
|
|
key: value
|
|
for key, value in (params or {}).items()
|
|
if value is not None and value != ""
|
|
}
|
|
url = self.settings.base_url + path
|
|
if query:
|
|
url = f"{url}?{urllib.parse.urlencode(query)}"
|
|
attempts = 1 + max(0, self.settings.retries)
|
|
last_error: DatahubError | None = None
|
|
for attempt in range(attempts):
|
|
try:
|
|
return self._request(url)
|
|
except DatahubError as exc:
|
|
last_error = exc
|
|
if exc.code not in {"TIMEOUT", "UNAVAILABLE"} or attempt + 1 >= attempts:
|
|
raise
|
|
LOGGER.warning(
|
|
"datahub retry %s/%s %s",
|
|
attempt + 1,
|
|
attempts,
|
|
redact_text(str(exc), self.settings.secrets()),
|
|
)
|
|
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
|
|
|
def _request(self, url: str) -> DatahubResponse:
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"X-Datahub-Token": self.settings.token,
|
|
"User-Agent": "XiaobaiReviewDatahub/1.0",
|
|
},
|
|
method="GET",
|
|
)
|
|
try:
|
|
with self._urlopen(request, timeout=self.settings.timeout_seconds) as response:
|
|
status = int(getattr(response, "status", 200) or 200)
|
|
raw = response.read().decode("utf-8")
|
|
except TimeoutError as exc:
|
|
raise DatahubError("TIMEOUT", "datahub request timed out") from exc
|
|
except urllib.error.HTTPError as exc:
|
|
body = _read_error_body(exc)
|
|
raise _http_error(exc.code, body, self.settings.secrets()) from exc
|
|
except urllib.error.URLError as exc:
|
|
reason = redact_text(str(getattr(exc, "reason", exc)), self.settings.secrets())
|
|
if "timed out" in reason.lower():
|
|
raise DatahubError("TIMEOUT", "datahub request timed out") from exc
|
|
raise DatahubError("UNAVAILABLE", f"datahub unavailable: {reason}") from exc
|
|
payload = _parse_json(raw, self.settings.secrets())
|
|
return _as_response(payload, status, self.settings.secrets())
|
|
|
|
|
|
def _parse_json(raw: str, secrets: tuple[str, ...]) -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise DatahubError("INTERNAL", "datahub returned invalid json") from exc
|
|
if not isinstance(payload, dict):
|
|
raise DatahubError("INTERNAL", "datahub returned a non-object payload")
|
|
return payload
|
|
|
|
|
|
def _as_response(payload: dict[str, Any], status: int, secrets: tuple[str, ...]) -> DatahubResponse:
|
|
error = payload.get("error")
|
|
if isinstance(error, dict):
|
|
raise _mapped_error(str(error.get("code") or "INTERNAL"), str(error.get("message") or "datahub error"), status)
|
|
if status >= 400:
|
|
raise DatahubError("UNAVAILABLE", f"datahub http {status}", status)
|
|
return DatahubResponse(
|
|
data=payload.get("data"),
|
|
meta=dict(payload.get("meta") or {}),
|
|
schema_version=int(payload.get("schema_version") or 1),
|
|
status=status,
|
|
)
|
|
|
|
|
|
def _http_error(status: int, payload: dict[str, Any], secrets: tuple[str, ...]) -> DatahubError:
|
|
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
|
|
code = str((error or {}).get("code") or "")
|
|
message = str((error or {}).get("message") or payload.get("message") or f"datahub http {status}")
|
|
message = redact_text(message, secrets)
|
|
if status == 401 or code == "UNAUTHORIZED":
|
|
return DatahubError("UNAUTHORIZED", message, status)
|
|
if status == 404 or code == "DATASET_NOT_PUBLISHED":
|
|
return DatahubError("DATASET_NOT_PUBLISHED", message, status)
|
|
if status == 400 or code == "INVALID_ARGUMENT":
|
|
return DatahubError("INVALID_ARGUMENT", message, status)
|
|
if status in {429, 503} or code in {"RATE_LIMITED", "SOURCE_UNAVAILABLE"}:
|
|
return DatahubError("UNAVAILABLE", message, status)
|
|
return DatahubError(code or "INTERNAL", message, status)
|
|
|
|
|
|
def _mapped_error(code: str, message: str, status: int) -> DatahubError:
|
|
if code == "STALE_DATA":
|
|
return DatahubError("STALE", message, status)
|
|
if code in {"UNAUTHORIZED", "DATASET_NOT_PUBLISHED", "INVALID_ARGUMENT"}:
|
|
return DatahubError(code, message, status)
|
|
if code in {"RATE_LIMITED", "SOURCE_UNAVAILABLE"}:
|
|
return DatahubError("UNAVAILABLE", message, status)
|
|
return DatahubError(code or "INTERNAL", message, status)
|
|
|
|
|
|
def _read_error_body(exc: urllib.error.HTTPError) -> dict[str, Any]:
|
|
try:
|
|
raw = exc.read().decode("utf-8")
|
|
payload = json.loads(raw)
|
|
return payload if isinstance(payload, dict) else {"message": raw}
|
|
except Exception:
|
|
return {"message": str(exc)}
|