fix(HEL-494): 日K默认45根并修复问天行业0/205覆盖

悬浮窗和详情页只画最近45个交易日,中枢仍保留250日历史。盘后缺sw_daily时保留成分日线内核,外显走免费申万;成分行情改为全市场快照+分页,不再截成前60只。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-08 17:00:01 +08:00
co-authored by Cursor multica-agent
parent 3e828b346c
commit b5d65ecb41
17 changed files with 343 additions and 127 deletions
+2 -2
View File
@@ -148,7 +148,7 @@ class DatahubBridge:
cleaned = [str(item or "").strip() for item in codes if str(item or "").strip()]
if not cleaned:
return None
return self._try_quote_rows("quotes", {"codes": ",".join(cleaned[:60])}, minimum=1)
return self._try_quote_rows("quotes", {"codes": ",".join(cleaned)}, minimum=1)
def try_index_quotes(self) -> list[dict[str, Any]] | None:
flags = self.settings.flags("index_quotes")
@@ -208,7 +208,7 @@ class DatahubBridge:
if not rows:
raise DatahubError("EMPTY", f"{dataset} chart empty")
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
return rows[-max(20, min(180, int(limit))):]
return rows[-max(1, int(limit)):]
except Exception as exc:
self._log_failure(dataset, exc)
return None
+116 -31
View File
@@ -132,21 +132,63 @@ class ShenwanIndustryMixin:
actual_trade_date = str(daily.get("trade_date") or "")
outer_precise = actual_trade_date == trade_date
outer_error = "" if outer_precise else (
f"No Shenwan daily returned for {sector_code} on {trade_date}"
f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
)
outer_source = "tushare_sw_daily" if outer_precise else "unavailable"
if not outer_precise and allow_realtime_close:
try:
return self._sw_realtime_sector_snapshot(
industry,
members,
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,
previous_trade_date,
finalized=True,
)
except TushareError as exc:
outer_error = f"{outer_error}; realtime close fallback failed: {exc}"
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
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 = _number(daily.get("pct_change")) if outer_precise else None
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,
@@ -173,9 +215,9 @@ class ShenwanIndustryMixin:
"amount_billion": round(amount_billion, 2),
"count": 0,
"max_streak": 0,
"source": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily",
"source": f"{outer_source}+tushare_member_daily" if outer_precise else "tushare_member_daily",
"inner_source": "tushare_member_daily",
"outer_source": "tushare_sw_daily" if outer_precise else "unavailable",
"outer_source": outer_source,
"taxonomy": "sw_l2",
"industry": industry,
"trade_date": trade_date,
@@ -189,7 +231,7 @@ class ShenwanIndustryMixin:
"inner_error": inner_error,
"outer_error": outer_error,
"schema_version": 6,
"methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合",
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核优先使用当日成分日线,不调用 rt_sw_k",
}
def _sw_sector_members(
@@ -508,29 +550,72 @@ class ShenwanIndustryMixin:
codes: list[str],
trade_date: str,
) -> tuple[list[dict[str, Any]], str]:
if not codes:
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)):
return filtered, "datahub"
hub = getattr(self, "try_quotes", None)
if callable(hub):
rows = hub(codes) or []
if rows:
return list(rows), "datahub"
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)):
return filtered, "datahub"
aggregator = getattr(self, "realtime_aggregator", None)
loader = getattr(aggregator, "eastmoney_stock_quotes", None) if aggregator else None
if callable(loader):
try:
filtered = consider(loader(wanted, expected_date=trade_date) or [], "eastmoney_ulist")
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
return filtered, "eastmoney_ulist"
except Exception:
pass
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
quotes, source = self._free_realtime_quotes(trade_date, "")
consider(quotes, source)
except TushareError:
pass
if best_rows:
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(
+2 -2
View File
@@ -23,7 +23,7 @@ class ChartDataError(RuntimeError):
pass
DAILY_CHART_LIMIT = 250
DAILY_CHART_LIMIT = 45
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
@@ -377,7 +377,7 @@ class MarketChartClient:
pass
if not normalized:
raise ChartDataError("No iFinD daily chart data returned")
return normalized[-max(20, min(180, int(limit))):]
return normalized[-max(1, int(limit)):]
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
today = datetime.now().astimezone().date().isoformat()
+1 -1
View File
@@ -1161,7 +1161,7 @@ class MarketServiceMixin:
intraday_status = "unavailable"
intraday_notice = "分时行情暂不可用,请稍后重试。"
prices = list(detail.get("prices") or [])[-60:]
prices = list(detail.get("prices") or [])[-DAILY_CHART_LIMIT:]
stock = dict(detail.get("stock") or {"code": code})
realtime = bool(detail_meta.get("realtime"))
return {