448 lines
20 KiB
Python
448 lines
20 KiB
Python
"""个股参考数据(tushare 参考数据版块,详情页按需单查懒加载)。
|
||
|
||
10 个常规 kind 走同一泛型管道:拉取 -> 精确去重 -> 按 df dtype 清洗 -> 排序 ->
|
||
5 年窗切片 -> 单行 JSON 快照 upsert(stock_reference,行即缓存,rows_json NULL=墓碑)。
|
||
|
||
repurchase 特殊:tushare 不支持按 ts_code 过滤(实测入参被忽略,返回全市场默认页),
|
||
改为全市场按月分块拉取(单块触顶 2000 行则窗口减半递归)后按股拆分入库;
|
||
全局状态记在 stock_sync_state('_MARKET_', 'repurchase')——从未回填时后台任务回填
|
||
近 24 个月(本次请求返回现有行,可能暂空),状态过期(>7 天)时内联补拉缺失月份。
|
||
|
||
单位沿用各接口原始口径(详见 _KINDS 注释与文档):top10 hold_amount 股、pledge 万股、
|
||
block_trade vol 万股 / amount 万元、repurchase vol 股 / amount 元、share_float float_share 股。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import calendar
|
||
import json
|
||
import logging
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config import settings
|
||
from ..db import async_session
|
||
from ..models import StockReference
|
||
from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, read_sync_state, s_clean, upsert_sync_state, utcnow
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
_REFRESH_DAYS = 7
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Kind:
|
||
api: str
|
||
fields: str # 期望列的文档化清单(实际不传 fields,见 _fetch_kind_sync 注释)
|
||
windowed: bool # True: 传 start_date(=N年前0101)+end_date(=今天) 窗口参数
|
||
order: tuple[tuple[str, bool], ...] # 排序 (列, 是否倒序);多列时靠稳定多趟排序
|
||
slice_col: str # 窗口切片依据列(未来日期 >= cutoff 自然保留)
|
||
group: tuple[str, ...] | None = None # 聚合键:share_float 每股东一行,按解禁事件聚合才可展示
|
||
years: int = 5 # 窗口年数(moneyflow 这类日频近况数据取 1 年)
|
||
refresh_days: int = 7 # 新鲜度门控(moneyflow 每日盘后更新,须 1 天)
|
||
|
||
|
||
def _window_start() -> str:
|
||
return f"{date.today().year - 5}0101"
|
||
|
||
|
||
def _window_start_for(spec: _Kind) -> str:
|
||
return f"{date.today().year - spec.years}0101"
|
||
|
||
|
||
def _today() -> str:
|
||
return date.today().strftime("%Y%m%d")
|
||
|
||
|
||
_TOP10_FIELDS = ("ts_code,ann_date,end_date,holder_name,hold_amount,hold_ratio,"
|
||
"hold_float_ratio,hold_change,holder_type")
|
||
_PLEDGE_STAT_FIELDS = "ts_code,end_date,pledge_count,unrest_pledge,rest_pledge,total_share,pledge_ratio"
|
||
_PLEDGE_DETAIL_FIELDS = ("ts_code,ann_date,holder_name,pledge_amount,start_date,end_date,"
|
||
"is_release,release_date,holding_amount,pledged_amount,"
|
||
"p_total_ratio,h_total_ratio,pledgor,is_buyback")
|
||
_REPURCHASE_FIELDS = "ts_code,ann_date,end_date,proc,exp_date,vol,amount,high_limit,low_limit"
|
||
_FLOAT_FIELDS = "ts_code,ann_date,float_date,float_share,float_ratio,holder_name,share_type"
|
||
_BLOCK_FIELDS = "ts_code,trade_date,price,vol,amount,buyer,seller"
|
||
_HOLDERNUM_FIELDS = "ts_code,ann_date,end_date,holder_num"
|
||
_HOLDERTRADE_FIELDS = ("ts_code,ann_date,holder_name,holder_type,in_de,change_vol,"
|
||
"change_ratio,after_share,after_ratio,avg_price,total_share")
|
||
_SHOCK_FIELDS = "ts_code,trade_date,name,trade_market,reason,period"
|
||
# 同花顺资金流向(万元);2027-07-06 起源头停供 5 日主力净额与占比(net_d5_amount)
|
||
_MF_FIELDS = ("ts_code,trade_date,name,pct_change,latest,net_amount,net_d5_amount,"
|
||
"buy_lg_amount,buy_lg_amount_rate,buy_md_amount,buy_md_amount_rate,"
|
||
"buy_sm_amount,buy_sm_amount_rate")
|
||
|
||
_KINDS: dict[str, _Kind] = {
|
||
# 前十大股东/流通股东:报告期窗;期内按持股数排序(tushare 返回顺序不稳定)
|
||
"top10_holders": _Kind("top10_holders", _TOP10_FIELDS, True,
|
||
(("end_date", True), ("hold_amount", True)), "end_date"),
|
||
"top10_floatholders": _Kind("top10_floatholders", _TOP10_FIELDS, True,
|
||
(("end_date", True), ("hold_amount", True)), "end_date"),
|
||
# 质押统计:周频全量(接口无窗口参数),取近 5 年切片
|
||
"pledge_stat": _Kind("pledge_stat", _PLEDGE_STAT_FIELDS, False,
|
||
(("end_date", True),), "end_date"),
|
||
"pledge_detail": _Kind("pledge_detail", _PLEDGE_DETAIL_FIELDS, True,
|
||
(("ann_date", True),), "ann_date"),
|
||
"share_float": _Kind("share_float", _FLOAT_FIELDS, False,
|
||
(("float_date", True),), "float_date",
|
||
# 每个解禁股东一行(IPO 原始股东解禁可达数千行,接口 6000 行封顶会截断尾部),
|
||
# 按解禁事件聚合:股数/占比求和,股东名并成「N名股东」
|
||
group=("ann_date", "float_date", "share_type")),
|
||
"block_trade": _Kind("block_trade", _BLOCK_FIELDS, True,
|
||
(("trade_date", True),), "trade_date"),
|
||
# 同花顺资金流向:日频盘后更新,窗口 1 年、新鲜度 1 天(其余 kind 默认 5 年/7 天)
|
||
"moneyflow": _Kind("moneyflow_ths", _MF_FIELDS, True,
|
||
(("trade_date", True),), "trade_date", years=1, refresh_days=1),
|
||
"holdernumber": _Kind("stk_holdernumber", _HOLDERNUM_FIELDS, True,
|
||
(("end_date", True), ("ann_date", True)), "end_date"),
|
||
"holdertrade": _Kind("stk_holdertrade", _HOLDERTRADE_FIELDS, True,
|
||
(("ann_date", True),), "ann_date"),
|
||
"shock": _Kind("stk_shock", _SHOCK_FIELDS, True,
|
||
(("trade_date", True),), "trade_date"),
|
||
# quicksync 镜像实测无该接口数据:管道照常,墓碑优雅降级
|
||
"high_shock": _Kind("stk_high_shock", _SHOCK_FIELDS, True,
|
||
(("trade_date", True),), "trade_date"),
|
||
}
|
||
|
||
#: 路由白名单(repurchase 走全市场特殊管道,不在 _KINDS 泛型表里)
|
||
REFERENCE_KINDS = frozenset(_KINDS) | {"repurchase"}
|
||
|
||
|
||
def _kv(v):
|
||
"""排序键归一:None < 字符串 < 数值,避免混合类型比较抛错。"""
|
||
if v is None:
|
||
return (0, 0.0)
|
||
if isinstance(v, str):
|
||
return (1, v)
|
||
return (2, float(v))
|
||
|
||
|
||
def _clean_df(df, keep_ts_code: bool = False) -> list[dict]:
|
||
"""按 df dtype 清洗(数值列 f_clean、其余列 s_clean),默认去掉冗余 ts_code 列,精确去重。
|
||
|
||
数值列判定必须用 pandas.api.types.is_numeric_dtype:pandas 3 的字符串列 dtype
|
||
是 'str' 而非 'object',按 `!= "object"` 判断会把日期列误当数值列(f_clean 变 float)。
|
||
"""
|
||
import pandas as pd
|
||
|
||
df = df.drop_duplicates()
|
||
num_cols = {c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])}
|
||
skip_ts = {"ts_code"} if not keep_ts_code else set()
|
||
rows: list[dict] = []
|
||
for _, r in df.iterrows():
|
||
row = {c: (f_clean(r[c]) if c in num_cols else s_clean(r[c])) for c in df.columns if c not in skip_ts}
|
||
if any(v is not None for v in row.values()):
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
def _group_rows(rows: list[dict], spec: _Kind) -> list[dict]:
|
||
"""share_float 类聚合:数值列求和、holder_name 并成「N名股东」。"""
|
||
agg: dict[tuple, dict] = {}
|
||
order_keys: list[tuple] = []
|
||
for r in rows:
|
||
key = tuple(r.get(c) for c in spec.group) # type: ignore[arg-type]
|
||
if key not in agg:
|
||
agg[key] = {
|
||
**{c: r.get(c) for c in spec.group}, # type: ignore[misc]
|
||
"_num": {}, "_holders": [],
|
||
}
|
||
order_keys.append(key)
|
||
a = agg[key]
|
||
for c, v in r.items():
|
||
if isinstance(v, (int, float)) and c not in spec.group: # type: ignore[operator]
|
||
a["_num"][c] = a["_num"].get(c, 0.0) + v
|
||
h = r.get("holder_name")
|
||
if h:
|
||
a["_holders"].append(h)
|
||
out = []
|
||
for key in order_keys:
|
||
a = agg[key]
|
||
row = {c: a[c] for c in spec.group} # type: ignore[misc]
|
||
row.update({c: round(v, 4) for c, v in a["_num"].items()})
|
||
hs = a["_holders"]
|
||
row["holder_name"] = f"{hs[0]} 等{len(hs)}名股东" if len(hs) > 1 else (hs[0] if hs else None)
|
||
row["holder_count"] = len(hs)
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
def _slice_and_sort(rows: list[dict], spec: _Kind) -> list[dict]:
|
||
if spec.group:
|
||
rows = _group_rows(rows, spec)
|
||
if spec.slice_col:
|
||
cutoff = _window_start_for(spec)
|
||
rows = [r for r in rows if r.get(spec.slice_col) is None or r[spec.slice_col] >= cutoff]
|
||
# 多列排序:逆序逐列稳定排序(Python sort 稳定,后排的列为主键)
|
||
for col, desc in reversed(spec.order):
|
||
rows.sort(key=lambda r, c=col: _kv(r.get(c)), reverse=desc)
|
||
return rows
|
||
|
||
|
||
def _fetch_kind_sync(ts_code: str, kind: str) -> list[dict]:
|
||
"""同步拉单股单分类(需在 to_thread 里跑);无数据返回空列表。
|
||
|
||
不传 fields:所用字段全为接口默认显示列,而 quicksync 镜像对部分接口
|
||
(实测 share_float)传 fields 时会忽略 ts_code 过滤、返回全市场数据——
|
||
所以下面再加一道 ts_code 后过滤兜底。
|
||
"""
|
||
spec = _KINDS[kind]
|
||
time.sleep(settings.screener_sync_interval)
|
||
params: dict = {"ts_code": ts_code}
|
||
if spec.windowed:
|
||
params["start_date"] = _window_start_for(spec)
|
||
params["end_date"] = _today()
|
||
df = call_retry(getattr(get_pro_lazy(), spec.api), **params)
|
||
if df is None or df.empty:
|
||
return []
|
||
if "ts_code" in df.columns:
|
||
df = df[df["ts_code"] == ts_code]
|
||
return _slice_and_sort(_clean_df(df), spec)
|
||
|
||
|
||
# ---------- 行即缓存读写 ----------
|
||
|
||
def _parse(rows_json: str | None) -> list[dict]:
|
||
if not rows_json:
|
||
return []
|
||
try:
|
||
return json.loads(rows_json)
|
||
except (ValueError, TypeError):
|
||
return []
|
||
|
||
|
||
async def _read_rows(session: AsyncSession, ts_code: str, kind: str) -> list[dict]:
|
||
row = (await session.execute(
|
||
select(StockReference).where(
|
||
StockReference.ts_code == ts_code, StockReference.kind == kind
|
||
))).scalar_one_or_none()
|
||
return _parse(row.rows_json) if row is not None else []
|
||
|
||
|
||
async def _upsert_row(session: AsyncSession, ts_code: str, kind: str, rows: list[dict]) -> None:
|
||
"""写单行快照(不 commit);空列表写墓碑(rows_json NULL)。"""
|
||
stmt = pg_insert(StockReference).values(
|
||
ts_code=ts_code, kind=kind,
|
||
rows_json=json.dumps(rows, ensure_ascii=False) if rows else None,
|
||
updated_at=utcnow(),
|
||
)
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=["ts_code", "kind"],
|
||
set_={"rows_json": stmt.excluded.rows_json, "updated_at": stmt.excluded.updated_at},
|
||
)
|
||
await session.execute(stmt)
|
||
|
||
|
||
# per-(code,kind) 锁:同键首次并发 N 个请求只有 1 个打 tushare,其余等锁后双检命中
|
||
_key_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||
_guard = asyncio.Lock()
|
||
|
||
|
||
async def _get_generic(session: AsyncSession, ts_code: str, kind: str) -> list[dict]:
|
||
"""常规 kind 的读穿透:新鲜直返;否则锁内双检 -> to_thread 拉 -> upsert。"""
|
||
spec = _KINDS[kind]
|
||
row = (await session.execute(
|
||
select(StockReference).where(
|
||
StockReference.ts_code == ts_code, StockReference.kind == kind
|
||
))).scalar_one_or_none()
|
||
if row is not None and fresh(row.updated_at, spec.refresh_days):
|
||
return _parse(row.rows_json)
|
||
# 释放请求会话连接(close 而非 rollback:rollback 会 expire 鉴权缓存的 User,见 finance.py 注释)
|
||
await session.close()
|
||
|
||
key = (ts_code, kind)
|
||
async with _guard:
|
||
lock = _key_locks.setdefault(key, asyncio.Lock())
|
||
async with lock:
|
||
async with async_session() as s2:
|
||
row = (await s2.execute(
|
||
select(StockReference).where(
|
||
StockReference.ts_code == ts_code, StockReference.kind == kind
|
||
))).scalar_one_or_none()
|
||
if row is not None and fresh(row.updated_at, spec.refresh_days):
|
||
return _parse(row.rows_json)
|
||
old = _parse(row.rows_json) if row is not None else []
|
||
try:
|
||
rows = await asyncio.to_thread(_fetch_kind_sync, ts_code, kind)
|
||
except Exception:
|
||
if old: # 降级:返旧行,不把「上游挂了」伪装成「无数据」
|
||
return old
|
||
raise
|
||
await _upsert_row(s2, ts_code, kind, rows)
|
||
await s2.commit()
|
||
return rows
|
||
|
||
|
||
# ---------- repurchase:全市场按月分块管道 ----------
|
||
|
||
_MARKET_CODE = "_MARKET_"
|
||
_BACKFILL_MONTHS = 24
|
||
_REPURCHASE_CHUNK_CAP = 2000 # 接口单次上限(触顶则窗口减半递归拆分)
|
||
|
||
_repurchase_lock = asyncio.Lock() # 回填任务与内联增量共用,防止并发重复拉
|
||
_repurchase_task: asyncio.Task | None = None
|
||
|
||
|
||
def _month_range(start_ym: tuple[int, int], end_ym: tuple[int, int]) -> list[tuple[int, int]]:
|
||
"""[(y, m), ...] 闭区间月列表。"""
|
||
out = []
|
||
y, m = start_ym
|
||
while (y, m) <= end_ym:
|
||
out.append((y, m))
|
||
m += 1
|
||
if m > 12:
|
||
y, m = y + 1, 1
|
||
return out
|
||
|
||
|
||
def _fetch_repurchase_window(pro, start: str, end: str) -> list[dict]:
|
||
"""拉一个日期窗的全市场回购(同步,线程内跑);触顶 2000 行则窗口减半递归。
|
||
|
||
不传 fields:镜像实测传 fields 时 repurchase 按日期窗也返回空,去掉后正常。
|
||
"""
|
||
time.sleep(settings.screener_sync_interval)
|
||
df = call_retry(pro.repurchase, start_date=start, end_date=end)
|
||
if df is None or df.empty:
|
||
return []
|
||
if len(df) >= _REPURCHASE_CHUNK_CAP and start < end:
|
||
mid_ts = (
|
||
date(int(start[:4]), int(start[4:6]), int(start[6:8]))
|
||
+ (date(int(end[:4]), int(end[4:6]), int(end[6:8])) - date(int(start[:4]), int(start[4:6]), int(start[6:8]))) / 2
|
||
)
|
||
mid = mid_ts.strftime("%Y%m%d")
|
||
if mid > start and mid < end:
|
||
return _fetch_repurchase_window(pro, start, mid) + _fetch_repurchase_window(pro, mid, end)
|
||
return _clean_df(df, keep_ts_code=True) # 保留 ts_code 作为按股分组键
|
||
|
||
|
||
def _fetch_repurchase_market(months: list[tuple[int, int]]) -> dict[str, list[dict]]:
|
||
"""按月分块拉全市场回购,按股分组返回(同步,线程内跑)。
|
||
|
||
注意 _clean_df 必须保留 ts_code(分组键),写入时再剥掉。
|
||
"""
|
||
pro = get_pro_lazy()
|
||
by_code: dict[str, list[dict]] = {}
|
||
for y, m in months:
|
||
last_day = calendar.monthrange(y, m)[1]
|
||
for r in _fetch_repurchase_window(pro, f"{y}{m:02d}01", f"{y}{m:02d}{last_day}"):
|
||
code = r.get("ts_code")
|
||
if code:
|
||
by_code.setdefault(code, []).append({k: v for k, v in r.items() if k != "ts_code"})
|
||
spec = _Kind("", _REPURCHASE_FIELDS, False, (("ann_date", True),), "ann_date")
|
||
for code in by_code:
|
||
by_code[code] = _slice_and_sort(by_code[code], spec)
|
||
return by_code
|
||
|
||
|
||
async def _write_repurchase_batch(session: AsyncSession, fetched: dict[str, list[dict]]) -> None:
|
||
"""与库内旧行合并去重后批量 upsert(不 commit;由调用方提交)。"""
|
||
codes = list(fetched)
|
||
if not codes:
|
||
return
|
||
old_rows = (await session.execute(
|
||
select(StockReference.ts_code, StockReference.rows_json).where(
|
||
StockReference.kind == "repurchase", StockReference.ts_code.in_(codes)
|
||
))).all()
|
||
old_map = {ts: _parse(rj) for ts, rj in old_rows}
|
||
|
||
def _dedupe_key(r: dict) -> str:
|
||
return json.dumps(r, ensure_ascii=False, sort_keys=True)
|
||
|
||
values = []
|
||
for code, rows in fetched.items():
|
||
merged = old_map.get(code, [])
|
||
seen = {_dedupe_key(r) for r in merged}
|
||
for r in rows:
|
||
k = _dedupe_key(r)
|
||
if k not in seen:
|
||
seen.add(k)
|
||
merged.append(r)
|
||
merged.sort(key=lambda r: _kv(r.get("ann_date")), reverse=True)
|
||
values.append({
|
||
"ts_code": code, "kind": "repurchase",
|
||
"rows_json": json.dumps(merged, ensure_ascii=False),
|
||
"updated_at": utcnow(),
|
||
})
|
||
stmt = pg_insert(StockReference).values(values)
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=["ts_code", "kind"],
|
||
set_={"rows_json": stmt.excluded.rows_json, "updated_at": stmt.excluded.updated_at},
|
||
)
|
||
await session.execute(stmt)
|
||
|
||
|
||
async def _sync_repurchase_locked(only_current: bool) -> dict[str, list[dict]]:
|
||
"""在全局锁内拉取缺失月份并入库(调用方须已持有 _repurchase_lock)。"""
|
||
async with async_session() as s:
|
||
state = await read_sync_state(s, _MARKET_CODE, "repurchase")
|
||
now = date.today()
|
||
if state is None:
|
||
months = _month_range((now.year - _BACKFILL_MONTHS // 12, now.month), (now.year, now.month))
|
||
else:
|
||
last = state.last_synced_at # naive UTC;增量从其所在月起补
|
||
months = _month_range((last.year, last.month), (now.year, now.month))
|
||
if only_current and state is not None:
|
||
# 内联增量拉近两个月(当前月 + 上月迟到公告),其余月份不会缺(首次已全量回填)
|
||
months = months[-2:]
|
||
fetched = await asyncio.to_thread(_fetch_repurchase_market, months)
|
||
await _write_repurchase_batch(s, fetched)
|
||
await upsert_sync_state(s, _MARKET_CODE, "repurchase", has_data=True)
|
||
await s.commit()
|
||
return fetched
|
||
|
||
|
||
async def _repurchase_backfill() -> None:
|
||
"""后台全量回填(近 24 个月);失败记录日志——下次触发重试。"""
|
||
try:
|
||
async with _repurchase_lock:
|
||
await _sync_repurchase_locked(only_current=False)
|
||
except Exception: # noqa: BLE001 后台任务无人接异常,至少留痕
|
||
log.warning("回购数据后台回填失败(下次触发重试)", exc_info=True)
|
||
|
||
|
||
def _spawn_repurchase_backfill() -> None:
|
||
global _repurchase_task
|
||
if _repurchase_task is not None and not _repurchase_task.done():
|
||
return
|
||
_repurchase_task = asyncio.create_task(_repurchase_backfill())
|
||
|
||
|
||
async def _get_repurchase(session: AsyncSession, ts_code: str) -> list[dict]:
|
||
state = await read_sync_state(session, _MARKET_CODE, "repurchase")
|
||
if state is not None and fresh(state.last_synced_at, _REFRESH_DAYS):
|
||
return await _read_rows(session, ts_code, "repurchase")
|
||
await session.close() # 释放请求会话连接(后面隔着网络调用)
|
||
if state is None:
|
||
# 从未回填:先返回现有行(可能空),后台任务补齐近 24 个月,下次打开即有
|
||
_spawn_repurchase_backfill()
|
||
async with async_session() as s:
|
||
return await _read_rows(s, ts_code, "repurchase")
|
||
# 已回填但过期:内联补拉当前月(1 次调用,秒级)
|
||
async with _repurchase_lock:
|
||
async with async_session() as s:
|
||
state = await read_sync_state(s, _MARKET_CODE, "repurchase")
|
||
if state is not None and fresh(state.last_synced_at, _REFRESH_DAYS):
|
||
return await _read_rows(s, ts_code, "repurchase")
|
||
try:
|
||
await _sync_repurchase_locked(only_current=True)
|
||
except Exception:
|
||
old = await _read_rows(s, ts_code, "repurchase")
|
||
if old:
|
||
return old # 降级返旧行
|
||
raise
|
||
return await _read_rows(s, ts_code, "repurchase")
|
||
|
||
|
||
async def get_reference(session: AsyncSession, ts_code: str, kind: str) -> list[dict]:
|
||
"""读穿透入口。返回空列表 = 确认无数据(墓碑);抛异常 = 上游失败且无旧行可降级。"""
|
||
if kind == "repurchase":
|
||
return await _get_repurchase(session, ts_code)
|
||
if kind not in _KINDS:
|
||
raise ValueError(f"未知参考数据分类: {kind}")
|
||
return await _get_generic(session, ts_code, kind)
|