This commit is contained in:
2026-09-09 15:07:58 +08:00
parent d656c05b3d
commit 71a0f6e404
31 changed files with 3657 additions and 9 deletions

View File

@@ -0,0 +1,233 @@
"""首页打板专题tushare 同花顺版limit_list_ths / limit_step / limit_cpt_list
整包 SWR仿 index_global 列表层):进程内 state 新鲜直返 -> Redis 回填 ->
有旧值先返 + 后台刷新 -> 冷启动同步拉。盘中(交易日 09:15-15:30数据源即有
当日快照(实测 quicksync 镜像盘中可取当日)-> fresh TTL 压到 5 分钟;其余时段 4 小时。
镜像坑(见 reference.py 注释):不传 fieldslimit_list_ths 必须显式传 trade_date
(缺省返回多日混包且 4000 行封顶);涨停/连扳池才有 tag/status/lu_desc/封单额,
炸板池只有价格与打开次数,跌停池几乎只有价格——行模型统一、字段可选。
"""
from __future__ import annotations
import asyncio
import math
import time
from datetime import date, datetime, timedelta
from .. import cache
from ..config import settings
from .sync_utils import call_retry, f_clean, get_pro_lazy, s_clean
_BOARD_KEY = "limit_board:daily:v1" # Redis 整包缓存键v1 起版)
_REDIS_TTL = 3600 # 进程重启后的回填来源
_INTRADAY_TTL = 300.0 # 交易时段内的 fresh TTL5 分钟准实时)
_MAX_DATE_BACKTRACK = 5 # trade_date 定位回退天数(覆盖节假日/盘前)
_BLOCKS_OUT = 12 # 最强板块输出条数
class LimitBoardError(RuntimeError):
"""三池全部拉不到token/网络故障)——接口层转 503。"""
def _yi(v) -> float | None:
"""元 -> 亿元2 位小数)。"""
f = f_clean(v)
return None if f is None else round(f / 1e8, 2)
def _fetch_pool_sync(pro, trade_date: str, limit_type: str):
time.sleep(settings.screener_sync_interval)
return call_retry(pro.limit_list_ths, trade_date=trade_date, limit_type=limit_type)
def _pool_rows(df, mode: str) -> list[dict]:
"""行裁剪 + 单位换算。mode: up / broken / down。"""
if df is None or df.empty:
return []
rows: list[dict] = []
for _, r in df.iterrows():
row = {
"ts_code": s_clean(r.get("ts_code")),
"name": s_clean(r.get("name")),
"price": f_clean(r.get("price")),
"pct_chg": f_clean(r.get("pct_chg")),
}
if not row["ts_code"]:
continue
if mode == "up":
row.update({
"tag": s_clean(r.get("tag")),
"status": s_clean(r.get("status")),
"lu_desc": s_clean(r.get("lu_desc")),
"open_num": f_clean(r.get("open_num")),
"limit_amount_yi": _yi(r.get("limit_amount")), # 封单额(亿)
"turnover_yi": _yi(r.get("turnover")), # 成交额(亿)
"first_lu_time": s_clean(r.get("first_lu_time")),
"limit_up_suc_rate": f_clean(r.get("limit_up_suc_rate")),
})
elif mode == "broken":
row.update({
"open_num": f_clean(r.get("open_num")),
"first_lu_time": s_clean(r.get("first_lu_time")),
"last_lu_time": s_clean(r.get("last_lu_time")),
})
rows.append(row)
if mode == "up":
# 封单额降序(打板看封单强度);封单额缺失(镜像个别行)沉底
rows.sort(key=lambda x: (x.get("limit_amount_yi") is None, -(x.get("limit_amount_yi") or 0)))
return rows
def _fetch_board_sync() -> dict:
"""定位交易日并拉三池 + 天梯 + 最强板块(同步网络 IO需在 to_thread 里跑)。"""
pro = get_pro_lazy()
errors: list[str] = []
# trade_date 定位:今日起逐日回退,取第一个涨停池非空的日期
# (盘前/节假日当日为空tushare 错误直接抛——定位失败无意义继续)
trade_date: str | None = None
up_rows: list[dict] = []
for i in range(_MAX_DATE_BACKTRACK):
d = (date.today() - timedelta(days=i)).strftime("%Y%m%d")
df = _fetch_pool_sync(pro, d, "涨停池")
if df is not None and not df.empty:
trade_date = d
up_rows = _pool_rows(df, "up")
break
if trade_date is None:
raise LimitBoardError(f"{_MAX_DATE_BACKTRACK} 天均无涨停池数据(节假日或数据源故障)")
broken_rows: list[dict] = []
try:
broken_rows = _pool_rows(_fetch_pool_sync(pro, trade_date, "炸板池"), "broken")
except Exception as e: # noqa: BLE001 —— 单池失败不拖垮整包
errors.append(f"炸板池: {str(e)[:60]}")
down_rows: list[dict] = []
try:
down_rows = _pool_rows(_fetch_pool_sync(pro, trade_date, "跌停池"), "down")
except Exception as e: # noqa: BLE001
errors.append(f"跌停池: {str(e)[:60]}")
ladder: list[dict] = []
try:
time.sleep(settings.screener_sync_interval)
step = call_retry(pro.limit_step, trade_date=trade_date)
if step is not None and not step.empty:
for _, r in step.iterrows():
code = s_clean(r.get("ts_code"))
n = f_clean(r.get("nums"))
if code and n:
ladder.append({"ts_code": code, "name": s_clean(r.get("name")), "nums": int(n)})
ladder.sort(key=lambda x: -x["nums"])
except Exception as e: # noqa: BLE001
errors.append(f"连板天梯: {str(e)[:60]}")
blocks: list[dict] = []
try:
time.sleep(settings.screener_sync_interval)
cpt = call_retry(pro.limit_cpt_list, trade_date=trade_date)
if cpt is not None and not cpt.empty:
for _, r in cpt.head(_BLOCKS_OUT).iterrows():
blocks.append({
"name": s_clean(r.get("name")),
"days": f_clean(r.get("days")),
"up_stat": s_clean(r.get("up_stat")),
"cons_nums": f_clean(r.get("cons_nums")),
"up_nums": f_clean(r.get("up_nums")),
"pct_chg": f_clean(r.get("pct_chg")),
})
except Exception as e: # noqa: BLE001
errors.append(f"最强板块: {str(e)[:60]}")
# 连板分布limit_step 只含 2 板及以上1 板 = 涨停池 tag 首板数)
dist: dict[int, int] = {}
for x in ladder:
dist[x["nums"]] = dist.get(x["nums"], 0) + 1
summary = {
"up_count": len(up_rows),
"broken_count": len(broken_rows),
"down_count": len(down_rows),
"first_board_count": sum(1 for x in up_rows if x.get("tag") == "首板"),
"max_ladder": ladder[0] if ladder else None,
"ladder_dist": [{"nums": k, "count": v} for k, v in sorted(dist.items())],
}
return {
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}",
"summary": summary,
"up": up_rows,
"broken": broken_rows,
"down": down_rows,
"ladder": ladder,
"blocks": blocks,
"errors": errors,
}
# ---------- 整包 SWR进程内 -> Redis 回填 -> 旧值先返 + 后台刷新 -> 冷启动同步拉) ----------
_state: dict = {"payload": None}
_refreshing = False
_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 # 判定失败回退 weekday 启发式
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)
async def _refresh() -> dict:
data = await asyncio.to_thread(_fetch_board_sync)
payload = {**data, "updated_at": datetime.now().isoformat(), "fetched_ts": time.time()}
_state["payload"] = payload
await cache.cache_set(_BOARD_KEY, payload, ttl=_REDIS_TTL)
return payload
async def _refresh_wrapped() -> None:
global _refresh_error, _refreshing
try:
await _refresh()
_refresh_error = None
except Exception as e: # noqa: BLE001 —— 后台刷新失败静默记错,下次并入 errors
_refresh_error = f"打板专题后台刷新: {str(e)[:60]}"
finally:
_refreshing = False
def _spawn_refresh() -> None:
global _refreshing
if _refreshing:
return
_refreshing = True
task = asyncio.create_task(_refresh_wrapped())
_bg_tasks.add(task)
task.add_done_callback(_bg_tasks.discard)
async def fetch_limit_board(is_trading_day: bool | None) -> dict:
"""打板专题整包读取SWR。冷启动同步拉5 次调用约 2-4s此后盘中 5 分钟/盘后 4 小时。"""
ttl = _fresh_ttl(is_trading_day)
p = _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(_BOARD_KEY)
if cached:
p = cached
_state["payload"] = p
if p is not None:
_spawn_refresh()
if _refresh_error and not p.get("errors"):
p = {**p, "errors": [_refresh_error]}
return p
return await _refresh()

View File

@@ -0,0 +1,242 @@
"""同花顺概念/行业板块ths_index 列表 + ths_daily 行情快照 + ths_member 成分)。
缓存分层(数据特性决定):
- 板块列表:一天不变 -> 直缓存(进程内 -> Redis 24h
- 行情快照ths_daily 盘中即有当日(实测镜像),全市场一日 1877 行单次拿全
-> 整包 SWR同 limit_board盘中 5 分钟 / 盘后 4 小时trade_date 回退定位)
- 成分:每板块懒加载直缓存 24h成分股行情 enrichcandles 最新+前收 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]