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

167
backend/app/data/finance.py Normal file
View File

@@ -0,0 +1,167 @@
"""个股财务数据fina_indicator 财务指标 + 三大报表关键值,详情页按需单查懒加载)。
四源按报告期合并进 stock_financial 宽表(一行 = 一个报告期):
fina_indicatordoc 79无 report_type 概念)
+ income / balancesheet / cashflowreport_type=1 合并报表)
近五年窗口start_date = 当年-5年 的 0101季更数据 7 天新鲜度门控。
每源独立容错:部分接口失败不拖垮整体,缺失列靠 upsert 列级 coalesce 保旧值,
7 天后下次过期刷新自动重试失败源。四源全失败且无旧行 -> 抛异常(上游故障)。
stock_sync_state(kind='finance') 做新鲜度与墓碑(无数据股票不重复穿透控频调用)。
"""
from __future__ import annotations
import asyncio
import time
from datetime import date
from sqlalchemy import func, 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 StockFinancial
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金额单位元、比率百分数沿用 tushare 原始)
_FI_FIELDS = ("ts_code,ann_date,end_date,eps,bps,ocfps,roe,roe_dt,grossprofit_margin,"
"netprofit_margin,debt_to_assets,or_yoy,netprofit_yoy,dt_netprofit_yoy,"
"profit_dedt,rd_exp")
_INC_FIELDS = "ts_code,ann_date,end_date,total_revenue,operate_profit,n_income_attr_p"
_BS_FIELDS = "ts_code,ann_date,end_date,total_assets,total_hldr_eqy_exc_min_int"
_CF_FIELDS = "ts_code,ann_date,end_date,n_cashflow_act"
# tushare 字段 -> 模型列名(仅 balancesheet 归母权益超长名改短)
_COL_MAP = {"total_hldr_eqy_exc_min_int": "total_hldr_eqy"}
_SOURCES = (
("fina_indicator", _FI_FIELDS, {}),
("income", _INC_FIELDS, {"report_type": "1"}),
("balancesheet", _BS_FIELDS, {"report_type": "1"}),
("cashflow", _CF_FIELDS, {"report_type": "1"}),
)
_COLS = (
"ts_code", "end_date", "ann_date",
"eps", "bps", "ocfps", "roe", "roe_dt", "grossprofit_margin", "netprofit_margin",
"debt_to_assets", "or_yoy", "netprofit_yoy", "dt_netprofit_yoy", "profit_dedt", "rd_exp",
"total_revenue", "operate_profit", "n_income_attr_p",
"total_assets", "total_hldr_eqy", "n_cashflow_act",
)
_VALUE_COLS = _COLS[3:] # ann_date 之外的其余业务列(批量 upsert 需统一键集)
def fetch_finance_sync(ts_code: str) -> dict[str, dict]:
"""拉近五年四源财务数据并按报告期合并(同步网络 IO需在 to_thread 里跑)。
返回 {end_date: 行dict};四源全部失败且无任何数据时抛异常。
"""
pro = get_pro_lazy()
start = f"{date.today().year - 5}0101" # 含整五个年度的年报
merged: dict[str, dict] = {}
failed = 0
for api, fields, kwargs in _SOURCES:
time.sleep(settings.screener_sync_interval)
try:
df = call_retry(getattr(pro, api), ts_code=ts_code, start_date=start, fields=fields, **kwargs)
except Exception: # noqa: BLE001 单源失败不拖垮整体缺列保旧值7 天后自动重试)
failed += 1
continue
if df is None or df.empty:
continue
# 同一报告期 tushare 可能返回多条(调整前/后副本),按公告日升序取最后一条
df = df.sort_values("ann_date", na_position="first").drop_duplicates("end_date", keep="last")
for _, r in df.iterrows():
end = s_clean(r.get("end_date"))
if not end:
continue
row = merged.setdefault(end, {"ts_code": ts_code, "end_date": end, "ann_date": None})
ann = s_clean(r.get("ann_date"))
if ann:
row["ann_date"] = ann
for col in fields.split(","):
if col in ("ts_code", "ann_date", "end_date"):
continue
v = f_clean(r.get(col))
if v is not None:
row[_COL_MAP.get(col, col)] = v
if failed == len(_SOURCES) and not merged:
raise RuntimeError(f"tushare 财务四源均失败: {ts_code}")
return merged
def _row_dict(row: StockFinancial) -> dict:
return {c: getattr(row, c) for c in _COLS}
async def _read_rows(session: AsyncSession, ts_code: str) -> list[dict]:
rs = (await session.execute(
select(StockFinancial).where(StockFinancial.ts_code == ts_code)
.order_by(StockFinancial.end_date.desc()))).scalars().all()
return [_row_dict(r) for r in rs]
async def _upsert_rows(session: AsyncSession, rows: list[dict]) -> None:
"""批量 upsert不 commit列级 coalesce 保旧值——失败源的列、tushare 改为空值的列都保留库内旧值。"""
now = utcnow()
full = []
for r in rows:
row = {c: None for c in _VALUE_COLS} # 统一键集:批量 insert 要求各 dict 同构
row.update(r)
row["updated_at"] = now
full.append(row)
stmt = pg_insert(StockFinancial).values(full)
set_ = {c: func.coalesce(stmt.excluded[c], getattr(StockFinancial, c)) for c in ("ann_date", *_VALUE_COLS)}
set_["updated_at"] = stmt.excluded.updated_at
stmt = stmt.on_conflict_do_update(index_elements=["ts_code", "end_date"], set_=set_)
await session.execute(stmt)
# per-code 锁:同 code 首次并发 N 个请求只有 1 个打 tushare其余等锁后双检命中
# uvicorn 单进程场景够用;多 worker 最坏情况是重复拉一次 + ON CONFLICT 幂等,无害)
_code_locks: dict[str, asyncio.Lock] = {}
_guard = asyncio.Lock()
async def get_finance(session: AsyncSession, ts_code: str) -> list[dict] | None:
"""读穿透:同步状态新鲜直返;否则 per-code 锁 -> 新会话双检 -> to_thread 拉四源 -> upsert。
返回 None = 确认无财务数据(墓碑已落库,多为新股/退市老股);
抛异常 = 四源均失败且无旧行可降级。
"""
state = await read_sync_state(session, ts_code, "finance")
if state is not None and fresh(state.last_synced_at, _REFRESH_DAYS):
if not state.has_data:
return None
return await _read_rows(session, ts_code)
# 释放请求会话持有的连接:后面可能隔着数秒的 tushare 调用,别长占连接池。
# 用 close() 而非 rollback()rollback 会把会话身份映射里的实例全部 expire——
# 包括 require_user 刚塞进 auth._session_cache 的 Userexpire_on_commit=False
# 只保 commit不保 rollback下个请求命中鉴权缓存即 DetachedInstanceError 500。
# close() 同样归还连接,且已加载属性保持可访问(脱管安全,与鉴权缓存约定一致)。
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, "finance")
if state is not None and fresh(state.last_synced_at, _REFRESH_DAYS):
if not state.has_data:
return None
return await _read_rows(s2, ts_code)
old = await _read_rows(s2, ts_code)
try:
merged = await asyncio.to_thread(fetch_finance_sync, ts_code)
except Exception:
# 降级:库内有旧行(哪怕超 7 天)照常返回,不把「上游挂了」伪装成「无数据」
if old:
return old
raise
if merged:
await _upsert_rows(s2, list(merged.values()))
await upsert_sync_state(s2, ts_code, "finance", has_data=bool(merged) or bool(old))
await s2.commit()
return await _read_rows(s2, ts_code)