fix(HEL-494): 修复个股缺失指标、问天遮罩、四爻外显并回补250日K
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
c8a9376adb
commit
3e828b346c
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from backend.data.numbers import finite_number as _number
|
||||
from backend.data.providers.tushare_helpers import (
|
||||
_display_time,
|
||||
_optional_number,
|
||||
_prices_equal,
|
||||
calendar_is_open,
|
||||
)
|
||||
@@ -137,7 +138,67 @@ class DailyMarketMixin:
|
||||
)
|
||||
item["capital_trade_date"] = str(capital.get("trade_date") or "")
|
||||
result.append(item)
|
||||
return result
|
||||
return self._overlay_board_fields(result, trade_date)
|
||||
|
||||
def _overlay_board_fields(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
trade_date: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not rows:
|
||||
return rows
|
||||
official = self._official_board_map(trade_date)
|
||||
free = self._free_board_map(trade_date) if not official else {}
|
||||
merged: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
code = str(row.get("ts_code") or "")
|
||||
extra = official.get(code) or free.get(code) or {}
|
||||
if not extra:
|
||||
merged.append(row)
|
||||
continue
|
||||
item = dict(row)
|
||||
for key in (
|
||||
"first_time",
|
||||
"last_time",
|
||||
"fd_amount",
|
||||
"open_times",
|
||||
"limit_times",
|
||||
"turnover_ratio",
|
||||
):
|
||||
incoming = extra.get(key)
|
||||
current = item.get(key)
|
||||
if incoming in (None, "", "--"):
|
||||
continue
|
||||
if current in (None, "", "--", 0, 0.0):
|
||||
item[key] = incoming
|
||||
merged.append(item)
|
||||
return merged
|
||||
|
||||
def _official_board_map(self, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||
mapped: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
for row in self._load_limit_lists(trade_date):
|
||||
code = str(row.get("ts_code") or "")
|
||||
if code:
|
||||
mapped[code] = row
|
||||
except Exception:
|
||||
return {}
|
||||
return mapped
|
||||
|
||||
def _free_board_map(self, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||
aggregator = getattr(self, "realtime_aggregator", None)
|
||||
loader = getattr(aggregator, "eastmoney_limit_pool", None) if aggregator else None
|
||||
if not callable(loader):
|
||||
return {}
|
||||
try:
|
||||
rows = loader(trade_date)
|
||||
except Exception:
|
||||
return {}
|
||||
return {
|
||||
str(row.get("ts_code") or ""): row
|
||||
for row in rows
|
||||
if row.get("ts_code")
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
|
||||
@@ -162,7 +223,11 @@ class DailyMarketMixin:
|
||||
"turnover_source": row.get("turnover_source") or "provider",
|
||||
"capital_trade_date": row.get("capital_trade_date") or "",
|
||||
"amount_billion": round(amount_billion, 2),
|
||||
"seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0),
|
||||
"seal_amount_million": (
|
||||
round(fd / 10000, 0)
|
||||
if (fd := _optional_number(row.get("fd_amount"))) is not None
|
||||
else None
|
||||
),
|
||||
"float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
@@ -365,10 +365,9 @@ class DashboardMixin:
|
||||
ts_code: str,
|
||||
reference_date: str = "",
|
||||
) -> dict[str, Any]:
|
||||
rows = self.query("rt_k", {"ts_code": ts_code})
|
||||
if not rows:
|
||||
row = self._realtime_quote_row(ts_code, reference_date)
|
||||
if not row:
|
||||
raise TushareError(f"No realtime quote returned for {ts_code}")
|
||||
row = rows[0]
|
||||
close = _number(row.get("close"))
|
||||
previous_close = _number(row.get("pre_close"))
|
||||
if close <= 0 or previous_close <= 0:
|
||||
@@ -452,10 +451,39 @@ class DashboardMixin:
|
||||
"float_share_10k": float_share,
|
||||
"capital_trade_date": str(capital.get("trade_date") or ""),
|
||||
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
|
||||
"data_source": "tushare",
|
||||
"data_source": str(row.get("source") or "tushare"),
|
||||
"realtime": True,
|
||||
}
|
||||
|
||||
def _realtime_quote_row(self, ts_code: str, reference_date: str = "") -> dict[str, Any]:
|
||||
hub = getattr(self, "try_quotes", None)
|
||||
if callable(hub):
|
||||
rows = hub([ts_code]) or []
|
||||
if rows:
|
||||
return dict(rows[0])
|
||||
try:
|
||||
rows = self.query("rt_k", {"ts_code": ts_code})
|
||||
if rows:
|
||||
return dict(rows[0])
|
||||
except TushareError:
|
||||
pass
|
||||
aggregator = getattr(self, "realtime_aggregator", None)
|
||||
if aggregator is None:
|
||||
return {}
|
||||
for loader in (
|
||||
getattr(aggregator, "eastmoney_stock_quote", None),
|
||||
getattr(aggregator, "tencent_stock_quote", None),
|
||||
):
|
||||
if not callable(loader):
|
||||
continue
|
||||
try:
|
||||
quote = loader(ts_code, expected_date=reference_date)
|
||||
except Exception:
|
||||
continue
|
||||
if quote:
|
||||
return dict(quote)
|
||||
return {}
|
||||
|
||||
def _stock_activity_metrics(
|
||||
self,
|
||||
ts_code: str,
|
||||
@@ -573,7 +601,7 @@ class DashboardMixin:
|
||||
for row in reference.get("basic_rows") or []
|
||||
if row.get("ts_code")
|
||||
]
|
||||
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
|
||||
quotes, quote_source = self._load_realtime_quotes(",".join(codes), trade_date)
|
||||
rows = [
|
||||
row for row in quotes
|
||||
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
||||
|
||||
@@ -23,6 +23,55 @@ def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _optional_number(value: Any) -> float | None:
|
||||
if value in (None, "", "-"):
|
||||
return None
|
||||
number = _number(value, default=float("nan"))
|
||||
if number != number:
|
||||
return None
|
||||
return number
|
||||
|
||||
|
||||
def _moneyflow_payload(flow: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not flow:
|
||||
return {
|
||||
"available": False,
|
||||
"net_million": None,
|
||||
"large_million": None,
|
||||
"medium_million": None,
|
||||
"small_million": None,
|
||||
}
|
||||
net = _optional_number(flow.get("net_mf_amount"))
|
||||
buy_lg = _optional_number(flow.get("buy_lg_amount"))
|
||||
sell_lg = _optional_number(flow.get("sell_lg_amount"))
|
||||
buy_elg = _optional_number(flow.get("buy_elg_amount"))
|
||||
sell_elg = _optional_number(flow.get("sell_elg_amount"))
|
||||
buy_md = _optional_number(flow.get("buy_md_amount"))
|
||||
sell_md = _optional_number(flow.get("sell_md_amount"))
|
||||
buy_sm = _optional_number(flow.get("buy_sm_amount"))
|
||||
sell_sm = _optional_number(flow.get("sell_sm_amount"))
|
||||
large = None
|
||||
if None not in (buy_lg, sell_lg, buy_elg, sell_elg):
|
||||
large = (buy_lg + buy_elg - sell_lg - sell_elg)
|
||||
elif _optional_number(flow.get("large_amount")) is not None:
|
||||
large = _optional_number(flow.get("large_amount"))
|
||||
medium = None if None in (buy_md, sell_md) else (buy_md - sell_md)
|
||||
if medium is None:
|
||||
medium = _optional_number(flow.get("medium_amount"))
|
||||
small = None if None in (buy_sm, sell_sm) else (buy_sm - sell_sm)
|
||||
if small is None:
|
||||
small = _optional_number(flow.get("small_amount"))
|
||||
if net is None and large is None and medium is None and small is None:
|
||||
return _moneyflow_payload(None)
|
||||
return {
|
||||
"available": True,
|
||||
"net_million": None if net is None else round(net / 100, 2),
|
||||
"large_million": None if large is None else round(large / 100, 2),
|
||||
"medium_million": None if medium is None else round(medium / 100, 2),
|
||||
"small_million": None if small is None else round(small / 100, 2),
|
||||
}
|
||||
|
||||
|
||||
def _prices_equal(left: Any, right: Any) -> bool:
|
||||
if left is None or right is None:
|
||||
return False
|
||||
|
||||
@@ -311,37 +311,37 @@ class ShenwanIndustryMixin:
|
||||
finalized: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
sector_code = str(industry.get("l2_code") or "")
|
||||
sw_rows = self.query(
|
||||
"rt_sw_k",
|
||||
{"ts_code": sector_code},
|
||||
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
|
||||
sw_row, outer_source, outer_error = self._sw_outer_realtime(
|
||||
sector_code,
|
||||
str(industry.get("l2_name") or ""),
|
||||
trade_date,
|
||||
finalized=finalized,
|
||||
)
|
||||
sw_row = sw_rows[0] if sw_rows else {}
|
||||
trade_time = str(sw_row.get("trade_time") or "")
|
||||
quote_date = trade_time[:10].replace("-", "")
|
||||
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else ""
|
||||
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
|
||||
quote_date = str(sw_row.get("quote_date") or trade_time[:10].replace("-", ""))
|
||||
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else str(sw_row.get("quote_clock") or "")
|
||||
outer_precise = bool(sw_row and quote_date == trade_date)
|
||||
if finalized and (not quote_clock or quote_clock < "15:00:00"):
|
||||
if finalized and quote_clock and quote_clock < "15:00:00":
|
||||
outer_precise = False
|
||||
official_change = _number(sw_row.get("pct_change"))
|
||||
official_change = _number(sw_row.get("pct_change") if sw_row.get("pct_change") not in (None, "") else sw_row.get("change"))
|
||||
if not official_change:
|
||||
close = _number(sw_row.get("close"))
|
||||
pre_close = _number(sw_row.get("pre_close"))
|
||||
close = _number(sw_row.get("close") if sw_row.get("close") not in (None, "") else sw_row.get("price"))
|
||||
pre_close = _number(sw_row.get("pre_close") if sw_row.get("pre_close") not in (None, "") else sw_row.get("previous_close"))
|
||||
official_change = (close / pre_close - 1) * 100 if close and pre_close else 0
|
||||
if not outer_precise:
|
||||
official_change = None
|
||||
outer_error = ""
|
||||
if not sw_row:
|
||||
outer_error = f"No Shenwan realtime index returned for {sector_code}"
|
||||
elif quote_date != trade_date:
|
||||
outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}"
|
||||
elif finalized and (not quote_clock or quote_clock < "15:00:00"):
|
||||
outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})"
|
||||
if not sw_row and not outer_error:
|
||||
outer_error = f"申万行业 {sector_code} 当日外显待盘后正式数据或免费实时源"
|
||||
elif quote_date and quote_date != trade_date:
|
||||
outer_error = f"申万实时行业日期是 {quote_date},期望 {trade_date}"
|
||||
elif finalized and quote_clock and quote_clock < "15:00:00":
|
||||
outer_error = f"申万行业尚未形成收盘快照({trade_time})"
|
||||
|
||||
valid: list[dict[str, Any]] = []
|
||||
codes: list[str] = []
|
||||
reference: dict[str, Any] = {}
|
||||
inner_error = ""
|
||||
inner_source = "unavailable"
|
||||
try:
|
||||
reference = self._load_realtime_reference(trade_date, previous_trade_date)
|
||||
active_codes = {
|
||||
@@ -354,18 +354,21 @@ class ShenwanIndustryMixin:
|
||||
for row in members
|
||||
if str(row.get("ts_code") or "") in active_codes
|
||||
]
|
||||
if codes:
|
||||
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
|
||||
for row in quotes:
|
||||
close = _number(row.get("close"))
|
||||
previous_close = _number(row.get("pre_close"))
|
||||
if close <= 0 or previous_close <= 0:
|
||||
continue
|
||||
valid.append({**row, "change": (close / previous_close - 1) * 100})
|
||||
else:
|
||||
quotes, inner_source = self._load_member_realtime_quotes(codes, trade_date)
|
||||
for row in quotes:
|
||||
close = _number(row.get("close"))
|
||||
previous_close = _number(row.get("pre_close"))
|
||||
if close <= 0 or previous_close <= 0:
|
||||
continue
|
||||
valid.append({**row, "change": (close / previous_close - 1) * 100})
|
||||
if not codes:
|
||||
inner_error = f"No active Shenwan members returned for {sector_code}"
|
||||
elif not quotes:
|
||||
inner_error = f"申万成分实时行情暂不可用:{sector_code}"
|
||||
except TushareError as exc:
|
||||
inner_error = str(exc)
|
||||
if "rt_k" in inner_error or "权限" in inner_error:
|
||||
inner_error = "申万成分实时行情暂不可用,已避开无权限接口"
|
||||
|
||||
coverage = len(valid) / max(len(codes), 1) * 100
|
||||
valid_codes = {str(item.get("ts_code") or "") for item in valid}
|
||||
@@ -390,16 +393,16 @@ class ShenwanIndustryMixin:
|
||||
}
|
||||
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
||||
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
||||
market_rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
self._ensure_realtime_market_cache(trade_date)
|
||||
with self._realtime_reference_lock:
|
||||
market_rows = list(
|
||||
(self._latest_realtime_market.get(trade_date) or {}).get("rows") or []
|
||||
)
|
||||
market_rows = self._ensure_realtime_market_cache(trade_date)
|
||||
except TushareError as exc:
|
||||
market_rows = []
|
||||
inner_precise = False
|
||||
inner_error = inner_error or str(exc)
|
||||
message = str(exc)
|
||||
if "rt_k" in message or "权限" in message:
|
||||
inner_error = inner_error or "全市场实时行情暂不可用,已避开无权限接口"
|
||||
else:
|
||||
inner_error = inner_error or message
|
||||
capital_map = {
|
||||
str(item.get("ts_code") or ""): item
|
||||
for item in reference.get("capital_rows") or []
|
||||
@@ -408,14 +411,17 @@ class ShenwanIndustryMixin:
|
||||
for item in valid:
|
||||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||
float_share = _number(capital.get("float_share"))
|
||||
if float_share:
|
||||
sector_turnovers.append(_number(item.get("vol")) / float_share / 100)
|
||||
volume = _number(item.get("vol"))
|
||||
if float_share and volume:
|
||||
# 免费源成交量为股;daily_basic.float_share 为万股。
|
||||
sector_turnovers.append(volume / float_share / 100)
|
||||
market_turnovers = []
|
||||
for item in market_rows:
|
||||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||
float_share = _number(capital.get("float_share"))
|
||||
if float_share:
|
||||
market_turnovers.append(_number(item.get("vol")) / float_share / 100)
|
||||
volume = _number(item.get("vol"))
|
||||
if float_share and volume:
|
||||
market_turnovers.append(volume / float_share / 100)
|
||||
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
|
||||
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
|
||||
relative_turnover = average_turnover / market_turnover if market_turnover else 0
|
||||
@@ -447,9 +453,9 @@ class ShenwanIndustryMixin:
|
||||
"amount_billion": round(amount_billion, 2),
|
||||
"count": sum(item["change"] >= 9.5 for item in valid),
|
||||
"max_streak": 0,
|
||||
"source": "tushare_rt_sw_k+sw_members_rt_k",
|
||||
"inner_source": "tushare_sw_members+rt_k",
|
||||
"outer_source": "tushare_rt_sw_k",
|
||||
"source": f"{outer_source or 'unavailable'}+{inner_source}",
|
||||
"inner_source": inner_source,
|
||||
"outer_source": outer_source or "unavailable",
|
||||
"taxonomy": "sw_l2",
|
||||
"industry": industry,
|
||||
"trade_date": trade_date,
|
||||
@@ -464,9 +470,68 @@ class ShenwanIndustryMixin:
|
||||
"inner_error": inner_error,
|
||||
"outer_error": outer_error,
|
||||
"schema_version": 6,
|
||||
"methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合",
|
||||
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核使用数据中枢/免费实时成分,不调用 rt_sw_k",
|
||||
}
|
||||
|
||||
def _sw_outer_realtime(
|
||||
self,
|
||||
sector_code: str,
|
||||
sector_name: str,
|
||||
trade_date: str,
|
||||
finalized: bool = False,
|
||||
) -> tuple[dict[str, Any], str, str]:
|
||||
aggregator = getattr(self, "realtime_aggregator", None)
|
||||
loader = getattr(aggregator, "eastmoney_shenwan_quote", None) if aggregator else None
|
||||
if callable(loader):
|
||||
try:
|
||||
row = loader(sector_code, expected_date="" if finalized else trade_date)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if finalized:
|
||||
return {}, "", f"申万行业 {sector_code} 盘后正式数据待入库"
|
||||
return {}, "", f"免费申万实时暂不可用:{message[:180]}"
|
||||
if row:
|
||||
return dict(row), str(row.get("source") or "eastmoney_sw"), ""
|
||||
if finalized:
|
||||
return {}, "", f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||||
if aggregator and sector_name:
|
||||
try:
|
||||
row = aggregator.eastmoney_sector(sector_name)
|
||||
except Exception as exc:
|
||||
return {}, "", f"免费行业实时暂不可用:{str(exc)[:180]}"
|
||||
if row:
|
||||
return dict(row), str(row.get("source") or "eastmoney_sector"), ""
|
||||
return {}, "", f"申万行业 {sector_code} 当日外显待补充"
|
||||
|
||||
def _load_member_realtime_quotes(
|
||||
self,
|
||||
codes: list[str],
|
||||
trade_date: str,
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
if not codes:
|
||||
return [], "unavailable"
|
||||
hub = getattr(self, "try_quotes", None)
|
||||
if callable(hub):
|
||||
rows = hub(codes) or []
|
||||
if rows:
|
||||
return list(rows), "datahub"
|
||||
try:
|
||||
quotes, source = self._load_realtime_quotes(",".join(codes), trade_date)
|
||||
return quotes, source
|
||||
except TushareError as exc:
|
||||
message = str(exc)
|
||||
if "rt_k" in message or "权限" in message:
|
||||
aggregator = getattr(self, "realtime_aggregator", None)
|
||||
loader = getattr(aggregator, "eastmoney_stock_quotes", None) if aggregator else None
|
||||
if callable(loader):
|
||||
try:
|
||||
rows = loader(codes, expected_date=trade_date)
|
||||
if rows:
|
||||
return list(rows), "eastmoney_ulist"
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _filter_members_by_listing(
|
||||
members: list[dict[str, Any]],
|
||||
|
||||
@@ -5,13 +5,14 @@ from typing import Any
|
||||
|
||||
from backend.bootstrap.config import display_compact_date as _display_date
|
||||
from backend.data.numbers import finite_number as _number
|
||||
from backend.data.providers.tushare_helpers import _moneyflow_payload
|
||||
|
||||
|
||||
class StockMixin:
|
||||
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
||||
trade_date, _ = self.resolve_trade_context(requested_date)
|
||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
|
||||
start_date = (end - timedelta(days=400)).strftime("%Y%m%d")
|
||||
daily = self.query(
|
||||
"daily",
|
||||
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
||||
@@ -41,7 +42,7 @@ class StockMixin:
|
||||
factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors}
|
||||
latest_factor = max(factor_map.values(), default=1) or 1
|
||||
prices = []
|
||||
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-90:]:
|
||||
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-250:]:
|
||||
factor = factor_map.get(row.get("trade_date"), latest_factor)
|
||||
ratio = factor / latest_factor
|
||||
prices.append(
|
||||
@@ -56,7 +57,7 @@ class StockMixin:
|
||||
"amount_billion": round(_number(row.get("amount")) / 100000, 2),
|
||||
}
|
||||
)
|
||||
flow = moneyflow[0] if moneyflow else {}
|
||||
flow = moneyflow[0] if moneyflow else None
|
||||
basic = basics[0] if basics else {}
|
||||
daily_basic = daily_basics[0] if daily_basics else {}
|
||||
latest = prices[-1] if prices else {}
|
||||
@@ -87,22 +88,7 @@ class StockMixin:
|
||||
"amount_billion": latest.get("amount_billion", 0),
|
||||
},
|
||||
"prices": prices,
|
||||
"moneyflow": {
|
||||
"net_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
|
||||
"large_million": round(
|
||||
(_number(flow.get("buy_lg_amount")) + _number(flow.get("buy_elg_amount"))
|
||||
- _number(flow.get("sell_lg_amount")) - _number(flow.get("sell_elg_amount"))) / 100,
|
||||
2,
|
||||
),
|
||||
"medium_million": round(
|
||||
(_number(flow.get("buy_md_amount")) - _number(flow.get("sell_md_amount"))) / 100,
|
||||
2,
|
||||
),
|
||||
"small_million": round(
|
||||
(_number(flow.get("buy_sm_amount")) - _number(flow.get("sell_sm_amount"))) / 100,
|
||||
2,
|
||||
),
|
||||
},
|
||||
"moneyflow": _moneyflow_payload(flow),
|
||||
}
|
||||
|
||||
def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
||||
|
||||
@@ -20,6 +20,8 @@ class TushareTransportMixin:
|
||||
params: dict[str, Any] | None = None,
|
||||
fields: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
if api_name == "rt_sw_k":
|
||||
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"api_name": api_name,
|
||||
|
||||
+188
-5
@@ -20,8 +20,10 @@ class RealtimeAggregateError(RuntimeError):
|
||||
|
||||
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
||||
EASTMONEY_STOCK_URL = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
EASTMONEY_STOCK_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f168"
|
||||
EASTMONEY_STOCK_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f168,f62,f66,f72,f78,f84"
|
||||
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
||||
EASTMONEY_ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
|
||||
EASTMONEY_ZB_POOL_URL = "https://push2ex.eastmoney.com/getTopicZBPool"
|
||||
EASTMONEY_A_SHARE_BOARDS = (
|
||||
"m:0+t:6",
|
||||
"m:0+t:80",
|
||||
@@ -321,6 +323,127 @@ class WebRealtimeAggregator:
|
||||
raise RealtimeAggregateError(f"Eastmoney stock quote unavailable for {ts_code}")
|
||||
return _require_quote_date(quote, expected_date)
|
||||
|
||||
def eastmoney_stock_quotes(
|
||||
self,
|
||||
codes: list[str],
|
||||
expected_date: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
secids = []
|
||||
for code in codes:
|
||||
try:
|
||||
_symbol, secid, _ts = _a_share_identity(code)
|
||||
except RealtimeAggregateError:
|
||||
continue
|
||||
secids.append(secid)
|
||||
quotes: list[dict[str, Any]] = []
|
||||
for index in range(0, len(secids), 60):
|
||||
payload = self._get_json(
|
||||
EASTMONEY_INDEX_URL,
|
||||
{
|
||||
"secids": ",".join(secids[index:index + 60]),
|
||||
"fltt": "2",
|
||||
"invt": "2",
|
||||
"fields": EASTMONEY_QUOTE_FIELDS,
|
||||
},
|
||||
referer="https://quote.eastmoney.com/",
|
||||
)
|
||||
for row in _diff_rows(payload.get("data") or {}):
|
||||
quote = _normalize_eastmoney_quote(row)
|
||||
if quote:
|
||||
quotes.append(quote)
|
||||
return self._filter_quotes_by_date(quotes, expected_date)
|
||||
|
||||
def eastmoney_shenwan_quote(
|
||||
self,
|
||||
ts_code: str,
|
||||
expected_date: str = "",
|
||||
) -> dict[str, Any]:
|
||||
code = str(ts_code or "").split(".")[0]
|
||||
if not code:
|
||||
raise RealtimeAggregateError("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/",
|
||||
)
|
||||
row = next((item for item in _diff_rows(payload.get("data") or {}) if item), None)
|
||||
if not row:
|
||||
raise RealtimeAggregateError(f"Eastmoney Shenwan quote missing for {code}")
|
||||
epoch = int(_number(row.get("f124")))
|
||||
quote_time = (
|
||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||
if epoch
|
||||
else ""
|
||||
)
|
||||
close = _number(row.get("f2"))
|
||||
previous = _number(row.get("f18"))
|
||||
if close <= 0 or previous <= 0:
|
||||
raise RealtimeAggregateError(f"Eastmoney Shenwan quote empty for {code}")
|
||||
result = {
|
||||
"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": _number(row.get("f17")),
|
||||
"high": _number(row.get("f15")),
|
||||
"low": _number(row.get("f16")),
|
||||
"change": _number(row.get("f3")),
|
||||
"pct_change": _number(row.get("f3")),
|
||||
"amount": _number(row.get("f6")),
|
||||
"leader": row.get("f128") or "--",
|
||||
"leader_code": row.get("f140") or "",
|
||||
"leading_pct": _number(row.get("f136")),
|
||||
"up_count": int(_number(row.get("f104"))),
|
||||
"down_count": int(_number(row.get("f105"))),
|
||||
"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",
|
||||
}
|
||||
return _require_quote_date(result, expected_date) if expected_date else result
|
||||
|
||||
def eastmoney_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 (
|
||||
(EASTMONEY_ZT_POOL_URL, "U"),
|
||||
(EASTMONEY_ZB_POOL_URL, "Z"),
|
||||
):
|
||||
try:
|
||||
payload = self._get_json(
|
||||
url,
|
||||
{
|
||||
"ut": "7eea3edcaed734bea9cbfc24409ed989",
|
||||
"dpt": "wz.ztzt",
|
||||
"PageIndex": "0",
|
||||
"PageSize": "200",
|
||||
"sort": "fbt:asc",
|
||||
"date": day,
|
||||
},
|
||||
referer="https://quote.eastmoney.com/ztb/detail",
|
||||
)
|
||||
except RealtimeAggregateError:
|
||||
continue
|
||||
pool = (payload.get("data") or {}).get("pool") or []
|
||||
if isinstance(pool, dict):
|
||||
pool = list(pool.values())
|
||||
for item in pool:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
parsed = _normalize_eastmoney_limit_row(item, limit_type)
|
||||
if parsed:
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
|
||||
def tencent_indices(self) -> list[dict[str, Any]]:
|
||||
raw, cache_age = self._get_text(
|
||||
TENCENT_INDEX_URL,
|
||||
@@ -372,11 +495,17 @@ class WebRealtimeAggregator:
|
||||
if not matched:
|
||||
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
||||
epoch = int(_number(matched.get("f124")))
|
||||
quote_time = (
|
||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||
if epoch else ""
|
||||
)
|
||||
return {
|
||||
"code": matched.get("f12") or "",
|
||||
"name": matched.get("f14") or query,
|
||||
"price": _number(matched.get("f2")),
|
||||
"close": _number(matched.get("f2")),
|
||||
"change": _number(matched.get("f3")),
|
||||
"pct_change": _number(matched.get("f3")),
|
||||
"change_amount": _number(matched.get("f4")),
|
||||
"turnover_rate": _number(matched.get("f8")),
|
||||
"up_count": int(_number(matched.get("f104"))),
|
||||
@@ -385,10 +514,9 @@ class WebRealtimeAggregator:
|
||||
"leader_code": matched.get("f140") or "",
|
||||
"leading_pct": _number(matched.get("f136")),
|
||||
"quote_time_epoch": epoch,
|
||||
"quote_time": (
|
||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||
if epoch else ""
|
||||
),
|
||||
"quote_time": quote_time,
|
||||
"trade_time": quote_time,
|
||||
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
|
||||
"source": "eastmoney_push2",
|
||||
"match_query": query,
|
||||
}
|
||||
@@ -636,6 +764,10 @@ def _normalize_eastmoney_stock_quote(
|
||||
"quote_date": quote_date,
|
||||
"quote_time_epoch": epoch,
|
||||
"turnover_rate": _number(row.get("f168")),
|
||||
"net_mf_amount": _eastmoney_flow_wan(row.get("f62")),
|
||||
"large_amount": _eastmoney_flow_wan(row.get("f62")),
|
||||
"medium_amount": _eastmoney_flow_wan(row.get("f78")),
|
||||
"small_amount": _eastmoney_flow_wan(row.get("f84")),
|
||||
"source": "eastmoney_stock",
|
||||
}
|
||||
|
||||
@@ -721,6 +853,57 @@ def _normalize_eastmoney_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _eastmoney_flow_wan(value: Any) -> float | None:
|
||||
if value in (None, "", "-"):
|
||||
return None
|
||||
amount = _number(value, default=float("nan"))
|
||||
if amount != amount:
|
||||
return None
|
||||
return amount / 10000
|
||||
|
||||
|
||||
def _board_clock(value: Any) -> str:
|
||||
digits = "".join(character for character in str(value or "") if character.isdigit())
|
||||
if len(digits) >= 6:
|
||||
return f"{digits[:2]}:{digits[2:4]}:{digits[4:6]}"
|
||||
if len(digits) == 5:
|
||||
digits = digits.zfill(6)
|
||||
return f"{digits[:2]}:{digits[2:4]}:{digits[4:6]}"
|
||||
if len(digits) == 4:
|
||||
return f"{digits[:2]}:{digits[2:]}:00"
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_eastmoney_limit_row(row: dict[str, Any], limit_type: str) -> dict[str, Any] | None:
|
||||
symbol = str(row.get("c") or row.get("code") or "").strip()
|
||||
if not symbol.isdigit() or len(symbol) != 6:
|
||||
return None
|
||||
market = int(_number(row.get("m") if row.get("m") not in (None, "") else row.get("market")))
|
||||
if market == 1 or symbol.startswith(("5", "6", "9")):
|
||||
ts_code = f"{symbol}.SH"
|
||||
elif symbol.startswith(("4", "8")):
|
||||
ts_code = f"{symbol}.BJ"
|
||||
else:
|
||||
ts_code = f"{symbol}.SZ"
|
||||
first_time = _board_clock(row.get("fbt") if row.get("fbt") not in (None, "") else row.get("first_time"))
|
||||
last_time = _board_clock(row.get("lbt") if row.get("lbt") not in (None, "") else row.get("last_time"))
|
||||
fund = row.get("fund")
|
||||
if fund in (None, ""):
|
||||
fund = row.get("fd_amount")
|
||||
return {
|
||||
"ts_code": ts_code,
|
||||
"name": row.get("n") or row.get("name") or symbol,
|
||||
"limit_type": limit_type,
|
||||
"first_time": first_time or None,
|
||||
"last_time": last_time or None,
|
||||
"open_times": int(_number(row.get("zbc") if row.get("zbc") not in (None, "") else row.get("open_times"))),
|
||||
"limit_times": max(1, int(_number(row.get("lbc") if row.get("lbc") not in (None, "") else 1))),
|
||||
"turnover_ratio": _number(row.get("hs") if row.get("hs") not in (None, "") else row.get("turnover_ratio")),
|
||||
"fd_amount": _number(fund) if fund not in (None, "", "-") else None,
|
||||
"source": "eastmoney_zt_pool",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_sector(value: Any) -> str:
|
||||
text = str(value or "").strip().replace(" ", "")
|
||||
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
||||
|
||||
Reference in New Issue
Block a user