"""增量 + 新浪「两边一致」校验兜底同步服务测试。 覆盖:一致性校验纯函数、报告期节奏、财务/日线同步服务 (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