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

111 lines
5.0 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 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