Files
qlib/backend/app/application/services/data_sync.py
T
Simon 442999f701 feat(data): 财务/日线同步增量 + 新浪「两边一致」校验兜底 + 逐只进度
- financial 默认增量:按 A 股披露节奏判断已最新并跳过;--full 强制全量重拉
- Tushare fina_indicator 增加报告期窗口与 100 条/请求自动分页(修复老报告期静默截断)
- 新浪兜底收紧为校验兜底:两源重叠历史一致才导入缺失键,行标记 source=sina;
  财务可比字段取 eps/销售毛利率(ROE 两端口径不同不作依据),日线只比较最近重叠交易日
- CLI 输出逐只进度与导入内容描述(来源/行数/报告期与公告区间),失败股票留待重跑
- financial_indicator 增 source 列(迁移 d3f6c9a21b04);新增一致性/分页/服务测试
2026-09-08 21:48:09 +08:00

612 lines
23 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.
"""数据同步服务:增量 + 新浪「两边一致」校验兜底(financial / daily)。
背景(AGENT.md §5/§7/§8):
- Tushare 是首选源;新浪财经只作备用。任何切源都必须可追溯(写 sync_log),
且禁止静默把未经核验的备用源数据并入主库。
- 本模块把「切到新浪」从 FailoverProvider 的『主源报错即兜底』收紧为
『校验兜底』:只有当某只股票**两边重叠的历史数据一致**时,才允许把新浪
的**新数据**(本地缺失键的行)导入;无本地历史或校验不一致 → 拒绝并告警,
留待 Tushare 恢复后重跑补齐(数据真实性优先)。
校验口径(经验证,见仓库数据):
- 财务可比字段只有 eps / gross_margin —— 两源同报告期数值逐位一致;
ROE 两边口径不同(Tushare 摊薄 vs 新浪加权),不作为一致性依据。
- 日线新浪为前复权,与本地不复权行仅「最近无除权区间」相等,因此只拿
两源重叠的最近若干个交易日做一致性校验(通道可信 → 才允许补缺)。
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal
from app.domain.entities.market import DailyBar, FinancialIndicator, SyncLog
from app.domain.providers import MarketDataProvider
from app.domain.repositories.market import (
AdjustFactorRepository,
DailyBarRepository,
FinancialRepository,
)
from app.infrastructure.data_sources.errors import DataSourceAuthenticationError
# 财务两源可比字段(其余字段两端口径不一致 / 单侧缺失,不能作校验依据)
FINANCIAL_COMPARE_FIELDS = ("eps", "gross_margin")
DAILY_COMPARE_FIELDS = ("open", "high", "low", "close")
# 新浪日 K 可达窗口(getKLineData datalen=320 自然日)
SINA_KLINE_DAYS = 320
# 校验回看:请求新浪时额外回看 begin 之前的天数,确保与本地近期历史有重叠可比
SINA_VERIFY_LOOKBACK_DAYS = 45
_EPOCH = date(1990, 1, 1)
# ---------------------------------------------------------------- 一致性校验(纯函数)
@dataclass
class OverlapVerdict:
"""两边重叠一致性结论。ok=True 才允许导入新浪新数据。"""
ok: bool
shared: int = 0 # 重叠报告期 / 重叠交易日数量
compared: int = 0 # 实际参与数值比较的行/日数量
mismatches: list[str] = field(default_factory=list)
def summary(self) -> str:
if self.ok:
return f"重叠 {self.shared} 项,数值一致(比较 {self.compared} 项)"
why = f"重叠 {self.shared} 项不足/为空"
if self.mismatches:
why = ";".join(self.mismatches[:3])
return f"校验未通过:{why}"
def _close_enough(a: Decimal, b: Decimal, *, rel_tol: float, abs_tol: float) -> bool:
if a is None or b is None:
return False
diff = abs(a - b)
if diff <= Decimal(str(abs_tol)):
return True
scale = max(abs(a), abs(b))
return diff <= Decimal(str(rel_tol)) * scale
def financial_overlap_consistent(
local_rows: Sequence[FinancialIndicator],
sina_rows: Sequence[FinancialIndicator],
*,
min_shared: int = 2,
rel_tol: float = 1e-4,
abs_tol: float = 1e-3,
) -> OverlapVerdict:
"""新浪财务行与本地(Tushare)行按报告期重叠校验。
新浪每个报告期只保留最新一版(getFinanceReport2022 的 report_list 按
报告期一份);本地同报告期可能有多版公告,取公告日最新者比较。
要求:重叠报告期数 >= min_shared,且全部可比字段(两源都非空)一致。
"""
local_latest: dict[date, FinancialIndicator] = {}
for row in local_rows:
cur = local_latest.get(row.report_date)
if cur is None or row.announce_date > cur.announce_date:
local_latest[row.report_date] = row
sina_by_report = {row.report_date: row for row in sina_rows}
verdict = OverlapVerdict(ok=False)
shared_dates = sorted(set(local_latest) & set(sina_by_report), reverse=True)
verdict.shared = len(shared_dates)
for report in shared_dates:
a = local_latest[report]
b = sina_by_report[report]
day_mismatch: list[str] = []
compared = 0
for f in FINANCIAL_COMPARE_FIELDS:
va, vb = getattr(a, f), getattr(b, f)
if va is None or vb is None:
continue
compared += 1
if not _close_enough(va, vb, rel_tol=rel_tol, abs_tol=abs_tol):
day_mismatch.append(f"{report}: {f} {va}≠{vb}")
verdict.compared += compared
verdict.mismatches.extend(day_mismatch)
verdict.ok = (
verdict.shared >= min_shared and verdict.compared > 0 and not verdict.mismatches
)
return verdict
def daily_overlap_consistent(
local_bars: Sequence[DailyBar],
sina_bars: Sequence[DailyBar],
*,
min_shared: int = 3,
max_recent: int = 8,
rel_tol: float = 1e-4,
abs_tol: float = Decimal("0.02"),
) -> OverlapVerdict:
"""新浪日 K(前复权)与本地(不复权)重叠校验。
前复权锚定最新价:仅「最近一次除权之后」的交易日两源数值相等,因此只
比较两源重叠的、最近的 max_recent 个交易日(此时若有除权发生在该段,
校验会判不一致 → 拒绝兜底,安全方向)。vol/amount 两源单位/口径不同,
不参与比较。
"""
local_by_day = {b.trade_date: b for b in local_bars}
sina_by_day = {b.trade_date: b for b in sina_bars}
shared = sorted(set(local_by_day) & set(sina_by_day), reverse=True)
verdict = OverlapVerdict(ok=False)
verdict.shared = len(shared)
for day in shared[:max_recent]:
a, b = local_by_day[day], sina_by_day[day]
day_mismatch: list[str] = []
compared = 0
for f in DAILY_COMPARE_FIELDS:
va, vb = getattr(a, f), getattr(b, f)
if va is None or vb is None:
continue
compared += 1
if not _close_enough(va, vb, rel_tol=rel_tol, abs_tol=abs_tol):
day_mismatch.append(f"{day}: {f} {va}≠{vb}")
verdict.compared += compared
verdict.mismatches.extend(day_mismatch)
checked = len(shared[:max_recent])
verdict.ok = (
checked >= min_shared and verdict.compared > 0 and not verdict.mismatches
)
return verdict
# ---------------------------------------------------------------- 报告期披露节奏
def latest_expected_report_period(today: date) -> date:
"""当前「应已披露」的最新报告期(报告期结束日)。
用作财务增量的已最新判断:本地已含该报告期 → 该股票已跟进到最新一季,
跳过(避免每轮全量重拉;--full 强制)。窗口按 A 股披露节奏划分:
- 1/1~2/14:年报季未开 → 上年三季报(09-30)
- 2/15~6/30:年报+一季报季 → 本年一季报(03-31)
- 7/1~10/15:半年报季 → 本年半年报(06-30)
- 10/16~12/31:三季报季 → 本年三季报(09-30)
"""
y = today.year
md = (today.month, today.day)
if md <= (2, 14):
return date(y - 1, 9, 30)
if md <= (6, 30):
return date(y, 3, 31)
if md <= (10, 15):
return date(y, 6, 30)
return date(y, 9, 30)
# ---------------------------------------------------------------- 审计
def _audit_sync(
audit: Callable[[SyncLog], None],
*,
source: str,
api: str,
success: bool,
row_count: int = 0,
reason: str | None = None,
start: date | None = None,
end: date | None = None,
) -> None:
audit(
SyncLog(
source=source,
api=api,
success=success,
failure_reason=reason,
row_count=row_count,
data_start=start,
data_end=end,
)
)
# ---------------------------------------------------------------- 结果结构
@dataclass
class FinancialSymbolResult:
"""单只股票财务同步结果(status: skip|ok|sina|failed)。"""
symbol: str
status: str
source: str | None = None # tushare | sina
fetched: int = 0 # 数据源返回行数
written: int = 0 # 实际落库行数(新增;--full 时含更新)
updated: int = 0 # --full 下覆盖的既有行数
report_first: date | None = None
report_last: date | None = None
announce_first: date | None = None
announce_last: date | None = None
notes: list[str] = field(default_factory=list)
@dataclass
class DailySymbolResult:
"""单只股票日线同步结果(status: skip|ok|sina|failed)。"""
symbol: str
status: str
source: str | None = None # tushare | sina
bars_fetched: int = 0
bars_written: int = 0
day_first: date | None = None
day_last: date | None = None
factors_written: int | None = None # None=未尝试(新浪兜底无因子)
notes: list[str] = field(default_factory=list)
# ---------------------------------------------------------------- 财务同步服务
class VerifiedFinancialSyncer:
"""财务指标增量同步:Tushare 窗口化拉取 → 失败则新浪校验兜底。"""
def __init__(
self,
*,
primary: MarketDataProvider,
fallback: MarketDataProvider | None,
repo: FinancialRepository,
audit: Callable[[SyncLog], None],
today: date | None = None,
min_shared: int = 2,
) -> None:
self.primary = primary
self.fallback = fallback
self.repo = repo
self.audit = audit
self.today = today or date.today()
self.min_shared = min_shared
def sync_symbol(self, symbol: str, *, force_full: bool = False) -> FinancialSymbolResult:
local = self.repo.list_symbol(symbol)
local_keys = {(r.symbol, r.report_date, r.announce_date) for r in local}
due = latest_expected_report_period(self.today)
if not force_full and local and any(r.report_date == due for r in local):
return FinancialSymbolResult(
symbol=symbol,
status="skip",
notes=[f"本地已含最新报告期 {due.isoformat()},跳过(--full 强制重拉)"],
)
# 拉取窗口:有本地行则从最早本地报告期起(含更正/补缺),无则全历史;
# 上限到最新应披露报告期。
hi = due
lo = min((r.report_date for r in local), default=None) or _EPOCH
try:
rows = self.primary.get_financial(symbol, lo, hi)
except DataSourceAuthenticationError:
# 凭证无效/接口无权限:属全局性故障,快速失败让用户修 token,
# 不要对全市场逐只做无意义的新浪试探
raise
except Exception as exc: # noqa: BLE001 —— 与 FailoverProvider 一致,统一走审计
_audit_sync(
self.audit,
source=self.primary.name,
api="get_financial",
success=False,
reason=str(exc),
start=lo,
end=hi,
)
return self._sina_fallback(symbol, local, local_keys, primary_error=str(exc))
_audit_sync(
self.audit,
source=self.primary.name,
api="get_financial",
success=True,
row_count=len(rows),
start=lo,
end=hi,
)
if force_full:
to_write = rows
updated = sum(1 for r in rows if _fin_key(r) in local_keys)
else:
to_write = [r for r in rows if _fin_key(r) not in local_keys]
updated = 0
written = self.repo.upsert_many(to_write)
return _fin_result(symbol, status="ok", source="tushare", written_rows=to_write,
written=written, updated=updated)
# ---- 新浪校验兜底 ----
def _sina_fallback(
self,
symbol: str,
local: list[FinancialIndicator],
local_keys: set[tuple],
*,
primary_error: str,
) -> FinancialSymbolResult:
if self.fallback is None:
return FinancialSymbolResult(
symbol=symbol,
status="failed",
notes=[f"Tushare 失败且未配置新浪兜底: {primary_error}"],
)
if not local:
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=False,
reason=f"无本地历史可做两边一致性校验,跳过待 Tushare 恢复重试({primary_error})",
)
return FinancialSymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[
f"Tushare 失败且本地无历史({symbol}),无法确认新浪数据真实性,"
f"跳过待重试。primary: {primary_error}"
],
)
try:
sina_rows = self.fallback.get_financial(symbol)
except Exception as exc: # noqa: BLE001
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=False,
reason=f"primary: {primary_error}; fallback: {exc}",
)
return FinancialSymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[f"主备数据源均失败: primary={primary_error}; sina={exc}"],
)
verdict = financial_overlap_consistent(local, sina_rows, min_shared=self.min_shared)
if not verdict.ok:
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=False,
reason=f"{verdict.summary()}(新浪返回 {len(sina_rows)} 行; primary={primary_error})",
)
return FinancialSymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[
f"新浪数据与本地历史不一致/无法校验({symbol}),拒绝导入。"
f"primary: {primary_error};{verdict.summary()}"
],
)
new_rows = [r for r in sina_rows if _fin_key(r) not in local_keys]
written = self.repo.upsert_many(new_rows)
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=True,
row_count=written,
)
return _fin_result(symbol, status="sina", source="sina", written_rows=new_rows,
written=written, updated=0,
note=f"新浪校验通过后补入 {written} 行(仅本地缺失键,source=sina)")
# ---------------------------------------------------------------- 日线同步服务
class VerifiedDailySyncer:
"""日线同步:Tushare 失败 → 新浪校验兜底(仅补缺失交易日、无复权因子)。"""
def __init__(
self,
*,
primary: MarketDataProvider,
fallback: MarketDataProvider | None,
bars: DailyBarRepository,
factors: AdjustFactorRepository,
audit: Callable[[SyncLog], None],
today: date | None = None,
min_shared: int = 3,
) -> None:
self.primary = primary
self.fallback = fallback
self.bars = bars
self.factors = factors
self.audit = audit
self.today = today or date.today()
self.min_shared = min_shared
def sync_symbol(self, symbol: str, begin: date, end: date) -> DailySymbolResult:
try:
bars = self.primary.get_daily(symbol, begin, end)
except DataSourceAuthenticationError:
raise # 凭证/权限故障 → 快速失败(见财务同步注释)
except Exception as exc: # noqa: BLE001
_audit_sync(
self.audit,
source=self.primary.name,
api="get_daily",
success=False,
reason=str(exc),
start=begin,
end=end,
)
return self._sina_fallback(symbol, begin, end, primary_error=str(exc))
_audit_sync(
self.audit,
source=self.primary.name,
api="get_daily",
success=True,
row_count=len(bars),
start=begin,
end=end,
)
try:
factors = self.primary.get_adjust_factor(symbol, begin, end)
except DataSourceAuthenticationError:
raise # 凭证/权限故障 → 快速失败(见财务同步注释)
except Exception as exc: # noqa: BLE001
# 复权因子是日线配套:缺因子不写本段,避免 resume 按日线已最新而跳过、因子永远补不上
_audit_sync(
self.audit,
source=self.primary.name,
api="get_adjust_factor",
success=False,
reason=str(exc),
start=begin,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="failed",
source="tushare",
bars_fetched=len(bars),
notes=[f"日线拉取成功但复权因子失败,本段未落库(防因子缺口): {exc}"],
)
_audit_sync(
self.audit,
source=self.primary.name,
api="get_adjust_factor",
success=True,
row_count=len(factors),
start=begin,
end=end,
)
self.bars.upsert_many(bars)
self.factors.upsert_many(factors)
return DailySymbolResult(
symbol=symbol,
status="ok",
source="tushare",
bars_fetched=len(bars),
bars_written=len(bars),
factors_written=len(factors),
day_first=min((b.trade_date for b in bars), default=None),
day_last=max((b.trade_date for b in bars), default=None),
)
# ---- 新浪校验兜底 ----
def _sina_fallback(
self, symbol: str, begin: date, end: date, *, primary_error: str
) -> DailySymbolResult:
if self.fallback is None:
return DailySymbolResult(
symbol=symbol,
status="failed",
notes=[f"Tushare 失败且未配置新浪兜底: {primary_error}"],
)
# 新浪只有最近 SINA_KLINE_DAYS 自然日数据;为拿到「本地近期历史」重叠做
# 校验,请求窗口需回看 begin 之前 SINA_VERIFY_LOOKBACK_DAYS 天
# (见 daily_overlap_consistent:只比较两源重叠的最近交易日)。
q_start = max(
self.today - timedelta(days=SINA_KLINE_DAYS - 1),
begin - timedelta(days=SINA_VERIFY_LOOKBACK_DAYS),
)
try:
sina_bars = self.fallback.get_daily(symbol, q_start, end)
except Exception as exc: # noqa: BLE001
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_daily",
success=False,
reason=f"primary: {primary_error}; fallback: {exc}",
start=q_start,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[f"主备数据源均失败: primary={primary_error}; sina={exc}"],
)
local_recent = self.bars.get_range(symbol, q_start, end)
verdict = daily_overlap_consistent(local_recent, sina_bars, min_shared=self.min_shared)
if not verdict.ok:
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_daily",
success=False,
reason=f"{verdict.summary()}(新浪返回 {len(sina_bars)} 行; primary={primary_error})",
start=q_start,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="failed",
source="sina",
bars_fetched=len(sina_bars),
notes=[
f"新浪数据与本地历史不一致/无法校验({symbol}),拒绝兜底补缺。"
f"primary: {primary_error};{verdict.summary()}"
],
)
local_dates = {b.trade_date for b in local_recent}
new_bars = [
b
for b in sina_bars
if begin <= b.trade_date <= end and b.trade_date not in local_dates
]
written = self.bars.upsert_many(new_bars)
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_daily",
success=True,
row_count=written,
start=begin,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="sina",
source="sina",
bars_fetched=len(sina_bars),
bars_written=written,
day_first=min((b.trade_date for b in new_bars), default=None),
day_last=max((b.trade_date for b in new_bars), default=None),
factors_written=None,
notes=[
f"新浪校验通过,仅补本地缺失交易日 {written} 根(前复权 source=sina,"
f"无复权因子;Tushare 恢复后 --resume 会按日覆盖回不复权口径)"
],
)
# ---------------------------------------------------------------- 小工具
def _fin_key(row: FinancialIndicator) -> tuple:
return (row.symbol, row.report_date, row.announce_date)
def _fin_result(
symbol: str,
*,
status: str,
source: str,
written_rows: Sequence[FinancialIndicator],
written: int,
updated: int,
note: str | None = None,
) -> FinancialSymbolResult:
reports = [r.report_date for r in written_rows]
announces = [r.announce_date for r in written_rows]
notes = [note] if note else []
return FinancialSymbolResult(
symbol=symbol,
status=status,
source=source,
fetched=len(written_rows),
written=written,
updated=updated,
report_first=min(reports, default=None),
report_last=max(reports, default=None),
announce_first=min(announces, default=None),
announce_last=max(announces, default=None),
notes=notes,
)