895 lines
38 KiB
Python
895 lines
38 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from backend.data.numbers import finite_number as _number
|
||
from backend.data.providers.tushare_transport import TushareError
|
||
|
||
_SW_MEMBER_TTL = timedelta(hours=24)
|
||
_SW_MEMBER_DIR = Path(__file__).resolve().parents[3] / "data" / "cache" / "sw_members"
|
||
|
||
|
||
class ShenwanIndustryMixin:
|
||
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
|
||
"""Return the Shenwan industry active for a stock on trade_date."""
|
||
rows = []
|
||
for is_new in ("Y", "N"):
|
||
rows.extend(
|
||
self.query(
|
||
"index_member_all",
|
||
{"ts_code": ts_code, "is_new": is_new},
|
||
"l1_code,l1_name,l2_code,l2_name,l3_code,l3_name,"
|
||
"ts_code,name,in_date,out_date,is_new",
|
||
)
|
||
)
|
||
rows = _reconcile_membership_rows(rows)
|
||
matched = [row for row in rows if _membership_active_on(row, trade_date)]
|
||
if not matched:
|
||
matched = [
|
||
row for row in rows
|
||
if row.get("is_new") == "Y"
|
||
and str(row.get("in_date") or "") <= trade_date
|
||
]
|
||
if not matched:
|
||
raise TushareError(f"No Shenwan industry returned for {ts_code}")
|
||
row = max(
|
||
matched,
|
||
key=lambda item: (
|
||
str(item.get("in_date") or ""),
|
||
1 if item.get("is_new") == "Y" else 0,
|
||
str(item.get("l3_code") or item.get("l2_code") or ""),
|
||
),
|
||
)
|
||
return {
|
||
"l1_code": str(row.get("l1_code") or ""),
|
||
"l1_name": str(row.get("l1_name") or ""),
|
||
"l2_code": str(row.get("l2_code") or ""),
|
||
"l2_name": str(row.get("l2_name") or ""),
|
||
"l3_code": str(row.get("l3_code") or ""),
|
||
"l3_name": str(row.get("l3_name") or ""),
|
||
"in_date": str(row.get("in_date") or ""),
|
||
"out_date": str(row.get("out_date") or ""),
|
||
"is_new": str(row.get("is_new") or ""),
|
||
}
|
||
|
||
def sw_sector_snapshot(
|
||
self,
|
||
ts_code: str,
|
||
requested_date: str,
|
||
realtime_expected: bool = False,
|
||
allow_realtime_close: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""Build the single Shenwan L2 sector context used by heaven trend."""
|
||
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
|
||
industry = self.sw_stock_industry(ts_code, trade_date)
|
||
sector_code = str(industry.get("l2_code") or "")
|
||
if not sector_code:
|
||
raise TushareError(f"Shenwan L2 code is unavailable for {ts_code}")
|
||
members = self._sw_sector_members(sector_code, trade_date)
|
||
if not members:
|
||
raise TushareError(f"No Shenwan members returned for {sector_code}")
|
||
raw_member_count = len(members)
|
||
members, excluded_members = _filter_members_by_listing(
|
||
members,
|
||
self._stock_listing_reference(),
|
||
trade_date,
|
||
)
|
||
if not members:
|
||
raise TushareError(f"No listed Shenwan members returned for {sector_code}")
|
||
|
||
if realtime_expected:
|
||
snapshot = self._sw_realtime_sector_snapshot(
|
||
industry,
|
||
members,
|
||
trade_date,
|
||
previous_trade_date,
|
||
finalized=False,
|
||
)
|
||
snapshot.update({
|
||
"raw_member_count": raw_member_count,
|
||
"excluded_member_count": len(excluded_members),
|
||
"excluded_members": excluded_members,
|
||
})
|
||
return snapshot
|
||
|
||
member_set = {str(item.get("ts_code") or "") for item in members}
|
||
member_names = {
|
||
str(item.get("ts_code") or ""): str(item.get("name") or "")
|
||
for item in members
|
||
}
|
||
member_rows = [
|
||
row for row in self._load_daily(trade_date)
|
||
if str(row.get("ts_code") or "") in member_set
|
||
]
|
||
quoted_codes = {str(row.get("ts_code") or "") for row in member_rows}
|
||
suspended_members = self._confirmed_suspended_members(
|
||
members, quoted_codes, trade_date
|
||
)
|
||
up_count = sum(_number(row.get("pct_chg")) > 0 for row in member_rows)
|
||
down_count = sum(_number(row.get("pct_chg")) < 0 for row in member_rows)
|
||
leader = max(member_rows, key=lambda row: _number(row.get("pct_chg")), default={})
|
||
leader_code = str(leader.get("ts_code") or "")
|
||
equal_change = (
|
||
sum(_number(row.get("pct_chg")) for row in member_rows) / len(member_rows)
|
||
if member_rows else 0
|
||
)
|
||
coverage = len(member_rows) / max(len(members), 1) * 100
|
||
explained_count = len(member_rows) + len(suspended_members)
|
||
explained_coverage = explained_count / max(len(members), 1) * 100
|
||
coverage_issue = _sector_coverage_issue(
|
||
len(members),
|
||
len(member_rows),
|
||
explained_coverage,
|
||
explained_count,
|
||
)
|
||
inner_precise = not coverage_issue
|
||
inner_error = coverage_issue
|
||
amount_billion = sum(_number(row.get("amount")) for row in member_rows) / 100000
|
||
rows = self.query(
|
||
"sw_daily",
|
||
{"ts_code": sector_code, "trade_date": trade_date},
|
||
"ts_code,trade_date,name,close,pct_change,vol,amount,pe,pb,float_mv,total_mv",
|
||
)
|
||
daily = rows[0] if rows else {}
|
||
actual_trade_date = str(daily.get("trade_date") or "")
|
||
outer_precise = actual_trade_date == trade_date
|
||
outer_error = "" if outer_precise else (
|
||
f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||
)
|
||
outer_source = "tushare_sw_daily" if outer_precise else "unavailable"
|
||
if not outer_precise and allow_realtime_close:
|
||
inner_ok = bool(member_rows) and not coverage_issue
|
||
if inner_ok:
|
||
sw_row, rt_source, rt_error = self._sw_outer_realtime(
|
||
sector_code,
|
||
str(industry.get("l2_name") or ""),
|
||
trade_date,
|
||
finalized=True,
|
||
)
|
||
if sw_row:
|
||
daily = sw_row
|
||
actual_trade_date = str(
|
||
sw_row.get("quote_date") or sw_row.get("trade_date") or ""
|
||
)
|
||
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
|
||
quote_clock = (
|
||
trade_time[11:19]
|
||
if len(trade_time) >= 19
|
||
else str(sw_row.get("quote_clock") or "")
|
||
)
|
||
outer_precise = actual_trade_date == trade_date
|
||
if quote_clock and quote_clock < "15:00:00":
|
||
outer_precise = False
|
||
outer_source = rt_source or "eastmoney_sw"
|
||
outer_error = "" if outer_precise else (
|
||
rt_error or f"申万行业 {sector_code} 免费实时尚未形成收盘快照"
|
||
)
|
||
else:
|
||
outer_error = rt_error or outer_error
|
||
if not outer_precise:
|
||
# The exact SW L2 member set and every member's final daily bar
|
||
# are already available. Use their equal-weight move as an
|
||
# explicitly labelled provisional outer value until Tushare's
|
||
# official sw_daily row is published.
|
||
daily = {"change": equal_change}
|
||
actual_trade_date = trade_date
|
||
outer_precise = True
|
||
outer_source = "sw_member_equal_daily"
|
||
outer_error = ""
|
||
else:
|
||
try:
|
||
snapshot = self._sw_realtime_sector_snapshot(
|
||
industry,
|
||
members,
|
||
trade_date,
|
||
previous_trade_date,
|
||
finalized=True,
|
||
)
|
||
snapshot.update({
|
||
"raw_member_count": raw_member_count,
|
||
"excluded_member_count": len(excluded_members),
|
||
"excluded_members": excluded_members,
|
||
})
|
||
return snapshot
|
||
except TushareError:
|
||
outer_error = f"{outer_error}; 免费实时成分暂不可用"
|
||
|
||
official_change = None
|
||
if outer_precise:
|
||
official_change = _number(
|
||
daily.get("pct_change")
|
||
if daily.get("pct_change") not in (None, "")
|
||
else daily.get("change")
|
||
)
|
||
return {
|
||
"code": sector_code,
|
||
"name": industry.get("l2_name") or daily.get("name") or sector_code,
|
||
"leader": str(leader.get("name") or member_names.get(leader_code) or "--"),
|
||
"leader_code": leader_code,
|
||
"leading_pct": round(_number(leader.get("pct_chg")), 3),
|
||
"change": round(official_change, 3) if official_change is not None else None,
|
||
"member_equal_change": round(equal_change, 3),
|
||
"turnover_rate": 0,
|
||
"up_count": up_count,
|
||
"down_count": down_count,
|
||
"flat_count": len(member_rows) - up_count - down_count,
|
||
"member_count": len(members),
|
||
"raw_member_count": raw_member_count,
|
||
"excluded_member_count": len(excluded_members),
|
||
"excluded_members": excluded_members,
|
||
"quote_count": len(member_rows),
|
||
"coverage": round(coverage, 1),
|
||
"explained_count": explained_count,
|
||
"explained_coverage": round(explained_coverage, 1),
|
||
"suspended_count": len(suspended_members),
|
||
"suspended_members": suspended_members,
|
||
"strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1),
|
||
"amount_billion": round(amount_billion, 2),
|
||
"count": 0,
|
||
"max_streak": 0,
|
||
"source": f"{outer_source}+tushare_member_daily" if outer_precise else "tushare_member_daily",
|
||
"inner_source": "tushare_member_daily",
|
||
"outer_source": outer_source,
|
||
"taxonomy": "sw_l2",
|
||
"industry": industry,
|
||
"trade_date": trade_date,
|
||
"inner_trade_date": trade_date if member_rows else "",
|
||
"outer_trade_date": actual_trade_date,
|
||
"realtime": False,
|
||
"finalized": True,
|
||
"inner_precise": inner_precise,
|
||
"outer_precise": outer_precise,
|
||
"precise": inner_precise and outer_precise,
|
||
"inner_error": inner_error,
|
||
"outer_error": outer_error,
|
||
"schema_version": 6,
|
||
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核优先使用当日成分日线,不调用 rt_sw_k",
|
||
}
|
||
|
||
def _sw_sector_members(
|
||
self,
|
||
sector_code: str,
|
||
trade_date: str,
|
||
) -> list[dict[str, Any]]:
|
||
cached_rows = self._read_local_sw_members(sector_code)
|
||
if cached_rows is not None:
|
||
return _active_members(cached_rows, trade_date)
|
||
rows: list[dict[str, Any]] = []
|
||
try:
|
||
for is_new in ("Y", "N"):
|
||
rows.extend(
|
||
self.query(
|
||
"index_member_all",
|
||
{"l2_code": sector_code, "is_new": is_new},
|
||
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
||
)
|
||
)
|
||
except TushareError:
|
||
stale = self._read_local_sw_members(sector_code, allow_stale=True) or []
|
||
if stale:
|
||
return _active_members(stale, trade_date)
|
||
raise
|
||
reconciled = _reconcile_membership_rows(rows)
|
||
self._write_local_sw_members(sector_code, reconciled)
|
||
return _active_members(reconciled, trade_date)
|
||
|
||
def _read_local_sw_members(
|
||
self,
|
||
sector_code: str,
|
||
allow_stale: bool = False,
|
||
) -> list[dict[str, Any]] | None:
|
||
now = datetime.now().astimezone()
|
||
cache = getattr(self, "_sw_member_cache", None)
|
||
lock = getattr(self, "_sw_member_lock", None)
|
||
if isinstance(cache, dict) and lock is not None:
|
||
with lock:
|
||
packed = cache.get(sector_code)
|
||
if isinstance(packed, dict):
|
||
loaded_at = packed.get("loaded_at")
|
||
rows = packed.get("rows")
|
||
fresh = (
|
||
isinstance(loaded_at, datetime)
|
||
and now - loaded_at < _SW_MEMBER_TTL
|
||
)
|
||
if isinstance(rows, list) and (fresh or allow_stale):
|
||
return [dict(item) for item in rows]
|
||
path = _sw_member_path(sector_code)
|
||
if not path.exists():
|
||
return None
|
||
try:
|
||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return None
|
||
rows = list(payload.get("rows") or [])
|
||
updated = str(payload.get("updated_at") or "")
|
||
fresh = False
|
||
try:
|
||
stamped = datetime.fromisoformat(updated)
|
||
if stamped.tzinfo is None:
|
||
stamped = stamped.replace(tzinfo=now.tzinfo)
|
||
fresh = now - stamped.astimezone(now.tzinfo) < _SW_MEMBER_TTL
|
||
except ValueError:
|
||
fresh = False
|
||
if rows and (fresh or allow_stale):
|
||
self._remember_sw_members(sector_code, rows)
|
||
return rows
|
||
return None
|
||
|
||
def _write_local_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||
packed = [dict(item) for item in rows]
|
||
self._remember_sw_members(sector_code, packed)
|
||
path = _sw_member_path(sector_code)
|
||
try:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(
|
||
json.dumps(
|
||
{
|
||
"sector_code": sector_code,
|
||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||
"rows": packed,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
except OSError:
|
||
pass
|
||
|
||
def _remember_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||
cache = getattr(self, "_sw_member_cache", None)
|
||
lock = getattr(self, "_sw_member_lock", None)
|
||
if not isinstance(cache, dict) or lock is None:
|
||
return
|
||
with lock:
|
||
cache[sector_code] = {
|
||
"loaded_at": datetime.now().astimezone(),
|
||
"rows": [dict(item) for item in rows],
|
||
}
|
||
|
||
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
|
||
"""Return constituents active in a Shenwan L2 industry on the target date."""
|
||
return self._sw_sector_members(sector_code, trade_date)
|
||
|
||
def _stock_listing_reference(self) -> dict[str, dict[str, Any]]:
|
||
now = datetime.now().astimezone()
|
||
with self._stock_listing_lock:
|
||
loaded_at = self._stock_listing_cache.get("loaded_at")
|
||
cached = self._stock_listing_cache.get("rows")
|
||
if (
|
||
isinstance(loaded_at, datetime)
|
||
and isinstance(cached, dict)
|
||
and now - loaded_at < timedelta(hours=6)
|
||
):
|
||
return cached
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
try:
|
||
for status in ("L", "D", "P"):
|
||
rows.extend(self.query(
|
||
"stock_basic",
|
||
{"list_status": status},
|
||
"ts_code,name,list_status,list_date,delist_date",
|
||
))
|
||
except TushareError:
|
||
# Unknown status must remain in the denominator so a reference-data
|
||
# failure cannot silently improve coverage.
|
||
return {}
|
||
reference = {
|
||
str(row.get("ts_code") or ""): dict(row)
|
||
for row in rows
|
||
if row.get("ts_code")
|
||
}
|
||
with self._stock_listing_lock:
|
||
type(self)._stock_listing_cache = {"loaded_at": now, "rows": reference}
|
||
return reference
|
||
|
||
def _confirmed_suspended_members(
|
||
self,
|
||
members: list[dict[str, Any]],
|
||
quoted_codes: set[str],
|
||
trade_date: str,
|
||
) -> list[dict[str, str]]:
|
||
suspended: list[dict[str, str]] = []
|
||
for member in members:
|
||
code = str(member.get("ts_code") or "")
|
||
if not code or code in quoted_codes:
|
||
continue
|
||
cache_key = f"{trade_date}:{code}"
|
||
with self._suspension_lock:
|
||
cached = self._suspension_cache.get(cache_key, "missing")
|
||
if cached == "missing":
|
||
try:
|
||
rows = self.query(
|
||
"suspend_d",
|
||
{"ts_code": code},
|
||
"ts_code,suspend_date,resume_date,ann_date,suspend_reason,reason_type",
|
||
)
|
||
except TushareError:
|
||
rows = []
|
||
active = [
|
||
row for row in rows
|
||
if str(row.get("suspend_date") or "")
|
||
and str(row.get("suspend_date") or "") <= trade_date
|
||
and (
|
||
not str(row.get("resume_date") or "")
|
||
or trade_date < str(row.get("resume_date") or "")
|
||
)
|
||
]
|
||
row = max(
|
||
active,
|
||
key=lambda item: str(item.get("suspend_date") or ""),
|
||
default=None,
|
||
)
|
||
cached = ({
|
||
"ts_code": code,
|
||
"name": str(member.get("name") or code),
|
||
"suspend_date": str(row.get("suspend_date") or ""),
|
||
"resume_date": str(row.get("resume_date") or ""),
|
||
"reason": str(row.get("suspend_reason") or row.get("reason_type") or "已确认停牌"),
|
||
} if row else None)
|
||
with self._suspension_lock:
|
||
type(self)._suspension_cache[cache_key] = cached
|
||
if isinstance(cached, dict):
|
||
suspended.append(cached)
|
||
return suspended
|
||
|
||
def _sw_realtime_sector_snapshot(
|
||
self,
|
||
industry: dict[str, Any],
|
||
members: list[dict[str, Any]],
|
||
trade_date: str,
|
||
previous_trade_date: str,
|
||
finalized: bool = False,
|
||
) -> dict[str, Any]:
|
||
sector_code = str(industry.get("l2_code") or "")
|
||
sw_row, outer_source, outer_error = self._sw_outer_realtime(
|
||
sector_code,
|
||
str(industry.get("l2_name") or ""),
|
||
trade_date,
|
||
finalized=finalized,
|
||
)
|
||
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 quote_clock and quote_clock < "15:00:00":
|
||
outer_precise = False
|
||
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") 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
|
||
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 = {
|
||
str(row.get("ts_code") or "")
|
||
for row in reference.get("basic_rows") or []
|
||
if row.get("ts_code")
|
||
}
|
||
codes = [
|
||
str(row.get("ts_code") or "")
|
||
for row in members
|
||
if str(row.get("ts_code") or "")
|
||
]
|
||
if active_codes:
|
||
listed = [code for code in codes if code in active_codes]
|
||
if listed:
|
||
codes = listed
|
||
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}
|
||
suspended_members = self._confirmed_suspended_members(
|
||
members, valid_codes, trade_date
|
||
)
|
||
explained_count = len(valid) + len(suspended_members)
|
||
explained_coverage = explained_count / max(len(codes), 1) * 100
|
||
coverage_issue = _sector_coverage_issue(
|
||
len(codes), len(valid), explained_coverage, explained_count
|
||
)
|
||
inner_precise = bool(codes) and not coverage_issue
|
||
if not inner_precise and not inner_error:
|
||
inner_error = coverage_issue or "申万实时有效成分为空"
|
||
up_count = sum(item["change"] > 0 for item in valid)
|
||
down_count = sum(item["change"] < 0 for item in valid)
|
||
leader = max(valid, key=lambda item: item["change"], default={})
|
||
leader_code = str(leader.get("ts_code") or "")
|
||
member_names = {
|
||
str(item.get("ts_code") or ""): str(item.get("name") or "")
|
||
for item in members
|
||
}
|
||
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
||
if not outer_precise and inner_precise:
|
||
official_change = equal_change
|
||
outer_precise = True
|
||
outer_source = "sw_member_equal_realtime"
|
||
outer_error = ""
|
||
quote_date = trade_date
|
||
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
||
market_rows: list[dict[str, Any]] = []
|
||
try:
|
||
market_rows = self._ensure_realtime_market_cache(trade_date)
|
||
except TushareError as exc:
|
||
message = str(exc)
|
||
if "rt_k" in message or "权限" in message:
|
||
market_error = "全市场实时行情暂不可用,已避开无权限接口"
|
||
else:
|
||
market_error = message
|
||
if not valid:
|
||
inner_precise = False
|
||
inner_error = inner_error or market_error
|
||
capital_map = {
|
||
str(item.get("ts_code") or ""): item
|
||
for item in reference.get("capital_rows") or []
|
||
}
|
||
sector_turnovers = []
|
||
for item in valid:
|
||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||
float_share = _number(capital.get("float_share"))
|
||
previous = _number(
|
||
item.get("pre_close")
|
||
if item.get("pre_close") not in (None, "")
|
||
else item.get("previous_close")
|
||
)
|
||
if not float_share and previous:
|
||
float_share = _number(capital.get("circ_mv")) / previous
|
||
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"))
|
||
previous = _number(
|
||
item.get("pre_close")
|
||
if item.get("pre_close") not in (None, "")
|
||
else item.get("previous_close")
|
||
)
|
||
if not float_share and previous:
|
||
float_share = _number(capital.get("circ_mv")) / previous
|
||
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
|
||
delayed = "delayed" in str(inner_source) or any(item.get("delayed") for item in valid)
|
||
delay_seconds = max((int(item.get("delay_seconds") or 0) for item in valid), default=0)
|
||
delay_notice = ""
|
||
if delayed:
|
||
delay_notice = next(
|
||
(str(item.get("delay_notice") or "") for item in valid if item.get("delay_notice")),
|
||
"",
|
||
) or f"主备免费行情均暂不可用,显示最近一次真实快照(延迟 {delay_seconds} 秒)"
|
||
return {
|
||
"code": sector_code,
|
||
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
|
||
"leader": str(leader.get("name") or member_names.get(leader_code) or "--").strip(),
|
||
"leader_code": leader_code,
|
||
"leading_pct": round(_number(leader.get("change")), 3),
|
||
"change": round(official_change, 3) if official_change is not None else None,
|
||
"member_equal_change": round(equal_change, 3),
|
||
"turnover_rate": round(average_turnover, 4),
|
||
"market_turnover_rate": round(market_turnover, 4),
|
||
"relative_turnover": round(relative_turnover, 4),
|
||
"up_count": up_count,
|
||
"down_count": down_count,
|
||
"flat_count": len(valid) - up_count - down_count,
|
||
"member_count": len(codes),
|
||
"quote_count": len(valid),
|
||
"coverage": round(coverage, 1),
|
||
"explained_count": explained_count,
|
||
"explained_coverage": round(explained_coverage, 1),
|
||
"suspended_count": len(suspended_members),
|
||
"suspended_members": suspended_members,
|
||
"strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1),
|
||
"amount_billion": round(amount_billion, 2),
|
||
"count": sum(item["change"] >= 9.5 for item in valid),
|
||
"max_streak": 0,
|
||
"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,
|
||
"inner_trade_date": trade_date if valid else "",
|
||
"outer_trade_date": quote_date,
|
||
"trade_time": trade_time,
|
||
"realtime": True,
|
||
"finalized": finalized,
|
||
"inner_precise": inner_precise,
|
||
"outer_precise": outer_precise,
|
||
"precise": inner_precise and outer_precise,
|
||
"inner_error": inner_error,
|
||
"outer_error": outer_error,
|
||
"delayed": delayed,
|
||
"delay_seconds": delay_seconds,
|
||
"delay_notice": delay_notice,
|
||
"schema_version": 6,
|
||
"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]:
|
||
hub = getattr(self, "try_sector_quote", None)
|
||
if callable(hub):
|
||
try:
|
||
row = hub(sector_code, "" if finalized else trade_date)
|
||
except Exception as exc:
|
||
message = str(exc)
|
||
if finalized:
|
||
return {}, "", f"申万行业 {sector_code} 盘后正式数据待入库"
|
||
return {}, "", f"数据中枢申万实时暂不可用:{message[:180]}"
|
||
if row:
|
||
actual_name = str(row.get("name") or "").strip()
|
||
if sector_name and actual_name != str(sector_name).strip():
|
||
return {}, "", (
|
||
f"数据中枢返回的行业名称不匹配:期望 {sector_name},实际 {actual_name or '--'}"
|
||
)
|
||
return dict(row), str(row.get("source") or "datahub"), ""
|
||
if finalized:
|
||
return {}, "", f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||
return {}, "", f"申万行业 {sector_code} 当日外显待补充"
|
||
|
||
def _load_member_realtime_quotes(
|
||
self,
|
||
codes: list[str],
|
||
trade_date: str,
|
||
) -> tuple[list[dict[str, Any]], str]:
|
||
wanted = [str(code).strip() for code in codes if str(code or "").strip()]
|
||
if not wanted:
|
||
return [], "unavailable"
|
||
best_rows: list[dict[str, Any]] = []
|
||
best_source = "unavailable"
|
||
|
||
def consider(rows: list[dict[str, Any]] | None, source: str) -> list[dict[str, Any]]:
|
||
nonlocal best_rows, best_source
|
||
filtered = _filter_quotes_for_codes(rows, wanted)
|
||
if len(filtered) > len(best_rows):
|
||
best_rows = filtered
|
||
best_source = source
|
||
return filtered
|
||
|
||
hub_market = getattr(self, "try_market_quotes", None)
|
||
if callable(hub_market):
|
||
filtered = consider(hub_market(trade_date) or [], "datahub")
|
||
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||
delayed = any(item.get("delayed") for item in filtered)
|
||
return filtered, "datahub_delayed" if delayed else "datahub"
|
||
|
||
hub = getattr(self, "try_quotes", None)
|
||
if callable(hub):
|
||
collected: list[dict[str, Any]] = []
|
||
for index in range(0, len(wanted), _QUOTE_BATCH):
|
||
collected.extend(hub(wanted[index:index + _QUOTE_BATCH]) or [])
|
||
filtered = consider(collected, "datahub")
|
||
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||
delayed = any(item.get("delayed") for item in filtered)
|
||
return filtered, "datahub_delayed" if delayed else "datahub"
|
||
|
||
if best_rows:
|
||
delayed = any(item.get("delayed") for item in best_rows)
|
||
if delayed and not str(best_source).endswith("_delayed"):
|
||
return best_rows, f"{best_source}_delayed"
|
||
return best_rows, best_source
|
||
return [], "unavailable"
|
||
|
||
|
||
_QUOTE_BATCH = 60
|
||
|
||
|
||
def _filter_quotes_for_codes(
|
||
rows: list[dict[str, Any]] | None,
|
||
codes: list[str],
|
||
) -> list[dict[str, Any]]:
|
||
wanted = {str(code) for code in codes if code}
|
||
filtered: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for row in rows or []:
|
||
ts_code = str(row.get("ts_code") or "")
|
||
if ts_code in wanted and ts_code not in seen:
|
||
seen.add(ts_code)
|
||
filtered.append(row)
|
||
return filtered
|
||
|
||
|
||
def _filter_members_by_listing(
|
||
members: list[dict[str, Any]],
|
||
listing_reference: dict[str, dict[str, Any]],
|
||
trade_date: str,
|
||
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||
eligible: list[dict[str, Any]] = []
|
||
excluded: list[dict[str, str]] = []
|
||
for member in members:
|
||
code = str(member.get("ts_code") or "")
|
||
listing = listing_reference.get(code)
|
||
if not listing:
|
||
eligible.append(member)
|
||
continue
|
||
list_date = str(listing.get("list_date") or "")
|
||
delist_date = str(listing.get("delist_date") or "")
|
||
reason = ""
|
||
effective_date = ""
|
||
if delist_date and delist_date <= trade_date:
|
||
reason = "目标日期前已退市"
|
||
effective_date = delist_date
|
||
elif list_date and list_date > trade_date:
|
||
reason = "目标日期尚未上市"
|
||
effective_date = list_date
|
||
if not reason:
|
||
eligible.append(member)
|
||
continue
|
||
excluded.append({
|
||
"ts_code": code,
|
||
"name": str(member.get("name") or listing.get("name") or code),
|
||
"reason": reason,
|
||
"effective_date": effective_date,
|
||
})
|
||
return eligible, excluded
|
||
|
||
|
||
def _sector_coverage_issue(
|
||
member_count: int,
|
||
quote_count: int,
|
||
coverage: float | None = None,
|
||
explained_count: int | None = None,
|
||
) -> str:
|
||
members = max(0, int(member_count or 0))
|
||
quotes = max(0, min(int(quote_count or 0), members))
|
||
if members <= 0:
|
||
if coverage is not None and float(coverage) >= 90:
|
||
return ""
|
||
if coverage is not None:
|
||
return "行业成分行情覆盖率低于90%"
|
||
return "申万有效成分为空"
|
||
explained = quotes if explained_count is None else max(
|
||
quotes, min(int(explained_count or 0), members)
|
||
)
|
||
actual_coverage = (
|
||
float(coverage)
|
||
if coverage is not None
|
||
else explained / members * 100
|
||
)
|
||
missing = members - explained
|
||
if members <= 7 and missing:
|
||
return f"小型行业有效成分状态仅确认 {explained}/{members},要求全部可解释"
|
||
if members <= 20 and (actual_coverage < 90 or missing > 1):
|
||
return f"中型行业有效成分状态仅确认 {explained}/{members},要求覆盖率至少90%且最多缺1只"
|
||
if members > 20 and actual_coverage < 90:
|
||
return f"行业有效成分状态仅确认 {explained}/{members},覆盖率低于90%"
|
||
return ""
|
||
|
||
|
||
def _membership_active_on(row: dict[str, Any], trade_date: str) -> bool:
|
||
start = str(row.get("in_date") or "")
|
||
end = str(row.get("out_date") or "")
|
||
return (not start or start <= trade_date) and (not end or end > trade_date)
|
||
|
||
|
||
def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""Merge duplicate Y/N membership rows before evaluating their date interval."""
|
||
reconciled: dict[tuple[str, str, str, str, str], dict[str, Any]] = {}
|
||
for raw in rows:
|
||
row = dict(raw)
|
||
key = (
|
||
str(row.get("ts_code") or ""),
|
||
str(row.get("l1_code") or ""),
|
||
str(row.get("l2_code") or ""),
|
||
str(row.get("l3_code") or ""),
|
||
str(row.get("in_date") or ""),
|
||
)
|
||
current = reconciled.get(key)
|
||
if current is None:
|
||
reconciled[key] = row
|
||
continue
|
||
current_end = str(current.get("out_date") or "")
|
||
candidate_end = str(row.get("out_date") or "")
|
||
if candidate_end and not current_end:
|
||
current["out_date"] = candidate_end
|
||
current["is_new"] = row.get("is_new") or current.get("is_new")
|
||
for field, value in row.items():
|
||
if not current.get(field) and value not in (None, ""):
|
||
current[field] = value
|
||
return list(reconciled.values())
|
||
|
||
|
||
def _sw_member_path(sector_code: str) -> Path:
|
||
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in str(sector_code or ""))
|
||
return _SW_MEMBER_DIR / f"{safe or 'unknown'}.json"
|
||
|
||
|
||
def _active_members(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]:
|
||
deduped: dict[str, dict[str, Any]] = {}
|
||
for row in rows:
|
||
code = str(row.get("ts_code") or "")
|
||
if code and _membership_active_on(row, trade_date):
|
||
current = deduped.get(code)
|
||
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
|
||
deduped[code] = dict(row)
|
||
return list(deduped.values())
|
||
|
||
|
||
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
|
||
if not rows:
|
||
return None
|
||
target = identifier.strip().upper()
|
||
code_match = next(
|
||
(row for row in rows if str(row.get("ts_code") or "").strip().upper() == target),
|
||
None,
|
||
)
|
||
if code_match:
|
||
return code_match
|
||
|
||
def normalized(value: Any) -> str:
|
||
text = str(value or "").strip().replace(" ", "")
|
||
for suffix in ("板块", "概念", "行业"):
|
||
text = text.removesuffix(suffix)
|
||
aliases = {
|
||
"元器件": "元件",
|
||
"电子元器件": "元件",
|
||
}
|
||
return aliases.get(text, text)
|
||
|
||
target_name = normalized(identifier)
|
||
exact = [row for row in rows if normalized(row.get("name")) == target_name]
|
||
if exact:
|
||
return min(exact, key=_sector_match_priority)
|
||
fuzzy = [
|
||
row for row in rows
|
||
if target_name and (
|
||
target_name in normalized(row.get("name"))
|
||
or normalized(row.get("name")) in target_name
|
||
)
|
||
]
|
||
return min(
|
||
fuzzy,
|
||
key=lambda row: (len(normalized(row.get("name"))), *_sector_match_priority(row)),
|
||
) if fuzzy else None
|
||
|
||
|
||
def _sector_match_priority(row: dict[str, Any]) -> tuple[int, int, int]:
|
||
code = str(row.get("ts_code") or "")
|
||
exchange = str(row.get("exchange") or "").upper()
|
||
return (
|
||
0 if exchange == "A" else 1,
|
||
0 if code.startswith("881") else 1,
|
||
0 if _number(row.get("count")) > 0 else 1,
|
||
)
|