498 lines
19 KiB
Python
498 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import http.client
|
|
import json
|
|
import re
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
from threading import Lock
|
|
from typing import Any, ClassVar
|
|
|
|
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
|
|
|
|
|
|
class ChartDataError(RuntimeError):
|
|
pass
|
|
|
|
|
|
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
|
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
|
BROWSER_USER_AGENT = (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/138.0.0.0 Safari/537.36"
|
|
)
|
|
INDEX_SECIDS = {
|
|
"000001.SH": "1.000001",
|
|
"399001.SZ": "0.399001",
|
|
"399006.SZ": "0.399006",
|
|
}
|
|
|
|
|
|
class MarketChartClient:
|
|
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
|
|
|
|
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
|
|
self.ifind = ifind
|
|
self.fallback = fallback
|
|
|
|
def stock_intraday(self, code: str) -> dict[str, Any]:
|
|
normalized = str(code or "").strip()
|
|
if not re.fullmatch(r"\d{6}", normalized):
|
|
raise ChartDataError("Invalid stock code")
|
|
ifind_code = _stock_market_code(normalized)
|
|
try:
|
|
return self._ifind_intraday(ifind_code, "stock", normalized)
|
|
except (IfindError, ChartDataError):
|
|
return self.fallback.stock_intraday(normalized)
|
|
|
|
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
|
normalized = str(code or "").strip()
|
|
if not re.fullmatch(r"\d{6}", normalized):
|
|
raise ChartDataError("Invalid stock code")
|
|
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
|
|
|
|
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
|
normalized = str(identifier or "").strip().upper()
|
|
if normalized not in INDEX_SECIDS:
|
|
raise ChartDataError("Unsupported index")
|
|
return self._ifind_daily(normalized, end_date, limit)
|
|
|
|
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
|
normalized = str(identifier or "").strip().upper()
|
|
if not normalized:
|
|
raise ChartDataError("Invalid board code")
|
|
return self._ifind_daily(normalized, end_date, limit)
|
|
|
|
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
|
normalized = str(identifier or "").strip().upper()
|
|
if normalized not in INDEX_SECIDS:
|
|
raise ChartDataError("Unsupported index")
|
|
try:
|
|
return self._ifind_intraday(normalized, "index", normalized)
|
|
except (IfindError, ChartDataError):
|
|
return self.fallback.index_intraday(normalized)
|
|
|
|
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
|
normalized = str(identifier or "").strip().upper()
|
|
try:
|
|
return self._ifind_intraday(normalized, "board", normalized, name)
|
|
except (IfindError, ChartDataError):
|
|
return self.fallback.board_intraday(normalized, name)
|
|
|
|
def _ifind_intraday(
|
|
self,
|
|
ifind_code: str,
|
|
entity_type: str,
|
|
identifier: str,
|
|
name: str = "",
|
|
) -> dict[str, Any]:
|
|
if not self.ifind.configured:
|
|
raise ChartDataError("iFinD is not configured")
|
|
now = datetime.now().astimezone()
|
|
rows: list[dict[str, Any]] = []
|
|
for offset in range(0, 8):
|
|
candidate = now.date() - timedelta(days=offset)
|
|
if candidate.weekday() >= 5:
|
|
continue
|
|
display_date = candidate.isoformat()
|
|
rows = self.ifind.intraday(
|
|
ifind_code,
|
|
f"{display_date} 09:30:00",
|
|
f"{display_date} 15:00:00",
|
|
cache_ttl=20 if offset == 0 else 6 * 60 * 60,
|
|
)
|
|
if rows:
|
|
break
|
|
points = [point for row in rows if (point := _ifind_point(row))]
|
|
if not points:
|
|
raise ChartDataError("No iFinD intraday chart data returned")
|
|
latest_date = points[-1]["date"]
|
|
points = [point for point in points if point["date"] == latest_date]
|
|
previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
|
|
return {
|
|
"entity_type": entity_type,
|
|
"identifier": identifier,
|
|
"name": name,
|
|
"code": identifier,
|
|
"trade_date": latest_date,
|
|
"previous_close": previous_close,
|
|
"points": points,
|
|
"source": "ifind",
|
|
}
|
|
|
|
def _ifind_daily(
|
|
self, ifind_code: str, end_date: str, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
if not self.ifind.configured:
|
|
raise ChartDataError("iFinD is not configured")
|
|
compact_end = str(end_date or "").replace("-", "")
|
|
if not re.fullmatch(r"\d{8}", compact_end):
|
|
raise ChartDataError("Invalid chart end date")
|
|
end = datetime.strptime(compact_end, "%Y%m%d")
|
|
start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
|
|
try:
|
|
rows = self.ifind.history(
|
|
ifind_code,
|
|
["open", "high", "low", "close", "volume", "amount"],
|
|
start,
|
|
compact_end,
|
|
cache_ttl=300,
|
|
)
|
|
except IfindError as exc:
|
|
raise ChartDataError("No iFinD daily chart data returned") from exc
|
|
normalized = []
|
|
for row in rows:
|
|
stamp = str(row.get("time") or "").strip()
|
|
trade_date = stamp[:10]
|
|
close = _number(row.get("close"))
|
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
|
|
continue
|
|
normalized.append(
|
|
{
|
|
"trade_date": trade_date,
|
|
"open": _number(row.get("open")),
|
|
"high": _number(row.get("high")),
|
|
"low": _number(row.get("low")),
|
|
"close": close,
|
|
"volume": _number(row.get("volume")),
|
|
"amount_billion": _number(row.get("amount")) / 100_000_000,
|
|
}
|
|
)
|
|
normalized.sort(key=lambda row: row["trade_date"])
|
|
for index, row in enumerate(normalized):
|
|
previous = normalized[index - 1]["close"] if index > 0 else 0
|
|
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
|
|
|
|
market_now = datetime.now().astimezone()
|
|
today = market_now.strftime("%Y%m%d")
|
|
market_open = (
|
|
market_now.weekday() < 5
|
|
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
|
)
|
|
today_display = market_now.date().isoformat()
|
|
if normalized and normalized[-1]["trade_date"] == today_display:
|
|
current_bar = normalized[-1]
|
|
current_bar_is_valid = (
|
|
current_bar["open"] > 0
|
|
and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
|
|
and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
|
|
and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
|
|
)
|
|
if not market_open or not current_bar_is_valid:
|
|
normalized.pop()
|
|
if compact_end == today and market_open:
|
|
try:
|
|
quote_rows = self.ifind.real_time(
|
|
ifind_code,
|
|
["open", "high", "low", "latest", "preClose", "volume", "amount"],
|
|
cache_ttl=10,
|
|
)
|
|
quote = quote_rows[0] if quote_rows else {}
|
|
latest = _number(quote.get("latest"))
|
|
previous = _number(quote.get("preClose"))
|
|
open_price = _number(quote.get("open"))
|
|
high = _number(quote.get("high"))
|
|
low = _number(quote.get("low"))
|
|
volume = _number(quote.get("volume"))
|
|
amount = _number(quote.get("amount"))
|
|
quote_date = str(quote.get("time") or "")[:10].replace("-", "")
|
|
quote_is_current = not quote_date or quote_date == today
|
|
has_market_activity = volume > 0 or amount > 0
|
|
if (
|
|
latest > 0
|
|
and open_price > 0
|
|
and high >= max(open_price, latest)
|
|
and 0 < low <= min(open_price, latest)
|
|
and has_market_activity
|
|
and quote_is_current
|
|
):
|
|
realtime = {
|
|
"trade_date": end.strftime("%Y-%m-%d"),
|
|
"open": open_price,
|
|
"high": high,
|
|
"low": low,
|
|
"close": latest,
|
|
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
|
|
"volume": volume,
|
|
"amount_billion": amount / 100_000_000,
|
|
"realtime": True,
|
|
}
|
|
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
|
|
normalized[-1] = realtime
|
|
else:
|
|
normalized.append(realtime)
|
|
except IfindError:
|
|
pass
|
|
if not normalized:
|
|
raise ChartDataError("No iFinD daily chart data returned")
|
|
return normalized[-max(20, min(180, int(limit))):]
|
|
|
|
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
|
today = datetime.now().astimezone().date().isoformat()
|
|
if trade_date == today:
|
|
try:
|
|
quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
|
|
value = _number((quote[0] if quote else {}).get("preClose"))
|
|
if value > 0:
|
|
return value
|
|
except IfindError:
|
|
pass
|
|
end = datetime.strptime(trade_date, "%Y-%m-%d")
|
|
try:
|
|
rows = self.ifind.history(
|
|
code,
|
|
["close"],
|
|
(end - timedelta(days=12)).strftime("%Y%m%d"),
|
|
end.strftime("%Y%m%d"),
|
|
cache_ttl=6 * 60 * 60,
|
|
)
|
|
closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
|
|
if len(closes) >= 2:
|
|
return closes[-2]
|
|
except IfindError:
|
|
pass
|
|
return fallback
|
|
|
|
|
|
@dataclass
|
|
class EastmoneyChartClient:
|
|
"""Isolated display-only minute chart source.
|
|
|
|
The returned data must not be used by market snapshots, scoring, screening,
|
|
or divination. Its only consumer is a chart-rendering endpoint.
|
|
"""
|
|
|
|
timeout: int = 6
|
|
cache_ttl_seconds: int = 20
|
|
retry_attempts: int = 2
|
|
_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
|
_cache_lock: ClassVar[Lock] = Lock()
|
|
_board_catalog: ClassVar[dict[str, dict[str, str]]] = {}
|
|
_board_catalog_at: ClassVar[float] = 0.0
|
|
_board_catalog_lock: ClassVar[Lock] = Lock()
|
|
|
|
def stock_intraday(self, code: str) -> dict[str, Any]:
|
|
normalized = str(code or "").strip()
|
|
if not re.fullmatch(r"\d{6}", normalized):
|
|
raise ChartDataError("Invalid stock code")
|
|
market = "1" if normalized.startswith(("5", "6", "9")) else "0"
|
|
return self._intraday(f"{market}.{normalized}", "stock", normalized)
|
|
|
|
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
|
normalized = str(identifier or "").strip().upper()
|
|
secid = INDEX_SECIDS.get(normalized)
|
|
if not secid:
|
|
raise ChartDataError("Unsupported index")
|
|
return self._intraday(secid, "index", normalized)
|
|
|
|
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
|
normalized = str(identifier or "").strip().upper()
|
|
if re.fullmatch(r"BK\d{4}", normalized):
|
|
board_code = normalized
|
|
else:
|
|
board_code = self._resolve_board_code(name or identifier)
|
|
return self._intraday(f"90.{board_code}", "board", board_code)
|
|
|
|
def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]:
|
|
cache_key = f"{entity_type}:{identifier}"
|
|
cached = self._get_cached(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
payload = self._request_json(
|
|
TRENDS_URL,
|
|
{
|
|
"secid": secid,
|
|
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
|
|
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
|
|
"iscr": "0",
|
|
"ndays": "1",
|
|
},
|
|
"https://quote.eastmoney.com/",
|
|
)
|
|
data = payload.get("data") or {}
|
|
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
|
|
if not points:
|
|
raise ChartDataError("No intraday chart data returned")
|
|
|
|
result = {
|
|
"entity_type": entity_type,
|
|
"identifier": identifier,
|
|
"name": str(data.get("name") or ""),
|
|
"code": str(data.get("code") or identifier),
|
|
"trade_date": points[-1]["date"],
|
|
"previous_close": _number(data.get("preClose")),
|
|
"points": points,
|
|
}
|
|
with self._cache_lock:
|
|
self._cache[cache_key] = {"created_at": time.time(), "payload": result}
|
|
return result
|
|
|
|
def _get_cached(self, cache_key: str) -> dict[str, Any] | None:
|
|
with self._cache_lock:
|
|
cached = self._cache.get(cache_key)
|
|
if not cached:
|
|
return None
|
|
if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds:
|
|
with self._cache_lock:
|
|
self._cache.pop(cache_key, None)
|
|
return None
|
|
return dict(cached["payload"])
|
|
|
|
def _resolve_board_code(self, name: str) -> str:
|
|
normalized = _normalize_name(name)
|
|
if not normalized:
|
|
raise ChartDataError("Board name is required")
|
|
catalog = self._load_board_catalog()
|
|
item = catalog.get(normalized)
|
|
if not item:
|
|
raise ChartDataError("No matching chart board")
|
|
return item["code"]
|
|
|
|
def _load_board_catalog(self) -> dict[str, dict[str, str]]:
|
|
now = time.time()
|
|
with self._board_catalog_lock:
|
|
if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60:
|
|
return dict(self._board_catalog)
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
for board_type in ("1", "2", "3"):
|
|
for page in range(1, 6):
|
|
payload = self._request_json(
|
|
BOARD_LIST_URL,
|
|
{
|
|
"pn": str(page),
|
|
"pz": "100",
|
|
"po": "1",
|
|
"np": "1",
|
|
"fltt": "2",
|
|
"invt": "2",
|
|
"fid": "f3",
|
|
"fs": f"m:90+t:{board_type}",
|
|
"fields": "f12,f14",
|
|
},
|
|
"https://quote.eastmoney.com/center/boardlist.html",
|
|
)
|
|
page_rows = (payload.get("data") or {}).get("diff") or []
|
|
rows.extend(page_rows)
|
|
if len(page_rows) < 100:
|
|
break
|
|
|
|
catalog: dict[str, dict[str, str]] = {}
|
|
for row in rows:
|
|
code = str(row.get("f12") or "").strip().upper()
|
|
board_name = str(row.get("f14") or "").strip()
|
|
if re.fullmatch(r"BK\d{4}", code) and board_name:
|
|
catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name})
|
|
if not catalog:
|
|
raise ChartDataError("Board chart directory is unavailable")
|
|
with self._board_catalog_lock:
|
|
type(self)._board_catalog = catalog
|
|
type(self)._board_catalog_at = now
|
|
return dict(catalog)
|
|
|
|
def _request_json(
|
|
self, url: str, params: dict[str, str], referer: str
|
|
) -> dict[str, Any]:
|
|
request_url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
last_error: Exception | None = None
|
|
for attempt in range(max(1, int(self.retry_attempts))):
|
|
request = urllib.request.Request(
|
|
request_url,
|
|
headers={
|
|
"Accept": "application/json,text/plain,*/*",
|
|
"Connection": "close",
|
|
"Referer": referer,
|
|
"User-Agent": BROWSER_USER_AGENT,
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise ChartDataError("Invalid intraday chart response")
|
|
return payload
|
|
except (
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
ConnectionError,
|
|
OSError,
|
|
http.client.HTTPException,
|
|
json.JSONDecodeError,
|
|
ChartDataError,
|
|
) as exc:
|
|
last_error = exc
|
|
if attempt + 1 < self.retry_attempts:
|
|
time.sleep(0.12)
|
|
raise ChartDataError("Intraday chart request failed") from last_error
|
|
|
|
|
|
def _parse_trend(raw: Any) -> dict[str, Any] | None:
|
|
fields = str(raw or "").split(",")
|
|
if len(fields) < 8 or " " not in fields[0]:
|
|
return None
|
|
stamp = fields[0].strip()
|
|
trade_date, trade_time = stamp.split(" ", 1)
|
|
close = _number(fields[2])
|
|
if close <= 0:
|
|
return None
|
|
return {
|
|
"date": trade_date,
|
|
"time": trade_time[:5],
|
|
"open": _number(fields[1]),
|
|
"close": close,
|
|
"high": _number(fields[3]),
|
|
"low": _number(fields[4]),
|
|
"volume": _number(fields[5]),
|
|
"amount": _number(fields[6]),
|
|
"average": _number(fields[7]),
|
|
}
|
|
|
|
|
|
def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
|
|
stamp = str(row.get("time") or "").strip()
|
|
if " " not in stamp:
|
|
return None
|
|
trade_date, trade_time = stamp.split(" ", 1)
|
|
close = _number(row.get("close"))
|
|
if close <= 0:
|
|
return None
|
|
return {
|
|
"date": trade_date,
|
|
"time": trade_time[:5],
|
|
"open": _number(row.get("open")),
|
|
"close": close,
|
|
"high": _number(row.get("high")),
|
|
"low": _number(row.get("low")),
|
|
"volume": _number(row.get("volume")),
|
|
"amount": _number(row.get("amount")),
|
|
"average": _number(row.get("avgPrice")),
|
|
}
|
|
|
|
|
|
def _stock_market_code(code: str) -> str:
|
|
if code.startswith(("4", "8", "9")):
|
|
suffix = "BJ"
|
|
elif code.startswith("6"):
|
|
suffix = "SH"
|
|
else:
|
|
suffix = "SZ"
|
|
return f"{code}.{suffix}"
|
|
|
|
|
|
def _number(value: Any) -> float:
|
|
try:
|
|
return float(value or 0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _normalize_name(value: Any) -> str:
|
|
normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold()
|
|
return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized)
|