"""个股分红送股(tushare dividend 全历史,详情页按需单查懒加载)。 每 code 同步时全量替换(dividend 无稳定唯一业务键,每票行数几十条,替换最简单); stock_sync_state(kind='dividend') 7 天新鲜度门控,空结果也是有效墓碑 (相当多股票从不分红,避免每次点击都穿透控频调用)。 """ from __future__ import annotations import asyncio import time from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..db import async_session from ..models import StockDividend from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, read_sync_state, s_clean, upsert_sync_state, utcnow _REFRESH_DAYS = 7 _FIELDS = ("ts_code,end_date,ann_date,div_proc,stk_div,stk_bo_rate,stk_co_rate," "cash_div,cash_div_tax,base_share,record_date,ex_date,pay_date," "div_listdate,imp_ann_date") _COLS = ("ts_code", "end_date", "ann_date", "div_proc", "stk_div", "stk_bo_rate", "stk_co_rate", "cash_div", "cash_div_tax", "base_share", "record_date", "ex_date", "pay_date", "div_listdate", "imp_ann_date") def fetch_dividends_sync(ts_code: str) -> list[dict]: """同步拉全历史分红(需在 to_thread 里跑);无分红返回空列表。""" time.sleep(settings.screener_sync_interval) df = call_retry(get_pro_lazy().dividend, ts_code=ts_code, fields=_FIELDS) if df is None or df.empty: return [] # tushare 会返回完全重复的行(实测 000001.SZ 同一除权日出现两次); # 前端按 ex_date 聚合求和,不去重会双计分红金额 df = df.drop_duplicates() rows: list[dict] = [] for _, r in df.iterrows(): rows.append({ "ts_code": ts_code, "end_date": s_clean(r.get("end_date")), "ann_date": s_clean(r.get("ann_date")), "div_proc": s_clean(r.get("div_proc")), "stk_div": f_clean(r.get("stk_div")), "stk_bo_rate": f_clean(r.get("stk_bo_rate")), "stk_co_rate": f_clean(r.get("stk_co_rate")), "cash_div": f_clean(r.get("cash_div")), "cash_div_tax": f_clean(r.get("cash_div_tax")), "base_share": f_clean(r.get("base_share")), "record_date": s_clean(r.get("record_date")), "ex_date": s_clean(r.get("ex_date")), "pay_date": s_clean(r.get("pay_date")), "div_listdate": s_clean(r.get("div_listdate")), "imp_ann_date": s_clean(r.get("imp_ann_date")), "updated_at": utcnow(), }) rows.sort(key=lambda x: ((x.get("end_date") or "", x.get("ann_date") or "")), reverse=True) return rows async def _read_rows(session: AsyncSession, ts_code: str) -> list[dict]: rs = (await session.execute( select(StockDividend).where(StockDividend.ts_code == ts_code) .order_by(StockDividend.end_date.desc(), StockDividend.ann_date.desc()))).scalars().all() return [{c: getattr(r, c) for c in _COLS} for r in rs] async def _replace(session: AsyncSession, ts_code: str, rows: list[dict]) -> None: """全量替换 + 状态落库(一个事务,由本函数 commit)。""" await session.execute(delete(StockDividend).where(StockDividend.ts_code == ts_code)) session.add_all([StockDividend(**r) for r in rows]) await upsert_sync_state(session, ts_code, "dividend", has_data=bool(rows)) await session.commit() # per-code 锁:同 code 首次并发 N 个请求只有 1 个打 tushare,其余等锁后双检命中 _code_locks: dict[str, asyncio.Lock] = {} _guard = asyncio.Lock() async def get_dividends(session: AsyncSession, ts_code: str) -> list[dict]: """读穿透:同步状态新鲜直返库内行;否则 per-code 锁 -> 新会话双检 -> to_thread 拉 -> 全量替换。 返回空列表 = 确认无分红(墓碑已落库);抛异常 = tushare 拉取失败且无旧行可降级。 """ state = await read_sync_state(session, ts_code, "dividend") if state is not None and fresh(state.last_synced_at, _REFRESH_DAYS): return await _read_rows(session, ts_code) # 释放请求会话连接(同 finance.get_finance:close() 而非 rollback(),防鉴权缓存 User 被 expire) await session.close() async with _guard: lock = _code_locks.setdefault(ts_code, asyncio.Lock()) async with lock: async with async_session() as s2: state = await read_sync_state(s2, ts_code, "dividend") if state is not None and fresh(state.last_synced_at, _REFRESH_DAYS): return await _read_rows(s2, ts_code) old = await _read_rows(s2, ts_code) try: fetched = await asyncio.to_thread(fetch_dividends_sync, ts_code) except Exception: if old: # 降级:返旧行,不把「上游挂了」伪装成「无分红」 return old raise await _replace(s2, ts_code, fetched) return fetched