"""每日增量同步(``hdiv sync daily``)测试。 要锁定的三条性质: 1. **缺口口径与断点续传一致** —— 判定「这天已同步」用的是 ``price.fetched_days`` 的按年份规模阈值,而不是「当天有没有行」。否则只填了几百只的半成品日会被 当成已完成,形成难以察觉的数据空洞(历史上真的踩过这个坑)。 2. **只抓缺口,不重拉** —— 计划里出现的交易日必须**恰好**是缺失的那些; 已完整的日、已最新的指数、已有数据的股票都不能出现在待抓清单里。 3. **财报水位按披露截止日推算** —— 年报/一季报 4-30、半年报 8-31、三季报 10-31。 水位算错会让财报季的队列要么永远排不空、要么漏掉整季新披露。 """ from __future__ import annotations import os import plistlib from datetime import date import pytest from hdiv.core.paths import project_root from hdiv.data.sync import daily as daily_sync # --------------------------------------------------------------------------- # 财报报告期水位 # --------------------------------------------------------------------------- class TestFinancialWatermark: @pytest.mark.parametrize( "asof,expected", [ # 三季报 10-31 截止 → 11-01 起水位是当年三季报 (date(2026, 11, 1), date(2026, 9, 30)), (date(2026, 12, 31), date(2026, 9, 30)), # 半年报 8-31 截止 → 9-01 起水位是当年半年报 (date(2026, 9, 1), date(2026, 6, 30)), (date(2026, 10, 31), date(2026, 6, 30)), # 一季报 4-30 截止 → 5-01 起水位是当年一季报 (date(2026, 5, 1), date(2026, 3, 31)), (date(2026, 8, 31), date(2026, 3, 31)), # 1~4 月是上一年年报季 (date(2026, 4, 30), date(2025, 12, 31)), (date(2026, 1, 1), date(2025, 12, 31)), ], ) def test_watermark(self, asof: date, expected: date) -> None: assert daily_sync.financial_watermark(asof) == expected def test_watermark_boundaries_are_monotonic(self) -> None: """水位只能随时间前进,不能回退 —— 否则已补的股票会反复进队列。""" days = [date(2025, m, d) for m in range(1, 13) for d in (1, 15, 28)] days += [date(2026, m, d) for m in range(1, 13) for d in (1, 15, 28)] wm = [daily_sync.financial_watermark(d) for d in sorted(days)] assert wm == sorted(wm) # --------------------------------------------------------------------------- # 目标选择 # --------------------------------------------------------------------------- class TestResolveOnly: def test_default_is_all_groups(self) -> None: assert daily_sync._resolve_only(None) == set(daily_sync.GROUPS) assert daily_sync._resolve_only([]) == set(daily_sync.GROUPS) def test_group_names(self) -> None: assert daily_sync._resolve_only(["price"]) == {"price"} assert daily_sync._resolve_only(["price", "dividend"]) == {"price", "dividend"} def test_single_target_maps_to_its_group(self) -> None: assert daily_sync._resolve_only(["daily_basic"]) == {"price"} assert daily_sync._resolve_only(["suspend"]) == {"trading"} assert daily_sync._resolve_only(["limit"]) == {"trading"} assert daily_sync._resolve_only(["index_weight"]) == {"index"} def test_unknown_target_is_rejected(self) -> None: with pytest.raises(ValueError, match="未知的同步目标"): daily_sync._resolve_only(["nope"]) def test_blank_entries_are_ignored(self) -> None: assert daily_sync._resolve_only(["", " ", "index"]) == {"index"} # --------------------------------------------------------------------------- # 缺口判定(隔离数据库) # --------------------------------------------------------------------------- class TestMissingDays: def test_only_incomplete_days_are_returned(self, monkeypatch: pytest.MonkeyPatch) -> None: """4 个交易日里只有 2 天完整 → 待抓恰好是另外 2 天。""" days = [date(2026, 9, 1), date(2026, 9, 2), date(2026, 9, 3), date(2026, 9, 4)] monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: days) monkeypatch.setattr( daily_sync.price_sync, "fetched_days", lambda *a, **k: {days[0], days[2]} ) out = daily_sync.missing_days("stock_daily", days[0], days[-1], cfg=None) assert out == [days[1], days[3]] def test_min_symbols_is_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None: """停牌表必须按「有行即算」传 min_symbols=1,否则每天都判为未完成。""" seen: dict = {} def fake_fetched(table, start, end, cfg, *, min_symbols=None): # noqa: ANN001 seen["min_symbols"] = min_symbols return set() monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 1)]) monkeypatch.setattr(daily_sync.price_sync, "fetched_days", fake_fetched) daily_sync.missing_days("hd_suspend", date(2026, 9, 1), date(2026, 9, 1), cfg=None, min_symbols=1) assert seen["min_symbols"] == 1 def test_no_trading_days_means_no_gap(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: []) assert daily_sync.missing_days("stock_daily", date(2026, 10, 1), date(2026, 10, 5), cfg=None) == [] # --------------------------------------------------------------------------- # 分红按日期补 # --------------------------------------------------------------------------- class TestDividendGaps: def test_future_ex_date_does_not_push_window_forward( self, monkeypatch: pytest.MonkeyPatch ) -> None: """已公告但尚未除权的记录会把 MAX(ex_date) 推到未来。 若直接拿它当锚点,窗口会落在未来、一个交易日都查不到 —— 新公告的分红就永远补不进来。锚点必须被夹到 asof。 """ import pandas as pd asof = date(2026, 10, 5) calls: list[tuple] = [] def fake_read_sql(sql, params=None, *, cfg=None): # noqa: ANN001 if "MAX(ann_date)" in sql: return pd.DataFrame({"m": [date(2026, 10, 23)]}) # 未来除权日 calls.append((params["s"], params["e"])) return pd.DataFrame({"d": []}) monkeypatch.setattr(daily_sync.db, "read_sql", fake_read_sql) monkeypatch.setattr( daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 28), date(2026, 9, 29), date(2026, 9, 30)], ) days, anchor = daily_sync.dividend_gaps(date(2026, 8, 21), asof, asof, cfg=None) assert anchor == asof assert days == [date(2026, 9, 28), date(2026, 9, 29), date(2026, 9, 30)] start, _ = calls[0] assert start == date(2026, 9, 28), "窗口起点应是 asof 往前 DIVIDEND_OVERLAP_DAYS 天" def test_days_already_queried_are_not_repeated(self, monkeypatch: pytest.MonkeyPatch) -> None: import pandas as pd asof = date(2026, 10, 5) seen_day = date(2026, 9, 30) def fake_read_sql(sql, params=None, *, cfg=None): # noqa: ANN001 if "MAX(ann_date)" in sql: return pd.DataFrame({"m": [seen_day]}) return pd.DataFrame({"d": [seen_day]}) monkeypatch.setattr(daily_sync.db, "read_sql", fake_read_sql) monkeypatch.setattr( daily_sync, "_trading_days", lambda s, e, cfg: [seen_day, date(2026, 10, 1)] ) days, _ = daily_sync.dividend_gaps(date(2026, 8, 21), asof, asof, cfg=None) assert days == [date(2026, 10, 1)] # --------------------------------------------------------------------------- # 指数行情 / 成分权重窗口 # --------------------------------------------------------------------------- class TestIndexGaps: def test_per_index_gap_starts_after_its_last_day( self, monkeypatch: pytest.MonkeyPatch ) -> None: import pandas as pd days = [date(2026, 9, 29), date(2026, 9, 30)] monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: days) monkeypatch.setattr( daily_sync.index_sync, "default_indices", lambda cfg: [{"code": "000300.SH", "name": "沪深300"}, {"code": "399006.SZ"}], ) monkeypatch.setattr( daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame( {"c": ["000300.SH"], "m": [date(2026, 9, 29)]} ), ) out = daily_sync.index_daily_gaps(date(2026, 9, 1), date(2026, 9, 30), cfg=None) assert out == {"000300.SH": [date(2026, 9, 30)], "399006.SZ": days} def test_up_to_date_table_has_no_window(self, monkeypatch: pytest.MonkeyPatch) -> None: import pandas as pd monkeypatch.setattr( daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 29), date(2026, 9, 30)] ) monkeypatch.setattr( daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame({"m": [date(2026, 9, 30)]}) ) assert daily_sync._index_weight_window(date(2026, 8, 21), date(2026, 9, 30), None) is None def test_window_end_is_clamped_to_last_trading_day( self, monkeypatch: pytest.MonkeyPatch ) -> None: """右端点收到最近交易日:否则长假里每天都会重查一段空区间。""" import pandas as pd monkeypatch.setattr( daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 29), date(2026, 9, 30)] ) monkeypatch.setattr(daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame({"m": [None]})) got = daily_sync._index_weight_window(date(2026, 8, 21), date(2026, 10, 5), None) assert got == (date(2026, 8, 21), date(2026, 9, 30)) def test_holiday_only_window_is_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None: """整段区间都是假期 → 没有可补的权重日,一次调用都不该发。""" monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: []) assert daily_sync._index_weight_window(date(2026, 10, 1), date(2026, 10, 5), None) is None def test_incremental_window_starts_after_last_weight(self, monkeypatch: pytest.MonkeyPatch) -> None: import pandas as pd monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 30)]) monkeypatch.setattr( daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame({"m": [date(2026, 8, 31)]}) ) got = daily_sync._index_weight_window(date(2026, 8, 21), date(2026, 10, 5), None) assert got == (date(2026, 9, 1), date(2026, 9, 30)) # --------------------------------------------------------------------------- # 计划对象与摘要 # --------------------------------------------------------------------------- class TestPlanObject: def test_empty_plan_detects_nothing_to_do(self) -> None: p = daily_sync.DailyPlan(asof=date(2026, 10, 5), window_start=date(2026, 8, 21)) assert p.empty assert p.total_days == 0 def test_any_gap_makes_plan_non_empty(self) -> None: p = daily_sync.DailyPlan( asof=date(2026, 10, 5), window_start=date(2026, 8, 21), day_gaps={"daily": [date(2026, 9, 30)]}, ) assert not p.empty assert p.total_days == 1 def test_plan_does_not_share_default_dicts(self) -> None: """两个计划的默认容器必须互相独立(dataclass 默认值的经典坑)。""" a = daily_sync.DailyPlan(asof=date(2026, 10, 5), window_start=date(2026, 8, 21)) b = daily_sync.DailyPlan(asof=date(2026, 10, 5), window_start=date(2026, 8, 21)) a.day_gaps["daily"] = [date(2026, 9, 30)] a.notes.append("x") assert b.day_gaps == {} assert b.notes == [] class TestFormatSummary: def test_success(self) -> None: assert "成功" in daily_sync.format_summary({"targets": {}, "errors": [], "ok": True}) def test_failure_lists_reasons(self) -> None: s = daily_sync.format_summary({"errors": ["daily: SyncError: 限频"], "ok": False}) assert "失败 1 项" in s assert "限频" in s def test_dry_run_reports_plan(self) -> None: s = daily_sync.format_summary( { "dry_run": True, "planned": {"day_gaps": {"daily": 3}, "index_days": {"000300.SH": 2}, "dividend_days": 1}, } ) assert "计划" in s assert "5" in s # --------------------------------------------------------------------------- # 部署契约:每天 17:00 # --------------------------------------------------------------------------- class TestScheduleContract: """「每天下午 5 点定时抓取」是本次的硬需求,必须有可执行的证据。 定时任务最容易在交付后悄悄失效:plist 写成 KeepAlive 会把一次性任务变成 常驻进程、小时写错会让它在收盘前跑到空数据。这两点用测试钉死。 """ def test_plist_triggers_at_17_00(self) -> None: path = project_root() / "deploy" / "com.hddiv.sync.plist.example" assert path.is_file(), "缺少 launchd 模板" data = plistlib.loads(path.read_bytes()) assert data["Label"] == "com.hddiv.sync" assert data["StartCalendarInterval"] == {"Hour": 17, "Minute": 0}, ( "触发时间必须是 17:00(收盘 15:00 后,当日数据已出)" ) assert data["KeepAlive"] is False, "定时任务不能 KeepAlive —— 否则会被无限拉起" assert data["RunAtLoad"] is False, "RunAtLoad 会让每次登录都多抓一次" def test_runner_script_is_wired(self) -> None: root = project_root() runner = root / "deploy" / "daily-sync.sh" assert runner.is_file(), "缺少执行包装脚本" assert os.access(runner, os.X_OK), "daily-sync.sh 必须可执行" text = runner.read_text(encoding="utf-8") assert "sync daily" in text, "包装脚本必须调用 hdiv sync daily" assert "LOCK_DIR" in text, "包装脚本必须有单实例锁(避免重叠运行互相抢限频额度)" installer = root / "deploy" / "install-sync-schedule.sh" assert installer.is_file(), "缺少安装脚本" itext = installer.read_text(encoding="utf-8") assert "com.hddiv.sync.plist.example" in itext assert "StartCalendarInterval" in ( root / "deploy" / "com.hddiv.sync.plist.example" ).read_text(encoding="utf-8") # --------------------------------------------------------------------------- # 真实数据库:计划与执行的一致性 # --------------------------------------------------------------------------- @pytest.mark.db def test_plan_matches_database_state() -> None: """计划里的缺口必须与「库里实际缺的日子」逐日一致。 这是本模块唯一不能靠 mock 保证的性质:``fetched_days`` 的阈值口径、 ``trading_calendar`` 的交易日、各表的日期列含义都来自真实库。 """ from hdiv.core.config import load_config from hdiv.data import db from hdiv.data.sync import price as price_sync try: db.load_dotenv_once() cfg = load_config("datasource") db.row_count("stock_daily", cfg) except Exception as exc: # pragma: no cover pytest.skip(f"数据库不可用:{exc}") p = daily_sync.plan(cfg, asof=date(2026, 9, 30), lookback_days=45, include_financial=False) # 窗口内每一天:要么在待抓清单里,要么在「已完整」集合里,二者互斥且完备 window_days = price_sync.open_days(date(2026, 8, 16), date(2026, 9, 30), cfg) done = price_sync.fetched_days("stock_daily", date(2026, 8, 16), date(2026, 9, 30), cfg) planned = set(p.day_gaps["daily"]) for d in window_days: assert (d in planned) != (d in done), f"{d} 的缺口判定与 fetched_days 不一致" assert planned == {d for d in window_days if d not in done} # 计划不包含窗口之外的日期(增量只补近期空洞) assert all(d >= p.window_start for days in p.day_gaps.values() for d in days) @pytest.mark.db def test_financial_queue_excludes_long_delisted() -> None: """已退市多年的标的不能再进每日队列。 回归:实测「报告期滞后」的股票里 200+ 只早已退市(最后一份财报停在退市前), 不过滤的话每天都要为它们发 800 次调用、拉回同一批旧数据 —— 实测一次全量 重拉耗时约 2 分钟、返回 6.5 万行,全部无功而返。 """ from hdiv.core.config import load_config from hdiv.data import db asof = date(2026, 9, 30) try: db.load_dotenv_once() cfg = load_config("datasource") db.row_count("stock", cfg) except Exception as exc: # pragma: no cover pytest.skip(f"数据库不可用:{exc}") p = daily_sync.plan(cfg, asof=asof) assert len(p.financial_symbols) < 50, ( f"每日财报队列 {len(p.financial_symbols)} 只,退市过滤可能失效" ) cutoff = daily_sync._active_since(asof) dead = db.read_sql( "SELECT symbol FROM stock WHERE delist_date IS NOT NULL AND delist_date <= :c", {"c": cutoff}, cfg=cfg, ) dead_set = set(dead["symbol"].astype(str)) if not dead.empty else set() assert not (set(p.financial_symbols) & dead_set), "长期退市标的混进了每日财报队列" @pytest.mark.db def test_json_mode_writes_no_progress_text(capsys: pytest.CaptureFixture[str]) -> None: """``--json`` 的 stdout 必须是**纯 JSON**。 回归:进度与计划文字混进 stdout 会让 ``| jq`` 之类的消费者直接解析失败, 而这类问题在交互式运行时完全看不出来(人眼只看到 JSON 在前面或后面)。 """ from hdiv.core.config import load_config from hdiv.data import db try: db.load_dotenv_once() cfg = load_config("datasource") db.row_count("stock", cfg) except Exception as exc: # pragma: no cover pytest.skip(f"数据库不可用:{exc}") summary = daily_sync.run( cfg, asof=date(2026, 9, 30), dry_run=True, include_financial=False, verbose=False ) captured = capsys.readouterr() assert captured.out == "", f"verbose=False 仍有 stdout 输出:{captured.out[:200]}" assert summary["dry_run"] is True @pytest.mark.db def test_dry_run_writes_nothing() -> None: """``--dry-run`` 不得写库,也不得调用 Tushare。""" from hdiv.core.config import load_config from hdiv.data import db try: db.load_dotenv_once() cfg = load_config("datasource") logs_before = db.read_sql("SELECT MAX(id) AS m FROM hd_sync_log", cfg=cfg)["m"].iloc[0] except Exception as exc: # pragma: no cover pytest.skip(f"数据库不可用:{exc}") summary = daily_sync.run(cfg, asof=date(2026, 9, 30), dry_run=True, include_financial=False) assert summary["dry_run"] is True assert summary["errors"] == [] after_logs = db.read_sql("SELECT MAX(id) AS m FROM hd_sync_log", cfg=cfg)["m"].iloc[0] assert after_logs == logs_before, "dry-run 不应产生新的 hd_sync_log 记录"