Files
qlib/backend/tests/test_data_sync.py
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

451 lines
18 KiB
Python
Raw Permalink 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.
"""增量 + 新浪「两边一致」校验兜底同步服务测试。
覆盖:一致性校验纯函数、报告期节奏、财务/日线同步服务
(temp SQLite + Fake Provider,不触网)。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
from app.application.services.data_sync import (
VerifiedDailySyncer,
VerifiedFinancialSyncer,
daily_overlap_consistent,
financial_overlap_consistent,
latest_expected_report_period,
)
from app.domain.entities.market import DailyBar, FinancialIndicator, SyncLog
from app.infrastructure.data_sources.errors import DataSourceError
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.models.market import (
AdjustFactorModel,
FinancialIndicatorModel,
StockDailyModel,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyAdjustFactorRepository,
SqlAlchemyDailyBarRepository,
SqlAlchemyFinancialRepository,
)
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
TODAY = date(2026, 9, 8)
DUE = date(2026, 6, 30) # TODAY 属于半年报季 → 应披露最新报告期
@pytest.fixture()
def session(tmp_path) -> Session:
engine = create_engine(f"sqlite:///{tmp_path / 'ds.db'}", future=True)
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
def _fin(
symbol: str,
report: str,
announce: str,
*,
eps: str = "1.00",
roe: str | None = None,
gross: str | None = None,
source: str = "tushare",
) -> FinancialIndicator:
return FinancialIndicator(
symbol=symbol,
report_date=date.fromisoformat(report),
announce_date=date.fromisoformat(announce),
source=source,
eps=Decimal(eps),
roe=Decimal(roe) if roe is not None else None,
gross_margin=Decimal(gross) if gross is not None else None,
)
def _bar(symbol: str, day: str, close: str, source: str = "tushare") -> DailyBar:
return DailyBar(
symbol=symbol,
trade_date=date.fromisoformat(day),
source=source,
adjust="none" if source == "tushare" else "qfq",
open=Decimal(close),
high=Decimal(close),
low=Decimal(close),
close=Decimal(close),
volume=Decimal("1000"),
)
# ================================================================ 一致性校验
class TestFinancialConsistency:
def test_overlap_consistent_passes(self) -> None:
local = [
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", roe="10.5687", gross="89.7592"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="17.9543", gross="89.5552"),
]
sina = [
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", roe="10.57", gross="89.759217", source="sina"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="16.75", gross="89.555212", source="sina"),
]
verdict = financial_overlap_consistent(local, sina)
# ROE 两边口径不同(新浪加权 vs tushare 摊薄),不参与比较也不判失败
assert verdict.ok
assert verdict.shared == 2
assert not verdict.mismatches
def test_roe_difference_not_compared(self) -> None:
"""口径差异字段绝不能导致误杀(17.9543 vs 16.75 是同源数据真实形态)。"""
local = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="17.9543", gross="89.5552")]
sina = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="16.75", gross="89.555212", source="sina")]
verdict = financial_overlap_consistent(local, sina, min_shared=1)
assert verdict.ok
def test_eps_mismatch_fails(self) -> None:
local = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", gross="89.5552")]
sina = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="99.99", gross="89.555212", source="sina")]
verdict = financial_overlap_consistent(local, sina, min_shared=1)
assert not verdict.ok
assert verdict.mismatches
def test_insufficient_overlap_fails(self) -> None:
local = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57")]
sina = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", source="sina")]
verdict = financial_overlap_consistent(local, sina, min_shared=2)
assert not verdict.ok
assert verdict.summary()
class TestDailyConsistency:
def test_recent_overlap_equal_passes(self) -> None:
local = [_bar("600519.SH", "2026-09-01", "10"), _bar("600519.SH", "2026-09-02", "10")]
sina = [
_bar("600519.SH", "2026-09-01", "10", source="sina"),
_bar("600519.SH", "2026-09-02", "10", source="sina"),
]
verdict = daily_overlap_consistent(local, sina, min_shared=2)
assert verdict.ok
def test_old_dividend_scale_gap_ignored(self) -> None:
"""新浪前复权:老交易日在除权后数值整体平移,只有「最近重叠段」可比。"""
local = [
_bar("600519.SH", "2025-06-02", "500"),
_bar("600519.SH", "2026-09-01", "10"),
_bar("600519.SH", "2026-09-02", "10"),
_bar("600519.SH", "2026-09-03", "10"),
]
sina = [
_bar("600519.SH", "2025-06-02", "480", source="sina"), # 除权后按因子平移
_bar("600519.SH", "2026-09-01", "10", source="sina"),
_bar("600519.SH", "2026-09-02", "10", source="sina"),
_bar("600519.SH", "2026-09-03", "10", source="sina"),
]
verdict = daily_overlap_consistent(local, sina, min_shared=2, max_recent=3)
assert verdict.ok
def test_recent_close_mismatch_fails(self) -> None:
local = [_bar("600519.SH", "2026-09-02", "10")]
sina = [_bar("600519.SH", "2026-09-02", "9", source="sina")]
verdict = daily_overlap_consistent(local, sina, min_shared=1)
assert not verdict.ok
class TestReportPeriodRhythm:
def test_windows(self) -> None:
assert latest_expected_report_period(date(2026, 1, 1)) == date(2025, 9, 30)
assert latest_expected_report_period(date(2026, 2, 14)) == date(2025, 9, 30)
assert latest_expected_report_period(date(2026, 2, 15)) == date(2026, 3, 31)
assert latest_expected_report_period(date(2026, 6, 30)) == date(2026, 3, 31)
assert latest_expected_report_period(date(2026, 7, 1)) == date(2026, 6, 30)
assert latest_expected_report_period(TODAY) == DUE
assert latest_expected_report_period(date(2026, 10, 15)) == date(2026, 6, 30)
assert latest_expected_report_period(date(2026, 10, 16)) == date(2026, 9, 30)
# ================================================================ 财务同步服务
class _FakeFinSource:
def __init__(self, *, name: str, payload=None, error: Exception | None = None) -> None:
self.name = name
self.payload = payload or []
self.error = error
self.calls: list[tuple] = []
def get_financial(self, symbol, start=None, end=None): # noqa: ANN001
self.calls.append((symbol, start, end))
if self.error is not None:
raise self.error
return list(self.payload)
def _fin_syncer(session, primary, fallback=None, *, today: date = TODAY):
logs: list[SyncLog] = []
syncer = VerifiedFinancialSyncer(
primary=primary,
fallback=fallback,
repo=SqlAlchemyFinancialRepository(session),
audit=logs.append,
today=today,
)
return syncer, logs
def _count(session, model) -> int:
return session.scalar(select(func.count()).select_from(model))
class TestVerifiedFinancialSyncer:
def _rows(self, symbol: str = "600519.SH") -> list[FinancialIndicator]:
return [
_fin(symbol, "2026-03-31", "2026-04-25", eps="21.76", gross="89.7592"),
_fin(symbol, "2026-06-30", "2026-08-15", eps="35.57", gross="89.5552"),
]
def test_first_fetch_writes_delta_and_second_run_skips(self, session: Session) -> None:
primary = _FakeFinSource(name="tushare", payload=self._rows())
syncer, logs = _fin_syncer(session, primary)
res = syncer.sync_symbol("600519.SH")
session.commit()
assert res.status == "ok"
assert res.written == 2
assert _count(session, FinancialIndicatorModel) == 2
assert logs[0].source == "tushare" and logs[0].success
# 第二次:本地已含最新应披露报告期 2026-06-30 → 跳过,不再调 API
res2 = syncer.sync_symbol("600519.SH")
assert res2.status == "skip"
assert len(primary.calls) == 1
def test_force_full_overwrites(self, session: Session) -> None:
primary = _FakeFinSource(name="tushare", payload=self._rows())
syncer, _logs = _fin_syncer(session, primary)
syncer.sync_symbol("600519.SH")
session.commit()
changed = [
r.model_copy(update={"eps": Decimal("99")}) for r in self._rows()
] + [_fin("600519.SH", "2025-12-31", "2026-04-17", eps="65.66", gross="91.1796")]
primary.payload = changed
res = syncer.sync_symbol("600519.SH", force_full=True)
session.commit()
assert res.status == "ok"
assert res.written == 3
assert res.updated == 2 # 既有 2 行被覆盖
got = session.scalars(select(FinancialIndicatorModel)).all()
assert len(got) == 3
assert max(float(r.eps) for r in got) == 99.0
def test_sina_fallback_verified_imports_only_new_keys(self, session: Session) -> None:
repo = SqlAlchemyFinancialRepository(session)
repo.upsert_many(
[
_fin("600519.SH", "2025-12-31", "2026-04-17", eps="65.66", gross="91.1796"),
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", gross="89.7592"),
]
)
session.commit()
sina_rows = [
_fin("600519.SH", "2025-12-31", "2026-04-17", eps="65.66", gross="91.179551", source="sina"),
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", gross="89.759217", source="sina"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", gross="89.555212", source="sina"),
]
primary = _FakeFinSource(name="tushare", error=DataSourceError("频率超限"))
fallback = _FakeFinSource(name="sina", payload=sina_rows)
syncer, logs = _fin_syncer(session, primary, fallback)
res = syncer.sync_symbol("600519.SH")
session.commit()
assert res.status == "sina"
assert res.written == 1 # 只补本地缺失键(2026-06-30)
rows = session.scalars(select(FinancialIndicatorModel)).all()
assert len(rows) == 3
new = next(r for r in rows if r.report_date == DUE)
assert new.source == "sina"
# 审计:tushare 失败 + sina 成功
assert [log.source for log in logs] == ["tushare", "sina"]
assert logs[0].success is False and logs[1].success is True
def test_sina_fallback_denied_on_mismatch(self, session: Session) -> None:
repo = SqlAlchemyFinancialRepository(session)
# 用非「最新应披露期」做种子,避免触发「已最新跳过」
repo.upsert_many(
[_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", gross="89.7592")]
)
session.commit()
primary = _FakeFinSource(name="tushare", error=DataSourceError("boom"))
# 同报告期 eps 不一致 → 真实性不成立 → 拒绝导入
fallback = _FakeFinSource(
name="sina",
payload=[
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="1.00", gross="89.759217", source="sina"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", gross="89.555212", source="sina"),
],
)
syncer, _logs = _fin_syncer(session, primary, fallback)
res = syncer.sync_symbol("600519.SH")
assert res.status == "failed"
assert res.source == "sina"
assert _count(session, FinancialIndicatorModel) == 1 # 未新增
def test_sina_fallback_no_local_history_denied(self, session: Session) -> None:
primary = _FakeFinSource(name="tushare", error=DataSourceError("频率超限"))
fallback = _FakeFinSource(
name="sina",
payload=[_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", source="sina")],
)
syncer, logs = _fin_syncer(session, primary, fallback)
res = syncer.sync_symbol("600519.SH")
assert res.status == "failed"
assert "本地无历史" in " ".join(res.notes)
assert _count(session, FinancialIndicatorModel) == 0
assert not logs[-1].success
# ================================================================ 日线同步服务
class _FakeDailyTushare:
name = "tushare"
def __init__(self, *, bars=None, factors=None, daily_error=None, factor_error=None) -> None: # noqa: ANN001
self.bars = bars or []
self.factors = factors or []
self.daily_error = daily_error
self.factor_error = factor_error
self.daily_calls = 0
def get_daily(self, symbol, start, end):
self.daily_calls += 1
if self.daily_error is not None:
raise self.daily_error
return list(self.bars)
def get_adjust_factor(self, symbol, start, end):
if self.factor_error is not None:
raise self.factor_error
return list(self.factors)
class _FakeDailySina:
name = "sina"
def __init__(self, bars) -> None:
self.bars = bars
def get_daily(self, symbol, start, end):
return [
b for b in self.bars if start <= b.trade_date <= end
]
def _daily_syncer(session, primary, fallback=None, *, today: date = TODAY):
logs: list[SyncLog] = []
syncer = VerifiedDailySyncer(
primary=primary,
fallback=fallback,
bars=SqlAlchemyDailyBarRepository(session),
factors=SqlAlchemyAdjustFactorRepository(session),
audit=logs.append,
today=today,
)
return syncer, logs
class TestVerifiedDailySyncer:
def test_primary_ok_writes_bars_and_factors(self, session: Session) -> None:
from app.domain.entities.market import AdjustFactor
bars = [_bar("600519.SH", "2026-09-01", "10"), _bar("600519.SH", "2026-09-02", "10")]
factors = [
AdjustFactor(symbol="600519.SH", trade_date=date(2026, 9, 1), factor=Decimal("1.0"))
]
syncer, logs = _daily_syncer(
session, _FakeDailyTushare(bars=bars, factors=factors)
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 1), date(2026, 9, 2))
assert res.status == "ok"
assert res.bars_written == 2 and res.factors_written == 1
assert _count(session, StockDailyModel) == 2
assert _count(session, AdjustFactorModel) == 1
assert [log.api for log in logs] == ["get_daily", "get_adjust_factor"]
def test_factor_failure_aborts_symbol_without_partial_write(self, session: Session) -> None:
bars = [_bar("600519.SH", "2026-09-01", "10")]
syncer, _logs = _daily_syncer(
session,
_FakeDailyTushare(bars=bars, factor_error=DataSourceError("限速")),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 1), date(2026, 9, 2))
assert res.status == "failed"
# 缺复权因子不写本段日线,避免 resume 漏补因子
assert _count(session, StockDailyModel) == 0
def _seed_local(self, session: Session) -> None:
repo = SqlAlchemyDailyBarRepository(session)
repo.upsert_many(
[
_bar("600519.SH", "2026-08-31", "10"),
_bar("600519.SH", "2026-09-01", "10"),
_bar("600519.SH", "2026-09-02", "10"),
]
)
session.commit()
def test_sina_fallback_fills_only_missing_dates(self, session: Session) -> None:
self._seed_local(session)
sina_bars = [
_bar("600519.SH", "2026-08-31", "10", source="sina"),
_bar("600519.SH", "2026-09-01", "10", source="sina"),
_bar("600519.SH", "2026-09-02", "10", source="sina"),
_bar("600519.SH", "2026-09-03", "10", source="sina"),
_bar("600519.SH", "2026-09-04", "10", source="sina"),
]
syncer, logs = _daily_syncer(
session,
_FakeDailyTushare(daily_error=DataSourceError("频率超限")),
fallback=_FakeDailySina(sina_bars),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 3), date(2026, 9, 4))
session.commit()
assert res.status == "sina"
assert res.bars_written == 2 # 只补 09-03/09-04
rows = session.scalars(select(StockDailyModel).order_by(StockDailyModel.trade_date)).all()
assert len(rows) == 5
gap = [r for r in rows if r.trade_date >= date(2026, 9, 3)]
assert all(r.source == "sina" and r.adjust == "qfq" for r in gap)
kept = [r for r in rows if r.trade_date < date(2026, 9, 3)]
assert all(r.source == "tushare" for r in kept) # 既有行不被覆盖
assert logs[-1].source == "sina" and logs[-1].success
def test_sina_fallback_denied_on_mismatch(self, session: Session) -> None:
self._seed_local(session)
# 最近重叠日收盘不一致(新浪 9 元 vs 本地 10 元)→ 通道不可信 → 拒绝
sina_bars = [_bar("600519.SH", "2026-09-02", "9", source="sina")]
syncer, _logs = _daily_syncer(
session,
_FakeDailyTushare(daily_error=DataSourceError("boom")),
fallback=_FakeDailySina(sina_bars),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 3), date(2026, 9, 4))
assert res.status == "failed"
assert _count(session, StockDailyModel) == 3 # 未新增
def test_sina_fallback_no_overlap_denied(self, session: Session) -> None:
# 本地没有任何近期历史可对照 → 拒绝新浪补缺
sina_bars = [_bar("600519.SH", "2026-09-03", "10", source="sina")]
syncer, logs = _daily_syncer(
session,
_FakeDailyTushare(daily_error=DataSourceError("boom")),
fallback=_FakeDailySina(sina_bars),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 3), date(2026, 9, 4))
assert res.status == "failed"
assert _count(session, StockDailyModel) == 0
assert not logs[-1].success