This commit is contained in:
2026-09-07 18:07:31 +08:00
parent 359f9ae2e4
commit bc1c72d558
27 changed files with 4532 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
"""个股分红送股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_financeclose() 而非 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