feat(HEL-402): 接通网站首批只读 datahub 并建立双路对比
默认全部读取/影子开关关闭,网站继续走旧 Tushare 链路;开启单项时只替换该类原料并在失败时回旧,问天保持旧路径。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
f5dc0f8076
commit
0d13066386
@@ -0,0 +1,184 @@
|
||||
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)}
|
||||
Reference in New Issue
Block a user