209 lines
8.6 KiB
Python
209 lines
8.6 KiB
Python
"""板块实时数据富化。
|
|
|
|
复用东方财富板块实时排名,为数据库中的板块快照补充今日实时涨幅、广度和成交额。
|
|
不触发扫描,只做轻量覆盖。
|
|
"""
|
|
|
|
import logging
|
|
import asyncio
|
|
|
|
from app.config import should_prefer_realtime_today, today_trade_date
|
|
from app.data.cache import cache
|
|
from app.data.eastmoney_client import get_sector_realtime_ranking
|
|
from app.data.models import SectorInfo
|
|
from app.data.tushare_client import tushare_client
|
|
from app.analysis.theme_mapper import merge_sectors_to_themes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_today_sector_board_tasks: dict[str, asyncio.Task] = {}
|
|
|
|
|
|
def _match_sector_name(em_name: str, ts_name: str) -> bool:
|
|
"""东方财富板块名与 Tushare 板块名模糊匹配。"""
|
|
em_clean = em_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
|
|
ts_clean = ts_name.rstrip("行业").rstrip("板块").rstrip("概念").strip()
|
|
if em_clean == ts_clean:
|
|
return True
|
|
short, long = (em_clean, ts_clean) if len(em_clean) <= len(ts_clean) else (ts_clean, em_clean)
|
|
return short in long
|
|
|
|
|
|
def _apply_empty_overlay(sector: SectorInfo) -> SectorInfo:
|
|
sector.realtime_pct_change = None
|
|
sector.realtime_limit_up_count = None
|
|
sector.realtime_amount = None
|
|
sector.realtime_turnover_rate = None
|
|
sector.realtime_up_count = None
|
|
sector.realtime_down_count = None
|
|
sector.leading_stocks_realtime = []
|
|
sector.is_realtime = False
|
|
sector.data_mode = "daily_snapshot"
|
|
sector.source = sector.source or "snapshot"
|
|
sector.board_type = sector.board_type or "snapshot"
|
|
sector.data_status = "snapshot"
|
|
sector.source_detail = "daily_snapshot"
|
|
return sector
|
|
|
|
|
|
def _sector_from_eastmoney(item: dict) -> SectorInfo:
|
|
"""把东方财富板块榜转换成今日展示用 SectorInfo。"""
|
|
source = item.get("source", "eastmoney")
|
|
sector = SectorInfo(
|
|
sector_code=item.get("sector_code", ""),
|
|
sector_name=item.get("sector_name", ""),
|
|
board_type=item.get("board_type", "industry"),
|
|
trade_date=today_trade_date(),
|
|
pct_change=float(item.get("pct_change", 0) or 0),
|
|
capital_inflow=0,
|
|
limit_up_count=0,
|
|
days_continuous=0,
|
|
heat_score=round(max(float(item.get("pct_change", 0) or 0), 0) * 12, 1),
|
|
stage="intraday",
|
|
turnover_avg=float(item.get("turnover_rate", 0) or 0),
|
|
realtime_pct_change=float(item.get("pct_change", 0) or 0),
|
|
realtime_limit_up_count=None,
|
|
realtime_amount=round(float(item.get("amount", 0) or 0) / 10000, 2),
|
|
realtime_turnover_rate=float(item.get("turnover_rate", 0) or 0),
|
|
realtime_up_count=int(item.get("up_count", 0) or 0),
|
|
realtime_down_count=int(item.get("down_count", 0) or 0),
|
|
is_realtime=True,
|
|
data_mode="realtime_today",
|
|
source=source,
|
|
data_status=item.get("data_status", "fresh" if source == "eastmoney" else "fallback"),
|
|
source_detail=item.get("source_detail", "eastmoney_push2" if source == "eastmoney" else source),
|
|
)
|
|
if item.get("leading_stock_name"):
|
|
sector.leading_stocks_realtime = [{
|
|
"ts_code": item.get("leading_stock_code", ""),
|
|
"name": item.get("leading_stock_name", ""),
|
|
"pct_chg": float(item.get("leading_stock_pct", 0) or 0),
|
|
"amount": 0,
|
|
}]
|
|
sector.leading_stocks = sector.leading_stocks_realtime
|
|
return sector
|
|
|
|
|
|
async def get_today_realtime_sector_board(limit: int = 20) -> list[SectorInfo]:
|
|
"""用实时行业榜 + 概念榜生成展示列表,作为 Tushare/定时扫描滞后的兜底。"""
|
|
cache_key = f"today_sector_board:{today_trade_date()}:{limit}"
|
|
cached = cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
task = _today_sector_board_tasks.get(cache_key)
|
|
if task and not task.done():
|
|
return await task
|
|
|
|
task = asyncio.create_task(_load_today_realtime_sector_board(limit))
|
|
_today_sector_board_tasks[cache_key] = task
|
|
try:
|
|
result = await task
|
|
cache.set(cache_key, result, ttl=60)
|
|
return result
|
|
finally:
|
|
if task.done():
|
|
_today_sector_board_tasks.pop(cache_key, None)
|
|
|
|
|
|
async def _load_today_realtime_sector_board(limit: int) -> list[SectorInfo]:
|
|
industry_sectors, concept_sectors = await _load_eastmoney_sector_boards(limit=max(limit, 20))
|
|
em_sectors = industry_sectors + concept_sectors
|
|
if not industry_sectors:
|
|
try:
|
|
from app.data.sina_client import get_sector_realtime_ranking_by_industry
|
|
sina_sectors = await get_sector_realtime_ranking_by_industry(limit=max(limit, 20))
|
|
em_sectors.extend(sina_sectors)
|
|
except Exception as e:
|
|
logger.warning("新浪行业实时榜兜底失败: %s", e)
|
|
if not em_sectors:
|
|
em_sectors = []
|
|
|
|
deduped = {}
|
|
for item in em_sectors:
|
|
key = f"{item.get('board_type', 'industry')}::{item.get('sector_name', '')}"
|
|
existing = deduped.get(key)
|
|
if existing is None or float(item.get("pct_change", 0) or 0) > float(existing.get("pct_change", 0) or 0):
|
|
deduped[key] = item
|
|
|
|
sectors = [_sector_from_eastmoney(item) for item in deduped.values()]
|
|
sectors = merge_sectors_to_themes(sectors, limit=limit)
|
|
return sectors[:limit]
|
|
|
|
|
|
async def _load_eastmoney_sector_boards(limit: int) -> tuple[list[dict], list[dict]]:
|
|
"""并行拉取行业/概念榜,允许部分成功。"""
|
|
import asyncio
|
|
|
|
results = await asyncio.gather(
|
|
get_sector_realtime_ranking(fs="m:90+t:2", page_size=limit, notify=False),
|
|
get_sector_realtime_ranking(fs="m:90+t:3", page_size=limit, notify=False),
|
|
return_exceptions=True,
|
|
)
|
|
boards: list[list[dict]] = []
|
|
for label, result in zip(("行业", "概念"), results):
|
|
if isinstance(result, Exception):
|
|
logger.warning("东方财富%s实时榜失败: %s", label, result)
|
|
boards.append([])
|
|
else:
|
|
boards.append(result)
|
|
return boards[0], boards[1]
|
|
|
|
|
|
async def enrich_sectors_with_realtime(sectors: list[SectorInfo]) -> list[SectorInfo]:
|
|
"""按需为板块快照追加实时字段并重排。"""
|
|
if not sectors:
|
|
return sectors
|
|
|
|
latest_trade_date = sectors[0].trade_date or tushare_client.get_latest_trade_date()
|
|
if not should_prefer_realtime_today(latest_trade_date):
|
|
return merge_sectors_to_themes([_apply_empty_overlay(sector) for sector in sectors], limit=len(sectors))
|
|
|
|
try:
|
|
em_sectors = await get_sector_realtime_ranking()
|
|
except Exception:
|
|
logger.warning("东方财富板块实时数据获取失败,回退到日级快照")
|
|
return merge_sectors_to_themes([_apply_empty_overlay(sector) for sector in sectors], limit=len(sectors))
|
|
|
|
if not em_sectors:
|
|
return merge_sectors_to_themes([_apply_empty_overlay(sector) for sector in sectors], limit=len(sectors))
|
|
|
|
em_name_map = {item["sector_name"]: item for item in em_sectors}
|
|
matched = 0
|
|
|
|
for sector in sectors:
|
|
em_data = em_name_map.get(sector.sector_name)
|
|
if not em_data:
|
|
for item in em_sectors:
|
|
if _match_sector_name(item["sector_name"], sector.sector_name):
|
|
em_data = item
|
|
break
|
|
|
|
if not em_data:
|
|
_apply_empty_overlay(sector)
|
|
continue
|
|
|
|
matched += 1
|
|
sector.realtime_pct_change = float(em_data.get("pct_change", 0) or 0)
|
|
sector.realtime_limit_up_count = None
|
|
sector.realtime_amount = round(float(em_data.get("amount", 0) or 0) / 10000, 2)
|
|
sector.realtime_turnover_rate = float(em_data.get("turnover_rate", 0) or 0)
|
|
sector.realtime_up_count = int(em_data.get("up_count", 0) or 0)
|
|
sector.realtime_down_count = int(em_data.get("down_count", 0) or 0)
|
|
sector.leading_stocks_realtime = []
|
|
if em_data.get("leading_stock_name"):
|
|
sector.leading_stocks_realtime = [{
|
|
"ts_code": em_data.get("leading_stock_code", ""),
|
|
"name": em_data.get("leading_stock_name", ""),
|
|
"pct_chg": float(em_data.get("leading_stock_pct", 0) or 0),
|
|
"amount": 0,
|
|
}]
|
|
sector.is_realtime = True
|
|
sector.data_mode = "realtime_overlay"
|
|
sector.source = em_data.get("source", "eastmoney")
|
|
sector.data_status = em_data.get("data_status", "fresh")
|
|
sector.source_detail = em_data.get("source_detail", sector.source)
|
|
|
|
logger.info("板块实时覆盖: %s/%s 匹配成功", matched, len(sectors))
|
|
sectors.sort(key=lambda s: (s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change), reverse=True)
|
|
return merge_sectors_to_themes(sectors, limit=len(sectors))
|