feat(HEL-463): 接入剩余行情数据到 datahub
扩展盘后正式集(涨跌停/人气/龙虎榜/板块日线)与盘中观察 API(报价/指数/分时),网站 bridge 按开关接入并回退旧链路;问天改为按数据依赖跟随开关,不再整栈强制旧路径。 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
16ba83ec01
commit
605f97e5df
@@ -0,0 +1,188 @@
|
||||
"""Provisional (盘中观察) serving: quotes, index quotes, intraday points.
|
||||
|
||||
Free sources only. Never writes official eod_* tables. Uses rt_cache + LKG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from datahub.adapters.eastmoney import EastmoneyAdapter
|
||||
from datahub.adapters.tencent import TencentAdapter
|
||||
from datahub.codes import resolve_code
|
||||
from datahub.db import HubDB
|
||||
from datahub.timeutil import isoformat, now_shanghai, yyyymmdd
|
||||
|
||||
QUOTE_TTL = 60
|
||||
INDEX_TTL = 60
|
||||
INTRADAY_TTL = 20
|
||||
|
||||
|
||||
class RealtimeApiError(RuntimeError):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _envelope(data: Any, meta: dict[str, Any]) -> dict[str, Any]:
|
||||
from datahub import SCHEMA_VERSION
|
||||
|
||||
return {"schema_version": SCHEMA_VERSION, "data": data, "meta": meta}
|
||||
|
||||
|
||||
def fetch_index_quotes(db: HubDB) -> dict[str, Any]:
|
||||
cache_key = "indexes:quotes"
|
||||
cached = _read_cache(db, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
eastmoney = EastmoneyAdapter()
|
||||
try:
|
||||
rows = eastmoney.fetch_indices()
|
||||
source = "eastmoney:ulist"
|
||||
except Exception:
|
||||
rows = TencentAdapter().fetch_indices()
|
||||
source = "tencent:qt"
|
||||
if len(rows) < 3:
|
||||
raise RealtimeApiError("SOURCE_UNAVAILABLE", "index quotes incomplete")
|
||||
payload = _envelope(
|
||||
rows,
|
||||
{
|
||||
"tier": "provisional",
|
||||
"trade_date": 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_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]:
|
||||
if not codes:
|
||||
raise RealtimeApiError("INVALID_ARGUMENT", "codes is required")
|
||||
resolved: list[str] = []
|
||||
for code in codes[:60]:
|
||||
item = resolve_code(db, code) or _guess_ts_code(code)
|
||||
if item:
|
||||
resolved.append(item)
|
||||
if not resolved:
|
||||
raise RealtimeApiError("INVALID_ARGUMENT", "no resolvable codes")
|
||||
cache_key = "quotes:" + ",".join(sorted(resolved))
|
||||
cached = _read_cache(db, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
adapter = EastmoneyAdapter()
|
||||
try:
|
||||
rows = adapter.fetch_quotes(resolved)
|
||||
source = "eastmoney:clist"
|
||||
except Exception as exc:
|
||||
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"quotes unavailable: {exc}") from exc
|
||||
payload = _envelope(
|
||||
rows,
|
||||
{
|
||||
"tier": "provisional",
|
||||
"trade_date": yyyymmdd(now_shanghai()),
|
||||
"source": source,
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"published_at": isoformat(now_shanghai()),
|
||||
},
|
||||
)
|
||||
_write_cache(db, cache_key, payload, QUOTE_TTL, source)
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_intraday(db: HubDB, code: str, date: str = "") -> dict[str, Any]:
|
||||
ts_code = resolve_code(db, code) or _guess_ts_code(code)
|
||||
if not ts_code:
|
||||
raise RealtimeApiError("INVALID_ARGUMENT", f"ambiguous code: {code}")
|
||||
cache_key = f"intraday:{ts_code}:{date or 'today'}"
|
||||
cached = _read_cache(db, cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
adapter = EastmoneyAdapter()
|
||||
try:
|
||||
payload_data = adapter.fetch_intraday(ts_code)
|
||||
source = "eastmoney:trends2"
|
||||
except Exception as exc:
|
||||
raise RealtimeApiError("SOURCE_UNAVAILABLE", f"intraday unavailable: {exc}") from exc
|
||||
payload = _envelope(
|
||||
payload_data,
|
||||
{
|
||||
"tier": "provisional",
|
||||
"trade_date": yyyymmdd(payload_data.get("trade_date") or date or now_shanghai()),
|
||||
"source": source,
|
||||
"stale": False,
|
||||
"staleness_seconds": 0,
|
||||
"published_at": isoformat(now_shanghai()),
|
||||
},
|
||||
)
|
||||
_write_cache(db, cache_key, payload, INTRADAY_TTL, source)
|
||||
return payload
|
||||
|
||||
|
||||
def _guess_ts_code(code: str) -> str | None:
|
||||
raw = str(code or "").strip().upper()
|
||||
if "." in raw:
|
||||
return raw
|
||||
if len(raw) == 6 and raw.isdigit():
|
||||
if raw.startswith(("5", "6", "9")):
|
||||
return f"{raw}.SH"
|
||||
return f"{raw}.SZ"
|
||||
return None
|
||||
|
||||
|
||||
def _read_cache(db: HubDB, cache_key: str) -> dict[str, Any] | None:
|
||||
row = db.fetchone("SELECT * FROM rt_cache WHERE cache_key = ?", (cache_key,))
|
||||
if not row:
|
||||
return None
|
||||
expires = str(row.get("expires_at") or "")
|
||||
now = isoformat(now_shanghai())
|
||||
if expires and expires < now:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(row["payload"])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if isinstance(payload, dict) and isinstance(payload.get("meta"), dict):
|
||||
stored = str(row.get("stored_at") or "")
|
||||
try:
|
||||
age = max(0, int(time.time() - datetime.fromisoformat(stored).timestamp()))
|
||||
except Exception:
|
||||
age = 0
|
||||
payload["meta"]["staleness_seconds"] = age
|
||||
payload["meta"]["stale"] = age > QUOTE_TTL
|
||||
return payload
|
||||
|
||||
|
||||
def _write_cache(db: HubDB, cache_key: str, payload: dict[str, Any], ttl: int, source: str) -> None:
|
||||
from datetime import timedelta
|
||||
|
||||
now = now_shanghai()
|
||||
stored = isoformat(now)
|
||||
expires = isoformat(now + timedelta(seconds=ttl))
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO rt_cache(cache_key, payload, source, stored_at, expires_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
payload=excluded.payload, source=excluded.source,
|
||||
stored_at=excluded.stored_at, expires_at=excluded.expires_at
|
||||
""",
|
||||
(cache_key, json.dumps(payload, ensure_ascii=False), source, stored, expires),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO last_known_good(cache_key, payload, source, stored_at)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
payload=excluded.payload, source=excluded.source, stored_at=excluded.stored_at
|
||||
""",
|
||||
(cache_key, json.dumps(payload, ensure_ascii=False), source, stored),
|
||||
)
|
||||
Reference in New Issue
Block a user