645 lines
27 KiB
Python
645 lines
27 KiB
Python
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
|