157 lines
6.6 KiB
Python
157 lines
6.6 KiB
Python
from __future__ import annotations
|
||
|
||
import copy
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
from backend.data.numbers import non_nan_number as _number
|
||
from backend.data.providers.tushare_client import TushareError
|
||
from backend.features.market.insights_context import _display_date
|
||
|
||
|
||
class MarketPopularityInsightsMixin:
|
||
def popularity(self, requested_date: str, force: bool = False) -> dict[str, Any]:
|
||
trade_date, previous_date = self._trade_context(requested_date)
|
||
if not force:
|
||
cached = self.database.get_data_snapshot("popularity_v1", trade_date)
|
||
if cached:
|
||
result = copy.deepcopy(cached)
|
||
result["meta"] = {**result.get("meta", {}), "cached": True}
|
||
return result
|
||
|
||
ths_rows, dc_rows, errors = self._hot_rows(trade_date)
|
||
actual_date = trade_date
|
||
carried_forward = False
|
||
if not ths_rows and not dc_rows and previous_date:
|
||
ths_rows, dc_rows, errors = self._hot_rows(previous_date)
|
||
actual_date = previous_date
|
||
carried_forward = bool(ths_rows or dc_rows)
|
||
if not ths_rows and not dc_rows:
|
||
fallback = self._latest_feature_snapshot("popularity_v1", trade_date)
|
||
if fallback:
|
||
result = copy.deepcopy(fallback)
|
||
result["meta"] = {
|
||
**result.get("meta", {}),
|
||
"requested_date": _display_date(requested_date),
|
||
"carried_forward": True,
|
||
"cached": True,
|
||
"notice": "当前榜单暂不可用,展示最近有效快照",
|
||
}
|
||
return result
|
||
return {
|
||
"meta": {
|
||
"requested_date": _display_date(requested_date),
|
||
"trade_date": _display_date(trade_date),
|
||
"previous_trade_date": _display_date(previous_date),
|
||
"carried_forward": False,
|
||
"cached": False,
|
||
"notice": "该交易日暂无可用人气榜",
|
||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||
},
|
||
"summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0},
|
||
"combined": [], "ths": [], "dc": [],
|
||
}
|
||
|
||
prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
|
||
prior_date, _ = self._trade_context(prior_request)
|
||
previous_ths, previous_dc, _ = self._hot_rows(prior_date)
|
||
ths = self._normalize_hot(ths_rows, "热股", previous_ths)
|
||
dc = self._normalize_hot(dc_rows, "A股市场", previous_dc)
|
||
ths_map = {item["ts_code"]: item for item in ths}
|
||
dc_map = {item["ts_code"]: item for item in dc}
|
||
combined = []
|
||
for ts_code in set(ths_map) | set(dc_map):
|
||
ths_item = ths_map.get(ts_code)
|
||
dc_item = dc_map.get(ts_code)
|
||
base = ths_item or dc_item or {}
|
||
ths_rank = int(ths_item["rank"]) if ths_item else None
|
||
dc_rank = int(dc_item["rank"]) if dc_item else None
|
||
score = (
|
||
(101 - (ths_rank or 101)) * 0.5
|
||
+ (201 - (dc_rank or 201)) * 0.25
|
||
)
|
||
combined.append(
|
||
{
|
||
**base,
|
||
"ths_rank": ths_rank,
|
||
"dc_rank": dc_rank,
|
||
"score": round(score, 2),
|
||
"dual_source": bool(ths_item and dc_item),
|
||
"concepts": (ths_item or {}).get("concepts") or [],
|
||
}
|
||
)
|
||
combined.sort(key=lambda item: (item["dual_source"], item["score"]), reverse=True)
|
||
for index, item in enumerate(combined, 1):
|
||
item["rank"] = index
|
||
result = {
|
||
"meta": {
|
||
"requested_date": _display_date(requested_date),
|
||
"trade_date": _display_date(actual_date),
|
||
"previous_trade_date": _display_date(prior_date),
|
||
"carried_forward": carried_forward,
|
||
"cached": False,
|
||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||
"notice": ";".join(errors),
|
||
},
|
||
"summary": {
|
||
"ths_count": len(ths),
|
||
"dc_count": len(dc),
|
||
"dual_count": sum(item["dual_source"] for item in combined),
|
||
},
|
||
"combined": combined[:200],
|
||
"ths": ths,
|
||
"dc": dc,
|
||
}
|
||
self.database.save_data_snapshot("popularity_v1", trade_date, "market", result)
|
||
return result
|
||
|
||
def _hot_rows(self, trade_date: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
|
||
errors = []
|
||
try:
|
||
ths = self.client.query("ths_hot", {"trade_date": trade_date})
|
||
except TushareError:
|
||
ths = []
|
||
errors.append("同花顺榜单暂不可用")
|
||
try:
|
||
dc = self.client.query("dc_hot", {"trade_date": trade_date})
|
||
except TushareError:
|
||
dc = []
|
||
errors.append("东方财富榜单暂不可用")
|
||
return ths, dc, errors
|
||
|
||
def _normalize_hot(
|
||
self,
|
||
rows: list[dict[str, Any]],
|
||
data_type: str,
|
||
previous_rows: list[dict[str, Any]],
|
||
) -> list[dict[str, Any]]:
|
||
previous = {
|
||
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
|
||
for row in previous_rows
|
||
if str(row.get("data_type") or "") == data_type
|
||
}
|
||
items = []
|
||
for row in rows:
|
||
if str(row.get("data_type") or "") != data_type:
|
||
continue
|
||
rank = int(_number(row.get("rank")))
|
||
ts_code = str(row.get("ts_code") or "")
|
||
prior_rank = previous.get(ts_code)
|
||
items.append(
|
||
{
|
||
"rank": rank,
|
||
"ts_code": ts_code,
|
||
"code": ts_code.split(".")[0],
|
||
"name": str(row.get("ts_name") or "--"),
|
||
"change": round(_number(row.get("pct_change")), 2),
|
||
"price": round(_number(row.get("current_price")), 2),
|
||
"hot": round(_number(row.get("hot")), 1),
|
||
"rank_change": (prior_rank - rank) if prior_rank else None,
|
||
"concepts": self._parse_concepts(row.get("concept")),
|
||
"reason": str(row.get("rank_reason") or ""),
|
||
"rank_time": str(row.get("rank_time") or ""),
|
||
}
|
||
)
|
||
items.sort(key=lambda item: item["rank"])
|
||
return items
|