from __future__ import annotations import json import math import re import urllib.error import urllib.request from collections import Counter from dataclasses import dataclass from datetime import datetime, time as dt_time, timedelta from threading import Lock from typing import Any, ClassVar from sentiment_engine import apply_sentiment_to_dashboard TUSHARE_URL = "http://api.tushare.pro" class TushareError(RuntimeError): pass @dataclass class TushareClient: token: str timeout: int = 30 _realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {} _realtime_reference_lock: ClassVar[Lock] = Lock() _capital_cache: ClassVar[dict[str, dict[str, Any]]] = {} _latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {} _stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {} _stock_listing_cache: ClassVar[dict[str, Any]] = {} _stock_listing_lock: ClassVar[Lock] = Lock() _suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {} _suspension_lock: ClassVar[Lock] = Lock() def query( self, api_name: str, params: dict[str, Any] | None = None, fields: str = "", ) -> list[dict[str, Any]]: payload = json.dumps( { "api_name": api_name, "token": self.token, "params": params or {}, "fields": fields, } ).encode("utf-8") request = urllib.request.Request( TUSHARE_URL, data=payload, headers={"Content-Type": "application/json", "User-Agent": "XiaobaiReviewWeb/0.2"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: result = json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: raise TushareError(f"Tushare request failed: {exc}") from exc if result.get("code") != 0: raise TushareError(result.get("msg") or "Tushare returned an unknown error") data = result.get("data") or {} columns = data.get("fields") or [] return [dict(zip(columns, item)) for item in data.get("items") or []] def dashboard(self, requested_date: str) -> dict[str, Any]: trade_date, previous_trade_date = self.resolve_trade_context(requested_date) if self.should_use_realtime(requested_date, trade_date): return self._realtime_dashboard( requested_date, trade_date, previous_trade_date, ) daily = self._load_daily(trade_date) if ( not daily and requested_date == datetime.now().astimezone().strftime("%Y%m%d") and trade_date == requested_date and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15) ): return self._realtime_dashboard( requested_date, trade_date, previous_trade_date, ) if not daily: raise TushareError(f"No daily data returned for {trade_date}") notices: list[str] = [] try: limit_rows = self._load_limit_lists(trade_date) previous_limit_rows = self._load_limit_type(previous_trade_date, "U") if not limit_rows: notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。") limit_rows = self._derive_limits(trade_date, daily) except TushareError as exc: notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}") limit_rows = self._derive_limits(trade_date, daily) previous_daily = self._load_daily(previous_trade_date) previous_limit_rows = [ row for row in self._derive_limits(previous_trade_date, previous_daily) if row.get("limit_type") == "U" ] up_rows = [row for row in limit_rows if row.get("limit_type") == "U"] down_rows = [row for row in limit_rows if row.get("limit_type") == "D"] broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"] limits = [self._normalize_limit(row, "涨停") for row in up_rows] broken = [self._normalize_limit(row, "炸板") for row in broken_rows] down_limits = [self._normalize_limit(row, "跌停") for row in down_rows] previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows] yesterday_limits = _build_yesterday_performance( previous_limits, daily, limits, broken, down_limits, ) sectors = _build_sectors(limits) previous_sectors = _build_sectors(previous_limits) dashboard = { "meta": { "requested_date": _display_date(requested_date), "trade_date": _display_date(trade_date), "previous_trade_date": _display_date(previous_trade_date), "source": "tushare", "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "notice": ";".join(notices), }, "overview": _build_overview(daily, up_rows, down_rows, broken_rows), "limits": limits, "broken": broken, "down_limits": down_limits, "yesterday_limits": yesterday_limits, "limit_performance": _build_limit_performance(yesterday_limits), "ladders": _build_ladders(limits), "sectors": sectors, "sector_rotation": _build_sector_rotation(sectors, previous_sectors), } return apply_sentiment_to_dashboard(dashboard) @staticmethod def should_use_realtime(requested_date: str, trade_date: str) -> bool: """Use rt_k for today's open market until end-of-day datasets settle.""" now = datetime.now().astimezone() today = now.strftime("%Y%m%d") return ( requested_date == today and trade_date == today and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30) ) def _realtime_dashboard( self, requested_date: str, trade_date: str, previous_trade_date: str, ) -> dict[str, Any]: reference = self._load_realtime_reference(trade_date, previous_trade_date) basic_rows = list(reference["basic_rows"]) codes = ",".join( str(row.get("ts_code") or "") for row in basic_rows if row.get("ts_code") ) if not codes: raise TushareError("No active stock codes available for rt_k") quotes = self.query("rt_k", {"ts_code": codes}) if not quotes: raise TushareError(f"No realtime data returned for {trade_date}") basic_map = {str(row.get("ts_code") or ""): row for row in basic_rows} daily: list[dict[str, Any]] = [] for quote in quotes: close = _number(quote.get("close")) previous_close = _number(quote.get("pre_close")) if close <= 0 or previous_close <= 0: continue basic = basic_map.get(str(quote.get("ts_code") or ""), {}) daily.append( { **quote, "trade_date": trade_date, "name": str(quote.get("name") or basic.get("name") or "--").strip(), "industry": basic.get("industry") or "其他", "pct_chg": round((close / previous_close - 1) * 100, 4), "amount_unit": "yuan", } ) with self._realtime_reference_lock: self._latest_realtime_market[trade_date] = { "rows": daily, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), } if len(self._latest_realtime_market) > 3: oldest = next(iter(self._latest_realtime_market)) self._latest_realtime_market.pop(oldest, None) limit_rows = self._derive_limits( trade_date, daily, price_limits=list(reference["price_limits"]), basic_rows=basic_rows, previous_limit_rows=list(reference["previous_limit_rows"]), capital_rows=list(reference["capital_rows"]), ) previous_limit_rows = list(reference["previous_limit_rows"]) up_rows = [row for row in limit_rows if row.get("limit_type") == "U"] down_rows = [row for row in limit_rows if row.get("limit_type") == "D"] broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"] limits = [self._normalize_limit(row, "涨停") for row in up_rows] broken = [self._normalize_limit(row, "炸板") for row in broken_rows] down_limits = [self._normalize_limit(row, "跌停") for row in down_rows] previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows] yesterday_limits = _build_yesterday_performance( previous_limits, daily, limits, broken, down_limits, ) sectors = _build_sectors(limits) previous_sectors = _build_sectors(previous_limits) now = datetime.now().astimezone() market_status = _realtime_market_status(now.time().replace(tzinfo=None)) dashboard = { "meta": { "requested_date": _display_date(requested_date), "trade_date": _display_date(trade_date), "previous_trade_date": _display_date(previous_trade_date), "source": "tushare", "mode": "realtime", "realtime": True, "market_status": market_status, "refresh_mode": "manual", "auto_refresh": False, "quote_count": len(daily), "updated_at": now.isoformat(timespec="seconds"), "notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。", }, "overview": _build_overview(daily, up_rows, down_rows, broken_rows), "limits": limits, "broken": broken, "down_limits": down_limits, "yesterday_limits": yesterday_limits, "limit_performance": _build_limit_performance(yesterday_limits), "ladders": _build_ladders(limits), "sectors": sectors, "sector_rotation": _build_sector_rotation(sectors, previous_sectors), } return apply_sentiment_to_dashboard(dashboard) def _load_realtime_reference( self, trade_date: str, previous_trade_date: str, ) -> dict[str, Any]: cache_key = f"{trade_date}:{previous_trade_date}" with self._realtime_reference_lock: cached = self._realtime_reference_cache.get(cache_key) if cached: return cached basic_rows = self.query( "stock_basic", {"exchange": "", "list_status": "L"}, "ts_code,name,industry,market,list_date", ) price_limits = self.query( "stk_limit", {"trade_date": trade_date}, "ts_code,trade_date,up_limit,down_limit", ) previous_limit_rows = self._load_limit_type(previous_trade_date, "U") capital_rows = self.query( "daily_basic", {"trade_date": previous_trade_date}, "ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv", ) if not basic_rows or not price_limits: raise TushareError(f"Realtime reference data is incomplete for {trade_date}") result = { "basic_rows": basic_rows, "price_limits": price_limits, "previous_limit_rows": previous_limit_rows, "capital_rows": capital_rows, } with self._realtime_reference_lock: self._realtime_reference_cache[cache_key] = result if len(self._realtime_reference_cache) > 3: oldest = next(iter(self._realtime_reference_cache)) self._realtime_reference_cache.pop(oldest, None) return result def realtime_stock_quote( self, ts_code: str, reference_date: str = "", ) -> dict[str, Any]: rows = self.query("rt_k", {"ts_code": ts_code}) if not rows: 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: raise TushareError(f"Realtime quote is unavailable for {ts_code}") basic: dict[str, Any] = {} with self._realtime_reference_lock: references = list(self._realtime_reference_cache.values()) for reference in reversed(references): basic = next( ( item for item in reference.get("basic_rows") or [] if str(item.get("ts_code") or "") == ts_code ), {}, ) if basic: break if not basic: basics = self.query( "stock_basic", {"ts_code": ts_code}, "ts_code,name,industry,market,list_date", ) basic = basics[0] if basics else {} capital = self._latest_capital(ts_code, reference_date) float_share = _number(capital.get("float_share")) # rt_k volume is shares; daily_basic float_share is reported in 10k shares. turnover_rate = _number(row.get("vol")) / float_share / 100 if float_share else 0 market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d") self._ensure_realtime_market_cache(market_date) with self._realtime_reference_lock: market_rows = list((self._latest_realtime_market.get(market_date) or {}).get("rows") or []) references = list(self._realtime_reference_cache.values()) capital_map: dict[str, dict[str, Any]] = {} for reference in reversed(references): capital_map = { str(item.get("ts_code") or ""): item for item in reference.get("capital_rows") or [] } if capital_map: break market_amounts = [_number(item.get("amount")) for item in market_rows if _number(item.get("amount")) > 0] amount_percentile = _value_percentile(_number(row.get("amount")), market_amounts) market_turnovers = [] for item in market_rows: item_capital = capital_map.get(str(item.get("ts_code") or ""), {}) item_float_share = _number(item_capital.get("float_share")) if item_float_share: market_turnovers.append(_number(item.get("vol")) / item_float_share / 100) market_turnover = ( sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0 ) turnover_relative = turnover_rate / market_turnover if market_turnover else 0 activity = self._stock_activity_metrics( ts_code, market_date, _number(row.get("vol")) / 100, ) return { "code": ts_code.split(".")[0], "ts_code": ts_code, "name": str(row.get("name") or basic.get("name") or "--").strip(), "sector": basic.get("industry") or "其他", "price": round(close, 3), "change": round((close / previous_close - 1) * 100, 4), "open": round(_number(row.get("open")), 3), "high": round(_number(row.get("high")), 3), "low": round(_number(row.get("low")), 3), "previous_close": round(previous_close, 3), "amount_billion": round(_number(row.get("amount")) / 100000000, 3), "volume": _number(row.get("vol")), "trade_count": int(_number(row.get("num"))), "turnover_rate": round(turnover_rate, 4), "market_turnover_rate": round(market_turnover, 4), "turnover_relative": round(turnover_relative, 4), "amount_percentile": round(amount_percentile * 100, 2), "volume_activity_ratio": activity.get("volume_activity_ratio", 0), "activity_history_date": activity.get("history_trade_date", ""), "activity_source": activity.get("source", "unavailable"), "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", "realtime": True, } def _stock_activity_metrics( self, ts_code: str, reference_date: str, current_volume_lots: float, ) -> dict[str, Any]: cache_key = f"{ts_code}:{reference_date}" with self._realtime_reference_lock: history = self._stock_activity_cache.get(cache_key) if history is None: try: end = datetime.strptime(reference_date, "%Y%m%d") except ValueError: end = datetime.now().astimezone().replace(tzinfo=None) rows = self.query( "daily", { "ts_code": ts_code, "start_date": (end - timedelta(days=30)).strftime("%Y%m%d"), "end_date": reference_date, }, "ts_code,trade_date,vol,amount", ) completed = [ item for item in rows if str(item.get("trade_date") or "") < reference_date and _number(item.get("vol")) > 0 ] completed.sort(key=lambda item: str(item.get("trade_date") or "")) recent = completed[-5:] history = { "average_volume_lots": ( sum(_number(item.get("vol")) for item in recent) / len(recent) if recent else 0 ), "history_trade_date": str(recent[-1].get("trade_date") or "") if recent else "", } with self._realtime_reference_lock: self._stock_activity_cache[cache_key] = history if len(self._stock_activity_cache) > 256: oldest = next(iter(self._stock_activity_cache)) self._stock_activity_cache.pop(oldest, None) average_volume = _number(history.get("average_volume_lots")) progress = _trading_session_progress(datetime.now().astimezone().time().replace(tzinfo=None)) expected_volume = average_volume * progress ratio = current_volume_lots / expected_volume if expected_volume else 0 return { **history, "volume_activity_ratio": round(ratio, 4), "session_progress": round(progress, 4), "source": "rt_volume/5d_average_at_same_progress" if expected_volume else "unavailable", } def realtime_factor_snapshot(self, requested_date: str) -> dict[str, Any]: trade_date, previous_trade_date = self.resolve_trade_context(requested_date) reference = self._load_realtime_reference(trade_date, previous_trade_date) codes = [ str(row.get("ts_code") or "") for row in reference.get("basic_rows") or [] if row.get("ts_code") ] quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") capital_map = { str(row.get("ts_code") or ""): row for row in reference.get("capital_rows") or [] } rows = [] for quote in quotes: ts_code = str(quote.get("ts_code") or "") close = _number(quote.get("close")) previous_close = _number(quote.get("pre_close")) if not ts_code or close <= 0 or previous_close <= 0: continue capital = capital_map.get(ts_code, {}) float_share = _number(capital.get("float_share")) rows.append( { "ts_code": ts_code, "trade_date": trade_date, "open": _number(quote.get("open")), "high": _number(quote.get("high")), "low": _number(quote.get("low")), "close": close, "pct_chg": (close / previous_close - 1) * 100, "vol": _number(quote.get("vol")) / 100, "amount": _number(quote.get("amount")), "turnover_rate": ( _number(quote.get("vol")) / float_share / 100 if float_share else 0 ), "capital_trade_date": str(capital.get("trade_date") or ""), } ) if not rows: raise TushareError(f"No realtime factor snapshot returned for {trade_date}") return { "trade_date": trade_date, "previous_trade_date": previous_trade_date, "source": "tushare_rt_k", "realtime": True, "rows": rows, } def _ensure_realtime_market_cache(self, requested_date: str) -> list[dict[str, Any]]: with self._realtime_reference_lock: cached = list( (self._latest_realtime_market.get(requested_date) or {}).get("rows") or [] ) if cached: return cached trade_date, previous_trade_date = self.resolve_trade_context(requested_date) if trade_date != requested_date: return [] reference = self._load_realtime_reference(trade_date, previous_trade_date) codes = [ str(row.get("ts_code") or "") for row in reference.get("basic_rows") or [] if row.get("ts_code") ] quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") rows = [ row for row in quotes if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0 ] with self._realtime_reference_lock: self._latest_realtime_market[trade_date] = { "rows": rows, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), } return rows def _latest_capital(self, ts_code: str, reference_date: str = "") -> dict[str, Any]: end_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d") cache_key = f"{ts_code}:{end_date}" with self._realtime_reference_lock: cached = self._capital_cache.get(cache_key) if cached: return cached try: end = datetime.strptime(end_date, "%Y%m%d") except ValueError: end = datetime.now().astimezone().replace(tzinfo=None) end_date = end.strftime("%Y%m%d") start_date = (end - timedelta(days=20)).strftime("%Y%m%d") rows = self.query( "daily_basic", {"ts_code": ts_code, "start_date": start_date, "end_date": end_date}, "ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share," "free_share,total_mv,circ_mv", ) rows.sort(key=lambda item: str(item.get("trade_date") or "")) result = rows[-1] if rows else {} with self._realtime_reference_lock: self._capital_cache[cache_key] = result if len(self._capital_cache) > 256: oldest = next(iter(self._capital_cache)) self._capital_cache.pop(oldest, None) return result def market_indices(self, requested_date: str, lookback_days: int = 45) -> dict[str, Any]: trade_date, _ = self.resolve_trade_context(requested_date) end = datetime.strptime(trade_date, "%Y%m%d") start_date = (end - timedelta(days=max(30, lookback_days * 2))).strftime("%Y%m%d") index_names = { "000001.SH": "上证指数", "399001.SZ": "深证成指", "399006.SZ": "创业板指", } indices = [] for ts_code, name in index_names.items(): rows = self.query( "index_daily", {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, "ts_code,trade_date,close,pct_chg,vol,amount", ) rows.sort(key=lambda item: str(item.get("trade_date") or "")) if not rows: continue latest = rows[-1] close = _number(latest.get("close")) close_5d = _number(rows[-6].get("close")) if len(rows) >= 6 else _number(rows[0].get("close")) close_20d = _number(rows[-21].get("close")) if len(rows) >= 21 else _number(rows[0].get("close")) indices.append( { "ts_code": ts_code, "name": name, "trade_date": str(latest.get("trade_date") or trade_date), "close": close, "pct_chg": round(_number(latest.get("pct_chg")), 3), "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, "return_20d": round((close / close_20d - 1) * 100, 3) if close_20d else 0, "amount_billion": round(_number(latest.get("amount")) / 100000, 2), } ) if not indices: raise TushareError(f"No index data returned for {trade_date}") return { "trade_date": trade_date, "source": "tushare", "realtime": False, "precise": all(item["trade_date"] == trade_date for item in indices), "indices": indices, "aggregate": { "average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3), "average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3), "average_return_20d": round(sum(item["return_20d"] for item in indices) / len(indices), 3), }, } def realtime_market_indices(self, requested_date: str) -> dict[str, Any]: trade_date, _ = self.resolve_trade_context(requested_date) index_names = { "000001.SH": "上证指数", "399001.SZ": "深证成指", "399006.SZ": "创业板指", } rows = self.query("rt_idx_k", {"ts_code": ",".join(index_names)}, "") row_map = {str(row.get("ts_code") or ""): row for row in rows} indices = [] for ts_code, name in index_names.items(): row = row_map.get(ts_code) if not row: continue close = _number(row.get("close")) previous_close = _number(row.get("pre_close")) if close <= 0 or previous_close <= 0: continue history = self.query( "index_daily", { "ts_code": ts_code, "start_date": (datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)).strftime("%Y%m%d"), "end_date": trade_date, }, "ts_code,trade_date,close,pct_chg", ) history.sort(key=lambda item: str(item.get("trade_date") or "")) previous_closes = [ _number(item.get("close")) for item in history if str(item.get("trade_date") or "") < trade_date and _number(item.get("close")) > 0 ] close_5d = previous_closes[-5] if len(previous_closes) >= 5 else previous_closes[0] if previous_closes else previous_close indices.append( { "ts_code": ts_code, "name": str(row.get("name") or name).strip(), "trade_date": trade_date, "close": close, "pct_chg": round((close / previous_close - 1) * 100, 3), "return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0, "amount_billion": round(_number(row.get("amount")) / 100000000, 2), } ) if len(indices) != len(index_names): raise TushareError("Realtime index quotes are incomplete") return { "trade_date": trade_date, "source": "tushare_rt_idx_k", "realtime": True, "precise": True, "indices": indices, "aggregate": { "average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3), "average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3), "average_return_20d": 0, }, } 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"No Shenwan daily returned for {sector_code} on {trade_date}" ) if not outer_precise and allow_realtime_close: try: return self._sw_realtime_sector_snapshot( industry, members, trade_date, previous_trade_date, finalized=True, ) except TushareError as exc: outer_error = f"{outer_error}; realtime close fallback failed: {exc}" official_change = _number(daily.get("pct_change")) if outer_precise else None 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": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily", "inner_source": "tushare_member_daily", "outer_source": "tushare_sw_daily" if outer_precise else "unavailable", "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": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合", } def _sw_sector_members( self, sector_code: str, trade_date: str, ) -> list[dict[str, Any]]: rows = [] 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", ) ) deduped: dict[str, dict[str, Any]] = {} for row in _reconcile_membership_rows(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] = row return list(deduped.values()) 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_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 = 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 "" outer_precise = bool(sw_row and quote_date == trade_date) if finalized and (not quote_clock or quote_clock < "15:00:00"): outer_precise = False official_change = _number(sw_row.get("pct_change")) if not official_change: close = _number(sw_row.get("close")) pre_close = _number(sw_row.get("pre_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})" valid: list[dict[str, Any]] = [] codes: list[str] = [] reference: dict[str, Any] = {} inner_error = "" 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 "") 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: inner_error = f"No active Shenwan members returned for {sector_code}" except TushareError as exc: inner_error = str(exc) 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 amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000 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 [] ) except TushareError as exc: market_rows = [] inner_precise = False inner_error = inner_error or str(exc) 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")) if float_share: sector_turnovers.append(_number(item.get("vol")) / 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) 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 if not relative_turnover: inner_precise = False inner_error = inner_error or "Shenwan member relative turnover is unavailable" 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": "tushare_rt_sw_k+sw_members_rt_k", "inner_source": "tushare_sw_members+rt_k", "outer_source": "tushare_rt_sw_k", "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, "schema_version": 6, "methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合", } def sector_snapshot( self, identifier: str, requested_date: str, realtime_expected: bool | None = None, ) -> dict[str, Any]: trade_date, _ = self.resolve_trade_context(requested_date) raw_identifier = identifier.strip() if not raw_identifier: raise TushareError("Sector identifier is empty") errors = [] now = datetime.now().astimezone() if realtime_expected is None: realtime_expected = ( trade_date == now.strftime("%Y%m%d") and dt_time(9, 15) <= now.time().replace(tzinfo=None) <= dt_time(15, 5) ) try: dc_params = {"trade_date": trade_date} if re.fullmatch(r"[A-Z0-9.]+", raw_identifier.upper()) and "." in raw_identifier: dc_params["ts_code"] = raw_identifier.upper() else: dc_params["name"] = raw_identifier dc_rows = self.query( "dc_index", dc_params, "ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct," "total_mv,turnover_rate,up_num,down_num", ) if not dc_rows and "name" in dc_params: dc_rows = self.query( "dc_index", {"trade_date": trade_date}, "ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct," "total_mv,turnover_rate,up_num,down_num", ) dc_row = _match_sector_row(dc_rows, raw_identifier) if dc_row and not realtime_expected: change = _number(dc_row.get("pct_change")) actual_trade_date = str(dc_row.get("trade_date") or "") return { "code": dc_row.get("ts_code") or "", "name": dc_row.get("name") or raw_identifier, "leader": dc_row.get("leading") or "--", "leader_code": dc_row.get("leading_code") or "", "leading_pct": _number(dc_row.get("leading_pct")), "change": change, "turnover_rate": _number(dc_row.get("turnover_rate")), "up_count": int(_number(dc_row.get("up_num"))), "down_count": int(_number(dc_row.get("down_num"))), "total_mv": _number(dc_row.get("total_mv")), "strength": round(max(0, min(100, 50 + change * 5)), 1), "amount_billion": 0, "count": 0, "max_streak": 0, "source": "tushare_dc", "trade_date": actual_trade_date, "realtime": False, "precise": actual_trade_date == trade_date, } except TushareError as exc: errors.append(f"DC: {exc}") ts_code = raw_identifier.upper() if re.fullmatch(r"\d{6}", ts_code): ts_code = f"{ts_code}.TI" try: if re.fullmatch(r"\d{6}\.TI", ts_code): index_rows = self.query( "ths_index", {"ts_code": ts_code}, "ts_code,name,count,exchange,list_date,type", ) else: index_rows = self.query( "ths_index", {}, "ts_code,name,count,exchange,list_date,type", ) basic = _match_sector_row(index_rows, raw_identifier) if not basic: raise TushareError(f"No THS sector returned for {raw_identifier}") except TushareError as exc: errors.append(f"THS: {exc}") raise TushareError("; ".join(errors)) from exc actual_code = str(basic.get("ts_code") or ts_code) if realtime_expected: try: realtime_sector = self._realtime_sector_snapshot(actual_code, basic, trade_date) if realtime_sector: return realtime_sector except TushareError as exc: errors.append(f"THS realtime members: {exc}") daily_rows = self.query( "ths_daily", {"ts_code": actual_code, "trade_date": trade_date}, "ts_code,trade_date,close,pct_change,vol,turnover_rate,total_mv,float_mv", ) daily = daily_rows[0] if daily_rows else {} actual_trade_date = str(daily.get("trade_date") or "") change = _number(daily.get("pct_change")) return { "code": actual_code, "name": basic.get("name") or raw_identifier, "leader": "--", "change": change, "leading_pct": change, "turnover_rate": _number(daily.get("turnover_rate")), "up_count": 0, "down_count": 0, "strength": round(max(0, min(100, 50 + change * 5)), 1), "amount_billion": 0, "count": 0, "max_streak": 0, "source": "tushare_ths", "trade_date": actual_trade_date, "realtime": False, "precise": actual_trade_date == trade_date, } def _realtime_sector_snapshot( self, sector_code: str, basic: dict[str, Any], trade_date: str, ) -> dict[str, Any] | None: members = self.query( "ths_member", {"ts_code": sector_code, "is_new": "Y"}, "ts_code,con_code,con_name,is_new", ) codes = [str(row.get("con_code") or "") for row in members if row.get("con_code")] if not codes: return None quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "") valid = [] 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, } ) minimum = max(1, math.ceil(len(codes) * 0.9)) if len(valid) < minimum: raise TushareError( f"Realtime sector coverage is insufficient ({len(valid)}/{len(codes)})" ) up_count = sum(item["change"] > 0 for item in valid) down_count = sum(item["change"] < 0 for item in valid) flat_count = len(valid) - up_count - down_count leader = max(valid, key=lambda item: item["change"]) change = sum(item["change"] for item in valid) / len(valid) amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000 self._ensure_realtime_market_cache(trade_date) with self._realtime_reference_lock: references = list(self._realtime_reference_cache.values()) market_rows = list((self._latest_realtime_market.get(trade_date) or {}).get("rows") or []) capital_map: dict[str, dict[str, Any]] = {} for reference in reversed(references): capital_map = { str(item.get("ts_code") or ""): item for item in reference.get("capital_rows") or [] } if capital_map: break sector_turnovers = [] 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) 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) 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 return { "code": sector_code, "name": basic.get("name") or sector_code, "leader": str(leader.get("name") or "--").strip(), "leader_code": leader.get("ts_code") or "", "leading_pct": round(leader["change"], 3), "change": round(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": flat_count, "member_count": len(codes), "quote_count": len(valid), "coverage": round(len(valid) / len(codes) * 100, 1), "strength": round(max(0, min(100, 50 + change * 5)), 1), "amount_billion": round(amount_billion, 2), "count": sum(item["change"] >= 9.5 for item in valid), "max_streak": 0, "source": "tushare_rt_ths_members", "trade_date": trade_date, "realtime": True, "precise": True, "methodology": "同花顺行业最新成分股的 rt_k 等权涨跌、宽度与成交额聚合", } def hot_money_profiles(self) -> dict[str, Any]: rows = self.query("hm_list", {}, "name,desc,orgs") profiles: list[dict[str, Any]] = [] seen_names: set[str] = set() for row in rows: name = str(row.get("name") or "").strip() if not name or name in seen_names: continue seen_names.add(name) description = _text(row.get("desc")) organization_text = _text(row.get("orgs")) parsed_organizations: Any = None if organization_text.startswith("["): try: parsed_organizations = json.loads(organization_text) except json.JSONDecodeError: parsed_organizations = None organization_parts = ( parsed_organizations if isinstance(parsed_organizations, list) else re.split(r"[,,;;\n]+", organization_text) ) organizations = list(dict.fromkeys( _text(part) for part in organization_parts if _text(part) )) profiles.append( { "id": f"hot-money-profile-{len(profiles) + 1}", "name": name, "description": description, "organizations": organizations, "organization_count": len(organizations), } ) return { "meta": { "source": "tushare", "status": "success" if profiles else "empty", "schema_version": 1, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "notice": "", }, "summary": { "profile_count": len(profiles), "described_count": sum(bool(item["description"]) for item in profiles), "organization_count": sum(item["organization_count"] for item in profiles), }, "profiles": profiles, } def dragon_tiger(self, requested_date: str) -> dict[str, Any]: trade_date, _ = self.resolve_trade_context(requested_date) detail_rows = self.query( "hm_detail", {"trade_date": trade_date}, "trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount," "hm_name,hm_orgs,tag", ) notices: list[str] = [] try: directory_rows = self.query("hm_list", {}, "name,desc,orgs") except TushareError as exc: directory_rows = [] notices.append(f"游资名录暂不可用:{exc}") directory = { str(row.get("name") or "").strip(): { "description": _text(row.get("desc")), "orgs": _text(row.get("orgs")), } for row in directory_rows if str(row.get("name") or "").strip() } # 个股龙虎榜仅用于补充涨幅和上榜原因,不参与游资身份识别。 try: top_rows = self.query( "top_list", {"trade_date": trade_date}, "trade_date,ts_code,name,pct_change,reason", ) except TushareError as exc: top_rows = [] notices.append(f"个股龙虎榜辅助信息暂不可用:{exc}") stock_context: dict[str, dict[str, Any]] = {} for row in top_rows: ts_code = str(row.get("ts_code") or "") if ts_code and ts_code not in stock_context: stock_context[ts_code] = row groups: dict[str, dict[str, Any]] = {} for row in detail_rows: trader_name = str(row.get("hm_name") or "未命名游资").strip() ts_code = str(row.get("ts_code") or "").strip() stock = stock_context.get(ts_code, {}) directory_item = directory.get(trader_name, {}) seat_name = _text(row.get("hm_orgs")) or directory_item.get("orgs") or "--" buy = round(_number(row.get("buy_amount")) / 1000000, 2) sell = round(_number(row.get("sell_amount")) / 1000000, 2) net_buy = round(_number(row.get("net_amount")) / 1000000, 2) group = groups.setdefault( trader_name, { "name": trader_name, "description": directory_item.get("description") or "", "directory_orgs": directory_item.get("orgs") or "", "identity_type": "trader", "identity_source": "tushare_hm", "recognized": True, "buy_million": 0.0, "sell_million": 0.0, "net_buy_million": 0.0, "seat_names": set(), "stock_codes": set(), "operations": [], }, ) group["buy_million"] += buy group["sell_million"] += sell group["net_buy_million"] += net_buy if seat_name != "--": group["seat_names"].add(seat_name) code = ts_code.split(".")[0] if code: group["stock_codes"].add(code) group["operations"].append( { "code": code, "ts_code": ts_code, "name": row.get("ts_name") or stock.get("name") or "--", "change": ( _number(stock.get("pct_change")) if stock.get("pct_change") is not None else None ), "direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平", "buy_million": buy, "sell_million": sell, "net_buy_million": net_buy, "seat_name": seat_name, "seat_alias": trader_name, "tag": _text(row.get("tag")) or "--", "reason": _text(stock.get("reason")) or "--", } ) traders = list(groups.values()) traders.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True) for index, group in enumerate(traders, start=1): group["id"] = f"hot-money-{index}" group["buy_million"] = round(group["buy_million"], 2) group["sell_million"] = round(group["sell_million"], 2) group["net_buy_million"] = round(group["net_buy_million"], 2) group["seat_count"] = len(group.pop("seat_names")) group["stock_count"] = len(group.pop("stock_codes")) group["operation_count"] = len(group["operations"]) group["operations"].sort( key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True ) operation_count = sum(item["operation_count"] for item in traders) active_stocks = { operation["code"] for item in traders for operation in item["operations"] if operation["code"] } net_buy_total = round(sum(item["net_buy_million"] for item in traders), 2) status = "success" if detail_rows else "partial" if top_rows else "empty" if not detail_rows: notices.insert( 0, f"当日有 {len(stock_context)} 只股票上榜,但未返回可识别的游资每日明细。" if top_rows else "该交易日未返回龙虎榜或游资每日明细。", ) return { "meta": { "requested_date": _display_date(requested_date), "trade_date": _display_date(trade_date), "source": "tushare", "status": status, "schema_version": 3, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "notice": ";".join(notices), }, "summary": { "trader_count": len(traders), "identity_count": len(traders), "operation_count": operation_count, "active_stock_count": len(active_stocks), "seat_net_buy_million": net_buy_total, "unclassified_count": 0, "directory_count": len(directory), "official_stock_count": len(stock_context), }, "traders": traders, "unclassified_seats": [], "rows": [], } 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") daily = self.query( "daily", {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", ) factors = self.query( "adj_factor", {"ts_code": ts_code, "start_date": start_date, "end_date": trade_date}, "ts_code,trade_date,adj_factor", ) basics = self.query( "stock_basic", {"ts_code": ts_code}, "ts_code,symbol,name,area,industry,market,list_date", ) daily_basics = self.query( "daily_basic", {"ts_code": ts_code, "trade_date": trade_date}, "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv", ) moneyflow = self.query( "moneyflow", {"ts_code": ts_code, "trade_date": trade_date}, "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", ) 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:]: factor = factor_map.get(row.get("trade_date"), latest_factor) ratio = factor / latest_factor prices.append( { "trade_date": _display_date(str(row.get("trade_date", ""))), "open": round(_number(row.get("open")) * ratio, 3), "high": round(_number(row.get("high")) * ratio, 3), "low": round(_number(row.get("low")) * ratio, 3), "close": round(_number(row.get("close")) * ratio, 3), "change": _number(row.get("pct_chg")), "volume": _number(row.get("vol")), "amount_billion": round(_number(row.get("amount")) / 100000, 2), } ) flow = moneyflow[0] if moneyflow else {} basic = basics[0] if basics else {} daily_basic = daily_basics[0] if daily_basics else {} latest = prices[-1] if prices else {} actual_trade_date = max( (str(row.get("trade_date") or "") for row in daily), default=trade_date, ) or trade_date return { "meta": { "requested_date": _display_date(requested_date), "trade_date": _display_date(actual_trade_date), "source": "tushare", "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "notice": "", }, "stock": { "code": ts_code.split(".")[0], "ts_code": ts_code, "name": basic.get("name") or "--", "industry": basic.get("industry") or "其他", "area": basic.get("area") or "--", "market": basic.get("market") or "--", "list_date": _display_date(str(basic.get("list_date") or "")), "price": latest.get("close", 0), "change": latest.get("change", 0), "turnover_rate": _number(daily_basic.get("turnover_rate")), "volume_ratio": _number(daily_basic.get("volume_ratio")), "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, ), }, } def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]: trade_date, _ = self.resolve_trade_context(requested_date) display_date = _display_date(trade_date) rows = self.query( "stk_mins", { "ts_code": ts_code, "freq": "1min", "start_date": f"{display_date} 09:00:00", "end_date": f"{display_date} 15:30:00", }, "ts_code,trade_time,open,close,high,low,vol,amount", ) points = [] for row in sorted(rows, key=lambda item: str(item.get("trade_time") or "")): trade_time = str(row.get("trade_time") or "") if not trade_time: continue points.append( { "time": trade_time[-8:-3] if len(trade_time) >= 8 else trade_time, "open": round(_number(row.get("open")), 3), "high": round(_number(row.get("high")), 3), "low": round(_number(row.get("low")), 3), "close": round(_number(row.get("close")), 3), "volume": _number(row.get("vol")), "amount": _number(row.get("amount")), } ) return {"trade_date": display_date, "points": points} def resolve_trade_context(self, requested: str) -> tuple[str, str]: requested_rows = self.query( "trade_cal", {"exchange": "SSE", "start_date": requested, "end_date": requested}, "cal_date,is_open,pretrade_date", ) if not requested_rows: trade_date = requested else: row = requested_rows[0] trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested) resolved_rows = self.query( "trade_cal", {"exchange": "SSE", "start_date": trade_date, "end_date": trade_date}, "cal_date,is_open,pretrade_date", ) previous = resolved_rows[0].get("pretrade_date") if resolved_rows else "" return trade_date, previous or trade_date def _load_daily(self, trade_date: str) -> list[dict[str, Any]]: return self.query( "daily", {"trade_date": trade_date}, "ts_code,trade_date,open,high,low,close,pct_chg,amount", ) def _load_limit_type(self, trade_date: str, limit_type: str) -> list[dict[str, Any]]: fields = ( "trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount," "float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time," "open_times,up_stat,limit_times" ) rows = self.query( "limit_list_d", {"trade_date": trade_date, "limit_type": limit_type}, fields, ) for row in rows: row["limit_type"] = limit_type row["amount_unit"] = "yuan" return rows def _load_limit_lists(self, trade_date: str) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for limit_type in ("U", "D", "Z"): rows.extend(self._load_limit_type(trade_date, limit_type)) return rows def _derive_limits( self, trade_date: str, daily: list[dict[str, Any]], price_limits: list[dict[str, Any]] | None = None, basic_rows: list[dict[str, Any]] | None = None, previous_limit_rows: list[dict[str, Any]] | None = None, capital_rows: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: if price_limits is None: price_limits = self.query( "stk_limit", {"trade_date": trade_date}, "ts_code,trade_date,up_limit,down_limit", ) limit_map = {row["ts_code"]: row for row in price_limits} if basic_rows is None: basic_rows = self.query( "stock_basic", {"list_status": "L"}, "ts_code,name,industry", ) basic_map = {row["ts_code"]: row for row in basic_rows} previous_limit_map = { str(row.get("ts_code") or ""): row for row in (previous_limit_rows or []) } capital_map = { str(row.get("ts_code") or ""): row for row in (capital_rows or []) } result: list[dict[str, Any]] = [] for row in daily: bounds = limit_map.get(row.get("ts_code")) if not bounds or row.get("close") is None: continue limit_type = "" if _prices_equal(row["close"], bounds.get("up_limit")): limit_type = "U" elif _prices_equal(row["close"], bounds.get("down_limit")): limit_type = "D" elif _prices_equal(row.get("high"), bounds.get("up_limit")): limit_type = "Z" if not limit_type: continue basic = basic_map.get(row["ts_code"], {}) previous_limit = previous_limit_map.get(str(row.get("ts_code") or ""), {}) streak = ( max(1, int(_number(previous_limit.get("limit_times"), 1)) + 1) if limit_type == "U" and previous_limit else 1 ) item = { **row, "name": basic.get("name", "--"), "industry": basic.get("industry") or "其他", "limit_type": limit_type, "limit_times": streak, "open_times": 1 if limit_type == "Z" else 0, "amount_unit": row.get("amount_unit") or "thousand_yuan", } if row.get("amount_unit") == "yuan": capital = capital_map.get(str(row.get("ts_code") or ""), {}) if not capital and capital_rows is None: capital = self._latest_capital(str(row.get("ts_code") or ""), trade_date) float_share = _number(capital.get("float_share")) item["turnover_ratio"] = ( _number(row.get("vol")) / float_share / 100 if float_share else 0 ) item["turnover_source"] = ( "rt_volume/latest_float_share" if float_share else "unavailable" ) item["capital_trade_date"] = str(capital.get("trade_date") or "") result.append(item) return result @staticmethod def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]: amount = _number(row.get("amount")) if row.get("amount_unit") == "thousand_yuan": amount_billion = amount / 100000 else: amount_billion = amount / 100000000 return { "code": str(row.get("ts_code", "")).split(".")[0], "ts_code": row.get("ts_code", ""), "name": row.get("name") or "--", "price": _number(row.get("close")), "change": _number(row.get("pct_chg")), "sector": row.get("industry") or "其他", "reason": row.get("industry") or "待补充", "first_time": _display_time(row.get("first_time")), "last_time": _display_time(row.get("last_time")), "open_times": int(_number(row.get("open_times"))), "streak": max(1, int(_number(row.get("limit_times"), 1))), "turnover_rate": _number(row.get("turnover_ratio")), "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), "float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1), "status": status, } def _number(value: Any, default: float = 0.0) -> float: try: number = float(value) return number if math.isfinite(number) else default except (TypeError, ValueError): return default def _text(value: Any) -> str: if isinstance(value, (list, tuple, set)): return "、".join(str(item).strip() for item in value if str(item).strip()) return str(value or "").strip() 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 _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, ) def _prices_equal(left: Any, right: Any) -> bool: if left is None or right is None: return False return abs(_number(left) - _number(right)) < 0.005 def _value_percentile(value: float, population: list[float]) -> float: valid = sorted(item for item in population if item >= 0) if not valid: return 0.0 below = sum(item < value for item in valid) equal = sum(item == value for item in valid) return (below + equal * 0.5) / len(valid) def _trading_session_progress(current_time: dt_time) -> float: morning_start = dt_time(9, 30) morning_end = dt_time(11, 30) afternoon_start = dt_time(13, 0) afternoon_end = dt_time(15, 0) if current_time <= morning_start: return 0.05 if current_time <= morning_end: minutes = (current_time.hour * 60 + current_time.minute) - (9 * 60 + 30) return max(0.05, min(0.5, minutes / 240)) if current_time < afternoon_start: return 0.5 if current_time <= afternoon_end: minutes = (current_time.hour * 60 + current_time.minute) - 13 * 60 return max(0.5, min(1.0, 0.5 + minutes / 240)) return 1.0 def _display_time(value: Any) -> str: raw = str(value or "").replace(":", "").zfill(6) if not raw.strip("0"): return "--" return f"{raw[:2]}:{raw[2:4]}:{raw[4:6]}" def _display_date(value: str) -> str: return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value def _realtime_market_status(current_time: dt_time) -> str: if current_time < dt_time(9, 25): return "pre_open" if current_time < dt_time(9, 30): return "auction" if current_time <= dt_time(11, 30) or dt_time(13, 0) <= current_time <= dt_time(15, 0): return "trading" if current_time < dt_time(13, 0): return "lunch_break" return "closed" def _build_overview( daily: list[dict[str, Any]], up_rows: list[dict[str, Any]], down_rows: list[dict[str, Any]], broken_rows: list[dict[str, Any]], ) -> dict[str, Any]: up_count = sum(1 for row in daily if _number(row.get("pct_chg")) > 0) down_count = sum(1 for row in daily if _number(row.get("pct_chg")) < 0) flat_count = len(daily) - up_count - down_count amount_billion = sum( _number(row.get("amount")) / (100000000 if row.get("amount_unit") == "yuan" else 100000) for row in daily ) limit_count = len(up_rows) broken_count = len(broken_rows) seal_rate = round(limit_count / max(limit_count + broken_count, 1) * 100, 1) return { "up_count": up_count, "down_count": down_count, "flat_count": flat_count, "limit_up_count": limit_count, "limit_down_count": len(down_rows), "broken_count": broken_count, "amount_billion": round(amount_billion, 1), "seal_rate": seal_rate, } def _build_ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: groups: dict[int, list[dict[str, Any]]] = {} for row in rows: groups.setdefault(int(row.get("streak") or 1), []).append(row) return [ { "level": level, "label": "首板" if level == 1 else f"{level}板", "count": len(stocks), "stocks": sorted(stocks, key=lambda item: item.get("first_time") or "99:99:99"), } for level, stocks in sorted(groups.items(), reverse=True) ] def _build_sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: counts = Counter(row.get("sector") or "其他" for row in rows) result: list[dict[str, Any]] = [] for name, count in counts.most_common(20): stocks = [row for row in rows if (row.get("sector") or "其他") == name] max_streak = max(item.get("streak", 1) for item in stocks) leader = max(stocks, key=lambda item: (item.get("streak", 1), item.get("amount_billion", 0))) result.append( { "name": name, "count": count, "strength": min(100, 44 + count * 8 + max_streak * 5), "amount_billion": round(sum(item.get("amount_billion", 0) for item in stocks), 1), "leader": leader.get("name", "--"), "change": round(sum(item.get("change", 0) for item in stocks) / count, 2), "max_streak": max_streak, } ) return result def _build_yesterday_performance( previous_limits: list[dict[str, Any]], daily: list[dict[str, Any]], current_limits: list[dict[str, Any]], current_broken: list[dict[str, Any]], current_down: list[dict[str, Any]], ) -> list[dict[str, Any]]: daily_map = {str(row.get("ts_code", "")).split(".")[0]: row for row in daily} limit_map = {row["code"]: row for row in current_limits} broken_codes = {row["code"] for row in current_broken} down_codes = {row["code"] for row in current_down} result = [] for previous in previous_limits: code = previous["code"] daily_row = daily_map.get(code, {}) current = limit_map.get(code) if current: outcome = "晋级" elif code in broken_codes: outcome = "炸板" elif code in down_codes: outcome = "跌停" else: outcome = "断板" result.append( { "code": code, "name": previous["name"], "prior_streak": previous.get("streak", 1), "current_streak": current.get("streak", 0) if current else 0, "current_change": _number(daily_row.get("pct_chg")), "current_price": _number(daily_row.get("close")), "sector": previous.get("sector", "其他"), "reason": previous.get("reason", "待补充"), "outcome": outcome, } ) return result def _build_limit_performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: result = [] for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True): group = [row for row in rows if int(row.get("prior_streak") or 1) == level] advanced = sum(row.get("outcome") == "晋级" for row in group) positive = sum(_number(row.get("current_change")) > 0 for row in group) result.append( { "level": level, "label": "昨日首板" if level == 1 else f"昨日{level}板", "count": len(group), "advanced": advanced, "advance_rate": round(advanced / len(group) * 100, 1), "positive_rate": round(positive / len(group) * 100, 1), "average_change": round(sum(_number(row.get("current_change")) for row in group) / len(group), 2), } ) return result def _build_sector_rotation( current: list[dict[str, Any]], previous: list[dict[str, Any]] ) -> list[dict[str, Any]]: previous_map = {row["name"]: row for row in previous} result = [] for index, sector in enumerate(current, start=1): previous_count = int(previous_map.get(sector["name"], {}).get("count", 0)) delta = int(sector["count"]) - previous_count result.append( { **sector, "rank": index, "previous_count": previous_count, "delta": delta, "trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平", } ) return result