feat: integrate iFinD data and refine intelligent workspaces
This commit is contained in:
@@ -8,9 +8,12 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ifind_client import IfindError, IfindHttpClient
|
||||
|
||||
|
||||
class ChartDataError(RuntimeError):
|
||||
pass
|
||||
@@ -30,6 +33,201 @@ INDEX_SECIDS = {
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
today = datetime.now().astimezone().strftime("%Y%m%d")
|
||||
if compact_end == today:
|
||||
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"))
|
||||
if latest > 0:
|
||||
realtime = {
|
||||
"trade_date": end.strftime("%Y-%m-%d"),
|
||||
"open": _number(quote.get("open")) or latest,
|
||||
"high": _number(quote.get("high")) or latest,
|
||||
"low": _number(quote.get("low")) or latest,
|
||||
"close": latest,
|
||||
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
|
||||
"volume": _number(quote.get("volume")),
|
||||
"amount_billion": _number(quote.get("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.
|
||||
@@ -225,6 +423,37 @@ def _parse_trend(raw: Any) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user