"""同花顺概念/行业板块(ths_index 列表 + ths_daily 行情快照 + ths_member 成分)。 缓存分层(数据特性决定): - 板块列表:一天不变 -> 直缓存(进程内 -> Redis 24h) - 行情快照:ths_daily 盘中即有当日(实测镜像),全市场一日 1877 行单次拿全 -> 整包 SWR(同 limit_board:盘中 5 分钟 / 盘后 4 小时,trade_date 回退定位) - 成分:每板块懒加载直缓存 24h;成分股行情 enrich(candles 最新+前收 LATERAL) 每次请求现算,不进缓存 type 代码:N 概念 / I 行业 / TH 主题 / S 特色 / R 地域 / BB 宽基 / ST 风格。 """ from __future__ import annotations import asyncio import time from datetime import date, datetime, timedelta from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from .. import cache from ..config import settings from .sync_utils import call_retry, f_clean, get_pro_lazy, s_clean _LIST_KEY = "ths_boards:list:v1" _DAILY_KEY = "ths_boards:daily:v1" _REDIS_TTL = 3600 _LIST_TTL = 86400 _INTRADAY_TTL = 300.0 _MAX_DATE_BACKTRACK = 5 class ThsBoardError(RuntimeError): """列表/行情全部拉不到 —— 接口层转 503。""" # ---------- 板块列表(直缓存:进程内 -> Redis 24h -> 拉取) ---------- _list_cache: list[dict] | None = None def _fetch_list_sync() -> list[dict]: time.sleep(settings.screener_sync_interval) df = call_retry(get_pro_lazy().ths_index, exchange="A") if df is None or df.empty: raise ThsBoardError("ths_index 板块列表为空") rows: list[dict] = [] for _, r in df.iterrows(): code = s_clean(r.get("ts_code")) if not code: continue rows.append({ "ts_code": code, "name": s_clean(r.get("name")), "type": s_clean(r.get("type")), "count": f_clean(r.get("count")), "list_date": s_clean(r.get("list_date")), }) return rows async def get_board_list() -> list[dict]: global _list_cache if _list_cache is not None: return _list_cache cached = await cache.cache_get(_LIST_KEY) if isinstance(cached, list) and cached: _list_cache = cached return cached rows = await asyncio.to_thread(_fetch_list_sync) _list_cache = rows await cache.cache_set(_LIST_KEY, rows, ttl=_LIST_TTL) return rows # ---------- 行情快照(整包 SWR,同 limit_board.py 三件套) ---------- _daily_state: dict = {"payload": None} _daily_refreshing = False _daily_refresh_error: str | None = None _bg_tasks: set[asyncio.Task] = set() def _fresh_ttl(is_trading_day: bool | None) -> float: """交易时段 5 分钟(镜像盘中即有当日快照);其余 4 小时。""" if is_trading_day is None: is_trading_day = datetime.now().weekday() < 5 if is_trading_day: now = datetime.now() t = now.hour * 60 + now.minute if 9 * 60 + 15 <= t <= 15 * 60 + 30: return _INTRADAY_TTL return float(settings.market_eod_fresh_ttl) def _fetch_daily_sync() -> dict: pro = get_pro_lazy() for i in range(_MAX_DATE_BACKTRACK): d = (date.today() - timedelta(days=i)).strftime("%Y%m%d") time.sleep(settings.screener_sync_interval) df = call_retry(pro.ths_daily, trade_date=d) if df is None or df.empty: continue quotes: dict[str, dict] = {} for _, r in df.iterrows(): code = s_clean(r.get("ts_code")) if code: quotes[code] = { "close": f_clean(r.get("close")), "pct_change": f_clean(r.get("pct_change")), "vol": f_clean(r.get("vol")), "turnover_rate": f_clean(r.get("turnover_rate")), } return {"trade_date": f"{d[:4]}-{d[4:6]}-{d[6:]}", "quotes": quotes} raise ThsBoardError(f"近 {_MAX_DATE_BACKTRACK} 天均无 ths_daily 板块行情") async def _refresh_daily() -> dict: data = await asyncio.to_thread(_fetch_daily_sync) payload = {**data, "updated_at": datetime.now().isoformat(), "fetched_ts": time.time()} _daily_state["payload"] = payload await cache.cache_set(_DAILY_KEY, payload, ttl=_REDIS_TTL) return payload async def _refresh_daily_wrapped() -> None: global _daily_refresh_error, _daily_refreshing try: await _refresh_daily() _daily_refresh_error = None except Exception as e: # noqa: BLE001 _daily_refresh_error = f"板块行情后台刷新: {str(e)[:60]}" finally: _daily_refreshing = False def _spawn_daily_refresh() -> None: global _daily_refreshing if _daily_refreshing: return _daily_refreshing = True task = asyncio.create_task(_refresh_daily_wrapped()) _bg_tasks.add(task) task.add_done_callback(_bg_tasks.discard) async def _get_daily(is_trading_day: bool | None) -> dict: ttl = _fresh_ttl(is_trading_day) p = _daily_state["payload"] if p is not None and time.time() - p["fetched_ts"] < ttl: return p if p is None: cached = await cache.cache_get(_DAILY_KEY) if cached: p = cached _daily_state["payload"] = p if p is not None: _spawn_daily_refresh() return p return await _refresh_daily() async def fetch_boards(is_trading_day: bool | None) -> dict: """列表 + 当日行情合并(行情缺失的板块价格为 null)。""" boards, daily = await asyncio.gather(get_board_list(), _get_daily(is_trading_day)) quotes: dict = daily.get("quotes", {}) merged = [{**b, **quotes.get(b["ts_code"], {})} for b in boards] errors = [_daily_refresh_error] if _daily_refresh_error else [] return { "trade_date": daily.get("trade_date"), "updated_at": daily.get("updated_at"), "boards": merged, "errors": errors, } # ---------- 成分(每板块懒加载直缓存 24h + 行情 enrich 现算) ---------- _member_cache: dict[str, list[dict]] = {} def _fetch_members_sync(board_code: str) -> list[dict]: time.sleep(settings.screener_sync_interval) df = call_retry(get_pro_lazy().ths_member, ts_code=board_code) if df is None or df.empty: return [] rows: list[dict] = [] for _, r in df.iterrows(): code = s_clean(r.get("con_code")) if code: rows.append({"con_code": code, "con_name": s_clean(r.get("con_name"))}) rows.sort(key=lambda x: x["con_code"]) return rows async def get_raw_members(board_code: str) -> list[dict]: hit = _member_cache.get(board_code) if hit is not None: return hit key = f"ths_members:{board_code}" cached = await cache.cache_get(key) if isinstance(cached, list): _member_cache[board_code] = cached return cached rows = await asyncio.to_thread(_fetch_members_sync, board_code) _member_cache[board_code] = rows await cache.cache_set(key, rows, ttl=_LIST_TTL) return rows # 成分股行情:candles 最新价 + 前收算涨跌幅(北交所等无底座数据的为 NULL) _MEMBERS_ENRICH_SQL = text(""" SELECT m.code AS con_code, c.close AS close, CASE WHEN c.close IS NOT NULL AND prev.close IS NOT NULL AND prev.close <> 0 THEN round(((c.close / prev.close - 1) * 100)::numeric, 2) END AS pct_chg FROM (SELECT unnest(CAST(:codes AS text[])) AS code) m LEFT JOIN LATERAL ( SELECT close, ts FROM candles WHERE symbol = split_part(m.code, '.', 1) AND timeframe = '1d' ORDER BY ts DESC LIMIT 1 ) c ON true LEFT JOIN LATERAL ( SELECT close FROM candles WHERE symbol = split_part(m.code, '.', 1) AND timeframe = '1d' AND ts < c.ts ORDER BY ts DESC LIMIT 1 ) prev ON c.ts IS NOT NULL """) async def get_members(session: AsyncSession, board_code: str) -> list[dict]: """成分 + 现价/涨跌幅(行情不缓存,每请求现算)。""" members = await get_raw_members(board_code) if not members: return [] rows = (await session.execute(_MEMBERS_ENRICH_SQL, { "codes": [m["con_code"] for m in members], })).mappings().all() quote = {r["con_code"]: {"close": float(r["close"]) if r["close"] is not None else None, "pct_chg": float(r["pct_chg"]) if r["pct_chg"] is not None else None} for r in rows} return [{**m, **quote.get(m["con_code"], {"close": None, "pct_chg": None})} for m in members]