扩展盘后正式集(涨跌停/人气/龙虎榜/板块日线)与盘中观察 API(报价/指数/分时),网站 bridge 按开关接入并回退旧链路;问天改为按数据依赖跟随开关,不再整栈强制旧路径。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
"""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),
|
|
)
|