fix(HEL-494): 数据中枢独占调度,主网站不再回退旧接口
主网站只向中枢要业务数据;来源选择、切源、补数全部在中枢内部完成,失败不再走东财/腾讯/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
ef13d6feb5
commit
0b8419abca
@@ -250,6 +250,11 @@ class EastmoneyAdapter(MarketAdapter):
|
||||
secid = INDEX_SECIDS[code]
|
||||
entity = "index"
|
||||
identifier = code
|
||||
elif code.startswith("BK") or code.endswith((".TI", ".SI")):
|
||||
symbol = code.split(".")[0]
|
||||
secid = f"90.{symbol}"
|
||||
entity = "board"
|
||||
identifier = symbol
|
||||
else:
|
||||
symbol = code.split(".")[0]
|
||||
market = "1" if symbol.startswith(("5", "6", "9")) else "0"
|
||||
@@ -294,6 +299,108 @@ class EastmoneyAdapter(MarketAdapter):
|
||||
"source": "eastmoney_trends2",
|
||||
}
|
||||
|
||||
def fetch_shenwan_quote(self, ts_code: str) -> dict[str, Any]:
|
||||
code = str(ts_code or "").split(".")[0]
|
||||
if not code:
|
||||
raise AdapterError("Invalid Shenwan code")
|
||||
payload = self._get_json(
|
||||
EASTMONEY_INDEX_URL,
|
||||
{
|
||||
"secids": f"90.{code}",
|
||||
"fltt": "2",
|
||||
"invt": "2",
|
||||
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f8,f104,f105,f128,f136,f140,f124",
|
||||
},
|
||||
referer="https://quote.eastmoney.com/",
|
||||
)
|
||||
rows = list((payload.get("data") or {}).get("diff") or [])
|
||||
row = next((item for item in rows if item), None)
|
||||
if not row:
|
||||
raise AdapterError(f"Eastmoney Shenwan quote missing for {code}")
|
||||
epoch = int(finite_number(row.get("f124")) or 0)
|
||||
close = round4(finite_number(row.get("f2")))
|
||||
previous = round4(finite_number(row.get("f18")))
|
||||
if close <= 0 or previous <= 0:
|
||||
raise AdapterError(f"Eastmoney Shenwan quote empty for {code}")
|
||||
quote_time = (
|
||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||
if epoch
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"ts_code": f"{code}.SI",
|
||||
"code": f"{code}.SI",
|
||||
"name": row.get("f14") or code,
|
||||
"price": close,
|
||||
"close": close,
|
||||
"pre_close": previous,
|
||||
"previous_close": previous,
|
||||
"open": round4(finite_number(row.get("f17"))),
|
||||
"high": round4(finite_number(row.get("f15"))),
|
||||
"low": round4(finite_number(row.get("f16"))),
|
||||
"change": round4(finite_number(row.get("f3"))),
|
||||
"pct_change": round4(finite_number(row.get("f3"))),
|
||||
"pct_chg": round4(finite_number(row.get("f3"))),
|
||||
"amount": round4(finite_number(row.get("f6"))),
|
||||
"leader": row.get("f128") or "--",
|
||||
"leader_code": row.get("f140") or "",
|
||||
"leading_pct": round4(finite_number(row.get("f136"))),
|
||||
"up_count": int(finite_number(row.get("f104")) or 0),
|
||||
"down_count": int(finite_number(row.get("f105")) or 0),
|
||||
"quote_time": quote_time,
|
||||
"trade_time": quote_time,
|
||||
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
|
||||
"quote_time_epoch": epoch,
|
||||
"source": "eastmoney_sw",
|
||||
}
|
||||
|
||||
def fetch_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]]:
|
||||
day = str(trade_date or "").replace("-", "")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for url, limit_type in (
|
||||
("https://push2ex.eastmoney.com/getTopicZTPool", "U"),
|
||||
("https://push2ex.eastmoney.com/getTopicZBPool", "Z"),
|
||||
):
|
||||
params = {
|
||||
"ut": "7eea3edcaed734bea9cbfc24409ed989",
|
||||
"dpt": "wz.ztzt",
|
||||
"PageIndex": "0",
|
||||
"PageSize": "200",
|
||||
"sort": "fbt:asc",
|
||||
"stat": "1",
|
||||
}
|
||||
if day:
|
||||
params["date"] = day
|
||||
try:
|
||||
payload = self._get_json(url, params, referer="https://quote.eastmoney.com/")
|
||||
except AdapterError:
|
||||
continue
|
||||
pool = ((payload.get("data") or {}).get("pool") or []) if isinstance(payload.get("data"), dict) else []
|
||||
for item in pool:
|
||||
code = str(item.get("c") or item.get("code") or "")
|
||||
if not code:
|
||||
continue
|
||||
market = str(item.get("m") or item.get("market") or "")
|
||||
suffix = "SH" if market in {"1", "SH"} or code.startswith(("5", "6", "9")) else "SZ"
|
||||
first = str(item.get("fbt") or item.get("first_time") or "")
|
||||
last = str(item.get("lbt") or item.get("last_time") or "")
|
||||
rows.append(
|
||||
{
|
||||
"ts_code": f"{code}.{suffix}",
|
||||
"limit_type": limit_type,
|
||||
"first_time": first,
|
||||
"last_time": last,
|
||||
"fd_amount": item.get("fund") or item.get("fd_amount"),
|
||||
"open_times": item.get("zbc") or item.get("open_times"),
|
||||
"limit_times": item.get("lbc") or item.get("limit_times"),
|
||||
"turnover_ratio": item.get("hs") or item.get("turnover_ratio"),
|
||||
"source": "eastmoney_zt_pool",
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise AdapterError("Eastmoney limit pool empty")
|
||||
return rows
|
||||
|
||||
def _get_json(self, url: str, params: dict[str, str], referer: str) -> dict[str, Any]:
|
||||
request_url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
request = urllib.request.Request(
|
||||
|
||||
@@ -50,6 +50,14 @@ TUSHARE_FIELDS = {
|
||||
"ths_daily": "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
|
||||
"dc_index": "ts_code,trade_date,name,open,high,low,close,pre_close,pct_change,vol,amount,turnover_rate",
|
||||
"sw_daily": "ts_code,trade_date,name,open,high,low,close,pct_change,vol,amount",
|
||||
"index_member_all": (
|
||||
"l1_code,l1_name,l2_code,l2_name,l3_code,l3_name,"
|
||||
"ts_code,name,in_date,out_date,is_new"
|
||||
),
|
||||
"stk_limit": "ts_code,trade_date,up_limit,down_limit",
|
||||
"suspend_d": "ts_code,suspend_date,resume_date,ann_date,suspend_reason,reason_type",
|
||||
"ths_member": "ts_code,con_code,con_name,in_date,out_date,is_new",
|
||||
"stk_mins": "ts_code,trade_time,open,close,high,low,vol,amount",
|
||||
}
|
||||
|
||||
DATASET_API = {
|
||||
@@ -254,3 +262,6 @@ class TushareAdapter(MarketAdapter):
|
||||
items = data.get("items") or []
|
||||
fields_list = data.get("fields") or (fields.split(",") if fields else [])
|
||||
return [dict(zip(fields_list, item)) for item in items]
|
||||
|
||||
def query_raw(self, api_name: str, params: dict[str, Any], fields: str = "") -> list[dict[str, Any]]:
|
||||
return self._query(api_name, params, fields or TUSHARE_FIELDS.get(api_name, ""))
|
||||
|
||||
@@ -42,7 +42,7 @@ class HubRequestHandler(BaseHTTPRequestHandler):
|
||||
self._json({"status": "ok"}, HTTPStatus.OK)
|
||||
return
|
||||
if path.startswith("/v1/"):
|
||||
self._v1(path, parsed.query)
|
||||
self._v1(path, parsed.query, method)
|
||||
return
|
||||
if path.startswith("/admin/api/"):
|
||||
self._admin_api(method, path)
|
||||
@@ -66,11 +66,16 @@ class HubRequestHandler(BaseHTTPRequestHandler):
|
||||
LOGGER.exception("internal error")
|
||||
self._json({"error": {"code": "INTERNAL", "message": "internal error"}}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
def _v1(self, path: str, query: str) -> None:
|
||||
def _v1(self, path: str, query: str, method: str = "GET") -> None:
|
||||
token = self.headers.get("X-Datahub-Token", "")
|
||||
if not self.hub.auth.check_api_token(token):
|
||||
self.hub.pipeline.audit("anonymous", "unauthorized", path, "")
|
||||
raise ApiError("UNAUTHORIZED", "missing or invalid X-Datahub-Token")
|
||||
if path == "/v1/query" and method == "POST":
|
||||
body = self._read_json(max_bytes=1_000_000)
|
||||
payload = self.hub.api.query_api(body)
|
||||
self._json(payload, HTTPStatus.OK)
|
||||
return
|
||||
payload = self.hub.api.handle(path, parse_query(query))
|
||||
self._json(payload, HTTPStatus.OK)
|
||||
|
||||
@@ -184,11 +189,11 @@ class HubRequestHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def _read_json(self, allow_empty: bool = False) -> dict[str, Any]:
|
||||
def _read_json(self, allow_empty: bool = False, max_bytes: int = 65536) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||
if length == 0 and allow_empty:
|
||||
return {}
|
||||
if length <= 0 or length > 65536:
|
||||
if length <= 0 or length > max_bytes:
|
||||
raise ValueError("请求内容为空或过大")
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
|
||||
@@ -149,6 +149,78 @@ def fetch_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_sector_quote(db: HubDB, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||
ts_code = str(code or "").strip().upper()
|
||||
if ts_code.isdigit():
|
||||
ts_code = f"{ts_code}.SI"
|
||||
cache_key = f"sector:{ts_code}"
|
||||
cached = _read_cache(db, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
errors: list[str] = []
|
||||
try:
|
||||
row = EastmoneyAdapter().fetch_shenwan_quote(ts_code)
|
||||
source = str(row.get("source") or "eastmoney_sw")
|
||||
except Exception as exc:
|
||||
errors.append(f"eastmoney:{exc}")
|
||||
recovered = _load_quotes_lkg(db, cache_key)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise RealtimeApiError(
|
||||
"SOURCE_UNAVAILABLE",
|
||||
"sector quote unavailable: " + ";".join(errors),
|
||||
) from exc
|
||||
want = str(expected_date or "").replace("-", "")[:8]
|
||||
quote_date = str(row.get("quote_date") or "")
|
||||
if want and quote_date and quote_date != want:
|
||||
recovered = _load_quotes_lkg(db, cache_key)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"sector quote date {quote_date} != {want}")
|
||||
payload = _envelope(
|
||||
row,
|
||||
{
|
||||
"tier": "provisional",
|
||||
"trade_date": quote_date or yyyymmdd(now_shanghai()),
|
||||
"source": source,
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"published_at": isoformat(now_shanghai()),
|
||||
},
|
||||
)
|
||||
_write_cache(db, cache_key, payload, INDEX_TTL, source)
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_limit_pool(db: HubDB, trade_date: str = "") -> dict[str, Any]:
|
||||
day = yyyymmdd(trade_date or now_shanghai())
|
||||
cache_key = f"limit-pool:{day}"
|
||||
cached = _read_cache(db, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
rows = EastmoneyAdapter().fetch_limit_pool(day)
|
||||
source = "eastmoney:zt_pool"
|
||||
except Exception as exc:
|
||||
recovered = _load_quotes_lkg(db, cache_key)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"limit pool unavailable: {exc}") from exc
|
||||
payload = _envelope(
|
||||
rows,
|
||||
{
|
||||
"tier": "provisional",
|
||||
"trade_date": day,
|
||||
"source": source,
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"published_at": isoformat(now_shanghai()),
|
||||
},
|
||||
)
|
||||
_write_cache(db, cache_key, payload, QUOTE_TTL, source)
|
||||
return payload
|
||||
|
||||
|
||||
def _eastmoney_named_quotes(codes: list[str]) -> list[dict[str, Any]]:
|
||||
adapter = EastmoneyAdapter()
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
@@ -87,12 +87,46 @@ class V1API:
|
||||
return self.index_quotes(q)
|
||||
if path == "/v1/intraday/points":
|
||||
return self.intraday_points(q)
|
||||
if path == "/v1/sectors/quote":
|
||||
return self.sector_quote(q)
|
||||
if path == "/v1/limit-pool":
|
||||
return self.limit_pool(q)
|
||||
if path == "/v1/query":
|
||||
return self.query_api(q)
|
||||
if path == "/v1/datasets/status":
|
||||
return self.dataset_status(q.get("date") or "")
|
||||
if path == "/v1/batches":
|
||||
return self.batches(q.get("date") or "", q.get("dataset") or "")
|
||||
raise ApiError("INVALID_ARGUMENT", f"unknown endpoint: {path}")
|
||||
|
||||
def query_api(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
from datahub.steward import steward_query
|
||||
|
||||
payload = dict(body or {})
|
||||
raw_params = payload.get("params")
|
||||
if isinstance(raw_params, str):
|
||||
payload["params"] = _parse_json(raw_params) or {}
|
||||
return steward_query(self, payload)
|
||||
|
||||
def sector_quote(self, q: dict[str, str]) -> dict[str, Any]:
|
||||
from datahub.realtime_serve import RealtimeApiError, fetch_sector_quote
|
||||
|
||||
code = str(q.get("code") or q.get("ts_code") or "").strip()
|
||||
if not code:
|
||||
raise ApiError("INVALID_ARGUMENT", "code is required")
|
||||
try:
|
||||
return fetch_sector_quote(self.db, code, str(q.get("date") or ""))
|
||||
except RealtimeApiError as exc:
|
||||
raise ApiError(exc.code, exc.message) from exc
|
||||
|
||||
def limit_pool(self, q: dict[str, str]) -> dict[str, Any]:
|
||||
from datahub.realtime_serve import RealtimeApiError, fetch_limit_pool
|
||||
|
||||
try:
|
||||
return fetch_limit_pool(self.db, str(q.get("date") or q.get("trade_date") or ""))
|
||||
except RealtimeApiError as exc:
|
||||
raise ApiError(exc.code, exc.message) from exc
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
today = yyyymmdd(now_shanghai())
|
||||
cal = self.db.fetchone(
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Website-facing data steward: pick source, fail over, cache, never fake zeros.
|
||||
|
||||
The main site asks for a business/Tushare-shaped API. This module decides whether
|
||||
to serve a published EOD table, live free quotes, or an internal Tushare pull.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from datahub.adapters.base import AdapterError
|
||||
from datahub.adapters.tushare import TUSHARE_FIELDS
|
||||
from datahub.numbers import finite_number
|
||||
from datahub.realtime_serve import (
|
||||
RealtimeApiError,
|
||||
_read_cache,
|
||||
_write_cache,
|
||||
fetch_index_quotes,
|
||||
fetch_market_quotes,
|
||||
fetch_quotes,
|
||||
)
|
||||
from datahub.serving import ApiError, envelope
|
||||
from datahub.timeutil import isoformat, now_shanghai, yyyymmdd
|
||||
|
||||
API_TO_DATASET = {
|
||||
"trade_cal": "calendar",
|
||||
"stock_basic": "stocks",
|
||||
"daily": "daily",
|
||||
"daily_basic": "valuation",
|
||||
"index_daily": "index_daily",
|
||||
"moneyflow": "moneyflow",
|
||||
"stk_auction": "auction",
|
||||
"limit_list_d": "limit_events",
|
||||
"ths_hot": "popularity",
|
||||
"dc_hot": "popularity",
|
||||
"hm_detail": "dragon_tiger",
|
||||
"ths_daily": "sector_daily",
|
||||
"dc_index": "sector_daily",
|
||||
"sw_daily": "sector_daily",
|
||||
}
|
||||
|
||||
DATASET_FETCHER = {
|
||||
"calendar": lambda api, q: api.calendar(q.get("from") or q.get("start_date") or "", q.get("to") or q.get("end_date") or ""),
|
||||
"stocks": lambda api, q: api.stocks(q.get("updated_since") or "", q),
|
||||
"daily": lambda api, q: api.daily_bars(_hub_query(q, adjust="none")),
|
||||
"valuation": lambda api, q: api.valuation(_hub_query(q)),
|
||||
"index_daily": lambda api, q: api.index_bars(_hub_query(q)),
|
||||
"moneyflow": lambda api, q: api.moneyflow(_hub_query(q)),
|
||||
"auction": lambda api, q: api.auction(_hub_query(q)),
|
||||
"limit_events": lambda api, q: api.limit_events(_hub_query(q)),
|
||||
"popularity": lambda api, q: api.popularity(_hub_query(q)),
|
||||
"dragon_tiger": lambda api, q: api.dragon_tiger(_hub_query(q)),
|
||||
"sector_daily": lambda api, q: api.sectors(_hub_query(q)),
|
||||
}
|
||||
|
||||
SCALE_TO_TUSHARE = {
|
||||
"daily": {"vol": 100.0, "amount": 1000.0},
|
||||
"index_daily": {"vol": 100.0, "amount": 1000.0},
|
||||
"valuation": {"total_mv": 10000.0, "circ_mv": 10000.0},
|
||||
"moneyflow": {
|
||||
"buy_sm_amount": 10000.0,
|
||||
"sell_sm_amount": 10000.0,
|
||||
"buy_md_amount": 10000.0,
|
||||
"sell_md_amount": 10000.0,
|
||||
"buy_lg_amount": 10000.0,
|
||||
"sell_lg_amount": 10000.0,
|
||||
"buy_elg_amount": 10000.0,
|
||||
"sell_elg_amount": 10000.0,
|
||||
"net_mf_amount": 10000.0,
|
||||
},
|
||||
"auction": {"vol": 100.0, "float_share": 10000.0},
|
||||
"limit_events": {"limit_amount": 10000.0, "float_mv": 10000.0, "total_mv": 10000.0},
|
||||
"dragon_tiger": {"buy_amount": 10000.0, "sell_amount": 10000.0, "net_amount": 10000.0},
|
||||
}
|
||||
|
||||
LIVE_TTL = {
|
||||
"index_member_all": 6 * 3600,
|
||||
"stk_limit": 3600,
|
||||
"suspend_d": 6 * 3600,
|
||||
"adj_factor": 3600,
|
||||
"hm_list": 24 * 3600,
|
||||
"ths_index": 24 * 3600,
|
||||
"ths_member": 6 * 3600,
|
||||
"stk_mins": 20,
|
||||
"top_list": 3600,
|
||||
"top_inst": 3600,
|
||||
}
|
||||
|
||||
BLOCKED_LIVE_APIS = {"rt_sw_k"}
|
||||
|
||||
|
||||
def steward_query(api, body: dict[str, Any]) -> dict[str, Any]:
|
||||
api_name = str(body.get("api_name") or "").strip()
|
||||
params = body.get("params") if isinstance(body.get("params"), dict) else {}
|
||||
fields = str(body.get("fields") or "")
|
||||
if not api_name:
|
||||
raise ApiError("INVALID_ARGUMENT", "api_name is required")
|
||||
if api_name in BLOCKED_LIVE_APIS:
|
||||
raise ApiError("INVALID_ARGUMENT", "rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||
if api_name == "rt_k":
|
||||
return _realtime_quotes(api, params, fields)
|
||||
if api_name == "rt_idx_k":
|
||||
return _realtime_index_quotes(api, params, fields)
|
||||
dataset = API_TO_DATASET.get(api_name)
|
||||
if dataset:
|
||||
published = _try_published(api, api_name, dataset, params, fields)
|
||||
if published is not None:
|
||||
return published
|
||||
rows = _live_tushare(api, api_name, params, fields)
|
||||
return envelope(
|
||||
_project(rows, fields),
|
||||
{
|
||||
"tier": "live",
|
||||
"source": "tushare",
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"row_shape": "tushare",
|
||||
"published_at": isoformat(now_shanghai()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _try_published(api, api_name: str, dataset: str, params: dict[str, Any], fields: str) -> dict[str, Any] | None:
|
||||
fetcher = DATASET_FETCHER.get(dataset)
|
||||
if fetcher is None:
|
||||
return None
|
||||
query = _hub_query(params)
|
||||
if dataset == "popularity":
|
||||
query["source"] = "ths" if api_name == "ths_hot" else "dc"
|
||||
if dataset == "sector_daily":
|
||||
query["family"] = {"ths_daily": "ths", "dc_index": "dc", "sw_daily": "sw"}.get(api_name, "")
|
||||
if dataset == "limit_events":
|
||||
limit_type = str(params.get("limit_type") or "").strip().upper()
|
||||
if limit_type:
|
||||
query["limit_type"] = limit_type
|
||||
if dataset == "calendar" and not (query.get("from") and query.get("to")):
|
||||
start = str(params.get("start_date") or params.get("from") or "")
|
||||
end = str(params.get("end_date") or params.get("to") or start)
|
||||
if not start or not end:
|
||||
return None
|
||||
query = {"from": start, "to": end}
|
||||
try:
|
||||
payload = fetcher(api, query)
|
||||
except ApiError as exc:
|
||||
if exc.code in {"DATASET_NOT_PUBLISHED", "STALE_DATA", "INVALID_ARGUMENT"}:
|
||||
return None
|
||||
raise
|
||||
rows = list(payload.get("data") or [])
|
||||
if dataset == "stocks":
|
||||
rows = _filter_stocks(rows, params)
|
||||
if dataset == "calendar":
|
||||
rows = _filter_calendar(rows, params)
|
||||
native = _to_tushare_native(dataset, rows)
|
||||
meta = dict(payload.get("meta") or {})
|
||||
meta["row_shape"] = "tushare"
|
||||
meta["source"] = str(meta.get("source") or "datahub")
|
||||
return envelope(_project(native, fields), meta)
|
||||
|
||||
|
||||
def _realtime_quotes(api, params: dict[str, Any], fields: str) -> dict[str, Any]:
|
||||
codes = [item.strip() for item in str(params.get("ts_code") or params.get("codes") or "").split(",") if item.strip()]
|
||||
try:
|
||||
payload = fetch_quotes(api.db, codes) if codes else fetch_market_quotes(api.db)
|
||||
except RealtimeApiError as exc:
|
||||
raise ApiError(exc.code, exc.message) from exc
|
||||
rows = [_quote_to_rt_k(item) for item in (payload.get("data") or []) if isinstance(item, dict)]
|
||||
rows = [item for item in rows if item]
|
||||
meta = dict(payload.get("meta") or {})
|
||||
meta["row_shape"] = "tushare"
|
||||
return envelope(_project(rows, fields), meta)
|
||||
|
||||
|
||||
def _realtime_index_quotes(api, params: dict[str, Any], fields: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = fetch_index_quotes(api.db)
|
||||
except RealtimeApiError as exc:
|
||||
raise ApiError(exc.code, exc.message) from exc
|
||||
wanted = {
|
||||
item.strip()
|
||||
for item in str(params.get("ts_code") or "").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
rows = []
|
||||
for item in payload.get("data") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
converted = _quote_to_rt_k(item)
|
||||
if not converted:
|
||||
continue
|
||||
if wanted and converted.get("ts_code") not in wanted and str(item.get("code") or "") not in {
|
||||
code.split(".")[0] for code in wanted
|
||||
}:
|
||||
continue
|
||||
rows.append(converted)
|
||||
meta = dict(payload.get("meta") or {})
|
||||
meta["row_shape"] = "tushare"
|
||||
return envelope(_project(rows, fields), meta)
|
||||
|
||||
|
||||
def _live_tushare(api, api_name: str, params: dict[str, Any], fields: str) -> list[dict[str, Any]]:
|
||||
wanted_fields = fields or TUSHARE_FIELDS.get(api_name, "")
|
||||
cache_key = _live_cache_key(api_name, params, wanted_fields)
|
||||
ttl = LIVE_TTL.get(api_name, 1800)
|
||||
cached = _read_cache(api.db, cache_key)
|
||||
if cached is not None:
|
||||
data = cached.get("data")
|
||||
if isinstance(data, list):
|
||||
return [dict(item) for item in data if isinstance(item, dict)]
|
||||
pipeline = api.pipeline
|
||||
if not pipeline.breaker.allow():
|
||||
recovered = _live_lkg(api.db, cache_key)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise ApiError("SOURCE_UNAVAILABLE", "Tushare circuit open")
|
||||
pipeline.bucket.acquire()
|
||||
try:
|
||||
rows = pipeline.adapter.query_raw(api_name, dict(params), wanted_fields)
|
||||
pipeline.breaker.record_success()
|
||||
except Exception as exc:
|
||||
pipeline.breaker.record_failure(str(exc))
|
||||
recovered = _live_lkg(api.db, cache_key)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
raise ApiError("SOURCE_UNAVAILABLE", f"Tushare {api_name} unavailable: {exc}") from exc
|
||||
payload = envelope(
|
||||
rows,
|
||||
{
|
||||
"tier": "live",
|
||||
"source": "tushare",
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"row_shape": "tushare",
|
||||
"published_at": isoformat(now_shanghai()),
|
||||
},
|
||||
)
|
||||
_write_cache(api.db, cache_key, payload, ttl, "tushare")
|
||||
return rows
|
||||
|
||||
|
||||
def _live_lkg(db, cache_key: str) -> list[dict[str, Any]] | None:
|
||||
from datahub.governance.lkg import LastKnownGood
|
||||
|
||||
item = LastKnownGood(db).load(cache_key)
|
||||
payload = item.get("payload") if item else None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
return None
|
||||
return [dict(row) for row in data if isinstance(row, dict)]
|
||||
|
||||
|
||||
def _live_cache_key(api_name: str, params: dict[str, Any], fields: str) -> str:
|
||||
packed = json.dumps({"api": api_name, "params": params, "fields": fields}, sort_keys=True, ensure_ascii=False)
|
||||
digest = hashlib.sha1(packed.encode("utf-8")).hexdigest()
|
||||
return f"steward:{api_name}:{digest}"
|
||||
|
||||
|
||||
def _hub_query(params: dict[str, Any], **extra: Any) -> dict[str, str]:
|
||||
query = {key: str(value) for key, value in extra.items() if value not in (None, "")}
|
||||
date = yyyymmdd(params.get("trade_date") or params.get("date") or "")
|
||||
start = yyyymmdd(params.get("start_date") or params.get("from") or date)
|
||||
end = yyyymmdd(params.get("end_date") or params.get("to") or date)
|
||||
code = str(params.get("ts_code") or params.get("code") or "").strip()
|
||||
if code:
|
||||
query["code"] = code
|
||||
if date and not (params.get("start_date") or params.get("end_date")):
|
||||
query["date"] = date
|
||||
else:
|
||||
if start:
|
||||
query["from"] = start
|
||||
if end:
|
||||
query["to"] = end
|
||||
return query
|
||||
|
||||
|
||||
def _to_tushare_native(dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
scales = SCALE_TO_TUSHARE.get(dataset) or {}
|
||||
converted: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
if item.get("vol") in (None, ""):
|
||||
item["vol"] = item.get("volume")
|
||||
item.pop("volume", None)
|
||||
for field, factor in scales.items():
|
||||
if field in item and item[field] not in (None, ""):
|
||||
number = finite_number(item.get(field))
|
||||
item[field] = number / factor if factor else number
|
||||
if dataset == "popularity" and item.get("ts_name") and not item.get("name"):
|
||||
item["name"] = item.get("ts_name")
|
||||
if dataset == "dragon_tiger" and item.get("ts_name") and not item.get("name"):
|
||||
item["name"] = item.get("ts_name")
|
||||
if dataset == "sector_daily" and item.get("pct_change") is not None and item.get("pct_chg") is None:
|
||||
item["pct_chg"] = item.get("pct_change")
|
||||
if dataset == "calendar":
|
||||
item["is_open"] = 1 if item.get("is_open") in (True, 1, "1", "Y", "y") else 0
|
||||
converted.append(item)
|
||||
return converted
|
||||
|
||||
|
||||
def _quote_to_rt_k(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
ts_code = str(row.get("ts_code") or "").strip()
|
||||
close = finite_number(row.get("close") if row.get("close") not in (None, "") else row.get("price"))
|
||||
previous = finite_number(
|
||||
row.get("pre_close") if row.get("pre_close") not in (None, "") else row.get("previous_close")
|
||||
)
|
||||
if not ts_code or close <= 0:
|
||||
return None
|
||||
item = {
|
||||
"ts_code": ts_code,
|
||||
"name": row.get("name") or "",
|
||||
"open": row.get("open"),
|
||||
"high": row.get("high"),
|
||||
"low": row.get("low"),
|
||||
"close": close,
|
||||
"pre_close": previous,
|
||||
"vol": row.get("vol") if row.get("vol") not in (None, "") else row.get("volume"),
|
||||
"amount": row.get("amount"),
|
||||
"pct_chg": row.get("pct_chg") if row.get("pct_chg") not in (None, "") else row.get("change"),
|
||||
"trade_time": row.get("quote_time") or row.get("trade_time") or "",
|
||||
"quote_date": row.get("quote_date") or "",
|
||||
"source": row.get("source") or "",
|
||||
"delayed": bool(row.get("delayed")),
|
||||
"delay_seconds": row.get("delay_seconds") or 0,
|
||||
"delay_notice": row.get("delay_notice") or "",
|
||||
}
|
||||
return item
|
||||
|
||||
|
||||
def _filter_stocks(rows: list[dict[str, Any]], params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
ts_code = str(params.get("ts_code") or "").strip().upper()
|
||||
status = str(params.get("list_status") or "").strip()
|
||||
name = str(params.get("name") or "").strip()
|
||||
filtered = rows
|
||||
if ts_code:
|
||||
filtered = [row for row in filtered if str(row.get("ts_code") or "").upper() == ts_code]
|
||||
if status:
|
||||
filtered = [row for row in filtered if str(row.get("list_status") or status) == status]
|
||||
if name:
|
||||
filtered = [row for row in filtered if name.casefold() in str(row.get("name") or "").casefold()]
|
||||
return filtered
|
||||
|
||||
|
||||
def _filter_calendar(rows: list[dict[str, Any]], params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
start = yyyymmdd(params.get("start_date") or params.get("from") or "")
|
||||
end = yyyymmdd(params.get("end_date") or params.get("to") or start)
|
||||
if start and end:
|
||||
rows = [row for row in rows if start <= yyyymmdd(row.get("cal_date")) <= end]
|
||||
if params.get("is_open") in (1, "1", True):
|
||||
rows = [row for row in rows if int(row.get("is_open") or 0) == 1]
|
||||
return rows
|
||||
|
||||
|
||||
def _project(rows: list[dict[str, Any]], fields: str) -> list[dict[str, Any]]:
|
||||
keys = [item.strip() for item in str(fields or "").split(",") if item.strip()]
|
||||
if not keys:
|
||||
return rows
|
||||
return [{key: row.get(key) for key in keys} for row in rows]
|
||||
Reference in New Issue
Block a user