提交
This commit is contained in:
110
backend/app/data/dividend.py
Normal file
110
backend/app/data/dividend.py
Normal 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_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
|
||||
Reference in New Issue
Block a user