Files
stock/backend/app/data/limit_board.py
2026-09-09 15:07:58 +08:00

234 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""首页打板专题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()