fix(HEL-494): 数据中枢独占调度,主网站不再回退旧接口

主网站只向中枢要业务数据;来源选择、切源、补数全部在中枢内部完成,失败不再走东财/腾讯/Tushare 保底。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-08 21:43:31 +08:00
co-authored by Cursor multica-agent
parent ef13d6feb5
commit 0b8419abca
23 changed files with 1159 additions and 368 deletions
@@ -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, ""))
+9 -4
View File
@@ -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:
+72
View File
@@ -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]] = []
+34
View File
@@ -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(
+360
View File
@@ -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]