Files
xiaobaifupan/app/backend/data/providers/tushare_dashboard.py
T

645 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections import Counter
from datetime import datetime, time as dt_time, timedelta
from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.data.numbers import finite_number as _number
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
from backend.data.providers.tushare_helpers import (
_realtime_market_status,
_trading_session_progress,
_value_percentile,
)
from backend.data.providers.tushare_transport import TushareError
class DashboardMixin:
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 _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