Files
stock/backend/app/data/reference.py
2026-09-07 18:07:31 +08:00

445 lines
20 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 参考数据版块,详情页按需单查懒加载)。
10 个常规 kind 走同一泛型管道:拉取 -> 精确去重 -> 按 df dtype 清洗 -> 排序 ->
5 年窗切片 -> 单行 JSON 快照 upsertstock_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 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
_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_dtypepandas 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 而非 rollbackrollback 会 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 后台任务无人接异常
pass
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)